an ckick pictrue

吃饭不洗...segmentfault 专栏

差点被SystemJs惊掉了下巴,解密模块加载黑魔法

Issue链接更新于:2021-03-01

背景

最近一直在做一个技术改进:微前端中子应用采用umd方式分包构建,取代现有的systemJs方式构建,解决子应用稍微复杂一点后构建资源过大造成应用加载缓慢的问题。

依赖umd分包,就需要依赖webpackJsonp的全局变量通信, 这个技改方案最后成功了,但这个过程让我对SystemJs有了新的认识。准确点说它差一点就成功忽悠住了我,幸好18岁的我保留了足够的好奇心,没有被表面现象懵逼。

根深蒂固的认知

作为一个工作6年的前端,虽然离牛逼还有成都地铁六号线那么远的距离,但自认为自己基础还是扎实。在我的认知里,所有的浏览器JS代码运行,都离不开script标签的引入,比如:

1.内联script

<script>
  console.log('I am inline script');
</script>

2.远程脚本加载

<script src="http://localhost:5001/run.js"></script>

3.Es6 module

和前面一致,只是多一个 type="module"标识

4.动态 import()

/* hello.js */

// Default export
export default () => {
  console.log('Hi from the default export!');
};

// Named export ``
export const sayHi = (user) => {
  console.log('Hi from the named export!', user);
};
<script type="module">
  import('./hello.js')
    .then((module) => {
      module.default();
      // → 'Hi from the default export!'
      module.doStuff('doddle');
      // → 'Hi from the named export!, doddle'
    });
</script>

但这个语法支持的浏览器很少,还只是一个提案,chrome也只有高版本做了支持。所以在业务开发中使用webpack打包,都对这个语法做了polyfill,其原理还是利用了script加载与webpackJsonp.push劫持做的发布订阅来实现,具体原理在去年我一篇流水账中有提到:webpack 打包的代码怎么在浏览器跑起来的?看不懂算我输

差点刷新我认知的SystemJs

这两年微前端的兴起,让SystemJs这个模块化方案也是火了一把,以前我是不知道webpack的libraryTarget配置还有system这一说的:webpack之libraryTarget设置

SystemJS是一个插件化的,基于标准的模块加载器。它提供了一个工作流,可以将为浏览器中编写的原始ES6模块代码转换为System.register模块格式,以在不支持原始ES6模块的旧版浏览器中运行,几乎可以达到运行原始ES模块的速度,同时支持顶层 await,动态导入,循环引用和实时绑定,import.meta.url,模块类型,导入映射,完整性和内容安全策略,并且在旧版浏览器中可兼容IE11。

SystemJs还没有概念的,可以跑一下官方demo感受一下它的黑魔法systemjs-examples

SystemJs看起牛逼在哪呢?以demo库的示例dynamic-import为例:

<html lang="en-US">
  <head>content="IE=edge">
    <title>SystemJS Dynamic Import Example</title>
    <script type="systemjs-importmap">
      {
        "imports": {
          "neptune": "./neptune.js"
        }
      }
    </script>
    <!-- 启动即运行neptune.js -->
    <script type="systemjs-module" src="import:neptune"></script>
    <!-- load SystemJS itself from CDN -->
    <script src="https://cdn.jsdelivr.net/npm/systemjs/dist/system.js"></script>
  </head>
  <body>
    <button id="load">加载</button>
  </body>
</html>
// neptune.js
System.register([], function (_export, _context) {
  return {
    execute: function() {
      document.body.appendChild(Object.assign(document.createElement('p'), {
        textContent: 'Neptune is a planet that revolves around the Sun'
      }));

      // 点击按钮后 加载triton.js
      document.querySelector('#load').addEventListener('click', () => {
        console.log('start debug');
        _context.import('./triton.js').then(function (triton) {
          console.log("Triton was discovered on", triton.discoveryDate);
        });
      });
    }
  };
});
// triton.js
System.register([], function (_export, _context) {
  return {
    execute: function() {
      document.body.appendChild(Object.assign(document.createElement('p'), {
        textContent: 'Triton is a moon that revolves around Neptune.'
      }));
      _export("discoveryDate", "Oct. 10, 1846");
    }
  };
});

20210223225507

Demo 我稍微改了一下,把triton.js从主动动态加载,改成点击按钮后再动态加载,只是为了加载过程更明显。点击按钮后,界面和元素长下面这样: 20210223225736

发现没?triton.js 没有被加载到html中,但这个JS的内容确实是已经执行了,洋气不洋气, 惊不惊喜?!!!难道script真的可以不加入到html就能执行?

但再仔细搜索,发现是有script请求下载记录的: 20210223231942

黑魔法解密

如果你想要快速知道答案,你可以在network直接点击script加载的触发节点: 20210223232541

顺着点开,你会发现黑魔法不过是一个戏法: 20210223232926

先把script加载到html中,加载完成后,再将这个script从html中移除,看起让人不明觉厉。
2021225-5617

浅入SystemJs

为什么要做这种骚操作(卸磨杀驴)?留在那貌似也没有什么问题。

这种操作也不是不可以,因为script标签加载完成就会马上执行,除非加上了defer标识,或者采用了preload或者prefetch标签来预加载。一旦script标签中的内容被执行,其有用或者需要再次被调用的部分,就会以引用的方式存在内存中,这时script中的内容确实就是个摆设,重绘重排都没用,只有重新加载才会触发执行。

简单了解一下SystemJs的原理:

20210228230222

当我们引入<script src="https://cdn.net//system.js"></script>时,就会完成以上操作,简单来讲就是生成一个System实例,遍历System相关的script标签,做一下预处理。system-module类的标签其实是唤起模块执行的一个入口,其实质是调用System.import方法。

与System.import相对应的,是System.register,仔细看上面示例:

// _context 意指实例与System
_context.import('./triton.js')
  .then(function (triton) {
    console.log("Triton was discovered on", triton.discoveryDate);
  });


System.register([/*依赖项*/], function (_export, _context) {
  return {
    execute: function() {
      document.body.appendChild(Object.assign(document.createElement('p'), {
        textContent: 'Triton is a moon that revolves around Neptune.'
      }));
      _export("discoveryDate", "Oct. 10, 1846");
    }
  };
});

当调用import('./triton.js')时,System就会发起triton.js的script加载,当加载完成后,就会开始System.register的模块注册,这时只会注册模块为一个函数,并还不会执行,因为要检测模块是否还有依赖,如果有,就需要待依赖模块加载完后,再调用execute方法执行并导出。然后通知import方法,导出已收到,resolve 执行then中内容。

除了支持SystemJs模块以外,还支持amdumd 模块,但其依赖扩展extras/amd.js, 其原理就是在window上注入了amd模块依赖的define方法,然后这个方法会把amd转化成register注入,原理还是比较易懂。但引入这个扩展前,还是有一些坑,我踩过:

  • 扩展加入时机:只能是在systemJs加载执行完后,扩展才能接着执行,因为其依赖global.System.constructor.prototype;
  • 扰乱全局umd模块加载,如果你应用本身有一些umd模块,其加载方式是global加载(注册在window上),比较常见的就是webpack打包,为了减少包体积,我们用了externals,但因为amd扩展的引入,这些global依赖就变成了SystemJs导入,应用会加载失效,所以有一种投机的加载方式就是: 待其他js script导入完成后,再执行extras/amd

以上只是SystemJs 浏览器相关的一些比较核心的流程,很多细节性的处理我也没深究,应该差不了多少。

总结

元宵也过完了,就以这一篇解(water)密(wen)开启我的2021 技术之旅吧。元宵节快乐,离5.1 还剩61天,坚持。。。