1. 浏览器对网页的 Rendering 到底是什么
当你在地址栏输入一个 URL,或者点开一个链接,浏览器会收到一堆文件:HTML,CSS,JavaScript,图片,字体,视频……把这些碎片拼成一个可以滚动、可点击、可输入、还能在动画里丝滑移动的页面,这个拼装并把内容变成屏幕像素的过程,就叫 Rendering。MDN 的描述非常直白:把一系列响应文件处理并组装成可交互页面的过程就是 Rendering。(MDN Web Docs)
不过在工程语境里,Rendering 往往不只等价于首屏画出来,它更像一条持续运转的流水线:页面初次出现是一轮 Rendering,之后任何一次 DOM 更新、样式变化、窗口尺寸变化、滚动带来的可视区域变化,都可能触发新一轮或部分阶段的 Rendering。也正因为它会反复发生,浏览器引擎才把它拆成多个阶段,并尽可能让某些阶段能跳过或并行,以保证交互不卡顿。
你可以把浏览器想象成一个大型舞台系统:
- HTML 像剧本,决定有哪些角色与道具
- CSS 像舞台美术与灯光方案,决定外观与排布规则
- JavaScript 像导演的实时指令,随时改台词、换布景、调灯光
- Rendering 引擎像舞台执行团队:读剧本、搭景、摆位、上灯、开演,最后把观众能看到的画面
输出到屏幕
接下来把这个执行团队的工作拆成清晰阶段,你就能理解为什么某些代码会导致卡一下,以及怎样写才能让浏览器走更短的路径。
2. Rendering 的核心阶段:从数据结构到 Pixels
不同浏览器的细节不完全一样,但主流引擎的关键阶段高度一致。MDN 在How browsers work里把渲染步骤概括为:Style,Layout,Paint,以及在一些情况下的 Compositing,并指出 DOM 与 CSSOM 会合并成 Render Tree,再计算布局并绘制到屏幕。(MDN Web Docs)
MDN 的Critical rendering path也强调关键路径由 DOM,CSSOM,Render Tree 和 Layout 等组成。(MDN Web Docs)
而 web.dev 的经典拆解进一步明确:DOM 与 CSSOM 合并成 Render Tree,用它计算可见元素布局,并作为 Paint 的输入。(web.dev)
为了讲得更落地,我会把它分成你在性能分析里最常见、也最有用的一套阶段模型:
- 资源获取与解析输入(HTML 解析生成 DOM,CSS 解析生成 CSSOM)
- 样式计算(Style / Recalculate Style)
- 构建渲染相关树(Render Tree 或 Layout Tree)
- 布局(Layout / Reflow)
- 绘制(Paint,生成 Display List)
- 分层与栅格化(Layerization,Raster)
- 合成与呈现(Compositing,最终显示)
这套模型的好处是:你在 Chrome DevTools 或 Firefox Profiler 里看到的时间条,基本都能对上这些名字;你在写优化策略时,也能精确控制让浏览器少走几步。
3. 阶段 A:HTML 解析生成 DOM,CSS 解析生成 CSSOM
3.1 DOM 是怎么来的:从字符流到节点树
HTML 并不是一次性解析完再渲染。浏览器通常边下载边解析,把字符流分词(tokenization),再做树构建(tree construction),逐步生成 DOM。DOM 是文档结构,它回答的问题是:页面里有哪些元素,它们是什么父子关系。
真实世界类比:像你在快递站拆箱。箱子没拆完,你已经能把拆出来的物品按类别放到桌面上;后面拆出来的新东西再继续归类。浏览器解析 HTML 也是类似的增量行为。
这里有一个非常重要的工程现象:解析过程中如果遇到会执行的 JavaScript,解析器可能被迫停一下,等待脚本下载与执行,因为脚本可能调用 DOM API 改变结构,甚至用 document.write 往回塞内容。这个停一下就是很多首屏性能问题的源头之一。
3.2 CSSOM 为什么单独存在:样式不是贴在 DOM 上就完事
CSS 解析会生成 CSSOM,它不是简单的把选择器存起来,而是一棵能快速回答样式查询的数据结构。Style 计算阶段需要 DOM 与 CSSOM 一起工作:对于某个元素,需要把匹配到的规则按优先级、继承、层叠、默认值等规则计算出最终样式。
经验上你会听到一句话:CSS 会阻塞渲染。原因并不神秘:在 CSSOM 未就绪时,浏览器无法可靠地算出元素最终样式,也就无法正确构建 Render Tree,更别说 Layout 与 Paint。这也是为什么关键 CSS 往往要更早到达。
4. 阶段 B:Style 计算,得到 Computed Style
Style 阶段会把级联规则真正落到每个元素上,产出你在 DevTools 里看到的 Computed Style。这个阶段经常被叫做 Recalculate Style,因为页面更新时它会反复发生。
一个更贴近现实的例子:你在电商详情页点了深色模式按钮,本质上可能就是给 html 或 body 加了一个 class。页面上几百上千个节点的颜色、背景、边框都要随之改变。浏览器不会傻到把整个页面重建一遍,但它确实需要重新计算受影响节点的最终样式,这一段时间条在性能面板里往往就叫 Recalculate Style。
Firefox 在样式系统上有很典型的工程路线:从 Firefox 57 开始,Gecko 使用来自 Servo 的并行样式系统 Stylo,利用多线程并行计算样式,以提升性能。(firefox-source-docs.mozilla.org)
这说明一个事实:Style 计算在复杂页面里是会贵的,贵到值得用并行化去解决。
5. 阶段 C:Render Tree 或 Layout Tree,决定哪些东西需要被画
DOM 包含所有节点,但并不是每个节点都需要出现在最终画面里,例如 display: none 的元素不会进入渲染树。浏览器会把 DOM 与 CSSOM 的结果结合,构造一个只关注可见渲染对象的树,历史上常叫 Render Tree。不同引擎术语略有差异:Firefox 把渲染树里的元素称为 frames,WebKit 常叫 renderers 或 render objects。(web.dev)
一个很生活的比喻:舞台后台堆了很多道具,但灯光一打,观众只会看到上台的那部分。Render Tree 就像上台清单,它不关心后台库存。
这一步通常还会生成或准备后续布局需要的数据,例如盒模型信息、格式化上下文、替换元素信息等。
6. 阶段 D:Layout / Reflow,计算几何位置与尺寸
Layout 要回答的问题非常具体:每个可见盒子在屏幕上占多大,摆在哪。它会处理:
- 盒模型计算(margin,border,padding,content)
- 文本排版(换行,字形度量,行高,断词)
- 百分比与包含块关系(width: 50% 到底是多少)
- 浮动、定位、flex,grid 等复杂布局算法
- 视口变化带来的重新排布(旋转屏幕、窗口缩放)
为什么 Layout 容易成为性能瓶颈?因为它具有强依赖性:父元素尺寸可能影响子元素,子元素内容又可能反过来影响父元素高度;某些布局模型还会引发多次测量。页面越复杂,依赖链越长,Layout 的最坏情况就越可怕。
案例:新闻站点的评论区输入框为什么会一抖
设想一个新闻页面:正文很长,底部有评论区。你聚焦输入框时,页面弹出软键盘,视口高度瞬间变化。浏览器必须重新计算可视区域与布局,输入框需要保持可见,滚动位置可能要调整,正文行盒的断行也可能重新计算。这一整套就是 Layout 的典型重算场景。你看到的一抖,往往不是 Paint 慢,而是 Layout 与滚动调整叠加导致的帧预算超标。
7. 阶段 E:Paint,生成 Display List,把要画什么描述出来
Paint 阶段不等于真的把每个像素都算出来。更常见的实现是生成 Display List:一串绘制指令,例如在这里画个矩形背景、在这里画文本、在这里画阴影。之后才会进入 Raster 把这些指令变成位图。
在 web.dev 的render tree construction里,Paint 被明确为 Render Tree 与 Layout 之后的关键输入输出链条的一部分。(web.dev)
案例:一个毛玻璃卡片为什么滚动时更容易掉帧
很多设计喜欢用 blur,box-shadow,大面积半透明叠加。它们往往属于Paint 成本高的效果:阴影和模糊需要更复杂的像素处理,重绘区域一大,Paint 时间就会上去。你在滚动时如果不断触发重绘,就更容易掉帧。
8. 阶段 F:Layerization 与 Raster,把指令变成位图 Tiles
现代浏览器为了性能,会把页面拆成多个层(layers),并把每层拆成小块 tiles,再栅格化(Raster)成位图。这样做的意义是:
- 小范围更新时只重栅格化受影响的 tiles
- 滚动时可以复用已栅格化的内容,减少主线程压力
- 某些动画可以只移动层,不必重绘内容
Chrome 的 RenderingNG 架构文档专门讲了渲染管线如何流经各组件,体现出现代 Chromium 在分层、栅格化、合成上的系统化设计。(Chrome for Developers)
9. 阶段 G:Compositing,把各层合成到屏幕,争取不打断主线程
Compositing 的核心思想是:把页面分成层,提前把每层内容 Raster 好,之后在合成线程里把这些位图按顺序叠起来,必要时再做 transform,opacity 等处理。
Chrome 团队在Inside look at modern web browser part 3里描述了合成的关键点:把页面拆分成 layers,分别 rasterize,再在 compositor thread 合成;滚动时因为层已 rasterize,只需合成新帧;动画也可以通过移动层并合成新帧实现。(Chrome for Developers)
Chromium 的 GPU compositing 设计文档也强调了渲染可视为两个阶段:先 paint,再 composite,compositor 会对每个 compositing layer 的位图应用变换后再合成。(chromium.org)
这就是你在性能优化里经常听到的建议:能用 transform 与 opacity 就别动 layout。原因不是玄学,而是它们更容易落在合成阶段解决,避免触发 Layout 与 Paint。
10. 页面更新时的短路路径:为什么改一个属性,成本差别巨大
web.dev 的Rendering performance把像素管线讲得非常工程化:一次视觉更新可能走完整链路 JS / CSS → Style → Layout → Paint → Composite,也可能省略 Layout,甚至省略 Paint,只走到 Composite。(web.dev)
把它翻译成开发者能直接用的判断规则:
- 会触发 Layout 的属性:width,height,top,left,margin,font-size 等
代价常见为 Style + Layout + Paint + Composite - 只触发 Paint 的属性:color,background-image,box-shadow 等
代价常见为 Style + Paint + Composite - 只触发 Composite 的属性:transform,opacity
代价常见为 Style + Composite,很多情况下甚至能把工作更多挪到 compositor thread
案例对比:做一个吸顶 Header
你想做一个滚动时吸顶的顶部栏,有两种写法:
- 写法 A:不断修改 top
- 写法 B:用 transform: translateY
示例代码(避免双引号,字符串用单引号):
const header = document.querySelector('.header')
window.addEventListener('scroll', () => {
const y = window.scrollY
// 写法 A:可能更容易触发 Layout
// header.style.top = y > 80 ? '0px' : '-60px'
// 写法 B:更倾向走 Composite
header.style.transform = y > 80 ? 'translateY(0)' : 'translateY(-60px)'
})
在真实项目里,写法 A 更容易让浏览器频繁 Layout,尤其当 header 参与文档流或影响其它元素时;写法 B 往往只需要合成阶段移动已有层位图。你看到的差别就是:A 容易滚动发涩,B 更容易滚动发滑。
11. 更贴近内核的视角:进程与线程怎样把渲染跑起来
到这里你已经能用阶段解释大多数性能现象,但在现代浏览器里,Rendering 还涉及线程与进程的协作:
- 主线程:DOM,Style,Layout,很多 Paint 相关工作都在这里排队
- 合成线程:专注把 layers 合成,尽量不被主线程阻塞
- GPU 进程:接收绘制或合成指令,调用图形 API 做最后输出
Firefox 在 WebRender 的路线里把显示列表交给 GPU这件事做得非常明确:Firefox Source Docs 的 Rendering Overview 提到 GPU process 会接收 WebRender Display List blob,反序列化成 Scene,并且为了滚动预取等原因 Scene 里可能包含超出可视区域的内容。(firefox-source-docs.mozilla.org)
Mozilla 的工程文章也解释过 WebRender 的目标之一是让绘制更像游戏渲染管线,甚至弱化传统paint 与 composite的分界,把更多工作交给 GPU 来同时完成。(Mozilla Hacks – the Web developer blog)
这两条路线背后反映出一个共同点:浏览器并不是只靠把 DOM 画出来这么朴素,它更像一台实时渲染引擎,需要把工作切片、并行、缓存、增量更新,才能在复杂页面里守住帧率与交互延迟。
12. 两个真实世界的性能事故复盘:把抽象阶段落到具体问题
12.1 事故一:无限列表越滚越卡,CPU 占用飙升
现象:一个社交 Feed 页面,刚打开很顺滑,滚到 200 条之后明显掉帧,输入也开始延迟。
排查:Performance 面板里 Layout 与 Paint 时间逐渐升高,且每次 scroll 回调里都有多次读取布局信息。
典型根因:Layout Thrashing(布局抖动)。代码在同一帧里交替做写样式与读布局,浏览器为了回答读取请求,被迫提前执行同步 Layout,导致主线程被打爆。
示意代码:
items.forEach(el => {
el.style.height = (el.offsetHeight + 1) + 'px' // 读 offsetHeight 会逼迫 Layout
})
修复思路:把读写分离,批量读取后再批量写入,或用 requestAnimationFrame 把写集中到同一批次。最终结果通常是:Layout 次数下降,单帧耗时回到可控范围。
这类问题能用阶段语言一句话讲清:你本来希望滚动时主要走 Composite,结果代码把浏览器拽回了 Layout。
12.2 事故二:主题切换瞬间白屏,用户以为页面崩了
现象:点击夜间模式按钮,页面闪白 200 ms 到 500 ms。
排查:Recalculate Style 与 Paint 峰值很高,且有大量大阴影、渐变背景、模糊效果。
根因:主题切换造成大面积样式失效,触发大范围 Style 重算与 Paint;视觉效果越复杂,Paint 成本越高。
修复策略:把可动画过渡的部分限制在小范围容器;把重的背景效果改成预渲染图片或更轻的渐变;对不需要立刻更新的区域做延迟更新。很多团队也会把切换做成先切关键区域,后切非关键区域,让用户感知更平滑。
同样用阶段语言总结:这是一次无法避免的 Style + Paint 洪峰,但可以通过减少重绘面积与降低绘制复杂度,把峰值压下来。
13. 用一段话把阶段串成完整故事
把页面 Rendering 想成一条从文本与规则到屏幕像素的生产线:HTML 解析形成 DOM,CSS 解析形成 CSSOM;Style 把规则落实到每个节点;Render Tree 过滤出真正要显示的对象;Layout 计算它们的几何;Paint 把它们变成可执行的绘制指令;Raster 把指令变成位图 tiles;Compositing 把不同层叠起来并输出到屏幕。MDN 与 web.dev 的多篇文档都以 DOM,CSSOM,Render Tree,Layout,Paint,Composite 作为关键骨架来描述这条路径。(MDN Web Docs)
当你理解这条流水线,前端性能优化就会从调参玄学变成阶段管理:你在写每一行代码时,都能判断它更可能让浏览器走哪条路径,成本会落在 Layout,Paint 还是 Composite,最终决定页面是顺滑还是发涩。
2万+

被折叠的 条评论
为什么被折叠?



