前端代码分割技术综述与实战:从路由懒加载到 Vite/Webpack 分包

前端代码分割技术综述与实战:从路由懒加载到 Vite/Webpack 分包

前端代码分割,英文通常叫 Code Splitting,核心思想很简单:

不要把所有 JS、CSS、第三方依赖一次性塞进首屏,而是把代码拆成多个 chunk,让用户在真正需要时再加载。

对于现代前端应用,尤其是 React/Vue 单页应用、中后台系统、低代码平台、文档阅读器、图表报表系统,代码分割几乎是性能优化的必修课。否则一个用户只是打开登录页,却被迫下载图表库、富文本编辑器、PDF
解析器、管理后台、报表模块,这就像去楼下买瓶水,结果背了一台冰箱出门。

本文分两部分:

  1. 综述篇:系统梳理常见代码分割技术。
  2. 实战篇:分别用 React/Vite、Vue/Vite、Webpack 给出可落地配置。

一、为什么需要代码分割?

前端性能问题通常不是单一原因造成的。代码分割主要解决的是这几类问题:

问题 代码分割能否改善 说明
首屏 JS 体积太大 减少首次下载和解析执行的代码
用户访问一个页面却下载所有页面代码 路由级懒加载可以解决
大型第三方库污染主包 vendor 分包、大组件懒加载可以解决
业务代码频繁变更导致缓存失效 vendor 和 runtime 独立后缓存更稳定
接口慢 不能直接解决 需要接口优化、缓存、SSR 等
图片太大 不能直接解决 需要图片压缩、懒加载、CDN
组件渲染慢 不能直接解决 需要虚拟列表、memo、调度优化
主线程长任务 部分改善 可配合 Web Worker/WASM 按需加载

代码分割的收益主要体现在:

  • 减少首屏资源体积。
  • 减少无关 JS 的解析、编译和执行。
  • 提高浏览器缓存命中率。
  • 让大型模块按需加载。
  • 为后续微前端、插件化、模块化架构打基础。

二、常见代码分割技术综述

1. 路由级代码分割

这是最常见、收益最大的代码分割方式。

例如一个后台系统有这些页面:

1
2
3
4
5
6
/login
/dashboard
/user
/order
/report
/system

用户第一次进入 /dashboard,理论上不需要下载 /report/system/order 的页面代码。

所以我们可以把不同路由拆成不同 chunk:

1
2
3
4
5
6
7
main.js
runtime.js
dashboard.js
user.js
order.js
report.js
system.js

适用场景:

  • 中后台系统。
  • 多页面 SPA。
  • 管理平台。
  • SaaS 控制台。
  • 移动端 H5。

优先级:最高


2. 组件级代码分割

不是所有重代码都在页面级别。有些组件本身就很重,例如:

  • 富文本编辑器。
  • Markdown 编辑器。
  • Monaco Editor。
  • ECharts / G2 / D3 图表。
  • PDF 阅读器。
  • 地图组件。
  • 大型表单设计器。
  • 复杂弹窗。

这些组件不应该进入首屏包,而应该在用户真正打开时再加载。

典型例子:

1
2
const RichTextEditor = lazy(() => import('@/components/RichTextEditor'));
const ChartPanel = lazy(() => import('@/components/ChartPanel'));

适用场景:

  • 用户不一定会打开。
  • 体积明显偏大。
  • 首屏不需要。
  • 依赖重,例如图表、编辑器、PDF、地图。

3. 动态 import

import() 是现代前端代码分割的基础能力。

1
2
const module = await import('./heavy-module');
module.run();

构建工具看到动态 import() 后,通常会自动把对应模块拆成异步 chunk。

它适合:

  • 点击按钮后加载某个功能。
  • 根据权限加载某个模块。
  • 根据环境加载不同实现。
  • 用户进入特定流程后再加载 SDK。

例如:

1
2
3
4
async function openReportDesigner() {
const {ReportDesigner} = await import('@/features/report-designer');
ReportDesigner.open();
}

4. Vendor 分包

Vendor 指第三方依赖,例如:

1
2
3
4
5
6
7
8
9
10
react
react-dom
vue
antd
element-plus
echarts
lodash
axios
monaco-editor
pdfjs-dist

这些依赖通常变化不频繁,适合和业务代码分开打包。

常见结构:

1
2
3
4
5
6
7
runtime.js
main.js
react-vendor.js
ui-vendor.js
chart-vendor.js
editor-vendor.js
vendor.js

好处:

  • 业务代码更新时,第三方依赖缓存不容易失效。
  • 大型依赖可以单独控制加载时机。
  • 更容易定位 bundle 体积问题。

但要注意:vendor 不是越碎越好。拆太细会制造过多请求和加载瀑布。切蛋糕可以,切成面粉就没必要了。


5. 公共模块分割

多个页面共享的组件、工具函数、业务 SDK,如果重复打进每个页面,会造成浪费。

例如:

1
2
3
UserPage 使用 Table
OrderPage 使用 Table
ReportPage 使用 Table

构建工具可以把公共部分提取为:

1
2
3
4
user.js
order.js
report.js
common.js

常见公共模块:

  • utils。
  • hooks / composables。
  • 业务组件。
  • 请求封装。
  • 权限逻辑。
  • 表格封装。
  • 字典转换。

6. CSS 代码分割

代码分割不仅包括 JS,也包括 CSS。

如果所有 CSS 都打进一个文件,用户访问登录页时也可能下载报表页、编辑器、管理模块的样式。

理想状态:

1
2
3
4
login.css
dashboard.css
report.css
editor.css

对于 Vite,build.cssCodeSplit 默认开启。开启后,异步 JS chunk 中引入的 CSS 会被保存为对应 CSS chunk,并在该异步 chunk
被加载时一并获取。

适用场景:

  • 页面样式差异很大。
  • UI 库体积较大。
  • 异步组件有独立样式。
  • 业务模块较多。

7. Preload / Prefetch

代码分割之后会出现一个新问题:

用户点击页面时才开始加载 chunk,可能会有短暂等待。

这时可以使用资源提示。

preload

preload 表示:这个资源马上就要用,浏览器应该尽早加载。

1
2

<link rel="preload" href="/assets/report.js" as="script"/>

适合关键资源。

prefetch

prefetch 表示:这个资源未来可能会用,浏览器可以在空闲时提前获取。

1
2

<link rel="prefetch" href="/assets/report.js"/>

适合下一跳页面、用户大概率会进入的模块。

常见做法:

  • 鼠标 hover 菜单时提前 import()
  • 用户进入 dashboard 后预取 report 页面。
  • 登录成功后预取用户最常访问的业务模块。

8. 框架自动代码分割

很多现代框架已经内置代码分割能力。

常见框架:

  • Next.js。
  • Nuxt。
  • Remix。
  • Astro。
  • SvelteKit。

例如 Next.js 会基于路由做自动拆分,同时也支持 next/dynamic 对组件和库做懒加载。

但是要注意:

自动代码分割不等于你可以随便在根入口引入重依赖。

比如在全局 layout 或 main 入口引入:

1
2
3
import 'echarts';
import 'monaco-editor';
import 'pdfjs-dist';

这些重依赖仍然可能污染首屏包。


9. 按权限 / 按角色分割

B 端系统很适合按权限拆模块。

例如:

1
2
3
4
普通用户:不需要系统管理模块
财务用户:需要结算、账单、报表模块
运维用户:需要配置、监控、日志模块
管理员:需要组织、权限、审计模块

可以根据权限动态加载:

1
2
3
4
if (permissions.includes('system:admin')) {
const adminModule = await import('@/modules/system-admin');
adminModule.mount();
}

注意:前端分割不是安全防线。真正的权限控制必须在后端完成。前端只负责减少无关代码下载和优化体验。


10. 微前端 / Module Federation

微前端可以看成更大颗粒度的代码分割。

不是把一个应用拆成多个 chunk,而是把系统拆成多个独立子应用:

1
2
3
4
5
shell 主应用
user-app 用户中心
order-app 订单系统
finance-app 财务系统
report-app 报表系统

适合:

  • 超大型中后台。
  • 多团队独立开发。
  • 业务边界清晰。
  • 子系统需要独立部署。

不适合:

  • 小项目。
  • 团队不大但强行拆分。
  • 业务边界混乱。
  • 只是为了“看起来高级”。

微前端不是银弹。它可能把一个大泥球拆成几个会联网的大泥球。


11. Web Worker 分割

如果某些逻辑计算量很大,可以放进 Web Worker。

适合:

  • Excel 解析。
  • 大文件处理。
  • PDF 解析。
  • 图片处理。
  • 加密压缩。
  • 大量数据计算。
  • 复杂图算法。

示例:

1
2
3
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});

这既是代码分割,也是线程分离。它的主要收益不是减少请求,而是避免主线程被长任务卡死。


12. WASM 按需加载

如果项目中使用 WASM,例如:

  • 图像处理。
  • 音视频编解码。
  • PDF 处理。
  • OCR。
  • 压缩算法。
  • 加密算法。

一般也应该按需加载。

1
2
const wasm = await import('./pkg/my_wasm_module');
await wasm.init();

WASM 文件往往不小,不要随便塞进首屏链路。


三、实战一:React + Vite 路由级代码分割

1. 项目结构

假设项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
src/
main.tsx
App.tsx
router/
index.tsx
pages/
Dashboard.tsx
User.tsx
Order.tsx
Report.tsx
components/
PageLoading.tsx
ChunkErrorBoundary.tsx

2. 使用 React.lazy 拆分页面

src/router/index.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import {lazy, Suspense} from 'react';
import {createBrowserRouter} from 'react-router-dom';
import PageLoading from '@/components/PageLoading';
import ChunkErrorBoundary from '@/components/ChunkErrorBoundary';

const Dashboard = lazy(() => import('@/pages/Dashboard'));
const User = lazy(() => import('@/pages/User'));
const Order = lazy(() => import('@/pages/Order'));
const Report = lazy(() => import('@/pages/Report'));

function withLazyPage(Page: React.LazyExoticComponent<() => JSX.Element>) {
return (
<ChunkErrorBoundary>
<Suspense fallback={<PageLoading/>}>
<Page/>
</Suspense>
</ChunkErrorBoundary>
);
}

export const router = createBrowserRouter([
{
path: '/',
element: withLazyPage(Dashboard),
},
{
path: '/user',
element: withLazyPage(User),
},
{
path: '/order',
element: withLazyPage(Order),
},
{
path: '/report',
element: withLazyPage(Report),
},
]);

src/main.tsx

1
2
3
4
5
6
7
8
9
10
import React from 'react';
import ReactDOM from 'react-dom/client';
import {RouterProvider} from 'react-router-dom';
import {router} from './router';

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<RouterProvider router={router}/>
</React.StrictMode>,
);

3. 加一个 chunk 加载失败兜底

用户访问期间,如果你刚好发布了新版本,旧 HTML 指向的 chunk 可能已经不存在。表现通常是:

1
2
Loading chunk xxx failed
Failed to fetch dynamically imported module

所以懒加载最好配合 ErrorBoundary。

src/components/ChunkErrorBoundary.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import React from 'react';

type Props = {
children: React.ReactNode;
};

type State = {
hasError: boolean;
};

export default class ChunkErrorBoundary extends React.Component<Props, State> {
state: State = {
hasError: false,
};

static getDerivedStateFromError() {
return {hasError: true};
}

componentDidCatch(error: unknown) {
console.error('Chunk load failed:', error);
}

handleReload = () => {
window.location.reload();
};

render() {
if (this.state.hasError) {
return (
<div style={{padding: 24}}>
<h2>页面资源加载失败</h2>
<p>可能是网络波动,或者系统刚刚发布了新版本。</p>
<button onClick={this.handleReload}>刷新页面</button>
</div>
);
}

return this.props.children;
}
}

src/components/PageLoading.tsx

1
2
3
export default function PageLoading() {
return <div style={{padding: 24}}>页面加载中...</div>;
}

四、实战二:React 大组件按需加载

假设报表页面里有一个很重的图表模块,不应该在用户进入首页时加载。

src/pages/Report.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {lazy, Suspense, useState} from 'react';

const ChartPanel = lazy(() => import('@/features/chart/ChartPanel'));

export default function Report() {
const [visible, setVisible] = useState(false);

return (
<div>
<h1>报表中心</h1>

<button onClick={() => setVisible(true)}>打开图表分析</button>

{visible && (
<Suspense fallback={<div>图表加载中...</div>}>
<ChartPanel/>
</Suspense>
)}
</div>
);
}

这样用户只进入 /report 时,不一定立即加载图表代码;只有点击按钮后才会加载 ChartPanel 对应 chunk。


五、实战三:用户 hover 菜单时预加载页面

用户把鼠标移到“报表中心”菜单上,说明他很可能马上要点击。此时可以提前加载页面代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import {Link} from 'react-router-dom';

function prefetchReportPage() {
import('@/pages/Report');
}

export function SideMenu() {
return (
<nav>
<Link to="/report" onMouseEnter={prefetchReportPage}>
报表中心
</Link>
</nav>
);
}

这个方式比一开始就加载所有页面温和很多:

  • 用户不 hover,不加载。
  • 用户 hover,浏览器提前拉取 chunk。
  • 用户点击时,页面更可能秒开。

六、实战四:Vite 中配置 manualChunks

Vite 底层生产构建使用 Rollup,可以通过 build.rollupOptions.output.manualChunks 控制 chunk 拆分。

vite.config.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';

export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
build: {
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) {
return;
}

if (id.includes('react') || id.includes('react-dom') || id.includes('scheduler')) {
return 'react-vendor';
}

if (id.includes('antd') || id.includes('@ant-design') || id.includes('rc-')) {
return 'ui-vendor';
}

if (id.includes('echarts') || id.includes('zrender')) {
return 'chart-vendor';
}

if (id.includes('monaco-editor')) {
return 'editor-vendor';
}

return 'vendor';
},
},
},
},
});

这个配置会形成类似结构:

1
2
3
4
5
6
assets/index-xxxx.js
assets/react-vendor-xxxx.js
assets/ui-vendor-xxxx.js
assets/chart-vendor-xxxx.js
assets/editor-vendor-xxxx.js
assets/vendor-xxxx.js

配置建议

不要一上来就把每个 npm 包都拆成一个 chunk。

更推荐:

1
2
3
4
5
React/Vue 核心框架单独拆
UI 库单独拆
图表库单独拆
编辑器/PDF/地图这类重模块单独拆
其他依赖保留在 vendor

常见坑

1. manualChunks 拆错导致依赖顺序问题

有些库之间存在隐式依赖关系,拆分过细可能导致加载顺序和执行时机问题。

处理原则:

  • 先粗拆,不要过度拆。
  • 把强相关依赖放在同一个 chunk。
  • 对 UI 库、图表库、编辑器库单独分组即可。

2. vendor 包仍然很大

如果 vendor 依旧巨大,先分析里面有哪些依赖,再决定是否单独拆分。

不要靠感觉优化。前端工程里“我感觉包很大”通常不如一张 bundle 分析图诚实。


七、实战五:Webpack SplitChunks 配置

如果项目使用 Webpack,可以通过 optimization.splitChunks 做分包。

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const path = require('node:path');

module.exports = {
mode: 'production',
entry: {
app: path.resolve(__dirname, 'src/main.tsx'),
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'static/js/[name].[contenthash:8].js',
chunkFilename: 'static/js/[name].[contenthash:8].chunk.js',
clean: true,
},
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
minSize: 20 * 1024,
cacheGroups: {
reactVendor: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'react-vendor',
priority: 30,
reuseExistingChunk: true,
},
uiVendor: {
test: /[\\/]node_modules[\\/](antd|@ant-design|rc-.+)[\\/]/,
name: 'ui-vendor',
priority: 20,
reuseExistingChunk: true,
},
chartVendor: {
test: /[\\/]node_modules[\\/](echarts|zrender)[\\/]/,
name: 'chart-vendor',
priority: 20,
reuseExistingChunk: true,
},
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: -10,
reuseExistingChunk: true,
},
common: {
name: 'common',
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};

runtimeChunk 为什么建议单独拆?

Webpack 的 runtime 包含模块加载、chunk 映射等运行时代码。单独拆出 runtime 后,业务代码和 vendor 的 hash 更稳定,有利于长期缓存。


八、实战六:Webpack 动态 import + magic comments

Webpack 支持在动态 import 中使用 magic comments。

1
2
3
4
5
6
7
const Report = lazy(() =>
import(
/* webpackChunkName: "report-page" */
/* webpackPrefetch: true */
'@/pages/Report'
),
);

常见 magic comments:

1
2
3
4
webpackChunkName:指定 chunk 名称
webpackPrefetch:提示浏览器未来可能需要
webpackPreload:提示浏览器马上需要
webpackMode:控制动态导入模式

注意:

  • prefetch 适合未来可能使用的资源。
  • preload 适合当前页面马上需要的资源。
  • 不要滥用 preload,否则会和关键资源抢带宽。

九、实战七:Vue Router 路由懒加载

Vue Router 的路由懒加载非常直接。

src/router/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import {createRouter, createWebHistory} from 'vue-router';

const routes = [
{
path: '/',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue'),
},
{
path: '/user',
name: 'user',
component: () => import('@/views/User.vue'),
},
{
path: '/order',
name: 'order',
component: () => import('@/views/Order.vue'),
},
{
path: '/report',
name: 'report',
component: () => import('@/views/Report.vue'),
},
];

export const router = createRouter({
history: createWebHistory(),
routes,
});

Vue Router 官方也特别提醒:路由懒加载和 Vue 的异步组件概念相似但并不完全一样,路由组件本身应直接使用返回 Promise
的函数,不要把路由组件再包成异步组件。


十、实战八:Vue 中大组件懒加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

<script setup lang="ts">
import {defineAsyncComponent, ref} from 'vue';

const visible = ref(false);

const ChartPanel = defineAsyncComponent(() => import('@/features/chart/ChartPanel.vue'));
</script>

<template>
<section>
<h1>报表中心</h1>
<button @click="visible = true">打开图表分析</button>

<ChartPanel v-if="visible"/>
</section>
</template>

适合组件级懒加载,不要和 Vue Router 路由组件懒加载混用概念。


十一、实战九:Next.js 中的动态导入

Next.js 支持通过 next/dynamic 懒加载 Client Components 和第三方库。

1
2
3
4
5
6
7
8
9
import dynamic from 'next/dynamic';

const ChartPanel = dynamic(() => import('@/components/ChartPanel'), {
loading: () => <p>图表加载中...</p>,
});

export default function Page() {
return <ChartPanel/>;
}

如果某个组件依赖浏览器环境,例如 windowdocument、Canvas、地图 SDK,可以禁用 SSR:

1
2
3
4
5
6
import dynamic from 'next/dynamic';

const ClientOnlyEditor = dynamic(() => import('@/components/ClientOnlyEditor'), {
ssr: false,
loading: () => <p>编辑器加载中...</p>,
});

使用建议:

  • Server Component 优先放服务端。
  • 真正需要交互的重组件再作为 Client Component 懒加载。
  • 浏览器专用组件才考虑 ssr: false
  • 不要把大型依赖引入全局 layout。

十二、如何判断分割是否有效?

代码分割不是写了 lazy 就结束。你需要验证。

1. 看构建产物

执行:

1
npm run build

观察 dist:

1
2
3
4
5
dist/assets/index-xxx.js
dist/assets/react-vendor-xxx.js
dist/assets/ui-vendor-xxx.js
dist/assets/report-xxx.js
dist/assets/chart-vendor-xxx.js

如果所有东西还是一个巨大 index.js,说明分割没有生效。


2. 看浏览器 Network

打开 Chrome DevTools:

1
Network -> JS -> Disable cache -> 刷新页面

观察:

  • 首屏加载了哪些 JS?
  • 点击某个菜单后是否新增加载对应 chunk?
  • 图表、编辑器、PDF 模块是否按需加载?
  • 是否存在过多小 chunk?
  • 是否存在加载瀑布过长?

3. 看 Coverage

Chrome DevTools:

1
More tools -> Coverage

可以查看 JS/CSS 使用率。

如果首屏加载了大量未使用代码,说明还有优化空间。


4. 看性能指标

重点关注:

指标 说明 代码分割的影响
LCP 最大内容绘制 减少首屏阻塞资源可能改善
INP 交互响应 减少主线程 JS 执行可能改善
TBT 总阻塞时间 减少 JS 解析执行通常明显改善
FCP 首次内容绘制 首屏资源减少可能改善
TTI 可交互时间 JS 执行减少可能改善

十三、推荐分包策略

中小型 React/Vue 后台项目

推荐:

1
2
3
4
5
6
路由级懒加载
大组件懒加载
React/Vue 核心 vendor
UI 库 vendor
图表/编辑器/PDF 独立 vendor
CSS code split

不推荐:

1
2
3
4
每个 npm 包单独拆
过早引入微前端
所有公共模块都强行拆成 common
滥用 preload

大型中后台 / SaaS 平台

推荐结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
main.js                应用入口
runtime.js 构建运行时代码
react-vendor.js React / ReactDOM
ui-vendor.js antd / icons / rc-*
chart-vendor.js echarts / zrender
editor-vendor.js monaco-editor / rich editor
pdf-vendor.js pdfjs-dist
auth.js 登录认证模块
dashboard.js 首页
finance.js 财务模块
report.js 报表模块
system.js 系统管理模块
common.js 高频公共业务组件

原则:

  1. 路由先拆。
  2. 重组件再拆。
  3. 大 vendor 独立。
  4. 低频模块按需。
  5. 缓存稳定性优先。
  6. 不要为了“文件多”而分割。

十四、常见坑与解决方案

1. 拆分太碎

问题:

1
2
3
4
首屏请求数过多
chunk 间加载瀑布明显
HTTP 请求调度成本增加
调试复杂度提高

解决:

1
2
3
4
减少 cacheGroup 数量
合并强相关依赖
保留一个兜底 vendor
只拆真正大的库

2. 重依赖被全局入口引用

错误示例:

1
2
3
4
// main.tsx
import 'echarts';
import 'monaco-editor';
import 'pdfjs-dist';

这样会导致所有页面都下载这些依赖。

正确方式:

1
2
// 进入图表页面后再加载
const ChartPage = lazy(() => import('@/pages/ChartPage'));

或者:

1
2
// 用户点击后再加载
const {default: Editor} = await import('@/components/Editor');

3. 公共 chunk 过大

有些项目把所有公共依赖塞进一个 vendor.js,最后 vendor 几 MB。

解决:

1
2
3
4
5
框架核心单独拆
UI 库单独拆
图表库单独拆
编辑器/PDF/地图单独拆
其他依赖归入 vendor

4. chunk 加载失败导致白屏

原因:

  • CDN 缓存问题。
  • 用户打开旧页面时系统发布了新版。
  • 异步 chunk 文件 hash 变化。
  • 网络波动。

解决:

  • 给 lazy 组件加 ErrorBoundary。
  • 失败时提示刷新。
  • 发布时保留旧版本静态资源一段时间。
  • HTML 不要设置过长强缓存。
  • JS/CSS 使用 contenthash 长缓存。

推荐缓存策略:

1
2
3
4
index.html: no-cache
assets/*.js: public, max-age=31536000, immutable
assets/*.css: public, max-age=31536000, immutable
旧 assets: 发布后保留一段时间

5. 滥用 preload

preload 会提高资源优先级,如果滥用,会和首屏关键资源抢带宽。

建议:

1
2
3
4
关键首屏资源用 preload
下一页资源用 prefetch
低概率资源不要提前加载
hover/idle 时机更温和

6. 只分割,不做 Tree Shaking

代码分割和 Tree Shaking 是两回事。

代码分割:

1
把代码拆成不同 chunk

Tree Shaking:

1
删除没有被使用的代码

两者应该一起做。

例如错误写法:

1
import * as lodash from 'lodash';

更好的方式:

1
import debounce from 'lodash/debounce';

或者使用支持 ESM Tree Shaking 的库版本。


十五、推荐落地流程

第一步:建立基线

先构建一次:

1
npm run build

记录:

1
2
3
4
5
首屏 JS 总体积
最大 JS 文件体积
vendor 体积
是否有图表/编辑器/PDF 进入首屏
LCP / INP / TBT

第二步:路由级懒加载

优先处理页面:

1
2
3
4
5
6
7
Report
System
Admin
Editor
PDF
Chart
LowCode

第三步:重组件懒加载

处理:

1
2
3
4
5
6
ECharts
Monaco Editor
PDF Reader
Map SDK
Rich Text Editor
Large Modal

第四步:Vendor 分包

按类型拆:

1
2
3
4
5
6
framework-vendor
ui-vendor
chart-vendor
editor-vendor
pdf-vendor
vendor

第五步:加预加载策略

使用:

1
2
3
4
hover 预加载
idle 预加载
登录后预加载常用模块
prefetch 下一跳页面

第六步:验证效果

看三件事:

1
2
3
首屏少了什么
点击时多了什么
性能指标有没有改善

不要只看 bundle 数量,要看用户真实路径。


十六、一个完整的优化前后示例

优化前

1
2
index.js              4.8 MB
style.css 900 KB

问题:

  • 首屏直接加载所有模块。
  • 图表、编辑器、PDF 都在主包。
  • vendor 缓存跟随业务变化失效。
  • 用户访问登录页也下载后台代码。

优化后

1
2
3
4
5
6
7
8
9
10
runtime.js            8 KB
main.js 280 KB
react-vendor.js 160 KB
ui-vendor.js 650 KB
vendor.js 300 KB
dashboard.js 120 KB
report.js 180 KB
chart-vendor.js 780 KB
editor-vendor.js 1.2 MB
pdf-vendor.js 900 KB

效果:

  • 首屏不再加载报表、编辑器、PDF。
  • 用户进入报表页时才加载 chart-vendor。
  • 用户打开编辑器时才加载 editor-vendor。
  • vendor 缓存更稳定。
  • 首屏 JS 明显下降。

十七、最终实践建议

如果你的项目是 React/Vue 中后台,我建议这样落地:

1
2
3
4
5
6
7
8
9
10
1. 所有一级业务路由全部 lazy import
2. 图表、编辑器、PDF、地图、低代码设计器单独 lazy import
3. Vite 用 manualChunks 粗粒度拆 vendor
4. Webpack 用 splitChunks + runtimeChunk
5. 开启 CSS code split
6. hover 或 idle 时预加载高概率页面
7. 给异步 chunk 加错误兜底
8. HTML 短缓存,assets 长缓存
9. 用 Network/Coverage/Lighthouse 验证结果
10. 不要为了拆而拆

最推荐的基础组合:

1
路由懒加载 + 大组件懒加载 + vendor 合理分包 + CSS 分割 + chunk 错误兜底

这套做完,大多数中后台项目的首屏体积都会明显下降。


十八、参考资料


前端代码分割技术综述与实战:从路由懒加载到 Vite/Webpack 分包
https://allendericdalexander.github.io/2026/06/23/scm/frontend-code-splitting-blog/
作者
AtLuoFu
发布于
2026年6月23日
许可协议