前端知库

怎样把 DOM 树转成 JSON,再把 JSON 还原成 DOM?

递归遍历节点:元素节点记下小写标签名、attributes 里的每个属性和转换后的子节点,文本节点直接记成字符串,注释和只含空白的文本节点按需要跳过。还原时反过来,字符串用 createTextNode,对象用 createElement 加 setAttribute,再逐个 appendChild 子节点。JSON 里存不下事件监听、表单当前值这类运行时状态。

参考答案

简答

题目一般是给一段 <div id="app"><span class="title">你好</span></div>,要求输出 { tag: 'div', attrs: { id: 'app' }, children: [{ tag: 'span', attrs: { class: 'title' }, children: ['你好'] }] },再写一个函数把它还原回 DOM。

转 JSON 用递归。先看 nodeType:1 是元素,3 是文本,8 是注释。元素节点记下标签名,遍历 attributes 把每个属性的名字和值存进对象,再对 childNodes 里的每个子节点递归;文本节点直接返回它的文字。

还原是反过来:遇到字符串就用 document.createTextNode 建文本节点,遇到对象就用 document.createElement 建元素,setAttribute 写回属性,再把子节点逐个建好 appendChild 进去。

要注意几个细节。childNodes 里除了元素,还有文本和注释,HTML 里的缩进换行也是文本节点,所以要约定哪些保留。tagName 在 HTML 文档里是大写的,要统一转成小写。JSON 只能存结构和属性,addEventListener 注册的监听、输入框当前输入的值这类运行时状态都存不下来。

手写 domToJson 和 jsonToDom

示例在 Node.js 里运行,所以用一个模拟的 document,它只实现了两个函数用到的属性和方法。domToJson 只读 nodeType、tagName、attributes、childNodes、textContent,在浏览器里可以直接传入真实节点。看 div 里的换行文本和注释最后去了哪里。

JavaScript
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;

function domToJson(node) {
  if (node.nodeType === TEXT_NODE) return node.textContent;
  const json = { tag: node.tagName.toLowerCase(), attrs: {}, children: [] };
  for (const attr of node.attributes) json.attrs[attr.name] = attr.value;
  for (const child of node.childNodes) {
    // 注释节点不保留,只含空白的文本节点通常是缩进和换行,也跳过。
    if (child.nodeType === TEXT_NODE && child.textContent.trim() === '') continue;
    if (child.nodeType !== ELEMENT_NODE && child.nodeType !== TEXT_NODE) continue;
    json.children.push(domToJson(child));
  }
  return json;
}

function jsonToDom(json, doc) {
  if (typeof json === 'string') return doc.createTextNode(json);
  const el = doc.createElement(json.tag);
  for (const [name, value] of Object.entries(json.attrs ?? {})) el.setAttribute(name, value);
  for (const child of json.children ?? []) el.appendChild(jsonToDom(child, doc));
  return el;
}

// 用来演示的模拟节点,只实现上面两个函数用到的属性和方法。
function createFakeDocument() {
  return {
    createTextNode: (text) => ({ nodeType: TEXT_NODE, textContent: text }),
    createComment: (text) => ({ nodeType: 8, textContent: text }),
    createElement(tag) {
      const attrMap = new Map();
      return {
        nodeType: ELEMENT_NODE,
        tagName: tag.toUpperCase(),
        childNodes: [],
        get attributes() {
          return [...attrMap].map(([name, value]) => ({ name, value }));
        },
        setAttribute(name, value) { attrMap.set(name, String(value)); },
        appendChild(child) { this.childNodes.push(child); return child; },
      };
    },
  };
}

const doc = createFakeDocument();
const div = doc.createElement('div');
div.setAttribute('id', 'app');
div.appendChild(doc.createTextNode('\n  '));
const span = doc.createElement('span');
span.setAttribute('class', 'title');
span.appendChild(doc.createTextNode('你好'));
div.appendChild(span);
div.appendChild(doc.createComment('注释'));
const ul = doc.createElement('ul');
ul.appendChild(doc.createElement('li'));
div.appendChild(ul);

const json = domToJson(div);
const text = JSON.stringify(json);
console.log(text); // {"tag":"div","attrs":{"id":"app"},"children":[{"tag":"span","attrs":{"class":"title"},"children":["你好"]},{"tag":"ul","attrs":{},"children":[{"tag":"li","attrs":{},"children":[]}]}]}

const rebuilt = jsonToDom(JSON.parse(text), doc);
console.log(rebuilt.tagName); // DIV
console.log(rebuilt.childNodes[0].childNodes[0].textContent); // 你好
console.log(JSON.stringify(domToJson(rebuilt)) === text ? 'same' : 'diff'); // same

div 里放了四个子节点:换行加空格的文本、span、注释和 ul,转出来的 children 只有 span 和 ul 两项。只含空白的文本节点被跳过了,注释的 nodeType 是 8,也被跳过了。

为什么默认要跳过空白文本?因为 MDN 说明 childNodes 包含元素、文本和注释,HTML 源码里为了排版写的缩进和换行,解析后都会变成文本节点。不跳过的话,JSON 里会塞满 "\n "。可是 <pre> 里的空白、两个行内元素之间的空格是有意义的,跳过就会改变显示效果,所以这条规则要和面试官确认,必要时对这类元素保留空白。

为什么标签名要转小写?MDN 写明,在 HTML 文档里 tagName 总是返回大写形式,<div> 读出来是 "DIV"。存成小写,JSON 更好读,也和写 HTML 的习惯一致。createElement('div') 在 HTML 文档里同样会得到 DIV,所以还原后 rebuilt.tagName 是 DIV。

最后一行把还原出来的 DOM 再转一次 JSON,结果和第一次一模一样,说明两个函数能互相还原。

attributes 怎样遍历

element.attributes 返回的是 NamedNodeMap,不是数组,没有 map、forEach。MDN 的示例直接用 for...of 遍历它,每一项是一个 Attr 对象,有 name 和 value。所以代码写的是 for (const attr of node.attributes)。要用数组方法,就先 Array.from(node.attributes)。

attributes 读到的是 HTML 特性,也就是写在标签上的那些。MDN 说明,value 特性对应的是 defaultValue,也就是输入框的初始值,用户输入的内容要从 input.value 读;checked 特性也只表示默认是否勾选,当前状态要读 checked 属性。题目要求“保存表单状态”时,要单独读这些属性存进 JSON。

还原时要注意什么

jsonToDom 里,文本一律用 createTextNode。不要把 JSON 拼成 HTML 字符串再赋给 innerHTML,因为 JSON 如果来自接口或用户,文本里的 <img onerror=...> 会被当成标签解析执行,用 createTextNode 建出来的永远只是文本。

还原属性用 setAttribute,MDN 写明,在 HTML 文档里它会把属性名自动转成小写,值不是字符串时也会被转成字符串。所以 JSON 里的属性名大小写不影响结果。

如果 JSON 来自不可信的来源,还要考虑白名单:on 开头的属性,比如 onclick,写成特性以后就是一段会执行的脚本;<script>、<iframe> 这类标签也要拦住。

常见追问

SVG 元素用这套代码有什么问题?

主要是命名空间。MDN 说明,在 HTML 里混用 SVG、MathML 时,要用 createElementNS 显式指定命名空间,SVG 元素要用 document.createElementNS('http://www.w3.org/2000/svg', tag) 创建,所以 JSON 里要记下节点的 namespaceURI。另外 SVG 有 foreignObject 这类带大写字母的标签名,一律转小写会改掉名字;而且 MDN 写明,在 HTML 文档里调用 createElement 会先把名字转成小写。

DOM 树很深会不会栈溢出?

递归深度等于树的深度,正常页面一般不会有问题。要处理极深的树,可以改用显式栈迭代,思路见《菜单、目录这类多叉树怎样用显式栈做前序遍历?》。