Webpack源码解读:理清编译主流程

16,807 阅读16分钟

前言

webpack的熟练使用已成为当代前端工程师必备的生存技能。毋庸置疑,webpack已成为前端构建工具的佼佼者,网络上关于如何使用webpack的技术文档层出不穷。但鲜有能将webpack的构建流程讲清楚的。本文尝试从解读源码以及断点调试的方式,来探究 webpack 是如何一步步的构建资源的。

截至本文发表前,webpack的最新版本为webpack 5.0.0-beta.1,即本文的源码来自于最新的webpack v5

特别说明,本文所列源码均经过精简加工,如果要看具体代码你可以根据我标识的源码文件名访问webpack官方库查看。本文精简部分:

  • 删除了模块引入,即 const xxx = require('XXX');
  • 异常兜底代码,虽然异常处理也很重要,但本文主要分析webpack正常工作的主流程,如果异常处理不可忽视,我会特别说明;

如何调试webpack

我一贯认为学习源码并不是硬着头皮去一行行的阅读代码,对于一个成熟的开源项目,必定是存在很多错综复杂的分支走向。试着一步步的调试代码来跟踪程序运行路径,是快速了解一个项目基本架构的最快方式。

VS Code编辑器中完善的Debugger功能是调试Node程序最好利器。

  1. 首先,为了学习webpack源码,你必须先从webpack库clone一份源码到本地:
git clone https://github.com/webpack/webpack.git
  1. 安装项目依赖;VS Code打开本地webpack仓库
npm install
cd webpack/
code .
  1. 为了不污染项目根目录,在根目录下新建debug文件夹,用于存放调试代码,debug文件夹结构如下:
debug-|
      |--dist    // 打包后输出文件
      |--src
         |--index.js   // 源代码入口文件
      |--package.json  // debug时需要安装一些loader和plugin
      |--start.js      // debug启动文件
      |--webpack.config.js  // webpack配置文件

详细debug代码如下:

//***** debug/src/index.js *****
import is from 'object.is'  // 这里引入一个小而美的第三方库,以此观察webpack如何处理第三方包
console.log('很高兴认识你,webpack')
console.log(is(1,1))


//***** debug/start.js *****
const webpack = require('../lib/index.js')  // 直接使用源码中的webpack函数
const config = require('./webpack.config')
const compiler = webpack(config)
compiler.run((err, stats)=>{
    if(err){
        console.error(err)
    }else{
        console.log(stats)
    }
})


//***** debug/webpack.config.js *****
const path = require('path')
module.exports = {
    context: __dirname,
    mode: 'development',
    devtool: 'source-map',
    entry: './src/index.js',
    output: {
        path: path.join(__dirname, './dist'),
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: ['babel-loader'],
                exclude: /node_modules/,
            }
        ]
    }
}
  1. 在VS Code的Debug栏添加调试 配置:
{
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "启动webpack调试程序",
            "program": "${workspaceFolder}/debug/start.js"
        }
    ]
}

配置完成后,试着点击一下 ► (启动) 看看调试程序是否正常运行(如果成功,在debug/dist中会打包出一个main.js文件)。

如果你有时间,我希望你能亲手完成一次webpack调试流程,我相信你会有收获的。探索欲是人类的天性。

接下来,通过断点调试,来一步步剖析webpack是如何工作的吧。

源码解读

webpack启动方式

webpack有两种启动方式:

  1. 通过webpack-cli脚手架来启动,即可以在Terminal终端直接运行;
webpack ./debug/index.js --config ./debug/webpack.config.js

这种方式是最为常用也是最快捷的方式,开箱即用。

  1. 通过require('webpack')引入包的方式执行;

其实第一种方式最终还是会用require的方式来启动webpack,用兴趣的可以查看./bin/webpack.js文件。

webpack编译的起点

一切从const compiler = webpack(config)开始。

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

const webpack = (options, callback) => {
    let compiler = createCompiler(options)
    // 如果传入callback函数,则自启动
    if(callback){
        compiler.run((err, states) => {
            compiler.close((err2)=>{
                callbacl(err || err2, states)
            })
        })
    }
    return compiler
}

webpack函数执行后返回compiler对象,在webpack中存在两个非常重要的核心对象,分别为compilercompilation,它们在整个编译过程中被广泛使用。

  • Compiler类(./lib/Compiler.js):webpack的主要引擎,在compiler对象记录了完整的webpack环境信息,在webpack从启动到结束,compiler只会生成一次。你可以在compiler对象上读取到webpack config信息,outputPath等;
  • Compilation类(./lib/Compilation.js):代表了一次单一的版本构建和生成资源。compilation编译作业可以多次执行,比如webpack工作在watch模式下,每次监测到源文件发生变化时,都会重新实例化一个compilation对象。一个compilation对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。

两者的区别?
compiler代表的是不变的webpack环境; compilation代表的是一次编译作业,每一次的编译都可能不同;

举个栗子🌰:
compiler就像一条手机生产流水线,通上电后它就可以开始工作,等待生产手机的指令; compliation就像是生产一部手机,生产的过程基本一致,但生产的手机可能是小米手机也可能是魅族手机。物料不同,产出也不同。

Compiler类在函数createCompiler中实例化(./lib/index.js):

const createCompiler = options => {
    const compiler = new Compiler(options.context)
    // 注册所有的自定义插件
    if(Array.isArray(options.plugins)){
        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()
    compiler.options = new WebpackOptionsApply().process(options, compiler)  // process中注册所有webpack内置的插件
    return compiler
}

Compiler类实例化后,如果webpack函数接收了回调callback,则直接执行compiler.run()方法,那么webpack自动开启编译之旅。如果未指定callback回调,需要用户自己调用run方法来启动编译。

从上面源码中,可以得出一些信息:

  • compiler由Compiler实例化,里面的属性和方法后面一节会提到,其中最重要的是compiler.run()方法;

  • 遍历webpack config中的plugins数组,这里我加粗了plugins数组,所以配置plugins时不要配成对象了。(事实上,在webpack函数中会对options做object schema的校验)。

  • plugin:如果 plugin 是函数,直接调用它;如果 plugin 是其他类型(主要是object类型),执行plugin对象的apply方法。apply函数签名:(compiler) => {}

    webpack非常严格的要求我们plugins数组元素必须是函数,或者一个有apply字段的对象且apply是函数,原因就在于此。

    {
    plugins: [ new HtmlWebpackPlugin() ]
    }
    
  • 调用钩子:compiler.hooks.environment.call() 以及 compiler.hooks.afterEnvironment.call()是源码阅读至此我们最先遇到的钩子调用,在之后的阅读中,你会遇到更多的钩子注册与调用。要理解webpack钩子的应用,需要先了解Tapable,这是编写插件的基础。

    关于Tapable,我会”另案处理“它的。 --> 我已经将它记录在案了,快去看看吧 《编写自定义webpack插件从理解Tapable开始》

  • process(options):在 webpack config中,除了plugins还有其他很多的字段呢,那么process(options)的作用就是一个个的处理这些字段。

至此,我们了解了webpack在初始化阶段做了哪些准备工作。当点燃导火索compiler.run()时,才是webpack真正强大的时候。”兵马未动,粮草先行“,在此之前,需要先看看new WebpackOptionsApply().process(options, compiler)做了哪些准备工作,它为后面编译阶段提供了重要的后勤保卫。

process(options, compiler)

WebpackOptionsApply类的工作就是对webpack options进行初始化。 打开源码文件lib/WebpackOptionsApply.js,你会发现前五十行都是各种webpack内置的Plugin的引入,那么可以猜想process方法应该是各种各样的new SomePlugin().apply()的操作,事实就是如此。

精简源码(lib/WebpackOptionsApply.js):

class WebpackOptionsApply extends OptionsApply {
    constructor() {
        super();
    }
    process(options, compiler){
    // 当传入的配置信息满足要求,处理与配置项相关的逻辑
        if(options.target) {
            new OnePlugin().apply(compiler)
        }
        if(options.devtool) {
            new AnotherPlugin().apply(compiler)
        }
        if ...
		
        new JavascriptModulesPlugin().apply(compiler);
        new JsonModulesPlugin().apply(compiler);
        new ...
		
        compiler.hooks.afterResolvers.call(compiler);
    }
}

源码中...省略号省略了很多相似的操作,process函数很长,有接近500行左右的代码,主要做了两件事:

  1. new很多的Plugin,并且apply它们。

    在上一小节中,我们知道webpack插件其实就是一个提供apply方法的类,它在合适的时候会被webpack实例化并执行apply方法。而apply方法接收了 compiler 对象,方便在hooks上监听消息。 同时在process函数中实例化的各个Plugin都是webpack自己维护的,因此你会发现webpack项目根目录下有很多的以Plugin结尾的文件。而用户自定义的插件在之前就已经注册完成了。 不同插件有自己不同的使命,它们的职责是钩住compiler.hooks上的一个消息,一旦某个消息被触发,注册在消息上的回调根据hook类型依次调用。所谓“钩住”的三个方式:tap tapAsync tapPromise,你需要知道Tapable的工作原理哦。

  2. 根据options.xxx的配置项,做初始化工作,而大多数初始化工作还是在干上面👆的事情

这一小结总结一下:process函数执行完,webpack将所有它关心的hook消息都注册完成,等待后续编译过程中挨个触发。

执行process方法装填好弹药,等待大战即发。

compiler.run()

先贴上源码吧(./lib/Compiler.js):

class Compiler {
    constructor(context){
    // 所有钩子都是由`Tapable`提供的,不同钩子类型在触发时,调用时序也不同
    this.hooks = {
            beforeRun: new AsyncSeriesHook(["compiler"]),
            run: new AsyncSeriesHook(["compiler"]),
            done: new AsyncSeriesHook(["stats"]),
            // ...
        }
    }
  
    // ...
	
    run(callback){
        const onCompiled = (err, compilation) => {
            if(err) return
            const stats = new Stats(compilation);
            this.hooks.done.callAsync(stats, err => {
                if(err) return
                callback(err, stats)
                this.hooks.afterDone.call(stats)
            })
        }
        this.hooks.beforeRun.callAsync(this, err => {
            if(err) return
            this.hooks.run.callAsync(this, err => {
                if(err) return
                this.compile(onCompiled)
            })
        })
    }
}

通读一遍run函数过程,你会发现它钩住了编译过程的一些阶段,并在相应阶段去调用已经提前注册好的钩子函数(this.hooks.xxxx.call(this)),效果与React中生命周期函数是一样的。在run函数中出现的钩子有:beforeRun --> run --> done --> afterDone。第三方插件可以钩住不同的生命周期,接收compiler对象,处理不同逻辑。

run函数钩住了webpack编译的前期和后期的阶段,那么中期最为关键的代码编译过程就交给了this.compile()来完成了。在this.comille()中,另一个主角compilation粉墨登场了。

compiler.compile()

compiler.compile函数是模块编译的主战场,话不多说,先贴上精简后伪代码:

compile(callback){
    const params = this.newCompilationParams()  // 初始化模块工厂对象
    this.hooks.beforeCompile.callAsync(params, err => {
        this.hooks.compile.call(params)
        // compilation记录本次编译作业的环境信息 
        const compilation = new Compilation(this)
        this.hooks.make.callAsync(compilation, err => {
            compilation.finish(err => {
                compilation.seal(err=>{
                    this.hooks.afterCompile.callAsync(compilation, err => {
                        return callback(null, compilation)
                    })
                })
            })
        })
    })
}

compile函数和run一样,触发了一系列的钩子函数,在compile函数中出现的钩子有:beforeCompile --> compile --> make --> afterCompile

其中make就是我们关心的编译过程。但在这里它仅是一个钩子触发,显然真正的编译执行是注册在这个钩子的回调上面。

webpack因为有Tapable的加持,代码编写非常灵活,node中流行的callback回调机制(说的就是回调地狱),webpack使用的炉火纯青,如果用断点调试,可能不太容易捕捉到。这里我使用搜索关键词的方法反向查找make钩子是在哪里注册的。

通过搜索关键词hooks.make.tapAsync我们发现在lib/EntryPlugin.js中找到了它的身影。

依靠搜索关键词,会列出较多干扰项,聪明的你就需要识别出哪个选项才是最接近实际情况的。

此时,我们要倒查一下这个EntryPlugin是在什么时候被调用的,继续关键词new EntryPlugin搜索,在lib/EntryOptionPlugin.js中找到了它,而且其中你发现了熟悉的“东西”:

if(typeof entry === "string" || Array.isArray(entry)){
	applyEntryPlugins(entry, "main")
}else if (typeof entry === "object") {
  for (const name of Object.keys(entry)) {
    applyEntryPlugins(entry[name], name);
  }
} else if (typeof entry === "function") {
  new DynamicEntryPlugin(context, entry).apply(compiler);
}

还记得在webpack.config.js中,entry字段是怎么配置的吗?此时你会明白entry是字符串或数组时,打包出来的资源统一叫main.js这个名字了。

我们的回溯还没有结束,继续搜索关键词new EntryOptionPlugin,Oops,搜索到的文件就是lib/WebpackOptionsApply.js。如此一切都明了了,make钩子在process函数中就已经注册好了,就等着你来调用。

回到lib/EntryPlugin.js看看compiler.hooks.make.tapAsync都干了啥。其实就是运行compiliation.addEntry方法,继续探索compiliation.addEntry

addEntry(context, entry, name, callback) {
    this.hooks.addEntry.call(entry, name);
    // entryDependencies中的每一项都代表了一个入口,打包输出就会有多个文件
    let entriesArray = this.entryDependencies.get(name)
	entriesArray.push(entry)
    this.addModuleChain(context, entry, (err, module) => {
        this.hooks.succeedEntry.call(entry, name, module);
        return callback(null, module);
    })
}

addEntry的作用是将模块的入口信息传递给模块链中,即addModuleChain,随后继续调用compiliation.factorizeModule,这些调用最后会将entry的入口信息”翻译“成一个模块(严格上说,模块是NormalModule实例化后的对象)。读这段源码的时候,有点难理解,由于node回调地狱的陷进,我一度以为entry的处理应该是同步,后来发现process.nextTick的使用使得很多回调都是异步调用的。建议在这里多断点,多调试,以理解弯弯绕的异步回调。

这里我列出相关函数的调用顺序:this.addEntry --> this.addModuleChain --> this.handleModuleCreation --> this.addModule --> this.buildModule --> this._buildModule --> module.build(this指代compiliation)`。

最终会走到NormalModule对象(./lib/NormalModule.js)中,执行build方法。

normalModule.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就是选用合适的loader去加载resource,目的是为了将这份resource转换为JS模块(原因是webpack只识别JS模块)。最后返回加载后的源文件source,以便接下来继续处理。

webpack对处理标准的JS模块很在行,但处理其他类型文件(css, scss, json, jpg)等就无能为力了,此时它就需要loader的帮助。loader的作用就是转换源代码为JS模块,这样webpack就可以正确识别了。 loader的作用就像是Linux中信息流管道,它接收源码字符串流,加工一下,然后返回加工后的源码字符串交给下一个loader继续处理。 loader的基本范式:(code, sourceMap, meta) => string

经过了doBuild后,任何的模块都转换成标准JS模块。

可以试着在js代码中引入css代码,观察一下转换出的标准JS模块的数据结构。

接下来就是编译标准JS代码了。在传入doBuild的回调函数中这样处理source

const result = this.parser.parse(source)

而这里的this.parser其实就是JavascriptParser的实例对象,最终JavascriptParser会调用第三方包acorn提供的parse方法对JS源代码进行语法解析。

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)
        // 这里webpack会遍历一次ast.body,其中会收集这个模块的所有依赖项,最后写入到`module.dependencies`中
        this.walkStatements(ast.body)
    }
}

有个线上小工具 AST explorer 可以在线将JS代码转换为语法树AST,将解析器选择为acorn即可。将调试代码./debug/src/index.js使用acron解析一下语法,得到如下的数据结构:

可能你会有些疑惑,通常我们会使用一些类似于babel-loader等 loader 预处理源文件,那么webpack 在这里的parse具体作用是什么呢?parse的最大作用就是收集模块依赖关系,比如调试代码中出现的import {is} from 'object-is'const xxx = require('XXX')的模块引入语句,webpack会记录下这些依赖项,记录在module.dependencies数组中。

compilation.seal()

至此,从入口文件开始,webpack收集完整了该模块的信息和依赖项,接下来就是如何进一步打包封装模块了。

在执行compilation.seal(./lib/Compliation)之前,你可以打个断点,查看此时compilation.modules的情况。此时compilation.modules有三个子模块,分别为./src/index.js node_modules/object.is/index.js 以及 node_modules/object.is/is.is

compilation.seal的步骤比较多,先封闭模块,生成资源,这些资源保存在compilation.assets, compilation.chunks

你会在多数第三方webpack插件中看到compilation.assetscompilation.chunks 的身影。

然后调用compilation.createChunkAssets方法把所有依赖项通过对应的模板 render 出一个拼接好的字符串:

createChunkAssets(callback){
    asyncLib.forEach(
        this.chunks,
        (chunk, callback) => {
            // manifest是数组结构,每个manifest元素都提供了 `render` 方法,提供后续的源码字符串生成服务。至于render方法何时初始化的,在`./lib/MainTemplate.js`中
            let manifest = this.getRenderManifest()
            asyncLib.forEach(
                manifest,
                (fileManifest, callback) => {
                    ...
                    source = fileManifest.render()
                    this.emitAsset(file, source, assetInfo)
                },
                callback
            )
        },
        callback
    )
}

可以在createChunkAssets方法体中的this.emitAsset(file, source, assetInfo)代码行打上断点,观察此时source中的数据结构。在source._source字段已经初见打包后源码雏形:

值得一提的是,createChunkAssets执行过程中,会优先读取cache中是否已经有了相同hash的资源,如果有,则直接返回内容,否则才会继续执行模块生成的逻辑,并存入cache中。

compiler.hooks.emit.callAsync()

在seal执行后,关于模块所有信息以及打包后源码信息都存在内存中,是时候将它们输出为文件了。接下来就是一连串的callback回调,最后我们到达了compiler.emitAssets方法体中。在compiler.emitAssets中会先调用this.hooks.emit生命周期,之后根据webpack config文件的output配置的path属性,将文件输出到指定的文件夹。至此,你就可以在./debug/dist中查看到调试代码打包后的文件了。

this.hooks.emit.callAsync(compilation, () => {
    outputPath = compilation.getPath(this.outputPath, {})
    mkdirp(this.outputFileSystem, outputPath, emitFiles)
 })

总结

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

  1. 调用webpack函数接收config配置信息,并初始化compiler,在此期间会apply所有 webpack 内置的插件;
  2. 调用compiler.run进入模块编译阶段;
  3. 每一次新的编译都会实例化一个compilation对象,记录本次编译的基本信息;
  4. 进入make阶段,即触发compilation.hooks.make钩子,从entry为入口: a. 调用合适的loader对模块源码预处理,转换为标准的JS模块; b. 调用第三方插件acorn对标准JS模块进行分析,收集模块依赖项。同时也会继续递归每个依赖项,收集依赖项的依赖项信息,不断递归下去;最终会得到一颗依赖树🌲;
  5. 最后调用compilation.seal render 模块,整合各个依赖项,最后输出一个或多个chunk;

以下为简单的时序图:

以上过程并不能完全概括webpack的全部流程,随着webpack.config配置越来越复杂,webpack会衍生更多的流程去应对不同的情况。

webpack复杂吗?很复杂,TabableNode回调让整个流程存在多种多样的走向,也因为它的插件系统,让 webpack 高度可配置。 webpack容易吗?也容易,它只做了一件事,编译打包JS模块,并把这件事做到极致完美。

最后

码字不易,如果:

  • 这篇文章对你有用,请不要吝啬你的小手为我点赞;
  • 有不懂或者不正确的地方,请评论,我会积极回复或勘误;
  • 期望与我一同持续学习前端技术知识,请关注我吧;
  • 转载请注明出处;

您的支持与关注,是我持续创作的最大动力!