从输入 URL 到页面展示
问题
从输入 URL 到页面展示,这中间发生了什么?
答案
这是一个超高频面试题,涵盖了网络、浏览器、渲染等多个知识点。整个过程可以分为以下几个阶段:
完整流程概览
阶段一:URL 解析与处理
1.1 用户输入处理
当用户在地址栏输入内容时,浏览器进程的 UI 线程会判断输入内容:
function handleUserInput(input: string): void {
if (isValidURL(input)) {
// 是有效 URL,准备导航
navigateTo(input);
} else {
// 不是有效 URL,使用搜索引擎
const searchURL = `https://www.google.com/search?q=${encodeURIComponent(input)}`;
navigateTo(searchURL);
}
}
function isValidURL(input: string): boolean {
// 检查是否符合 URL 格式
// 包含协议(http://、https://)
// 或者是有效域名(example.com)
const urlPattern = /^(https?:\/\/)?[\w\-]+(\.[\w\-]+)+/i;
return urlPattern.test(input);
}
1.2 URL 结构解析
https://www.example.com:443/path/page.html?name=test&id=1#section
│ │ │ │ │ │
│ │ │ │ │ └── Fragment(片段标识符)
│ │ │ │ └── Query String(查询参数)
│ │ │ └── Path(路径)
│ │ └── Port(端口)
│ └── Host(主机名)
└── Protocol(协议)
interface URLComponents {
protocol: string; // 'https:'
host: string; // 'www.example.com:443'
hostname: string; // 'www.example.com'
port: string; // '443'
pathname: string; // '/path/page.html'
search: string; // '?name=test&id=1'
hash: string; // '#section'
}
// 使用 URL API 解析
const url = new URL('https://www.example.com:443/path/page.html?name=test#section');
console.log(url.hostname); // 'www.example.com'
console.log(url.pathname); // '/path/page.html'
1.3 检查 HSTS(HTTP Strict Transport Security)
浏览器会检查该域名是否在 HSTS 预加载列表中,如果是,则强制使用 HTTPS:
// HSTS 检查逻辑
function checkHSTS(hostname: string): boolean {
// 1. 检查 HSTS 预加载列表
if (HSTS_PRELOAD_LIST.includes(hostname)) {
return true;
}
// 2. 检查之前响应头中的 Strict-Transport-Security
const cachedHSTS = hstsCache.get(hostname);
if (cachedHSTS && cachedHSTS.maxAge > Date.now()) {
return true;
}
return false;
}
阶段二:DNS 解析
2.1 DNS 解析流程
DNS(Domain Name System) 将域名解析为 IP 地址。
2.2 DNS 缓存层级
| 层级 | 位置 | 缓存时间 |
|---|---|---|
| 浏览器缓存 | Chrome 等浏览器内部 | 约 1 分钟 |
| 操作系统缓存 | 系统 DNS 客户端 | 由 TTL 决定 |
| hosts 文件 | 本地配置文件 | 永久(手动更新) |
| 本地 DNS 服务器 | 路由器/ISP DNS | 由 TTL 决定 |
| 权威 DNS 服务器 | 域名注册商配置 | 源数据 |
# 查看 DNS 解析过程
nslookup www.example.com
# macOS 刷新 DNS 缓存
sudo dscacheutil -flushcache
# 查看 hosts 文件
cat /etc/hosts
2.3 DNS 记录类型
| 类型 | 说明 | 示例 |
|---|---|---|
| A | 域名到 IPv4 地址 | example.com -> 93.184.216.34 |
| AAAA | 域名到 IPv6 地址 | example.com -> 2606:2800:... |
| CNAME | 域名别名 | www.example.com -> example.com |
| MX | 邮件服务器 | example.com -> mail.example.com |
| NS | 域名服务器 | example.com -> ns1.example.com |
| TXT | 文本记录 | 用于 SPF、DKIM 等验证 |
2.4 DNS 优化
<!-- DNS 预解析 -->
<link rel="dns-prefetch" href="//api.example.com">
<link rel="dns-prefetch" href="//cdn.example.com">
<!-- 预连接(包含 DNS + TCP + TLS) -->
<link rel="preconnect" href="https://api.example.com">
阶段三:建立 TCP 连接
3.1 三次握手
TCP 是面向连接的协议,需要通过三次握手建立连接:
| 步骤 | 客户端 | 服务器 | 说明 |
|---|---|---|---|
| 第一次握手 | 发送 SYN | 接收 | 客户端请求建立连接 |
| 第二次握手 | 接收 | 发送 SYN+ACK | 服务器确认并请求连接 |
| 第三次握手 | 发送 ACK | 接收 | 客户端确认,连接建立 |
- 两次不够:服务器无法确认客户端收到了响应
- 四次没必要:第二次握手可以合并 SYN 和 ACK
- 核心目的:确认双方的发送和接收能力都正常
3.2 四次挥手(连接关闭)
TCP 是全双工通信,双方都可以发送数据。关闭连接时需要分别关闭两个方向的通道,因此需要四次挥手。
阶段四:TLS 握手(HTTPS)
如果是 HTTPS 请求,TCP 连接建立后还需要进行 TLS 握手:
4.1 TLS 1.2 握手流程
4.2 TLS 1.3 优化(1-RTT)
TLS 1.3 将握手从 2-RTT 减少到 1-RTT:
4.3 连接复用优化
| 技术 | 说明 | 效果 |
|---|---|---|
| HTTP Keep-Alive | TCP 连接复用 | 避免重复三次握手 |
| TLS Session Resumption | TLS 会话复用 | 避免重复 TLS 握手 |
| HTTP/2 | 多路复用 | 单连接并行请求 |
| HTTP/3 (QUIC) | 基于 UDP | 0-RTT 连接建立 |
阶段五:发送 HTTP 请求
5.1 构建 HTTP 请求
GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: session_id=abc123
Cache-Control: no-cache
5.2 请求方法
| 方法 | 说明 | 幂等性 | 请求体 |
|---|---|---|---|
| GET | 获取资源 | ✅ | ❌ |
| POST | 创建资源 | ❌ | ✅ |
| PUT | 更新资源(完整替换) | ✅ | ✅ |
| PATCH | 更新资源(部分更新) | ❌ | ✅ |
| DELETE | 删除资源 | ✅ | ❌ |
| HEAD | 获取响应头 | ✅ | ❌ |
| OPTIONS | 预检请求 | ✅ | ❌ |
阶段六:服务器处理请求
6.1 服务器处理流程
6.2 HTTP 响应
HTTP/1.1 200 OK
Date: Thu, 13 Feb 2026 10:00:00 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 1234
Content-Encoding: gzip
Cache-Control: max-age=3600
ETag: "abc123"
Connection: keep-alive
<!DOCTYPE html>
<html>
...
</html>
6.3 常见状态码
| 状态码 | 含义 | 说明 |
|---|---|---|
| 200 | OK | 请求成功 |
| 301 | Moved Permanently | 永久重定向 |
| 302 | Found | 临时重定向 |
| 304 | Not Modified | 资源未修改(协商缓存命中) |
| 400 | Bad Request | 请求语法错误 |
| 401 | Unauthorized | 需要身份验认证 |
| 403 | Forbidden | 拒绝访问 |
| 404 | Not Found | 资源不存在 |
| 500 | Internal Server Error | 服务器内部错误 |
| 502 | Bad Gateway | 网关错误 |
| 503 | Service Unavailable | 服务不可用 |
阶段七:浏览器解析渲染
7.1 渲染流程概览
7.2 HTML 解析 → DOM 树
// HTML 解析过程
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Example</title>
</head>
<body>
<div id="app">
<h1>Hello</h1>
<p>World</p>
</div>
</body>
</html>
`;
// 解析为 DOM 树
// Document
// └── html
// ├── head
// │ └── title
// │ └── "Example"
// └── body
// └── div#app
// ├── h1
// │ └── "Hello"
// └── p
// └── "World"
7.3 CSS 解析 → CSSOM 树
// CSS 解析过程
const css = `
body { font-size: 16px; }
#app { margin: 20px; }
h1 { color: red; }
p { color: blue; }
`;
// 解析为 CSSOM 树,并计算样式
// 每个节点包含 computed style
7.4 构建 Render 树
- 不包含不可见元素:
display: none的元素不会出现在 Render 树中 - 包含伪元素:
::before、::after会出现在 Render 树中 - head 标签不渲染:
<head>及其子元素不会出现在 Render 树中
7.5 Layout(布局/重排)
计算每个元素的精确位置和大小:
interface LayoutBox {
x: number; // 相对于视口的 x 坐标
y: number; // 相对于视口的 y 坐标
width: number; // 元素宽度
height: number; // 元素高度
}
// 获取元素的布局信息
const element = document.getElementById('app');
const rect = element.getBoundingClientRect();
console.log(rect); // { x: 20, y: 20, width: 500, height: 300, ... }
7.6 Paint(绑制)
将布局信息转换为绑制指令:
// 绑制指令示例
const paintCommands = [
{ type: 'drawRect', x: 0, y: 0, width: 1200, height: 800, color: 'white' },
{ type: 'drawRect', x: 20, y: 20, width: 500, height: 300, color: 'transparent' },
{ type: 'drawText', x: 20, y: 50, text: 'Hello', color: 'red', font: '32px Arial' },
{ type: 'drawText', x: 20, y: 100, text: 'World', color: 'blue', font: '16px Arial' },
];
7.7 Composite(合成)
将不同图层合成为最终画面:
会创建新图层的情况:
| 触发条件 | 说明 |
|---|---|
will-change | 显式提示浏览器 |
transform: translateZ(0) | 3D 变换 |
position: fixed | 固定定位 |
<video>、<canvas> | 特殊元素 |
opacity 动画 | 透明度动画 |
filter | CSS 滤镜 |
7.8 JavaScript 的影响
优化策略:
<!-- 1. 脚本放在 body 底部 -->
<body>
<div id="app"></div>
<script src="app.js"></script>
</body>
<!-- 2. 使用 defer:HTML 解析完成后执行 -->
<script defer src="app.js"></script>
<!-- 3. 使用 async:下载完成后立即执行 -->
<script async src="analytics.js"></script>
| 属性 | 下载 | 执行时机 | 执行顺序 |
|---|---|---|---|
| 无 | 阻塞解析 | 下载后立即执行 | 按顺序 |
defer | 并行下载 | HTML 解析完成后 | 按顺序 |
async | 并行下载 | 下载完成后立即执行 | 不保证顺序 |
阶段八:页面交互
8.1 事件循环
页面渲染完成后,浏览器进入事件循环,等待用户交互:
// 事件循环简化模型
while (true) {
// 1. 执行宏任务
const macroTask = macroTaskQueue.shift();
if (macroTask) {
execute(macroTask);
}
// 2. 执行所有微任务
while (microTaskQueue.length > 0) {
const microTask = microTaskQueue.shift();
execute(microTask);
}
// 3. 检查是否需要渲染
if (shouldRender()) {
// 执行 requestAnimationFrame 回调
executeRAFCallbacks();
// 渲染页面
render();
}
// 4. 执行 requestIdleCallback(空闲时)
if (isIdle()) {
executeIdleCallbacks();
}
}
8.2 用户交互流程
完整流程总结
各阶段耗时参考
| 阶段 | 典型耗时 | 优化方向 |
|---|---|---|
| DNS 解析 | 20-120ms | DNS 预解析、CDN |
| TCP 连接 | 20-100ms | Keep-Alive、HTTP/2 |
| TLS 握手 | 50-200ms | TLS 1.3、Session 复用 |
| HTTP 请求 | 50-500ms | CDN、Gzip、缓存 |
| HTML 解析 | 10-100ms | 减少 DOM 节点 |
| CSS 解析 | 10-50ms | 减少选择器复杂度 |
| JS 执行 | 50-500ms | 代码分割、延迟加载 |
| Layout | 10-100ms | 减少重排 |
| Paint | 10-50ms | 减少绘制区域 |
| Composite | 5-20ms | 合理分层 |
常见面试问题
Q1: 从输入 URL 到页面展示的完整过程是什么?
答案:
完整流程(按时间顺序):
- URL 解析:浏览器解析 URL,判断是网址还是搜索词
- 检查缓存:检查强缓存是否命中
- DNS 解析:将域名解析为 IP 地址
- 建立 TCP 连接:三次握手
- TLS 握手:如果是 HTTPS,建立加密通道
- 发送 HTTP 请求:构建请求报文并发送
- 服务器处理:服务器处理请求并返回响应
- 接收响应:浏览器接收响应数据
- 解析 HTML:构建 DOM 树
- 解析 CSS:构建 CSSOM 树
- 执行 JavaScript:可能修改 DOM/CSSOM
- 构建 Render 树:合并 DOM 和 CSSOM
- Layout:计算元素位置和大小
- Paint:生成绘制指令
- Composite:合成图层并显示
// 简化的时间线
interface NavigationTiming {
domainLookupStart: number; // DNS 开始
domainLookupEnd: number; // DNS 结束
connectStart: number; // TCP 开始
secureConnectionStart: number;// TLS 开始
connectEnd: number; // 连接建立完成
requestStart: number; // 请求开始
responseStart: number; // 首字节到达
responseEnd: number; // 响应结束
domInteractive: number; // DOM 可交互
domContentLoadedEventEnd: number; // DOMContentLoaded
loadEventEnd: number; // load 事件结束
}
Q2: 如何优化从 URL 到页面展示的性能?
答案:
网络层优化:
| 阶段 | 优化策略 |
|---|---|
| DNS | DNS 预解析、使用 CDN |
| TCP | Keep-Alive、HTTP/2 多路复用 |
| TLS | TLS 1.3、Session 复用 |
| HTTP | Gzip 压缩、缓存策略、资源合并 |
渲染层优化:
| 阶段 | 优化策略 |
|---|---|
| HTML | 减少 DOM 深度和节点数 |
| CSS | 避免复杂选择器、减少重排 |
| JavaScript | 代码分割、延迟加载、Web Worker |
| 布局 | 避免强制同步布局 |
| 绘制 | 使用 transform/opacity 动画 |
// 关键渲染路径优化
// 1. 内联关键 CSS
<style>
/* 首屏关键样式 */
.header { ... }
.hero { ... }
</style>
// 2. 延迟非关键 CSS
<link rel="preload" href="styles.css" as="style" onload="this.rel='stylesheet'">
// 3. 延迟 JavaScript
<script defer src="app.js"></script>
// 4. 预加载关键资源
<link rel="preload" href="hero.jpg" as="image">
Q3: 为什么 CSS 要放在 head 中,JavaScript 要放在 body 底部?
答案:
CSS 放在 head 的原因:
- CSS 不会阻塞 DOM 解析,但会阻塞渲染
- 如果 CSS 放在底部,浏览器会先渲染无样式内容(FOUC:Flash of Unstyled Content)
- 放在 head 中可以让浏览器尽早开始下载和解析 CSS
JavaScript 放在 body 底部的原因:
- 传统
<script>会阻塞 DOM 解析 - 放在底部可以让 DOM 先解析完成,页面更快呈现
- JavaScript 执行时通常需要操作 DOM,DOM 需要先构建完成
<!DOCTYPE html>
<html>
<head>
<!-- CSS 放在 head -->
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app">...</div>
<!-- JavaScript 放在 body 底部 -->
<script src="app.js"></script>
</body>
</html>
<!-- 现代方案:使用 defer -->
<head>
<link rel="stylesheet" href="styles.css">
<script defer src="app.js"></script>
</head>
Q4: TCP 三次握手和四次挥手的过程是什么?为什么挥手比握手多一次?
答案:
三次握手:
- 客户端 → 服务器:SYN(请求连接)
- 服务器 → 客户端:SYN+ACK(确认+请求连接)
- 客户端 → 服务器:ACK(确认)
四次挥手:
- 客户端 → 服务器:FIN(请求关闭)
- 服务器 → 客户端:ACK(确认收到)
- 服务器 → 客户端:FIN(请求关闭)
- 客户端 → 服务器:ACK(确认收到)
为什么挥手多一次:
- TCP 是全双工通信,两个方向的数据传输相互独立
- 客户端发送 FIN 表示不再发送数据,但仍可以接收数据
- 服务器需要先 ACK 确认,然后等待自己的数据发送完毕后,再发送 FIN
- 因此 ACK 和 FIN 不能合并,需要四次
Q5: 什么是重排(Reflow)和重绘(Repaint)?如何减少重排?
答案:
| 概念 | 触发条件 | 性能影响 |
|---|---|---|
| 重排 | 元素位置、大小改变 | 高(重新计算布局) |
| 重绘 | 元素外观改变(颜色等) | 中(只需重新绘制) |
触发重排的操作:
- 添加/删除 DOM 元素
- 修改元素尺寸(width、height、padding、margin)
- 修改元素位置(position、top、left)
- 获取布局信息(offsetTop、clientWidth、getComputedStyle)
减少重排的方法:
// ❌ 频繁重排
for (let i = 0; i < 100; i++) {
element.style.left = i + 'px'; // 100 次重排
}
// ✅ 批量修改样式
element.style.cssText = 'left: 100px; top: 100px;';
// ✅ 使用 class 切换
element.classList.add('moved');
// ✅ 使用 DocumentFragment
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
fragment.appendChild(li);
}
list.appendChild(fragment); // 只重排一次
// ✅ 脱离文档流后修改
element.style.display = 'none'; // 脱离文档流
// ... 多次修改 ...
element.style.display = 'block'; // 恢复,只重排一次
// ✅ 使用 transform 代替位置改变
element.style.transform = 'translateX(100px)'; // 不触发重排
Q6: DNS 预解析和预连接对首屏性能的影响
答案:
DNS 预解析(dns-prefetch)和预连接(preconnect)是两种资源提示(Resource Hints),可以显著减少首屏加载中的网络延迟。
两者的区别:
| 特性 | dns-prefetch | preconnect |
|---|---|---|
| 作用 | 仅完成 DNS 解析 | DNS 解析 + TCP 连接 + TLS 握手 |
| 节省时间 | 20-120ms | 100-300ms |
| 资源消耗 | 极低 | 中等(维持连接有成本) |
| 浏览器支持 | 非常好 | 好 |
| 适用场景 | 可能用到的第三方域名 | 确定会用到的关键域名 |
<!-- DNS 预解析:仅解析域名到 IP -->
<link rel="dns-prefetch" href="//cdn.example.com">
<link rel="dns-prefetch" href="//fonts.googleapis.com">
<link rel="dns-prefetch" href="//analytics.example.com">
<!-- 预连接:DNS + TCP + TLS 全流程 -->
<link rel="preconnect" href="https://api.example.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
实际应用的最佳实践:
// 动态添加 preconnect(用于 SPA 中的按需预连接)
function addPreconnect(url: string): void {
// 避免重复添加
const existing = document.querySelector(`link[rel="preconnect"][href="${url}"]`);
if (existing) return;
const link = document.createElement('link');
link.rel = 'preconnect';
link.href = url;
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
}
// 结合其他资源提示
function optimizeResourceLoading(): void {
// 1. 关键 API 域名 - 用 preconnect
addPreconnect('https://api.example.com');
// 2. CDN 域名 - 用 preconnect
addPreconnect('https://cdn.example.com');
// 3. 可能用到的第三方 - 用 dns-prefetch
const prefetch = document.createElement('link');
prefetch.rel = 'dns-prefetch';
prefetch.href = '//analytics.example.com';
document.head.appendChild(prefetch);
}
preconnect不要滥用,建议不超过 4-6 个域名,否则连接维护成本会抵消收益- 如果连接建立后 10 秒内没有使用,浏览器会自动关闭连接,造成资源浪费
dns-prefetch成本很低,可以多配几个;preconnect成本较高,只用于关键域名- 跨域请求需要添加
crossorigin属性,否则实际请求时会重新建立连接
Q7: HTTP/2 的多路复用如何改变了资源加载策略?
答案:
HTTP/2 的多路复用(Multiplexing)允许在同一个 TCP 连接上并行发送多个请求和响应,彻底解决了 HTTP/1.1 的队头阻塞问题,从而改变了许多传统的前端优化策略。
HTTP/1.1 vs HTTP/2 的区别:
HTTP/2 带来的优化策略变化:
| 传统策略(HTTP/1.1) | HTTP/2 下是否还需要 | 原因 |
|---|---|---|
| 雪碧图(CSS Sprites) | 不再必要 | 多路复用下多个小图请求无额外开销 |
| 文件合并(concat) | 不再必要 | 多文件并行传输,合并反而影响缓存粒度 |
| 域名分片(domain sharding) | 反而有害 | 多域名导致多个 TCP 连接,无法利用多路复用 |
| 内联资源(inline CSS/JS) | 按需使用 | 小文件可保持独立以利用缓存 |
| 代码分割(code splitting) | 更加重要 | 粒度更细的分割能充分利用多路复用和缓存 |
// HTTP/1.1 时代 - 需要合并请求减少连接数
// ❌ 在 HTTP/2 下不再必要
async function fetchAllDataHTTP1(): Promise<void> {
// 合并多个请求为一个,减少 HTTP 连接数
const response = await fetch('/api/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requests: ['/users', '/posts', '/comments'],
}),
});
const { users, posts, comments } = await response.json();
}
// HTTP/2 时代 - 可以直接并行请求
// ✅ 多路复用下多个请求共用一个 TCP 连接
async function fetchAllDataHTTP2(): Promise<void> {
const [users, posts, comments] = await Promise.all([
fetch('/api/users').then((r) => r.json()),
fetch('/api/posts').then((r) => r.json()),
fetch('/api/comments').then((r) => r.json()),
]);
}
HTTP/2 服务端推送(Server Push):
// 服务端可以主动推送客户端可能需要的资源
// Express + spdy/http2 示例
import http2 from 'http2';
function handleRequest(
stream: http2.ServerHttp2Stream,
headers: http2.IncomingHttpHeaders
): void {
// 当客户端请求 index.html 时,主动推送 CSS 和 JS
if (headers[':path'] === '/index.html') {
// 推送 CSS
stream.pushStream({ ':path': '/styles.css' }, (err, pushStream) => {
if (!err) {
pushStream.respond({ ':status': 200, 'content-type': 'text/css' });
pushStream.end('body { margin: 0; }');
}
});
}
}
- 细粒度代码分割:利用多路复用,将代码分割为更小的 chunk,提升缓存命中率
- 减少域名数量:将资源集中到同一域名下,充分利用多路复用
- 使用 preload:提前告知浏览器关键资源,配合 HTTP/2 并行加载效果更佳
- 保持独立文件:不再需要合并文件,独立文件有更好的缓存策略
Q8: 白屏时间和首屏时间的区别,如何优化?
答案:
白屏时间(FP/FCP)和首屏时间(LCP)是衡量页面加载速度的两个核心指标,但它们关注的时间节点不同。
| 指标 | 英文 | 含义 | 触发时机 |
|---|---|---|---|
| 白屏时间 | FP(First Paint) | 浏览器开始渲染第一个像素 | 页面不再是白色 |
| 首次内容绘制 | FCP(First Contentful Paint) | 首次渲染文本、图片等内容 | 用户看到"有内容" |
| 最大内容绘制 | LCP(Largest Contentful Paint) | 最大可见内容元素渲染完成 | 用户感知"加载完成" |
| 首屏时间 | - | 首屏所有内容渲染完毕 | 无需滚动即可看到完整内容 |
测量方法:
// 使用 Performance API 测量
function measurePageTiming(): void {
// 1. 白屏时间 (FP)
const fpEntry = performance.getEntriesByName('first-paint')[0];
if (fpEntry) {
console.log(`白屏时间 (FP): ${fpEntry.startTime.toFixed(2)}ms`);
}
// 2. 首次内容绘制 (FCP)
const fcpEntry = performance.getEntriesByName('first-contentful-paint')[0];
if (fcpEntry) {
console.log(`首次内容绘制 (FCP): ${fcpEntry.startTime.toFixed(2)}ms`);
}
// 3. 最大内容绘制 (LCP)
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log(`最大内容绘制 (LCP): ${lastEntry.startTime.toFixed(2)}ms`);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// 4. 使用 Navigation Timing API 计算关键时间
window.addEventListener('load', () => {
const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
console.log({
DNS: timing.domainLookupEnd - timing.domainLookupStart,
TCP: timing.connectEnd - timing.connectStart,
TTFB: timing.responseStart - timing.requestStart,
DOM解析: timing.domInteractive - timing.responseEnd,
DOMContentLoaded: timing.domContentLoadedEventEnd - timing.fetchStart,
完全加载: timing.loadEventEnd - timing.fetchStart,
});
});
}
白屏时间优化方案(目标:减少 FP/FCP 时间):
// 1. 内联关键 CSS - 避免阻塞首次渲染
// 在 HTML head 中内联首屏关键样式
const criticalCSS = `
<style>
body { margin: 0; font-family: system-ui; }
.header { height: 64px; background: #fff; }
.hero { min-height: 400px; }
.skeleton { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); }
</style>
`;
// 2. 骨架屏 - 让用户感知到页面在加载
// 在 HTML 中直接放入骨架屏,不依赖 JS
const skeletonHTML = `
<div id="root">
<div class="skeleton header"></div>
<div class="skeleton hero"></div>
</div>
`;
<!-- 3. 资源加载优化 -->
<head>
<!-- 内联关键 CSS -->
<style>/* critical CSS */</style>
<!-- 预连接关键域名 -->
<link rel="preconnect" href="https://api.example.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- 预加载关键资源 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/critical.js" as="script">
<!-- 异步加载非关键 CSS -->
<link rel="preload" href="/non-critical.css" as="style" onload="this.rel='stylesheet'">
<!-- defer 加载 JS,不阻塞 HTML 解析 -->
<script defer src="/app.js"></script>
</head>
首屏时间优化方案(目标:减少 LCP 时间):
| 策略 | 说明 | 效果 |
|---|---|---|
| SSR / SSG | 服务端直出 HTML | 减少 JS 执行等待时间 |
| 代码分割 | 只加载首屏需要的 JS | 减少主包体积 |
| 图片优化 | WebP/AVIF + 懒加载 + 响应式 | 减少最大元素的加载时间 |
| CDN 加速 | 静态资源 CDN 分发 | 减少网络延迟 |
| 缓存策略 | 强缓存 + 协商缓存 | 二次访问秒开 |
| 流式渲染 | React 18 Streaming SSR | 首字节更快到达 |
// 图片懒加载 + LCP 优化
function optimizeImages(): void {
// 首屏图片使用 eager loading + fetchpriority
// <img src="hero.jpg" loading="eager" fetchpriority="high" />
// 非首屏图片使用懒加载
// <img src="below-fold.jpg" loading="lazy" />
// 使用 Intersection Observer 实现自定义懒加载
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
img.src = img.dataset.src!;
observer.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]').forEach((img) => {
observer.observe(img);
});
}
- 首先优化白屏:让用户尽快看到内容(骨架屏、内联 CSS、preconnect)
- 然后优化首屏:让首屏内容尽快完整展示(SSR、代码分割、图片优化)
- 最后优化交互:让页面尽快可交互(TTI 优化、JS 延迟加载)