夜雨聆风学习资料网

ARTICLE · 1121109

为什么 HTML 下载完了页面还是白屏?浏览器渲染过程和 DOM/CSSOM 合成,比你想的复杂

为什么 HTML 下载完了页面还是白屏?浏览器渲染过程和 DOM/CSSOM 合成,比你想的复杂

去年双十一前,一个活动页接口 200ms 就返回了,HTML 也很快下载完,但用户盯着白屏等了 2 秒。LCP 跑到 4.2s,转化率掉了 7%。排查发现,CSS 文件放在 <body> 底部,还用 @import 串行加载了另一个样式文件。浏览器拿不到完整 CSSOM,渲染树无法生成,页面只能白屏。浏览器的渲染过程不是“下载完就画”,它要先把 HTML 解析成 DOM 树,把 CSS 解析成 CSSOM 树,两棵树合成渲染树,再布局、绘制、合成。任何一步被卡住,用户就看到空白。

浏览器不是先画页面,而是先建两棵树

浏览器从网络进程拿到 HTML 字节流后,渲染进程开始解析。解析器把字节转成字符,再转成 token,然后构建 DOM 树。遇到 <link rel="stylesheet">,浏览器会发起 CSS 请求,同时继续解析 HTML,但 CSSOM 的构建会阻塞渲染树的生成。遇到没有 async 或 defer 的 <script>,解析器会暂停,等脚本下载并执行完再继续,因为脚本可能修改 DOM 或读取样式。

Blink 里负责样式和布局树更新的核心逻辑简化后大概是这样:

// 简化自 Blink 的 Document::UpdateStyleAndLayoutTreevoid Document::UpdateStyleAndLayoutTree(){  // 1. 确保 CSSOM 已构建  if (!style_engine_->HasCSSOM()) {    style_engine_->BuildCSSOM(); // 解析 CSS,构建 CSSOM  }  // 2. 计算样式,生成 RenderStyle  style_engine_->RecalcStyle(); // 把 CSSOM 规则匹配到 DOM 节点  // 3. 构建布局树(Layout Tree,旧称 Render Tree)  if (layout_tree_needs_update_) {    layout_tree_builder_->BuildLayoutTree(); // 只包含可见节点  }  // 4. 布局计算  layout_tree_->Layout(); // 计算每个节点的几何位置  // 5. 绘制和合成  paint_layer_->Paint();  compositor_->Commit();}

关键点:DOM 树和 CSSOM 树是两棵独立的树。DOM 树描述结构,CSSOM 树描述样式。渲染树(现代浏览器叫 Layout Tree)是两者合成的结果,只包含可见节点。display: none 的节点不会进入渲染树,visibility: hidden 的节点会进入,因为它占据布局空间。<head>、<script>、<meta> 这些不渲染的节点也不会进入。

为什么 CSS 阻塞渲染?因为浏览器不想先画一版没有样式的页面,再重画一版有样式的页面。那样用户会看到 FOUC(无样式内容闪烁)。所以浏览器会等 CSSOM 构建完成,再合成渲染树。但 CSS 不阻塞 DOM 解析,只阻塞渲染和脚本执行。如果脚本在 CSS 后面,浏览器会等 CSSOM 构建完再执行脚本,因为脚本可能查询样式。

从字节到像素,六个阶段谁在卡谁

下面这张表把渲染过程拆成六个阶段,标清楚输入、输出、阻塞因素和优化点。

阶段
输入
输出
阻塞因素
优化点
构建 DOM
HTML 字节流
DOM 树
同步脚本、解析器阻塞
减少 HTML 体积,避免 document.write
构建 CSSOM
CSS 字节流
CSSOM 树
CSS 文件下载、@import 串行
关键 CSS 内联,非关键异步
合成渲染树
DOM + CSSOM
Layout Tree
CSSOM 未就绪
避免 display:none 大量节点
布局
Layout Tree + 样式
几何位置
强制同步布局、频繁读写
批量读写,用 transform 代替 top/left
绘制
布局结果
绘制指令
复杂阴影、渐变、大量文本
减少重绘区域,用 will-change
合成
绘制指令
屏幕像素
合成层过多、内存
合理提升合成层,避免层爆炸

验证代码可以直接跑,观察 FCP 和 LCP:

<!DOCTYPE html><html><head>  <meta charset="UTF-8">  <title>渲染过程验证</title>  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css"></head><body>  <div id="app">内容</div>  <script>    // 观察首次内容绘制和最大内容绘制    new PerformanceObserver((list) => {      for (const entry of list.getEntries()) {        console.log(entry.name, entry.startTime.toFixed(0) + 'ms');      }    }).observe({ type: 'paint', buffered: true });    new PerformanceObserver((list) => {      const entries = list.getEntries();      const last = entries[entries.length - 1];      console.log('LCP:', last.startTime.toFixed(0) + 'ms', last.element);    }).observe({ type: 'largest-contentful-paint', buffered: true });    // 测量强制同步布局    const box = document.querySelector('#app');    const start = performance.now();    for (let i = 0; i < 1000; i++) {      box.style.width = box.offsetWidth + 1 + 'px'; // 读 offsetWidth 触发强制布局    }    console.log('强制同步布局耗时:', (performance.now() - start).toFixed(1) + 'ms');</script></body></html>

另一个关键差异是 DOMContentLoaded 和 load。DOMContentLoaded 在 DOM 树构建完成、所有 defer 脚本执行完后触发,此时 CSSOM 可能还没就绪,图片也没加载。load 在页面所有资源(图片、样式、脚本)加载完后触发。渲染树合成发生在 DOMContentLoaded 之前或之后,取决于 CSS 是否阻塞。

事件
触发时机
与渲染树关系
DOMContentLoaded
DOM 解析完,defer 脚本执行完
可能 CSSOM 未就绪,渲染树未合成
load
所有资源加载完
渲染树已合成,页面已绘制
first paint
首次绘制
渲染树合成后,可能是背景色
first contentful paint
首次内容绘制
文本或图片首次出现
largest contentful paint
最大内容绘制
首屏最大元素渲染完成

CSS 放错位置,首屏多等 1.8 秒

我们有个活动页,CSS 放在 <body> 底部,还用 @import 加载字体图标:

<body>  <div class="banner">...</div>  <link rel="stylesheet" href="/css/main.css">  <style>    @import url('/css/icon.css'); /* 串行加载,阻塞 CSSOM */</style></body>

浏览器解析到 <link> 时才开始下载 CSS,之前已经渲染了一部分 DOM,但渲染树无法合成,页面白屏。@import 又让 icon.css 等 main.css 下载完才开始下载,串行耗时 1.8 秒。FCP 从 1.2s 变成 3.0s。

修复方案:把关键 CSS 内联到 <head>,非关键 CSS 用 preload 异步加载:

<head>  <style>    /* 关键 CSS 内联,首屏立即渲染 */    .banner { height: 200px; background: #ff4d4f; }</style>  <link rel="preload" href="/css/main.css" as="style" onload="this.rel='stylesheet'">  <noscript><link rel="stylesheet" href="/css/main.css"></noscript></head>

上线后 FCP 从 3.0s 降到 1.2s,LCP 从 4.2s 降到 2.1s,转化率回升 5%。

第二个场景是布局抖动。一个列表渲染组件,循环里读 offsetHeight 再写 style:

// 错误写法:读写交替,每次读都触发强制同步布局items.forEach(item => {  const h = item.offsetHeight; // 强制布局  item.style.height = h + 10 + 'px'; // 写样式,标记需要布局});

1000 个节点耗时 320ms,帧率掉到 12。修复方案:先读后写,用 requestAnimationFrame 批量处理:

// 正确写法:先读后写,避免强制同步布局const heights = items.map(item => item.offsetHeight); // 批量读requestAnimationFrame(() => {  items.forEach((item, i) => {    item.style.height = heights[i] + 10 + 'px'; // 批量写  });});

优化后耗时 18ms,帧率稳定 58。数据来自我们内部性能监控,不是理论值。

面试时怎么把渲染流程答出架构味

高分回答不要背“DOM 树和 CSSOM 树合成渲染树”。用分层递进:

  1. 1. 网络层:HTML 字节流到达渲染进程,解析器开始工作。
  2. 2. 解析层:构建 DOM 树,遇到 CSS 请求 CSSOM,遇到同步脚本暂停解析。
  3. 3. 样式层:CSSOM 构建完成,与 DOM 合成渲染树,只包含可见节点。
  4. 4. 布局层:计算几何位置,生成布局树。
  5. 5. 绘制层:生成绘制指令,光栅化。
  6. 6. 合成层:分层合成,提交到 GPU 显示。

如果被追问,可以这样接:

追问 1:CSS 阻塞渲染吗?CSS 阻塞渲染树的合成,但不阻塞 DOM 解析。浏览器不想先画无样式页面再重画,所以等 CSSOM 就绪再合成渲染树。CSS 还阻塞后面的同步脚本执行,因为脚本可能查询样式。

追问 2:DOMContentLoaded 和 load 有什么区别?DOMContentLoaded 在 DOM 解析完、defer 脚本执行完后触发,此时 CSSOM 可能未就绪,图片未加载。load 在所有资源加载完后触发。渲染树合成可能在 DOMContentLoaded 之前或之后,取决于 CSS 是否阻塞。

追问 3:渲染树和 DOM 树有什么区别?渲染树只包含可见节点。display: none 不进入,visibility: hidden 进入。<head>、<script>、<meta> 不进入。渲染树节点包含样式和几何信息,DOM 节点只有结构。

追问 4:重排和重绘有什么区别?重排(reflow)是几何位置变化,比如宽高、位置、字体大小,代价高。重绘(repaint)是外观变化,比如颜色、背景,代价低。重排一定触发重绘,重绘不一定触发重排。用 transform 和 opacity 可以跳过重排,直接合成。

追问 5:合成层什么时候产生?当元素有 transform、opacity、will-change、position: fixed、video、canvas 等属性时,浏览器可能提升为合成层。合成层在 GPU 单独绘制,不触发主线程重排重绘。但层过多会消耗内存,层爆炸反而变慢。

面试官真正想看的是:你知不知道渲染管线、懂不懂关键渲染路径、能不能定位性能瓶颈、有没有优化经验。反向提问可以放在面试官问完项目后:“你们性能监控有采集 FCP、LCP、CLS 吗?关键渲染路径有没有做阻塞资源分析?合成层有没有做过层爆炸排查?”

让 AI 先跑一遍关键渲染路径

浏览器渲染过程最适合 AI 扮演两个角色:性能预测器和代码审查员。因为关键渲染路径涉及网络、解析、样式、布局多个阶段,人工分析容易漏,AI 可以按规则批量扫描。

提示词一:关键渲染路径审查。

你是一名前端性能审查员。输入:一段 HTML、CSS 和 JS。任务:1. 识别阻塞渲染的资源(CSS、同步脚本、@import);2. 预测 FCP 和 LCP 的大致时间范围;3. 标记强制同步布局的代码(读写交替、offsetHeight/offsetWidth);4. 给出优化建议,比如内联关键 CSS、preload、批量读写、transform 代替 top/left;5. 输出优化后的代码片段。输出 JSON:{ "blockingResources": [...], "fcpPrediction": "...", "layoutThrashing": [...], "fixes": [...] }HTML:{{html}}CSS:{{css}}JS:{{js}}

提示词二:生成性能测试。

你是一名测试工程师。输入:一段 HTML/CSS/JS。任务:生成 Playwright 测试用例,验证:- FCP 小于 1.5s;- LCP 小于 2.5s;- 没有强制同步布局;- 没有超过 50ms 的长任务;- 合成层数量不超过 30。输出可直接运行的测试代码,包含 PerformanceObserver 和断言。

把这两个提示词接进 CI,AI 就能在合并前告诉你:这个 CSS 文件阻塞渲染,FCP 可能超过 2s;这个循环里读写交替,会导致布局抖动。浏览器渲染过程不是“下载完就画”,它是 DOM 树和 CSSOM 树合成渲染树,再经过布局、绘制、合成,最终变成屏幕像素。搞懂这条管线,性能优化才不是瞎猜。

相关学习资料