Webpack源码浅析

吐槽君 分类:javascript

前言

上一篇文章中我们实现了一个简易打包器,简单了解了 webpack 是的工作原理,为了验证我们的猜想对不对,那我们就去源码找找看吧

How 怎么读源码

读源码之前,首页要准备好源码(废话)

有两种获得源码的方式

第一种git clone , 这种方法可以得到各个版本的 webpack 代码,当 webpack 更新的时候也可以通过 git pull 拉取最新代码,不过可能是网络原因,我是使用 git clone 一直无法拉取下来,退而求其次我选择了第二种方法

第二种是通过 releases 下载,那就需要选择对应的版本下载,这里我选择的是 5.10.0 版本,下载对应的压缩文件即可

带着问题读源码

webpack 代码量极大,里面分支众多,错综复杂,如果想一行一行的把代码都读一遍,显然是不可行的,也是低效的,那么我们的目标应该是只看核心代码,了解 webpack 打包的整体流程

为了了解 webpack 的整体运行流程这一个大目标,我们需要把它分解成一个个小目标或问题,通过不断地拆解问题,找到答案的过程中,了解整个脉络

那么第一问题是: 编译的起点?

源码解析

如何运行 webpack

在问题开始之前,我们先了解一个基本知识,启动 webpack 有两种方法

第一种方法 使用 webpack-cli 从命令行启动

npx webpack-cli
 

这是最快最便捷的方法,默认的打包入口是 src/index.js

第二种方法 通过 require('webpack') 引入包的方式执行

两种方式都会调用 webpack 来打包代码,只是操作方式不同

问题一:编译的起点

一切从 compiler = webpack(options, callback); 开始

webpack 函数源码(lib/webpack.js)

const webpack = ((options,callback) => {
    const create = () => {
        ...
        let compiler;
        if (Array.isArray(options)) {
            ...
        } else {
            /** @type {Compiler} */
            compiler = createCompiler(options);
            ...
	}
	return { compiler, watch, watchOptions }
    };
    if (callback) {
        try {
            const { compiler, watch, watchOptions } = create();
                if (watch) {
                    compiler.watch(watchOptions, callback);
		} else {
                    compiler.run((err, stats) => {
			compiler.close(err2 => {
                            callback(err || err2, stats);
			});
                    });
		}
		return compiler;
            } catch (err) {...}
	} else {...}
});
 

这段代码中可以看到 compiler.run 是核心代码,代表者编译的开始,而 createCompiler 里面主要是实例化 compiler 和做一些初始化,比如读取配置,激活 webpack 的内置插件...

webpackcompiler 是核心对象之一,通过 new Compiler 实例化,compiler 记录了完整的 webpack 环境信息,webpack 从启动到结束,compiler 只会被生成一次,你可以在 compiler 对象上读取到 webpack config 信息,outputPath

comilper.run() 编译真正开始了

Tapable

在阅读 webpack 源码的时候,经常会看到 xxx.hooks.xxx,这实际上是一个事件管理库 Tapable, 想要继续阅读源码就必须快速了解一下 Tapable 的基本用法

// 定义一个事件/钩子
this.hooks.eventname = new SyncHook(['arg1', 'arg2'])
// 监听一个事件/钩子
this.hooks.eventName.tab('监听理由',fn)
// 触发一个事件/钩子
this.hooks.eventName.call('arg1', arg2)
 

问题二:webpack 的流程是怎样的

上文说到 compiler.run() 是编译的开始,那我们跟着这个方法,进去看吧

找到 Compiler 类的定义文件 lib/Compiler.js (源码)

run(callback) {
    const onCompiled = (err, compilation) => {...};
    const run = () => {
        this.hooks.beforeRun.callAsync(this, err => {
            ...
            this.hooks.run.callAsync(this, err => {
                ...
                this.readRecords(err => {
                    ...
                    this.compile(onCompiled);
               });
            });
          });
    };

    if (this.idle) {
        ...
	run();
    });
    } else {
        run();
    }
}
 

compiler.run 的代码中,我们可以看到在编译的过程中,会在相应的阶段触发钩子,如 hooks.beforeRun hooks.run,这种方式就很像我们在用 vue 或者 react 中的生命周期钩子,第三方插件可以定义对于不同的事件的处理函数,在相应的编译时间段触发,来实现插件的效果

在阅读的过程中,我们应该把重要的钩子和方法记录下来,方便阅读

比如上面的代码就可以记成

new Compiler // 重要的方法
-env         // -hooks 代表钩子
-init
compiler.run   
-beforeRun    
-run
compiler.readRecords
compiler.compiler
...
 

通过这种方法,我们可以快速了解 webpack 的流程

接着上面的代码,我们要开始看 compiler.compiler

webpack 源码 lib/Compiler.js

compile(callback) {
    const params = this.newCompilationParams();
    this.hooks.beforeCompile.callAsync(params, err => {
        this.hooks.compile.call(params);
        const compilation = this.newCompilation(params);
        this.hooks.make.callAsync(compilation, err => {
            this.hooks.finishMake.callAsync(compilation, err => {
                process.nextTick(() => {
                    compilation.finish(err => {
                            compilation.seal(err => {
				this.hooks.afterCompile.callAsync(compilation, err => {
                                    return callback(null, compilation);
                                });
                            });
                        });
                    });
                });
            });
        });
}
 

因为源码细节比较多,所以上面简略了一些错误处理和log信息

继续记录编译过程

new Compiler // 重要的方法
-env         // -hooks 代表钩子
-init
compiler.run   
-beforeRun   
-run
compiler.readRecords
compiler.compiler
-beforeCompile
-compile
newCompilation
-make
-finishMake
nextTick
compilation.finish
compilation.seal
-afterCompile
 

最终我们基本可以知道 webpack 的编译可以分为 env > init > run > compile > compilation > make > finishMake > afterCompile > seal > emit 这几个阶段

另外从代码中我们看到了一个重量级的对象 compilation, 看名字就可以知道,这个对象在编译过程中一定是起着重要作用的。compilation 代表一次单一的编译作业,compilation 记录了当前编译作业的模块资源和编译生成的文件、以及依赖信息等。

问题三:读取 index.js 并分析依赖是在哪个阶段

按照我们刚刚分析的几个阶段,我们首先可以排除 env init(猜的),肯定是在 compiler > afterCompile 之间

make > finishMake 这几个阶段的可能性是最大的

为什么呢?了解过 C 语言就会知道,make是编译时必须用到的工具

那么我们就在这两个阶段中寻找

直接贴上源码

image.png

上面的代码除了 logger 和 错误处理之后,好像什么都没做,怎么就从 make 变成 finishMake 了呢?

image.png

问题四:make-finishMake 之间做了什么

还记得之前提到的 Tabable 库吗?也就是 webpack 的事件分发系统,那么我们可以从 make 这个事件绑定的处理函数开始下手

直接全局搜索 make.tapAsync 可以发现都是插件在定义处理函数

image.png

其中 EntryPlupin 特别显眼,编译既然要分析依赖,分析之前就要有入口,那么 entryPlupin 显然是必须看的

查看 make.tapAsync("EntryPlugin") 代码可以发现,里面实际上调用了 compilation.addEntry, 经过多次的查找 compilation.addEntry -> compilation._addEntryItem -> compilation.addModuleChain -> compilation.handleModuleCreation

我们研究一下 handleModuleCreation

handleModuleCreation({ factory, dependencies, originModule, context, recursive = true },callback) {
    ...
    this.factorizeModule({ currentProfile, factory, dependencies, originModule, context },(err, newModule) => {
        ...
        this.addModule(newModule, (err, module) => {
            ...
            this.processModuleDependencies(module, err => {
                ...
                callback(null, module);
            });
        });
    }
    );
}
 

可以看到这里有很多重要步骤,我们从第一开始分析,也就是 this.factorizeModule(), 点击这个函数,我们最终会看到 this.factorizeQueue.add(options, callback)

那么 factorizeQueue 到底是什么呢? 可以看看他的定义

image.png

这时我们应该查看它的处理器 processor,在 this._factorizeModule 中,我们最终可以看到是 factory.create()

问题五:factory.create 是什么东西

image.png

在这里我们需要摸着参数一个个根据调用关系往上找,最终在 addModuleChain 中我们找到了线索,factory 是从 this.dependencyFactories.get(Dep) 得到的

image.png

然后线索就中断了 this.dependencyFactories.get(Dep) 是什么呢? 既然有 get 那就应该有 set,经过再次全局搜索(全靠运气),终于在 EntryPlugin 中找到了

image.png

factory 就是 normalModuleFactory

那么 factory.create 就是 normalModuleFactory.create

由于 normalModuleFactory 太长了,以下简称 nmf

阶段性小结

目前为止我们了解到

  • webpack 使用 hooks 把主要的阶段固定下来
  • webpack 执行过程中,插件自己选择阶段做事
  • 入口是由入口插件 EntryPlugin.js 搞定的

理清思路后,继续探究...

问题六:nmf.create 做了什么

源码 nmf.create

create(data, callback) {
    ...
    this.hooks.beforeResolve.callAsync(resolveData, (err, result) => {
        ...
        this.hooks.factorize.callAsync(resolveData, (err, module) => {
            ...
            callback(null, factoryResult);
        });
    });
}
 

通过这里我们可以看到实际上触发了两个 hooks ,boforeResolvefactorize , 通过查看对应的事件处理函数,我们发现 factory.tap 里面由重要代码

它触发了 resolveresolve 主要的作用就是收集 loaders

然后它触发了 createModule 得到了 createdMolude

也就是说,factory.create 的作用是收集 loaders 和得到了 module 对象

问题七:addModule 做了什么

既然我们已经清楚了 factory.create 做了什么,那我们再回到 compilation.factorizeModle()(忘记了可以回看问题四的代码图) , 发现后面的操作是 addModulebuildModule

那么 addModule 做了什么呢?

addModule 就是把一个 module 加入到 compilation.modules 里面

回看问题四里面的代码,我们可以看到这个阶段的 addModule 实际上是把,factory.create() 得到的 newModule 加入到 copilation.modules 里面

问题八:buildModule 做了什么

一看这名字,就知道是很重要的一步

buildModule 实际上是调用了 normalModule.buildnormalModule.build 则调用了自身的 doBuild 方法

const { runLoaders } = require("loader-runner");
doBuild(options, compilation, resolver, fs, callback){
    // runLoaders从包'loader-runner'引入的方法
    runLoaders({
        resource: this.resource,  // 这里的resource可能是js文件,可能是css文件,可能是img文件
        loaders: this.loaders,
    }, (err, result) => {
        const source = result[0];
        const sourceMap = result.length >= 1 ? result[1] : null;
        const extraInfo = result.length >= 2 ? result[2] : null;
        // ...
    })
}
 

doBuild 函数里面可以看到调用了 runLoaders 方法,作用是通过 loader 将一些非 js 的文件,比如 css,html..., 转成 js(webpack 只能识别 js),runLoaders 之后得到 result 继续下一步处理

接下来就是把 js 转成 ast 代码了

result = this.parser.parse(source);

其中 this.parse.parse 实际上是一个第三方库 acorn 提供的 parse 方法

parse(code, options){
    // 调用第三方插件`acorn`解析JS模块
    let ast = acorn.parse(code)
    ...
    if (this.hooks.program.call(ast, comments) === undefined) {
        this.detectStrictMode(ast.body)
        this.prewalkStatements(ast.body)
        this.blockPrewalkStatements(ast.body) // 分析依赖
        this.walkStatements(ast.body)
    }
}
 

问题九:webpack 如何知道 index.js 依赖了哪些文件的

上一个问题,我们已经分析到了 webpack 会使用 runLoaders 把代码都转成 js,然后通过 acorn 库,将 js 转换成 ast 代码

那么 webpack 如何分析依赖呢?

按照上篇文章的原理分析,应该会 traverse 这个 ast,寻找 import 语句

在源码中可以发现 this.blockPrewalkStatements(ast.body)ImportDeclaration 进行了检查,一旦发现 import 'xxx', 就会触发 import 钩子,对应的监听函数会处理依赖,将依赖加入到 module.dependencies 的数组中

image.png

问题十:怎么把 module 合并成一个文件

经过上面的分析,我们大概知道 webpack 分为 env > compile > make > seal > emit 这几个阶段

我们可以猜出合并文件会在 seal > emit 阶段之间

在这个阶段调用了 compilation.seal, 该函数会创建 chunks、对每个 chunk 进行 codeGeneration, 然后为每个 chunk 创建 asset

seal 之后就是 emit 阶段,也就是发射,很明显就行文件写出去,最终得到 dist/main.js 和其他 chunk 文件

总结

本文篇幅比较长,非常感谢你阅读到最后,最后简单总结一下 webpack 的基本流程:

  1. env 和 init 阶段,调用 webpack 函数接收 config 配置信息,并 apply 所有 webpack 内置插件
  2. 调用 compiler.run 进入编译阶段
  3. make 阶段,从 entry 为入口,通过 loaders 对模块的源代码进行转换成 JS 模块,然后使用 acorn parse 成 ast(抽象语法数),再遍历语法树分析和收集依赖
  4. seal 阶段,webpackmodule 转为 chunk,每个 chunk 可以是一个模块或多个模块
  5. emit 阶段,为每个 chunk 创建文件,并写入硬盘

码字不易,如果这篇文章对你有用请点个赞吧

如果有写得不好的地方,欢迎大佬们指导

回复

我来回复
  • 暂无回复内容