隔离子应用元素作用域
我们在使用 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 = originalQuerySelector Document.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 标签替换为子应用挂载容器的 id cssText.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.cssText const selectorText = (cssRule as CSSStyleRule).selectorText result += cssRule.cssText.replace( selectorText, getNewSelectorText(selectorText, app), ) }) return result } let count = 0 const re = /^(\s|,)?(body|html)\b/g function 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,则随机生成一个 id let id = app.container.id if (!id) { id = 'single-spa-id-' + count++ app.container.id = id } // 将 body html 标签替换为子应用挂载容器的 id return arr.join(',').replace(re, `#${id}`) }
核心代码在 getNewSelectorText()
上,这个函数给每一个 css 规则都加上了 [single-spa-name=${app.name}]
。这样就把样式作用域限制在了对应的子应用内了。
效果演示
大家可以对比一下下面的两张图,这个示例同时加载了 vue、react 两个子应用。第一张图里的 vue 子应用部分字体被 react 子应用的样式影响了。第二张图是添加了样式作用域隔离的效果图,可以看到 vue 子应用的样式是正常的,没有被影响。
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] = value this.emitChange('set', key) } get(key: string) { return this.state[key] } onChange(callback: Callback) { const appName = getCurrentAppName() if (!appName) return const { stateChangeCallbacksMap } = this if (!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 Application if (!(isActive(app) && app.status === AppStatus.MOUNTED)) return callbacks.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 } = this if (!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 Application if (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.emit('testEvent', '父应用发送了一个全局事件: testEvent') // 子应用 window.spaGlobalState.on('testEvent', () => alert('vue 子应用监听到父应用发送了一个全局事件: testEvent'))
总结
至此,一个简易微前端框架的技术要点已经讲解完毕。强烈建议大家在看文档的同时,把 demo 运行起来跑一跑,这样能帮助你更好的理解代码。
如果你觉得我的文章写得不错,也可以看看我的其他一些技术文章或项目: