ARTICLE · 1121109
为什么 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 构建完再执行脚本,因为脚本可能查询样式。
从字节到像素,六个阶段谁在卡谁
下面这张表把渲染过程拆成六个阶段,标清楚输入、输出、阻塞因素和优化点。
验证代码可以直接跑,观察 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 | ||
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. 网络层:HTML 字节流到达渲染进程,解析器开始工作。 2. 解析层:构建 DOM 树,遇到 CSS 请求 CSSOM,遇到同步脚本暂停解析。 3. 样式层:CSSOM 构建完成,与 DOM 合成渲染树,只包含可见节点。 4. 布局层:计算几何位置,生成布局树。 5. 绘制层:生成绘制指令,光栅化。 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 树合成渲染树,再经过布局、绘制、合成,最终变成屏幕像素。搞懂这条管线,性能优化才不是瞎猜。