阅读视图

发现新文章,点击刷新页面。

浏览器是怎么把代码变成页面的?

你在地址栏输入一个URL,敲下回车,页面就出现了。但浏览器内部到底经历了什么?HTML、CSS、JS是如何变成你看到的页面的?

今天用**"装修房子"**的故事,聊聊浏览器的渲染原理。


原文地址

墨渊书肆/浏览器是怎么把代码变成页面的?


从URL到页面:渲染总览

当你在浏览器输入URL并回车,浏览器内部经历了:

浏览器地址栏
├── URL输入
├── DNS解析
   └── 域名  IP地址
├── TCP连接
   └── 三次握手
├── HTTP响应
   └── 服务器返回HTML/CSS/JS
└── 渲染进程处理
    ├── 构建阶段:HTML解析 + CSS解析
    └── 绘制阶段:布局  分层  绘制  合成

渲染流水线可以分为构建阶段绘制阶段

构建阶段(并行):
┌─────────────┐     ┌─────────────┐
  HTML解析          CSS解析    
   生成DOM           生成CSSOM  
└──────┬──────┘     └──────┬──────┘
                           
       └────────┬───────────┘
                
          渲染树构建
                
绘制阶段:
```yaml
绘制阶段
├── 布局计算
   └── 计算每个元素的位置、大小、边距
├── 分层
   └── 哪些元素需要独立图层(fixed/动画/视频)
├── 绘制
   └── 生成绘制指令(矩形、文字、线条)
└── 合成输出
    └── GPU合并图层  显示到屏幕

解读

  • 构建阶段:HTML和CSS解析同时进行(并行),完成后合并成渲染树
  • 绘制阶段:按顺序执行布局、分层、绘制、合成,最终输出画面
阶段 输入 输出
HTML解析 HTML字符串 DOM树
CSS解析 CSS字符串 CSSOM树
渲染树构建 DOM + CSSOM 渲染树
布局 渲染树 盒模型信息
分层 布局信息 图层树
绘制 图层 绘制指令
合成 图层+指令 画面

第一步:HTML解析 → DOM树

浏览器收到HTML响应后,首先要解析HTML,构建DOM树

DOM是什么?

DOM(Document Object Model,文档对象模型)是HTML/XML文档的编程接口。浏览器把HTML文档解析成一棵树状结构,每个HTML标签都是树上的一个节点

<html>
  <head>
    <title>标题</title>
  </head>
  <body>
    <h1>欢迎</h1>
    <p>这是段落</p>
  </body>
</html>

DOM树结构:

html
├── head
   └── title  "标题"
└── body
    ├── h1  "欢迎"
    └── p  "这是段落"

HTML解析过程

解析器从上到下读取HTML,遇到<head>标签创建head节点,遇到<body>标签创建body节点,遇到嵌套标签创建子节点...

HTML解析器:逐行读取  创建节点  构建DOM树
<html>  html节点
<head>  head节点  title节点  文本节点  关闭title  关闭head
<body>  body节点  h1节点  文本节点  关闭h1  p节点  文本节点  关闭p  关闭body  关闭html
 DOM树构建完成

遇到JS会怎样?

HTML解析器遇到<script>标签时会暂停解析,先执行JS:

解析HTML  构建DOM  完成
    
遇到<script>:暂停  执行JS  继续

因为JS可能document.write()修改DOM,所以HTML解析器必须等JS执行完成才能继续。

这就是为什么把JS放在body底部可以加快首屏渲染——让HTML先解析完,显示内容,JS最后再执行。


第二步:CSS解析 → CSSOM树

HTML解析的同时,浏览器也在解析CSS,构建CSSOM树(CSS Object Model)。

CSSOM是什么?

CSSOM是CSS样式表的树状结构,描述了每个元素的样式信息。

body { font-size: 16px; }
h1 { color: red; font-size: 24px; }
p { color: blue; }

CSSOM树结构:

body
├── font-size: 16px
├── color: (inherited)
└── children
    ├── h1
       ├── color: red
       └── font-size: 24px
    └── p
        └── color: blue

CSS解析特性

与HTML不同,CSS解析是上下文相关的

标签选择器:p { color: blue; }      所有<p>生效
类选择器:.title { ... }          class="title"生效
ID选择器:#header { ... }        id="header"生效

CSS解析器需要考虑选择器优先级(ID > 类 > 标签)、层叠规则、继承规则等。


第三步:渲染树(Render Tree)

DOM树 + CSSOM = 渲染树(Render Tree)

渲染树只包含可见节点——display: none的元素不会出现在渲染树中。

DOM + CSSOM = 渲染树

DOM节点 CSSOM样式 渲染树
display:none ✗ 不显示
容器样式 body
├─h1 color:red h1(red)
├─p display:none ✗ 不显示
└─span color:green span(green)

注意<p style="display: none">不会生成渲染树节点,但<p style="visibility: hidden">会生成(只是不可见)。


第四步:布局(Layout)

渲染树构建完成后,浏览器计算每个元素的几何信息:位置、大小、边距、边框等。

布局计算

渲染树  布局计算  盒模型信息
元素1:x=0, y=0, width=200, height=50
元素2:x=0, y=50, width=200, height=30
元素3:x=0, y=80, width=100, height=80
 每个元素都有精确的位置和大小

盒模型(Box Model)

CSS中的盒模型定义了元素的空间占用:

┌─margin─────────────────────────────┐
  ┌─border───────────────────────┐  
    ┌─padding──────────────────┐   
      ┌─content─────────────┐    
         width × height       
      └─────────────────────┘    
    └──────────────────────────┘   
  └───────────────────────────────┘  
└─────────────────────────────────────┘
属性 说明
content 内容区域(width × height)
padding 内边距,内容与边框之间的空间
border 边框,围绕内边距的线条
margin 外边距,边框与其他元素之间的空间

回流(Reflow)

当元素的几何信息发生变化时,浏览器需要重新计算布局,这称为回流(Reflow)

触发回流的操作:

  • 添加/删除可见DOM元素
  • 元素位置/尺寸变化
  • 浏览器窗口大小变化
  • 获取元素的offsetWidth/Height(强制触发计算)
回流过程:
修改DOM  重新计算布局  重绘(耗时操作)

回流比重绘更昂贵,因为它需要重新计算整棵布局树。


第五步:分层(Layer)

布局完成后,浏览器根据一定规则把页面分成多个图层(Layer)

为什么要分层?

分层可以让页面的不同部分独立绘制和合成,避免互相影响。

分层示意:
Layer 3: 固定定位的导航栏(最顶层)
Layer 2: 主体内容
Layer 1: 背景图片
Layer 0: 页面根元素(最底层)

哪些元素会生成独立图层?

生成独立图层的触发条件:

  • position: fixed(固定定位)
  • will-change: transform(transform动画)
  • <video><canvas>元素
  • 3D变换:transform: translate3d()
  • CSS动画:@keyframes + transform
  • 加速属性:opacitytransform

浏览器会为这些元素创建独立的合成层(Compositing Layer),让它们的渲染不影响其他图层。

CSS Containment

contain属性可以告诉浏览器元素内容独立于页面其他部分,帮助浏览器优化:

.container {
  contain: content;  /* 布局、样式、绘制都独立 */
}

第六步:绘制(Paint)

分层后,每个图层内部需要绘制,生成绘制指令。

绘制顺序

浏览器按从后到前的顺序绘制各图层:

绘制顺序:
1. 背景色(最底层)
2. 背景图片
3. 边框
4. 内容(从左上到右下)
5. 伪元素
6. 轮廓(最顶层)

绘制指令

绘制不是直接画像素,而是生成绘制指令列表(Paint Records):

绘制指令示例:
1. drawRect(x=0, y=0, w=100, h=50)  矩形
2. drawText("Hello", x=10, y=30)   文字
3. drawRect(x=0, y=50, w=200, h=1)  分割线

这些指令会交给**光栅线程(Raster)**执行,将指令转换为实际像素。

重绘(Repaint)

当元素的外观改变但不影响布局时,触发重绘:

触发重绘(不改布局):改变颜色、改变可见性、改变边框样式
改变样式  重绘  完成(比回流快)

重绘比回流快,因为它不需要重新计算布局。


第七步:合成(Composite)

绘制完成后,所有图层提交给GPU,GPU将各图层合成成最终画面。

合成过程

Layer 0(背景层)
Layer 1(内容层)
Layer 2(浮动层)
    
GPU合成  输出到屏幕

为什么需要合成层?

  1. 滚动流畅:合成层有自己的GPU加速,滚动不经过主线程
  2. 动画流畅:transform/opacity动画在合成线程执行,不被JS阻塞
  3. 分离更新:只有一个图层内容变化,只需重绘该图层
传统渲染(无合成层)
└── JS修改  重排  重绘  合成  输出
    └── 主线程执行(可能被JS阻塞)

现代渲染(有合成层)
├── JS修改  重排  重绘  合成  输出
└── 合成线程独立执行(不受JS阻塞)

关键渲染路径(Critical Rendering Path)

关键渲染路径是浏览器从接收HTML到首次绘制页面的最短路径

优化关键渲染路径

想让页面更快显示?优化关键渲染路径:

优化目标 说明
减少关键资源数量 合并文件,减少请求
减少关键资源大小 压缩文件,删除注释空格
缩短关键路径长度 内联CSS、JS放底部、懒加载

回流与重绘:性能杀手

浏览器渲染过程中最怕什么?频繁的回流和重绘

强制回流/重绘

某些CSS属性和方法会强制触发回流或重绘:

// 读取以下属性会强制触发回流
element.offsetWidth;     // 布局信息
element.offsetHeight;
element.scrollTop;
element.clientWidth;
getComputedStyle(element).width;

// 修改DOM结构
element.appendChild(child);
element.removeChild(child);

批量读写原则

读写分离,避免交叉触发回流:

// 错误:每次读取触发一次回流
element.width = element.offsetWidth * 2;
element.height = element.offsetHeight * 2;
element.marginTop = element.offsetTop * 2;

// 正确:先读后写,写只触发一次回流
const width = element.offsetWidth;
const height = element.offsetHeight;
const marginTop = element.offsetTop;
element.style.width = width * 2;
element.style.height = height * 2;
element.style.marginTop = marginTop * 2;

requestAnimationFrame

对于需要连续动画的场景,使用requestAnimationFrame代替setTimeout/setInterval

// 不推荐:可能在帧之间执行
setTimeout(() => {
  element.style.transform = 'translateX(100px)';
}, 16);

// 推荐:在下一帧开始前执行
requestAnimationFrame(() => {
  element.style.transform = 'translateX(100px)';
});

总结:渲染流水线

阶段 输入 输出 耗时
HTML解析 HTML字符串 DOM树
CSS解析 CSS字符串 CSSOM树
渲染树构建 DOM + CSSOM 渲染树
布局 渲染树 盒模型信息
分层 布局信息 图层树
绘制 图层 绘制指令
合成 图层+指令 画面

核心思想:浏览器渲染页面如同装修房子——先搭骨架(DOM),再刷墙(CSS),然后布局家具位置(Layout),最后上色绘制(Paint),不同房间(Layer)可以同时施工,最后统一验收(Composite)。

理解渲染原理,才能写出性能更好的页面。


扩展阅读

概念 说明
虚拟DOM React等框架用JS对象模拟DOM,减少真实DOM操作
增量更新 只更新变化的部分,不全量重渲染
Content-visibility CSS新属性,跳过屏幕外内容的渲染
渲染性能指标 LCP(最大内容绘制)、CLS(布局偏移)、FID(首次输入延迟)

开100个标签页,为什么浏览器没崩?

你开了一个视频,又开了10个网页,再开了20个标签页...Chrome 居然没崩?而其他软件早就卡死了。Chrome是怎么做到的?

今天用**"酒店"**的故事,聊聊 Chrome 的多进程架构。


原文地址

墨渊书肆/开100个标签页,为什么浏览器没崩?


进程与线程:有什么区别?

想象一下:

进程如同一个独立的厨房,有自己的灶台、冰箱、厨师。

线程如同厨房里的厨师,多个厨师共享同一个厨房的资源——灶台是共用的,冰箱是共用的,但每个厨师可以同时干活。

进程A(独立厨房)              进程B(独立厨房)
┌─────────────────┐            ┌─────────────────┐
   厨师A1                      厨师B1       
   厨师A2                      厨师B2       
   厨师A3                      厨师B3       
                                        
 一个厨师中毒                其他厨师正常   
 其他厨师没事                继续做饭       
└─────────────────┘            └─────────────────┘

关键区别

  • 进程是"隔离的":进程A崩溃了,进程B完全不受影响
  • 线程共享资源:线程A1崩溃,可能影响整个进程A,其他线程都完蛋

Chrome多进程架构

Chrome 不像某些浏览器把所有功能塞进一个进程,而是把不同任务交给不同进程

Chrome 多进程架构:

┌─────────────────────────────────────────────────────┐
                    浏览器主进程(Browser)              
            (负责UI、地址栏、书签、下载、标签页管理)      
└─────────────────────────────────────────────────────┘
                            
            ┌───────────────┼───────────────┐
                                          
                                          
        ┌─────────┐    ┌─────────┐    ┌─────────┐
        │渲染进程1     │渲染进程2   ... │渲染进程N 
        │(Tab 1)      │(Tab 2)        │(Tab N)  
        └─────────┘    └─────────┘      └─────────┘
                                          
                                          
         GPU进程        网络进程        插件进程
进程 职责 崩溃影响
浏览器主进程(Browser) 标签页管理、地址栏、书签、下载、UI渲染 整个浏览器崩溃
渲染进程(Renderer) 运行网页内容(HTML/CSS/JS) 只影响当前标签页
GPU进程 图形渲染、视频解码、GPU加速 不影响网页渲染
网络进程(Network) 网络请求、DNS缓存、SSL验证 所有标签页断网
插件进程(Plugin) 运行浏览器插件(如Flash、PDF插件) 只影响使用该插件的页面
实用工具进程(Utility) 处理PDF阅读、扩展安装、打印等 不影响主功能

渲染进程:每个标签页一个

最重要的进程是渲染进程——每个标签页都有自己的渲染进程:

标签页1  渲染进程A(独立内存空间)
标签页2  渲染进程B(独立内存空间)
标签页3  渲染进程C(独立内存空间)
   ...
标签页100  渲染进程100(独立内存空间)

这就是为什么一个标签页崩溃不会影响其他标签页——每个渲染进程都有自己独立的内存空间,互不干扰。

为什么Chrome选择多进程?

早期浏览器(如IE、Firefox早期版本)都是单进程架构

单进程浏览器:
┌─────────────────────────────┐
  所有标签页 + UI + 插件 + JS     全在一个进程
          一个崩,全部崩         
└─────────────────────────────┘

单进程的问题:

  1. 一个标签页死循环,UI就卡死
  2. 一个标签页内存泄漏,慢慢拖垮整个浏览器
  3. 插件崩溃,浏览器跟着崩溃
  4. JS可以访问浏览器内部任意资源,安全隐患大

Chrome设计者认为:稳定性和安全性比内存占用更重要


进程间通信:IPC

不同进程之间怎么"对话"?

Chrome 使用**IPC(Inter-Process Communication,进程间通信)**机制。就像酒店房间之间不能直接串门,得通过对讲机沟通。

渲染进程(标签页1)              浏览器主进程
┌──────────────────┐         ┌──────────────────┐
  JS执行引擎                 标签页管理器    
  HTML解析器       ←───────→│  UI渲染引擎      
  CSS解析器         IPC      地址栏管理      
  DOM操作          消息通道    书签管理        
└──────────────────┘         └──────────────────┘

IPC消息类型

Chrome中主要的消息类型:

消息类型 说明 示例
ViewMsg 渲染进程→主进程 "用户点击了链接"
HandleViewMsg 主进程→渲染进程 "创建新标签页"
Route 路由消息 跨进程路由分发

IPC工作流程

点击链接时,Chrome 内部经历了:

┌───────────────────────────────────┐
 步骤1:渲染进程检测点击             
 JS事件监听器捕获 <a> 点击          
└───────────────────────────────────┘
                
                 ViewMsg_LinkOpened
                
┌───────────────────────────────────┐
 步骤2:主进程接收消息              
 决定打开新标签页                   
└───────────────────────────────────┘
                
                 HandleViewMsg_CreateWidget
                
┌───────────────────────────────────┐
 步骤3:创建新渲染进程              
 分配新内存空间,初始化V8引擎       
└───────────────────────────────────┘
                
                 Channel_LoadURL
                
┌───────────────────────────────────┐
 步骤4:新渲染进程加载URL           
 网络请求、HTML解析、渲染           
└───────────────────────────────────┘

整个过程仅需几十毫秒。


渲染进程内部:线程

每个渲染进程内部也不是单线程,而是多线程协作

渲染进程内部:

┌───────────────────────────────────────┐
            主线程(Main Thread)        
  V8 JS引擎执行                       
  HTML/CSS解析                        
  DOM树构建·布局计算·事件处理         
  requestAnimationFrame               
└───────────────────────────────────────┘
                    
        ┌───────────┴───────────┐
                               
┌──────────────┐         ┌──────────────┐
   合成线程                光栅线程     
│(Compositor)│            (Raster)   
├──────────────┤         ├──────────────┤
│• 图层合成             │• 绘制指令执行 
│• 滚动·动画           │• 像素填充     
│• 接收输入事件│         │• 纹理上传GPU 
└──────────────┘         └──────────────┘
线程 职责 为什么需要独立
主线程 JS执行、DOM、Layout、事件处理 JS必须单线程执行
合成线程 图层合成、滚动、动画 滚动必须60fps,不能等JS
光栅线程 绘制指令执行、像素填充 耗时操作,不能阻塞主线程

为什么主线程这么忙?

主线程要干太多事情:

  • JS引擎执行
  • HTML解析成DOM树
  • CSS解析成CSSOM
  • DOM + CSSOM = 渲染树
  • 布局计算每个元素位置
  • 绘制指令生成
  • 事件处理
  • 定时器回调
  • 网络回调
  • ...

这就是为什么长任务(Long Task)会卡页面——主线程太忙,用户的点击、滚动都没人处理。

合成线程的秘密

Chrome把滚动交给了合成线程处理,不经过主线程

传统方式(经过主线程):
滚动事件  主线程处理  重新布局  重绘  合成
         
       可能被JS阻塞

Chrome方式(合成线程直接处理):
滚动事件  合成线程  直接合成  输出
         
       完全不经过主线程

所以即使JS卡住了,页面滚动和动画依然流畅。


安全机制:沙箱

渲染进程为什么能"安全"地运行任意网页?

因为 Chrome 给渲染进程加了沙箱(Sandbox)——如同酒店房间:你可以用自己的东西,但不能动酒店的基础设施,也不能进别人房间。

沙箱限制:

渲染进程能做的事:
├──  执行JS(V8引擎隔离)
├──  操作DOM(沙箱内DOM树)
├──  计算样式
└──  发送网络请求(通过IPC代理)

渲染进程不能做的事:
├──  直接读写文件系统
├──  直接访问摄像头/麦克风(需用户授权)
├──  直接访问系统剪贴板(全权)
├──  直接读取本机Cookie/密码
├──  直接创建网络连接(必须经过网络进程)
└──  直接调用系统API

沙箱的技术原理

沙箱主要依赖操作系统提供的隔离机制

机制 说明
进程隔离 每个渲染进程有独立虚拟地址空间
用户权限限制 渲染进程以低权限用户运行
系统调用过滤 禁止某些危险系统调用
文件访问限制 无法访问用户文件

即使网页中的恶意代码能执行,它也被"关在笼子里",无法直接伤害你的电脑。


Site Isolation:更严格的安全

2018 年 Chrome 引入Site Isolation(站点隔离),把安全提升到新级别。

以前的规则

每个标签页一个渲染进程

标签页1  渲染进程A  可以访问标签页1的内存
标签页2  渲染进程A  可以访问标签页2的内存
                        
                   同一个进程
                   理论上可以访问彼此

现在的规则

每个跨站点的iframe也可能是独立进程

example.com 页面:
┌─────────────────────────────────────────┐
  主页面(主框架)      渲染进程A         
    ├── iframe(ads.example.com)   渲染进程B 
    ├── iframe(analytics.com)    渲染进程C 
    └── iframe(cdn.example.com)   渲染进程D 
└─────────────────────────────────────────┘
         
    进程级别完全隔离

为什么需要这么严格?

防止Spectre/Meltdown等侧信道攻击

攻击场景:
1. evil.com 运行在 渲染进程A
2. victim.com 也在 渲染进程A(作为iframe)
3. 恶意JS利用Spectre漏洞
4. 通过侧信道 timing攻击 读取渲染进程A的内存
5. 理论上可以读到 victim.com 的数据!

有了 Site Isolation,即使 evil.com 被攻破,它的渲染进程也无法访问 victim.com 的数据——因为它们根本不在同一个进程里。

Site Isolation的代价

更严格的隔离带来更高的内存占用:

情况 进程数
10个同源标签页 10个渲染进程
10个跨源标签页 可能10+个渲染进程
一个页面有5个跨站iframe 6个渲染进程

Chrome为了安全,愿意付出更多内存代价


为什么Chrome占用内存高?

很多人抱怨Chrome"吃内存"。

确实,多进程架构比单进程消耗更多内存,但这是故意的设计权衡

对比 单进程浏览器 Chrome多进程
内存占用 高(每个进程有独立内存空间)
稳定性 一个标签页崩,全部崩 一个崩,不影响其他
安全性 低(JS可以访问更多资源) 高(沙箱保护,进程隔离)
流畅度 JS卡住就卡顿 滚动动画由合成线程处理,更流畅
溃恢复 全部丢失 崩溃的标签页可以单独恢复

Chrome的内存管理优化

虽然多进程更耗内存,但Chrome也做了很多优化:

  1. 渲染进程合并:同源的多个标签页可能共享一个渲染进程
  2. 内存共享:使用**共享内存(Shared Memory)**减少复制
  3. 进程休眠:长时间未激活的标签页进程可以休眠
  4. 垃圾回收优化:V8 的垃圾回收已经高度优化

什么时候会内存爆炸?

内存爆炸场景:
├── 开100个淘宝/京东商品页(每个都有大量JS)
├── 开50个在线文档(Google Docs、Notion)
├── 开20个视频网站(爱奇艺、优酷、B站)
└── 结果:内存占用轻松上10GB

这是Chrome的"有钱任性"设计哲学——用内存换稳定性和用户体验


总结:Chrome核心知识点

概念 说明 类比
多进程架构 不同任务交给不同进程 酒店各部门分工
渲染进程 每个标签页一个,隔离运行 每人一间房
IPC通信 进程间通过消息传递协作 对讲机沟通
主线程 JS执行、DOM、Layout、事件处理 客房服务员(单线程)
合成线程 滚动、动画(不经主线程) 专属电梯(直达)
沙箱 限制渲染进程权限 房间门禁
Site Isolation 跨站iframe也隔离 同一房间的不同访客也分开
内存换稳定 多进程占用更多内存,但更安全稳定 酒店房间多,但互不干扰

核心思想:Chrome用"酒店"架构——每个房间(进程)独立,隔音好,一个房间出问题不影响其他;房间内有限制,不能动基础设施;甚至同一页面的不同访客也要隔开。

技术不复杂,但正是这套架构,让"100个网页同时运行"成为可能。

下次 Chrome 占用几百MB甚至几GB内存时,别急着骂它——那是它"有钱任性"的设计,是为了让你的浏览器更稳定、更安全、更流畅。

❌