Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

92.Webpack如何编译打包 #92

Open
webVueBlog opened this issue Feb 20, 2023 · 0 comments
Open

92.Webpack如何编译打包 #92

webVueBlog opened this issue Feb 20, 2023 · 0 comments

Comments

@webVueBlog
Copy link
Member

本质上,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 实现的。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant