你打开Chrome的任务管理器,发现一个简单的网页竟然占用了多个进程——浏览器进程、渲染进程、GPU进程、网络服务进程,甚至还有扩展进程。这不是Chrome"吃内存",而是现代浏览器架构的必然结果。

2018年,Chrome团队在Chrome 67中默认启用了Site Isolation(站点隔离)功能,将不同网站的iframe放入独立的渲染进程。这个决策源于Spectre漏洞的发现——攻击者可以通过侧信道攻击读取同一进程内其他网站的数据。进程隔离成为最有效的安全边界。

但要理解为什么浏览器需要如此复杂的架构,我们必须从渲染管道说起。

从DOM到像素:渲染管道的完整流程

当你访问一个网页时,浏览器需要完成一系列复杂的步骤才能将代码转换为屏幕上的像素。这个流程被称为渲染管道(Rendering Pipeline)。

渲染管道流程图

图片来源: developer.chrome.com

Chrome的RenderingNG架构将渲染过程分解为以下关键阶段:

1. 样式计算(Style)

浏览器将CSS规则应用到DOM元素上,计算每个元素的最终样式。这个阶段需要处理CSS选择器匹配、样式继承和层叠规则。CSS选择器的复杂度直接影响这个阶段的性能——后代选择器(div p)比直接子选择器(div > p)更昂贵,因为前者需要遍历整个祖先链。

2. 布局(Layout)

布局阶段计算每个元素的确切位置和大小。这是一个递归过程:父元素的大小可能依赖于子元素,子元素的位置又依赖于父元素。布局的代价与DOM树的复杂度直接相关,更重要的是,布局几乎总是针对整个文档进行的——即使只改变一个元素,浏览器也可能需要重新计算整个页面的布局。

3. 预绘制(Pre-paint)

计算属性树(Property Trees),确定哪些内容需要被重绘。属性树是RenderingNG引入的关键数据结构,它将视觉效果(如transform、opacity、filter)与布局分离,使得动画和滚动可以在合成线程独立处理。

4. 绘制(Paint)

生成显示列表(Display List),记录如何将元素绘制到屏幕上。注意,这个阶段并不实际绘制像素,而是生成一系列绘制指令。这些指令会被发送到GPU进程进行光栅化。

5. 合成(Composite)

将绘制指令分解为多个合成层(Composited Layers),每个层可以独立光栅化和动画。这是GPU加速的关键——浏览器将页面分解为多个"层",每个层作为GPU纹理独立处理。

6. 光栅化(Raster)

将显示列表转换为实际的像素数据。现代Chrome支持两种光栅化方式:软件光栅化(在CPU上完成)和GPU光栅化(直接在GPU上执行Skia指令)。

7. 绘制(Draw)

GPU将所有合成层组合成最终的屏幕图像。这个阶段在GPU进程的viz组件中完成。

多进程架构:为什么一个页面需要这么多进程?

打开Chrome的任务管理器,你会看到多个进程。这不是设计缺陷,而是精心设计的安全和性能架构。

Chrome多进程架构

图片来源: developer.chrome.com

浏览器进程(Browser Process):控制浏览器的"chrome"部分——地址栏、书签、前进后退按钮。更重要的是,它处理网络请求和文件访问,这些是特权操作。

渲染进程(Renderer Process):负责网页内容的渲染、动画、滚动和输入路由。这是最不信任的部分,运行在沙箱中,被剥夺了访问文件系统和网络的权限。

GPU进程(GPU Process):处理所有GPU操作。独立的GPU进程有两个关键原因:一是GPU驱动崩溃不会导致整个浏览器崩溃;二是安全隔离,GPU API(如Vulkan)需要更宽松的沙箱。

Viz进程:负责聚合来自多个渲染进程的合成器帧。在Site Isolation之前,一个页面只有一个渲染进程,合成可以在渲染进程内完成。现在,一个页面可能有多个跨站iframe运行在不同的渲染进程中,需要一个中央协调者。

Site Isolation的安全边界

2018年,Chrome 67在桌面平台(Windows、Mac、Linux、Chrome OS)默认启用了Site Isolation。这是一个多年的工程努力,改变了iframe之间的通信方式,甚至影响了DevTools和页面内搜索的实现。

Site Isolation的核心思想是:每个站点(site)运行在独立的渲染进程中。站点定义为eTLD+1(有效顶级域名+一级子域名)。例如,a.example.comb.example.com属于同一站点,而example.comattacker.com属于不同站点。

这种隔离提供了强安全保证:

  • 同源策略在进程边界强制执行
  • Spectre攻击无法读取跨进程内存
  • 渲染进程被攻陷不会影响其他站点

代价是内存消耗增加约10-13%——每个渲染进程都有独立的V8引擎实例和Blink渲染引擎副本。Chrome通过动态调整进程数量来平衡安全性和资源消耗。

重排与重绘:性能优化的核心战场

理解渲染管道后,重排(Reflow/Layout)和重绘(Repaint)的概念就清晰了。

重排发生在元素的几何属性变化时:widthheightpaddingmarginlefttop等。重排是昂贵的,因为它需要重新计算整个文档的布局。

重绘发生在元素的视觉样式变化但不影响布局时:colorbackground-colorvisibility等。重绘比重排便宜,但仍需要重新生成显示列表。

布局抖动(Layout Thrashing)

更危险的是强制同步布局(Forced Synchronous Layout),也称布局抖动。

// 危险:在循环中交替读写布局属性
for (let i = 0; i < paragraphs.length; i++) {
  paragraphs[i].style.width = box.offsetWidth + 'px';
}

这段代码的问题在于:每次迭代,JavaScript先读取offsetWidth(触发布局),然后写入style.width(标记布局脏位)。下一次迭代再次读取offsetWidth时,浏览器必须先完成布局才能返回正确的值。

正确的做法是先批量读取,再批量写入:

// 先读取
const width = box.offsetWidth;
// 再写入
for (let i = 0; i < paragraphs.length; i++) {
  paragraphs[i].style.width = width + 'px';
}

浏览器的事件循环设计使得布局延迟计算成为可能:JavaScript执行时不进行渲染,DOM变化在任务完成后统一处理。但某些属性和方法会强制浏览器立即执行布局:

触发布局的属性/方法 说明
offsetTop, offsetLeft, offsetWidth, offsetHeight 返回元素的布局位置/尺寸
scrollTop, scrollLeft, scrollWidth, scrollHeight 滚动相关
clientTop, clientLeft, clientWidth, clientHeight 元素内容区域
getBoundingClientRect() 返回布局矩形
getComputedStyle() 返回计算样式(某些情况)
scrollIntoView() 滚动元素到视口

GPU加速合成:为什么transform动画如此流畅?

你可能注意到,使用transform: translateX()做动画比改变left属性流畅得多。这涉及渲染管道中的一个关键优化:合成层(Compositing Layer)

当元素满足以下条件之一时,浏览器会为其创建独立的合成层:

  • 拥有3D或透视CSS变换
  • 使用<video><canvas>(WebGL/加速2D上下文)
  • CSS动画或过渡中使用opacitytransform
  • 使用will-change属性
  • position: fixed(某些情况)
  • 拥有合成层的后代元素

合成层的优势在于:动画可以完全在合成线程完成,不需要主线程参与

合成线程的工作

Chrome的线程架构将渲染工作分为两个关键线程:

主线程(Main Thread):运行JavaScript、处理DOM、执行布局和绘制。这是最繁忙的线程,也是性能瓶颈的常见来源。

合成线程(Compositor Thread):处理输入事件、滚动、CSS动画,协调光栅化和绘制任务。

当用户滚动页面时,合成线程可以独立处理滚动,更新属性树中的滚动偏移,生成新的合成器帧——完全不需要主线程参与。这就是为什么即使主线程被阻塞,页面仍然可以流畅滚动。

但有一个例外:触摸事件监听器。如果页面注册了touchstarttouchmove事件监听器,合成线程必须将事件发送到主线程,等待JavaScript处理完毕后才能决定是否滚动。这就是为什么Chrome建议使用passive: true选项:

document.addEventListener('touchmove', handler, { passive: true });

这告诉浏览器:监听器不会调用preventDefault(),合成线程可以立即开始滚动。

will-change的双刃剑

will-change属性可以提前告知浏览器元素将发生变化,触发合成层创建:

.animated-element {
  will-change: transform, opacity;
}

但这不是性能万灵药。每个合成层都需要独立的GPU纹理,消耗显存。过多的合成层会导致:

  • GPU内存压力增大
  • 合成阶段开销增加(需要遍历更多层)
  • 在低端设备上可能导致崩溃

Chrome的Layer Squashing机制会尝试将重叠的非直接合成原因的层合并到单个纹理中,但手动创建过多合成层仍会绕过这个优化。

事件循环:JavaScript执行的隐秘规则

渲染发生在事件循环的特定时机。理解事件循环对于性能优化至关重要。

sequenceDiagram
    participant MT as 主线程
    participant MQ as 宏任务队列
    participant mQ as 微任务队列
    participant R as 渲染
    
    MT->>MQ: 从宏任务队列取一个任务
    MT->>MT: 执行同步代码
    MT->>mQ: 执行所有微任务
    MT->>R: 更新渲染(如果需要)
    MT->>MQ: 等待下一个宏任务

宏任务(Macrotask):包括script整体代码、setTimeoutsetIntervalsetImmediate(Node.js)、I/O、UI渲染。

微任务(Microtask):包括Promise.then/catch/finallyqueueMicrotask()MutationObserver

关键规则:每个宏任务之后,渲染之前,执行所有微任务。这意味着微任务可以在当前帧渲染前执行,而setTimeout(..., 0)的回调要等到下一个宏任务。

console.log('script start');

setTimeout(() => console.log('timeout'), 0);

Promise.resolve().then(() => console.log('promise'));

console.log('script end');

// 输出顺序:
// script start
// script end
// promise
// timeout

requestAnimationFrame的回调在渲染前、微任务之后执行。这使得它成为动画更新的最佳时机:

function animate() {
  // 更新动画状态
  element.style.transform = `translateX(${x}px)`;
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

V8引擎:JavaScript的执行管道

渲染管道之外,V8引擎的执行管道同样复杂。V8使用多层JIT编译策略来平衡启动速度和峰值性能。

V8 JIT编译架构

图片来源: v8.dev

1. 解释器(Ignition)

所有JavaScript代码首先被编译为字节码,由Ignition解释执行。这是最快的启动方式,但执行速度最慢。

2. 基线编译器(Sparkplug)

2021年引入,快速将字节码编译为机器码,几乎不进行优化。编译速度极快(亚毫秒级),生成的代码比解释器快约40%。

3. 中级优化编译器(Maglev)

2023年在Chrome 117引入,填补了Sparkplug和TurboFan之间的空白。Maglev使用SSA(静态单赋值)中间表示,根据运行时类型反馈生成专门化代码。

编译时间约为Sparkplug的10倍,但只有TurboFan的1/10。在Speedometer基准测试中,添加Maglev带来了约10%的性能提升。

4. 顶级优化编译器(TurboFan)

基于"海节点"(Sea of Nodes)IR的优化编译器,执行激进的优化:逃逸分析、循环不变量外提、内联缓存等。编译时间最长,但生成最高质量的代码。

类型反馈与去优化

JIT编译的关键是投机优化。编译器假设变量类型不变,生成专门化代码:

function add(a, b) {
  return a + b;
}

// 如果a和b总是整数
for (let i = 0; i < 10000; i++) {
  add(i, i + 1);  // 编译器假设add总是处理整数
}

// 突然传入字符串
add('hello', 'world');  // 去优化!回退到解释器或重新编译

类型改变触发去优化,这是JIT性能不稳定的主要来源。保持类型稳定是编写高性能JavaScript的关键。

Orinoco:并行与并发垃圾回收

V8的垃圾回收器代号Orinoco,实现了三种关键技术的组合:

Orinoco GC技术

图片来源: v8.dev

并行GC(Parallel):主线程和辅助线程同时工作,缩短暂停时间。仍是"Stop-the-World",但工作被分摊。

增量GC(Incremental):将GC工作分成小段,穿插在主线程任务之间。不减少总工作量,但让主线程保持响应。

并发GC(Concurrent):辅助线程完全在后台执行GC工作,主线程自由执行JavaScript。这是最难实现的,因为JavaScript执行期间对象图可能随时变化。

现代V8在Scavenger(新生代GC)中使用并行技术,在Major GC(老生代GC)中使用并发标记和并行压缩。结果是:主线程暂停时间大幅缩短,Gmail在空闲时可以减少45%的JavaScript堆内存。

关键渲染路径优化实践

理解渲染管道后,优化策略就清晰了。

减少渲染阻塞资源

CSS默认是渲染阻塞的:浏览器必须等待所有CSS下载和解析完成才能渲染。JavaScript默认是解析阻塞的:脚本执行会阻止HTML解析。

优化策略:

<!-- 异步加载非关键JavaScript -->
<script src="analytics.js" async></script>

<!-- 延迟执行非关键JavaScript -->
<script src="app.js" defer></script>

<!-- 预加载关键资源 -->
<link rel="preload" href="critical-font.woff2" as="font" crossorigin>

<!-- 预连接到关键域名 -->
<link rel="preconnect" href="https://cdn.example.com">

async脚本在下载完成后立即执行,不保证顺序。defer脚本在DOM解析完成后、DOMContentLoaded事件前按顺序执行。

优化关键渲染路径

关键渲染路径(Critical Rendering Path)是从收到HTML到首次渲染的路径。目标是最小化首次渲染时间:

  1. 最小化关键资源:内联关键CSS,延迟加载非关键CSS
  2. 最小化关键字节数:压缩、最小化CSS和JavaScript
  3. 最小化关键路径长度:减少往返次数

诊断工具

Chrome DevTools的Performance面板可以完整记录渲染管道的每个阶段:

  • Recalculate Style:样式计算时间
  • Layout:布局时间
  • Paint:绘制时间
  • Composite:合成时间

更重要的是,DevTools可以识别强制同步布局,标记为"Layout Shift"或"Forced Reflow"。

写在最后

浏览器的渲染管道是一个精妙的工程设计,每个阶段都经过深思熟虑的优化:

  • 多进程架构提供安全隔离,代价是内存开销
  • 合成线程独立于主线程,保证滚动和动画的流畅性
  • 多层JIT编译平衡启动速度和峰值性能
  • 并发垃圾回收最小化主线程暂停

理解这些原理,才能写出真正高性能的Web应用。性能优化不是魔法,而是对系统行为的深刻理解和对权衡的明智选择。


参考资料

  1. RenderingNG architecture - Chrome for Developers
  2. Inside look at modern web browser - Chrome for Developers
  3. Avoid large, complex layouts and layout thrashing - web.dev
  4. Maglev - V8’s Fastest Optimizing JIT - V8.dev
  5. Trash talk: the Orinoco garbage collector - V8.dev
  6. Event loop: microtasks and macrotasks - JavaScript.info
  7. GPU Accelerated Compositing in Chrome - Chromium.org
  8. Site Isolation Design Document - Chromium.org
  9. Mitigating Spectre with Site Isolation in Chrome - Google Security Blog
  10. Critical rendering path - MDN
  11. Assist the browser with resource hints - web.dev