前端代码分割技术综述与实战:从路由懒加载到 Vite/Webpack 分包
前端代码分割,英文通常叫 Code Splitting,核心思想很简单:
不要把所有 JS、CSS、第三方依赖一次性塞进首屏,而是把代码拆成多个 chunk,让用户在真正需要时再加载。
对于现代前端应用,尤其是 React/Vue 单页应用、中后台系统、低代码平台、文档阅读器、图表报表系统,代码分割几乎是性能优化的必修课。否则一个用户只是打开登录页,却被迫下载图表库、富文本编辑器、PDF
解析器、管理后台、报表模块,这就像去楼下买瓶水,结果背了一台冰箱出门。
本文分两部分:
- 综述篇:系统梳理常见代码分割技术。
- 实战篇:分别用 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。
1 2 3 4 5 6 7
| const Report = lazy(() => import( '@/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/>; }
|
如果某个组件依赖浏览器环境,例如 window、document、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. 看构建产物
执行:
观察 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:
可以查看 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 高频公共业务组件
|
原则:
- 路由先拆。
- 重组件再拆。
- 大 vendor 独立。
- 低频模块按需。
- 缓存稳定性优先。
- 不要为了“文件多”而分割。
十四、常见坑与解决方案
1. 拆分太碎
问题:
1 2 3 4
| 首屏请求数过多 chunk 间加载瀑布明显 HTTP 请求调度成本增加 调试复杂度提高
|
解决:
1 2 3 4
| 减少 cacheGroup 数量 合并强相关依赖 保留一个兜底 vendor 只拆真正大的库
|
2. 重依赖被全局入口引用
错误示例:
1 2 3 4
| 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 是两回事。
代码分割:
Tree Shaking:
两者应该一起做。
例如错误写法:
1
| import * as lodash from 'lodash';
|
更好的方式:
1
| import debounce from 'lodash/debounce';
|
或者使用支持 ESM Tree Shaking 的库版本。
十五、推荐落地流程
第一步:建立基线
先构建一次:
记录:
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 错误兜底
|
这套做完,大多数中后台项目的首屏体积都会明显下降。
十八、参考资料