webpack构建流程分析

9,420 阅读12分钟

前言

webpack是一个强大的打包工具,拥有灵活、丰富的插件机制,网上关于如何使用webpack及webpack原理分析的技术文档层出不穷。最近自己也在学习webpack的过程中,记录并分享一下,希望对你有点帮助。 本文主要探讨,webpack的一次构建流程中,主要干了哪些事儿。 (咱们只研究研究构建的整体流程哈,细节不看🙈)

已知,Webpack 源码是一个插件的架构,很多功能都是通过诸多的内置插件实现的。Webpack为此专门自己写一个插件系统,叫 Tapable 主要提供了注册和调用插件的功能。 一起研究之前,希望你对 tapable 有所了解~

调试

阅读源码最直接的方式是在 chrome 中通过断点在关键代码上进行调试,我们可以用 node-inspector进行此次debugger。

"scripts": {
    "build": "webpack --config webpack.prod.js",
    "debug": "node --inspect-brk ./node_modules/webpack/bin/webpack.js --inline --progress",
},

执行npm run build && npm run debug

// 入口文件
import { helloWorld } from './helloworld.js';
document.write(helloWorld());

// helloworld.js
export function helloWorld() {
    return 'bts';
}

// webpack.prod.js
module.exports = {
    entry: {
        index: './src/index.js',
    },
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name]_[chunkhash:8].js'
    },
    module: {
        rules: [
            {
                test: /.js$/,
                use: 'babel-loader',
            },
        ]
    },
};

基本架构

先通过一张大图整体梳理一下webpack的主体流程,再细节一点的稍后再介绍

流程图中展示了些核心任务点,简要说明下这些任务点做了事儿:

  • 通过 yargs 解析 configshell 中的配置项
  • webpack 初始化过程,首先会根据第一步的 options 生成 compiler 对象,然后初始化 webpack 的内置插件及 options 配置
  • run 代表编译的开始,会构建 compilation 对象,用于存储这一次编译过程的所有数据
  • make 执行真正的编译构建过程,从入口文件开始,构建模块,直到所有模块创建结束
  • seal 生成 chunks,对 chunks 进行一系列的优化操作,并生成要输出的代码
  • seal 结束后,Compilation 实例的所有工作到此也全部结束,意味着一次构建过程已经结束
  • emit 被触发之后,webpack 会遍历 compilation.assets, 生成所有文件,然后触发任务点 done,结束构建流程

构建流程

在学习其他技术博客时都有类似上面的主体流程的分析,道理都懂,但不打断点看的细节点,说服不了自己。以下是一些任务点的详细动作,建议有兴趣的小伙伴多打几个debugger

强烈建议在每个重要钩子的回调函数中打debugger,不然可能跳着跳着就走远了

webpack准备阶段

webpack启动入口,webpack-cli/bin/cli.js

const webpack = require("webpack");
    // 使用yargs来解析命令行参数并合并配置文件中的参数(options),
    // 然后调用lib/webpack.js实例化compile 并返回
let compiler;
try {
	compiler = webpack(options);
} catch (err) {}
// lib/webpack.js
const webpack = (options, callback) => {
    // 首先会检查配置参数是否合法
    
    // 创建Compiler
    let compiler;
    compiler = new Compiler(options.context);
    
    compiler.options = new WebpackOptionsApply().process(options, compiler);
    
    ...
    if (options.watch === true || ..) {
        ...
        return compiler.watch(watchOptions, callback);
    }
	compiler.run(callback);
}

创建Compiler

创建了 compiler 对象,compiler 可以理解为 webpack 编译的调度中心,是一个编译器实例,在 compiler 对象记录了完整的 webpack 环境信息,在 webpack 的每个进程中,compiler 只会生成一次。

class Compiler extends Tapable {
    constructor(context) {
        super();
        this.hooks = {
            beforeCompile: new AsyncSeriesHook(["params"]),
            compile: new SyncHook(["params"]),
            afterCompile: new AsyncSeriesHook(["compilation"]),
            make: new AsyncParallelHook(["compilation"]),
            entryOption: new SyncBailHook(["context", "entry"])
            // 定义了很多不同类型的钩子
        };
        // ...
    }
}

可以看到 Compiler 对象继承自 Tapable,初始化时定义了很多钩子。

初始化默认插件和Options配置

WebpackOptionsApply 类中会根据配置注册对应的插件,其中有个比较重要的插件

new EntryOptionPlugin().apply(compiler);
compiler.hooks.entryOption.call(options.context, options.entry);

EntryOptionPlugin插件中订阅了compiler的entryOption钩子,并依赖SingleEntryPlugin插件

module.exports = class EntryOptionPlugin {
	apply(compiler) {
		compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
			return new SingleEntryPlugin(context, item, name);
		});
	}
};

SingleEntryPlugin 插件中订阅了 compilermake 钩子,并在回调中等待执行 addEntry,但此时 make 钩子还并没有被触发哦

apply(compiler) {
    compiler.plugin("compilation", (compilation, params) => {
        const normalModuleFactory = params.normalModuleFactory;
        // 这里记录了 SingleEntryDependency 对应的工厂对象是 NormalModuleFactory
        compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory);
    });
    compiler.hooks.make.tapAsync(
        "SingleEntryPlugin",
        (compilation, callback) => {
        	const { entry, name, context } = this;
        
        	// 创建单入口依赖 
        	const dep = SingleEntryPlugin.createDependency(entry, name);
        	// 正式进入构建阶段
        	compilation.addEntry(context, dep, name, callback);
        }
    );
}

run

初始化 compiler 后,根据 optionswatch 判断是否启动了 watch,如果启动 watch 了就调用 compiler.watch 来监控构建文件,否则启动 compiler.run 来构建文件,compiler.run 就是我们此次编译的入口方法,代表着要开始编译了。

构建编译阶段

调用 compiler.run 方法来启动构建

run(callback) {
    const onCompiled = (err, compilation) => {
    	this.hooks.done.callAsync(stats, err => {
    		return finalCallback(null, stats);
    	});
    };
    
    // 执行订阅了compiler.beforeRun钩子插件的回调
    this.hooks.beforeRun.callAsync(this, err => {
        // 执行订阅了compiler.run钩子插件的回调
    	this.hooks.run.callAsync(this, err => {
    		this.compile(onCompiled);
    	});
    });
}

compiler.compile 开始真正执行我们的构建流程,核心代码如下

compile(callback) {
    // 实例化核心工厂对象
    const params = this.newCompilationParams();
    // 执行订阅了compiler.beforeCompile钩子插件的回调
    this.hooks.beforeCompile.callAsync(params, err => {
        // 执行订阅了compiler.compile钩子插件的回调
        this.hooks.compile.call(params);
        // 创建此次编译的Compilation对象
        const compilation = this.newCompilation(params);
        
        // 执行订阅了compiler.make钩子插件的回调
        this.hooks.make.callAsync(compilation, err => {
            
            compilation.finish(err => {
                compilation.seal(err => {
                    this.hooks.afterCompile.callAsync(compilation, err => {
                		return callback(null, compilation);
                	});
                })
            })
        })
    })
}

compile阶段,Compiler 对象会开始实例化两个核心的工厂对象,分别是 NormalModuleFactoryContextModuleFactory。工厂对象顾名思义就是用来创建实例的,它们后续用来创建 module 实例的,包括 NormalModule 以及 ContextModule 实例。

Compilation

创建此次编译的 Compilation 对象,核心代码如下:

newCompilation(params) {
    // 实例化Compilation对象
    const compilation = new Compilation(this);
    this.hooks.thisCompilation.call(compilation, params);
    // 调用this.hooks.compilation通知感兴趣的插件
    this.hooks.compilation.call(compilation, params);
    return compilation;
}

Compilation 对象是后续构建流程中最核心最重要的对象,它包含了一次构建过程中所有的数据。也就是说一次构建过程对应一个 Compilation 实例。在创建 Compilation 实例时会触发钩子 compilaiionthisCompilation

在Compilation对象中:

  • modules 记录了所有解析后的模块
  • chunks 记录了所有chunk
  • assets记录了所有要生成的文件

上面这三个属性已经包含了 Compilation 对象中大部分的信息,但目前也只是有个大致的概念,特别是 modules 中每个模块实例到底是什么东西,并不太清楚。先不纠结,毕竟此时 Compilation 对象刚刚生成。

make

Compilation 实例创建完成之后,webpack 的准备阶段已经完成,下一步将开始 modules 的生成阶段。

this.hooks.make.callAsync() 执行订阅了 make 钩子的插件的回调函数。回到上文,在初始化默认插件过程中(WebpackOptionsApply类),SingleEntryPlugin 插件中订阅了 compilermake 钩子,并在回调中等待执行 compilation.addEntry 方法。

生成modules

compilation.addEntry 方法会触发第一批 module 的解析,即我们在 entry 中配置的入口文件 index.js。在深入 modules 的构建流程之前,我们先对模块实例 module 的概念有个了解。

modules

一个依赖对象(Dependency)经过对应的工厂对象(Factory)创建之后,就能够生成对应的模块实例(Module)。

Dependency,可以理解为还未被解析成模块实例的依赖对象。比如配置中的入口模块,或者一个模块依赖的其他模块,都会先生成一个 Dependency 对象。每个 Dependency 都会有对应的工厂对象,比如我们这次debuger的代码,入口文件 index.js 首先生成 SingleEntryDependency, 对应的工厂对象是 NormalModuleFactory。(前文说到SingleEntryPlugin插件时有放代码,有疑惑的同学可以往前翻翻看)

// 创建单入口依赖 
const dep = SingleEntryPlugin.createDependency(entry, name);
// 正式进入构建阶段
compilation.addEntry(context, dep, name, callback);

SingleEntryPlugin插件订阅的make事件,将创建的单入口依赖传入compilation.addEntry方法,addEntry主要执行_addModuleChain()

_addModuleChain

_addModuleChain(context, dependency, onModule, callback) {
   ...
   
   // 根据依赖查找对应的工厂函数
   const Dep = /** @type {DepConstructor} */ (dependency.constructor);
   const moduleFactory = this.dependencyFactories.get(Dep);
   
   // 调用工厂函数NormalModuleFactory的create来生成一个空的NormalModule对象
   moduleFactory.create({
       dependencies: [dependency]
       ...
   }, (err, module) => {
       ...
       const afterBuild = () => {
   	    this.processModuleDependencies(module, err => {
       		if (err) return callback(err);
       		callback(null, module);
           });
   	};
       
       this.buildModule(module, false, null, null, err => {
           ...
           afterBuild();
       })
   })
}

_addModuleChain中接收参数dependency传入的入口依赖,使用对应的工厂函数NormalModuleFactory.create方法生成一个空的module对象,回调中会把此module存入compilation.modules对象和dependencies.module对象中,由于是入口文件,也会存入compilation.entries中。随后执行buildModule进入真正的构建module内容的过程。

buildModule

buildModule方法主要执行module.build(),对应的是NormalModule.build()

// NormalModule.js
build(options, compilation, resolver, fs, callback) {
    return this.doBuild(options, compilation, resolver, fs, err => {
        ...
        // 一会儿讲
    }
}

先来看看doBuild中做了什么

doBuild(options, compilation, resolver, fs, callback) {
    ...
    runLoaders(
    	{
            resource: this.resource, //   /src/index.js
            loaders: this.loaders, // `babel-loader`
            context: loaderContext,
            readResource: fs.readFile.bind(fs)
    	},
    	(err, result) => {
    	    ...
    	    const source = result.result[0]; 
    	    
    	    this._source = this.createSource(
            	this.binary ? asBuffer(source) : asString(source),
            	resourceBuffer,
            	sourceMap
            );
    	}
    )
}

一句话说,doBuild 调用了相应的 loaders ,把我们的模块转成标准的JS模块。这里,使用babel-loader 来编译 index.jssource就是 babel-loader 编译后的代码。

// source
"debugger; import { helloWorld } from './helloworld.js';document.write(helloWorld());”

同时,还会生成this._source对象,有namevalue两个字段,name就是我们的文件路径,value就是编译后的JS代码。模块源码最终是保存在 _source 属性中,可以通过 _source.source() 来得到。回到刚刚的NormalModule中的build方法

build(options, compilation, resolver, fs, callback) {
    ...
    return this.doBuild(options, compilation, resolver, fs, err => {
        const result = this.parser.parse(
        	this._source.source(),
        	{
        		current: this,
        		module: this,
        		compilation: compilation,
        		options: options
        	},
        	(err, result) => {
        		
        	}
        );
    }
}

经过 doBuild 之后,我们的任何模块都被转成了标准的JS模块。接下来就是调用Parser.parse方法,将JS解析为AST。

// Parser.js
const acorn = require("acorn");
const acornParser = acorn.Parser;
static parse(code, options) {
    ...
    let ast = acornParser.parse(code, parserOptions);
    return ast;
}

生成的AST结果如下:

解析成AST最大作用就是收集模块依赖关系,webpack会遍历AST对象,遇到不同类型的节点执行对应的函数。比如调试代码中出现的import { helloWorld } from './helloworld.js'const xxx = require('XXX')的模块引入语句,webpack会记录下这些依赖项,并记录在module.dependencies数组中。到这里,入口module的解析过程就完成了,解析后的module大家有兴趣可以打印出来看下,这里我只截图了module.dependencies数组。
每个 module 解析完成之后,都会触发 Compilation例对象的succeedModule钩子,订阅这个钩子获取到刚解析完的 module 对象。 随后,webpack会遍历module.dependencies数组,递归解析它的依赖模块生成module,最终我们会得到项目所依赖的所有 modules。遍历的逻辑在afterBuild() -> processModuleDependencies() -> addModuleDependencies() -> factory.create()
make阶段到此结束,接下去会触发compilation.seal方法,进入下一个阶段。

生成chunks

compilation.seal 方法主要生成chunks,对chunks进行一系列的优化操作,并生成要输出的代码。webpack 中的 chunk ,可以理解为配置在 entry 中的模块,或者是动态引入的模块。

chunk内部的主要属性是_modules,用来记录包含的所有模块对象。所以要生成一个chunk,就先要找到它包含的所有modules。下面简述一下chunk的生成过程:

  • 先把 entry 中对应的每个 module 都生成一个新的 chunk
  • 遍历module.dependencies,将其依赖的模块也加入到上一步生成的chunk中
  • 若某个module是动态引入的,为其创建一个新的chunk,接着遍历依赖

下图是我们此次demo生成的this.chunks,_modules中有两个模块,分别是入口index模块,与其依赖helloworld模块。

在生成chunk的过程中与过程后,webpack会对chunk和module进行一系列的优化操作,优化操作大都是由不同的插件去完成。可见compilation.seal 方法中,有大量的钩子执行的代码。

this.hooks.optimizeModulesBasic.call(this.modules);
this.hooks.optimizeModules.call(this.modules);
this.hooks.optimizeModulesAdvanced.call(this.modules);

this.hooks.optimizeChunksBasic.call(this.chunks, this.chunkGroups);
this.hooks.optimizeChunks.call(this.chunks, this.chunkGroups);
this.hooks.optimizeChunksAdvanced.call(this.chunks, this.chunkGroups);

...

例如,插件SplitChunksPlugin订阅了compilation的optimizeChunksAdvanced钩子。至此,我们的modules和chunks都生成了,该去生成文件了。

生成文件

首先需要生成最终的代码,主要在compilation.seal 中调用了 compilation.createChunkAssets方法。

for (let i = 0; i < this.chunks.length; i++) {
    const chunk = this.chunks[i];
    const template = chunk.hasRuntime()
        ? this.mainTemplate
        : this.chunkTemplate;
    const manifest = template.getRenderManifest({
        ...
    })
    ...
    for (const fileManifest of manifest) {
        source = fileManifest.render();
    }
    
    ...
    this.emitAsset(file, source, assetInfo);
    
}

createChunkAssets方法会遍历chunks,来渲染每一个chunk生成代码。其实,compilation对象在实例化时,同时还会实例化三个对象,分别是MainTemplate, ChunkTemplateModuleTemplate。这三个对象是用来渲染chunk,得到最终代码模板的。它们之间的不同在于,MainTemplate用来渲染入口 chunk,ChunkTemplate用来渲染非入口 chunk,ModuleTemplate用来渲染 chunk 中的模块。

这里, MainTemplateChunkTemplaterender 方法是用来生成不同的"包装代码"的,MainTemplate 对应的入口 chunk 需要带有 webpack 的启动代码,所以会有一些函数的声明和启动。而包装代码中,每个模块的代码是通过 ModuleTemplate 来渲染的,不过同样只是生成”包装代码”来封装真正的模块代码,而真正的模块代码,是通过模块实例的 source 方法来提供。这么说可能不是很好理解,直接看看最终生成文件中的代码,如下:

每个chunk的源码生成之后,会调用 emitAsset 将其存在 compilation.assets 中。当所有的 chunk 都渲染完成之后,assets 就是最终更要生成的文件列表。至此,compilationseal 方法结束,也代表着 compilation 实例的所有工作到此也全部结束,意味着一次构建过程已经结束,接下来只有文件生成的步骤了。

emit

Compiler 开始生成文件前,钩子 emit 会被执行,这是我们修改最终文件的最后一个机会,生成的在此之后,我们的文件就不能改动了。

this.hooks.emit.callAsync(compilation, err => {
    if (err) return callback(err);
    outputPath = compilation.getPath(this.outputPath);
    this.outputFileSystem.mkdirp(outputPath, emitFiles);
});

webpack 会直接遍历 compilation.assets 生成所有文件,然后触发钩子done,结束构建流程。

总结

我们将webpack核心的构建流程都过了一遍,希望在阅读完全文之后,对大家了解 webpack原理有所帮助~

本片文章代码都是经过删减更改处理的,都是为了能更好的理解。能力有限,如果有不正确的地方欢迎大家指正,一起交流学习。