
在移动端项目里被一个 DOM 渲染性能问题折腾到凌晨两点排查到最后发现罪魁祸首竟是innerHTML拼接列表项和每次请求都全量重绘。那次之后我花了不少时间系统性整理了 JavaScript 操作 DOM 的正确姿势从早期的能跑就行到后来追求极致性能的过程踩了不少坑。这篇内容不讲虚的直接梳理我从基础操作到性能优化全链路沉淀下来的经验和教训适合刚接触前端想打好 DOM 基础的新人也适合已经有项目经验、想在性能优化上再进一步的同学参考。1. 先聊清楚DOM 操作为什么需要核心攻略很多新人一上来就写document.getElementById和document.querySelector功能倒是实现了但完全没有意识到每一次 DOM 操作背后其实牵连着浏览器的一大堆内部机制。我早期写代码也是只管功能不管性能直到真正面对复杂业务场景才明白什么叫渲染瓶颈。1.1 浏览器渲染流水线与 DOM 代价当你通过 JavaScript 修改 DOM 的时候浏览器内部大致要经历这么几个阶段从上一次布局完成后你的 JS 代码执行了对 DOM 的增删改。浏览器需要重新计算受影响元素的几何位置、尺寸这个过程叫Layout布局也叫Reflow重排。然后浏览器要把重新布局后的结果绘制到屏幕上这个过程叫Paint绘制也叫Repaint重绘。如果是层合并和合成阶段处理得不好还会影响Composite合成的性能。我习惯把一个页面想象成一间正在营业的餐厅厨房。DOM 节点就是厨房里的食材和厨具操纵它们相当于厨师随手挪动锅碗瓢盆。如果动不动就把整个厨房重新布置一遍那后厨必然乱成一锅粥。重排和重绘的区别在于重排一定会伴随重绘但重绘不一定需要重排。比方说只改变color或background-color这类不影响布局的属性浏览器通常只需要重绘但如果你改了width、height、left、top这类几何属性那整个布局阶段都得重新来一遍。1.2 我为什么建议把性能意识放在最前面网上大部分教程是API 速查手册告诉你有哪些方法、传什么参数、返回什么结果。但实际开发里真正坑人的往往不是 API 不会用而是用错了方式导致页面卡顿排查半天找不到问题。举个最常见的例子循环里操作 DOM。const list document.getElementById(list); for (let i 0; i 1000; i) { const li document.createElement(li); li.textContent Item i; list.appendChild(li); }这段代码从功能上看完全没问题1000 个li都能正确加到list中。但问题在于每执行一次appendChild浏览器都会经历一次布局更新在循环中反复触发重排。虽然现代浏览器对批量操作有一定的批处理优化但 1000 次同步的 DOM 写入依然是明显的性能隐患。看到这里可以先记住一句话减少 DOM 操作的次数把多次操作合并成一次是最有效也最容易被忽视的优化手段。后面我会给出具体的优化写法。2. DOM 查询与节点操作的基础功这块内容算是基本功但我发现很多人用了几年 JavaScript仍然会在查询和节点操作上踩到一些细节坑。这里把我整理过的常用知识点系统梳理一下。2.1 选择器 API各有各的坑常见的选择方法有getElementById、getElementsByClassName、getElementsByTagName、querySelector、querySelectorAll。其中前三个返回的是HTMLCollection后两个返回的是NodeList。区别在于HTMLCollection是动态集合也就是说文档变化时它会实时更新。通过querySelectorAll获取的NodeList是静态快照文档变化时它不会自动更新。我举个例子你大概就能体会到这种差异const divs1 document.getElementsByTagName(div); const divs2 document.querySelectorAll(div); const newDiv document.createElement(div); document.body.appendChild(newDiv); console.log(divs1.length); // 会包含新加的 div动态更新 console.log(divs2.length); // 不会包含新加的 div静态快照这个差异最容易坑人的地方在于遍历。比如你在循环里根据条件向页面添加新的节点同时又在用getElementsByTagName获取的集合做遍历那集合的长度会不断变化循环逻辑很容易出问题。所以我个人的习惯是如果只是查询一次、不需要实时反映文档变化优先用querySelector或querySelectorAll如果你确实需要一个实时映射的集合再用getElementsBy*。2.2 节点增删改的标准姿势操作节点的核心 API 大家应该都熟createElement、appendChild、insertBefore、removeChild、replaceChild。但有几个细节容易忽略一个节点只能有一个父节点。如果你把 A 节点appendChild到 B 中而 A 原本是 C 的子节点那么 A 会先从 C 中被移除自动完成搬运。cloneNode(true)可以深拷贝节点但事件监听器不会被复制。推荐使用before()和after()这样的新 API相比insertBefore更直观const parent document.getElementById(container); const newNode document.createElement(p); newNode.textContent Hello 文档片段; parent.before(newNode); // 在 parent 前插入 parent.after(newNode); // 在 parent 后插入很多教程没有强调Element.before()和Element.after()但实践中用起来确实比insertBefore舒服很多不需要传入参考节点语义也清楚。2.3 innerHTML、textContent 与 createElement 的取舍这三者经常被拿来对比实际开发里也不是用得越多越好。innerHTML解析 HTML 字符串灵活但危险容易在拼接时插入恶意脚本也就是搜索热词里提到的DOM 型 XSS风险。textContent只处理纯文本完全不解析 HTML安全性高。createElement配合textContent或append是最正统的方式灵活度和安全性都平衡得比较好。如果只是更新一段纯文本请直接用textContent。不要写element.innerHTML span value /span因为当你没法保证value来自可信来源时很容易埋下安全漏洞。就算你能保证来源可信频繁用innerHTML也意味着更长的字符串解析过程。3. 事件机制的三个关键词冒泡、捕获、委托关于 DOM 事件面试必问、开发必用的就是这三件事。热词里也出现了dom事件流分为三个阶段、事件冒泡、事件捕获、事件委托(面试口述)说明这是大家公认的重难点。这部分我把三者的关系和实际应用讲透。3.1 事件流的三阶段与执行顺序DOM 事件流分为三个阶段捕获阶段、目标阶段、冒泡阶段。当你触发某个元素的事件时事件会先从window开始向下逐层到达目标元素这叫捕获阶段到达目标元素后事件在目标上触发这是目标阶段然后再从目标元素逐层向上回到window这是冒泡阶段。理解这个顺序非常关键。用addEventListener注册事件时第三个参数控制回调在哪个阶段执行element.addEventListener(click, handler, true); // 捕获阶段执行 element.addEventListener(click, handler, false); // 冒泡阶段执行默认实际开发中绝大多数情况都在冒泡阶段处理事件但偶尔也会用到捕获阶段。比如说你想实现一个点击任意子元素都能监听的全局逻辑就可以在捕获阶段提前拦截避免一些特殊子元素调用了stopPropagation导致父级监听器收不到通知。3.2 事件委托的核心原理与写法事件委托本质上是利用了事件冒泡机制与其给每个子元素分别绑定监听器不如在它们的共同祖先上绑一个监听器然后在回调里通过event.target判断真正触发事件的元素。document.getElementById(list).addEventListener(click, function (event) { const target event.target; if (target.tagName LI) { console.log(clicked:, target.textContent); } });这样做最直接的好处是不管列表里有多少个子元素甚至未来动态新增了li都不需要额外绑定事件。很多新人容易忘掉的一点是动态插入的元素如果用循环内逐一绑定的方式会非常麻烦而事件委托天然就处理好了这种场景。这在列表渲染、表格操作、菜单交互中非常常见。3.3 stopPropagation 与 preventDefault这两个方法经常被搞混stopPropagation()阻止事件继续传播无论是捕获还是冒泡阶段。preventDefault()阻止浏览器默认行为比如点击链接跳转、表单提交、右键菜单等。有一个容易被忽略的细节stopPropagation()不会阻止同一个元素上的其他监听器执行如果你需要彻底阻断同一元素上的其他处理器要使用stopImmediatePropagation()。这个我就是在实际项目里踩过坑——点了按钮之后弹窗确实没冒泡到外层但同一个按钮上的另一个绑定逻辑还是执行了排查了好一阵子才意识到区别。4. 性能优化实战批量操作、文档片段与样式隔离接下来是重头戏。前面的基础铺垫都是为了这里做准备的。性能优化在实践中不是单独一个点而是一整套策略的组合应用。4.1 批量操作 DOM 的核心技巧DocumentFragment回到前面循环插入 1000 个li的例子。最直接的优化方式就是用DocumentFragment把节点先攒起来再一次性地加到真实 DOM 里const list document.getElementById(list); const fragment document.createDocumentFragment(); for (let i 0; i 1000; i) { const li document.createElement(li); li.textContent Item i; fragment.appendChild(li); } list.appendChild(fragment);DocumentFragment可以理解为一个虚拟的轻量级文档片段它存在于内存中不属于真实 DOM 树。所有子节点都加到它身上不会引起页面回流的重新计算。最后一次性插入真实 DOM浏览器只处理一次结构性变更性能开销自然大幅下降。我在一个真实移动端项目里测过一个 2000 条数据的列表用循环直接appendChild的方式需要大约 800ms 才能完成渲染而改用DocumentFragment之后压缩到了 200ms 以内。这个数字在不同设备上会有差异但量级的差距是非常明显的。4.2 重排与重绘的触发源与规避方式以下是常见的触发重排的操作我整理出一个表格方便对照操作类型具体示例影响范围几何属性修改width、height、left、top、margin、padding可能会影响周边元素重构整个布局树结构性变更appendChild、removeChild、insertBefore根据变更范围影响子树或整个页面获取布局属性offsetWidth、offsetHeight、getComputedStyle会强制浏览器同步计算布局改变字体或窗口大小字体增大、窗口缩放影响整页布局其中获取布局属性这一点需要特别留意。你可能只是想在控制台打印一下元素的offsetHeight但浏览器会为了返回精确值提前把布局结算一遍。换句话说写操作之后立刻做读操作会强制浏览器执行同步布局这段期间任何改动都会卡住渲染管线。规避策略是优先修改class而不是逐条修改内联样式。使用requestAnimationFrame将视觉变化锁定到下一帧避免在布局抖动中反复改动。需要读取布局信息时尽量一次性读取完然后再集中写入。4.3 样式隔离与 CSS 变量结合如果你要给一组元素统一加样式最优雅的方式不是 JS 里拼 CSS 字符串而是定义一个class然后修改className或classList。element.classList.add(active); element.classList.remove(active);同时CSS 自定义属性CSS Variables也能和 JS 很好地配合。比如动态改变主题色document.documentElement.style.setProperty(--theme-color, #cb3837);这样一次设置会影响所有引用该变量的样式规则而不需要去遍历元素逐个改。这种方案在主题切换场景里非常实用。4.4 虚拟 DOM 与 diff 算法热词里反复出现虚拟dom和diff算法面试很多专门讲 React 或 Vue 的文章也有所涉及。虚拟 DOM 解决的核心问题本质上正是如何减少真实 DOM 操作。它的思路是用 JS 对象描述真实 DOM 的结构树数据变化时先在虚拟 DOM 上做新旧对比找出差异再将差异批量映射到真实 DOM 上。这里的 diff 算法决定了差异发现的效率和精度。如果你能从反复操作真实 DOM 代价高这个角度去理解虚拟 DOM 的动机面试时回答深度会完全不一样。但需要清醒的是虚拟 DOM 不是银弹。对于复杂交互场景真实 DOM 操作配合性能优化策略依然有效且灵活。工程上没有唯一正确的方案只有哪个更适合你的场景和团队技术栈。5. 移动端性能优化实践一次真实项目记录说了这么多理论我用一次实际项目经历来串一遍。这个项目是一个移动端 H5 列表页数据量中等每次请求后需要把返回的数据渲染到列表中同时要支持滚动加载更多。5.1 初始实现与卡顿表现第一版实现很直白每次拿到新数据就用一个updateList函数把整个列表的 HTML 字符串拼好然后container.innerHTML html一次性写进去。功能没毛病但在低端安卓机上滑动列表时能明显感觉到卡顿而且快速滚动时会频繁出现白屏闪烁。为什么会闪烁因为innerHTML整体替换会导致原节点全部销毁、新节点重新创建。即便现代浏览器对字符串解析做了不少优化创建大量全新 DOM 节点仍然是很大的开销同时页面重新布局和绘制也逃不掉。再加上低端设备的 GPU 合成能力弱卡顿就暴露出来了。5.2 一步步优化链路第一步引入DocumentFragment只对新增的数据做节点创建和插入不再全量替换整块列表。第二步把滚动加载改为加载前先判断距离页面底部是否还有阈值距离避免滚动事件里频繁发起请求。第三步对滚动监听做requestAnimationFrame节流确保事件回调不会在每一帧里跑多次。第四步图片懒加载在进入可视区之前不要创建img节点加载资源。经过这几步列表滚动明显流畅了帧率稳定了不少低端设备上白屏闪烁基本消失。5.3 性能面板和内存分析我自己调试时用 Chrome DevTools 的 Performance 面板记录了一段滚动和列表渲染过程能清晰看到哪些操作占了大量耗时。再看 Memory 面板排查是否存在不再使用的 DOM 节点还被变量引用导致内存无法释放。移动端性能优化尤其要关注内存泄漏问题因为手机浏览器内存资源更紧张泄漏到一定程度页面就会越来越卡。6. 构建一个可复用的精简 DOM 操作工具最后分享一下我平时在项目里经常用的一套小而美的工具函数。这些函数不是多么高深的框架而是在多个项目中摸爬滚打之后留下的实用封装你可以直接参考改造。6.1 统一的 $ 与 $$ 选择器function $(selector, context document) { if (typeof selector string) { return context.querySelector(selector); } return selector; } function $$(selector, context document) { if (typeof selector string) { return Array.from(context.querySelectorAll(selector)); } return Array.from(selector); }单层的$让我可以不用每次都写querySelector至少看起来简洁很多。$$返回的是数组而不是静态 NodeList方便直接调用map、filter、forEach等数组方法这在处理集合数据时特别顺手。6.2 批量插入与列表渲染function renderList(container, items, renderItem) { const fragment document.createDocumentFragment(); items.forEach((item, index) { const node renderItem(item, index); if (node) fragment.appendChild(node); }); container.appendChild(fragment); }这个模式几乎覆盖了绝大多数列表渲染场景。函数接收容器、数据数组和渲染函数渲染函数返回 DOM 节点。好处是把创建什么节点和怎么批量插入分离业务代码只需关心数据结构转成什么 UI插入过程统一走文档片段。6.3 事件委托的一行式封装function on(container, eventType, targetSelector, handler) { container.addEventListener(eventType, function (event) { const target event.target.closest(targetSelector); if (target container.contains(target)) { handler.call(target, event, target); } }); } on(document.getElementById(list), click, .item, function (event, el) { console.log(委托点击:, el.dataset.id); });closest方法可以从当前元素向上查找匹配的选择器再加上container.contains(target)防止事件在容器外部时误触发代码表达得很简洁。我封装这个函数后日常写交互少了一堆重复绑定代码。6.4 值得记忆的调试技巧如果页面出现卡顿但你不知道是不是 DOM 操作引起的问题可以用一个很简单的探测方式在 Chrome DevTools 里打开 Rendering 面板开启 Paint flashing 选项。一旦页面上出现绿色高亮闪烁的区块就说明这里有重绘发生。再用 Performance 面板录制一段操作看 Long tasks 都耗在哪里定位会更精准。7. 我踩过的一些典型坑与复盘这部分算是我吐血整理的高频问题每一条都有真实的项目背景。7.1 动态绑定事件 vs 事件委托早期写列表页我是这么写的items.forEach(item { item.addEventListener(click, () handleItemClick(item)); });问题是列表新增数据后新节点没有绑定事件。后来换成事件委托新老元素无差别响应一步到位。这种场景下委托的优势是压倒性的。7.2 读取布局属性导致的强制同步布局写动画时我踩过一个很深的坑在一个循环里反复读取元素的offsetHeight再设置它的top结果动画非常卡。问题本质就是我前面提到的写后读。优化方案是把需要读取的布局值先全部存下来计算完毕再集中写入样式。7.3 长列表节点销毁成本长列表如果一次性渲染几千条节点即便是DocumentFragment也要考虑节点数量本身带来的创建成本和内存占据。对于数据量极大的场景比如聊天记录、信息流通常需要配合虚拟列表/窗口化渲染也就是只渲染可视区附近的节点。这是另一个大话题但方向你应该了解一下。7.4 DOM 型 XSS 风险前面提过innerHTML的安全隐患。做搜索展示、用户评论回流、富文本编辑预览等场景时如果直接把用户的输入用innerHTML填进去很有可能会被注入恶意脚本。热词里出现的dom型xss就是这个问题。基本原则是能不用innerHTML就不用必须用时需要对内容做转义或通过信任的 sanitize 库过滤。我在项目中用自己的方式处理了这类问题优先用textContent渲染文本用createElement构建可信任的结构只有在完全可信任内容自己拼的模板字符串不包含外部输入时才用innerHTML。这样基本杜绝了绝大多数 DOM 型 XSS 场景。8. 最后的体会在做性能优化的过程中我最大的感受是工具和方法都在变但减少不必要的 DOM 操作、合并高频操作、隔离布局读写这套原则始终没变。不管是 jQuery 时代、原生 JavaScript 时代还是 React 和 Vue 占据主流的今天底层逻辑都是相通的。虚拟 DOM 也是因为解决了频繁全量更新真实 DOM 代价高的问题才被广泛接受。如果这篇内容对你有帮助建议你从今天起就在自己的项目里做三件事一是把循环里插入节点的代码改成DocumentFragment写法二是把列表项的点击事件改成事件委托三是养成分离读写布局信息的编码习惯。这三件事做下来你写出的 DOM 代码在性能表现上会有一个肉眼可见的提升。等熟练了再深入虚拟 DOM 和 diff 算法的实现细节你会有一种原来如此的通透感。