一、前言
最近在一个 Vite + React 项目打包部署的过程中遇到了如下图所示的警告,提示打包产物中单个文件大小超出了默认 500kb 的限制。可以看到打包产物中只有一个 html/css/js 文件,其中包含了整个应用程序的所有代码,体积庞大也是理所当然。
随着打包产物单个文件的体积越来越庞大,会出现一系列应用加载性能问题,如首页出现长时间白屏,严重影响用户体验。
针对这个问题 Vite 也给出了三种解决方案:
- 使用动态导入进行代码分割
- 修改
manualChunks
配置进行模块分割 修改单个文件大小限制(指标不治本因此本文不介绍)
二、解决方案
1. 动态导入
使用动态导入进行代码分割的好处在于实现了按需加载 ,如首页加载时不再需要加载其它页面中尚未涉及到的资源文件,而是只有当组件实际渲染时,对应的代码才会被加载,从而优化加载性能。
1.1 基于组件的动态导入 ------ React 异步组件
将包含大量依赖的复杂组件进行拆分能在 React 中基于组件的动态导入是通过异步组件来实现的,具体例子可以看下面的代码:
tsx
export default function PostDetailPage() {
const PostDetail = React.lazy(() => import("@/components/PostDetail"));
return (
<React.Suspense fallback={<Loading/>}>
<PostDetail/>
</React.Suspense>
);
}
其中lazy
用于动态导入,需要传入一个工厂方法,该方法返回一个Promise
,而import()
是 ES Module 中提供的用于动态导入模块的方法,其同样返回一个Promise
。
异步组件渲染需要通过Suspense
组件来实现,children
自然是传入使用lazy
方法动态导入的异步组件,通过fallback
还可以指定异步加载过程中需要展示的内容,如加载动画等。
如果组件内部还需要异步加载数据,也可以将这一部分逻辑写在lazy
中:
tsx
export default function PostDetailPage() {
const [data, setData] = useState();
const PostDetail = React.lazy(async () => {
const newData = await fetchData(); // 异步加载数据
setData(newData);
return await import("@/components/PostDetail");
});
return (
<React.Suspense fallback={<Loading/>}>
<PostDetail data={data}/>
</React.Suspense>
);
}
同样也可以借助 react-loadable 库来实现异步组件渲染:
tsx
export default function PostDetailPage() {
const LoadablePostDetail = Loadable.Map({
loader: {
component: () => import("@/components/PostDetail"),
data: () => fetchData(),
},
loading() {
return (
<Loading/>
);
}
render(loaded) {
const PostDetail = loaded.component.default;
const data = loaded.data;
return (
<PostDetail data={data}/>
);
},
});
return (
<LoadablePostDetail/>
);
}
其中loader
可以指定多个异步加载数据源,loading
指定异步加载过程中展示的内容,render
渲染异步加载完成后的组件,异步数据源加载完成的结果将通过参数传入。
1.2 基于路由的动态导入(React Router v6)
基于组件的动态导入方法粒度小,能实现更灵活的代码分割,如果只是需要针对不同页面进行大粒度的代码分割,则可以采用基于路由的动态导入方法。基于 React Router v6 的代码示例如下:
tsx
const router = createBrowserRouter([
{
path: "/:userId",
async lazy() {
const {default: PostPage} = await import("@/pages/PostPage");
return {Component: PostPage};
},
},
{
path: "/:userId/post/:postId",
async lazy() {
const {default: PostDetailPage} = await import("@/pages/PostDetailPage");
return {Component: PostDetailPage};
},
},
]);
export default function App() {
return (
<RouterProvider router={router}/>
);
}
其实也是对 React 异步组件进行了一层封装而已。
再次打包看一下效果:
可以看到每个异步加载的组件都被打包成了单个文件,从而减少了文件的大小。
2. 自定义模块分割策略
若项目中包含大量依赖包,也可能导致打包产物单个文件过大,此时可以通过将依赖模块拆分到不同的文件来解决。Vite/Rollup 提供了 manualChunks 配置可以自定义模块分割策略。
ts
// vite.config.ts
import {defineConfig} from "vite";
export default defineConfig({
// 此处省略其它配置项
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("/node_modules/")) {
const module = id.split("/node_modules/")[1].split("/")[1];
const map: Record<string, string> = {
// "antd": "antd",
"@ant-design": "ant",
"@arco-design": "arco",
"ahooks": "ahooks",
"lodash": "lodash",
"d3": "d3",
"lucide": "lucide",
};
for (const key of Object.keys(map)) {
if (module.startsWith(key)) {
return map[key];
}
}
}
},
},
},
},
});
manualChunks
可以指定一个方法,传入参数为拆分模块的完整路径(如下图所示),当方法返回一个字符串时该模块被打包到文件名以该字符串为开头的文件中,否则采用默认模块分割策略。
可以看到打包产物中@ant-design
和@arco-design
等依赖模块被打包到了对应的单个文件中:
然而自定义模块拆分策略可能导致一些问题:
- 打包产物由于依赖缺失或循环依赖等问题无法正常运行
- 单个依赖模块本身过于庞大也会导致打包产物单个文件体积过大
这些问题我目前还没有找到较好的解决方案,后续解决后会继续进行分享。