第2章:浏览器渲染原理——关键渲染路径(CRP)

聊到前端性能,绕不开一个核心概念——关键渲染路径(Critical Rendering Path,简称CRP)。

说白了,就是浏览器从拿到HTML代码,到最终在屏幕上画出像素的整个过程。我当年刚入行时,觉得这玩意儿太底层,跟业务开发没啥关系。直到有一次,一个简单的列表页加载慢得像蜗牛,我翻来覆去找原因,最后发现是CSS阻塞了渲染。嗯,从那以后,我再也不敢小看CRP了。

2.1 关键渲染路径的五个步骤

浏览器渲染一个页面,大致走五步:

  1. 构建DOM树——解析HTML,生成节点树
  2. 构建CSSOM树——解析CSS,生成样式树
  3. 合并成渲染树——DOM + CSSOM = Render Tree
  4. 布局(Layout)——计算每个节点的几何位置
  5. 绘制(Paint)——把像素画到屏幕上

你想想看,这五步是串行的。哪一步卡住了,页面就出不来。我见过不少团队,只顾着压缩图片、优化接口,却忽略了CSS和JS的加载顺序——结果首屏还是慢。

核心要点:关键渲染路径的每一步都可能成为性能瓶颈。优化CRP,就是缩短从请求到首屏渲染的时间。

2.2 DOM树构建——从字节到节点

浏览器拿到HTML后,先做词法分析,把字节流转换成字符,再解析成标签,最后构建成节点树。

这个过程叫「解析」。但有个坑——HTML解析是边下载边解析的,不是等全部下载完再解析。所以,如果HTML文件很大,浏览器会分段处理。

我在项目中遇到过一个问题:一个页面有3000多行HTML,全是服务端渲染的表格行。结果首屏渲染时间长达8秒。后来发现,浏览器解析这3000行时,DOM树太大,光构建就花了2秒多。

避坑指南:我曾经以为DOM节点多没关系,反正浏览器能处理。后来发现,DOM节点超过1500个时,布局和重绘的开销会明显上升。建议控制DOM深度不超过32层,节点数不超过1000个。

2.3 CSSOM树构建——渲染的拦路虎

CSSOM的构建和DOM类似,但有一个关键区别:CSS是阻塞渲染的

为什么?因为浏览器不知道CSS规则什么时候加载完。如果CSS没加载完就渲染,页面可能会出现「无样式内容闪烁」(FOUC)。所以,浏览器干脆等CSSOM构建完,再开始渲染。

我习惯把CSS分为三类:

类型 加载方式 是否阻塞渲染
关键CSS 内联在<head>中
非关键CSS 异步加载(media="print" 或 rel="preload")
第三方CSS 延迟加载(defer)

举个例子,你写了一个按钮样式,放在外部CSS文件里。如果这个CSS文件加载慢,整个页面都得等着。我建议把首屏用到的样式内联到HTML里,剩下的异步加载。

注意:CSSOM的构建规则是「从右向左」匹配的。比如 .nav .item a 这个选择器,浏览器会先找所有 a 标签,再往上找 .item,最后找 .nav。所以,选择器越具体,匹配越慢。我建议尽量用类选择器,少用后代选择器。

2.4 渲染树——DOM和CSSOM的合体

DOM树和CSSOM树构建完成后,浏览器把它们合并成渲染树(Render Tree)。

渲染树只包含可见节点。像 <head><script>display: none 的元素,都不会出现在渲染树里。

这里有个容易混淆的点:visibility: hidden 的元素会出现在渲染树里,因为它占位置;而 display: none 不会。我刚开始做前端时,以为两者一样,结果布局老是出问题。

性能启示:渲染树越小,合并速度越快。所以,尽量减少不必要的DOM节点,尤其是那些用 display: none 隐藏的「幽灵节点」。

2.5 布局(Layout)——算位置、算大小

布局阶段,浏览器计算每个可见节点的几何信息:宽、高、x、y坐标。

这个过程也叫「回流」(Reflow)。回流很消耗性能,因为一旦某个元素的位置或尺寸变了,浏览器得重新计算它和它子元素、父元素、兄弟元素的位置。

我遇到过最夸张的一次:一个页面里用了大量绝对定位,而且每个绝对定位元素都依赖父容器计算位置。结果每次滚动,浏览器都要重新布局,页面卡得不行。

哪些操作会触发回流?

  • 改变窗口大小(resize)
  • 修改元素的宽高、边距、边框
  • 添加或删除可见DOM元素
  • 改变字体大小
  • 读取某些属性(如 offsetHeightscrollTop
个人经验:我习惯把「读」和「写」分开。比如先批量读取所有元素的 offsetHeight,再批量修改样式。这样浏览器可以合并回流,而不是读一次改一次,来回折腾。

2.6 绘制(Paint)——画像素

布局完成后,浏览器开始绘制。绘制就是把每个节点的视觉样式(颜色、背景、阴影、边框)画成像素。

绘制阶段也分两步:

  1. 生成绘制指令——浏览器把每个元素的绘制命令记录下来
  2. 栅格化——把指令转换成屏幕上的实际像素

绘制本身不慢,但如果有大量复杂的视觉效果(比如多个阴影、渐变、圆角),绘制时间会明显增加。

我记得有一次优化一个卡片列表,每个卡片都有 box-shadowborder-radius。结果列表滚动时,帧率掉到20fps。后来我把阴影改成用伪元素模拟,绘制时间直接降了一半。

警告:绘制阶段最怕「重绘」(Repaint)。只要元素的颜色、背景、阴影变了,就会触发重绘。虽然重绘比重排(回流)轻一些,但频繁重绘也会卡顿。建议用 transformopacity 做动画,因为它们不会触发重绘,而是走合成线程。

2.7 总结:CRP优化的三个方向

说了这么多,其实CRP优化就三个方向:

  1. 减少关键资源数量——内联关键CSS,异步加载非关键CSS和JS
  2. 减少关键路径长度——压缩HTML、CSS、JS,减少请求次数
  3. 减少关键字节数——用更小的文件,更少的代码

我个人习惯在项目初期就做一次CRP分析。打开Chrome DevTools的Performance面板,录一次加载过程,看看DOMContentLoaded和First Paint的时间。如果这两个时间差距大,多半是CSS或JS阻塞了渲染。

好了,这一章就到这里。下一章我们聊聊「资源加载策略:预加载、预连接与预渲染」,到时候我会分享一个真实项目的优化案例——那个页面从5秒降到1.2秒,就是靠这些技巧。


公众号:蓝海资料掘金营,微信deep3321