浏览器渲染基础笔记:理解页面是怎么被画出来的
同一个“页面卡顿”的现象,背后可能是完全不同的原因:JavaScript 占住了主线程、布局计算太频繁、绘制区域太大,甚至只是层合成用多了。这几种情况的表现很像,但优化手段完全不同——压缩 JS 包对布局抖动没用,减少重排对一段同步计算太重的脚本也没用。
性能优化文章里常见的结论,比如“少操作 DOM”“用 transform 做动画”“资源要按需加载”,都只是某一类问题的解法。真正遇到一个列表页滚动卡顿时,光记结论并不够,得先判断问题出在哪一层,再挑对应的手段。
浏览器渲染不是用来应付面试的知识点,而是排查问题时用来定位层级的地图:DOM 和 CSSOM 决定结构和样式,布局决定位置尺寸,绘制决定像素内容,合成决定最终呈现。搞清楚这条链路,才能把“页面慢”拆成可验证的具体问题。
从 HTML 到像素,中间被拆成了几层
浏览器拿到页面内容,不是一口气把整个页面画出来的,中间被切成几个各司其职的阶段。它先解析 HTML 建出 DOM 树、解析 CSS 建出 CSSOM 树,两者结合算出每个节点最终"长什么样",进入布局阶段确定每个元素的位置和尺寸,再进入绘制阶段把每个元素填成具体像素,最后由合成阶段把这些像素拼成屏幕上看到的一帧。
真实浏览器内部比这条主线复杂,中间还夹着样式计算、分层这些子步骤,但排查问题时抓住这条链就够了:DOM 和 CSSOM 决定结构和样式,布局决定位置尺寸,绘制决定像素内容,合成决定最终呈现。之所以要把它拆这么清楚,是因为"页面慢"这个现象可能卡在其中任何一层,而每一层对应的优化手段完全不通用。
这条链还有个重要性质:它是从上往下的,上游的改动会连累下游,下游的改动却动不了上游。改一个影响布局的属性,布局之后的绘制、合成都得跟着重来;改一个只影响绘制的属性(比如颜色),布局那步就能跳过;只改合成层能处理的(transform、opacity),布局和绘制都能跳过,直接到最后一步。这个"越靠下游越便宜"的层级关系,是后面所有优化判断的地基——重排、重绘、合成三个词说的其实就是"这次改动从链条的哪一层开始往下重跑"。把这层关系记牢,比单独记住每个术语的定义有用得多,因为它能让你在写下一行样式改动时就大致估出它有多贵。
CSS 之所以会阻塞渲染,就藏在这条链的前半段。浏览器要构建渲染树,得先知道每个节点最终该长成什么样,这意味着光有 HTML 不够,CSS 也得就位。样式资源还没下载解析完,后续的布局和绘制就没法完整推进——这就是为什么一个阻塞渲染的 CSS 文件会拖慢首屏。反过来说,关键 CSS 内联、按路由拆分样式表、砍掉首屏用不到的样式,这些优化之所以成立,不是因为 CSS 文件天生是坏东西,而是首屏需要的那部分样式越早备好,浏览器越早能往下走。
CSSOM 这层还藏着 JavaScript 阻塞的一个不那么直观的原因。同步的 <script> 不只是自己阻塞 HTML 解析,它还会被前面尚未加载完的 CSS 卡住——因为脚本可能读样式(getComputedStyle),浏览器为了保证读到的值是准的,会等 CSSOM 构建完才执行这段脚本。于是就出现"一个慢 CSS 顺带把后面的 JS 也拖住"的连锁。这也是为什么首屏那几个 <script> 要么加 defer、要么放到 body 末尾——让它们别卡在解析关键路径上。理解了渲染树"必须等 DOM 和 CSSOM 都就位"这个前提,这些平时当口诀背的规则就都有了因果。
布局阶段在算什么,以及它为什么会变贵
布局阶段的核心工作,是确定每个元素在页面上的确切位置和尺寸:它有多宽、多高、在父元素里排在哪、要不要换行。页面结构一复杂、节点一多、元素之间的尺寸依赖一密集,这一步的成本就会陡增。
在中后台里,布局成本最集中的地方是表格、树、复杂表单和拖拽配置器。它们的共同特征是节点数量大、尺寸相互依赖多,而且交互时频繁变化。要是每一次输入、滚动、拖拽都触发一轮完整布局,卡顿会非常直观。
尺寸相互依赖这一点值得多说一句,因为它解释了为什么有些布局特别贵。像 table 的自动列宽、flex 的 flex: 1 分配、min-content/max-content 这类内容驱动的尺寸,浏览器都得先量子内容、再回头定容器、有时还要再量一遍子内容,属于要来回跑好几趟的布局。一个几百行、每列宽度都靠内容自适应的大表格,改一个单元格内容可能触发整表重排,就是因为列宽是全表内容一起决定的。这类地方如果性能吃紧,一个实用的取舍是给表格上 table-layout: fixed、或者干脆固定列宽,把"内容决定宽度"这条依赖切断,浏览器就不用为了算一列宽度去扫全表,布局成本能降一大截。这是拿一点布局灵活性换确定性和速度的典型例子。
当一个改动会影响到位置和尺寸——比如改宽高、改边距、改定位、改字体大小——浏览器就得重新跑一遍相关元素的布局计算,这个过程通常叫重排(reflow)。重排频繁发生,性能就容易被拖下去。最典型的一个反面写法是布局抖动,代码一边读布局、一边改布局,让浏览器在读写之间反复被迫重算:
1items.forEach((item) => { 2 const height = item.offsetHeight; 3 item.style.height = `${height + 10}px`; 4});
offsetHeight 这类读取可能迫使浏览器把前面的样式和布局计算完,后面又继续写样式。循环里反复读写交错,成本会被放大。列表越长,这种交错的代价越是线性甚至更陡地涨——一百个元素就是一百次强制结算,卡顿肉眼可见。
更好的方式是先读、再统一写:
1const heights = items.map((item) => item.offsetHeight); 2 3items.forEach((item, index) => { 4 item.style.height = `${heights[index] + 10}px`; 5});
这不是所有场景都能解决问题,但思路很重要:减少读写交错,让浏览器有机会批量处理。把这个原则抽象出来,就是"分相"——把一段涉及布局的操作明确切成读相和写相,读相里只读不写、写相里只写不读,中间不交叉。有些库(比如做布局测量的 FLIP 动画库)会显式提供 measure() 和 mutate() 两个钩子,逼你把读和写分到不同阶段,本质就是在帮你避开强制同步布局。自己写代码时不一定要引库,但心里得有这根线:一旦发现循环里既有 offsetHeight 这类读、又有 style.xxx 这类写,就该警觉是不是在制造抖动。
真实项目里这个坑最常出现在"根据内容动态量高度"的场景。比如一个消息列表要根据每条消息的实际高度做定位,很容易写成"量一条、定位一条"的循环,每次量高度都触发一次强制结算。改法就是先把所有需要量的高度一次量完存进数组,再统一根据数组去写定位:
1// 读相:一次把所有高度量完 2const rects = rows.map((row) => row.getBoundingClientRect()); 3 4// 写相:拿着量好的数据统一写,中间不再读布局 5let top = 0; 6rows.forEach((row, i) => { 7 row.style.transform = `translateY(${top}px)`; 8 top += rects[i].height; 9});
量高度和写 transform 之间没有任何读操作插进来,浏览器就能把这些写攒成一次布局结算,而不是量一条算一次。
麻烦的是,会强制浏览器立刻算布局(forced synchronous layout,DevTools 里叫 "Layout Thrashing" 或标红的 "Forced reflow")的属性并不止 offsetHeight 一个。offsetTop/offsetWidth、clientHeight、scrollTop/scrollHeight、getBoundingClientRect()、getComputedStyle()、还有 scrollIntoView(),只要你在改过样式之后去读它们,浏览器就得把挂起的样式和布局全部结算掉才能给你准确值。列表里那种"读一个、改一个、再读下一个"的循环之所以慢,就是每一轮都触发了一次这样的强制结算。记住这张清单,比记住"少操作 DOM"这种笼统结论有用得多——你能一眼认出代码里哪一行是罪魁。
这里的关键机制是浏览器本来会把样式改动攒起来批处理:你连着改十个元素的 style,浏览器并不会真的算十遍布局,它把这些改动排进一个待处理队列,等一帧结束前一次性算完。这个攒批的过程叫布局失效(invalidation),失效是廉价的,真正贵的是"结算"。而上面那些读取属性会打断攒批——你一读,浏览器发现队列里还有没算的改动,为了给你一个准确的当前值,只能立刻把队列清空、当场算一遍。所以"读写交错"慢的本质不是读或写单独慢,是读强行把本可以批处理的写提前结算了,攒批的好处全没了。想通这一点,就明白为什么"先把要读的一次读完、再把要写的一次写完"能快——它让浏览器的攒批机制重新生效。
想确认某段代码是不是真的触发了强制同步布局,DevTools 的 Performance 面板里那些标红的三角警告最直接,hover 上去会告诉你"Forced reflow is a likely performance bottleneck",还能顺着调用栈定位到具体是哪一行读了布局。比对着代码猜哪行会触发靠谱得多——因为触发点有时藏在第三方库里,你自己的代码看着很干净,是某个动画库或者测量工具在循环里读了 getBoundingClientRect。
如果读写实在没法拆开,可以借 requestAnimationFrame 把写操作推到下一帧的渲染时机,让读和写落在不同的时间点:
1// 先在这一帧把所有读操作做完 2const heights = items.map((item) => item.offsetHeight); 3 4requestAnimationFrame(() => { 5 // 写操作推到下一帧统一做,和读错开 6 items.forEach((item, index) => { 7 item.style.height = `${heights[index] + 10}px`; 8 }); 9});
浏览器的现代实现里还有 ResizeObserver、IntersectionObserver 这类回调,它们本身就被安排在合适的渲染时机触发,用它们替代自己手动轮询 getBoundingClientRect(),能天然避开一部分强制重排。
这两个 Observer 的价值不只是"省一次读取",而是它们把"读布局"这件事从你的代码里挪到了浏览器的渲染时机里。以前判断一个元素是不是进了视口,常见写法是监听 scroll 事件,在回调里读 getBoundingClientRect()——scroll 触发得极其频繁,每次都读一次布局,正是强制同步布局的高发区。IntersectionObserver 把这件事交给浏览器,它在合成阶段自己就知道元素的相交状态,回调只在相交关系真的变了才触发,既不密集又不逼你手动读布局:
1const io = new IntersectionObserver((entries) => { 2 entries.forEach((entry) => { 3 if (entry.isIntersecting) { 4 loadImage(entry.target); 5 io.unobserve(entry.target); // 加载过就不用再盯着了 6 } 7 }); 8}, { rootMargin: '200px' }); 9 10document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));
rootMargin 那个 200px 是提前量,让元素还没进视口就先触发,用户滚到时东西已经在了。ResizeObserver 同理,替代的是过去监听 window.resize 再手动量尺寸那套——它只在被观察元素的尺寸真的变了才回调,而且回调里 entry.contentRect 直接给你尺寸,不用再读一次布局。这两个 API 到 2024 年早就是随手可用的东西,但很多存量代码还停在 scroll/resize 手动轮询上,换过来往往能顺手消掉一批强制重排。
布局之前那步:样式计算也会变贵
布局阶段之前还有一步常被略过的样式计算(style recalculation)——浏览器要把 CSSOM 里的规则匹配到每个 DOM 节点上,算出每个节点最终生效的样式值。节点多、规则多、选择器复杂的时候,这一步本身就有成本,而且它经常和布局连在一起被触发。DevTools 火焰图里那个紫色的 "Recalculate Style" 就是它,如果它频繁大段出现,说明每次改动牵动了大范围的样式重算。
放大样式计算成本的一个常见来源是"改一个高层节点的 class,导致整棵子树都要重新匹配样式"。比如给 <body> 切换一个主题 class,理论上只有用到主题变量的元素受影响,但浏览器为了保险可能要把大片子树的样式重算一遍。这类操作单次没感觉,如果绑在一个高频事件上(比如跟着滚动改 body class),就会持续吃主线程。CSS 自定义属性(CSS variables)在这里反而有帮助——把主题值收敛到几个 -- 变量上,切主题只改变量值,浏览器要重算的范围能小很多,比切换成套的 class 规则更省。
选择器复杂度也进得来这本账,但 2024 年它已经不是主要矛盾了。现代浏览器的选择器匹配做了大量优化,日常那点后代选择器、类选择器几乎不用担心;真正还值得留意的是那种作用在超大列表、又频繁触发重算的复杂选择器组合。所以我不会为了"选择器性能"去牺牲可读性,只在样式计算确实在火焰图里冒头、又定位到某个大范围选择器时才动它。
重绘:不动布局,但要重画像素
如果一个改动不影响布局、只影响外观——改颜色、改背景、改阴影——浏览器就不必重新算位置尺寸,只需要把受影响的区域重新绘制一遍,这叫重绘(repaint)。重绘通常比重排轻,因为它跳过了布局计算,但发生得太频繁一样是负担。这里有个容易混的点:重排一定会带上重绘(位置尺寸变了,像素当然得重画),但重绘不一定伴随重排。所以优化时的排序很自然——先想办法把改动从"会触发重排"降到"只触发重绘",再想办法降到"只触发合成",每降一级都省掉链条上一整段工作。
比重绘频率更隐蔽的是绘制区域过大。给一个很大的列表容器加复杂阴影、滤镜或者背景渐变,滚动时这块区域每帧都得重画,成本会被容器面积放大。视觉效果不是不能做,但得心里有数:它可能不是"加一行 CSS"那么便宜,尤其在移动端 GPU 填充率有限的设备上。
排查绘制问题有个直观的工具:DevTools 的 Rendering 面板里开 "Paint flashing",页面上每次重绘的区域会闪一层绿色。理论上一个只改了角落里一个小按钮颜色的操作,应该只有那个按钮闪绿;如果一大片区域甚至整屏跟着闪,说明重绘范围被无谓地放大了,往往是某个祖先元素的合成层设置或者一个覆盖全屏的伪元素把重绘边界撑大了。这个绿色闪烁比火焰图直观,肉眼就能看出"改一个小地方为什么牵动一大片"。
还有个和绘制相关但容易归错因的现象是 box-shadow 和 filter: blur() 的开销。这两个的成本不只在于绘制区域大小,还在于模糊本身是逐像素的高开销运算,一个大范围的高斯模糊,哪怕面积不变,光是算模糊就够呛。移动端做毛玻璃效果(backdrop-filter)时这点尤其明显——它要对背后一整块内容做实时模糊,滚动时背景在变,这块模糊每帧都得重算,低端机上很容易掉帧。所以这类效果不是能不能用的问题,是用的时候得知道它贵在哪、贵多少,别在一个高频滚动的容器上叠满。
合成层:跳过布局和绘制的第三条路
除了重排和重绘,还有一类改动理论上什么都不用重新算,直接在合成阶段完成,这就是合成层(compositing layer)的作用。
浏览器在满足特定条件时,会把某个元素单独提升成一个合成层,交给 GPU 处理。最典型的触发条件是 transform、opacity、will-change、position: fixed 配合较高的 z-index,或者视频、canvas 这类天然需要独立层的内容。提升为合成层后,动画这个元素时浏览器只需要重新合成,不用重新走布局和绘制,这也是“动画尽量用 transform 和 opacity”这条建议成立的原因。
1.modal-overlay { 2 transform: translateZ(0); /* 早期常见的强制提升写法 */ 3} 4 5.drawer { 6 will-change: transform; /* 更明确的意图声明 */ 7}
反过来,"动画尽量用 transform 和 opacity"这句口诀的另一半是:别去动画那些会触发布局的属性。用 left/top/width/margin 做移动或缩放,每一帧都要重排加重绘,60fps 下就是一秒钟算六十遍布局,卡顿几乎是必然的。同样一个"从左划入"的效果,left: 0 → left: 100px 走的是布局,transform: translateX(-100%) → translateX(0) 走的是合成,视觉一样、成本差一个数量级。判断一个动画贵不贵,就看它改的属性落在链路的哪一层——改布局层的属性最贵,改绘制层的(颜色、阴影)中等,只改合成层能吃到的(transform、opacity)最省。这条规律比死记"哪些属性能用"更好用,因为它直接对应前面那条渲染流水线。
但合成层不是免费的。每提升一层,都要占用额外的显存,层数一多,合成阶段本身也会变慢,移动端尤其明显。滥用 will-change 给一堆元素强制提升,反而可能引发“层爆炸”,页面整体变卡。我的做法是只在真正要做动画的元素上临时加 will-change,动画结束后就撤掉,而不是长期挂着当性能保险。
will-change 具体怎么用其实有点讲究,它是给浏览器的"提前准备"信号,写法上讲究的是"临用前加、用完就撤":
1const el = document.querySelector('.drawer'); 2 3el.addEventListener('pointerenter', () => { 4 el.style.willChange = 'transform'; // 快要动了,提前提层 5}); 6 7el.addEventListener('transitionend', () => { 8 el.style.willChange = 'auto'; // 动完撤掉,把显存还回去 9});
长期挂着 will-change: transform 的坏处是那层显存一直占着,浏览器也一直按"随时会变"来对待这个元素,失去了它平时该有的优化机会。反面就是那种"给全站每个卡片都写 will-change"的偷懒做法,层数瞬间爆掉。
DevTools 的 Layers 面板可以直接看当前页面被提升成了多少层、每层多大、为什么被提升。排查"层爆炸"时打开它,一眼能看出是不是某个规则批量把一堆元素提了层。这里还有个经典的意外提层:一个元素本身没设 transform,但它盖在一个合成层上面、又有非默认的 z-index,浏览器为了正确的层叠顺序,可能被迫也把它提成合成层,这叫隐式合成。所以有时候你只给一个元素加了 transform,Layers 面板里却多出来好几层,多出来的就是被它牵连隐式提升的邻居。理解这一层,才不会对着"我只加了一个 transform 怎么多了这么多层"发懵。
频繁操作 DOM 为什么容易卡,JS 又是怎么牵连进来的
前面那些概念串起来,就能解释"频繁操作 DOM 容易卡"这句老话到底在说什么。每一次修改 DOM,都有可能触发后续的样式计算、布局、绘制;代码里如果不停地读布局信息、又不停地改样式,浏览器就被反复拽回重算状态,出不来。所以那些"批量更新 DOM""别在循环里逐个改样式""少做没必要的布局读取"的建议,本质上都是在减少这种被迫重算的次数。
补一句被这句老话掩盖的细节:"操作 DOM"本身其实不贵,贵的是它触发的后续重算。往一个 DocumentFragment 里插一千个节点、最后一次性挂到文档上,一点都不卡,因为 fragment 不在渲染树里,插的时候不触发布局;真正卡的是"插一个、量一下、再插一个"这种边插边读的写法。所以"少操作 DOM"更准确的说法是"少触发被迫重算"——同样是插一千个节点,攒着一次挂和逐个挂再逐个量,是天壤之别。理解了这点就不会一味追求"减少 DOM 操作次数",而是盯住真正的成本源:读写有没有交错、改动有没有跨越布局那一层。
现在框架帮我们挡掉了大量直接 DOM 操作,但这类成本并没有消失,只是换了个入口。状态更新过密、长列表没上虚拟滚动、组件无谓地重复渲染,最终都会落回主线程的这条渲染流水线上。React 里一次 setState 触发的重渲染,最后也是要 diff、要提交 DOM 变更、要走布局绘制的,只是这条链被框架包在了里面。所以"组件为什么慢"和"页面为什么卡"到最底下是同一套东西——框架层的 render 次数、提交的 DOM 变更量,决定了压到浏览器渲染流水线上的负载有多重。
有个细节是框架的批处理和浏览器的批处理是两层,别混。React 会把一个事件里的多次 setState 合并成一次重渲染,这是框架层的攒批;浏览器把多次样式改动攒到一帧结算,是渲染层的攒批。前面说的"读布局打断浏览器攒批"这个坑,在框架项目里同样存在——如果你在一个 useLayoutEffect 里读了 getBoundingClientRect 又立刻改样式,照样会触发强制同步布局,框架帮不了你。所以理解底层这条流水线,在用框架的时候一样有用,它决定了你在 effect、在 ref 回调里读写布局时会不会踩到同一个坑。
而 JavaScript 会拖累渲染,根子在于浏览器的主线程是同一根:它既要跑你的 JS,又要处理布局和绘制。一段 JS 长时间占住主线程,渲染工作只能排队等它让出,用户看到的就是卡顿、掉帧、点了没反应。所以性能优化从来不只是压资源体积,还包括控制主线程负载。
这也解释了为什么"包体积小"和"运行流畅"是两件事。一个几十 KB 的脚本可能因为在主线程上做了一次几百毫秒的同步计算而卡住整页,而一个几百 KB 但执行分散、不长时间占线程的脚本反而不卡。压包体积优化的是下载和解析那一段,控制主线程负载优化的是运行那一段,两者都要但不能互相替代。我见过有人把包压到极致、卡顿却纹丝不动,一录 Performance 发现是某个初始化在主线程上同步跑了一大轮,那和包多大没关系。
一帧的预算大约只有 16.6ms,想稳住 60fps,就不能让一段脚本长时间霸着主线程不放。而且这 16.6ms 不是全归你的 JS——浏览器自己还要在这一帧里做样式计算、布局、绘制、合成,真正留给脚本的往往只有几毫秒。所以"一段函数跑了 8ms 不算慢"这种感觉是错的,它可能已经把这一帧的渲染预算吃掉大半,导致这帧来不及画,掉帧就发生在这里。
重计算可以考虑分片、延后到空闲时段、或者甩给 Web Worker,长列表可以上虚拟滚动。延后到空闲时段有个专门的 API requestIdleCallback,它把回调排到浏览器这一帧忙完、还有富余时间的时候执行,适合那种"做不做都行、晚点做也没关系"的活,比如上报埋点、预热缓存:
1requestIdleCallback((deadline) => { 2 // deadline.timeRemaining() 告诉你这次空闲还剩多少毫秒 3 while (deadline.timeRemaining() > 0 && tasks.length) { 4 processOne(tasks.shift()); 5 } 6}, { timeout: 2000 });
它和 requestAnimationFrame 是两个方向的工具:rAF 是"赶在下一帧渲染前做",用于和视觉更新强相关的写操作;requestIdleCallback 是"等有空了再做",用于不急的后台活。分清一件事该抢帧还是该让帧,比笼统地"用 rAF 优化"精确得多。但关键不是上来就套方案,而是先搞清楚主线程到底被什么占住了——这就得靠工具去看,而不是靠猜。
用 DevTools 找到问题在哪一层
遇到卡顿时,我现在会先录一段 Performance,而不是直接猜。看的时候顺序大致是这样:先扫 Main 线程那条轨道,有没有横得离谱的长任务块,那是 JavaScript 长时间霸着主线程的信号;再看火焰图下方 Layout 和 Paint 这两类事件出现得密不密,Layout 反复出现指向读写交错或者列表尺寸不稳定,Paint 一大块一大块的指向绘制区域过大;然后看顶部的网络瀑布,有没有 CSS 或同步脚本卡在首屏关键路径上;最后如果是交互卡,就在录制时复现那个操作,看交互点之后主线程有没有立刻冒出一个长任务,那正是"点了没反应"的来源。
判断的分岔就在这里:如果火焰图里大部分时间花在脚本执行,那优化 CSS 没什么用,得回头拆计算;如果 Layout 一直出现,就要顺着标红的 Forced reflow 去查是不是频繁读写布局;如果 Paint 很重,就要看绘制区域、阴影、滤镜、图片和层合成。同样是"页面慢",这三条路对应完全不同的改法,录一段就是为了知道自己该走哪条,而不是三条一起试。
Chrome 这两年把 Performance 面板重做过一版,多了 Interactions 轨道,交互从触发到页面给出下一帧的这段时间被单独画出来,排查交互卡顿比过去顺手很多——以前得自己在火焰图里对齐时间点,现在直接有一条轨道标着每次交互花了多久。录制时有个小习惯值得养成:勾上 CPU 降速(4x 或 6x throttling)再录。开发机太快,很多在低端手机上明显的长任务在本机上一闪而过看不出来,降速之后它们会被拉长、暴露出来,和用户的真实设备更接近。这个过程会比背优化清单慢一点,但结论更可靠。
滚动为什么会卡在主线程上
滚动是个特别能暴露渲染问题的场景,因为它天生高频。现代浏览器其实能把滚动跑在一个独立的合成线程上,不占主线程——这就是为什么一个纯静态页面滚动再长也很顺。但有几件事会把滚动从合成线程拽回主线程:给 scroll/touchstart/wheel 这类事件挂了非被动监听器,浏览器不确定你会不会 preventDefault,只能等你的回调跑完才敢滚,滚动就被你的 JS 卡住了。解决办法是显式声明被动监听:
1window.addEventListener('scroll', onScroll, { passive: true });
passive: true 等于告诉浏览器"我不会拦截这次滚动",浏览器就能放心地让滚动走合成线程,不等你的回调。前面说过 scroll 回调里读 getBoundingClientRect 会触发强制同步布局,两件事叠一起——既卡回调又强制重排——滚动想不卡都难。所以滚动相关的逻辑,优先能用 IntersectionObserver/ResizeObserver 就别自己挂 scroll,实在要挂就上 passive 并且回调里绝不读布局。
现代指标也在提醒主线程问题
2024 年再看 Web 性能,除了过去常说的 FCP、LCP,也会更关注交互响应。INP 正式成为 Core Web Vitals 之后,很多以前被 FID 掩盖的交互卡顿会更明显地暴露出来。它强调的是用户交互之后页面多久能给出反馈,本质上仍然和主线程负载、渲染任务排队有关。
这对业务项目很有启发。很多中后台页面首屏不一定特别重,但交互很密集:筛选、展开、拖拽、批量编辑、弹窗联动。如果交互后主线程被大量同步计算占住,用户感知就是“点了没反应”。
INP 衡量的其实是一次交互的三段:输入延迟(事件排队等主线程空出来)、处理时间(你的事件回调跑了多久)、以及呈现延迟(回调改完 DOM 到浏览器把下一帧画出来的这段)。这三段任何一段长了 INP 都会差,但根子往往在处理时间——一个点击回调里同步做了一大堆状态更新和重排,主线程被占住,下一帧迟迟出不来。有个实用的缓解手段是把"更新反馈"和"重活"拆开:点击后先同步把按钮变成 loading 态让下一帧尽快画出来给用户反馈,把真正的重计算用 setTimeout 或者调度让出一下主线程再做。这样即便重活没变快,用户至少立刻看到了响应,INP 里那段呈现延迟就短了。这一年也有人开始用 scheduler.postTask 这类新的调度 API 更精细地安排优先级,不过它还比较新,我目前只在内部项目里试。
线上采集 INP 和采集 LCP 是一套思路,用 PerformanceObserver 观测 event 类型的 entry,web-vitals 这类库封装好了直接接就行。所以性能优化不能只盯首屏资源,也要看交互过程中的长任务和渲染开销——很多以前藏在 FID 背后的交互卡顿,换成 INP 之后才被量出来。
把渲染流程当地图用,而不是当术语背
理解浏览器渲染流程最实际的价值,不在于能背出几个术语,而在于遇到问题时能判断三件事:某种改动大概会碰到哪一层、某个性能现象更可能卡在哪个阶段、哪些流传的优化建议是真的有因果关系而不是玄学。有了这张地图,"页面慢"才能被拆成可以逐个验证的具体问题。
这一两年 CSS 里也长出了几个更直接的性能工具,content-visibility 和 contain 是其中最有用的两个,能在特定场景下让浏览器跳过对不可见内容的布局和绘制。它们的意义在于,过去要跳过视口外内容的渲染,只能靠 JavaScript 虚拟滚动——手动算哪些该渲染、维护占位、处理滚动,逻辑不轻;现在对一部分场景,浏览器原生就能干这件事,声明式地写一行 CSS 就行。但它们不是万能开关,用之前仍然得理解页面结构和渲染成本,否则很容易为了摁下一个性能问题,反手引入一个新的布局问题。
需要分清的是 content-visibility 和虚拟滚动解决的不完全是一回事。虚拟滚动是连 DOM 节点都不创建视口外的项,省的是节点本身的内存和渲染;content-visibility: auto 是节点还在 DOM 里、只是跳过它们的布局和绘制。所以极长的列表(几万条)还是虚拟滚动更彻底,content-visibility 更适合"节点数量可控、但每项渲染较重"的场景,比如一篇长文里几十个带图表的区块。选哪个还是回到那个判断:瓶颈是节点太多,还是每个节点太重。
content-visibility: auto 是这几个工具里收益最直接的一个。给一个长列表的每一项加上它,浏览器会跳过视口之外内容的布局和绘制,只保留一个占位尺寸:
1.list-item { 2 content-visibility: auto; 3 contain-intrinsic-size: 0 120px; /* 告诉浏览器没渲染时按多大空间占位 */ 4}
contain-intrinsic-size 这一步容易被漏掉。如果不给一个估算尺寸,元素在没渲染时高度会塌成 0,滚动条会跳来跳去,体验反而更糟。这跟前面重绘那节的思路是一致的:省下的成本不能靠制造新的布局不稳定去换。估算值也不用特别精确,给个和真实内容大致一个量级的高度就行——它只是让滚动条的比例和总高度不至于离谱,浏览器真正渲染到那一项时会用实际尺寸替换掉估算值。估得偏差太大的副作用是滚动条会在滚过那些项时轻微跳动,但比塌成 0 好得多。
contain 家族(layout、paint、size、style)则更底层,作用是告诉浏览器“这个子树的某些变化不会影响外部”,从而减少一次改动需要重新计算的范围。content-visibility 内部就是靠隐式应用 contain 来实现跳过渲染的。理解这一层之后,遇到类似容器查询、虚拟列表这些依赖“尺寸边界”的特性时,会更容易判断它们的性能收益从哪来、代价在哪。
contain: layout 单独拿出来也很有用:它承诺这个元素内部的布局变化不会往外冒,于是浏览器算它内部布局时可以不用回头重算它的祖先和兄弟。中后台里那些独立的卡片、面板,加上 contain: layout 之后,一个卡片内部的重排就被圈在卡片里,不会牵动整页。但要注意 contain: size 有个前提坑——它让浏览器完全忽略子内容来算这个元素的尺寸,所以你必须自己给它一个明确的尺寸,否则元素会塌成 0。这和前面 contain-intrinsic-size 要配 content-visibility 是同一类约束:你告诉浏览器"别管里面了",就得自己把外面的坑留够。
容器查询这两年成熟之后,contain 的意义又多了一层。容器查询要求容器声明 container-type: inline-size 之类,这本质上就是给容器加了尺寸方向的 containment——它在告诉浏览器"这个容器的尺寸不依赖子内容",浏览器才敢拿容器尺寸去决定子元素怎么排。所以用容器查询时那个若隐若现的"容器高度塌了"的问题,根子就是 containment 带来的尺寸约束,理解 contain 就不会对它发懵。这几个特性看着各管各的,底下共用的都是"给浏览器划好范围、让它少算"这一个思路。
回到开头那个列表页滚动卡顿的例子。光记着"少操作 DOM"是没法直接下手的,但拆到这条链上就有了抓手:先看是不是滚动回调里读了布局触发强制重排,再看每项渲染是不是太重、要不要 content-visibility 或虚拟滚动,还要看有没有非被动的 scroll 监听把滚动拽回主线程。同一个"卡",在这张地图上能拆出好几个互相独立的嫌疑点,逐个验证排除,比换写法碰运气可靠。
我现在遇到页面卡顿,一般不会先问“要不要换个写法”,而是先打开 Performance 看证据。
如果长任务集中在 JS,就先拆计算、减少同步逻辑或把重活延后;如果 Layout 反复出现,就查是不是读写布局交错、列表尺寸不稳定;如果 Paint 很重,就看阴影、滤镜、大图和重绘区域;如果 Composite 层太多,就查 will-change、fixed、transform 有没有滥用。
这四条分岔背后是同一个判断逻辑:先用工具确认时间花在链路的哪一层,再挑那一层对应的手段,而不是把所有手段一股脑全上。全上的坏处不只是白费力气,有时候还会互相打架——为了省重绘去滥用合成层,结果层爆炸把合成阶段拖慢了,等于按下葫芦浮起瓢。这也是为什么这张渲染流程图的价值不在术语,而在它逼你先定位、再动手。
DOM、CSSOM、布局、绘制和合成这些概念,真正有用的地方就在这里:它们能帮你把“页面慢”拆成可验证的阶段。知道问题卡在哪一层,优化才不会变成照着清单碰运气。那些流传的优化建议——少操作 DOM、用 transform 做动画、读写分离、will-change 别乱加——单独记是一堆彼此无关的口诀,串到这条流水线上就都有了各自的位置和因果,也就知道了它们各自在什么场景成立、什么场景其实用不上。