Skip to content
代码分割的背景与两种实现路径
不对构建产物做拆分时,单入口应用最终会聚合成单个 bundle。哪怕只改动一行按钮文案,整个 bundle 的哈希都会变化,浏览器原本缓存的所有内容将全部失效,用户需要重新下载包含全部第三方库在内的完整文件[8]。代码分割正是为了解决这一问题:将代码拆分为多个可独立加载的 chunk,首屏只引入必要部分,其余按需拉取。这样,任意时刻只有实际用到的代码才会被下载,更新也只会影响发生变化的 chunk,而不会连累其他模块。对于依赖大量第三方库、路由众多、体量较大的单页应用,拆分做法能直接改善首屏加载时长和长期缓存命中率。
拆分的路径有两种:
- 静态拆分:在构建阶段就确定 chunk 边界,例如通过配置入口点或
splitChunks/manualChunks将node_modules抽离为独立的 vendor chunk。 - 动态拆分:代码中使用
import()函数标记拆分点,构建工具遇到该语法时自动将其拆分为异步 chunk,运行时按需加载。
webpack 与 Vite 对两种路径均有支持,但 API 和默认行为差异明显。
webpack 的代码分割:SplitChunksPlugin 与动态导入
webpack 提供了三类代码分割方式:通过 entry 手动拆分、借助 SplitChunksPlugin 去重与拆分、以及使用动态 import() [2]。
多入口拆分方式最直观,在 webpack.config.js 中声明多个 entry,每个入口会生成一个对应的 bundle[3]:
js
// webpack.config.js
module.exports = {
entry: {
index: './src/index.js',
another: './src/another-module.js',
},
output: {
filename: '[name].bundle.js',
},
};构建后会得到 index.bundle.js 与 another.bundle.js。但如果两个入口都引用了 lodash,lodash 会被分别打入两个 bundle。消除这种重复需要依赖 SplitChunksPlugin [4]。
SplitChunksPlugin 自 webpack 4 起内置,通过 optimization.splitChunks 配置。默认配置已对 node_modules 中的依赖做了拆分,但项目通常需要根据实际情况调整 cacheGroups。
js
// webpack.config.js
module.exports = {
// ...
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
common: {
minChunks: 2,
name: 'common',
chunks: 'all',
reuseExistingChunk: true,
},
},
},
},
};chunks: 'all' 表示同时对同步和异步 chunk 进行拆分。vendor 组将 node_modules 下的模块合并为一个 vendors.chunk.js,common 组则把至少被两个 chunk 引用的业务模块抽成公共块。
动态导入直接在模块内使用 import() 语法,webpack 会自动将动态引入的模块拆分为单独 chunk[5]:
js
// 事件处理中
button.addEventListener('click', () => {
import('./heavy-module').then((module) => {
module.doSomething();
});
});此时 webpack 会为 heavy-module.js 生成一个带哈希的异步 chunk,只有点击按钮时才会发起网络请求。
Vite 的代码分割:基于 Rollup 的 manualChunks 与动态导入
Vite 的开发服务器基于浏览器原生 ESM,不进行打包;生产构建阶段则交由 Rollup。Rollup 同样对动态导入执行自动拆分,因此代码中一旦出现 import(),就会产生产异步 chunk[9]。
默认策略下,业务代码与第三方包会分别处理。Vite 2.9 后,入口 JS 会合并为一个初始 chunk,CSS 也会按 chunk 自动拆分。异步 chunk 由动态 import 触发。若需调整粒度,可通过 build.rollupOptions.output.manualChunks 手动控制[10]。
manualChunks 可以是一个对象,键为 chunk 名称,值为包名组成的数组:
ts
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom'],
lodash: ['lodash-es'],
library: ['antd'],
},
},
},
},
});对象形式将明确列出的依赖合并到指定 chunk。如果需要更复杂的拆分逻辑,可以传递一个函数:
ts
manualChunks(id: string) {
if (id.includes('node_modules/react')) return 'react-vendor';
if (id.includes('node_modules/lodash-es')) return 'lodash';
if (id.includes('src/utils/')) return 'utils';
}函数形式虽然灵活,但容易引入循环依赖[11]。比如 a.js 异步加载 b.js,b.js 反过来异步加载 a.js,Rollup 处理这种场景时可能产生循环引用,导致运行时白屏。采用函数式 manualChunks 之前,应当检查产物的依赖结构,避免将相互引用的模块拆分到不同 chunk。
懒加载:按钮、路由与组件的按需引入
懒加载本质上是 import() 的工程化运用。点击按钮加载功能模块是最简单的例子:
js
const btn = document.getElementById('loadBtn');
btn.addEventListener('click', async () => {
const { renderChart } = await import('./chart.js');
renderChart(document.getElementById('container'));
});在浏览器 DevTools 的 Network 面板中可以看到,点击后才会发起对 chart-xxxx.js 的请求。webpack 与 Vite 对此例的处理方式一致,都会产生产异步 chunk。
路由级别的懒加载更为常见。React Router 和 Vue Router 均支持将路由组件写成动态导入形式,对应异步 chunk 在路由跳转时才加载。
React 示例:
jsx
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));Vue 示例:
js
const routes = [
{ path: '/dashboard', component: () => import('./pages/Dashboard.vue') },
{ path: '/settings', component: () => import('./pages/Settings.vue') },
];两者都会生成相应的异步 chunk,首屏不加载未激活路由的代码。React 的 lazy 需搭配 Suspense 提供加载中的回退 UI,Vue 异步组件则可以自行设置 loading 状态。
Tree Shaking 的支柱:ESM、副作用标记与打包优化
Tree shaking 是指在打包时移除模块中未被引用的导出。它依赖 ESM 的静态 import/export 语法 —— require 是运行时加载,不存在确定的依赖关系,因此无法在构建阶段分析哪些导出会被使用[7]。
webpack 在 production 模式下启用 tree shaking;Vite 的 Rollup 构建默认也会执行 shake。二者均要求源码使用 ESM 形式的模块。
除此之外,还须在 package.json 中声明 "sideEffects": false(或列出有副作用的文件路径)。该字段告知打包工具:模块内不存在具有顶级副作用的代码,可以安全地删除未被使用的导出。
js
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// 无全局副作用代码js
// main.js
import { add } from './math';
console.log(add(1, 2));若 math.js 不含副作用且 package.json 配置了 "sideEffects": false,最终 bundle 会移除 subtract 的代码。但如果某个模块存在带副作用的样式导入(如 import './styles.css'),则需要将样式文件模式列入 sideEffects 白名单,否则样式可能被误删。
webpack 的 tree shaking 依靠 TerserPlugin 做死代码消除,同时 optimization.usedExports 标记哪些导出被使用;Vite / Rollup 则利用自身静态分析能力,大多数情况下效果与 webpack 相当。
静态资源处理:从 url-loader 到内置方案
webpack 历史上处理图片、字体等资源需要配置一系列 loader:小于特定体积的图片用 url-loader 转为 base64 内联,较大的用 file-loader 拷贝到输出目录。webpack 5 引入的 asset modules 替代了这两类 loader。
当前 webpack 处理图片可按如下配置 module.rules:
js
module: {
rules: [
{
test: /\.(png|jpe?g|gif|svg)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024, // 8KB 以下内联为 base64
},
},
generator: {
filename: 'images/[name].[hash][ext]',
},
},
],
}type: 'asset' 会根据 maxSize 自动在内联和文件拷贝间切换。asset/resource 相当于 file-loader,asset/inline 相当于 url-loader。
Vite 对静态资源的处理更加内聚。直接在代码中 import 图片,开发时返回对应 URL,生产构建时默认将小资源内联为 base64(通过 build.assetsInlineLimit 控制),其余文件拷贝到 dist/assets 目录。
ts
import logo from './logo.png';
img.src = logo; // 开发时是 src="/logo.png",构建后变成带哈希路径对于 SVG,Vite 支持通过插件将其作为 React 组件导入(例如 vite-plugin-svgr)。webpack 则可借助 @svgr/webpack 实现类似的组件化导入。
生产构建优化对比:压缩、缓存与产物分析
代码压缩方面,webpack 5 默认使用 TerserPlugin 处理 JS,通过 optimization.minimizer 配置 CssMinimizerPlugin 处理 CSS。Vite 使用 esbuild 执行 JS 与 CSS 压缩,速度远快于 Terser,但 esbuild 对 ES5 语法降级支持有限;若必须输出 ES5 代码,可以通过 build.target 指定目标或切换回 Terser。
CSS 抽取上,webpack 通常搭配 mini-css-extract-plugin 将 CSS 从 JS 中抽成独立文件,利用 contenthash 实现持久缓存。Vite 的生产构建默认会为每个异步 chunk 生成对应 CSS 文件,入口 CSS 也会抽离。
持久化缓存的核心在于文件名中的哈希。webpack 可使用 [contenthash] 保证只有内容变化的文件才会更新哈希。Vite 在 build.rollupOptions.output 中同样支持 [hash]:
js
output: {
entryFileNames: 'js/[name]-[hash].js',
chunkFileNames: 'js/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
}如果哈希策略配置不当,某个公共 chunk 的改动仍可能引起连锁的哈希更新。在实践中,webpack 的 SplitChunksPlugin 配合 runtimeChunk 可以将运行时代码单独抽出,减少对模块树的依赖变化;Vite 的 Rollup 构建也可以通过 output.manualChunks 稳定 chunk 边界。
产物分析方面,webpack 有 webpack-bundle-analyzer 生成体积占比的可视化报告。Vite 生态中同样可使用 rollup-plugin-visualizer 实现类似功能。
进阶构建能力的差异与权衡
webpack 在代码分割上提供了较大的配置空间:splitChunks 的 cacheGroups 可以通过正则、函数、优先级、chunk 类型等方式精细控制每个 chunk 的组成,并能结合 runtimeChunk 稳定模块关系。代价是配置较为复杂,不合理的分组反而可能增加请求数量或造成冗余加载。
Vite 的 manualChunks 对象形式简单可控,但粒度较粗;函数形式虽然灵活,却容易引入循环依赖的问题。若要达到与 webpack 同等的精细度,需要在 Rollup 的输出配置上做额外工作。
Tree shaking 方面,两者均依赖 ESM 和 sideEffects 声明,日常项目中的效果基本没有显著差异。如果依赖包的源码不是 ESM 格式,则两者都无法进行 shake。
资源处理上,Vite 内置方案覆盖了常见图片、字体、JSON、CSS 预处理器等场景,兼容性通过 esbuild 与 PostCSS 的组合完成;webpack 虽然需要 loader 拼装,但在适配非标准资源时灵活性更高。
生产构建的压缩速度上,Vite 具备明显优势;但如果需要精细的缓存拆分和面向 HTTP/2 的 chunk 分发策略,webpack 的控制力会更强。两类工具的最终产物在组织方式上的差异并不会影响运行时行为,选择通常取决于团队对构建工具的熟悉程度和项目长期维护的配置成本。
