手握手教你解读webpack源码

364 阅读25分钟

背景

俗话说:“授人以鱼不如授人以渔”,重要的不是解读源码,而是,手把手教会其他人如何解读源码的方法,才是真正的“授人以渔” 。所以,想着以工程化打包工具webpack为例,深度解析其中的原理和思路。和大家一起学习,实现共用学习,共同进步,共用成长。

在工作中,大家都会使用各种打包工具。目前,大家耳熟能详的有webpack,esbuild,rollup,vite等。但是大家少有机会深度了解,要不已经搭建好了相关的配置,要不有工程模版。当遇到问题相关打包问题时,翻阅官方文档快速的解决问题,成为了一个“会用就行”的配置工程师。那么,是否真的了解其中的流程和机制吗?对大部分工程师来说,工具都是一个“黑盒”,大家会用就行。但是,要拒绝平庸,从 webpack 的深度剖析开始,希望能学有所得!

导读

webpack目前已进入5.x版本时代,集成了非常非常多的能力,其中包括:代码打包,Tree-shaking,代码分割,HMR,Plugins,Loader,sourceMap等等。对应的代码量也到达恐怖的程度,阅读源码的上手成本非常非常的高。那么,我们如何上手阅读源码,从哪里看起,具体怎么看呢?

在这里,提供几个方式

  1. 专注主流程,针对于webpack初始化 -> 递归解析构建 -> 生成代码的主流程进行分析。当然,针对一些重要的上下文和相关知识也要进行拓展。

  2. 调试代码,通过VsCode打断点对webpack流程进行调试,方便理解

  3. 画图总结,针对于各种功能,代码结构等,进行画图总结能加深理解。在这里推荐泳道图和流程图

  4. 注重思想,注重代码涉及的设计模式,架构设计等核心思想,对其深入挖掘,定能有所收获

本文以上述为主旨,针对其他涉及的核心知识点会在**「知识点Tips章节」**进行补充说明 。如果有不正确的地方,欢迎反馈指正

基本知识

Entry: 指定从哪个路径开始构建其内部依赖关系图,可以是多个入口

Output: 指定bundles命名和输入路径

Loaders: 由于webpack只能理解 JavaScript 和 JSON 文件,Loader帮助webpack处理其它类型的文件并转换

Plugins: 用于webpack的拓展,能够执行更广泛的任务,例如bundle优化,资源管理,环境变量注入等

Mode: 参数值为development,production和none,用于开启每个不同的优化

Browser Compatibility: webpack支持所有es5的浏览器,低版本浏览器需要使用polyfill

Module: Webpack Module根据导入语句分析依赖关系,把代码分为不同的模块。例如:ES2015 import,commonJS require、AMD require、@import css/sass/less file,等,文件和资源都属于webpack中的module

chunk: 是指 Webpack 在打包过程中生成的一个独立的文件,由多个module代码组成。chunk通常用于实现按需加载(dynamic import)和代码分割(code splitting)等功能。将代码分割成多个较小的代码块,以优化加载性能和资源利用率。chunk有两种形式:

  1. initial是入口点的主要chunk。 该chunk包含您为入口点指定的所有module及其依赖
  2. non-initial 是一个可能被延迟加载的chunk。 当使用动态导入或SplitChunksPlugin时可能会出现

chunkGraph: 是 Webpack 中表示chunk及其之间关系的结构,用于管理chunk的创建、拆分和合并,以及优化代码块的加载和资源利用

chunkGroups: 由多个chunk组成的集合,包括入口chunkGroups、异步chunkGroups和共享chunkGroups等。用于更高效的构建和加载

前置工作

  1. 创建一个webpack打包的工程,可以参考webpack快速开始(webpack.js.org/guides/gett…

  2. 下载webpack源码(github.com/webpack/web…

  3. 下载webpack-cli源码(github.com/webpack/web…

  4. 创建一个文件夹,把上面三个工程放在一起,并下载依赖

目录结构:

webpack-source
|--myProgram // 测试工程--webpack // webpack源码--webpack-cli // webpakc-cli源码

把webpack-source项目代码提交 github 上了,链接点 这里

webpack 源码版本:v5.90.1

调试源码

首先,捋清楚思路。之后,逐步分析,各个击破

  1. myProgram工程中,如何执行webpack打包操作?

  2. 如何调用本地webpack源码进行打包?

  3. 如何调试webpack源码,剖析打包流程?

webpack打包流程

首先,按照习惯先查看package.json,打开myProgram项目根目录下的package.json文件。发现scripts中定义的build打包命令,其本质上是执行webpack

{
  ......
  "scripts": {
    "build": "webpack",
  },
}

其次,如何定位webpack打包命令执行的脚本在哪里?是在webpack依赖定义的,我们找到myProgram工程根目录下的node_modules/webpack路径。照例先查看package.json文件,发现定义了bin字段。"webpack": "bin/webpack.js"表示定义了一个webpack命令,会执行对应路径的脚本进行处理(在**「知识点Tips」**章节,介绍npm bin字段

{
    ......
    "bin": {
        "webpack": "bin/webpack.js"
    }
}

然后,找到bin/webpacask.js路径,开启debugger模式打断点,跟踪调用堆栈。发现调用runCli方法,其中require路径是node_modules/webpack-cli/bin/cli.js。根据线索继续追踪,找到最后调用的打包方法

最后,绕了一大圈,发现使用npm commander包处理命令CLI。并且,webpack-cli获取webpack.config.js配置文件,执行webpack/lib/webpack.js路径下的webpack(options, callback)方法,把webpack.config配置当作options参数传入。到这里,我们找到了打包核心方法和路径,接下来想想如何调用本地webpack源码?🤔️

调用webapck源码

目前,了解到npm run build打包流程,本质上是调用webpack方法(node_modules/webpack/lib/webpack.js)。那么,自己写一个build.js脚本,导入并调用webpack方法,是不是可行?说干就干,尝试一下💪

第一步:修改myProgram工程下package.json文件定义的build命令,并创建对应build.js文件,内容如下:

{
    "scripts": {
        "build": "node build.js",
    },
}

第二步:解析webpack/lib/webpack.js路径下webpack(options, callback)源码

const webpack = (options, callback) => {
    ......
    if (callback) {
        try {
            const { compiler, watch, watchOptions } = create();
            if (watch) {
                ......
            } else {
                compiler.run(() => {......});
            }
            return compiler;
        } catch (err) {
            ......
        }
    } else {
        const { compiler, watch } = create();
        if (watch) {
            ......
        }
        return compiler;
    }
}

webpack方法接收两个参数,options 和 callback,核心逻辑:

  1. 接收参数

    1. options:webpack.config.js配置参数
    2. callback:compiler.run之后的回调方法
  2. 两种调用方式

    1. 有callback,直接调用compiler.run方法 最后返回compiler对象
    2. 无callback,直接返回compiler对象,由用户调用compiler.run方法和calback
  3. 核心方法,compiler.run()开始执行打包操作

第三步:在build.js文件中引入webpack(options, callback)方法和webpack.config.js配置文件,进行调用

const webpack = require('../webpack-master/lib/webpack')
const config = require('./webpack.config')

// 传入callback调用方法 webpack(config, () => {})  // 不传入callback调用方法 webpack(config).run(() => {}) 

myProgram项目下,执行npm run build命令尝试进行打包。在项目根目录下,成功生成打包产物dist文件夹。同时,也证实了方法可行~

断点调试

最后一步,VsCode开启debug模式,在build.js文件中的webpack(options, callback)方法上打上断点。可以一步步调试,开始上手解读webpack源码。到这里,我们已经完成了相关的准备工作,取得了阶段性成功!🎉

Tips:关于VSCode debug调试的使用,在这里推荐文章《2022年了,该学会用VSCode debug了》

流程总结

核心主流程

主流程图

暂时无法在飞书文档外展示此内容

image.png

初始化阶段

webpack.create()

从构建方法webpack.run()入手阅读,由于这一小节的目标是初始化流程。我们发现重点是compiler.run方法,那看看create方法是如何创建compiler对象,让我们深入了解一下~

const webpack = (options, callback) => {
    ......
    if (callback) {
        try {
            // compiler为核心对象,create方法并返回
            const { compiler, watch, watchOptions } = create(); 
        } catch (err) {
            ......
        }
    } else {
        const { compiler, watch } = create(); 
        ......
        return compiler;
    }
}

const create = () => {
    // 校验webpack.config.js的参数
    validateSchema(webpackOptionsSchema, options);
    let compiler;

    // options为数组的情况较少,忽略
    if (Array.isArray(options)) {
        ......
    } else {
        compiler = createCompiler(options); 
        ......
    }

    return { compiler, watch, watchOptions } ;
};

webpack.create()核心逻辑:

  1. validateSchema校验webpack.config.js的参数

  2. 调用createCompiler(options)方法创建compiler对象

  3. 返回compiler, watch, watchOptions

createCompiler()

继续跟踪createCompiler方法,初始化的核心处理方法都在WebpackOptionsApply()方法中,作用是把webpack配置转化为内置插件plugin进行初始化处理。这是一个值得学习的插件思维,后面小节会深入了解~

const createCompiler = rawOptions => {
    // 对webpack.config进行规范化和默认值操作
    const options = getNormalizedWebpackOptions(rawOptions);
    applyWebpackOptionsBaseDefaults(options);

    // 创建compiler实例
    const compiler = new Compiler(options.context);
    
    // 把node环境变量绑定到compiler实例
    new NodeEnvironmentPlugin({
        infrastructureLogging: options.infrastructureLogging
    }).apply(compiler);
    
    if (Array.isArray(options.plugins)) {
        // 遍历&注册所有自定义plugin
        for (const plugin of options.plugins) {
            if (typeof plugin === "function") {
                plugin.call(compiler, compiler);
            } else {
                plugin.apply(compiler);
            }
        }
    }

    compiler.hooks.environment.call();
    compiler.hooks.afterEnvironment.call();

    // 核心方法,把webpack配置使用内置插件plugin进行初始化处理
    new WebpackOptionsApply().process(options, compiler); 
    compiler.hooks.initialize.call();

    return compiler;
};

createCompiler核心流程:

  1. 调用getNormalizedWebpackOptions + applyWebpackOptionsBaseDefaults对webpack.config对options进行规范化和默认值操作

  2. new compiler(),初始化compiler实例(在**「知识点Tips」**章节,介绍Compiler类

  3. new NodeEnvironmentPlugin调用内置插件,把node环境变量绑定到compiler实例

  4. 遍历options.plugins,注册所有自定义plugin

  5. 调用environment、afterEnvironment生命周期回调方法

  6. new WebpackOptionsApply().process(options, compiler),进行各种options解析

  7. 调用initialize生命周期回调方法,说明初始化已经执行完成

  8. 返回compiler实例

WebpackOptionsApply()

下面我们来了解一下WebpackOptionsApply().process(options,compiler)方法,其主要作用是解析webpack.config.js配置,数十个插件在该方法中完成注册,在合适的时机运行plugin插件,注册&运行依赖于npm tapable。同时,plugins插件和hooks贯穿了webpack全文,是一个重要概念,让我们继续往下看~

class WebpackOptionsApply extends OptionsApply {
    ......
    process(options, compiler) {
        ......
        if (options.externals) {
            const ExternalsPlugin = require("./ExternalsPlugin");
            // 解析options.xxx配置,注册插件进行处理
            new ExternalsPlugin(options.externalsType, options.externals).apply(
                compiler
            );
        }
        // 注册插件进行初始化处理
new EntryOptionPlugin().apply(compiler); 
        // hooks.<hook name>.call调用,plugin插件响应
        compiler.hooks.entryOption.call(options.context, options.entry);
        new RuntimePlugin().apply(compiler);
        ......
    }
}

WebpackOptionsApply()核心流程:

  1. new xxxPlugin().apply(compiler)的写法注册内置插件,用以解析webpack.config.js配置,同时传入compiler实例

  2. 对入口、运行时等进行处理,例如:new EntryOptionPlugin().apply(compiler)

  3. 调用 npm ``tapable方法hooks.<hook name>.call,plugin插件响应(在**「知识点Tips」章节**,介绍tapable

到这里,完成初始化阶段,下一步探索解析构建流程🎉🎉🎉

流程总结

解析构建

Compiler.run

回过头来,继续回到Compiler.run()方法,开始探索解析构建的任务线

const webpack = (options, callback) => {
    ......
    if (callback) {
        try {
            const { compiler, watch, watchOptions } = create();
            ......
            compiler.run(() => {......});
            
            return compiler;
        }
    }
}

run(callback) {
    ......
    // 失败回调
    const finalCallback = (err, stats) => {};

    // compile回调函数
    const onCompiled = (err, compilation) => {};

    // 当前作用域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);
                // 调用readRecords方法,json文件形式记录模块的build变化
                this.readRecords(err => {
                    if (err) return finalCallback(err);
                    // 执行编译
                    this.compile(onCompiled) ;
                });
            });
        });
    };

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

Compiler.run()核心逻辑:

  1. 定义finalCallback失败回调方法,用于err处理
  2. 定义onCompiled方法,传入this.compile用于回调
  3. 执行当前作用域run方法
  4. 调用readRecords方法,json文件形式记录模块的build变化
  5. Compiler.compile(onCompiled),执行编译

Compiler.compile

对核心方法 Compiler.compile(onCompiled)继续跟踪,穷追不舍~

compile(callback) {
    const params = this.newCompilationParams();
    
    this.hooks.beforeCompile.callAsync(params, err => {
        ......
        this.hooks.compile.call(params);
        // 创建Compilation实例
        const compilation = this.newCompilation(params);
        // 传入Compilation实例,调用插件进行构建
        this.hooks.make.callAsync(compilation, err => {
            ......
            this.hooks.finishMake.callAsync(compilation, err => {
                ......
                process.nextTick(() => {
                    // 对mudule上的错误进行处理
                    compilation.finish(err => {
                        ......
                        // 对打包产物封装
                        compilation.seal(err => {
                            ......
                            this.hooks.afterCompile.callAsync(compilation, err => {
                                return callback(null, compilation);
                            });
                        });
                    });
                });
            });
        });
    });
}

Compiler.compile()核心流程:

  1. newCompilationParams方法,初始化Compilation参数
  2. 执行hooks生命周期beforeCompile -> compile -> make -> finishMake -> afterCompile, 可以看出是整个编译的流程,从编译前 -> 编译后
  3. newCompilation方法,创建Compilation实例(在**「知识点Tips」章节**,介绍Compilation类
  4. hooks.make,传入Compilation实例,调用插件进行构建
  5. process.nextTick方法在下一个事件循环迭代之前执行该任务,compilation.finish方法,对mudule上的错误进行处理(在**「知识点Tips」章节**,介绍process.nextTick
  6. compilation.seal方法,对打包产物封装
  7. 构建 & 封装流程结束之后,调用回调(onCompiled)输出

hooks.make

目前,已知hooks.make注册插件会处理编译,但怎么定位到是具体哪一个插件?如何去顺藤摸瓜找到最后的真相?靠直觉,还是靠有理有据的连蒙带猜,让我们一起尝试找找~

第一步:全局搜索

compiler.hooks.make.callAsync是tapable的调用事件,那我们全局搜索hooks.make看看哪些插件注册监听了当前hooks,尝试寻找线索(在**「知识点Tips」章节**,介绍tapable

通过全局搜索找到了7个相关插件进行注册,根据插件名猜测,圈定缩小范围,优先浏览EntryPlugin和ContainerPlugin

第二步:顺藤摸瓜

EntryPlugin中的注册插件方法,根据入口顺藤摸瓜找到核心流程,一步步深入观察。从EntryPlugin -> compilation.addEntry -> compilation._addEntryItem -> compilation.addModuleTree -> handleModuleCreation。最后,找到handleModuleCreation方法处理和创建模块。看到这里,离真相越来越近了~

// EntryPlugin注册插件,监听make hooks
apply(compiler) {
    ......
    compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
        compilation.addEntry(context, dep, options, err => {
            callback(err);
        });
    });
}

// compilation.addEntry方法,判断options并解析
addEntry(context, entry, optionsOrName, callback) {
    ......
    this._addEntryItem(context, entry, "dependencies", options, callback);
}

// compilation._addEntryItem方法,执行addModuleTree把当前modules添加到module tree中
_addEntryItem(context, entry, target, options, callback) {
    ......
    this.addModuleTree({......}, (err, module) => {});
}

// compilation.addModuleTree方法,创建moduleFactory,执行handleModuleCreation
addModuleTree({ context, dependency, contextInfo }, callback) {
    ......
    const moduleFactory = this.dependencyFactories.get(Dep);
    if (!moduleFactory) {
        return callback();
    }

    this.handleModuleCreation({......},err => {......});
}

核心流程:

  1. EntryPlugin,createDependency解析入口依赖,调用addEntry添加入口
  2. addEntry,判断options并解析,调用_addEntryItem
  3. _addEntryItem,执行hooks生命周期addEntry -> failedEntry -> succeedEntry,执行addModuleTree把当前modules添加到module tree中
  4. addModuleTree 创建moduleFactory,执行handleModuleCreation

第三步:找到线索

到这里,顺着流程一步步找到了handleModuleCreation方法处理module,感觉找到了线索。大胆猜测一下,下一步应该就是对module的解析和构建。接着向下看handleModuleCreation源码

handleModuleCreation(
    { ...... },
    callback
) {
    // module的收集操作
    const moduleGraph = this.moduleGraph;
    ......
    // 创建并返回newModule
    this.factorizeModule(
        {......},
        (err, newModule) => {
            if (err) { ...... }
            // 添加模块
            this.addModule(newModule, (err, module) => {
                if (err) { ...... }
                ......
                this._handleModuleBuildAndDependencies(......);
            });
        }
    );
}

_handleModuleBuildAndDependencies(params) {
    ......
    this.buildModule(module, err => {......});
}

_buildModule(module, callback) {
    ......
    module.needBuild(
        { ...... },
        (err, needBuild) => {
            if (err) return callback(err);
            ......
            // 模块构建
            module.build(......);
        }
    );
}

handleModuleCreation核心流程:

  1. 创建moduleGraph,在当前方法中进行module的收集操作
  2. 执行factorizeModule,将传入的参数加到队列 factorizeQueue,并创建并返回newModule,以参数的形式传递给回调方法
  3. 执行addModule传入newModule参数,回调中执行processModuleDependencies -> buildModule方法
  4. 把newModule加入队列buildQueue,执行_buildModule方法处理(在**「知识点Tips」章节**,介绍AsyncQueue异步队列
  5. module.needBuild判断module是否需要build,如果需要执行callback回调
  6. module.build对模块构建build操作

第四步:最后真相

最后的最后,终于找到了真相!module.build()就是模块构建方法,但是,发现进入module.build竟然只有一个抛出AbstractMethodError错误的操作。难道,是思路错了,方向不对?别怕,我们会断点调试,在module.build方法上打个端点,深入看看~

build(options, compilation, resolver, fs, callback) {
    const AbstractMethodError = require("./AbstractMethodError");
    throw new AbstractMethodError();
}

在执行build方法callback打断点调试,逐步执行。对module.build添加debug监听,发现展示FuntionLocation路径是webpack/lib/NormalModule.js。深入探究发现了其中的秘密:NormalModule继承了Modules类,定义了同名方法build,对build进行了override重写。(在**「知识点Tips」章节**,介绍继承重写

所以,最后实际调用并不是Module.build,而是NormalModule.build,最后的真相浮出水面~

NormalModule.build

我们来看看override重写实现NormalModule.build方法源码~

build(options, compilation, resolver, fs, callback) {
    ......
    // 核心方法_doBuild
    return this._doBuild(options, compilation, resolver, fs, err => {
        ......
        const handleParseError = e => { ...... };
        const handleParseResult = result => { ...... };
        const handleBuildDone = () => { ...... };
        if (this.shouldPreventParsing(noParseRule, this.request)) {
            ......
            return handleBuildDone();
        }

        let result;
        try {
            // 对ast或源码进行解析
            result = this.parser.parse(this._ast || this._source.source(), {......});
        } catch (e) {
            // 处理解析错误
            handleParseError(e);
            return;
        }
        // 处理解析结果
        handleParseResult(result);
    });
}

NormalModule.build核心逻辑:

  1. 初始化一些相关属性,例如,buildMeta,buildInfo等。在当前方法中进行消费
  2. 调用NormalModule._doBuild方法,定义相关callback回调
  3. 回调中定义handleParseError、handleParseResult和handleBuildDone相关方法
  4. NormalModule.parser.parse,对ast或源码进行解析
  5. 在合适的时机进行调用定义的handle处理方法

NormalModule._doBuild

进入NormalModule._doBuild核心方法,看看其中源码~

_doBuild(options, compilation, resolver, fs, callback) {
    const processResult = (err, result) => {......};

    // 执行loader转化模块
    runLoaders({ ...... },
        (err, result) => {
            ......
            processResult(err, result.result);
        }
    );
}

NormalModule._doBuild核心流程:

  1. runLoaders(loader-runner)用于执行loader,对webpack.config.js loader配置正则匹配到的文件进行转化(在**「知识点Tips」章节**,介绍loader-runner
  2. 在回调方法中,执行processResult处理转化后的result结果

NormalModule.parser.parse

回头来看NormalModule.parser.parse核心方法,用于解析AST和源码。这个方法和上述module.build方法一样,NormalModule.parser继承了Parse类型,子类override重写。和上面的步骤一样,找到真正的实现方法的路径是webpack/lib/javascript/JavascriptParser.js,源码如下:

parse(source, state) {
    ......
    if (typeof source === "object") {
        ......
    } else {
        comments = [];
        // 解析源码生成AST
        ast = JavascriptParser._parse(source, {
            sourceType: this.sourceType,
            onComment: comments,
            onInsertedSemicolon: pos => semicolons.add(pos)
        });
    }
    ......
    if (this.hooks.program.call(ast, comments) === undefined) {
        this.detectMode(ast.body);
        // 预遍历变量声明的范围
        this.preWalkStatements(ast.body);
        this.prevStatement = undefined;
        // 块预遍历块变量声明的范围
        this.blockPreWalkStatements(ast.body);
        this.prevStatement = undefined;
        // 遍历语句和表达式并处理它们
        this.walkStatements(ast.body);
    }
}

NormalModule.parser.parse核心流程:

  1. JavascriptParser._parse解析源码生成AST
  2. 调用detectMode、preWalkStatements、blockPreWalkStatements、walkStatements方法进行处理

Compilation.processModuleDependencies

上述完成一个模块解析,那如何递归操作解析所有依赖关系?从头来看,一起回溯到**hooks.make ->「找到线索」小节**对handleModuleCreation方法解析,其中调用Compilation.buildModule方法callback回调。执行Compilation.processModuleDependencies方法把modules添加队列中,同时会触发_processModuleDependencies方法进行处理。对dependencies依赖进行遍历执行handleModuleCreation方法,直到所有module模块解析构建完成~

// webpack/lib/Compilation.js
handleModuleCreation({......}, callback) {
    ......
    this.factorizeModule({......}, (err, newModule) => {
        ......
        this.addModule(newModule, (err, module) => {
            ......
            this._handleModuleBuildAndDependencies(......);
        }
    }
}

_handleModuleBuildAndDependencies(params) {
    ......
    this.buildModule(module, err => {
        ......
        // 触发_processModuleDependencies方法进行处理              
        this.processModuleDependencies(module, callback);
    });
}

processModuleDependencies(module, callback) {
    // new AsyncQueue类型,执行_processModuleDependencies进行处理和响应
    this.processDependenciesQueue.add(module, callback);
}

_processModuleDependencies(module, callback) {
    ......
    for (const item of sortedDependencies) {
        inProgressTransitive++;
        this.handleModuleCreation(item, err => { ...... });
    }
}

processModuleDependencies核心流程:

  1. Compilation.buildModule方法callback回调中执行processModuleDependencies方法
  2. 添加module到_processModuleDependencies(AsyncQueue类型)中,执行_processModuleDependencies进行处理和响应(在**「知识点Tips」章节**,介绍AsyncQueue异步队列
  3. 遍历dependencies依赖,递归执行handleModuleCreation方法,直到所有module模块解析构建完成

遍历模块Modules

这个小节,我们看看如何遍历模块modules,并对依赖dependencies进行解析的~

_processModuleDependencies(module, callback) {
    ......
    // 遍历解析sortedDependencies数组,递归执行handleModuleCreation
    const onDependenciesSorted = err => {
        ......
        for (const item of sortedDependencies) {
            inProgressTransitive++;
            // 递归执行handleModuleCreation方法
            this.handleModuleCreation(item, err => {
                ......
                if (--inProgressTransitive === 0) onTransitiveTasksFinished();
            });
        }
        // 遍历结束
        if (--inProgressTransitive === 0) onTransitiveTasksFinished();
    };
    
    // 遍历任务结束,AsyncQueue异步队列并行队列数量减一
    const onTransitiveTasksFinished = err => {
        ......
    };

    // 判断是否开启options.module.unsafeCache,开启则从缓存中获取moudle解析结果
    // 最后,执行processDependencyForResolving
    const processDependency = (dep, index) => {
        ......
        processDependencyForResolving(dep);
    };
    
    // 优化依赖项处理,记录依赖结果到缓存中;同时,收集sortedDependencies用于后续遍历消费
    // 对比方法:
    // 快速对比path1: 与前一个项具有相同的构造函数constructor
    // 快速对比path2: 与前一个项具有相同的factory
    const processDependencyForResolving = dep => {
        ......
    };

    // 首次遍历,解析module队列中的依赖dependencies
    try {
        const queue = [module];
        do {
            const block = queue.pop();
            if (block.dependencies) {
                currentBlock = block;
                let i = 0;
                // 解析依赖dependencies
                for (const dep of block.dependencies) processDependency(dep, i++) ;
            }
            if (block.blocks) {
                // 遍历blocks,获取其中modules添加入modules队列中
                for (const b of block.blocks) queue.push(b);
            }
        } while (queue.length !== 0);
    } catch (e) {
        return callback(e);
    }

    // 遍历sortedDependencies数组,递归执行handleModuleCreation
    if (--inProgressSorting === 0) onDependenciesSorted(); 
}

遍历模块Modules核心流程:

  1. 首次操作,遍历blocks属性收集模块modules添加到队列中。同时,遍历获取dependencies依赖项并进行解析

  2. 遍历过程中调用processDependency方法,在其中判断是否开启options.module.unsafeCache,开启则从缓存中获取moudle解析结果。最后,执行processDependencyForResolving

  3. 执行processDependencyForResolving方法记录依赖结果缓存和收集sortedDependencies数组

  4. modules队列遍历结束后执行onDependenciesSorted方法,遍历sortedDependencies数组,结束执行onTransitiveTasksFinished方法

  5. 递归执行handleModuleCreation方法,直到所有module模块解析构建完成

到这里,通过遍历操作完成所有module模块的解析,下一步是生产输出~

流程总结

生成输出

上面章节,介绍了解析构建的全流程,接下来我们介绍最后一步「生成输出」的过程。hooks.make注册插件处理完毕,回到Compiler.compile -> hooks.make.callAsync 的回调callback中继续执行。

callback(hooks.make)

执行顺序hooks.make -> callback(make) -> hooks.finishMake -> callback(finishMak) -> process.nextTick -> compilation.finish -> callback(compilation.finish) -> compilation.seal -> hooks.afterCompile回调嵌回调。其中,compilation.seal方法是核心封装方法

compile(callback) {
    ......
    this.hooks.beforeCompile.callAsync(params, err => {
        ......
        this.hooks.make.callAsync(compilation, err => {
            ......
            this.hooks.finishMake.callAsync(compilation, err => {
                ......
                process.nextTick(() => {
                    // 遍历modules对其中dependencies依赖error和warning进行report处理
                    compilation.finish(err => {
                        ......
                        // 进行封装
                        compilation.seal(err => {
                            ......
                            this.hooks.afterCompile.callAsync(compilation, err => {
                                .......
                            });
                        });
                    });
                });
            });
        });
    });
}

callback(hooks.make) 核心流程:

  1. hooks.make执行callback回调,在callback中调用hooks.finishMake
  2. hooks.finishMake完成,在callback中调用compilation.finish
  3. compilation.finish执行hooks.finishModules,遍历modules对其中dependencies依赖error和warning进行report处理
  4. 执行compilation.seal方法,进行封装

Tips:避免重复,参考「hooks.make」小节流程图

compilation.seal

执行各种hooks.optimizeXXXX优化hooks,注册插件响应,对modules,chunks,tree等进行处理

seal(callback) {
    // 生成chunk之间的关系
    const chunkGraph = new ChunkGraph(this.moduleGraph);
    this.chunkGraph = chunkGraph;
    
    // 相关插件进行响应,优化tree
    this.hooks.optimizeTree.callAsync(this.chunks, this.modules, err => {
        this.hooks.afterOptimizeTree.call(this.chunks, this.modules);
        // 调用各种hooks.optimizeXXXX, 注册插件响应执行进行优化操作
        this.hooks.optimizeChunkModules.callAsync(
            this.chunks,
            this.modules,
            err => {
                this.codeGeneration(err => {
                    this._runCodeGenerationJobs(codeGenerationJobs, err => {
                        // 遍历module生成ModuleAssets,调用Compilation.emitAsset
                        this.createModuleAssets();
                        if (this.hooks.shouldGenerateChunkAssets.call() !== false) {
                            // 获取manifest并遍历生成ChunkAssets,调用Compilation.emitAsset
                            this.createChunkAssets(err => { ...... }); 
                        } else {
                            ......
                        }
                    });
                });
            }
        );
    });
}

compilation.seal核心流程:

  1. 对modules,chunk进行遍历解析,生成之间的关系并赋值moduleGraph、chunkGraph
  2. 调用各种优化hooks,注册插件响应执行进行优化操作
  3. 执行codeGeneration、_runCodeGenerationJobs生成相关代码code
  4. 执行createModuleAssets,遍历module生成ModuleAssets,调用Compilation.emitAsset
  5. 执行createChunkAssets,获取manifest并遍历生成ChunkAssets,调用Compilation.emitAsset

暂时无法在飞书文档外展示此内容

Compilation.emitAsset

emitAsset方法中,把相关的source源码添加到Compilation.assets属性

emitAsset(file, source, assetInfo = {}) {
    if (this.assets[file]) {
        if (!isSourceEqual(this.assets[file], source)) {
            ......
            this.assets[file] = source;
            this._setAssetInfo(file, assetInfo);
            return;
        }
        ......
        return;
    }
    // 代码保存到compilation.assets
    this.assets[file] = source ; 
    this._setAssetInfo(file, assetInfo, undefined);
}

Compiler.emitRecords

源码添加到Compilation.assets之后,如何定位到消费Compilation.assets的位置?webpack源码中,传入callback回调的写法比比皆是,导致调用堆栈非常的多。

既然,无法debug模式继续跟踪,那从头开始执行,结合当前流程一步步定位目标。最后,定位到compiler.run方法中执行了Compiler.compile(onCompiled),并且在onCompiled中执行了emitRecords方法,进行写入文件操作~

run(callback) {
    const onCompiled = (err, compilation) => {
        process.nextTick(() => {
            // 此处为Compiler.emitAssets方法
            this.emitAssets(compilation, err => {
                // 输出打包产物
                this.emitRecords(err => {
                    this.hooks.done.callAsync(stats, err => {......});
                });
            });
        });
    };

    const run = () => {
        this.hooks.beforeRun.callAsync(this, err => {
            this.hooks.run.callAsync(this, err => {
                this.readRecords(err => {
                    // 传入onCompiled作为回调方法执行
                    this.compile(onCompiled); 
                });
            });
        });
    };

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

emitRecords(callback) {
    // 把打包的产物写入指定文件夹
    const writeFile = () => {
        this.outputFileSystem.writeFile(......);
    };

    mkdirp(this.outputFileSystem, recordsOutputPathDirectory, err => {
        if (err) return callback(err);
        writeFile(); 
    });
}

emitRecords核心流程:

  1. compiler.run方法中执行了Compiler.compile(onCompiled),onCompiled作为callback传入
  2. onCompiled中执行Compiler.emitRecords,调用文件写操作
  3. 最后writeFile把打包的产物写入指定文件夹,默认是dist文件夹

流程总结

暂时无法在飞书文档外展示此内容

知识点Tips

npm bin字段

npm bin字段说明文档

当使用 npm 或者 yarn 命令安装包时,如果该包的 package.json 文件有 bin 字段,就会在 node_modules/.bin路径下复制了 bin 字段链接的脚本文件。执行脚本文件,响应定义的命令

Compiler类

Webpack Compiler介绍

从源码中可以看出,Compiler中记录了hooks生命周期,webpack相关属性,方法。没错,compiler实例从webpack流程开始创建,且只初始化一次,仅仅只有一个。记录着webpack运行环境和配置的所有属性。

class Compiler {
    constructor(context) {
        // hooks生命周期定义
        this.hooks = Object.freeze({
            ......
            initialize: new SyncHook([]),
            shouldEmit: new SyncBailHook(["compilation"]),
            done: new AsyncSeriesHook(["stats"]),
            afterDone: new SyncHook(["stats"]),
        });

        // 属性定义
        this.webpack = webpack;
        this.name = undefined;
        ......
    }
    // 相关方法定义
    getCache(name){}
    getInfrastructureLogger(name){}
    ......
}

Compiler中的hooks生命周期类型属于tapable,hooks.<hook name>.call进行调用hooks,插件的注册和执行来自于对hooks的监听。可以看出webpack插件和生命周期依赖于tapable。为了更好的理解其中的机制,一起学习一下tapable~

Compilation类

Webpack Complilation介绍

class Compilation {
    constructor(compiler, params) {
        // 所有模块及其依赖项属性
        this.moduleGraph = new ModuleGraph();
        this.chunkGraph = undefined;
        this.chunks = new Set();
        this.chunkGroups = [];
    }
    // 定义相关方法
    handleModuleCreatio(){}
    addModuleTre(){}
    emitAsset(){}
    createHash(){}
    ......
}

由Compiler用于创建新的编译(或构建)。Compilation实例可以访问所有模块及其依赖项(其中大部分是循环引用)。它是应用程序dependency graph中所有模块进行直接编译输出代码。在编译阶段,模块被加载、封装、优化、chunk、hash和恢复。

Tapable

tapable官网文档

介绍

一句话总结:tapack本质上是发布订阅模式,webpack中用于定义hooks和注册执行插件plugins

用法

第一步,创建一个hooks,以SyncHook为例。SyncHook是一个类,初始化传入['arg1', 'arg2']表示回调方法中存在两个参数。例如:

const { SyncHook } = require('tapable');

const myHook = new SyncHook(['arg1', 'arg2']);

第二步,注册插件,使用tap方法注册插件到hooks中,每个插件都会被添加到hooks的执行队列中,按照添加的顺序执行。定义插件名称,回调方法。例如:

myHook.tap('Plugin1', (arg1, arg2) => {
  console.log('Plugin1', arg1, arg2);
});

myHook.tap('Plugin2', (arg1, arg2) => {
  console.log('Plugin2', arg1, arg2);
});

myHook.tap('Plugin3', (arg1, arg2) => {
  console.log('Plugin3', arg1, arg2);
});

第三步,触发hooks,使用call()方法触发hooks执行,传入两个参数。例如:

myHook.call('Hello', 'World');

类型

hooks类型

  • Basic hook:只是按顺序调用每个触发的函数。
  • Waterfall hook:会按顺序调用每个触发的函数。与基本hook不同的是,它会将每个函数的返回值传递给下一个函数。
  • Bail hook:允许提前退出。当任何一个触发的函数返回任何值时,hook将停止执行剩余的函数。
  • Loop hook:当插件返回一个非undefined值时,hook将从第一个插件重新启动。它将循环执行,直到所有插件返回undefined。

执行方式

  • Sync:只能通过同步函数来触发(使用myHook.tap())。
  • AsyncSeries:可以通过同步、基于回调的和基于Promise的函数来触发(使用myHook.tap()、myHook.tapAsync()和myHook.tapPromise())。它们按顺序调用每个异步方法。
  • AsyncParallel:可以通过同步、基于回调的和基于Promise的函数来触发(使用myHook.tap()、myHook.tapAsync()和myHook.tapPromise())。但是,它们并行运行每个异步方法。

总结

tapable是webpack的核心模块之一,实现模块打包过程中的plugin管理,也是由webpack团队进行维护。tapable提供了定义hooks的能力,开发者可以编写plugin,监听执行。在合适的时机执行plugin,webpack利用tapable构建了一个灵活的插件系统,实现定制化行为的能力。

process.nextTick

process.nextTick 是一个 Node.js 中的函数,用于在当前执行阶段结束后立即执行回调函数。它提供了一种在事件循环(event loop)中插入任务的方式,process.nextTick 允许将回调函数插入到事件循环的当前阶段的末尾,以便在下一个时间循环迭代之前执行。使用 process.nextTick 的语法如下:

process.nextTick(callback[, ...args])

process.nextTick 的一些特点和用途:

  1. 立即执行:process.nextTick 中的回调函数会在当前代码执行完成后立即执行,而不需要等待其他事件循环阶段。
  2. 优先级高:process.nextTick 中的回调函数的优先级比 setTimeoutsetImmediate 更高,它们会在下一个事件循环迭代之前执行。
  3. 递归调用:如果在 process.nextTick 回调函数中再次调用 process.nextTick,它们会被递归地执行,直到达到最大递归深度。
  4. 避免阻塞:使用 process.nextTick 可以将一些需要立即执行的回调函数插入到事件循环中,以避免长时间等待其他任务的完成。

继承重写

在 JavaScript 中,子类的继承重写demo如下:

class Parent {
  sayHello() {
    console.log("Hello from parent");
  }
}

class Child extends Parent {
  sayHello() {
    console.log("Hello from child");
  }
}

const child = new Child();
child.sayHello(); // 输出: Hello from child

Child 类通过 extends 关键字继承了Parent类。然后,Child 类中重写了父类的 sayHello 方法,提供了自己的实现。并且,可以多个子类进行继承重写操作

另外,分享一个定位子类具体实现的方法。打断点调试,查看执行链接

loader-runner

loader-runner 的作用是模拟 webpack 在构建过程中运行 loaders 的功能。loader-runner 模块提供了一个 runLoaders 函数,它接收 loader 请求和callback回调作为参数。每个 loader 请求包含了要转换的模块代码、模块的文件路径等信息。回调函数将在 loaders 执行完成后被调用,返回经过 loaders 处理后的模块代码。使用demo如下:

const { runLoaders } = require('loader-runner');

const resource = '/path/to/resource.js';
const loaders = [
  {
    loader: '/path/to/loader1.js',
    options: {
      // Loader1 options
    }
  },
  {
    loader: '/path/to/loader2.js',
    options: {
      // Loader2 options
    }
  },
  // ...
];
const context = {
  resourcePath: resource,
  resourceQuery: '',
  emitWarning: (warning) => console.warn(warning),
  emitError: (error) => console.error(error),
  // 其他上下文信息
};

runLoaders({
  resource,
  loaders,
  context,
  readResource: fs.readFile.bind(fs)  // 读取资源的方法
}, (err, result) => {
  if (err) {
    console.error(err);
  } else {
    const transformedSource = result.result[0].source;
  }
});

在上述示例中,我们使用 loader-runner 模块的 runLoaders 函数来运行一组 loaders。我们提供了要转换的资源路径、loaders 数组、上下文信息和读取资源的方法。在回调函数中,我们可以获取经过 loaders 处理后的结果,并进行进一步的处理。

AsyncQueue异步队列

AsyncQueue异步队列是Webpack中一个任务调度器,控制异步任务的并发执行。下面是相关源码:

class AsyncQueue {
    constructor({ name, parallelism, parent, processor, getKey }) {
        // 属性
        this._name = name;
        this._parallelism = parallelism || 1;
        this._processor = processor;
        ......
        
        // 定义hooks生命周期
        this.hooks = {
            beforeAdd: new AsyncSeriesHook(["item"]),
            added: new SyncHook(["item"]),
            beforeStart: new AsyncSeriesHook(["item"]),
            started: new SyncHook(["item"]),
            result: new SyncHook(["item", "error", "result"])
        };
    }

    // 核心方法
    add(item, callback) {}
    invalidate(item) {}
    waitFor(item, callback) {}
    stop() {}
    ......
}

是初始化队列queue,调用add方法添加item到队列中,定义的processor方法进行响应处理。下面是使用示例:

const queue = new AsyncQueue({
    name: "xxxx", // 队列名称
    parallelism: 10, // 并行处理数量
    processor:() => {}, // 处理方法
    parent: 'parent',  // parent,其优先级高于该队列并具有共享并行性
    getKey: () => {} // 每项队列的额外名称
});

// 添加item到队列中
queue.add(item)

参考文章