说起微前端框架,很多人第一反应就是 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 就相当于页面里再开个窗口加载别的页面,但是它有很多弊端:
而 SPA 正好可以解决上面的问题:
这就给我们一个启发:能不能有这么一个巨型 SPA 框架,把现有的 SPA 当成 Page Component 来组装成一个新的 SPA 呢?这就是微前端的由来。
微前端应该有如下特点:
等一下等一下,说了一堆,到底啥是 single-spa 啊。
嘿嘿,single-spa 框架并没有实现上面任何特点,对的,一个都没有,Just Zero。
single-spa 仅仅是一个子应用生命周期的调度者。single-spa 为应用定义了 boostrap, load, mount, unmount 四个生命周期回调:
只要写过 SPA 的人都能理解,无非就是生、老、病、死。不过有几个点需要注意一下:
registerApplication
函数这一步unloadApplication
函数才会被调用OK,上面 4 个生命周期的回调顺序是 single-spa 可以控制的,我能理解,那什么时候应该开始这一套生命周期呢?应该是有一个契机来开始整套流程的,或者某几个流程的。
契机就是当 window.location.href
匹配到 url 时,开始走对应子 App 的这一套生命周期嘛。所以,single-spa 还要监听 url 的变化,然后执行子 app 的生命周期流程。
到此,我们就有了 single-spa 的大致框架了,无非就两件事:
画个草图如下:
是不是感觉 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?
相信很多人看过一些微前端的博客,它们都会说 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'
。
其实,也不是不行,只需要在 标签加上
type="module"
,也是可以实现的,例如:
// providesHelperMethod.js
export function helperMethod() {
console.info(`I'm helping!`);
}
但是,遇到导入模块依赖的,像 import axios from 'axios'
这样的,就需要 importmap 了:
我是:{{name}}
importmap 的功能就是告诉 'vue' 这个玩意要从 "https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.esm.browser.js" 这里来的。不过,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 机制来引入不同模块:
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。
有人可能会想:都 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 来实现主应用。
由于 single-spa 非常强调 in-browser 的方式来实现主应用,所以 index.html 就充当了静态资源、子应用的路径声明的角色。
Polyglot Microfrontends
<% if (isLocal) { %>
<% } %>
而 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 文件
虽然一个 index.html 是完美的轻量微前端主应用,但是就算再压缩主应用的交互,那总得告诉子应用放置的位置吧,那不还得 DOM API 一把梭?一样麻烦?
为了解决这个问题,single-spa 说:没事,我帮你搞,然后造了 single-spa-layout。具体使用请看代码:
不能说和 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
函数一个个注册就完事了。
上面说的都是主应用的事情,现在我们来关心一下子应用。
子应用最关键的一步就是导出 bootstrap, mount, unmount 三个生命周期钩子。
import SubApp from './index.tsx'
export const bootstrap = () => {}
export const mount = () => {
// 使用 React 来渲染子应用的根组件
ReactDOM.render( , document.getElementById('root'));
}
export const unmount = () => {}
emmmm,怎么说的呢,上面三个 export 不太好看,能不能有一种更直接的方法就实现 3 个生命周期的导出呢?
single-spa 说:可以啊,搞!所以有了 single-spa-react:
import React from 'react';
import ReactDOM from 'react-dom';
import SubApp from './index.tsx';
import singleSpaReact, {SingleSpaContext} from 'single-spa-react';
const reactLifecycles = singleSpaReact({
React,
ReactDOM,
rootComponent: SubApp,
errorBoundary(err, info, props) {
return (
出错啦!
);
},
});
export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;
single-spa 说:我不能单给 react 搞啊,别的框架也要给它们整上一个,一碗水端平,所以有这了这些牛鬼蛇神:
不禁感慨:这些小轮子是真能造啊。
不知道你有没有注意到,在刚刚的子应用注册里我们仅仅用 System.import
导入了一个 JS 文件,那 CSS 样式文件怎么搞呢?可能可以 System.import('xxx.css')
来导入。
但是,这又有问题了:在切换了应用时,unmount 的时候要怎么把已有的 CSS 给删掉呢?官方说可以这样:
const style = document.createElement('style');
style.textContent = `.settings {color: blue;}`;
export const mount = [
async () => {
document.head.appendChild(styleElement);
},
reactLifecycles.mount,
]
export const unmount = [
reactLifecycles.unmount,
async () => {
styleElement.remove();
}
]
我:single-spa,求求你做个人吧,搭个 Demo,还要我来处理 CSS?single-spa 说:好,等我再去造一个轮子。于是,就有了 single-spa-css。用法如下:
import singleSpaCss from 'single-spa-css';
const cssLifecycles = singleSpaCss({
// 这里放你导出的 CSS,如果 webpackExtractedCss 为 true,可以不指定
cssUrls: ['https://example.com/main.css'],
// 是否要使用从 Webpack 导出的 CSS,默认为 false
webpackExtractedCss: false,
// 是否 unmount 后被移除,默认为 true
shouldUnmount: true,
// 超时,不废话了,都懂的
timeout: 5000
})
const reactLifecycles = singleSpaReact({...})
// 加入到子应用的 bootstrap 里
export const bootstrap = [
cssLifecycles.bootstrap,
reactLifecycles.bootstrap
]
export const mount = [
// 加入到子应用的 mount 里,一定要在前面,不然 mount 后会有样式闪一下的问题
cssLifecycles.mount,
reactLifecycles.mount
]
export const unmount = [
// 和 mount 同理
reactLifecycles.unmount,
cssLifecycles.unmount
]
这里要注意一下,上面的 https://example.com/main.css 并没有看起来那么简单易用。
假如你用了 Webpack 来打包,很有可能会用分包或者 content hash 来给 CSS 文件命名,比如 filename: "[name].[contenthash].css"
。那请问 cssUrls
要怎么写呀,每次都要改 cssUrls
参数么?太麻烦了吧。
single-spa-css 说:我可以通过 Webpack 导出的 __webpack_require__.cssAssetFileName
获取导出之后的真实 CSS 文件名。ExposeRuntimeCssAssetsPlugin 这个插件正好可以解决这个问题。这么一来 cssUrls
就可以不用指定了,直接把 Webpack 导出的真实 CSS 名放到 cssUrls
里了。
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const ExposeRuntimeCssAssetsPlugin = require("single-spa-css/ExposeRuntimeCssAssetsPlugin.cjs");
module.exports = {
plugins: [
new MiniCssExtractPlugin({
filename: "[name].css",
}),
new ExposeRuntimeCssAssetsPlugin({
// The filename here must match the filename for the MiniCssExtractPlugin
filename: "[name].css",
}),
],
};
虽然 single-spa-css 解决了子应用的 CSS 引入和移除问题,但是又带来了另一个问题:怎么保证各个子应用的样式不互相干扰呢?官方给出的建议是:
第一种方法:使用 Scoped CSS,也即在子应用的 CSS 选择器上加前缀就好了嘛,像这样:
.app1__settings-67f89dd87sf89ds {
color: blue;
}
要是嫌麻烦,可以在 Webpack 使用 PostCSS Prefix Selector 给样式自动加前缀:
const prefixer = require('postcss-prefix-selector');
module.exports = {
plugins: [
prefixer({
prefix: "#single-spa-application\\:\\@org-name\\/project-name"
})
]
}
另一种方法是在加载子应用的函数里,将子应用挂载到 Shadow DOM 上,可以实现完美的样式隔离。Shadow DOM 是什么,怎么玩可见 MDN这里。
上面说的都是子应用自己的 CSS 样式,那如果子应用之间要共享 CSS 怎么办呢?比如有两个子应用都用了 antd,那都要 import 两次 antd.min.css 了。
这个问题和上面提到的处理“公共依赖”的问题是差不多的。官方给出两个建议:
import { Button } from '@your-org-name/utility';
去引入里面的组件其实上面两个方法都大同小异,思路都是在主应用一波引入,只是一个统一引入CSS,另一个统一引入 UI 库。
我们来想想应用的 JS 隔离本质是什么,本质其实就是在 B 子应用里使用 window 全局对象里的变量时,不要被 A 子应用给污染了。
一个简单的解决思路就是:在 mount A 子应用时,正常添加全局变量,比如 jQuery 的 $
, lodash 的 _
。在 unmount A 子应用时,用一个对象记录之前给 window 添加的全局变量,并把 A 应用里添加 window 的变量都删掉。下一次再 mount A 应用时,把记录的全局变量重新加回来就好了。
single-spa 再次站出来:这个不用你自己手动记录 window 的变更了。single-spa-leaked-globals 已经实现好了,直接用就好了:
import singleSpaLeakedGlobals from 'single-spa-leaked-globals';
// 其它 single-spa-xxx 提供的生命周期函数
const frameworkLifecycles = ...
const leakedGlobalsLifecycles = singleSpaLeakedGlobals({
globalVariableNames: ['$', 'jQuery', '_'], // 新添加的全局变量
})
export const bootstrap = [
leakedGlobalsLifecycles.bootstrap, // 放在第一位
frameworkLifecycles.bootstrap,
]
export const mount = [
leakedGlobalsLifecycles.mount, // mount 时添加全局变量,如果之前有记录在案的,直接恢复
frameworkLifecycles.mount,
]
export const unmount = [
leakedGlobalsLifecycles.unmount, // 删掉新添加的全局变量
frameworkLifecycles.unmount,
]
但是,这个库的局限性在于:每个 url 只能加一个子 app,如果多个子 app 之间还是会访问同一个 window 对象,也因此会互相干扰,并不能做到完美的 JS 沙箱。
比如:一个页面里,导航栏用 3.0 的 jQuery,而页面主体用 5.0 的 jQuery,那就会有冲突了。
所以这个库的场景也仅限于:首页用 3.0 的 jQuery,订单详情页使用 5.0 的 jQuery 这样的场景。
上面我们说到了,当 url 匹配 activeWhen 参数时,就会执行对应子应用的生命周期。那这样就相当于子应用和 url 绑定在了一起了。
我们再来看 single-spa-leaked-globals,single-spa-css 这些库,虽然它们也导出了生命周期,但这些生命周期与页面渲染、url 变化没有多大关系。
它们与普通的 application 唯一不同的地方就是:普通 application 的生命周期是通过 single-spa 来自动调度的,而这些库是要通过手动调度的。只不过我们一般选择在子应用里的生命周期里手动调用它们而已。
这种与 url 无关的 “app” 在微前端也有着非常重要的作用,一般是在子应用的生命周期里提供一些功能,像 single-spa-css 就是在 mount 时添加 标签。single-spa 将这样的 “类子 app” 称为 Parcel。
同时,single-spa 还分出另一个类:Utility Modules。很多子应用都用 antd, dayjs, axios 的,那么就可以搞一个 utility 集合这些公共库,然后统一做 export,然后在 importmap 里统一导入。子应用就可以不需要在自己的 package.json 里添加 antd, dayjs, axios 的依赖了。
总结一下,single-spa 将微前端分为三大类:
分类 | 功能 | 导出 | 是否与 url 有关 |
---|---|---|---|
Application | 子应用 | bootstrap, mount, unmount | 是 |
Parcel | 功能组件,比如给子应用的生命周期打一些补丁 | bootstrap, mount, unmount, update | 否 |
Utility Module | 公共资源 | 所有公共资源 | 否 |
上面介绍了一堆的与子应用相关的库,如果自己要从 0 开始慢慢地配置子应用就比较麻烦。所以,single-spa 说:不麻烦,有脚手架工具,一行命令生成子应用,都给您配好了。
npm install --global create-single-spa
# 或者
yarn global add create-single-spa
然后
create-single-spa
注意!这里的 create-single-spa 指的是创建子应用!
以上就是 singles-spa 文档里的所有内容了(除了 SSR 和 Dev Tools,前者用的不多,后者自己看一下就会了,不多废话)。由于本文是通过发现问题到解决问题来讲述文档内容的,所以从头看到尾还是有点乱,这里就做一下总结:
特点: 技术栈无关 独立开发、独立部署 增量升级 独立运行时
只做两件事: 提供生命周期概念,并负责调度子应用的生命周期 挟持 url 变化事件和函数,url 变化时匹配对应子应用,并执行生命周期流程
三大分类: Application:子应用,和 url 强相关,交由 single-spa 调用生命周期 Parcel:组件,和 url 无关,手动调用生命周期 * Utility Module:统一将公共资源导出的模块
“重要”概念 Root Config:指主应用的 index.html + main.js。HTML 负责声明资源路径,JS 负责注册子应用和启动主应用 Application:要暴露 bootstrap, mount, umount 三个生命周期,一般在 mount 开始渲染子 SPA 应用 * Parcel:也要暴露 bootstrap, mount, unmount 三个生命周期,可以再暴露 update 生命周期。Parcel 可大到一个 Application,也可以小到一个功能组件。与 Application 不同的是 Parcel 需要开发都手动调用生命周期
可以在浏览器使用 ES6 的 import/export 语法,通过 importmap 指定依赖库的地址。
和 single-spa 没有关系,只是 in-browser import/export 和 single-spa 倡导的 in-browser run time 相符合,所以 single-spa 将其作为主要的导入导出工具。
用 Webpack 动态引入可不可以,可以,甚至可能比 SystemJS 好用,并无好坏之分。
和 Vue Router 差不多,主要功能是可以在 index.html 指定在哪里渲染哪个子应用。
给子应用快速生成 bootstrap, mount, unmount 的生命周期函数的工具库。
隔离前后两个子应用的 CSS 样式。
在子应用 mount 时添加子应用的 CSS,在 unmount 时删除子应用的 CSS。子应用使用 Webpack 导出 CSS 文件时,要配合 ExposeRuntimeCssAssetsPlugin
插件来获取最终导出的 CSS 文件名。
算实现了一半的 CSS 沙箱。
如果要在多个子应用进行样式隔离,可以有两种方法:
postcss-prefix-selector
这个包来快速添加前缀在子应用 mount 时给 window 对象恢复/添加一些全局变量,如 jQuery 的 $
或者 lodash 的 _
,在 unmount 时把 window 对象的变量删掉。
实现了“如果主应用一个url只有一个页面”情况下的 JS 沙箱。
有两种方法处理:
哪个更推荐?都可以。
single-spa 文档就这些了嘛?没错,就这些了。文档好像给了很多“最佳实践”,但真正将所有“最佳实践”结合起来并落地的又没多少。
比如文档说用 Shadow CSS 来做子应用之间的样式隔离,但是 single-spa-leaked-globals 又不让别人在一个 url 上挂载多个子应用。感觉很不靠谱:这里行了,那里又不行了。
再说回 Shadow CSS 来做样式隔离,但是也没有详细说明要具体要怎么做。像这样的例子还有很多:文档往往只告诉了一条路,怎么走还要看开发者自己。这就你给人一种 “把问题只解决一半” 的感觉。
如果真的想用 single-spa 来玩小 Demo 的,用上面提到的小库来搭建微前端是可以的,但是要用到生产环境真的没那么容易。
所以,为了填平 single-spa 遗留下来的坑,阿里基于 single-spa 造出了 qiankun 微前端框架,真正实现了微前端的所有特性,不过这又是另外一个故事了。