从 8 道面试题看浏览器渲染过程与性能优化
Category(分类): Browser Status: 已更新
本文保留原文用 8 道面试题梳理浏览器进程、渲染流程、脚本加载、回流重绘和合成层的结构。Chrome、Firefox、Safari 的进程和线程实现并不完全相同,文中用“主线程、合成线程、栅格化线程”等概念帮助理解,不把它们当成所有浏览器都固定存在的公开 API。
前言:先看 8 个问题
- 为什么 JavaScript 通常在一个执行环境中按单线程运行?
- 为什么长时间运行的 JavaScript 会让页面卡顿?
- CSS 加载会造成阻塞吗?
DOMContentLoaded与load有什么区别?- 什么是关键渲染路径(CRP),如何优化?
defer和async有什么区别?- 什么是回流和重绘?如何减少不必要的布局工作?
- 什么是渲染层合成(Composite)?为什么不能滥用硬件加速?

早期资料常用“GUI 渲染线程”和“JS 引擎线程互斥”的模型。这个模型可以解释长任务为什么会挡住页面更新,但现代浏览器的实际架构更复杂:页面主线程负责大部分脚本、样式、布局和绘制准备,合成线程可以独立处理部分滚动和合成,栅格化还可能在线程池或 GPU 路径中完成。
一、进程(Process)和线程(Thread)
进程通常拥有独立的虚拟地址空间、资源和安全边界;线程是进程内的执行单元,同一进程的线程可以共享部分内存和资源。教材常把进程称为“资源分配的最小单位”、线程称为“CPU 调度的最小单位”,这是帮助理解的常见模型,具体调度和资源管理由操作系统决定。

可以用工厂来类比:进程像有边界的工厂,线程像工厂中的工人;不同工厂之间隔离较强,同一工厂中的工人可以共享仓库,但需要同步访问共享资源。
多线程并不意味着多个线程可以无条件同时修改同一份数据。共享内存需要锁、原子操作或其他同步机制;浏览器对 DOM 的设计则让一个页面的 DOM 操作主要集中在所属渲染进程的主线程上,避免把大量并发锁直接暴露给 Web 开发者。
二、浏览器为什么使用多进程?
现代浏览器不是“打开一个浏览器就只有一个进程”。以 Chromium 为例,常见的进程或服务包括:
- Browser 进程:浏览器窗口、标签页管理、会话历史、权限、进程协调等;
- Renderer 进程:运行网页脚本、解析文档、计算样式、布局和绘制准备;
- Network Service:网络请求相关能力,可能运行在独立进程或服务中;
- GPU/Viz 相关进程:图形设备、合成和显示相关工作;
- Utility、Storage、音视频和扩展进程:根据功能和安全边界动态创建。

“每个 Tab 对应一个 Renderer 进程”是早期和入门资料中的粗略说法。实际进程数量会受站点隔离、同源关系、浏览器版本、设备资源、扩展和页面策略影响。一个渲染进程也可能承载多个相关页面,单个页面还可能使用多个进程。Chrome 的任务管理器可以观察当前版本下的进程分布。

多进程的优点
- 一个页面或扩展崩溃时,通常不会直接拖垮整个浏览器;
- 通过沙盒和进程隔离降低网页内容访问浏览器资源的风险;
- 可以利用多核 CPU,让网络、图形、页面和浏览器 UI 互相协作;
- 资源和权限边界更清晰,便于浏览器回收闲置页面。
多进程的代价
每个进程都有内存、线程、IPC 和调度开销。页面越多不一定越快,浏览器会根据资源压力冻结、丢弃或合并部分页面状态。不要用固定的“一个 Tab 必然一个进程”推导性能或安全结论。

三、渲染进程内部的主要线程
一个渲染进程内部可能包含以下工作角色:

1. 主线程(Main Thread)
主线程通常负责:
- HTML 解析和 DOM 构建;
- CSS 解析、样式计算和部分样式表处理;
- JavaScript 执行和事件回调;
- 布局计算、绘制记录和部分资源处理。
JavaScript 长任务会占用主线程,使输入、样式、布局和绘制准备排队,从而出现卡顿。主线程不是“整个浏览器唯一的线程”,页面的合成和网络工作可以在其他线程或进程中进行,但这不能替代拆分主线程长任务。
2. 合成线程(Compositor Thread)
合成线程负责把已经准备好的图层或表面按顺序、裁剪、透明度和变换关系组合起来,并可以处理部分独立于主线程的滚动和动画。它不能在不经过主线程的情况下任意修改 DOM。
3. 栅格化工作线程和 GPU 路径
绘制记录需要转成图块位图,浏览器可能使用栅格化线程池、CPU 或 GPU 加速路径。是否使用 GPU 取决于浏览器、驱动、设备和内容,不能把“栅格化”简单等同于“全部由 GPU 完成”。
4. 事件、定时器和网络
事件循环、定时器和网络回调由浏览器和 JavaScript 执行环境共同协调。规范描述的是任务何时排队,而不是要求浏览器必须为 setTimeout、XHR 或每种事件各开一个固定线程。网络完成后,浏览器把相应回调排入任务队列,最后仍由对应 JavaScript agent 的执行线程运行。
5. Web Worker
Worker 为 JavaScript 提供独立的执行环境:
// main.js
const worker = new Worker('/worker.js', { type: 'module' })
worker.postMessage({ values: [1, 2, 3] })
worker.addEventListener('message', event => {
console.log('计算结果:', event.data)
})
// worker.js
self.addEventListener('message', event => {
const result = event.data.values.reduce((sum, value) => sum + value, 0)
self.postMessage(result)
})
Worker 不能直接访问页面 DOM,主线程与 Worker 通常通过结构化克隆、可转移对象或 SharedArrayBuffer(配合 Atomics 和安全隔离要求)通信。Worker 有自己的全局对象和事件循环,因此可以把 CPU 密集计算移开,但创建、通信和数据复制也有成本。
SharedWorker 可以被同源的多个页面连接到同一个共享工作环境,但规范只定义它的共享 agent 和生命周期,不保证浏览器一定为它创建一个独立操作系统进程。不要把 Worker 的抽象模型和 Chrome 当前的进程实现混为一谈。
四、浏览器渲染流程
下面是便于理解的简化流程:

- 解析 HTML,逐步构建 DOM;
- 解析 CSS 并生成 CSSOM,计算节点最终样式;
- 生成布局树并计算尺寸、位置和几何关系;
- 生成绘制记录,把背景、文字、边框、图片和阴影等绘制操作记录下来;
- 根据滚动、变换、重叠、视频、滤镜等因素建立图层或合成表面;
- 把绘制记录按图块栅格化为位图;
- 合成线程组合图层并提交给显示系统。
浏览器会缓存和增量更新这些结果。页面变化可能只触发样式、布局、绘制或合成中的一部分,不应把流程理解成每次都从头执行。
五、题解 1:为什么 JavaScript 通常是单线程的?
JavaScript 的语言语义允许脚本直接操作 DOM、事件监听器和页面状态。如果同一个页面的多个线程同时修改、删除或移动同一个节点,就需要处理大量竞态和锁问题,开发者也很难预测最终结果。
因此,浏览器把一个 Window/Document JavaScript agent 的脚本执行安排为串行任务。这里的“JavaScript 单线程”应该限定在一个 agent 或页面主执行环境内:Worker、SharedWorker、Service Worker 等可以拥有独立执行环境,整个浏览器当然也有很多线程。
单线程不等于不能并发:网络请求、图片解码、合成、Worker 计算可以与主线程工作重叠;只是同一个 JavaScript 执行上下文中的代码不会同时在两个线程执行。
六、题解 2:为什么长时间 JavaScript 会阻塞页面?
当主线程执行长任务时,它不能同时处理新的输入、事件回调、样式计算、布局或绘制记录。下面的代码会让页面在循环期间无法及时响应:
const startedAt = performance.now()
while (performance.now() - startedAt < 500) {
// 模拟主线程长任务
}
页面的合成线程有时仍能执行独立的滚动或合成动画,但如果动画依赖主线程样式更新,或者主线程无法及时提交新帧,用户依旧会看到卡顿。优化方向包括:
- 删除不必要的计算和重复渲染;
- 把大任务拆成小片段,使用
setTimeout、scheduler.yield()(支持时)或requestIdleCallback; - 将适合的 CPU 密集计算移入 Worker;
- 减少同步布局和大规模 DOM 更新;
- 使用 Performance 面板观察 Long Task、输入延迟和实际帧耗时。
七、题解 3:CSS 加载会造成阻塞吗?
要区分“阻塞 HTML 解析”“阻塞脚本执行”和“阻塞渲染”:
- 外部样式表通常不会让 HTML 解析器停止读取后续标记;
- 浏览器通常需要拿到影响当前媒体的样式后才能安全地绘制最终样式,因此样式表可能阻塞首次渲染;
- 经典的同步脚本可能需要等待前面尚未准备好的样式表,因为脚本可以读取和修改样式;
defer脚本和模块脚本也会受到文档解析与阻塞样式表的生命周期影响;media不匹配的样式表不一定阻塞当前渲染路径,但如果媒体条件随后改变,浏览器仍可能重新计算样式。
所以“CSS 不阻塞 DOM 解析”与“CSS 不阻塞任何事情”是两回事。应将关键样式尽早加载,移除未使用 CSS,并用 Performance 面板验证实际阻塞链。
八、题解 4:DOMContentLoaded 与 load
DOMContentLoaded
DOMContentLoaded 在文档解析完成,并且需要等待的延迟脚本和模块脚本执行完成后触发。它不需要等待图片、视频等普通子资源全部下载完成;异步脚本也不构成它的统一等待条件。阻塞脚本前的样式表可能间接推迟延迟/模块脚本执行,因此也会影响事件时间。
load
load 通常在文档及其需要参与加载的子资源完成后触发,例如样式表、脚本、图片和 iframe。懒加载资源、用户交互后才请求的资源不应简单理解为会阻塞初始 load。load 也不等于页面已经可交互或用户体验良好。
一般情况下:
DOMContentLoaded 早于 load
但要注意页面导航、BFCache、动态添加资源和不同资源加载策略,不能用一个固定事件替代真正的性能指标。可以使用 PerformanceNavigationTiming、FCP、LCP 和 INP 观察体验。
九、题解 5:什么是关键渲染路径(CRP)?
关键渲染路径是浏览器从 HTML、CSS 和 JavaScript 得到首批可见像素所经历的关键依赖链。传统资料常用三个变量描述它:
- 关键资源数量;
- 关键路径长度,即获取和处理关键资源所需的网络往返与依赖;
- 关键字节数,即完成首批渲染需要传输和处理的数据量。
这三个变量是分析模型,不是所有页面都能用三个数字完整描述。现在还应关注服务端 TTFB、渲染阻塞资源、LCP 候选、字体、图片优先级、主线程任务和真实用户网络。
HTML、CSS 优化
- 删除不必要的标记、注释和未使用样式;
- 压缩文本资源,并使用 Brotli/Gzip 等传输压缩;
- 将关键 CSS 尽早提供,把非关键样式延后;
- 通过缓存和内容指纹提高复用率;
- 给首屏图片设置尺寸和合适的优先级;
- 使用
preload只提示真正关键且当前页面一定需要的资源; - 使用
preconnect、dns-prefetch时确认第三方域名确实会被使用。
JavaScript 优化
- 减少首屏必须执行的代码和长任务;
- 使用代码分割和按需加载;
- 非关键脚本使用
defer、模块脚本或合适的动态导入; - 只对确实独立的脚本使用
async; - 不要为了减少请求把所有页面代码合并成一个无法缓存的大包;
- 不要只用 Lighthouse 单次结果判断,应结合真实用户 LCP、INP、CLS。
十、题解 6:defer 和 async 的区别
普通经典脚本
<script src="app.js"></script>
解析器遇到它时通常会暂停解析,等待脚本获取并执行完成后继续。
async
<script async src="analytics.js"></script>
脚本可以与 HTML 解析并行下载,下载完成后尽快执行,执行时可能短暂暂停解析。多个 async 脚本按下载完成顺序执行,彼此不保证顺序,因此适合不依赖 DOM、其他脚本或执行顺序的独立脚本。
defer
<script defer src="app.js"></script>
<script defer src="feature.js"></script>
经典外部 defer 脚本通常与 HTML 解析并行下载,在文档解析完成后、DOMContentLoaded 之前按文档顺序执行。它适合依赖 DOM 和保持脚本顺序的页面代码。
模块脚本
<script type="module" src="app.js"></script>
模块脚本默认具有类似延迟执行的行为,并支持模块依赖图;需要独立加载的模块可以使用 async。动态 import() 则在运行时加载模块,适合路由和功能级代码分割。

选择原则:
- 依赖 DOM、依赖其他脚本顺序:优先
defer或模块脚本; - 统计、广告等完全独立代码:评估
async,同时处理隐私、性能和失败降级; - 脚本放在
body末尾仍可作为兼容旧代码的策略,但不是现代页面唯一选择; - 任何策略都应通过网络瀑布和实际交互性能验证。
十一、题解 7:回流、重绘和强制同步布局
术语在不同浏览器中可能叫 Layout、Reflow、Paint。可以用下面的模型理解:
- 样式计算:根据 CSS、继承和状态得到最终样式;
- 布局/回流:计算元素几何位置和尺寸;
- 绘制/重绘:把文字、背景、边框、阴影等记录为绘制操作;
- 合成:组合已经准备好的图层或表面。
如果几何关系改变,可能触发布局和后续绘制;只有颜色等视觉属性改变时,可能只需要重绘;transform 和 opacity 在条件合适时可能只更新合成。实际影响范围取决于浏览器优化和页面结构。
可能触发布局的操作包括:
- 首次渲染和窗口尺寸变化;
- 修改尺寸、位置、字体、内容或可见 DOM;
- 读取
offsetWidth、clientHeight、scrollTop、getBoundingClientRect()等布局信息; - 在样式写入后立刻读取依赖最新布局的属性。
下面的写法可能造成读写交错:
for (const item of items) {
item.style.width = `${item.offsetWidth + 1}px`
}
可以先批量读取,再批量写入,或使用 class 和离线节点:
const widths = items.map(item => item.offsetWidth)
items.forEach((item, index) => {
item.style.width = `${widths[index] + 1}px`
})
优化建议:
- 使用 class 批量更新样式;
- 读写分组,避免循环中的强制同步布局;
- 使用
DocumentFragment或一次性替换内容; - 对复杂动画使用
transform、opacity,并确认语义和可访问性; - 对脱离文档流的动画元素仍要观察绘制和合成成本;
- 不要把
calc()叫作 CSS 表达式并一概禁用,现代 CSScalc()是正常的布局能力,只有造成实际复杂计算时才需要优化。
十二、题解 8:什么是 Composite?
浏览器会把绘制结果组织成图层、图块或合成表面,再由合成器按照层级、裁剪、透明度和变换关系组合。并不是每个 DOM 节点都有一个独立图层,也不是所有图层都必然上传到 GPU。

合成路径的价值
如果元素已经拥有合适的合成资源,更新 transform 或 opacity 时可能跳过布局和重新绘制,只修改合成参数。这对滑动、淡入淡出和位移动画很有帮助。但以下情况仍可能触发主线程工作:
- 首次创建或重新创建合成资源;
- 动画同时改变尺寸、文字、背景或阴影;
- 使用复杂滤镜、混合模式或
backdrop-filter; - 图层过大、图片需要重新栅格化或内存带宽不足;
- 主线程本身被长任务阻塞。
谨慎使用 will-change
.card.is-preparing {
will-change: transform, opacity;
}
will-change 是优化提示,不是“强制打开 GPU”的开关。长期给大量元素设置它,会增加内存、栅格化和图层管理成本。应只对即将变化的少量元素短时间使用,并在动画结束后移除或通过类名控制。
translateZ(0)、translate3d(0, 0, 0) 等旧技巧也不是通用秘籍。现代浏览器会基于内容、平台和资源压力自行决定合成策略,盲目创建图层可能让性能更差、文字变模糊或耗电增加。
如何验证
使用 Chrome DevTools:
- Performance 面板录制动画,观察 Style、Layout、Paint、Raster 和 Composite;
- Rendering 面板开启 Paint flashing,确认是否每帧大面积重绘;
- Layers 面板观察图层大小、数量和创建原因;
- 结合低端设备、不同刷新率、内存和真实用户指标测试。
总结
- 浏览器是多进程、多线程系统,“一个 Tab 一个进程”只是粗略模型;
- 一个页面 JavaScript agent 通常串行执行,但 Worker 提供独立执行环境;
- 长 JavaScript 任务会占用主线程,阻塞输入、样式、布局和绘制准备;
- CSS 可能阻塞渲染,并影响部分脚本执行,但不等于完全阻塞 HTML 解析;
DOMContentLoaded和load等待的资源范围不同,不能替代 LCP、INP 等体验指标;defer保持顺序并在解析后执行,async下载完成后尽快执行且不保证顺序,模块脚本默认具有延迟特征;- 回流、重绘和合成是不同阶段,
transform/opacity只是优化机会; - 合成层和 GPU 资源有成本,必须使用 DevTools 和真实设备验证。