前言
上周排查一个电商首页的性能问题,用户反馈"页面白屏时间太长"。打开 DevTools 一看,首屏依赖的 Google Fonts 字体文件要等 2.8 秒才开始下载------光 DNS 解析就花了 300ms,TCP 握手 150ms,TLS 协商又 200ms,真正开始传输数据已经是 650ms 之后了。而这些连接开销,本可以在浏览器空闲时提前完成。
这就是今天要聊的主题:Resource Hints(资源提示) 。浏览器提供了四个关键指令------dns-prefetch、preconnect、preload、prefetch,它们各自作用于网络请求生命周期的不同阶段,合理使用可以显著减少资源加载的等待时间。
本文会从网络连接的完整生命周期出发,逐一拆解这四个指令的原理、适用场景和踩坑经验,最后给出 React/Next.js 项目中的实战配置。
一、先搞清楚:一个网络请求到底经历了什么
在讨论这四个指令之前,必须先理解一个 HTTPS 请求的完整生命周期:
sql
浏览器发起请求的完整时间线(HTTPS)
DNS 解析 TCP 握手 TLS 协商 发送请求 接收响应
|-----------|--------------|---------------|-----------|------------|
| ~50-300ms | ~50-150ms | ~100-200ms | ~10-50ms | 取决于大小 |
| | | | | |
v v v v v v
+----------+ +------------+ +-------------+ +---------+ +----------+
| 域名 -> | | SYN | | ClientHello | | GET / | | 200 OK |
| IP 地址 | | SYN-ACK | | ServerHello | | ... | | data... |
| | | ACK | | Certificate | | | | |
+----------+ +------------+ | Key Exchange| +---------+ +----------+
| Finished |
+-------------+
总连接开销: 200-650ms(还没开始传数据)
关键认识:对于跨域资源,连接开销往往比数据传输本身还要慢。 一个 20KB 的字体文件,传输可能只要 30ms,但连接建立要 400ms。Resource Hints 的核心价值就是把这些连接开销前置到浏览器空闲时段。
四个指令分别作用于不同阶段:
lua
请求时间线上各指令的作用范围
DNS 解析 TCP 握手 TLS 协商 HTTP 请求/响应
|-----------|--------------|---------------|----------------------|
|<-- dns-prefetch -->|
| 只做 DNS 解析
|<----------- preconnect ------------->|
| DNS + TCP + TLS,建立完整连接
|<----------------------- preload --------------------------->|
| DNS + TCP + TLS + 发请求 + 收响应(当前页面立即需要)
|<----------------------- prefetch -------------------------->|
| DNS + TCP + TLS + 发请求 + 收响应(下一个页面可能需要)
二、dns-prefetch:最轻量的提速手段
2.1 原理
dns-prefetch 告诉浏览器:"我后面会用到这个域名,你现在就去解析它的 IP 地址吧。"
html
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://cdn.example.com">
<link rel="dns-prefetch" href="https://api.example.com">
DNS 解析通常耗时 20-120ms(首次解析可能到 300ms),提前完成后,后续请求可以直接用缓存的 IP 地址,跳过这段等待。
2.2 效果对比
scss
没有 dns-prefetch 时:
字体请求触发
|
v
[DNS 120ms][TCP 80ms][TLS 150ms][下载 40ms]
|<------------- 总计 390ms ------------->|
使用 dns-prefetch 后:
页面解析阶段 字体请求触发
| |
v v
[DNS 120ms] [TCP 80ms][TLS 150ms][下载 40ms]
(提前完成) |<------- 总计 270ms -------->|
节省: 120ms DNS 解析时间
2.3 最佳使用场景
- 第三方分析/监控域名:Google Analytics、百度统计、Sentry 等
- CDN 域名:如果你的静态资源和 API 在不同的 CDN 域名上
- 社交分享相关域名:微信 JSSDK、微博分享等
html
<!-- 典型的 dns-prefetch 配置 -->
<head>
<!-- 第三方服务 -->
<link rel="dns-prefetch" href="https://hm.baidu.com">
<link rel="dns-prefetch" href="https://www.googletagmanager.com">
<!-- CDN 和 API -->
<link rel="dns-prefetch" href="https://cdn.example.com">
<link rel="dns-prefetch" href="https://api.example.com">
<!-- 字体服务 -->
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
</head>
2.4 注意事项
dns-prefetch只做 DNS 解析,开销极低,可以放心用于多个域名- 对同源请求没用(同源已经解析过了)
- 浏览器会自行判断是否执行,在弱网环境下可能被忽略
- 不要滥用:超过 10 个以上的 dns-prefetch 意义不大,DNS 缓存有容量限制
三、preconnect:完整的连接预热
3.1 原理
preconnect 比 dns-prefetch 更进一步,它会提前完成 DNS + TCP + TLS 的全部连接建立过程:
html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
3.2 效果对比
scss
没有任何预连接:
字体请求触发
|
v
[DNS 120ms][TCP 80ms][TLS 150ms][下载 40ms]
|<------------- 总计 390ms ------------->|
dns-prefetch:
页面解析 字体请求触发
| |
v v
[DNS 120ms] [TCP 80ms][TLS 150ms][下载 40ms]
(提前完成) |<------- 总计 270ms -------->|
preconnect:
页面解析 字体请求触发
| |
v v
[DNS 120ms][TCP 80ms][TLS 150ms] [下载 40ms]
|<----- 提前完成 350ms ----->| |<-40ms->|
preconnect 节省: 350ms 连接开销
3.3 crossorigin 属性的坑
这是 preconnect 最容易踩的坑:如果实际请求需要 CORS,preconnect 也必须加 crossorigin 属性,否则浏览器会建立两次连接。
html
<!-- 错误:字体文件请求是 CORS 的,但 preconnect 没加 crossorigin -->
<link rel="preconnect" href="https://fonts.gstatic.com">
<!-- 浏览器会为 preconnect 建立一个非 CORS 连接,然后发现字体请求需要 CORS,
又重新建立一个 CORS 连接,preconnect 完全白费 -->
<!-- 正确写法 -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
规则很简单:
- 字体文件(
@font-face):始终加crossorigin fetch()跨域请求:始终加crossorigin- 普通的
<script>、<link rel="stylesheet">跨域加载:不加crossorigin
3.4 dns-prefetch + preconnect 组合使用
推荐的做法是两者一起用,dns-prefetch 作为 preconnect 的降级方案(兼容不支持 preconnect 的老浏览器):
html
<head>
<!-- Google Fonts:API 域名不需要 crossorigin,字体文件域名需要 -->
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- 自家 CDN -->
<link rel="dns-prefetch" href="https://cdn.example.com">
<link rel="preconnect" href="https://cdn.example.com">
</head>
3.5 preconnect 的注意事项
- 不要预连接超过 4-6 个域名:每个预连接都要占用 CPU 和网络资源,过多反而拖慢页面
- 连接有有效期:浏览器会在 10 秒左右关闭空闲连接,如果预连接后 10 秒内没发请求,就白费了
- HTTP/2 复用:同一域名只需要一个连接,所以一个域名只需要一个 preconnect
四、preload:当前页面的关键资源提前加载
4.1 原理
preload 和前两个完全不同------它不只是建立连接,而是直接把资源下载下来。它告诉浏览器:"这个资源当前页面马上就要用,你现在就去下载。"
html
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/css/critical.css" as="style">
<link rel="preload" href="/images/hero.webp" as="image">
4.2 关键属性:as
as 属性必须填写,它告诉浏览器资源的类型,影响:
- 请求优先级的分配
- 正确的
Accept请求头 - CSP(内容安全策略)的检查
- 是否复用已有的缓存
as 值 |
对应资源类型 | 请求优先级 |
|---|---|---|
style |
CSS 样式表 | Highest |
script |
JavaScript 脚本 | High |
font |
字体文件 | High |
image |
图片资源 | Low(可通过 fetchpriority 提升) |
fetch |
fetch/XHR 请求 | High |
document |
HTML 文档 | High |
4.3 fetchpriority 微调优先级
fetchpriority 属性可以在默认优先级基础上进一步调整:
html
<!-- Hero 图片:默认 image 优先级是 Low,提升到 High -->
<link rel="preload" href="/images/hero.webp" as="image" fetchpriority="high">
<!-- 非关键脚本:降低优先级 -->
<link rel="preload" href="/js/analytics.js" as="script" fetchpriority="low">
4.4 典型使用场景
场景一:预加载关键字体(最常用)
字体文件通常在 CSS 解析完成后才会发起请求,preload 可以让字体和 CSS 并行下载:
lua
没有 preload:
HTML 下载 CSS 下载 CSS 解析 字体下载 字体渲染
|------------|-------------|-----------|------------|-----------|
^
|
字体请求在这里才发出
(瀑布流延迟)
使用 preload:
HTML 下载 CSS 下载 CSS 解析 字体渲染
|------------|-------------|-----------| |-----------|
字体下载(与 CSS 并行)
|------------------------------------------|
^
|
preload 让字体在 HTML 解析时就开始下载
节省: 一整个 CSS 下载+解析的时间(通常 200-500ms)
html
<head>
<!-- 预加载字体 -->
<link
rel="preload"
href="/fonts/inter-var-latin.woff2"
as="font"
type="font/woff2"
crossorigin
>
<!-- 然后正常加载 CSS(CSS 中会引用这个字体) -->
<link rel="stylesheet" href="/css/main.css">
</head>
场景二:预加载首屏 Hero 图片
html
<head>
<link
rel="preload"
href="/images/hero-banner.webp"
as="image"
fetchpriority="high"
>
</head>
<!-- 在 CSS 背景图或延迟渲染的组件中使用 -->
<section class="hero" style="background-image: url('/images/hero-banner.webp')">
...
</section>
场景三:预加载关键 CSS(用于 Code Splitting 场景)
html
<head>
<!-- 关键路径 CSS,预加载确保最早下载 -->
<link rel="preload" href="/css/above-the-fold.css" as="style">
<link rel="stylesheet" href="/css/above-the-fold.css">
</head>
4.5 在 JavaScript 中动态 preload
有些场景需要根据运行时条件决定是否预加载:
typescript
// 动态预加载资源
function preloadResource(href: string, as: string, crossOrigin?: boolean): void {
// 避免重复创建
const existing = document.querySelector(`link[rel="preload"][href="${href}"]`);
if (existing) return;
const link = document.createElement('link');
link.rel = 'preload';
link.href = href;
link.as = as;
if (crossOrigin) {
link.crossOrigin = 'anonymous';
}
document.head.appendChild(link);
}
// 示例:根据视口宽度预加载不同尺寸的图片
function preloadHeroImage(): void {
const width = window.innerWidth;
if (width >= 1200) {
preloadResource('/images/hero-desktop.webp', 'image');
} else if (width >= 768) {
preloadResource('/images/hero-tablet.webp', 'image');
} else {
preloadResource('/images/hero-mobile.webp', 'image');
}
}
4.6 preload 的警告和陷阱
陷阱一:preload 了但没用
如果你 preload 了一个资源但在 3 秒内没有使用它,Chrome 会在控制台抛出警告:
vbnet
The resource https://example.com/font.woff2 was preloaded using link preload
but not used within a few seconds from the window's load event. Please make
sure it has an appropriate `as` value and it is preloaded intentionally.
这个警告说明你的 preload 是多余的,要么删掉,要么检查资源路径是否正确。
陷阱二:as 值写错或不写
html
<!-- 错误:没写 as,浏览器不知道资源类型,会重复下载 -->
<link rel="preload" href="/font.woff2">
<!-- 错误:as 值不对 -->
<link rel="preload" href="/font.woff2" as="style">
<!-- 正确 -->
<link rel="preload" href="/font.woff2" as="font" type="font/woff2" crossorigin>
陷阱三:字体 preload 忘加 crossorigin
字体文件的请求始终是 CORS 的(即使同源),所以 preload 字体时必须加 crossorigin:
html
<!-- 错误:会导致字体被下载两次 -->
<link rel="preload" href="/fonts/my-font.woff2" as="font" type="font/woff2">
<!-- 正确 -->
<link rel="preload" href="/fonts/my-font.woff2" as="font" type="font/woff2" crossorigin>
五、prefetch:为下一个页面做准备
5.1 原理
prefetch 告诉浏览器:"这个资源当前页面不需要,但用户接下来很可能会访问的页面需要它,你在空闲时去下载吧。"
html
<link rel="prefetch" href="/js/product-detail.chunk.js" as="script">
<link rel="prefetch" href="/css/checkout.css" as="style">
核心特点:
- 最低优先级:只在浏览器空闲时才会下载,不影响当前页面
- 跨页面缓存:下载的资源会存入 HTTP 缓存,用户导航到下一个页面时可以直接使用
- 不执行:prefetch 只下载不执行,只有页面真正引用时才会执行
5.2 效果对比
css
没有 prefetch(用户从列表页点击进入详情页):
列表页 详情页
|------- 用户浏览中 -------| |------- 加载中 -------|
用户点击
|
v
[DNS][TCP][TLS][下载 detail.js 200ms][解析执行]
|<---------- 总计 550ms ----------->|
使用 prefetch:
列表页 详情页
|------- 用户浏览中 -------| |------- 加载中 -------|
用户点击
[空闲时下载 detail.js] |
|<---- 提前完成 ---->| v
[从缓存读取 detail.js 5ms][解析执行]
|<----- 总计 55ms ----->|
节省: 约 500ms 的网络请求时间
5.3 SPA 中的路由级 prefetch
在 SPA(单页应用)中,prefetch 最典型的用法是按路由预取代码包:
typescript
// route-prefetch.ts
// 基于路由配置的预取策略
interface RouteConfig {
path: string;
chunkName: string;
prefetch: boolean;
}
const routes: RouteConfig[] = [
{ path: '/', chunkName: 'home', prefetch: false },
{ path: '/product', chunkName: 'product', prefetch: true },
{ path: '/cart', chunkName: 'cart', prefetch: true },
{ path: '/checkout',chunkName: 'checkout', prefetch: false }, // 不是所有用户都会走到结账
];
// 在首页加载完成后,预取可能用到的路由
function prefetchRoutes(): void {
if (!('requestIdleCallback' in window)) return;
window.requestIdleCallback(() => {
routes
.filter(route => route.prefetch)
.forEach(route => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = `/js/${route.chunkName}.chunk.js`;
link.as = 'script';
document.head.appendChild(link);
});
});
}
// 页面加载完成后执行
window.addEventListener('load', prefetchRoutes);
5.4 基于用户行为的智能 prefetch
更精细的策略是根据用户行为来决定 prefetch:
typescript
// hover-prefetch.ts
// 用户 hover 链接时预取目标页面的资源
class HoverPrefetcher {
private prefetchedUrls = new Set<string>();
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.init();
}
private init(): void {
document.addEventListener('mouseover', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const anchor = target.closest('a[href]') as HTMLAnchorElement | null;
if (!anchor) return;
if (!this.shouldPrefetch(anchor)) return;
// 延迟 200ms,避免鼠标快速划过时触发
this.hoverTimer = setTimeout(() => {
this.prefetch(anchor.href);
}, 200);
});
document.addEventListener('mouseout', (e: MouseEvent) => {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
this.hoverTimer = null;
}
});
}
private shouldPrefetch(anchor: HTMLAnchorElement): boolean {
// 只预取同源链接
if (anchor.origin !== window.location.origin) return false;
// 不重复预取
if (this.prefetchedUrls.has(anchor.href)) return false;
// 不预取当前页面
if (anchor.href === window.location.href) return false;
// 尊重 data-no-prefetch 属性
if (anchor.hasAttribute('data-no-prefetch')) return false;
return true;
}
private prefetch(url: string): void {
this.prefetchedUrls.add(url);
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
link.as = 'document';
document.head.appendChild(link);
}
}
// 使用
new HoverPrefetcher();
5.5 prefetch 的注意事项
- 不要 prefetch 太多资源:虽然优先级最低,但还是会消耗用户流量
- 考虑移动端用户:移动网络下应该减少或禁用 prefetch
- 用
Save-Data请求头检测:尊重用户的"省流量"设置
typescript
// 检测用户是否开启了省流量模式
function shouldPrefetch(): boolean {
// 检查 Save-Data 请求头(通过 Network Information API)
const connection = (navigator as any).connection;
if (connection) {
// 省流量模式
if (connection.saveData) return false;
// 慢速网络(2G/slow-2g)
if (/2g/.test(connection.effectiveType)) return false;
}
return true;
}
六、四者对比:一张表搞清楚
6.1 核心对比表
| 特性 | dns-prefetch | preconnect | preload | prefetch |
|---|---|---|---|---|
| 作用 | DNS 解析 | DNS + TCP + TLS | 完整下载资源 | 空闲时下载资源 |
| 目标 | 减少 DNS 延迟 | 消除连接开销 | 加速当前页面关键资源 | 加速下一页面加载 |
| 优先级 | 极低 | 低 | 取决于 as 值 |
Lowest |
| 典型节省 | 20-300ms | 200-600ms | 消除瀑布流延迟 | 消除下一页加载延迟 |
| 资源开销 | 极小 | 小(一个连接) | 中(占带宽) | 低(空闲时) |
| 建议数量 | 不超过 10 个 | 不超过 4-6 个 | 不超过 5-6 个 | 按需 |
| 是否下载 | 否 | 否 | 是 | 是 |
| 执行时机 | 立即 | 立即 | 立即(高优先级) | 空闲时 |
需要 as |
否 | 否 | 是(必须) | 推荐 |
| 跨页面缓存 | 否 | 否 | 否 | 是 |
6.2 时序对比图
ini
页面加载时间线(从左到右)
页面可交互
0ms 100ms 200ms 300ms 400ms 500ms |
|-----------|-----------|-----------|-----------|-----------|--------|
什么都不做时(baseline):
请求触发 [DNS][TCP][TLS][----下载----]
资源可用 ^
dns-prefetch:
[DNS] 请求触发 [TCP][TLS][----下载----]
资源可用 ^
节省 ~100ms --|
preconnect:
[DNS][TCP][TLS] 请求触发 [----下载----]
资源可用 ^
节省 ~300ms ---------|
preload:
[DNS][TCP][TLS][----下载----] 页面渲染时
资源已就绪 ^
节省 ~400ms+ -----------|
prefetch(针对下一页):
当前页空闲时 [DNS][TCP][TLS][----下载----]
资源已缓存 ^
用户导航 -> 直接从缓存读取
6.3 选择决策树
lua
需要优化资源加载?
|
+-- 这个资源当前页面需要吗?
| |
| +-- 是 --> 这个资源在关键渲染路径上吗?
| | |
| | +-- 是 --> 用 preload
| | |
| | +-- 否 --> 资源在第三方域名上吗?
| | |
| | +-- 是 --> 用 preconnect
| | |
| | +-- 否 --> 不需要 hints
| |
| +-- 否 --> 这个资源下一页面大概率会用吗?
| |
| +-- 是 --> 用 prefetch
| |
| +-- 否 --> 不需要 hints
|
+-- 只是想减少 DNS 延迟(低成本)?
|
+-- 是 --> 用 dns-prefetch
七、常见错误与踩坑总结
7.1 过度预加载
html
<!-- 反模式:把所有资源都 preload -->
<link rel="preload" href="/js/chunk1.js" as="script">
<link rel="preload" href="/js/chunk2.js" as="script">
<link rel="preload" href="/js/chunk3.js" as="script">
<link rel="preload" href="/js/chunk4.js" as="script">
<link rel="preload" href="/js/chunk5.js" as="script">
<link rel="preload" href="/js/chunk6.js" as="script">
<link rel="preload" href="/js/chunk7.js" as="script">
<link rel="preload" href="/js/chunk8.js" as="script">
<!-- 问题:所有资源都变成高优先级,等于没有优先级 -->
<!-- 反而会拖慢真正关键的资源 -->
正确做法是只 preload 关键路径上的 3-5 个资源:
html
<!-- 正确:只预加载首屏关键资源 -->
<link rel="preload" href="/fonts/main-font.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/css/critical.css" as="style">
<link rel="preload" href="/images/hero.webp" as="image" fetchpriority="high">
7.2 preload 和 prefetch 搞混
html
<!-- 错误:当前页面要用的资源不该用 prefetch(优先级太低) -->
<link rel="prefetch" href="/css/above-the-fold.css" as="style">
<!-- 错误:下一页才需要的资源不该用 preload(浪费当前页面带宽) -->
<link rel="preload" href="/js/page-b.chunk.js" as="script">
记住口诀:preload 是"现在就要",prefetch 是"等会儿可能要"。
7.3 优先级冲突
html
<!-- 问题:preload 了图片(默认 Low 优先级),又设了 fetchpriority="high" -->
<!-- 这在某些浏览器版本中可能导致意外行为 -->
<link rel="preload" href="/images/hero.webp" as="image" fetchpriority="high">
<img src="/images/hero.webp" fetchpriority="high" alt="Hero">
<!-- 建议:如果 img 标签已经有 fetchpriority="high" 且在 HTML 顶部,
通常不需要额外 preload。preload 主要解决的是"发现太晚"的问题,
如果资源发现得够早,直接设 fetchpriority 就够了 -->
7.4 preconnect 连接超时浪费
html
<!-- 问题:preconnect 了一个域名,但真正发请求是 15 秒之后 -->
<!-- 浏览器会在 ~10 秒后关闭空闲连接,preconnect 白费了 -->
<link rel="preconnect" href="https://api.example.com">
<!-- ... 用户操作 15 秒后才调 API ... -->
<!-- 建议:只为页面加载后立即(10 秒内)就会用到的域名做 preconnect -->
八、React / Next.js 项目实战配置
8.1 纯 React 项目(CRA / Vite)
在 public/index.html 或 Vite 的 index.html 中配置:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My App</title>
<!-- 1. dns-prefetch + preconnect 组合(第三方域名) -->
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="https://cdn.example.com">
<link rel="preconnect" href="https://cdn.example.com">
<link rel="dns-prefetch" href="https://api.example.com">
<link rel="preconnect" href="https://api.example.com">
<!-- 2. preload 关键字体 -->
<link
rel="preload"
href="/fonts/inter-var-latin.woff2"
as="font"
type="font/woff2"
crossorigin
>
<!-- 3. preload 关键 CSS(如果用了 CSS Code Splitting) -->
<link rel="preload" href="/css/critical.css" as="style">
<link rel="stylesheet" href="/css/critical.css">
<!-- 4. preload Hero 图片 -->
<link
rel="preload"
href="/images/hero-banner.webp"
as="image"
fetchpriority="high"
media="(min-width: 768px)"
>
<link
rel="preload"
href="/images/hero-banner-mobile.webp"
as="image"
fetchpriority="high"
media="(max-width: 767px)"
>
</head>
<body>
<div id="root"></div>
</body>
</html>
8.2 Next.js 项目
Next.js 项目中有更优雅的方式:
tsx
// app/layout.tsx (App Router)
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'My App',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<head>
{/* dns-prefetch + preconnect */}
<link rel="dns-prefetch" href="https://cdn.example.com" />
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="dns-prefetch" href="https://api.example.com" />
<link rel="preconnect" href="https://api.example.com" />
{/* preload 关键字体 */}
<link
rel="preload"
href="/fonts/inter-var-latin.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
{/* preload Hero 图片 */}
<link
rel="preload"
href="/images/hero.webp"
as="image"
fetchPriority="high"
/>
</head>
<body>{children}</body>
</html>
);
}
tsx
// components/PrefetchLinks.tsx
// 在特定页面中 prefetch 下一步可能访问的路由资源
'use client';
import { useEffect } from 'react';
interface PrefetchConfig {
href: string;
as: string;
}
const PREFETCH_MAP: Record<string, PrefetchConfig[]> = {
'/': [
{ href: '/product/_chunk.js', as: 'script' },
{ href: '/cart/_chunk.js', as: 'script' },
],
'/product': [
{ href: '/cart/_chunk.js', as: 'script' },
{ href: '/checkout/_chunk.js', as: 'script' },
],
};
export function PrefetchLinks({ currentPath }: { currentPath: string }) {
useEffect(() => {
// 检查省流量模式
const conn = (navigator as any).connection;
if (conn?.saveData || /2g/.test(conn?.effectiveType || '')) return;
const configs = PREFETCH_MAP[currentPath];
if (!configs) return;
// 页面加载完成后再执行 prefetch
const timer = setTimeout(() => {
configs.forEach(({ href, as }) => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = href;
link.as = as;
document.head.appendChild(link);
});
}, 3000); // 延迟 3 秒,确保当前页面加载完成
return () => clearTimeout(timer);
}, [currentPath]);
return null;
}
8.3 Webpack 插件自动注入
如果不想手动管理,可以用 Webpack 插件自动处理:
typescript
// webpack.config.ts
import HtmlWebpackPlugin from 'html-webpack-plugin';
// 在 HtmlWebpackPlugin 的模板中使用预加载
const config = {
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
}),
// @vue/preload-webpack-plugin 或类似插件可以自动为
// 初始 chunk 添加 preload,异步 chunk 添加 prefetch
],
};
// 或者在 Vite 中,vite-plugin-preload 可以自动处理
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
// Vite 默认会为动态 import 生成 <link rel="modulepreload">
// 这是 preload 的模块版本,效果类似
],
});
九、如何用 DevTools 验证效果
配置好 Resource Hints 后,必须验证它们是否生效。以下是用 Chrome DevTools 检查的方法。
9.1 Network 面板查看
-
打开 DevTools -> Network 面板
-
刷新页面(建议用
Ctrl+Shift+R强制刷新避免缓存干扰) -
查看资源的 Initiator 列:
- 如果显示
(preload)或<link rel="preload">,说明 preload 生效 - 如果显示
(prefetch),说明 prefetch 生效
- 如果显示
-
查看 Timing 标签页(选中某个请求后点击 Timing):
yaml
DNS Lookup: 0 ms <-- 如果 dns-prefetch 生效,这里应该是 0
Connection: 0 ms <-- 如果 preconnect 生效,这里应该是 0
TLS: 0 ms <-- 如果 preconnect 生效,这里应该是 0
Waiting (TTFB): xx ms
Content Download: xx ms
9.2 Performance 面板查看
- 打开 DevTools -> Performance 面板
- 录制页面加载过程
- 在 Network 行中观察请求的时间线:
css
没有 Resource Hints 时的瀑布图:
main.css |====|
main.js |=======|
font.woff2 |===[DNS][TCP][TLS]====[下载]====|
hero.webp |===[DNS][TCP][TLS]========[下载]========|
使用 Resource Hints 后的瀑布图:
main.css |====|
main.js |=======|
font.woff2 |===============| (preload: 与 CSS 并行下载)
hero.webp |===================| (preload: 与 CSS 并行下载)
可以清晰看到瀑布流被"拍平"了
9.3 使用 Lighthouse 检查
Lighthouse 会在审计报告中指出:
- "Preconnect to required origins":建议你对哪些第三方域名添加 preconnect
- "Preload key requests":建议你 preload 哪些关键资源
- "Avoid chaining critical requests":提示关键请求链过长
9.4 通过 Resource Timing API 量化效果
typescript
// 量化 Resource Hints 的实际效果
function measureResourceTiming(): void {
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
entries.forEach(entry => {
const dns = entry.domainLookupEnd - entry.domainLookupStart;
const tcp = entry.connectEnd - entry.connectStart;
const tls = entry.secureConnectionStart > 0
? entry.connectEnd - entry.secureConnectionStart
: 0;
const ttfb = entry.responseStart - entry.requestStart;
const download = entry.responseEnd - entry.responseStart;
const total = entry.responseEnd - entry.startTime;
// dns=0 且 tcp=0 说明 preconnect 生效了
// startTime 很早说明 preload 生效了
if (dns === 0 && tcp === 0) {
console.log(`[已预连接] ${entry.name}`);
console.log(` TTFB: ${ttfb.toFixed(1)}ms, 下载: ${download.toFixed(1)}ms, 总计: ${total.toFixed(1)}ms`);
}
});
}
window.addEventListener('load', () => {
setTimeout(measureResourceTiming, 1000);
});
总结
回到开头那个 Google Fonts 加载慢的问题,最终的优化方案只需要加三行 HTML:
html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Inter" as="style">
字体的加载时间从 2.8 秒降到了 0.9 秒,白屏时间减少了近 2 秒。
最后,总结一下四个指令的使用原则:
- dns-prefetch:成本最低的优化,所有第三方域名都可以加,不用犹豫
- preconnect:对于马上要用的第三方域名(字体、CDN、API),配合 dns-prefetch 一起使用
- preload:只用于当前页面关键渲染路径上的资源,不超过 5 个
- prefetch:用于预取下一页面的资源,注意检测慢网和省流量模式
这四个指令看起来简单,但用好它们需要理解浏览器的资源发现和优先级机制。建议在每次使用后都通过 DevTools 验证效果,避免适得其反。
如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。