webpack核心概念、性能优化和问题分析

工具终将被更好的工具替代,但是解决问题的思路会永远传承下去。随着前端进入深水区,更加内卷,出现了许多webpack的替代品。在学习和了解这些替代品之前,让我们重温下webpack的一些核心概念。

webpack核心概念是什么?

webpack会将所有的文件理解成「模块module」。这些模块有不同的文件格式,比如js、css、text。

webpack会从entry定义的文件出发,根据「模块引用」依次找到它依赖的各个子模块,然后将它们都打包输出到output

webpack原生支持js,如果是ts、css、text等其他格式的「模块」,就需要第三方loader来解析了。

如果想在构建的特定时刻,进行进一步控制,就需要plugin来挂入“钩子”,帮忙实现了。

loader和plugin的区别?

loader是用来解析不同的模块。比如css-loader,可以支持 require('./main.css') 读取css文件;比如style-loader可以将css文件合并到javascript中。

plugin是用来扩展webpack功能,实现特定时机的钩子。比如 mini-css-extract-plugin 可以放在css-loader后,在解析css之后,将其输出为单独的文件。

代码案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const path = require('path');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
// JavaScript 执行入口文件,webpack会按照模块自动解析
entry: './main.js',
output: {
// 把所有依赖的模块合并输出到一个 bundle.js 文件
filename: 'bundle.js',
// 输出文件都放到 dist 目录下
path: path.resolve(__dirname, './dist'),
},
module: {
rules: [
{
// 用正则去匹配要用该 loader 转换的 CSS 文件
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
]
}
],
},
plugins: [
new MiniCssExtractPlugin({
// 从 .js 文件中提取出来的 .css 文件的名称
filename: `[name]_[contenthash:8].css`,
}),
]
};

编译原理层面优化

TreeShaking

主要从编译原理角度解决问题。会把引入的,但是没用过的var、func、obj等shake(抖)掉,从而减少产物体积。

坑点:

  • 由于它是基于静态导入分析代码依赖关系,也就是import和export可以,但是require以及import()不可以。
  • 对于class,它可以shake整个class,但是没法shake其上具体的函数。webpack的shake不支持检查class的fucntion是否被调用。

根据第一点,在配合babel使用的时候,babel 的 env插件的"modules": false 这样设置。防止babel转换引用模块的代码,出现webpack无法理解的问题。

Prepack

Prepack 由 Facebook 开源,它采用较为激进的方法:在保持运行结果一致的情况下,改变源代码的运行逻辑,输出性能更高的 JavaScript 代码。

原理:实际上 Prepack 就是一个部分求值器,编译代码时提前将计算结果放到编译后的代码中,而不是在代码运行时才去求值。

流程:

  • 借助AST解析源码关系
  • 实现了一个 JavaScript 解释器,用于执行源码。借助这个解释器 Prepack 才能掌握源码具体是如何执行的,并把执行过程中的结果返回到输出中。

比如源码中是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React, {Component} from 'react';
import {renderToString} from 'react-dom/server';

function hello(name) {
return 'hello ' + name;
}

class Button extends Component {
render() {
return hello(this.props.name);
}
}
const tags = [ 1,3 ]
if (tags.include(1)) { // true
console.log(renderToString(<Button name='webpack'/>));
} else {
// ...
}

转化后u就是:

1
console.log("hello webpack");

接入方式:

1
2
3
4
5
6
7
const PrepackWebpackPlugin = require('prepack-webpack-plugin').default;

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

Scope Hoisting

作用域提升。将打包结果,都提升到全局下的一个闭包作用域。

优势:

  • 闭包少,性能更高
  • 产物体积更小

使用:

1
2
3
4
5
6
7
8
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');

module.exports = {
plugins: [
// 开启 Scope Hoisting
new ModuleConcatenationPlugin(),
],
};

webpack如何缩小文件搜索范围?

webpack文件搜索原理

Webpack 启动后会从配置的 Entry 出发,解析出文件中的导入语句,再递归的解析

在遇到导入语句时 Webpack 会做两件事情:

  1. 根据导入语句去寻找对应的要导入的文件。例如 require('react') 导入语句对应的文件是 ./node_modules/react/react.jsrequire('./util') 对应的文件是 ./util.js
  2. 根据找到的要导入文件的后缀,使用配置中的 Loader 去处理文件。例如使用 ES6 开发的 JavaScript 文件需要使用 babel-loader 去处理。

优化手段

  • 针对loader:可以使用include和exclude,来缩小和排除范围
  • 针对require引入的依赖:默认是像nodejs那样,一直向上寻找,直到 /node_modules 。可以修改 resolve.modules 属性,将其只在工作目录
  • 减少对require依赖的不同的endpoint的依赖:通过修改 resolve.mainFields 指定只搜索依赖库的pakcage.json中指定的入口。

比如package.json中存在 main、browser、jsnext:main 3种入口,默认情况下,webpack只会按照顺序找到main的入口,但是可以通过制定 resolve.mainFields 配置,来修改webpack查找的顺序。

  • 可以声明只解析特定后缀的依赖
  • 针对递归解析:可以通过 module.noParse 跳过指定模块的递归解析。比如对于 react.min.js 没必要解析。

如何利用多核CPU(并行)?

HappyPack

原理:在递归解析时,webpack的loader做文件解析和转换,这个非常耗时。HappyPack其实就是把这些loader进行调度,分配给多个进程。

代码实现:它还支持提前创建pool,实现复用和限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const HappyPack = require('happypack');
// 构造出共享进程池,进程池中包含5个子进程
const happyThreadPool = HappyPack.ThreadPool({ size: 5 });

module.exports = {
plugins: [
new HappyPack({
// 用唯一的标识符 id 来代表当前的 HappyPack 是用来处理一类特定的文件
id: 'babel',
// 如何处理 .js 文件,用法和 Loader 配置中一样
loaders: ['babel-loader?cacheDirectory'],
// 使用共享进程池中的子进程去处理任务
threadPool: happyThreadPool,
}),
new HappyPack({
id: 'css',
// 如何处理 .css 文件,用法和 Loader 配置中一样
loaders: ['css-loader'],
// 使用共享进程池中的子进程去处理任务
threadPool: happyThreadPool,
}),
new ExtractTextPlugin({
filename: `[name].css`,
}),
],
};

ParallelUglifyPlugin

原理:webpack中要实现压缩代码的逻辑。这个就是uglifyjs实现的。社区里为了利用多核cpu,也出了一个plugin插件,支持并行调用压缩代码。

如何代码复用?

使用 CommonsChunkPlugin

提取公共代码。pass,太常用。

DLL Plugin

原理:类似windows的 .dll 文件。是一种动态链接库。在一个动态链接库中可以包含给其他模块调用的函数和数据。包含大量复用模块的动态链接库只需要编译一次,在之后的构建过程中被动态链接库包含的模块将不会再重新编译,而是直接使用动态链接库中的代码。

要给 Web 项目构建接入动态链接库的思想,需要完成以下事情:

  • 把网页依赖的基础模块抽离出来,打包到一个个单独的动态链接库中去。一个动态链接库中可以包含多个模块。
  • 当需要导入的模块存在于某个动态链接库中时,这个模块不能被再次被打包,而是去动态链接库中获取。
  • 页面依赖的所有动态链接库需要被加载。

编译出的效果其实还是JS文件。只是使用了DLL的思想,以react.dll.js 为例:

1
2
3
4
5
6
7
8
9
10
11
12
var _dll_react = (function(modules) {
// ... 此处省略 webpackBootstrap 函数代码
}([
function(module, exports, __webpack_require__) {
// 模块 ID 为 0 的模块对应的代码
},
function(module, exports, __webpack_require__) {
// 模块 ID 为 1 的模块对应的代码
},
// ... 此处省略剩下的模块对应的代码
]));

react.manifest.json文件也是由 DllPlugin 生成出,用于描述动态链接库文件中包含哪些模块, 以 react.manifest.json文件为例,其文件内容大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
// 描述该动态链接库文件暴露在全局的变量名称
"name": "_dll_react",
"content": {
"./node_modules/process/browser.js": {
"id": 0,
"meta": {}
},
// ... 此处省略部分模块
"./node_modules/react-dom/lib/ReactBrowserEventEmitter.js": {
"id": 42,
"meta": {}
} }
}

编译动态链接库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');

module.exports = {
// JS 执行入口文件
entry: {
// 把 React 相关模块的放到一个单独的动态链接库
react: ['react', 'react-dom'],
// 把项目需要所有的 polyfill 放到一个单独的动态链接库
polyfill: ['core-js/fn/object/assign', 'core-js/fn/promise', 'whatwg-fetch'],
},
output: {
// 输出的动态链接库的文件名称,[name] 代表当前动态链接库的名称,
// 也就是 entry 中配置的 react 和 polyfill
filename: '[name].dll.js',
// 输出的文件都放到 dist 目录下
path: path.resolve(__dirname, 'dist'),
// 存放动态链接库的全局变量名称,例如对应 react 来说就是 _dll_react
// 之所以在前面加上 _dll_ 是为了防止全局变量冲突
library: '_dll_[name]',
},
plugins: [
// 接入 DllPlugin
new DllPlugin({
// 动态链接库的全局变量名称,需要和 output.library 中保持一致
// 该字段的值也就是输出的 manifest.json 文件 中 name 字段的值
// 例如 react.manifest.json 中就有 "name": "_dll_react"
name: '_dll_[name]',
// 描述动态链接库的 manifest.json 文件输出时的文件名称
path: path.join(__dirname, 'dist', '[name].manifest.json'),
}),
],
};

使用动态链接库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const path = require('path');
const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');

module.exports = {
entry: {
// 定义入口 Chunk
main: './main.js'
},
output: {
// 输出文件的名称
filename: '[name].js',
// 输出文件都放到 dist 目录下
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
// 项目源码使用了 ES6 和 JSX 语法,需要使用 babel-loader 转换
test: /\.js$/,
use: ['babel-loader'],
exclude: path.resolve(__dirname, 'node_modules'),
},
]
},
plugins: [
// 告诉 Webpack 使用了哪些动态链接库
new DllReferencePlugin({
// 描述 react 动态链接库的文件内容
manifest: require('./dist/react.manifest.json'),
}),
new DllReferencePlugin({
// 描述 polyfill 动态链接库的文件内容
manifest: require('./dist/polyfill.manifest.json'),
}),
],
devtool: 'source-map'
};

如何写loader?

作用:用于处理不同后缀的模块。

实现:一个xml-loader。这样可以在代码中,直接 import xxx from ‘xxxx.xml’

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const xml2js = require('xml2js');
const parser = new xml2js.Parser();

// source 就是模块的代码。也可能是 buffer。严格来说需要判断类型。
module.exports = function(source) {
// 开启缓存
this.cacheable && this.cacheable();
// self对象很关键。上面有callback。调用callback后,webpack才会继续执行。
// 由于需要用到this,因此需要使用es5写法
const self = this;
parser.parseString(source, function (err, result) {
self.callback(err, !err && "module.exports = " + JSON.stringify(result));
});
};

可以参考:

https://segmentfault.com/a/1190000018980814

如何写plugin?

核心要素:

  • Plugin本身是个类。上面提供apply方法,供webpack核心引擎调用
  • apply参数就会被注入 compiler (Webpack 环境所有的的配置信息,包含 options,loaders,plugins 这些信息,可以理解成webpack实例)。
  • 可以给 compiler绑定事件回调,也可以像全局广播。
    参考:https://www.webpackjs.com/api/compiler-hooks/#afterenvironment
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 广播出事件
* event-name 为事件名称,注意不要和现有的事件重名
* params 为附带的参数
*/
compiler.apply('event-name',params);
/**
* 监听名称为 event-name 的事件,当 event-name 事件发生时,函数就会被执行。
* 同时函数中的 params 参数为广播事件时附带的参数。
*/
compiler.plugin('event-name',function(compilation, callback) {
});

  • 回调函数中的compilation,包含了当前的模块资源、编译生成资源、变化的文件等。重新触发构建时,里面会有新的内容。
  • 也可以给它绑定各种钩子。参考:https://www.webpackjs.com/api/compilation-hooks/

webpack内部流程:(https://www.zoo.team/article/webpack-plugin)

webpack内部把这些回调理解成钩子(hook),并且基于 tapable 组织复杂的事件流,在特定时机,执行plugin传入的钩子。

例子:根据react文件信息,自动生成router文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
const fs = require('fs');
const path = require('path');
const _ = require('lodash');

function resolve(dir) {
return path.join(__dirname, '..', dir);
}

function MegerRouterPlugin(options) {
// options是配置文件,你可以在这里进行一些与options相关的工作
}

MegerRouterPlugin.prototype.apply = function (compiler) {
// 注册 before-compile 钩子,触发文件合并
compiler.plugin('before-compile', (compilation, callback) => {
// 最终生成的文件数据
const data = {};
const routesPath = resolve('src/routes');
const targetFile = resolve('src/router-config.js');
// 获取路径下所有的文件和文件夹
const dirs = fs.readdirSync(routesPath);
try {
dirs.forEach((dir) => {
const routePath = resolve(`src/routes/${dir}`);
// 判断是否是文件夹
if (!fs.statSync(routePath).isDirectory()) {
return true;
}
delete require.cache[`${routePath}/index.js`];
const routeInfo = require(routePath);
// 多个 view 的情况下,遍历生成router信息
if (!_.isArray(routeInfo)) {
generate(routeInfo, dir, data);
// 单个 view 的情况下,直接生成
} else {
routeInfo.map((config) => {
generate(config, dir, data);
});
}
});
} catch (e) {
console.log(e);
}

// 如果 router-config.js 存在,判断文件数据是否相同,不同删除文件后再生成
if (fs.existsSync(targetFile)) {
delete require.cache[targetFile];
const targetData = require(targetFile);
if (!_.isEqual(targetData, data)) {
writeFile(targetFile, data);
}
// 如果 router-config.js 不存在,直接生成文件
} else {
writeFile(targetFile, data);
}

// 最后调用 callback,继续执行 webpack 打包
callback();
});
};

如何分析编译构建过程?

plugin/loader耗时

在支持抖音电商前端时,经常遇到那种古早项目,webpack打包和流水线编译动辄十几分钟。

为了分析webpack的插件/loader的耗时,引入 speed-measure-webpack-plugin 库。

写法:

1
2
3
4
5
6
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
// 包裹下原来的 webpack 配置即可
const webpackConfig = smp.wrap({
plugins: [new MyPlugin(), new MyOtherPlugin()],
});

耗时结果分析出来后,基本都是图片处理的插件占了大头(9分钟):

Untitled.png

解决方案:关闭这个插件的压缩功能即可。

bundle依赖和映射关系

在响应内部的质量建设时,需要把代码中的es6代码都降低到es5。在配置babel后,编译依然存在es6语法。由于代码已经被转译,并且文件名均为hash后的名字,所以对应不上到底是哪里出的问题。

为了分析bundle的大小和依赖关系,以及源码和产物之间的映射关系,引入 webpack-bundle-analyzer 库。

Untitled.jpeg

最终跑出了左侧的图。找到 31.xxx.js 文件,点进去,能看到是 tinycolor.js 的问题。

Untitled.jpeg

提了个 https://github.com/bgrins/TinyColor/pull/263,虽然没有合并,但是作者也在最新版本中解决了产物出现es6语法的问题。