从输入 URL 到页面渲染的完整流程
概述
"从输入 URL 到页面显示发生了什么?"是前端面试中最经典的问题之一。这个问题涵盖了网络、浏览器、渲染等多个知识领域,能够全面考察候选人的知识广度和深度。
流程总览
用户输入 URL
↓
URL 解析
↓
DNS 解析
↓
建立 TCP 连接(三次握手)
↓
TLS 握手(HTTPS)
↓
发送 HTTP 请求
↓
服务器处理请求
↓
返回 HTTP 响应
↓
浏览器解析响应
↓
构建 DOM 树
↓
构建 CSSOM 树
↓
构建渲染树
↓
布局(Layout)
↓
绘制(Paint)
↓
合成(Composite)
↓
页面显示第一阶段:URL 处理
1. 用户输入处理
javascript
// 浏览器判断输入内容
function processInput(input) {
// 1. 检查是否是合法 URL
if (isValidURL(input)) {
return input;
}
// 2. 检查是否是搜索关键词
// 如果不是 URL,使用默认搜索引擎搜索
return `https://www.google.com/search?q=${encodeURIComponent(input)}`;
}2. URL 解析
javascript
// URL 结构
const url = new URL('https://www.example.com:8080/path/page?name=value#hash');
console.log(url.protocol); // 'https:'
console.log(url.host); // 'www.example.com:8080'
console.log(url.hostname); // 'www.example.com'
console.log(url.port); // '8080'
console.log(url.pathname); // '/path/page'
console.log(url.search); // '?name=value'
console.log(url.hash); // '#hash'3. 检查缓存
浏览器按顺序检查以下缓存:
1. Service Worker 缓存
2. Memory Cache(内存缓存)
3. Disk Cache(磁盘缓存)
4. Push Cache(HTTP/2 推送缓存)javascript
// Service Worker 拦截请求示例
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
// 缓存命中,直接返回
if (response) {
return response;
}
// 缓存未命中,发起网络请求
return fetch(event.request);
})
);
});第二阶段:网络请求
1. DNS 解析
将域名解析为 IP 地址:
浏览器 DNS 缓存
↓ (未命中)
操作系统 DNS 缓存
↓ (未命中)
本地 hosts 文件
↓ (未命中)
路由器 DNS 缓存
↓ (未命中)
ISP DNS 服务器
↓ (未命中)
递归查询根域名服务器
↓
顶级域名服务器(.com)
↓
权威域名服务器
↓
返回 IP 地址javascript
// DNS 预解析优化
// <link rel="dns-prefetch" href="//example.com">
// DNS 解析时间测量
const [navigation] = performance.getEntriesByType('navigation');
console.log('DNS 解析时间:', navigation.domainLookupEnd - navigation.domainLookupStart, 'ms');2. 建立 TCP 连接(三次握手)
客户端 服务器
| |
|------- SYN=1 --------->| 第一次:客户端发送 SYN
| seq=x |
| |
|<--- SYN=1, ACK=1 ------| 第二次:服务器返回 SYN+ACK
| seq=y, ack=x+1 |
| |
|------- ACK=1 --------->| 第三次:客户端发送 ACK
| ack=y+1 |
| |
|====== 连接建立 =========|为什么是三次握手?
- 确认双方的发送和接收能力都正常
- 防止历史连接请求造成混乱
- 同步双方的初始序列号
3. TLS 握手(HTTPS)
客户端 服务器
| |
|------ Client Hello ---------->| 发送支持的加密套件
| |
|<----- Server Hello -----------| 选择加密套件
|<----- Certificate ------------| 发送证书
|<----- Server Hello Done ------|
| |
|------ Client Key Exchange --->| 发送预主密钥
|------ Change Cipher Spec ---->| 切换加密模式
|------ Finished -------------->|
| |
|<----- Change Cipher Spec -----|
|<----- Finished ---------------|
| |
|======= 加密通道建立 ===========|javascript
// 性能测量
const [navigation] = performance.getEntriesByType('navigation');
console.log('TCP 连接时间:', navigation.connectEnd - navigation.connectStart, 'ms');
console.log('TLS 握手时间:', navigation.secureConnectionStart > 0
? navigation.connectEnd - navigation.secureConnectionStart
: 0, 'ms');4. 发送 HTTP 请求
http
GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0
Accept: text/html,application/xhtml+xml
Accept-Language: zh-CN,zh;q=0.9
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: session_id=abc1235. 服务器处理与响应
http
HTTP/1.1 200 OK
Date: Thu, 01 Jan 2024 00:00:00 GMT
Server: nginx/1.18.0
Content-Type: text/html; charset=utf-8
Content-Length: 1234
Content-Encoding: gzip
Cache-Control: max-age=3600
ETag: "abc123"
<!DOCTYPE html>
<html>...6. TCP 四次挥手(连接关闭)
客户端 服务器
| |
|------- FIN=1 --------->| 第一次:客户端请求关闭
| seq=u |
| |
|<------ ACK=1 ----------| 第二次:服务器确认
| ack=u+1 |
| |
|<------ FIN=1 ----------| 第三次:服务器请求关闭
| seq=v |
| |
|------- ACK=1 --------->| 第四次:客户端确认
| ack=v+1 |
| |
|====== 连接关闭 =========|为什么是四次挥手?
- TCP 是全双工,需要双向都关闭
- 服务器可能还有数据要发送
第三阶段:浏览器解析
1. 构建 DOM 树
字节 (Bytes)
↓
字符 (Characters)
↓
令牌 (Tokens)
↓
节点 (Nodes)
↓
DOM 树html
<!-- 输入 HTML -->
<!DOCTYPE html>
<html>
<head>
<title>页面标题</title>
</head>
<body>
<div class="container">
<p>Hello World</p>
</div>
</body>
</html>javascript
// DOM 树结构
Document
├── DOCTYPE: html
└── html
├── head
│ └── title
│ └── #text: 页面标题
└── body
└── div.container
└── p
└── #text: Hello World2. 构建 CSSOM 树
css
/* 输入 CSS */
body { font-size: 16px; }
.container { width: 100%; }
p { color: blue; }javascript
// CSSOM 树结构
StyleSheetList
└── CSSStyleSheet
└── CSSRuleList
├── CSSStyleRule: body { font-size: 16px }
├── CSSStyleRule: .container { width: 100% }
└── CSSStyleRule: p { color: blue }3. JavaScript 执行
html
<!-- 同步脚本会阻塞解析 -->
<script src="app.js"></script>
<!-- defer: 延迟到 DOM 解析完成后执行,按顺序执行 -->
<script defer src="app.js"></script>
<!-- async: 加载完立即执行,不保证顺序 -->
<script async src="analytics.js"></script> HTML 解析
|
┌───────┴───────┐
| |
同步 script defer/async
| |
阻塞 HTML 解析 不阻塞 HTML 解析
| |
执行完毕 defer: DOMContentLoaded 前执行
| async: 加载完立即执行
继续解析4. 构建渲染树(Render Tree)
javascript
// 渲染树 = DOM 树 + CSSOM 树
// 特点:
// 1. 只包含可见元素
// 2. display: none 的元素不在渲染树中
// 3. visibility: hidden 的元素在渲染树中(占位但不显示)
// 4. 伪元素(::before, ::after)在渲染树中
RenderTree
└── RenderView
└── RenderBody
└── RenderBlock (div.container)
└── RenderBlock (p)
└── RenderText: "Hello World"第四阶段:渲染流程
1. 布局(Layout / Reflow)
计算每个元素的几何信息(位置、大小):
javascript
// 布局计算的信息
{
element: 'div.container',
x: 0,
y: 0,
width: 1920,
height: 500,
children: [
{
element: 'p',
x: 0,
y: 0,
width: 1920,
height: 20
}
]
}触发重排的操作:
javascript
// 这些操作会触发重排
element.offsetHeight; // 读取几何属性
element.style.width = '100px'; // 修改几何属性
element.className = 'new-class'; // 修改样式
window.getComputedStyle(element); // 获取计算样式
// 优化:批量修改样式
// 不好
element.style.width = '100px';
element.style.height = '100px';
element.style.margin = '10px';
// 好
element.style.cssText = 'width: 100px; height: 100px; margin: 10px';
// 或使用 class
element.className = 'new-styles';2. 绘制(Paint)
将渲染树转换为屏幕上的像素:
javascript
// 绘制顺序(Stacking Context)
// 1. 背景颜色
// 2. 背景图片
// 3. 边框
// 4. 子元素
// 5. 轮廓(outline)
// 绘制分层
// - 某些属性会创建新的图层
// - transform, opacity, will-change 等触发重绘的操作:
javascript
// 只触发重绘,不触发重排
element.style.color = 'red';
element.style.backgroundColor = 'blue';
element.style.visibility = 'hidden';3. 合成(Composite)
将多个图层合成为最终图像:
javascript
// 创建合成层的属性
// - transform: translateZ(0)
// - will-change: transform
// - position: fixed
// - video, canvas, iframe 等
// 合成层的优点
// 1. 独立于主线程
// 2. GPU 加速
// 3. 只重绘当前层
// 使用 will-change 提示浏览器
.animated-element {
will-change: transform;
}4. 关键渲染路径优化
html
<!-- 优化关键渲染路径 -->
<!-- 1. 关键 CSS 内联 -->
<style>
/* 首屏关键样式 */
.header { ... }
.hero { ... }
</style>
<!-- 2. 非关键 CSS 异步加载 -->
<link rel="preload" href="non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<!-- 3. JavaScript 延迟加载 -->
<script defer src="app.js"></script>
<!-- 4. 预加载关键资源 -->
<link rel="preload" href="critical-font.woff2" as="font" type="font/woff2" crossorigin>性能指标
关键性能指标
javascript
// 使用 Performance API 测量
const timing = performance.timing;
// DNS 解析时间
const dnsTime = timing.domainLookupEnd - timing.domainLookupStart;
// TCP 连接时间
const tcpTime = timing.connectEnd - timing.connectStart;
// 请求响应时间
const requestTime = timing.responseEnd - timing.requestStart;
// DOM 解析时间
const domParseTime = timing.domComplete - timing.domLoading;
// 首次渲染时间
const firstPaint = timing.domContentLoadedEventEnd - timing.navigationStart;
// 页面完全加载时间
const loadTime = timing.loadEventEnd - timing.navigationStart;
// 使用 PerformanceObserver
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name}: ${entry.startTime}ms`);
}
});
observer.observe({ entryTypes: ['paint', 'largest-contentful-paint'] });Web Vitals
javascript
// LCP (Largest Contentful Paint) - 最大内容绘制
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// FID (First Input Delay) - 首次输入延迟
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
entries.forEach(entry => {
console.log('FID:', entry.processingStart - entry.startTime);
});
}).observe({ type: 'first-input', buffered: true });
// CLS (Cumulative Layout Shift) - 累计布局偏移
let clsValue = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
console.log('CLS:', clsValue);
}).observe({ type: 'layout-shift', buffered: true });完整流程图
┌─────────────────────────────────────────────────────────────────────┐
│ 用户输入 URL │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ URL 解析 │
│ 解析协议、域名、端口、路径、查询参数 │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 检查缓存 │
│ Service Worker → Memory Cache → Disk Cache → Push Cache │
└────────────────────────────────┬────────────────────────────────────┘
│ 缓存未命中
▼
┌─────────────────────────────────────────────────────────────────────┐
│ DNS 解析 │
│ 浏览器缓存 → 系统缓存 → hosts → 路由器 → ISP → 递归查询 │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 建立 TCP 连接 │
│ 三次握手 │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ TLS 握手(HTTPS) │
│ 协商加密套件,交换密钥 │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 发送 HTTP 请求 │
│ 请求行 + 请求头 + 请求体 │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 服务器处理请求 │
│ 负载均衡 → Web 服务器 → 应用服务器 → 数据库 │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 返回 HTTP 响应 │
│ 状态行 + 响应头 + 响应体 │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 浏览器解析 │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 构建 DOM 树 │ │ 构建 CSSOM 树│ │
│ └──────┬───────┘ └──────┬───────┘ │
│ └────────┬──────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ 构建渲染树 │ │
│ └──────┬───────┘ │
│ │ │
└────────────────┼────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 渲染流程 │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ 布局 │ → │ 绘制 │ → │ 合成 │ │
│ │(Layout)│ │(Paint) │ │(Comp.) │ │
│ └────────┘ └────────┘ └────────┘ │
└────────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 页面显示 │
└─────────────────────────────────────────────────────────────────────┘面试回答模板
简短版(1-2分钟)
从输入 URL 到页面显示,主要经历以下步骤:
1. URL 解析:解析协议、域名、路径等
2. DNS 解析:将域名解析为 IP 地址
3. 建立连接:TCP 三次握手,HTTPS 还需要 TLS 握手
4. 发送请求:发送 HTTP 请求到服务器
5. 服务器响应:返回 HTML 内容
6. 解析渲染:
- 构建 DOM 树和 CSSOM 树
- 合并为渲染树
- 布局计算位置大小
- 绘制和合成
7. 显示页面详细版(展开每个环节的细节)
可以根据面试官的追问,详细展开:
- DNS 解析的层级缓存
- TCP 三次握手的具体过程
- 浏览器的多进程架构
- 渲染过程中的重排重绘
- 各种优化手段
常见面试追问
1. 为什么 DNS 解析需要递归查询?
因为 DNS 是分层设计的,没有一个服务器知道所有域名。从根域名服务器开始,逐级查询到最终的权威服务器。
2. TCP 为什么是三次握手而不是两次?
两次握手无法确认客户端的接收能力,可能导致服务器资源浪费。三次握手能确保双方的发送和接收能力都正常。
3. CSS 和 JavaScript 如何影响渲染?
- CSS 不阻塞 DOM 解析,但阻塞渲染
- JavaScript 阻塞 DOM 解析(除非 async/defer)
- 脚本前的 CSS 会阻塞脚本执行
4. 如何优化关键渲染路径?
- 内联关键 CSS
- 异步加载非关键 CSS
- 使用 defer/async 加载 JavaScript
- 减少关键资源数量和大小
- 使用预加载(preload)和预连接(preconnect)
5. 重排和重绘的区别?
- 重排:几何属性变化,需要重新计算布局,开销大
- 重绘:样式变化但不影响布局,开销相对小
- 重排必定触发重绘,重绘不一定触发重排
总结
理解从 URL 到页面渲染的完整流程,需要掌握:
- 网络层面:DNS、TCP/IP、HTTP/HTTPS
- 浏览器层面:多进程架构、渲染引擎
- 解析层面:HTML、CSS、JavaScript 解析
- 渲染层面:渲染树、布局、绘制、合成
- 优化层面:缓存策略、关键渲染路径优化
这个问题能够串联起前端开发中的大部分知识点,是理解 Web 工作原理的重要基础。