懒加载没懒成?Nuxt 动态 import 自动 prefetch 的坑
最近我一直在做站点的性能优化。毕竟在线工具和博客都跑在阿里云的一台3M小水管小机器上,换算下来理论峰值 384 KB/s,实际也就是 300 KB/s 的水平。
最大的工作是给首屏减负,把不需要执行的 JS 从首屏上拿掉。毕竟首屏多下 200 KB,用户就得多等大半秒。现在用不上,那就等真用到时再下:
const { renderLua } = await import('~/lib/format-lua')
改完后 F12 一看。不对劲。
我什么都没点,那个触发才下载的 JS 还是被下载了,只是 Priority = Lowest。
背景
这里先说明一下。
目前使用版本 Node v24.18.0、Nuxt 4.5.2 、pnpm 11.15.1
Nuxt 项目打包之后,代码会被切成很多个 .js 文件,也就是常说的 chunk。一个页面不会用到全部 chunk,所以打包器要在 HTML 里留几个标记,告诉浏览器「这个文件待会儿要用,先去拿来」。
负责留标记的是一类 <link> 标签,常用的有两种:
<link rel="modulepreload" href="/_nuxt/xxx.js">
<link rel="prefetch" href="/_nuxt/xxx.js">
modulepreload 的意思是「这个文件我现在就要用」。浏览器解析到这一行,立刻用高优先级去下载,跟首屏的 CSS、字体抢带宽。
prefetch 的意思是「这个文件以后可能会用」。浏览器先把首屏的事干完,等网络闲下来,再用低优先级慢慢下。
两个标签的区别不在「下不下」,而在「什么时候下、抢不抢带宽」。两者最终都会把文件拿到本地,只是 modulepreload 要跟首屏抢,prefetch 排在队尾。
而我写的是 await import(),既没有静态导入,也不该在首屏用到。却在 generate 时被转换成了 prefetch:
<link rel="prefetch" href="/_nuxt/Dxxxxxxx.js">
复现
我新建了一个 Nuxt 项目,分别把动态导入的几种写法拆成几个独立页面,每个页面都配了一个体积相同的200 KB载荷。项目代码放在文章末尾了。
| 页面 | 写法 |
|---|---|
/clean/ | 什么都不导入,基线 |
/link-modulepreload/ | 顶层静态 import |
/link-prefetch/ | <link rel="prefetch"> |
/await-import-prefetch/ | 函数内 await import()(一级) |
/await-import-nested/ | 页面 → 一级 mid → 二级载荷 |
/async-component/ | defineAsyncComponent,无条件渲染 |
/async-component-conditional/ | defineAsyncComponent + v-if |
pnpm gen # 生成载荷,必须先跑
pnpm generate
pnpm analyze
npx --yes http-server .output/public -p 6521 -c-1

实测
先看基线。
/clean/ 什么都没导入,理论上应该最干净。实测下来它仍然下了两个文件:

4.0 KB 和 3.7 KB,Priority 都是 Lowest。这是 Nuxt 内置的 error-404 和 error-500,被 prefetch 到了每一个页面上,七个页面一个不落。它们是 Nuxt 的固定开销,后面所有页面的数字里都包含这 7.7 KB。
然后看关键的那一页。
/await-import-prefetch/ 里,那个 200 KB 的载荷是这样导入的:
async function load() {
const mod = await import('~/lib/payload-dynamic')
output.value = mod.payloadRender()
}
它挂在一个按钮上。用户不点,这段代码就不会执行。
打开页面,什么都不点,等三秒:

那个 178 KB 的 chunk 已经在列表里了,Type 是 javascript,Priority 是 Lowest。
我用 await import() 的本意是「用到才下」。但页面刚一空闲,浏览器就把它下完了。
点击按钮,同一个文件会以另一种身份再出现一次:

同一个文件 Dxxxxxxx.js,这次的 Type 是 script,Priority 变成 High。
这说明 prefetch 并未替代这次加载,它只是把文件提前放进了缓存。用户真正触发时,浏览器仍会走完整的加载流程。
省下的是等待,没省下的是流量。
异步组件也绕不开
那不用 await import(),改用异步组件呢?
const PayloadAsync = defineAsyncComponent(() => import('~/components/PayloadAsync.vue'))
<PayloadAsync />
实测:

178 KB,Type 是 script,Priority 是 High。
比 prefetch 更狠。它不是等网络空闲,而是和首屏的 CSS、字体一起抢带宽。我的理解是:这个组件在服务端渲染时确实被渲染了,Nuxt 判断它「首屏就要用」,于是用最高优先级预加载,省掉客户端水合时再等一次。
那给它加个 v-if,让它别在首屏渲染会怎样?
<PayloadAsync v-if="show" />

Priority 从 High 降到了 Lowest。
但页面总传输量还是 373 KB,和上一个页面一模一样。我没点按钮,那 178 KB 已经下完了。
点一下按钮,它才真正被用上:

所以 v-if 只解决了「抢首屏带宽」,没解决「白下」。异步组件这条路,绕不开。
三组对照
七个页面里,有三组可以两两对比。对比出来的结论比单看一页更有意思。
一、静态导入 vs 异步组件
| 页面 | 载荷 | Type | Priority |
|---|---|---|---|
/link-modulepreload/ | 178 KB | script | High |
/async-component/ | 178 KB | script | High |

两者情况一样。
静态导入走 modulepreload 理所当然。问题在于后面那一行:一个叫「异步」的组件,只要它在首屏渲染了,Nuxt 就把它当首屏资源处理,「异步」这两个字等于白叫。
二、手写的 prefetch vs 框架注入的 prefetch
| 页面 | 载荷 | Type | Priority |
|---|---|---|---|
/link-prefetch/ | 208 KB | javascript | Lowest |
/await-import-prefetch/ | 178 KB | javascript | Lowest |

这一组验证一件事:Nuxt 注入的 prefetch 标签,和手写的有没有区别。
结论是没有。两者的 Type 都是 javascript,Priority 都是 Lowest,加载时机也一致。
也就是说,Nuxt 注入的这一行,与手写的 <link rel="prefetch"> 等价,区别只在于前者由构建过程自动完成,后者由开发者显式声明。
三、一级动态 import vs 条件渲染的异步组件
| 页面 | 载荷 | Type | Priority | 页面总传输 |
|---|---|---|---|---|
/await-import-prefetch/ | 178 KB | javascript | Lowest | 372 KB |
/async-component-conditional/ | 178 KB | javascript | Lowest | 373 KB |
两条路的结果一致。
await import() 和 defineAsyncComponent + v-if 的区别只在语法层面。只要被导入的模块挂在「一级」,结果都是零交互下先下载 178 KB,仅优先级为 Lowest。
更换写法并不能规避。
七页放在一起看
| 页面 | 载荷 | Type | Priority | 页面总传输 |
|---|---|---|---|---|
/clean/ | — | — | — | 194 KB |
/link-modulepreload/ | 178 KB | script | High | 372 KB |
/link-prefetch/ | 208 KB | javascript | Lowest | 402 KB |
/await-import-prefetch/ | 178 KB | javascript | Lowest | 372 KB |
/await-import-nested/ | 0.6 KB | javascript | Lowest | 195 KB |
/async-component/ | 178 KB | script | High | 373 KB |
/async-component-conditional/ | 178 KB | javascript | Lowest | 373 KB |
几点结论:
一级 await import() 的懒加载不成立。 零交互下 178 KB 即被下载,仅优先级为 Lowest。
二级动态导入成立。 页面总传输 195 KB,与基线基本持平。
v-if 只改变优先级,不改变流量。
异步组件无条件渲染时,行为等同于静态导入。
**另有固定开销:**error-404 与 error-500 两个 chunk 被 prefetch 到全部七个页面,每页 7.7 KB。
对照引言中那台 3M 带宽的机器:178 KB 约合半秒以上的等待,7.7 KB 则是每个页面都要重复支付的一次成本。
规避:往下藏一层
现象陈述完毕,下面说规避方式。
一级会被补 prefetch,那二级呢?
我把载荷往深处挪了一层。页面不再直接导入那个 200 KB 的模块,而是导入一个很小的中间模块,中间模块内部再去导入载荷:
// 页面
async function load() {
const mid = await import('~/lib/nested-mid')
output.value = await mid.loadLeaf()
}
// nested-mid.ts
export async function loadLeaf() {
const mod = await import('./payload-nested')
return mod.payloadRender()
}
同样不做任何操作,等待三秒:

一级那个中间模块(0.6 KB)仍被 prefetch,但二级的 178 KB 连请求都没有发出。
点击按钮后才会加载:

再对比页面总传输量:
| 页面 | 总传输 |
|---|---|
/clean/(基线) | 194 KB |
/await-import-nested/ | 195 KB |
/await-import-prefetch/ | 372 KB |
二级嵌套的页面只比基线多出 1 KB,就是那个中转模块的体积。而一级动态导入的页面,多出 178 KB。
我目前的处理方式是把体积大的模块下移到二级。中间那层转发模块很小,即使被 prefetch 影响也有限。
关于这个问题的记录
最后我查了一下 GitHub,发现这不是新问题:
- #18376 Disable
prefetchfor dynamic imports(2023-01-20 提出,2026-05-05 关闭) - #14584 optimisations for prefetching chunks(2022-08-15 提出,2026-09-06 关闭)
- #36391 unified client prefetch scheduler(2026-09-23 合并,目标版本 5.x)
#18376 描述的场景和本文遇到的一模一样。上游近期把客户端预取的调度统一到了一套队列(#36391),但它调整的是预取的秩序,不是「要不要预取动态 import」,是否影响本文描述的现象尚不确定,需要持续观察。
在此之前,可行的做法仍是自行处理:把大体积模块放到第二层动态 import,第一层只保留一个转发用的空壳。
相关代码
相关代码在 https://gitee.com/Megawatt/blog-code-pool 的 nuxt-prefetch-lazy-load/ 目录下。
