What is the reaction of most people when they see this topic? Early grunt, gulp? Or the popular Webpack?

As these tools become more powerful and important, mature frameworks have packaged them into specialized command-line tools, such as Angular-CLI and vue-CLI.

So this time we will analyze the source code of WebPack (5.0.0-beta.23) to understand its principle.

Webpack has two execution portals, bin/webpack.js, which is invoked from the command line, and lib/webpack.js, which is referenced directly in the code. We analyze lib/webpack.js by bypassing command parameter parsing and process invocation.

In the code

const webpack = ((option, callback) = >{
   validateSchema(webpackOptionsSchema, options);
   let compiler;
   compiler = createCompiler(options);
   if(callback){
      compiler.run((err,stats) = >{
        compiler.close(err2= >{
           callback(err || err2, stats)
        })
      })
   }
   returncompiler; })Copy the code

As you can see from this code, there are three important operations inside the Webpack () function: validating configuration items, creating a compiler, and performing compilation;

Verify configuration item

Validation configuration items are implemented by calling the vaildateSchema() function, which inside is the vaildate() function of the calling schema-utils module, which supports validation of JSON objects against JSONSchema rules. These JSONSchema rules stored in schemas/WebpackOptions json file, webpackOptionsSchema variables corresponding to the code.

A quick introduction to JSONSchema, which describes JSON files as JSON files, can be used to validate JSON objects, mock data, and describe the structure of JSON objects. The following is a partial validation rule for the output parameter.

"Output": { 
  "description""Options affecting the output of the compilation. `output` options tell webpack how to write the compiled files to disk."."type""object"."properties": {..."path": { 
      "$ref""#/definitions/Path"}}}..."definitions": { 
 "Path": { 
    "description""The output directory as **absolute path** (required)."."type""string"}}Copy the code

From “type”: “object”, you can see that Output is an object that has a Path type defined in the Path attribute of the Definitions object, via “type”: “String”, as you can see, is a string type. The webpackOptions. json file has a lot of content, with more than 3000 lines. I don’t want to introduce it here, so you can study it carefully.

In a nutshell, the validateSchema() function validates options against JSONSchema. If it does not meet the configuration rules, it exits and prints a formatted error message on the console. This avoids errors caused by incorrect option parameters.

Creating a compiler

The Compiler creation is done by calling the createCompiler() function in the compiler.compile() function, which returns an instance of the Compiler. CreateCompiler ()

// lib/webpack.js 

const createCompiler = rawOptions= > { 
  const options = getNormalizedWebpackOptions(rawOptions); 
  applyWebpackOptionsBaseDefaults(options); 
  const compiler = new Compiler(options.context); 
  compiler.options = 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; 
} 

Copy the code

In createCompiler () function inside you can see, the first by getNormalizedWebpackOptions () function will default configuration parameters and the custom configuration parameters for synthetic rawOptions, assigned to a variable of the options. ApplyWebpackOptionsBaseDefaults () function will process the current execution path assigned to the options. The context properties.

After the above processing, the options variable is passed to the class Compiler as an argument to generate the instance. In the constructor, many attributes of the instance are initialized, notably the hooks attribute. Here is an excerpt of the source code:

// lib/Compiler.js 

constructor(context) { 
    this.hooks = Object.freeze({ 
      initializenew SyncHook([]), 
      shouldEmitnew SyncBailHook(["compilation"]), 
      donenew AsyncSeriesHook(["stats"]), 
      afterDonenew SyncHook(["stats"]), 
      additionalPassnew AsyncSeriesHook([]), 
      beforeRunnew AsyncSeriesHook(["compiler"]), 
      runnew AsyncSeriesHook(["compiler"]), 
      emitnew AsyncSeriesHook(["compilation"]), 
      assetEmittednew AsyncSeriesHook(["file"."info"]), 
      afterEmitnew AsyncSeriesHook(["compilation"]), 
      thisCompilationnew SyncHook(["compilation"."params"]), 
      compilationnew SyncHook(["compilation"."params"]), 
      normalModuleFactorynew SyncHook(["normalModuleFactory"]), 
      contextModuleFactorynew SyncHook(["contextModuleFactory"]), 
      beforeCompilenew AsyncSeriesHook(["params"]), 
      compilenew SyncHook(["params"]), 
      makenew AsyncParallelHook(["compilation"]), 
      finishMakenew AsyncSeriesHook(["compilation"]), 
      afterCompilenew AsyncSeriesHook(["compilation"]), 
      watchRunnew AsyncSeriesHook(["compiler"]), 
      failednew SyncHook(["error"]), 
      invalidnew SyncHook(["filename"."changeTime"]), 
      watchClosenew SyncHook([]), 
      infrastructureLognew SyncBailHook(["origin"."type"."args"]), 
      environmentnew SyncHook([]), 
      afterEnvironmentnew SyncHook([]), 
      afterPluginsnew SyncHook(["compiler"]), 
      afterResolversnew SyncHook(["compiler"]), 
      entryOptionnew SyncBailHook(["context"."entry"])}); }Copy the code

To prevent the hooks attribute from being modified, the object.freeze () function is used to create the Object. A brief introduction to the object.freeze() function, which freezes an object. [Fixed] A frozen object can no longer be modified If you freeze an object, you cannot add new attributes to the object, delete existing attributes, modify the enumerability, configurability, writability of existing attributes of the object, or modify the value of existing attributes. In addition, the stereotype of an object cannot be modified after it is frozen.

There are four types of hooks created. Their names and functions are as follows:

  • SyncHook, which calls the callback functions in the hook queue when the hook is fired.
  • SyncBailHook: When the hook is triggered, the callback functions in the hook queue are called in sequence, and the call stops if a function returns a value.
  • AsyncSeriesHook. If there are asynchronous callback functions in the hook queue, the rest of the callback functions will be executed after the completion of their execution.
  • Asyncparallelhooks that can asynchronously execute all asynchronous callback functions in the hook queue.

The following code is a simple hook function. Create the hook instance with the new keyword, and then call tap() to listen for the hook, adding a callback to the hook queue of the hook. When the hook.call() function is executed, the callbacks in the queue are called in turn and arguments are passed to them. Note that the number of arguments must match the length of the array being instantiated. In the following example, only 1 parameter can be passed. The Tapable module provides more than a dozen hooks that I won’t cover in detail here, as long as we know that it implements some special subscription mechanisms. Those interested in hooks can refer to its documentation.

  const { SyncHook } = require('tapable'); 
const hook = new SyncHook(['whatever']); 
hook.tap('1'.function (arg1{ 
  console.log(arg1); 
}); 
hook.call('lagou'); 

Copy the code

And then if you look further down, you’ll find this line of code.

// lib/webpack.js 

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

Copy the code

The use of apply() is common in Webpack. The main function is to listen for the Compiler hook event, or to insert a callback function into the hook queue that is called when the corresponding hook event is fired.

Three hook events are called after hook initialization:

// lib/webpack.js 

compiler.hooks.environment.call(); 
compiler.hooks.afterEnvironment.call(); 
new WebpackOptionsApply().process(options, compiler); 
compiler.hooks.initialize.call(); 

Copy the code

The process() function introduces some default plug-ins and calls its apply() function depending on the execution environment. For example, the Node environment introduces the following plug-ins:

// lib/WebpackOptionsApply.js 

const NodeTemplatePlugin = require("./node/NodeTemplatePlugin"); 
const ReadFileCompileWasmPlugin = require("./node/ReadFileCompileWasmPlugin"); 
const ReadFileCompileAsyncWasmPlugin = require("./node/ReadFileCompileAsyncWasmPlugin"); 
const NodeTargetPlugin = require("./node/NodeTargetPlugin"); 
new NodeTemplatePlugin({ 
  asyncChunkLoading: options.target === "async-node" 
}).apply(compiler); 
new ReadFileCompileWasmPlugin({ 
  mangleImports: options.optimization.mangleWasmImports 
}).apply(compiler); 
new ReadFileCompileAsyncWasmPlugin().apply(compiler); 
new NodeTargetPlugin().apply(compiler); 
new LoaderTargetPlugin("node").apply(compiler); 

Copy the code

At this point, the compiler is created. To summarize the main logic of the compiler creation step, first modify the configuration parameters, such as adding some default configuration items; Then create an instance of the compiler whose constructor initializes some hooks. The last step is to call the plug-in’s apply() function to listen for hooks and actively fire hook events.

compiling

Calling compiler.compile() marks the start of the compilation phase, which relies heavily on hooks and can be difficult to understand by jumping around. Here is part of the compile() function:

// lib/Compiler.js 

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= >{})})}Copy the code

The compiler.hooks.compile hook is triggered and some plugins are initialized in preparation for compilation, such as the LoaderTargetPlugin which will load the desired loader.

Calling the newCompilation() function creates a Compilation instance. Note that the Compilation differs from the Compiler created earlier: The Compiler is globally unique and contains configuration parameters, loaders, plugins, etc., which are always present in the webPack lifecycle; The Compilation contains information about the current module and only represents a Compilation process.

When creating a compilation is triggered after the completion of the compiler. The hooks. ThisCompilation hooks and compiler.hooks.com pilation, activate JavaScriptModulesPlugin plugin surveillance function, To load the JavaScript parsing module Acorn.

// lib/Compiler.j s 

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; 
} 

Copy the code

Firing the Compiler.hooks. Make hook in the compiler.compile() function marks the start of compilation. So which functions listen for the make hook? A search of the code reveals that eight plug-ins are listening for it.

The EntryPlugin plugin is responsible for analyzing the entry file. Here is an excerpt of the code:

// lib/EntryPlugin.js 

class EntryPlugin { 
  apply(compiler) { 
    compiler.hooks.make.tapAsync("EntryPlugin".(compilation, callback) = > { 
    	const { entry, options, context } = this; 
    	const dep = EntryPlugin.createDependency(entry, options); 
        // Start entry parsing
    	compilation.addEntry(context, dep, options, err= >{ callback(err); }); }); }}Copy the code

The addEntry() function of the compilation object is called in the EntryPlugin, which in turn calls the _addEntryItem() function to add the entry module to the module dependency list.

_addEntryItem(context, entry, target, options, callback) { 
  this.addModuleChain(context, entry, (err, module) = > { 
    if (err) { 
      this.hooks.failedEntry.call(entry, options, err); 
      return callback(err); 
    } 
    this.hooks.succeedEntry.call(entry, options, module); 
    return callback(null.module); 
  }); 
} 

Copy the code

The Programmer’s handleModuleCreation() function is called within the addModuleChain() function, which has a lot of code, and the Programmer’s buildModule() function is called to build modules.

After the completion of module construction, acorn generates the abstract syntax tree of module code, and analyzes whether the module still has dependent modules according to the abstract syntax tree. If so, continue to parse each dependent module until all dependency parsing is completed, and finally merge to generate output files. This process is similar to the compiler execution process mentioned in the previous lectures, and will not be described here.

conclusion

Analyzing the working principle of Webpack from the source level, the execution process of Webpack can be roughly divided into three steps, including: checking configuration items, creating a compiler, and executing compilation.

JSONSchema is used to validate configuration parameters when validating configuration items. When the compiler is created, the hook mechanism provided by the Tapable module is used to trigger the appropriate hook event for the corresponding plug-in to initialize.

In the execution of compilation, compiler.hooks. Make hook event is used as the starting point to trigger the parsing of the entry file, and the loader is called to process the resources. Then abstract syntax tree is constructed, and the final abstract syntax tree is converted into the target file and output to the directory specified by the configuration item.