乐趣区

关于javascript:手把手教你写一个简易的微前端框架

最近看了几个微前端框架的源码(single-spa、qiankun、micro-app),感觉播种良多。所以打算造一个迷你版的轮子,来加深本人对所学常识的理解。

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

  1. 反对不同框架的子利用(v1 分支)
  2. 反对子利用 HTML 入口(v2 分支)
  3. 反对沙箱性能,子利用 window 作用域隔离、元素隔离(v3 分支)
  4. 反对子利用款式隔离(v4 分支)
  5. 反对各利用之间的数据通信(main 分支)

每一个版本的代码都是在上一个版本的根底上批改的,所以 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()
  2. 重写 window.history.replaceState()
  3. 监听 popstate 事件
  4. 监听 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)
}

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

  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,即卸载后的利用可再次加载。

当初咱们来看看什么时候会加载一个子利用,当页面 URL 扭转后,如果子利用满足以下两个条件,则须要加载该子利用:

  1. activeRule() 的返回值为 true,例如 URL 从 / 变为 /vue,这时子利用 vue 为激活状态(假如它的激活规定为 /vue)。
  2. 子利用状态必须为 bootstrapunmount,这样能力向 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()

演示成果如下:

小结

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

下面代码的逻辑:

  1. 利用 ajax 申请子利用入口 URL 的内容,失去子利用的 HTML
  2. 提取 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 内容。

上图就是一个 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
}

这样微前端也能拿到每个子利用裸露的办法,从而实现加载、卸载子利用的性能。

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

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

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 来代理对子利用 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 语句将代码包一下,让子利用的 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 规定,而后替换外面的 bodyhtml 字符串:

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 版本次要增加了一个全局数据通信的性能,设计思路如下:

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

全局事件示例代码

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

总结

至此,一个繁难微前端框架的技术要点曾经解说结束。强烈建议大家在看文档的同时,把 demo 运行起来跑一跑,这样能帮忙你更好的了解代码。

如果你感觉我的文章写得不错,也能够看看我的其余一些技术文章或我的项目:

  • 带你入门前端工程
  • 可视化拖拽组件库一些技术要点原理剖析
  • 前端性能优化 24 条倡议(2020)
  • 前端监控 SDK 的一些技术要点原理剖析
  • 手把手教你写一个脚手架
  • 计算机系统因素 - 从零开始构建古代计算机
退出移动版