1. 一次页面卡顿排查:DOM操作不是"赋值"那么简单
先从一个我实际遇到的场景说起。有次接手一个后台管理项目,功能很简单:打开页面,请求接口拿回几百条数据,渲染成一张表格。逻辑看着一点问题没有,可页面加载就是慢得离谱,数据到位后表格要等半秒多才显示完,期间整个页面像卡死了一样。当时第一反应是接口慢或者数据量大,可打开浏览器控制台一看,请求几百毫秒就完成了,数据量也就几百条。再切到 Performance 面板录制一遍操作,结果清清楚楚:耗时大头不在网络,不在JS计算,而是密密麻麻排在一起的Layout和Parse HTML记录。问题出在了DOM操作本身。
1.1 数据到了之后,浏览器在背后做了哪些事
很多刚入门的朋友会把DOM操作等同于普通变量赋值:let a = 1; a = 2;,内存里一个值换掉就完了。但DOM不一样。你把一个元素插到页面里,浏览器需要把新的节点挂到DOM树上,然后重新计算这棵树上有多少元素、每个元素多大、在什么位置、要怎么绘制,最后还得把像素画到屏幕上去。这一整套流程简写下来大致是:JavaScript 改 DOM -> 计算样式 -> 布局(Layout)-> 绘制(Paint)-> 合成(Composite)。
这里面最贵的不是"改"的那一下,而是改完之后触发的布局计算。尤其是当你往页面里循环插入节点时,每一次appendChild都可能让浏览器重新算一遍整棵树的几何信息。循环五百次,它就尝试处理五百次,哪怕你在循环体里只加了一行文本,它也不知道"你后面还会继续加",它只能按最保守的方式先重排一遍再说。这就是为什么很多看似不复杂的页面会卡。
那次排查的代码大概是这样的写法,我相信很多朋友都写过同款:
const list = await fetch('/api/list').then(r => r.json()); const tbody = document.getElementById('tbody'); list.forEach((item, index) => { const tr = document.createElement('tr'); const cell = document.createElement('td'); cell.textContent = `${index + 1}. ${item.name}`; tr.appendChild(cell); tbody.appendChild(tr); // 每次插入都可能触发一次重排 });这段代码的逻辑完全正确,甚至算得上整洁。但它忽略了一个事实:DOM 节点的每一次插入,都不只是一次内存操作,而是一次可能波及全页面的布局计算。几百条数据循环下来,就等于强制浏览器进行了几百次全量布局,不卡才奇怪。
1.2 从卡顿现场反推优化方向
排查的时候,我先把循环里的appendChild去掉,改成先用一个数组收集所有的tr字符串,最后一次性设置innerHTML,页面从半秒变成几十毫秒。虽然直接拼字符串不算最高雅的做法,但它至少证明了一件事:大多数时候,DOM 的问题不在"操作了DOM",而在"操作的次数和方式"。
反过来,如果一开始就用性能面板去观察页面,你会发现每次appendChild前后都跟着一次Layout,而且这几次Layout互相之间完全没有可合并的余地。浏览器是单线程的,它不会说"你等会儿循环完了我再一起算布局",它只会在每次该计算的时候立刻去算,因为脚本随时可能读取位置信息(比如offsetHeight),它必须保证你读到的值是最新的。换句话说,浏览器根本没法替你预判你后面还会不会再改。
所以,关于这个卡顿问题,我当时总结出的优化方向有两条:一是减少触发重排的次数,把多次增量插入合并成一次批量插入;二是缩短每次重排的影响范围,尽可能在脱离文档流的节点上操作,最后再挂回文档。后面第四章会详细拆这两条路的具体做法。现在先把另一个同样隐蔽、同样影响性能的坑说清楚:你选用的DOM查询方法,可能从一开始就让页面慢了一截。
2. 选择器的性能差异:getElementById 系列为什么快
很多教程告诉你"想查元素就用 querySelector",因为它像CSS选择器一样灵活,能.querySelector('#app .item:first-child')一次到位。这个建议本身没错,但它忽略了一个前提:querySelector 的灵活是有代价的。它需要解析整个选择器字符串,然后按照选择器的语义从根节点开始遍历匹配。一次调用可能没什么感觉,但如果在forEach循环体里反复使用,积少成多,性能差距就完全暴露出来了。
2.1 四种常见查询方式的底层差异
JavaScript 里最常用的节点查询方法就这么几类:getElementById、getElementsByClassName、getElementsByTagName、querySelector/querySelectorAll。表面上看它们只是"写法不同",但底层逻辑差别很大。我整理了一张表格,方便直接对照:
| 查询方法 | 返回内容 | 是否实时动态 | 底层策略 | 典型使用场景 |
|---|---|---|---|---|
getElementById | 单个元素 | 否 | 按 ID 索引直接命中,浏览器内部维护了所有带 ID 元素的映射表 | 查找顶级区域容器、锚点元素 |
getElementsByClassName | HTMLCollection | 是,文档变化时自动更新 | 遍历后代节点,按类名匹配;结果集合是"活的" | 统计某类元素数量,批量操作同类元素 |
getElementsByTagName | HTMLCollection | 是 | 遍历后代节点,按标签名匹配 | 操作同一类标签,如获取所有td |
querySelector | 单个元素 | 否 | 解析选择器字符串,从根节点开始按CSS规则深度优先遍历 | 需要复杂选择器、结构定位时 |
querySelectorAll | NodeList | 否,返回静态快照 | 同 querySelector,但会收集全部匹配结果 | 需要一次性获取符合复杂条件的元素集合 |
看到"实时动态"这一栏,很多人的第一反应是"那 getElementsByClassName 返回的是活集合,是不是效率更高,随用随新?"其实对也不对。活集合确实省掉了"DOM变了我还得重新查询"的成本,但它同时带来了一个经典问题:你在遍历一个活集合时,如果同时往里添加符合条件的节点,遍历次数会变得不可控,甚至可能陷入死循环。这个坑后面第三节还会碰到。
2.2 从"每次都查"到"查一次存起来"
我在代码里最常见到的低效写法,是在循环里反复查询同一个东西:
for (let i = 0; i < rows.length; i++) { const checkbox = document.querySelector(`#row-${i} .checkbox`); checkbox.checked = true; }这个循环每次迭代都会执行一次querySelector,每次都解析一遍模板字符串,都从根节点往下重新递归匹配。更好的做法是缓存节点引用:
const row = document.getElementById('row-' + i); const checkbox = row.querySelector('.checkbox'); checkbox.checked = true;更进一步的话,如果循环体里根本不用row之外的东西,连row这个变量都不需要重复在循环里创建,直接在循环外缓存一个container,循环内只用container.children[i]这类集合访问方式就能完成操作。
需要特别提醒的是,缓存节点引用缓存的是引用本身,不是快照。节点依然受到后续DOM变化的实时影响。比如你缓存了一个div,然后在别的地方把这个div移到了另一个容器下,你缓存的div变量依然指向同一个节点,它的parentNode已经变了。这不算什么大坑,但新手经常因此产生"为什么查出来的元素不是我想象的位置"的困惑。定位元素时,更重要的是把查询范围缩小,而不是每次都从全局document开始。
2.3 选择器越"像CSS"越慢吗
直接说结论:querySelectorAll的性能取决于选择器本身。选择器写得太长,比如#app .content .list .item .title span,浏览器每一步都要做一次匹配和筛选,工作量自然成倍增加。它和 CSS 选择器的解析逻辑基本一致,都是从右到左匹配(先找符合条件的末尾元素,再逐级往上验证)。所以一个常见经验是:能把父容器先找出来,就先用 getElementById 锁定容器,再在容器范围内做进一步查询,这比写一长串全局选择器要稳定可靠得多。
遇到要拿"一组元素里的某个"这种需求,我也建议优先考虑用closest方法,它在匹配一个元素是否处于某个容器内时非常方便,也就是从元素自身开始向上走链表,直到找到符合条件的选择器或到达根节点。比如处理动态列表的点击事件时,event.target.closest('.item')一步到位,比手动写多层parentNode判断要省心。这一块我们紧接着就要用到。
3. 用事件委托把监听器数量降下来
如果页面上一层一层地给每个按钮、每个列表项都绑定addEventListener,初期看着没啥问题,但数据一多、节点一多、交互一复杂,"一个个绑定"的维护成本就会爆炸。
3.1 冒泡:事件是怎么找到处理函数的
先回忆一下事件流的基本规律。事件从触发的最深元素开始,一路向上传递到祖先节点,这个过程就是"冒泡"。比如你点击一个表格里的单元格,事件不仅触发了那个td,它还会依次触发tr、tbody、table、body、document上的所有同类型监听器,直到最顶层或者有人调用stopPropagation()。
这就给了我们一个机会:与其给 N 个子元素各绑一个监听器,不如只给它们共同的父元素绑一个监听器,然后在事件处理函数里判断"这次点击到底落在谁身上"。一次绑定,全列表都生效。这不算什么新技巧,但很多人被框架的"事件绑定语法"宠坏了,原生 JS 反而用得越来越生疏。
3.2 动态列表的事件绑定旧方案与新方案
实际开发里最常见的痛点是:列表是异步加载的,数据到了以后才往容器里添加<li>。如果一开始用的是给每个<li>单独绑定的做法:
fetch('/api/list').then(res => res.json()).then(list => { const ul = document.getElementById('list'); list.forEach(item => { const li = document.createElement('li'); li.textContent = item.name; li.addEventListener('click', () => handleItem(item)); ul.appendChild(li); }); });代码本身没问题,但每次新增数据,都要重新创建并绑定监听器。如果列表还支持筛选、分页,那函数里会写一堆"清空列表再重新绑定"的逻辑,稍不留神就绑重了,内存里积累一堆重复监听器,页面一开一关,泄漏就来了。
改成事件委托之后,逻辑简洁很多:
const ul = document.getElementById('list'); ul.addEventListener('click', (event) => { const li = event.target.closest('li'); if (!li || !ul.contains(li)) return; const index = Array.from(ul.children).indexOf(li); handleItem(listData[index]); // listData 是缓存的全量数据 });这里比较关键的三个细节是:第一,用event.target.closest('li')来定位真正触发事件的列表项,而不是直接假设event.target就是li——因为用户可能点的是li里的某个<span>或<strong>;第二,判断closest('li')拿到的元素确实还在容器里,防止点到容器外搞出连锁反应;第三,把数据索引和 DOM 节点对应起来,不需要在全局到处找>for (const item of items) { const div = document.createElement('div'); div.textContent = item.name; container.appendChild(div); // 写 console.log(container.offsetHeight); // 读,强制布局 }
每一次循环都有写有读,而读到的最新高度又取决于上一次写入的结果。浏览器没法在下一次循环之前偷懒跳过布局,因为它知道你要的是实时高度。这会在性能面板里形成一道"长条锯齿",非常典型。优化方案很简单:要么把写和读分成两个独立阶段,要么干脆不要反复读。对于那个表格场景,最干净的办法自然是不中断地插入节点,最后只读一次总高度。
4.2 DocumentFragment 实操:把节点先在"文档外"组织好
DocumentFragment是官方提供的一个轻量级"临时容器"。它的特点是不在文档流里,往它里面添加节点不会引起页面布局变化,也不会影响现有 DOM。最后你只需要把整个 fragment 一次性挂到目标容器里。
const list = await fetch('/api/list').then(r => r.json()); const tbody = document.getElementById('tbody'); const fragment = document.createDocumentFragment(); list.forEach((item, index) => { const tr = document.createElement('tr'); const cell = document.createElement('td'); cell.textContent = `${index + 1}. ${item.name}`; tr.appendChild(cell); fragment.appendChild(tr); }); tbody.appendChild(fragment);只看代码可能觉得差别不大:不就是多了一个 fragment 吗?但实际效果完全不同。循环里所有节点都挂在 fragment 下面,与主文档没有任何关联,浏览器不需要为每一次appendChild计算布局。只有最后一行tbody.appendChild(fragment)才真正触发一次插入与重排。一次插入几百个节点,和一次插入一个节点,布局开销天差地别。
用 fragment 时还要注意一点:append 之后,fragment 的子节点会全部被移走,fragment 自身变为空。所以不要幻想 fragment 还能二次使用,也不要尝试在 fragment 里再嵌套 fragment 然后想着复用。需要重复渲染时,重新创建 fragment 就好,它本身开销极低。
4.3 微任务合并更新和 requestAnimationFrame 的适用场景
如果数据不是一次性全部到位,而是分批到达,比如 WebSocket 推送或者滚动分页加载,这种情况下每次都把一小撮数据插进 DOM,依然会产生多次重排。更稳妥的思路是:先收集数据,等到当前宏任务结束后,用一个微任务统一执行渲染。
经典实现思路是:定义全局变量pendingList和一个标记scheduled,每次数据到达就 push 进pendingList,然后设置一个Promise.resolve().then(flush)。nextTick 也好,某些框架的批处理也好,本质就是这个模式。我在自己写原生模块时用的简化版本:
let pendingItems = []; let scheduled = false; function queueRender(item) { pendingItems.push(item); if (scheduled) return; scheduled = true; Promise.resolve().then(() => { scheduled = false; const data = pendingItems; pendingItems = []; render(data); // render 内部统一使用 fragment 批量插入 }); }这么做的意义在于:无论这一轮里queueRender被调用了多少次(哪怕被连续调用 500 次),真正执行渲染的次数只有一次。微任务一定是在当前脚本剩余部分执行完之后、浏览器绘制屏幕之前运行,所以从用户视角看,数据和界面最终是"一帧内同时到位"的。
另一种方案requestAnimationFrame则适合"同样要批量更新,但你希望更新节奏跟随屏幕刷新率"的场景。比如拖拽过程中的位置更新、滚动条引起的视差变化,这类高频、且每一帧只需要最新值的事件,用 rAF 可以避免累加出大量中间状态。一个简单的经验判断:是数据完成任务后的批量渲染,用微任务;是连续高频的用户交互动作,用 rAF。两者不冲突,甚至可以叠加使用。
到这里,性能问题算是有了比较完整的解法。不过性能不是 DOM 操作的全部,另一类高频 bug 来自时机问题:数据拿到了,但 DOM 还没"准备好了",你立刻去操作它,结果拿到的不是null就是一个奇怪的旧状态。
5. 时序陷阱与观测方案:别在 DOM 更新前抢跑
这类 bug 在异步加载的页面里几乎百分百会出现。典型症状是:明明数据在then里打印出来了,下一步去getElementById却拿到null;或者用return false阻止了表单默认行为,结果提交事件还是照常发生了。说到底,就是代码的执行顺序和 DOM 的实际更新顺序没有对上。
5.1 数据到达、渲染完成、用户看到的顺序
用一个小例子说明。假设页面上有一个<div id="container"></div>,你在脚本里写:
fetch('/api/config').then(res => res.json()).then(config => { document.getElementById('container').innerHTML = '<button id="save">保存</button>'; }); document.getElementById('save').addEventListener('click', () => { console.log('点击了保存'); });看起来"先是请求,后是初始化按钮",直觉上认为addEventListener应该没问题。但fetch是异步的,它返回的 Promise 回调会放到微任务队列,而第二行的addEventListener会立刻执行。此时 DOM 里还没有#save这个按钮,拿到的自然是null。这不是因为 DOM 没渲染,而是代码执行顺序根本就不是从上往下严格"先完成请求再继续下一行"。
要解决这类问题,核心原则是:一切依赖异步数据或依赖于动态创建节点的操作,都必须等到数据真正到位、节点真正存在之后再做。具体方法就是把初始化逻辑放进对应的异步回调里,或者统一收口到一个"初始化渲染"函数里:
let isReady = false; fetch('/api/config').then(res => res.json()).then(config => { document.getElementById('container').innerHTML = '<button id="save">保存</button>'; isReady = true; bindEvents(); // 节点就位后再绑事件 }); function bindEvents() { if (!isReady) return; document.getElementById('save').addEventListener('click', handleSave); }这就是很多所谓"nextTick"机制的原始需求:在你操作 DOM 之前,保证上一次 DOM 更新已经完成了。
5.2 动态插入节点后拿不到元素:时机判断的几种手段
除了把逻辑放进回调里,还有几种常见手段:强制等待一个微任务、用一个setTimeout(0)、或者使用requestAnimationFrame。但这三种优先级不同,不要一刀切都用 setTimeout。DOM 更新后立刻需要读取元素时,await Promise.resolve()一个微任务通常就够了;如果是依赖浏览器绘制完成之后才能做动画测量,才用requestAnimationFrame。setTimeout(0)延迟过长且不保证精确,它会先被压进宏任务队列,执行时机大概率在下一帧之后,只适合"确实需要把所有同步任务和渲染任务都让路"的场景。
实际项目里我更多用await配合一个简单的工具函数:
async function waitForRender() { await new Promise(resolve => { requestAnimationFrame(() => resolve()); }); }这样写能保证两件基本的事情:微任务队列清空了、当前帧之前的布局和绘制完成了。对于"动态插入节点之后马上要拿它的尺寸、坐标"这种需求,这个工具函数比setTimeout(0)更靠谱。
严格来说,这种"等待"仍然是在赌时间,只是赌了一个足够安全的节点。如果元素可能因为数据不全而不出现,等待再久也拿不到,那就得回到数据层去排查,而不是继续调等待时间。
5.3 MutationObserver:观察 DOM 变化而不打断操作
有时候你没法控制向 DOM 写入的那段代码(比如引用了某个第三方插件),但你又必须在某个节点出现或某个属性变化时做出响应。这时做一个轮询会很耗资源,用setInterval监听也不优雅。标准方案是MutationObserver。它和事件监听最大的不同是:事件是"用户或程序做了某个动作,浏览器响应这个动作而触发",而 MutationObserver 是"DOM 树发生变化之后回调",它可以从容地批量处理一批变更记录。
const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1 && node.matches('.dynamic-item')) { // 处理新出现的节点 initItem(node); } }); }); }); observer.observe(document.getElementById('container'), { childList: true, subtree: true });使用 MutationObserver 时有几个细节务必要记住:childList和subtree最好按需开启,subtree: true意味着整个后代树全部监听,节点一多性能下降非常明显;addedNodes可能包含从一个位置移动到另一个位置的节点,这种"移动"属于删除加新增的组合,容易造成重复处理,判断时要多一层来源检查;另外回调执行时你还在当前微任务里改变 DOM,如果再主动去改 DOM,可能会再次触发 observer 回调,如果不小心会形成回调风暴。所以处理函数逻辑一定要轻,必要时在主线程里加一个标志位防止递归干扰。
说了这么多,其实这一类问题背后都是同一个逻辑:DOM 操作必须围绕"时机"和"范围"来做取舍。时机不对,结果就是 null 和状态错乱;范围不对,结果就是性能和内存的隐性损耗。把这两条主线想清楚了,前面那些各种具体的技巧和 API 就不再是零散的知识点,而成了顺手就能组合使用的工具。这也是我个人在实际项目里最深刻的体会。