从输入 URL 到页面渲染完毕,中间发生了什么?以及在每个环节可以做哪些优化。核心原则:空间换时间。
从输入 URL 到渲染
加载阶段
- DNS 解析:域名 → IP 地址。浏览器缓存 → OS 缓存 → 路由器缓存 → ISP DNS 服务器,逐级查询
- TCP 连接:三次握手建立连接。HTTPS 额外进行 TLS 握手
- HTTP 请求:浏览器发送请求,服务器处理并返回响应
渲染阶段
- 解析 HTML,构建 DOM Tree
- 解析 CSS,构建 CSSOM(CSS Object Model)
- 将 DOM Tree 和 CSSOM 合并为 Render Tree(只包含可见元素,
display: none不在其中) - Layout(布局/回流):计算每个节点的几何位置和尺寸
- Paint(绘制):将每个节点绘制到屏幕上
- Composite(合成):将不同图层合并显示
HTML ──→ DOM Tree ──┐
├──→ Render Tree → Layout → Paint → Composite
CSS ──→ CSSOM ────┘
<script>标签会阻塞 HTML 解析(除非有async或defer)。CSS 不会阻塞 HTML 解析但会阻塞渲染——在 CSSOM 构建完成之前,浏览器不会渲染任何内容。
async vs defer
<!-- 普通 script:阻塞 HTML 解析,立即下载并执行 -->
<script src="main.js"></script>
<!-- async:异步下载,下载完立即执行(执行时阻塞解析),适合独立脚本 -->
<script src="analytics.js" async></script>
<!-- defer:异步下载,HTML 解析完才执行,保证执行顺序 -->
<script src="app.js" defer></script>| 属性 | 下载 | 执行时机 | 执行顺序 |
|---|---|---|---|
| 无 | 阻塞解析 | 下载后立即执行 | 按文档顺序 |
async | 异步 | 下载完立即执行 | 不保证顺序(谁先下载完谁先执行) |
defer | 异步 | DOM 解析完后、DOMContentLoaded 前 | 按文档顺序 |
DOMContentLoaded vs load
// DOM 解析完毕(CSS、图片可能尚未加载完)
window.addEventListener('DOMContentLoaded', () => {
// 可以开始操作 DOM,初始化交互
});
// 全部资源(图片、CSS、iframe)加载完
window.addEventListener('load', () => {
// 可以获取图片尺寸等信息
});DOMContentLoaded→ HTML 解析完,但样式表、图片、子框架可能尚未加载load→ 所有资源加载完毕CSS样式表会阻塞DOMContentLoaded(因为 JS 可能需要读取样式信息)
性能优化
核心原则:多利用内存和缓存,减少 CPU 计算和网络加载耗时。
让加载更快
| 方向 | 具体手段 |
|---|---|
| 减少资源体积 | 代码压缩(Terser、cssnano)、Gzip/Brotli 压缩、图片压缩(WebP/AVIF)、Tree Shaking |
| 减少请求数 | 代码合并(webpack splitChunks)、SSR 服务端渲染、雪碧图/icon font/SVG sprite |
| 更快网络 | CDN 就近服务、HTTP/2 多路复用、DNS 预解析(<link rel="dns-prefetch">) |
| 预加载 | <link rel="preload"> 提前加载关键资源、<link rel="prefetch"> 预加载下一页资源 |
资源文件名带 hash
// webpack
module.exports = {
mode: 'production',
output: {
filename: 'bundle.[contenthash:8].js', // 8 位 hash
},
};文件内容不变 → hash 不变 → URL 不变 → 浏览器走 HTTP 缓存(304)。配合 Cache-Control: max-age=31536000, immutable 使用效果最佳。
CDN
- 静态资源部署到 CDN
- 根据用户地理位置就近服务,降低延迟
- 配合 HTTP 缓存和内容 hash 实现”永久”缓存
SSR
| 方式 | 流程 | 首屏速度 | 代表 |
|---|---|---|---|
| SSR | 服务端渲染 HTML,数据和页面一起返回 | 快(首屏直接显示) | Next.js、Nuxt.js |
| 前后端分离 | 先加载空壳 HTML → 请求数据 → 渲染 | 慢(需要等待 JS 执行) | 传统 SPA |
SSR 加速的是首屏可见的时间(FCP / LCP),但不是 TTI(Time to Interactive)——页面可见后仍需要加载并执行 JS 才能交互。
资源提示(Resource Hints)
<!-- DNS 预解析:提前解析域名 -->
<link rel="dns-prefetch" href="https://api.example.com" />
<!-- 预连接:DNS + TCP + TLS 全部提前建立 -->
<link rel="preconnect" href="https://api.example.com" />
<!-- 预加载:当前页面需要的关键资源,优先级高 -->
<link rel="preload" href="/font.woff2" as="font" crossorigin />
<!-- 预获取:下个页面可能用到的资源,空闲时下载,优先级低 -->
<link rel="prefetch" href="/next-page.js" />让渲染更快
| 方向 | 具体手段 |
|---|---|
CSS 在 <head>,JS 在 </body> 前 | 避免 JS 阻塞 HTML 解析 |
| 尽早触发非关键 JS | 用 DOMContentLoaded、defer |
| 懒加载 | 图片 <img loading="lazy">、滚动加载更多、路由懒加载 |
| 缓存 DOM 查询 | 减少重复读取布局属性 |
| 批量 DOM 操作 | DocumentFragment 或 display: none 容器 |
| 防抖与节流 | 控制 scroll、resize、input 等高频事件 |
| will-change | 提前告知浏览器哪些属性会变化,创建独立图层 |
| 虚拟列表 | 只渲染可视区域的 DOM 节点(大量数据列表场景) |
图片懒加载
<!-- 原生懒加载 -->
<img src="real.png" loading="lazy" alt="" />
<!-- JS 手动实现 -->
<img src="placeholder.png" data-src="real.png" alt="" />
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
});
document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));
</script>