浏览器 Rendering 全景图:从 HTML 到 Pixels 的渲染分层与阶段拆解

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,最终决定页面是顺滑还是发涩

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

汪子熙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值