Webpack详解

一、为什么需要Webpack?

1. 背景

在最早的开发中,如果需要引入一个模块,那就需要借助script,就像下面这样:

1
2
<script src="module-a.js"></script>
<script src="module-b.js"></script>

这样做有一个致命的问题,那就是这些模块都是在全局工作的,这就导致模块成员之间会互相污染,模块与模块之间也看不出依赖关系,导致维护起来特别困难。于是后面就有人提出采用命名空间的方式,也就是每个模块只暴露一个全局成员,并挂在到window下面。

1
2
3
4
5
window.moduleA = {
method: function1() {
console.log('moduleA_function1')
}
}

但这里还有一个问题,那就是这样的方式无法得知这个函数是依赖了哪些外部库,于是有人想到了用自执行函数来传入依赖库,比如下面这样

1
2
3
4
5
6
7
8
9
10
11
12
(function ($) {
var name = 'module-a'

function method1() {
console.log(name + '#method1')
$('body').animate({margin: '200px'})
}

window.moduleA = {
method1: method1
}
})(jQuery)

然而这样做依旧不够方便。于是有人就提出了一整套模块化规范,目前最流行的模块化规范有两套,即CommonJS(module.exports导出/require导入)和ES Modules(export导出/import导入)。
随着前端项目越来越大,前端开发也变得十分复杂,这时就出现了以下问题:

  • 需要通过模块化的方式来开发功能
  • 使用一些高级特性来加快开发效率,比如ES6+、TypeScript、Sass/Less等
  • 监听文件变化并且反映到浏览器上,提高开发效率
  • JS模块化之后,CSS跟HTML同样也需要模块化
  • 开发完成之后需要对代码进行压缩、合并以及其他相关优化

为了解决这些问题,Webpack就诞生。Webpack是一个模块打包工具,开发者可以很方便地使用Webpack来管理依赖,并编译出模块们所需要的静态文件

2. Webpack

Webpack在处理程序的时候,会先将ES6的代码转化ES5代码,解决浏览器兼容性问题。接着会在内部生成构建一个依赖图,此依赖图对应映射到项目生成的每个模块,并生成一个或多个bundle.js,减少请求的次数。最后再对CSS,图片进行整合。

总的来说,Webpack起到了模块打包、编译兼容、能力扩展等功能,其中编译兼容就是借助Loader来实现的,而能力扩展是借助Plugin来实现的。

3.配置Webpack的基本要素

  • entry(入口)

入口是关系依赖图的开始,Webpack从入口开始寻找依赖,打包构建。
Webpack允许有多个入口。
配置示例如下:

1
2
3
module.exports = {
entry: 'index.js'
}
  • output(输出)

用于指定最终输出文件的名称及位置

1
2
3
4
5
6
module.exports = {
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
}
}
  • loader(加载器):用来翻译非JavaScript的文件,如CSS、HTML、图片等,具体参考下文

  • plugin(插件):用来扩展Webpack的功能,基础Tapable事件流框架,能够监听Webpack的生命周期函数,具体参考下文

  • mode(模式):用来区分开发环境跟生产环境
    配置如下:

    1
    2
    3
    4
    module.exports = {
    mode: 'development' // 开发环境
    mode: 'production' // 生产环境
    }
  • resolve(解析):用于设置模块如何解析,具体参考下文的优化章节

  • optimization(优化):Webpack内置的优化配置,一般用于生产模式提升性能。
    常用配置如下:

  • minimize:是否需要压缩bundle

  • minimizer:压缩工具。如TerserPlugin、OptimizeCSSAssetsPlugin

  • splitChunk:拆分bundle

  • runtimeChunk:是否需要将所有生成的chunk之间共享的运行时文件拆分出来

配置示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module.exports = {
optimization: {
minimizer:[
new CssMinimizerPlugin()
],
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
chunk: all,
name: 'vendors',
priority: 10,
enforce: true
}
}
}
}
}

二、构建流程

Webpack构建主要有三大流程:

  • 初始化阶段

从webpack.config.js中读取配置参数,启动webpack,加载所有Plugin,创建Compiler对象。
执行run函数,创建一个Compilation对象,这个对象包含了本次构建的所有数据。

  • 编译构建阶段

从Entry出发,调用Loader对模块进行翻译,最终转为JavaScript文件
找到所有依赖的模块,递归解析,最终生成依赖关系树,并通过acorn库生成AST树

  • 输出阶段

对编译后的Module组合成Chunk,把Chunk转换成文件,输出到文件系统中。最终打包出来的就是一个bundle.js,这个bundle是一个IIFE(自执行函数)

1. 初始化阶段

Webpack的用户配置一般都保存在webpack.config.js之中,常见样式如下:

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
var path = require('path')
var node_modules = path.resolve(__dirname, 'node_modules')
var pathToReact = path.resolve(node_modules, 'react/dist/react.min.js')

module.exports = {
// 入口文件
entry: './index.js',
// 指定文件路径,加快打包过程
resolve: {
alias: {
'react': pathToReact
}
},
// 输出文件
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].js'
},
// 配置各种loader的模块
module: {
loaders: [
{
test: /\.js$/,
loader: 'babel',
query: {
presets: ['es2015', 'react']
}
}
],
noParse: pathToReact
},
// 配置plugin
plugins: [
new webpack.HotModuleReplacementPlugin()
]
}

Webpack在拿到webpack.config.js之后,会将其中的各个配置拷贝到options对象之中,然后加载用户配置的plugin。最后初始化Compiler编译对象,该对象一个全局单例,掌控着Webpack的生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Compiler extends Tapable {
constructor(context) {
super()
this.hooks = {
beforeCompiler: new AsyncSeriesHook(['param']),
compile: new SyncHook(['params']),
afterCompile: new AsyncSeriesHook(['compilation']),
make: new AsyncParallelHook(['compilation']),
entryOption: new SyncBailHook(['context', 'entry'])
// ... 以及其他一些钩子
}
}
}

function webpack(options) {
var compiler = new Compiler()
return compiler
}

2. 编译构建阶段

通过Compiler对象加载所有插件,执行run方法进行编译。主要编译流程如下:

  • compile:开始编译
  • make:从入口点分析模块及其依赖的模块,创建这些模块对象
  • build-module:构建模块
  • seal:封装构建结果
  • emit:把各个chunk输出到结果文件

1)compile编译

执行run之后,首先触发compile方法,这个方法会构建一个compilation对象。该对象是编译阶段的主要执行者,它包含了当次构建所需要的信息。会依次执行以下流程:

  • 执行模块创建
  • 依赖收集
  • 分块
  • 打包

2)make编译模块

当完成compilation对象之后,就开始从entry入口文件开始读取代码,主要执行的是_addModuleChian函数,大致代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
_addModuleChain(context, dependency, onModule, callback) {
...
// 根据依赖查找对应的工厂函数
const Dep = /** @type { DepConstructor } */ (dependency.constructor)
const moduleFactory = this.dependencyFactories.get(Dep)

moduleFactory.create({
dependencies: [dependency]
...
}, (err, module) => {
...
const afterBuild = () => {
this.processModuleDependencies(module, err => {
if (err) return callback(err)
return callback(null, module)
})
}

this.buildModule(module, false, null, null, err => {
...
afterBuild()
})
})
}

_addModuleChain接收参数dependency传入的入口依赖,使用对应的工厂函数ModuleFactory.create生成一个空的module对象。回调会把这个module对象存到compilation.modules和dependencies。由于是入口文件,还会被存到compilation.entries之中。最后执行buildModule进入真正构建模块内容的阶段。

3)build module完成模块编译

这个阶段主要调用开发者配置的loaders,将CSS、Html等模块转换成标准的JS模块。接着使用Acorn(一个解析JS代码的解析器)输出对应的AST树。
从AST树的入口开始,寻找所有引入语句,如require、import等。接着逐渐递归深入,最后生成一份依赖收集表,彻底搞清楚依赖关系

4)seal和emit输出

seal方法主要生成chunk,对chunks进行一系列优化操作,最终输出代码。这个chunk可以理解为模块。
根据入口与模块之间的关系,组装出一个个包含多个模块的chunk,再把每个chunk转换成一个单独的文件加入到输出列表。在确定好输出内容之后,根据配置确定输出的路径和文件名即可,即以下这部分:

1
2
3
4
output: {
path: path.resolve(__dirname, 'build'),
filename: '[name].js'
}

在Compiler开始生成文件之前,会触发emit钩子函数,这是我们最后一次修改处理结果的机会。

在Webpack5中,最终输出的bundle.js会像下面这样展示:

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
// webpack 5 打包的bundle文件内容

(() => { // webpackBootstrap
var __webpack_modules__ = ({
'file-A-path': ((modules) => { // ... })
'index-file-path': ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => { // ... })
})

// The module cache
var __webpack_module_cache__ = {};

// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};

// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);

// Return the exports of the module
return module.exports;
}

// startup
// Load entry module and return exports
// This entry module can't be inlined because the eval devtool is used.
var __webpack_exports__ = __webpack_require__("./src/index.js");
})

相比Webpack4,Webpack5做了相当的精简。整个bundle里面只有三个变量和一个函数方法。
其中**webpack_modules_**存放了编译后各个文件模块的内容。
**webpack_module_cache**用来做模块缓存。
**webpack_require**是Webpack内部实现的一套依赖引入函数。它会把我平常使用的ES Module或者CommonJS导入导出规范,替换成自己的__webpack_require__,以此来抹平不同规范之间的差异性。
最后一句是代码运行的起点,从入口开始,启动整个项目。

整个流程如下图所示:

三、Loader

1. 什么是Loader

Loader的本质是一个函数,在该函数对接收的内容进行转换,最终返回转换后的结果。由于Webpack只认识JavaScript,所以需要Loader充当翻译官,对其他类型的资源进行转译的预处理工作

2. 如何配置Loader

配置Loader常见的有三种方式:

  • 配置方式(推荐),在webpack.config.js中配置Loader
  • 内联方式,在每个import语句中显式指定Loader
  • Cli方式,在Shell命令中指定它们

在配置方式中,我们通常将Loader写在module.rules之中,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
{ loader: 'style-loader' },
{
loader: 'css-loader',
options: {
modules: true
}
},
{ loader: 'sass-loader' }
]
}
]
}
}

从上面的例子可以看出,rules是一个数组,里面存放着多个匹配规则,而用来匹配的属性叫做test,这个属性是通过正则表达式来匹配的。接着通过use来决定使用哪些loader。

3. Loader的特性

从上一段示例代码可以看到,同一个test匹配多个Loader,这就涉及到Loader的特性:链式调用,每个Loader都会处理前一个Loader返回的结果。值得注意的是,这个处理顺序是反着来的,也就是按sass-loader -> css-loader -> style-loader这个顺序来处理。
如果想要指定Loader的顺序,可以使用enfore属性。另外还有pre和post,pre代表在所有Loader之前执行,post则在所有Loader之后。

4. 常用Loader

以下列出常见的Lodaer:

  • style-loader:将css添加到DOM的内联样式标签style里,然后通过DOM操作去加载css
  • css-loader:允许css通过require的方式引入,并返回css代码
  • less-loader:处理less代码,将less代码转换成css代码
  • sass-loader:处理sass代码,将scss/sass代码转换成css代码
  • file-loader:分发文件到output目录并返回相对路径
  • url-loader:和file-loader类似,但是当文件小于设定的limit时可以返回一个DataUrl
  • html-minify-loader:压缩HTML
  • babel-loader:用babel来转换ES6文件到ES
  • awesome-typescript-loader:将TypeScript代码转换成JavaScript,性能优于ts-loader
  • eslint-loader:通过eslint检查JavaScript代码
  • tslint-loader:通过tslint检查TypeScript代码
  • cache-loader:可以在一些性能开销比较大的Loader之前添加,将结果缓存到磁盘中,下次无需重新计算

下面以css-loader为例,说明具体如何配置一个Loader

在配置之前,首先需要安装插件:

1
npm i -D css-loader

接着将规则配置到module.rules之中即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
rules: [
...,
{
test: /\.css$/,
use: {
loader: 'css-loader',
options: {
url: true,
import: true,
sourceMap: false
}
}
}
]

5.自定义Loader

Loader本质上是一个函数,但要特别注意的是它的this作为上下文会被Webpack填充,所以不能使用箭头函数,因为箭头函数无法改变this的指向。
Loader函数接收一个参数,Webpack会将原内容通过这个参数传递给Loader。

Loader有四种类型:

  • 同步loader

一般loader都是同步的,可以像下面这样定义:

1
2
3
4
module.exports = function(content) {
const res = dosth(content)
return res
}

也可以通过this.callback返回,将return设定为undefined来告诉Webapck去callback中寻找结果。
this.callback的参数如下:

1
2
3
4
5
6
this.callback(
err: Error | null, // 一个无法正常编译时的Error或者直接给个null
content: string | Buffer, // 处理后返回的内容,可以是string或者Buffer
sourceMap?: sourceMap, // 可选,可以是一个被正常解析的sourceMap
meta?: any // 可选,可以是任何东西,比如一个公用的AST树
)

接下来展示一个使用实例:

1
2
3
4
5
6
7
8
module.exports = function(content) {
// 从Webpack5开始,this.getOptions可以获取用户传递的参数
const options = this.getOptions()
const res = someSyncOperation(content, options)
this.callback(null, res, sourceMaps)
// 使用callback的时候,这里必须return
return
}
  • 异步loader

异步主要就是调用this.async来实现异步构建,具体代码如下:

1
2
3
4
5
6
7
module.exports = function(content) {
var callback = this.async()
someAsyncOperation(content, function(err, res) {
if (err) return callback(err)
callback(null, result, sourceMaps, meta)
})
}
  • Raw Loader

传入参数content默认为UTF-8字符串,如果希望传入的是Buffer,那就需要设置一下raw。具体代码如下:

1
2
3
4
5
6
module.exports = function(content) {
console.log(content instanceof Buffer) // true
return doSthOpera(content)
}

module.exports.raw = true
  • Pitching Loader

这个有点类似冒泡捕获这个概念,一般我们loader是从右往左加载的,但是在此之前还有一个从左往右的pitch过程。
就像下图这样:

可以看到,先通过pitch逐个进行,再通过loader调用往回走。

这里的pitch有三个参数:

  • remainingRequest:保存着loader链中排在自己后面的loader的绝对路径,以**!**作为字符串的连接符
  • precedingRequest:保存着loader链中排在自己前面的loader的绝对路径,以**!**作为字符串的连接符
  • data:每个loader存放的上下文字段,pitch可以通过这个传递数据给loader。
    比如下面这段代码:
    1
    2
    3
    4
    5
    6
    7
    module.exports = function(content) {
    return someSyncOperation(content, this.data.value)
    }

    module.exports.pitch = function(remainingRequest, precedingRequest, data) {
    data.value = 42
    }
    如果我们在pitch中直接return,loader链会直接跳过后面部分,直接回头。
    比如下面这段代码:
    1
    2
    3
    4
    5
    6
    7
    module.exports = function(content) {
    return someSyncOperation(content)
    }

    module.exports.pitch = function(remainingRequest, precedingRequest, data) {
    return '打断loader链'
    }
    这会导致下图这个结果:

    也就是当开发者在b pitch中使用了return,那么就会直接回到a loader的调用上。不会执行后面的b loader和c loader

其他loader的API:

  • this.addDependency:加入一个文件监听,一旦文件产生变化,就会调用这个loader进行处理
  • this.cacheable:默认情况下loader的处理结果会有缓存,给这个方法传入false可以关闭效果
  • this.clearDependencies:清除loader的所有依赖
  • this.context:文件所在的目录(不包含文件名)
  • this.data:pitch阶段和正常调用阶段共享的对象
  • **this.getOptions()**:用来获取loader的参数选项
  • this.resolve:像require表达式一样解析一个request。resolve(context: string, request: string, callback:function(err, result: string))
  • this.loaders:所有loader组成的数组,它在pitch的时候可以写入
  • this.resource:获取当前请求路径,包含参数:’/abc/resource.js?rrr’
  • this.resourcePath:不包含参数的路径:’/abc/resource.js’
  • this.sourceMap:bool类型,是否应该生成一个sourceMap

6.如何使用自定义的loader

主要有两种,一种是通过Npm link的方式。另一种是直接在项目中通过路径配置的方式。
这里主要将第二种,而这第二种又分为两种情况:

  • 匹配单个loader

直接在rule中通过path.resolve来指定路径

1
2
3
4
5
6
7
8
9
{
test: /\.js$/,
use: [
{
loader: path.resolve(__dirname, 'path/to/loader.js'),
options: {}
}
]
}
  • 匹配多个loader

可以使用resolveLoader.modules进行配置。Webpack将会从这些目录中搜索这些loaders。具体代码如下:

1
2
3
4
5
6
resolveLoader: {
modules: [
'node_modules',
path.resolve(__dirname, 'loaders')
]
}

四、Plugin

1. 什么是Plugin

Plugin是基于Tapable事件流框架,在Webpack运行的生命周期中会广播许多事件,而Tapable可以通过监听钩子函数来获取这些事件,在合适的时机通过Webpack提供的API改变输出结果。Plugin常用来进行打包优化、资源管理、环境变量注入等工作。

使用的时候,先引入该plugin,然后在plugin属性中new一个实例即可

例如这样:

1
2
3
4
5
6
7
8
9
10
const HtmlWebpackPlugin = require('html-webpack-plugin')
const webpack = require('webpack')

module.exports = {
...
plugins: [
new webpack.ProgressPlugin(),
new HtmlWebpackPlugin({ template: './src/index.html' })
]
}

2. Plugin的特性

在Plugin中有一个apply方法,这个apply会被Compiler对象调用,并且Compiler会作为参数传入到apply里面,这意味着开发者可以访问到Compiler的各个时期的钩子函数。

1
2
3
4
5
6
7
8
9
10
11
const pluginName = 'ConsoleLogPlugin'

class ConsoleLogPlugin {
apply(compiler) {
compiler.hooks.run.tap(pluginName, compilation => {
console.log('Webpack构建开始了')
})
}
}

module.export = ConsoleLogPlugin

其中run就是构建开始时的钩子函数,其他钩子函数可以参考官方文档

3. 常见的Plugin

常见的Plugin有下列这些:

  • ignore-plugin:忽略部分文件
  • html-webpack-plugin:简化HTML文件创建(依赖于html-loader)
  • web-webpack-plugin:可以方便地为单页面应用输出HTML,比html-webpack-plugin好用
  • terser-webpack-plugin:压缩代码,支持ES6
  • webpack-paralle-uglify-plugin:多进程执行代码压缩,提升构建速度
  • clean-webpack-plugin:自动清理目录
  • speed-measure-webpack-plugin:可以看到每个Loader和Plugin的执行耗时

插件展示的大致内容如下图:

具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()

module.exports = (production) => {
if (production) {
const endProdConfig = merge(commonConfig, prodConfig)
return smp.wrap(endProdConfig)
}
else {
const endDevConfig = merge(commonConfig, devConfig)
return smp.wrap(endDevConfig)
}
}
  • webpack-bundle-analyzer:可视化Webpack文件输出的体积

配置如下:

1
2
3
4
5
6
7
8
9
10
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')

const commonConfig = {
plugins: [
new BundleAnalyzerPlugin({
analyzerPort: 8889,
openAnalyzer: false
})
]
}

实际上webpack-bundle-analyzer也是通过分析stats.json来得出数据的。直接生成stats.json的指令为:

1
webpack --profile --json > stats.json
  • wepack-dashboard:可以更好的展示打包信息
  • webpack-merge:提取公共配置,减少重复代码
  • size-plugin:监控资源体积的变化
  • progress-bar-webpack-plugin:用来查看编译的进度情况的

配置如下:

1
2
3
4
5
6
7
8
9
10
const chalk = require('chalk')
const ProgressBarPlugin = require('progress-bar-webpack-plugin')

module.exports = {
plugins: [
new ProgressBarPlugin({
format: ` :msg [:bar] ${chalk.green.bold(':percent')} (:elapsed s)`
})
]
}

其中包含了内容、进度条、百分比以及用时,效果如下:

4. 自定义Plugin

Webpack编译的时候会创建两个对象:Compiler和Compilation

  • Compiler:包含Webpack环境的所有配置信息,包括options、loader和plugin,以及Webpack整个生命周期的钩子
  • Compilation:作为Plugin内置事件回调函数的参数,包含当前的模块资源、编译生成资源、变化的文件以及被跟踪依赖的状态信息。当检测到一个文件变化,一个新的Compilation将被创建

自定义Plugin需要遵循以下规范:

  • 插件要么是一个函数,要么必须包含是一个apply方法的对象,这样才能访问到Compiler实例
  • 传给每个插件的Compilation和Compiler对象是同一个引用,不建议修改
  • 异步事件需要在插件处理完任务时调用callback通知Webpack进入下一个流程,不然会卡住

下面是一个自定义Plugin的模板:

1
2
3
4
5
6
7
8
class MyPlugin {
//
apply(compiler) {
compiler.hooks.emit.tap('MyPlugin', compilation => {
console.log(compilation)
})
}
}

如果要用到异步,就需要使用tapAsync或者tapPromise

  • tapAsync

使用tapAsync的时候,需要传入一个callback回调,告知Webpack这是段异步操作。
具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class HelloPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync(HelloPlugin, (compilation, callback) => {
setTimeout(() => {
console.log('async')
callback
}, 1000)
})
}
}

module.exports = HelloPlugin
  • tapPromise

使用tapPromise的时候,我们则需要返回一个Promise。
具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class HelloPlugin {
apply(compiler) {
compiler.hooks.emit.tapPromise(HelloPlugin, compilation => {
return new Promise(resolve => {
setTimeout(() => {
console.log('async')
resolve()
}, 1000)
})
})
}
}

六、热更新

1.什么是热更新

热更新又称为热替换(Hot Module Replacement),缩写为HMR。这个机制可以做到修改本地文件之后,不需要刷新页面或者重启服务器,就能达到更新的效果。

2.如何开启

在webpack.config.js添加如下配置:

1
2
3
4
5
module.exports = {
devServer: {
hot: true
}
}

默认情况下,热更新只更新css部分,如果想要更新其他资源,比如js文件,需要做以下设置:

1
2
3
4
5
if (module.hot) {
module.hot.accept('./util.js', () => {
console.log('util.js更新了!')
})
}

3.实现原理

首先来看一下HMR的架构图:

这里涉及到这些概念:

  • Webpack Compile:将JS源代码编译成bundle.js
  • HMR Server:用来将热更新的文件输出给HMR Runtime
  • Bundle Server:静态资源文件服务器,提供文件访问路径
  • HMR Runtime:socket服务器,会被注入到浏览器,更新文件变化
  • 在HMR Runtime和HMR Server之间建立websocket,用于实时更新文件变化

整个流程我们可以分为两个阶段:启动阶段和更新阶段

启动阶段的工作是:
Webpack Compile将源代码和HMR Runtime一起编译成bundle文件,传输给Bundle Server静态资源服务器。
也就是express,这个express是随着WDS(Webpack Dev Server)启动的。

接着是更新阶段:
当webpack启动的时候,webpack会给编译后的文件生成一个唯一的hash值。
当发生更新的时候,HMR Server会向浏览器推送一条消息,消息包含文件改动后生成的hash。
浏览器接收到消息之后,就会创建一个ajax去请求变化的内容。
HMR Server会根据变化的内容生成两个补丁文件:manifest和chunk.js模块
其中manifest包含hash和chunkId,hash用来跟本地记录的hash值进行对比,chunkId则代表了变化模块的Id。
而chunk.js包含变化的内容。
浏览器的HMR Runtime根据manifest文件获取模块变化的内容,从而触发render函数,实现局部更新。

七、Proxy的工作原理

在开发中,我们不可避免会遇到跨域的问题,而Webpack在本地开发的环境下恰好提供了一套解决方案,那就是使用Proxy。
所谓Proxy,就是把发送给远程服务器的请求发给一个本地的服务器,让本地服务器代为转发,因为跨域规则只在浏览器中存在,本地服务器并没有跨域问题,并且本地服务器跟浏览器是同源的。
而要使用Proxy,就需要用到Webpack提供的本地开发工具,即Webpack Dev Server

1.什么是Webpack Dev Server

Webpack Dev Server是Webpack官方推出的一款开发工具,特点是能够自动编译和自动刷新浏览器,为开发提供了方便。其配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const path = require('path')

module.exports = {
devServer: {
contentBase: path.join(__dirname, 'dist'),
compress: true,
port: 9000,
proxy: {
'/api': {
target: 'http://api.github.com'
}
}
}
}

这里面的proxy属性就是我们说的代理,它可以将/api路径下面的请求转发到target指定的Url上,从而实现跨域的目的。
另外还可以给/api添加以下属性:

  • target:目标地址
  • pathRewrite:默认情况下,会把/api带上,如果希望修改或者删除,可以使用该属性
  • secure:支持https请求
  • changeOrigin:是否更新代理后请求的headers中host地址

2.Proxy的原理

proxy本质上是利用了http-proxy-middleware这个http代理中间件,实现请求转发给其他服务器的功能。
我们可以通过下面的代码实现相似的功能:

1
2
3
4
5
6
7
const express = require('express')
const proxy = require('http-proxy-middleware')

const app = express()

app.use('/api', proxy({ target: 'http://www.example.org', changeOrigin: true }))
app.listen(3000)

八、Mock接口

Webpack中的Mock接口主要是利用了自定义中间件devServer.setupMiddleware,在middleware.unshift中的回调函数使用res.send将mock数据传递回去。
具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
devServer: {
setupMiddleware: (middlewares, devServer) => {
if (!devServer) {
throw new Error('webpack-dev-server is not defined')
}

middlewares.unshift({
name: 'user-info',
path: '/user',
middleware: (req, res) => {
res.send({ name: 'moon mock ' })
}
})

return middlewares
}
}

九、优化性能

1.JS代码压缩

TerserPlugin是Webpack内置的JS代码压缩工具,它的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
const TerserPlugin = require('terser-webpack-plugin')

module.exports = {
...
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
paraller: true
})
]
}
}

TerserPlugin常见的属性:

  • extractComments:默认值为true,表示会将注释抽离到一个单独的文件中
  • parallel:使用多进程并发运行提高构建速度,默认值为true,并发运行的数量可通过以下代码获取
    1
    os.cpus().length - 1
  • terserOptions:设置Terser的相关配置
    • compress:设置压缩相关选项
    • mangle:设置丑化相关选项
    • toplevel:底层变量是否进行转换
    • keep_classnames:保留类的名称
    • keep_fnames:保留函数的名称

2.CSS代码压缩

CSS压缩通常就是去掉一些无用的空格换行等,我们可以使用插件:css-minimizer-webpack-plugin。配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')

module.exports = {
optimization: {
minimize: true,
minimizer: [
new CssMinimizerPlugin({
parallerl: true
})
]
}
}

还可以使用插件optimize-css-assets-webpack-plugin,其默认使用的压缩引擎是cssnano。配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
const prodConfig = {
optimization: {
minimizer: [
new OptimizeCSSAssetsPlugin({
assetNameRegExp: /\.optimize\.css$/g,
cssProcessor: require('cssnano'),
cssProcessorPluginOptions: {
preset: ['default', { discardComments: { removeAll: true } }]
},
canPrint: true
})
]
}
}

擦出无用的CSS可以使用PurgeCSS,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
const PurgeCSSPlugin = require('purgecss-webpack-plugin')
const PATHS = {
src: path.join(__dirname, './src')
}

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

3.HTML代码压缩

使用HtmlWebpackPlugin插件来生成HTML模板,主要通过属性minify来设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
...
plugins: [
new HtmlWebpackPlugin({
...
minify: {
minifyCSS: false, // 是否压缩CSS
collapseWhitespace: false, // 是否折叠空格
removeComments: true // 是否移除注释
}
})
]
}

4.文件大小压缩

文件压缩可以使用插件:compression-webpack-plugin。配置如下:

1
2
3
4
5
6
new CompresssionPlugin({
test: /\.(css|js)$/,
threshold: 500, // 设置文件多大开始压缩
minRatio: .7, // 设置压缩比例
algorithm: 'gzip' // 采用的压缩算法
})

5.图片压缩

图片压缩是体积优化的大头,可以使用file-loader跟image_webpack-loader这两个插件,配置如下:

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
module: {
rules: [
{
test: /\.(png|jpg|gif)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name]_[hash].ext',
outputPath: 'images/'
}
},
{
loader: 'image_webpack-loader',
options: {
mozjepg: {
progressive: true,
quality: 65
},
optipng: {
enabled: false
},
pngquant: {
quality: '65-90',
speed: 4
},
gifsicle: {
interlaced: false
},
// 开启webp,会把png和jpg转换成webp
webp: {
quality: 75
}
}
}
]
}
]
}

6.Tree Shaking

Tree Shaking的作用是消除无用的代码,依赖于ES Module的静态语法分析。在Webpack中有两种实现Tree Shaking的方案:

  • usedExports:通过标记函数是否使用,之后通过Terser进行优化

usedExposrts的配置如下:

1
2
3
4
5
module.exports: {
optimization: {
usedExposrts: true
}
}
  • sideEffects:跳过整个模块/文件,看看是否有副作用

这里的副作用指的是除了module.exports之外做的事情,如果有哪些有副作用,但又不想被删除的代码,可以通过以下配置来避免删除:

1
"sideEffects": ['./src/utils/format.js', '*.css' // 所有CSS文件]

7.代码分离/分割

代码分离用于将首包用不到的代码分开打包,减少首包的体积。通常使用splitChunkPlugin插件来实现,配置如下:

1
2
3
4
5
6
7
8
module.exports = {
...
optimization: {
spiltChunks: {
chunks: 'all'
}
}
}

splitChunk有如下属性:

  • Chunks:对同步还是异步代码进行处理
  • minSize:拆分包的大小,不小于minSize,如果这个包小于minSize,则不会被拆分
  • maxSize:将大于maxSize的包,拆分为不小于minSize的包
  • minChunks:被引入的次数,默认是1

8.内联chunk

可以使用InlineChunkHtmlPlugin将一些代码量不大,但是必须加载的chunk模块内联到html中。
如runtime的代码、对模块进行解析,加载以及模块信息相关的代码。
配置如下:

1
2
3
4
5
6
7
8
9
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.export = {
...
plugins: [
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime.+\.js/])
]
}

9.提取公共资源

使用html-webpack-externals-plugin,将基础包通过CDN引入,不打入bundle中

10.合理使用sourceMap

sourceMap是一项将编译、打包、压缩之后的代码映射回源代码的技术。由于打包后的代码并没有可读性,一旦遇到报错,直接在混淆后的代码上debug是一件十分痛苦的事情,因此这个时候就需要sourceMap来告知我们这个报错在源代码的什么地方。

既然是一种映射,那就肯定有一份映射文件来标记源代码的位置,这份文件通常以.map命名,里面的数据结构大概长这样:

1
2
3
4
5
6
7
8
9
{
'version': 3, // Source Map版本
'file': 'out.js', // 输出文件(可选)
'sourceRoot': '', // 源文件根目录(可选)
'sources': ['foo.js', 'bar.js'], // 源文件列表
'sourcesContent': [null, null], // 源内容列表(可选,要跟源文件列表顺序保持一致)
'names': ['src', 'maps', 'are', 'fun'], // mappings使用的符号名称列表
'mappings': 'A,AAAB;;ABCDE' // 带有编码映射数据的字符串
}

其中mappings数据就是混淆后的函数名,它有一套规则:

  • 每个组用分号分割
  • 每一段用逗号分隔
  • 每个段由1、4、5个可变长度字段组成

有了这份文件,我们在我们压缩代码的最后端加上这句注视,即可让sourceMap生效:

1
//# sourceURL=/path/to/file.js.map

sourceMap映射表有一套较复杂的规则,具体可以参考以下两篇文章:
Source Map的原理探究
Source Maps under the hood – VLQ, Base64 and Yoda

开发环境一般将sourceMap设置为eval-cheap-module-source-map,如下配置:

1
2
3
module.export = {
devtool: 'eval-cheap-module-source-map'
}

生产环境一般有三种方案:

  • hidden-source-map:借助第三方监控平台Sentry使用
  • nosources-source-map:只会显示具体行数以及查看源代码的错误栈,安全性比sourcemap高
  • sourcemap:通过nginx设置将.map只对白名单开放(公司内网)

十、提高构建速度

1.优化Loader配置

在使用Loader的时候,可以用include和exclude来指定匹配哪些文件,加快构建速度,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
module: {
rules: [
{
test: /\.js$/,
// cacheDirectory可以缓存转换的结果
use: ['babel-loader?cacheDirectory'],
// 指定要匹配的文件夹
include: path.resolve(__dirname, 'src')
}
]
}
}

2.合理使用resolve.extensions

当import一个模块的时候,我们一般需要写出文件的后缀名,Webpack才能知道这是什么文件。
而extensions可以帮助我们省略后缀名,比如我们这样写:

1
import '/path/to/file'

这个时候file是一个js文件,那我们可以给extensions配置一个数组,Webpack就会从数组中查找可能的后缀名,从而识别改文件
具体配置如下:

1
2
3
4
module.exports = {
...
extensions: ['.warm', '.mjs', '.js', '.json']
}

3.优化resolve.modules

resolve.modules用于指明去哪寻找第三方模块,默认值为[‘node_modules’]。

1
2
3
4
5
module.exports = {
resolve: {
modules: [path.resolve(__dirname, 'node_modules')]
}
}

4.优化resolve.alias

alias可以给路径起别名,减少查找的过程,配置如下:

1
2
3
4
5
6
7
8
module.exports = {
...
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
}

5.使用DLLPlugin插件

DLL全称动态链接库,是Windows实现共享函数库的一种方式,而Webpack也内置了打包DLL的功能,可以将不常改动的代码抽成一个共享库,就不需要反复编译这个库了。
这里就涉及两个问题:如何打包成DLL和如何使用

  • 如何打包成DLL

新建一个webpack_dll.config.js专门用来打包DLL
配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const path = require('path')
const DllPlugin = require('webpack/lib/DllPlugin')
module.exports = {
entry: {
react: ['react', 'react-dom'],
polyfill: ['core-js/fn/promise', 'whatwg-fetch']
},
output: {
filename: '[name].dll.js',
path: path.resolve(__dirname, 'dist'),
library: '_dll_[name]' // dll的全局变量名
},
plugins: [
new webpack.DllPlugin({
name: '_dll_[name]', // dll的全局变量名
path: path.resolve(__dirname, './dll/[name].manifest.json')
})
]
}

其中manifest.json代表模块的映射关系,解析的时候需要用到
需要注意的是DllPlugin的name属性必须跟output.library保持一致,并且生成的manifest文件也会用到output.library
最终构建出的文件:

1
2
3
4
|-- polyfill.dll.js
|-- polyfill.manifest.json
|-- react.dll.js
└── react.manifest.json
  • 如何使用

首先要用到Webpack自带的DllReferencePlugin对映射文件manifest.json进行分析,获取要使用的Dll库。然后通过AddAssetHtmlPlugin将Dll库引入Html模块。
配置如下:

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
...
new webpack.DllReferencePlugin({
context: path.resolve(__dirname, './dll/dll_react.js'),
manifest: path.resolve(__dirname, './dll/dll_react.manifest.json')
}),
new AddAssetHtmlPlugin({
outputPath: './auto',
filePath: path.resolve(__dirname, './dll/dll_react.js')
})
}

6.开启缓存

  • babel-loader开启缓存

将cacheDirectory设为true即可,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module: {
rules: [
{
test: /\.jsx$/,
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: true
}
}
]
}
]
}
  • terser-webpack-plugin开启缓存

给cache属性设为true即可,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const TerserPlugin = require('terser-webpack-plugin')

const commonConfig = {
...
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: 4,
cache: true
})
]
}
}
  • 使用cache-loader

有一些开销比较大的loader,我们可以把运行结果缓存在磁盘中,下次就不需要重新计算,实现这个功能的便是cache-loader。
具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
module: {
rules: [
{
test: /\.ext$/,
use: ['cache-loader', ...loaders],
include: path.resolve(__dirname, 'src')
}
]
}
}

这个功能在Webpack5已经实现了内置,我们只需要进行如下配置

1
2
3
4
5
6
7
8
9
10
11
// 开发环境
cache: {
type: 'memory'
}
// 生产环境
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename]
}
}

7.terser启动多线程

这个在前面介绍TerserPlugin插件的时候说过,具体配置如下:

1
2
3
4
5
6
7
8
9
module.exports = {
optimization: {
minimizer: [
new TerserPlugin({
parallel: true
})
]
}
}

8.升级版本

Webpack4的构建速度比Webpack3快了60%-98%
Webpack4带来的优化:

  • v8引擎带来的优化,如:for of替代forEach、Map和Set替代Object、includes替代indexOf
  • 默认使用更快的md4 hash算法
  • webpack AST可以从loader直接传递给AST,减少解析时间
  • 使用字符串方法替代正则表达式

9.区分生产环境跟开发环境的配置

一般常用的webpack配置有以下三种:

  • webpack.dev.js:开发环境
  • webpack.prod.js:生产环境
  • webpack.common.js:公共配置

然后安装插件webpack-merge来整合这些环境。
比如说在webpack.dev.js中整合webpack.common.js,就可以这样设置:

1
2
3
4
5
6
const { merge } = require('webpack-merge')
const commonConfig = require('./webpack-base')

module.exports = merge(commonConfig, {
...
})

10.多进程构建

thread-loader是Webpack官方推出的一个多进程方案,用来替代HappyHack。
原理和HappyHack相似,webpack每解析一个模块,thread-loader会将它的依赖分配给worker线程中,从而达到多进程打包的目的。
具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const commonConfig = {
module: {
rules: [
{
test: /\.jsx$/,
use: [
{
loader: 'thread-loader',
options: {
workers: 3
}
},
'babel-loader'
]
}
]
}
}

11.使用noParse

如果有些第三方库没有依赖库,像JQuery。那就可以用module.noParse指定不用解析。具体用法如下:

1
2
3
module: {
noParse: /jquery/
}

十一、Chunk

1.什么是Chunk

Chunk在Webpack里面指一个代码块,是多个module的集合,也是多个bundle的集合。

2.Chunk与module的关系

Chunk是多个module的集合,当开发者在entry配置一个入口文件,Webpack就会寻着这个入口文件找到所有相关模块,最终打包成一个Chunk。多个entry就有多条Chunk。

3.Chunk与bundle的区别

通常我们会以为bundle等于Chunk,但它们其实是有区别的。在大多数情况下,Webpack构建完成之后只生成一个bundle.js,这让我们误以为bundle就是Chunk。
但当我们把devtool设置为source-map时,就会产生一份sourcemap文件。而这份sourcemap文件也属于一个Chunk里面的,如下图:

也就是说,Chunk包含bundle

4.产生Chunk的途径

  • entry产生Chunk

entry的配置有三种方式:

  • 传递一个字符串

比如这样:

1
entry: './src/js/main.js'

这种情况只会产生一个Chunk

  • 传递数组

像这样:

1
entry: ['./src/js/main.js', './src/js/other.js']

这种情况也只会产生一个Chunk。Webpack会将数组里的多个文件都打包成一个bundle。

  • 传递对象
1
2
3
4
5
6
7
8
entry: {
main: './src/js/main.js',
other: './src/js/other.js'
},
output: {
path: path.resolve(__dirname, '/public'),
filename: '[name].js'
}

这里一个字段会产生一个Chunk,所以在output.filename中写死名称会报错,需要用[name]来区分不同的Chunk

  • 异步产生的Chunk

异步加载的模块,也会单独生成一个Chunk,比如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
entry: {
'index': 'pages/index.jsx'
},
output: {
filename: '[name].min.js',
chunkFileName: '[name].min.js'
}
}
// require.ensure是用来异步加载文件
// 第一个参数是依赖模块
// 第二个参数是要引入的模块
// 第三个参数是模块的名称
const MyModel = r => require.ensure([], () => r(require('./MyVue.vue')), 'myModel')

这里的chunkFileName就是用来定义异步加载生成的文件名称。

  • 代码分割产生的Chunk

来看看以下代码会产生几个Chunk,其中main.js和two.js都引用了greeter.js,main.js使用了react.js:

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
module.exports = {
entry: {
main: __dirname + '/app/main.js',
other: __dirname + '/app/two.js'
},
output: {
path: __dirname + '/public', // 打包后文件存放的地方
filename: '[name].js',
chunkFileName: '[name].js'
},
optimization: {
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
commons: {
chunks: 'initial',
minChunks: 2,
maxInitialRequests: 5,
minSize: 0
},
vendor: {
test: /node_modules/,
chunks: 'initial',
name: 'vendor',
priority: 10,
enfore: true
}
}
}
}
}

答案是五个。
两个入口各一个
runtimeChunk: ‘single’会将Webpack浏览器端运行时需要的代码抽离到一个文件。
common配置下会产生一个Chunk
vendor配置下会产生一个Chunk。
具体如下图:

十二、FAQ

1.模块打包的原理是什么?

Webpack实际上为每个模块创造一个导出和导入的环境,并没有改变代码的执行逻辑,代码执行顺序和模块加载顺序跟原来一样。

2.如何对bundle体积进行监控和分析?

VSCode中有一个插件叫Import Cost,可以帮助我们对引入模块的大小进行实时地监控。
还可以使用webpack-bundle-analyzer生成bundle的模块组成图,显示所占的体积。
bundlesize工具包可以进行自动化资源体积管理

3.文件指纹是什么?怎么用?

文件指纹指的是文件名中包含的那串hash码,一般用来确定文件的唯一性。

  • JS文件指纹设置
1
2
3
4
5
6
7
8
9
10
module.exports = {
entry: {
app: './src/app.js',
search: './src/search.js'
},
output: {
filename: '[name][chunkhash:8].js',
path: __dirname + '/dist'
}
}
  • CSS文件指纹设置

这里用到了插件MiniCssExtractPlugin

1
2
3
4
5
6
7
module.exports = {
plugins: [
new MiniCssExtractPlugin({
filename: '[name][chunkhash:8].css'
})
]
}
  • 图片文件指纹设置

这里用到了file-loader,具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
module: {
rules: [
test: /\.(png|svg|jpg|gif)$/,
use: [
{
loader: 'file-loader',
options: {
name: 'img/[name][hash:8].[ext]'
}
}
]
]
}
}

4. Babel的原理是什么?

大多数JavaScript Parser都遵循estree的规范,Babel最初基于acorn项目
Babel大概分为三个部分:

  • 解析:将代码转成AST
    • 词法分析:将代码分割为token流,即语法单元组成的数组
    • 语法分析:分析token流,并生成AST
  • 转换:访问AST节点进行变换操作生成新的AST
    • Taro就是利用babel完成小程序的语法转换
  • 生成:以新的AST为基础生成代码

十三、Webpack的简易实现

由于Vue遵循的是ES Module规范,而NodeJS遵循的是CommonJS规范。这些都不是所有浏览器都支持的,因此Webpack的主要工作就是将这些规范转换成浏览器可以理解的方式,最后使用<script>标签加载。
比如说有下面这三个文件:
src/index.js

1
2
const ptintA = require('./a')
printA()

src/a.js

1
2
3
4
5
6
const printB = require('./b')

module.exports = function printA() {
console.log('module A')
printB()
}

src/b.js

1
2
3
module.exports = function printB() {
console.log('module B')
}

执行npx webpack –mode development打包产出dist/main.js文件,如下图

这里面主要就三个属性一个函数。
第一段__webpack_modules__中,require被替换成__webpack_require__,这是Webpack内部实现的导入导出环境。而其他部分代码基本是不变的。
第二段的__webpack_module_cache,用来将缓存__webpack_require__加载的模块,加快二次运行的速度,也能避免加载两个互相引用的模块。
第三段就是__webpack_require__的实现。
最后一段就是入口文件的加载。

因此如果我们想实现一个Webpack,其核心工作就是找到所有依赖,然后将相关的导入导出语句给替换成Webpack自己的语句即可。最终生成类似上图的代码。

那么接下来就来动手实现一个简单的Webpack。

首先梳理一下思路,要做的事情有下面这些:

  • 读取入口文件,并收集依赖信息
  • 递归地读取所有依赖模块,产出完整的依赖列表
  • 将各模块内容打包成一份完整的可运行的代码

一开始肯定需要创建一个项目,这个就不多说了。
接着安装依赖:

1
2
npm init -y
npm i @babel/core @babel/parser @babel/traverse webpack webpack-cli -D

其中:

  • @babel/parser:用于解析代码,生成AST
  • @babel/traverse:用于遍历AST,将require语句改为_require_,将引入路径改为相对根路径
  • @babel/core:用于将修改后的AST转换成新的代码输出
    创建一个入口文件myPack.js并引入依赖
    1
    2
    3
    4
    5
    const fs = require('fs')
    const path = require('path')
    const parser = require('@babel/parser')
    const traverse = require('@babel/traverse').default
    const babel = require('@babel/core')
    紧接着,我们需要对某一个模块进行解析,并产出模块信息。包括模块路径、模块依赖、模块转换后的代码
    具体代码如下:
    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
    // 保存根路径,所有模块根据根路径产出相对路径
    let root = process.cwd()

    function readModuleInfo(filePath) {
    // 准备好相对路径作为module的key
    filePath = './' + path.relative(root, path.resolve(filePath).replace(/\\+/g, '/'))
    // 读取源码
    const content = fs.readFileSync(filePath, 'utf-8')
    // 解析源码,生成AST
    const ast = parser.parse(content)
    // 用于存储依赖的模块
    const deps = []
    traverse(ast, {
    CallExpression: ({ node }) => {
    // 如果是require语句,就收集依赖
    if (node.callee.name === 'require') {
    // 改造require关键字
    node.callee.name = '_require_'
    let moduleName = node.argument[0].value
    moduleName += path.extname(moduleName) ? '' : '.js'
    moduleName = path.join(path.dirname(filePath), moduleName)
    moduleName = './' + path.relative(root, moduleName).replace(/\\+/g, '/')
    deps.push(moduleName)

    // 改造依赖路径
    node.argument[0].value = moduleName
    }
    }
    })

    // 编译回代码
    const { code } = babel.tranformFromAstSync(ast)

    return {
    filePath,
    deps,
    code
    }
    }
    接下来,从入口出发递归找到所有模块,并构建依赖树:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function buildDependencyGraph(entry) {
    // 获取入口模块信息
    const entryInfo = readModuleInfo(entry)

    // 项目依赖树
    const graphArr = []
    graphArr.push(entryInfo)

    // 从入口出发,递归查找所有依赖模块,保存到graphArr中,形成依赖树
    // 原文这个写法有问题,不是真正的递归
    for (const module of graphArr) {
    module.deps.forEach(depPath => {
    const moduleInfo = readModuleInfo(path.resolve(depPath))
    graphArr.push(moduleInfo)
    })
    }
    }
    经过上一步,我们已经得到依赖树,接下来就是按照目标格式进行打包输出即可:
    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
    function pack(graph, entry) {
    const moduleArr = graph.map(module => {
    return {
    `"${module.filePath}": function(module, exports, _require_) {
    eval(\`` +
    module.code +
    `\`)
    }`
    }
    })

    const output = `;(() => {
    var module = {
    ${moduleArr.join(',\n')}
    }
    var modules_cache = {}
    var _require_ = function(moduleId) {
    if (modules_cache[moduleId]) return modules_cache[moduleId].exports

    var module = modules_cache[moduleId] = {
    exports: {}
    }
    modules[moduleId](module, module.exports, _require_)
    return module.exports
    }

    _require_('${entry}')
    })()`
    return output
    }

最后编写一个入口函数main用以启动打包进程

1
2
3
4
function main(entry = './src/index.js', output = './dist.js') {
fs.writeFileSync(output, pack(buildDependencyGraph(entry), entry))
}
main()

参考:

Webpack常见面试题总结
使用 Acorn 来解析 JavaScript
当面试官问Webpack的时候他想知道什么
「吐血整理」再来一打Webpack面试题
浅谈 webpack 性能优化(内附巨详细 webpack 学习笔记)
webpack 最佳实践
学习 Webpack5 之路(基础篇)
Webpack 理解 Chunk
require.ensure简单理解
学习 Webpack5 之路(优化篇)
90 行代码的webpack,你确定不学吗?
Webpack - 手把手教你写一个 loader / plugin