DocumentFragment:批量插入 DOM 时如何避免多次重排
同样是往一个空列表里插入一千条数据,两种写法的观感完全不是一个量级。一种是 for 循环里对着真实 DOM 不停 appendChild,低端机上点一下能卡出一两秒,肉眼可见列表一行一行往外蹦;另一种是先在内存里拼好一千个节点,最后对页面只做一次插入,同样的数据量几乎感觉不到停顿。两段代码逻辑上做的事情一样,性能表现却天差地别,差别就出在要不要用 DocumentFragment 这个容器。
为什么循环插入会卡
先看最直接的写法:
1const list = document.querySelector("#list"); 2 3items.forEach((item) => { 4 const li = document.createElement("li"); 5 li.textContent = item.name; 6 list.appendChild(li); 7});
这段代码没有错,items 少的时候完全看不出问题。但 items 上千条时,每次 appendChild 都是对真实 DOM 树的一次修改,浏览器需要为这次修改重新计算布局、决定要不要重绘。现代浏览器其实已经对连续的 DOM 写操作做了不少合并优化,所以不能简单理解成"每 append 一次就必然触发一次重排";真正容易出问题的,是插入和读取布局混在一起的写法:
1items.forEach((item) => { 2 const li = document.createElement("li"); 3 li.textContent = item.name; 4 list.appendChild(li); 5 6 console.log(list.offsetHeight); 7});
插入之后立刻读 offsetHeight,这个动作有个专门的名字,叫强制同步布局(forced synchronous layout),有些工具里报出来是 layout thrashing(布局抖动)。浏览器本来想把一批 DOM 写操作攒起来一次性结算,但只要你去读 offsetHeight、offsetTop、getBoundingClientRect() 这类几何信息,它为了给你一个准确的值,就必须把前面攒着的写操作立刻结算一遍。写一次、读一次、再写一次、再读一次,循环里这么交替着来,等于逼浏览器每一轮都重新算一次布局,一千条数据就是一千次布局计算。
打开 Chrome 的 Performance 面板录制这段循环,能看到一大片紫色的 Layout 色块密密麻麻排在一起,每一条对应循环里的一次布局重算。把读操作挪出循环、或者干脆用 fragment 把拼装过程和真实 DOM 隔开之后,这片紫色基本就消失了。
为了拿到确切的数字而不是凭肉眼判断,我把三种写法各自跑一千条数据,用 performance.now() 掐了个粗略的耗时对比:
1function testDirectAppend(items) { 2 const list = document.querySelector("#list"); 3 list.innerHTML = ""; 4 5 const start = performance.now(); 6 7 items.forEach((item) => { 8 const li = document.createElement("li"); 9 li.textContent = item.name; 10 list.appendChild(li); 11 // 故意读一次布局信息,模拟最坏情况 12 void list.offsetHeight; 13 }); 14 15 return performance.now() - start; 16} 17 18function testFragmentAppend(items) { 19 const list = document.querySelector("#list"); 20 list.innerHTML = ""; 21 22 const start = performance.now(); 23 const fragment = document.createDocumentFragment(); 24 25 items.forEach((item) => { 26 const li = document.createElement("li"); 27 li.textContent = item.name; 28 fragment.appendChild(li); 29 }); 30 31 list.appendChild(fragment); 32 33 return performance.now() - start; 34}
在我手头这台开发机上(Chrome 86),一千条数据、每次都强制读一次 offsetHeight 的直接插入写法耗时基本落在 800~1200 毫秒这个区间,且波动很大;换成 fragment 拼装、最后一次插入的写法,耗时稳定在 15~25 毫秒左右,两者差了近两个数量级。这个数字不是绝对值,机器配置、DOM 复杂度、是否有其他样式计算在跑都会影响具体读数,但量级上的差距是稳定可复现的——只要循环里出现"写完立刻读"这个模式,耗时就会随数据量线性甚至超线性增长,而 fragment 的耗时基本只随数据量线性增长,增长斜率也小得多。如果去掉直接插入写法里那句 void list.offsetHeight,只保留纯粹的循环 appendChild,耗时会明显下降,因为现代浏览器对纯写操作确实做了合并优化,但一千条这个量级下,它依然比 fragment 慢,只是慢的幅度不像强制同步布局那么夸张。这也是为什么前面要强调:真正的性能杀手是"写读交替",而不是 appendChild 调用本身。
DocumentFragment 怎么用
DocumentFragment 是浏览器原生 API,不依赖任何库,兼容性完全不用操心。它本质是一个游离在文档之外的节点容器——节点挂在它上面不会触发页面渲染,因为它压根不在页面的渲染树里,你在它上面读不到有意义的 offsetHeight,也就没有机会手贱去读,从源头上避开了前面说的抖动问题。
用法很直接,把原来直接插入 list 的节点先插到 fragment 里,循环结束后再对真实 DOM 做一次 appendChild:
1const list = document.querySelector("#list"); 2const fragment = document.createDocumentFragment(); 3 4items.forEach((item) => { 5 const li = document.createElement("li"); 6 li.textContent = item.name; 7 fragment.appendChild(li); 8}); 9 10list.appendChild(fragment);
这里有个细节容易看漏:list.appendChild(fragment) 插入页面的不是 fragment 本身,而是它里面的子节点,插入完成后 fragment 会变空。有次我图省事想把同一个 fragment 拿去插两个地方,第二次插进去发现里面空空如也——fragment 只是个搬运工,插一次就把货卸完了,不留副本。真要插两份,得先 fragment.cloneNode(true) 克隆一份再插。
反过来看,"插入后自己消失"也是 fragment 的一个优点:不像拿一个真实的 <div> 当临时容器,最后页面上会多出一层没有意义的包裹元素,fragment 插完就不留痕迹,子节点直接成为目标父元素的孩子,DOM 结构干净。
这个特性也解释了为什么 fragment 上读不到有意义的布局信息:它不是文档树的一部分,浏览器渲染引擎压根不会为它计算样式、走布局。用 Elements 面板去看运行中的页面,永远看不到一个 #document-fragment 节点挂在真实 DOM 树里——它存在的时间很短,从创建到子节点被转移出去,一般也就是拼装数据的这几行代码运行的时间,转移完成之后这个 fragment 对象本身如果没有别的引用,会正常被垃圾回收,不会遗留在内存里。用 Chrome 的 Memory 面板做过一次堆快照对比,插入前和插入后 detached 节点的数量没有变化,说明这套"离线拼装、转移子节点、fragment 自身回收"的流程确实没有额外的内存残留,不用担心用多了 fragment 反而造成内存泄漏。
如果是整段列表重新渲染,可以配合 replaceChildren 一步到位:
1function renderList(items) { 2 const fragment = document.createDocumentFragment(); 3 4 items.forEach((item) => { 5 const li = document.createElement("li"); 6 li.textContent = item.name; 7 fragment.appendChild(li); 8 }); 9 10 list.replaceChildren(fragment); 11}
replaceChildren 是这两年才铺开支持的新方法,主流浏览器 2020 年前后才陆续跟上,老项目里遇到不支持的环境,我还是退回这种写法:
1function renderListLegacy(items) { 2 const fragment = document.createDocumentFragment(); 3 items.forEach((item) => { 4 const li = document.createElement("li"); 5 li.textContent = item.name; 6 fragment.appendChild(li); 7 }); 8 list.innerHTML = ""; // 先清空 9 list.appendChild(fragment); // 再一次性插入 10}
innerHTML = "" 是清空旧列表最省事的方式,比 while (list.firstChild) list.removeChild(list.firstChild) 这种循环删节点利落得多。两步合起来,页面只重排一次,用户也不会看到"先空一下再填上"的闪烁。
顺带记一下 DocumentFragment 和 <template> 之间的关系,之前一直没细想过。document.createDocumentFragment() 创建的是一个"空"的 fragment,节点需要自己一个个 appendChild 进去;而 <template> 元素本身自带一个 .content 属性,这个属性的值恰好就是一个 DocumentFragment,只不过它已经预先装好了 <template> 标签里写的那些子节点。换句话说,<template>.content 不是"类似于" fragment,它字面意义上就是一个 fragment 实例,只是来源不是手动创建、而是浏览器解析 <template> 标签时自动生成的。这也是为什么克隆 <template>.content 里的节点、把它塞进真实 DOM,跟手动 createDocumentFragment() 拼装节点最后走的是同一条插入路径——都是"游离的 fragment 子节点转移到真实 DOM"这一套机制,只是节点从哪来的不同。
用 template 生成复杂结构
如果列表项结构比较复杂,比字符串拼接更合适的做法是用 <template> 保存静态骨架,再克隆节点填数据:
1<template id="user-row-template"> 2 <li class="user-row"> 3 <strong class="user-name"></strong> 4 <span class="user-role"></span> 5 </li> 6</template> 7 8<ul id="user-list"></ul>
1const template = document.querySelector("#user-row-template"); 2const list = document.querySelector("#user-list"); 3 4function renderUsers(users) { 5 const fragment = document.createDocumentFragment(); 6 7 users.forEach((user) => { 8 const node = template.content.firstElementChild.cloneNode(true); 9 10 node.querySelector(".user-name").textContent = user.name; 11 node.querySelector(".user-role").textContent = user.role; 12 fragment.appendChild(node); 13 }); 14 15 list.replaceChildren(fragment); 16}
<template> 标签里的内容浏览器不会渲染、不会加载(图片不发请求、脚本不执行),纯粹是个待克隆的模子,通过 .content 拿到的正好是一个 DocumentFragment。比起在 JS 里一层层 createElement 加 appendChild 手搭结构,把静态骨架写在 HTML 里、JS 只管填数据,结构和逻辑分得清楚,改样式直接改 template,不用动渲染函数。
克隆的时候要记得传 cloneNode(true),参数为 true 才是深克隆,连子节点一起复制;写成 false 或者不传,只会克隆最外层那个空壳,.user-name、.user-role 全都不见了,querySelector 拿到的是 null,对着"Cannot set property textContent of null"这种报错摸不着头脑。另外 template.content.firstElementChild 用的是 firstElementChild 而不是 firstChild,是为了跳过模板里换行、缩进产生的空白文本节点——firstChild 很可能拿到的就是这样一个文本节点,又是一个新手常踩的坑。
如果模板结构里包含好几个同级子节点、不是单一根元素,template.content 本身就可以整体克隆,不用只取 firstElementChild:
1const template = document.querySelector("#user-row-template"); 2 3function renderUsers(users) { 4 const fragment = document.createDocumentFragment(); 5 6 users.forEach((user) => { 7 const clonedContent = template.content.cloneNode(true); 8 9 clonedContent.querySelector(".user-name").textContent = user.name; 10 clonedContent.querySelector(".user-role").textContent = user.role; 11 fragment.appendChild(clonedContent); 12 }); 13 14 list.replaceChildren(fragment); 15}
这里 template.content.cloneNode(true) 克隆出来的还是一个 fragment,直接 fragment.appendChild(clonedContent) 会把这个新 fragment 里的子节点转移进外层 fragment,两层 fragment 嵌套着用完全没问题,因为 fragment 本身不会被真正插入页面,最终落到真实 DOM 上的永远是子节点。批量创建相同结构的节点时,cloneNode 通常比重新 createElement 再逐个设置属性要快一些,尤其是结构本身比较深、子节点数量多的时候——克隆是浏览器内部对已有节点树的复制,省掉了逐个解析标签、逐个创建元素对象的开销。不过这个优势在结构简单(比如就是个 <li> 套一段文本)的场景里不明显,真正复杂的表单行、卡片组件这类多层嵌套结构上才看得出差距。
拿一个稍微复杂点的结构做过对比:一个包含头像、姓名、角色标签、三个按钮的用户卡片,一共嵌套四层、七八个元素,一千张卡片分别用"逐个 createElement 手搭"和"cloneNode 克隆模板"两种方式生成。手搭的写法每张卡片都要走七八次 createElement、若干次 setAttribute 或者 classList.add,一千张卡片下来累计有大几千次这样的调用;克隆的写法每张卡片只有一次 cloneNode(true) 加两三次 querySelector 定位到要填数据的节点。实测下来克隆版本的总耗时大约是手搭版本的六到七成,不算脱胎换骨的差距,但在同样追求流畅度的场景里,这个提升是白捡的——代码量还更少,不用为了拼一个结构写一长串 createElement/appendChild。真正让人在意的收益还是可维护性:结构改动只需要动 HTML 里的 <template>,不需要去 JS 里翻找对应的节点搭建代码。
顺带记一个容易在这类代码里踩到的小坑:fragment.querySelector 是能用的,fragment 虽然不在文档树里,但它自己维护着一份完整的子树结构,querySelector、querySelectorAll 在它上面工作方式跟在普通元素上一样。容易搞混的是 document.querySelector——它只能查到已经在当前文档树里的元素,如果节点还挂在一个尚未插入页面的 fragment 上,用 document.querySelector 是查不到的,得对着 fragment 本身或者 fragment 里那个具体的克隆节点去查。这个区别在这次改造 <template> 渲染逻辑时被我踩了一次,一开始顺手写成 document.querySelector(".user-name"),取到的是页面上已存在的第一个同名节点(如果之前渲染过的话)而不是这次新克隆出来的那个,改成对着 clonedContent.querySelector 才对上号。
innerHTML 拼接是另一条路,但代价不同
innerHTML 写起来确实更短:
1list.innerHTML = items 2 .map((item) => `<li>${item.name}</li>`) 3 .join("");
代价是三点:字符串拼接容易把用户输入直接当 HTML 解析,带来 XSS 风险;事件绑定需要在替换之后重新处理;结构复杂起来,模板字符串的可读性会明显下降。
XSS 这一条不是纸上谈兵,团队之前一个内部反馈表单的展示页就出过这类问题:用户填写的"备注"字段被后端原样存下来,前端展示列表时用的正是 innerHTML 直接拼接,某次有人在备注里填了一段 <img src=x onerror="alert(document.cookie)">,页面渲染的时候这段字符串被当成真实的 <img> 标签解析,src 指向的资源加载失败触发 onerror,脚本就这么跑起来了。修复思路很直接:把 innerHTML 拼接换成 createElement 加 textContent,用户输入原样作为文本显示,浏览器不会把文本内容里的尖括号当成标签定界符去解析,这类注入天然失效。这个案例也是"直接创建节点并设置 textContent" 这条建议在这篇里反复出现的原因——不是习惯问题,是真出过事。
如果数据来自用户输入,用 DOM API 创建节点、配合 textContent 通常更安全;如果内容完全可信、结构又简单,innerHTML 也不是不能用,关键是别把用户输入原样拼进去:
1function escapeHtml(value) { 2 return String(value) 3 .replaceAll("&", "&") 4 .replaceAll("<", "<") 5 .replaceAll(">", ">") 6 .replaceAll('"', """) 7 .replaceAll("'", "'"); 8} 9 10list.innerHTML = items 11 .map((item) => `<li>${escapeHtml(item.name)}</li>`) 12 .join("");
对大多数业务代码来说,直接创建节点并设置 textContent 比转义字符串更不容易出错,也不用担心漏转义某个特殊字符。
还有一条路:Range.createContextualFragment
翻 MDN 的时候注意到 Range 对象上有个 createContextualFragment 方法,也能从 HTML 字符串批量生成节点,效果和 innerHTML 拼接有点像,但产出的是一个 DocumentFragment:
1const range = document.createRange(); 2range.selectNodeContents(list); // 给一个上下文,决定字符串按什么规则解析 3 4const html = items.map((item) => `<li>${escapeHtml(item.name)}</li>`).join(""); 5const fragment = range.createContextualFragment(html); 6 7list.appendChild(fragment);
"contextual"(上下文相关)这个词是关键:同一段 HTML 字符串,在 <table> 里解析和在 <div> 里解析结果可能不一样——比如一段 <tr><td>x</td></tr> 字符串,如果直接扔给一个 <div> 的 innerHTML,浏览器会因为不认识"游离在 <table> 之外的 <tr>"而丢弃这段内容;createContextualFragment 会参考 range 所在的上下文节点来决定怎么解析这段字符串,行为更接近"如果这段 HTML 真的写在这个位置会被解析成什么样"。这一点是它和单纯的字符串拼接 innerHTML 的本质区别。
性能上,createContextualFragment 和先拼字符串、再逐个 createElement 相比,优势在于省掉了逐个创建元素、逐个设置属性的手工步骤,浏览器解析引擎本身效率更高;和直接对目标元素设置 innerHTML 相比,两者做的解析工作差不多,区别主要在于产出物:innerHTML 直接替换目标元素的全部内容,createContextualFragment 产出的是一个独立的 fragment,可以先拿在手上做进一步处理(比如再往里插几个动态节点)、想好了再插入页面,多一层灵活性。安全性上它和 innerHTML 面临同样的 XSS 风险——字符串里如果混进了用户输入且没转义,一样会被当成真实标签解析,该转义还是要转义,这一点没有因为换了个 API 就自动变安全。这个方法在 2020 年这个时间点已经是各主流浏览器的标准实现,不算新鲜的东西,只是平时用的人不多,多数场景直接用 innerHTML 或者 DOM API 就够了,只有明确需要"先拿到 fragment 再决定要不要插入"这种中间态的时候才会想起它。
fragment 不止能整体插入,插到中间也一样
前面的例子都是把 fragment 整体 appendChild 到父元素末尾,其实它和普通节点一样,可以配合 insertBefore、before、after 这些方法插到任意位置,子节点会按原有顺序整体插进目标位置:
1const list = document.querySelector("#list"); 2const anchor = list.children[5]; // 假设要插在第 6 个已有节点之前 3 4const fragment = document.createDocumentFragment(); 5newItems.forEach((item) => { 6 const li = document.createElement("li"); 7 li.textContent = item.name; 8 fragment.appendChild(li); 9}); 10 11list.insertBefore(fragment, anchor);
这个用法在"往一个已经渲染好的长列表中间插入一批新数据"这类场景里很实用,比如下拉加载更多是往末尾追加,某些排序或置顶操作则需要往中间插。不管插入位置在哪,只要是一次性转移一批子节点,就还是只触发一次布局重排,跟插在末尾没有本质区别,性能收益是一样的。较新的 before/after 方法(作为 ChildNode 接口的一部分)写法上更直观一些,同样支持直接传入 fragment 或者一批节点:
1anchor.before(fragment);
需要注意的是 insertBefore 的参数顺序是"新节点在前,参照节点在后",跟直觉容易反过来的 list.insertBefore(anchor, fragment) 正好是错的,这个方法签名从原生 DOM API 诞生起就是这个顺序,写多了会习惯,但刚接触的时候确实容易记反。
事件绑定交给委托,别绑在每个节点上
批量插入节点之后,如果给每一个节点都单独绑一次事件,列表一大同样会有额外成本——一千条数据对应一千个监听器,占内存不说,列表重新渲染时旧节点被替换掉,监听器是不是彻底清干净还得看清理逻辑写得严不严实。更稳的做法是把事件绑在父级,靠冒泡接住所有子项的点击:
1list.addEventListener("click", (event) => { 2 const item = event.target.closest("[data-id]"); 3 4 if (!item || !list.contains(item)) { 5 return; 6 } 7 8 console.log("点击用户:", item.dataset.id); 9});
渲染时只需要在节点上补一个 data-id:
1li.dataset.id = item.id; 2li.textContent = item.name;
event.target.closest("[data-id]") 这一步是必须的:用户点的可能是 li 内部的某个 <span> 或 <strong>,event.target 拿到的是那个内层元素,得用 closest 往上找到真正带 data-id 的那个 li。后面再补一句 list.contains(item) 做保险,确认找到的节点确实属于这个列表,避免 closest 意外匹配到列表外面的元素。监听器挂在父级之后,不管列表怎么增删重渲,它就只有那一个,新插入的节点天然"带"上了事件,不需要额外处理。事件委托和 fragment 搭配着用,一个管插入效率,一个管交互成本,两者没有冲突,反而是互补的。
closest 这个方法本身也值得多说一句:它接受一个 CSS 选择器,从调用它的元素开始,沿着父节点链一路往上找,找到第一个匹配这个选择器的元素就返回,找不到返回 null。它和 matches 是配套的两个方法——matches 判断当前元素本身是否匹配选择器,closest 是在当前元素连同它所有祖先里找第一个匹配的。这两个方法在这一年(2020 年)已经是各主流浏览器的标准实现,不需要再考虑兼容性垫片,写事件委托逻辑时可以放心用,不用像早两年那样还要自己手写一个"沿着 parentNode 往上遍历直到某个 class 匹配"的小函数。
事件委托也不是没有代价:所有点击都要先冒泡到父节点、再执行一次 closest 查找,如果列表结构本身嵌套很深,closest 要往上遍历的层级也会变多,这个开销通常远小于"一千个监听器各自占用的内存和维护成本",所以整体上依然划算,只是不能简单认为委托就是"零成本"的免费午餐。另外要注意的是,并不是所有事件都会冒泡——focus、blur 这两个经典的不冒泡事件,委托在父节点上是接不住的,得改用会冒泡的 focusin、focusout,或者索性给每个可聚焦元素单独绑定。这是刚开始用事件委托时容易忽略的一个边界,绑上去没反应,排查半天才想起来是事件本身不冒泡,不是选择器写错了。
数据量再大一个数量级,该换虚拟滚动
fragment 解决的是"一次性插入一千条"这类场景,但如果列表要装的是几万条、甚至更多,即便一次性插入不卡,几万个真实 DOM 节点常驻在页面上本身就是负担——滚动的时候浏览器要为这么多节点做样式计算和合成,内存占用也上去了。这种量级更合适的方案是虚拟滚动:只渲染当前视口附近能看到的那几十个节点,滚动时动态替换内容、用一个撑高度的占位元素模拟"总共有这么多条"的滚动条效果。fragment 和虚拟滚动不是互斥的关系,虚拟滚动内部替换可见区域节点时,同样可以先用 fragment 拼装好这一批可见节点再整体替换,两者可以叠加。
虚拟滚动的核心思路拆开来看并不复杂,最简化的版本大概是这几步:外层容器固定高度并开滚动条,内部放一个撑起总高度的占位元素(高度等于"总条数乘以单条高度"),再放一个实际渲染可见内容的容器,监听外层的 scroll 事件,根据当前滚动位置算出应该显示第几条到第几条,重新渲染这一小段,同时用 transform: translateY 把渲染出来的内容平移到正确的可视位置。写成最简单的样子大概是这样:
1const viewport = document.querySelector("#viewport"); // 固定高度、overflow: auto 2const itemHeight = 40; // 假设每条固定高度 3const visibleCount = Math.ceil(viewport.clientHeight / itemHeight) + 2; // 多渲染两条做缓冲 4 5function renderVisible(allItems, scrollTop) { 6 const startIndex = Math.floor(scrollTop / itemHeight); 7 const endIndex = Math.min(startIndex + visibleCount, allItems.length); 8 const visibleItems = allItems.slice(startIndex, endIndex); 9 10 const fragment = document.createDocumentFragment(); 11 12 visibleItems.forEach((item) => { 13 const li = document.createElement("li"); 14 li.textContent = item.name; 15 li.style.height = itemHeight + "px"; 16 fragment.appendChild(li); 17 }); 18 19 const content = viewport.querySelector("#content"); 20 content.style.transform = `translateY(${startIndex * itemHeight}px)`; 21 content.replaceChildren(fragment); 22} 23 24viewport.addEventListener("scroll", () => { 25 renderVisible(allItems, viewport.scrollTop); 26});
这只是个简化到能说明思路的版本,真拿去生产用还差得远:条目高度不固定的情况要维护一份每条高度的缓存表,用累加高度而不是简单乘法去算 startIndex;scroll 事件触发频率很高,实际项目里通常会做节流或者放进 requestAnimationFrame 里执行,避免每次滚动像素变化都重新渲染一轮;缓冲区(前后多渲染几条)留少了,快速滚动时会看到瞬间的白屏,留多了又违背了"只渲染可见区域"的初衷,需要按实际滚动速度调参。市面上成熟的虚拟滚动方案要处理的边界情况比这个示例复杂得多,自己写只是为了理解原理,真到项目里,能用现成方案就不必重新造。
虚拟滚动动态替换内容这个动作本身,也会带来一个新问题:如果每次滚动都是"先整体清空、再整体插入"这种替换方式,即便量不大,也可能因为替换的时机和滚动动画的时机没对齐,产生轻微的卡顿感。这里 transform: translateY 而不是改 top 或者 margin-top 去定位内容的位置,是特意选的:transform 改变的是合成层的属性,浏览器可以直接在合成阶段完成这次移动,不需要重新走一遍布局计算;改 top 这类会影响文档流的属性,就会触发重排。配合给这个内容容器加一条 will-change: transform,相当于提前告诉浏览器"这个元素接下来会频繁做变换",浏览器可以提前把它提升为独立的合成层,减少滚动过程中反复合成的开销。不过 will-change 不是越多越好,滥用在大量元素上反而会占用更多内存去维护这些合成层,一般只在明确知道会频繁变化的少数几个元素上用。
如果数据量介于"一次性插入没问题"和"必须上虚拟滚动"之间,另一个折中办法是配合 requestAnimationFrame 分批渲染:把一万条数据切成一片一片,每一帧只处理其中一片、塞进 fragment 再插入页面,让出主线程给浏览器画面重绘,避免一次性算完卡住整个页面无响应。写成代码大概是这样:
1function renderInChunks(items, chunkSize) { 2 const list = document.querySelector("#list"); 3 let index = 0; 4 5 function renderChunk() { 6 const fragment = document.createDocumentFragment(); 7 const end = Math.min(index + chunkSize, items.length); 8 9 for (; index < end; index++) { 10 const li = document.createElement("li"); 11 li.textContent = items[index].name; 12 fragment.appendChild(li); 13 } 14 15 list.appendChild(fragment); 16 17 if (index < items.length) { 18 requestAnimationFrame(renderChunk); 19 } 20 } 21 22 requestAnimationFrame(renderChunk); 23}
这个思路和 fragment 并不冲突——分批的每一片内部依然可以用 fragment 离线拼装,只是把"一次性插入"改成了"分帧插入",用来换取页面在渲染过程中依然能响应用户操作。chunkSize 取多大要看单条渲染的实际开销,取大了单帧耗时超过 16 毫秒还是会掉帧,取小了总渲染时间拉长、用户等更久才能看到完整列表,这个值多半是跑几次 Performance 面板试出来的,没有放之四海皆准的答案。
框架里要不要手动用它
如果项目用 Vue 这类框架,通常不需要手动去用 DocumentFragment。以团队现在用的 Vue 2.6 为例,框架内部维护了一份虚拟 DOM,数据变化后不会每改一次就立刻同步到真实 DOM,而是把这些变化收集起来,通过 nextTick 在同一个事件循环的微任务里合并处理,最后一次性计算出需要打的 patch 再应用到真实 DOM 上。这和手动拼 fragment 要解决的问题其实是同一件事——都是把"零散多次操作真实 DOM"合并成"一次集中操作",只是框架把这层合并做在了数据变化到渲染之间,业务代码完全不需要关心它。所以在 Vue 组件里手动 document.createDocumentFragment() 去插入节点通常是没有意义的,框架的 diff 和批量更新机制下一次重渲染时会把这些手动操作覆盖掉,反而增添了排查成本。
具体到 Vue 2.6 这套响应式系统,触发批量更新的链路大致是:某个响应式数据被改了,触发对应 watcher 的 update,update 不会立刻跑渲染函数,而是把这个 watcher 塞进一个队列(scheduler 维护的 queue),同一个 tick 内同一个 watcher 被标记多次也只会入队一次;等到当前执行栈清空,nextTick 注册的回调触发,才统一把队列里的 watcher 依次执行、重新计算虚拟 DOM 并 patch 到真实节点上。这也是为什么连续改十次数据、只会看到一次视图更新,而不是十次——原理上跟"先拼一堆节点到 fragment、最后一次性插入"是同一种"攒批处理"的思路,只是 Vue 攒的是"需要重渲染的 watcher",手动 fragment 攒的是"待插入的节点"。
React 这边虽然本博客团队日常不写,但作为对照顺带看了看:16.8 版本的 Hooks 之外,React 自己内部同样有一套批量更新机制,在合成事件(比如 onClick 里)触发的多次 setState 会被合并成一次重渲染,不会每 setState 一次就走一遍完整的 reconciliation。这套机制和 Vue 的 nextTick 队列思路相通,都是框架层把"多次状态变化"合并成"一次 DOM 操作",只是各自实现的调度时机和细节不同——Vue 依赖微任务队列,React 早期版本依赖事件系统内部的批处理标记。两边殊途同归的地方在于:只要是成熟框架,业务代码基本不需要再操心"怎么减少 DOM 操作次数"这件事,框架已经把这层优化做掉了。
DocumentFragment 更适合的场景,是那些不在框架管辖范围内的原生 JS 代码:埋点或监控 SDK 这类不能依赖框架、得跑在各种宿主页面上的脚本;浏览器插件往别人的页面里注入内容;老项目里某个明确定位到的性能瓶颈点做局部优化,又不想为了这一处改动引入整个框架。团队里有个内部管理后台至今还留着几处纯 jQuery 时代遗留的表格渲染代码,没有排期去做框架化改造,这类代码里手动用 fragment 依然是最直接的优化手段。
顺带一提,React 里也有个 <Fragment>(写作 <>...</>),名字和思路都有点像——都是"不额外留包裹层"——但完全是两个不同的东西:React 的 Fragment 只是 JSX 语法层面用来避免多余包裹元素的写法,一个组件的 render 返回多个同级节点时,不用套一层没意义的 <div>,写成 <Fragment> 或者简写的 <>...</> 就行,这一层"Fragment"最终会在 React 生成真实 DOM 的阶段被完全展开、不会留下任何真实节点。它和这里讨论的浏览器原生 DocumentFragment 没有继承关系,两者只是恰好都用了"fragment(片段)"这个词表达"不引入额外包裹层"这个类似的意图,一个是 JSX 编译时的语法糖,一个是浏览器提供的真实 DOM 接口,别混着用、也别指望其中一个的行为能套到另一个身上。
排查这类问题的一般路径
对比几种写法的这个过程本身也是个可以复用的排查套路:先怀疑哪里在做多余的 DOM 操作,打开 Performance 面板录一段真实操作,找色块最密集的那一段时间,展开看具体是 Layout、Recalculate Style 还是 Paint 占大头。Layout 密集通常对应"写读交替"或者"逐条插入触发多次重排";Recalculate Style 密集大概率是选择器复杂或者一次改动波及了大量元素的样式计算;Paint 和 Composite 密集则往往跟大面积重绘、或者滥用了某些强制触发合成层的属性有关。定位到具体是哪一类之后,再对照这一类问题对应的常见解法——重排问题看 fragment、批量写入、transform 替代影响布局的属性;重绘问题看是否有不必要的透明度或者滤镜叠加;选择器问题看 CSS 选择器的复杂度。这套"录制、定位、分类、对症"的顺序比直接凭经验猜要靠谱,尤其是在不确定具体是哪个环节拖慢的场景下,先花两分钟录一段远比来回试错节省时间。
几种方案怎么选
几百上千个节点的批量渲染,或者 Performance 面板里已经能看到密集的 Layout 色块,DocumentFragment 离线拼装、最后一次插入就很顺手;只是插三五个节点的场景,硬上 fragment 意义不大。结构复杂、需要批量创建相同骨架的场景,<template> 配合 cloneNode 比手写 createElement 更省力;内容来自可信字符串、需要按上下文解析的场景,Range.createContextualFragment 是介于 innerHTML 和纯 DOM API 之间的一个选项。数据量继续往上到几万条,光靠 fragment 减少插入次数已经不够,节点常驻带来的持续开销需要虚拟滚动来解决,中间地带可以用 requestAnimationFrame 分批渲染兜底,虚拟滚动内部做可视区域内容替换时再叠加 transform 定位和 will-change 提示合成层,尽量把滚动这个高频动作的成本压到最低。真到手写列表渲染时,我会把几件事一起处理:用户可控的内容用 textContent 而不是拼 HTML 字符串,确实要拼字符串就一定过一遍转义;整块更新用 replaceChildren 或经典的清空再插入;点击交互交给父节点做事件委托;数据量上到会影响滚动流畅度的量级,提前想清楚是分批渲染就够,还是必须上虚拟滚动——性能、安全和交互体验是同一套思路里的三个环节,不是各自单独打的补丁。