快捷搜索:  汽车  科技

微前端框架开源(手把手教你写一个简易的微前端框架)

微前端框架开源(手把手教你写一个简易的微前端框架)// 当 location.pathname 以 /vue 为前缀时切换到 vue 子应用 https://www.example.com/vue/xxx // 当 location.pathname 以 /react 为前缀时切换到 React 子应用 https://www.example.com/react/xxx这可以通过重写两个 API 和监听两个事件来完成:一个 SPA 应用必不可少的功能就是监听页面 URL 的变化,然后根据不同的路由规则来渲染不同的路由组件。因此,微前端框架也可以根据页面 URL 的变化,来切换到不同的子应用:Github 项目地址:https://github.com/woai3c/mini-single-spaV1 版本打算实现一个最简单的微前端框架,只要它能够正常加载、卸载子应用就行。如果将 V1 版本细分一下的话,它主要由以下两个功能组成:1.监听页

最近看了几个微前端框架的源码(single-spa[1]、qiankun[2]、micro-app[3]),感觉收获良多。所以打算造一个迷你版的轮子,来加深自己对所学知识的了解。

这个轮子将分为五个版本,逐步的实现一个最小可用的微前端框架:

1.支持不同框架的子应用(v1[4] 分支)2.支持子应用 HTML 入口(v2[5] 分支)3.支持沙箱功能,子应用 window 作用域隔离、元素隔离(v3[6] 分支)4.支持子应用样式隔离(v4[7] 分支)5.支持各应用之间的数据通信(main[8] 分支)

每一个版本的代码都是在上一个版本的基础上修改的,所以 V5 版本的代码是最终代码。

Github 项目地址:https://github.com/woai3c/mini-single-spa

V1 版本

V1 版本打算实现一个最简单的微前端框架,只要它能够正常加载、卸载子应用就行。如果将 V1 版本细分一下的话,它主要由以下两个功能组成:

1.监听页面 URL 变化,切换子应用2.根据当前 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 和监听两个事件来完成:

1.重写 window.history.pushState()[9]2.重写 window.history.replaceState()[10]3.监听 popstate[11] 事件4.监听 hashchange[12] 事件

其中 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) }

这段代码的逻辑也比较简单:

1.卸载所有已失活的子应用2.初始化所有刚注册的子应用3.加载所有符合条件的子应用

根据当前 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,即卸载后的应用可再次加载。

微前端框架开源(手把手教你写一个简易的微前端框架)(1)

现在我们来看看什么时候会加载一个子应用,当页面 URL 改变后,如果子应用满足以下两个条件,则需要加载该子应用:

1.activeRule() 的返回值为 true,例如 URL 从 / 变为 /vue,这时子应用 vue 为激活状态(假设它的激活规则为 /vue)。2.子应用状态必须为 bootstrap 或 unmount,这样才能向 mount 状态转换。如果已经处于 mount 状态并且 activeRule() 返回值为 true,则不作任何处理。

如果页面的 URL 改变后,子应用满足以下两个条件,则需要卸载该子应用:

1.activeRule() 的返回值为 false,例如 URL 从 /vue 变为 /,这时子应用 vue 为失活状态(假设它的激活规则为 /vue)。2.子应用状态必须为 mount,也就是当前子应用必须处于加载状态(如果是其他状态,则不作任何处理)。然后 URL 改变导致失活了,所以需要卸载它,状态也从 mount 变为 unmount。

API 介绍

V1 版本主要向外暴露了两个 API:

1.registerApplication(),注册子应用。2.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()

演示效果如下:

微前端框架开源(手把手教你写一个简易的微前端框架)(2)

小结

V1 版本的代码打包后才 100 多行,如果只是想了解微前端的最核心原理,只看 V1 版本的源码就可以了。

V2 版本

V1 版本的实现还是非常简陋的,能够适用的业务场景有限。从 V1 版本的示例可以看出,它要求子应用提前把资源都加载好(或者把整个子应用打包成一个 NPM 包,直接引入),这样才能在执行子应用的 mount() 方法时,能够正常渲染。

举个例子,假设我们在开发环境启动了一个 vue 应用。那么如何在主应用引入这个 vue 子应用的资源呢?首先排除掉 NPM 包的形式,因为每次修改代码都得打包,不现实。第二种方式就是手动在主应用引入子应用的资源。例如 vue 子应用的入口资源为:

微前端框架开源(手把手教你写一个简易的微前端框架)(3)

那么我们可以在注册子应用时这样引入:

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)) }) }

上面代码的逻辑:

1.利用 ajax 请求子应用入口 URL 的内容,得到子应用的 HTML2.提取 HTML 中 script style 的内容或 URL,如果是 URL,则再次使用 ajax 拉取内容。最后得到入口页面所有的 script style 的内容3.将所有 style 添加到 document.head 下,script 代码直接执行4.将剩下的 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 内容。

微前端框架开源(手把手教你写一个简易的微前端框架)(4)

上图就是一个 vue 子应用的 HTML 内容,箭头所指的是要提取的资源,方框标记的内容要赋值给子应用所挂载的 DOM。

二、解析 HTML 并提取 style script 标签内容

这需要使用一个 API DOMParser[13],它可以直接解析一个 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 }

这样微前端也能拿到每个子应用暴露的方法,从而实现加载、卸载子应用的功能。

另外,子应用还得做两件事:

1.配置 cors,防止出现跨域问题(由于主应用和子应用的域名不同,会出现跨域问题)2.配置资源发布路径

如果子应用是基于 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()

微前端框架开源(手把手教你写一个简易的微前端框架)(5)

V3 版本

V3 版本主要添加以下两个功能:

1.隔离子应用 window 作用域2.隔离子应用元素作用域

隔离子应用 window 作用域

在 V2 版本下,主应用及所有的子应用都共用一个 window 对象,这就导致了互相覆盖数据的问题:

// 先加载 a 子应用 window.name = 'a' // 后加载 b 子应用 window.name = 'b' // 这时再切换回 a 子应用,读取 window.name 得到的值却是 b console.log(window.name) // b

为了避免这种情况发生,我们可以使用 Proxy[14] 来代理对子应用 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 对象:

1.当子应用里的代码访问 window.xxx 属性时,就会被这个代理对象拦截。它会先看看子应用的代理 window 对象有没有这个属性,如果找不到,就会从父应用里找,也就是在真正的 window 对象里找。2.当子应用里的代码修改 window 属性时,会直接在子应用的代理 window 对象上修改。

那么问题来了,怎么让子应用里的代码读取/修改 window 时候,让它们访问的是子应用的代理 window 对象?

刚才 V2 版本介绍过,微前端框架会代替子应用拉取 js 资源,然后直接执行。我们可以在执行代码的时候使用 with[15] 语句将代码包一下,让子应用的 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 属性(如果不知道这个名称是哪来的,请往上翻一下第一版的描述)。

微前端框架开源(手把手教你写一个简易的微前端框架)(6)

所以我们可以给子应用的每个样式加上子应用名称,也就是将这样的样式:

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 获取:

微前端框架开源(手把手教你写一个简易的微前端框架)(7)

拿到了每一条 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 子应用的样式是正常的,没有被影响。

微前端框架开源(手把手教你写一个简易的微前端框架)(8)

微前端框架开源(手把手教你写一个简易的微前端框架)(9)

V5 版本

V5 版本主要添加了一个全局数据通信的功能,设计思路如下:

1.所有应用共享一个全局对象 window.spaGlobalState,所有应用都可以对这个全局对象进行监听,每当有应用对它进行修改时,会触发 change 事件。2.可以使用这个全局对象进行事件订阅/发布,各应用之间可以自由的收发事件。

下面是实现了第一点要求的部分关键代码:

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}`) })

微前端框架开源(手把手教你写一个简易的微前端框架)(10)

全局事件示例代码

// 父应用 window.spaGlobalState.emit('testEvent' '父应用发送了一个全局事件: testEvent') // 子应用 window.spaGlobalState.on('testEvent' () => alert('vue 子应用监听到父应用发送了一个全局事件: testEvent'))

微前端框架开源(手把手教你写一个简易的微前端框架)(11)

总结

至此,一个简易微前端框架的技术要点已经讲解完毕。强烈建议大家在看文档的同时,把 demo 运行起来跑一跑,这样能帮助你更好的理解代码。

如果你觉得我的文章写得不错,也可以看看我的其他一些技术文章或项目:

•带你入门前端工程[16]•可视化拖拽组件库一些技术要点原理分析[17]•前端性能优化 24 条建议(2020)[18]•前端监控 SDK 的一些技术要点原理分析[19]•手把手教你写一个脚手架 [20]•计算机系统要素-从零开始构建现代计算机[21]

References

[1] single-spa: https://github.com/single-spa/single-spa
[2] qiankun: https://github.com/umijs/qiankun
[3] micro-app: https://github.com/micro-zoe/micro-app
[4] v1: https://github.com/woai3c/mini-single-spa/tree/v1
[5] v2: https://github.com/woai3c/mini-single-spa/tree/v2
[6] v3: https://github.com/woai3c/mini-single-spa/tree/v3
[7] v4: https://github.com/woai3c/mini-single-spa/tree/v4
[8] main: https://github.com/woai3c/mini-single-spa
[9] window.history.pushState(): https://developer.mozilla.org/zh-CN/docs/Web/API/History/pushState
[10] window.history.replaceState(): https://developer.mozilla.org/zh-CN/docs/Web/API/History/replaceState
[11] popstate: https://developer.mozilla.org/zh-CN/docs/Web/API/Window/popstate_event
[12] hashchange: https://developer.mozilla.org/zh-CN/docs/Web/API/Window/hashchange_event
[13] DOMParser: https://developer.mozilla.org/zh-CN/docs/Web/API/DOMParser
[14] Proxy: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
[15] with: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/with
[16] 带你入门前端工程: https://woai3c.gitee.io/introduction-to-front-end-engineering/
[17] 可视化拖拽组件库一些技术要点原理分析: https://github.com/woai3c/Front-end-articles/issues/19
[18] 前端性能优化 24 条建议(2020): https://github.com/woai3c/Front-end-articles/blob/master/performance.md
[19] 前端监控 SDK 的一些技术要点原理分析: https://github.com/woai3c/Front-end-articles/issues/26
[20] 手把手教你写一个脚手架 : https://github.com/woai3c/Front-end-articles/issues/22
[21] 计算机系统要素-从零开始构建现代计算机: https://github.com/woai3c/nand2tetris

猜您喜欢: