SystemJS: 如何注册全局脚本

有如下可以正常运行的代码,功能很简单,加载 React, ReactDOM,然后在 app 中渲染 React 元素:

<div id="app"></div>
<script
  src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.7.1/system.js"></script>
<script>
  (async () => {
    const React = await System.import(
      "//cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.development.js"
    );
    const ReactDOM = await System.import(
      "https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.development.min.js"
    );

    const { createElement: h } = React;
    const container = document.getElementById("app");

    ReactDOM.render(h("p", {}, "Hello world."), container);
  })();
</script>

一切看上去很正常,ちょっと待って,引入的两个模块都是 UMD 的,为什么 SystemJS 可以正常注册模块呢?

从上图我们可以看出在 React 模块初始化时,exports, module, define 都是不存在的,React 直接被添加到了全局对象 window 上。

通过断点,我找到了这段代码(剔除其中无关代码):

/*
 * SystemJS global script loading support
 * Extra for the s.js build only
 * (Included by default in system.js build)
 */
(function (global) {
  var systemJSPrototype = global.System.constructor.prototype;

  // safari unpredictably lists some new globals first or second in object order
  var firstGlobalProp, secondGlobalProp, lastGlobalProp;
  function getGlobalProp() {
    var cnt = 0;
    var lastProp;
    for (var p in global) {
      // do not check frames cause it could be removed during import
      if (shouldSkipProperty(p)) continue;
      if (
        (cnt === 0 && p !== firstGlobalProp) ||
        (cnt === 1 && p !== secondGlobalProp)
      )
        return p;
      cnt++;
      lastProp = p;
    }
    if (lastProp !== lastGlobalProp) return lastProp;
  }

  function noteGlobalProps() {
    // alternatively Object.keys(global).pop()
    // but this may be faster (pending benchmarks)
    firstGlobalProp = secondGlobalProp = undefined;
    for (var p in global) {
      // do not check frames cause it could be removed during import
      if (shouldSkipProperty(p)) continue;
      if (!firstGlobalProp) firstGlobalProp = p;
      else if (!secondGlobalProp) secondGlobalProp = p;
      lastGlobalProp = p;
    }
    return lastGlobalProp;
  }

  var impt = systemJSPrototype.import;
  systemJSPrototype.import = function (id, parentUrl) {
    noteGlobalProps();
    return impt.call(this, id, parentUrl);
  };

  // balabala

  var isIE11 =
    typeof navigator !== "undefined" &&
    navigator.userAgent.indexOf("Trident") !== -1;

  function shouldSkipProperty(p) {
    return (
      !global.hasOwnProperty(p) ||
      (!isNaN(p) && p < global.length) ||
      (isIE11 &&
        global[p] &&
        typeof window !== "undefined" &&
        global[p].parent === window)
    );
  }
})(typeof self !== "undefined" ? self : global);

可以看到,每次 SystemJS 在 import 新的模块前,会调用 noteGlobalProps 检查当前全局的属性(亦可使用 Object.keys),记录下最后一条属性。当模块加载完成后,再调用 getGlobalProp 获得当前全局对象中的最后一条属性,若此属性和加载前的得到的最后一条属性不同,则认为此属性是新加载的模块。需要注意的 Safari 有可能将新的属性放在第一或第二位,所以要特殊处理。

综上,我们可以知晓, SystemJS 是通过检查全局对象中新增的属性来注册全局的脚本的。

那么就有了一个新的问题,如果我们在模块加载过程中不断的向全局对象新增属性,是否会影响模块的注册呢?

通过在 getGlobalProp 断点,观察调用栈我们可以发现在 JavaScript 脚本 load 完成后,从 getRegistergetGlobalProp 的过程都是同步的 Task,所以模块加载过程中动态向全局对象增加属性不会影响模块的注册。