v1-支持不同框架的子应用
监听页面 URL 变化,切换子应用
一个 SPA 应用必不可少的功能就是监听页面 URL 的变化,然后根据不同的路由规则来渲染不同的路由组件。因此,微前端框架也可以根据页面 URL 的变化,来切换到不同的子应用:
// 当 location.pathname 以 /vue 为前缀时切换到 vue 子应用https://www.example.com/vue/xxx// 当 location.pathname 以 /react 为前缀时切换到 react 子应用https://www.example.com/react/xxx
这可以通过重写两个 API 和监听两个事件来完成:
重写 window.history.pushState()重写 window.history.replaceState()监听 popstate 事件监听 hashchange 事件
其中 pushState()、replaceState() 方法可以修改浏览器的历史记录栈,所以我们可以重写这两个 API。当这两个 API 被 SPA 应用调用时,说明 URL 发生了变化,这时就可以根据当前已改变的 URL 判断是否要加载、卸载子应用。
// 执行下面代码后,浏览器的 URL 将从 https://www.xxx.com 变为 https://www.xxx.com/vuewindow.history.pushState(null, '', '/vue')
当用户手动点击浏览器上的前进后退按钮时,会触发 popstate 事件,所以需要对这个事件进行监听。同理,也需要监听 hashchange 事件。
这一段逻辑的代码如下所示:
import { loadApps } from '../application/apps'const originalPushState = window.history.pushStateconst originalReplaceState = window.history.replaceStateexport default function overwriteEventsAndHistory() {window.history.pushState = function (state: any, title: string, url: string) {const result = originalPushState.call(this, state, title, url)// 根据当前 url 加载或卸载 apploadApps()return result}window.history.replaceState = function (state: any, title: string, url: string) {const result = originalReplaceState.call(this, state, title, url)loadApps()return result}window.addEventListener('popstate', () => {loadApps()}, true)window.addEventListener('hashchange', () => {loadApps()}, true)}
从上面的代码可以看出来,每次 URL 改变时,都会调用 loadApps() 方法,这个方法的作用就是根据当前的 URL、子应用的触发规则去切换子应用的状态:
export async function loadApps() {// 先卸载所有失活的子应用const toUnMountApp = getAppsWithStatus(AppStatus.MOUNTED)await Promise.all(toUnMountApp.map(unMountApp))// 初始化所有刚注册的子应用const toLoadApp = getAppsWithStatus(AppStatus.BEFORE_BOOTSTRAP)await Promise.all(toLoadApp.map(bootstrapApp))const toMountApp = [...getAppsWithStatus(AppStatus.BOOTSTRAPPED),...getAppsWithStatus(AppStatus.UNMOUNTED),]// 加载所有符合条件的子应用await toMountApp.map(mountApp)}
这段代码的逻辑:
- 卸载所有已失活的子应用
 - 初始化所有刚注册的子应用
 - 加载所有符合条件的子应用
根据当前 URL、子应用的触发规则来判断是否要加载、卸载子应用
为了支持不同框架的子应用,所以规定了子应用必须向外暴露bootstrap() ``mount()unmount()这三个方法。bootstrap()方法在第一次加载子应用时触发,并且只会触发一次,另外两个方法在每次加载、卸载子应用时都会触发。
不管注册的是什么子应用,在 URL 符合加载条件时就调用子应用的mount()方法,能不能正常渲染交给子应用负责。在符合卸载条件时则调用子应用的unmount()方法。
上面是一个简单的子应用注册示例,其中 activeRule() 方法用来判断该子应用是否激活(返回 true 表示激活)。每当页面 URL 发生变化,微前端框架就会调用 loadApps() 判断每个子应用是否激活,然后触发加载、卸载子应用的操作。registerApplication({name: 'vue',// 初始化子应用时执行该方法loadApp() {return {mount() {// 这里进行挂载子应用的操作app.mount('#app')},unmount() {// 这里进行卸载子应用的操作app.unmount()},}},// 如果传入一个字符串会被转为一个参数为 location 的函数// activeRule: '/vue' 会被转为 (location) => location.pathname === '/vue'activeRule: (location) => location.hash === '#/vue'})
何时加载、卸载子应用
首先我们将子应用的状态分为三种: 
bootstrap,调用registerApplication()注册一个子应用后,它的状态默认为bootstrap,下一个转换状态为mount。mount,子应用挂载成功后的状态,它的下一个转换状态为unmount。unmount,子应用卸载成功后的状态,它的下一个转换状态为mount,即卸载后的应用可再次加载。
现在我们来看看什么时候会加载一个子应用,当页面 URL 改变后,如果子应用满足以下两个条件,则需要加载该子应用:
activeRule()的返回值为 true,例如 URL 从 / 变为 /vue,这时子应用 vue 为激活状态(假设它的激活规则为 /vue)。- 子应用状态必须为 
bootstrap或unmount,这样才能向mount状态转换。如果已经处于 mount 状态并且activeRule()返回值为 true,则不作任何处理。 - 如果页面的 URL 改变后,子应用满足以下两个条件,则需要卸载该子应用:
 activeRule()的返回值为 false,例如 URL 从 /vue 变为 /,这时子应用 vue 为失活状态(假设它的激活规则为 /vue)。- 子应用状态必须为 
mount,也就是当前子应用必须处于加载状态(如果是其他状态,则不作任何处理)。然后 URL 改变导致失活了,所以需要卸载它,状态也从mount变为unmount。API 介绍
 
registerApplication(),注册子应用start(),注册完所有的子应用后调用,在它的内部会执行 loadApps() 去加载子应用。
registerApplication(Application) 接收的参数如下:
interface Application {// 子应用名称name: string/*** 激活规则,例如传入 /vue,当 url 的路径变为 /vue 时,激活当前子应用。* 如果 activeRule 为函数,则会传入 location 作为参数,activeRule(location) 返回 true 时,激活当前子应用。*/activeRule: Function | string// 传给子应用的自定义参数props: AnyObject/*** loadApp() 必须返回一个 Promise,resolve() 后得到一个对象:* {* bootstrap: () => Promise<any>* mount: (props: AnyObject) => Promise<any>* unmount: (props: AnyObject) => Promise<any>* }*/loadApp: () => Promise<any>}
完整示例
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title></head><body><p>This is single-spa demo</p><div id="app"></div><button class="btn1">/</button><button class="btn2">/vue</button><button class="btn3">/react</button><script src="https://unpkg.com/vue@3.2.26/dist/vue.global.js"></script><script src="https://unpkg.com/react@17.0.2/umd/react.production.min.js"></script><script src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.production.min.js"></script><script type="module">import { registerApplication, start } from '../dist/mini-single-spa.esm.js'function $(selector) {return document.querySelector(selector)}$('.btn1').onclick = () => {location.hash = '/'}$('.btn2').onclick = () => {location.hash = '/vue'}$('.btn3').onclick = () => {location.hash = '/react'}let vueAppregisterApplication({name: 'vue',loadApp() {return Promise.resolve({bootstrap() {console.log('vue bootstrap')},mount() {console.log('vue mount')vueApp = Vue.createApp({data() {return {text: 'Vue App'}},render() {return Vue.h('div', // 标签名称this.text // 标签内容)},})vueApp.mount('#app')},unmount() {console.log('vue unmount')vueApp.unmount()},})},activeRule:(location) => location.hash === '#/vue',})class LikeButton extends React.Component {constructor(props) {super(props);this.state = { liked: false };}render() {if (this.state.liked) {return 'You liked this.';}return React.createElement('button',{ onClick: () => this.setState({ liked: true }) },'Like');}}registerApplication({name: 'react',loadApp() {return Promise.resolve({bootstrap() {console.log('react bootstrap')},mount() {console.log('react mount')ReactDOM.render(React.createElement(LikeButton),$('#app'));},unmount() {console.log('react unmount')ReactDOM.unmountComponentAtNode($('#app'));},})},activeRule: (location) => location.hash === '#/react'})start()</script></body></html>
上面的代码我们使用了vue和react渲染了不同的组件,当切换路由的时候会渲染不同的组件。
直接在根目录下使用http-server进行访问:

上述代码参考:https://github.com/xiumubai/mini-single-spa/tree/v1
v2-支持子应用 HTML 入口
V1 版本的实现还是非常简陋的,能够适用的业务场景有限。从 V1 版本的示例可以看出,它要求子应用提前把资源都加载好(或者把整个子应用打包成一个 NPM 包,直接引入),这样才能在执行子应用的 mount() 方法时,能够正常渲染。
举个例子,假设我们在开发环境启动了一个 vue 应用。那么如何在主应用引入这个 vue 子应用的资源呢?首先排除掉 NPM 包的形式,因为每次修改代码都得打包,不现实。第二种方式就是手动在主应用引入子应用的资源。例如 vue 子应用的入口资源为:
我们可以在注册子应用的时候,把子应用的入口 URL 写上,由微前端来负责加载资源文件。
registerApplication({// 子应用入口 URLpageEntry: 'http://localhost:8081'// ...})
“自动”加载资源文件
现在我们来看一下如何自动加载子应用的入口文件(只在第一次加载子应用时执行):
export default function parseHTMLandLoadSources(app: Application) {return new Promise<void>(async (resolve, reject) => {const pageEntry = app.pageEntry// load htmlconst html = await loadSourceText(pageEntry)const domparser = new DOMParser()const doc = domparser.parseFromString(html, 'text/html')const { scripts, styles } = extractScriptsAndStyles(doc as unknown as Element, app)// 提取了 script style 后剩下的 body 部分的 html 内容app.pageBody = doc.body.innerHTMLlet isStylesDone = false, isScriptsDone = false// 加载 style script 的内容Promise.all(loadStyles(styles)).then(data => {isStylesDone = true// 将 style 样式添加到 document.head 标签addStyles(data as string[])if (isScriptsDone && isStylesDone) resolve()}).catch(err => reject(err))Promise.all(loadScripts(scripts)).then(data => {isScriptsDone = true// 执行 script 内容executeScripts(data as string[])if (isScriptsDone && isStylesDone) resolve()}).catch(err => reject(err))})}
上面代码的逻辑:
- 利用 ajax 请求子应用入口 URL 的内容,得到子应用的 HTML
 - 提取 HTML 中 script style 的内容或 URL,如果是 URL,则再次使用 ajax 拉取内容。最后得到入口页面所有的 script style 的内容
 - 将所有 style 添加到 document.head 下,script 代码直接执行
 - 将剩下的 body 部分的 HTML 内容赋值给子应用要挂载的 DOM 下。
 
一、拉取 HTML 内容
export function loadSourceText(url: string) {return new Promise<string>((resolve, reject) => {const xhr = new XMLHttpRequest()xhr.onload = (res: any) => {resolve(res.target.response)}xhr.onerror = rejectxhr.onabort = rejectxhr.open('get', url)xhr.send()})}
上面的代码就是使用 ajax 发起一个请求,得到 HTML 内容。
上图就是一个 vue 子应用的 HTML 内容,箭头所指的是要提取的资源,方框标记的内容要赋值给子应用所挂载的 DOM。
二、解析 HTML 并提取 style script 标签内容
这需要使用一个 API DOMParser,它可以直接解析一个 HTML 字符串,并且不需要挂到 document 对象上。
const domparser = new DOMParser()const doc = domparser.parseFromString(html, 'text/html')
提取标签的函数 extractScriptsAndStyles(node: Element, app: Application) 。这个函数主要的功能就是递归遍历上面生成的 DOM 树,提取里面所有的 style script 标签。
export const globalLoadedURLs: string[] = []function extractScriptsAndStyles(node: Element, app: Application) {if (!node.children.length) return { scripts: [], styles: [] }let styles: Source[] = []let scripts: Source[] = []for (const child of Array.from(node.children)) {const isGlobal = Boolean(child.getAttribute('global'))const tagName = child.tagNameif (tagName === 'STYLE') {removeNode(child)styles.push({isGlobal,value: child.textContent || '',})} else if (tagName === 'SCRIPT') {removeNode(child)const src = child.getAttribute('src') || ''if (app.loadedURLs.includes(src) || 1.includes(src)) {continue}const config: Source = {isGlobal,type: child.getAttribute('type'),value: child.textContent || '',}if (src) {config.url = srcif (isGlobal) {globalLoadedURLs.push(src)} else {app.loadedURLs.push(src)}}scripts.push(config)} else if (tagName === 'LINK') {removeNode(child)const href = child.getAttribute('href') || ''if (app.loadedURLs.includes(href) || globalLoadedURLs.includes(href)) {continue}if (child.getAttribute('rel') === 'stylesheet' && href) {styles.push({url: href,isGlobal,value: '',})if (isGlobal) {globalLoadedURLs.push(href)} else {app.loadedURLs.push(href)}}} else {const result = extractScriptsAndStyles(child, app)scripts = scripts.concat(result.scripts)styles = styles.concat(result.styles)}}return { scripts, styles }}
三、添加 style 标签,执行 script 脚本内容
这一步比较简单,将所有提取的 style 标签添加到 document.head 下:
export function addStyles(styles: string[] | HTMLStyleElement[]) {styles.forEach(item => {if (typeof item === 'string') {const node = createElement('style', {type: 'text/css',textContent: item,})head.appendChild(node)} else {head.appendChild(item)}})}
js 脚本代码则直接包在一个匿名函数内执行:
export function executeScripts(scripts: string[]) {try {scripts.forEach(code => {new Function('window', code).call(window, window)})} catch (error) {throw error}}
四、将剩下的 body 部分的 HTML 内容赋值给子应用要挂载的 DOM 下
为了保证子应用正常执行,需要将这部分的内容保存起来。然后每次在子应用 mount() 前,赋值到所挂载的 DOM 下。
// 保存 HTML 代码app.pageBody = doc.body.innerHTML// 加载子应用前赋值给挂载的 DOMapp.container.innerHTML = app.pageBodyapp.mount()
现在我们已经可以非常方便的加载子应用了,但是子应用还有一些东西需要修改一下。
子应用需要做的事情
在 V1 版本里,注册子应用的时候有一个 loadApp() 方法。微前端框架在第一次加载子应用时会执行这个方法,从而拿到子应用暴露的三个方法。现在实现了 pageEntry 功能,我们就不用把这个方法写在主应用里了,因为不再需要在主应用里引入子应用。
import { loadApps } from './application/apps'let isStarted = falseexport default function start() {if (!isStarted) {isStarted = truetry {loadApps()} catch (error) {throw error}}}export function isStart() {return isStarted}
但是又得让微前端框架拿到子应用暴露出来的方法,所以我们可以换一种方式暴露子应用的方法:
// 每个子应用都需要这样暴露三个 API,该属性格式为 `mini-single-spa-${appName}`window['mini-single-spa-vue'] = {bootstrap,mount,unmount}
这样微前端也能拿到每个子应用暴露的方法,从而实现加载、卸载子应用的功能。
另外,子应用还得做两件事:
- 配置 cors,防止出现跨域问题(由于主应用和子应用的域名不同,会出现跨域问题)
 - 配置资源发布路径
 
如果子应用是基于 webpack 进行开发的,可以这样配置:
module.exports = {devServer: {port: 8001, // 子应用访问端口headers: {'Access-Control-Allow-Origin': '*'}},publicPath: "//localhost:8001/",}
完整示例
registerApplication({name: 'vue',pageEntry: 'http://localhost:8001',activeRule: pathPrefix('/vue'),container: $('#subapp-viewport')})registerApplication({name: 'react',pageEntry: 'http://localhost:8002',activeRule:pathPrefix('/react'),container: $('#subapp-viewport')})start()

上述代码参考:https://github.com/xiumubai/mini-single-spa/tree/v2
v3-支持沙箱功能,子应用 window 作用域隔离、元素隔离
两件事:
- 隔离子应用 window 作用域
 - 隔离子应用元素作用域
 
隔离子应用 window 作用域
在 V2 版本下,主应用及所有的子应用都共用一个 window 对象,这就导致了互相覆盖数据的问题:
// 先加载 a 子应用window.name = 'a'// 后加载 b 子应用window.name = 'b'// 这时再切换回 a 子应用,读取 window.name 得到的值却是 bconsole.log(window.name) // b
new Proxy代理window对象
为了避免这种情况发生,我们可以使用 Proxy 来代理对子应用 window 对象的访问:
app.window = new Proxy({}, {get(target, key) {if (Reflect.has(target, key)) {return Reflect.get(target, key)}const result = originalWindow[key]// window 原生方法的 this 指向必须绑在 window 上运行,否则会报错 "TypeError: Illegal invocation"// e.g: const obj = {}; obj.alert = alert; obj.alert();return (isFunction(result) && needToBindOriginalWindow(result)) ? result.bind(window) : result},set: (target, key, value) => {this.injectKeySet.add(key)return Reflect.set(target, key, value)}})
从上述代码可以看出,用 Proxy 对一个空对象做了代理,然后把这个代理对象作为子应用的 window 对象:
- 当子应用里的代码访问 window.xxx 属性时,就会被这个代理对象拦截。它会先看看子应用的代理 window 对象有没有这个属性,如果找不到,就会从父应用里找,也就是在真正的 window 对象里找。
 - 当子应用里的代码修改 window 属性时,会直接在子应用的代理 window 对象上修改。
 
那么问题来了,怎么让子应用里的代码读取/修改 window 时候,让它们访问的是子应用的代理 window 对象?
刚才 V2 版本介绍过,微前端框架会代替子应用拉取 js 资源,然后直接执行。我们可以在执行代码的时候使用 with 语句将代码包一下,让子应用的 window 指向代理对象:
export function executeScripts(scripts: string[], app: Application) {try {scripts.forEach(code => {// 如果子应用提供了 loaderif (isFunction(app.loader)) {// @ts-ignorecode = app.loader(code)}// ts 使用 with 会报错,所以需要这样包一下// 将子应用的 js 代码全局 window 环境指向代理环境 proxyWindowconst warpCode = `;(function(proxyWindow){with (proxyWindow) {(function(window){${code}\n}).call(proxyWindow, proxyWindow)}})(this);`new Function(warpCode).call(app.sandbox.proxyWindow)})} catch (error) {throw error}}
卸载时清除子应用 window 作用域
当子应用卸载时,需要对它的 window 代理对象进行清除。否则下一次子应用重新加载时,它的 window 代理对象会存有上一次加载的数据。刚才创建 Proxy 的代码中有一行代码 this.injectKeySet.add(key),这个 injectKeySet 是一个 Set 对象,存着每一个 window 代理对象的新增属性。所以在卸载时只需要遍历这个 Set,将 window 代理对象上对应的 key 删除即可:
for (const key of injectKeySet) {Reflect.deleteProperty(microAppWindow, key as (string | symbol))}
记录绑定的全局事件、定时器,卸载时清除
通常情况下,一个子应用除了会修改 window 上的属性,还会在 window 上绑定一些全局事件。所以我们要把这些事件记录起来,在卸载子应用时清除这些事件。同理,各种定时器也一样,卸载时需要清除未执行的定时器。
下面的代码是记录事件、定时器的部分关键代码:
// 部分关键代码microAppWindow.setTimeout = function setTimeout(callback: Function, timeout?: number | undefined, ...args: any[]): number {const timer = originalWindow.setTimeout(callback, timeout, ...args)timeoutSet.add(timer)return timer}microAppWindow.clearTimeout = function clearTimeout(timer?: number): void {if (timer === undefined) returnoriginalWindow.clearTimeout(timer)timeoutSet.delete(timer)}microAppWindow.addEventListener = function addEventListener(type: string,listener: EventListenerOrEventListenerObject,options?: boolean | AddEventListenerOptions | undefined,) {if (!windowEventMap.get(type)) {windowEventMap.set(type, [])}windowEventMap.get(type)?.push({ listener, options })return originalWindowAddEventListener.call(originalWindow, type, listener, options)}microAppWindow.removeEventListener = function removeEventListener(type: string,listener: EventListenerOrEventListenerObject,options?: boolean | AddEventListenerOptions | undefined,) {const arr = windowEventMap.get(type) || []for (let i = 0, len = arr.length; i < len; i++) {if (arr[i].listener === listener) {arr.splice(i, 1)break}}return originalWindowRemoveEventListener.call(originalWindow, type, listener, options)}
下面这段是清除事件、定时器的关键代码:
for (const timer of timeoutSet) {originalWindow.clearTimeout(timer)}for (const [type, arr] of windowEventMap) {for (const item of arr) {originalWindowRemoveEventListener.call(originalWindow, type as string, item.listener, item.options)}}
缓存子应用快照
之前提到过子应用每次加载的时候会都执行 mount() 方法,由于每个 js 文件只会执行一次,所以在执行 mount() 方法之前的代码在下一次重新加载时不会再次执行。
举个例子:
window.name = 'test'function bootstrap() { // ... }function mount() { // ... }function unmount() { // ... }
上面是子应用入口文件的代码,在第一次执行 js 代码时,子应用可以读取 window.name 这个属性的值。但是子应用卸载时会把 name 这个属性清除掉。所以子应用下一次加载的时候,就读取不到这个属性了。
为了解决这个问题,我们可以在子应用初始化时(拉取了所有入口 js 文件并执行后)将当前的子应用 window 代理对象的属性、事件缓存起来,生成快照。下一次子应用重新加载时,将快照恢复回子应用上。
生成快照的部分代码:
const { windowSnapshot, microAppWindow } = thisconst recordAttrs = windowSnapshot.get('attrs')!const recordWindowEvents = windowSnapshot.get('windowEvents')!// 缓存 window 属性this.injectKeySet.forEach(key => {recordAttrs.set(key, deepCopy(microAppWindow[key]))})// 缓存 window 事件this.windowEventMap.forEach((arr, type) => {recordWindowEvents.set(type, deepCopy(arr))})
恢复快照的部分代码:
const {windowSnapshot,injectKeySet,microAppWindow,windowEventMap,onWindowEventMap,} = thisconst recordAttrs = windowSnapshot.get('attrs')!const recordWindowEvents = windowSnapshot.get('windowEvents')!recordAttrs.forEach((value, key) => {injectKeySet.add(key)microAppWindow[key] = deepCopy(value)})recordWindowEvents.forEach((arr, type) => {windowEventMap.set(type, deepCopy(arr))for (const item of arr) {originalWindowAddEventListener.call(originalWindow, type as string, item.listener, item.options)}})
隔离子应用元素作用域
我们在使用 document.querySelector() 或者其他查询 DOM 的 API 时,都会在整个页面的 document 对象上查询。如果在子应用上也这样查询,很有可能会查询到子应用范围外的 DOM 元素。为了解决这个问题,我们需要重写一下查询类的 DOM API:
// 将所有查询 dom 的范围限制在子应用挂载的 dom 容器上Document.prototype.querySelector = function querySelector(this: Document, selector: string) {const app = getCurrentApp()if (!app || !selector || isUniqueElement(selector)) {return originalQuerySelector.call(this, selector)}// 将查询范围限定在子应用挂载容器的 DOM 下return app.container.querySelector(selector)}Document.prototype.getElementById = function getElementById(id: string) {// ...}
将查询范围限定在子应用挂载容器的 DOM 下。另外,子应用卸载时也需要恢复重写的 API:
Document.prototype.querySelector = originalQuerySelectorDocument.prototype.querySelectorAll = originalQuerySelectorAll// ...
除了查询 DOM 要限制子应用的范围,样式也要限制范围。假设在 vue 应用上有这样一个样式:
body {color: red;}
当它作为一个子应用被加载时,这个样式需要被修改为:
/* body 被替换为子应用挂载 DOM 的 id 选择符 */#app {color: red;}
实现代码也比较简单,需要遍历每一条 css 规则,然后替换里面的 body、html 字符串:
const re = /^(\s|,)?(body|html)\b/g// 将 body html 标签替换为子应用挂载容器的 idcssText.replace(re, `#${app.container.id}`)
v4-支持子应用样式隔离
V3 版本实现了 window 作用域隔离、元素隔离,在 V4 版本上我们将实现子应用样式隔离。
子应用样式隔离
我们都知道创建 DOM 元素时使用的是 document.createElement() API,所以我们可以在创建 DOM 元素时,把当前子应用的名称当成属性写到 DOM 上:
Document.prototype.createElement = function createElement(tagName: string,options?: ElementCreationOptions,): HTMLElement {const appName = getCurrentAppName()const element = originalCreateElement.call(this, tagName, options)appName && element.setAttribute('single-spa-name', appName)return element}
这样所有的 style 标签在创建时都会有当前子应用的名称属性。我们可以在子应用卸载时将当前子应用所有的 style 标签进行移除,再次挂载时将这些标签重新添加到 document.head 下。这样就实现了不同子应用之间的样式隔离。
移除子应用所有 style 标签的代码:
export function removeStyles(name: string) {const styles = document.querySelectorAll(`style[single-spa-name=${name}]`)styles.forEach(style => {removeNode(style)})return styles as unknown as HTMLStyleElement[]}
基于以上代码的样式作用域隔离完成后,它只能对每次只加载一个子应用的场景有效。例如先加载 a 子应用,卸载后再加载 b 子应用这种场景。在卸载 a 子应用时会把它的样式也卸载。如果同时加载多个子应用,第一版的样式隔离就不起作用了。
由于每个子应用下的 DOM 元素都有以自己名称作为值的 single-spa-name 属性
所以我们可以给子应用的每个样式加上子应用名称,也就是将这样的样式:
div {color: red;}
改成
div[single-spa-name=vue] {color: red;}
这样一来,就把样式作用域范围限制在对应的子应用所挂载的 DOM 下。
给样式添加作用域范围
现在我们来看看具体要怎么添加作用域:
/*** 给每一条 css 选择符添加对应的子应用作用域* 1. a {} -> a[single-spa-name=${app.name}] {}* 2. a b c {} -> a[single-spa-name=${app.name}] b c {}* 3. a, b {} -> a[single-spa-name=${app.name}], b[single-spa-name=${app.name}] {}* 4. body {} -> #${子应用挂载容器的 id}[single-spa-name=${app.name}] {}* 5. @media @supports 特殊处理,其他规则直接返回 cssText*/
主要有以上五种情况。
通常情况下,每一条 css 选择符都是一个 css 规则,这可以通过 style.sheet.cssRules获取:
拿到了每一条 css 规则之后,我们就可以对它们进行重写,然后再把它们重写挂载到 document.head 下:
function handleCSSRules(cssRules: CSSRuleList, app: Application) {let result = ''Array.from(cssRules).forEach(cssRule => {const cssText = cssRule.cssTextconst selectorText = (cssRule as CSSStyleRule).selectorTextresult += cssRule.cssText.replace(selectorText,getNewSelectorText(selectorText, app),)})return result}let count = 0const re = /^(\s|,)?(body|html)\b/gfunction getNewSelectorText(selectorText: string, app: Application) {const arr = selectorText.split(',').map(text => {const items = text.trim().split(' ')items[0] = `${items[0]}[single-spa-name=${app.name}]`return items.join(' ')})// 如果子应用挂载的容器没有 id,则随机生成一个 idlet id = app.container.idif (!id) {id = 'single-spa-id-' + count++app.container.id = id}// 将 body html 标签替换为子应用挂载容器的 idreturn arr.join(',').replace(re, `#${id}`)}
核心代码在 getNewSelectorText()上,这个函数给每一个 css 规则都加上了 [single-spa-name=${app.name}]。这样就把样式作用域限制在了对应的子应用内了。
v5-支持各应用之间的数据通信
V5 版本主要添加了一个全局数据通信的功能,设计思路如下:
- 所有应用共享一个全局对象 window.spaGlobalState,所有应用都可以对这个全局对象进行监听,每当有应用对它进行修改时,会触发 change 事件。
 - 可以使用这个全局对象进行事件订阅/发布,各应用之间可以自由的收发事件。
 
下面是实现了第一点要求的部分关键代码:
export default class GlobalState extends EventBus {private state: AnyObject = {}private stateChangeCallbacksMap: Map<string, Array<Callback>> = new Map()set(key: string, value: any) {this.state[key] = valuethis.emitChange('set', key)}get(key: string) {return this.state[key]}onChange(callback: Callback) {const appName = getCurrentAppName()if (!appName) returnconst { stateChangeCallbacksMap } = thisif (!stateChangeCallbacksMap.get(appName)) {stateChangeCallbacksMap.set(appName, [])}stateChangeCallbacksMap.get(appName)?.push(callback)}emitChange(operator: string, key?: string) {this.stateChangeCallbacksMap.forEach((callbacks, appName) => {/*** 如果是点击其他子应用或父应用触发全局数据变更,则当前打开的子应用获取到的 app 为 null* 所以需要改成用 activeRule 来判断当前子应用是否运行*/const app = getApp(appName) as Applicationif (!(isActive(app) && app.status === AppStatus.MOUNTED)) returncallbacks.forEach(callback => callback(this.state, operator, key))})}}
下面是实现了第二点要求的部分关键代码:
export default class EventBus {private eventsMap: Map<string, Record<string, Array<Callback>>> = new Map()on(event: string, callback: Callback) {if (!isFunction(callback)) {throw Error(`The second param ${typeof callback} is not a function`)}const appName = getCurrentAppName() || 'parent'const { eventsMap } = thisif (!eventsMap.get(appName)) {eventsMap.set(appName, {})}const events = eventsMap.get(appName)!if (!events[event]) {events[event] = []}events[event].push(callback)}emit(event: string, ...args: any) {this.eventsMap.forEach((events, appName) => {/*** 如果是点击其他子应用或父应用触发全局数据变更,则当前打开的子应用获取到的 app 为 null* 所以需要改成用 activeRule 来判断当前子应用是否运行*/const app = getApp(appName) as Applicationif (appName === 'parent' || (isActive(app) && app.status === AppStatus.MOUNTED)) {if (events[event]?.length) {for (const callback of events[event]) {callback.call(this, ...args)}}}})}}
以上两段代码都有一个相同的地方,就是在保存监听回调函数的时候需要和对应的子应用关联起来。当某个子应用卸载时,需要把它关联的回调函数也清除掉。
监听数据修改
// 父应用window.spaGlobalState.set('msg', '父应用在 spa 全局状态上新增了一个 msg 属性')// 子应用window.spaGlobalState.onChange((state, operator, key) => {alert(`vue 子应用监听到 spa 全局状态发生了变化: ${JSON.stringify(state)},操作: ${operator},变化的属性: ${key}`)})

监听事件触发
// 父应用window.spaGlobalState.on('vue', () => alert('父应用监听到 vue 子应用发送了一个全局事件: vue'))// 子应用window.spaGlobalState.emit('vue')
总结
至此,一个简易微前端框架的技术要点已经讲解完毕。强烈建议大家在看文档的同时,把 demo 运行起来跑一跑,这样能帮助你更好的理解代码。
回顾一下我们实现的功能点:
:::tips
