导读
上一篇文章(Webpack5详细教程-导读篇)主要讲述了模块化规范和自动化构建工具发展历史,以及它们的优缺点。今天我们开始系统地对 Webpack5 一些核心特性进行讲解,文章后面也会带大家一起搭建一个 Vue3 项目。相关源代码已上传 Github:webpack-basic。
本文 Webpack 相关版本如下:
- webpack:5.62.1
- webpack-cli:4.9.1
大家跟着一起动手实践吧~
Webpack使用指南
核心概念:构建依赖图
上一篇文章我们花了大篇幅讲述了自动化构建工具发展历程,以及它们产生的背景,这些自动化构建工具有一个共性:将源代码经过一系列操作之后得到宿主环境可识别代码。
宿主环境可识别代码:宿主环境如浏览器平台,只认识 html/css/js/img 等资源,其他如 sass/jsx/vue 都不识别,需要特殊处理。
这个过程有的是分成一个个任务,有的则是“管道流”机制,而 Webpack 跟 “流” 这种机制很类似,它可以做到和自动化构建工具一样的工作,经过它的 loader 机制,处理完后得到目标代码。但它不被称为自动化构建工具的原因是,在 Webpack 眼里一切皆模块(js/css/img/...),从入口开始通过模块化找到其他依赖模块,依次构建,最后得到目标产物,而这个过程就是构建依赖图的过程。
入口/出口(entry/output)
这节开始我们便正式进入动手环节,首先我们需要创建一个空项目 webpack-basic
,安装 webpack
和 webpack-cli
:
mkdir webpack-basic
cd webpack-basic
yarn init -y # 或者 npm init -y
yarn add webpack webpack-cli -D # 或 npm i webpack webpack-cli -D
接下来我们创建以下目录结构:
├── package.json
├── src
| ├── index.js
| └── js
| └── createTitle.js
└── yarn.lock
其中 createTitle.js
代码如下:
export default (content) => {
const h2 = document.createElement('h2')
h2.innerText = content
return h2
}
index.js
导入createTitle.js
模块:
import createTitle from './js/createTitle'
const h2 = createTitle('hello webpack')
document.body.appendChild(h2)
从上面依赖图可以看出, webpack 会从入口开始,然后建立依赖图,最后经过处理之后得到一个目标产物,默认情况下如果我们不做任何配置,webpack
默认会找到 src/index.js
,并且输出到 dist/main.js
,我们可以使用 yarn webpack
(或npx webpack
)进行测试。
从上图可以看到,我们在不做任何配置的前提下,打包是正常的。不过控制台会有一个提示告诉我们 mode
没有配置,并且在默认情况下被设置为了 production
模式,这个是 webpack5 新加的一个提示,后续我们再讲解。
假如我们需要自定义入口文件,以及输出目录名称,该怎么做呢?
我们可以在项目根目录下创建一个 webpack.config.js
,当然你也可以自定义名称然后在命令行配置一下 config
参数,这个我们后续会讲到。
const path = require('path')
module.exports = {
entry: {
main: './src/main.js',
},
output: {
filename: 'js/[name].[fullhash:8].bundle.js',
path: path.resolve('output'),
},
}
entry
也可以配置成字符串形式,表示单入口打包,而在上面配置中它被配置成了一个对象,key
就是我们要打包的文件名称,value
是一个相对路径,它相对的是 process.cwd()
的目录,也就是我们执行 webpack
这个命令所在的目录,当然如果不配置 key
的话可以使用数组。
output
用于配置出口,主要是有两个属性:filename
用于配置输出文件的名称,可以使用/
来增加目录;path
是输出文件所在的目录,一般都是绝对路径。
fullhash:8
表示输出文件的哈希位数为8位,在以前的版本是hash
,还可以配置contenthash
、chunkhash
等值,主要是为了更好的缓存。
此时我们在项目再创建一个index.html
文件,放在根目录下的 public
目录,引入打包文件就可以看到结果了(我这里使用的是 VSCode Live Server 插件,也可以使用 serve
工具预览查看效果)。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="../output/js/main.0b48a4fc.bundle.js"></script>
</body>
</html>
当然,我们实际项目当然不止 js 文件,还有样式、图片、ts、vue等模块,还需要将高版本 js 处理成兼容性良好的低版本 js 代码,这个时候就要借助 webpack 提供一个核心功能:loader。
它本质上就是一个函数,接受字符串/buffer数据,然后经过处理返回 js字符串/buffer,后续我们会专门对 loader 进行系统化讲解,下面是一些常见的 loader 使用场景介绍。
样式处理
实际开发中我们大部分都会使用到 CSS 预处理和后处理工具,如:sass/less/stylus/postcss
,而要想利用这些工具构建我们的代码就需要 loader 处理。首先我们来创建一个styles
目录存放样式文件:
├── package.json
├── public
| └── index.html
├── src
| ├── js
| | └── createTitle.js
| ├── main.js
+ | └── styles
+ | ├── global.css
+ | ├── title.css
+ | └── title.less
├── webpack.config.js
└── yarn.lock
title.less
@fontColor: #1a5f0611;
h2 {
font-size: 20px;
color: @fontColor;
}
title.css
h2 {
display: grid;
transition: all 0.2s;
}
global.css
@import './title.css';
body {
background: orange;
}
接下来安装所需要的依赖包:
yarn add style-loader css-loader postcss-loader less-loader less postcss -D
注意:less-loader
可以处理 less
文件,但是编译需要借助 less
,同样 postcss-loader
也依赖 postcss
。下面是 loader 配置:
const path = require('path')
module.exports = {
entry: {
main: './src/main.js',
},
output: {
filename: 'js/[name].bundle.js',
path: path.resolve(__dirname, 'output'),
},
+ module: {
+ rules: [
+ {
+ test: /\.css$/,
+ use: ['style-loader', 'css-loader', 'postcss-loader'],
+ },
+ {
+ test: /\.less$/,
+ use: ['style-loader', 'css-loader', 'postcss-loader', 'less-loader'],
+ },
+ ],
+ },
}
注意点:
- 所有
loader
都在module.rules
进行配置,该选项是一个对象数组。 - 规则配置使用正则表达式匹配文件。
use
可以是字符串、数组、对象,用于对loader
进行配置。loader
应用规则是从右到左。
配置完成后进行打包,发现页面是能正常应用样式的,说明我们 loader
是应用成功的:
虽然样式成功编译了,但是好像 postcss
并没有工作,这是为啥呢?
下面我介绍一下上述配置文件工作的流程:
-
首先在入口
main.js
导入了less
和css
文件,webpack
并不认识这些模块,接着去查找loader
去处理。 -
匹配
less-loader
,处理less
文件,内部使用less
编译样式,最后输出 JS 字符串,交给下一个loader
处理。 -
匹配
postcss-loader
,处理编译好的样式,内部使用postcss
并且查找插件,发现并未配置插件,不处理,返回 JS 字符串,交给下一个loader
处理。 -
匹配
css-loader
,解析文件中的@import
andurl()
,处理完成后返回 JS 字符串。 -
匹配
style-loader
,创建style
标签,将样式添加到里面。
可以看到应用postcss
时,并未添加插件,所以我们需要安装相关插件。我们的需求是,希望添加一些兼容性的CSS前缀,而且想对八位十六进制颜色这种 CSS 新语法进行处理(有很多浏览器不识别),这时候可以借助 postcss-preset-env
来做这个事情。
先安装:
yarn add postcss-preset-env -D
然后在 webpack
进行配置:
const path = require('path')
module.exports = {
entry: {
main: './src/main.js',
},
output: {
filename: 'js/[name].bundle.js',
path: path.resolve(__dirname, 'output'),
},
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
'css-loader',
- 'postcss-loader',
+ {
+ loader: 'postcss-loader',
+ options: {
+ postcssOptions: {
+ plugins: [require('postcss-preset-env')],
+ },
+ },
+ },
],
},
{
test: /\.less$/,
use: [
'style-loader',
'css-loader',
- 'postcss-loader',
+ {
+ loader: 'postcss-loader',
+ options: {
+ postcssOptions: {
+ plugins: [require('postcss-preset-env')],
+ },
+ },
+ },
'less-loader',
],
},
],
},
}
此时我们再次打包,可以看到样式已经兼容成了:color: rgba(26,95,6,0.06667);
。
当然我更喜欢单独拆分成一个文件来配置,这样更好去管理项目:
postcss.config.js
module.exports = {
plugins: [
require('postcss-preset-env')
]
}
但是我们从编译后结果可以看到,相关的 css 前缀并未添加:
这又是为何?
我们现在知道了 postcss
可以利用插件来处理兼容性,但是要兼容哪些平台呢?这个虽然可以单独配置,但是我想介绍一下 browserslist
,它可以告诉 postcss
去兼容哪些平台,不仅如此,它还可以为 babel
提供兼容平台参考,所以只要有需要提供兼容平台的相关构建工具都可以使用这个文件。
而且,Webpack
在安装的同时也会安装 browerslist
这个包,我们只需要配置一下就可以了。在根目录下面创建 .browserslistrc
:
> 0.01% # 市场占有率超过0.01%的浏览器
last 2 version # 最近两个版本的浏览器
not dead # 未停止更新,还活着
browerslist
会利用Can I use的数据来筛选一些浏览器,这里我为了测试兼容性,把占有率设置成了 0.01%
,这是因为现在大多浏览器兼容性已经很好了,不过实际项目不推荐,会带来性能开销。如果要查看更多的配置用法,可以查看browerslist官方文档。
我们可以使用
yarn browserslist
查看匹配了哪些浏览器。
此时再次打包按理来说,应该能看到效果了,但是其实并没有效果,这又是为何?我们再回看一下 global.css
内容:
@import './title.css';
body {
background: orange;
}
文件使用了 @import
导入css模块,而这个规则在 css-loader
才会去解析,而我们的 postcss-loader
早就处理完了,也就是说 css-loader
不可能再走“回头路”了,这该怎么办?
解决方法也很简单,对 css-loader
进行配置:
const path = require('path')
module.exports = {
// ... entry/ouput
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
- 'css-loader',
+ {
+ loader: 'css-loader',
+ options: {
+ importLoaders: 1,
+ },
+ },
'postcss-loader',
],
},
{
test: /\.less$/,
use: [
'style-loader',
- 'css-loader',
+ {
+ loader: 'css-loader',
+ options: {
+ importLoaders: 1,
+ },
+ },
'postcss-loader',
'less-loader',
],
},
],
},
}
这个 importLoaders
设置为 1
指的是往后找一个 loader
进行处理,如果再后面其他位置如后两位,就设置 2
。到这为止,我们的样式处理就成功了,下面是成功后的效果:
图片/字体处理
实际开发中,我们最常见使用图片的场景一般有两种:
img
标签引入图片。css
背景图片。
在 Webpack 4.x 版本中,我们处理图片通常使用的是 url-loader
和 file-loader
:
- file-loader:将文件发送到输出文件夹,并返回(相对)URL。
- url-loader: 像 file loader 一样工作,但如果文件小于限制,可以返回 data URL。
module.exports = {
module: {
rules: [
{
test: /\.(png|svg|gif|jpe?g)$/,
use: [
{
loader: 'url-loader', // 内部会使用 file-loader
options: {
name: 'img/[name].[fullhash:6].[ext]', // 自定义文件输出名称
limit: 4 * 1024, // 图片小于 4kb 转换成 base64
},
},
],
},
{
test: /\.(ttf|woff2?)$/,
use: 'file-loader'
},
],
},
}
而在 Webpack 5,我们不必再安装这两个 loader 了,它提供了一个新特性 type
,可以配置资源模块类型,它有四个值:
asset/resource
替代file-loader
,发送一个单独的文件并导出URL。asset/inline
替代url-loader
,导出data URL
。asset/source
替代raw-loader
,导出资源源代码。asset
代替url-loader
,可以根据文件大小决定是输出data URL
还是发送单独文件。
上面的配置可以改造成下面这样:
module.exports = {
module: {
rules: [
{
test: /\.(png|svg|gif|jpe?g)$/,
type: 'asset',
generator: {
filename: 'img/[name].[fullhash:4][ext]',
},
parser: {
dataUrlCondition: {
maxSize: 4 * 1024,
},
},
},
{
test: /\.(ttf|woff2?)$/,
type: 'asset/resource',
generator: {
filename: 'font/[name].[fullhash:4][ext]',
},
},
],
},
}
自定义输出资源名称还可以在
output.assetModuleFilename
进行配置,不过个人建议还是给每个资源进行单独配置。
下面我们改造一下代码,让项目支持图片处理。
目录结构:
├── package.json
├── postcss.config.js
├── public
| └── index.html
├── src
+ | ├── img
+ | | ├── bg.png # > 4kb
+ | | └── vue.png # < 4kb
| ├── js
+ | | ├── createImg.js
| | └── createTitle.js
| ├── main.js
| └── styles
| ├── global.css
| ├── title.css
| └── title.less
├── webpack.config.js
└── yarn.lock
createImg.js
export default (content) => {
const img = document.createElement('img')
img.src = require('../img/vue.png')
return img
}
global.css
@import './title.css';
body {
background: orange;
+ background-image: url('../img/bg.png');
}
main.js
+ import createImg from './js/createImg'
import createTitle from './js/createTitle'
import './styles/global.css'
import './styles/title.less'
const h2 = createTitle('hello webpack')
+ const img = createImg()
document.body.appendChild(h2)
+ document.body.appendChild(img)
此时再次打包,图片就可以正常处理了:
- 大于 4kb 的图片直接输出到目录。
- 小于 4kb 的图片输出
data URL
。
注意:要保证安装的 webpack
和 css-loader
最新版本的 ,否则会出现 [object Object]
的情况,这是因为在某些 5.x 版本中通过 require
引入的图片默认是以 ESM
导出的,而在某些版本的 css-loader
处理 url
引入的图片也是以 ESM
导出的图片,这时候如果出现问题可以进行以下处理:
- 通过
require
导入的图片可以改成import
导入,或者在require
之后加上default
// 方法一:import logo from '../img/vue.png'
export default (content) => {
const img = document.createElement('img')
// 方法一:img.src = logo
img.src = require('../img/vue.png').default
return img
}
- 在
css
通过url
引入的背景图片,需要配置css-loader
:
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
+ esModule: false
},
},
'postcss-loader',
],
},
{
test: /\.less$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
+ esModule: false
},
},
'postcss-loader',
'less-loader',
],
},
],
},
}
脚本文件处理
在项目中使用 TypeScript
和 ES6+
是很常见的需求,所以我们可以借助 babel
来帮我们一次性搞定。安装以下包:
- @babel/core:babel 核心库。
- @babel/preset-env:一些常见的语法转换插件集合。
- @babel/preset-typescript:TypeScript 转换插件。
- babel-loader。
- typescript:支持 TS 类型校验功能。
注意: 也可以使用
ts-loader
来处理,但是速度会慢一些,原因是使用ts-loader
之后可能还是需要 babel 去编译一次,流程就变成了TS > TS 编译器 > JS > Babel > JS (再次)
。使用@babel/preset-typescript
只需要管理一个编译器即可。
yarn add @babel/core @babel/preset-env @babel/preset-typescript babel-loader typescript -D
配置 loader
:
module.exports = {
mode: 'development', // 开启开发模式打包,方便待会查看打包后代码。
devtool: false, // 关闭默认的 eval 代码块。
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
}, // 文件省略后缀时的查找规则,默认只能查找 `.js`、`.json` 类型文件。
module: {
rules: [
{
test: /\.(js|ts)x?$/,
exclude: /node_modules/, // 排除 node_modules 检测
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: true, // 开启目录缓存功能
},
},
],
},
],
},
}
上面多了一些前面未提及的配置,主要是为了待会说明打包后代码,大家可以看看注释。
在 Babel 中 集成 TS 插件并不具有类型检测功能,所以需要单独配置一个检测命令在打包前进行类型检查(也可以利用 ESLint + VSCode 强大的检测能力,后续会提到)。
- 初始化
tsconfig.json
。
yarn tsc --init
修改 tsconfig.json
配置:
{
"compilerOptions": {
// 不输出文件
"noEmit":true
}
}
配置一下脚本:
{
"scripts": {
"check-type": "tsc"
}
}
有了这些准备之后,接下来进行下面的操作进行测试:
- 将
src
目录下所有js
后缀文件改成ts
,其中main.ts
增加一个由Promise
封装的sleep
函数:
import createImg from './js/createImg'
import createTitle from './js/createTitle'
import './styles/global.css'
import './styles/title.less'
function sleep(time = 1) {
return new Promise((resolve) => {
setTimeout(() => {
resolve('done')
}, time * 1000);
})
}
const h2 = createTitle('hello webpack')
const img = createImg()
document.body.appendChild(h2)
document.body.appendChild(img)
sleep().then(console.log)
webpack.config.js
中的entry
改成main.ts
。- 根目录下创建
babel.config.js
导入预设插件。
module.exports = {
presets: [['@babel/preset-env'], ['@babel/preset-typescript']],
}
然后打包,这时候可以正常编译 ts
了,但是我们打开编译后源码后文件搜索 Promise
它依然使用的是原生的 API,这是因为预设并不能实现一些新的特性 ,如 Promise、Map、Set 、Generator
等 API,这时候就需要借助 polyfill
来实现,在 babel 7.4.0
以前,是默认把 polyfill
加进来的,但是这样会导致包的体积增大,所以需要额外的配置。如果需要在某些文件下使用这些 API 的 polyfill
需要导入两个包:
- core-js 3:实现一些新特性的 polyfill。
- regenerator-runtime:Generator API 的实现。
在使用的模块导入:
import "core-js/stable";
import "regenerator-runtime/runtime";
当然,我们也可以在 babel.config.js
中进行配置,然后根据 .browserslist
中的目标浏览器实现,这里只需要安装一下最新版本的 core-js
就可以了,它会自动导入上面的两个包。
module.exports = {
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage',
corejs: 3,
},
],
['@babel/preset-typescript'],
],
}
useBuiltIns
有三个值:
- usage:找到源代码使用最新 ES 新特性的地方,然后根据
broswserslist
配置的目标平台进行填充。 - false : 默认。啥也不干。
- entry:找到
broswserslist
所有目标平台进行填充。
此时我们再次打包,此时就能看到构建后的代码 Promise
实现了,如果没有效果查看 .browserslist
市场占有率是否配置成 > 0.01%
(这里配置这么低主要是为了测试)。
plugin
是 Webpack
最强大的功能,它也是 Webpack 的核心,后续的文章我也会着重介绍,下面主要是给大家介绍插件的使用。
html-webpack-plugin 使用
前面我们打完包后,需要将 js 文件引入到 html 才可以使用,而 html-webpack-plugin
很好地解决了这个问题,它可以将脚本自动注入 html 文件 ,我们也可以自定义一个模板,它支持 ejs
语法。
public/index.html
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
</html>
安装:
yarn add html-webpack-plugin -D
在 webpack 配置 plugins
属性:
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
// 其他配置...
plugins: [
new HtmlWebpackPlugin({
title: 'hello webpack',
template: path.resolve(__dirname, './public/index.html'),
}),
],
}
此时我们再次打包就能看到输出目录多了一个 index.html
文件,而且脚本也自动注入了。
clean-webpack-plugin 使用
每次打包后我们都需要手动清除 dist 就很麻烦(配置 hash 每次都不一样),这个插件一般是和上面插件配套的,它在每次打包输出目录前会删除以前的文件。
安装使用:
yarn add clean-webpack-plugin -D
配置:
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
// 其他配置...
plugins: [new CleanWebpackPlugin()],
}
开启 devServer
前文我们修改一次源代码就需要手动打包一次很是麻烦,而且实际开发中我们需要本地开发服务器去预览我们的页面效果,除此之外我们还需要在本地解决跨域问题,所以我们可以使用 webpack-dev-server
去解决。
安装 webpack-dev-server
:
yarn add webpack-dev-server -D
webpack.config.js
const path = require('path')
module.exports = {
// 其他配置...
devServer: {
static: path.resolve(__dirname, 'public'), // 设置静态服务器目录
hot: 'only', // 防止 error 导致整个页面刷新
compress: true, // 开启本地服务器 gzip 压缩
historyApiFallback: true, // 防止 history 路由刷新后空白
// 配置接口代理
proxy: {
'/api': {
target: 'https://api.github.com',
pathRewrite: { '^/api': '' },
changeOrigin: true,
},
},
},
}
注意:在某些 webpack v5.x 版本中,开发服务器提供的静态目录配置是
contentBase
,而hot: 'only'
则是hotOnly: true
。
然后配置脚本:
package.json
{
"scripts": {
"dev": "webpack serve"
},
}
接着在 main.ts
增加一个请求代码:
import createImg from './js/createImg'
import createTitle from './js/createTitle'
import './styles/global.css'
import './styles/title.less'
function sleep(time = 1) {
return new Promise((resolve) => {
setTimeout(() => {
resolve('done')
}, time * 1000);
})
}
+ async function fetchData(url: string) {
+ return (await fetch(url)).json()
+ }
const h2 = createTitle('hello webpack')
const img = createImg()
document.body.appendChild(h2)
document.body.appendChild(img)
sleep().then(console.log)
+ fetchData('/api/users').then(console.log).catch(console.log)
此时我们只需要使用 yarn dev
就可以了,如果能在浏览器控制台看到打印的数据就说明代理配置成功了。
注意:webpack-dev-server v4 版本之后默认启动 HMR (HotModuleReplacement 热模块替换,一种不需要刷新页面只需要按需更新的机制),我们上面配置
hot: 'only'
主要是防止错误会导致浏览器刷新的情况。
source-map
source-map
是开发阶段必备的一个功能(由谷歌浏览器提供),它可以帮我们去定位错误源代码的位置。在 webpack
中可以通过 devtool
进行配置,主要有以下几大类:
- eval:使用
eval
包裹模块代码,通过在eval
包裹的模块末尾添加//# sourceURL
来找到原始代码位置,不产生.map
文件,定位的是经过babel-loader
处理后的代码。 - source-map: 未经
loader
处理的源代码(完整行列信息),产生.map
文件,并在打包后文件末尾加上//# sourceMappingURL=main.bundle.js.map
来引入map
文件。 - cheap-source-map:经过
loader
处理后的源代码。 - cheap-module-source-map:未经loader处理的源代码,只有行信息。
- inline-cheap-source-map: 将
.map
作为DataURL嵌入,不单独生成.map
文件,经过loader
处理后的源代码,只有行信息。 - inline-cheap-module-source-map: 将
.map
作为 DataURL 嵌入,不单独生成.map
文件,未经过loader
处理后的源代码,只有行信息。
后面还有其他的类型,不过大体看下来无非就是就是[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map
模式的组合,详细信息可以查阅文档,根据需求进行定制。
模式与环境变量
前面我们提到了 mode
选项,它是用于区分 webpack
中打包模式的,不同的模式下会做不同的优化。主要有三个值:
- development:会将
DefinePlugin
中process.env.NODE_ENV
的值设置为development
. 为模块和 chunk 启用有效的名。 - production:会将
DefinePlugin
中process.env.NODE_ENV
的值设置为production
。为模块和 chunk 启用确定性的混淆名称,并设置一些优化插件如TerserPlugin
。 - none:不使用任何优化选项。
详细配置说明可以查看webpack中文文档。
这就是为啥我们经常能在 webpack 搭建的项目中可以直接使用 process.env.NODE_ENV
的原因了,我们也可以自己注入环境变量,比如 html
文件中的 favicon.ico
文件的 BASE_URL
变量:
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
可以这么配置:
plugins: [
new HtmlWebpackPlugin({
title: 'hello webpack',
template: path.resolve(__dirname, './public/index.html'),
}),
new DefinePlugin({
BASE_URL: JSON.stringify(''),
}),
],
注意:注入变量的值必须是 JS 字符串。
区分打包环境
有了模式和环境变量,我们就可以区分打包环境了,我们希望:
- 开发环境提供开发服务器、HMR、开发调试功能。
- 生产环境提供优化等功能。
下面我们将对配置文件进行拆分,然后利用 webpack-merge
这个包进行配置合并(不演示安装了)。
根目录新建以下目录结构:
├── config
| ├── utils.js # 一些通用方法
| ├── webpack.common.js # 公共配置
| ├── webpack.dev.js # 开发环境配置
| └── webpack.prod.js # 生产环境配置
我们先来抽离出一些常用的路径:
utils.js:
const path = require('path')
// 工作目录
const WORK_PATH = process.cwd()
// 解析路径
function resolvePath(target) {
return path.join(WORK_PATH, target)
}
module.exports = {
SRC_PATH: resolvePath('src'),
OUTPUT_PATH: resolvePath('dist'),
PUBLIC_PATH: resolvePath('public'),
WORK_PATH,
resolvePath,
}
webpack.common.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { DefinePlugin } = require('webpack')
const { OUTPUT_PATH, PUBLIC_PATH } = require('./utils')
module.exports = {
entry: {
main: './src/main.ts',
},
output: {
filename: 'js/[name].bundle.js',
path: OUTPUT_PATH,
},
resolve: {
extensions: ['.js', '.json', '.ts', '.vue'],
},
module: {
rules: [
{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
},
},
'postcss-loader',
],
},
{
test: /\.less$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
},
},
'postcss-loader',
'less-loader',
],
},
{
test: /\.(png|svg|gif|jpe?g)$/,
type: 'asset',
generator: {
filename: 'img/[name].[fullhash:4][ext]',
},
parser: {
dataUrlCondition: {
maxSize: 4 * 1024,
},
},
},
{
test: /\.(ttf|woff2?)$/,
type: 'asset/resource',
generator: {
filename: 'font/[name].[fullhash:4][ext]',
},
},
{
test: /\.(js|ts)x?$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
},
],
},
],
},
plugins: [
new HtmlWebpackPlugin({
title: 'hello webpack',
template: path.join(PUBLIC_PATH, 'index.html'),
}),
new DefinePlugin({
BASE_URL: JSON.stringify(''),
}),
],
}
webpack.dev.js:
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const { PUBLIC_PATH } = require('./utils')
module.exports = merge(baseConfig, {
mode: 'development',
devtool: 'cheap-module-source-map',
devServer: {
static: PUBLIC_PATH,
hot: 'only', // 防止 error 导致整个页面刷新
compress: true, // 开启本地服务器 gzip 压缩
historyApiFallback: true, // 防止 history 路由刷新后空白
// 配置接口代理
proxy: {
'/api': {
target: 'https://api.github.com',
pathRewrite: { '^/api': '' },
changeOrigin: true,
},
},
},
})
webpack.prod.js:
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = merge(baseConfig, {
mode: 'production',
plugins: [new CleanWebpackPlugin()],
})
此外,如果我们需要给 webpack 传递操作系统级别的环境变量可以通过 cross-env
这个包来帮我们处理,它和 DefinePlugin
的区别在于一个是运行时使用,一个是编译时使用。
安装:
yarn add cross-env -D
完成配置后,还需要变更一下脚本,因为配置文件已经不在根目录了:
{
"scripts": {
"dev": "cross-env NODE_ENV=development webpack serve --config config/webpack.dev.js",
"build": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
},
}
有了上述配置之后,接下来我们就可以很方便地对不同环境进行配置了。
代码拆分
在上面我们不管是什么模块最终都打包到一个文件中,这样看似 http 请求减少了,但是这个文件将变得十分庞大,而且在网络传输上也会带来一定的延时,所以需要进行代码拆分的操作。
使用多入口
代码拆分最简单的方式就是配置多入口,webpack 会为每个入口单独打包一个文件。
webpack.common.js
module.exports = {
entry: {
main: './src/main.ts',
main2: './src/main.ts',
},
}
此时就能看到输出目录多了一个 main2.bundle.js
的文件。在此基础上,假如两个入口都依赖了第三方模块,我们希望第三方依赖打包到其他文件,就可以这么配置:
webpack.common.js
module.exports = {
entry: {
// 依赖共享模块
main: { import: './src/main.ts', dependOn: 'shared' },
// 依赖共享模块
main2: { import: './src/main.ts', dependOn: 'shared' },
shared: ['lodash-es'],
},
}
需要安装
lodash-es @types/lodash-es
两个包,然后在入口文件导入就可以测试了。
此时就能发现打包后目录变成这样了:
那个 shared.bundle.js
就是 lodash-es
这个包抽离出来的,并且生成了一个 LICENSE
文件,这个是使用开源库的版本说明,原因是我开启了 production
模式打包,它会自动生成,如果不需要的话可以进行以下配置:
webpack.prod.js
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const Terser = require('terser-webpack-plugin') // webpack v5 自动安装
module.exports = merge(baseConfig, {
mode: 'production',
optimization: {
minimizer: [
new Terser({
extractComments: false,
}),
],
},
})
注意:跟优化有关的配置都在
optimization
这个配置中集中配置。
配置 splitChunks
上面那种方式并不常见,而对于拆包分 chunks 使用 splitChunks
尤为常见。我们参照 VueCLI 打包后输出目录进行配置,VueCLI 输出目录会有三大类型的 chunk:
- 第三方依赖,chunk-vendors。
- 主入口 chunk。
- 路由懒加载 chunk。
当然可能还会有一些公共模块,这个也比较常见,下面我们来对配置详细说明。
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
module.exports = merge(baseConfig, {
optimization: {
splitChunks: {
chunks: 'all', // 支持同步/异步导入的模块,有三个值:initial(同步)、async (异步)、all(所有)
minSize: 20000, // 生成 chunk 最小体积,单位字节
minChunks: 1, // 这个模块至少被导入一次就分包
cacheGroups: {
// 分组一:针对第三方依赖,会继承前面的配置
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10, // 当同时匹配到两个分组时设置的优先级,值越大优先级越大
filename: 'js/chunk-vendors.[fullhash:8].js',
},
// 分组一:针对公共模块,会继承前面的配置
default: {
minChunks: 2, // 模块被导入两次进行分包
priority: -20,
filename: 'js/[name]-common.[fullhash:8].js',
},
}, // 提取 chunk 分组
},
},
})
更详细的配置可以查看文档,传送门 -> optimization.splitChunks 。
import() + webpackChunkName
在 SPA 应用中通常有路由的功能,我们一般都会配置路由懒加载,这样只有跳转到对应路由的时候才会去加载 js 文件,这个功能可以使用 import()
函数实现。
我们把之前 main.ts
中请求数据的代码拆分到 js/fetch.ts
目录下面,然后改成动态导入:
;(async () => {
try {
await sleep(2)
const { default: fetchData } = await import('./js/fetch')
const data = await fetchData('/api/users')
console.log(data);
} catch (error) {
console.log(error);
}
})()
我们再次打包,可以看到生成了一个 335.bundle.js
文件,这个名称有点古怪,这个数字 335
我们之前并未配置过,它是怎么生成的?这就要说到一个 chunkIds
属性了,它是决定 chunk 在输出文件名时选择的算法,常见的值有:
deterministic
:默认值,在不同的编译中不变的短数字 id。有益于长期缓存。natural
:生产自然数字,1、2、3...。named
:根据 chunk 源文件目录生成有意义的字符串。
比如我们配置 named
:
webpack.prod.js
module.exports = merge(baseConfig, {
mode: 'production',
optimization: {
chunkIds: 'named',
})
此时会生成一个有意义的名称:
当然我们也可以配置自定义 chunk 名称,可以通过 output.chunkFilename
配置:
webpack.common.js
module.exports = {
output: {
chunkFilename: 'js/chunk-[name].[fullhash:8].js',
},
}
占位符 name
依然会采取前面提到的算法进行生成。当然如果你觉得这种方式还是不够人性化,可以在 import()
内部通过魔法注释进行自定义设置:
main.ts
;(async () => {
try {
await sleep(2)
const { default: fetchData } = await import(/* webpackChunkName: 'fetch' */'./js/fetch')
const data = await fetchData('/api/users')
console.log(data);
} catch (error) {
console.log(error);
}
})()
此时 webpack 会根据 output.chunkFilename
和 魔法注释的名称生成我们需要的 chunkName 了。
开启 runtimeChunk
runtimeChunk
可以将 webpack 加载模块的代码(webpack为了兼容多个模块化规范实现了自己的模块加载方式)单独抽离出来,可以增强浏览器缓存能力,缺点就是多了一次请求。
webpack.prod.js
module.exports = merge(baseConfig, {
mode: 'production',
optimization: {
runtimeChunk: true,
}
}
构建优化
提取 css 到单独的文件
前面我们的样式最后是通过 style-loader
创建了 style
标签,将样式内联在了 html 中,这样做好处就是减少了请求,但是一旦文件变大也会对性能造成影响,我们可以借助 mini-css-extract-plugin
来抽离样式到单独的文件,此外我们希望在开发阶段还是使用 style-loader
,生成模式下才提取文件,可以利用前文提到的 cross-env
传递的环境变量进行判断。
webpack.common.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const isProd = process.env.NODE_ENV === 'production'
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
isProd ? MiniCssExtractPlugin.loader : 'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
},
},
'postcss-loader',
],
},
{
test: /\.less$/,
use: [
isProd ? MiniCssExtractPlugin.loader : 'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
},
},
'postcss-loader',
'less-loader',
],
},
],
},
}
webpack.prod.js
const { merge } = require('webpack-merge')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = merge(baseConfig, {
mode: 'production',
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].[fullhash:6].css',
}),
],
})
此时打包后应该是可以看到多了一个 css 目录(yarn build
),但是 css 代码并没有压缩,我们可以借助 css-minimizer-webpack-plugin
这个插件来搞定。
webpack.prod.js
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const Terser = require('terser-webpack-plugin')
const CssMinimizerWebpackPlugin = require('css-minimizer-webpack-plugin')
module.exports = merge(baseConfig, {
mode: 'production',
optimization: {
runtimeChunk: true,
// 这个选项专门配置代码压缩
minimizer: [
// 压缩 js
new Terser({
extractComments: false,
}),
// 压缩 css
new CssMinimizerWebpackPlugin(),
],
},
})
资源预获取/预加载(preload/prefetch)
- prefetch(预获取):将来某些导航下可能需要的资源,在浏览器空闲时下载,对于用户来说是无感的,推荐使用。
- preload(预加载):当前导航下可能需要资源,随其他 chunk 并行下载,如果 chunk 很大的话可能会影响性能,不推荐。
前面我们使用 import()
函数来实现动态加载 chunk,我们再看看这段代码:
;(async () => {
try {
await sleep(2)
const { default: fetchData } = await import(/* webpackChunkName: 'fetch' */'./js/fetch')
const data = await fetchData('/api/users')
console.log(data);
} catch (error) {
console.log(error);
}
})()
fetch
这个 chunk 会等待 2s 后才会去请求 js 文件,来达到懒加载的目的。而我们可以利用 prefetch
的特性,提前去加载这个资源,因为将来可能会用到这个 chunk,它会在浏览器空闲时加载,不会影响用户体验。
开启 prefetch
:
;(async () => {
try {
await sleep(2)
const { default: fetchData } = await import(
/* webpackChunkName: 'fetch' */
/* webpackPrefetch: true */
'./js/fetch')
const data = await fetchData('/api/users')
console.log(data);
} catch (error) {
console.log(error);
}
})()
打包后,会在浏览器添加一个 link
标签,表示该资源将在浏览器空闲时加载:
<link rel="prefetch" as="script" href="http://127.0.0.1:5501/webpack-basic/dist/js/../js/chunk-fetch.56cdf1d3.js">
TreeShaking
webpack 中文文档的翻译解释 TreeShaking :
你可以将应用程序想象成一棵树。绿色表示实际用到的 source code(源码) 和 library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。
它是一种通过 ES Module 静态模块解析的特性来对代码的一种优化手段,最早由 Rollup
这个工具带来的概念。在 webpack v5 版本中已经开始支持 CommonJs Tree Shaking 了,而且在 mode: 'production'
模式下默认启动 TreeShaking
。如果要演示这个功能可以在开发模式下进行配置,这里涉及到两个很重要的配置:
- usedExports:标记未引用的代码 -> 找到枯萎的树叶;开启代码压缩功能后移除未引用代码 -> 对树使劲踹了一脚,让枯萎的树叶掉下。
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const Terser = require('terser-webpack-plugin')
module.exports = merge(baseConfig, {
mode: 'development', // 手动体验 treeshaking
devtool: false, // 去除 eval 包裹的代码,方便查看代码
optimization: {
usedExports: true, // 标记未使用成员
minimize: true, // “摇”掉未使用成员,并使用下面提供的插件压缩代码
minimizer: [
new Terser({
extractComments: false,
}),
],
},
},
})
- sideEffects:标记代码有无副作用,和
usedExports
不冲突,主要用于安全删除代码。它和usedExports
区别在于,如果有sideEffects
被标记成有副作用的代码是不会把“枯萎”的树叶“摇”掉的。可以在package.json
中进行配置:
{
"sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}
介绍完后我们做个小测试,将之前 main.ts
中的函数全部抽离到 js/utils.ts
文件中:
export async function fetchData(url: string) {
return (await fetch(url)).json()
}
export function createTitle(content: string) {
const h2 = document.createElement('h2')
h2.innerText = content
return h2
}
export function createImg(src: string) {
const img = document.createElement('img')
img.src = src
return img
}
export function sleep(time = 1) {
return new Promise((resolve) => {
setTimeout(() => {
resolve('done')
}, time * 1000);
})
}
然后在 main.ts
只导入 sleep
函数:
import { sleep, fetchData } from './js/utils'
sleep(2)
为了查看 usedExports
效果,我们先把 minimize
设置 false
,然后打包。此时我们能看到一些特别的注释:
webpack
会给未导出的成员加上特殊的标记:unused harmony exports ...
,将来开启 minimize
后就会被剔除。值得注意的是,我们即使导入了 fetchData
,如果我们不使用还是会被标记成未引用代码。这一点特别重要,大家请着重理解这句话。
而实际情况是,我们虽然不会直接使用这个导入的变量,但是我们需要让这个模块执行一些代码,这些代码可能会改变状态,如定时器里面执行函数、变更全局状态、样式等代码,这种情况下我们必须借助 sideEffects
来表明这个模块是有副作用的,我们不希望把这些代码 摇
掉,这就是 sideEffects
与 usedExports
的区别,它们相辅相成,来让代码更加精简。
配置 CDN
一句话解释:CDN (内容分发网络)是一种让用户就最近网络节点获取资源的技术,来提升网络传输速率。
我们项目中大部分会使用到 Vue、Vue-Router、Axios 等第三方模块,它们在打包时会一并打包到 chunk-vendors
(Vue 中专门放第三方依赖的 chunk) 中,如果使用的生产依赖特别多就会导致初始页面加载很慢,所以我们可以把这些包抽离成 CDN ,不参与打包了。我们以 lodash-es
为例,配置一下 externals
就可以了。
webpack.common.js
module.exports = {
externals: {
// key 是 外部依赖名称,value 是你导入的名称
'lodash-es': '_',
},
}
打包 Dll 库
Dll 概念最先由微软引入,是一种“动态链接库”。它和 CDN 类似,不过文件一般存放在本地,把一些不经常变动的代码和第三方模块抽离出来,不参与打包,来提升构建速度的一种方式。我们还是以上面的 lodash-es
为例,假如我们不想把 lodash-es
通过 CDN 引入,则可以把它抽离成 Dll 库,在本地直接通过 script
引入不参与打包。
下面是 Dll
使用流程:
- 在
config
创建webpack.dll.js
。
const { resolvePath, WORK_PATH } = require('./utils')
const webpack = require('webpack')
module.exports = {
mode: 'production',
entry: {
lodash: ['lodash-es', 'lodash'],
},
output: {
path: resolvePath('dll'),
filename: 'dll_[name].js',
library: 'dll_[name]', // 这里你可以理解为导出的一个全局变量,将来如果在浏览器使用是通过 `dll_lodash.forEach` 去使用的
},
plugins: [
new webpack.DllPlugin({
name: 'dll_[name]', // 设置成 library 的值
path: resolvePath('dll/[name].manifest.json'), // 设置 manifest.json 输出目录的绝对路径,这个文件你可以理解为 source-map 中的 .map 文件,用于资源定位查找。
}),
],
}
- 配置脚本。
"scripts": {
"dll": "webpack --config config/webpack.dll.js",
},
- 打包。
yarn dll
之后根目录结构会创建 dll
,内容如下:
├── dll
| ├── dll_lodash.js
| ├── dll_lodash.js.LICENSE.txt
| └── lodash.manifest.json
.txt
那个文件可以配置 minimizer
中的 TerserPlugin
属性去除,上文有介绍。
接着我们需要在项目中使用 DllReferencePlugin
和 AddAssetHtmlPlugin
来引入 dll 库,前者用于 dll 动态库查找,后者主要是讲文件嵌入到 html 文件中。
config/webpack.common.js
module.exports = {
plugins: [
new DllReferencePlugin({
context: WORK_PATH, // 保证跟 package.json 同级目录,绝对路径
manifest: resolvePath('dll/lodash.manifest.json'), // 指定 manifest 文件的绝对路径
}),
new AddAssetHtmlPlugin({
outputPath: 'auto', // 将来输出到的文件目录
filepath: resolvePath('dll/dll_lodash.js'), // dll 文件的绝对路径
}),
],
// externals: {
// 'lodash-es': '_',
// },
}
到这里就算是搞定配置了,可见我们为了一个优化要做这么多事情,其实是很麻烦的(即使使用了 AutoDllPlugin
插件来自动做导入配置也很),而且在 VueCLI 和 CRA 脚手架工具都没使用 dll 了,而是使用 webpack 自身的优化。如果大家对这块有疑问也不必过于深究,了解即可。
控制台优化
我们在开发中一般不需要编译后结果,最重要的就是报错有提示,所以我们可以借助 FriendlyErrorsWebpackPlugin
和 stats
选项来优化我们的控制台:
webpack.dev.js
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const { PUBLIC_PATH } = require('./utils')
const FriendlyErrorsWebpackPlugin = require('@nuxtjs/friendly-errors-webpack-plugin')
// 获取启动端口,默认是 8080
const portArgvIndex = process.argv.indexOf('--port')
let port = portArgvIndex !== -1 ? process.argv[portArgvIndex + 1] : 8080
module.exports = merge(baseConfig, {
mode: 'development',
devtool: 'cheap-module-source-map',
stats: 'errors-only',
devServer: {
host: '0.0.0.0',
port,
static: PUBLIC_PATH,
hot: 'only', // 防止 error 导致整个页面刷新
compress: true, // 开启本地服务器 gzip 压缩
historyApiFallback: true, // 防止 history 路由刷新后空白
// 配置接口代理
proxy: {
'/api': {
target: 'https://api.github.com',
pathRewrite: { '^/api': '' },
changeOrigin: true,
},
},
},
plugins: [
new FriendlyErrorsWebpackPlugin({
compilationSuccessInfo: {
// 修改启动后终端显示localhost和network访问地址
messages: [
`App runing at: `,
`Local: http://localhost:${port}`,
`Network: http://${require('ip').address()}:${port}`,
],
},
}),
],
})
然后再命令行脚本 dev
再配置一个 --progress
参数,这个可以显示编译时的百分比,更加人性化。配置后我们启动本地服务器就可以看到我们很熟悉的控制台了:
这里推荐一个插件:webpack-dashboard,它提供了更加全的面板,有兴趣大家可以去官方文档查看。
构建后分析
在生产模式下构建后,如果我们想分析打包后文件的体积可以借助 webpack-bundle-analyzer
这个插件,它提供了可视化功能帮我们分析。
- 安装
yarn add -D webpack-bundle-analyzer
- 使用
webpack.prod.js
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = merge(baseConfig, {
mode: 'production',
plugins: [
new BundleAnalyzerPlugin(),
],
})
此时我们使用 yarn build
进行打包,可以发现这个插件帮我们启动了一个服务,并且可以看到打包后的可视化页面:
使用 copy-webpack-plugin
我们开发中对于不需要参与打包的文件如 favicon.ico
、静态资源等可以通过 copy-webpack-plugin
直接拷贝到输出目录,而在开发阶段我们不需要拷贝的原因是,文件 I/O 效率低下,可以直接利用 devServer
静态服务器的能力直接提供资源(前文配置的 devServer.static
属性)。
安装使用方式都很简单,下面我给出 plugins
中的配置:
{
plugins: [
new CopyWebpackPlugin({
patterns: [
{
from: 'public',
globOptions: {
// 忽略 index.html,该文件由 html-webpack-plugin 拷贝
ignore: ['**/index.html'],
},
},
],
}),
],
}
打包 Library
如果我们是库的开发者或者要抽离出一个函数进行单独发布,首先推荐的是 rollup
,因为它提供了很干净的源代码,当然 webpack 也是支持的。下面我们讲 src/js/utils.ts
发布成 lib。
- 首先在
config
目录下面再整一个webpack.lib.js
:
const { resolvePath } = require('./utils')
module.exports = {
mode: 'production',
entry: './src/js/utils.ts',
output: {
filename: 'utils.js',
path: resolvePath('lib'),
libraryTarget: 'umd', // 兼容 AMD、CJS、ESM 等多种模块化
library: 'utils', // 我们包的名称,即全局变量
globalObject: 'this', // 使用哪个全局对象,默认是 'self' 即 window 对象,为了兼容 Node 以及其他平台可以设置成 this
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [
{
test: /\.(js|ts)x?$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
},
],
},
],
},
}
打包后,根目录下就多了一个 lib
文件,你可以直接 require
进行使用,也可以在浏览器通过 script
来引入使用,这里就不演示了。
实战
前面我们基本上把 Webpack 核心的一些特性进行了讲解,我们现在都知道了如何使用 loader
来对各种各样的资源进行处理,使用 plugin
来让我们拓展构建系统的能力。下面要介绍的 Vue3 / React 项目其实很简单,就是在前面的基础上,再配上相关的 loader
和 plugin
就可以工作了。
规范化项目
不管是什么项目,都需要代码质量的管控,所以 ESLint 和 Git 提交都需要被规范化,前面我们只是支持了 TS 语法,如果需要代码提示还需要实时的通过 watch
模式启动前面配置的 check-type
这很麻烦。
ESLint + Prettier 功能集成
安装以下依赖:
- eslint:使用其语法检测功能。
- prettier:使用其代码风格检测功能。
- eslint-webpack-plugin:webpack 集成 eslint 的插件。
- eslint-plugin-vue:支持 Vue 语法检测功能。
- eslint-plugin-prettier:集成 prettier 代码风格功能。
- eslint-config-prettier:覆盖 eslint 中的代码风格检测,或者说解决 eslint 与 prettier 之间的冲突。
- @typescript-eslint/eslint-plugin:集成 TS 代码检查功能。
- @typescript-eslint/parser:TS 解析器。
yarn add eslint prettier eslint-webpack-plugin eslint-plugin-vue eslint-plugin-prettier eslint-config-prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser -D
根目录下创建 .eslintrc.js :
module.exports = {
parser: 'vue-eslint-parser', // 解析 <template> ...
env: {
browser: true,
node: true,
es2021: true,
'vue/setup-compiler-macros': true // Vue 3 编译宏
},
extends: [
'plugin:vue/vue3-strongly-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended'
],
parserOptions: {
parser: '@typescript-eslint/parser', // 解析 SFC 中的 script
ecmaVersion: 12,
sourceType: 'module',
ecmaFeatures: {
jsx: true
}
},
rules: {
'prettier/prettier': 'error',
'vue/multi-word-component-names': 'off'
}
}
创建 .eslintigore 忽略某些文件检测(默认不检测 node_modules,记得项目是在根目录,否则不生效):
*.sh
.vscode
.idea
.husky
.local
*.js
/public
/dist
/config
/dll
/lib
创建 prettier.config.js
:
module.exports = {
printWidth: 100,
tabWidth: 2,
useTabs: false,
semi: false,
vueIndentScriptAndStyle: true,
singleQuote: true,
quoteProps: 'as-needed',
bracketSpacing: true,
trailingComma: 'none',
arrowParens: 'always',
insertPragma: false,
requirePragma: false,
proseWrap: 'never',
htmlWhitespaceSensitivity: 'strict',
endOfLine: 'lf'
}
相关规则不用记,下面是一些规则配置的文档,按需查找即可:
配置 webpack.dev.js,增强开发实时检测能力:
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.common')
const ESLintPlugin = require('eslint-webpack-plugin')
module.exports = merge(baseConfig, {
plugins: [
new ESLintPlugin({
extensions: ['js', 'ts', 'jsx', 'tsx'],
emitError: true,
emitWarning: true,
failOnError: true
})
]
})
eslint-webpack-plugin
版本要安装成2.1.0
,不然没法在控制台找到错误。我是在官方 issue 找到的,传送门在此。
搞定配置后,记得重启 VSCode ,然后启动开发服务器,你就可以看到控制台一堆报错了:
Git 提交约束
上面的配置只是规范的第一道屏障,有的人其实很厌烦,它甚至把 ESLint 检测通过行内注释给关闭了,这可如何是好?
没关系,代码总要上传 Git 的对吧,那我们在他提交代码的时候检测一下不就好了嘛,如果没有通过就不准提交代码。这就要依赖下面的工具了:
husky
:触发Git Hooks,执行脚本。lint-staged
:检测文件,只对暂存区中有改动的文件进行检测,可以在提交前进行 Lint 操作。commitizen
:使用规范化的message
提交。commitlint
:检查message
是否符合规范。cz-conventional-changelog
:适配器。提供conventional-changelog
标准(约定式提交标准)。基于不同需求,也可以使用不同适配器(比如:cz-customizable
)。
安装:
yarn add husky lint-staged commitizen @commitlint/config-conventional @commitlint/cli -D
设置适配器:
# yarn
yarn commitizen init cz-conventional-changelog --yarn --dev --exact --force
# npm
npx commitizen init cz-conventional-changelog --save-dev --save-exact --force
使用
--force
参数防止你以前安装过会出现冲突的情况。
它会在本地项目中配置适配器,然后去安装 cz-conventional-changelog
这个包,最后在 package.json
文件中生成下面代码:
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
接下里配置一个脚本,用于以后的 git 提交:
{
"scripts": {
"commit": "git cz"
},
}
然后配置 commitlint
,用于校验 Git 提交消息。
echo "module.exports = {extends: ['@commitlint/config-conventional']};" > commitlint.config.js
有了这个校验工具,怎么才可以触发校验呢,我们希望在提交代码的时候就进行校验,这时候husky
就可以出场了,他可以触发Git Hook
来执行相应的脚本,而我们只需要把刚刚的校验工具加入脚本就可以了,下面是具体使用方法:
我们需要定义触发 hook
时要执行的 Npm 脚本:
- 提交前对暂存区的文件进行代码风格语法校验
- 对提交的信息进行规范化校验
{
"scripts": {
"lint-staged": "lint-staged",
"commitlint": "commitlint --config commitlint.config.js -e -V",
"lint": "eslint ./src/**/*.{js,jsx,vue,ts,tsx} --fix",
"prepare": "husky install"
},
"lint-staged": {
"*.{js,jsx,vue,ts,tsx}": [
"yarn lint",
"prettier --write"
]
},
}
接下来就是配置 husky
通过触发Git Hook执行脚本:
# 安装钩子,项目开发人员只要拉取代码都会安装
yarn prepare
# 设置`pre-commit`钩子,提交前执行校验
yarn husky add .husky/pre-commit "yarn lint-staged"
# 设置`pre-commit`钩子,提交message执行校验
yarn husky add .husky/commit-msg "yarn commitlint"
注意:你的仓库在此之前必须是一个 git 仓库。
完成配置后,使用 git add . && yarn commit
进行测试吧~。
支持 Vue3 项目
我们知道 Vue 项目主要以 SFC(单文件组件) 为主,要支持识别需要安装以下依赖:
-
vue:生产依赖。
-
vue-router:生产依赖。
-
@vue/compiler-sfc (必须与 vue 同版本):用于编译 SFC 中的 template。
-
vue-loader v16.x:识别并解析 .vue 文件,将
script/style
拆分后交给其他 loader 处理(VueLoaderPlugin
)。 -
安装
yarn add vue@next vue-router@next
yarn add vue-loader@next @vue/compiler-sfc -D
- 配置 webpack.common.js
const { VueLoaderPlugin } = require('vue-loader')
module.exports = {
entry: {
app: './src/entry-vue.ts'
},
resolve: {
extensions: ['.js', '.json', '.ts', '.vue']
},
module: {
rules: [
{
test: /\.vue$/,
use: ['vue-loader']
},
]
},
plugins: [
new VueLoaderPlugin()
]
}
配置 @babel/preset-typescript
识别 vue 文件中 ts代码:
babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage',
corejs: 3,
modules: false
}
],
[
'@babel/preset-typescript',
{
allExtensions: true // 支持所有文件扩展名
}
]
]
}
对标
ts-loader
中的appendTsSuffixTo
配置。
- src 目录添加下面结构:
├── entry-vue.ts # vue 入口
├── env.d.ts # ts 声明文件
└── VueApp
├── App.vue
├── router
| └── index.ts
└── views
├── home
| └── index.vue
└── login
└── index.vue
其中我们看几个核心的文件:
- entry-vue.ts
import { createApp } from 'vue'
import App from './VueApp/App.vue'
import router from './VueApp/router'
createApp(App).use(router).mount('#app-vue')
VueApp/App.vue
就一个导航跳转功能,很简单:
<template>
<button @click="$router.push({ path: '/', query: { id: 1 } })">to home</button>
<button @click="handleClick">to login</button>
<router-view></router-view>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const handleClick = () => {
router.push({ path: '/login', query: { id: 1 } })
}
</script>
VueApp/router/index.ts 路由配置:
import { createRouter, RouteRecordRaw, createWebHistory } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('../views/home/index.vue')
},
{
path: '/login',
component: () => import('../views/login/index.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
拓展 .vue
模块的识别:
env.d.ts
declare module '*.vue' {
import { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}
yarn dev
启动项目,大功告成!
等等,这个警告很烦人,这是个啥?它意思是说我们现在用的是 esm-bundler
版本(vue.runtime.esm-bundler.js)的 Vue,也就是说在如 webpack 这种打包器使用时需要注入环境变量:
- VUE_OPTIONS_API: 是否支持 Options API
- VUE_PROD_DEVTOOLS: 是否开启生产环境下 DevTool
我们使用 DefinePlugin
注入一下就好了:
webpack.common.js
{
plugins: [
new DefinePlugin({
BASE_URL: JSON.stringify(''),
__VUE_OPTIONS_API__: false, // 不支持 options API
__VUE_PROD_DEVTOOLS__: false // 不支持生产 DevTool
})
],
}
到这为止,一个简单的 Vue 3 项目算是搭建好了。如果是多入口可能会出现 HMR 不起作用的情况,相关 issue,解决方案是:在开发配置里加上 optimization.runtimeChunk: 'single'
,单入口目前没发现任何问题。
webpack.dev.js
optimization: {
runtimeChunk: 'single'
},
待优化
这一套流程搞下来,我们项目目录变得很庞大且臃肿,其实有些配置文件很少需要变动,而且将来有一个新项目之后可能又要搭建一次,所以我提供的思路就是搞一个类似 VueCLI 的脚手架工具,且符合自己公司业务需求的脚手架,这样就能大大提升效率了,将来我有时间了且能力够的情况下我会写一篇关于 CLI 开发的流程。
总结
本文主要是讲述了 webpack v5 版本中的一些核心特性(当然一些新特性如模块联邦并没有提及,这个以后讲到微前端架构会单独出一篇文章,现在详细讲意义根本不大,了解即可),并且以一个 Vue 3 的实战搭建作为结尾,相信大家看了这篇文章一定会有收获的。笔者花了大概 5 天时间去写这篇文章,比我计划要慢了很久,因为中途写着写着遇到不少坑,大家可以看到很多的 “tips” ,这些都是踩坑记录,不过对于个人而言成长是很大的。
在实际开发中我并不推荐从 0 到 1 直接搭建,因为会占用你和团队大量的时间,使用 VueCLI / create-react-app 是更佳的选择,但是对于自身发展来说,它们终究是一个黑盒,我们只有进入黑盒才能有更大的提升,我们掌握这些技能之后就有了对整个工程的思考维度,所以在时间充裕的情况下自己从 0 到 1 去搭建一个项目收获是很大的。
这几天先缓缓,过阵子开始写 loader/plugin 手写与原理分析,敬请期待!