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 } }
]
}
})
]
}
};
_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 把这些「死代码」删掉。
要让它生效,需要满足三个条件:
- 使用 ES Module 语法——
import/export,而不是require/module.exports - 在
package.json中设置"sideEffects": false——告诉 Webpack 这个包里的模块没有副作用,可以安全删除 - 生产模式——
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"
]
}
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'
})
]
};
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 本章知识体系
下面这张图把本章的核心逻辑串起来了。你可以看到,从源码到最终部署,每一步都在解决一个具体问题:
从这张图可以看得很清楚:构建压缩不是单一操作,而是一条流水线。每一层都依赖上一层的结果。如果你跳过 Tree Shaking 直接做缓存,那缓存里可能还藏着大量没用的代码;如果你做了压缩但没做指纹,那缓存策略就无从谈起。
好了,这一章的内容就到这里。记住一个原则:能压缩的压缩,能删的删,能拆的拆,最后用指纹锁住缓存。这套组合拳打下来,你的前端项目加载速度会有质的提升。