资源加载提速四件套:dns-prefetch / preconnect / preload / prefetch 实战

前言

上周排查一个电商首页的性能问题,用户反馈"页面白屏时间太长"。打开 DevTools 一看,首屏依赖的 Google Fonts 字体文件要等 2.8 秒才开始下载------光 DNS 解析就花了 300ms,TCP 握手 150ms,TLS 协商又 200ms,真正开始传输数据已经是 650ms 之后了。而这些连接开销,本可以在浏览器空闲时提前完成。

这就是今天要聊的主题:Resource Hints(资源提示) 。浏览器提供了四个关键指令------dns-prefetchpreconnectpreloadprefetch,它们各自作用于网络请求生命周期的不同阶段,合理使用可以显著减少资源加载的等待时间。

本文会从网络连接的完整生命周期出发,逐一拆解这四个指令的原理、适用场景和踩坑经验,最后给出 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 最佳使用场景

  1. 第三方分析/监控域名:Google Analytics、百度统计、Sentry 等
  2. CDN 域名:如果你的静态资源和 API 在不同的 CDN 域名上
  3. 社交分享相关域名:微信 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 原理

preconnectdns-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 属性必须填写,它告诉浏览器资源的类型,影响:

  1. 请求优先级的分配
  2. 正确的 Accept 请求头
  3. CSP(内容安全策略)的检查
  4. 是否复用已有的缓存
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 面板查看

  1. 打开 DevTools -> Network 面板

  2. 刷新页面(建议用 Ctrl+Shift+R 强制刷新避免缓存干扰)

  3. 查看资源的 Initiator 列:

    • 如果显示 (preload)<link rel="preload">,说明 preload 生效
    • 如果显示 (prefetch),说明 prefetch 生效
  4. 查看 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 面板查看

  1. 打开 DevTools -> Performance 面板
  2. 录制页面加载过程
  3. 在 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 秒。

最后,总结一下四个指令的使用原则:

  1. dns-prefetch:成本最低的优化,所有第三方域名都可以加,不用犹豫
  2. preconnect:对于马上要用的第三方域名(字体、CDN、API),配合 dns-prefetch 一起使用
  3. preload:只用于当前页面关键渲染路径上的资源,不超过 5 个
  4. prefetch:用于预取下一页面的资源,注意检测慢网和省流量模式

这四个指令看起来简单,但用好它们需要理解浏览器的资源发现和优先级机制。建议在每次使用后都通过 DevTools 验证效果,避免适得其反。

如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。

相关推荐
豹哥学前端3 小时前
JavaScript 异步编程完全指南:从回调地狱到 async/await,一次通关
前端·javascript·面试
kyriewen3 小时前
面试官让我手写Promise,我打开Cursor三秒生成,他愣了两秒说“你过了”
前端·javascript·面试
Bacon3 小时前
RAG 从入门到入土:Agent 时代,你的检索增强生成到底行不行?
前端·人工智能
软件开发技术深度爱好者3 小时前
HTML实现DOCX文档版题库图文考试系统(修订)
前端·javascript·html
宁雨桥3 小时前
从跨项目预览到分层架构:一次 `postMessage` 封装的深度思考
前端·架构·postmessage
蝎子莱莱爱打怪3 小时前
我花两年业余时间做了个IM系统,然后呢😂??
后端·flutter·面试
问征夫以前路3 小时前
Promise知识点回顾
前端·javascript
努力成为AK大王4 小时前
Java并发线程核心知识(一)
java·开发语言·面试
拓荒牛儿4 小时前
前端内存可观测实践
前端