手把手系列之——Webpack 优化技巧

0 阅读7分钟

大家的打包工具使用的是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-loaderreact-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-loaderTerserPluginparallel,并配合 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 Moduleimport/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,效果会非常明显,大家可以动手试试。