共计 18753 个字符,预计需要花费 47 分钟才能阅读完成。
最近看了几个微前端框架的源码(single-spa、qiankun、micro-app),感觉播种良多。所以打算造一个迷你版的轮子,来加深本人对所学常识的理解。
这个轮子将分为五个版本,逐渐的实现一个最小可用的微前端框架:
- 反对不同框架的子利用(v1 分支)
- 反对子利用 HTML 入口(v2 分支)
- 反对沙箱性能,子利用 window 作用域隔离、元素隔离(v3 分支)
- 反对子利用款式隔离(v4 分支)
- 反对各利用之间的数据通信(main 分支)
每一个版本的代码都是在上一个版本的根底上批改的,所以 V5 版本的代码是最终代码。
Github 我的项目地址:https://github.com/woai3c/mini-single-spa
V1 版本
V1 版本打算实现一个最简略的微前端框架,只有它可能失常加载、卸载子利用就行。如果将 V1 版本细分一下的话,它次要由以下两个性能组成:
- 监听页面 URL 变动,切换子利用
- 依据以后 URL、子利用的触发规定来判断是否要加载、卸载子利用
监听页面 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/vue
window.history.pushState(null, '','/vue')
当用户手动点击浏览器上的后退后退按钮时,会触发 popstate
事件,所以须要对这个事件进行监听。同理,也须要监听 hashchange
事件。
这一段逻辑的代码如下所示:
import {loadApps} from '../application/apps'
const originalPushState = window.history.pushState
const originalReplaceState = window.history.replaceState
export default function overwriteEventsAndHistory() {window.history.pushState = function (state: any, title: string, url: string) {const result = originalPushState.call(this, state, title, url)
// 依据以后 url 加载或卸载 app
loadApps()
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()
办法。
registerApplication({
name: 'vue',
// 初始化子利用时执行该办法
loadApp() {
return {mount() {
// 这里进行挂载子利用的操作
app.mount('#app')
},
unmount() {
// 这里进行卸载子利用的操作
app.unmount()},
}
},
// 如果传入一个字符串会被转为一个参数为 location 的函数
// activeRule: '/vue' 会被转为 (location) => location.pathname === '/vue'
activeRule: (location) => location.hash === '#/vue'
})
下面是一个简略的子利用注册示例,其中 activeRule()
办法用来判断该子利用是否激活(返回 true
示意激活)。每当页面 URL 发生变化,微前端框架就会调用 loadApps()
判断每个子利用是否激活,而后触发加载、卸载子利用的操作。
何时加载、卸载子利用
首先咱们将子利用的状态分为三种:
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 介绍
V1 版本次要向外裸露了两个 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>}
一个残缺的示例
当初咱们来看一个比拟残缺的示例(代码在 V1 分支的 examples 目录):
let vueApp
registerApplication({
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',
})
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()
演示成果如下:
小结
V1 版本的代码打包后才 100 多行,如果只是想理解微前端的最外围原理,只看 V1 版本的源码就能够了。
V2 版本
V1 版本的实现还是十分简陋的,可能实用的业务场景无限。从 V1 版本的示例能够看出,它要求子利用提前把资源都加载好(或者把整个子利用打包成一个 NPM 包,间接引入),这样能力在执行子利用的 mount()
办法时,可能失常渲染。
举个例子,假如咱们在开发环境启动了一个 vue 利用。那么如何在主利用引入这个 vue 子利用的资源呢?首先排除掉 NPM 包的模式,因为每次批改代码都得打包,不事实。第二种形式就是手动在主利用引入子利用的资源。例如 vue 子利用的入口资源为:
那么咱们能够在注册子利用时这样引入:
registerApplication({
name: 'vue',
loadApp() {
return Promise.resolve({bootstrap() {import('http://localhost:8001/js/chunk-vendors.js')
import('http://localhost:8001/js/app.js')
},
mount() {// ...},
unmount() {// ...},
})
},
activeRule: (location) => location.hash === '#/vue'
})
这种形式也不靠谱,每次子利用的入口资源文件变了,主利用的代码也得跟着变。还好,咱们有第三种形式,那就是在注册子利用的时候,把子利用的入口 URL 写上,由微前端来负责加载资源文件。
registerApplication({
// 子利用入口 URL
pageEntry: 'http://localhost:8081'
// ...
})
“主动”加载资源文件
当初咱们来看一下如何主动加载子利用的入口文件(只在第一次加载子利用时执行):
export default function parseHTMLandLoadSources(app: Application) {return new Promise<void>(async (resolve, reject) => {
const pageEntry = app.pageEntry
// load html
const 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.innerHTML
let 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 = reject
xhr.onabort = reject
xhr.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
标签。
三、增加 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
// 加载子利用前赋值给挂载的 DOM
app.container.innerHTML = app.pageBody
app.mount()
当初咱们曾经能够十分不便的加载子利用了,然而子利用还有一些货色须要批改一下。
子利用须要做的事件
在 V1 版本里,注册子利用的时候有一个 loadApp()
办法。微前端框架在第一次加载子利用时会执行这个办法,从而拿到子利用裸露的三个办法。当初实现了 pageEntry
性能,咱们就不必把这个办法写在主利用里了,因为不再须要在主利用里引入子利用。
然而又得让微前端框架拿到子利用裸露进去的办法,所以咱们能够换一种形式裸露子利用的办法:
// 每个子利用都须要这样裸露三个 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/",
}
一个残缺的示例
示例代码在 examples 目录。
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()
V3 版本
V3 版本次要增加以下两个性能:
- 隔离子利用 window 作用域
- 隔离子利用元素作用域
隔离子利用 window 作用域
在 V2 版本下,主利用及所有的子利用都共用一个 window 对象,这就导致了相互笼罩数据的问题:
// 先加载 a 子利用
window.name = 'a'
// 后加载 b 子利用
window.name = 'b'
// 这时再切换回 a 子利用,读取 window.name 失去的值却是 b
console.log(window.name) // b
为了防止这种状况产生,咱们能够应用 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 => {
// ts 应用 with 会报错,所以须要这样包一下
// 将子利用的 js 代码全局 window 环境指向代理环境 proxyWindow
const 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) return
originalWindow.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} = this
const 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,
} = this
const 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 = 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 运行起来跑一跑,这样能帮忙你更好的了解代码。
如果你感觉我的文章写得不错,也能够看看我的其余一些技术文章或我的项目:
- 带你入门前端工程
- 可视化拖拽组件库一些技术要点原理剖析
- 前端性能优化 24 条倡议(2020)
- 前端监控 SDK 的一些技术要点原理剖析
- 手把手教你写一个脚手架
- 计算机系统因素 - 从零开始构建古代计算机