Webpack启动流程

下面我们根据codewhy大佬整理的webpack启动流程图,按照顺序去看webpack的启动流程:
wps18.jpg

首先我们会使用npm run build进行打包,本质上是找到了package.json里的命令(其实这个命令就相当于npx webpack ……)。
它会找到node_modules/bin目录下的webpack.js文件并执行。

  1. //package.json
  2. "scripts": {
  3. "build": "webpack --config ./config/webpack.common.js --env production",
  4. "serve": "webpack serve --config ./config/webpack.common.js --env development"
  5. },

我们可以看到在webpack.js文件里看到很多代码,
其实它主要做两件事:

  • 一个是判断当前项目有没有安装webpack-cli,如果没有会在终端一步步的指引安装。
  • 另一个事,就是用户安装了webpack-cli的情况下执行runcli()。(我们可以看到runcli这个函数)
    1. //node_modules/bin/webpack
    2. const runCli = cli => {
    3. //一步步的路径查找
    4. const path = require("path");
    5. const pkgPath = require.resolve(`${cli.package}/package.json`);
    6. const pkg = require(pkgPath);
    7. //最终找到webpack-cli/bin下cli文件的代码,并执行
    8. require(path.resolve(path.dirname(pkgPath), pkg.bin[cli.binName]));
    9. };

上边的runcli函数会找到webpack-cli/bin下的cli.js文件,cli.js文件跟上边的webpack.js一样是去做两件事。

  • 一个是去判断webpack是否安装,如果没有安装会在终端指引安装。
  • 另一个事,就是继续执行又封装了一层的runcli函数。

我们直接看runlic函数本质上在做什么事:我们可以看到它是去声明了一个WebpackCLI类的实例对象,并执行了run方法。

  1. const runCLI = async (args, originalModuleCompile) => {
  2. // 创建一个webpackCli实例。
  3. const cli = new WebpackCLI();
  4. cli._originalModuleCompile = originalModuleCompile;
  5. //执行实例方法run
  6. await cli.run(args);
  7. };

这个run方法核心:调用webpack库的webpack(config,callback)方法进行打包编译

  1. //在run方法一步步找到createCompiler方法下的webpack调用
  2. this.webpack(
  3. config.options,
  4. callback
  5. ? (error, stats) => {
  6. if (error && this.isValidationError(error)) {
  7. this.logger.error(error.message);
  8. process.exit(2);
  9. }
  10. callback(error, stats);
  11. }
  12. : callback
  13. );

小结

我们看到上边webpack-cli很多的代码,一层一层地去找发现最后的核心代码就是对webpack的调用。
我们甚至可以删掉webpack-cli的很多额外代码,只保留核心代码。也是可以使用webpack进行打包的。

尝试自己封装一个webpack-cli
我们模仿webpack-cli的核心代码,自己写一个build.js文件。
使用命令node build.js(node环境下跑我们的js代码)。这样就可以完成webpack打包了。
核心代码就两步:

  • 调用webpack(config)获取compiler。
  • 调用compiler.run()打包编译。 ```javascript 1.//build.js
    2.const webpack = require(‘webpack’);
    3.const config = require(“./config/webpack.common”)({
  1. production: true
    5.});
    6. 7.//看完后续源码我们就会发现,其实webpack(config,callback)相当于这里的webpack(config).run 8.const compiler = webpack(config);
  2. 10.compiler.run((err, stats) => {
  3. if (err) {
  4. console.error(err);
  5. } else {
  6. console.log(stats);
  7. }
    16.}); ```

Webpack-cli本质:

  • 合并我们所有的配置options;
  • 调用webpack库的webpack(config,callback)方法进行打包编译

接下来我们就可以去看webpack库的源码,更深入地学习webpack(config,callback)方法是怎么实现打包编译的。

Webpack源码阅读

接下来我们来到webpack的github仓库将webpack库的源码下载到本地,然后就开始我们的源码阅读之路。
https://github.com/webpack/webpack
接下来我们会带着问题看每一步的源码,更方便理清webpack每一步都要做什么事情。

入口webpack函数

wps19.jpg

入口webpack函数做了什么事?
根据源码我们得出答案:
create()拿到compiler并且返回compiler对象。
最终调用compiler.run()方法进行打包编译

  1. // lib/webpack.js
  2. // 核心:
  3. //1. create()创建compiler对象 ,最终返回结果都是compiler对象
  4. //2. 调用compiler.run方法进行打包编译
  5. if (callback) {
  6. try {
  7. //create拿到compiler对象
  8. const { compiler, watch, watchOptions } = create();
  9. if (watch) {
  10. compiler.watch(watchOptions, callback);
  11. } else {
  12. // 如果有callback,帮我们调用compiler.run方法()
  13. compiler.run((err, stats) => {
  14. compiler.close(err2 => {
  15. callback(err || err2, stats);
  16. });
  17. });
  18. }
  19. // 最终都会返回compiler对象
  20. return compiler;
  21. } catch (err) {
  22. process.nextTick(() => callback(err));
  23. return null;
  24. }
  25. } else {
  26. //create拿到compiler对象
  27. const { compiler, watch } = create();
  28. if (watch) {
  29. util.deprecate(...)();
  30. }
  31. // 没有callback最终也会返回compiler对象,由webpack-cli帮我们执行compiler.run()
  32. return compiler;
  33. }

创建compiler对象

wps20.jpg

create()函数做了什么事?
根据源码我们得出答案:create函数核心就是从createCompiler函数拿到compiler对象

// lib/webpack.js  
// 核心:从createCompiler拿到compiler对象返回出去
const create = () => {  
    validateSchema(webpackOptionsSchema, options);  
    // 1.定义compiler  
    let compiler;  
    let watch = false;  
    let watchOptions;  
    if (Array.isArray(options)) {  
        // 2.如果是数组,创建多个compiler(一般只有一个options,不会走这里)  
        compiler = createMultiCompiler(options, options);  
        watch = options.some(options => options.watch);  
        watchOptions = options.map(options => options.watchOptions || {});  
    } else {  
        // 2.创建compiler  
        compiler = createCompiler(options);  
        watch = options.watch;  
        watchOptions = options.watchOptions || {};  
    }  
    return { compiler, watch, watchOptions };  
};

createCompiler函数做了什么事?
根据源码我们得出答案:核心是做了四件事

  • 根据我们的options配置,new一个compiler对象
  • 将所有的plugin注册到compiler
  • 调用了两个hook钩子(environment和afterEnvironment)
  • 处理除了plugins外的其他属性(比如entry、output、devtool)

    // lib/webpack.js  
    const createCompiler = rawOptions => {  
      const options = getNormalizedWebpackOptions(rawOptions);  
      applyWebpackOptionsBaseDefaults(options);  
    
      // 1.通过new创建Compiler对象(compiler绑定这我们的options配置)  
      const compiler = new Compiler(options.context);  
      compiler.options = options;  
      new NodeEnvironmentPlugin({  
          infrastructureLogging: options.infrastructureLogging  
      }).apply(compiler);  
    
      // 2.注册所有的plugin插件到compiler  
      if (Array.isArray(options.plugins)) {  
          for (const plugin of options.plugins) {  
              //使用call或apply,将plugin插件注册到compiler上  
              if (typeof plugin === "function") {  
                  // 如果plugin是一个函数, 调用call方法完成注册  
                  // 第一个参数是this绑定值, 第二个是传入的参数  
                  plugin.call(compiler, compiler);  
              } else {  
                  // 如果plugin是一个对象, 调用apply方法完成注册  
                  plugin.apply(compiler);  
              }  
          }  
      }  
      applyWebpackOptionsDefaults(options);  
    
      // 3.调用钩子environment和afterEnvironment的call()  
      // tapable  
      compiler.hooks.environment.call();  
      compiler.hooks.afterEnvironment.call();  
    
      // 4. WebpackOptionsApply().process作用:处理config文件中除了plugins的其他属性(比如entry/output/devtool等等)  
      new WebpackOptionsApply().process(options, compiler);  
      compiler.hooks.initialize.call();  
      return compiler;  
    };
    

createCompiler做了四件事,其实这四件事都是核心。需要我们一个个地去理解每一件事里边都做了什么。所以我们又得思考如下几个问题:

  1. 我们在Compiler类上创建compiler实例,Compiler类都做了什么?
  2. 我们通过apply()注册了所有的plugins插件到compiler对象上,那plugins又是在哪个阶段执行?
  3. 调用的钩子hook是干嘛用的?
  4. WebpackOptionsApply().process对Plugins外的其他配置做了处理,这些处理是什么?

Ok,我们接下来继续带着问题看源码
根据源码我们得出答案:一进入Compiler类,在其constrouct函数上,就绑定了一大堆的hooks。
这些hooks其实可以理解为compiler的生命周期,我们把plugins绑定在compiler对象上,然后监听所有的hooks,监听到每个hook阶段就去执行这个阶段上的plugins。

// lib/Compiler.js  
lass Compiler {  
constructor(context) {  
    // 在constructor上,this.hooks初始化了一系列的hooks  
    this.hooks = Object.freeze({  
        initialize: new SyncHook([]),  

        shouldEmit: new SyncBailHook(["compilation"]),  
        done: new AsyncSeriesHook(["stats"]),  
        afterDone: new SyncHook(["stats"]),  
        additionalPass: new AsyncSeriesHook([]),  
        beforeRun: new AsyncSeriesHook(["compiler"]),  
        run: new AsyncSeriesHook(["compiler"]),  
        emit: new AsyncSeriesHook(["compilation"]),  
        assetEmitted: new AsyncSeriesHook(["file", "info"]),  
        afterEmit: new AsyncSeriesHook(["compilation"]),  
        ......  
        ......

WebpackOptionsApply().process核心:

  • 根据环境判断,将需要的内置的Plugin进行导入
  • 将除了plugins外的所有配置转化为plugin插件,并注册到compiler上(entry、output、devServer等等)

根据源码我们得出答案:WebpackOptionsApply().process将Plugins外的其他配置统统转化为plugins插件,并且注册到compiler上。

// lib/WebpackOptionsApply.js  
process(options, compiler) {  
    // 核心:做了两件事  
    // 1. 根据环境判断,将需要的内置的Plugin进行导入  
    // 2. 将除了plugins外的所有配置转化为plugin插件,并注册到compiler上(entry、output、devServer等等)  
    // (这些Plugin后续是通过tapable来实现钩子的监听, 并进行自己的处理逻辑)  

    // 1. 根据环境判断,将需要的内置的Plugin进行导入  

    // 比如这里是NodeTarget时需要的Plugin  
    if (options.externalsPresets.node) {  
        const NodeTargetPlugin = require("./node/NodeTargetPlugin");  
        new NodeTargetPlugin().apply(compiler);  
    }  
    // 比如这里是Electron环境时需要的Plugin  
    if (options.externalsPresets.electronMain) {  
        //@ts-expect-error https://github.com/microsoft/TypeScript/issues/41697  
        const ElectronTargetPlugin = require("./electron/ElectronTargetPlugin");  
        new ElectronTargetPlugin("main").apply(compiler);  
    }  
   ......  
   ......  
   ......  
    // 2. 将除了plugins外的所有配置转化为plugin插件(entry、output、devServer等等)  
               // 使用new ...Plugin().apply(compiler)将其他配置转化为plugins插件,并注册到compiler上  

    // 入口处理的插件是EntryOptionPlugin  
    new EntryOptionPlugin().apply(compiler);  
    new RuntimePlugin().apply(compiler);

在上边看完了compiler对象的创建都做了什么事,更好地理解了compiler对象里都有什么。但是最终打包编译的还是compiler里的run方法。接下来我们一起看看run方法都有什么内容。

compiler.run方法中执行的hook

wps21.jpg

Compiler在constrouct构造函数时,绑定了很多的hooks。这些hooks都在什么时候执行?
Webpack会使用“this.hooks.beforeRun.callAsync()”这种方式执行hooks。
根据源码我们得出答案:在run方法中执行的hooks顺序为:beforeRun- run - beforeCompiler - compile - make - finishMake - afterCompiler - done

// lib/Compiler.js  
const run = () => { 
    // 先执行一系列的hooks:this.hooks.beforeRun.callAsync()就是执行hooks  
    // beforeRun hook
    this.hooks.beforeRun.callAsync(this, err => {  
        if (err) return finalCallback(err);  
         ////run hook
        this.hooks.run.callAsync(this, err => {  
            if (err) return finalCallback(err);  

            this.readRecords(err => {  
                if (err) return finalCallback(err);  

                // 进入compile函数,接着看执行了哪些hooks  
                this.compile(onCompiled);  
            });  
        });  
    });  
 };

接着上边的代码,进入compile函数中看执行了哪些hooks

// lib/Compiler.js  
compile(callback) {  
  // new创建一个compilation实例
  const compilation = this.newCompilation(params);
  const logger = compilation.getLogger("webpack.Compiler");
  //beforeCompile hook  
  this.hooks.beforeCompile.callAsync(params, err => {  
    if (err) return callback(err);  
    // make  hook  
    this.hooks.make.callAsync(compilation, err => {  
        if (err) return callback(err);  
        //finishMake hook  
        this.hooks.finishMake.callAsync(compilation, err => {  
            if (err) return callback(err);  
               // finishMake hook  
               this.hooks.afterCompile.callAsync(compilation, err => {  
                    if (err) return callback(err);  
                        return callback(null, compilation);

Compilation对Module的处理

我们所有的Module在哪个hook阶段执行呢?
在前面我们知道了各个hooks的执行顺序。而且我们之前就在源码里看到了在创建compiler对象时,我们所有的Module,都通过WebpackOptionsApply().process转化成了plugins,绑定在了compiler上了。
这是目前我们知道的。我们要想知道所有的Module在哪个阶段开始执行,就应该去看entry入口转化后的plugins,被绑定在哪个hook上。

那我们接下来就去WebpackOptionsApply().process这里边找关于entry被绑定在哪个hook。
于是就按照这个路径EntryOptionPlugin.apply -> EntryPlugin.apply找到了我们的答案。
根据源码得到答案:使用hooks.make.tapAsync将entry绑定在compiler的make hook上。
所以说我们的Module是在make hook上开始执行。

//EntryPlugin.js      
apply(compiler) {  
    //核心:使用hooks.make.tapAsync将entry绑定在compiler的make hook上  
    compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {  
        const { entry, options, context } = this;  

        // 创建依赖  
        const dep = EntryPlugin.createDependency(entry, options);  

        // 调用了compilation的addEntry方法!!!  
        compilation.addEntry(context, dep, options, err => {  
            callback(err);  
        });  
    });  
}

Compilation对Module做了什么处理?
wps22-16539941253161.jpg

在上边问题中,我们找到了在make hook里绑定entry,并且其回调函数里调用了compilation.addEntry。
继续按照这个思路阅读源码,就能找到新的答案了。
来到compilation.js里,通过阅读源码,下边内容就是Compilation对module的处理。

  • addEntry
  • -> _addEntryItem
  • -> addModuleTree(将所有模块加入到模块树里)
  • -> handleModuleCreation(对模块进行一些处理然后传给下一步)
  • -> addModule(把模块添加到模块队列中,然后由_addModule对模块队列中模块保存到Compilation里)
  • -> buildModule(把模块添加到构建队列中,由_buildModule将队列里的每个模块添加后续操作)
  • -> module.needBuild(判断是否需要构建)
  • -> module.build(模块开始构建)

module的build阶段

wps23.jpg

从上个问题的结果中,我们知道了模块的构建就交给了module处理。我们直接来到NormalModule.js下的build方法。开始解决我们下一个问题。
Module是怎么完成build构建的?
我们看到build方法,发现其核心是使用了doBuild方法。所以我们直接看核心doBuild方法。

在doBuild中,调用loader-runner库中拿到runLoader方法,也就是使用loader去处理我们的module然后拿到一个结果。然后去执行processResult方法。
在processResult方法里做了很多处理结果的事情,最终将结果存到_source里,然后执行doBuild的回调。

//NormalModule.js     
doBuild(options, compilation, resolver, fs, callback) {  
    hooks.beforeLoaders.call(this.loaders, this, loaderContext);  
    // 使用loader-runner库的runLoaders方法,使用loader处理resource资源,然后把结果传给processResult  
    runLoaders(  
        {  
            processResource: (loaderContext, resource, callback) => {  
                ...  
            }  
        },  
        //runLoaders的回调:将结果传给processResult方法处理  
        (err, result) => {  
            if (!result) {  
                processResult(  
                    err || new Error("No result from loader-runner processing"),  
                    null  
                );  
            }  
        }  
    // 处理结果的函数  
    const processResult = (err, result) => {  
         //继续处理结果
          ......
        //结果保存到_source中  
        this._source = this.createSource(  
            ......  
        );  
        this._ast = ......  
        // 最终执行回调,这里的回调是doBuild的callback  
        return callback();  
    };  
 }

我们回到build方法中找到doBuild的回调函数,直接看其核心:
调用JavaScriptParse来处理ast树(或_source),目的就是看一下当前模块有没有对其他模块存在依赖(JavaScriptParse本质上是acorn这个JavaScript解析器)

// doBuild的回调函数的核心代码
try {  
   // 使用JavaScriptParser看当前模块有没有依赖
    result = this.parser.parse(this._ast || this._source.source(), {  
        current: this,  
        module: this,  
        compilation: compilation,  
        options: options   
    });  
} catch (e) {  
    handleParseError(e);  
    return;  
}  
 handleParseResult(result);

如果当前模块存在依赖模块,就接着执行handleParseResult,它会回到Compilation里的_buildModule方法将新的依赖模块放到构建模块队列里,重新执行构建操作。

输出asset阶段

wps24.jpg

在前面我们看到了module模块及其相关的依赖被build构建解析好了,之后开始进行回调callback,我们一直向上回溯,回到了compiler里最开始的compile方法中。

//compilation.js的compile方法
compilation.finish(err => {  
    if (err) return callback(err);  
    //执行compilation.seal,seal在finishMake与afterCompile两个hook之间  
    compilation.seal(err => {  
        if (err) return callback(err);  
        //开始执行afterCompile hook  
        this.hooks.afterCompile.callAsync(compilation, err => {  
            if (err) return callback(err);  
            //继续执行回调                                                       
            return callback(null, compilation);  
        });  
    });  
});

接下来我们思考两个问题:
Compilation的seal封存具体做了什么?
seal封存后,开始执行afterCompile hook,afterCompile hook的回调做了什么?

我们可以看下源码中seal中的关键代码: 可以看到seal方法里会对之前解析好的所有模块进行处理(optimize优化相关的配置在这里开始执行)。然后进入代码生成阶段,生成好的代码交给createChunkAssets方法处理。

//compilation.js  
seal(callback) {  
         ......  
    // 1.这里遍历所有模块modules,加入到ChunkGraph    
    for (const module of this.modules) {  
        ChunkGraph.setChunkGraphForModule(module, chunkGraph);  
    }  
         ......  
        // 2.执行关于optimize的优化配置的hook  
        this.hooks.optimizeChunkModules.callAsync(  
           //处理了很多optimize配置  
           // 3.代码生成的阶段  
            this.codeGeneration(err => {  
                 ......  
                // 4.创建chunkAssets资源  
                this.createChunkAssets(err => {}  
          }  
       )  
}

我们来到源码看createChunkAssets里的核心代码:原来createChunkAssets会统一的将所有的chunks生成的代码使用manifest对象的render集中处理后,交给emitAsset方法。
最终由emitAsset方法实现最终想要实现的目的:将所有要打包输出的资源都放到compilation的assets内存里,准备输出。(compilation.seal封存的最终目的)(至此,我们解决了第一个问题。)

//compilation.js
createChunkAssets(){  
   // 1. 创建了一个manifest对象, 将chunks都放到对象里,  
   //目的是要使用manifest对象的render方法  
   manifest = this.getRenderManifest({})  
   //2. 将所有chunks通过render输出到source  
   source = fileManifest.render();  

   //3.  source中有_source就是要输出的内容,最终将source传给emitAsset  
   this.emitAsset(file, source, assetInfo);  
}  

emitAsset(file, source, assetInfo = {}){  
   ......  
   //核心:将source存到compilation的assets里  
   this.assets[file] = source;  
}

然后我们再去看afterCompile hook的回调函数具体是执行了什么。根据callback回调一直追溯,找到了Compiler类里run的onComiled -> Compiler.emitAssets。
这里看一下emitAssets做了什么?

emitAssets(compilation, callback) {  
    // 将asset的资源写入到指定的输出目录 
    this.hooks.emit.callAsync(compilation, err => {  
        if (err) return callback(err);  
        outputPath = compilation.getPath(this.outputPath, {});  
        mkdirp(this.outputFileSystem, outputPath, emitFiles);  
    });  
}

Compiler.emitAssets将所有的assets资源输出文件夹。emitAssets执行完后,就会在其回调函数里执行done hook。(现在我们也知道了第二个问题的答案了)

至此,整个打包编译的流程就走完了。

Compiler和Compilation的区别

最后补充一个面试常见问题:compiler与compilation的区别
Compiler:

  • 在webpack构建的之初就会创建的一个对象, 并且在webpack的整个生命周期都会存在(before - run - beforeCompiler - compile - make - finishMake - afterCompiler - done);
  • Compiler对象在webpack的配置发送变化,重新进行npm run build时才会重新创建;

Compilation

  • 到准备编译模块(比如main.js), 才会创建Compilation对象,主要是存在于 compile - make 阶段主要使用的对象;
  • Compilation则是代码发生改变后就会重新创建。