likes
comments
collection
share

webpack打包流程浅析

作者站长头像
站长
· 阅读数 35

之前webpack一直停留在用的阶段,最近刚好有时间学习了一下webpack的源码,通过读源码以加深对webpack的了解。

源码版本5.73.0

下面逐步分析一下webpack的编译流程

一、初始化

首先回顾一下平时我们是如何使用webpack的。常见如下两种场景

  1. 配置好webpack.config.js文件后, 在package.json文件中配置对应的script如: npx webpack --config webpack.config.js
  2. 或者做一些定制化,通过node执行自定义的文件比如 node build.js
    // build.js
    const webpack = require('webpack')
    const config = require('./webpack.config.js')
    ...
    
    webpack(config, () => {
       ...
    })
    

针对第一种场景, 执行命令时webpack会校验是否安装webpack-cliwebpack-cli会校验命令行参数以及config文件,最终也是执行webpack(config, callback)和场景2类似。 基于以上两种场景,最终都是执行webpack()函数,因此我们需要找到该函数

根据webpack的package.json信息找到对应的入口文件lib/index.js=>lib/webpack.js找到对应的webpack函数如下。(为了方便解释,代码精简了一部分)

const webpack = (options, callback) => {
    const create = () => {
       let compiler;
       ...
       compiler = createCompiler(webpackOptions);
       return { compiler }
       
    }
    if (callback) {
        const { compiler } = create() 
        compiler.run((err, stats) => {
            compiler.close(err2 => {
                callback(err || err2, stats);
            });
        });
        return compiler
    } else {
        const { compiler } = create() 
        return compiler
    }
}

可以看到默认导出的函数只是做了进一步封装, 重头戏在createCompiler

const createCompiler = rawOptions => {
    const options = getNormalizedWebpackOptions(rawOptions);
    applyWebpackOptionsBaseDefaults(options);
    const compiler = new Compiler(options.context, options);
    new NodeEnvironmentPlugin({
        infrastructureLogging: options.infrastructureLogging
    }).apply(compiler);
    if (Array.isArray(options.plugins)) {
        for (const plugin of options.plugins) {
            if (typeof plugin === "function") {
                plugin.call(compiler, compiler);
            } else {
                plugin.apply(compiler);
            }
        }
    }
    applyWebpackOptionsDefaults(options);
    compiler.hooks.environment.call();
    compiler.hooks.afterEnvironment.call();
    new WebpackOptionsApply().process(options, compiler);
    compiler.hooks.initialize.call();
    return compiler;
};

逐步分析createCompiler的逻辑

  1. 首先通过getNormalizedWebpackOptions组装、合并、转化默认参数,比如entry: './index.js'会被转化成{main:{import: ['./index.js']}}, 最终生成一个完整的配置信息options webpack打包流程浅析
  2. 接着通过applyWebpackOptionsBaseDefaults设置context的值和logger的配置
  3. 然后创建compiler对象
  4. 接着通过NodeEnvironmentPlugin初始化编译需要的inputFileSystem、outputFileSystem、watchFileSystem等信息到compiler对象上
  5. 因为compiler对象已创建,所以可以初始化使用者自定义配置的plugins
  6. 然后通过applyWebpackOptionsDefaults给第一步生成的完整options赋值默认值, 可以和上一张图做下对比。webpack打包流程浅析
  7. 然后通过new WebpackOptionsApply().process基于合并后的options初始化对应的内置插件。比如:基于mode初始化DefinePlugin生成process.env.NODE_ENV变量、基于optimization.splitChunks初始化SplitChunksPlugin等;这里重点讲一下EntryOptionPlugin, 会基于options的entry配置初始化EntryOptionPlugin插件进而初始化EntryPlugin,在EntryPlugin内部会注册compiler的hooks.make事件
    // WebpackOptionsApply.js
    class WebpackOptionsApply extends OptionsApply {
        process(options, compiler) {
                ...
                // 注意这里,下面重点分析
                new EntryOptionPlugin().apply(compiler);
                compiler.hooks.entryOption.call(options.context, options.entry);
                ...
        }
    }
    
    // EntryPlugin.js
    class EntryPlugin {
            apply(compiler) {
                compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
                  // dep即为EntryDependency的实例,里面包含entry配置的信息
                    compilation.addEntry(context, dep, options, err => {
                            callback(err);
                    });
                });
            }
    }
    

经过以上步骤创建好compiler对象,通过初始化一系列插件注册了很多事件, 需要一个时机来触发对应的动作。 万事具备,只欠东风

二、编译阶段1 -- make阶段

compiler.run登场了, 进入编译阶段

先看一下run方法

class Compiler {

  run() {
      ...
      this.hooks.run.callAsync(this, err => {
            if (err) return finalCallback(err);
            this.readRecords(err => {
                if (err) return finalCallback(err);
                this.compile(onCompiled);
            });
      });
  }
  
  compile() {
        const params = this.newCompilationParams();
        ...
        const compilation = this.newCompilation(params);
        
        const logger = compilation.getLogger("webpack.Compiler");

        logger.time("make hook");
        // 触发make事件
        this.hooks.make.callAsync(compilation, err => {
            logger.timeEnd("make hook");
            if (err) return callback(err);
        }
  
  }
  
  newCompilationParams() {
        // 注意这里
        const params = {
            normalModuleFactory: this.createNormalModuleFactory(),
            contextModuleFactory: this.createContextModuleFactory()
        };
        return params;
  }
}

可以看到,通过run方法调用compile,在compile内部创建了compilation对象, 同时触发compiler.hooks.make事件。还记得我们上面讲的初始化内置插件时,通过EntryPlugin注册了compiler.hooks.make事件,在这里触发了make事件执行compilation.addEntry方法

整个流程如下, 流程中第1步对应上面讲的第一章节, 第2步对应上面讲的make阶段

(流程中红色字体代表hooks的调用, 蓝色字体代表hooks注册,圆形嵌套代表函数嵌套调用😄)

webpack打包流程浅析

接下来继续讲compilation.addEntry。先大概说一下addEntry的整体流程, 然后根据流程图进一步分析。在该过程首先分析入口文件, 根据入口文件AST找到其依赖的文件,然后再分析其引入文件将依赖文件的依赖进一步分析编译,执行完所有相关文件的编译。有点绕,看下流程图

webpack打包流程浅析

经过addEntry一系列调用最终通过factory.create将入口文件转换成Module对象,并根据文件的格式匹配对应的loader,记录在Module对象上。

factory是什么? 常见的factory有两种,分别为normalModuleFactorycontextModuleFacotry, 这两个factory在创建compilation时被赋值到compilation对象。

  1. normalModuleFactory常用来处理import { A } from './a'这种导入语句的文件,a.js对应的文件最终会被转换成NormalModule
  2. contextModuleFactory常用来处理const locale = require('./locale/' + name)这种带变量的导入语句, 在编译阶段webpack没办法识别真正运行时该用哪个文件,因此会将locale目录下的所有文件都进行编译;在转成Module时,Module对象上会带有一个正则reg: /*/用来匹配所有文件。也正因如此,webpack提供了ContextReplacementPlugin插件来替换默认的正则以及路径,提前告诉webpack编译指定的文件。

经过normalModuleFactorycontextModuleFactory处理后的文件,我们暂且统称为Module,在编译过程中都是对Module做相应转换与存储。在compilation内部只会通过compilation.modules记录所有Module,以及通过moduleGraph记录Module之前的依赖关系,真正执行build、codeGeneration时都是调用Module自身模块的对应方法。上面流程图中只画了NormalModuleFactory的处理, ContextModuleFactory的处理逻辑也一样,区别在于每个Module的内部实现不一样。

normalModuleFactorycontextModuleFactory解析和build之前,都可以调用factory对应的hooks来忽略该文件的处理,比如beforeResolvefactorizedresolve等,注意这些hooks为ModuleFacotry的并不是compilation的,可以通过compilation.normalModuleFacotry.hooks或者compilation.contextModuleFacotry.hooks来注册;webpack.IgnorePlugin插件就是利用了这个特性

build时,会根据该文件匹配到的loader,执行runLoaders转换文件内容。

Module被build之后,会在Module对象上记录对应的Dependencies, 然后轮询调用applyFactoryResultDependencies再对Dependencies以及Dependencies的Dependencies进行build,这样所有依赖相关的文件都会被编译并记录到compilation.modules中, 模块间的依赖关系会被收集到moduleGraph中。

可以看看编译后的moduleGraph

moduleGraph
webpack打包流程浅析
  • exports中记录了该模块导出的语句(用于tree-shaking、splitChunk分析等);

    webpack打包流程浅析
  • incomingConnections中维护了该模块被其他模块引入的关系

    webpack打包流程浅析
  • outgoingConnections中维护了该模块与其引入别的模块的对应关系

    webpack打包流程浅析

经过以上的处理,make阶段基本结束,然后进入seal阶段

三、编译阶段二--seal阶段

在make阶段,根据loader已经build所有文件,那在seal阶段做哪些工作呢?

首先回想一下, 在正常情况下,我们在webpack.config.js中配置几个entry,最终就会生成几个bundle。 没错seal阶段就是根据entry配置找到对应的依赖Module,组装整合到一起, 生成对应的chunk;正常情况,有几个entry就会对应几个chunk,但是也有特殊情况,比如不同chunk 之间会存在重复引用某些文件的情况,以及当我们引用一些较大的library时导致最终打出的bundle文件较大时,就需要做一些优化;因此出现了SplitChunksPlugin, 通过webpack.optimization配置以优化上述两种场景,除了生成正常的chunk之外,还会生成额外的chunk

那么每个chunk中应该包含哪些模块的代码呢? 在addEntry方法中除了编译、收集Module外,还会将entry对应的信息记录到compilation.entries中; 在seal阶段,根据compilation.entries分析dependencies, 将与该入口文件相关的Module都与chunk绑定上关系,该关系最终维护在chunkGraph中,看下其结构

webpack打包流程浅析

在依赖都聚合、组装、提取之后,根据chunk调用compilation.codeGeneration,找到与该chunk有关系的Module,调用Module自身的codeGeneration生成对应的代码,代码合并之后记录到compilation.assets字段上,最后的最后再调用compiler.emitAssets写到对应的output目录中去。

因此如果想通过插件优化modules或者改变Modulechunk的固有关系,就需要在seal阶段的codeGeneration之前处理。比如SplitChunksPlugin

整体流程如下 webpack打包流程浅析

至此整个编译流程结束。

我是如何debug源码的

首先clone下来源码,装上对应的依赖,执行编译的examples的命令,还是会报错;命令最终通过webpack-cli去调用webpack,又因为webpack-cli还是在node_modules依赖中,其内部通过require('webpack')方式引入webpack,在node_modules中是没有webpack的,所以最简单的就是把require('webpack')的方式改成相对依赖,引入webpacklib/index.js就可以了。 然后就可以通过配置vscode debug,单独调试某个example了。个人认为相对于调试其他库,调试webpack是最简单的了😄

webpack打包流程浅析

~~水平有限,文中有错误之处,还望各位大佬指正

转载自:https://juejin.cn/post/7124938898190368775
评论
请登录