ChickenDreamFactory/fe-chicken

92.Webpack如何编译打包

Opened this issue · 0 comments

本质上,webpack 是一个现代 Javascript 应用程序的静态模块打包工具。当 webpack 处理应用程序时,它会在内部构建一个 依赖图 (dependency graph),此依赖图会映射项目所需的每个模块,并生成一个多或多个 bundle。

从 v4.0.0开始,webpack 开箱即用,可以无需使用任何配置文件。然而,webpack 会假定项目的入口起点为 src/index,然后会在 dist/main.js 输出结果,并且在生产环境开启压缩和优化。

通常,你的项目还需要继续扩展此能力,为此你可以在项目根目录下创建一个 webpack.config.js 文件,webpack 会自动使用它。

核心概念

入口(entry)

输出(output)

loader

插件(plugin)

模式(mode)

Compiler 和 Compilation

Compiler 和 Compilation 类是 webpack的核心模块,都继承于 Tapable

Compiler 从宏观层面上,负责控制 webpack 打包的整个生命周期,而 Compilation 则从微观层面上,负责具体的读取文件内容、借助loader转译、借助 AST语法树 分析依赖、递归编译依赖文件等细颗粒度的工作。

整体打包流程

读取 webpack 配置和命令行参数,生成最终的配置;

启动 webpack,根据配置创建 Compiler 或 MultiCompiler 实例,将所有内部插件和自己配置的插件全部实例化,并通过实例方法 pluginInstance.apply(compiler),挂载到 compiler 上不同的hooks上,并打包项目;

打包阶段(compiler.run()),一开始会有一些准备工作,然后调用 compiler 的 compile 方法进入编译准备环节。

编译准备环节,会先触发 compiler.hooks.beforeCompile 钩子, 然后触发 compiler.hooks.compile 钩子, 然后新建 Compilation 实例。

在 compilation 的创建过程中,会触发 compiler.hooks.compilation 钩子,把之前创建的不同类型 module 的工厂实例注册到 compilation 的 dependencyFactories 上,用于 compiler.hooks.make 阶段使用。

compiler.hooks.make 阶段,就是编译阶段,最耗时的环节,会触发 compilation 上的不同钩子。首先会先从入口文件(entry)开始解析,并 acorn 生成AST语法树,将import、require等语法替换成webpack自定义的模块加载方法,如__webpack_require__,并分析其中的依赖文件,生成依赖列表,重复前面的操作,递归编译。等所有模块都加载完毕后,make 阶段才结束。

make 阶段结束后,compilation 调用 seal 方法进入 compilation 的seal 阶段, 会触发compilation.hooks.seal 、compilation.hooks.optimize、compilation.hooks.optimizeTree 等钩子,从钩子名称可以看出来,Tree Shaking , Code Spliting、代码压缩等都是在此阶段完成的。然后进入 emit 阶段。

compiler.hooks.emit阶段,使用 neo-async 库,并行写入文件。

读取配置

当开始执行 npx webpack时,先检查是否安装了 webpack-cli 或者 webpack-command(webpack-command 是一个简化版的 webpack-cli,目前已废弃)。然后实例化 WebpackCli,调用 run 方法。

webpack-cli 使用 commander 来封装命令,默认执行 build 命令。

// webpack-cli/lib/webpack-cli.js 1454行
 const loadedConfig = await loadConfig(foundDefaultConfigFile.path);
 const evaluatedConfig = await evaluateConfig(loadedConfig, options.argv || {});

默认从 webpack.config、.webpack/webpack.config、.webpack/webpackfile 读取配置信息,调用 evaluateConfig 合并配置文件信息和命令行参数。

实例化 Compiler

// webpack-cli/lib/webpack-cli.js 1847行
 compiler = this.webpack(
    config.options,
    callback
        ? (error, stats) => {
              if (error && this.isValidationError(error)) {
                  this.logger.error(error.message);
                  process.exit(2);
              }

              callback(error, stats);
          }
        : callback,
);

如果 config.options 是数组,则实例化 MultiCompiler,否则则实例化 Compiler。

options = new WebpackOptionsDefaulter().process(options);

WebpackOptionsDefaulter 会把其余未指定的默认配置添加到 options 中。

new NodeEnvironmentPlugin({
        infrastructureLogging: options.infrastructureLogging
}).apply(compiler);

NodeEnvironmentPlugin 借助 graceful-fs 模块,为 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);
        }
    }
}

接下来,遍历所有自己配置的 plugin,调用 apply 方法,订阅不同的compiler 生命周期。例如, 我们常用的webpack.DefinePlugin 会订阅 compiler.hooks.compilation 钩子,插件会在编译的时候,替换符合条件的代码。

// webpack/lib/webpack.js 57行
compiler.options = new WebpackOptionsApply().process(options, compiler);

WebpackOptionsApply 会根据 webpack 配置中的 devtool、 target 等字段,再次往配置中添加内置的插件,并订阅相应的生命周期。其中最重要的是 EntryOptionPlugin 插件,没有它 webpack 就无法找到入口文件:

// webpack/lib/WebpackOptionsApply.js 290行
new EntryOptionPlugin().apply(compiler);
compiler.hooks.entryOption.call(options.context, options.entry);

EntryOptionPlugin 会订阅 compiler.hooks.entryOption 钩子,紧接着在下一行触发钩子,这时会根据是单入口还是多入口,选择实例化 SingleEntryPlugin 或 MultiEntryPlugin (当entry是函数时,会实例化 DynamicEntryPlugin) , 插件会订阅 compiler.hooks.comilation 和 compiler.hooks.make。在后续当 compiler 触发 make 钩子时,会执行 compilation.addEntry...,从入口文件开始执行依赖分析、编译等操作。

启动 (compiler.run())

上述操作完成之后,compiler 调用 run 方法开始打包。

// webpack/lib/Compiler.js 312行
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);
        });
    });
});

依次触发 beforeRun、run 钩子,然后调用 compile, 进入编译阶段。

编译准备阶段(compiler.compile(onCompiled))

// webpack/lib/Compiler.js 661行
const params = this.newCompilationParams();

// webpack/lib/Compiler.js 651行
newCompilationParams() {
    const params = {
        normalModuleFactory: this.createNormalModuleFactory(),
        contextModuleFactory: this.createContextModuleFactory(),
        compilationDependencies: new Set()
    };
    return params;
}

当调用 compiler 的 compile 方法后,会先新建一个用于实例化 Compilation 的参数对象,该对象包含 normalModuleFactory、contextModuleFactory 两种模块工厂。

normalModule 很好理解,就是编译时确定的普通模块,但大家看到 contextModule 可能会有些疑惑,这是什么模块?

require('template/' + name + '.js');

当出现形如上面的代码时,webpack 会对 require() 的调用进行解析,从中提取有用的信息。

Directory: ./template
Regular expression: /^.*\.js$/

于是就会产生一个contextModule。

如果下面是一个 id为 2 的 contextMoudle, 它包含了一个 map,里面保存了 template 目录下所有模块的引用:

{
'a.js':10,
'b.js':11
}

require('template/' + name + '.js'); // 运行时 name 为 a
// 编译过程中会转换为
__webpack_require__(21)(name + '.js')

! 注意: 为了满足动态require的需求,所以所有符合条件的 module 都会被打包到 bundle中。

接下来,会依次触发 beforeCompile、compile 钩子,然后新建一个 Compilation 实例。新建过程中,会触发 compiler.hooks.compilation 钩子,把之前创建的不同类型 module 的工厂实例注册到 compilation 的 dependencyFactories 上,用于 compiler.hooks.make 阶段使用。

编译阶段 compiler.hooks.make

实例化 Compilation,进入 compiler.hooks.make 阶段, 触发 SingleEntryPlugin 订阅的回调函数,执行 compilation.addEntry(), 从入口文件开始,使用 loader-runner 执行符合条件的 loader,然后使用 acorn 生成 AST语法树, 获取依赖,再递归执行前面的操作,直到所有依赖都被加载过。

// SingleEntryPlugin.js 40行
compiler.hooks.make.tapAsync(
    "SingleEntryPlugin",
    (compilation, callback) => {
        const { entry, name, context } = this;

        const dep = SingleEntryPlugin.createDependency(entry, name); // dep 就是入口模块
        compilation.addEntry(context, dep, name, callback); // context 是 process.cwd()的结果
    }
);

addEntry() 后面继续调用 this._addModuleChain(....), 然后一系列操作后,会执行 module.build(...),这就是 入口模块执行编译了。

// NormalModule.js 287行
build(options, compilation, resolver, fs, callback) {
// ...
    return this.doBuild(options, compilation, resolver, fs,err => {
        // ...
    });

}
// ...
doBuild(options, compilation, resolver, fs, callback) {
    // 创建当前 module 所有 loader 共用的 上下文
    const loaderContext = this.createLoaderContext(
        resolver,
        options,
        compilation,
        fs
    );
    // 
    runLoaders({
        resource: this.resource, // 模块路径
        loaders: this.loaders, // loaders
        context: loaderContext, // 上下文
        readResource: fs.readFile.bind(fs) // 读取文件内容的能力
    })
}

runLoaders 会使用 readResouce 读取文件内容,按照从右到左的顺序执行 loader。然后调用 this.parser.parse(code) (acorn) 生成 AST语法树, 读取依赖存储到 module 的 dependencies 上, 然后调用 compilation 的 addModuleDependencies 方法, 使用 neo-async 库异步处理每个 dependency 。

代码优化(compilation.seal 阶段)

我截取部分源码,看看 seal 阶段做了什么?

// Compilation.js 1186行
seal(callback) {
    this.hooks.seal.call();
    while (
        this.hooks.optimizeDependenciesBasic.call(this.modules) ||
        this.hooks.optimizeDependencies.call(this.modules) ||
        this.hooks.optimizeDependenciesAdvanced.call(this.modules)
    ) {
        /* empty */
    }
    this.hooks.afterOptimizeDependencies.call(this.modules);
    this.hooks.beforeChunks.call();
    // ...
}

可以看出,这个阶段是 webpack.config.js 的 optimization 的配置在起作用,Tree Shaking、Code Spliting 等代码优化工作都是在这个阶段完成的。

输出文件到指定的输出目录(compiler.hooks.emit 阶段)

代码优化完毕,差不多就会执行 compiler.compile(onCompiled) 中的 onCompiled 回调函数,我们再看下里面是什么?

const onCompiled = (err, compilation) => {
// ...
this.emitAssets(compilation, err => {
    if (err) return finalCallback(err);
   // ... 
});
// ...
}			

我们看到里面又调用了 this.emitAssets() 方法,输出文件的意思。具体源码就不展示了,里面是使用 aeo-async 异步输出文件,文件的 io 操作是通过 compiler.outputFileSystem 实现的。