likes
comments
collection
share

Webpack5源码解读系列4 - 运行时、代码生成原理

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

背景

Code Generation即代码生成,Webpack在经过make阶段模块分析建立ModuleGraph以及在seal阶段前半段通过构建ChunkGraph描述产物输出组织形式,接下来在seal的下半段会根据ChunkGraph将应用转为运行时代码,本阶段Webpack会把Module封装进Chunk,并最终输出为产物文件。

为了更好地了解代码生成原理,在讲解代码生成流程前会讲解打包时插入的应用运行时能力,所以本文包含两方面内容:

  1. Webpack在浏览器上以什么机制管理应用模块、打包出来分割产物如何加载,以及其搜集原理
  2. 模块、文件代码生成流程,更深入一点涉及到Webpack把所有资源都视为模块底层实现原理。

Webpack Runtime

Runtime运行时原理

Runtime是指提供应用在浏览器运行时模块管理基础库代码,是应用运行时的基石。在ESM标准出来之前,浏览器上运行js代码是没有原生模块管理机制,应用需要依靠AMDSystemUMD等模块管理库才能在浏览器上实现模块管理。为了使应用在浏览器上能够有模块管理机制且能够支持Webpack特性,Webpack提供了一套运行时基础库,该基础库不仅有模块管理能力,还能够提供异步加载、Module Federation等强大功能。

假设如下应用,其中A为入口文件,引用了B模块

// A模块
import { B } from './b';

export function A() {
  B();
}
// B模块
export function B() {}

打包出来的产物经过精简后我们可以得到下面这份代码:

(() => { // webpackBootstrap
  "use strict";
  var __webpack_modules__ = ({
    "./src/dependOn/b.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, {
        "B": () => (B)
      });

      // b.js
      function B() {
      }
    })
  });

  // The module cache
  var __webpack_module_cache__ = {};

  // The require function
  function __webpack_require__(moduleId) {
    // Check if module is in cache
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    // Create a new module (and put it into the cache)
    var module = __webpack_module_cache__[moduleId] = {
      // no module.id needed
      // no module.loaded needed
      exports: {}
    };

    // Execute the module function
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);

    // Return the exports of the module
    return module.exports;
  }


  /* webpack/runtime/define property getters */
  (() => {
    // define getter functions for harmony exports
    __webpack_require__.d = (exports, definition) => {
      for (var key in definition) {
        if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
          Object.defineProperty(exports, key, {enumerable: true, get: definition[key]});
        }
      }
    };
  })();

  /* webpack/runtime/hasOwnProperty shorthand */
  (() => {
    __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
  })();

  /* webpack/runtime/make namespace object */
  (() => {
    // define __esModule on exports
    __webpack_require__.r = (exports) => {
      if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, {value: 'Module'});
      }
      Object.defineProperty(exports, '__esModule', {value: true});
    };
  })();
  
  var __webpack_exports__ = {};
  // This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
  (() => {
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, {
      "A": () => (A)
    });
    var _b__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/dependOn/b.js");

    // a.js
    function A() {
      (0, _b__WEBPACK_IMPORTED_MODULE_0__.B)();
    }
  })();
})();

仔细观察产物代码,可以发现上面是应用模块管理代码,并且可以发现两个特征:

  1. 整体上使用IIFE包裹创造作用域避免影响全局以及受全局环境影响;

  2. 除了业务代码之外,还有一些辅助闭包缓存、工具方法支持模块管理能力:

    1. __webpack_module_cache__:模块缓存,所有被加载的模块都会被缓存起来;
    2. __webpack_require__:模块引用代码,通过该方法可直接导入代码;
    3. __webpack_exports__:记录模块导出的对象;
    4. __webpack_require__.r:定义导出模块为esModule的工具方法;
    5. __webpack_require__.d:定位导出模块的getter方法的工具方法;
    6. __webpack_require__.o:hasOwnProperty方法;
  • 这些__webpack_开头的代码内容是webpack根据代码需要生成的webpack运行时代码,上面代码逻辑为:
    1. 内部通过IIFE执行A模块,在A模块中使用__webpack_require__.r__webpack_require__.d将A模块的导出处理为符合ES模块标准。
    2. 接着通过__webpack_require__方法加载B模块代码,同样的会用__webpack_require__.r__webpack_require__.d将B模块的导出处理为符合ES模块标准。
    3. 加载完毕后会将加载结果缓存起来,下次加载时不用重新执行B模块代码,保证B模块全局只有一个实例(内部可能存在闭包缓存共享状态)。

上面case中只使用到了ES模块管理能力,如果代码中使用到了异步加载能力,则会插入与异步加载能力相关代码,下面是将B模块改为异步导入后编译产物,产物有两个文件,下面是主文件:

(() => { // webpackBootstrap
  "use strict";
  // ...省略模块管理代码
  
  /* webpack/runtime/ensure chunk */
  (() => {
    __webpack_require__.f = {};
    // This file contains only the entry chunk.
    // The chunk loading function for additional chunks
    __webpack_require__.e = (chunkId) => {
      // 调用__webpack_require_f.l方法
      return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
        __webpack_require__.f[key](chunkId, promises);
        return promises;
      }, []));
    };
  })();

  /* webpack/runtime/load script */
  (() => {
    var inProgress = {};
    var dataWebpackPrefix = "webpack-test:";
    // loadScript function to load a script via script tag
    __webpack_require__.l = (url, done, key, chunkId) => {
      if (inProgress[url]) {
        inProgress[url].push(done);
        return;
      }
      var script, needAttach;
      if (key !== undefined) {
        var scripts = document.getElementsByTagName("script");
        for (var i = 0; i < scripts.length; i++) {
          var s = scripts[i];
          if (s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) {
            script = s;
            break;
          }
        }
      }
      if (!script) {
        needAttach = true;
        script = document.createElement('script');

        script.charset = 'utf-8';
        script.timeout = 120;
        if (__webpack_require__.nc) {
          script.setAttribute("nonce", __webpack_require__.nc);
        }
        script.setAttribute("data-webpack", dataWebpackPrefix + key);
        script.src = url;
      }
      inProgress[url] = [done];
      var onScriptComplete = (prev, event) => {
        // avoid mem leaks in IE.
        script.onerror = script.onload = null;
        clearTimeout(timeout);
        var doneFns = inProgress[url];
        delete inProgress[url];
        script.parentNode && script.parentNode.removeChild(script);
        doneFns && doneFns.forEach((fn) => (fn(event)));
        if (prev) return prev(event);
      };
      var timeout = setTimeout(onScriptComplete.bind(null, undefined, {type: 'timeout', target: script}), 120000);
      script.onerror = onScriptComplete.bind(null, script.onerror);
      script.onload = onScriptComplete.bind(null, script.onload);
      needAttach && document.head.appendChild(script);
    };
  })();

  /* 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;
  })();

  /* webpack/runtime/jsonp chunk loading */
  (() => {
    // object to store loaded and loading chunks
    // undefined = chunk not loaded, null = chunk preloaded/prefetched
    // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
    var installedChunks = {
      "A": 0
    };

    // 提供模块加载入口方法
    __webpack_require__.f.j = (chunkId, promises) => {
      // JSONP chunk loading for javascript
      var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
      if (installedChunkData !== 0) { // 0 means "already installed".

        // a Promise means "currently loading".
        if (installedChunkData) {
          promises.push(installedChunkData[2]);
        } else {
          if (true) { // all chunks have JS
            // setup Promise in chunk cache
            var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));
            promises.push(installedChunkData[2] = promise);

            // start chunk loading
            var url = __webpack_require__.p + __webpack_require__.u(chunkId);
            // create error before stack unwound to get useful stacktrace later
            var error = new Error();
            var loadingEnded = (event) => {
              if (__webpack_require__.o(installedChunks, chunkId)) {
                installedChunkData = installedChunks[chunkId];
                if (installedChunkData !== 0) installedChunks[chunkId] = undefined;
                if (installedChunkData) {
                  var errorType = event && (event.type === 'load' ? 'missing' : event.type);
                  var realSrc = event && event.target && event.target.src;
                  error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
                  error.name = 'ChunkLoadError';
                  error.type = errorType;
                  error.request = realSrc;
                  installedChunkData[1](error);
                }
              }
            };
            __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
          } else installedChunks[chunkId] = 0;
        }
      }
    };

    // install a JSONP callback for chunk loading
    var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
      var [chunkIds, moreModules, runtime] = data;
      // add "moreModules" to the modules object,
      // then flag all "chunkIds" as loaded and fire callback
      var moduleId, chunkId, i = 0;
      if (chunkIds.some((id) => (installedChunks[id] !== 0))) {
        for (moduleId in moreModules) {
          if (__webpack_require__.o(moreModules, moduleId)) {
            __webpack_require__.m[moduleId] = moreModules[moduleId];
          }
        }
        if (runtime) var result = runtime(__webpack_require__);
      }
      if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
      for (; i < chunkIds.length; i++) {
        chunkId = chunkIds[i];
        if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
          installedChunks[chunkId][0]();
        }
        installedChunks[chunkId] = 0;
      }
    }

    // 全局定义注册模块方法,其他模块通过该方法注册模块
    var chunkLoadingGlobal = self["webpackChunkwebpack_test"] = self["webpackChunkwebpack_test"] || [];
    chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
    chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
  })();


  // 以下是A模块内容
  var __webpack_exports__ = {};
  __webpack_require__.r(__webpack_exports__);
  __webpack_require__.d(__webpack_exports__, {
    "A": () => (A)
  });

  function A() {
    __webpack_require__.e("src_dependOn_b_js").then(__webpack_require__.bind(__webpack_require__, /*! ./b */ "./src/dependOn/b.js"));
  }

})();

另外一个是异步加载文件,由于没有runtime代码,所以比较简单:

"use strict";
// 使用上述
(self["webpackChunkwebpack_test"] = self["webpackChunkwebpack_test"] || []).push([["src_dependOn_b_js"], {
  "./src/dependOn/b.js":
    ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, {
        "B": () => (B)
      });
      function B() {}
    })
}]);

相比于普通模块管理,采用异步模块管理会多出三个主要的工具方法:

  1. __webpack_require__.l:通过script标签加载并运行其他模块方法

  2. __webpack_require__.e:调用__webpack_require__.f.l方法

  3. __webpack_require__.f.j:提供异步模块加载功能

  4. webpackJsonpCallback:注册模块加载回调能力,可能同时存在一个模块多次加载情况,该方法会搜集回调并统一执行

    1.   异步加载整体流程如下:

Webpack5源码解读系列4 - 运行时、代码生成原理

上述两个例子可以看出Webpack在编译时会通过分析并搜集模块使用到的运行时能力,往产物代码中按需插入运行时代码,那么内部是如何实现呢?

Runtime代码实现原理

Webpack5源码解读系列3 - 分析产物组织形式 中讲到每个模块资源在Webpack中都会处理为NormalModule,与NormalModule类似,Webpack编译时将运行时能力都视为模块,由与NormalModule平级的RuntimeModule进行抽象,具体的能力实现继承于RuntimeModule,Module类继承关系如下:

Webpack5源码解读系列4 - 运行时、代码生成原理

每个RuntimeModule都会实现generate方法,用于代码生成时插入对应功能代码,如下面是HasOwnPropertyRuntimeModule

class HasOwnPropertyRuntimeModule extends RuntimeModule {
  constructor() {
    super("hasOwnProperty shorthand");
  }

  /**
   * @returns {string} runtime code
   */
  generate() {
   const { runtimeTemplate } = this.compilation; 
  
   return Template.asString([
     `${RuntimeGlobals.hasOwnProperty} = ${runtimeTemplate.returningFunction(
       "Object.prototype.hasOwnProperty.call(obj, prop)",
       "obj, prop"
     )}`
   ]);
  }
}

Webpack在代码一共进行两轮模块代码生成,两次目的各不相同:

  1. 第一轮模块代码生成时将所有的NormalModule放进执行队列中,目的在于获取模块所使用的Runtime能力,在代码生成结束后会根据模块使用的Runtime能力创建RuntimeModule,用于下一轮代码生成。
  2. 第二轮模块代码生成时将所有NormalModule和RuntimeModule放进执行队列中,此阶段获取用于输出的代码。

在经过第一轮代码生成之后,所有模块都会得到一份runtimeRequirement集合,里面存储着每个模块使用到的运行时能力。比如下面这个模块解析结果:

import {c} from "./c";
import {d} from "./d";

export function a() {
  d();
  c();
}

经过模块代码生成之后会得到如下runtimeRequirement集合:

[    "__webpack_require__",    "__webpack_require__.r",    "__webpack_exports__",    "__webpack_require__.d"]

接下来会分别将ModuleruntimeRequirement与关联的Chunk绑定起来、ChunkruntimeRequirementEntry Chunk绑定起来:

Webpack5源码解读系列4 - 运行时、代码生成原理

一个应用入口只需要一份Runtime代码,上述操作目的是将Runtime代码打包到入口文件中,这样能够保证从该入口进入的所有代码都能够使用到Runtime能力。另外,如果在入口选项配置了runtime字段,那么runtime代码会从入口Chunk抽离出去单独处理成一个Chunk:

module.exports = {
  entry: {
    A: {
      import: './src/a.js',
      runtime: 'runtime-chunk',
    },
  },
}

Entry Chunk拿到runtimeRequirement之后,会通过RuntimePlugin注册的钩子回调中往Chunk插入RuntimeModule

class RuntimePlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap("RuntimePlugin", compilation => {
      // ...省略不必要代码
      compilation.hooks.runtimeRequirementInTree
        .for(RuntimeGlobals.definePropertyGetters)
        .tap("RuntimePlugin", chunk => {
          compilation.addRuntimeModule(
            chunk,
            new DefinePropertyGettersRuntimeModule()
          );
          return true;
        });
      compilation.hooks.runtimeRequirementInTree
        .for(RuntimeGlobals.makeNamespaceObject)
        .tap("RuntimePlugin", chunk => {
          compilation.addRuntimeModule(
            chunk,
            new MakeNamespaceObjectRuntimeModule()
          );
          return true;
        });
    });
  }
}

代码生成原理

在Webpack中Chunk对应一个输出资源文件,Chunk内部封装了多个Module,应用经过Webpack打包之后会产生两部分代码:

  1. Module代码:包括业务代码和运行时代码,如下图的黄色和蓝色部分。
  2. Chunk代码:将Module代码整合起来,并提供运行的基本框架,如下图中的红色部分。

Webpack5源码解读系列4 - 运行时、代码生成原理

Module代码转译

Webpack将所有的资源都视为模块,所有资源在内部以NormalModule形式存在,但NormalModule在代码生成时并不会直接抹平所有资源差异,而是通过创建NormalModule时传入的generator处理差异,如JavaScriptGenerator、CssGenerator、WebAssemblyGenerator等解决各种资源代码生成差异。

下面以JavaScript文件类型讲解代码生成流程。

Webpack5源码解读系列4 - 运行时、代码生成原理

在讲解模块代码生成逻辑之前,需要讲解代码生成过程中的数据结构:

  1. Source:在代码转译时需要直接对源码进行操作,Webpack中使用了Source以及其子类对源码内容进行领域模型封装,允许生成器对Source进行修改,具体有ReplaceSource用于源码字符串替换、ConcatSource用于源码和代码碎片合并。
  2. InitFragment:提供插入代码碎片能力,如添加条件判断语句。

两者均是修改源码的方式,在转译代码之后,JavaScriptGenerator会通过InitFragment.addToSource静态方法将两者合并,最终返回Source实例。

Generator在创建ReplaceSource实例之后,接下来会将模块代码进行转义。Webpack是静态模块打包工具,会针对模块的导入导出语法做转义,在前面模块分析时,每个模块的导入导出语句都会处理为Dependency,所以这里会获取模块的Dependency并逐一处理。

每个Dependency都会有对应的Template去处理语法转译,Dependency对应语法转译为Webpack运行时代码,如下面是cjs导出语句的Template,由于代码比较多,所以省略部分代码:

const handleDependencyBase = (depBase, module, runtimeRequirements) => {
  let base = undefined;
  let type;
  switch (depBase) {
    case "exports":
      // 往module.runtimeRequirement添加运行时全局能力标识,标识本模块使用到该能力
      runtimeRequirements.add(RuntimeGlobals.exports);
      base = module.exportsArgument;
      type = "expression";
      break;
    case "module.exports":
      runtimeRequirements.add(RuntimeGlobals.module);
      base = `${module.moduleArgument}.exports`;
      type = "expression";
      break;
    // ...省略其他代码
    default:
      throw new Error(`Unsupported base ${depBase}`);
  }

  return [type, base];
};

class CommonJsExportsDependencyTemplate extends (
  NullDependency.Template
) {
  /**
   * @param {Dependency} dependency the dependency for which the template should be applied
   * @param {ReplaceSource} source the current replace source which can be modified
   * @param {DependencyTemplateContext} templateContext the context object
   * @returns {void}
   */
  apply(
    dependency,
    source,
    {module, moduleGraph, initFragments, runtimeRequirements, runtime}
  ) {
    const dep = /** @type {CommonJsExportsDependency} */ (dependency);
    const used = moduleGraph
      .getExportsInfo(module)
      .getUsedName(dep.names, runtime);

    // 解析模块依赖语法
    const [type, base] = handleDependencyBase(
      dep.base,
      module,
      runtimeRequirements
    );

    // 根据语法类型对ReplaceSource进行替换或者添加代码碎片
    switch (type) {
      case "expression":
        if (!used) {
          // 往模块生成代码中插入代码碎片
          initFragments.push(
            new InitFragment(
              "var __webpack_unused_export__;\n",
              InitFragment.STAGE_CONSTANTS,
              0,
              "__webpack_unused_export__"
            )
          );
          // 替换源码内容
          source.replace(
            dep.range[0],
            dep.range[1] - 1,
            "__webpack_unused_export__"
          );
          return;
        }
        source.replace(
          dep.range[0],
          dep.range[1] - 1,
          `${base}${propertyAccess(used)}`
        );
        return;
        // 其他代码
    }
  }
};

从上面代码中可以看到Template中做了两件事情:

  1. 根据语法往runtimeRequirement中添加元素,用于RuntimeModule的生成。
  2. 根据语法对source进行修改,以及往模块转译产物中添加代码碎片。

上面是针对于cjs导出语句的处理逻辑,而js中还有amd、systemjs以及esm模块管理方案,这些模块的导入导出语句会由专门的Dependency实现类处理,每个Dependency会有Template进行代码转义为符合webpack模块管理能力代码,从而抹平了这些模块管理能力的差异。

Chunk代码生成

Chunk封装了多个Module,在代码生成时提供了Module代码运行容器并将NormalModule、RuntimeModule渲染到运行的容器中,最终输出为产物文件。Chunk能够支持多种类型文件代码生成,如js、css、wasm,webpack通过Plugin方式拓展Chunk代码生成能力。

Compilation进入Chunk代码生成时,会通过tapable触发代码生成钩子,由事先注册好的各种类型文件插件实现具体类型文件代码生成逻辑,如JavaScriptModulePlugin、CssModulePlugin、WasmModulePlugin等等。与Module代码生成类似,Chunk代码生成时由Source及其子类管理代码生成产物,通过InitFragment向Source插入代码碎片。所以Webpack生成多类型文件的底层实现如下:

Webpack5源码解读系列4 - 运行时、代码生成原理

以JavaScriptModulePlugin为例子讲解Chunk代码生成,Webpack5源码解读系列3 - 分析产物组织形式 文章中了解到异步导入会产生Chunk,与原有Chunk具有父子关系,这种关系会在生成代码时处理为引用顺序关系。除此之外,在生成代码时会分别为含有Runtime和不含Runtime能力两类Chunk做代码生成:

  1. 含有Runtime Chunk:入口Chunk或者特别配置runtime属性将runtime抽离出去的Chunk,这一类Chunk会含有RuntimeModule代码生成产物。
  2. 不含有Runtime Chunk:异步导入Chunk,只含有业务代码。

由于Runtime能力全局只需要一份,且需要所有代码均能够访问到Runtime能力,所以一般情况下都会将代码生成在入口Chunk文件中,除非有特殊说明需要单独抽离Chunk:

Webpack5源码解读系列4 - 运行时、代码生成原理Webpack5源码解读系列4 - 运行时、代码生成原理

  1. 含有Runtime Chunk:代码分为三部分:

    1. Chunk容器代码,创建作用域并提供存放模块代码
    2. runtime代码:根据模块代码时获取所有RuntimeModule生成的代码
    3. module代码:NormalModule生成的代码
  1. 不含Runtime Chunk:将本Chunk包含所有NormalModule代码生成存放于容器中即可

以上代码并不是简单地堆叠存放,而是在JavaScriptModulePlugin内部会利用Source和InitFragment根据模块特性对代码进行组装,涉及到具体功能实现这里逐一讲解,我们只需要了解Chunk代码生成流程即可:

Webpack5源码解读系列4 - 运行时、代码生成原理

小结

本文以JavaScript为例子讲解Webpack代码运行时以及代码生成原理:

  1. 运行时能力:Webpack在运行时提供一套模块管理、代码分割产物加载等功能基础能力代码,业务代码运行在这一套基础能力之上。

  2. 在代码生成阶段,webpack分为模块代码生成和Chunk代码生成:

    1. 模块代码生成分为两次代码生成,其目的各有不同:

      1. 第一次获取应用中所有模块进行代码生成,目的是为了搜集模块所使用的运行时能力,以便后续生成RuntimeModule;
      2. 第二次会获取应用中所有模块进行代码生成,用于最终代码封装。
    2. Chunk分为是否含有runtime能力两类,其中含有runtime能力会包含runtime代码,Chunk会将代码封装成文件并用于输出。

暂时无法在飞书文档外展示此内容