前言
说起微前端框架,很多人第一反应就是 single-spa[1]。但是再问深入一点:它是干嘛的,它有什么用,可能就回答不出来了。
一方面没多少人研究和使用微前端。可能还没来得及用微前端扩展项目,公司就已经倒闭了。
另一方面是中文博客对微前端的研究少之又少,很多文章只是简单翻译一下官方文档,读几个API,放个官方的 Demo 就完事了。很少有深入研究到底 single-spa 是怎么一回事的。
还有一方面是 single-spa 的文档非常难看懂,和 Redux 文档一样喜欢造概念。讲一个东西的时候,总是把别的库拉进来一起讲,把一个简单的东西变得非常复杂。最令人吐槽的一点就是官方的 sample code 都是只言片语,完全拼凑不出来一个 Demo,而 Github 的 Demo 还贼复杂,没解释,光看完都要 clone 好几个 repo。
最后,求人不如求己,刚完源码再刚一下文档。
这篇文章将不会聊怎么搭建一个 Demo,而是会从 “Why” 和 “How” 的角度来聊一下官方文档的都讲了哪些内容,相信看完这篇文章就能看懂 官方的 Demo[2] 了。
一个需求
让我们从一个最小的需求开始说起。有一天产品经理突然说:我们要做一个 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' 这个玩意要从 "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 机制来引入不同模块:
代码语言:javascript复制<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 差不多。关于模块联邦是个啥,可以参考 这篇文章[3]。
至于用 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 的动态引入:
代码语言:javascript复制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 就充当了静态资源、子应用的路径声明的角色。
代码语言:javascript复制<!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 则实现子应用注册、主应用启动。
代码语言:javascript复制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。具体使用请看代码:
代码语言:javascript复制<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 等信息了:
代码语言:javascript复制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 三个生命周期钩子。
代码语言:javascript复制import SubApp from './index.tsx'
export const bootstrap = () => {}
export const mount = () => {
// 使用 React 来渲染子应用的根组件
ReactDOM.render(<SubApp/>, document.getElementById('root'));
}
export const unmount = () => {}
single-spa-react, single-spa-vue, single-spa-angular, single-spa-xxx, ...
emmmm,怎么说的呢,上面三个 export 不太好看,能不能有一种更直接的方法就实现 3 个生命周期的导出呢?
single-spa 说:可以啊,搞!所以有了 single-spa-react:
代码语言:javascript复制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 (
<div>出错啦!</div>
);
},
});
export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;
single-spa 说:我不能单给 react 搞啊,别的框架也要给它们整上一个,一碗水端平,所以有这了这些牛鬼蛇神:
不禁感慨:这些小轮子是真能造啊。
导入子应用的 CSS
不知道你有没有注意到,在刚刚的子应用注册里我们仅仅用 System.import
导入了一个 JS 文件,那 CSS 样式文件怎么搞呢?可能可以 System.import('xxx.css')
来导入。
但是,这又有问题了:在切换了应用时,unmount 的时候要怎么把已有的 CSS 给删掉呢?官方说可以这样:
代码语言:javascript复制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。用法如下:
代码语言:javascript复制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",
}),
],
};
子应用 CSS 样式隔离
虽然 single-spa-css 解决了子应用的 CSS 引入和移除问题,但是又带来了另一个问题:怎么保证各个子应用的样式不互相干扰呢?官方给出的建议是:
第一种方法:使用 Scoped CSS,也即在子应用的 CSS 选择器上加前缀就好了嘛,像这样:
代码语言:javascript复制.app1__settings-67f89dd87sf89ds {
color: blue;
}
要是嫌麻烦,可以在 Webpack 使用 PostCSS Prefix Selector 给样式自动加前缀:
代码语言:javascript复制const prefixer = require('postcss-prefix-selector');
module.exports = {
plugins: [
prefixer({
prefix: "#single-spa-application\:\@org-name\/project-name"
})
]
}
另一种方法是在加载子应用的函数里,将子应用挂载到 Shadow DOM 上,可以实现完美的样式隔离。Shadow DOM 是什么,怎么玩可见 MDN这里[4]。
公共 CSS 样式怎么处理
上面说的都是子应用自己的 CSS 样式,那如果子应用之间要共享 CSS 怎么办呢?比如有两个子应用都用了 antd,那都要 import 两次 antd.min.css 了。
这个问题和上面提到的处理“公共依赖”的问题是差不多的。官方给出两个建议:
1.将公共的 CSS 放到 importmap 里,也可以理解为在 index.html 里直接加个 link 获取 antd 的 CSS 完事2.将所有的公共的 UI 库都 import 到 utility 里,将 antd 所有内容都 export,再把 utility 包放到 importmap 里,然后 import { Button } from '@your-org-name/utility';
去引入里面的组件
其实上面两个方法都大同小异,思路都是在主应用一波引入,只是一个统一引入CSS,另一个统一引入 UI 库。
子应用的 JS 隔离
我们来想想应用的 JS 隔离本质是什么,本质其实就是在 B 子应用里使用 window 全局对象里的变量时,不要被 A 子应用给污染了。
一个简单的解决思路就是:在 mount A 子应用时,正常添加全局变量,比如 jQuery 的 $
, lodash 的 _
。在 unmount A 子应用时,用一个对象记录之前给 window 添加的全局变量,并把 A 应用里添加 window 的变量都删掉。下一次再 mount A 应用时,把记录的全局变量重新加回来就好了。
single-spa 再次站出来:这个不用你自己手动记录 window 的变更了。single-spa-leaked-globals 已经实现好了,直接用就好了:
代码语言:javascript复制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 时添加 <link/>
标签。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 | 公共资源 | 所有公共资源 | 否 |
create-single-spa
上面介绍了一堆的与子应用相关的库,如果自己要从 0 开始慢慢地配置子应用就比较麻烦。所以,single-spa 说:不麻烦,有脚手架工具,一行命令生成子应用,都给您配好了。
代码语言:javascript复制npm install --global create-single-spa
# 或者
yarn global add create-single-spa
然后
代码语言:javascript复制create-single-spa
注意!这里的 create-single-spa 指的是创建子应用!
总结
以上就是 singles-spa 文档里的所有内容了(除了 SSR 和 Dev Tools,前者用的不多,后者自己看一下就会了,不多废话)。由于本文是通过发现问题到解决问题来讲述文档内容的,所以从头看到尾还是有点乱,这里就做一下总结:
微前端概念
特点:
•技术栈无关•独立开发、独立部署•增量升级•独立运行时
single-spa
只做两件事:
•提供生命周期概念,并负责调度子应用的生命周期•挟持 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 需要开发都手动调用生命周期
SystemJS
可以在浏览器使用 ES6 的 import/export 语法,通过 importmap 指定依赖库的地址。
和 single-spa 没有关系,只是 in-browser import/export 和 single-spa 倡导的 in-browser run time 相符合,所以 single-spa 将其作为主要的导入导出工具。
用 Webpack 动态引入可不可以,可以,甚至可能比 SystemJS 好用,并无好坏之分。
single-spa-layout
和 Vue Router 差不多,主要功能是可以在 index.html 指定在哪里渲染哪个子应用。
single-spa-react, single-spa-xxx....
给子应用快速生成 bootstrap, mount, unmount 的生命周期函数的工具库。
single-spa-css
隔离前后两个子应用的 CSS 样式。
在子应用 mount 时添加子应用的 CSS,在 unmount 时删除子应用的 CSS。子应用使用 Webpack 导出 CSS 文件时,要配合 ExposeRuntimeCssAssetsPlugin
插件来获取最终导出的 CSS 文件名。
算实现了一半的 CSS 沙箱。
如果要在多个子应用进行样式隔离,可以有两种方法:
•Shadow DOM,样式隔离比较好的方法,但是穿透比较麻烦•Scoped CSS,在子应用的 CSS 选择器上添加前缀做区分,可以使用 postcss-prefix-selector
这个包来快速添加前缀
single-spa-leaked-globals
在子应用 mount 时给 window 对象恢复/添加一些全局变量,如 jQuery 的 $
或者 lodash 的 _
,在 unmount 时把 window 对象的变量删掉。
实现了“如果主应用一个url只有一个页面”情况下的 JS 沙箱。
公共依赖
有两种方法处理:
•造一个 Utility Module 包,在这个包导出所有公共资源内容,并用 SystemJS 的 importmap 在主应用的 index.html 里声明•使用 Webpack 5 Module Federation 特性实现公共依赖的导入
哪个更推荐?都可以。
最后
single-spa 文档就这些了嘛?没错,就这些了。文档好像给了很多“最佳实践”,但真正将所有“最佳实践”结合起来并落地的又没多少。
比如文档说用 Shadow CSS 来做子应用之间的样式隔离,但是 single-spa-leaked-globals 又不让别人在一个 url 上挂载多个子应用。感觉很不靠谱:这里行了,那里又不行了。
再说回 Shadow CSS 来做样式隔离,但是也没有详细说明要具体要怎么做。像这样的例子还有很多:文档往往只告诉了一条路,怎么走还要看开发者自己。这就你给人一种 “把问题只解决一半” 的感觉。
如果真的想用 single-spa 来玩小 Demo 的,用上面提到的小库来搭建微前端是可以的,但是要用到生产环境真的没那么容易。
所以,为了填平 single-spa 遗留下来的坑,阿里基于 single-spa 造出了 qiankun[5] 微前端框架,真正实现了微前端的所有特性,不过这又是另外一个故事了。
References
[1]
single-spa: https://single-spa.js.org/
[2]
官方的 Demo: https://single-spa.js.org/docs/examples
[3]
这篇文章: https://zhuanlan.zhihu.com/p/115403616
[4]
MDN这里: https://developer.mozilla.org/zh-CN/docs/Web/Web_Components/Using_shadow_DOM
[5]
qiankun: https://qiankun.umijs.org/zh