ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入理解DOM:从HTML解析到虚拟DOM与性能优化实战

深入理解DOM:从HTML解析到虚拟DOM与性能优化实战 DOM 这三个字母大概是前端面试里出现频率最高、但也最容易被讲糊的基础概念。很多人能背出“Document Object Model”的全称可一旦被问到“浏览器是怎么从一段 HTML 字符串变成你能点击、能修改的页面”“为什么操作 DOM 慢”“虚拟 DOM 到底虚拟在哪”就开始支支吾吾。我早期也一样写 jQuery 写了挺久对原生 DOM 的理解一直停留在“document.getElementById 能拿到东西”的层面。直到后来手写组件库、啃 React 源码、做性能优化才把这条链路真正串起来。这篇我会从浏览器解析 HTML 开始一路讲到节点树、增删改查的性能陷阱、渲染流水线、虚拟 DOM 的 diff 策略再到事件机制里的捕获和冒泡最后顺手聊聊最近热搜里那几个“DOM”——DOM 型 XSS、ArcMap 切片、OSGB 和 DOM 的关系。内容有点多建议直接收藏慢慢看面试前翻一遍会更稳。1. 从 HTML 到 DOM 树浏览器在看不见的地方做了什么1.1 文档对象模型到底是个什么东西很多人一听到“DOM 就是文档对象模型”直接懵了文档还能变成对象其实把它拆开看就清楚了。Document文档本身也就是这份 HTML。Object浏览器把标签、文本、注释、属性都变成了一个个对象。Model这些对象不是散装的是按父子、兄弟关系组织成的一张树形网。换句话说DOM 是 HTML 文档在浏览器内存里的结构化表示。页面里不是一个字符串里的“尖括号块”而是一个有属性、有方法、能挂事件的 JavaScript 对象。document.querySelector 之所以能精确找到某个元素正是因为浏览器在加载页面时早就把整个页面翻译成了一棵可编程的对象树。如果不建树只把 HTML 当纯文本处理那改一个词的文案就得重刷整个页面想做弹窗、表单校验、局部刷新就寸步难行。DOM 的存在本质上就是给 JavaScript 开了一扇操作页面的门。1.2 一段 HTML 是怎么变成内存里的一棵树浏览器的解析过程大致是四步字节流 → 字符流 → 令牌 → 节点 → DOM 树。比如网络传来这么一段 HTML!DOCTYPE html html head titleDOM 小例子/title /head body div idapp你好/div /body /html浏览器拿到的是字节0 和 1先按编码规则通常是 UTF-8解码成字符串然后进入标签解析器把字符串切成一个个“令牌token”——开始标签、结束标签、属性名、文本内容等接着每个令牌会被转成一个相应类型的节点最后按照令牌嵌套顺序把这些节点组装成一棵带层次关系的树。这棵树的大致长这样Document根html元素节点head元素节点title元素节点“DOM 小例子”文本节点body元素节点div元素节点id“app”“你好”文本节点注意几个容易忽略的点文本“你好”也是一个独立节点注释也会变成 Comment 类型节点属性虽然挂在元素上但早期 DOM 规范里属性也能被视为一种节点。树的根不是而是 document 对象本身。1.3 为什么要新增这一层“中间表示”最直接的理由有三个。第一运行时数据结构和静态文本的表达维度不一样。字符串是线性的页面是嵌套的。线性的字符串表达不了“这个 div 的父亲是 body”这种结构化关系只有树能干这事。第二操作粒度不同。字符串操作通常一次要替换一大片而树状结构可以精确定位到某个叶子节点。页面里百万个节点改最后一个按钮的文案不需要重建整个页面只需要更新对应的那个文本节点。第三行为和渲染能解耦。DOM 只是一份“内存描述”怎么显示由渲染引擎决定怎么交互由事件系统负责。这份描述层足够稳定才能支撑后面各种框架在上面做花样。记住一句话HTML 是输入DOM 是浏览器解析后的产物而你在控制台里看到的 Element 就是 DOM 树上那些节点们的“实体形象”。2. 节点树底层的那些事Node、Element、Text 的爱恨情仇2.1 节点类型不止“元素”一种DOM 是一棵节点树但节点分很多种。日常工作里接触最多的当然是元素节点但面试和实际开发里文本节点、注释节点、文档碎片也经常冒头。下面这张表建议收藏几乎是必查项nodeType常量名代表内容典型访问方式1ELEMENT_NODE元素节点比如 div、span最常见的节点2ATTRIBUTE_NODE属性节点已被 Element 属性取代很少单独讨论3TEXT_NODE文本节点element.childNodes 里常出现8COMMENT_NODE注释节点注释内容也在树里9DOCUMENT_NODEDocument 对象document10DOCUMENT_TYPE_NODEDOCTYPE 声明document.doctype11DOCUMENT_FRAGMENT_NODE文档碎片DocumentFragment为什么文本节点重要因为布局、选区、innerText 的实现都依赖它。比如你给一个 div 设置 textContent本质上就是替换掉它的所有子节点把字符串变成一个新的文本节点插进去。2.2 Node 和 Element 是什么关系这是很多初学者绕不过去的坎。看着 nodeType 都分不清 Node 和 Element其实关系非常简单Element 是 Node 的一个子类。完整的继承链是这样的EventTarget → Node → Element → HTMLElement → HTMLDivElement → Text → Comment所以元素节点“既是 Node又是 Element”。document 是 Node但不是 ElementText 是 Node也不是 Element。凡是能在树里当节点存在的都是 Node而 Element 特指那些能嵌套子节点、携带属性、能通过 CSS 选择器匹配的节点类型。这个区别带来一个很实用的判断技巧Node 上有很多属性在 Element 上不一定适用比如 textContent 是 Node 的属性而 innerHTML 是 Element 的属性。你要是拿到一个文本节点还想读它的 innerHTML直接得到 undefined。2.3 遍历树上节点的常用姿势很多教程让你无脑用 children 和 parentNode但真正在遍历节点时你要分清楚childNodes返回所有子节点含文本、注释是 Node 层面的接口动态集合。children只返回元素子节点是 Element 层面的接口同样动态。parentNode和parentElement绝大多数情况指向同一个父节点但 parentNode 会包含 document 这样的父级。firstChild/lastChild是 Node 的可能拿到文本节点firstElementChild/lastElementChild才是只认元素的。写一个简单的遍历需求打印元素的所有后代标签名。function walk(node) { let child node.firstElementChild; while (child) { console.log(child.tagName); walk(child); child child.nextElementSibling; } }这里刻意用 firstElementChild 和 nextElementSibling 而不是 firstChild / nextSibling就是防着文本节点捣乱。实际开发里这种“防文本节点”的意识很重要一旦用错了遍历结果里会莫名多出一堆换行符和空格组成的文本节点bug 很难排查。3. DOM 操作实战增删改查的正确姿势和性能陷阱3.1 查元素选择器 API 和那点内存的小心思现在查元素基本是三板斧document.getElementById(id)最快但只能按 id 查单个。document.querySelector(选择器)/querySelectorAll灵活匹配 CSS 选择器。document.getElementsByClassName/getElementsByTagName返回 HTMLCollection动态集合。这里有个关键差异也是不少面试官爱问的点querySelectorAll 返回的是静态 NodeListgetElementsByClassName 返回的是动态 HTMLCollection。所谓“动态”意思是集合不是快照它会在你后续修改 DOM 时自动更新。比如const list document.getElementsByClassName(item); console.log(list.length); // 假设是 3 document.body.appendChild(document.createElement(div).className item); console.log(list.length); // 变成 4集合自己变了若换成 querySelectorAll 的结果第二次打印还是 3。动态集合在某些场景效率高但也会带来隐性的“幽灵更新”修改 DOM 后遍历结果经常意外变化。我的习惯是需要基于当前结果做快照式处理时用 querySelectorAll需要在循环里持续查找最新元素时用 getElementsBy 系列。3.2 增删节点从 appendChild 到 remove 的演化创建节点老资历的方法是这样const div document.createElement(div); div.className card; div.textContent Hello DOM; parent.appendChild(div);插入位置上还有 insertBefore 这个老接口parent.insertBefore(div, parent.firstChild);删除则是 removeChildparent.removeChild(div);不过现在浏览器已经铺开了一批更现代、更符合直觉的接口parent.append(div, 文本)可以同时塞多个节点或字符串比 appendChild 只收一个节点强。div.remove()直接删除自己不用再“先找父再删子”。div.replaceWith(newNode)把自己替换掉。parent.replaceChildren(...nodes)一次性清空并替换全部子节点。老接口不是不能用但新接口的表达明显更贴近人的直觉代码更干净。如果项目不需要兼容那种远古浏览器优先用新的那套。3.3 批量插入节点的三种姿势以及回流控制动态渲染一个列表最直观也最糟糕的写法是循环里逐个 appendfor (let i 0; i 1000; i) { const li document.createElement(li); li.textContent i; ul.appendChild(li); // 触发 1000 次布局计算 }这段代码性能很差因为每一次 appendChild 都可能导致浏览器重新计算布局也就是俗称的“回流reflow”。优化方式主要有三种。第一种用 DocumentFragment 当临时容器。把新节点先挂到一个“虚拟的文档碎片”上最后把它一次性插入真实 DOMconst frag document.createDocumentFragment(); for (let i 0; i 1000; i) { const li document.createElement(li); li.textContent i; frag.appendChild(li); } ul.appendChild(frag); // 只触发一次视图变更DocumentFragment 的巧妙之处在于它是个“不存在于页面上的迷你 document”。你在上面随便造节点不会引起任何渲染将它整个塞进真实 DOM 时所有子节点会“平迁”过去碎片本身则消失。第二种拼 HTML 字符串再一次性设置 innerHTML。适合“整段替换”且内容可信的场景let html ; for (let i 0; i 1000; i) { html li${i}/li; } ul.innerHTML html;但对用户输入内容直接拼接 innerHTML 是安全大忌可能引来 DOM 型 XSS这点后面会专门讲。第三种用现代框架的列表渲染思路先在内存中算出最终结构再使用 replaceChildren 一次性替换整个列表。本质上和 DocumentFragment 殊途同归都是减少布局抖动。在日常开发里我的总结是少于 20 个节点随便 append几十到几百个节点用 DocumentFragment数量上万且是一次性渲染优先考虑分片渲染而不是一口气全塞进去。这里的判断准绳只有一个——别让浏览器在短时间内反复计算布局。3.4 热词题“DOM 从上到下顺序渲染是不是更快”这个问题常见于性能优化讨论。先说结论浏览器的 HTML 解析确实是自上而下进行的但这不等于“按 DOM 顺序修改页面就一定最快”。HTML 解析器本质是流式的一边下载一边解析碰到普通元素直接就构建节点碰到
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进