浏览器渲染流程:重排、重绘和合成层怎么把页面拖慢

表格一滚就掉帧,右上角的计数器像卡住的秒针,这类页面性能问题最容易让人下意识去怀疑 JavaScript。可浏览器一帧卡住,不一定是逻辑算慢了;从 DOM 变化到真正画到屏幕上,中间还要过样式计算、布局、绘制和合成,任何一段都可能把页面拖垮。

这次出问题的是商品池管理页:大表格滚动时要吸顶操作栏,操作栏里还实时展示选中数量和库存汇总。逻辑看起来不复杂,但数据量一上去,滚轮一动,页面就开始一顿一顿地挪。我在自己电脑上复现了几次,能明显感觉到卡顿不是错觉。

所以这回我没有先改代码,而是先录了一段 Performance。结果火焰图一打开,问题方向立刻变了:主线程里大片大片的 Layout 和 Paint 说明,瓶颈不只是“算了多少 JS”,而是页面在滚动时被迫做了太多渲染工作。后面的排查,也就顺着重排、重绘和合成这条链路一路往下拆。

一片紫色

Chrome DevTools 的 Performance 面板,录了三秒滚动。火焰图打开的一瞬间就能看出问题不小:主线程上密密麻麻一大片紫色的 Layout 块,中间夹着绿色的 Paint,鼠标悬到紫块上,DevTools 直接给了警告——"Forced reflow is a likely performance bottleneck"。点进去,跳到了我自己写的滚动回调那一行。

紫色是重排,绿色是重绘。要看懂这两个词到底意味着什么,得先把浏览器渲染这条流水线补清楚。下面就把排查过程和对应概念一起摊开。

补课:浏览器怎么把代码变成像素

浏览器拿到一个页面,大致要做这些事:解析 HTML 生成 DOM 树;解析 CSS 生成 CSSOM 树;两棵树合成渲染树;计算布局(每个元素多大、在哪);绘制(把每个元素画成像素);最后合成显示到屏幕上。

DOM 描述结构,CSSOM 描述样式,两者结合后浏览器才知道每个元素长什么样、放在哪里。这条流水线里有几个工程上很要紧的细节,我是吃过亏才记住的。

一是 CSS 会阻塞渲染。CSSOM 没构建完,浏览器不会往下渲染——不然会先闪一下没样式的"裸 HTML",也就是 FOUC。所以一个又大又慢的 CSS 文件会实打实拖住首屏。我们后来把关键样式内联进 HTML,非关键的异步加载,白屏时间肉眼可见地短了。

二是 JS 默认会阻塞 DOM 解析。浏览器解析到 <script> 就停下来下载并执行,因为脚本可能 document.write 改 DOM。不影响首屏的脚本要加 defer(保证顺序、DOMContentLoaded 前执行)或 async(下载完就执行、不保证顺序),别一股脑塞 <head> 里。

三是 JS 还会被 CSSOM 阻塞。因为脚本可能读样式(比如 getComputedStyle),所以挂在前面的 CSS 没下完,后面的内联脚本也得等。这个隐藏依赖上半年坑过我一次——老后台有个 jQuery 遗留页面,明明 JS 不大却迟迟不执行,最后发现是前面一个 CSS 请求慢。

理解这条链路,才知道"为什么白屏"往往不是 JS 慢,而是关键资源把渲染卡住了。不过这次商品池的问题不在首屏,在滚动,问题出在流水线的后半段:布局和绘制被反复触发了。

什么是重排:那片紫色的学名

重排也叫 reflow,指布局计算重新发生。修改元素宽高、改 margin 和 padding、改字体大小、添加或删除 DOM、改变窗口大小,都会触发它。

1box.style.width = '200px'

元素尺寸变了,浏览器要重新计算它和相关元素的位置。注意"相关元素"这四个字——重排很少是孤立的,改一个元素的宽度,它的兄弟、子元素、甚至父元素的布局都可能跟着变,最坏会触发整棵渲染树重新布局。这就是为什么重排贵:**成本不取决于你改了几个元素,而取决于受影响的范围有多大。**我们那张表格几千行,吸顶操作栏一变宽,牵动的就是整片布局。

还有一类更隐蔽的"隐式重排",正是我这次的第一个元凶:读取某些属性也会触发重排。下面这些属性和方法,浏览器为了给你一个准确的当前值,会被迫立刻把还没算的布局算完,术语叫强制同步布局(forced synchronous layout):

1offsetTop/offsetLeft/offsetWidth/offsetHeight
2scrollTop/scrollLeft/scrollWidth/scrollHeight
3clientTop/clientLeft/clientWidth/clientHeight
4getComputedStyle()
5getBoundingClientRect()

我一直以为只有"写"才会重排。可这次 Performance 里那行被点名的代码,是我在滚动监听里反复读 scrollTopgetBoundingClientRect() 判断操作栏该不该吸顶——读,也会触发。火焰图里一大片紫色 Layout,就是这么来的。

读写交错的循环

顺着那行代码往下翻,我还挖出一段更离谱的。选中商品后,操作栏要根据每行的高度对齐一个侧边提示,我当时是这么写的:

1for (var i = 0; i < items.length; i++) {
2  var height = box.offsetHeight   // 读,强制把布局算完
3  items[i].style.height = height + 10 + 'px'   // 写,布局又脏了
4}

读取 offsetHeight 强制浏览器完成布局计算,紧接着一个"写"又把布局弄脏,下一轮循环再读,又逼它重算一遍。读写交错造成多次重排,这有个专门的名字叫布局抖动(layout thrashing)。浏览器本来很聪明,它会把一连串 DOM 写操作攒起来,到一帧结束时批量算一次布局;可你中间插一个"读",它为了给你准确值,被迫立刻刷新布局,批处理的优化就被打断了。循环里读一次写一次,N 次循环就是 N 次强制重排。

修法是先把要读的全读完,再统一写:

1var height = box.offsetHeight  // 只读这一次
2
3for (var i = 0; i < items.length; i++) {
4  items[i].style.height = height + 10 + 'px'  // 只写,浏览器批量处理
5}

如果读写本身没法分开——比如每个元素要读自己的高度再设别的——就拿两个数组分两批:先一轮循环把所有布局值读出来存数组,再一轮循环统一写回。核心思想是把读和写在时间上分开,别交错。改完这段我给自己定了条规矩:循环体里绝对不出现 offsetXXXgetBoundingClientRect 这种读布局的代码,需要的话提到循环外面去。

重绘:绿色块在说什么

紫色收拾完,再看火焰图里的绿色。重绘也叫 repaint,指元素几何位置不变,但外观变了:

1box.style.color = 'red'
2box.style.backgroundColor = '#fff'

颜色变了,不需要重新布局,但需要重新绘制。一般来说重排成本比重绘高,因为它可能牵连周围元素的布局。两者之间还有一条因果关系要记牢:**重排必然引起重绘,重绘不一定引起重排。**位置都变了,画面当然得重画;只改个颜色,位置没动,就不用重新算布局。

哪些属性改了只重绘、不重排,心里有个谱能帮你做优化判断:

1只触发重绘(不动布局):color、background-color、visibility、box-shadow、outline、border-color
2触发重排(动布局):width/height、padding/margin、display、position、font-size、top/left/right/bottom
3基本不碰主线程(走合成层):transform、opacity

最后那一类是优化的重点,下面动画那节细说。这张表我抄下来贴在工位显示器边上,写样式动画前扫一眼,能避开八成卡顿。组里新来的实习生路过看见,还问我这是不是什么密码本。

老页面的 DOM 插入

商品池的问题查到一半,我顺手把同一个后台里另一个"公认很卡"的页面也翻了出来——一个 jQuery 时代遗留的活动配置页,渲染长列表时是这么干的:

1list.appendChild(createItem())
2list.appendChild(createItem())
3list.appendChild(createItem())

一条一条往真实 DOM 里塞,每次 appendChild 都可能触发一次重排。改成文档片段:

1var fragment = document.createDocumentFragment()
2
3data.forEach(function(item) {
4  fragment.appendChild(createItem(item))
5})
6
7list.appendChild(fragment)

DocumentFragment 是个游离在 DOM 树外的容器,往里塞节点不触发任何重排,最后整体 appendChild 一次,只重排一次。一千个节点从一千次重排降到一次,差别巨大。

类似思路还有几个,都是这次补课记下来的。要对一个已经在页面里的节点做大量修改,可以先 display: none 把它从渲染中摘掉(这步触发一次重排),改完再显示(再一次重排),中间所有改动都白嫖,两次重排换掉中间几十次,划算。或者 node.cloneNode(true) 拿个离线副本,改完用 replaceChild 换上去,原理一样。渲染长列表时还可以拼好 HTML 字符串一次 innerHTML 赋值,往往比循环 createElementappendChild 更快——但有 XSS 风险,数据一定要转义,活动配置页里的商品名就是用户输入,我特意加了转义才敢用。

这些技巧的共同点只有一个:把多次会触发重排的操作,合并成一次。

动画那点事:transform 为什么顺滑

双十一备战还有个小需求,设计师给后台首页出了一条倒计时横幅,有个小图标要来回移动。我一开始写的是改 left

1.badge {
2  left: 100px;
3}

换成 transform 之后顺滑得完全是两个东西:

1.badge {
2  transform: translateX(100px);
3}

差别得说透。改 left 走的是"布局到绘制到合成"的全流程,每一帧都要在主线程重算,主线程一忙——比如后台正好有个大接口回来在处理数据——动画就掉帧。而 transformopacity 如果元素被提升到独立的合成层,动画可以交给 GPU 在合成阶段处理,不经过主线程的布局和绘制,所以即使主线程在忙,动画也能保持流畅。同样是移动一个盒子,translateX()left 顺滑得多,原因就在这。

怎么让元素上合成层?常见办法两种:

1.badge {
2  /* 老办法:用一个不影响视觉的 3D 变换"骗"浏览器开启硬件加速 */
3  transform: translateZ(0);
4  /* 现在更推荐的写法:明确告诉浏览器这个属性要变 */
5  will-change: transform;
6}

will-change 是提前给浏览器打招呼"我待会儿要改 transform",让它提前把元素提升成层、准备好。

但别滥用。每个合成层都要单独占一块显存存它的位图,大量元素都提升层,内存会暴涨。我在社区帖子里见过有人给几百个列表项全加 will-change,低端机直接卡死。will-change 要按需加、用完即撤(动画结束后设回 auto),别当成万能加速咒语全局乱撒。我的判断标准是:只给"真的会持续动"的元素加,静态元素不要碰。DevTools 的 Layers 面板可以直接看当前页面有多少合成层、各占多少内存,加完 will-change 去那里确认一眼,比想当然踏实。

高频回调:节流和 rAF

回到商品池。读写分离做完,滚动还有一点顿挫,因为 scroll 这种事件触发频率极高,一秒几十上百次,回调里哪怕只剩合法的读写,频率本身也是负担。我的处理是两条腿走路。

一是节流,把执行频率压下来。选中数量的统计这种逻辑根本不需要每秒跑一百次:

1function throttle(fn, wait) {
2  var last = 0
3  return function () {
4    var now = Date.now()
5    if (now - last >= wait) {
6      last = now
7      fn.apply(this, arguments)
8    }
9  }
10}
11
12window.addEventListener('scroll', throttle(onScroll, 100))

二是把视觉更新对齐到浏览器的渲染节奏,用 requestAnimationFrame。它的回调在每一帧重绘之前执行,正好是改 DOM 的最佳时机,浏览器一帧只回调一次,天然避免一帧内重复改:

1var ticking = false
2window.addEventListener('scroll', function () {
3  if (!ticking) {
4    ticking = true
5    requestAnimationFrame(function () {
6      updateToolbar()  // 在这里读写布局
7      ticking = false
8    })
9  }
10})

千万别用 setInterval 做动画,它和屏幕刷新不同步,要么掉帧要么浪费帧,requestAnimationFrame 才是和渲染节奏对齐的那个。另外滚动监听记得加 { passive: true },等于向浏览器承诺"我不会 preventDefault 拦截滚动",浏览器就敢立刻响应滚动手势,不用等你的回调跑完——去年在移动端项目里学到的,后台页面上加了也没坏处。

工具:让浏览器自己招供

这一案从头到尾,最大的收获其实是排查方法。光靠猜没用,得看数据。Performance 面板录一段操作,火焰图里紫色块是 Layout(重排),多且密就是布局抖动;绿色块是 Paint(重绘),大面积绿色说明绘制区域太大;悬上去看到 "Forced reflow is a likely performance bottleneck" 的警告,就是有代码在读写交错强制同步布局,点进去直接跳到那行 JS。

Rendering 面板里还有两个好东西。开 "Paint flashing",页面上重绘的区域会闪绿框,肉眼就能看出是不是有不该重绘的地方在反复刷——开着它一滚商品池,整个吸顶操作栏疯狂闪绿,问题一下子就现形了。开 "FPS meter" 能实时看帧率,改一版代码看一眼数字,比"感觉流畅多了"客观得多。

排查卡顿别靠脑补,录一段、看火焰图、找最宽的那块往下钻,比读十遍代码快。

番外:表格实在太长怎么办

商品池优化完,滚动顺滑了,但我心里清楚还有个天花板:运营真要一次拉两万条商品,DOM 节点本身就是负担,重排重绘优化得再好,两万行 <tr> 摆在那里,内存和首次渲染就先扛不住。

社区里对这个问题已经有成熟思路了——虚拟滚动:只渲染视口内可见的几十行,滚动时动态替换内容,DOM 数量恒定。Vue 生态里 vue-virtual-scroller 这类库做的就是这个事,Element UI 的表格暂时没内置,需要自己包一层。我在预研分支上试了个雏形,两万条数据滚起来帧率纹丝不动,打算双十一之后正式排期改造。

另一个我在观望的新东西是 CSS 的 contain 属性,contain: layout 可以告诉浏览器"这个元素内部的布局变化不会影响外面",让重排的波及范围被围在一个格子里。Chrome 已经支持,兼容性还没到闭眼用的程度,先记在笔记里。

结果

周三下午我把优化版发上预发环境,请运营同事再试。她滚了一分钟,说了句"这还差不多",就低头继续打标去了。

浏览器渲染不是黑盒,DOM、CSSOM、布局、绘制、合成,每一步都有成本。项目里要少做无意义的 DOM 操作,避免读写布局交错,批量修改节点,动画优先考虑 transformopacity,高频回调节流或对齐到 rAF。验证这些的方式只有一个:打开 Performance 录一段,紫色和绿色的分布会说明问题出在哪一段。

以前页面卡了只会怀疑是 JS 写得烂,现在该问的问题是:这一帧里,浏览器到底被迫干了多少布局和绘制的活。双十一还有一个月,后台这边类似的性能债,趁现在赶紧排查一遍。