Webpack Runtime 小析

3,305 阅读2分钟

如果查看 Webpack 打包后的代码,会发现 __webpack_modules____webpack_module_cache____webpack_require__ 等一些不属于业务的 Webpack 运行时代码,之所以要将这些运行时代码和业务代码一起打包,是为了能够正确运行业务代码,并且运行时代码不是固定的,而是由我们所使用的特性决定,比如我们使用了 HMR 功能,那么将包含 __webpack_require__.hmrD__webpack_require__.hmrC__webpack_require__.hmrI 等与 HMR 相关的运行时代码。那么运行时代码是如何与业务代码融合的呢?这正是本文我们需要探讨的问题。

原理解析

让我们通过一个例子来探讨 Webpack 运行时原理:

// src/index.js
__webpack_public_path__ = '/';

// webpack.config.js
const path = require('path');
module.exports = {
  entry: './src/index.js',
  mode: 'development',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
};

运行 npx webpack 并查看生成的 bundle.js,我们会发现 __webpack_public_path__ 被替换成了 __webpack_require__.p,并且 Webpack 自动注入了 globalpublicPath 运行时代码:

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/***/ ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {

eval("__webpack_require__.p = '/';\n\n//# sourceURL=webpack://webpack_debug/./src/index.js?");

/***/ })
/************************************************************************/
/******/ 	// The require scope
/******/ 	var __webpack_require__ = {};
/******/
/************************************************************************/
/******/ 	/* webpack/runtime/global */
/******/ 	(() => {
/******/ 		__webpack_require__.g = (function() {
/******/ 			if (typeof globalThis === 'object') return globalThis;
/******/ 			try {
/******/ 				return this || new Function('return this')();
/******/ 			} catch (e) {
/******/ 				if (typeof window === 'object') return window;
/******/ 			}
/******/ 		})();
/******/ 	})();
/******/
/******/ 	/* webpack/runtime/publicPath */
/******/ 	(() => {
/******/ 		var scriptUrl;
/******/ 		if (__webpack_require__.g.importScripts) scriptUrl = __webpack_require__.g.location + "";
/******/ 		var document = __webpack_require__.g.document;
/******/ 		if (!scriptUrl && document) {
/******/ 			if (document.currentScript)
/******/ 				scriptUrl = document.currentScript.src
/******/ 			if (!scriptUrl) {
/******/ 				var scripts = document.getElementsByTagName("script");
/******/ 				if(scripts.length) scriptUrl = scripts[scripts.length - 1].src
/******/ 			}
/******/ 		}
/******/ 		// When supporting browsers where an automatic publicPath is not supported you must specify an output.publicPath manually via configuration
/******/ 		// or pass an empty string ("") and set the __webpack_public_path__ variable from your code to use your own logic.
/******/ 		if (!scriptUrl) throw new Error("Automatic publicPath is not supported in this browser");
/******/ 		scriptUrl = scriptUrl.replace(/#.*$/, "").replace(/\?.*$/, "").replace(/\/[^\/]+$/, "/");
/******/ 		__webpack_require__.p = scriptUrl;
/******/ 	})();
/******/
/************************************************************************/

那么这一切是如何发生的呢?顺着命令 npx webpack 的执行逻辑往下挖,会进入到 lib/webpack.js 中的 createCompiler 函数:

const createCompiler = rawOptions => {
  const options = getNormalizedWebpackOptions(rawOptions);
  applyWebpackOptionsBaseDefaults(options);
  const compiler = new Compiler(options.context, options);
  //省略非关键代码……
  new WebpackOptionsApply().process(options, compiler);
  //省略非关键代码……
  return compiler;
};

重点关注 new WebpackOptionsApply().process(options, compiler) 调用,它的作用是根据配置动态注入 Webpack 构建所需的 plugin,继续查看 WebpackOptionsApply#process 的实现会发现它在内部调用了 new APIPlugin().apply(compiler),其中 APIPlugin 核心逻辑如下:

// lib/APIPlugin.js
const REPLACEMENTS = {
  //省略非关键代码……
  //RuntimeGlobals.publicPath 值为 __webpack_require__.p
  __webpack_public_path__: {
    expr: RuntimeGlobals.publicPath,
    req: [RuntimeGlobals.publicPath],
    type: "string",
    assign: true,
  }
};

class APIPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap(
      "APIPlugin",
      (compilation, { normalModuleFactory }) => {
        //省略非关键代码……
        const handler = parser => {
          Object.keys(REPLACEMENTS).forEach(key => {
            const info = REPLACEMENTS[key];
            parser.hooks.expression
              .for(key)
              .tap(
                "APIPlugin",
                toConstantDependency(parser, info.expr, info.req),
              );
            //省略非关键代码……
          });
        };
        normalModuleFactory.hooks.parser
          .for("javascript/auto")
          .tap("APIPlugin", handler);
        normalModuleFactory.hooks.parser
          .for("javascript/dynamic")
          .tap("APIPlugin", handler);
        normalModuleFactory.hooks.parser
          .for("javascript/esm")
          .tap("APIPlugin", handler);
      }
    )
  }
}

APIPlugincompiler.hooks.compilation 的回调中设置了 JavascriptParser,接着在 JavascriptParser 回调中遍历 REPLACEMENTS,然后通过 parser.hooks.expression.for 匹配业务代码中的 __webpack_public_path__ 关键字,并将其回调函数设置为 toConstantDependency 来完成 __webpack_public_path____webpack_require__.p 的转换及依赖 __webpack_require__.p 的注册。

如果查看 toConstantDependency 的实现:

// lib/javascript/JavascriptParserHelpers.js
exports.toConstantDependency = (parser, value, runtimeRequirements) => {
  return function constDependency(expr) {
    const dep = new ConstDependency(value, expr.range, runtimeRequirements);
    dep.loc = expr.loc;
    parser.state.module.addPresentationalDependency(dep);
    return true;
  };
};

toConstantDependency 实际上返回了一个闭包(constDependency)作为 JavascriptParser 的回调,闭包中声明了 ConstDependency 实例(此时 value = ‘__webpack_require__.p’runtimeRequirements = ['__webpack_require__.p']expr 数据结构如下所示)并通过调用 parser.state.module.addPresentationalDependency 将其添加到依赖中:

// expr 数据结构
{
  type: "Identifier",
  start: 0,
  end: 23,
  loc: {
    start: { line: 1, column: 0 },
    end: { line: 1, column :23},
  },
  range:[0, 23],
  name: "__webpack_public_path__",
}

完成了准备工作,Webpack 最终会在打包阶段调用 Compilation.seal 方法进行运行时依赖收集、运行时代码注入等操作,关键逻辑代码如下:

this.codeGeneration(err => {
  //省略非关键代码……
  this.logger.time("runtime requirements");
  this.hooks.beforeRuntimeRequirements.call();
  this.processRuntimeRequirements();
  this.hooks.afterRuntimeRequirements.call();
  this.logger.timeEnd("runtime requirements");

  //省略非关键代码……
  const codeGenerationJobs = this.createHash();
  //省略非关键代码……
  this._runCodeGenerationJobs(codeGenerationJobs, err => {
    //省略非关键代码……
  });
});

通过调用 codeGeneration 方法,我们能得到各个 module 转译后的结果:

{
  sources: Map(1) { 'javascript' => [CachedSource] },
  runtimeRequirements: Set(1) { '__webpack_require__.p' },
  data: undefined
}

codeGeneration 方法成功执行后,会在回调中将调用 Compilation.processRuntimeRequirements 方法来完成运行时依赖的收集(为节省篇幅,此处不再粘贴 processRuntimeRequirements 的实现):

由于 RuntimePlugin 注册了上述所说的一系列钩子函数,比如下面的代码片段:

class RuntimePlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap("RuntimePlugin", (compilation) => {
      //省略部分代码……
      for (const req of Object.keys(TREE_DEPENDENCIES)) {
        const deps = TREE_DEPENDENCIES[req];
        compilation.hooks.runtimeRequirementInTree
          .for(req)
          .tap("RuntimePlugin", (chunk, set) => {
            for (const dep of deps) set.add(dep);
          });
      }

      compilation.hooks.runtimeRequirementInTree
        .for(RuntimeGlobals.publicPath)
        .tap("RuntimePlugin", (chunk, set) => {
          const { outputOptions } = compilation;
          const { publicPath: globalPublicPath, scriptType } = outputOptions;
          const entryOptions = chunk.getEntryOptions();
          const publicPath = entryOptions && entryOptions.publicPath !== undefined ? entryOptions.publicPath : globalPublicPath;
          if (publicPath === "auto") {
            const module = new AutoPublicPathRuntimeModule();
            if (scriptType !== "module") set.add(RuntimeGlobals.global);
            compilation.addRuntimeModule(chunk, module);
          } else {
            const module = new PublicPathRuntimeModule(publicPath);
            if (typeof publicPath !== "string" || /\[(full)?hash\]/.test(publicPath)) {
              module.fullHash = true;
            }
            compilation.addRuntimeModule(chunk, module);
          }
          return true;
        });

      compilation.hooks.runtimeRequirementInTree
        .for(RuntimeGlobals.global)
        .tap("RuntimePlugin", (chunk) => {
          compilation.addRuntimeModule(chunk, new GlobalRuntimeModule());
          return true;
        });

      compilation.hooks.additionalTreeRuntimeRequirements.tap(
        "RuntimePlugin",
        (chunk, set) => {
          const { mainTemplate } = compilation;
          if (
            mainTemplate.hooks.bootstrap.isUsed()
            || mainTemplate.hooks.localVars.isUsed()
            || mainTemplate.hooks.requireEnsure.isUsed()
            || mainTemplate.hooks.requireExtensions.isUsed()
          ) {
            compilation.addRuntimeModule(chunk, new CompatRuntimeModule());
          }
        },
      );
    });
  }
}

在钩子函数的回调中,RuntimePlugin 主要通过以下两种方式完成相应运行时模块的初始化:

  • 通过操作依赖列表(参数 set)来更改依赖;
  • 通过 compilation.addRuntimeModule 方法来添加新的运行时模块。

codeGeneration 回调中完成了运行时的收集以及初始化后,将通过一下调用将业务代码与运行时代码打包到一起:

//省略非关键代码……
const codeGenerationJobs = this.createHash();
//省略非关键代码……
this._runCodeGenerationJobs(codeGenerationJobs, err => {
  //省略非关键代码……
});

代码片段中,首先调用 Compilation.createHash 获取需要转译的运行时模块:

[
  {
    module: [AutoPublicPathRuntimeModule],
    hash: 'e5edbfe2865af436c0afde2db3646016',
    runtime: 'main',
    runtimes: [Array]
  },
  {
    module: [GlobalRuntimeModule],
    hash: '70a6c3a2f933cdf0a42c1b05d93df069',
    runtime: 'main',
    runtimes: [Array]
  }
]

然后调用 Compilation._runCodeGenerationJobs 转译上一步得到的运行时模块代码,如果执行成功则在回调中完成代码优化、生成等后续操作。

至此,我们对 Webpack 运行时原理进行了简要的分析,总结一下流程:

  • plugin 中,通过 JavascriptParser 识别业务代码中使用的特性(例如本文中的:__webpack_public_path__),并通过 parser.state.module.addPresentationalDependency 将其添加到依赖中;
  • plugin 中,通过监听 additionalModuleRuntimeRequirementsruntimeRequirementInModuleadditionalChunkRuntimeRequirementsruntimeRequirementInChunkadditionalTreeRuntimeRequirementsruntimeRequirementInTree 钩子来完成对应运行时模块(例如本文的 __webpack_require__.p)的初始化;
  • 在打包阶段 Webpack 通过调用 Compilation.seal 方法来转译 module 代码、收集运行时依赖、转译运行时依赖代码等一系列操作来完成运行时代码与业务代码的融合。

自定义运行时

本节我们将自定义一个运行时来加深对上一节内容的理解。该示例的入口文件为:

// src/index.js

__webpack_sw_path__ = '/sw.js';

代码片段中,我们通过设置变量 __webpack_sw_path__ 的值以实现自动注册 ServiceWorker 的目的,运行 npx webpack 并查看生成的 bundle.js 文件,会发现 __webpack_sw_path__ 被替换成了 __webpack_require__.sw,并包含了 ServiceWorker 注册相关的运行时代码:

/******/ (() => { // webpackBootstrap
/******/ 	var __webpack_modules__ = ({

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/***/ (() => {

eval("__webpack_require__.sw = '/sw.js';\n\n\n//# sourceURL=webpack://webpack_debug/./src/index.js?");

/***/ })
/******/ 	});
/************************************************************************/
/******/ 	/* webpack/runtime/serviceWorker */
/******/ 	(() => {
/******/
/******/ 		__webpack_require__.O(0, ["main"],
/******/ 		function () {
/******/ 		  if (__webpack_require__.sw) {
/******/ 		    window.navigator.serviceWorker.register(__webpack_require__.sw);
/******/ 		  }
/******/ 		}
/******/ 		, 1);
/******/
/******/ 	})();
/******/ })()

要弄明白这是如何发生的,继续查看 webpack.config.js

// webpack.config.js

const path = require('path');
const SWPlugin = require('./src/SWPlugin');

module.exports = {
  entry: './src/index.js',
  mode: 'development',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
  plugins: [
    new SWPlugin(),
  ],
};

上述配置除了 SWPlugin 外,没有任何特别之处,继续查看 SWPlugin 的实现:

// src/SWPlugin.js

const {
  evaluateToString,
  toConstantDependency,
} = require('webpack/lib/javascript/JavascriptParserHelpers');
const RuntimeGlobals = require('webpack/lib/RuntimeGlobals');
const SWRuntimeModule = require('./SWRumtimeModule');

class SWPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap(
      'SWPlugin',
      (compilation, { normalModuleFactory }) => {
        const handler = parser => {
          parser.hooks.expression
            .for('__webpack_sw_path__')
            .tap(
              'SWPlugin',
              toConstantDependency(
                parser,
                '__webpack_require__.sw',
                ['__webpack_require__.sw']
              ),
            );
          parser.hooks.evaluateTypeof
            .for('__webpack_sw_path__')
            .tap('SWPlugin', evaluateToString('string'));
        };
        normalModuleFactory.hooks.parser
          .for('javascript/auto')
          .tap('SWPlugin', handler);
        normalModuleFactory.hooks.parser
          .for('javascript/dynamic')
          .tap('SWPlugin', handler);
        normalModuleFactory.hooks.parser
          .for('javascript/esm')
          .tap('SWPlugin', handler);

        compilation.hooks.runtimeRequirementInTree
          .for('__webpack_require__.sw')
          .tap('SWPlugin', (chunk, set) => {
            // RuntimeGlobals.onChunksLoaded 的值为 __webpack_require__.O
            set.add(RuntimeGlobals.onChunksLoaded);
            const module = new SWRuntimeModule();
            compilation.addRuntimeModule(chunk, module);
            return true;
          });
      }
    );
  }
}

module.exports = SWPlugin;

有了前面的基础,相信大家对 SWPlugin 的实现逻辑非常清楚了:

  • 通过 JavascriptParser 识别 __webpack_sw_path__ 关键字,然后将其转换成 __webpack_require__.sw,并将 __webpack_require__.sw 声明为依赖;
  • 通过监听 compilation.hooks.runtimeRequirementInTree 钩子,在匹配依赖 __webpack_require__.sw 的回调中初始化我们需要的运行时模块(添加 __webpack_require__.O 依赖及 SWRuntimeModule 模块)。

查看 SWRuntimeModule 的实现:

// src/SWRumtimeModule.js

const RuntimeModule = require('webpack/lib/RuntimeModule');
const RuntimeGlobals = require('webpack/lib/RuntimeGlobals');

class SWRuntimeModule extends RuntimeModule {
  constructor() {
    super('serviceWorker');
  }

  generate() {
    const { chunk } = this;
    return `
${RuntimeGlobals.onChunksLoaded}(0, ${JSON.stringify([chunk.id])}, ${`
  function () {
    if (__webpack_require__.sw) {
      window.navigator.serviceWorker.register(__webpack_require__.sw);
    }
  }`}, 1);
`;
  }
}

module.exports = SWRuntimeModule;

SWRuntimeModule 的实现主要包含了以下几个部分:

  • 继承自 Webpack 内置的 RuntimeModule
  • 在构造函数中调用父类的构造函数,并指定该运行时的名称;
  • 通过 generate 返回运行时代码(必须为字符串)。

由于 Webpack 将运行时模块代码包裹在立即调用函数表达式中,我们又希望 SWRuntimeModule 能够在当前 chunk 加载完毕之后再执行,所以我们在 generate 中生成的代码其实是 __webpack_require__.O 方法的调用(这也是我们为什么要在 SWPlugin 中添加 __webpack_require__.O 依赖的原因所在),该方法会将延迟执行 ServiceWorker 注册逻辑直到当前 chunk 加载完毕。

总结

本文我们首先探讨了 Webpack 处理运行时代码的机制,然后通过实现一个自动注册 ServiceWorker 的运行时来加深我们对相关内容的理解与应用。由于 Webpack 是一个庞大且复杂的体系,梳理过程中难免有遗漏错误之处,还望诸位读者批评指出 ^ _ ^。