大家的打包工具使用的是Webpack吗?体验怎么样呢?本文将从开发体验、构建速度、产物体积、运行性能四个维度,手把手教你把webpack 调教成「又快又省」的构建引擎。
一、优化分类
一次打包背后,其实在干三件事:编译(ESLint、Babel、Loader)→ 打包合并 → 压缩输出。
优化就是:少干活、多复用、结果更小、跑得更快。
可以简单记成四类:
| 方向 | 目标 | 典型手段 |
|---|---|---|
| 开发体验 | 报错能快速定位到源码 | Source Map |
| 构建速度 | 改一点不用全量重编、少处理无关文件 | HMR、OneOf、Include/Exclude、Cache、多进程 |
| 产物体积 | 少打无用代码、少重复、资源更小 | Tree Shaking、Babel 复用、图片压缩 |
| 运行性能 | 首屏快、按需加载、缓存友好、兼容与离线 | Code Split、Preload/Prefetch、contenthash、Core-js、PWA |
二、开发体验:让报错「说人话」
打包后的代码被合并、混淆,报错行号对不上源码,排查成本高。
Source Map 就是「打包后代码 ↔ 源代码」的映射表,让浏览器和调试工具能反推到真正的文件和行号。
- 开发环境:追求编译快,用
cheap-module-source-map(有行映射即可,不要列)。 - 生产环境:要精准定位,用
source-map(行+列,体积会大一些)。
// 开发
module.exports = {
mode: "development",
devtool: "cheap-module-source-map",
};
// 生产
module.exports = {
mode: "production",
devtool: "source-map",
};
配置好之后,控制台里点报错就能直接跳到源码对应行,体验会好很多。
三、构建速度:少做无用功、多复用结果
3.1 热更新(HMR)—— 只重编改动的模块
默认行为是:改一个文件,整个项目重新打包。
Hot Module Replacement 可以在不刷新页面的前提下,只替换发生变化的模块,其余用缓存,开发时反馈会快很多。
在 devServer 里打开即可,CSS 经 style-loader 处理后通常已经支持;JS 若需要细粒度热更,可配合 vue-loader、react-hot-loader 或手动 module.hot.accept。
module.exports = {
devServer: {
host: "localhost",
port: "3000",
open: true,
hot: true, // 开启 HMR
},
};
3.2 OneOf —— 每个文件只走一个 Loader
默认情况下,每个文件都会把 rules 里的规则「过一遍」,即使很多根本匹配不上。
用 oneOf 包一层,命中一个 loader 后就不再往下匹配,减少无效遍历。
module.exports = {
module: {
rules: [
{
oneOf: [
{ test: /\.css$/, use: ["style-loader", "css-loader"] },
{ test: /\.less$/, use: ["style-loader", "css-loader", "less-loader"] },
{ test: /\.js$/, include: path.resolve(__dirname, "src"), loader: "babel-loader" },
// ...
],
},
],
},
};
3.3 Include / Exclude —— 别动 node_modules
第三方库在 node_modules 里,一般已经是可以直接用的 ES5,不需要再被 Babel/ESLint 处理。
用 exclude 排除,或用 include 只包含 src,都能明显减少处理量。
{
test: /\.js$/,
include: path.resolve(__dirname, "../src"), // 只处理 src
// exclude: /node_modules/, // 或排除 node_modules
loader: "babel-loader",
}
ESLint 同理,在插件里加上 exclude: "node_modules"。
3.4 Cache —— 第二次打包直接复用
ESLint 和 Babel 的结果在源码不变时是可以复用的。开启缓存后,第二次、第 N 次打包会快很多。
// Babel 缓存
{
loader: "babel-loader",
options: {
cacheDirectory: true,
cacheCompression: false,
},
}
// ESLint 缓存
new ESLintWebpackPlugin({
context: path.resolve(__dirname, "../src"),
cache: true,
cacheLocation: path.resolve(__dirname, "../node_modules/.cache/.eslintcache"),
}),
3.5 多进程(Thread)—— 把 CPU 用满
对 JS 的处理主要集中在 ESLint、Babel、Terser,它们都可以多进程并行。
用 thread-loader 和 TerserPlugin 的 parallel,并配合 CPU 核数,在模块较多时能明显缩短构建时间。
注意:进程有启动开销(约 600ms 量级),小项目可能反而变慢,适合「真的大」的项目。
const os = require("os");
const threads = os.cpus().length;
// babel 前加 thread-loader
use: [
{ loader: "thread-loader", options: { workers: threads } },
{ loader: "babel-loader", options: { cacheDirectory: true } },
],
// Terser 开多进程
new TerserPlugin({ parallel: threads }),
四、产物体积:能少打就少打
4.1 Tree Shaking —— 未用到的代码别打进包
引用一个工具库时,如果只用其中一个函数,理想情况是只打包这一份。
Tree Shaking 就是按 ES Module 的静态结构,把「没被用到的导出」从 bundle 里删掉。
Webpack 在生产模式下默认会做,前提是代码写成 ES Module(import/export),并且没有在库里把副作用写得太「重」。
无需额外配置,保证用 ES Module 即可。
4.2 Babel 辅助代码复用 —— 别在每个文件里塞一遍 runtime
Babel 会注入一些辅助函数(如 _extend),默认每个用到的新语法文件都塞一份,体积会重复膨胀。
用 @babel/plugin-transform-runtime 把这些辅助代码抽成从同一处引用,整体体积会小不少。
npm i @babel/plugin-transform-runtime -D
{
loader: "babel-loader",
options: {
cacheDirectory: true,
cacheCompression: false,
plugins: ["@babel/plugin-transform-runtime"],
},
}
4.3 图片压缩(Image Minimizer)
本地静态图片多时,可以在打包阶段做一次压缩(支持无损/有损),减少请求体积。
若资源全是 CDN 或在线链接,可以不做。
npm i image-minimizer-webpack-plugin imagemin imagemin-gifsicle imagemin-jpegtran imagemin-optipng imagemin-svgo -D
const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin");
optimization: {
minimizer: [
new CssMinimizerPlugin(),
new TerserPlugin({ parallel: threads }),
new ImageMinimizerPlugin({
minimizer: {
implementation: ImageMinimizerPlugin.imageminGenerate,
options: {
plugins: [
["gifsicle", { interlaced: true }],
["jpegtran", { progressive: true }],
["optipng", { optimizationLevel: 5 }],
["svgo", { plugins: ["preset-default", "prefixIds", { name: "sortAttrs", params: { xmlnsOrder: "alphabetical" } }] },
],
},
},
}),
],
},
五、运行性能:首屏快、按需加载、缓存稳
5.1 Code Split —— 拆包 + 按需加载
问题:单入口时,所有 JS 打成一个文件,首屏就要下载整份 bundle,慢且浪费。
思路:
1)把代码拆成多份(多入口 或 splitChunks 抽公共/第三方);
2)用 动态 import 做按需加载,用到再加载。
多入口示例:
entry: {
main: "./src/main.js",
app: "./src/app.js",
},
output: {
filename: "js/[name].js",
// ...
},
单入口 + 公共代码提取:
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
default: {
minSize: 0,
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
按需加载:用动态 import(),webpack 会自动把该模块打成单独 chunk,需要时再请求。
document.getElementById("btn").onclick = function () {
import(/* webpackChunkName: "math" */ "./math.js").then(({ sum }) => {
alert(sum(1, 2, 3, 4, 5));
});
};
这样首屏只加载主 chunk,点击再加载「计算」相关代码,首屏体积和解析时间都会下来。
5.2 Preload / Prefetch —— 提前拉取关键资源
- Preload:当前页立刻加载,优先级高,适合当前页马上要用的资源。
- Prefetch:浏览器空闲时加载,优先级低,适合「下一个页面可能用到的」资源。
用 @vue/preload-webpack-plugin 可以自动给动态 chunk 加 preload(或 prefetch),用户真正点击时资源可能已经在缓存里了。
const PreloadWebpackPlugin = require("@vue/preload-webpack-plugin");
plugins: [
new PreloadWebpackPlugin({
rel: "preload",
as: "script",
}),
],
5.3 Network Cache —— 用 contenthash 做长期缓存
发布新版本时,如果文件名不变,浏览器会继续用旧缓存,用户看不到新代码。
解决办法:用 contenthash 把「内容 → 文件名」绑定,内容变了文件名才变,未变的文件可以长期缓存。
- fullhash:任意文件改一点,所有文件名 hash 都变,缓存几乎全废。
- chunkhash:按 chunk 算,同一 chunk 的 js/css 会共用一个 hash。
- contenthash:按文件内容算,每个文件独立,最适合做缓存。
推荐产出命名:
output: {
filename: "static/js/[name].[contenthash:8].js",
chunkFilename: "static/js/[name].[contenthash:8].chunk.js",
},
// MiniCssExtractPlugin
filename: "static/css/[name].[contenthash:8].css",
chunkFilename: "static/css/[name].[contenthash:8].chunk.css",
还有一个细节:只改某个动态 chunk(如 math.js)时,如果主 chunk 里「引用关系」变了,主 chunk 的 contenthash 也会变,导致主包缓存失效。
可以把 runtime 抽成单独文件(存 chunk 与 hash 的映射),这样改子 chunk 时只有 runtime 和该 chunk 变,主 chunk 不变:
optimization: {
runtimeChunk: {
name: (entrypoint) => `runtime~${entrypoint.name}`,
},
},
5.4 Core-js —— 补齐 ES6+ API
Babel 的 preset-env 主要转语法(箭头函数、展开运算符等),但 Promise、Array.prototype.includes、async 等 API 在旧环境里没有,需要 polyfill。
core-js 就是做这件事的,可以全量引入,也可以按需(推荐在 Babel 里配 useBuiltIns: "usage" 自动按需)。
// babel.config.js
module.exports = {
presets: [
["@babel/preset-env", { useBuiltIns: "usage", corejs: { version: "3", proposals: true } }],
],
};
这样打包只会带上你用到的 API 的 polyfill,体积更可控。
5.5 PWA —— 离线也能用
希望 Web 应用在断网时仍能打开已访问过的页面,可以用 PWA:通过 Service Worker 把静态资源缓存到本地。
用 workbox-webpack-plugin 可以在构建时生成 Service Worker,再在入口里注册即可(具体路径要以你实际部署为准,例如用 serve dist 本地预览时注意 service-worker.js 的路径)。
六、小结
- 开发体验:
devtool: "cheap-module-source-map"(开发)/"source-map"(生产)。 - 构建速度:HMR + OneOf + Include/Exclude + ESLint/Babel Cache + 多进程(大项目)。
- 产物体积:Tree Shaking(ESM)+
@babel/plugin-transform-runtime+ 图片压缩(有本地图时)。 - 运行性能:Code Split + 动态 import + Preload/Prefetch + contenthash + runtimeChunk + core-js(按需)+ 可选 PWA。
如果你手头已经是基于 webpack 的 Vue/React 项目,可以从 HMR、OneOf、Include/Exclude 和 Cache 先做起,再根据包体积和首屏情况加 Code Split 和 contenthash,效果会非常明显,大家可以动手试试。