最接地气的webpack源码解析(三)

最接地气的webpack源码解析(三)

本系列按照主线:webpack执行入口、构建前准备阶段、编译构建阶段、构建后优化阶段、文件输出阶段这五部分来分享,承接上篇讲解到的webpack构建过程中的关键节点钩子,本章的重点是分析webpack的核心编译构建过程,也就是从run -> make -> buildModule -> normal-module-loader -> program。其中webpack的整体构建过程可以参考下图。

webpack构建流程2.0.png

如果还没有看之前的两篇文章的小伙伴,建议先简单过一遍之前的内容,最接地气的webpack源码解析(二)最接地气的webpack源码解析(一)

webpack/lib/webpack.js

const Compiler = require("./Compiler");

const webpack = (options, callback) => {
    options = new WebpackOptionsDefaulter().process(options);

    compiler = new Compiler(options.context);
    compiler.options = options;
    new NodeEnvironmentPlugin({
        infrastructureLogging: options.infrastructureLogging
    }).apply(compiler);
    
    if (options.plugins && 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);
    
    return compiler;
}
exports = module.exports = webpack;
复制代码

可以看到webpack在构建前准备工作完成之后,就返回了实例化后的compiler对象,所以想要运行compiler.run()来启动编译构建过程,就一定要在Compiler类里定义一个run方法,接下来我们就去研究Compiler类是如何定义的,当然定义的文件位置可以从const Compiler = require("./Compiler")中找到。

webpack/lib/Compiler.js

const {
    Tapable,
    SyncHook,
    SyncBailHook,
    AsyncParallelHook,
    AsyncSeriesHook
} = require("tapable");

const Compilation = require("./Compilation");

class Compiler extends Tapable {
    constructor(context) {
        super();
        this.hooks = {};
    }
    
    run(callback) {
        const onCompiled = (err, compilation) => {}
    
        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);
                });
            });
        });
    }
    
    compile(callback) {}
}
复制代码

可以看出来Compiler类中主要做了两件事情:

  1. 定义各种钩子供插件使用;
  2. 定义run()函数,用来启动编译构建过程。

接下来看看run()函数内都做了哪些事情?先定义编译构建完成时的回调函数onCompiled,按照之前提到的流程主线,可以看到onCompiled内主要是文件输出阶段相关的处理,这部分在后续的章节会再分析。然后继续看源码在定义了onCompiled之后,就开始触发各个钩子,从beforeRun -> run,随着触发过run钩子就开始了编译的过程,执行compile函数,这个函数也是本章的第一个重点函数。

webpack/lib/Compiler.js-compile()

compile(callback) {
    const params = this.newCompilationParams();
    this.hooks.beforeCompile.callAsync(params, err => {
        if (err) return callback(err);

        this.hooks.compile.call(params);

        const compilation = this.newCompilation(params);

        this.hooks.make.callAsync(compilation, err => {
            if (err) return callback(err);

            compilation.finish(err => {
                if (err) return callback(err);

                compilation.seal(err => {
                    if (err) return callback(err);
                    this.hooks.afterCompile.callAsync(compilation, err => {
                        if (err) return callback(err);

			return callback(null, compilation);
                    });
                });
            });
        });
    });
}
复制代码

毕竟是在compiler.js文件中定义的compile函数,看函数命名就能猜出来它是重点了,在这个函数里确实也做了很多重要的处理。首先触发编译相关的两个钩子beforeCompile -> compile,然后创建compilation对象,重点又来了!注意看这个引入compilation的地方,其实在webpack整个编译构建过程中有两个特别重要的对象,一个是之前碰到的compiler(可以理解为编译器),一个就是compilation(可以理解为编译过程)。

compliation看代码是从const compilation = this.newCompilation(params)这里得到的,但是真正的来源是从webpack/lib/Compilation.js文件中引入的。由于这个对象过于重要,就花点功夫讲清楚它是怎么被实现的,我们把关注点放到newCompilation()这个函数。

    const Compilation = require("./Compilation");
    
    createCompilation() {
        return new Compilation(this);
    }
    
    newCompilation(params) {
        const compilation = this.createCompilation();
	compilation.fileTimestamps = this.fileTimestamps;
	compilation.contextTimestamps = this.contextTimestamps;
	compilation.name = this.name;
	compilation.records = this.records;
	compilation.compilationDependencies = params.compilationDependencies;
        this.hooks.thisCompilation.call(compilation, params);
        this.hooks.compilation.call(compilation, params);
        return compilation;
    }
复制代码

其实看到这个代码的实现过程,就可以很清楚得发现compilation这个对象,是通过实例化Compilation.js中定义的类来实现的。

回到上面提到的compile()函数内的操作,在生成compilation对象之后,就触发了make钩子,重点又来了!注意看这个make钩子就是流程图里面触发run之后的下一个钩子,看源码是触发了make钩子之后紧接着就开始compilation.finish() compilation.seal() ,最后触发afterCompile钩子代表编译构建以及优化过程结束,触发回调进行文件输出,最后结束构建。构建后的优化阶段和文件输出阶段是后面两篇文章分享的内容,那本章的重点就放在compilation.finish()这里。

其实源码读到这里,主流程代码已经都分析完毕了,但我当时心里有一个疑问,为什么源码中一触发make钩子,紧接着就compilation.finish(),中间的核心构建过程去哪里了呢?这里又是一个黑盒。。。不过也可以猜测出来,既然有触发make钩子,则有果必有因,我们一起去查找webpack在哪里提前注册了make钩子的监听,以及在监听到make钩子触发之后又做了哪些处理。(这里分享个小彩蛋,webpack其实是在构建前的准备阶段提前注册的这些钩子,有兴趣的小伙伴可以回过头看一下第二篇的内容,里面webpack在实例化Compiler之后,分别先遍历注册了用户自定义的插件数组,然后又注册了webpack内置的插件,compiler.options = new WebpackOptionsApply().process(options, compiler),也就是在这里注册了对make钩子的监听和回调函数)

如果大家读到这里感觉需要消化一下前面的内容,可以先暂停进度,后面要讲的是从make钩子触发到构建完成(compilation.finish)这之间的一系列过程,前后两部分内容被webpack很好的解耦开,大家也可以先收藏,后面根据需要再分配阅读时间。


下面为了分析webpack中从make钩子触发到编译完成这个阶段做的事情,我们在webpack文件夹中搜索.hooks.make,然后会搜出7个文件内有使用,其中看钩子的注册代码,我们主要看SingleEntryPlugin.js和MutiEntryPlugin.js这两个文件,这两个文件分别代表单入口打包和多入口打包。其实webpack的整个模块打包思想也就是从入口开始,先对入口模块进行编译构建,然后分析入口模块对应的依赖模块,然后再编译这些入口模块的依赖模块,接着不断循环直到所有使用到的模块都被编译构建,最后得到一张完整的依赖图。

我们从SingleEntryPlugin.js入手,因为多入口打包也就是在单入口打包的基础上,循环每个入口分别进行单入口打包的操作。

webpack/lib/SingleEntryPlugin.js

class SingleEntryPlugin {
    constructor(context, entry, name) {
	this.context = context;
	this.entry = entry;
	this.name = name;
    }
    
    apply(compiler) {
	compiler.hooks.compilation.tap(
            "SingleEntryPlugin",
            (compilation, { 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);
            }
        );
    }
}
复制代码

可以看到SingleEntryPlugin.js中make钩子触发之后,会将入口模块传递给compilation.addEntry(),所以我们接下来看看compilation.addEntry()做了什么事情,需要到webpack/lib/Compilation.js文件一探究竟。

webpack/lib/Compilation.js

class Compilation extends Tapable {
    constructor(compiler) {
        super();
	this.hooks = {};
        
        this.compiler = compiler;
        this.options = options;
        this.entries = [];
        this.modules = [];
    }
    
    buildModule(module, optional, origin, dependencies, thisCallback) {}
    _addModuleChain(context, dependency, onModule, callback) {}
    addEntry(context, entry, name, callback) {}
}
复制代码

这里我先提供了Compilation这个类的几个重要函数和内置参数,其实compilation也提供了很多钩子可以供插件使用,方便开发人员对编译构建过程进行定制化,而且可以通过compiler属性拿到compiler,通过options获取到webpack配置,entries可以拿到构建开始的入口模块,modules可以获取到所有编译构建后的模块,极大程度得支持了开发人员去自定义插件。

现在开始进入正题,开始编译构建的时候,addEntry()函数内都做了什么处理?

webpack/lib/Compilation.js-addEntry()

addEntry(context, entry, name, callback) {
    this.hooks.addEntry.call(entry, name);
    
    this._addModuleChain(
        context,
	entry,
	module => {
            this.entries.push(module);
	},
	(err, module) => {
            this.hooks.succeedEntry.call(entry, name, module);
            return callback(null, module);
        }
    )
}
复制代码

addEntry()内主要做了两件事情,把entry模块放到this.entries数组内,然后调用this._addModuleChain,看函数命名的话可以猜到_addModuleChain会链式递归构建模块。

webpack/lib/Compilation.js-_addModuleChain()

_addModuleChain(context, dependency, onModule, callback) {
    this.semaphore.acquire(() => {
        moduleFactory.create({}, (err, module) => {
            const addModuleResult = this.addModule(module);
            module = addModuleResult.module;
            
            const afterBuild = () => {
                if (addModuleResult.dependencies) {		
                    this.processModuleDependencies(module, err => {
			if (err) return callback(err);
                        callback(null, module);
                    });
		} else {
                    return callback(null, module);
		}
            };
            
            if (addModuleResult.build) {
		this.buildModule(module, false, null, null, err => {
                    afterBuild();
                });
            }
        })
    })
}
复制代码

_addModuleChain就是真正递归遍历构建模块的部分,首先构建当前模块,调用this.buildModule(),当前模块构建完成之后再调用afterBuild(),通过this.processModuleDependencies()递归得处理当前模块所依赖的模块。至于究竟是怎样构建当前模块的呢?

先看一下this.buildModule的定义:

webpack/lib/Compilation.js-buildModule()

buildModule(module, optional, origin, dependencies, thisCallback) {
    this.hooks.buildModule.call(module);
    module.build();
}
复制代码

this.buildModule内触发了另一个关键的钩子buildModule,到这里完成了从run -> make -> buildModule的分析。buildModule()主要是调用了module.build()方法,这里的build()方法是module模块内部提供的。

为了搞清楚module.build是在哪里定义的,需要先扩展一下webpack中的模块定义,webpack的模块一共分为五种(NormalModule ContextModule ExternalModule DelegatedModule MutiModule),我们通常遇到的模块基本都是NormalModule(第三方依赖模块)和ContextModule(上下文模块-用户自定义的模块),下面我们以NormalModule为例子,去看该模块提供的build方法。

webpack/lib/NormalModule.js

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

doBuild(options, compilation, resolver, fs, callback) {
    const loaderContext = this.createLoaderContext(
        resolver,
	options,
	compilation,
	fs
    );

    runLoaders({
        resource: this.resource,
	loaders: this.loaders,
	context: loaderContext,
	readResource: fs.readFile.bind(fs)
    }, () => {})
}

createLoaderContext() {
    compilation.hooks.normalModuleLoader.call(loaderContext, this);
}
复制代码

module.build()执行时会再调用this.doBuild(),到doBuild这里,我们才真正揭开webpack如何构建模块的神秘面纱,原来也是通过loader-runner提供的runLoaders来使用开发者定义的loaders去处理文件模块,然后把编译后的结果传递给const result = this.parser.parse(this._ast || this._source.source()),使用parser方法去解析编译后的代码。在执行runloader方法之前需要先得到loaderContext这个必传参数,也正是在获取这个参数的过程中会触发normalModuleLoader这个钩子,完成了从make -> buildModule -> normalModuleLoader的分析。然后我们回到使用parser解析编译结果的过程,这个this.parser最终是从webpack/lib/parser.js中引入的。

webpack/lib/parser.js

parse(source, initialState) {
    if (typeof source === "object" && source !== null) {
	ast = source;
        comments = source.comments;
    } else {
        comments = [];
	ast = Parser.parse(source, {
            sourceType: this.sourceType,
            onComment: comments
	});
    }
    
    if (this.hooks.program.call(ast, comments) === undefined) {
        this.detectMode(ast.body);
	this.prewalkStatements(ast.body);
	this.blockPrewalkStatements(ast.body);
	this.walkStatements(ast.body);
    }
}

prewalkStatements(statements) {
    for (let index = 0, len = statements.length; index < len; index++) {
	const statement = statements[index];
	this.prewalkStatement(statement);
    }
}

prewalkStatement(statement) {
    case "ImportDeclaration":
	this.prewalkImportDeclaration(statement);
	break;
}

prewalkImportDeclaration(statement) {
    for (const specifier of statement.specifiers) {
        switch (specifier.type) {
            case "ImportDefaultSpecifier":
		this.hooks.importSpecifier.call(statement, source, "default", name);
		break;
            case "ImportSpecifier":
		this.hooks.importSpecifier.call(
                    statement,
                    source,
                    specifier.imported.name,
                    name
		);
            break;
            case "ImportNamespaceSpecifier":
                this.hooks.importSpecifier.call(statement, source, null, name);
            break;
	}
    }
}
复制代码

parser解析的过程,会先触发program钩子,实现了构建子过程的最后一个钩子,从make -> buildModule -> normalModuleLoader -> program的分析。到program触发其实就完成了模块的编译构建,接下来就是构建之后的优化阶段和文件输出阶段,这两部分将在后面的两篇文章中分享。

这里parser的解析过程是很值得借鉴的,尤其是在开发人员自定义loader的时候,都需要先将源代码转化成AST,webpack是借助acorn提供的parser方法去生成AST,然后遍历AST去判断语法做特殊处理。这里我截选了对ImportDeclaration-import声明语法的处理,因为当parser遇到import语句的时候需要进行依赖收集,当前模块编译构建完成之后还需要递归编译依赖模块,直到所有模块编译完成。

下一章将会分享所有模块编译完成后的优化阶段。

分类:
前端
标签: