3、Webpack与构建压缩:JS/CSS/HTML压缩插件、Tree Shaking与代码分割、资源指纹与缓存策略

构建压缩,说白了就是把我们写的「人话」代码,转成浏览器能高效加载的「机器话」。Webpack 在这个过程中扮演的角色,就像一个精明的物流中心——它负责把零散的包裹(模块)打包、压缩、贴标签,最后以最优的方式送到客户端。

我个人习惯把构建压缩分成三个层面:体积压缩、代码裁剪、缓存利用。这三者配合好了,你的页面加载速度能提升一个量级。

3.1 JS/CSS/HTML 压缩插件

先聊聊最基础的压缩。压缩的本质是去掉代码中「人可读但机器不需要」的部分。

3.1.1 JavaScript 压缩:TerserWebpackPlugin

Webpack 5 内置了 terser-webpack-plugin,默认在生产模式下启用。它会做这几件事:

  • 删除空白和注释——去掉换行、空格、块注释
  • 缩短变量名——把 userName 变成 a、b
  • 删除死代码——比如 if (false) { ... }
  • 计算常量表达式——2 * 3 直接变成 6

我在项目中遇到过一个问题:默认配置下,Terser 会删除所有 console.log。这对线上环境是好事,但调试时很头疼。后来我加了配置,保留 console.error:

// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true,   // 删除所有 console
            pure_funcs: ['console.log'] // 只删除 console.log
          }
        }
      })
    ]
  }
};
小提示:如果你用 drop_console: true,记得在开发环境关掉它。否则你连 console.error 都看不到,线上出问题排查起来很痛苦。

3.1.2 CSS 压缩:CssMinimizerPlugin

CSS 压缩和 JS 不太一样。CSS 没有变量名可以缩短,主要做的是:

  • 合并相同选择器——.a { color: red } .a { font-size: 12px } 合并成一条
  • 删除无效规则——比如 color: red; color: blue 只保留最后一个
  • 压缩颜色值——#ff0000 变成 red,#ffffff 变成 #fff

我推荐用 css-minimizer-webpack-plugin,它底层用的是 cssnano,压缩率很高:

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [
      new CssMinimizerPlugin({
        minimizerOptions: {
          preset: [
            'default',
            { discardComments: { removeAll: true } }
          ]
        }
      })
    ]
  }
};
注意:CSS 压缩可能会破坏某些 Hack 写法。比如 _color: red(IE6 专属)会被当成无效属性删掉。我曾经因为这个踩过坑,线上 IE6 样式全乱了。如果你需要兼容老 IE,记得在 cssnano 配置里关掉 mergeRules。

3.1.3 HTML 压缩:HtmlWebpackPlugin

HTML 压缩相对简单,主要是去掉空格、注释、换行。我用 html-webpack-plugin 配合 html-minifier-terser:

const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html',
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeAttributeQuotes: true,
        minifyCSS: true,
        minifyJS: true
      }
    })
  ]
};

嗯,这里要注意:removeAttributeQuotes: true 在某些场景下会出问题。比如 data-value="a b" 去掉引号后变成 data-value=a b,浏览器会解析成两个属性。我建议只对生产环境开启这个选项,并且确保你的属性值里没有空格。

3.2 Tree Shaking 与代码分割

这两个概念经常被放在一起说,但它们的职责完全不同。

  • Tree Shaking:去掉「用了但没完全用」的代码(死代码消除)
  • Code Splitting:把代码拆成多个文件,按需加载

3.2.1 Tree Shaking 的原理与配置

Tree Shaking 依赖 ES Module 的静态结构——import 和 export 是在编译时确定的,不像 CommonJS 的 require 可以在运行时动态加载。Webpack 通过分析模块的依赖图,标记出哪些导出没有被使用,然后在压缩阶段由 Terser 把这些「死代码」删掉。

要让它生效,需要满足三个条件:

  1. 使用 ES Module 语法——import/export,而不是 require/module.exports
  2. 在 package.json 中设置 "sideEffects": false——告诉 Webpack 这个包里的模块没有副作用,可以安全删除
  3. 生产模式——mode: 'production' 会自动开启 Tree Shaking

举个例子:

// utils.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }

// main.js
import { add } from './utils';
console.log(add(1, 2));

经过 Tree Shaking 后,subtract 函数会被完全删除。但这里有个坑——如果 utils.js 里有全局的副作用代码,比如 Array.prototype.xxx = ...,Webpack 就不敢删了。这时候你需要在 package.json 里明确声明哪些文件有副作用:

{
  "name": "my-project",
  "sideEffects": [
    "./src/polyfill.js",
    "*.css"
  ]
}
核心要点:Tree Shaking 不是 Webpack 独有的,Rollup、esbuild 都有类似机制。但 Webpack 的 Tree Shaking 依赖 Terser 做最后的删除,所以压缩插件必须开启。

3.2.2 代码分割的三种方式

代码分割解决的是「首屏加载太多无用代码」的问题。我常用的有三种方式:

方式 配置 适用场景
入口分割 entry: { main: './src/main.js', admin: './src/admin.js' } 多页面应用,每个页面独立入口
动态导入 import('./module.js').then(...) 路由懒加载、按需加载组件
SplitChunks optimization.splitChunks 提取公共依赖(如 React、lodash)

我个人最常用的是 SplitChunks。它能把 node_modules 里的第三方库单独打包成一个 vendor.js,这样业务代码更新时,vendor 的缓存还能继续用。

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          chunks: 'all'
        }
      }
    }
  }
};

我曾经遇到过一个性能问题:某个项目把所有第三方库都打进了 vendor.js,结果这个文件有 2MB。用户每次访问都要下载整个 vendor,即使只用了其中一小部分。后来我改用 chunks: 'async',只对动态导入的模块做分割,首屏体积降了 60%。

3.3 资源指纹与缓存策略

资源指纹,说白了就是给文件名加个 Hash。这样浏览器就能知道文件有没有变化——Hash 变了就重新下载,没变就用缓存。

3.3.1 三种 Hash 的区别

Webpack 提供了三种 Hash,很多人搞混它们:

类型 生成方式 变化条件
[hash] 基于整个项目构建 任何文件变化都会变
[chunkhash] 基于每个 Chunk 只有该 Chunk 内容变化才变
[contenthash] 基于文件内容 只有该文件内容变化才变

我建议用 [contenthash]。为什么?因为 [hash] 太粗了——你改了一个 CSS 文件,所有 JS 文件的 Hash 都变了,缓存全失效。而 [contenthash] 能做到「改谁谁变」。

// webpack.config.js
module.exports = {
  output: {
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js'
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].[contenthash:8].css'
    })
  ]
};
小技巧:Hash 长度用 8 位就够了。32 位太长,增加文件名长度;4 位又太短,容易碰撞。8 位是平衡点。

3.3.2 缓存策略的落地

有了资源指纹,缓存策略就简单了:

  • HTML:不缓存,或者缓存很短时间(比如 5 分钟)。因为 HTML 是入口,它引用的资源名变了,必须重新获取
  • JS/CSS:设置 Cache-Control: max-age=31536000(一年)。文件名变了就相当于新文件,浏览器自然会重新下载
  • 图片/字体:同样设置长期缓存,配合指纹使用

在 Nginx 里可以这样配:

location /static/ {
  # 带 contenthash 的资源,缓存一年
  if ($uri ~* "\.[a-f0-9]{8}\.") {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
}

location / {
  # HTML 不缓存
  add_header Cache-Control "no-cache, must-revalidate";
}
避坑指南:我曾经遇到一个诡异的问题——用户反馈页面样式不对,但清缓存就好了。排查后发现是 immutable 这个指令的问题。它告诉浏览器「这个文件永远不会变」,有些浏览器会忽略服务器返回的 304,强制使用本地缓存。如果你用了 immutable,一定要确保文件名 Hash 是 contenthash,否则用户永远看不到更新。

3.4 本章知识体系

下面这张图把本章的核心逻辑串起来了。你可以看到,从源码到最终部署,每一步都在解决一个具体问题:

Webpack 构建压缩知识体系 Webpack 构建压缩知识体系 源码(JS/CSS/HTML) 压缩插件:TerserPlugin / CssMinimizerPlugin / HtmlMinifier Tree Shaking(死代码消除)+ Code Splitting(代码分割) 资源指纹:[contenthash:8] 生成唯一文件名 缓存策略:HTML 不缓存 / JS/CSS 长期缓存(1年) 部署到 CDN / Nginx 每一层都依赖上一层的结果:压缩 → 裁剪 → 分割 → 指纹 → 缓存,缺一不可

从这张图可以看得很清楚:构建压缩不是单一操作,而是一条流水线。每一层都依赖上一层的结果。如果你跳过 Tree Shaking 直接做缓存,那缓存里可能还藏着大量没用的代码;如果你做了压缩但没做指纹,那缓存策略就无从谈起。

好了,这一章的内容就到这里。记住一个原则:能压缩的压缩,能删的删,能拆的拆,最后用指纹锁住缓存。这套组合拳打下来,你的前端项目加载速度会有质的提升。