likes
comments
collection
share

从架构的角度看 webpack5 的编译主流程

作者站长头像
站长
· 阅读数 3

一、前文回顾

上文详细讨论了 Compiler 的构造函数中的重要细节工作,包括以下几点:

  1. 调度整个构建过程的 compiler.hooks 的注册,并且挑选其中具有代表性的钩子进行了讲解,包括其类型、参数、调用时机、作用等;
  2. 介绍了 webpack 的四个文件系统包括,负责输入、输出、中间产物、watch 四种文件系统;outputFileSystem:用于向文件系统写入的场景;intermediateFileSystem:这个属于 webpack 特有的文件系统,负责 webpack 构建过程中的中间产物,比如持久化缓存的读写工作;inputFileSystem:用于 webpack 文件的读场景;
  3. watchFileSystem:该文件系统封装了监听文件变化的能力,用于 watch 模式下的监听文件变化的文件系统;
  4. records 对象的作用和相关输入和输出路径;
  5. resolverFactory 的实例化以及 resolverFactory 的作用;
  6. 持久化缓存的声明及 compiler.cache、IdleFileCachePlugin、PackFileSCachetrategy 三者间的关系;

从本文开始正式进入到了编译阶段,进入的标志是 compiler.run 方法的调用,本文将详细讨论 run 方法的工作;

二、回顾编译流程启动

前面在介绍创建 Compiler 的过程中提及过,调用 createCompiler 方法后得到 compiler 对象,接着就是根据是有 watch 选项选择调用 compiler.watch() 或者 compiler.run() 方法;


const webpack = (options, callback) => {
    const create = () => { /*创建 compiler 的逻辑 */ };
    const { compiler, watch, watchOptions } = create();
    // 启动编译流程
    if (watch) {
        compiler.watch(watchOptions, callback);
    } else {
        compiler.run((err, stats) => {});
    }
    return compiler;
);

watch 模式相关内容我们后面用专门的篇幅去讲,这里我们不做讨论,我们的中心放在一次性编译的启动方法 compiler.run()

三、compiler.run 方法

该方法来自 Compiler.prototype.run 方法:

  1. 方法定义位置:webpack/lib/Compiler.js -> Compiler.prototype.run
  2. 参数:callback,受理编译结论的回调函数;
  3. 调用实参:webpack/lib/webpack.js 调用时 :compiler.run((err, stats) => { /*这个就是 callback */ });

3.1 整体结构

class Compiler {
    constructor () {}
    
    run(callback) {
        if (this.running) {
            return callback(new ConcurrentCompilationError());
        }

        const finalCallback = (err, stats) => {};

        this.running = true;

        const onCompiled = (err, compilation) => { };

        const run = () => {};

        if (this.idle) {
            this.cache.endIdle(err => {
                this.idle = false;
                run();
            });
        } else {
            run();
        }
    }
}

经过剥离后,我们可以看到该方法内做了以下内容:

  1. 判断 compiler.running 状态,防止重复执行相同编译任务,若处于执行状态(this.running 为 true)则抛出异常终止本次调用;
  2. 声明 finalCallback 这个作为编译流程的最终回调,持久化缓存的写入信号就是在这里释放的;
  3. 设置 compiler.running 为 true;
  4. 声明内部 run 方法,该方法封装了启动的具体工作;
  5. 声明 onCompiled 内部方法,用于处理编译过程中的事件回调,根据编译的状态和钩子函数的返回值执行不同的操作;
  6. 判断当前的空闲状态:compiler.idle,该标识在持久化缓存写入的时候为 true,根据该状态不同有以下处理:
    • this.idle 为 true,则需要等到缓存处理结束的回调里调用 run() 方法启动编译;
    • this.idle 为 false,则直接调用 run 方法;

3.2 run 方法

这个私有的 run 方法作为启动入口,我们先来研究它,先来看下整体:

const run = () => {
    this.hooks.beforeRun.callAsync(this, err => {
        if (err) return finalCallback(err);

        this.hooks.run.callAsync(this, err => {
            if (err) return finalCallback(err);

            this.readRecords(err => {
                if (err) return finalCallback(err);

                this.compile(onCompiled);
            });
        });
    });
};

该方法的整体结构还是比较简单的,很清晰,是个回调的嵌套,很显然是个明确了先后顺序的事儿:

3.2.1 compiler.hooks.beforeRun.call

触发 compiler.hooks.beforeRun 钩子,传入 compiler 实例和回调,订阅该钩子的插件有:

  1. NodeEnvironmentPlugin:
class NodeEnvironmentPlugin {  

constructor(options) {  
    this.options = options;  
}  

apply(compiler) {  

    compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {  
        if (compiler.inputFileSystem === inputFileSystem) {  
            compiler.fsStartTime = Date.now();  
            inputFileSystem.purge();   // 出清文件系统
        }  
    });  
    }  
}

如果输入文件系统是给定的inputFileSystem,则记录当前时间并清除该文件系统。

此前的文件系统中的内容没用了,这已经是一次新编译了,前面的文件输入不要了。

  1. ProgressPlugin:
class ProgressPlugin {
    constructor () {}
    apply (compiler) {
         interceptHook(compiler.hooks.beforeRun, 0.01, "setup", "before run");
    }
}

简化后的插件定义的比较简单,这个插件顾名思义,输出 webpack 构建进度的,订阅 beforeRun 的钩子,输出 0.01。你会发现这个进度是人为定义的😂。

TIPS: 如果想看着快,这里直接输出99%😂

3.2.2 compiler.hooks.run.call

在 compiler.hooks.beforeRun 的回调里触发 hooks.run 钩子,webpack 内部暂无插件订阅该钩子;

3.2.3 调用 compiler.readRecords 方法

class Compiler {
    readRecords(callback) {
        if (this.hooks.readRecords.isUsed()) {
                if (this.recordsInputPath) {
                        asyncLib.parallel([
                                cb => this.hooks.readRecords.callAsync(cb),
                                this._readRecords.bind(this)
                        ]);
                } else {
                        this.records = {};
                        this.hooks.readRecords.callAsync(callback);
                }
        } else {
                if (this.recordsInputPath) {
                        this._readRecords(callback);
                } else {
                        this.records = {};
                        callback();
                }
        }
    }

}

首先判断是否注册了 compiler.hooks.readRecords 钩子,如果有则判断是否有 recordsInputPath 配置,有则触发 this.hooks.readRecords,这样会触发使用 records 的相关插件执行。然后调用 this._readRecords 方法:

class Compielr {
    //....
    _readRecords(callback) {
        if (!this.recordsInputPath) {
            this.records = {};
            return callback();
        }
        this.inputFileSystem.stat(this.recordsInputPath, err => {
            this.inputFileSystem.readFile(this.recordsInputPath, (err, content) => {
                this.records = parseJson(content.toString("utf-8"));

                return callback();
            });
        });
    }
}

_readRecords 方法的实现也很简单,调用前面我们说过的 inputFileSystem.stat 判断给定的 recordsInputPath 是否存在,若存在则读取这个 records 文件,得到结果后解析成 json 对象并复制给 this.records 属性;

如果没有 recordsInputPath,则直接置 this.records 为对象,然后触发 this.hooks.readRecords 钩子;

3.2.4 调用 compiler.compile() 方法

在完成上面的 records 文件读取后,即 this.readRecords 的回调中,调用 this.compile() 方法并传入 onCompiled;

compile 方法封装了编译的流程,即包含了 compiler 的生命周期: compiler.hooks.beforeCompile 到 hooks.afterCompile 中间的所有过程;

这个方法我们后面展开讲,这里不做过多的展开。

3.2.5 onCompiled 函数

on 这个介词表示在一个具体的时刻,所以 compiled 后面肯定是标识的是编译结束后。所以这个命名就是处理编译结束后的工作的。

上面的 compiler.compile 方法处理的 beforeComile 到 afterCompile 钩子,onCompiled 函数内部则处理后续的编译产物和 records 的写入工作是否继续,并在得到肯定结果后进行写入工作。

  1. 参数:

    • 1.1 err 对象,编译失败
    • 1.2 compilation对象:
  2. 详细工作如下:

    • 2.1 判断 err,若 err 非空则说明编译失败,调用 finalCallback 终止编译;
    • 2.2 传入 compilation 触发 this.hooks.shouldEmit.call 钩子,这个 shouldEmit 标识是否输出编译产物,如果不想让产物输出,则可以订阅这个钩子,并在这个钩子最后返回 false,这样即可阻止产物写入本地文件系统(本地磁盘);
    • 2.3 在下个事件循环开头开始执行文件写入工作,这些工作位 compiler.emitAssets 封装,这里不展开这个方法;
    • 2.4 完成编译产物的写入工作后(this.emitAssets 方法的回调)调用 compiler.emitRecords 方法进行 records 文件的写入工作;
    • 2.5 records 完成后调用 finalCallback 函数完成最终的收尾工作;
const onCompiled = (err, compilation) => {
    if (err) return finalCallback(err);

    if (this.hooks.shouldEmit.call(compilation) === false) {
       // 阻止文件写入
        compilation.startTime = startTime;
        compilation.endTime = Date.now();
        const stats = new Stats(compilation);
        this.hooks.done.callAsync(stats, err => {
                if (err) return finalCallback(err);
                return finalCallback(null, stats);
        });
        return;
    }

    process.nextTick(() => {
        // 写入文件
        this.emitAssets(compilation, err => {
        
            if (compilation.hooks.needAdditionalPass.call()) {
              // 暂时忽略
            }

            
            this.emitRecords(err => {
                 
                compilation.startTime = startTime;
                compilation.endTime = Date.now();
                
                const stats = new Stats(compilation);
                this.hooks.done.callAsync(stats, err => {

                    this.cache.storeBuildDependencies(
                            compilation.buildDependencies,
                            err => {

                                    return finalCallback(null, stats);
                            }
                    );
                });
            });
        });
    });
};

3.2.6 finalCallback 函数

finalCallback 顾名思义,最后的回调了,这个用于处理 compiler.run 方法的收尾工作;

  1. 函数的参数:
    • 1.1 err,错误对象,标识编译失败;
    • 1.2 stats,stat 统计信息对象,编译的时候的错误、bundle 信息等都包含在内了;
  2. 具体工作如下:
    • 2.1 将 compiler.idle 置为 true,前面说过这个 idle 是处理持久化缓存的标识,开始空闲起来了(我愿将 idle 翻译成 摸鱼 😂)
    • 2.2 调用 compiler.cache.beginIdle() 方法,前面讲过 compiler.cache 对象负责调度缓存的读写工作,这里开始 compiler.cache.beginIdle 发出了 “编译器空闲,请启动缓存写入工作” 的指令,后面的工作交给 IdleFileCachePlugin 和 PackFileCacheStrategy 完成;
    • 2.3 compiler.running 置为 false,标识本编译器停止工作了(下班了,溜了溜了😂)
    • 2.4 如果有 callback 则调用 callback,这个 callback 是前面 webpack-cli 里面启动编译器时传入的,这里算是 webpack 编译器和 webpack-cli 在通信了。
    • 2.5 触发 compiler.hooks.afterDone 钩子,告知订阅插件做收编译工作总结,编译工作收官;
const finalCallback = (err, stats) => {
   
    this.idle = true;
    this.cache.beginIdle();
    this.idle = true;
    
    this.running = false;
    if (err) {
            this.hooks.failed.call(err);
    }
    if (callback !== undefined) callback(err, stats);
    this.hooks.afterDone.call(stats);
};

四、总结

本篇小作文完成了启动编译流程的 compiler.run 方法的详细内容讲解,代码执行肯定是个深度优先的事儿,但是我这里是个广度优先

所谓启动,其实是整个编译流程的统揽,看完这篇你就可以大大方方的告诉面试官,webpack 编译大致流程了。

之所以不过度展开,是因为我之前看代码经常丢失主线剧情,这也是导致看不懂的主要原因,因此重点放在主线剧情很多问题都会迎刃而解。

下面整体回顾一下全文:

  1. compiler.run 方法内部封装 run 方法,run 来负责具体的工作;
  2. run 方法首先触发 hooks.beforeRun,触发 NodeEnvironmentPlugin 和 ProgressPlugin
  3. 接着触发 hooks.run 钩子,暂无插件;
  4. 调用 compiler.readRecords 方法读取 records 文件,并介绍了 compiler._readRecords 方法的具体实现;
  5. 调用 compiler.compile 方法开启编译流程;
  6. 介绍了处理 compiler.compile 编译结果的 onCompiled 回调;
  7. 介绍了处理最终编译结果并负责和 webpack-cli 通信的 finalCallback 回调函数;