懒加载没懒成?Nuxt 动态 import 自动 prefetch 的坑

#Nuxt#性能优化#前端

最近我一直在做站点的性能优化。毕竟在线工具和博客都跑在阿里云的一台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 异步组件

页面载荷TypePriority
/link-modulepreload/178 KBscriptHigh
/async-component/178 KBscriptHigh

两者情况一样。

静态导入走 modulepreload 理所当然。问题在于后面那一行:一个叫「异步」的组件,只要它在首屏渲染了,Nuxt 就把它当首屏资源处理,「异步」这两个字等于白叫。

二、手写的 prefetch vs 框架注入的 prefetch

页面载荷TypePriority
/link-prefetch/208 KBjavascriptLowest
/await-import-prefetch/178 KBjavascriptLowest

这一组验证一件事:Nuxt 注入的 prefetch 标签,和手写的有没有区别。

结论是没有。两者的 Type 都是 javascript,Priority 都是 Lowest,加载时机也一致。

也就是说,Nuxt 注入的这一行,与手写的 <link rel="prefetch"> 等价,区别只在于前者由构建过程自动完成,后者由开发者显式声明。

三、一级动态 import vs 条件渲染的异步组件

页面载荷TypePriority页面总传输
/await-import-prefetch/178 KBjavascriptLowest372 KB
/async-component-conditional/178 KBjavascriptLowest373 KB

两条路的结果一致。

await import() 和 defineAsyncComponent + v-if 的区别只在语法层面。只要被导入的模块挂在「一级」,结果都是零交互下先下载 178 KB,仅优先级为 Lowest。

更换写法并不能规避。

七页放在一起看

页面载荷TypePriority页面总传输
/clean/———194 KB
/link-modulepreload/178 KBscriptHigh372 KB
/link-prefetch/208 KBjavascriptLowest402 KB
/await-import-prefetch/178 KBjavascriptLowest372 KB
/await-import-nested/0.6 KBjavascriptLowest195 KB
/async-component/178 KBscriptHigh373 KB
/async-component-conditional/178 KBjavascriptLowest373 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 prefetch for 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/ 目录下。