前言
说起微前端框架,很多人第一反应就是 single-spa。但是再问深入一点:它是干嘛的,它有什么用,可能就回答不出来了。
一方面没多少人研究和使用微前端。可能还没来得及用微前端扩展项目,公司就已经倒闭了。
另一方面是中文博客对微前端的研究少之又少,很多文章只是简单翻译一下官方文档,读几个API,放个官方的 Demo 就完事了。很少有深入研究到底 single-spa 是怎么一回事的。
还有一方面是 single-spa 的文档非常难看懂,和 Redux 文档一样喜欢造概念。讲一个东西的时候,总是把别的库拉进来一起讲,把一个简单的东西变得非常复杂。最令人吐槽的一点就是官方的 sample code 都是只言片语,完全拼凑不出来一个 Demo,而 Github 的 Demo 还贼复杂,没解释,光看完都要 clone 好几个 repo。
最后,求人不如求己,刚完源码再刚一下文档。
这篇文章将不会聊怎么搭建一个 Demo,而是会从 “Why” 和 “How” 的角度来聊一下官方文档的都讲了哪些内容,相信看完这篇文章就能看懂 官方的 Demo 了。
一个需求
让我们从一个最小的需求开始说起。有一天产品经理突然说:我们要做一个 A 页面,我看到隔壁组已经做过这个 A 页面了,你把它放到我们项目里吧,应该不是很难吧?明天上线吧。
此时,产品经理想的是:应该就填一个 URL 就好吧?再不行,复制粘贴也很快吧。而程序员想的却是:又要看屎山了。又要重构了。又要联调了。测试数据有没有啊?等一下,联调的后端是谁啊?
**估计这是做大项目时经常遇到的需求了:搬运一个现有的页面。**我想大多数人都会选择在自己项目里复制粘贴别人的代码,然后稍微重构一下,再测试环境联调,最后上线。
但是,这样就又多了一份代码了,如果别人的页面改了,那么自己项目又要跟着同步修改,再联调,再上线,非常麻烦。
所以程序员就想能不能我填一个 url,然后这个页面就到项目里来了呢?所以,<iframe/>
就出场了。
iframe 的弊端
iframe 就相当于页面里再开个窗口加载别的页面,但是它有很多弊端:
- 每次进来都要加载,状态不能保留
- DOM 结构不共享。比如子应用里有一个 Modal,显示的时候只能在那一小块地方展示,不能全屏展示
- 无法跟随浏览器前进后退
- 天生的硬隔离,无法与主应用进行资源共享,交流也很困难
而 SPA 正好可以解决上面的问题:
- 切换路由就是切换页面组件,组件的挂载和卸载非常快
- 单页应用肯定共享 DOM
- 前端控制路由,想前就前,想后就后
- React 通信有 Redux,Vue 通信有 Vuex,可与 App 组件进行资源共享,交流很爽
这就给我们一个启发:能不能有这么一个巨型 SPA 框架,把现有的 SPA 当成 Page Component 来组装成一个新的 SPA 呢?这就是微前端的由来。
微前端是什么
微前端应该有如下特点:
- 技术栈无关,主框架不限制接入应用的技术栈,微应用具备完全自主权
- 独立开发、独立部署,微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
- 增量升级,在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
- 独立运行时,每个微应用之间状态隔离,运行时状态不共享
等一下等一下,说了一堆,到底啥是 single-spa 啊。
嘿嘿,single-spa 框架并没有实现上面任何特点,对的,一个都没有,Just Zero。
single-spa 到底是干嘛的
**single-spa 仅仅是一个子应用生命周期的调度者。**single-spa 为应用定义了 boostrap, load, mount, unmount 四个生命周期回调:
只要写过 SPA 的人都能理解,无非就是生、老、病、死。不过有几个点需要注意一下:
- Register 不是生命周期,指的是调用
registerApplication
函数这一步 - Load 是开始加载子应用,怎么加载由开发者自己实现(等会会说到)
- Unload 钩子只能通过调用
unloadApplication
函数才会被调用
OK,上面 4 个生命周期的回调顺序是 single-spa 可以控制的,我能理解,那什么时候应该开始这一套生命周期呢?应该是有一个契机来开始整套流程的,或者某几个流程的。
契机就是当 window.location.href
匹配到 url 时,开始走对应子 App 的这一套生命周期嘛。所以,single-spa 还要监听 url 的变化,然后执行子 app 的生命周期流程。
到此,我们就有了 single-spa 的大致框架了,无非就两件事:
- 实现一套生命周期,在 load 时加载子 app,由开发者自己玩,别的生命周期里要干嘛的,还是由开发者造的子应用自己玩
- 监听 url 的变化,url 变化时,会使得某个子 app 变成 active 状态,然后走整套生命周期
画个草图如下:
是不是感觉 single-spa 很鸡贼?虽然 single-spa 说自己是微前端框架,但是一个微前端的特性都没有实现,都是需要开发者在加载自己子 App 的时候实现的,要不就是通过一些第三方工具实现。
注册子应用
有了上面的了解之后,我们再来看 single-spa 里最重要的 API:registerApplication
,表示注册一个子应用。使用如下:
singleSpa.registerApplication({ name: 'taobao', // 子应用名 app: () => System.import('taobao'), // 如何加载你的子应用 activeWhen: '/appName', // url 匹配规则,表示啥时候开始走这个子应用的生命周期 customProps: { // 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到 authToken: 'xc67f6as87f7s9d' } }) singleSpa.start() // 启动主应用 复制代码
上面注册了一个子应用 'taobao'。我们自己实现了加载子应用的方法,通过 activeWhen
告诉 single-spa 什么时候要挂载子应用,好像就可以上手开撸代码喽。
可以个鬼!请告诉我 System.import
是个什么鬼。哦,是 SystemJS,诶,SystemJS 听说过,它是个啥?为啥要用 SystemJS?凭啥要用 SystemJS?
SystemJS
相信很多人看过一些微前端的博客,它们都会说 single-spa 是基于 SystemJS 的。错!single-spa 和 SystemJS 一点关系都没有!这里先放个主应用和子应用的关系图:
single-spa 的理念是希望主应用可以做到非常非常简单的和轻量,简单到只要一个 index.html + 一个 main.js 就可以完成微前端工程,连 Webpack 都不需要,直接在浏览器里执行 singleSpa.registerApplication
就收工了,这种执行方式也就是 in-browser 执行。
但是,浏览器里执行 JS,别说实现 import xxx from 'https://taobao.com'
了,我要是在浏览器里实现 ES6 的 import/export 都不行啊: import axios from 'axios'
。
其实,也不是不行,只需要在 <script>
标签加上 type="module"
,也是可以实现的,例如:
<script type="module" src="module.js"></script> <script type="module"> // or an inline script import {helperMethod} from './providesHelperMethod.js'; helperMethod(); </script> // providesHelperMethod.js export function helperMethod() { console.info(`I'm helping!`); } 复制代码
但是,遇到导入模块依赖的,像 import axios from 'axios'
这样的,就需要 importmap 了:
<script type="importmap"> { "imports": { "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.esm.browser.js" } } </script> <div id="container">我是:{{name}}</div> <script type="module"> import Vue from 'vue' new Vue({ el: '#container', data: { name: 'Jack' } }) </script> 复制代码
importmap 的功能就是告诉 'vue' 这个玩意要从 "cdn.jsdelivr.net/npm/vue@2.6…" 这里来的。不过,importmap 现在只有 Chrome 是支持的。
所以,SystemJS 就将这一块补齐了。当然,除了 importmap,它还有很多的功能,比如获取当前加载的所有模块、当前模块的 URL、可以 import html, import css,import wasm。
等等,这在 Webpack 不也可以做到么?Webpack 还能 import less, import scss 呢?这不比 SystemJS 牛逼?对的,如果不是因为要在浏览器使用 import/export,没人会用 SystemJS。SystemJS 的好处和优势有且仅有一点:那就是在浏览器里使用 ES6 的 import/export。
而正因为 SystemJS 可以在浏览器里可以使用 ES6 的 import/export 并支持动态引入,正好符合 single-spa 所提倡的 in-browser 执行思路,所以 single-spa 文档里才反复出现 SystemJS 的身影,而且 Github Demo 里依然是使用 SystemJS 的 importmap 机制来引入不同模块:
<script type="systemjs-importmap"> { "imports": { "@react-mf/root-config": "//localhost:9000/react-mf-root-config.js" } } </script> <script> singleSpa.registerApplication({ name: 'taobao', // 子应用名 app: () => System.import('@react-mf/root-config'), // 如何加载你的子应用 activeWhen: '/appName', // url 匹配规则,表示啥时候开始走这个子应用的生命周期 customProps: { // 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到 authToken: 'xc67f6as87f7s9d' } }) </script> 复制代码
公共依赖
SystemJS 另一个好处就是可以通过 importmap 引入公共依赖。
假如,我们有三个子应用,它们都有公共依赖项 antd,那每个子应用打包出来都会有一份 antd 的代码,就显示很冗余。
一个解决方法就是在主应用里,通过 importmap 直接把 antd 代码引入进来,子应用在 Webpack 设置 external 把 antd 打包时排除掉。子应用打包就不会把 antd 打包进去了,体积也变小了。
有人会说了:我用 CDN 引入不行嘛?不行啊,因为子应用的代码都是 import {Button} from 'antd'
的,浏览器要怎么直接识别 ES6 的 import/export 呢?那还不得 SystemJS 嘛。
难道 Webpack 就没有办法可以实现 importmap 的效果了么?Webpack 5 提出的 Module Federation 模块联邦就可以很好地做的 importmap 的效果。这是 Webpack 5 的新特性,使用的效果和 importmap 差不多。关于模块联邦是个啥,可以参考 这篇文章。
至于用 importmap 还是 Webpack 的 Module Federation,singles-spa 是推荐使用 importmap 的,但是,文档也没有反对使用 Webpack 的 Module Federation 的理由。能用就OK。
SystemJS vs Webpack ES
有人可能会想:都 1202 年了,怎么还要在浏览器环境写 JS 呢?不上个 Webpack 都不好意思说自己是前端开发了。
没错,Webpack 是非常强大的,而且可以利用 Webpack 很多能力,让主应用变得更加灵活。比如,写 less,scss,Webpack 的 prefetch 等等等等。然后在注册子应用时,完全可以利用 Webpack 的动态引入:
singleSpa.registerApplication({ name: 'taobao', // 子应用名 app: () => import('taobao'), // 如何加载你的子应用 activeWhen: '/appName', // url 匹配规则,表示啥时候开始走这个子应用的生命周期 customProps: { // 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到 authToken: 'xc67f6as87f7s9d' } }) 复制代码
那为什么 single-spa 还要推荐 SystemJS 呢?个人猜测是因为 single-spa 希望主应用应该就一个空壳子,只需要管内容要放在哪个地方,所有的功能、交互都应该交由 index.html 来统一管理。
当然,这仅仅是一种理念,可以完全不遵循它。像我个人还是喜欢用 Webpack 多一点,SystemJS 还是有点多余,而且觉得有点奥特曼了。不过,为了跟着文档的节奏来,这里假设就用 SystemJS 来实现主应用。
Root Config
由于 single-spa 非常强调 in-browser 的方式来实现主应用,所以 index.html 就充当了静态资源、子应用的路径声明的角色。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Polyglot Microfrontends</title> <meta name="importmap-type" content="systemjs-importmap" /> <script type="systemjs-importmap" src="https://storage.googleapis.com/polyglot.microfrontends.app/importmap.json"></script> <% if (isLocal) { %> <script type="systemjs-importmap"> { "imports": { "@polyglot-mf/root-config": "//localhost:9000/polyglot-mf-root-config.js" } } </script> <% } %> <script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2.2.0/dist/import-map-overrides.js"></script> <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.min.js"></script> </head> <body> <script> System.import('@polyglot-mf/root-config'); System.import('@polyglot-mf/styleguide'); </script> <import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full> </body> </html> 复制代码
而 main.js 则实现子应用注册、主应用启动。
import { registerApplication, start } from "single-spa"; registerApplication({ name: "@polyglot-mf/navbar", app: () => System.import("@polyglot-mf/navbar"), activeWhen: "/", }); registerApplication({ name: "@polyglot-mf/clients", app: () => System.import("@polyglot-mf/clients"), activeWhen: "/clients", }); registerApplication({ name: "@polyglot-mf/account-settings", app: () => loadWithoutAmd("@polyglot-mf/account-settings"), activeWhen: "/settings", }); start(); // A lot of angularjs libs are compiled to UMD, and if you don't process them with webpack // the UMD calls to window.define() can be problematic. function loadWithoutAmd(name) { return Promise.resolve().then(() => { let globalDefine = window.define; delete window.define; return System.import(name).then((module) => { window.define = globalDefine; return module; }); }); } 复制代码
像这样的资源声明 + 主子应用加载的组件,single-spa 称之为 Root Config。 它不是什么新概念,就只有两个东西:
- 一个主应用的 index.html
- 一个执行
registerApplication
函数的 JS 文件
single-spa-layout
虽然一个 index.html 是完美的轻量微前端主应用,但是就算再压缩主应用的交互,那总得告诉子应用放置的位置吧,那不还得 DOM API 一把梭?一样麻烦?
为了解决这个问题,single-spa 说:没事,我帮你搞,然后造了 single-spa-layout。具体使用请看代码:
<html> <head> <template id="single-spa-layout"> <single-spa-router> <nav class="topnav"> <application name="@organization/nav"></application> </nav> <div class="main-content"> <route path="settings"> <application name="@organization/settings"></application> </route> <route path="clients"> <application name="@organization/clients"></application> </route> </div> <footer> <application name="@organization/footer"></application> </footer> </single-spa-router> </template> </head> </html> 复制代码
不能说和 Vue Router 很像,只能说一模一样吧。当然上面这么写很直观,但是浏览器并不认识这些元素,所以 single-spa-layout 把识别这些元素的逻辑都封装成了函数,并暴露给开发者,开发者只要调用一下就能识别出 appName 等信息了:
import { registerApplication, start } from 'single-spa'; import { constructApplications, constructRoutes, constructLayoutEngine, } from 'single-spa-layout'; // 获取 routes const routes = constructRoutes(document.querySelector('#single-spa-layout')); // 获取所有的子应用 const applications = constructApplications({ routes, loadApp({ name }) { return System.import(name); // SystemJS 引入入口 JS }, }); // 生成 layoutEngine const layoutEngine = constructLayoutEngine({ routes, applications }); // 批量注册子应用 applications.forEach(registerApplication); // 启动主应用 start(); 复制代码
没什么好说的,constrcutRoutes
, constructApplication
和 constructLayoutEngine
本质上就是识别 single-spa-layout 定义的元素标签,然后获取里面的属性,再通过 registerApplication
函数一个个注册就完事了。