阅读 1894

微前端时代如何做 JS 模块的动态加载

微前端是2019年很火的一个话题,很多公司都分享了他们的微前端解决方案,个人觉得“微前端”这个名字还是比较贴切的,因为它的目标主要是对标后端的“微服务”,希望前端的巨石工程也能够拆分成小工程来更好地进行维护。笔者近期也在做微前端的工作,参考了业界的很多方案,有了自己的一些体会,希望通过这篇文章对微前端的一个核心技术点——“动态加载 JS 模块”,或者说“加载远程的 JS 模块”做一些总结。

微前端方案分类

目前正经的微前端方案主要是两种类型:

  • 一种是以蚂蚁金服 qiankun 为代表的工程之间技术栈无关型。
  • 另一种是以美团外卖为代表的工程之间技术栈统一型。

对于技术栈无关型来说,动态加载子工程主要是让子工程自己将内容渲染到某个 DOM 节点,因而动态加载的目的主要是执行子工程的代码,另外是需要拿到子工程声明的一些生命周期钩子;而技术栈统一型的目标则是要直接拿到子工程输出的组件等内容,将其动态嵌入到主工程内完成解析。

下面我们来具体看一下几个方案的实现:

字节跳动:new Function()

先来看一下字节跳动的实现,他们的方案是: “子模块(Modules)就是一个个的 CMD 包,我用 new Function 来包起来。” 简单的两句话,这应该是说用 fetch 或者其他请求库直接拿到作为 cmd 包的子工程内容,然后用 new Function 将子模块作为 function 的函数体来执行,传入自定义的 define 等参数来执行模块并拿到模块的输出。new Function 的用法如下,将子模块内容作为函数文本,跟 eval 是类似的,但使用起来会清晰一些:

let sum = new Function('a', 'b', 'return a + b');

alert( sum(1, 2) ); // 3
复制代码

但是这里本可以跟 requirejs 一样全局定义好 define 等全局变量,然后用 script 标签直接引用子工程自然加载执行,为什么要用 fetch + new Function 呢?可能是因为全局的 define 方法不方便在组件方法内部动态使用吧。

蚂蚁金服:eval()

qiankun 动态加载
再来看一下典型的技术栈无关型方案蚂蚁金服 qiankun 的实现方式,相关的代码都在它使用的 import-html-entry 仓库中,同样是通过 fetch 等请求库,但拿到的是作为 umd 包的子工程内容,并没有将子模块作为 amd 或者 cmd 使用,而是直接 eval 执行,挂载了 window。eval 执行时更改了绑定的 window 对象,这样做主要是通过 Proxy 拦截子工程对 window 全局变量的更改,做自定义的隔离处理。

qiankun 推荐的子应用 webpack 配置:

const packageName = require('./package.json').name;

module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${packageName}`,
  },
};
复制代码

美团外卖:jsonp

接下来我们看一下技术栈统一型的美团外卖方案中模块的动态加载方法,他们的方案介绍中对于模块的加载方式没有细讲,但是从贴出的代码里可以看到 loadAsyncSubapp 和 subappRoutes,也提到了触发 jsonp 钩子window.wmadSubapp,这表示他们的方案是通过 jsonp 实现的。可以设置子工程 webpack 的 output 的 libraryTarget 为 jsonp,这样配置的的打包产物在加载时会执行全局的 jsonp 方法,传入主模块的 export 值作为参数。参考 webpack 文档 other-targets。亲测可行:

子工程 webpack config

output: {
  library: `registerSubApp`,
  libraryTarget: 'jsonp',
}
复制代码

主模块

export default App
复制代码

webpack 产物
webpack 产物

父工程

window.registerSubApp = function (lib) {
  ReactDOM.render(
    React.createElement(lib.default),
    document.getElementById('root')
  )
}
// lib = {default: App}
复制代码

这样父工程就可以直接拿到子工程的组件,进而可以将组件动态整合到主工程中。可以参考他们文章中介绍的结合 react-router 做的动态路由解析。

Webpack module federation

由于目前前端工程的主要打包方案是 webpack,微前端的很多动态加载方案都需要借助 webpack 的能力,后来自然就有人想到让 webpack 更好更方便地支持不同工程之间构建产物的互相加载,这就是 webpack module federation,使用方式可能是:

new ModuleFederationPlugin({
    name: 'app_two',
    library: { type: 'global', name: 'app_a' },
    remotes: {
      app_one: 'app_one',
      app_three: 'app_three'
    },
    exposes: {
       AppContainer: './src/App'
    },
    shared: ['react', 'react-dom', 'relay-runtime']
})
----
import('app_one/AppContainer')
复制代码

目前这项工作还在进行当中,可以在这里看到。

小结

总的来说,JS 模块的动态加载可以分为两个阶段,加载阶段和执行阶段。按照加载方式分,有 script 标签加载和 fetch 请求两种方式。在执行阶段, script 标签加载一般配合全局的 jsonp 函数来做解析,函数具体做什么就看约定方式了,比如 requirejs 中的 define 方法和 webpack 动态加载时的 webpackJsonpCallback。而 fetch 请求方式拿到模块内容之后,则需要用 eval 或者 new Function 方法进行包装控制解析。

两种方式基本上都能达到效果,前一种方式的加载方法更简单,后一种方式对解析的控制可以更精细。

参考:

文章分类
前端
文章标签