在 Vue 3 单页应用里,路由组件通常会使用动态 import 做按需加载。这样做压缩了首屏体积,却把压力转移到了路由切换阶段。用户点击菜单后,浏览器需要先下载对应 chunk、解析、执行,再渲染组件,移动端弱网下可能产生明显白屏。预取指令的本质,是提前把后续可能用到的资源放进缓存,让真正的请求命中缓存从而减少等待。

这里的关键词是工程化:不能只靠手写几个 link 标签,而要形成一个可以随路由、组件状态变化的机制。预取指令接收资源地址或动态导入函数,在浏览器空闲时创建资源提示,同时具备去重、取消和并发限制能力。
一、先厘清预取和预加载的边界
预取(prefetch)与预加载(preload)经常被混在一起。preload 表示当前页面马上要用的资源,优先级高,浏览器通常立即请求;prefetch 表示未来可能需要的资源,优先级低,浏览器会在空闲时请求。Vue 3 工程化里的预取指令应该默认使用 prefetch 语义,因为它处理的是后续路由或交互可能用到的 chunk,而不是当前渲染必需的资源。直接引入 <link rel="preload"> 会抢占带宽,可能拖慢当前页面关键资源。
在单页应用中,路由 A 跳转到路由 B 时,真正阻塞渲染的是 B 对应的 JavaScript chunk。假设这个 chunk 有 380 KB,4G 网络下可能耗时 300 到 800 毫秒。如果用户停留在 A 页面超过 2 秒,浏览器在这段时间基本空闲,预取指令完全有机会把 B 的 chunk 提前下载。等用户点击跳转,模块已经进入 HTTP 缓存或内存缓存,路由渲染时间能明显缩短。
但并非所有资源都适合预取。首屏关键资源应该走 preload 或直接内联;低频访问的后台页面、需要登录才能访问的敏感数据接口,不适合在未确认意图前预取。工程化预取指令需要保留开关和条件判断能力,避免在用户流量敏感或设备性能较弱的场景下造成浪费。
二、实现一个可复用的 v-prefetch 指令
Vue 3 的自定义指令生命周期和组件生命周期一致,适合承载预取这类副作用逻辑。我们可以把指令绑定到触发预取的容器上,例如一个卡片、菜单项或路由页面。指令接收到地址数组后,不立即请求,而是交给空闲调度器统一处理。
const prefetchQueue = new Set();
let idleHandle = null;
function prefetch(url) {
if (!url || prefetchQueue.has(url)) return;
prefetchQueue.add(url);
schedule();
}
function schedule() {
if (idleHandle) return;
const task = () => {
idleHandle = null;
const next = prefetchQueue.values().next();
if (next.done) return;
const url = next.value;
prefetchQueue.delete(url);
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
link.as = url.endsWith('.js') ? 'script' : url.endsWith('.css') ? 'style' : '';
document.head.appendChild(link);
schedule();
};
if ('requestIdleCallback' in window) {
idleHandle = window.requestIdleCallback(task, { timeout: 2000 });
} else {
idleHandle = window.setTimeout(task, 200);
}
}
上面的调度器用 requestIdleCallback 或 setTimeout 垫底,逐个取出 URL 并创建 <link> 元素。通过 Set 去重可以避免同一资源被多次插入。需要注意的是,<link> 元素的 rel 属性要设置为 prefetch;如果资源类型明确,建议同时设置 as 属性,例如 script 或 style,帮助浏览器更准确地分配优先级。
自定义指令在 mounted 和 updated 阶段读取 binding.value,可以传入字符串,也可以传入数组。清理函数保存在 el 属性上,避免组件卸载后继续触发预取。路由页面或菜单项使用 v-prefetch 时,不需要关心底层调度逻辑,只要把目标资源列表传进去即可。
import { ref, onMounted, onBeforeUnmount } from 'vue';
const vPrefetch = {
mounted(el, binding) {
const urls = Array.isArray(binding.value) ? binding.value : [binding.value];
el.__prefetchCleanup = createPrefetch(urls);
},
updated(el, binding) {
if (el.__prefetchCleanup) el.__prefetchCleanup();
const urls = Array.isArray(binding.value) ? binding.value : [binding.value];
el.__prefetchCleanup = createPrefetch(urls);
},
unmounted(el) {
if (el.__prefetchCleanup) el.__prefetchCleanup();
}
};
function createPrefetch(urls) {
const validUrls = urls.filter(Boolean);
validUrls.forEach(prefetch);
return () => {
validUrls.forEach((url) => prefetchQueue.delete(url));
};
}
<div v-prefetch="['/js/report.js', '/css/report.css']"> <button @click="openReport">查看报表</button> </div>
这套指令可以单独使用,也可以作为更复杂工程化方案的基础层。下一步要做的是让它识别路由级别的资源,而不是只在模板里写死地址。
三、把预取指令接入路由与构建链路
单独使用指令只能覆盖手动交互场景,真正的工程化还需要与 Vue Router 协同。路由的 meta 字段很适合声明每个页面需要预取的资源或动态导入函数。beforeResolve 钩子会在导航完成前触发,适合处理页面级预取;而 afterEach 更适合处理下一级页面预取。我们可以设计两套规则:当前路由组件自身的资源用 preload 语义立即请求;子路由或相关页面的资源用 prefetch 语义空闲请求。
构建工具也能直接参与预取。Webpack 的动态 import 支持 webpackPrefetch 注释,Vite 则基于 Rollup 对动态导入做代码分割。webpackPrefetch 会向 HTML 注入 <link rel="prefetch">,但它通常是静态的;自定义指令则能在运行时根据用户行为动态决定是否预取,更灵活。
const routes = [
{
path: '/dashboard',
component: Dashboard,
meta: {
prefetch: [
() => import('./views/ReportPanel.vue'),
() => import('./views/ChartPanel.vue')
]
}
},
{
path: '/settings',
component: () => import('./views/Settings.vue'),
meta: { prefetch: ['/css/settings.css'] }
}
];
对于首屏性能要求较高的项目,可以把体积较大的第三方库单独拆包。例如把 ECharts 拆到 charts chunk,编辑器拆到 editor chunk。路由 meta 中只声明这些 chunk 的地址或导入函数,预取指令在用户悬停、触碰或进入相关区域时提前下载。这样既避免首屏加载过重,又能在真正需要时快速呈现。
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
charts: ['echarts', 'vue-echarts'],
editor: ['monaco-editor']
}
}
}
}
};
路由预取逻辑可以封装成一个独立的模块,在 beforeResolve 中读取 meta.prefetch 并逐个执行动态导入。预取失败时不能阻断导航,因为真实加载仍然会发生。这样业务代码和预取策略完全解耦,后续调整预取范围只需要修改路由配置或指令绑定。
四、控制预取边界:并发、取消与降级
预取不是越多越好。浏览器对同一域名的并发连接数有限制,如果预取指令一次性发起十几个请求,反而会阻塞当前页面正常的接口请求和图片加载。因此调度器需要设置优先级和并发上限。前面示例中的队列是串行执行,更复杂场景可以设计并发池,比如同时最多处理 3 个预取请求,剩余任务等空闲时继续。
页面不可见时应该暂停预取。浏览器切到后台,或者用户已经完成主要操作,预取的意义已经不大。监听 visibilitychange 事件,当 document.hidden 为 true 时暂停调度,恢复可见时继续。还可以利用 navigator.connection 的 effectiveType 和 saveData 判断网络环境,在 2G 或省流量模式下直接关闭预取。
预取失败不应影响业务。动态导入函数被预取时可以使用 then 提前加载,也可以用 catch 静默处理。真正的组件加载仍由 Vue Router 的正常流程触发,即使预取缓存未命中,也只是退回原有的按需加载。通过 Performance 面板或自定义埋点对比预取前后的路由切换耗时,可以验证效果并调整预取范围。
router.beforeEach((to, from, next) => {
const start = performance.now();
to.meta.__navigateStart = start;
next();
});
router.afterEach((to) => {
const start = to.meta.__navigateStart;
if (start) {
const cost = performance.now() - start;
console.log(`route ${to.path} cost ${cost.toFixed(0)}ms`);
}
});
一个完整的工程化 PREFETCH 预取指令方案,需要同时考虑资源优先级、路由上下文、构建产物、网络环境和用户体验。它不是一次性把全部资源塞给浏览器,而是用指令和策略在空闲时段把未来可能用到的模块提前下载。做好边界控制后,路由切换的感知等待会明显下降,同时不会牺牲首屏性能和用户流量。

