拓展 - webpack的配置与优化
包含 webpack 的运行机制、基础配置以及打包优化方面的知识
运行机制
webpack 的运行过程可以简单概述为如下流程:
初始化配置参数 -> 绑定事件钩子回调 -> 确定Entry逐一遍历 -> 使用loader编译文件 -> 输出文件
整个过程就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。
插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。
webpack 通过 Tapable 来组织这条复杂的生产线。 webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。
webpack 的详细运行过程如下:
- webpack 会读取你在命令行传入的配置以及项目里的 webpack.config.js 文件,初始化本次构建的配置参数,并且执行配置文件中的插件实例化语句,生成 Compiler 传入 plugin 的 apply 方法,为 webpack 事件流挂上自定义钩子。
- webpack开始读取配置的 Entries,递归遍历所有的入口文件。
- webpack 会依次进入其中每一个入口文件(entry),先使用用户配置好的 loader 对文件内容进行编译(buildModule),之后再将编译好的文件内容解析生成 AST 静态语法树,分析文件的依赖关系逐个拉取依赖模块并重复上述过程,最后将所有模块中的 require 语法替换成 webpack_require 来模拟模块化操作。
- 所有文件的编译及转化都已经完成,包含了最终输出的资源,我们可以拿到输出的资源、代码块chunk 等等信息。
这里涉及到插件(plugin)和 解析器(loader)
loader:它是一个转换器,将A文件进行编译成B文件,比如:将A.less转换为A.css,单纯的文件转换过程。
plugin:它并不直接操作文件,而是基于事件机制工作,会监听webpack打包过程中的某些节点,执行广泛的任务,比如打包优化、文件管理、环境注入等。
插件
从形态上看,插件通常是一个带有 apply 函数的类:
class SomePlugin {
apply(compiler) {
}
}
webpack 会在启动后按照注册的顺序逐次调用插件对象的 apply 函数,同时传入编译器对象 compiler ,例如:
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template: resolve(__dirname, 'index.html'),
cache: true
}),
new CopyPlugin({
patterns: [
{
from: resolve(__dirname, 'src/assets'),
to: resolve(__dirname, 'dist/assets')
}
]
})
],
插件开发者可以以此为起点触达到 webpack 内部定义的任意钩子,例如:
class SomePlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap('SomePlugin', (compilation) => {
})
}
}
配置拆分
对配置进行拆分是为了便于在不同环境中使用不同的配置项,例如在生产环境需要生成文件的 hash 值,但是开发环境不需要,在开发环境取消 hash 命名能够加快构建的速度。
webpack 一般会分为三个配置文件:base(公共部分)、dev(开发环境)、prod(生产环境)
在调用 npm script 时传入的环境参数会被用来判断导出何种配置,如果传入的是 development,那么使用webpack-merge 插件将 base 和 dev 配置文件进行合并,然后输出;如果传入的是 production,那么将 base 和 prod 配置文件合并导出
// webpack.config.js
const { merge } = require('webpack-merge');
const devConfig = require('./webpack.dev');
const prodConfig = require('./webpack.prod');
const baseConfig = { ... };
module.exports = (env) => {
return env.development ? merge(baseConfig, devConfig) : merge(baseConfig, prodConfig);
};
在 package.json 文件中调用
"scripts": {
"start": "webpack-dev-server --env.development",
"build": "webpack --env.production"
}
插件合集
下面列举了常用的 loader 和插件合集:
npm i node-sass sass fibers @babel/core @babel/preset-env @babel/preset-typescript autoprefixer cssnano css-loader sass-loader babel-loader postcss-loader file-loader --save-dev
npm i webpack webpack-cli webpack-dev-server html-webpack-plugin clean-webpack-plugin copy-webpack-plugin mini-css-extract-plugin terser-webpack-plugin webpack-merge webpack-bundle-analyzer progress-bar-webpack-plugin --save-dev
基础配置
// 配置遵循 CommonJS 规范
// path 用于解决不同系统下的路径差异
const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const { merge } = require('webpack-merge');
const devConfig = require('./webpack.dev');
const prodConfig = require('./webpack.prod');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const baseConfig = {
// 单入口配置生成 chunk 会被命名为 main
// entry: "./index.js",
// 非 SPA 应用需要配置多个入口
// 每个属性的键名会是生成的 chunk 的名称
entry: {
index: './index.js'
// search: "./search.js"
},
output: {
// resolve 传入路径从右至左解析,遇到第一个绝对路径是完成解析
// __dirname 则是获得当前文件所在目录的完整路径名
path: resolve(__dirname, 'dist'),
// 单入口应用可以直接固定文件名
// filename: "index.bundle.js"
// 在多入口应用中常使用 [name].bundle.js 来确保每个文件具有唯一名称
// 在 webpack.pro.js 中还会使用 [contentHash] 来生成全新的文件名
filename: '[name].bundle.js'
},
// resolve 用来配置模块如何被解析
resolve: {
// 设置别名,用于简化路径
alias: {
// vue 需要的特殊配置
// $ 用于表示精确匹配,此时 import 'vue/index.js' 不会被解析作别名
vue$: 'vue/dist/vue.esm.browser.js'
// Utilities: resolve(__dirname, 'src/utilities/'),
// 原先的导入方式:import Utility from '../../utilities/utility'
// 配置了别名之后可以简化为:import Utility from 'Utilities/utility';
}
},
// 配置 loader
module: {
rules: [
// loader 自下而上执行,处理后的结果会传递给下一个 loader
// test 用来筛选资源,当 test 的值为字符串时,可以为资源(或其所在目录)的绝对路径
// use 支持数组形式添加多个 loader
// include 表示哪些目录的文件需要被处理
// exclude 表示哪些目录的文件不需要被处理,能够有效的加快打包速度
// test、include、exclude 都支持使用数组传入多个值
{
test: /\.(sa|sc|c)ss$/,
// loader 是从右往左执行的,这里先经过 sass-loader 再到 css-loader
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader', 'sass-loader'],
include: resolve(__dirname, 'src'),
exclude: /node_modules/
},
// use 也支持对象传入,这是为了添加格外配置 options
// 自 babel7 开始,babel 已经能够识别 ts,不需要添加额外的 ts-loader
{
test: /\.(js|jsx|ts|tsx)$/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
presets: ['@babel/preset-env', '@babel/preset-TypeScript']
}
},
include: resolve(__dirname, 'src'),
exclude: /node_modules/
},
// file-loader 目的是保持 css 定义的 url 属性或者 img 标签中的 src 属性在打包时的正确引用
{
test: /\.(png|svg|jpg|jpeg|gif|webp|woff|woff2|eot|ttf|otf)$/,
use: [
'file-loader'
],
include: resolve(__dirname, 'src'),
exclude: /node_modules/
}
]
},
plugins: [
// 在打包之前将指定文件夹清空,默认是 dist
new CleanWebpackPlugin(),
// 将 CSS 内容提取到单独文件
new MiniCssExtractPlugin(),
// 会在打包结束之后自动创建一个index.html, 并将打包好的JS自动引入到这个文件中
new HtmlWebpackPlugin({
template: resolve(__dirname, 'index.html'),
cache: true
}),
// 将素材文件拷贝到指定文件夹
new CopyPlugin({
patterns: [
{
from: resolve(__dirname, 'src/assets'),
to: resolve(__dirname, 'dist/assets')
}
]
}),
new ProgressBarPlugin()
],
// 用于屏蔽不必要的控制台输出
stats:{
modules: false,
children: false,
chunks: false,
chunkModules: false
}
};
// 根据 npm script 传入的环境参数暴露出不同的配置
// webpack-merge 能够合并两个配置文件
module.exports = (env) => {
return env.development ? merge(baseConfig, devConfig) : merge(baseConfig, prodConfig);
};
开发环境配置
const { HotModuleReplacementPlugin } = require('webpack');
module.exports = {
// 可选值 'none' | 'development' | 'production'
// 通过 CLI 参数 --mode=production 传递,会将 process.env.NODE_ENV 设置为对应的 mode 值
mode: 'development',
output: {
// 取消在 bundle 中引入「所包含模块信息」的相关注释,有利于加快大型项目的构建速度
pathinfo: false
},
// 生成 sourcemap 文件,便于定位错误位置,不使用 sourcemap 能够加快编译速度
devtool: 'source-map',
plugins: [
// 内置热更新功能
new HotModuleReplacementPlugin(),
],
// 设置本地服务器
devServer: {
port: 10086,
// 自动打开浏览器
open:true,
// hot 开启 HMR 功能
hot: true
}
};
生产环境配置
const TerserPlugin = require('terser-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
mode: 'production',
output: {
filename: '[name].[contentHash].bundle.js'
},
plugins: [
// bundle 包分析工具
new BundleAnalyzerPlugin()
],
// optimization 优化
optimization: {
// splitChunks 用于提取公共依赖模块,减轻 index.bundle.js 的大小
// chunk 默认使用 async,只提取按需加载模块,其他参数:initial(初始块)、all(全部块)
splitChunks: { chunks: 'async' },
// 文件压缩
minimize: true,
minimizer: [
new TerserPlugin({
// 开启缓存
cache: true
})
]
}
};
编译速度优化
构建打点
使用 Speed Measure Plugin 插件对构建的全过程进行打点,了解每一个构建步骤的耗时,针对性的进行优化,配置也很简单,在原有的配置外使用smp.wrap
包裹即可
cnpm install --save-dev speed-measure-webpack-plugin
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap(YourWebpackConfig);
输入结果如下图:
缓存
类似浏览器缓存,当目标文件已经存在的时候,就不会重复进行编译,直接读取即可,loader 中大多都有 cache 配置项。
module.exports = {
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
use: {
loader: 'babel-loader',
options: {
cacheDirectory: true,
presets: ['@babel/preset-env', '@babel/preset-TypeScript']
}
},
}
],
},
};
如果 loader 没有内置缓存,也可以使用 cache-loader ,在原有的 loader 前加上 cache-loader 即可:
cnpm install --save-dev cache-loader
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'cache-loader',
'css-loader',
'postcss-loader',
'sass-loader'
]
},
],
},
};
下面是开启了 css、babel、HtmlWebpackPlugin、TerserPlugin 缓存的耗时,相比未开启缓存快了 41%
多核打包
happypack 可以获取到 cpu 的核数,榨干 cpu 的线程,加快打包速度
实现方式:将原本卸载 rules 内的 loader 移至 plugins 下的 HappyPack 函数内:
cnpm install --save-dev happypack
const HappyPack = require('happypack');
const os = require('os');
// 开辟一个线程池
// 拿到系统CPU的最大核数,happypack 将编译工作灌满所有线程
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });
module.exports = {
module: {
rules: [
{
test: /\.(sa|sc|c)ss$/,
use: ['happypack/loader?id=css']
},
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: ['happypack/loader?id=babel'],
},
],
},
plugins:[
new HappyPack({
id: 'babel',
threadPool: happyThreadPool,
loaders: [{
loader: 'babel-loader',
query: {
cacheDirectory: true,
presets: ['@babel/preset-env', '@babel/preset-TypeScript']
}
}]
}),
// 可以定义多个 happypack
new HappyPack({
id: 'other',
threadPool: happyThreadPool,
loaders: []
}),
]
};
重要:MiniCssExtractPlugin 无法与 happypack 共存,不要用 happypack 对 MiniCssExtractPlugin 进行包裹。
下面是开启了 babel 多线程的耗时,相比未开启多线程快了 8%
抽离
webpack 进行抽离的方式有两种:
- 使用 webpack-dll-plugin 在首次构建的时候就对静态资源进行打包,后续只要引用这个已经打包好的静态资源即可,类似于预编译。
- 使用 externals 将静态资源进行剔除,并通过 CDN 进行加载。
两者的优缺点:
webpack-dll-plugin:
- 因为第一次编译后就不再参与编译,需要手动去维护,容易出现版本错误;
- 插件会将静态资源打包成一份文件,虽然减少了请求数量,但是单个文件会变得很大, HTTP2 多路复用特性更适合碎片化的小文件。
external:极度依赖 CDN 资源的稳定性,如果是关键依赖资源缺失,页面会无法加载
使用 external
修改 webpack 配置:
module.exports = {
...,
externals: {
// key是我们 import 的包名,value 是CDN为我们提供的全局变量名
// 所以最后 webpack 会把一个静态资源编译成:module.export.react = window.React
"react": "React",
"react-dom": "ReactDOM",
"redux": "Redux",
"react-router-dom": "ReactRouterDOM"
}
}
配置好 external 属性后,就需要在 index.html 中引入 CDN 资源,例如:
<head>
<script src="https://cdn.bootcdn.net/ajax/libs/rxjs/6.4.0/rxjs.umd.min.js"></script>
</head>
将 vue、localforage、rxjs、lodash 剥离后,打包时间缩短了 13%,但其主要的作用还是减少包的大小
优化后的配置
基础配置
// 配置遵循 CommonJS 规范
// path 用于解决不同系统下的路径差异
const { resolve } = require('path');
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const { merge } = require('webpack-merge');
const devConfig = require('./webpack.dev');
const prodConfig = require('./webpack.prod');
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
const smp = new SpeedMeasurePlugin();
const HappyPack = require('happypack');
const os = require('os');
// 开辟一个线程池
// 拿到系统CPU的最大核数,happypack 将编译工作灌满所有线程
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });
const baseConfig = {
// 单入口配置生成 chunk 会被命名为 main
// entry: "./index.js",
// 非 SPA 应用需要配置多个入口
// 每个属性的键名会是生成的 chunk 的名称
entry: {
index: './index.js'
// search: "./search.js"
},
output: {
// resolve 传入路径从右至左解析,遇到第一个绝对路径是完成解析
// __dirname 则是获得当前文件所在目录的完整路径名
path: resolve(__dirname, 'dist'),
// 单入口应用可以直接固定文件名
// filename: "index.bundle.js"
// 在多入口应用中常使用 [name].bundle.js 来确保每个文件具有唯一名称
// 在 webpack.pro.js 中还会使用 [contentHash] 来生成全新的文件名
filename: '[name].bundle.js'
},
// resolve 用来配置模块如何被解析
resolve: {
// 设置别名,用于简化路径
alias: {
// vue 需要的特殊配置
// $ 用于表示精确匹配,此时 import 'vue/index.js' 不会被解析作别名
// vue$: 'vue/dist/vue.esm.browser.js'
// Utilities: resolve(__dirname, 'src/utilities/'),
// 原先的导入方式:import Utility from '../../utilities/utility'
// 配置了别名之后可以简化为:import Utility from 'Utilities/utility';
}
},
// 配置 loader
module: {
rules: [
// loader 自下而上执行,处理后的结果会传递给下一个 loader
// test 用来筛选资源,当 test 的值为字符串时,可以为资源(或其所在目录)的绝对路径
// use 支持数组形式添加多个 loader
// include 表示哪些目录的文件需要被处理
// exclude 表示哪些目录的文件不需要被处理,能够有效的加快打包速度
// test、include、exclude 都支持使用数组传入多个值
{
test: /\.(sa|sc|c)ss$/,
// loader 是从右往左执行的,这里先经过 sass-loader 再到 css-loader
use: [MiniCssExtractPlugin.loader, 'cache-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
include: resolve(__dirname, 'src'),
exclude: /node_modules/
},
// use 也支持对象传入,这是为了添加格外配置 options
// 自 babel7 开始,babel 已经能够识别 ts,不需要添加额外的 ts-loader
{
test: /\.(js|jsx|ts|tsx)$/,
use: ['happypack/loader?id=babel'],
include: resolve(__dirname, 'src'),
exclude: /node_modules/
},
// file-loader 目的是保持 css 定义的 url 属性或者 img 标签中的 src 属性在打包时的正确引用
{
test: /\.(png|svg|jpg|jpeg|gif|webp|woff|woff2|eot|ttf|otf)$/,
use: [
'file-loader'
],
include: resolve(__dirname, 'src'),
exclude: /node_modules/
}
]
},
plugins: [
// 在打包之前将指定文件夹清空,默认是 dist
new CleanWebpackPlugin(),
// 将 CSS 内容提取到单独文件
new MiniCssExtractPlugin(),
// 会在打包结束之后自动创建一个index.html, 并将打包好的JS自动引入到这个文件中
new HtmlWebpackPlugin({
template: resolve(__dirname, 'index.html'),
cache: true
}),
// 将素材文件拷贝到指定文件夹
new CopyPlugin({
patterns: [
{
from: resolve(__dirname, 'src/assets'),
to: resolve(__dirname, 'dist/assets')
}
]
}),
new ProgressBarPlugin(),
new HappyPack({
id: 'babel',
threadPool: happyThreadPool,
loaders: [{
loader: 'babel-loader',
query: {
cacheDirectory: true,
presets: ['@babel/preset-env', '@babel/preset-TypeScript']
}
}]
})
],
// 用于屏蔽不必要的控制台输出
stats: {
modules: false,
children: false,
chunks: false,
chunkModules: false
}
};
// 根据 npm script 传入的环境参数暴露出不同的配置
// webpack-merge 能够合并两个配置文件
module.exports = (env) => {
return smp.wrap(env.development ? merge(baseConfig, devConfig) : merge(baseConfig, prodConfig));
};
开发环境
const { HotModuleReplacementPlugin } = require('webpack');
module.exports = {
// 可选值 'none' | 'development' | 'production'
// 通过 CLI 参数 --mode=production 传递,会将 process.env.NODE_ENV 设置为对应的 mode 值
mode: 'development',
output: {
// 取消在 bundle 中引入「所包含模块信息」的相关注释,有利于加快大型项目的构建速度
pathinfo: false
},
// 生成 sourcemap 文件,便于定位错误位置,不使用 sourcemap 能够加快编译速度
devtool: 'source-map',
plugins: [
// 内置热更新功能
new HotModuleReplacementPlugin(),
],
// 设置本地服务器
devServer: {
port: 10086,
// 自动打开浏览器
open:true,
// hot 开启 HMR 功能
hot: true
}
};
生产环境
const TerserPlugin = require('terser-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
mode: 'production',
output: {
filename: '[name].[contentHash].bundle.js'
},
plugins: [
// bundle 包分析工具
new BundleAnalyzerPlugin()
],
// optimization 优化
optimization: {
// splitChunks 用于提取公共依赖模块,减轻 index.bundle.js 的大小
// chunk 默认使用 async,只提取按需加载模块,其他参数:initial(初始块)、all(全部块)
splitChunks: { chunks: 'async' },
// 文件压缩
minimize: true,
minimizer: [
new TerserPlugin({
// 开启缓存
cache: true
})
]
}
};
webpack 常见的坑
使用 yarn link 或者 npm link 进行开发时,可能会出现以下报错:
ReactJS: How to handle Invalid Hook Call Warning “Uncaught Error: Invalid hook call. Hooks can only be called inside of the body of a function component” in ReactJS
这是由于存在多个 react 版本所造成的,如果两个项目引用同一个 react 即可解决,所以在使用 link 库的项目的 webpack.config.js 中设置 alias:
alias: {
react: path.resolve("./node_modules/react")
}
此时报错就消失了
拓展 - webpack的配置与优化