ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端DOM完全指南:从节点操作、渲染性能到虚拟DOM与事件流

前端DOM完全指南:从节点操作、渲染性能到虚拟DOM与事件流 如果你是刚接触前端没多久可能会觉得DOM这个名词很抽象它明明就摆在浏览器里能被document.querySelector找到可真要你说清楚它到底是什么又说不出来。我当年学DOM也踩过类似的坑——以为HTML源码就是DOM结果用JS改了页面内容打开“查看网页源代码”一看还是老样子当场就懵了。后来才明白DOM不是HTML源码而是浏览器把HTML解析之后生成的一棵“活树”所有标签都变成了节点每个节点都可以被JavaScript增删改查。这篇文章我就把DOM彻底拆开讲从它的真实身份、操作API到渲染流程、安全风险、虚拟DOM和Diff算法、事件流顺带提一句测绘领域那个同名缩写DOM避免你搜资料时被带偏。内容偏干建议先收藏再往下看。1. DOM到底是什么从一张网页到一棵活树的过程1.1 与HTML的区别源码是“剧本”DOM是“现场”很多人最先接触的概念是HTML于是想当然地以为DOM就是HTML标签。实际上两者差别非常大。你在编辑器里写下的HTML文件本质上只是一段有固定语法的纯文本字符串浏览器拿到这段文本后要经过读取字节、解析标签、构建结构等多个步骤才会在内存里生成一个可操作的对象结构这个结构就是DOM。你可以把这个过程想象成拍电影HTML源码是剧本DOM是在舞台上真正演起来的现场。剧本只要不改写文字永远不变但现场却可以被导演实时调整——演员站位变了、灯光变了、台词换了可剧本依然躺在那里没动过。于是你在DevTools里看到的结构和“查看源代码”看到的不一样就不奇怪了。DOM的全称是Document Object Model中文叫“文档对象模型”。拆开看Document指网页文档Object指浏览器把文档中的标签、属性、文本都封装成了对象Model则说明这些对象之间存在层级关系。所以DOM是浏览器提供的一份编程接口让JavaScript能够访问和修改网页展示的内容。它不仅是前端面试的高频考点也是真正理解框架的一把钥匙。1.2 DOM树长什么样节点和层级关系浏览器解析HTML后会把整个文档构建成树状结构这棵树就是“DOM树”。树上的每一个分支和叶子都对应一个“节点”。其中你最常打交道的有四类节点节点类型nodeType值举例文档节点Document9代表整个页面入口是document元素节点Element1div、p、span等标签文本节点Text3标签之间的文字内容包括空格和换行注释节点Comment8!-- 注释内容 --我去年带一个实习生写DOM遍历他卡了整整一个下午原因是页面结构明明没错用childNodes却老是遍历出一些奇怪的东西。后来发现罪魁祸首正是“文本节点”——HTML里元素之间的换行和空格都会被当作文本节点挂在树里。所以如果你要纯粹操作元素节点请优先使用children而不是childNodes这一点后面讲遍历时会展开。1.3 为什么浏览器非要弄出DOM这个中间层HTML是一种标记语言它只能描述内容长什么样本身不具备编程能力。而JavaScript是一门脚本语言它需要一个统一、稳定的接口去访问页面。DOM就是这个接口。没有DOMJavaScript就是一门“看不见网页”的语言什么按钮点击、表单校验、动态列表全都无从谈起。浏览器的渲染过程简单来说就是HTML解析成DOM树CSS解析成CSSOM树两棵树合并后生成渲染树浏览器再把渲染树绘制到页面上。而JavaScript能做的事情本质上都是对DOM树“这棵内存树”进行读取和修改修改完之后浏览器重新计算布局并刷新画面。另外注意一点DOM标准由W3C等组织制定浏览器只是按照规范实现了这套接口。这也是为什么主流浏览器都提供document、window这些对象且API高度一致前端能一套代码跑遍各个浏览器DOM规范功不可没。2. 操作DOM的核心技巧找节点、读节点、改节点2.1 查询DOM元素到底用querySelector还是getElementById日常开发里最常见的第一件事就是找到页面上的某个元素。最常见的API有两个阵营// 传统方式 const a document.getElementById(app); const b document.getElementsByTagName(div); const c document.getElementsByClassName(item); // 现代方式 const d document.querySelector(#app); const e document.querySelectorAll(.item);querySelector和querySelectorAll最大的优点是支持任意CSS选择器写起来非常直观比如document.querySelector(ul.list li.active)一行就能定位到深层元素。getElementById虽然在性能上略快一点但正常业务里这点的差距小到可以忽略我更看重代码可读性。但这里面有个容易踩坑的细节getElementsByTagName和getElementByClassName返回的是HTMLCollection它是“动态集合”。意思是如果后续往页面里新增了同类元素集合内容会自动更新甚至在你用for循环遍历的同时新增元素会出现无限循环的诡异现象。而querySelectorAll返回的是NodeList通常是“静态快照”定义时捕获的列表不会自动变化。所以如果你一边遍历一边增删元素优先用querySelectorAll否则极易出bug。2.2 节点之间的父子兄弟关系小心空白文本节点的埋伏定位到节点之后经常需要在DOM树里“爬”取父节点、子节点、兄弟节点。很多初学者会用parentNode、childNodes、firstChild、nextSibling结果经常被空白文本节点折磨。正确且推荐的做法是使用只考虑“元素节点”的APIconst item document.querySelector(.item); item.parentElement; // 父元素 item.children; // 所有子元素HTMLCollection item.firstElementChild; // 第一个子元素 item.lastElementChild; // 最后一个子元素 item.previousElementSibling; // 前一个兄弟元素 item.nextElementSibling; // 后一个兄弟元素举个例子你有一份列表想拿到每一项的序号来完成高亮操作用item.parentElement找到外层容器再用Array.from(container.children).indexOf(item)整个过程不会碰任何空白文本逻辑非常干净。如果用的是原生children返回的集合记得用Array.from转成真数组否则很多数组方法用不了。2.3 创建与插入从一个空容器到完整列表动态创建页面结构是前端的家常便饭。最基础的一套是document.createElement创建元素再用appendChild或insertBefore把它挂进DOM树const ul document.querySelector(#list); const li document.createElement(li); li.textContent 新项目; ul.appendChild(li);这里我要特别分享一个实测经验如果循环创建大量节点千万别写一个循环就往页面里appendChild一次。每次插入都会触发浏览器重新执行布局计算性能非常差。正确做法是先把所有新节点挂到DocumentFragment文档片段上最后一次插进真实DOMconst fragment document.createDocumentFragment(); for (let i 0; i 1000; i) { const li document.createElement(li); li.textContent 条目 i; fragment.appendChild(li); } ul.appendChild(fragment); // 只触发一次页面更新如果只是想在某个位置插入一段HTML字符串insertAdjacentHTML比innerHTML更安全也更灵活它有四个位置参数beforebegin、afterbegin、beforeend、afterend能精确控制插入点在目标元素的内外前后。2.4 删除、克隆与替换别忽略移除后的事件清理删除节点老API是parent.removeChild(child)新API可以直接child.remove()。替换节点则是parent.replaceChild(newChild, oldChild)。克隆节点用node.cloneNode(deep)参数deep为true时连子节点一起克隆。这里有一个很多人不会注意到的内存泄漏隐患用removeChild移除一个绑定了事件监听器的节点后如果还有外部变量引用着这个节点它的监听器可能依然存活造成内存无法释放。在大型单页应用里反复创建和移除组件时间久了页面就会越来越卡。稳妥的做法是移除节点前主动解绑事件el.removeEventListener(click, handler)或者用AbortController统一控制事件的添加和取消。这块虽然像细节但线上性能问题十有八九就这样积累出来的。3. 浏览器如何从上到下构建DOM以及你该怎么优化渲染3.1 “DOM从上到下顺序渲染是不是更快”的真相很多人在学习性能优化时会问浏览器从上到下边读边构建DOM那我把代码全写在页面顶部是不是解析得更快这句话对了一半。浏览器的确是从上到下解析HTML按顺序构建DOM的。但关键在于DOM的“构建”不等于“渲染”。HTML解析过程中一旦遇到普通的script标签浏览器会立刻停止DOM构建先把JS下载并执行完再继续往下解析。如果脚本放在head里且代码逻辑又笨重用户会一直盯着白屏。这就是为什么老派优化建议都强调“把脚本放到/body之前”。同时还有一个容易误解的点CSS会阻塞渲染但一般不阻塞DOM构建。浏览器可以一边继续解析HTML生成DOM树一边等待CSS处理完毕只是没有CSSOM就拼不出渲染树也就画不出任何像素。因此典型的优化方案是把首屏必要的CSS以内联或尽早方式加载非关键的脚本用defer或async标注避免插队阻塞。3.2 DOMContentLoaded和load到底差在哪判断页面什么时候可以交互是两个经典事件最常见的用途事件触发时机适合场景DOMContentLoadedDOM树构建完成样式表、图片、iframe可能还没加载完绑定事件、初始化业务逻辑越快越好load页面所有资源图片、样式、脚本等都已加载完统计页面完整加载耗时、图片尺寸计算实际开发中如果你在load里才绑定点击事件用户会觉得按钮反应慢半拍在DOMContentLoaded之前去查询图片尺寸往往又会拿不到正确数据。所以性能看板里统计“白屏时间”常用前者统计“完全加载时间”才用后者。这两个事件是理解页面生命周期最基础的一对。3.3 回流与重绘操作DOM最该躲开的性能坑修改DOM必然带来代价但代价分两种。重绘repaint指样式变化不影响几何布局比如改颜色、改背景浏览器只需要重新绘制那一片区域。回流reflow则是指元素几何属性发生了变化比如宽度、高度、位置、字体大小变化浏览器需要重新计算整棵渲染树的部分甚至全部布局代价大得多。为什么说要躲开因为有些代码会引发“布局抖动”。比如循环里先读element.offsetWidth再立刻修改element.style.width浏览器为了保证读取到的值是最新的不得不提前强制回流一次。读一次回一次流性能瞬间崩掉。我自己的习惯是把读取集中在一起改写法集中在一起。比如// 性能差循环里读写交杂 for (let i 0; i list.length; i) { const w list[i].offsetWidth; list[i].style.width w 10 px; } // 性能好先批量读数再批量改写 const widths []; for (let i 0; i list.length; i) { widths.push(list[i].offsetWidth); } for (let i 0; i list.length; i) { list[i].style.width widths[i] 10 px; }此外需要频繁变动的元素可以先用display: none把它移出文档流改完再显示回来或者用绝对定位把动画效果限定在一个局部区域减少回流波及范围。也正是因为手动操作真实DOM容易踩这些坑前端圈才逐渐走向了“虚拟DOM Diff算法”这套思路下一节就聊它。4. DOM安全一句innerHTML可能让你的网站被攻破4.1 什么是DOM型XSS不需要过服务器也能捅娄子说到安全就绕不开XSS跨站脚本攻击。跨站脚本里有一类叫“DOM型XSS”它的特点是不必经过服务器处理攻击代码直接在浏览器的DOM操作过程中被注入和执行。换句话说就算你的后端过滤做得再严格只要前端写了危险的DOM拼接攻击依然可能发生。常见的数据源头有这么几个URL哈希location.hash、URL查询参数location.search、document.referrer、window.name以及postMessage接收到的消息。这些内容用户可以直接控制比如在一个搜索页面里我把参数改成?keywordimg srcx onerroralert(1)如果前端代码把keyword直接拼进innerHTML攻击代码就原地执行了。4.2 最容易写出漏洞的三种代码我见过很多项目里存在下面这些写法每一种都是DOM型XSS的“卧底”// 危险1直接把用户输入拼进HTML el.innerHTML div欢迎你 username /div; // 危险2用document.write输出外部可控制内容 document.write(input value location.search.slice(1) ); // 危险3把不可信内容丢给eval或Function执行 eval(location.hash.slice(1));它们的共同问题是把“数据”当成了“代码”来解析。用户名本该是普通文本一旦被拼进HTML浏览器会把其中的img onerror、script当成真实标签去执行。防范的核心思路很简单永远不要把用户可控的内容当作HTML代码来执行。4.3 防御DOM XSS的实操清单首先是默认使用textContent而不是innerHTML。只想显示文字时textContent会将所有内容按纯文本渲染img onerror...只会原样显示出来不会执行。如果确实需要插入HTML那就要对用户输入做HTML实体转义function escapeHTML(str) { return String(str) .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); } el.innerHTML div欢迎你 escapeHTML(username) /div;再往上是传输防线给页面设置内容安全策略CSP禁止内联脚本和eval类方法执行。比如在响应头里加上Content-Security-Policy: default-src self; script-src self这种情况下即便攻击者注入了script标签浏览器也会直接拒绝执行。最后是自测输入一段测试字符串img srcx onerroralert(document.cookie)如果页面弹出弹窗说明你的拼接点已经泄漏了。别问我怎么知道的反正我在本地项目里测出过三次。5. 虚拟DOM和Diff算法面试常考但经常讲不清的优化方案5.1 直接操作真实DOM慢在哪前面讲回流和重绘核心结论是真实DOM每一次修改都可能触发高昂的布局计算。对于一个复杂页面我们常常在一段业务逻辑里连续修改多个节点如果每改一个就立刻反映到浏览器上代价会被无限放大。虚拟DOM的思路则是在JS层面用普通对象模拟一棵DOM树先在这棵“虚拟树”上完成所有修改再用Diff算法对比修改前后的两棵虚拟树找出最小差异集合最后一次性批量更新真实DOM。类比一下你有一面墙要重新贴装饰真实DOM操作相当于每挪一片装饰都要叫装修师傅来一趟虚拟DOM则是先在图纸上用笔标好所有要动的装饰最后让师傅一次性施工。省掉了大量中间过程。5.2 Diff算法到底在比什么虚拟DOM要高效关键在Diff算法如何快速找出“哪里变了”。经典的算法做了两条重要假设让复杂度从理论上的O(n^3)降到接近O(n)同层对比不跨层级移动再比较不同类型的节点直接替换不去深究内部内容。实际操作中Diff会从树的根节点开始一层一层往下走。如果新旧两个节点类型不同比如原来是div现在是span直接整个替换。如果类型相同就对比属性是否变化然后递归对比子节点。子节点的对比往往离不开key框架通过key判断哪些子节点是“同一个东西”从而复用旧节点而不是把整个子列表删了重建。没有key或key不唯一时列表元素哪怕只是顺序调换了一下框架也可能把所有节点都重新创建一遍白白浪费性能。更致命的是如果某个子组件内部有输入框或本地状态一旦被重建用户正在输入的内容就丢了。5.3 面试三连问快不快、key怎么选、Fiber是什么第一个高频问题虚拟DOM一定比直接操作真实DOM快吗答案是否定的。如果只做一次简单修改手动textContent hi比虚拟DOM的全套流程更快。虚拟DOM的价值在于面对复杂、高频、易出错的交互时把性能下限兜住同时让开发者不用天天手动优化操作顺序。面试时能说出“虚拟DOM不一定更快它降低的是心智负担和出错风险顺便提供批量更新的能力”会比只背“更快”高级得多。第二个高频问题key为什么不能只用index我建议用一个具体场景回答一个可拖拽排序的列表渲染时用数组下标当作key。某次你拖动了列表顺序框架对比新旧节点时发现同一个key位置上的元素内容变了就会直接就地更新导致组件状态错乱。最典型的翻车场景是列表里有输入框拖拽后输入框里填的内容跑到了别的行上。正确做法是使用业务数据里稳定且唯一的ID比如商品编号、用户ID。第三个问题如果问到Fiber简单说来是React在16版本后把虚拟DOM的协调过程拆成了可中断的“小任务单元”让浏览器能在渲染过程中优先响应用户输入不再一卡到底。这部分不用扯太深能说清“可调度、可中断、优先级”几个关键词就够了。6. DOM事件流捕获、冒泡、委托一次讲透6.1 一次点击在DOM树里经历了什么当你在页面上点击一个按钮事件并不是简简单单发生在按钮上就结束的。它的完整传播路径要分三个阶段捕获阶段、目标阶段、冒泡阶段。你可以把DOM树想象成一栋办公楼事件是一则消息。消息先由顶楼广播室往下逐层传达经过一层层走廊直到目标房间这是捕获阶段消息到达目标房间这是目标阶段随后消息从目标房间再一层层向上汇报直到回到顶楼广播室这是冒泡阶段。所以在一个嵌套结构中点击最内层元素时外层元素的监听器也会收到这次事件只是时机不同。完整路径永远是window-document-html- ... - 目标元素 - ... -document-window。也就是说最外层和最内层之间每个祖先节点都有两次机会处理这个事件一次在捕获阶段一次在冒泡阶段。6.2 addEventListener第三个参数以及stopPropagation的坑addEventListener的第三个参数最基础的形式是布尔值true代表在捕获阶段触发false或省略代表在冒泡阶段触发。div idouter stylepadding:20px;background:#eee; div idinner stylepadding:20px;background:#ccc;点击我/div /divconst outer document.getElementById(outer); const inner document.getElementById(inner); outer.addEventListener(click, () console.log(outer 捕获), true); outer.addEventListener(click, () console.log(outer 冒泡), false); inner.addEventListener(click, () console.log(inner 目标), false);点击内层元素控制台会依次输出outer 捕获-inner 目标-outer 冒泡。如果只想让事件停留在某个节点可以用e.stopPropagation()阻止后续传播。但要注意如果有多个监听器绑在同一个元素上stopPropagation并不会阻止同一元素上其他监听器继续执行想彻底“闭嘴”得用e.stopImmediatePropagation()。此外针对高频事件比如滚动、鼠标移动可以在监听器里加passive: true告诉浏览器“我不会调用preventDefault你可以放心滚动”减少不必要的阻塞。6.3 事件委托一个监听器管理一大片元素事件委托之所以能成立靠的就是冒泡阶段。当一个事件从最内层元素冒上来中间所有祖先都能捕获到。于是我只需要在父级挂一个监听器就能处理所有子元素的事件。以前需要给100个li各绑一个点击监听现在只需给ul绑一个document.querySelector(#list).addEventListener(click, function (e) { if (e.target.tagName LI) { console.log(你点的是, e.target.textContent); } });这样做至少有三大好处节省内存——不用为每个子元素创建独立监听函数动态生效——后添加进来的子元素无需重新绑定天然就能响应代码集中——改动逻辑时只动一处。缺点也很明显如果监听器挂在document上页面上任何点击都会经过它所以必须用e.target做类型判断避免无关元素触发。同时像mouseleave这种不冒泡的事件委托就无能为力了。6.4 面试口述模板30秒把事件流讲成亮点每次面试我都会准备一段流利的“口述稿”核心思路是把三个知识点串成一条逻辑线“DOM事件流分三个阶段。首先是从window到目标节点的捕获阶段事件一层层向下传播其次是目标阶段事件到达实际触发元素最后是从目标元素向上返回的冒泡阶段。默认情况下我们用addEventListener监听事件时第三个参数不传或传false回调是在冒泡阶段执行的传true则在捕获阶段执行。事件委托的原理就是利用冒泡我不用给每个子元素单独绑监听只在父元素绑定一个监听器然后通过e.target判断实际点击的是谁。这样既能节省内存也方便处理动态新增的元素。”这段话不长但“三个阶段、默认冒泡、委托原理”三点全踩中了面试官基本可以确认你是真懂而不是背概念。7. 别混淆测绘和前端都叫DOM但不是同一个东西7.1 数字正射影像图Digital Orthophoto Map是什么搜“DOM”资料时经常会有做GIS、测绘的朋友搜到一篇前端博客反过来也一样。测绘领域里也有个缩写DOM全称是Digital Orthophoto Map中文叫“数字正射影像图”。它是由航空或卫星影像经过几何校正、投影变换后生成的平面影像数据相当于一张消除了地形起伏和相机角度畸变的“真实照片地图”。你如果同时接触过OSGB会发现它们经常一起出现。OSGB是倾斜摄影测量里常见的一种三维模型数据格式OpenSceneGraph Binary记录的是具有立体结构的建筑物、地形模型而DOM提供的是二维正射影像底图。在三维GIS平台里DOM通常被覆盖在地形表面上当“贴图底稿”OSGB模型则作为立体的构筑物叠在上面两者一个管平面一个管立体配合使用能建出既直观又带真实纹理的三维场景。7.2 如果你在ArcMap 10.8里遇到“DOM切片”该怎么理解ArcMap 10.8及ArcGIS系列软件里说的“DOM切片”是把大范围的DOM影像数据按金字塔层级切成很多小块瓦片Tile存储到本地缓存或瓦片包中方便快速浏览和发布。这个操作在测绘内业、GIS数据生产里很常见本地切片通常关注三个关键点坐标系是否统一、切片方案是否匹配比如切片原点、比例尺级别、像元大小与压缩格式是否合理。这块我只是简单提一嘴因为本文主线是前端开发里的DOM。如果你是被“arcmap DOM切片”搜进来的建议去找GIS教程如果你是前端读者以后看到别人讨论DOM时涉及“影像图”“切片”“金字塔”要知道他们说的不是网页节点。最后说几句我自己学DOM的路线大致是先盘旋在增删改查API里然后被页面卡顿逼着去深入渲染机制接着被某个安全演练平台吓出一身冷汗再把这一切串起来发现面试题和实际工程经验终于对上了号。调试DOM我有个小习惯在控制台用console.dir(document.querySelector(.item))而不是console.log因为dir会完整展开对象的属性和方法想快速理解事件流最好亲自动手写一个嵌套div的demo再加上stopPropagation跑几遍比背十篇博客都管用。收藏这篇文章只算第一步希望你找时间打开浏览器把这些例子一行行敲出去。把DOM当成熟悉的老朋友之后再学框架、读源码都会顺手很多。
返回列表