跳到主要内容

代码分割与懒加载

问题

什么是代码分割?Webpack 中如何实现代码分割和懒加载?

答案

什么是代码分割

代码分割(Code Splitting)是一种将打包产物拆分为多个较小 bundle 的优化技术。它的核心目标是按需加载 —— 用户只加载当前页面所需的代码,而不是一次性下载整个应用的所有代码。

为什么需要代码分割

现代 SPA 应用动辄数百个组件和数十个依赖库,如果全部打包到一个 JS 文件中:

  • 首屏加载慢:用户必须等待整个 bundle 下载完毕才能看到页面
  • 缓存失效范围大:修改任何一行代码,整个 bundle 的 hash 都会变化,用户需要重新下载全部内容
  • 资源浪费:用户可能永远不会访问某些路由/功能,但对应代码已被加载

代码分割解决的核心问题可以概括为:

问题代码分割的解决方案
首屏加载慢只加载首屏必要代码,其他模块延迟加载
缓存利用率低将第三方库和业务代码分开,第三方库很少变化可长期缓存
带宽浪费按需加载,用户只下载真正使用的代码

三种代码分割方式

1. 入口起点(Entry Points)

最简单的分割方式,通过配置多个入口来产生多个 bundle:

webpack.config.ts
import type { Configuration } from 'webpack';

const config: Configuration = {
entry: {
app: './src/app.ts',
admin: './src/admin.ts',
},
output: {
filename: '[name].[contenthash].js',
path: __dirname + '/dist',
},
};

export default config;
入口起点的局限性
  • 如果两个入口都引用了 lodash,它会被重复打包到两个 bundle 中
  • 不够灵活,无法实现动态按需加载
  • 需要手动维护入口配置

因此,入口起点通常需要配合 splitChunks 来去重公共模块。

2. 动态导入(Dynamic Imports)

动态导入是最推荐的代码分割方式,使用 import() 语法在运行时按需加载模块:

src/router.ts
// 静态导入 —— 会被打包到主 bundle
import Home from './pages/Home';

// 动态导入 —— 会产生单独的 chunk,访问时才加载
const About = () => import('./pages/About');
const Dashboard = () => import('./pages/Dashboard');

import() 返回一个 Promise,resolve 的值就是模块对象:

src/utils/loadModule.ts
async function loadModule(): Promise<void> {
const { default: Chart } = await import('chart.js');
const chart = new Chart(/* ... */);
}

通过 Magic Comments 可以控制 chunk 名称和加载行为:

src/loadFeature.ts
// 指定 chunk 名称
const module = await import(
/* webpackChunkName: "feature-chart" */
'./features/Chart'
);

// 预获取:浏览器空闲时加载
const settings = await import(
/* webpackChunkName: "settings", webpackPrefetch: true */
'./pages/Settings'
);

// 预加载:与父 chunk 并行加载
const modal = await import(
/* webpackChunkName: "modal", webpackPreload: true */
'./components/Modal'
);

3. SplitChunks 插件

Webpack 内置的 SplitChunksPlugin 可以自动识别和抽取公共模块,是代码分割的核心配置。

splitChunks 详细配置

基础配置参数

webpack.config.ts
import type { Configuration } from 'webpack';

const config: Configuration = {
optimization: {
splitChunks: {
// 'all': 同步 + 异步模块都会被分割(推荐)
// 'async': 只分割异步模块(默认)
// 'initial': 只分割同步模块
chunks: 'all',

// 生成 chunk 的最小体积(字节),小于此值不分割
minSize: 20000, // 20KB

// 拆分后 chunk 的最大体积,超过则继续拆分
maxSize: 250000, // 250KB

// 一个模块被至少 N 个 chunk 引用才会被抽取
minChunks: 1,

// 按需加载时的最大并行请求数
maxAsyncRequests: 30,

// 入口点的最大并行请求数
maxInitialRequests: 30,

// 自动命名分隔符
automaticNameDelimiter: '~',
},
},
};

export default config;

chunks 参数的区别:

分割范围适用场景
'async'仅异步导入的模块默认值,保守策略
'initial'仅同步导入的模块多入口共享代码
'all'同步 + 异步模块推荐,最大化复用

cacheGroups 配置策略

cacheGroupssplitChunks 的核心,用于定义分包规则。每个 cache group 会继承 splitChunks 的顶层配置,并可覆盖:

webpack.config.ts — 完整分包配置
import type { Configuration } from 'webpack';

const config: Configuration = {
optimization: {
splitChunks: {
chunks: 'all',
minSize: 20000,
maxSize: 250000,
minChunks: 1,

cacheGroups: {
// 1. 第三方库单独打包(优先级最高)
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 20,
reuseExistingChunk: true,
},

// 2. 将大型库单独拆分(按需细化)
react: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
name: 'react-vendor',
priority: 30, // 优先级高于 vendors
},

antd: {
test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
name: 'antd-vendor',
priority: 30,
},

// 3. 公共业务代码
commons: {
name: 'commons',
minChunks: 2, // 被 2 个以上 chunk 引用才提取
priority: 10,
reuseExistingChunk: true,
},

// 4. 默认分割规则(兜底)
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};

export default config;
cacheGroups 关键配置说明
  • test:匹配规则,支持正则、函数、字符串
  • name:chunk 名称,设为固定字符串可以将匹配到的模块合并为一个 chunk
  • priority:优先级,一个模块可能匹配多个 group,取优先级最高的
  • reuseExistingChunk:如果当前 chunk 已包含目标模块,直接复用,不重复打包
  • enforce:设为 true 则忽略 minSizeminChunks 等条件,强制分割

常见分包策略

根据项目类型,以下是几种典型的分包方案:

方案一:基础分包(中小型项目)

简单但实用的分包策略
// 将所有 node_modules 打包为 vendors
// 业务代码打包为 app
// 公共代码打包为 commons
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
},
},
}

方案二:精细分包(大型项目)

按依赖类型细分
// 框架 → react-vendor
// UI 组件库 → ui-vendor
// 工具库 → utils-vendor
// 业务公共 → commons
// 各路由 → 异步 chunk
splitChunks: {
chunks: 'all',
maxSize: 250000,
cacheGroups: {
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
name: 'framework',
priority: 40,
},
ui: {
test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
name: 'ui-vendor',
priority: 30,
},
utils: {
test: /[\\/]node_modules[\\/](lodash|dayjs|axios)[\\/]/,
name: 'utils-vendor',
priority: 30,
},
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 20,
},
commons: {
name: 'commons',
minChunks: 2,
priority: 10,
reuseExistingChunk: true,
},
},
}

动态导入与懒加载

React.lazy + Suspense

React 提供了原生的懒加载支持:

src/App.tsx
import React, { Suspense, lazy } from 'react';
import type { FC } from 'react';

// React.lazy 接受一个返回 import() 的函数
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import(
/* webpackChunkName: "dashboard" */
'./pages/Dashboard'
));

const Loading: FC = () => <div>加载中...</div>;

const App: FC = () => {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
);
};

Vue 异步组件

Vue 通过 defineAsyncComponent 实现组件级懒加载:

src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';

const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('../views/Home.vue'),
},
{
path: '/about',
component: () => import('../views/About.vue'),
},
];

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

export default router;
使用 defineAsyncComponent
import { defineAsyncComponent } from 'vue';
import type { Component } from 'vue';

const AsyncChart: Component = defineAsyncComponent({
loader: () => import('./components/Chart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 200ms 后才显示 loading
timeout: 10000, // 10s 超时
});

路由级别懒加载

路由级别的懒加载是最常见也是收益最大的分割方式,因为每个路由页面通常是独立的业务模块:

React Router 路由懒加载
import { lazy } from 'react';
import type { RouteObject } from 'react-router-dom';

const routes: RouteObject[] = [
{
path: '/',
Component: lazy(() => import('./layouts/MainLayout')),
children: [
{
index: true,
Component: lazy(() => import('./pages/Home')),
},
{
path: 'users',
Component: lazy(() => import('./pages/UserList')),
},
{
path: 'users/:id',
Component: lazy(() => import('./pages/UserDetail')),
},
],
},
];

预加载与预获取

webpackPrefetch vs webpackPreload

这两种策略的触发时机和优先级完全不同:

特性webpackPrefetchwebpackPreload
HTML 标签<link rel="prefetch"><link rel="preload">
加载时机父 chunk 加载完成后,浏览器空闲时加载与父 chunk 并行加载
优先级低优先级高优先级
适用场景用户将来可能访问的页面当前页面一定会用到的资源
浏览器支持良好(除 Safari)良好
典型用法下一个路由页面首屏关键依赖、弹窗组件
使用示例
// Prefetch:用户可能会进入设置页,空闲时预获取
const Settings = () => import(
/* webpackPrefetch: true */
'./pages/Settings'
);

// Preload:当前页面的弹窗组件,点击按钮时需要立即显示
const EditModal = () => import(
/* webpackPreload: true */
'./components/EditModal'
);

Webpack 会在 HTML 中自动注入对应的 <link> 标签:

生成的 HTML
<!-- Prefetch: 空闲时下载 -->
<link rel="prefetch" href="/static/js/settings.chunk.js">

<!-- Preload: 立即高优先级下载 -->
<link rel="preload" as="script" href="/static/js/edit-modal.chunk.js">
使用 Preload 需谨慎
  • Preload 的资源如果 3 秒内没有被使用,浏览器会在控制台输出警告
  • 过度使用 Preload 会与当前页面资源争夺带宽,反而拖慢首屏
  • 建议优先使用 Prefetch,只在确实需要并行加载时使用 Preload

Chunk 类型

Webpack 将产出的 chunk 分为三种类型:

Chunk 类型说明示例
Initial Chunk入口起点对应的 chunk,包含所有同步依赖main.jsvendors.js
Async Chunk通过 import() 动态导入产生的 chunk,按需加载dashboard.chunk.js
Runtime ChunkWebpack 运行时代码,负责模块加载和 chunk 管理runtime.js

将 Runtime 单独抽取可以避免业务代码 hash 因 runtime 变化而失效:

webpack.config.ts
const config: Configuration = {
optimization: {
// 将 runtime 代码提取为单独的 chunk
runtimeChunk: 'single',

splitChunks: {
chunks: 'all',
},
},
};
Runtime Chunk 的作用

Runtime 代码包含模块加载器(__webpack_require__)和 chunk 映射关系。将其单独抽取后:

  • 业务代码变化不会影响 runtime 的 hash
  • vendor 代码变化不会影响 runtime 的 hash
  • runtime 文件很小(通常 < 5KB),可以直接内联到 HTML 中减少一次网络请求

Vite 中的代码分割

Vite 底层使用 Rollup 进行打包,代码分割的配置方式与 Webpack 不同。

Vite 默认会自动进行代码分割:

  • 动态 import() 会自动生成独立 chunk
  • CSS 代码自动按组件分割
  • 公共依赖自动提取

通过 manualChunks 可以自定义分包策略:

vite.config.ts
import { defineConfig } from 'vite';
import type { UserConfig } from 'vite';

export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// 将 React 相关库打包在一起
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
// 将 UI 库单独打包
'ui-vendor': ['antd', '@ant-design/icons'],
// 工具库
'utils': ['lodash-es', 'dayjs', 'axios'],
},
},
},
},
} satisfies UserConfig);

也可以使用函数形式进行更灵活的控制:

vite.config.ts — 函数形式
import { defineConfig } from 'vite';

export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id: string): string | undefined {
// node_modules 中的依赖按包名分割
if (id.includes('node_modules')) {
// React 生态
if (/react|react-dom|react-router/.test(id)) {
return 'react-vendor';
}
// UI 组件库
if (/antd|@ant-design/.test(id)) {
return 'ui-vendor';
}
// 其他第三方库统一打包
return 'vendor';
}
// 公共组件
if (id.includes('src/components/')) {
return 'components';
}
},
},
},
},
});
Vite vs Webpack 代码分割对比
特性WebpackVite(Rollup)
配置方式splitChunks + cacheGroupsmanualChunks
默认行为只分割异步 chunk自动分割异步 chunk + CSS
灵活度更高(支持复杂条件)较简洁
配置复杂度较高较低
Tree Shaking需配置 sideEffects默认更激进的 Tree Shaking

代码分割策略最佳实践

分割策略决策流程

关键原则

代码分割的黄金法则
  1. 路由级别分割是基础:每个路由页面应该是独立的 async chunk
  2. 第三方库与业务代码分离:利用浏览器的长期缓存能力
  3. 大型库单独抽取:超过 50KB 的库考虑独立为一个 chunk
  4. 避免过度分割:chunk 数量太多会导致 HTTP 请求增多,反而降低性能
  5. 配合 Prefetch 使用:可预测的用户行为路径提前加载资源
  6. 持续监控产物体积:使用 webpack-bundle-analyzerrollup-plugin-visualizer 分析

常见面试问题

Q1: Webpack 中代码分割有哪几种方式?各自适用场景?

答案

Webpack 提供三种代码分割方式:

1. 入口起点(Entry Points)

通过配置多个 entry 来生成多个 bundle:

多入口配置
entry: {
app: './src/index.ts',
admin: './src/admin.ts',
}
  • 适用场景:多页应用(MPA),每个页面有独立入口
  • 缺点:无法处理重复依赖,需配合 splitChunks 使用

2. 动态导入(Dynamic Imports)

使用 import() 语法按需加载模块:

// 路由懒加载
const Home = lazy(() => import('./pages/Home'));

// 条件加载
if (needChart) {
const { Chart } = await import('chart.js');
}
  • 适用场景:路由懒加载、条件加载、大型组件延迟加载
  • 优点:最灵活,与框架的懒加载机制无缝配合

3. SplitChunks 插件

Webpack 内置插件,自动提取公共模块:

optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: { test: /node_modules/, priority: 10 },
commons: { minChunks: 2, priority: 5 },
},
},
}
  • 适用场景:提取公共依赖、第三方库分包、优化缓存
  • 优点:自动化程度高,可细粒度控制分包规则
面试加分点

在实际项目中,三种方式通常组合使用

  • 用入口起点区分应用和管理后台
  • 用动态导入实现路由懒加载
  • 用 splitChunks 抽取公共模块和第三方库

Q2: splitChunks 的配置策略是怎样的?如何设计合理的分包方案?

答案

合理的分包方案需要平衡缓存效率请求数量两个因素。

核心参数解读

参数作用推荐值
chunks分割范围'all'(同步+异步)
minSize最小 chunk 体积20000(20KB)
maxSize最大 chunk 体积250000(250KB)
minChunks被引用次数阈值1(vendors)/ 2(commons)
prioritycacheGroup 优先级数值越大优先级越高

推荐的分包方案

生产级别的 splitChunks 配置
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
maxSize: 250000,
cacheGroups: {
// 第一层:框架核心(极少变化,长期缓存)
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|vue)[\\/]/,
name: 'framework',
priority: 40,
enforce: true,
},
// 第二层:UI 库(较少变化)
ui: {
test: /[\\/]node_modules[\\/](antd|@ant-design|element-plus)[\\/]/,
name: 'ui-lib',
priority: 30,
},
// 第三层:其他第三方库
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 20,
},
// 第四层:业务公共代码
commons: {
minChunks: 2,
name: 'commons',
priority: 10,
reuseExistingChunk: true,
},
},
},
}

设计原则

  1. 按变更频率分层:变化越少的代码越适合独立为 chunk,利用浏览器缓存
  2. 控制 chunk 数量:初始加载的 chunk 建议不超过 5 个,避免过多并行请求
  3. 设置合理的体积范围:单个 chunk 建议在 20KB ~ 250KB 之间
  4. 优先级层级清晰:确保更精确的 cacheGroup 优先级高于宽泛的匹配规则

Q3: prefetch 和 preload 的区别是什么?

答案

Prefetch 和 Preload 都是资源加载的优化手段,但在加载时机优先级适用场景上有本质区别:

对比项prefetchpreload
HTML 语法<link rel="prefetch"><link rel="preload">
加载时机浏览器空闲时与当前页面资源并行
网络优先级最低(Lowest)(High)
是否阻塞渲染不阻塞不阻塞,但会占用带宽
缓存行为存入 HTTP Cache 或 prefetch cache存入内存缓存(memory cache)
适用场景下一页面可能用到的资源当前页面一定需要的资源
Webpack 注释/* webpackPrefetch: true *//* webpackPreload: true */

具体使用场景举例

Prefetch — 用户可能的下一步操作
// 用户在首页,可能会进入文章详情页
const ArticleDetail = () => import(
/* webpackPrefetch: true */
'./pages/ArticleDetail'
);

// 用户在列表页,可能会打开编辑弹窗
const EditDialog = () => import(
/* webpackPrefetch: true */
'./components/EditDialog'
);
Preload — 当前页面必需的关键资源
// 字体文件:当前页面渲染必需
// <link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>

// 首屏图表组件:页面加载后立即需要展示
const HeroChart = () => import(
/* webpackPreload: true */
'./components/HeroChart'
);
常见误区
  • 不要把所有路由都设为 Preload:Preload 会争夺当前页面的网络带宽,滥用会适得其反
  • Prefetch 不保证一定会加载:浏览器会根据网络状况、电量等因素决定是否执行
  • Safari 对 Prefetch 支持有限:在 Safari 中 <link rel="prefetch"> 不会被执行

Q4: React.lazy 和 import() 动态导入的关系?Suspense 在其中的作用?

答案

React.lazy 是 React 对 import() 动态导入的封装,它接受一个返回 Promise<{ default: Component }> 的函数(即 import() 的返回值),将其转换为 React 可以渲染的懒加载组件。Suspense 则负责在组件加载过程中展示 fallback UI。

三者的关系

完整使用示例

src/App.tsx
import React, { Suspense, lazy } from 'react';
import type { FC, ReactNode } from 'react';

// 1. React.lazy 包装 import() 返回的 Promise
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

// 2. 错误边界处理加载失败
class ErrorBoundary extends React.Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };

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

render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}

// 3. Suspense 提供 fallback 展示加载状态
const App: FC = () => {
return (
<ErrorBoundary fallback={<div>加载失败,请刷新重试</div>}>
<Suspense fallback={<div>加载中...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</ErrorBoundary>
);
};

底层原理

// React.lazy 的简化实现原理
function lazy<T extends React.ComponentType>(
factory: () => Promise<{ default: T }>
): React.LazyExoticComponent<T> {
let status: 'pending' | 'fulfilled' | 'rejected' = 'pending';
let result: T;
let error: Error;

const promise = factory().then(
(module) => {
status = 'fulfilled';
result = module.default; // 注意:必须是 default 导出
},
(err) => {
status = 'rejected';
error = err;
}
);

// 返回一个特殊组件,Suspense 通过 throw promise 机制感知加载状态
return {
$$typeof: Symbol.for('react.lazy'),
_payload: { status, result, error, promise },
_init: /* ... */,
} as any;
}
关键要点
  1. import() 返回的模块必须有 default 导出,否则 React.lazy 无法获取组件
  2. Suspense 通过 React 的 throw promise 机制感知子组件的加载状态
  3. 加载失败时 Suspense 不会捕获错误,必须配合 ErrorBoundary 处理
  4. 嵌套的 Suspense 可以实现更细粒度的加载状态控制

Q5: Prefetch 和 Preload 有什么区别?在代码分割中如何使用?

答案

Prefetch 和 Preload 是两种浏览器资源提示(Resource Hints),在 Webpack 代码分割中通过 Magic Comments 使用。

核心区别

对比项PrefetchPreload
HTML 标签<link rel="prefetch"><link rel="preload">
加载时机父 chunk 加载完成后,浏览器空闲时与父 chunk 并行加载
网络优先级最低(Lowest)高(High)
适用场景未来可能需要的资源当前页面一定需要的资源
缓存位置prefetch cache / HTTP cachememory cache
未使用警告3 秒内未使用会有控制台警告

在 Webpack 中使用 Magic Comments

Prefetch 示例 — 下一个可能访问的页面
// 用户在首页,可能会点击"关于"页面
const About = () => import(
/* webpackPrefetch: true */
'./pages/About'
);

// Webpack 会在父 chunk 加载完成后注入:
// <link rel="prefetch" href="/static/js/about.chunk.js">
Preload 示例 — 当前页面关键资源
// 当前页面一定会用到的弹窗组件,需要与主 chunk 并行加载
const Modal = () => import(
/* webpackPreload: true */
'./components/Modal'
);

// Webpack 会注入:
// <link rel="preload" as="script" href="/static/js/modal.chunk.js">

实际项目中的使用策略

src/App.tsx
import { lazy } from 'react';

// 首页 — 不需要 Prefetch/Preload(入口 chunk 直接包含)
const Home = lazy(() => import('./pages/Home'));

// 用户大概率会访问的页面 — Prefetch
const ProductList = lazy(
() => import(/* webpackPrefetch: true */ './pages/ProductList')
);

// 用户可能会访问的页面 — Prefetch
const UserProfile = lazy(
() => import(/* webpackPrefetch: true */ './pages/UserProfile')
);

// 管理后台 — 不 Prefetch(大多数用户不会访问)
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
使用建议
  1. 优先用 Prefetch,谨慎用 Preload:Prefetch 在空闲时加载不影响首屏,Preload 会抢占带宽
  2. 只对高概率访问的路由使用 Prefetch:不要给所有路由都加 Prefetch
  3. Preload 仅用于当前页面确定需要的资源:如首屏 Hero 组件依赖的大型库
  4. 移动端网络差时考虑禁用 Prefetch:可通过 navigator.connection.saveData 判断

Q6: 如何分析和优化代码分割的效果?

答案

分析代码分割效果的核心工具是 webpack-bundle-analyzer,它能将打包产物以可视化的方式呈现,帮助你发现问题并优化。

1. 安装和配置 webpack-bundle-analyzer

npm install webpack-bundle-analyzer --save-dev
webpack.config.ts
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';

const config: Configuration = {
plugins: [
// 按需开启分析
...(process.env.ANALYZE
? [new BundleAnalyzerPlugin({
analyzerMode: 'static', // 生成静态 HTML 报告
reportFilename: 'report.html',
openAnalyzer: false,
})]
: []),
],
};

// 运行: ANALYZE=true npx webpack --mode production

2. 分析报告中要关注的问题

问题表现解决方案
chunk 过大单个 chunk > 300KB(gzip 前)拆分 cacheGroups 或增加动态 import()
重复依赖同一个库出现在多个 chunk 中调整 splitChunksprioritytest
无用代码整个大库被打包但只用了少量功能按需导入(如 lodash-es)或 sideEffects
chunk 过多初始加载 > 10 个请求合并小 chunk,调大 minSize
vendor 过大node_modules 打包体积 > 1MB拆分大型库、使用 CDN externals

3. 合理的 chunk 大小范围

chunk 大小经验值(gzip 前)
  • 理想范围:100KB ~ 300KB
  • 初始加载的 chunk 总数:控制在 3~6 个
  • 初始 JS 总体积:< 200KB(gzip 后),确保首屏 < 3 秒

4. 优化流程实操

package.json scripts
{
"scripts": {
"analyze": "ANALYZE=true webpack --mode production",
"build:stats": "webpack --mode production --json=stats.json"
}
}

优化步骤:

  1. 运行 npm run analyze 生成可视化报告
  2. 找出过大的 chunk:检查是否有单个 chunk 超过 300KB
  3. 识别重复依赖:同一个库是否出现在多个 chunk 中
  4. 检查按需导入:大库(如 lodash、moment)是否整包引入
  5. 优化 splitChunks:调整 cacheGroupsminSizemaxSize
  6. 重新分析:对比优化前后的体积变化
典型优化示例
// 优化前:lodash 整包引入(71KB gzip)
import _ from 'lodash';
const result = _.get(obj, 'a.b.c');

// 优化后方案一:按需导入(仅 2KB gzip)
import get from 'lodash/get';
const result = get(obj, 'a.b.c');

// 优化后方案二:使用 lodash-es + Tree Shaking
import { get } from 'lodash-es';
const result = get(obj, 'a.b.c');
Vite 项目的分析方式

Vite(基于 Rollup)使用 rollup-plugin-visualizer

vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
plugins: [
visualizer({ open: true, gzipSize: true }),
],
});

相关链接