一、先建立整体认知
你可以把页面加载理解成:
用户打开页面
↓
浏览器解析 HTML
↓
发现 CSS / JS / 图片
↓
建立连接
↓
下载资源
↓
解析执行
而:
| 技术 | 提前做什么 |
|---|---|
| preconnect | 提前建立连接 |
| preload | 提前下载"当前页面必需资源" |
| prefetch | 提前下载"未来页面可能资源" |
你会发现:
它们优化的是不同阶段。
二、preconnect(最早阶段优化)
1、为什么它快?提前和目标服务器建立连接!
正常情况下:
发现资源
↓
DNS
↓
TCP
↓
TLS
↓
开始下载
而 preconnect:
在资源真正请求之前:
提前:
DNS + TCP + TLS
所以后面真正请求资源时:直接下载,省掉大量等待时间。
2、适用场景(极其常见)
尤其适合:
CDN
html
<link rel="preconnect" href="https://cdn.xxx.com" crossorigin>
字体服务
例如:
- Google Fonts
- 阿里字体
- 字节字体 CDN
html
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
第三方接口
例如:
- 支付
- 埋点
- 图片服务
- 视频服务
html
<link
rel="preconnect"
href="https://pay.xxx.com"
crossorigin
/>
3、注意事项
不要乱加
连接是有成本的。
浏览器:
- socket 数有限
- keep-alive 有成本
- 会占用资源
一般:
只 preconnect 最关键的域名
通常:
1~3 个
三、preload(当前页面资源提前下载)
html
<!-- 关键脚本预加载 -->
<link rel="preload" href="/iconfont.js" as="script" />
<link rel="preload" href="/config.js" as="script" />
<script src="/iconfont.js"></script>
<script src="/config.js"></script>
1、核心本质
提高资源优先级
默认浏览器:并不是立刻发现所有资源。
浏览器:只有执行到 JS,才知道需要。
而 preload:在 HTML 阶段就开始下载,性能会提升很多。
2、最重要理解
- 首屏关键但发现得晚的资源(异步路由里的字体、LCP 大图)
- 和
<script defer>配合的关键脚本 - 避免「HTML 解析到很晚才发现要下这个大文件」
3、典型场景
(1)首屏字体
html
<link
rel="preload"
href="/font.woff2"
as="font"
type="font/woff2"
crossorigin
/>
字体特别适合 preload。
因为:
默认字体发现太晚。
会导致:
FOIT/FOUT
(2)Hero 图片
首屏大图:
html
<link
rel="preload"
as="image"
href="/banner.webp"
/>
(3)关键 JS
首屏大图:
html
<link rel="preload" href="/main.js" as="script">
(4)关键 CSS
html
<link rel="preload" href="/main.css" as="style">
5、as 非常重要
很多人不会。
html
<link rel="preload" href="/main.js" as="script">
as 决定:
- 资源优先级
- Content Security Policy
- 缓存复用
- Accept 请求头
常见:
| 类型 | as |
|---|---|
| JS | script |
| CSS | style |
| 图片 | image |
| 字体 | font |
| fetch | fetch |
6、preload 最大误区
所有资源都 preload
preload:是高优先级下载
如果滥用:会抢占带宽。导致:真正关键资源反而变慢。
7、什么时候不该 preload
例如:
- 用户不一定会看到的图片
- 非首屏资源
- 路由切换后资源
这些应该:
prefetch
而不是 preload。
四、prefetch(未来资源预取)
1、核心特点
低优先级
浏览器会:
当前页面忙完
↓
网络空闲
↓
偷偷下载
因此:
不会影响当前页面。
html
<!-- 下一页可能展示的大图 -->
<link rel="prefetch" href="/images/banner-next.webp" as="image" />
<!-- 下一页字体 -->
<link rel="prefetch" href="/fonts/inter-latin.woff2" as="font" crossorigin />
<!-- 下一页可能请求的 JSON -->
<link rel="prefetch" href="/api/dashboard-summary.json" as="fetch" crossorigin />
2. react项目中预取下一页的 JS chunk
记录是否请求过某个JS chunk
TypeScript
type RouteKey =
| "alg-list"
| "alg-flow"
| "resource-auth"
| "resource-container"
| "repo-list"
| "repo-train"
| "repo-data-pool"
| "repo-data-annotation";
// 写成函数,不会立即 import 引入
const routeImportMap: Record<RouteKey, () => Promise<unknown>> = {
"alg-list": () =>
import("@/pages/algorithmApplication/components/algListPage"),
"alg-flow": () => import("@/pages/algorithmApplication/components/flowPage"),
"resource-auth": () => import("@/pages/hostAuth"),
"resource-container": () => import("@/pages/containerManage"),
"repo-list": () => import("@/pages/repository/components/modelList"),
"repo-train": () => import("@/pages/repository/components/modelTrain"),
"repo-data-pool": () => import("@/pages/imgDataPool"),
"repo-data-annotation": () => import("@/pages/dataAnnotation"),
};
const prefetched = new Set<RouteKey>();
const no_request_idle_callback_delay = 300;
/** 空闲时预取(prefetch) */
export function prefetchRoute(key: RouteKey) {
if (prefetched.has(key)) return;
prefetched.add(key);
const run = () => {
routeImportMap[key]().catch(() => {
// 预取失败不影响主流程
prefetched.delete(key);
});
};
// 当前浏览器支持 requestIdleCallback
// 检查 window 上是否存在 requestIdleCallback 这个属性。
// 有则用浏览器 API,在主线程空闲时再执行 run。
// requestIdleCallback 的作用:把预取放到"不那么忙"的时候,尽量不抢首屏渲染、交互的 CPU/网络。
if ("requestIdleCallback" in window) {
(
window as Window & { requestIdleCallback: (cb: () => void) => void }
).requestIdleCallback(run);
} else {
// 不支持,走降级方案
setTimeout(run, no_request_idle_callback_delay);
}
}
/** 立即加载(用于 hover/focus 时提速) */
export function warmupRoute(key: RouteKey) {
if (prefetched.has(key)) return;
prefetched.add(key);
routeImportMap[key]().catch(() => prefetched.delete(key));
}
export type { RouteKey };
在 src/router/index.tsx 做"首屏后预取次级路由"
TypeScript
import { prefetchRoute } from "./routePrefetch";
useEffect(() => {
// 首屏渲染后,空闲预取次级路由
prefetchRoute("alg-flow");
prefetchRoute("repo-list");
prefetchRoute("resource-auth");
}, []);
在导航交互上做"hover 即 warmup"
TypeScript
import { warmupRoute } from "@/router/routePrefetch";
// 示例:某个菜单项
<Menu.Item
key="repo-model-list"
onMouseEnter={() => warmupRoute("repo-list")}
onFocus={() => warmupRoute("repo-list")}
onClick={() => navigate("/repository/modelList")}
>
模型仓库
</Menu.Item>
| 维度 | 只有 lazy |
lazy + prefetchRoute |
|---|---|---|
| 首屏体积 | 更小(好) | 若过早 prefetch 多个页面,会多占带宽 |
| 首次进入某页 | 点击后才下载 chunk,常有 loading | 若已 prefetch,点击后几乎无等待 |
| 用户从不去的页 | 不浪费流量 | 可能白下载 |
理解:HTTP 缓存 和 ES Module 模块缓存。
第一次 import("@/pages/xxx")
- Vite 打包后对应一个 chunk URL,例如
/assets/FlowPage-xxxxx.js。 - 浏览器发起网络请求,下载 JS 文件(可走 disk/memory cache)。
- 模块执行一次,结果放进 模块实例缓存。
第二次(无论是 lazy 触发的 import,还是 prefetchRoute 里的 import)
- 同一路径、同一 chunk:通常不会重新下载(304 或 memory cache)。
- 模块本身:已加载过的模块,再次
import()会返回已 resolved 的 Promise,不会重新执行模块顶层代码(除非 HMR 热更新)。