webpack 打包
以下针对 webpack 为 5 的情况,所有依赖的版本如下:
快速上手
const path = require('path')
module.exports = {
// mode:工作模式:development, production, null
// 不设置默认为 production。
// production 模式会自动启用一些优化插件,比如压缩,打包结果无法阅读
// development 模式会自动优化打包速度,添加调试过程中的辅助
// null 模式运行最原始状态的打包,不做任何额外的处理
mode: 'none',
// entry:入口文件路径。 如果是相对路径的话, ./ 不能省略
entry: './src/main.js',
// output: 输出文件路径,是一个对象
output: {
// filename:输出文件名
filename: 'bundle.js',
// 输出文件路径,必须为绝对路径,所以使用 path.join(__dirname, xxx)
path: path.join(__dirname, 'dist'),
publicPath: 'dist/', // 打包过后的文件最终位置
},
}
Loader
在我们的项目中,我们需要处理的不仅仅是 js 的代码,我们可以使用加载器对不同类型的文件进行处理。Loader 是实现前端模块化的核心,借助于 Loader 可以加载任何类型的资源。
通过配置 module 来配置 loader。rules 为规则配置。
可以将 Loader 分为几个类型:
- 编译转换类。例如 css-loader,将 css 代码转换为 js 进行工作。
- 文件操作类。例如 file-loader,对文件进行拷贝,再将文件路径向外导出。
- 代码检查类。统一代码风格,从而提高代码质量,不会修改生产环境的代码。例如 eslint-loader。
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/',
},
module: {
rules: [
{
test: /.js$/,
use: {
// es6+ 新特性可以使用 babel-loader 进行编译转换
// webpack 只是打包工具,加载器可以用来编译转换代码
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
},
{
// 匹配打包过程中遇到的文件路径
test: /.css$/,
// 如果配置了多个 loader,执行时从后往前执行
use: [
'style-loader', // 将 css-loader 转换过后的结果通过 style 标签的形式追加到页面上
'css-loader' // 将css文件转换为js模块,
]
},
{
test: /\.(png|jpg|gif)$/i,
// 小文件使用 Data URLs,减少请求次数
// 大文件单独提取存放,提高加载速度
// use: 'file-loader', // 文件资源加载器
// use: 'url-loader' // 将图片转换为 Data Urls,图片将被转为 base64
// 将 use 设为对象,loader 设为 url-loader,并设置一个 limit
// 此时文件小于 10kb 使用 url-loader,大于10kb则默认使用 file-loader
use: [
{
loader: 'url-loader',
options: {
limit: 10 * 1024, // 10 KB
// esModule: false, // 解决 html 中载入图片导致的[Object Module]问题
}
}
]
},
{
test: /\.html$/i,
loader: 'html-loader',
options: {
esModule: false, // 禁用 es modules 语法
sources: {
list: [
'...',
{
tag: 'a',
attribute: 'href',
type: 'src'
}
]
}
}
},
]
}
}
Plugin
插件机制是 webpack 中另外一个核心特性,目的是为了增强 webpack 在项目自动化方面的能力。Loader 专注实现资源模块加载,从而实现整体项目打包,而 Plugin 是为了解决除资源加载以外其他的自动化工作。eg:
- 在打包之前清除上一次的 dist 目录
- 拷贝静态文件至输出目录
- 压缩输出代码
clean-webpack-plugin:用来在打包前清除 dist 的插件。
html-webpack-plugin:自动生成使用 bundle.js 的 HTML。由于我们的HTML都是通过硬编码的方式放在根目录下,发布的时候需要同时发布这个HTML文件,而且还要确保资源文件路径正确,需要手动修改。通过这个插件就可以解决这个问题。webpack 打包的时候知道自己生成了多少 bundle,将 bundle 自动放入 HTML 文件中,这样 html 也输出到了 dist 目录,而且对 bundle 的引入是注入的,能够确保路径正确。
copy-webpack-plugin: 拷贝文件。
const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
module.exports = {
// mode:工作模式:development, production, null
// 不设置默认为 production。
// production 模式会自动启用一些优化插件,比如压缩,打包结果无法阅读
// development 模式会自动优化打包速度,添加调试过程中的辅助
// null 模式运行最原始状态的打包,不做任何额外的处理
mode: 'none',
// entry:入口文件路径。 如果是相对路径的话, ./ 不能省略
entry: './src/main.js',
// output: 输出文件路径,是一个对象
output: {
// filename:输出文件名
filename: 'bundle.js',
// 输出文件路径,必须为绝对路径,所以使用 path.join(__dirname, xxx)
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/', // 打包过后的文件最终位置
},
// 使用加载器对不同类型的文件进行处理
// Loader 是实现前端模块化的核心,借助于 Loader 就可以加载任何类型的资源
module: {
// 规则配置
rules: [
{
test: /.js$/,
exclude: /(node_modules)|ejs$/,
use: {
// es6+ 新特性可以使用 babel-loader 进行编译转换
// webpack 只是打包工具,加载器可以用来编译转换代码
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
},
{
// 匹配打包过程中遇到的文件路径
test: /.css$/,
// 如果配置了多个 loader,执行时从后往前执行
use: [
'style-loader', // 将 css-loader 转换过后的结果通过 style 标签的形式追加到页面上
'css-loader' // 将css文件转换为js模块,
]
},
{
test: /\.(png|jpg|gif)$/i,
// 小文件使用 Data URLs,减少请求次数
// 大文件单独提取存放,提高加载速度
// use: 'file-loader', // 文件资源加载器
// use: 'url-loader' // 将图片转换为 Data Urls,图片将被转为 base64
// 将 use 设为对象,loader 设为 url-loader,并设置一个 limit
// 此时文件小于 10kb 使用 url-loader,大于10kb则默认使用 file-loader
use: [
{
loader: 'url-loader',
options: {
limit: 10 * 1024, // 10 KB
// esModule: false, // 解决 html 中载入图片导致的[Object Module]问题
}
}
]
},
{
test: /\.html$/i,
loader: 'html-loader',
options: {
esModule: false, // 禁用 es modules 语法
sources: {
list: [
'...',
{
tag: 'a',
attribute: 'href',
type: 'src'
}
]
}
}
},
{
test: /.md$/,
use: [
'html-loader',
'./markdown-loader'
]
}
]
},
// plugins: 用来配置插件
// 绝大多数插件都是导出一个类型
// 使用插件就是创建一个这个类型的实例,将实例放入 Plugin 数组中
plugins: [
// clean-webpack-plugin 是用来在打包前清除 dist 的插件
new CleanWebpackPlugin(),
// html-webpack-plugin 是自动生成使用 bundle.js 的 HTML
// html-webpack-plugin 中也可以传入一个 options 作为配置选项
// 用于生成 index.html
new HtmlWebpackPlugin({
meta: { // 设置元数据标签
viewport: 'width=device-width'
},
title: 'webpack plugin sample', // 标题
// html-webpack-plugin 中的 <%= htmlWebpackPlugin.options.title %> 会被 html-loader 当做字符串处理,所以会不生效,需要将 html 模板改为 ejs 类型
// 如果使用了 babel-loader,就会去跑 ejs 文件,会报错,所以要在 babel-loader 设置忽略 ejs 文件
template: 'src/index.ejs' // 模板,根据模板生成页面
}),
// html-webpack-plugin 可以用于生成多个 html 文件
// 用于生成 about.html
new HtmlWebpackPlugin({
filename: 'about.html'
}),
// copy-webpack-plugin: 拷贝
// 开发阶段最好不要使用这个插件
new CopyWebpackPlugin({
patterns: [
{ from: 'public', to: 'public' }
]
})
]
}
webpack-dev-server
安装依赖,然后执行 yarn webpack serve --open
会自动打开浏览器并打开 watch 模式监听页面变化刷新页面。
webpack-dev-server 默认会把所有可以打包的文件放到内存里(不会写入磁盘)。一般在开发阶段不需要将静态资源打包,还可以通过在 devServer 中配置 contentBase,设置为静态资源的目录,开发阶段就可以访问到静态资源。
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/', // 打包过后的文件最终位置
},
devServer:{
// 静态资源目录,可以是字符串或数组,也就是说可以配置一个或多个
contentBase:'./public'
},
}
HMR - 热更新
自动刷新页面会导致用户的操作状态丢失,这个时候我们可以使用 HMR - 热更新(热替换),热替换只将修改的模块实时替换至应用中,在页面同步更新的同时保持应用的运行状态不受影响。它极大程度的提高了开发者的工作效率。
HMR 已经集成在 webpack-dev-server 中,不需要再引入依赖。
- 首先引入 webpack
const webpack = require('webpack')
- 在 devServer 中配置 hot:true
devServer: {
hot: true
}
- 在 plugins 中配置插件
// 热更新
new webpack.HotModuleReplacementPlugin()
然后运行项目,发现修改 css 时实现了热更新,而修改 js 时没有实现热更新。这是因为需要手动处理JS更新后的热更新逻辑。在成熟的项目框架中是不需要我们手动来处理的,因为框架已经为我们处理了。
Proxy - 代理
devServer: {
// 可以是字符串或数组,也就是说可以配置一个或多个
contentBase: './public',
proxy: {
'/api': {
// http://localhost:8080/api/users -> https://api.github.com/api/user
target: 'https://api.github.com',
// http://localhost:8080/api/users -> https://api.github.com/user
pathRewrite: {
'^/api': ''
},
// 不能使用 localhost:8080 作为请求 github 的主机名
changeOrigin: true
}
}
},
Source Map
通过 webpack 打包后的项目,我们想要调试或者定位错误信息就会变的困难,可以用过 Source Map 来解决。它是一个源代码和转换后的代码的映射,一个转换过后的代码,通过 Source Map 的逆向解析,就可以得到源代码。
先简单使用一下:
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/', // 打包过后的文件最终位置
},
// 在你的配置中加入这行代码
devtool: 'source-map',
}
报错信息就会现实文件名,点击文件名就会跳转到对应的代码,而且可以在这里进行断点调试。
devtool 不仅有 source-map 的值,还有很多的值,他们的区别有编译速度,重新编译速度,适用环境等。
开发环境(cheap-module-eval-source-map):
- 能够定位到行
- 定位到的文件会以真实代码的样子显示(cheap-eval-source-map 会显示转译成es5 的样子,不好和源代码定位)
- 虽然首次打包速度慢,但是重写打包相对较快
生产环境(nosources-source-map):
- Source Map 会暴露源代码
- 可以找到报错源代码的位置
webpack 不同环境下的配置
webpack.config.js 中的 module.exports 可以导出一个对象,也可以导出一个数组,里边为多组配置,同样也可以导出一个函数,函数的参数是 env(环境)和 argv(运行 cli 过程中传入的所有参数)。
module.exports = (env, argv) => {
// 这里放置所有基本配置
const config = {
...
}
if(env === 'production'){
config.mode = 'production'
config.devtool = false
config.plugins = {
...config.plugins,
new CleanWebpackPlugin(),
new CopyWebpackPlugin(['public'])
}
}
return config
}
这样的话,执行 yarn webpack 默认打包的还是dev的配置,执行 yarn webpack --env production,就相当于给 传递了参数 env 为 productioin,从而实现 prod的打包。
但是更多情况下,项目比较大,都是通过配置不同文件来实现的。3个文件,一个公共配置,一个dev配置,一个prod配置,在dev 和prod配置文件中将公共配置文件引入,使用webpack 的依赖 'webpack-merge', 将 自己的配置 merge 到公共配置,这样向plugins 这种数组结构的也可以 merge 过去,并且不会将原来的配置完全覆盖,而是合并处理。
const merge = require('webpack-merge')
const common = require('./webpack.common')
module.exports = merge(common, {
mode:'production',
plugins:[
new CleanWebpackPlugin(),
new CopyWebpackPlugin({
patterns: [
{ from: 'public', to: 'public' }
]
}),
]
})
DefinePlugin
webpack 为我们提供了很多开箱即用的插件。
eg: DefinePlugin
为代码注入全局成员,自动启用,会为全局注入 process.env.NODE_ENV 常量,用来判断运行环境。
适用方法:
new webpack.DefinePlugin({
// 这里的 value 是一个 js 代码片段,所以传入的是 字符串,这个字符串里边是一个字符串
// 也可以写成 JSON.stringify('https://api.example.com')
// API_BASE_URL:'"https://api.example.com"'
API_BASE_URL: JSON.stringify('https://api.example.com')
})
然后在全局打印 API_BASE_URL 变量,可以拿到它的值。
Tree Shaking
Tree Shaking 是在打包时自动去除项目中一些没有引用的东西。例如一个函数中 return 后的操作会被移除,export 出去的成员没有引用会被移除等。
Tree Shaking 在生产环境打包过程中会自动开启。在其他环境,需要自己手动配置:
module.export = {
mode:'none',
...
// optimization: 用来集中配置 webpack 的优化功能
optimization: {
// 模块只导出被使用的成员
usedExports: true,
// 尽可能合并每一个模块到一个函数中
// concatenateModules: true,
// 压缩输出结果
minimize: true
}
}
Tree Shaking 只在 ES Module 语法生效,但是如果项目打包配置了 babel-loader,它可能会将 ES Module 转换为 CommonJS 规范,那么 Tree Shaking 将不会生效。不过最新版本的 babel-loader 已经自动关闭了ES Module 转换的插件,所以不会出现这个问题,但是为了保险起见,可以对 babel-loader 进行一些配置。
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
// 如果 Babel 加载模块时已经转换了 ESM,则会导致 Tree Shaking 失效
// modules 表示转换为什么模式, 设为 false 代表不会将 ES Module 转换为 CommonJS,auto 默认配置最新版中也会关闭转换
// ['@babel/preset-env', { modules: 'commonjs' }]
// ['@babel/preset-env', { modules: false }]
// 也可以使用默认配置,也就是 auto,这样 babel-loader 会自动关闭 ESM 转换
['@babel/preset-env', { modules: 'auto' }]
]
}
}
}
concatenateModules - 合并模块
webpack 打包后将一个模块打包成一个函数,就会有多个模块函数。通过 concatenateModules 可以合并模块。=,载配合 minimize 进行压缩,就会大大较少体积。
sideEffects - 副作用
它允许我们通过配置的方式标识我们的代码是否有副作用,从而为 Tree Shaking 提供更大的压缩空间。
副作用:模块执行时除了导出成员之外所做的事情。
如上图,一般我们在写组件的时候,会有多个组件文件,然后在 components/index 中统一导入再导出,但是在 index 中我们可能只引入一个组件,但是因为引入了 './components', 而 ‘components/index’ 又引入了所有模块,导致所有组件都被加载执行。sideEffects 就可以解决这个问题。
我们在 webpack.config.js 中的 optimization 中设置 sideEffects:true 来开启这个属性(这个属性在生产环境会自动开启)
optimization: {
sideEffects: true,
}
webpack 在打包的时候就会检查 package.json 中是否有 sideEffects 的标识,以此来判断是否有副作用,我们来设置为 false, 表示没有副作用,这样打包后,没有用到的组件就不会被打包进来了。
使用 sideEffects 的前提是确保你的代码真的没有副作用,否则在打包时就会误删掉有副作用的代码。比如引入的 css 文件,或者引入一个扩展的对象的原型方法的 js 文件,它们没有导出任何成员,所以在引入的时候也不用导入什么成员,但是在引入后可以使用它们提供的方法,这就属于这个 css 或 js 的副作用。这个时候还标识没有副作用的话,这些文件就不会被打包,这时可以在 Package.json 中关掉 sideEffects 或者设置哪些文件有副作用,这样 webpack 就不会忽略这些文件了。
代码分割
通过 webpack 实现前端整体模块化的优势固然很明显,但是它同样存在一些弊端,那就是我们项目中所有代码最终都会被打包到一起。如果我们的应用非常复杂,模块非常多,bundle 体积就会特别的大,而大多数时候并不是每个模块在启动时都是必要的,但是这些又被打包到一起,就必须把所有模块都加载进来才能使用。应用运行在浏览器端,这就意味着会浪费掉很多的流量和带宽。所以我们需要把打包结果按照一定的规则分离到多个 bundle 中,然后根据应用的运行需要按需加载。这样就可以大大提高应用的响应效率以及运行速度。
前面说过 webpack 就是把我们项目中散落的模块打包到一起从而提高运行效率,这里又说应该分离开来,这两个是不是自相矛盾呢?
其实不是的,只是物极必反。资源太大了也不行,太碎了也不行。
webpack 支持一种分包的功能,也就是代码分割。
Code Splitting - 代码分包/代码分割
- 多入口打包
- 动态导入
多入口打包
多入口打包一般适用于传统的多页应用程序。一个页面对应一个打包入口,对于页面公共部分再单独提取。
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'none',
// 将 entry 配置成一个对象
// 一个属性就是打包的一路入口
// 属性名就是入口名称,值就是入口路径
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
// 输出文件名动态输出
filename: '[name].bundle.js'
},
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
}
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html',
// 由于打包后会将所有的 bundle 载入html,但是我们只需要载入对应的那个 bundle 载入对应的 html
// chunks 指定载入的 bundle
chunks: ['index']
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/album.html',
filename: 'album.html',
chunks: ['album']
})
]
}
提取公共模块
此时 index 和 album 模块中都会引入一些公共模块,如果引入一些大型的模块,比如vue 等,就会让每个模块体积都很大,所以我们需要把公共模块提取出来。
optimizatioin: {
splitChunks: {
// 表示会把所有的公共模块都提取到单独的 bundle 中
chunks: 'all'
}
},
动态导入
按需加载是我们开发浏览器应用一个常见的需求,一般我们常说的按需加载指的是加载数据,这里所说的按需加载指的是我们应用在运行过程中需要用到某个模块时,再加载这个模块。这种方式可以极大的节省我们的带宽和流量。webpack 中支持使用动态导入的方式实现按需加载,而且所有动态导入的模块都会被自动的提取到单独的 bundle 中,从而实现分包。对比与多入口的方式,动态导入更加灵活,可以通过代码的逻辑去控制需不需要加载某个模块,或者什么时候需要加载某个模块。而我们分包的目的中就有很重要的一点是要让模块实现按需加载,从而提高应用的响应速度。
// import posts from './posts/posts'
// import album from './album/album'
const render = () => {
const hash = window.location.hash || '#posts'
const mainElement = document.querySelector('.main')
mainElement.innerHTML = ''
if (hash === '#posts') {
// mainElement.appendChild(posts())
// 将以上直接导入的方式改为这种动态导入的方式
// 这是ES Module 提供的动态导入,返回一个 Promise 对象,用 then 方法可以接受返回值
// /* webpackChunkName: 'components' */ 为魔法注释,如果没有魔法注释,导出的文件将会以序号命名
// 加上魔法注释可以为组件起一个名字,如果名字相同,将会打包到同一个 bundle
import(/* webpackChunkName: 'components' */'./posts/posts').then(({ default: posts }) => {
mainElement.appendChild(posts())
})
} else if (hash === '#album') {
// mainElement.appendChild(album())
import(/* webpackChunkName: 'components' */'./album/album').then(({ default: album }) => {
mainElement.appendChild(album())
})
}
}
render()
window.addEventListener('hashchange', render)
只需按照 ES Module 的按需加载的方式导入,webpack 无需处理,就可以自动分包。
MiniCssExtractPlugin
MiniCssExtractPlugin 是一个可以将 css 从打包结果提取出来的插件,通过这个插件可以实现 css 的按需加载。
- 首先引入插件
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
- 然后在 plugins 中加入
new MiniCssExtractPlugin()
- 在 loader 中我们之前是先通过 css-loader 去解析,然后交给 style-loader 将样式通过 style 标签注入。使用 MiniCssExtractPlugin 我们是将样式放入文件中通过 Link 的方式引入,也就不需要使用 style-loader,使用 MiniCssExtractPlugin.loader 的方式注入。
{
test: /\.css$/,
use: [
// 'style-loader', // 将样式通过 style 标签注入
MiniCssExtractPlugin.loader,
'css-loader'
]
}
需要注意的是,如果样式文件体积不是很大的话,提取到单个文件中效果可能适得其反。如果 css 体积超过了 150kb 左右,才需要考虑是否将它提取到单独文件中,否则的话 css 嵌入到代码当中减少了一次请求效果可能会更好。
OptimizeCssAssetsWebpackPlugin
当我们打包生产的包时会发现,刚刚提取出来的 css 文件没有被压缩,这是因为 webpack 提供的压缩只针对 js 文件,想要对 css 文件进行压缩就需要借助插件,,webpack 官方推荐了一个插件 - ss-assets-webpack-plugin。
在 plugins 中加入 new mizeCssAssetsWebpackPlugin()
,打包后css 文件也被压缩了。
但是在官方文档中会发现,这个插件并不是配置在 plugins 属性中,而是在 optimization 的 minimizer 属性中。这是因为如果配置在 plugins 下,这个插件在任何情况下都会正常工作,而配置在 minimizer 中,只会在 minimize 这样一个特性开启是才会工作,所以webpack 建议压缩类的插件应该配置在 minimizer 中,以便于可以通过 minimize 这个选项统一控制。
optimization: {
minimizer: [
new OptimizeCssAssetsWebpackPlugin()
]
},
执行 yarn webpack --mode production
可以发现,css 文件压缩了,但是这时又没有压缩了,这是因为设置了 minimizer 数组,webpack 认为如果配置了这个数组,就是要用自定义压缩插件,内部的 js 压缩器就会被覆盖掉,所以我们需要手动再把它添加回来。
yarn add terser-webpack-plugin --dev
const TerserWebpackPlugin = require('terser-webpack-plugin')
再将这个插件手动添加进 minimizer
optimization: {
minimizer: [
new TerserWebpackPlugin(),
new OptimizeCssAssetsWebpackPlugin()
]
},
这时在生产模式打包,js 和 css 文件都可以被正常压缩了。
输出文件名 Hash
一般我们在部署前端资源文件时,都会启用服务器的静态资源缓存。这样的话,对于用户的浏览器而言,可以缓存住我们应用中的静态资源,后续就不再需要请求服务器得到静态资源文件了。整体应用的响应速度就有一个大幅度提升。不过也会有一些小小的问题,如果缓存时间设置过短,效果不是特别明显,如果过期时间设置的比较长,在这个过程中应用重新部署,那这些更新将无法更新到客户端。所以在生产模式下,我们建议给文件名设置 hash 值,一旦资源文件改变,文件名称也可以一起变化,对于客户端而言,全新的文件名就是全新的请求,也就没有缓存的问题,这样就可以把服务端的缓存时间设置的特别长,也就不用担心文件更新过后的问题。
webpack 中 output 中的 filename 和插件中的 filename 都支持设置 hash 值。它们支持3中 hash,效果各不相同。
- 首先是最普通的 hash,可以通过 [hash] 拿到。这种 hash 是整个项目级别的,也就是这个项目中有任何一个改动,所有的 hash 值都会改变。
- chunkhash:在打包过程中,同一路的打包 chunkhash 都是相同的。比如动态导入的文件是一路 chunk。
这是两路 chunk。
一个文件改变,同一个 chunk 下的 chunkhash 都会改变,如果文件被别的文件引入,那引入的那个文件 chunkhash 也会被动改变。相比于普通 hash,chunkhash 更精确。
- contenthash: 文件级别的hash。文件修改时对应的 bundle 的 contenthash 修改,引入它的文件也被动修改。
contenthash 是解决缓存问题最好的方式,因为它精确的定位到了文件级别的 hash。
如果觉得 20 位的 hash 太长,可以指定长度,[contenthash:8] 指定 hash 长度为8。