改造子应用
上面说的都是主应用的事情,现在我们来关心一下子应用。
子应用最关键的一步就是导出 bootstrap, mount, unmount 三个生命周期钩子。
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:
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 给删掉呢?官方说可以这样:
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 ] 复制代码
这里要注意一下,上面的 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 选择器上加前缀就好了嘛,像这样:
.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 样式,那如果子应用之间要共享 CSS 怎么办呢?比如有两个子应用都用了 antd,那都要 import 两次 antd.min.css 了。
这个问题和上面提到的处理“公共依赖”的问题是差不多的。官方给出两个建议:
- 将公共的 CSS 放到 importmap 里,也可以理解为在 index.html 里直接加个 link 获取 antd 的 CSS 完事
- 将所有的公共的 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 已经实现好了,直接用就好了:
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 说:不麻烦,有脚手架工具,一行命令生成子应用,都给您配好了。
npm install --global create-single-spa # 或者 yarn global add create-single-spa 复制代码
然后
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 微前端框架,真正实现了微前端的所有特性,不过这又是另外一个故事了。