侧边栏壁纸
博主头像
踏浪而行生活圈

行动起来,活在当下

  • 累计撰写 22 篇文章
  • 累计创建 7 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

从按下回车到屏幕亮起,你的浏览器到底经历了什么?

我们每天打开浏览器,输入一个网址,然后页面就"唰"地出现了。这个过程太快了,快到我们根本不会去想中间发生了什么。

但如果你真的把这几秒钟拆开来看——从你按下回车的那一刻起,到屏幕上第一个像素点亮——浏览器至少要经历 六个大的阶段,涉及到 DNS、TCP、TLS、HTTP、HTML 解析、CSS 构建、JavaScript 执行、布局、绘制、合成……每一个阶段都有值得聊的故事。

我第一次系统性地梳理这条流水线的时候,感觉像是拆开了一台精密的手表——里面的齿轮咬合之精密,让人对浏览器工程师肃然起敬。

搜索


第一站:DNS 解析 —— "请问 www.example.com 住哪?"

你在地址栏输入 www.example.com,浏览器首先要做的一件事就是:把这个域名翻译成 IP 地址

这就好比你给朋友寄快递,只知道他叫"张三",但快递员需要知道他住哪儿。DNS 就是互联网世界的"电话簿"。

这个过程是这样的:

浏览器:我查一下 www.example.com 对应哪个 IP
    ↓
浏览器本地缓存:有吗? → 有,直接用
    ↓
操作系统缓存:有吗? → 有,返回
    ↓
路由器缓存:有吗? → 有,返回
    ↓
ISP 的 DNS 服务器:有吗? → 没有,我帮你问
    ↓
根 DNS 服务器 → .com 服务器 → example.com 权威服务器
    ↓
找到了!→ 93.184.216.34

你可能注意到了,这中间有好几级缓存。这其实是 DNS 设计的精妙之处——如果每次都从根服务器查起,互联网早就瘫痪了。

但问题是,DNS 查询是明文传输的。什么意思?你在查什么网站,沿途的路由器、ISP 都看得到。

有一次我在咖啡店调试代码,发现 DNS 查询居然走了整整 5 跳才拿到结果,延迟高得离谱。后来换成 DoH(DNS over HTTPS),查询速度直接快了一倍——不仅因为加密了,还因为走了更优的解析路径。

// 如果你想在代码里手动做 DNS 解析(Node.js)
const dns = require('dns');
dns.lookup('www.example.com', (err, address) => {
  console.log('IP 地址:', address); // 93.184.216.34
});

第二站:TCP 三次握手 —— "喂,听得到吗?"

拿到 IP 地址后,浏览器就要跟服务器建立连接了。用的是 TCP 协议,通过经典的"三次握手":

浏览器 → 服务器:SYN(嘿,你在吗?)
服务器 → 浏览器:SYN+ACK(在的,你呢?)
浏览器 → 服务器:ACK(我也在,开始传数据吧)

这个过程看起来简单,但在高延迟网络下(比如跨洋连接),每次握手可能要几十甚至上百毫秒。一个页面可能需要连接多个域名(CDN、API、第三方脚本),每个域名都要走一遍握手——这就是为什么有时候"明明网速很快,但网页打开很慢"。

HTTPS 还要多一步:TLS 握手

如果网站用了 HTTPS(现在大部分都是了),握手之后还有一层 TLS 握手:

浏览器 → 服务器:我支持这些加密算法,你选一个
服务器 → 浏览器:好的,这是我的证书,你验证一下
浏览器:(验证证书有效性……)
浏览器 → 服务器:验证通过,这是对称密钥,以后咱就用这个加密通信

TLS 1.3 把这个过程优化到了 1-RTT(一个往返),甚至支持 0-RTT 恢复之前的会话。但如果是老版本的 TLS 1.2,可能要 2-3 个往返才能完成。

我记得以前优化一个接口的首次加载时间,排查了半天发现瓶颈不在代码,而在 TLS 握手上——服务器用的 TLS 1.2 + RSA 密钥交换,光握手就花了 200ms。升级到 TLS 1.3 + ECDHE 之后,直接砍掉了一半。有时候性能优化就是这样,你以为是代码的问题,其实是协议的问题。

5w36nr7jw1.jpg

第三站:HTTP 请求 —— "把页面给我"

连接建立后,浏览器终于可以发 HTTP 请求了:

GET /index.html HTTP/2
Host: www.example.com
Accept: text/html
Accept-Encoding: gzip, br
Cookie: session_id=abc123

服务器收到请求后,经过后端处理(数据库查询、模板渲染、业务逻辑……),返回响应:

HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Cache-Control: max-age=3600

<!DOCTYPE html>
<html>...

这里有几个值得说的点:

HTTP/2 的多路复用:HTTP/1.1 时代,浏览器对同一域名最多只能开 6 个 TCP 连接。要想并发更多请求,开发者搞出了"域名分片"这种 hack——把资源分散到 a.cdn.comb.cdn.comc.cdn.com。HTTP/2 支持多路复用后,一个连接就能跑所有请求,这个 hack 就不必要了。

HTTP/3 走的是 QUIC 协议:基于 UDP,彻底告别了 TCP 队头阻塞的问题。在弱网环境下提升尤其明显。不过说实话,目前 HTTP/3 的普及率还不算特别高,很多 CDN 只是选择性支持。

压缩的重要性Content-Encoding: gzipbr(Brotli)能把 HTML 文件体积压缩到原来的 1/5 甚至 1/10。我曾经见过一个没开压缩的接口,返回了 2MB 的 JSON 数据——打开 gzip 之后瞬间变成 200KB,加载时间从 3 秒降到 300ms。

第四站:解析与构建 —— "这堆标签是什么意思?"

浏览器收到 HTML 文件后,真正的"重活"才开始。

HTML 解析 → DOM 树

浏览器把 HTML 文本解析成一棵 DOM 树(Document Object Model)。这个过程是流式的——浏览器不需要等整个 HTML 下载完才开始解析,而是边下载边解析。

<html>
  <head>
    <title>My Page</title>
  </head>
  <body>
    <div>
      <p>Hello</p>
    </div>
  </body>
</html>

解析后的 DOM 树大致是这样的:

Document
 └── html
      ├── head
      │    └── title
      │         └── "My Page"
      └── body
           └── div
                └── p
                     └── "Hello"

但 DOM 树构建过程中有两个"拦路虎":

1. CSS 文件阻塞渲染

当解析器遇到 <link rel="stylesheet"> 时,会暂停 DOM 构建,等待 CSS 下载和解析完成。因为浏览器需要知道元素的样式才能正确渲染页面。

这就是为什么你经常听到"CSS 放在 <head> 里"的建议——不是放哪都行的,位置不对会阻塞渲染。

2. JavaScript 阻塞解析

<script> 标签默认会阻塞 HTML 解析(除非加了 asyncdefer)。因为 JavaScript 可能会通过 document.write() 修改 DOM,浏览器不得不暂停下来等它执行完。

<!-- 阻塞解析 -->
<script src="app.js"></script>

<!-- 下载不阻塞,加载完后执行(顺序不保证) -->
<script src="app.js" async></script>

<!-- 下载不阻塞,DOM 解析完后按顺序执行 -->
<script src="app.js" defer></script>

我以前在团队里做过一个 review,发现有个同事把一个 500KB 的 <script> 放在了 <head> 里——没有 async、没有 defer。页面白屏了整整 2 秒才开始渲染。加了个 defer 之后,白屏时间直接归零。

教训:永远不要低估一个标签的位置对性能的影响。

CSS 解析 → CSSOM 树

跟 DOM 类似,CSS 文件也会被解析成一棵树——CSSOM(CSS Object Model)。

Stylesheet
 ├── body { font-size: 16px; color: #333; }
 │    └── font-size: 16px
 │    └── color: #333
 ├── .container { max-width: 1200px; margin: 0 auto; }
 │    └── max-width: 1200px
 │    └── margin: 0 auto
 └── p { line-height: 1.6; }
      └── line-height: 1.6

CSS 解析有个特殊之处:它是阻塞渲染的。因为没有 CSSOM,浏览器不知道该把元素画成什么样——可能一个 <div> 要显示成蓝色 24px,也可能显示成红色 12px。

而且 CSS 解析有一个"级联"规则:同一个元素可能匹配多条 CSS 规则,浏览器要按优先级(specificity)计算出最终样式。这个过程叫 样式计算(Style Computation)

如果你的 CSS 里有大量的通配符选择器(*)或者深层嵌套选择器(.a .b .c .d .e .f p),样式计算的时间会显著增加。不过说实话,在现代浏览器引擎里,CSS 选择器的性能差异已经非常小了,真正拖后腿的往往是CSS 文件本身的体积

第五站:渲染流水线 —— "现在来画东西"

DOM 和 CSSOM 都有了,浏览器终于可以开始"画"了。这个过程是一条流水线,每一步都有明确的分工:

DOM + CSSOM → Render Tree(渲染树)→ Layout(布局)→ Paint(绘制)→ Composite(合成)

1. Render Tree(渲染树)

把 DOM 和 CSSOM 合并,生成一棵只包含可见元素的渲染树。display: none 的元素不会出现在渲染树里,但 visibility: hidden 的元素会(因为它仍然占空间)。

2. Layout(布局/回流)

计算每个元素在页面上的精确位置和大小。这一步是 CPU 密集型的,因为浏览器要递归遍历整棵树,计算每个元素的 x、y、width、height。

触发回流的常见操作:

// 这些操作都会触发回流!
element.offsetWidth;           // 读取布局属性
element.style.width = '100px'; // 修改几何属性
element.classList.add('new');   // 可能改变布局的类名
window.scrollTo(0, 100);       // 滚动

回流是性能杀手。一个回流可能会连带触发整棵渲染树的重新计算。

3. Paint(绘制/重绘)

把每个元素"画"到图层上——填充颜色、画边框、写文字、加阴影……这一步是 GPU 友好的,浏览器通常会把绘制任务交给 GPU 来加速。

触发重绘但不触发回流的操作:

// 只改变颜色,不影响布局
element.style.color = 'red';
element.style.backgroundColor = '#f0f0f0';
element.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';

4. Composite(合成)

最后一步,把所有图层按正确的顺序合成为最终画面,输出到屏幕上。

现代浏览器通常会把页面分成多个 合成层(Compositing Layers),比如:

  • transformopacity 动画的元素
  • will-change 声明的元素
  • position: fixed 的元素
  • <video><canvas> 等特殊元素

每个层独立交给 GPU 处理,互不影响。这就是为什么 transform: translateX() 动画比 left: 100px 动画更流畅——前者只触发合成,后者触发回流 + 重绘 + 合成。

/* 好:只触发合成,GPU 加速 */
.animate {
  transition: transform 0.3s ease;
}
.animate:hover {
  transform: translateX(100px);
}

/* 差:触发回流,CPU 重排 */
.animate {
  transition: left 0.3s ease;
  position: relative;
}
.animate:hover {
  left: 100px;
}

渲染流水线

第六站:JavaScript 的"插曲"

渲染流水线看起来是一条直线,但现实远没那么美好。因为 JavaScript 随时可能跳出来"捣乱"。

浏览器有一个专门的线程叫 主线程(Main Thread),它同时负责:

  • 解析 HTML
  • 执行 JavaScript
  • 运行渲染流水线

没错,这三个操作共享同一个线程。所以当 JavaScript 在执行的时候,渲染就得等着。这就是为什么复杂的 JavaScript 计算会让页面"卡顿"。

// 这段代码会阻塞主线程 5 秒
const start = Date.now();
while (Date.now() - start < 5000) {
  // 空转,啥也不干
}
// 这 5 秒内,页面是完全卡死的——你点不动、滚不动、看不到任何动画

为了缓解这个问题,浏览器提供了 requestAnimationFrame(在下一帧渲染前执行)和 requestIdleCallback(在主线程空闲时执行):

// 适合做动画更新——在每帧渲染前同步执行
requestAnimationFrame(() => {
  element.style.transform = `translateX(${pos}px)`;
});

// 适合做非紧急任务——在主线程空闲时执行
requestIdleCallback(() => {
  // 发送统计数据、预加载资源等低优先级任务
  analytics.report();
});

说到这儿我必须吐槽一下——我以前见过有人用 setInterval 做动画,每 16ms 触发一次,以为能跑到 60fps。但实际上 setInterval 跟渲染完全不在一个节拍上,经常出现掉帧、撕裂。换成 requestAnimationFrame 之后,动画丝滑了,CPU 占用还降了一半。

很多时候,性能优化不是让代码跑得更快,而是让代码在正确的时间跑。


一条完整的时间线

让我们把所有步骤串起来,看看一次完整的页面加载要经过什么:

按下回车
  │
  ├── DNS 解析(10-100ms)
  │     本地缓存 → 系统缓存 → 路由器 → ISP → 权威 DNS
  │
  ├── TCP 三次握手(1-2 RTT)
  │     SYN → SYN+ACK → ACK
  │
  ├── TLS 握手(1-2 RTT,HTTPS 才有)
  │     协商加密算法 → 验证证书 → 交换密钥
  │
  ├── HTTP 请求/响应
  │     GET /index.html → 200 OK + HTML 内容
  │
  ├── HTML 解析 → DOM 树(流式,边下载边解析)
  │     遇到 <link> → 下载 CSS → 解析 CSSOM
  │     遇到 <script> → 可能阻塞解析
  │
  ├── Render Tree → Layout → Paint → Composite
  │     合并 DOM + CSSOM → 计算位置 → 绘制 → 合成
  │
  └── 首次渲染(First Paint / FCP)
        用户终于看到了内容!

整个过程在现代网络下通常在 1-3 秒内完成。但如果你的服务器在地球另一端、用的是 HTTP/1.1、没有开压缩、JS 文件还放在 <head> 里没有 defer……那 10 秒都算快的。

那些"应该知道但经常忘"的优化点

既然聊到了渲染流水线,顺手总结几个实战中特别管用的优化技巧:

1. 关键渲染路径优化

核心思想:让首次渲染需要的资源越少越好、越早到达越好。

<!-- 内联关键 CSS,减少一次网络请求 -->
<style>
  /* 只放首屏渲染需要的最小 CSS */
  body { font-family: system-ui; }
  .hero { height: 100vh; background: #000; }
</style>

<!-- 异步加载非关键 CSS -->
<link rel="preload" href="/styles.css" as="style" onload="this.rel='stylesheet'">

<!-- JS 加 defer,不阻塞解析 -->
<script src="/app.js" defer></script>

2. 减少回流的"批处理"

// ❌ 每次修改都触发回流
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';

// ✅ 用 class 一次性修改
element.classList.add('resized');

// ✅ 或者用 cssText
element.style.cssText = 'width: 100px; height: 200px; margin: 10px;';

// ✅ 或者用 DocumentFragment 批量操作 DOM
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  fragment.appendChild(li);
}
list.appendChild(fragment); // 只触发一次回流

3. 图片懒加载

<!-- 原生懒加载,零 JS -->
<img src="photo.jpg" loading="lazy" alt="风景照">

一行 loading="lazy",就能让视口外的图片延迟加载,首屏性能直接起飞。

4. 预连接关键域名

<!-- 提前完成 DNS + TCP + TLS 握手 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

这个技巧我用得特别多。比如你的页面要从 Google Fonts 加载字体,加一行 preconnect,字体加载时间能减少 100-300ms。

个人感悟

写到这里,我想说几句可能"不太技术"的话。

浏览器的渲染流水线是人类工程学的一个奇迹。你想想看——从一个 URL 字符串开始,经过网络传输、协议协商、语法解析、样式计算、布局排版、像素渲染……在几秒钟内把一个复杂的网页呈现在你面前,中间经历了多少个学科的交叉(网络、编译原理、图形学、操作系统)。

我做前端这些年,越来越觉得:那些"显而易见"的东西往往最难理解。

页面为什么会白屏?JavaScript 为什么会卡顿?CSS 放在哪里有区别?这些问题看起来简单,但真正理解了渲染流水线之后,你会发现每一个"为什么"背后都有一个完整的工程故事。

所以如果你问我前端最值得学的基础知识是什么,我的答案不是 Vue、不是 React、不是某个框架——而是浏览器本身。理解了浏览器怎么工作,框架只是工具;不理解浏览器,框架就是魔法。

代码之美


推荐的探索工具

如果你想亲眼"看到"浏览器渲染的过程,推荐几个工具:

  • Chrome DevTools → Performance 面板:录制一次页面加载,能看到完整的渲染时间线
  • Chrome DevTools → Network 面板:查看每个请求的 DNS、TCP、TLS 耗时
  • WebPageTest:在线性能测试,能看到渲染的视频回放
  • Lighthouse:Chrome 内置的性能审计工具
  • v8.dev/blog:V8 引擎的官方博客,JavaScript 性能内幕

参考与致谢

本文在写作过程中参考了以下资料:

🙏 特别感谢所有为 Web 性能标准和浏览器引擎做出贡献的开发者们。Web 之所以能成为最开放的平台,离不开这群人的默默耕耘。


往期精彩:

未登录也能知道你是谁?浏览器指纹了解一下!

小米深夜放了个"核弹":三大自研 MiMo-V2模型,这次真要"为发烧而生"

腾讯推出QClaw:微信远程操控电脑,本地AI助手开启内测

扫码_搜索联合传播样式-标准色版-fd2n.png

0

评论区