Skip to main content

玩转webpack5

前言

读完本文将收获:

  • 能够根据项目续期灵活的进行webpack的配置。
  • 理解Three-shaking、Scope Hoistiong等原理。
  • 能够实现打包多页应用、组件库和基础库、SSR等。
  • 编写可维护的webpack配置,结合单元测试、冒烟测试等控制代码质量,使用Travis实现持续集成。
  • 了解如何优化构建速度和构建资源体积优化。
  • 通过源代码掌握webpack打包原理
  • 编写loader和plugin

初识webpack

配置文件:webpack.config.js

webpack配置组成:

Locale Dropdown

安装webpack:

yarn add webpack webpack-cli -D

通过命令行执行webpack:

./node_modules/.bin/webpack

通过npm script运行web:

原理:在node_modules/.bin目录中创建软链接

//package.json
"scripts":{
"build": "webpack --mode=development"
}
//命令行运行
yarn run build

webpack基础概念

核心概念-entry

指定webpack打包的入口

Locale Dropdown
依赖图(构建机制):webpack会将所有的资源都当成模块处理,从入口文件开始,递归地解析依赖模块,形成一颗依赖树,递归完成后,输出构建后的资源。

使用方法:

单入口:entry的值是字符串

webpack.config.js
module.exports = {
entry: './src/index.js'
}

多入口:entry的值是对象

webpack.config.js
module.exports = {
entry: {
main: './src/index.js'
}
}

核心概念-output

告诉webpack将构建后的资源放在磁盘的什么位置

使用方法:

单入口:

webpack.config.js
const path = require('path')
module.exports = {
entry: './src/index.js',
output: {
filename: 'main.build.js',
path: path.resolve(__dirname, '/dist')
}
}

多入口:

通过占位符确保文件名称的唯一

const path = require('path')
module.exports = {
entry: './src/index.js',
output: {
filename: '[name].build.js',
path: path.resolve(__dirname, '/dist')
}
}

核心概念-loader

webpack默认只支持js和json两种文件类型,通过loader可以配置其他文件类型的解析规则,从而让webpack将其他文件的类型加载到依赖图中。

loader本身是一个函数,接收源文件作为参数,返回转换的结果。

Locale Dropdown

使用方法

webpack.config.js
const path = require('path');

module.exports = {
module:{
rules: [
{
test: /\.txt/,
use:{
loader: 'raw-loader',
options: {}
}
}
]
}
}

核心概念-plugin

plugin用于bundle文件的优化、资源管理和环境变量注入,作用于整个构建过程。

Locale Dropdown

核心概念-mode

mode指定构建环境:production development none

Locale Dropdown

webpack基础用法

解析es6和jsx

解析es6

使用babel-loader,babel的配置文件是babel.config.json

babel.config.json
{
"presets": ["@babel/preset-env"],
"plugins": ["@babel/proposal-class-properties"]
}

presets是一系列plugin的集合,标识预设项,plugins特定某一项功能的集合。

安装依赖

yarn add @babel/core @babel/preset-env babel-loader -D

配置loader

webpack.config.js
module.exports = {
module:{
rules:[
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options:{}
}
}
]
}
}

解析jsx

安装依赖

yarn add react react-dom @babel/preset-react
babel.config.json
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}

使用babel-loader完成jsx的解析

解析css/less/sass

解析css

css-loader用于加载.css文件,将其转换成commonjs对象
style-loader将样式通过<style>标签注入到<head>

webpack.config.js
module.exports = {
module:{
rules:[
{
test: /.css$/
use:['style-loader','css-loader']
}
]
}
}

loader的解析顺序是从右到左,也就是先使用css-loader解析文件,然后把处理结果交给style-loader

解析less

安装依赖

yarn add less-loader -D
module.exports = {
module:{
rules:[
{
test: /\.(less|css)$/,
use:['style.loader', 'css-loader', 'less-loader']
}
]
}
}

解析图片和字体资源

使用file-loader

安装file-loader

yarn add file-loader -D
webpack.config.js
module.exports = {
module:{
rules:[
test: /\.(png|png|jpeg|fig|woff|woff2|ttf)$/,
use:['file-loader']
]
}
}

使用url-loader

可以设置小资源自动base64

webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
use: [{ loader: 'file-loader', options: { limit: 10240 } }],
},
],
},
};

使用webpack5的内置asset

在webpack5之前的版本中,常用的loader如下:

  • raw-loader将模板处理成字符串。
  • url-loader可以设置指定资源的大小,如果小于设置的大小则内联进bundle。
  • file-loader将文件发送到输出目录。

在webpack5中,asset modules替换了上述的loader,添加了4种内置类型:

  • asset/resource 之前由file-loader实现。
  • asset/inline 之前由url-loader实现
  • asset/source 导出资源的源码(字符串类型),之前由raw-loader实现。
  • asset 可以自动选择导出为data URL还是直接发送文件,之前由url-loader实现

解析图片和字体资源时,希望在限制的大小内将资源导出为data URL,而超过限制的资源直接将文件发送到输出目录,所以使用asset

webpack.dev.js
module.exports = {
module:{
rule:[
{
test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
type: 'asset',
parser:{
dataUrlCondition:{
maxSize: 10 * 1024,//10kb
}
}
}
]
}
}

监听文件热更新

watch

监听文件的改动,自动构建

package.json
{
"scripts":{
"watch": "webpack --watch"
}
}

文件监听原理解析:

  • 轮询判断文件的最后编辑时间是否变化
  • 某个文件发生了变化并不会立即告诉监听者,而是缓存起来,等aggregateTimeout到期再执行构建任务
webpack.config.js
module.exports = {
watch:true,//开启监听
watchOptions:{
//默认为空,忽略监听的文件夹,可以提升一定性能
ignored:/node_modules/,
//判断文件变化是通过不停地询问系统指定文件有没有变化实现的,每秒询问1次
poll:1000,
//监听到变化后的300ms后再去执行
aggregateTimeout: 300,
}
}

#### 热更新
webpack-deb-server
- 不刷新浏览器
- 不输出文件,而是放在内存中(构建速度有更大的优势)
- 使用HotModuleReplacementPlugin

安装依赖

yarn add webpack-dev-server -D


```javascript title=webpack.config.js
const path = require('path')
module.exports = {
devServer:{
contentBase: path.join(__dirname, 'dist'),
historyApiFallback:true,
hot:true,
open:true,
quiet:true,
port: 8083
}
}
package.json
{
"scripts":{
"server": "webpack serve"
}
}
注意

如果有多个入口,但只配置了一个HtmlWebpackPlugin,多个chunk都会被插入到生成的html中,此时更更新无法正常使用

热更新-WDM

webpack-dev-middleware:这是一个express的中间件,可以让webpack把文件交给服务器处理,比如接下来要使用的express,这给了我们更多的控制权。

安装依赖:

yarn add express webpack-dev-middleware -D
webpack.config.js
module.exports = {
output:{
publicPath: '/'
}
}
server.js
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');

const app = express();
const config = require('./webpack.config');
const compiler = webpack(config);

app.use(
webpackDevMiddleware(compiler, {
publicPath: config.output.publicPath,
})
);

app.listen(8081, function () {
console.log('server is running on port 8081');
});

启动服务node server.js

热更新原理

Locale Dropdown

概念:

  • webpack compiler:将js编译成bundle
  • bundle server:提供一个服务,使文件在浏览器中访问
  • HMR runtime:会被注入到bundle中,用于更新文件(使用websocket)和服务端通信
  • bundle.js构建产物
  1. 启动阶段:首先源代码通过webpack compiler被编译成bundle.js,然后提交到bundle server,浏览器就可以在服务下访问文件。
  2. 变化阶段:WDS每隔一段时间回去检测文件最后编辑的时间是否发生变化,编译后的代码交给HMR server,HRM server再通知HMR runtime变化的文件(以json格式传输),HMR runtime再去更新响应的模块代码。

文件指纹策略

chunkhash contenthash hash

文件指纹:打包后输出文件的文件名,通常用作版本管理,只更新修改的文件内容,未更新的文件指纹不会改变,仍然可以使用浏览器的缓存。

常见的文件指纹:

  • hash:和整个项目的结构相关,只有项目文件有改动,整个项目构建的hash值就会改变。
    • 打包阶段有compile和compilation,webpack启动时会创建一个compile对象,只要文件发生改变,compilation就会发生改变,对应的hash值就会发生变化。
    • A页面发生变化,B页面未发生变化,但是hash也会发生改变。
  • chunkhash:和webpack打包的chunk(入口)有关,不同的entry会生成不同的chunkhash
    • js一般采用chunkhash
  • contenthash:根据内容来定义hash,文件内容不变,contenthash则不变。
    • css一般使用contenthash

文件指纹设置

提醒

只能在生产环境下使用

js文件的文件指纹设置

webpack.config.js
module.exports = {
output:{
filename: '[name]_[chunkhash:8].bundle.js'
}
}

css的文件指纹设置:使用mini-css-extract-plugin将css提取成一个文件,然后设置filename,使用[contenthash]

提醒

如果使用style-loader,css会被注入到页面的head中,无法设置文件指纹

webpack.common.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
module:{
rules:[
{
test: /\.css$/,
use:[MiniCssExtractPlugin.loader, 'css-loader']
},
{
test: /\.less$/,
use:[MiniCssExtractPlugin.loader, 'css-loader', 'less-loader']
}
]
},
plugins:[
new MiniCssExtractPlugin({
filename: '[name]-[contenthash:8].css'
})
]
}

图片等静态资源文件指纹设置:file-loader的name使用hash
Locale Dropdown

webpack.config.js
module.exports = {
module:{
rules:[
{
test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
use:[
{
loader: 'file-loader',
options:{
name: '[name]-[hash:8].[ext]'
}
}
]
}
]
}
}

分离配置文件

安装webpack-merge

yarn add webpack-merge -D

分离成三个文件,并存放在config目录下:

  • webpack.common.js
  • webpack.dev.js
  • webpack.prod.js 配置如下:
webpack.common.js
module.exports = {
entry:{
main: './src/index.js',
worker: './src/worker.js'
},
output: {
filename: '[name]-[chunkhash:8].js',
path:path.resolve(__dirname, '../', 'dist'),
publicPath: '/'
},
module:{
rules:[
{
test: /\.js$/,
use:{
loader: 'babel-loader',
}
}
]
}
}
webpack.dev.js
const {merge}  = require('webpack-merge');
const path = require('path');

const common = require('./webpack.common.js');

module.export = merge(common, {
mode: 'development',
devServer:{
contentBase: path.join(__dirname, '../dist'),
historyApiFallback:true,
hot: true,
open: false,
quiret:true,
port: 8082,
},
module:{
rules:[
{
test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
use: [
{
loader: 'file-loader',
options: {
name: 'assets/[name].[ext]',
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.less$/,
use: ['style-loader', 'css-loader', 'less-loader'],
},
],
}
]
}
})
webpack.prod.js
const { merge } = require('webpack-merge');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const path = require('path');

const common = require('./webpack.common');

module.exports = merge(common, {
mode: 'production',
output: {
filename: '[name]_[chunkhash:8].bundle.js',
path: path.resolve(__dirname, '../', 'dist'),
// publicPath: '/',
},
module: {
rules: [
{
test: /\.(png|jpg|jpeg|gif|woff|woff2|eot|ttf)$/,
use: [
{
loader: 'file-loader',
options: {
name: 'assets/[name]_[hash:8].[ext]',
},
},
],
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
{
test: /\.less$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name]_[contenthash:8].css',
}),
],
});

HTML、CSS和JavaScript代码压缩

webpack默认开启对JavaScript代码的压缩

压缩CSS

使用css-minimizer-webpack-plugin,相比optimize-css-assets-webpack-plugin,在source map和assets中更精确,允许缓存和使用并行模式。

安装依赖:

yarn add css-minimizer-webpack-plugin -D

配置:

webpack.config.js
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
optimization: {
minimize: true,
// '...' 可以继承默认的压缩配置
minimizer: [new CssMinimizerPlugin(), '...'],
},
};

压缩html

安装依赖html-webpack-plugin,生产环境下会默认开启压缩html。会自动将构建的产物如bundle.js、mini.css等插入到生成的html中;如果有多个入口,只指定了一个HtmlWebpackPlugin,则都会插入到该html中。

yarn add html-webpack-plugin -D
webpack.config.js
module.exports = {
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, '../', 'public/index.html'),
}),
],
};

Webpack进阶用法

自动清理构建产物

  1. 使用npm scripts 清理构建目录rm -rf ./dist && webpack
  2. 在webpack5中,output配置提供了clean参数,它是一个boolean类型,如果为true,它会在构建前清除上一次的构建产物。

使用PostCSS的插件

autoprefixer自动补齐浏览器厂商前缀。

安装依赖:

yarn  add postcss-loader autoprefixer -D
webpack.common.js
module.exports = {
module: {
rules: [
{
test: /\.less$/,
use: [
'style-loader',
'css-loader',
'less-loader',
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [require('autoprefixer')],
},
},
},
],
},
],
},
};

package.json中配置autoprefixer

package.json
{
"browserslist": ["> 1%", "last 2 versions", "not ie <= 10"]
}

px自动转换成rem

使用px2rem-loader自动将px转换成rem,配合手淘的lib-flexible库,可以再渲染时计算根元素的font-size,这样就可以实现移动端的自适应。

安装依赖:

yarn add px2rem-loader -D
yarn add lib-flexible -S
webpack.prod.js
module.exports = {
module:{
rules:[
{
test: /\.less$/,
use:[
MiniCssExtractPlugin.loader,
'css-loader',
'less-loader',
{
loader: 'postcss-loader',
options:{
postcssOptions:{
plugin: [require('autoprefixer')]
}
}
},
{
loader:'px2rem-loader',
options:{
remUnit: 75,
remPrecision: 8
}
}
]
}
]
}
}

由于目前的配置还不支持静态资源内联,lib-flexible的使用在下一小节中介绍。

静态资源内联

资源内联的意义

代码层面:

  • 页面框架的初始化脚本
  • 上报相关打点(css加载完成、js加载完成)
  • css内联可以避免页面的闪动,再首屏加载时体验更好(跟随html一起回来)

请求层面:

减少HTTP网络请求数、浏览器同时发起请求数量限制

  • 小图片或者字体内联(url-loader | type: 'asset') 接下来实现meta.html和lib-flexible的资源内联,首先将public/index.html改为public.index.ejs,因为使用了html-webpack-plugin,默认使用的ejs模板引擎。

修改webpack配置:

webpack.common.js
module.exports = {
module: {
rules: [
{
resourceQuery: /raw/,
type: 'asset/source',
},
],
},
};

将资源内联进index.ejs

<!DOCTYPE html>
<html lang="en">
<head>
<%= require('./meta.html?raw') %>
<title>玩转 webpack</title>
<script>
<%= require('../node_modules/lib-flexible/flexible?raw') %>
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>

多页应用打包通用方案

安装依赖:

yarn add glob -D
// 设置多页打包,思路是使用 glob 解析出对应的入口文件,然后设置对应的 entry 和 HtmlWebpackPlugin
function setMpa() {
const entry = {};
const htmlWebpackPlugins = [];

const pagePaths = glob.sync(path.join(__dirname, '../src/mpa/**/index.js'));

pagePaths.forEach((pagePath) => {
const name = pagePath.match(/src\/mpa\/(.*)\/index\.js/)[1];

entry[name] = pagePath;
htmlWebpackPlugins.push(
new HtmlWebpackPlugin({
filename: `${name}.html`,
chunks: [name],
template: path.join(__dirname, '../', `src/mpa/${name}/index.html`),
})
);

return name;
});
}

使用source-map

关键字:

  • eval:使用eval包裹模块代码
  • source map:产生.map文件(和源文件分离)
  • cheap:不包含列信息
  • inline:将.map作为DataURL嵌入,不单独生成.map文件(会造成源文件特别大)
  • module:包含loader的source map

Locale Dropdown

注意点:

  • 出于对性能的考虑,在生产环境不推荐使用source-map,这样有最好的打包性能。
  • 开发环境开启,线上环境关闭
    • 如果想使用,可以使用分析出不同业务类型的source-map类型。
    • 线上排查问题的时候可以将source-map上传到错误监控系统。
    • 生产环境:devtool:source-map拥有高质量的source-map
    • 开发环境推荐使用:devtool:eval-cheap-module-source-map

提取页面公共资源

思路:将react、react-dom基础包通过cdn引入,不打入bundle。

  • 使用html-wepback-external-plugin分离基础库
  • 使用SplitChunkPlugin,webpack4之后已经内置

分离react、react-dom基础库

安装依赖:

yarn add html-webpack-externals-plugin -D
webpack-prod.js
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');

module.exports = {
plugins:[
new HtmlWebpackExternalsPlugin({
externals:[
{
module: 'react',
entry: 'https://now8.gtimg.com/now/lib/16.8.6/react.min.js',
global: 'React',
},
{
module: 'react-dom',
entry: 'https://now8.gtimg.com/now/lib/16.8.6/react-dom.min.js',
global: 'ReactDom',
}
]
})
]
}

entry使用cdn地址,然后在index.ejs中将react/react-dom的库引入:

<!DOCTYPE html>
<html lang="en">
<head>
<%= require('./meta.html?raw') %>
<title>玩转 webpack</title>
<script>
<%= require('../node_modules/lib-flexible/flexible?raw') %>
</script>
</head>
<body>
<div id="root"></div>

<script src="https://now8.gtimg.com/now/lib/16.8.6/react.min.js"></script>
<script src="https://now8.gtimg.com/now/lib/16.8.6/react-dom.min.js"></script>
</body>
</html>

chunk参数说明

  • async 对异步引入的库进行分离(默认)
  • inital对同步引入的库进行分离
  • all对所有引入的库进行分离(推荐)

例如:

webpack.prod.js
modulex.exports = {
optimization: {
splitChunks: {
chunk: 'async', // 只会分析异步导入的库,如果达到设置的条件,就会将其抽成单独的一个包,即分包
},
},
};

使用SplitChunksPlugin分离基础包

test:匹配出要分离的包,将react和react-dom分离为vendors包。

miniChunks:设置最小引用次数为2次

miniSize:分离的包体积的大小

webpack.prod.js
module.exports = {
optimization: {
cacheGroups: {
minSize: 0,
commons: {
test: /(react|react-dom)/,
name: 'vendors',
chunks: 'all',
minChunks: 2, // 有两个及以上的页面引用的库,将其抽离
},
},
},
};

然后在HtmlWebpackPlugin中将vendors chunk引入:

webpack.prod.js
module.exports = {
plugins:[
new HtmlWebpackPlugin({
chunks: ['vendors'],
template:path.join(__dirname, '../', 'plugin/index.ejs')
})
]
}

Tree Shaking的使用和原理分析

摇树优化:擦除无用的代码

  • 代码必须是es6的写法
  • 如果有副作用,tree shaking会失效

DCE(Elimination)

mode:production默认开始tree shaking

  • 代码不会被执行,不可达到
  • 代码执行的结果不会被用到
  • 代码只影响死变量(只写不读)
if(false){
//不可达到
}

function getSex(){
return 'male';
}

getSex()//代码的执行结果不会被用到

var name = 'shixiaobo'//只写不读

原理

利用es6模块的特点

  • 只能在模块顶层出现import
  • import的模块名只能是字符串常量
  • import binding是immutable的,即不可变

在编译阶段(静态分析)确定用到的代码,对没用到的代码进行标记,然后在uglify阶段删除被标记的代码。

Scope Hoisting原理分析

现象:构建后的代码存在大量闭包代码

问题:

  • bundle体积增大
  • 函数作用域变多,内存开销变大

原理:将所有模块的代码按照引用顺序放在一个函数作用域里,然后适当的重命名一些变量以防止变量名冲突。

实现:通过scope hoisting可以减少函数声明代码和内存开销

使用:mode为production默认开启

提醒

必须是es6语法

代码分割和动态import

代码分割之前介绍过,就是使用splitChunks将基础包和公共的函数分离

代码分割的意义:对于大的web应用来讲,将所有的代码都放在一个文件中显然是不够有效的,特别是当某些代码块是在某些特殊的时候才会被使用到。webpack有一个功能就是将你的代码分割成chunks,当需要的时候再进行加载,而不是一次性加载所有的。

使用场景:

  • 抽离相同的代码块到一个共享块
  • 脚本懒加载(按需加载)使得初始下载的代码更小

懒加载js脚本的方式:

  • Commonjs:require.ensure
  • ES6:动态import(需要babel转换)

原理:在加载的时候使用jsonp的方式,创建一个script标签,动态地引入脚本。

实现按需导入组件,当点击按钮的时候,将getComponent所包含的异步代码加载进来:

btn.addEEventListemer('click', function(){
getComponent().then((comp) => {
document.body.appendChild(comp)
})
})

async function getCommonent(){
const {default:_} = await import('lodash');
const ele = document.createElement('div');
ele.innerHtml = _.join(['hello','webpack','我是动态import生成的代码'])

return ele;
}

遇到的问题

在使用async的时候,运行时报错,配置.babelrc即可解决:

{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"esmodules": true
}
}
]
]
}
注意

存在问题会导致HMR(Hot Module Replacement)失效,原因是在package.json中加入了 browserslist的配置,在这配置postcss的autoprefixer时用到,配置了该字段HMR就不能正常使用

Hot Module Replacement(简称 HMR)是 webpack 发展至今引入的最令人兴奋的特性之一 ,当你对代码进行修改并保存后,webpack 将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。例如,在开发 Web 页面过程中,当你点击按钮,出现一个弹窗的时候,发现弹窗标题没有对齐,这时候你修改 CSS 样式,然后保存,在浏览器没有刷新的前提下,标题样式发生了改变。感觉就像在 Chrome 的开发者工具中直接修改元素样式一样。

{
"browserslist": ["> 1%", "last 2 versions", "not ie <= 10"]
}

解决方法是在webpack配置中添加taget配置

webpack.common.js
modulex.exports = {
target: 'web',
};

在webpack中使用eslint

行业里面优秀的eslint规范时实践:

  • Airbnb: eslint-config-airbnb、eslint-config-airbnb-base
  • alloyteam: eslint-config-alloy
  • ivweb: eslint-config-ivweb
  • umijs: fabric

指定团队的eslint规范:

  • 不重复造轮子,基于eslint:recommend配置并改进
  • 能够帮助发现代码错误的规则,全部开启
  • 帮助保持团队的代码风格统一,而不是限制开发体验(eslint通常检测可能存在的问题,而代码风格一般交给prettier进行统一规范)

eslint落地

  • 和CI/CD系统集成
  • 和webpack集成(eslint不通过构建不成功)

安装eslint及airbnb的规范实践:

yarn add eslint eslint-plugin-import eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y eslint-config-airbnb -D

安装eslint-loader:

yarn add eslint-loader babel-eslint -D

方案一:webpack和CI/CD集成

在CI环节中的build之前增加lint,lint通过之后才允许执行后面的流程。

Locale Dropdown

本地开发阶段增加precommit钩子

安装husky

yarn add husky -D

增加npm scrip,通过lint-staged增量检查修改的文件:

{
"scripts": {
"precommit": "lint-staged"
},
"lint-staged": {
"*.{js}": ["eslint --fix", "git add"]
}
}

为避免绕过git precommit钩子,在CI步骤需要增加lint步骤

方案二:webpack与eslint集成

使用eslint-loader,构建时检查js规范,适合新项目,默认会检查所有的js文件。

webpack.common.js
module.exports = {
rules: [
{
test: /\.jsx?$/,
use: ['babel-loader', 'eslint-loader'],
},
],
};
.eslintrc
module.exports = {
parser: 'babel-eslint',
extends: 'airbnb',
env: {
browser: true,
node: true,
},
rules: {
'comma-dangle': 'off',
'no-console': 'off',
'jsx-quotes': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
},
};

webpack打包组件和基础库

rollup更适合打包组件和库,它更加纯粹

webpack除了可以用来打包应用,也可以用来打包js库。

实现一个大整数加法库的打包:

  • 需要打包压缩版和非压缩版
  • 支持AMD/CJS/ESM模块引入

将库暴露出去:

  • library:指定库的全局变量
  • libraryTargetL支持库的引入方式 源代码:
src/index.js
// 大整数加法
/**
* 从个位开始加,注意进位
*
* @export
* @param {*} a string
* @param {*} b string
*/
export default function add(a, b) {
let i = a.length - 1;
let j = b.length - 1;
let res = '';
let carry = 0; // 进位

while (i >= 0 || j >= 0) {
let x = 0;
let y = 0;
let sum = 0;

if (i >= 0) {
x = +a[i];
i -= 1;
}

if (j >= 0) {
y = +b[j];
j -= 1;
}

sum = x + y + carry;

if (sum >= 10) {
carry = 1;
sum -= 10;
} else {
carry = 0;
}

res = sum + res;
}

if (carry) {
res = carry + res;
}

return res;
}

webpack配置:

webpack.config.js
const path = require('path');
const TerserWebpackPlugin = require()
module.exports = {
mode: 'production',
entry:{
'big-number': path.join(__dirname, './src/index.js'),
'big-number.min': path.join(__dirname, './src/index.js')
},
output:{
filename: '[name].js',
path:path.resolve(__dirname, './dist'),
library: 'bigNumber',
libraryTarget: 'umd',
clean:true
},
optimization:{
//minimize:false,
//webpack5默认使用terser-webpack-plugin 插件压缩代码,此处使用它自定义
minimizer:[
new TerserWebpackPlugin({
new TerserWebpackPlugin({
test: /\.min\.js/i
})
})
]
}
}

打包完成后,编写组件库的入口文件,在package.json中指定,如 main.js,这里根据不同的环境变量指定使用不同的版本。

if(process.env.NODE_ENV === 'production'){
module.exports = require('./dist/big-number.min.js');
} else {
module.exports = require('./dist/big-number.js');
}

到这里大整数加法库开发已经完成了,接下来将它发布到npm仓库,假设已经执行npm login登陆npm账户,然后执行npm publish发布。

注意:如果使用的是淘宝镜像,需要切换回官方镜像。

发布成功后可以安装并使用它:

import bigNumber from 'yw-big-number';

console.log(bigNumber('999', '1')); // 1000

webpack实现SSR打包

为什么需要服务端渲染,它有什么优势?

客户端渲染在页面加载时,需要先获取并解析url,在解析html的过程中,如果遇到外部的js、css需要等加载之后才会继续解析,当然浏览器也会对资源进行预请求,而且非关键性资源不会阻塞html解析,在解析过程中页面处于白屏,解析完成后页面开始渲染,此时可能只有loading,js脚本正在请求接口并等待返回,拿到数据后才开始展示真正的内容,如果有图片资源,此时图片还是不可兼得,需要等待加载完成,所有资源准备就绪页面才可以进行交互。

可以发现页面在加载的时候经历了一系列步骤,才真正展现在用户面前,而服务端渲染的优势是,静态资源和数据时随着html一起获取到的,浏览器拿到html后直接解析,等js脚本执行完成后可完全交互,它主要有以下几点优势。

  • 减少白屏时间
    • 所有模板等静态资源都存储在服务端
    • 内网机器拉取数据快
    • 一个html返回所有数据
  • 对seo友好

总结:服务端渲染的核心是减少请求,组装html的工作在服务端完成,客户端直接渲染。

Locale Dropdown

Locale Dropdown

代码实现思路:

  1. 配置webpack.ssr.js,将客户端代码以umd规范导出
  2. 服务端代码使用express,将导出的客户端代码引入,然后注册路由,开启监听服务端口。

问题

  1. 执行node server/index.js时报self is not defined,由于服务端没有self全局变量,在执行的最顶端加入如下判断。
if(typeof self === 'undefined'){
global.self = {}
}
  1. 打包出的组件需要兼容写法,如服务端模块使用的是commonjs写法,客户端编写组件的时候也需要遵循commonjs规范。
  2. 将fetch或ajax请求方法改成isomorphic-fetch或axios
  3. 样式问题(nodejs无法解析css)
    1. 服务端打包通过ignore-loader忽略css的解析
    2. 将style-loader替换成isomorphic-style-loader(css module的写法不能直接引入)
    使用打包出来的浏览器端html为模板,设置占位符,动态地插入组件:
sever/index.js
const template = fs.readFileSync(
path.join(__dirname, '../dist/index.html')
, 'utf-8'
);

const useTemplate = (html) => template.replace('<!--HTML_PLACEHOLDER -->', html);

app.get('/app', (req, res) => {
const html = useTemplate(renderToString(App));

res.status(200).send(html)
}))

实现后会有白屏问题。

  1. 白屏问题如何处理?

服务端获取数据后,替换占位符。

优化构建时命令的显示日志

使用 friendly-errors-webpack-plugin 提供友好的构建信息提示。

构建异常和中断处理

主动捕获并处理构建错误:

  • compiler 在每次构建结束后会触发 done 这个 hook
  • process.exit 主动处理构建报错
module.exports = {
plugins: [
function () {
// this 指向 compiler
this.hooks.done.tap('done', (stats) => {
if (
stats.compilation.errors &&
stats.compilation.errors.length &&
process.argv.indexOf('--watch') === -1
) {
process.exit(1); // 抛出异常,终端就知道构建失败了
}
});
},
],
};

编写可维护的webpack构建配置

这一章节会根据之前的配置,编写一个可维护的 webpack 构建配置库,它遵循完整库的编写规范,包含开发规范、冒烟测试、单元测试、持续集成等。

构建配置抽离成npm包的意义

  • 通用性
    • 开发人员无需关注构建配置
    • 统一团队构建脚本
  • 可维护性
    • 构建配置合理的拆分
    • README文档、ChangeLog文档等
  • 质量
    • 冒烟测试、单元测试、覆盖测试
    • 集成测试

构建配置管理的可选方案

  • 通过多个配置文件管理不同环境的构建,webpack --config参数进行控制
    • 基础配置 webpack.common.js
    • 开发环境 webpack.dev.js
    • 生产环境 webpack.prod.js
    • SSR环境 webpack.ssr.js
  • 将构建配置设计成一个库统一管理
    • 规范:git commit 日志、README、Eslint规范
    • 质量:冒烟测试、单元测试、测试覆盖率和CI

使用webpack-merge合并管理。

功能模块设计和目录结构

Locale Dropdown

Locale Dropdown

使用eslint规范开发

由于是基础库的开发,只需要用到airbnb的eslint-config-airbnb-base版本。

安装依赖:

yarn add eslint babel-eslint eslint-config-airbnb-base -D

配置.eslintrc.js

module.exports = {
parser: 'babel-eslint',
extends: 'airbnb-base',
env: {
browser: true,
node: true,
},
rules: {
'comma-dangle': 'off',
'no-console': 'off',
'jsx-quotes': 'off',
'global-require': 'off',
'import/extensions': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
'no-restricted-globals': 'off',
},
};

将eslint检查加入scripts:

package.json
{
"scripts":{
"eslint": "eslint config --fix"
}
}

冒烟测试介绍和实际运用

冒烟测试是指对提交测试的代码再进行深入的测试之前的测试,这种测试的目的是暴露导致软件需重新发布的基础功能失效等严重问题。

冒烟测试执行:

  • 判断构建是否成功
  • 构建产物是否有内容
    • 是否有js、css等静态资源文件
    • 是否有html文件

安装需要的依赖:

yarn add rimraf webpack mocha assert glob-all

编写判断构建是否成功的测试用例:

test/smoke/index.js
const rimraf = require('rimraf');
const webpack = require('webpack');
const Mocha = require('mocha');
const path = require('path');

const mocha = new Mocha({
timeout: '10000',
});

// 删除旧的构建产物
// process.chdir();
// 改变工作目录

rimraf('../../dist', () => {
const prodConfig = require('../../config/webpack.prod');

webpack(prodConfig, (err, stats) => {
if (err) {
console.error(err);
process.exit(2);
}
console.log(
stats.toString({
colors: true,
modules: false,
children: false,
})
);

console.log('webpack build succeeded, begin to test.');

mocha.addFile(path.join(__dirname, './html-test.js'));
mocha.addFile(path.join(__dirname, './js-css-test.js'));

mocha.run();
});
});

需要注意的是路径是否正确,最终运行成功的结果如下:

Locale Dropdown

单元测试和测试覆盖率

单纯的测试框架:Mocha/AVA,需要安装额外的断言库:chai/should.js/expect/better-assert集成测试框架,开箱即用:Jasmine/Jest(React)

使用Mocha+Chan主要测试api:

  • describe 描述需要测试的文件
  • it一个文件中多个测试用例
  • expect断言

执行测试命令:

mocha add.test.js

单元测试

编写单元测试用例:

mocha默认会查找test/index.js

test/index.js
describe('webpack config test.', () => {
require('./unit/webpack-base.test');
});
const assert = require('assert');

describe('webpack.common.js test case.', () => {
const baseConfig = require('../../config/webpack.common');

it('entry', () => {
// 测试入口的文件路径是否正确
assert.strictEqual(
baseConfig.entry.main,
'/Users/yewei/Project/source-code-realize/play-webpack/lib/yw-build-webpack/src/index.jsx'
);
});
});

测试通过后如下:

Locale Dropdown

测试覆盖率

安装istanbul.

安装好之后修改test:unit命令:

{
"script":{
"test:unit": 'istanbul cover mocha'
}
}
注意

测试的目标代码中不能有es6+语法的代码,否则无法收集到测试覆盖率数据

执行yarn test:unit的结果如下。并且会在根目录下生成coverage的目录,用来存放代码覆盖率的结果:

Locale Dropdown

集成测试和Travis CI

持续集成的作用:

  • 快速发现错误
  • 防止分支大幅偏离猪肝

核心思路:代码集成到主干前,必须通过自动化测试,只要有一个错误,就不能集成。

Github最流行的CI:

Locale Dropdown

接入Travis CI:

  1. Travis点击登录
  2. 激活需要持续集成的项目
  3. 项目根目录新增.travis.yml

在github创建新项目,然后执行一下步骤将xb-build-webpack下的代码上传到该仓库:

# 进入xb-build-webpack,初始化 git
git init
git add .
git commit -m "xxx"
# 将远程仓库添加进来
git remote add origin https://github.com/passerByBo/xb-build-webpack.git
# 推送代码
git push -u origin master

添加.travis.yml:

language: node_js # 语言

sudo: false

node_js:
- 12.16.1

cache: # 保存缓存
- npm
- yarn

before_install: # 安装依赖
- npm install -g yarn
- yarn

scripts: # 执行测试
- yarn test

当代码提交时,会自动触发构建任务。

发布构建包到npm社区

发布npm

添加用户:npm adduser

升级版本:

  • 升级补丁版本号:npm version patch
  • 升级小版本号:npm version minor
  • 升级大版本号:npm version major

发布版本:npm publish

进入要发布的项目根目录,然后登陆npm并执行发布操作

npm login
npm publish

当要发布补丁时,执行以下步骤:

git add .
git commit -m "doc: udpate reamde"
npm version patch
git push -u origin master
npm publish

良好的git commit 规范优势:

  • 加快code review的流程
  • 根据git commit的元数据生成changelog
  • 方便后续维护者维护

angular git commit规范

Locale Dropdown

Locale Dropdown

本地开发阶段增加precommit钩子

添加依赖:

yarn add conventional-changelog-cli @commitlint/{config-conventional,cli}

changelog生成

按照规范commit之后,可以很方便地生成changelog

Locale Dropdown

语义化版本

开源项目版本信息:

  • 通常由三位组成x.y.z
  • 版本严格递增:16.2.0->16.3.0->16.3.1
  • 发布重要版本时,可以发布 alpha(内部), beta(外部小范围), rc(公测) 等先行版本 16.2.0-rc.123

遵循semver规范:

  • 避免出现循环依赖
  • 减少也起来冲突

规范格式

  • 主版本号:做了不兼容的API修改
  • 次版本号:新增向下兼容的功能
  • 修订版本号:向下兼容的问题修正

Webpack构建速度和体积优化策略

初级分析:使用stats

在webpack5中可以得到构建各个阶段的处理过程、耗费时间以及缓存使用的情况。

webpack.prod.js
module.exports = {
stats: 'verbose',//输出所有信息normal:标准信息;errors-only:只有错误的时候才会输出信息
}

在根目录下生成stats.json,包含了构建的信息。

package.json
{
"scripts":{
"analyze:stats": "webpack --config config/webpack.prod.js --json stats.json"
}
}

速度分析:使用speed-measure-webpack-plugin

这个插件在webpack5中已经不可使用,可以使用内置的stats替代

作用:

  • 分析整个打包总耗时
  • 每个插件和loader的耗时情况
yarn add speed-measure-webpack-plugin -D
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');

const smp = new SpeedMeasurePlugin();

const webpackConfig = smp.wrap({
plugins: [new MyPlugin(), new MyOtherPlugin()],
});

体积分析:webpack-bundle-analyzer

分析:

  • 依赖的大小
  • 业务组件代码的大小
yarn add webpack-bundle-analyzer
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
plugins: [new BundleAnalyzerPlugin()],
};

执行完成后会自动打开 http://127.0.0.1:8888/,如下图所示:

Locale Dropdown

使用更高版本的webpack和nodejs

webpack4和nodejs高版本较之前所做的优化

  • V8带来的优化:for of替代forEach;Map/Set替代Object;includes替代indexOf
  • md5->md4算法
  • 使用字符号串方法替代正则表达式

webpack5的主要优化及特性:

  • 持久化缓存,可以设置基于内存的临时缓存和基于文件系统的持久化缓存
    • 一旦开启,会忽略其他插件的缓存设置
  • Tree Shaking
    • 增加了对乔涛模块的导出跟踪功能,能够找到那些嵌套在最内层而未被使用的模块属性
    • 增加了对cjs模块代码的静态分析功能
  • webpack5构建输出的日志要丰富完整的多,通过这些日志能够很好地反应构建各阶段的处理过程、耗费时间、以及缓存使用的情况。
  • 新增了改变微前端构建运行流程的Module Federation
  • 对产物代码优化处理Runtime Modules
  • 优化了处理模块的工作队列
  • 再生命周期中增加了stage选项

多进程/多实例构建

可选方案:

  • thread-loader
  • parallel-webpack
  • 一些插件内置的parallel参数(如 TerserWebpackPlugin, CssMinimizerWebpackPlugin, HtmlMinimizerWebpackPlugin)
  • HapyPack(作者已经不再维护)

thread-loader

原理:每次webpack解析一个模块,thread-loader会将它及它的依赖分配给worker线程中。

module.exports = {
module: {
rules: [
{
test: /\.jsx?$/,
use: [
{
loader: 'thread-loader',
options: {
workder: 3,
},
},
'babel-loader',
'eslint-loader',
],
},
],
},
};

并行压缩

可以配置并行压缩的插件

  • terser-webpack-plugin
  • css-minimizer-webpack-plugin
  • html-minimizer-webpack-plugin
module.exports = {
optimization: {
minimize: true,
minimizer: [
new CssMinimizerPlugin(),
new TerserPlugin({ parallel: 2 }),
'...',
],
},
};

进一步分包:预编译资源模块(DLL)

回顾之前的思路:

使用SplitChunkPlugin将react、react-dom等基础库分离成单独的chunk。

缺点是每次打包时任然会对基础包进行解析编译,更好的方式是进行预编译资源模块,通过DLLPlugin,DLLReferencePlugin实现。

预编译资源模块

思路:将 react, react-dom, redux, react-redux 基础包和业务基础包打包成一个文件,可以提供给其它项目使用。

方法:使用 DLLPlugin 进行分包,DllReferencePlugin 对 manifest.json 引用。

首先定义一个config/webpack.dll.js, 用于将基础库进行分离:

config/webpack.dll.js
const path = require('path');
const webpack = require('webpack');

module.exports = {
mode: 'production',
entry:{
library: ['react', 'react-dom'],
},
output:{
filename: '[name]_[chunkhash].dll.js',
path:path.resolve(__dirname, '../build/library'),
library: '[name]'
},
plugins:[
new webpack.DLLPlugin({
context: __dirname,
name: '[name]_[hash]',
path: path.join(__dirname, '../build/library/[name].json')
})
]
}

然后在webpack.common.js中将预编译资源模块引入:

webpack.common.js
module.exports = {
plugins:[
new webpack.DllReferencePlugin({
context: __dirname,
mainfest:require('../build/library/library.json'),
scope: 'xyz',
scopeType: 'commonjs2'
})
]
}

充分利用缓存提升二次构建速度

目的:提升二次构建速度

缓存思路:

  • webpack5内置的基于内存的临时缓存和基于文件系统的持久化缓存
  • cache-loader
  • terser-webpack-plugin开启缓存

基于文件系统的持久化缓存,在node_module下会生成.cache目录:

module.exports = {
cache:{
type: 'filesystem',//memory基于内存的临时缓存
//cacheDirectory:path.resolve(__dirname, '.temp_cache')
}
}

缩小构建目标时间

目的:减少需要解析的模块

  • babel-loader 不解析node_modules

减少文件搜索范围

  • resolve.modules减少模块搜素层级,指定当前node_modules。
  • resolve.mainFields指定入口文件
  • resolve.extension对于没有指定后缀的引用,指定解析的文件后缀算法。
  • 合理利用alias,引用三方依赖的生成版本
module.exports = {
resolve: {
alias: {
react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
},
modules: [path.resolve(__dirname, './node_modules')],
extensions: ['.js', '.jsx', '.json'],
mainFields: ['main'],
},
};

Tree Shaking擦除无用css

前面已经介绍了使用Tree Shaking擦除无用的js,这在webpack5中已经内置了,这一小节介绍如何擦除无用的css。

  • PurifyCSS:遍历代码,识别已经用到的css class
  • uncss:html需要通过jsdom加载,所有的样式通过PostCSS解析,通过document.querySelector识别html文件中不存在的选择器。

在webpack中使用PurufyCSS:

  • 使用purgecss-webpack-plugin
  • 和mini-css-extract-plugin配合使用
const PurgeCSSPlugin = require('purgecss-webpack-plugin');
const glob = require('glob');

const PATHS = { src: path.resolve('../src') };

module.exports = {
plugins: [
new PurgeCSSPlugin({
paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
}),
],
};

图片压缩

yarn add image-minimizer-webpack-plugin

无损压缩推荐使用下面依赖:

yarn add imagemin-gifsicle imagemin-jpegtran imagemin-optipng imagemin-svgo
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');

module.exports = {
plugins: [
new ImageMinimizerPlugin({
minimizerOptions: {
plugins: [['jpegtran', { progressive: true }]],
},
}),
],
};

使用动态polyfill服务

用于体积优化。

polyfill-service: 只给用户返回需要的 polyfill,国内部分浏览器可能无法识别 User Agent,可以采用优雅降级的方案。

polyfill-service 原理:识别 User Agent,下发不同的 polyfill,做到按需加载需要的 polyfill。

Locale Dropdown

polyfill.io

体积优化策略总结

  • Scope Hoisting
  • Tree Shaking
  • 公共资源抽离
    • SplitChunks
    • 预编译资源模块
  • 图片压缩
  • 动态Polyfill

通过源代码掌握webpack打包原理

webpack启动过程分析

webpack命令:

  • 通过 npm scripts运行 webpack
    • 开发环境:npm run build:dev
    • 生产环境:npm run build:prod
  • 通过webpack直接运行
    • webpack entry.js bundle.js 这个过程发生了什么?

执行上述命令后,npm会让命令进入node_modules/.bin目录下查找webpack.js,如果存在就执行,不存在就抛出错误。

.bin目录下的文件实际上是软链接,webpack.js真正指向的文件是node_modules/webpack/bin/webpack.js

分析webpack的入口文件:webpack.js

认识几个关键函数:

  • runCommand->运行命令
  • isInstalled->判断某个包是否安装成功
  • runCli->执行webpack-cli

执行流程:

  1. 判断webpack-cli是否存在
  2. 不存在则抛出异常,存在则直接到第六步
  3. 判断当前使用的包管理工具是yarn/npm/pnpm中的哪一种
  4. 使用包管理工具自动安装webpack-cli(runCommand -> yarn webpack-cli -D)
  5. 安装成功后执行runCli
  6. 执行runCli(执行webpack-cli/bin/cli.js

总结:webpack最终会找到webpack-cli这个包,并且执行webpack-cli

webpack-cli源码阅读

webpack-cli所做的事情:

  • 引入Commander.js,对命令进行定制。
  • 分析命令行参数,对各个参数进行转换,组成编译配置项。
  • 引用webpack,根据生成的配置项进行编译和构建

命令行工具包Commander.js介绍

完成nodejs命令行解决方案

  • 提供命令和分组参数
  • 动态生成help帮助信息
  • 监听分发执行函数

具体的执行流程

  1. 接着上面一小节的执行webpack-cli,也就是执行node_modules/webpack-cli/bin/cli.js
  2. 检查是否有webpack,有的话执行 runCli,它主要做的是实例化webpack-cli,node_modules/webpack-cli/webpack-cli.js,然后调用它的run方法。
  3. 使用Commander.js定制命令行参数。
  4. 解析命令行的参数,如果是内置参数,则调用createCompiler,主要做的是想将得到的参数传递给webpack,生成实例化对象compiler,调用compiler的run方法。

总结:webpack-cli对命令行参数进行转换,最终生成配置项参数options,将options传递给webpack对象,执行构建流程(最后会判断是否有监听函数,如果有就执行监听的动作)

Tapable插件架构与Hooks设计

webpack的本质:webpack可以理解成是一种基于事件流(发布订阅模式)的编程范例,一系列的插件运行。

Compiler和Compilation都是继承Tapable,那么Tapable是什么呢?

Tapable是一个类似于nodejs的EventEmitter库,主要控制钩子函数的发布与订阅,控制着webpack的插件系统。

  • Tapable暴露很多Hook类,为插件提供挂载的钩子

Tapable hooks类型:

Locale Dropdown
Tapable提供了同步和异步绑定钩子的方法,并且都有绑定时间和执行事件对应的方法。 Locale Dropdown

实现一个Car类,其中有个hooks对象,包含了加速、刹车、计算路径等hooks,对其分别注册时间和触发事件。

car.js
console.time('cost');

class Car{
constructor(){
this.hooks = {
acclerate: new SyncHook(['newspped']),//加速
brake: new SyncHook(),//刹车
calculateRoutes: new AsyncSeriesHook(['source','target','routes'])//计算路径
}
}

const myCar = new Car();

//绑定同步钩子
myCar.hooks.brake.tap('WarningLmapPlugin', () => {
console.log('WarningLmapPlugin')
})

//绑定同步钩子并传参
myCar.hooks.acclerate.tap("LoggerPlugin", (newSpeed) => {
console.log(`accelaerating spped to ${newSpeed}`)
})

//绑定一个异步的promise
myCar.hooks.calculateRoute.tapPromise('calculateRoutes tabPromise',
(params) =>{
return new Prmise((resolve, reject) => {
setTimeout(() =>{
console.log(`tapPromise to ${params}`);
resolve();
},1000)
})
}
)
}

//触发同步钩子
myCar.hooks.break.call();
//触发同步钩子并传入参数
myCar.hooks.accleratr.call(120)
//触发异步钩子
myCar.hooks.calulateRoutes.promise(['Async', 'hook', 'demo']).then(
() => {
console.timeEnd('cost')
}
).catch((err) => {
console.error(err);
console.timeEnd('cost')
})

Tapable是如何和webpack进行关联起来的

上面说到webpack-cli.js中执行createCompiler的时候,将转换后得到的options传递给webpack方法然后生成compiler对象,接下来说webpack.js中做的事情,如下贴出createCommpiler的 源码贴出便于理解:

webpack-cli.js
const createCompiler = (rawOptions) => {
const options = getNormalizedWebpackOptions(rawOptions);
applyWebpackOptionsBaseDefaults(options);
const compiler = new Compiler(options.context); // 实例化 compiler
compiler.options = options;
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging,
}).apply(compiler);
if (Array.isArray(options.plugins)) {
// 遍历并调用插件
for (const plugin of options.plugins) {
if (typeof plugin === 'function') {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}
applyWebpackOptionsDefaults(options);
// 触发监听的 hooks
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
new WebpackOptionsApply().process(options, compiler); // 注入内部插件
compiler.hooks.initialize.call();
return compiler; // 将实例返回
};

通过上述代码可以得到两个插件的结论:

  • 插件就是监听compiler对象上的hooks
  • 执行插件需要调用插件的apply方法,并将compiler对象作为参数传入。

webpack.js:

  • webpack中也有createCompiler方法,它会先实例化Compiler对象,生成compiler实例。
  • Compiler中核心在于挂载了许多继承自Tapable的hooks,其他地方可以使用compiler实例注册和触发事件,在webpack构建的不同阶段,会触发不同的hook。
  • options.plugins即配置的一系列插件,在createCompiler中,生成compiler实例后,如果options.plugin是数组类型,则会遍历调用它,并传入compiler,形如plugin.apply(compiler), 内部绑定compiler上的一些hooks事件。

简易模拟Compiler和插件的实现:

Compiler.js
//Compiler对象挂载了一些hooks
const {SyncHook, AsyncSeriesHook} = require('tapable');

module.exports = class Compiler{
constructor(){
this.hooks = {
acclerate: new SyncHook(['newspped']),
brake: new SyncHook(),
calculateRoutes: new AsyncSeriesHook(['source', 'target', 'routesList']),
}
}
run(){
this.acclerate(100);
this.brake();
this.calculateRoutes('Async', 'hook', 'demo');
}

run() {
this.acclerate(100);
this.brake();
this.calculateRoutes('Async', 'hook', 'demo');
}

acclerate(speed) {
this.hooks.acclerate.call(speed);
}

brake() {
this.hooks.brake.call();
}

calculateRoutes(...params) {
this.hooks.calculateRoutes.promise(...params).then(
() => {},
(err) => {
console.log(err);
}
);
}
}

webpack插件,根据传入的compiler对象,选择性监听一些hook:

my-plugin.js
const Compiler = require('./compiler');

class MyPlugin{
apply(compiler){
//绑定事件
compiler.hooks.acclerate.tap('打印速度', (newSpeed) =>
console.log(`speed acclerating to ${newSpeed}`)
)
compiler.hooks.brake.tap('刹车警告', () => console.log('正在刹车'))
compiler.hooks.calculateRoutes.tapPromise(
'计算路径',
(source, target, routesList) =>
new Promise((resolve, reject) => {
setTimeout(() => {
console.log(`计算路径: ${source} ${target} ${routesList}`);
resolve();
}, 1000);
})
);
}
}
// 模拟插件执行
const compiler = new Compiler();
const myPlugin = new MyPlugin();

// 模拟 webpack.config.js 的 plugins 配置
const options = { plugins: [myPlugin] };

//插件执行
for (const plugin of options.plugins) {
if (typeof plugin === 'function') {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler); // 绑定事件
}
}

compiler.run(); // 触发事件

Webpack流程篇:准备阶段

webpack 的打包流程可以分为三个阶段:

  1. 准备:初始化参数,为对应的参数注入插件
  2. 模块编译和打包
  3. 模块优化、代码生成和输出到磁盘

webpack的编译按照下面钩子的调用顺序进行:

Locale Dropdown

  1. entry-option:初始化option
  2. run:开始编译
  3. make:从entry开始递归地分析依赖,
  4. before-resolve:对模块位置进行解析。
  5. build-module:开始构建某个模块。
  6. normal-module-loader:将loader加载完成的module进行编译,生成AST树。
  7. program:遍历AST,当遇到require等一些调用表达式,收集依赖。
  8. seal:所以后依赖build完成,开始优化。
  9. emit:输出到dist目录。

entry-option

首先第一步,在目录下查询entryOption字符串位置:

grep "\.entryOption\." -rn ./node_modules/webpack

得到如下结果:

Locale Dropdown

可以看到,在 EntryOptionPluginDllPlugin 中有绑定该 hook,在 WebpackOptionsApply 中触发该 hook。

WebpackOptionsApply

  1. 将所有的配置 options 参数转换成 webpack 内部插件 如:

    • ptions.externals 对应 ExternalsPlugin。
    • options.output.clean 对应 CleanPlugin。
    • options.experiments.syncWebAssembly 对应 WebAssemblyModulesPlugin。
  2. 绑定 entryOption hook 并触发它。

    最后准备阶段以一张较为完整的流程图结束:

    Locale Dropdown

webpack流程篇:模块构建和chunk生成阶段

相关hook

流程相关:

  • (before-)run
  • (before-/after-)compile
  • make
  • (after-)emit
  • done

监听相关:

  • watch-run
  • watch-close

Compilation

Compiler 调用 Compilation 生命周期方法:

  • addEntry -> addModuleChain
  • finish(上报模块错误)
  • seal

ModuleFactory

Compiler 会创建两个工厂函数,分别是 NormalModuleFactory 和 ContextModuleFactory,均继承 ModuleFactory。

  • NormalModuleFactory: 普通模块名导入。
  • ContextModuleFactory: 以路径形式导入的模块。

Locale Dropdown

NormalModule

Build-构建阶段:

  • 使用 loader-runner 运行 loaders 解析模块生成 js 代码。
  • 通过 Parser 解析(内部使用 acron) 解析依赖
  • ParserPugins 添加依赖 所有依赖解析完成后,make 阶段就结束了。

具体流程

  • compiler.compile: hooks.compile -> hooks.make(开始构建) -> compilation.addEntry(添加入口文件)。 查看绑定和触发hooks.make的地方

    Locale Dropdown

  • 模块构建完成后,触发 hook.finishMake -> compilation.finish -> compilation.seal -> hooks.afterCompile,最终得到经过 loaders(loader-runner) 解析生成的代码。

  • 以 NormalModule 的构建为例,说说它的过程:

    1. 构建,通过 loader 解析:build -> doBuild -> runLoaders。
    2. 分析及添加依赖:parser.parse(将 loader 编译过得代码使用 acron 解析并添加依赖)。
    3. 将最终得到的结果存储到 compilation.modules。
    4. hook.finishMake 完成构建。

chunk生成算法

  1. webpack 先将 entry 中对应的 module 都生成一个新的 chunk。
  2. 遍历 module 的依赖列表,将依赖的 module 也加入到 chunk 中。
  3. 如果一个依赖 module 是动态引入的模块,那么就会根据这个 module 创建一个新的 chunk,继续遍历依赖。
  4. 重复上面的过程,知道生成所有的 chunk。

webpack流程篇:文件生成

完成构建后,执行 hooks.seal、hooks.optimize 对构建结果进行优化,优化完成后出触发 hooks.emit,将构建结果输出到磁盘上。

动手编写一个简易的webpack

模块化:增强代码可读性和维护性

闭包+立即执行函数->angularjs的依赖注入->nodejs的commonjs->AMD->es2015的esmodule。

  • 传统的网页开发转变成Web App开发
  • 代码复杂度在逐步增高
  • 分离js文件/模块,便于后续代码的维护。
  • 部署时希望把代码优化成多个 HTTP 请求。

常见的几种模块化方式:

// esmodule
// 静态分析,不能动态,只能在文件最顶层导入。
import * as largeNumber from 'large-number';

largeNumber('99');
// commonjs
// nodejs 默认遵循的规范,支持动态导入
const largeNumber = require('large-number');

largeNumber('99');
// AMD
// 借鉴 commonjs,浏览器中经常使用
require(['large-number'], function (largeNumber) {
largeNumber.add('99');
});

AST基础知识

AST即抽象语法树,是源代码的抽象语法结构的树状表现形式。

Locale Dropdown

AST 的使用场景:

webpackd的模块机制

  • 打包出来的是一个 IIFE(匿名闭包函数)。
  • modules 是一个数组,每一项是一个模块初始化函数。
  • __webpack_require__ 用来加载模块,返回 module.exports
  • 通过 WEBPACK_REQUIRE_METHOD(0) 启动程序。

一个简易的 webpack 需要支持一下特性:

  • 支持将 es6 转换成 es5。
    • 通过 parse 生成 AST。
    • 通过 transformFromAstSync 将 AST 重新生成 es5 源码。
  • 可以分析模块之间的依赖关系。
    • 通过 traverseImportDeclaration 方法获取依赖属性。
  • 生成的 js 文件可以在浏览器中运行。

编写步骤

  • 编写 minipack.config.js
  • 编写 parser.js,实现将 es6 的代码转换成 AST,然后分析依赖,将 AST 转换成 es5 代码。
  • 编写 compiler.js,实现开始构建、构建模块、将结果输出到磁盘功能。

实现的源码,点击查看

编写loader和插件

loader的链式调用和执行顺序

一个简单的loader代码结构

定义:loader是一个导出为函数的js模块:

module.exports = function(source){
return source;
}

多loader时的执行顺序

  • 串行执行:前一个的执行结果会传递给后一个loader
  • 按从右往左的顺序执行
module.exports = {
module:{
rules: [
test: /\.less/,
use:['style-loader', 'css-loader', 'less-loader']
]
}
}

函数组合的两种情况

  • Unix中的pipeline
  • Compose
const compose = (f,g) => (...args) => f(g(...args));

验证

实现的源码,点击查看 执行yarn build 能查看loader日志打印的顺序

使用loader-runner高效进行loader的调试

上一小节验证 loader 执行顺序的时候,需要先安装 webpack webpack-cli,然后编写 webpack.config.js,将编写的 loader 引入 对应的配置文件中,这个过程比较繁琐,可以使用更高效的 loader-runner,进行 loader 的开发和调试。它允许在不安装 webpack的情况下运行 loader。

laoder-runner的作用:

  • 作为 webpack 的依赖,webpack 中使用它执行 loader。
  • 进行 loader 的开发和调试。

编写raw-loader,使用loader-runner运行

实现raw-loader

raw-loader.js
module.exports = function rawLoader(source) {
const str = JSON.stringify(source)
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029'); // 模板字符串存在安全问题,这里对模板字符串进行转义处理

return `export default ${str}`; // 将文件内容转换成模块
};

在loader-runner中调用raw-loader:

run-loader.js
const path = require('path');
const { runLoaders } = require('loader-runner');
const fs = require('fs');

runLoaders(
{
resource: path.join(__dirname, './src/info.txt'),
loaders: [path.join(__dirname, './loaders/raw-loader.js')],
context: { minimize: true }, // 接收的上下文
readResource: fs.readFile.bind(fs), // 读取文件的方式
},
(err, result) => {
if (err) {
console.log(err);
} else {
console.log(result);
}
}
);

执行node-run-loader,得到如下结果:

Locale Dropdown

更复杂的loader开发场景

loader的参数获取

通过loader-utils的getOptions方法获取。

修改run-loader.js,改成可以传递参数的形式:

run-loader.js
runLoaders(
{
resource: path.join(__dirname, './src/info.txt'),
loaders: [
{
loader: path.join(__dirname, './loaders/raw-loader.js'),
options: { name: 'shixiaobo' }, // 传递了 name 参数
},
],
context: { minimize: true },
readResource: fs.readFile.bind(fs),
},
(err, result) => {
if (err) {
console.log(err);
} else {
console.log(result);
}
}
);

raw-loader.js中使用该参数:

raw-loader.js
const loaderUtils = require('loader-utils');

module.exports = function rawLoader(source) {
const { name } = loaderUtils.getOptions(this); // 引入参数

console.log(name, 'name');

const str = JSON.stringify(source)
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');

return `export default ${str}`;
};

loader的异常处理

同步:

- throw
- this.callback
- 返回处理结果
- 抛出异常
- 回传更多的值
// throw
throw new Error('error');
// this.callback
this.callback(err: Error | null, content: string | Buffer, sourceMap?: SourceMap, meta?: any);

异步处理

在开发 loader 过程中,可能需要处理异步,如异步读取文件,等读取完成后将内容返回。

可以通过 this.async() 实现,修改 raw-loader 中的例子,增加异步文件的读取:

// ...
module.exports = function rawLoader(source) {
const callback = this.async();

fs.readFile(
path.join(__dirname, '../src/async.txt'),
'utf-8',
(err, data) => {
callback(null, data);
}
);
};

Locale Dropdown

loader中使用缓存

  • webpack 中默认开启 loader 缓存
    • 使用 this.cacheable(false) 关闭默认缓存
  • 缓存条件:loader的结果在相同的输入下有确定的输出
    • 有依赖的loader无法使用缓存

关闭缓存

raw-loader
module.exports = function () {
this.cacheable(false);
};

Locale Dropdown

在loader中输出文件到磁盘中

使用 this.emitFile 进行文件写入

loader-a.js 中输出文件,最终会在 dist 下生成 demo.txt

module.exports = function (source) {
console.log('loader a is running');

this.emitFile('demo.txt', '在 loader 中输出文件。');

return source;
};

或使用loader-utils,在本例中,会在dist下生成index.js:


const loaderUtils = require('loader-utils');

module.exports = function (source) {
console.log('loader a is running');

// 匹配出符合规则的文件名称,如这里会匹配到 index.js
const filename = loaderUtils.interpolateName(this, '[name].[ext]', source);

this.emitFile(filename, source);

return source;
};

实战开发一个自动合成雪碧图的loader

雪碧图的应用可以减少http的请求次数,有效提升页面加载的速度

实现将多张图片合成一张图片,支持如下效果:

Locale Dropdown

使用spritesmith

实现sprite-loader:

const path = require('path');
const Spritesmith = require('spritesmith');
const fs = require('fs');

module.exports = function (source) {
const callback = this.async();
const regex = /url\((\S*)\?__sprite\S*\)/g;

let imgs = source.match(regex); // [ "url('./images/girl.jpg?__sprite", "url('./images/glasses.jpg?__sprite" ]

imgs = imgs.map((img) => {
const imgPath = img.match(/\/(images\/\S*)\?/)[1];

return path.join(__dirname, '../src', imgPath);
});

Spritesmith.run({ src: imgs }, function handleResult(err, result) {
// 将生成的图片写入 dist/sprites.jpg
// 在 webpack 中,应该使用 emitFile 来写入文件
fs.writeFileSync(
path.join(process.cwd(), 'dist/sprites.jpg'),
result.image
);

const code = source.replace(regex, (match) => "url('./sprites.jpg')");

// 输出 index.css
fs.writeFileSync(path.join(process.cwd(), 'dist/index.css'), code);

callback(null, code);
});

return source;
};

loader-runner 中使用 sprite-loader:

run-sprites-loader.js
const path = require('path');
const { runLoaders } = require('loader-runner');
const fs = require('fs');

runLoaders(
{
resource: path.join(__dirname, './src/index.css'),
loaders: [
{
loader: path.join(__dirname, './loaders/sprites-loader.js'),
},
],
context: { minimize: true },
readResource: fs.readFile.bind(fs),
},
(err, result) => {
if (err) {
console.log(err);
} else {
console.log(result);
}
}
);

执行node run-sprites-loader.js,结果如下:

index.css
body {
background: url('./sprites.jpg');
}

.banner {
background: url('./sprites.jpg');
}

插件的基本结构介绍

loader负责处理资源,即将各种资源当成模块来处理,而插件可以介入webpack构建的生命周期中

  • 插件没有像loader那样独立的运行环境(loader-runner)。

  • 只能在webpack中运行,依赖于webpack

    Locale Dropdown

案例:

my-plugin.js
module.exports = class MyPlugin {
constructor(options) {
console.log(options);
}

apply(compiler) {
console.log('执行 my-plugin');
}
};
webpack.config.js
const path = require('path');
const MyPlugin = require('./plugins/my-plugin'); // 自定义插件

module.exports = {
entry: path.join(__dirname, './src/index.js'),
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './dist'),
},
plugins: [new MyPlugin({ name: 'shixiaobo' })],
};

更复杂的插件开发场景

插件的错误处理

  • throw new Error('error');。
  • 通过compilation对象的warnings和errors接收。
    • compilation.warnings.push('warning');
    • compilation.errors.push('error');

通过Compilation进行文件写入

文件的生成在emit阶段,可以监听emit,然后获取到compilation对象。

Compilation 上的 assets 可以用于文件写入

  • 可以将 zip 资源包设置到 compilation.assets 对象上。

文件写入需要使用 webpack-sources 库,示例:

const { RawSource } = require('webpack-sources');

module.exports = class DemoPlugin {
constructor(options) {
this.options = options;
}

apply(compiler) {
const { name } = this.options;

compiler.compilation.hooks.emit.tap('emit', (compilation, cb) => {
compilation.assets[name] = new RawSource('demo');

cb();
});
}
};

插件扩展:编写插件的插件

插件自身也可以通过暴露 hooks 的方式进行自身扩展,以 html-webpack-plugin 为例,它支持一下 hook:

  • html-webpack-plugin-after-chunks(sync)
  • html-webpack-plugin-before-html-generation(async)
  • html-webpack-plugin-after-asset-tags(async)
  • html-webpack-plugin-after-html-processing(async)
  • html-webpack-plugin-after-emit(async)

实战开发一个压缩构建资源的zip包的插件

要求:

  • 生成的 zip 包文件名称可以通过插件传入。
  • 需要使用 compiler 对象上的 hooks 进行资源的生成。

准备知识

nodejs 里使用 jszip 创建和编辑 zip 包。

Compiler上负责文件生成的hook

emit, 一个异步的 hook(AsyncSeriesHook

emit 生成文件阶段,读取的是 compilation.assets 对象的值。

  • 将 zip 资源包设置到 compilation.assets 对象上。

实现 zip-plugin.js:

const JSZip = require('jszip');
const path = require('path');
const { Compilation, sources } = require('webpack');

module.exports = class ZipPlugin {
constructor(options) {
this.options = options;
}

apply(compiler) {
const { filename } = this.options;

// 监听 compilation 的 hooks
compiler.hooks.compilation.tap('ZipPlugin', (compilation) => {
// 监听 processAssets hook,即在处理构建资源的过程中,可以拿到静态资源
compilation.hooks.processAssets.tapPromise(
{
name: 'ZipPlugin',
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
(assets) => {
return new Promise((resolve, reject) => {
const zip = new JSZip();

// 创建压缩包
const folder = zip.folder(filename);

Object.entries(assets).forEach(([fname, source]) => {
// 将打包好的资源文件添加到压缩包中
folder.file(fname, source.source());
});

zip.generateAsync({ type: 'nodebuffer' }).then(function (content) {
// /Users/yewei/Project/source-code-realize/play-webpack/source/mini-plugin/dist/ywhoo.zip
const outputPath = path.join(
compilation.options.output.path,
`${filename}.zip`
);

// 相对路径 ywhoo.zip
const relativeOutputPath = path.relative(
compilation.options.output.path,
outputPath
);

// 将 buffer 转船 raw source
// 将 zip 包添加到 compilation 的构建资源中
compilation.emitAsset(
relativeOutputPath,
new sources.RawSource(content)
);

resolve();
});
}).catch((e) => {
console.log(e, 'e');
});
}
);
});
}
};

使用插件

webpack.config.js
const path = require('path');
const ZipPlugin = require('./plugins/zip-plugin');

module.exports = {
entry: path.join(__dirname, './src/index.js'),
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './dist'),
},
plugins: [new ZipPlugin({ filename: 'shixiaobo' })],
};

运行 yarn build,会在 dist 目录下生成shixiaobo.zip

Locale Dropdown

总结

从webapck配置入门到编写实用性的webpack配置分离,后续学了webpack的配置优化,然后通过对webpack的原理了解更进一步了解webpack5,最后通过对webpackloader和plugin的学习可以对webpack功能进行扩展。