webpack 的官方教程对于 webpack 的入门的各个部分已经讲得分清楚了。但从自身来说,教程看了很多遍,知识总还是像教程一样,零散的分布在脑子里。如何将其串联起来呢?这是本篇文章的目的。
webpack 是一个工具,工具以解决问题为目的,解放生产力。这些问题从何而来,来自实际的项目。本篇文章以当前热门的 vue SPA 为导线,探究 webpack 的工作流配置。
起步
npm init -y
npm install --save vue
npm install --save-dev webpack webpack-cli
复制代码
目录结构:
├── index.html
├── node_modules
├── package-lock.json
├── package.json
├── src
│ └── index.js
└── webpack.config.js
复制代码
-
webpack.config.js
const path = require('path'); module.exports = { context: path.resolve(__dirname, '/'), entry: { app: './src/index.js', }, output: { filename: '[name].[chunckhash].js', path: path.resolve(__dirname, 'dist'), publicPath: '/' // 如果想要在浏览器直接打开,请修改为 ./ }, resolve: { alias:{ 'vue$':'vue/dist/vue.esm.js', // 参考 https://cn.vuejs.org/v2/guide/installation.html } }, plugins: [ new CleanWebpackPlugin(), // 清理输出 new HtmlWebpackPlugin({ template: 'index.html' }) ] }; 复制代码
-
index.js
import Vue from 'vue'; const app = new Vue({ el: '#app', data: { text: 'Hello webpack!' }, template: '<div>{{ text }}</div>' }); 复制代码
-
html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>custom-vue</title> </head> <body> <div id="app"></div> </body> </html> 复制代码
-
package.json
{ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "webpack", }, } 复制代码
运行 npm run build
,会得到一个 dist 文件夹,在浏览器打开,得到如下结果:
环境分离
在上面的配置中,改完代码后每次都需要重新构建然后在本地打开它,这样的非常的麻烦。思考这样的情况,在项目中,开发环境的配置和生产环境的配置具有明显的区别。
开发环境中,我们期待这样的效果:
- 自动构建
- 快速看到更改后的效果
- 能够看到源码
生产环境,我们期待另外的效果:
- 最小的代码
- 最大化利用浏览器并发下载
- 最大化利用缓存
- 更快的构建速度
或者在一些特殊场景的项目中,还需要区分其他部署环境,如预发布环境等等,不同的环境就意味着 webpack 需要不同的配置,但这些环境配置中有些是公共的部分,从程序设计的角度来讲,我们需要将这些公共的部分抽取出来。
在这里我们可以利用 webpack-merge 库来完成配置文件的合并。
调整目录结构
将 webpack 配置文件拆分:
├── build
│ ├── utils.js
│ ├── webpack.config.base.js // 公共配置
│ ├── webpack.config.dev.js // 开发配置
│ └── webpack.config.prod.js // 生产配置
├── index.html
├── node_modules
├── package-lock.json
├── package.json
├── src
│ └── index.js
└── webpack.config.js
复制代码
修改公共配置文件
webpack.config.base.js
来源于 webpack.config.js
。
-
修改
webpack.config.base.js
module.exports = { context: path.resolve(__dirname, '../'), output: { path: resolve('dist'), }, }; 复制代码
-
package.json
{ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "webpack --inline --progress --config build/webpack.config.dev.js", }, } 复制代码
inline
:内联热更新代码progress
:显示进度config
:使用配置文件
抽取公共配置
webpack.config.base.js
module.exports = {
context: path.resolve(__dirname, '../'),
entry: {
polyfill: './src/utils/polyfill.js',
app: './src/index.js',
},
output: {
filename: '[name].js',
path: resolve('dist'),
publicPath: '/'
},
resolve: {
alias:{
'vue$':'vue/dist/vue.esm.js',
}
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: vueLoaderConfig
},
{
test: /\.js$/,
loader: 'babel-loader',
include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')]
},
{
test: /\.css$/i,
loaders: [
'style-loader',
'css-loader',
]
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
},
{
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('media/[name].[hash:7].[ext]')
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
}
},
{
test: /\.(sass|scss)$/,
loaders:[
"style-loader", // creates style nodes from JS strings
"css-loader", // translates CSS into CommonJS
{
loader: 'postcss-loader',
options: {
plugins: [
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
}),
]
}
},
"sass-loader" // compiles Sass to CSS, using Node Sass by default
]
},
]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template: 'index.html'
})
],
};
复制代码
配置开发环境
const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.config.base');
const webpack = require('webpack');
const utils = require('./utils');
const { resolve } = utils;
module.exports = merge(baseConfig, {
mode: 'development', // 设置为开发模式
devtool: 'inline-source-map', // 设置 source map
devServer: {
clientLogLevel: 'warning', // 默认为 info,会打印各种信息,我们将其设置为 warning,只打印有用的信息
contentBase: resolve('dist'),
compress: true, // 开启 gzip
port: 3000, // 端口号
hot: true, // 热模块替换,更改完代码可以自动构建,刷新浏览器
open: true // 自动打开浏览器
},
plugins: [
new webpack.NoEmitOnErrorsPlugin() //
]
});
复制代码
上面的代码存在一个问题,当 3000 端口被占用时会报错,构建会失败。我们在开发中往往只想能够正常启动服务进行调整,并不真正关心到底使用哪一个端口,因此,如果能够在端口号在被占用的情况下自动更换端口号,这个问题就得到了解决。
但有时候我们忘记之前已经启动这个服务了,这时候再更换另一个端口启动一个服务,这样会造成额外的开销。这时候可以给用户展示提示信息,告知客户端口已占用,甚至可以提示用户暂用端口的应用程序是哪一个。
自动更换端口号
为了达到这个目的,我们使用 node-portfinder 来查找空闲的端口号,并且需要将配置导出为一个 Promise:
const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.config.base');
const utils = require('./utils');
const portfinder = require('portfinder');
const { resolve } = utils;
const devWebpackConfig = merge(baseConfig, {
mode: 'development',
devtool: 'inline-source-map',
devServer: {
clientLogLevel: 'warning',
contentBase: resolve('dist'),
compress: true,
port: 3000,
hot: true,
open: true,
quiet: true,
host: 'localhost',
},
});
module.exports = () => {
return new Promise((resolve, reject) => {
// portfinder.basePort = process.env.PORT || config.dev.port;
portfinder.basePort = 3000; // 这里已经出现两次 3000,因此,这里可以将其抽取到一个单独的配置文件中了
// 获取端口号
portfinder.getPort(function (err, port) {
if (err) {
reject(err);
} else {
// 使用取得的端口号
process.env.PORT = port;
devWebpackConfig.devServer.port = port;
resolve(devWebpackConfig);
}
});
})
}
复制代码
交互式端口选择
修改 webpack.config.dev.js:
module.exports = () => {
return new Promise((resolve, reject) => {
utils.choosePort(3000).then((port) => {
process.env.PORT = port;
devWebpackConfig.devServer.port = port;
resolve(devWebpackConfig);
}, (err) => {
reject(err);
});
});
}
复制代码
我们把选择端口的功能抽取到 utils 里面:
新引入几个库和工具:
const chalk = require('chalk'); // 样式化终端输出
const inquirer = require('inquirer'); // 终端人机交互
const portfinder = require('portfinder'); // 查找端口
const clearConsole = require('./clearConsole'); // 清理控制台
const getProcessForPort = require('./getProcessForPort'); // 获取在目标端口运行的线程
复制代码
exports.choosePort = (defaultPort) => new Promise((resolve, reject) => {
portfinder.basePort = defaultPort;
portfinder.getPort(function (err, port) {
if (err) {
return reject(err);
}
// 如果相同,直接返回
if (port === defaultPort) {
return resolve(port);
}
clearConsole();
const message =
process.platform !== 'win32' && defaultPort < 1024 && !isRoot()
? `Admin permissions are required to run a server on a port below 1024.`
: `Something is already running on port ${defaultPort}.`;
const existingProcess = getProcessForPort(defaultPort);
const question = {
type: 'confirm',
name: 'shouldChangePort',
message:
chalk.yellow(
message +
`${existingProcess ? ` Probably:\n ${existingProcess}` : ''}`
) + '\n\nWould you like to run the app on another port instead?',
default: true,
};
inquirer.prompt(question).then(answer => {
if (answer.shouldChangePort) {
resolve(port);
} else {
reject(null);
}
});
})
});
复制代码
样式化控制台信息
在项目中,我们的控制台常常会输出很多信息,比如代码检查、模块解析、编译等等的。但默认情况下的输出很难去辨别到底哪些是自己想要的,因此需要一个东西来格式化输出这些信息。
我们采用 friendly-errors-webpack-plugin 来实现这个功能:
module.exports = () => {
return new Promise((resolve, reject) => {
utils.choosePort(3000).then((port) => {
process.env.PORT = port;
devWebpackConfig.devServer.port = port;
devWebpackConfig.plugins.push(new FriendlyErrorsPlugin({
compilationSuccessInfo: {
messages: [`Your application is running here: http://${devWebpackConfig.devServer.host}:${port}`],
},
onErrors: true
? utils.createNotifierCallback()
: undefined
}));
resolve(devWebpackConfig);
}, (err) => {
reject(err);
});
});
}
复制代码
结果:
更多信息请参考 friendly-errors-webpack-plugin 文档
生产环境
首先将 webpack.config.prod.js
的 mode
选项设置为 production
:
{
mode: 'production'
}
复制代码
再来看看生产环境的需求:
- 最小的代码
- 最大化利用缓存
- 更快的构建速度
针对每个目标,我们都进行相应的配置:
最小的代码
HTML
const HtmlWebpackPlugin = require('html-webpack-plugin');
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'index.html',
inject: true,
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
}
}),
复制代码
CSS
-
提取
-
添加 loader
{ test: /\.css$/i, loaders: [ { loader: MiniCssExtractPlugin.loader, options: { publicPath: '../', }, }, 'css-loader', { loader: 'postcss-loader', options: { plugins: [ require('postcss-flexbugs-fixes'), require('postcss-preset-env')({ autoprefixer: { flexbox: 'no-2009', }, stage: 3, }), ] } }, ], }, 复制代码
这里,不仅添加了提取 CSS 的 loader,还增加了
postcss-loader
,用来对 CSS 进行额外的处理,比如添加厂商前缀。注意:
postcss-loader
我们采用options
属性来配置所需的插件,而没有采用配置文件的方式,因为在实际应用中,我发现配置文件并未起效。使用 postcss 值得注意的一点是,
package.json
中browserlist
的值会影响 autoprefixer 的结果。 -
添加插件:
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); plugins: [ new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css', chunkFilename: 'css/[name].[contenthash:8].chunk.css', }), ] 复制代码
-
-
压缩
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); optimization: { minimizer: [ new OptimizeCSSAssetsPlugin({ cssProcessorOptions: { parser: postCssSafeParser, map: { inline: false, annotation: true, } }, }) ] }, 复制代码
JS
-
压缩
在之前的 webpack 的教程中,常常使用
UglifyJsPlugin
插件对 js 代码进行压缩,但这在 webpack 4.x 中是不必要的,因为mode
为production
的时候会默认开启TerserPlugin
插件,并使用其对 JS 进行压缩。我们可以对
TerserPlugin
插件进行配置:optimization: { minimizer: [ new TerserPlugin({ terserOptions: { parse: { ecma: 8, }, compress: { ecma: 5, warnings: false, comparisons: false, inline: 2, }, mangle: { safari10: true, }, output: { ecma: 5, comments: false, ascii_only: true, }, }, parallel: !isWsl, // Enable file caching cache: true, sourceMap: shouldUseSourceMap, }), ] } 复制代码
图片处理
可以使用 image-webpack-loader 或者 imagemin-webpack-plugin 对图片进行压缩
最大化利用缓存
web 应用的缓存是基于资源的路径(URL—— Uniform Resource Location)的,实现方式和规则由 HTTP 协议所规定。
所谓缓存,简单来讲就是将获取的资源存放在本地,当资源变化时再更新。因此这里需要解决的问题就出现了:
- 如何定义资源的路径
- 如何在资源更改时对其路径进行更新
当资源部署到固定的服务器时,当前资源的基础路径就已经固定了,比如 https://www.example.com/static/img/...
。我们能够决定的,就是后面的部分:
- 附加路径
- 文件名
- 查询参数
附加路径会使文件的目录结构复杂化,不是一个好方案。那就剩下了文件名和查询参数这两项,这两项都可以达到目的。
那如何在资源更改时能够更新这个 URL 呢?如何统一名称的资源内容更新后唯一区分它们呢?那肯定就需要有一个跟他们内容一一对应的东西,这个东西就是根据他们的内容计算出来的散列值(hash 值)。
计算出内容的散列之后,我们将其赋予文件名或者查询参数,那这个资源就永远都是唯一的,并且与其内容息息相关。
需要缓存的资源:
- JS
- CSS
- 图片
代码分离
在项目中,我们使用一个文件作为入口构建了整个依赖树,所有的代码,资源都被打包到了一个文件中。这样不仅导致文件过大加载太慢,也使缓存无从谈起,因此需要将这些代码按照一定的规则进行分割。
对于缓存来讲,我们将经常变化和不经常变化地分离开。不常变化的部分就可以最大化地利用缓存。
具体分割请参照 webpack 的官方教程。
webpack 4.x 配置代码分离很简单:
optimization: {
splitChunks: { // 该选项将用于配置 SplitChunksPlugin 插件
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'false',
chunks: 'all'
}
}
},
runtimeChunk: true
},
复制代码
文件名
-
JS
在webpack 中使用内容 hash 的文件名很简单,只需要在
ouput
选项中设置即可:output: { path: resolve('dist/js'), filename: '[name].[chunkhash].js'), // 使用 chunkhash chunkFilename: utils.assetsPath('[id].[chunkhash].js'), publicPath: "https://cdn.example.com/assets/" }, 复制代码
-
CSS
由于 CSS 需要单独从文件中抽离出来,单独进行压缩,这部分已经在上面的代码中实现了
-
图片
更快的构建速度
函数化
在实际使用的过程中会发现,webpack-merge 并不像想象中的智能,它只能在一定程度上进行浅合并。因此,不能从细粒度上去区分环境。
比如:
- 开发:
module: {
rules: [
{
test: /\.css$/i,
loaders: [
'style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
plugins: [
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
})
]
}
}
]
},
]
}
复制代码
- 生产:
module: {
rules: [
{
test: /\.css$/i,
loaders: [
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: '../',
},
},
'css-loader',
{
loader: 'postcss-loader',
options: {
plugins: [
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
})
]
}
}
]
},
]
}
复制代码
在上面的代码中,加载 CSS 时在开发环境中需要使用 style-loader
将其内联到 JS 文件中,在生产环境中我们使用 MiniCssExtractPlugin.loader
将其分离到单独的文件中,但这部分代码大部分都是相同的,产生了很多冗余。
从另一个角度来讲,在不同环境进行分开配置的时候可能会应该疏忽少配置了某一项,或者忘了区分环境,这也会导致各种问题。
因此我们可以采用将 webpack 配置导出为函数的形式:
module.exports = function(env, args) {
return {
mode: env.production ? 'production' : 'development',
devtool: env.production ? 'source-maps' : 'eval',
module: {
rules: [
{
test: /\.css$/i,
loaders: [
env.production ? ({
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: '../',
},
}) : 'style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
plugins: [
require('postcss-flexbugs-fixes'),
require('postcss-preset-env')({
autoprefixer: {
flexbox: 'no-2009',
},
stage: 3,
})
]
}
}
]
},
]
}
}
}
复制代码
其他
一个项目的 webpack 配置可看作一个独立的模块,其中还有值得优化的地方:
-
抽取公共的工具
在配置过程中避免不了要使用相同功能的代码,这些代码应该被提取出来,至于是分配到单独的文件中,还是作为某个模块(文件)的内部工具方法,那就需要再看这个方法的通用性。如果这个方法是一个通用方法,可以在几个模块间使用,那就应该提取到一个单独的文件中,如果只有某个模块使用,那就作为该模块的内部方法就可以了。
-
独立环境相关的项目配置文件
这里所的配置文件与上面写的配置文件(
webpack.config.base.js
)概念是不同的。上面的配置我们将其称为 webpack 配置。这里所说的配置文件是跟具体项目的使用有关的,在不同项目间会频繁变的,将其称为项目配置。比如,在使用框架等基础设置相同的情况下的
project_1
和project_2
,它们在使用的loader
,需要的插件,基本的开发和打包输出上的都基本是一致的。但project_1
和project_2
可能需要不同的开发代理,要使用的开发端口,也可能具有不同的部署目录(如一个根目录,一个相对目录)等等,并且相同的选项在不同的环境下可能不同。从关注点分离的角度来讲,我们应该将这些常需要变化的和不常变化的开来,将变化的部分抽离到单独的项目配置文件中。
如 vue-cli 的配置:
'use strict' // Template version: 1.3.1 // see http://vuejs-templates.github.io/webpack for documentation. const path = require('path') module.exports = { dev: { // Paths assetsSubDirectory: 'static', assetsPublicPath: '/', proxyTable: {}, // Various Dev Server settings host: 'localhost', // can be overwritten by process.env.HOST port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined autoOpenBrowser: false, errorOverlay: true, notifyOnErrors: true, poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions- // Use Eslint Loader? // If true, your code will be linted during bundling and // linting errors and warnings will be shown in the console. useEslint: true, // If true, eslint errors and warnings will also be shown in the error overlay // in the browser. showEslintErrorsInOverlay: false, /** * Source Maps */ // https://webpack.js.org/configuration/devtool/#development devtool: 'cheap-module-eval-source-map', // If you have problems debugging vue-files in devtools, // set this to false - it *may* help // https://vue-loader.vuejs.org/en/options.html#cachebusting cacheBusting: true, cssSourceMap: true }, build: { // Template for index.html index: path.resolve(__dirname, '../dist/index.html'), // Paths assetsRoot: path.resolve(__dirname, '../dist'), assetsSubDirectory: 'static', assetsPublicPath: '/', /** * Source Maps */ productionSourceMap: true, // https://webpack.js.org/configuration/devtool/#production devtool: '#source-map', // Gzip off by default as many popular static hosts such as // Surge or Netlify already gzip all static assets for you. // Before setting to `true`, make sure to: // npm install --save-dev compression-webpack-plugin productionGzip: false, productionGzipExtensions: ['js', 'css'], // Run the build command with an extra argument to // View the bundle analyzer report after build finishes: // `npm run build --report` // Set to `true` or `false` to always turn it on or off bundleAnalyzerReport: process.env.npm_config_report } } 复制代码
问题
模块热替换
-
错误:
ERROR in chunk app [entry] [name].[chunkhash].bundle.js Cannot use [chunkhash] or [contenthash] for chunk in '[name].[chunkhash].bundle.js' (use [hash] instead) 复制代码
原因:模块热替换和
[chunkhash] or [contenthash]
不能共存解决方法:修改 webpack 配置,删除掉
[chunkhash] or [contenthash]
,或者替换为[hash]