前言
在现代前端开发中,Vite 和 Vue 的结合已经成为构建高性能、模块化应用的主流选择。Vite 的热模块替换(HMR)和快速冷启动能力,加上 Vue 的响应式系统和组件化设计,为开发者提供了无与伦比的开发体验。然而,随着项目复杂度的增加和动态模块加载的广泛应用,开发者可能会遇到一些令人头疼的问题,比如 TypeError: Failed to fetch dynamically imported module
。
在本文中,我们将深入探讨这个报错的成因,无论你是初学者还是资深开发者,理解动态导入的机制和常见问题的解决方法,都是提升开发效率和项目健壮性的关键一步。接下来,让我们一起揭开这个报错的神秘面纱,探索 Vite 和 Vue 的动态模块加载世界。
错误产生的原因
这个错误是在import()
语法动态导入一个ECMAScript
模块,在浏览器加载这个模块失败时抛出的,有可能这个模块本身就不存在,也有可能是网络原因导致模块加载失败。
import()动态导入
- 基本语法
typescript
import(moduleName)
在引用的模块不存在时,浏览器会抛出 TypeError
异常。参考MDN动态导入import()
项目中使用到这个语法的地方
- 路由懒加载
可以根据用户的导航动态加载对应的路由组件,而不是在应用启动时加载所有组件
typescript
{
name: 'PageA',
path: 'page-a',
component: () => import('src/views/page-a.vue')
}
- 功能模块按需加载
只有当用户触发某个功能时,才加载相关的模块代码
typescript
// 动态导入
const loadSomeModule = async () => {
const someModule = await import('./SomeModule.js');
return someModule.default
};
使用import()
语法目的主要是为了不在程序启动时加载所有模块。这种方式可以显著减少初始加载时间,提高应用的性能。
Vite打包后是如何处理import语法的?
通过npx create-vue@latest
命令,快速生成一个Vue + Vite
的项目,然后运行npm install && npm run build
来看一下最终打包的结果到底是什么样子的。
可以看到路由router.js
打包后的产物是下面这样的
javascript
// _vite_Deps
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/HomeView-w0ePAOjH.js","assets/HomeView-BFujd8Wq.css","assets/AboutView-9y6kMFrI.js","assets/AboutView-DNtNIo95.css"])))=>i.map(i=>d[i]);
const router = createRouter({
history: createWebHistory("/"),
routes: [
{
path: "/",
name: "home",
component: () => __vitePreload(() => import("./HomeView-w0ePAOjH.js"), true ? __vite__mapDeps([0,1]) : void 0)
},
{
path: "/about",
name: "about",
component: () => __vitePreload(() => import("./AboutView-9y6kMFrI.js"), true ? __vite__mapDeps([2,3]) : void 0)
}
]
});
可以看到使用的预加载语法import()
被保留了下来,而且还在他外面套了一层函数__vitePreload
,这个函数就是Vite
预加载资源的核心逻辑,接下来一起看下这个函数的代码。 其中__vite__mapDeps
代表这个路由模块所依赖的其他的一些其他模块资源,包括css
和js
javascript
const scriptRel = "modulepreload";
const assetsURL = function(dep) {
return "/" + dep;
};
const seen = {};
/**
* 预加载
* @param {*} baseModule import语法动态导入的模块
* @param {*} deps 模块依赖的其他js和css资源
* @return Promise
*/
const __vitePreload = function preload(baseModule, deps, importerUrl) {
let promise = Promise.resolve();
if (deps && deps.length > 0) {
document.getElementsByTagName("link");
const cspNonceMeta = document.querySelector(
"meta[property=csp-nonce]"
);
const cspNonce = (cspNonceMeta == null ? void 0 : cspNonceMeta.nonce) || (cspNonceMeta == null ? void 0 : cspNonceMeta.getAttribute("nonce"));
// 使用Promise.allSettled 等待所有的模块都处理完成
promise = Promise.allSettled(
deps.map((dep) => {
dep = assetsURL(dep);
if (dep in seen) return;
seen[dep] = true;
const isCss = dep.endsWith(".css");
const cssSelector = isCss ? '[rel="stylesheet"]' : "";
// 如果页面已经存在资源,则跳过
if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) {
return;
}
// 创建link标签
const link = document.createElement("link");
// css直接通过stylesheet加载, js通过modulepreload进行预加载
link.rel = isCss ? "stylesheet" : scriptRel;
if (!isCss) {
link.as = "script";
}
link.crossOrigin = "";
link.href = dep;
if (cspNonce) {
link.setAttribute("nonce", cspNonce);
}
// 添加到head标签中
document.head.appendChild(link);
if (isCss) {
// CSS 加载完毕后再执行,避免发生 FOUC 现象
return new Promise((res, rej) => {
link.addEventListener("load", res);
// 加载失败 抛出错误
// 这就是为什么线上有时候也会有 Unable to preload CSS for这类报错了
link.addEventListener(
"error",
() => rej(new Error(`Unable to preload CSS for ${dep}`))
);
});
}
})
);
}
function handlePreloadError(err) {
// 创建一个vite:preloadError事件
const e = new Event("vite:preloadError", {
cancelable: true
});
e.payload = err;
// 触发事件
window.dispatchEvent(e);
if (!e.defaultPrevented) {
// 如果事件没有被调用event.preventDefault(),则抛出错误
throw err;
}
}
return promise.then((res) => {
for (const item of res || []) {
// 遍历所有资源的加载结果
if (item.status !== "rejected") continue;
// 加载失败,调用handlePreloadError处理错误
handlePreloadError(item.reason);
}
// 依赖资源都处理完成,执行基础模块import()导入
return baseModule().catch(handlePreloadError);
});
};
如何处理这个错误
一把梭处理
按照Vite官方文档给出的方案,直接刷新页面
javascript
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
window.location.reload()
})
分场景处理
1、功能模块的处理
动态导入功能模块可能有以下两个原因
- 用户点击了某个按钮触发
- 拆包, 将一些不是首屏必须的包放到页面加载完成之后再加载,加快首屏渲染速度
javascript
import('.someModule').then(module => {
// 导入成功
}).catch(e => {
// 导入失败
// 可以根据业务 可以Toast提示 或者其他的操作
})
1、路由模块的处理
针对路由模块也可以有两种处理方式
- 监听router.onError事件
监听到加载报错后,直接使用location.href
导航到目标页面
javascript
router.onError((error, to) => {
const errors = ['Failed to fetch dynamically imported module', 'Unable to preload CSS'];
if (errors.some((e) => error.message.includes(e))) {
window.location.href = to.fullPath;
}
});
- 如果异步路由组件加载失败, 降级显示一个加载失败提示组件
这个降级组件具体显示的内容可以根据业务场景决定
javascript
import { createRouter, createWebHistory } from 'vue-router'
import { h } from 'vue'
// 失败提示组件
const ErrorComponent = {
render() {
return h('div', '天哪,网络似乎出了点小问题~,找一个网好的地方试试?')
}
}
function injectErrorComponent(importFun) {
return () => importFun().catch(() => {
return ErrorComponent
})
}
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: injectErrorComponent(() => import('../views/HomeView.vue')),
},
{
path: '/about',
name: 'about',
component: injectErrorComponent(() => import('../views/AboutView.vue')),
},
],
})
export default router
结语
没有最好的方案,只有最适合的方案❤️。