【webpack系列】webpack内部机制

2,721 阅读10分钟

webpack本质上可以将其理解为是一种基于事件流的编程范例,一系列的插件运行。

1. webpack启动过程分析

1. 运行webpack

有两种方式:

  • 通过npm scripts运行webpack
// package.json
{
  ...
  "scripts": {
    "build": "webpack --config webpack.pro.config.js",
    "dev": "webpack-dev-server --config webpack.dev.config.js  --open"
  },
  ...
}
  • 通过webpack直接运行
webpack entry.js

2. 查找webpack入口文件

在运行以上命令后,npm 会让命令行工具进入 node_modules.bin 目录查找是否存在 webpack.sh 或者 webpack.cmd 文件,如果存在就执行,不存在就抛出错误。

实际的文件入口是:node_modules/webpack/bin/webpack.js

webpack最终找到webpack-cli这个npm包,并且执行CLI

2. webpack-cli做了什么

  • 引入yargs,对命令进行定制
  • 分析命令行参数,对各个参数进行转换,组成编译配置项
  • 引用webpack,根据配置项进行编译和构建 webpack-cli对配置文件和命令行参数进行转换,最终生成配置选项参数options,然后根据配置参数实例化webpack对象,执行构建流程。
// Compiler 模块是 webpack 的支柱引擎,它通过 CLI 或 Node API 传递的所有选项,创建出一个 compilation 实例。它扩展(extend)自 Tapable 类,以便注册和调用插件。大多数面向用户的插件会首先在 Compiler 上注册。
compiler = webpack(options);
if (argv.progress) {
  const ProgressPlugin = require("webpack").ProgressPlugin;
  new ProgressPlugin({
    profile: argv.profile
  }).apply(compiler);
}

3. Tapable插件架构与Hooks设计

compiler和compilation两个核心对象,都是继承自Tapable类。Tapable是一个类似于nodejs的EventEmitter库,主要是控制钩子函数的发布与订阅,控制着webpack的插件系统。

Tapable钩子

Tapable库提供了很多Hook类,为插件提供挂载的钩子:

  • SyncHook:同步钩子
  • SyncBailHook:同步熔断钩子
  • SyncWaterfallHook:同步流水钩子
  • SyncLoopHook:同步循环钩子
  • AsyncParallelHook:异步并发钩子
  • AsyncParallelBailHook:异步并发熔断钩子
  • AsyncSeriesHook:异步串行钩子
  • AsyncSeriesBailHook:异步串行熔断钩子
  • AsyncSeriesWaterfallHook:异步串行流水钩子

Tapable钩子的绑定与执行

  1. Async* 钩子:
  • 绑定:tapAsync/tapPromise/tap
  • 执行:callAsync/promise
  1. Sync* 钩子
  • 绑定:tap
  • 执行:call

Tapable Hook的使用

const hook1 = new SyncHook(['arg1', 'arg2', 'arg3']);

// 绑定事件到webpack事件流,第一个参数是事件名, 第二个参数是回调函数
hook1.tap('hook1', (arg1, arg2, arg3) => { console.log(arg1, arg2, arg3); }); // 1 2 3

// 执行绑定的事件
hook1.call(1, 2, 3);

Compiler与插件的执行机制

webpack的插件有以下特点:

  1. 独立的 JS 模块,暴露相应的函数
  2. 函数原型上的 apply 方法会注入 compiler 对象
  3. compiler 对象上挂载了相应的 webpack 事件钩子
  4. 事件钩子的回调函数里能拿到编译后的 compilation 对象,如果是异步钩子还能拿到相应的 callback

compiler和compilation的区别

Compiler 对象包含了当前运行Webpack的所有配置信息,包含entry、output、loaders、plugins等配置,这个对象在webpack启动时候被实例化,它是全局唯一的。我们可以把它理解为webpack的实列。Plugin可以通过该对象获取到Webpack的配置信息进行处理。

compilation 对象包含了当前的模块资源、编译生成资源、文件的变化等。当webpack在开发模式下运行时,每当检测到一个文件发生改变的时候,那么一次新的 Compilation将会被创建。从而生成一组新的编译资源。一个编译对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。

Compiler对象 与 Compilation 对象 的区别是:Compiler代表了是整个webpack从启动到关闭的生命周期。Compilation 对象只代表了一次新的编译。

类似于以下模拟过程:

// Compiler.js
const { SyncHook, AsyncSeriesHook } = require('tapable');

class Compiler {
  constructor() {
    this.hooks = {
      accelerate: new SyncHook(['newspeed']),
      brake: new SyncHook(),
      caculateRoutes: new AsyncSeriesHook(['source', 'target', 'routesList'])
    }
  }
  // 入口
  run () {
    this.brake();
    this.accelerate(11);
    this.caculateRoutes('async', 'hook', 'demo');
  }
  brake() {
    this.hooks.brake.call();
  }
  accelerate(speed) {
    this.hooks.accelerate.call(speed);
  }
  caculateRoutes() {
    this.hooks.caculateRoutes.promise(...arguments).then(() => {
      console.log('1');
    }).catch(err => {
      console.log(err);
    })
  }
}

module.exports = Compiler;
// myPlugin.js
class MyPlugin {
  constructor() {}
  // 插件的apply方法会注入compiler对象
  apply(compiler) {
    // compiler对象上挂载了相应的webpack事件钩子, 事件钩子的回调函数里,能拿到编译后的compilation对象
    // 绑定同步钩子
    compiler.hooks.brake.tap('WarningLampPlugin', () => { console.log('warning Lamp'); });

    // 绑定同步钩子并传参
    compiler.hooks.accelerate.tap('LoggerLogin', (newspeed) => { console.log(newspeed) });

    // 绑定一个异步promise钩子
    compiler.hooks.caculateRoutes.tapPromise('CacularRoutes', (source, target, routesList, callback) => {
      console.log('source', source);
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          console.log('tapPromise');
          resolve();
        }, 1000)
      })
    });
  }
}

module.exports = MyPlugin;
// index.js
const Compiler = require('./Compiler');
const MyPlugin = require('./myPlugin');

const myPlugin = new MyPlugin();

const options = {
  plugins: [myPlugin]
}

const compiler = new Compiler();

for (let plugin of options.plugins) {
  if (typeof plugin === "function") {
    plugin.call(compiler, compiler);
  } else {
    plugin.apply(compiler);
  }
}

// 调用run
compiler.run();

Compiler 对象继承于 Tapable(Tapable 是 webpack 的一个底层库),发布: compiler.applyPlugins('eventName')、 订阅:compiler.plugin('eventName', callback)、 注册所有插件: new WebpackPlugin().apply(compiler) ,插件必须提供 apply 方法给 WebPack 完成注册流程,插件在 apply 方法内做一些初始化操作并监听 WebPack 构建过程中的生命周期事件,等待构建时生命周期事件的发布。

function Tapable() {
    this._plugins = {};
}
//发布name消息
Tapable.prototype.applyPlugins = function applyPlugins(name) {
    if(!this._plugins[name]) return;
    var args = Array.prototype.slice.call(arguments, 1);
    var plugins = this._plugins[name];
    for(var i = 0; i < plugins.length; i++) {
        plugins[i].apply(this, args);
    };
}
// fn订阅name消息
Tapable.prototype.plugin = function plugin(name, fn) {
    if(!this._plugins[name]) {
        this._plugins[name] = [fn];
    } else {
        this._plugins[name].push(fn);
    }
}
//给定一个插件数组,对其中的每一个插件调用插件自身的apply方法注册插件
Tapable.prototype.apply = function apply() {
    for(var i = 0; i < arguments.length; i++) {
        arguments[i].apply(this);
    }
};

4. webpack的构建流程

function webpack(options, callback) {
    // 设置options的默认值,如output.path的默认值为process.cwd(),target的默认值为web
    new WebpackOptionsDefaulter().process(options);
 
    compiler = new Compiler();
    // 指定上下文context
    compiler.context = options.context;
    compiler.options = options;
    // 注册nodeEveironmentPlugin插件,触发‘before-run’时执行
    new NodeEnvironmentPlugin().apply(compiler);
    if(options.plugins && Array.isArray(options.plugins)) {
        // 注册配置文件中的所有插件
        compiler.apply.apply(compiler, options.plugins);
    }
    // 触发environment和after-environment事件
    compiler.applyPlugins("environment");
    compiler.applyPlugins("after-environment");
    // 处理参数,例如为不同的target注册插件,为externals配置注册ExternalsPlugin,为不同的devtool注册对应的插件,如果cache为true就注册CachePlugin等
    compiler.options = new WebpackOptionsApply().process(options, compiler);
 
    // 如果有callback就会直接调用compiler.run(callback)
    if(callback) {
        if(options.watch === true || (Array.isArray(options) && options.some(o => o.watch))) {
            const watchOptions = Array.isArray(options) ? options.map(o => o.watchOptions || {}) : (options.watchOptions || {});
            return compiler.watch(watchOptions, callback);
        }
        compiler.run(callback);
    }
    return compiler;
}
exports = module.exports = webpack;
 
// 导出各种插件,这样我们就可以直接使用插件:new webpack.DefinePlugin()
exportPlugins(exports, {
    "DefinePlugin": () => require("./DefinePlugin"),
    "NormalModuleReplacementPlugin": () => require("./NormalModuleReplacementPlugin"),
    ...
});

这里主要做了几件事情:

  1. 设置配置参数的默认值。
  2. new 一个 Compiler 实例。
  3. 调用实例方法 compiler.apply(plugins) 或 plugin.apply(compiler) 注册插件,比如将 nodeEveironmentPlugin 插件注册在 before-run 阶段。
  4. 调用 compiler.applyPlugins() 触发插件执行,比如 compiler.applyPlugins(‘before-run’),就会通知注册在 before-run 这个阶段的插件执行。
  5. 最后导出 WebPack,以及 WebPack 官方提供的一些插件。

Compiler 对象在 WebPack 构建过程中代表着整个 WebPack 环境,包含上下文、项目配置信息、执行、监听、统计等等一系列的信息,提供给 loader 和插件使用。

compiler.run(callback); 方法就开始了 WebPack 的编译流程,显示异步触发了 before-run,执行完对应的插件回调后再触发 run。最后执行 this.compile(onCompiled)。

// Compiler.js

run(callback) {
    this.applyPluginsAsync("before-run", this, err => {
        if(err) return callback(err);
        this.applyPluginsAsync("run", this, err => {
            if(err) return callback(err);
            this.readRecords(err => {
                if(err) return callback(err);
                this.compile(onCompiled);
            });
        });
    });
},
compile(callback) {
    const params = this.newCompilationParams();
    this.applyPluginsAsync("before-compile", params, err => {
        if(err) return callback(err);
        this.applyPlugins("compile", params);
        const compilation = this.newCompilation(params);
        this.applyPluginsParallel("make", compilation, err => {
            if(err) return callback(err);
            compilation.finish();
            compilation.seal(err => {
                if(err) return callback(err);
                this.applyPluginsAsync("after-compile", compilation, err => {
                    if(err) return callback(err);
                    return callback(null, compilation);
                });
            });
        });
    });
}

在 make 这个阶段根据配置文件中 entry 字段的值注册了对应的插件:SingleEntryPlugin、MultiEntryPlugin、DynamicEntryPlugin。触发 make 阶段时,调用了 compilation.addEntry(this.context, dep, this.name, callback);,就这样,WebPack 编译从入口模块开始了。调用 compilation.addEntry 后大概就是 解析模块、分析模块依赖、对每个模块用相应的 loader 处理。整个 make 阶段处理完毕后进入 seal 阶段,封装构建结果,最后进入 emit 阶段输出结果。

比较核心的几个文件和方法: webpack.js > Compiler.js(run > compile) > Compilation.js(addEntry)。 其中 Compiler 和 Compilation都继承了 Tapable.

webpack构建流程

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译。
  3. 确定入口:根据配置中的 entry 找出所有的入口文件。
  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。
  5. 完成模块编译:在经过第 4 步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系。
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会。
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

webpack钩子函数的执行顺序

webpack的编译按照下面钩子函数的调用顺序执行

  1. entry-option:初始化option
  2. run:开始编译
  3. make:从entry开始递归的分析依赖,对每个依赖模块进行build
  4. before-resolve:对模块位置进行解析
  5. build-module:开始构建某个模块
  6. normal-module-loader:将loader加载完成的模块进行编译,生成AST树
  7. program:遍历AST树,当遇到require等一些调用表达式时,收集依赖
  8. seal:所有依赖build完成,开始优化
  9. emit:输出到dist目录

准备阶段

WebpackOptionsApply

在WebpackOptionsApply.js中调用了entry-option这个钩子,这个方法内部主要的功能是:

  1. 将所有的配置options参数转换成webpack内部插件,挂载到compiler实例上
  2. 使用默认插件列表 比如:
  • output.library -> LibraryTemplatePlugin
  • externals -> ExternalsPlugin
  • devtool -> EvalDevtoolModulePlugin、SourceMapDevtoolModulePlugin
  • AMDPlugin
  • CommonJsPlugin
  • RemoveEmptyChunksPlugin

EntryOptionPlugin.js中订阅了entry-option钩子,在entry 配置项处理过之后,执行插件,初始化完成,实例化compiler时,会创造compilation对象,和contextModule和normalModule两个工厂函数,最终调用compiler.run()。

模块构建和资源生成阶段

流程相关Hooks

  • beforeRun
  • run
  • beforeCompile:编译(compilation)参数创建之后,执行插件。
  • compile:一个新的编译(compilation)创建之后,钩入(hook into) compiler。
  • make
  • compilation.addEntry -> addModuleChain -> buildModule
  • compilation.finish
  • compilation.seal
  • afterCompile
  • emit:生成资源到 output 目录之前。
  • afterEmit:生成资源到 output 目录之后。
  • done:编译(compilation)完成。

监听相关:

  • watch-run:监听模式下,一个新的编译(compilation)触发之后,执行一个插件,但是是在实际编译开始之前。
  • watch-close:监听模式停止。

每次文件系统访问文件都会被缓存,以便于更快触发对同一文件的多个并行或串行请求。在 watch 模式 下,只有修改过的文件会被从缓存中移出。如果关闭 watch 模式,则会在每次编译前清理缓存。

compiler调用compilation生命周期方法

  • addEntry -> addModuleChain
  • finish
  • seal

chunk生成算法

chunk 有两种形式
  • initial(初始化) 是入口起点的 main chunk。此 chunk 包含为入口起点指定的所有模块及其依赖项。
  • non-initial 是可以延迟加载的块。可能会出现在使用 动态导入(dynamic imports) 或者 SplitChunksPlugin 时。 每个 chunk 都有对应的 asset(资源)。资源,是指输出文件(即打包结果)。

默认情况下,这些 non-initial chunk 没有名称,因此会使用唯一 ID 来替代名称。 在使用动态导入时,我们可以通过使用 magic comment(魔术注释) 来显式指定 chunk 名称

import(
  /* webpackChunkName: "app" */
  './app.jsx'
).then(App => ReactDOM.render(<App />, root));

输出文件的名称会受配置中的两个字段的影响:

  • output.filename - 用于 initial chunk 文件
  • output.chunkFilename - 用于 non-initial chunk 文件
chunk生成过程
  1. webpack先将entry中对应的module都生成一个新的chunk
  2. 遍历module的依赖列表,将依赖的module也加入到chunk中
  3. 如果一个依赖module是动态引入的模块,那么就会根据这个module创建一个新的chunk,继续遍历依赖
  4. 重复上面的过程,直到得到所有的chunks

webpack loader

关于webpack loader

loader的参数获取

通过loader-utils的getOptions方法获取loader中配置的options参数

loader异常处理
  1. 内部throw
  2. 通过this.callback(err, res)返回