juejin.cn/post/684490… juejin.cn/post/702324… zhuanlan.zhihu.com/p/30669007 juejin.cn/post/700283… juejin.cn/post/708351… juejin.cn/post/698718… juejin.cn/post/684490…
juejin.cn/post/716087… juejin.cn/post/684490…
一、webpack的 基础配置
很多人确实没有机会参与项目中的 Webpack 配置,更别说配置优化了。
所以就容易有下面这些问题: 这里我找了一些代表性的问题,试试看你能回答多少 😉
问:Webpack 配置中用过哪些 Loader ?都有什么作用?
问:Webpack 配置中用过哪些 Plugin ?都有什么作用?
问:Loader 和 Plugin 有什么区别?
问:如何编写 Loader ? 介绍一下思路?
问:如何编写 Plugin ? 介绍一下思路?
问:Webpack optimize 有配置过吗?可以简单说说吗?
问:Webpack 层面如何性能优化?
问:Webpack 打包流程是怎样的?
问:tree-shaking 实现原理是怎样的?
问:Webpack 热更新(HMR)是如何实现?
问:Webpack 打包中 Babel 插件是如何工作的?
问:Webpack 和 Rollup 有什么相同点与不同点?
问:Webpack5 更新了哪些新特性?
怎么样?这些问题都 OK 吗? 接下来,我们拆解归纳一下这些面试题,绘制一个大概的知识体系
建立好知识体系之后,我们会围绕知识体系,简单分为三个层级: 基础 -- 会配置
进阶 -- 能优化
深入 -- 懂原理
1. 简单配置
该部分需要掌握:
Webpack 常规配置项有哪些?
常用 Loader 有哪些?如何配置?
常用插件(Plugin)有哪些?如何的配置?
Babel 的如何配置?Babel 插件如何使用?
1.1 安装依赖
毫无疑问,先本地安装一下 webpack 以及 webpack-cli
$ npm install webpack webpack-cli -D # 安装到本地依赖
安装完成 ✅
+ webpack-cli@4.7.2
+ webpack@5.44.0
1.2 工作模式
webpack 在 4 以后就支持 0 配置打包,我们可以测试一下
新建 ./src/index.js 文件,写一段简单的代码
const a = 'Hello ITEM'
console.log(a)
module.exports = a;
此时目录结构
webpack_work
├─ src
│ └─ index.js
└─ package.json
直接运行 npx webpack,启动打包
打包完成,我们看到日志上面有一段提示:The 'mode' option has not been set,... 意思就是,我们没有配置 mode(模式),这里提醒我们配置一下
- 模式: 供 mode 配置选项,告知 webpack 使用相应模式的内置优化,默认值为 production,另外还有 development、none,他们的区别如下
怎么配置呢?很简单
只需在配置对象中提供 mode 选项:
module.exports = {
mode: 'development',
};
从 CLI 参数中传递:
$ webpack --mode=development
1.3 配置文件
虽然有 0 配置打包,但是实际工作中,我们还是需要使用配置文件的方式,来满足不同项目的需求
根路径下新建一个配置文件 webpack.config.js
新增基本配置信息
const path = require('path')
module.exports = {
mode: 'development', // 模式
entry: './src/index.js', // 打包入口地址
output: {
filename: 'bundle.js', // 输出文件名
path: path.join(__dirname, 'dist') // 输出文件目录
}
}
这个就不过多说了,最基本的配置
1.4 Loader
这里我们把入口改成 CSS 文件,可能打包结果会如何 1.新增 ./src/main.css
body {
margin: 0 auto;
padding: 0 20px;
max-width: 800px;
background: #f4f8fb;
}
修改 entry 配置
const path = require('path')
module.exports = {
mode: 'development', // 模式
entry: './src/main.css', // 打包入口地址
output: {
filename: 'bundle.css', // 输出文件名
path: path.join(__dirname, 'dist') // 输出文件目录
}
}
3.运行打包命令:npx webpack
这里就报错了!
- 这是因为:webpack 默认支持处理 JS 与 JSON 文件,其他类型都处理不了,这里必须借助 Loader 来对不同类型的文件的进行处理。
4.安装 css-loader 来处理 CSS
npn install css-loader -D
5.配置资源加载模块
const path = require('path')
module.exports = {
mode: 'development', // 模式
entry: './src/main.css', // 打包入口地址
output: {
filename: 'bundle.css', // 输出文件名
path: path.join(__dirname, 'dist') // 输出文件目录
},
module: {
rules: [ // 转换规则
{
test: /\.css$/, //匹配所有的 css 文件
use: 'css-loader' // use: 对应的 Loader 名称
}
]
}
}
6.重新运行打包命令 npx webpack
哎嘿,可以打包了 😁
dist
└─ bundle.css # 打包得到的结果
这里这是尝试,入口文件还是需要改回 ./src/index.js 这里我们可以得到一个结论:Loader 就是将 Webpack 不认识的内容转化为认识的内容
1.5 插件(plugin)
与 Loader 用于转换特定类型的文件不同,插件(Plugin)可以贯穿 Webpack 打包的生命周期,执行不同的任务 下面来看一个使用的列子:
1.新建 ./src/index.html 文件
<!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>ITEM</title>
</head>
<body>
</body>
</html>
如果我想打包后的资源文件,例如:js 或者 css 文件可以自动引入到 Html 中,就需要使用插件 html-webpack-plugin来帮助你完成这个操作
2.本地安装 html-webpack-plugin
npm install html-webpack-plugin -D
3.配置插件
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode: 'development', // 模式
entry: './src/index.js', // 打包入口地址
output: {
filename: 'bundle.js', // 输出文件名
path: path.join(__dirname, 'dist') // 输出文件目录
},
module: {
rules: [
{
test: /\.css$/, //匹配所有的 css 文件
use: 'css-loader' // use: 对应的 Loader 名称
}
]
},
plugins:[ // 配置插件
new HtmlWebpackPlugin({
template: './src/index.html'
})
]
}
运行一下打包,打开 dist 目录下生成的 index.html 文件
<!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>ITEM</title>
<script defer src="bundle.js"></script></head>
<body>
</body>
</html>
可以看到它自动的引入了打包好的 bundle.js ,非常方便实用
1.6 自动清空打包目录
每次打包的时候,打包目录都会遗留上次打包的文件,为了保持打包目录的纯净,我们需要在打包前将打包目录清空 这里我们可以使用插件 clean-webpack-plugin 来实现
安装
$ npm install clean-webpack-plugin -D
配置
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
// 引入插件
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
// ...
plugins:[ // 配置插件
new HtmlWebpackPlugin({
template: './src/index.html'
}),
new CleanWebpackPlugin() // 引入插件
]
}
1.7 区分环境 本地开发和部署线上,肯定是有不同的需求 本地环境:
- 需要更快的构建速度
- 需要打印 debug 信息
- 需要 live reload 或 hot reload 功能
- 需要 sourcemap 方便定位问题 ...
生产环境:
- 需要更小的包体积,代码压缩+tree-shaking
- 需要进行代码分割
- 需要压缩图片体积 ...
针对不同的需求,首先要做的就是做好环境的区分
1.本地安装 cross-env [文档地址]
npm install cross-env -D
2.配置启动命令
打开 ./package.json
"scripts": {
"dev": "cross-env NODE_ENV=dev webpack serve --mode development",
"test": "cross-env NODE_ENV=test webpack --mode production",
"build": "cross-env NODE_ENV=prod webpack --mode production"
},
3.在 Webpack 配置文件中获取环境变量
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
console.log('process.env.NODE_ENV=', process.env.NODE_ENV) // 打印环境变量
const config = {
entry: './src/index.js', // 打包入口地址
output: {
filename: 'bundle.js', // 输出文件名
path: path.join(__dirname, 'dist') // 输出文件目录
},
module: {
rules: [
{
test: /\.css$/, //匹配所有的 css 文件
use: 'css-loader' // use: 对应的 Loader 名称
}
]
},
plugins:[ // 配置插件
new HtmlWebpackPlugin({
template: './src/index.html'
})
]
}
module.exports = (env, argv) => {
console.log('argv.mode=',argv.mode) // 打印 mode(模式) 值
// 这里可以通过不同的模式修改 config 配置
return config;
}
4.测试一下看看
执行 npm run build
process.env.NODE_ENV= prod
argv.mode= production
执行 npm run test
process.env.NODE_ENV= test
argv.mode= production
执行 npm run dev
process.env.NODE_ENV= dev
argv.mode= development
这样我们就可以不同的环境来动态修改 Webpack 的配置
1.8 启动 devServer
1.安装 webpack-dev-server
npm intall webpack-dev-server@3.11.2 -D
- ⚠️注意:本文使用的 webpack-dev-server 版本是 3.11.2,当版本 version >= 4.0.0 时,需要使用 devServer.static 进行配置,不再有 devServer.contentBase 配置项。
2.配置本地服务
// webpack.config.js
const config = {
// ...
devServer: {
contentBase: path.resolve(__dirname, 'public'), // 静态文件目录
compress: true, //是否启动压缩 gzip
port: 8080, // 端口号
// open:true // 是否自动打开浏览器
},
// ...
}
module.exports = (env, argv) => {
console.log('argv.mode=',argv.mode) // 打印 mode(模式) 值
// 这里可以通过不同的模式修改 config 配置
return config;
}
为什么要配置 contentBase ? 因为 webpack 在进行打包的时候,对静态文件的处理,例如图片,都是直接 copy 到 dist 目录下面。但是对于本地开发来说,这个过程太费时,也没有必要,所以在设置 contentBase 之后,就直接到对应的静态目录下面去读取文件,而不需对文件做任何移动,节省了时间和性能开销。
3.启动本地服务
$ npm run dev
为了看到效果,我在 html 中添加了一段文字,并在 public 下面放入了一张图片 logo.png
<!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>ITEM</title>
</head>
<body>
<p>ITEM</p>
</body>
</html>
public
└─ logo.png
接着访问 http://localhost:8080/logo.png
没问题
1.9 引入 CSS
上面,我们在 Loader 里面讲到了使用 css-loader 来处理 css,但是单靠 css-loader 是没有办法将样式加载到页面上。这个时候,我们需要再安装一个 style-loader 来完成这个功能
style-loader 就是将处理好的 css 通过 style 标签的形式添加到页面上
1.安装 style-loader [文档地址]
npm install style-loader -D
2.配置 Loader
const config = {
// ...
module: {
rules: [
{
test: /\.css$/, //匹配所有的 css 文件
use: ['style-loader','css-loader']
}
]
},
// ...
}
⚠️注意: Loader 的执行顺序是固定从后往前,即按 css-loader --> style-loader 的顺序执行
3.引用样式文件
在入口文件 ./src/index.js 引入样式文件 ./src/main.css
// ./src/index.js
import './main.css';
const a = 'Hello ITEM'
console.log(a)
module.exports = a;
/* ./src/main.css */
body {
margin: 10px auto;
background: cyan;
max-width: 800px;
}
重启一下本地服务,访问 http://localhost:8080/
这样样式就起作用了,继续修改一下样式
body {
margin: 10px auto;
background: cyan;
max-width: 800px;
/* 新增 */
font-size: 46px;
font-weight: 600;
color: white;
position: fixed;
left: 50%;
transform: translateX(-50%);
}
保存之后,样式就自动修改完成了
style-loader 核心逻辑相当于:
const content = `${样式内容}`
const style = document.createElement('style');
style.innerHTML = content;
document.head.appendChild(style);
通过动态添加 style 标签的方式,将样式引入页面
1.10 CSS 兼容性
使用 postcss-loader,自动添加 CSS3 部分属性的浏览器前缀
上面我们用到的 transform: translateX(-50%);,需要加上不同的浏览器前缀,这个我们可以使用 postcss-loader 来帮助我们完成
npm install postcss-loader postcss -D
const config = {
// ...
module: {
rules: [
{
test: /\.css$/, //匹配所有的 css 文件
use: ['style-loader','css-loader', 'postcss-loader']
}
]
},
// ...
}
- 这里有个很大的坑点:参考文档配置好后,运行的时候会报错
Error: Loading PostCSS "postcss-import" plugin failed:
Cannot find module 'postcss-import'
后面尝试安装插件的集合 postcss-preset-env ,然后修改配置为
// webpack.config.js
// 失败配置
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
[
'postcss-preset-env',
{
// 其他选项
},
],
],
},
},
},
运行之后依然会报错,在查阅资料后,终于找到了正确的打开方式,我们重新来一遍
npm install postcss postcss-loader postcss-preset-env -D
添加 postcss-loader 加载器
const config = {
// ...
module: {
rules: [
{
test: /\.css$/, //匹配所有的 css 文件
use: [
'style-loader',
'css-loader',
'postcss-loader'
]
}
]
},
// ...
}
创建 postcss 配置文件 postcss.config.js
// postcss.config.js
module.exports = {
plugins: [require('postcss-preset-env')]
}
创建 postcss-preset-env 配置文件 .browserslistrc
# 换行相当于 and
last 2 versions # 回退两个浏览器版本
> 0.5% # 全球超过0.5%人使用的浏览器,可以通过 caniuse.com 查看不同浏览器不同版本占有率
IE 10 # 兼容IE 10
再尝试运行一下
前缀自动加上了 👏
如果你对 .browserslistrc 不同配置产生的效果感兴趣,可以使用 autoprefixer 进行在线转化查看效果
1.11 引入 Less 或者 Sass
less 和 sass 同样是 Webpack 无法识别的,需要使用对应的 Loader 来处理一下
Less 处理相对比较简单,直接添加对应的 Loader 就好了
Sass 不光需要安装 sass-loader 还得搭配一个 node-sass,这里 node-sass 建议用淘宝镜像来安装,npm 安装成功的概率太小了 🤣
这里我们就使用 Sass 来做案例
1.安装
$ npm install sass-loader -D
# 淘宝镜像
$ npm i node-sass --sass_binary_site=https://npm.taobao.org/mirrors/node-sass/
2.新建 ./src/sass.scss
Sass 文件的后缀可以是 .scss(常用) 或者 .sass
$color: rgb(190, 23, 168);
body {
p {
background-color: $color;
width: 300px;
height: 300px;
display: block;
text-align: center;
line-height: 300px;
}
}
3.引入 Sass 文件
import './main.css';
import './sass.scss' // 引入 Sass 文件
const a = 'Hello ITEM'
console.log(a)
module.exports = a;
4.修改配置
const config = {
// ...
rules: [
{
test: /\.(s[ac]|c)ss$/i, //匹配所有的 sass/scss/css 文件
use: [
'style-loader',
'css-loader',
'postcss-loader',
'sass-loader',
]
},
]
},
// ...
}
来看一下执行结果
1.12 分离样式文件
前面,我们都是依赖 style-loader 将样式通过 style 标签的形式添加到页面上
但是,更多时候,我们都希望可以通过 CSS 文件的形式引入到页面上
1.安装 mini-css-extract-plugin
$ npm install mini-css-extract-plugin -D
2.修改 webpack.config.js 配置
// ...
// 引入插件
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const config = {
// ...
module: {
rules: [
// ...
{
test: /\.(s[ac]|c)ss$/i, //匹配所有的 sass/scss/css 文件
use: [
// 'style-loader',
MiniCssExtractPlugin.loader, // 添加 loader
'css-loader',
'postcss-loader',
'sass-loader',
]
},
]
},
// ...
plugins:[ // 配置插件
// ...
new MiniCssExtractPlugin({ // 添加插件
filename: '[name].[hash:8].css'
}),
// ...
]
}
// ...
3.查看打包结果
dist
├─ avatar.d4d42d52.png
├─ bundle.js
├─ index.html
├─ logo.56482c77.png
└─ main.3bcbae64.css # 生成的样式文件
1.13 图片和字体文件
虽然上面在配置开发环境的时候,我们可以通过设置 contentBase 去直接读取图片类的静态文件,看一下下面这两种图片使用情况
1.页面直接引入
<!-- 本地可以访问,生产环境会找不到图片 -->
<img src="/logo.png" alt="">
2.背景图引入
<div id="imgBox"></div>
/* ./src/main.css */
...
#imgBox {
height: 400px;
width: 400px;
background: url('../public/logo.png');
background-size: contain;
}
直接会报错
所以实际上,Webpack 无法识别图片文件,需要在打包的时候处理一下 常用的处理图片文件的 Loader 包含:
1.安装 file-loader
npm install file-loader -D
2.修改配置
const config = {
//...
module: {
rules: [
{
// ...
},
{
test: /\.(jpe?g|png|gif)$/i, // 匹配图片文件
use:[
'file-loader' // 使用 file-loader
]
}
]
},
// ...
}
3.引入图片
<!-- ./src/index.html -->
<!DOCTYPE html>
<html lang="en">
...
<body>
<p></p>
<div id="imgBox"></div>
</body>
</html>
样式文件中引入
/* ./src/sass.scss */
$color: rgb(190, 23, 168);
body {
p {
width: 300px;
height: 300px;
display: block;
text-align: center;
line-height: 300px;
background: url('../public/logo.png');
background-size: contain;
}
}
js 文件中引入
import './main.css';
import './sass.scss'
import logo from '../public/avatar.png'
const a = 'Hello ITEM'
console.log(a)
const img = new Image()
img.src = logo
document.getElementById('imgBox').appendChild(img)
启动服务,我们看一下效果
显示正常 ✌️ 我们可以看到图片文件的名字都已经变了,并且带上了 hash 值,然后我看一下打包目录
dist
├─ 56482c77280b3c4ad2f083b727dfcbf9.png
├─ bundle.js
├─ d4d42d529da4b5120ac85878f6f69694.png
└─ index.html
dist 目录下面多了两个文件,这正是 file-loader 拷贝过来的 如果想要修改一下名称,可以加个配置
const config = {
//...
module: {
rules: [
{
// ...
},
{
test: /\.(jpe?g|png|gif)$/i,
use:[
{
loader: 'file-loader',
options: {
name: '[name][hash:8].[ext]'
}
}
]
},
{
loader: 'file-loader',
options: {
name: '[name][hash:8].[ext]'
}
}
]
},
// ...
}
再次打包看一下
dist
├─ avatard4d42d52.png
├─ bundle.js
├─ index.html
└─ logo56482c77.png
再看一下 url-loader
4.安装 url-loader
$ npm install url-loader -D
5.配置 url-loader
配置和 file-loader 类似,多了一个 limit 的配置
const config = {
//...
module: {
rules: [
{
// ...
},
{
test: /\.(jpe?g|png|gif)$/i,
use:[
{
loader: 'url-loader',
options: {
name: '[name][hash:8].[ext]',
// 文件小于 50k 会转换为 base64,大于则拷贝文件
limit: 50 * 1024
}
}
]
},
]
},
// ...
}
看一下,我们两个图片文件的体积
public
├─ avatar.png # 167kb
└─ logo.png # 43kb
我们打包看一下效果
很明显可以看到 logo.png 文件已经转为 base64 了👌 再看字体文件的处理
6.配置字体文件
首先,从 iconfont.cn 下载字体文件到本地
在项目中,新建 ./src/fonts 文件夹来存放字体文件 然后,引入到入口文件
// ./src/index.js
import './main.css';
import './sass.scss'
import logo from '../public/avatar.png'
// 引入字体图标文件
import './fonts/iconfont.css'
const a = 'Hello ITEM'
console.log(a)
const img = new Image()
img.src = logo
document.getElementById('imgBox').appendChild(img)
接着,在 ./src/index.html 中使用
<!DOCTYPE html>
<html lang="en">
...
<body>
<p></p>
<!-- 使用字体图标文件 -->
<!-- 1)iconfont 对应 font-family 设置的值-->
<!-- 2)icon-member 图标 class 名称可以在 iconfont.cn 中查找-->
<i class="iconfont icon-member"></i>
<div id="imgBox"></div>
</body>
</html>
最后,增加字体文件的配置
const config = {
// ...
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/i, // 匹配字体文件
use: [
{
loader: 'url-loader',
options: {
name: 'fonts/[name][hash:8].[ext]', // 体积大于 10KB 打包到 fonts 目录下
limit: 10 * 1024,
}
}
]
},
// ...
}
打包一下,看看效果
但是在 webpack5,内置了资源处理模块,file-loader 和 url-loader 都可以不用安装
1.14 资源模块的使用
webpack5 新增资源模块(asset module),允许使用资源文件(字体,图标等)而无需配置额外的 loader。
资源模块支持以下四个配置:
asset/resource 将资源分割为单独的文件,并导出 url,类似之前的 file-loader 的功能.
asset/inline 将资源导出为 dataUrl 的形式,类似之前的 url-loader 的小于 limit 参数时功能.
asset/source 将资源导出为源码(source code). 类似的 raw-loader 功能.
asset 会根据文件大小来选择使用哪种类型,当文件小于 8 KB(默认) 的时候会使用 asset/inline,否则会使用 asset/resource
贴一下修改后的完整代码
// ./src/index.js
const config = {
// ...
module: {
rules: [
// ...
{
test: /\.(jpe?g|png|gif)$/i,
type: 'asset',
generator: {
// 输出文件位置以及文件名
// [ext] 自带 "." 这个与 url-loader 配置不同
filename: "[name][hash:8][ext]"
},
parser: {
dataUrlCondition: {
maxSize: 50 * 1024 //超过50kb不转 base64
}
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/i,
type: 'asset',
generator: {
// 输出文件位置以及文件名
filename: "[name][hash:8][ext]"
},
parser: {
dataUrlCondition: {
maxSize: 10 * 1024 // 超过100kb不转 base64
}
}
},
]
},
// ...
}
module.exports = (env, argv) => {
console.log('argv.mode=',argv.mode) // 打印 mode(模式) 值
// 这里可以通过不同的模式修改 config 配置
return config;
}
执行打包,结果和之前一样
1.15 JS 兼容性(Babel)
在开发中我们想使用最新的 Js 特性,但是有些新特性的浏览器支持并不是很好,所以 Js 也需要做兼容处理,常见的就是将 ES6 语法转化为 ES5。 这里将登场的“全场最靓的仔” -- Babel
1.未配置 Babel
我们写点 ES6 的东西
// ./src/index.js
import './main.css';
import './sass.scss'
import logo from '../public/avatar.png'
import './fonts/iconfont.css'
// ...
class Author {
name = 'ITEM'
age = 18
email = 'lxp_work@163.com'
info = () => {
return {
name: this.name,
age: this.age,
email: this.email
}
}
}
module.exports = Author
为了方便看源码,我们把 mode 换成 development 接着执行打包命令 打包完成之后,打开 bundle.js 查看打包后的结果
虽然我们还是可以找打我们的代码,但是阅读起来比较不直观,我们先设置 mode 为 none,以最原始的形式打包,再看一下打包结果
打包后的代码变化不大,只是对图片地址做了替换,接下来看看配置 babel 后的打包结果会有什么变化
2.安装依赖
$ npm install babel-loader @babel/core @babel/preset-env -D
babel-loader 使用 Babel 加载 ES2015+ 代码并将其转换为 ES5 @babel/core Babel 编译的核心包 @babel/preset-env Babel 编译的预设,可以理解为 Babel 插件的超集
配置 Babel 预设
// webpack.config.js
// ...
const config = {
entry: './src/index.js', // 打包入口地址
output: {
filename: 'bundle.js', // 输出文件名
path: path.join(__dirname, 'dist'), // 输出文件目录
},
module: {
rules: [
{
test: /\.js$/i,
use: [
{
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env'
],
}
}
]
},
// ...
]
},
//...
}
// ...
配置完成之后执行一下打包
刚才写的 ES6 class 写法 已经转换为了 ES5 的构造函数形式 尽然是做兼容处理,我们自然也可以指定到底要兼容哪些浏览器 为了避免 webpack.config.js 太臃肿,建议将 Babel 配置文件提取出来 根目录下新增 .babelrc.js
// ./babelrc.js
module.exports = {
presets: [
[
"@babel/preset-env",
{
// useBuiltIns: false 默认值,无视浏览器兼容配置,引入所有 polyfill
// useBuiltIns: entry 根据配置的浏览器兼容,引入浏览器不兼容的 polyfill
// useBuiltIns: usage 会根据配置的浏览器兼容,以及你代码中用到的 API 来进行 polyfill,实现了按需添加
useBuiltIns: "entry",
corejs: "3.9.1", // 是 core-js 版本号
targets: {
chrome: "58",
ie: "11",
},
},
],
],
};
好了,这里一个简单的 Babel 预设就配置完了 常见 Babel 预设还有:
@babel/preset-flow @babel/preset-react @babel/preset-typescript
感兴趣的可以自己去了解一下,这里不做扩展了,下面再说说插件的使用
4.配置 Babel 插件
对于正在提案中,还未进入 ECMA 规范中的新特性,Babel 是无法进行处理的,必须要安装对应的插件,例如:
// ./ index.js
import './main.css';
import './sass.scss'
import logo from '../public/avatar.png'
import './fonts/iconfont.css'
const a = 'Hello ITEM'
console.log(a)
const img = new Image()
img.src = logo
document.getElementById('imgBox').appendChild(img)
// 新增装饰器的使用
@log('hi')
class MyClass { }
function log(text) {
return function(target) {
target.prototype.logger = () => `${text},${target.name}`
}
}
const test = new MyClass()
test.logger()
执行一下打包
不出所料,识别不了 🙅🏻 怎么才能使用呢?Babel 其实提供了对应的插件:
@babel/plugin-proposal-decorators
@babel/plugin-proposal-class-properties
安装一下:
$ npm install babel/plugin-proposal-decorators @babel/plugin-proposal-class-properties -D
打开 .babelrc.js 加上插件的配置
module.exports = {
presets: [
[
"@babel/preset-env",
{
useBuiltIns: "entry",
corejs: "3.9.1",
targets: {
chrome: "58",
ie: "11",
},
},
],
],
plugins: [
["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
]
};
这样就可以打包了,在 bundle.js 中已经转化为浏览器支持的 Js 代码
同理,我们可以根据自己的实际需求,搭配不同的插件进行使用
2 SourceMap 配置选择
SourceMap 是一种映射关系,当项目运行后,如果出现错误,我们可以利用 SourceMap 反向定位到源码位置
2.1 devtool 配置
const config = {
entry: './src/index.js', // 打包入口地址
output: {
filename: 'bundle.js', // 输出文件名
path: path.join(__dirname, 'dist'), // 输出文件目录
},
devtool: 'source-map',
module: {
// ...
}
// ...
执行打包后,dist 目录下会生成以 .map 结尾的 SourceMap 文件
dist
├─ avatard4d42d52.png
├─ bundle.js
├─ bundle.js.map
└─ index.html
除了 source-map 这种类型之外,还有很多种类型可以用,例如:
eval
eval-source-map
cheap-source-map
inline-source-map
cheap-module-source-map
inline-cheap-source-map
cheap-module-eval-source-map
inline-cheap-module-source-map
hidden-source-map
nosources-source-map
这么多种,到底都有什么区别?如何选择呢?
2.2 配置项差异
1.为了方便比较它们的不同,我们新建一个项目
webpack_source_map
├─ src
│ ├─ Author.js
│ └─ index.js
├─ package.json
└─ webpack.config.js
2.打开 ./src/Author.js
class Author {
name = 'ITEM'
age = 18
email = 'lxp_work@163.com'
info = () => {
return {
name: this.name,
age: this.age,
email: this.email
}
}
}
module.exports = Author
3.打开 ./src/index.js
import Author from './Author'
const a = 'Hello ITEM'
console.log(a)
const img = new Image()
img.src = logo
document.getElementById('imgBox').appendChild(img)
const author = new Author();
console.log(author.info)
4.打开 package.json
{
"name": "webpack-source-map",
"version": "1.0.0",
"description": "",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "webpack"
},
"devDependencies": {
"@babel/core": "^7.6.4",
"@babel/preset-env": "^7.6.3",
"babel-loader": "^8.0.6",
"html-webpack-plugin": "^3.2.0",
"webpack": "^5.44.0",
"webpack-cli": "^4.7.2"
}
}
5.打开 webpack.config.js
// 多入口打包
module.exports = [
{
entry: './src/index.js',
output: {
filename: 'a.js'
}
},
{
entry: './src/index.js',
output: {
filename: 'b.js'
}
}
]
执行打包命令 npm run build,看一下结果
dist
├─ a.js
└─ b.js
不用关心打包结果 a.js b.js 里面是什么,到这步的目的是测试多入口打包 改造成多入口的目的是方便我们后面进行比较
6.不同配置项使用单独的打包入口,打开 webpack.config.js 修改
const HtmlWebpackPlugin = require('html-webpack-plugin')
// 1)定义不同的打包类型
const allModes = [
'eval',
'source-map',
'eval-source-map',
'cheap-source-map',
'inline-source-map',
'cheap-eval-source-map',
'cheap-module-source-map',
'inline-cheap-source-map',
'cheap-module-eval-source-map',
'inline-cheap-module-source-map',
'hidden-source-map',
'nosources-source-map'
]
// 2)循环不同 SourceMap 模式,生成多个打包入口
module.exports = allModes.map(item => {
return {
devtool: item,
mode: 'none',
entry: './src/main.js',
output: {
filename: `js/${item}.js`
},
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
},
plugins: [
3)输出到不同的页面
new HtmlWebpackPlugin({
filename: `${item}.html`
})
]
}
}
7.模拟代码错误
// ./src/index.js
import Author from './Author'
const a = 'Hello ITEM'
// 故意使用了错误的 console.log
console.log11(a)
const img = new Image()
img.src = logo
document.getElementById('imgBox').appendChild(img)
const author = new Author();
console.log(author.info)
8.尝试打包
报错了!!
提示有SourceMap 模式的名称不对,原来它们的拼接是有规律和意义的 我们按照校验规则 ^(inline-|hidden-|eval-)?(nosources-)?(cheap-(module-)?)?source-map$ 检查一下 cheap-eval-source-map 和 cheap-module-eval-source-map 好像有问题,eval 跑后面去了,改一下
// 修改之后
const allModes = [
'eval',
'source-map',
'eval-source-map',
'cheap-source-map',
'inline-source-map',
'eval-cheap-source-map',
'cheap-module-source-map',
'inline-cheap-source-map',
'eval-cheap-module-source-map',
'inline-cheap-module-source-map',
'hidden-source-map',
'nosources-source-map'
]
再执行一下打包 还是有报错!!接着改
错误信息我查了一下,大概率是 html-webpack-pugin 的版本太老,不兼容 webpack5,我们升级一下版本至 "html-webpack-plugin": "^5.3.2" 再尝试一下,OK 了
dist
├─ js
│ ├─ cheap-module-source-map.js #............ 有对应的 .map 文件
│ ├─ cheap-module-source-map.js.map
│ ├─ cheap-source-map.js #................... 有
│ ├─ cheap-source-map.js.map
│ ├─ eval-cheap-module-source-map.js #....... 无
│ ├─ eval-cheap-source-map.js #.............. 无
│ ├─ eval-source-map.js #.................... 无
│ ├─ eval.js #............................... 无
│ ├─ hidden-source-map.js #.................. 有
│ ├─ hidden-source-map.js.map
│ ├─ inline-cheap-module-source-map.js #..... 无
│ ├─ inline-cheap-source-map.js #............ 无
│ ├─ inline-source-map.js #.................. 无
│ ├─ nosources-source-map.js #............... 有
│ ├─ nosources-source-map.js.map
│ ├─ source-map.js #......................... 有
│ └─ source-map.js.map
├─ cheap-module-source-map.html
├─ cheap-source-map.html
├─ eval-cheap-module-source-map.html
├─ eval-cheap-source-map.html
├─ eval-source-map.html
├─ eval.html
├─ hidden-source-map.html
├─ inline-cheap-module-source-map.html
├─ inline-cheap-source-map.html
├─ inline-source-map.html
├─ nosources-source-map.html
└─ source-map.html
从目录结构我们可以很容易看出来,含 eval 和 inline 模式的都没有对应的.map 文件,具体为什么,下面接着分析 接着,我们在 dist 目录起一个服务,在浏览器打开
然后,我们一个个来分析 eval 模式:
1.生成代码通过 eval 执行 👇🏻
2.源代码位置通过 @sourceURL 注明 👇🏻
3.无法定位到错误位置,只能定位到某个文件
4.不用生成 SourceMap 文件,打包速度快
source-map 模式:
- 生成了对应的 SourceMap 文件,打包速度慢
- 在源代码中定位到错误所在行列信息 👇🏻
eval-source-map 模式:
1.生成代码通过 eval 执行 👇🏻
2.包含 dataUrl 形式的 SourceMap 文件
3.可以在编译后的代码中定位到错误所在行列信息
4.生成 dataUrl 形式的 SourceMap,打包速度慢
eval-cheap-source-map 模式:
1.生成代码通过 eval 执行 2.包含 dataUrl 形式的 SourceMap 文件 3.可以在编译后的代码中定位到错误所在行信息 4.不需要定位列信息,打包速度较快
eval-cheap-module-source-map 模式:
1.生成代码通过 eval 执行 2.包含 dataUrl 形式的 SourceMap 文件 3.可以在编译后的代码中定位到错误所在行信息 4.不需要定位列信息,打包速度较快 5.在源代码中定位到错误所在行信息 👇🏻
inline-source-map 模式:
1.通过 dataUrl 的形式引入 SourceMap 文件 👇🏻
... 余下和 source-map 模式一样
hidden-source-map 模式:
看不到 SourceMap 效果,但是生成了 SourceMap 文件
nosources-source-map 模式:
1.能看到错误出现的位置 👇🏻
2.但是没有办法现实对应的源码
接下来,我们稍微总结一下:
对照一下校验规则 ^(inline-|hidden-|eval-)?(nosources-)?(cheap-(module-)?)?source-map$ 分析一下关键字
好了,到这里 SourceMap 就分析完了
2.3 推荐配置
1.本地开发:
推荐:eval-cheap-module-source-map
理由:
本地开发首次打包慢点没关系,因为 eval 缓存的原因,rebuild 会很快 开发中,我们每行代码不会写的太长,只需要定位到行就行,所以加上 cheap 我们希望能够找到源代码的错误,而不是打包后的,所以需要加上 module
生产环境:
推荐:(none) 理由:
就是不想别人看到我的源代码
3.三种 hash 值
Webpack 文件指纹策略是将文件名后面加上 hash 值。特别在使用 CDN 的时候,缓存是它的特点与优势,但如果打包的文件名,没有 hash 后缀的话,你肯定会被缓存折磨的够呛 😂
例如我们在基础配置中用到的:filename: "[name][hash:8][ext]"
这里里面 [] 包起来的,就叫占位符,它们都是什么意思呢?请看下面这个表 👇🏻
表格里面的 hash、chunkhash、contenthash 你可能还是不清楚差别在哪
hash :任何一个文件改动,整个项目的构建 hash 值都会改变;
chunkhash:文件的改动只会影响其所在 chunk 的 hash 值;
contenthash:每个文件都有单独的 hash 值,文件的改动只会影响自身的 hash 值;
二、 webpack的优化配置
1. 优化构建速度
1.1 构建费时分析
这里我们需要使用插件 speed-measure-webpack-plugin,我们参考文档配置一下
1.首先安装一下
$ npm i -D speed-measure-webpack-plugin
2.修改我们的配置文件 webpack.config.js
...
// 费时分析
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
...
const config = {...}
module.exports = (env, argv) => {
// 这里可以通过不同的模式修改 config 配置
return smp.wrap(config);
}
3.执行打包
报错了🤦🏻♂️ 这里就暴露了使用这个插件的一个弊端,就是:
- 有些 Loader 或者 Plugin 新版本会不兼容,需要进行降级处理
这里我们对 mini-css-extract-plugin 进行一下降级处理: ^2.1.0 -> ^1.3.6 重新安装一下依赖,再次执行打包
降了版本之后,还是报错,根据提示信息,我们给配置加上 publicPath: './'
output: {
filename: 'bundle.js', // 输出文件名
path: path.join(__dirname, 'dist'), // 输出文件目录
publicPath: './'
},
在尝试一次
成功了!
- 注意:在 webpack5.x 中为了使用费时分析去对插件进行降级或者修改配置写法是非常不划算的,这里因为演示需要,我后面会继续使用,但是在平时开发中,建议还是不要使用。
1.2 优化 resolve 配置
1.2.1 alias
alias 用的创建 import 或 require 的别名,用来简化模块引用,项目中基本都需要进行配置。
const path = require('path')
...
// 路径处理方法
function resolve(dir){
return path.join(__dirname, dir);
}
const config = {
...
resolve:{
// 配置别名
alias: {
'~': resolve('src'),
'@': resolve('src'),
'components': resolve('src/components'),
}
}
};
配置完成之后,我们在项目中就可以
// 使用 src 别名 ~
import '~/fonts/iconfont.css'
// 使用 src 别名 @
import '@/fonts/iconfont.css'
// 使用 components 别名
import footer from "components/footer";
1.2.2 extensions
webpack 默认配置
const config = {
//...
resolve: {
extensions: ['.js', '.json', '.wasm'],
},
};
如果用户引入模块时不带扩展名,例如
import file from '../path/to/file';
那么 webpack 就会按照 extensions 配置的数组从左到右的顺序去尝试解析模块 需要注意的是:
1.高频文件后缀名放前面; 2.手动配置后,默认配置会被覆盖
如果想保留默认配置,可以用 ... 扩展运算符代表默认配置,例如
const config = {
//...
resolve: {
extensions: ['.ts', '...'],
},
};
1.2.3 modules
告诉 webpack 解析模块时应该搜索的目录,常见配置如下
const path = require('path');
// 路径处理方法
function resolve(dir){
return path.join(__dirname, dir);
}
const config = {
//...
resolve: {
modules: [resolve('src'), 'node_modules'],
},
};
告诉 webpack 优先 src 目录下查找需要解析的文件,会大大节省查找时间
1.2.4 resolveLoader
resolveLoader 与上面的 resolve 对象的属性集合相同, 但仅用于解析 webpack 的 loader 包。 一般情况下保持默认配置就可以了,但如果你有自定义的 Loader 就需要配置一下,不配可能会因为找不到 loader 报错
例如:我们在 loader 文件夹下面,放着我们自己写的 loader
我们就可以怎么配置
const path = require('path');
// 路径处理方法
function resolve(dir){
return path.join(__dirname, dir);
}
const config = {
//...
resolveLoader: {
modules: ['node_modules',resolve('loader')]
},
};
1.3 externals
externals 配置选项提供了「从输出的 bundle 中排除依赖」的方法。此功能通常对 library 开发人员来说是最有用的,然而也会有各种各样的应用程序用到它。
例如,从 CDN 引入 jQuery,而不是把它打包:
1.引入链接
<script
src="https://code.jquery.com/jquery-3.1.0.js"
integrity="sha256-slogkvB1K3VOkzAI8QITxV3VzpOnkeNVsKvtkYLMjfk="
crossorigin="anonymous"
></script>
2.配置 externals
const config = {
//...
externals: {
jquery: 'jQuery',
},
};
3.使用 jQuery
import $ from 'jquery';
$('.my-element').animate(/* ... */);
我们可以用这样的方法来剥离不需要改动的一些依赖,大大节省打包构建的时间。
1.3 缩小范围
在配置 loader 的时候,我们需要更精确的去指定 loader 的作用目录或者需要排除的目录,通过使用 include 和 exclude 两个配置项,可以实现这个功能,常见的例如:
- include:符合条件的模块进行解析
- exclude:排除符合条件的模块,不解析
- exclude 优先级更高
例如在配置 babel 的时候
const path = require('path');
// 路径处理方法
function resolve(dir){
return path.join(__dirname, dir);
}
const config = {
//...
module: {
noParse: /jquery|lodash/,
rules: [
{
test: /\.js$/i,
include: resolve('src'),
exclude: /node_modules/,
use: [
'babel-loader',
]
},
// ...
]
}
};
1.3 noParse
- 不需要解析依赖的第三方大型类库等,可以通过这个字段进行配置,以提高构建速度
- 使用 noParse 进行忽略的模块文件中不会解析 import、require 等语法
const config = {
//...
module: {
noParse: /jquery|lodash/,
rules:[...]
}
};
1.4 IgnorePlugin
防止在 import 或 require 调用时,生成以下正则表达式匹配的模块:
- requestRegExp 匹配(test)资源请求路径的正则表达式。
- contextRegExp 匹配(test)资源上下文(目录)的正则表达式。
new webpack.IgnorePlugin({ resourceRegExp, contextRegExp });
以下示例演示了此插件的几种用法。
1.安装 moment 插件(时间处理库)
$ npm i -S moment
2.配置 IgnorePlugin
// 引入 webpack
const webpack = require('webpack')
const config = {
...
plugins:[ // 配置插件
...
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/,
}),
]
};
目的是将插件中的非中文语音排除掉,这样就可以大大节省打包的体积了
1.5 多进程配置
- 注意:实际上在小型项目中,开启多进程打包反而会增加时间成本,因为启动进程和进程间通信都会有一定开销。
1.5.1 thread-loader
配置在 thread-loader 之后的 loader 都会在一个单独的 worker 池(worker pool)中运行
1.安装
$ npm i -D thread-loader
2.配置
const path = require('path');
// 路径处理方法
function resolve(dir){
return path.join(__dirname, dir);
}
const config = {
//...
module: {
noParse: /jquery|lodash/,
rules: [
{
test: /\.js$/i,
include: resolve('src'),
exclude: /node_modules/,
use: [
{
loader: 'thread-loader', // 开启多进程打包
options: {
worker: 3,
}
},
'babel-loader',
]
},
// ...
]
}
};
1.5.2 happypack ❌
同样为开启多进程打包的工具,webpack5 已弃用。
1.6 利用缓存
利用缓存可以大幅提升重复构建的速度
1.6.1 babel-loader 开启缓存
babel 在转译 js 过程中时间开销比价大,将 babel-loader 的执行结果缓存起来,重新打包的时候,直接读取缓存 缓存位置: node_modules/.cache/babel-loader
具体配置如下:
const config = {
module: {
noParse: /jquery|lodash/,
rules: [
{
test: /\.js$/i,
include: resolve('src'),
exclude: /node_modules/,
use: [
// ...
{
loader: 'babel-loader',
options: {
cacheDirectory: true // 启用缓存
}
},
]
},
// ...
]
}
}
那其他的 loader 如何将结果缓存呢?
cache-loader 就可以帮我们完成这件事情
1.6.2 cache-loader
- 缓存一些性能开销比较大的 loader 的处理结果
- 缓存位置:node_modules/.cache/cache-loader
1.安装
$ npm i -D cache-loader
2.配置 cache-loader
const config = {
module: {
// ...
rules: [
{
test: /\.(s[ac]|c)ss$/i, //匹配所有的 sass/scss/css 文件
use: [
// 'style-loader',
MiniCssExtractPlugin.loader,
'cache-loader', // 获取前面 loader 转换的结果
'css-loader',
'postcss-loader',
'sass-loader',
]
},
// ...
]
}
}
1.6.3 hard-source-webpack-plugin
hard-source-webpack-plugin 为模块提供了中间缓存,重复构建时间大约可以减少 80%,但是在 webpack5 中已经内置了模块缓存,不需要再使用此插件
1.6.4 dll ❌
在 webpack5.x 中已经不建议使用这种方式进行模块缓存,因为其已经内置了更好体验的 cache 方法
1.6.5 cache 持久化缓存
通过配置 cache 缓存生成的 webpack 模块和 chunk,来改善构建速度。
const config = {
cache: {
type: 'filesystem',
},
};
2. 优化构建结果
2.1 构建结果分析
借助插件 webpack-bundle-analyzer 我们可以直观的看到打包结果中,文件的体积大小、各模块依赖关系、文件是够重复等问题,极大的方便我们在进行项目优化的时候,进行问题诊断。
1.安装
$ npm i -D webpack-bundle-analyzer
2.配置插件
// 引入插件
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
const config = {
// ...
plugins:[
// ...
// 配置插件
new BundleAnalyzerPlugin({
// analyzerMode: 'disabled', // 不启动展示打包报告的http服务器
// generateStatsFile: true, // 是否生成stats.json文件
})
],
};
3.修改启动命令
"scripts": {
// ...
"analyzer": "cross-env NODE_ENV=prod webpack --progress --mode production"
},
4.执行编译命令 npm run analyzer
打包结束后,会自行启动地址为 http://127.0.0.1:8888 的 web 服务,访问地址就可以看到
如果,我们只想保留数据不想启动 web 服务,这个时候,我们可以加上两个配置
new BundleAnalyzerPlugin({
analyzerMode: 'disabled', // 不启动展示打包报告的http服务器
generateStatsFile: true, // 是否生成stats.json文件
})
这样再次执行打包的时候就只会产生 state.json 的文件了
2.2 压缩 CSS
1.安装 optimize-css-assets-webpack-plugin
$ npm install -D optimize-css-assets-webpack-plugin
2.修改 webapck.config.js 配置
// ...
// 压缩css
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')
// ...
const config = {
// ...
optimization: {
minimize: true,
minimizer: [
// 添加 css 压缩配置
new OptimizeCssAssetsPlugin({}),
]
},
// ...
}
// ...
3.查看打包结果
2.3 压缩 JS
- 注意:在生成环境下打包默认会开启 js 压缩,但是当我们手动配置 optimization 选项之后,就不再默认对 js 进行压缩,需要我们手动去配置。
因为 webpack5 内置了terser-webpack-plugin 插件,所以我们不需重复安装,直接引用就可以了,具体配置如下 const TerserPlugin = require('terser-webpack-plugin');
const config = {
// ...
optimization: {
minimize: true, // 开启最小化
minimizer: [
// ...
new TerserPlugin({})
]
},
// ...
}
2.4 清除无用的 CSS
purgecss-webpack-plugin 会单独提取 CSS 并清除用不到的 CSS
1.安装插件
$ npm i -D purgecss-webpack-plugin
2.添加配置
// ...
const PurgecssWebpackPlugin = require('purgecss-webpack-plugin')
const glob = require('glob'); // 文件匹配模式
// ...
function resolve(dir){
return path.join(__dirname, dir);
}
const PATHS = {
src: resolve('src')
}
const config = {
plugins:[ // 配置插件
// ...
new PurgecssPlugin({
paths: glob.sync(`${PATHS.src}/**/*`, {nodir: true})
}),
]
}
3.index.html 新增节点
<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>ITEM</title>
</head>
<body>
<p></p>
<!-- 使用字体图标文件 -->
<i class="iconfont icon-member"></i>
<div id="imgBox"></div>
<!-- 新增 div,设置 class 为 used -->
<div class="used"></div>
</body>
</html>
4.在 sass.scss 中添加样式
.used {
width: 200px;
height: 200px;
background: #ccc;
}
.unused {
background: chocolate;
}
5.执行一下打包
我们可以看到只有 .used 被保存下来 如何证明是这个插件的作用呢?注释掉再打包就可以看到,.unused 也会被打包进去,由此可证...
2.5 Tree-shaking
Tree-shaking 作用是剔除没有使用的代码,以降低包的体积
- webpack 默认支持,需要在 .bablerc 里面设置 model:false,即可在生产环境下默认开启
了解更多 Tree-shaking 知识,推荐阅读 👉🏻 从过去到现在,聊聊 Tree-shaking
module.exports = {
presets: [
[
"@babel/preset-env",
{
module: false,
useBuiltIns: "entry",
corejs: "3.9.1",
targets: {
chrome: "58",
ie: "11",
},
},
],
],
plugins: [
["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }],
]
};
2.6 Scope Hoisting
Scope Hoisting 即作用域提升,原理是将多个模块放在同一个作用域下,并重命名防止命名冲突,通过这种方式可以减少函数声明和内存开销。
webpack 默认支持,在生产环境下默认开启 只支持 es6 代码
3. 优化运行时体验
运行时优化的核心就是提升首屏的加载速度,主要的方式就是
- 降低首屏加载文件体积,首屏不需要的文件进行预加载或者按需加载
3.1 入口点分割
配置多个打包入口,多页打包,这里不过多介绍
3.2 splitChunks 分包配置
optimization.splitChunks 是基于 SplitChunksPlugin 插件实现的
默认情况下,它只会影响到按需加载的 chunks,因为修改 initial chunks 会影响到项目的 HTML 文件中的脚本标签。
webpack 将根据以下条件自动拆分 chunks:
- 新的 chunk 可以被共享,或者模块来自于 node_modules 文件夹
- 新的 chunk 体积大于 20kb(在进行 min+gz 之前的体积)
- 当按需加载 chunks 时,并行请求的最大数量小于或等于 30
- 当加载初始化页面时,并发请求的最大数量小于或等于 30
1.默认配置介绍
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'async', // 有效值为 `all`,`async` 和 `initial`
minSize: 20000, // 生成 chunk 的最小体积(≈ 20kb)
minRemainingSize: 0, // 确保拆分后剩余的最小 chunk 体积超过限制来避免大小为零的模块
minChunks: 1, // 拆分前必须共享模块的最小 chunks 数。
maxAsyncRequests: 30, // 最大的按需(异步)加载次数
maxInitialRequests: 30, // 打包后的入口文件加载时,还能同时加载js文件的数量(包括入口文件)
enforceSizeThreshold: 50000,
cacheGroups: { // 配置提取模块的方案
defaultVendors: {
test: /[\/]node_modules[\/]/,
priority: -10,
reuseExistingChunk: true,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};
2.项目中的使用
const config = {
//...
optimization: {
splitChunks: {
cacheGroups: { // 配置提取模块的方案
default: false,
styles: {
name: 'styles',
test: /\.(s?css|less|sass)$/,
chunks: 'all',
enforce: true,
priority: 10,
},
common: {
name: 'chunk-common',
chunks: 'all',
minChunks: 2,
maxInitialRequests: 5,
minSize: 0,
priority: 1,
enforce: true,
reuseExistingChunk: true,
},
vendors: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
chunks: 'all',
priority: 2,
enforce: true,
reuseExistingChunk: true,
},
// ... 根据不同项目再细化拆分内容
},
},
},
}
3.3 代码懒加载
针对首屏加载不太需要的一些资源,我们可以通过懒加载的方式去实现,下面看一个小🌰
- 需求:点击图片给图片加一个描述
1. 新建图片描述信息
desc.js
const ele = document.createElement('div')
ele.innerHTML = '我是图片描述'
module.exports = ele
2. 点击图片引入描述
index.js
import './main.css';
import './sass.scss'
import logo from '../public/avatar.png'
import '@/fonts/iconfont.css'
const a = 'Hello ITEM'
console.log(a)
const img = new Image()
img.src = logo
document.getElementById('imgBox').appendChild(img)
// 按需加载
img.addEventListener('click', () => {
import('./desc').then(({ default: element }) => {
console.log(element)
document.body.appendChild(element)
})
})
3. 查看效果
点击前
点击后
3.4 prefetch 与 preload
上面我们使用异步加载的方式引入图片的描述,但是如果需要异步加载的文件比较大时,在点击的时候去加载也会影响到我们的体验,这个时候我们就可以考虑使用 prefetch 来进行预拉取
3.4.1 prefetch
prefetch (预获取):浏览器空闲的时候进行资源的拉取
改造一下上面的代码
// 按需加载
img.addEventListener('click', () => {
import( /* webpackPrefetch: true */ './desc').then(({ default: element }) => {
console.log(element)
document.body.appendChild(element)
})
})
3.4.2 preload
- preload (预加载):提前加载后面会用到的关键资源
- ⚠️ 因为会提前拉取资源,如果不是特殊需要,谨慎使用
官网示例:
import(/* webpackPreload: true */ 'ChartingLibrary');
二、webpack 从哪些方面进行优化
juejin.cn/post/684490… juejin.cn/post/708351…
webpack5 相关知识整理
主要是 webpack5 相比的优化有哪些 zhufengpeixun.com/strong/html…
1.webpack5的新特性大致如下几个方面
- 持久化缓存
- 资源模块
- moduleIds & chunkIds的优化
- 更智能的tree shaking
- nodeJs的polyfill脚本被移除
- 支持生成e6/es2015的代码
- SplitChunk和模块大小
- Module Federation
2.持久化缓存
- 缓存生成的webpack模块和chunk,来改善构建速度
- cache 会在开发模式被设置成 type: 'memory' 而且在 生产 模式 中被禁用
- 在webpack5中默认开启,缓存默认是在内存里,但可以对cache进行设置
- 当设置cache.type: "filesystem"的时候,webpack会在内部启用文件缓存和内存缓存,写入的时候会同时写入内存和文件,读取缓存的时候会先读内存,如果内存里没有才会读取文件
- 每个缓存最大资源占用不超过500MB,当逼近或超过500MB时,会优先删除最老的缓存,并且缓存的有效期最长为2周 FileMiddleware.js
PackFileCacheStrategy.js:1036
FileSystemInfo.js:1691
- 默认情况下,webpack 假定 webpack 所在的 node_modules 目录只被包管理器修改。对 node_modules 来说,哈希值和时间戳会被跳过
2.1 安装
cnpm i webpack webpack-cli webpack-dev-server babel-loader @babel/core @babel/preset-env -D
2.2 webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
cache: {
type: 'filesystem', // 'memory' | 'filesystem'
cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'), // 默认将缓存存储在 node_modules/.cache/webpack
},
watch: true,
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
"@babel/preset-env"
]
}
}
]
}
]
}
}
2.3 package.json
"scripts": {
"build": "webpack",
"debug": "webpack"
},
3.资源模块
1.资源模块(asset module)是一种模块类型,它允许使用资源文件(字体,图标等)而无需配置额外 loader
2.在 webpack 5 之前,通常使用: raw-loader 将文件导入为字符串 url-loader 将文件作为 data URI 内联到 bundle 中 file-loader 将文件发送到输出目录
资源模块类型(asset module type),通过添加 4 种新的模块类型,来替换所有这些 loader
asset/resource 发送一个单独的文件并导出 URL。之前通过使用 file-loader 实现。
asset/inline 导出一个资源的 data URI。之前通过使用 url-loader 实现。
asset/source 导出资源的源代码。之前通过使用 raw-loader 实现。
asset 在导出一个 data URI 和发送一个单独的文件之间自动选择。之前通过使用 url-loader,并且配置资源体积限制实现
module.exports = {
module: {
rules: [
{
test: /\.png$/,
type: 'asset/resource'
},
{
test: /\.ico$/,
type: 'asset/inline'
},
{
test: /\.txt$/,
type: 'asset/source'
}
]
},
experiments: {
asset: true
},
};
3.1 老方式
3.1.1 src\index.js
src\index.js
import url from './images/kf.jpg';
let img = new Image();
img.src = url;
document.body.appendChild(img);
3.1.2 webpack.config.js
webpack.config.js
module: {
rules: [
{
test: /\.(jpg|png|gif)$/,
type:'asset'
}
]
}
3.2 新方式
新的方式语法是为了允许在没有打包工具的情况下运行代码。这种语法也可以在浏览器中的原生 ECMAScript 模块中使用
3.2.1 src\index.js
src\index.js
let url = new URL('./images/kf.jpg', import.meta.url);
let img = new Image();
img.src = url;
document.body.appendChild(img);
4.URIs
- Webpack 5 支持在请求中处理协议
- 支持data 支持 Base64 或原始编码,MimeType可以在module.rule中被映射到加载器和模块类型
- 支持http(s) src\index.js
import data from "data:text/javascript,export default 'title'";
import url from 'https://img.zhufengpeixun.com/zfjg.png';
console.log(data,url);
webpack.config.js
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
target: ['es6'],
plugins:[
new webpack.experiments.schemes.HttpsUriPlugin()
]
}
5.moduleIds & chunkIds的优化
5.1 概念
module: 每一个文件其实都可以看成一个 module
chunk: webpack打包最终生成的代码块,代码块会生成文件,一个文件对应一个chunk
5.2 优化
在webpack5之前,没有从entry打包的chunk文件,都会以1、2、3...的文件命名方式输出,删除某些些文件可能会导致缓存失效
在生产模式下,默认启用这些功能chunkIds: "deterministic", moduleIds: "deterministic",此算法采用确定性的方式将短数字 ID(3 或 4 个字符)短hash值分配给 modules 和 chunks
const path = require('path');
module.exports = {
mode: 'development',
devtool:false,
+ optimization:{
+ moduleIds:'deterministic',
+ chunkIds:'size'
+ },
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
"@babel/preset-env"
]
}
}
]
}
]
}
}
6.移除Node.js的polyfill
webpack4带了许多Node.js核心模块的polyfill,一旦模块中使用了任何核心模块(如crypto),这些模块就会被自动启用
webpack5不再自动引入这些polyfill
6.1 安装
cnpm i crypto-js crypto-browserify stream-browserify buffer -D
6.2 src\index.js
import CryptoJS from 'crypto-js';
console.log(CryptoJS.MD5('zhufeng').toString());
6.3 webpack.config.js
resolve:{
/* fallback:{
"crypto": require.resolve("crypto-browserify"),
"buffer": require.resolve("buffer"),
"stream":require.resolve("stream-browserify")
}, */
fallback:{
"crypto":false,
"buffer": false,
"stream":false
}
},
7.更强大的tree-shaking
tree-shaking (webpack.js.org/guides/tree…)
webpack4 本身的 tree shaking 比较简单,主要是找一个 import 进来的变量是否在这个模块内出现过,非常简单粗暴
7.1 原理
webpack从入口遍历所有模块的形成依赖图,webpack知道那些导出被使用
遍历所有的作用域并将其进行分析,消除未使用的范围和模块的方法
webpack-deep-scope-demo(diverse.space/webpack-dee…)
webpack-deep-scope-analysis-plugin(github.com/vincentdcha…)
7.1.1 作用域
而对于一个模块来说,只有 class 和 function 的作用域是可以导出到其他模块的
// module scope start
// Block
{ // <- scope start
} // <- scope end
// Class
class Foo { // <- scope start
// |
} // <- scope end
// If else
if (true) { // <- scope start
} /* <- scope end */ else { // <- scope start
} // <- scope end
// For
for (;;) { // <- scope start
} // <- scope end
// Catch
try {
} catch (e) { // <- scope start
} // <- scope end
// Function
function() { // <- scope start
} // <- scope end
// Scope
switch() { // <- scope start
} // <- scope end
// module scope end
7.1.2 工作过程
7.2 开启
7.2.1 开发环境
webpack.config.js
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
mode: 'development',
optimization: {
usedExports: true,
},
};
7.2.2 生产环境
生产环境默认开启
7.2.3 sideEffects
"sideEffects": false,意思就是对所有的模块都进行Tree Shaking
也就是将没有引入的方法等不进行打包到打包输出文件中
package.json
{"sideEffects": ["@babel/polyfill"]}
{"sideEffects": ["*.css"]}
7.3 嵌套的 tree-shaking
webpack 现在能够跟踪对导出的嵌套属性的访问
- 这可以改善重新导出命名空间对象时的Tree Shaking(清除未使用的导出和混淆导出)
7.3.1 src\index.js
src\index.js
import * as calculator from "./calculator";
console.log(calculator.operators.add);
7.3.2 src\calculator.js
src\calculator.js
import * as operators from "./operators";
export { operators };
7.3.3 src\operators.js
src\operators.js
export const add = 'add';
export const minus = 'minus';
7.3.4 webpack.config.js
webpack.config.js
module.exports = {
mode: 'production'
}
7.4 内部模块 tree-shaking
-
webpack 4 没有分析模块的导出和引用之间的依赖关系
-
webpack 5 可以对模块中的标志进行分析,找出导出和引用之间的依赖关系
7.4.1 src\index.js
src\index.js
import { getPostUrl } from './api';
console.log('getPostUrl',getPostUrl);
7.4.2 src\api.js
src\api.js
import { host } from './constants';
function useHost() {
return host;
}
export function getUserUrl() {
return useHost()+'/user';
}
export function getPostUrl() {
return '/post';
}
7.4.3 src\constants.js
src\constants.js
export const host = 'http://localhost';
7.5 CommonJs Tree Shaking
-
webpack 曾经不进行对 CommonJS 导出和 require()调用时的导出使用分析
-
webpack 5 增加了对一些 CommonJS 构造的支持,允许消除未使用的 CommonJs 导出,并从 require() 调用中跟踪引用的导出名称 支持以下构造:
- exports|this|module.exports.xxx = ...
- exports|this|module.exports = require("...") (reexport)
- exports|this|module.exports.xxx = require("...").xxx (reexport)
- Object.defineProperty(exports|this|module.exports, "xxx", ...)
- require("abc").xxx
- require("abc").xxx()
7.5.1 src\index.js
src\index.js
let api = require('./api');
console.log(api.getPostUrl);
7.5.2 src\api.js
src\api.js
function getUserUrl() {
return '/user';
}
function getPostUrl() {
return '/post';
}
exports.getPostUrl=getPostUrl;
8.splitChunks
split-chunks-plugin(webpack.js.org/plugins/spl…)
参考
changelog-v5(github.com/webpack/cha…
三、webpack 面试题整理1
0.有哪些常见的Loader?你用过哪些Loader?
- raw-loader:加载文件原始内容(utf-8)
- file-loader:把文件输出到一个文件夹中,在代码中通过相对 URL 去引用输出的文件 (处理图片和字体)
- url-loader:与 file-loader 类似,区别是用户可以设置一个阈值,大于阈值会交给 file-loader 处理,小于阈值时返回文- 件 base64 形式编码 (处理图片和字体)
- source-map-loader:加载额外的 Source Map 文件,以方便断点调试
- svg-inline-loader:将压缩后的 SVG 内容注入代码中
- image-loader:加载并且压缩图片文件
- json-loader 加载 JSON 文件(默认包含)
- handlebars-loader: 将 Handlebars 模版编译成函数并返回
- babel-loader:把 ES6 转换成 ES5
- ts-loader: 将 TypeScript 转换成 JavaScript
- awesome-typescript-loader:将 TypeScript 转换成 JavaScript,性能优于 ts-loader
- sass-loader:将SCSS/SASS代码转换成CSS
- css-loader:加载 CSS,支持模块化、压缩、文件导入等特性
- style-loader:把 CSS 代码注入到 JavaScript 中,通过 DOM 操作去加载 CSS
- postcss-loader:扩展 CSS 语法,使用下一代 CSS,可以配合 autoprefixer 插件自动补齐 CSS3 前缀
- eslint-loader:通过 ESLint 检查 JavaScript 代码
- tslint-loader:通过 TSLint检查 TypeScript 代码
- mocha-loader:加载 Mocha 测试用例的代码
- coverjs-loader:计算测试的覆盖率
- vue-loader:加载 Vue.js 单文件组件
- i18n-loader: 国际化
- cache-loader: 可以在一些性能开销较大的 Loader 之前添加,目的是将结果缓存到磁盘里
更多 Loader 请参考官网
1.有哪些常见的Plugin?你用过哪些Plugin?
- define-plugin:定义环境变量 (Webpack4 之后指定 mode 会自动配置)
- ignore-plugin:忽略部分文件
- html-webpack-plugin:简化 HTML 文件创建 (依赖于 html-loader)
- web-webpack-plugin:可方便地为单页应用输出 HTML,比 html-webpack-plugin 好用
- uglifyjs-webpack-plugin:不支持 ES6 压缩 (Webpack4 以前)
- terser-webpack-plugin: 支持压缩 ES6 (Webpack4)
- webpack-parallel-uglify-plugin: 多进程执行代码压缩,提升构建速度
- mini-css-extract-plugin: 分离样式文件,CSS 提取为独立文件,支持按需加载 (替代extract-text-webpack-plugin)
- serviceworker-webpack-plugin:为网页应用增加离线缓存功能
- clean-webpack-plugin: 目录清理
- ModuleConcatenationPlugin: 开启 Scope Hoisting
- speed-measure-webpack-plugin: 可以看到每个 Loader 和 Plugin 执行耗时 (整个打包耗时、每个 Plugin 和 Loader - 耗时)
- webpack-bundle-analyzer: 可视化 Webpack 输出文件的体积 (业务组件、依赖第三方模块)
更多 Plugin 请参考官网
2.那你再说一说Loader和Plugin的区别?
Loader 本质就是一个函数,在该函数中对接收到的内容进行转换,返回转换后的结果。
因为 Webpack 只认识 JavaScript,所以 Loader 就成了翻译官,对其他类型的资源进行转译的预处理工作。
Plugin 就是插件,基于事件流框架 Tapable,插件可以扩展 Webpack 的功能,在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
Loader 在 module.rules 中配置,作为模块的解析规则,类型为数组。每一项都是一个 Object,内部包含了 test(类型文件)、loader、options (参数)等属性。
Plugin 在 plugins 中单独配置,类型为数组,每一项是一个 Plugin 的实例,参数都通过构造函数传入。
3.Webpack构建流程简单说一下
Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:
- 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数
- 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译
- 确定入口:根据配置中的 entry 找出所有的入口文件
- 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
- 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系
- 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
- 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。
简单说
- 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler
- 编译:从 Entry 出发,针对每个 Module 串行调用对应的 Loader 去翻译文件的内容,再找到该 Module 依赖的 Module,递归地进行编译处理
- 输出:将编译后的 Module 组合成 Chunk,将 Chunk 转换成文件,输出到文件系统中
4.使用webpack开发时,你用过哪些可以提高效率的插件?
- webpack-dashboard:可以更友好的展示相关打包信息。
- webpack-merge:提取公共配置,减少重复配置代码
- speed-measure-webpack-plugin:简称 SMP,分析出 Webpack 打包过程中 Loader 和 Plugin 的耗时,有助于找到构建过程中的性能瓶颈。
- size-plugin:监控资源体积变化,尽早发现问题
- HotModuleReplacementPlugin:模块热替换
5.source map是什么?生产环境怎么用?
source map 是将编译、打包、压缩后的代码映射回源代码的过程。打包压缩后的代码不具备良好的可读性,想要调试源码就需要 soucre map。
map文件只要不打开开发者工具,浏览器是不会加载的。
线上环境一般有三种处理方案:
hidden-source-map:借助第三方错误监控平台 Sentry 使用
nosources-source-map:只会显示具体行数以及查看源代码的错误栈。安全性比 sourcemap 高
sourcemap:通过 nginx 设置将 .map 文件只对白名单开放(公司内网)
- 注意:避免在生产中使用 inline- 和 eval-,因为它们会增加 bundle 体积大小,并降低整体性能。
6.模块打包原理知道吗?
Webpack 实际上为每个模块创造了一个可以导出和导入的环境,本质上并没有修改代码的执行逻辑,代码执行顺序与模块加载顺序也完全一致。
7.文件监听原理呢?
在发现源码发生变化时,自动重新构建出新的输出文件。
Webpack开启监听模式,有两种方式:
- 启动 webpack 命令时,带上 --watch 参数
- 在配置 webpack.config.js 中设置 watch:true
缺点:每次需要手动刷新浏览器
原理:轮询判断文件的最后编辑时间是否变化,如果某个文件发生了变化,并不会立刻告诉监听者,而是先缓存起来,等 aggregateTimeout 后再执行。
module.export = {
// 默认false,也就是不开启
watch: true,
// 只有开启监听模式时,watchOptions才有意义
watchOptions: {
// 默认为空,不监听的文件或者文件夹,支持正则匹配
ignored: /node_modules/,
// 监听到变化发生后会等300ms再去执行,默认300ms
aggregateTimeout:300,
// 判断文件是否发生变化是通过不停询问系统指定文件有没有变化实现的,默认每秒问1000次
poll:1000
}
}
8.说一下 Webpack 的热更新原理吧
(这道题必考) Webpack 的热更新又称热替换(Hot Module Replacement),缩写为 HMR。 这个机制可以做到不用刷新浏览器而将新变更的模块替换掉旧的模块。
HMR的核心就是客户端从服务端拉去更新后的文件,准确的说是 chunk diff (chunk 需要更新的部分),实际上 WDS 与浏览器之间维护了一个 Websocket,当本地资源发生变化时,WDS 会向浏览器推送更新,并带上构建时的 hash,让客户端与上一次资源进行对比。客户端对比出差异后会向 WDS 发起 Ajax 请求来获取更改内容(文件列表、hash),这样客户端就可以再借助这些信息继续向 WDS 发起 jsonp 请求获取该chunk的增量更新。
后续的部分(拿到增量更新之后如何处理?哪些状态该保留?哪些又需要更新?)由 HotModulePlugin 来完成,提供了相关 API 以供开发者针对自身场景进行处理,像react-hot-loader 和 vue-loader 都是借助这些 API 实现 HMR。
细节请参考(我的链接 热更新部分的)
9.如何对bundle体积进行监控和分析?
VSCode 中有一个插件 Import Cost 可以帮助我们对引入模块的大小进行实时监测,还可以使用 webpack-bundle-analyzer 生成 bundle 的模块组成图,显示所占体积。
bundlesize 工具包可以进行自动化资源体积监控。
10.文件指纹是什么?怎么用?
文件指纹是打包后输出的文件名的后缀。
- Hash:和整个项目的构建相关,只要项目文件有修改,整个项目构建的 hash 值就会更改
- Chunkhash:和 Webpack 打包的 chunk 有关,不同的 entry 会生出不同的 chunkhash
- Contenthash:根据文件内容来定义 hash,文件内容不变,则 contenthash 不变
JS的文件指纹设置
设置 output 的 filename,用 chunkhash。
module.exports = {
entry: {
app: './scr/app.js',
search: './src/search.js'
},
output: {
filename: '[name][chunkhash:8].js',
path:__dirname + '/dist'
}
}
CSS的文件指纹设置
设置 MiniCssExtractPlugin 的 filename,使用 contenthash。
module.exports = {
entry: {
app: './scr/app.js',
search: './src/search.js'
},
output: {
filename: '[name][chunkhash:8].js',
path:__dirname + '/dist'
},
plugins:[
new MiniCssExtractPlugin({
filename: `[name][contenthash:8].css`
})
]
}
图片的文件指纹设置
设置file-loader的name,使用hash。
占位符名称及含义
- ext 资源后缀名
- name 文件名称
- path 文件的相对路径
- folder 文件所在的文件夹
- contenthash 文件的内容hash,默认是md5生成
- hash 文件内容的hash,默认是md5生成
- emoji 一个随机的指代文件内容的emoj
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename:'bundle.js',
path:path.resolve(__dirname, 'dist')
},
module:{
rules:[{
test:/\.(png|svg|jpg|gif)$/,
use:[{
loader:'file-loader',
options:{
name:'img/[name][hash:8].[ext]'
}
}]
}]
}
}
11.在实际工程中,配置文件上百行乃是常事,如何保证各个loader按照预想方式工作?
可以使用 enforce 强制执行 loader 的作用顺序,pre 代表在所有正常 loader 之前执行,post 是所有 loader 之后执行。(inline 官方不推荐使用)
12.如何优化 Webpack 的构建速度?
(这个问题就像能不能说一说「从URL输入到页面显示发生了什么」一样)
-
使用高版本的 Webpack 和 Node.js
-
多进程/多实例构建:HappyPack(不维护了)、thread-loader
-
压缩代码
-
多进程并行压缩
- webpack-paralle-uglify-plugin
- uglifyjs-webpack-plugin
- 开启 parallel 参数 (不支持ES6)terser-webpack-plugin 开启 parallel 参数
- 通过 mini-css-extract-plugin 提取 Chunk 中的 CSS 代码到单独文件,通过 css-loader 的 minimize 选项开启 cssnano 压缩 CSS。
-
图片压缩
使用基于 Node 库的 imagemin (很多定制选项、可以处理多种图片格式)配置
image-webpack-loader
-
缩小打包作用域:
exclude/include (确定 loader 规则范围)
resolve.modules 指明第三方模块的绝对路径 (减少不必要的查找)
resolve.mainFields 只采用 main 字段作为入口文件描述字段 (减少搜索步骤,需要考虑到所有运行时依赖的第三方模块的入口文件描述字段)
resolve.extensions 尽可能减少后缀尝试的可能性
noParse 对完全不需要解析的库进行忽略 (不去解析但仍会打包到 bundle 中,注意被忽略掉的文件里不应该包含 import、require、define 等模块化语句)
IgnorePlugin (完全排除模块)
合理使用alias
-
提取页面公共资源: 基础包分离: 使用 html-webpack-externals-plugin,将基础包通过 CDN 引入,不打入 bundle 中 使用 SplitChunksPlugin 进行(公共脚本、基础包、页面公共文件)分离(Webpack4内置) ,替代了 CommonsChunkPlugin 插件
-
DLL:
使用 DllPlugin 进行分包,使用 DllReferencePlugin(索引链接) 对 manifest.json 引用,让一些基本不会改动的代码先打包成静态资源,避免反复编译浪费时间。 HashedModuleIdsPlugin 可以解决模块数字id问题
-
充分利用缓存提升二次构建速度:
babel-loader 开启缓存 terser-webpack-plugin 开启缓存使用 cache-loader 或者 hard-source-webpack-plugin
-
Tree shaking
打包过程中检测工程中没有引用过的模块并进行标记,在资源压缩时将它们从最终的bundle中去掉(只能对ES6 Modlue生效) 开发中尽可能使用ES6 Module的模块,提高tree shaking效率 禁用babel-loader 的模块依赖解析,否则 Webpack 接收到的就都是转换过的 CommonJS 形式的模块,无法进行 tree-shaking 使用 PurifyCSS(不在维护) 或者 uncss 去除无用 CSS 代码 purgecss-webpack-plugin 和 mini-css-extract-plugin配合使用(建议)
-
Scope hoisting
构建后的代码会存在大量闭包,造成体积增大,运行代码时创建的函数作用域变多,内存开销变大。 Scope hoisting 将所有模块的代码按照引用顺序放在一个函数作用域里,然后适当的重命名一些变量以防止变量名冲突
必须是ES6的语法,因为有很多第三方库仍采用 CommonJS 语法,为了充分发挥 Scope hoisting 的作用,需要配置 mainFields 对第三方模块优先采用 jsnext:main 中指向的ES6模块化语法
- 动态Polyfill 建议采用 polyfill-service 只给用户返回需要的polyfill,社区维护。 (部分国内奇葩浏览器UA可能无法识别,但可以降级返回所需全部polyfill)
更多优化请参考官网-构建性能
13.你刚才也提到了代码分割,那代码分割的本质是什么?有什么意义呢?
代码分割的本质其实就是在源代码直接上线和打包成唯一脚本main.bundle.js这两种极端方案之间的一种更适合实际场景的中间状态。
「用可接受的服务器性能压力增加来换取更好的用户体验。」
源代码直接上线:虽然过程可控,但是http请求多,性能开销大。
打包成唯一脚本:一把梭完自己爽,服务器压力小,但是页面空白期长,用户体验不好。
(Easy peezy right)
14.是否写过Loader?简单描述一下编写loader的思路?
Loader 支持链式调用,所以开发上需要严格遵循“单一职责”,每个 Loader 只负责自己需要负责的事情。
Loader的API 可以去官网查阅
Loader 运行在 Node.js 中,我们可以调用任意 Node.js 自带的 API 或者安装第三方模块进行调用
Webpack 传给 Loader 的原内容都是 UTF-8 格式编码的字符串,当某些场景下 Loader 处理二进制文件时,需要通过 exports.raw = true 告诉 Webpack 该 Loader 是否需要二进制数据
尽可能的异步化 Loader,如果计算量很小,同步也可以
Loader 是无状态的,我们不应该在 Loader 中保留状态使用
loader-utils 和 schema-utils 为我们提供的实用工具加载本地 Loader 方法 Npm link
ResolveLoader
手写一个loader
一个简单的style-loader
// 作用:将css内容,通过style标签插入到页面中
// source为要处理的css源文件
function loader(source) {
let style = `
let style = document.createElement('style');
style.setAttribute("type", "text/css");
style.innerHTML = ${source};
document.head.appendChild(style)`;
return style;
}
module.exports = loader;
在vue项目中使用自定义loader
1)在vue.config.js引入该loader
const MyStyleLoader = require('./style-loader')
2)在configureWebpack中添加配置
module.exports = {
configureWebpack: {
module: {
rules: [
{
// 对main.css文件使用MyStyleLoader处理
test: /main.css/,
loader: MyStyleLoader
}
]
}
}
};
3)项目重新编译 main.css样式已加载到页面中
loader的组成部分
loader的本质是一个 node模块,该模块导出一个函数,函数接收source(源文件),返回处理后的source
loader执行顺序
相同优先级的loader链,执行顺序为:从右到左,从下到上
如use: ['loader1', 'loader2', 'loader3'],执行顺序为 loader3 → loader2 → loader1
常用的loader
15.是否写过Plugin?简单描述一下编写Plugin的思路?
webpack在运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在特定的阶段钩入想要添加的自定义功能。Webpack 的 Tapable 事件流机制保证了插件的有序性,使得整个系统扩展性良好。
Plugin的API 可以去官网查阅
- compiler 暴露了和 Webpack 整个生命周期相关的钩子
- -compilation 暴露了与模块和依赖有关的粒度更小的事件钩子
- 插件需要在其原型上绑定apply方法,才能访问 compiler 实例
- 传给每个插件的 compiler 和 compilation对象都是同一个引用,若在一个插件中修改了它们身上的属性,会影响后面的插件
- 找出合适的事件点去完成想要的功能
- emit 事件发生时,可以读取到最终输出的资源、代码块、模块及其依赖,并进行修改(emit 事件是修改 Webpack 输出资源的最后时机)
- watch-run 当依赖的文件发生变化时会触发
- 异步的事件需要在插件处理完任务时调用回调函数通知 Webpack 进入下一个流程,不然会卡住
手写一个Plugin插件
// 自定义一个名为MyPlugin插件,该插件在打包完成后,在控制台输出"打包已完成"
class MyPlugin {
// 原型上需要定义apply 的方法
apply(compiler) {
// 通过compiler获取webpack内部的钩子
compiler.hooks.done.tap("My Plugin", (compilation, cb) => {
console.log("打包已完成");
// 分为同步和异步的钩子,异步钩子必须执行对应的回调
cb();
});
}
}
module.exports = MyPlugin;
在vue项目中使用自定义插件
1)在vue.config.js引入该插件 const MyPlugin = require('./MyPlugin.js') 2)在configureWebpack的plugins列表中注册该插件
module.exports = {
configureWebpack: {
plugins: [new MyPlugin()]
}
};
3)执行项目的打包命令
当项目打包成功后,会在控制台输出:打包已完成
Plugin的组成部分 1)Plugin的本质是一个 node 模块,这个模块导出一个JavaScript 类 2)它的原型上需要定义一个apply 的方法 3)通过compiler获取webpack内部的钩子,获取webpack打包过程中的各个阶段 钩子分为同步和异步的钩子,异步钩子必须执行对应的回调 4)通过compilation操作webpack内部实例特定数据 5)功能完成后,执行webpack提供的cb回调
compiler上暴露的一些常用的钩子简介
常用的Plugin插件
Webpack5 模块联邦
webpack5 模块联邦(Module Federation) 使 JavaScript应用,得以从另一个 JavaScript应用中动态的加载代码,实现共享依赖,用于前端的微服务化
比如项目A和项目B,公用项目C组件,以往这种情况,可以将C组件发布到npm上,然后A和B再具体引入。 当C组件发生变化后,需要重新发布到npm上,A和B也需要重新下载安装
使用模块联邦后,可以在远程模块的Webpack配置中,将C组件模块暴露出去, 项目A和项目B就可以远程进行依赖引用。当C组件发生变化后,A和B无需重新引用 模块联邦利用webpack5内置的ModuleFederationPlugin插件,实现了项目中间相互引用的按需热插拔
Webpack ModuleFederationPlugin
重要参数说明 1)name 当前应用名称,需要全局唯一
2)remotes 可以将其他项目的 name 映射到当前项目中
3)exposes 表示导出的模块,只有在此申明的模块才可以作为远程依赖被使用
4)shared 是非常重要的参数,制定了这个参数,可以让远程加载的模块对应依赖,改为使用本地项目的依赖,如React 或 ReactDOM 配置示例
new ModuleFederationPlugin({
name: "app_1",
library: { type: "var", name: "app_1" },
filename: "remoteEntry.js",
remotes: {
app_02: 'app_02',
app_03: 'app_03',
},
exposes: {
antd: './src/antd',
button: './src/button',
},
shared: ['react', 'react-dom'],
}),
精读《Webpack5 新特性 - 模块联邦》(zhuanlan.zhihu.com/p/115403616) Webpack 5模块联邦引发微前端的革命?(juejin.cn/post/684490…)
16.聊一聊Babel原理吧
大多数JavaScript Parser遵循 estree 规范,Babel 最初基于 acorn 项目(轻量级现代 JavaScript 解析器)
Babel大概分为三大部分: -解析:将代码转换成 AST -词法分析:将代码(字符串)分割为token流,即语法单元成的数组语法分析:分析token流(上面生成的数组)并生成 AST -转换:访问 AST 的节点进行变换操作生产新的 AST
想了解如何一步一步实现一个编译器的同学可以移步 Babel 官网曾经推荐的开源项目 the-super-tiny-compiler(github.com/jamiebuilds…
四、 webpack的 热更新原理
Webpack HMR 原理
Hot Module Replacement(以下简称 HMR)是 webpack 发展至今引入的最令人兴奋的特性之一 , 当你对代码进行修改并保存后,webpack 将对代码重新打包,并将新的模块发送到浏览器端, 浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。 例如,在开发 Web 页面过程中,当你点击按钮,出现一个弹窗的时候,发现弹窗标题没有对齐, 这时候你修改 CSS 样式,然后保存,在浏览器没有刷新的前提下,标题样式发生了改变。 感觉就像在 Chrome 的开发者工具中直接修改元素样式一样。
本篇文章不是告诉你怎么使用 HMR,如果你对 HMR 依然感觉到陌生,建议先阅读官网 HMR 指南,上面有 HMR 最简单的用例,我会等着你回来的。
为什么需要 HMR
在 webpack HMR 功能之前,已经有很多 live reload 的工具或库,比如 live-server,这些库监控文件的变化, 然后通知浏览器端刷新页面,那么我们为什么还需要 HMR 呢?答案其实在上文中已经提及一些。
- live reload 工具并不能够保存应用的状态(states),当刷新页面后,应用之前状态丢失,还是上文中的例子,点击按钮出现弹窗,当浏览器刷新后,弹窗也随即消失,要恢复到之前状态,还需再次点击按钮。而 webapck HMR 则不会刷新浏览器,而是运行时对模块进行热替换,保证了应用状态不会丢失,提升了开发效率。
- 在古老的开发流程中,我们可能需要手动运行命令对代码进行打包,并且打包后再手动刷新浏览器页面,而这一系列重复的工作都可以通过 HMR 工作流来自动化完成,让更多的精力投入到业务中,而不是把时间浪费在重复的工作上。
-HMR 兼容市面上大多前端框架或库,比如 React Hot Loader,Vue-loader,能够监听 React 或者 Vue 组件的变化,实时将最新的组件更新到浏览器端。Elm Hot Loader 支持通过 webpack 对 Elm 语言代码进行转译并打包,当然它也实现了 HMR 功能。
HMR 的工作原理图解
初识 HMR 的时候觉得其很神奇,一直有一些疑问萦绕在脑海。
- webpack 可以将不同的模块打包成 bundle 文件或者几个 chunk 文件,但是当我通过 webpack HMR 进行开发的过程中,我并没有在我的 dist 目录中找到 webpack 打包好的文件,它们去哪呢?
- 通过查看 webpack-dev-server 的 package.json 文件,我们知道其依赖于 webpack-dev-middleware 库,那么 webpack-dev-middleware 在 HMR 过程中扮演什么角色?
- 使用 HMR 的过程中,通过 Chrome 开发者工具我知道浏览器是通过 websocket 和 webpack-dev-server 进行通信的,但是 websocket 的 message 中并没有发现新模块代码。打包后的新模块又是通过什么方式发送到浏览器端的呢?为什么新的模块不通过 websocket 随消息一起发送到浏览器端呢?
- 浏览器拿到最新的模块代码,HMR 又是怎么将老的模块替换成新的模块,在替换的过程中怎样处理模块之间的依赖关系?
- 当模块的热替换过程中,如果替换模块失败,有什么回退机制吗? 带着上面的问题,于是决定深入到 webpack 源码,寻找 HMR 底层的奥秘。
图一:HMR 工作流程图解
上图是webpack 配合 webpack-dev-server 进行应用开发的模块热更新流程图。
- 上图底部红色框内是服务端,而上面的橙色框是浏览器端。
- 绿色的方框是 webpack 代码控制的区域。蓝色方框是 webpack-dev-server 代码控制的区域,洋红色的方框是文件系统,文件修改后的变化就发生在这,而青色的方框是应用本身。
上图显示了我们修改代码到模块热更新完成的一个周期,通过深绿色的阿拉伯数字符号已经将 HMR 的整个过程标识了出来。
- 第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。
- 第二步是 webpack-dev-server 和 webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互,webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack,将代码打包到内存中。
- 第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念。
- 第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。
- webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpack,webpack/hot/dev-server 的工作就是根据 webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。
- HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。
- 而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。
- 最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。
运用 HMR 的简单例子
在上一个部分,通过一张 HMR 流程图,简要的说明了 HMR 进行模块热更新的过程。当然你可能感觉还是很迷糊,对上面出现的一些英文名词也可能比较陌生(上面这些英文名词代表着代码仓库或者仓库中的文件模块),没关系,在这一部分,我将通过一个最简单最纯粹的例子,通过分析 wepack及 webpack-dev-server 源码详细说明各个库在 HMR 过程中的具体职责。
在开始这个例子之前简单对这个仓库文件进行下说明,仓库中包含文件如下:
--hello.js
--index.js
--index.html
--package.json
--webpack.config.js
项目中包含两个 js 文件,项目入口文件是 index.js 文件,hello.js 文件是 index.js 文件的一个依赖,js 代码如你所见(点击上面例子链接可以查看源码),将在 body 元素中添加一个包含「hello world」的 div 元素。
webpack.config.js的配置如下:
const path = require('path')
const webpack = require('webpack')
module.exports = {
entry: './index.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, '/')
},
devServer: {
hot: true
}
}
值得一提的是,在上面的配置中并没有配置 HotModuleReplacementPlugin,原因在于当我们设置 devServer.hot 为 true 后,并且在package.json 文件中添加如下的 script 脚本:
"start": "webpack-dev-server --hot --open"
添加 —hot 配置项后,devServer 会告诉 webpack 自动引入 HotModuleReplacementPlugin 插件,而不用我们再手动引入了。
进入到仓库目录,npm install 安装依赖后,运行 npm start 就启动了 devServer 服务,访问 http://127.0.0.1:8080 就可以看到我们的页面了。
下面将进入到关键环节,在简单例子中,我将修改 hello.js 文件中的代码,在源码层面上来分析 HMR 的具体运行流程,当然我还是将按照上面图解来分析。修改代码如下:(以下所有代码块首行就是该文件的路径)
// hello.js
- const hello = () => 'hello world' // 将 hello world 字符串修改为 hello eleme
+ const hello = () => 'hello eleme'
页面中 hello world 文本随即变成 hello eleme。
第一步:webpack 对文件系统进行 watch 打包到内存中
webpack-dev-middleware 调用 webpack 的 api 对文件系统 watch,当 hello.js 文件发生改变后,webpack 重新对文件进行编译打包,然后保存到内存中。
// webpack-dev-middleware/lib/Shared.js
if(!options.lazy) {
var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback);
context.watching = watching;
}
你可能会疑问了,为什么 webpack 没有将文件直接打包到 output.path 目录下呢?文件又去了哪儿?原来 webpack 将 bundle.js 文件打包到了内存中,不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs,memory-fs 是 webpack-dev-middleware 的一个依赖库,webpack-dev-middleware 将 webpack 原本的 outputFileSystem 替换成了MemoryFileSystem 实例,这样代码就将输出到内存中。webpack-dev-middleware 中该部分源码如下:
// webpack-dev-middleware/lib/Shared.js
var isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;
if(isMemoryFs) {
fs = compiler.outputFileSystem;
} else {
fs = compiler.outputFileSystem = new MemoryFileSystem();
}
首先判断当前 fileSystem 是否已经是 MemoryFileSystem 的实例,如果不是,用 MemoryFileSystem 的实例替换 compiler 之前的 outputFileSystem。这样 bundle.js 文件代码就作为一个简单 javascript 对象保存在了内存中,当浏览器请求 bundle.js 文件时,devServer就直接去内存中找到上面保存的 javascript 对象返回给浏览器端。
第二步:devServer 通知浏览器端文件发生改变
在这一阶段,sockjs 是服务端和浏览器端之间的桥梁,在启动 devServer 的时候,sockjs 在服务端和浏览器端建立了一个 webSocket 长连接,以便将 webpack 编译和打包的各个阶段状态告知浏览器,最关键的步骤还是 webpack-dev-server 调用 webpack api 监听 compile的 done 事件,当compile 完成后,webpack-dev-server通过 _sendStatus 方法将编译打包后的新模块 hash 值发送到浏览器端。
// webpack-dev-server/lib/Server.js
compiler.plugin('done', (stats) => {
// stats.hash 是最新打包文件的 hash 值
this._sendStats(this.sockets, stats.toJson(clientStats));
this._stats = stats;
});
...
Server.prototype._sendStats = function (sockets, stats, force) {
if (!force && stats &&
(!stats.errors || stats.errors.length === 0) && stats.assets &&
stats.assets.every(asset => !asset.emitted)
) { return this.sockWrite(sockets, 'still-ok'); }
// 调用 sockWrite 方法将 hash 值通过 websocket 发送到浏览器端
this.sockWrite(sockets, 'hash', stats.hash);
if (stats.errors.length > 0) { this.sockWrite(sockets, 'errors', stats.errors); }
else if (stats.warnings.length > 0) { this.sockWrite(sockets, 'warnings', stats.warnings); } else { this.sockWrite(sockets, 'ok'); }
};
第三步:webpack-dev-server/client 接收到服务端消息做出响应
可能你又会有疑问,我并没有在业务代码里面添加接收 websocket 消息的代码,也没有在 webpack.config.js 中的 entry 属性中添加新的入口文件,那么 bundle.js 中接收 websocket 消息的代码从哪来的呢?原来是 webpack-dev-server 修改了webpack 配置中的 entry 属性,在里面添加了 webpack-dev-client 的代码,这样在最后的 bundle.js 文件中就会有接收 websocket 消息的代码了。
webpack-dev-server/client 当接收到 type 为 hash 消息后会将 hash 值暂存起来,当接收到 type 为 ok 的消息后对应用执行 reload 操作,如下图所示,hash 消息是在 ok 消息之前。
图二:websocket 接收 dev-server 通过 sockjs 发送到浏览器端的消息列表
在 reload 操作中,webpack-dev-server/client 会根据 hot 配置决定是刷新浏览器还是对代码进行热更新(HMR)。代码如下:
// webpack-dev-server/client/index.js
hash: function msgHash(hash) {
currentHash = hash;
},
ok: function msgOk() {
// ...
reloadApp();
},
// ...
function reloadApp() {
// ...
if (hot) {
log.info('[WDS] App hot update...');
const hotEmitter = require('webpack/hot/emitter');
hotEmitter.emit('webpackHotUpdate', currentHash);
// ...
} else {
log.info('[WDS] App updated. Reloading...');
self.location.reload();
}
}
如上面代码所示,首先将 hash 值暂存到 currentHash 变量,当接收到 ok 消息后,对 App 进行 reload。如果配置了模块热更新,就调用 webpack/hot/emitter 将最新 hash 值发送给 webpack,然后将控制权交给 webpack 客户端代码。如果没有配置模块热更新,就直接调用 location.reload 方法刷新页面。
第四步:webpack 接收到最新 hash 值验证并请求模块代码
在这一步,其实是 webpack 中三个模块(三个文件,后面英文名对应文件路径)之间配合的结果,首先是 webpack/hot/dev-server(以下简称 dev-server) 监听第三步 webpack-dev-server/client 发送的 webpackHotUpdate 消息,调用 webpack/lib/HotModuleReplacement.runtime(简称 HMR runtime)中的 check 方法,检测是否有新的更新,在 check 过程中会利用 webpack/lib/JsonpMainTemplate.runtime(简称 jsonp runtime)中的两个方法 hotDownloadUpdateChunk 和 hotDownloadManifest , 第二个方法是调用 AJAX 向服务端请求是否有更新的文件,如果有将发更新的文件列表返回浏览器端,而第一个方法是通过 jsonp 请求最新的模块代码,然后将代码返回给 HMR runtime,HMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。
图三:hotDownloadManifest方法获取更新文件列表
图四:hotDownloadUpdateChunk获取到更新的新模块代码
如上两图所示,值得注意的是,两次请求的都是使用上一次的 hash 值拼接的请求文件名,hotDownloadManifest 方法返回的是最新的 hash 值,hotDownloadUpdateChunk 方法返回的就是最新 hash 值对应的代码块。然后将新的代码块返回给 HMR runtime,进行模块热更新。
还记得 HMR 的工作原理图解 中的问题 3 吗?为什么更新模块的代码不直接在第三步通过 websocket 发送到浏览器端,而是通过 jsonp 来获取呢?我的理解是,功能块的解耦,各个模块各司其职,dev-server/client 只负责消息的传递而不负责新模块的获取,而这些工作应该有 HMR runtime 来完成,HMR runtime 才应该是获取新代码的地方。再就是因为不使用 webpack-dev-server 的前提,使用 webpack-hot-middleware 和 webpack 配合也可以完成模块热更新流程,在使用 webpack-hot-middleware 中有件有意思的事,它没有使用 websocket,而是使用的 EventSource。综上所述,HMR 的工作流中,不应该把新模块代码放在 websocket 消息中。
第五步:HotModuleReplacement.runtime 对模块进行热更新
这一步是整个模块热更新(HMR)的关键步骤,而且模块热更新都是发生在HMR runtime 中的 hotApply 方法中,这儿我不打算把 hotApply 方法整个源码贴出来了,因为这个方法包含 300 多行代码,我将只摘取关键代码片段。
// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
// ...
var idx;
var queue = outdatedModules.slice();
while(queue.length > 0) {
moduleId = queue.pop();
module = installedModules[moduleId];
// ...
// remove module from cache
delete installedModules[moduleId];
// when disposing there is no need to call dispose handler
delete outdatedDependencies[moduleId];
// remove "parents" references from all children
for(j = 0; j < module.children.length; j++) {
var child = installedModules[module.children[j]];
if(!child) continue;
idx = child.parents.indexOf(moduleId);
if(idx >= 0) {
child.parents.splice(idx, 1);
}
}
}
// ...
// insert new code
for(moduleId in appliedUpdate) {
if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
modules[moduleId] = appliedUpdate[moduleId];
}
}
// ...
}
从上面 hotApply 方法可以看出,模块热替换主要分三个阶段,第一个阶段是找出 outdatedModules 和 outdatedDependencies,这儿我没有贴这部分代码,有兴趣可以自己阅读源码。第二个阶段从缓存中删除过期的模块和依赖,如下:
- delete installedModules[moduleId];
- delete outdatedDependencies[moduleId];
第三个阶段是将新的模块添加到 modules 中,当下次调用 webpack_require (webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了。
模块热更新的错误处理,如果在热更新过程中出现错误,热更新将回退到刷新浏览器,这部分代码在 dev-server 代码中,简要代码如下:
module.hot.check(true).then(function(updatedModules) {
if(!updatedModules) {
return window.location.reload();
}
// ...
}).catch(function(err) {
var status = module.hot.status();
if(["abort", "fail"].indexOf(status) >= 0) {
window.location.reload();
}
});
dev-server 先验证是否有更新,没有代码更新的话,重载浏览器。如果在 hotApply 的过程中出现 abort 或者 fail 错误,也进行重载浏览器。
第六步:业务代码需要做些什么?
当用新的模块代码替换老的模块后,但是我们的业务代码并不能知道代码已经发生变化,也就是说,当 hello.js 文件修改后,我们需要在 index.js 文件中调用 HMR 的 accept 方法,添加模块更新后的处理函数,及时将 hello 方法的返回值插入到页面中。代码如下:
// index.js
if(module.hot) {
module.hot.accept('./hello.js', function() {
div.innerHTML = hello()
})
}
这样就是整个 HMR 的工作流程了。
webpack 的手写实现
简易流程
1、首先肯定是要先解析入口文件entry,将其转为AST(抽象语法树),使用@babel/parser 2、然后使用@babel/traverse去找出入口文件所有依赖模块 3、然后使用@babel/core+@babel/preset-env将入口文件的AST转为Code 4、将2中找到的入口文件的依赖模块,进行遍历递归,重复执行1,2,3 5。重写require函数,并与4中生成的递归关系图一起,输出到bundle中
代码实现
webpack具体实现原理是很复杂的,这里只是简单实现一下,让大家粗略了解一下,webpack是怎么运作的。在代码实现过程中,大家可以自己console.log一下,看看ast,dependcies,code这些具体长什么样,我这里就不展示了,自己去看会比较有成就感,嘿嘿!!
目录
config.js
这个文件中模拟webpack的配置
const path = require('path')
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'main.js'
}
}
入口文件
src/index.js是入口文件
// src/index
import { age } from './aa.js'
import { name } from './hh.js'
console.log(`${name}今年${age}岁了`)
// src/aa.js
export const age = 18
// src/hh.js
console.log('我来了')
export const name = '林三心'
1. 定义Compiler类
// index.js
class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {}
// 重写 require函数,输出bundle
generate() {}
}
2. 解析入口文件,获取 AST
我们这里使用@babel/parser,这是babel7的工具,来帮助我们分析内部的语法,包括 es6,返回一个 AST 抽象语法树
const fs = require('fs')
const parser = require('@babel/parser')
const options = require('./webpack.config')
const Parser = {
getAst: path => {
// 读取入口文件
const content = fs.readFileSync(path, 'utf-8')
// 将文件内容转为AST抽象语法树
return parser.parse(content, {
sourceType: 'module'
})
}
}
class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {
const ast = Parser.getAst(this.entry)
}
// 重写 require函数,输出bundle
generate() {}
}
new Compiler(options).run()
3. 找出所有依赖模块
Babel 提供了@babel/traverse(遍历)方法维护这 AST 树的整体状态,我们这里使用它来帮我们找出依赖模块。
const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const Parser = {
getAst: path => {
// 读取入口文件
const content = fs.readFileSync(path, 'utf-8')
// 将文件内容转为AST抽象语法树
return parser.parse(content, {
sourceType: 'module'
})
},
getDependecies: (ast, filename) => {
const dependecies = {}
// 遍历所有的 import 模块,存入dependecies
traverse(ast, {
// 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
ImportDeclaration({ node }) {
const dirname = path.dirname(filename)
// 保存依赖模块路径,之后生成依赖关系图需要用到
const filepath = './' + path.join(dirname, node.source.value)
dependecies[node.source.value] = filepath
}
})
return dependecies
}
}
class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {
const { getAst, getDependecies } = Parser
const ast = getAst(this.entry)
const dependecies = getDependecies(ast, this.entry)
}
// 重写 require函数,输出bundle
generate() {}
}
new Compiler(options).run()
4. AST 转换为 code
将 AST 语法树转换为浏览器可执行代码,我们这里使用@babel/core 和 @babel/preset-env。
const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')
const Parser = {
getAst: path => {
// 读取入口文件
const content = fs.readFileSync(path, 'utf-8')
// 将文件内容转为AST抽象语法树
return parser.parse(content, {
sourceType: 'module'
})
},
getDependecies: (ast, filename) => {
const dependecies = {}
// 遍历所有的 import 模块,存入dependecies
traverse(ast, {
// 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
ImportDeclaration({ node }) {
const dirname = path.dirname(filename)
// 保存依赖模块路径,之后生成依赖关系图需要用到
const filepath = './' + path.join(dirname, node.source.value)
dependecies[node.source.value] = filepath
}
})
return dependecies
},
getCode: ast => {
// AST转换为code
const { code } = transformFromAst(ast, null, {
presets: ['@babel/preset-env']
})
return code
}
}
class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {
const { getAst, getDependecies, getCode } = Parser
const ast = getAst(this.entry)
const dependecies = getDependecies(ast, this.entry)
const code = getCode(ast)
}
// 重写 require函数,输出bundle
generate() {}
}
new Compiler(options).run()
5. 递归解析所有依赖项,生成依赖关系图
const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')
const Parser = {
getAst: path => {
// 读取入口文件
const content = fs.readFileSync(path, 'utf-8')
// 将文件内容转为AST抽象语法树
return parser.parse(content, {
sourceType: 'module'
})
},
getDependecies: (ast, filename) => {
const dependecies = {}
// 遍历所有的 import 模块,存入dependecies
traverse(ast, {
// 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
ImportDeclaration({ node }) {
const dirname = path.dirname(filename)
// 保存依赖模块路径,之后生成依赖关系图需要用到
const filepath = './' + path.join(dirname, node.source.value)
dependecies[node.source.value] = filepath
}
})
return dependecies
},
getCode: ast => {
// AST转换为code
const { code } = transformFromAst(ast, null, {
presets: ['@babel/preset-env']
})
return code
}
}
class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {
// 解析入口文件
const info = this.build(this.entry)
this.modules.push(info)
this.modules.forEach(({ dependecies }) => {
// 判断有依赖对象,递归解析所有依赖项
if (dependecies) {
for (const dependency in dependecies) {
this.modules.push(this.build(dependecies[dependency]))
}
}
})
// 生成依赖关系图
const dependencyGraph = this.modules.reduce(
(graph, item) => ({
...graph,
// 使用文件路径作为每个模块的唯一标识符,保存对应模块的依赖对象和文件内容
[item.filename]: {
dependecies: item.dependecies,
code: item.code
}
}),
{}
)
}
build(filename) {
const { getAst, getDependecies, getCode } = Parser
const ast = getAst(filename)
const dependecies = getDependecies(ast, filename)
const code = getCode(ast)
return {
// 文件路径,可以作为每个模块的唯一标识符
filename,
// 依赖对象,保存着依赖模块路径
dependecies,
// 文件内容
code
}
}
// 重写 require函数,输出bundle
generate() {}
}
new Compiler(options).run()
6. 重写 require 函数,输出 bundle
const fs = require('fs')
const path = require('path')
const options = require('./webpack.config')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')
const Parser = {
getAst: path => {
// 读取入口文件
const content = fs.readFileSync(path, 'utf-8')
// 将文件内容转为AST抽象语法树
return parser.parse(content, {
sourceType: 'module'
})
},
getDependecies: (ast, filename) => {
const dependecies = {}
// 遍历所有的 import 模块,存入dependecies
traverse(ast, {
// 类型为 ImportDeclaration 的 AST 节点 (即为import 语句)
ImportDeclaration({ node }) {
const dirname = path.dirname(filename)
// 保存依赖模块路径,之后生成依赖关系图需要用到
const filepath = './' + path.join(dirname, node.source.value)
dependecies[node.source.value] = filepath
}
})
return dependecies
},
getCode: ast => {
// AST转换为code
const { code } = transformFromAst(ast, null, {
presets: ['@babel/preset-env']
})
return code
}
}
class Compiler {
constructor(options) {
// webpack 配置
const { entry, output } = options
// 入口
this.entry = entry
// 出口
this.output = output
// 模块
this.modules = []
}
// 构建启动
run() {
// 解析入口文件
const info = this.build(this.entry)
this.modules.push(info)
this.modules.forEach(({ dependecies }) => {
// 判断有依赖对象,递归解析所有依赖项
if (dependecies) {
for (const dependency in dependecies) {
this.modules.push(this.build(dependecies[dependency]))
}
}
})
// 生成依赖关系图
const dependencyGraph = this.modules.reduce(
(graph, item) => ({
...graph,
// 使用文件路径作为每个模块的唯一标识符,保存对应模块的依赖对象和文件内容
[item.filename]: {
dependecies: item.dependecies,
code: item.code
}
}),
{}
)
this.generate(dependencyGraph)
}
build(filename) {
const { getAst, getDependecies, getCode } = Parser
const ast = getAst(filename)
const dependecies = getDependecies(ast, filename)
const code = getCode(ast)
return {
// 文件路径,可以作为每个模块的唯一标识符
filename,
// 依赖对象,保存着依赖模块路径
dependecies,
// 文件内容
code
}
}
// 重写 require函数 (浏览器不能识别commonjs语法),输出bundle
generate(code) {
// 输出文件路径
const filePath = path.join(this.output.path, this.output.filename)
// 懵逼了吗? 没事,下一节我们捋一捋
const bundle = `(function(graph){
function require(module){
function localRequire(relativePath){
return require(graph[module].dependecies[relativePath])
}
var exports = {};
(function(require,exports,code){
eval(code)
})(localRequire,exports,graph[module].code);
return exports;
}
require('${this.entry}')
})(${JSON.stringify(code)})`
// 把文件内容写入到文件系统
fs.writeFileSync(filePath, bundle, 'utf-8')
}
}
new Compiler(options).run()
7. 看看main里的代码
实现了上面的代码,也就实现了把打包后的代码写到main.js文件里,咱们来看看那main.js文件里的代码吧:
(function(graph){
function require(module){
function localRequire(relativePath){
return require(graph[module].dependecies[relativePath])
}
var exports = {};
(function(require,exports,code){
eval(code)
})(localRequire,exports,graph[module].code);
return exports;
}
require('./src/index.js')
})({
"./src/index.js": {
"dependecies": {
"./aa.js": "./src\\aa.js",
"./hh.js": "./src\\hh.js"
},
"code": "\"use strict\";\n\nvar _aa = require(\"./aa.js\");\n\nvar _hh = require(\"./hh.js\");\n\nconsole.log(\"\".concat(_hh.name, \"\\u4ECA\\u5E74\").concat(_aa.age, \"\\u5C81\\u4E86\"));"
},
"./src\\aa.js": {
"dependecies": {},
"code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.age = void 0;\nvar age = 18;\nexports.age = age;"
},
"./src\\hh.js": {
"dependecies": {},
"code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.name = void 0;\nconsole.log('我来了');\nvar name = '林三心';\nexports.name = name;"
}
})
大家可以执行一下main.js的代码,输出结果是: 我来了 今年18岁了