29

I recently read the source code of several micro-frontend frameworks ( single-spa , qiankun , micro-app ), and I feel that I have gained a lot. So I plan to build a mini version of the wheel to deepen my understanding of what I have learned.

This wheel will be divided into five versions, gradually implementing a minimal usable micro-frontend framework:

  1. Support sub-apps for different frameworks ( v1 branch)
  2. Support for sub-app HTML entry ( v2 branch)
  3. Support sandbox function, sub-application window scope isolation, element isolation ( v3 branch)
  4. Support for sub-app style isolation ( v4 branch)
  5. Support data communication between applications ( main branch)

The code of each version is modified on the basis of the previous version, so the code of the V5 version is the final code.

Github project address: https://github.com/woai3c/mini-single-spa

V1 version

The V1 version intends to implement the simplest micro-frontend framework, as long as it can load and unload sub-applications normally. If the V1 version is subdivided, it mainly consists of the following two functions:

  1. Monitor page URL changes and switch sub-applications
  2. Determine whether to load or unload the sub-application according to the current URL and the triggering rules of the sub-application

Monitor page URL changes and switch sub-applications

An essential function of a SPA application is to monitor changes in page URLs, and then render different routing components according to different routing rules. Therefore, the micro-frontend framework can also switch to different sub-applications according to the change of the page URL:

// 当 location.pathname 以 /vue 为前缀时切换到 vue 子应用
https://www.example.com/vue/xxx
// 当 location.pathname 以 /react 为前缀时切换到 react 子应用
https://www.example.com/react/xxx

This can be done by overriding two APIs and listening to two events:

  1. Override window.history.pushState()
  2. Override window.history.replaceState()
  3. Listen popstate event
  4. Listen hashchange event

The pushState() and replaceState() methods can modify the browser's history stack, so we can rewrite these two APIs. When these two APIs are called by the SPA application, it means that the URL has changed. At this time, it can be judged whether to load or unload the sub-application according to the currently changed URL.

// 执行下面代码后,浏览器的 URL 将从 https://www.xxx.com 变为 https://www.xxx.com/vue
window.history.pushState(null, '', '/vue')

When the user manually clicks the forward and back buttons on the browser, the popstate event will be triggered, so this event needs to be monitored. Similarly, you also need to monitor hashchange event.

The code for this piece of logic is as follows:

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

As can be seen from the above code, every time the URL changes, the loadApps() method will be called. The function of this method is to switch the status of the sub-application according to the current URL and the triggering rules of the sub-application:

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

The logic of this code is also relatively simple:

  1. Uninstall all deactivated sub-apps
  2. Initialize all newly registered sub-applications
  3. Load all eligible sub-apps

    Determine whether to load or unload the sub-application according to the current URL and the triggering rules of the sub-application

    In order to support sub-applications of different frameworks, it is stipulated that sub-applications must expose three methods: bootstrap() mount() unmount() . bootstrap() method is triggered when the sub-app is loaded for the first time, and will only be triggered once, and the other two methods are triggered every time the sub-app is loaded and unloaded.

No matter what sub-application is registered, when the URL meets the loading conditions, the sub-application's mount() method is called, and the sub-application is responsible for whether it can render normally. When the uninstall condition is met, the unmount() method of the sub-application is called.

registerApplication({
    name: 'vue',
    // 初始化子应用时执行该方法
    loadApp() { 
        return {
            mount() {                
                // 这里进行挂载子应用的操作
                app.mount('#app')
            },
            unmount() {
                // 这里进行卸载子应用的操作 
                app.unmount()
            },
        }
    },
    // 如果传入一个字符串会被转为一个参数为 location 的函数
    // activeRule: '/vue' 会被转为 (location) => location.pathname === '/vue'
    activeRule: (location) => location.hash === '#/vue'
})

The above is a simple sub-application registration example, in which the activeRule() method is used to determine whether the sub-application is activated (return true to indicate activation). Whenever the page URL changes, the micro-frontend framework will call loadApps() determine whether each sub-application is activated, and then trigger the operation of loading and unloading the sub-application.

When to load and unload sub-apps

First, we divide the status of the sub-application into three types:

  • bootstrap , after calling registerApplication() register a child application, its state defaults to bootstrap , and the next transition state is mount .
  • mount , the state after the sub-application is successfully mounted, its next transition state is unmount .
  • unmount , the state after the sub-app is uninstalled successfully, its next transition state is mount , that is, the uninstalled app can be loaded again.

在这里插入图片描述

Now let's see when a sub-application will be loaded. When the page URL changes, the sub-application needs to be loaded if the following two conditions are met:

  1. The return value of activeRule() is true . For example, the URL changes from / to /vue . At this time, the sub-application vue is activated (assuming its activation rule is /vue ).
  2. The sub-app state must be bootstrap or unmount in order to transition to the mount state. If already in mount state and activeRule() return value is true , do nothing.

If the sub-application meets the following two conditions after the URL of the page is changed, the sub-application needs to be uninstalled:

  1. The return value of activeRule() is false . For example, the URL changes from /vue to / . At this time, the sub-application vue is inactive (assuming its activation rule is /vue ).
  2. The sub-application state must be mount , that is, the current sub-application must be in the loading state (if it is in other states, nothing will be done). Then the URL changed and deactivated it, so it needed to be uninstalled and the status changed from mount to unmount .

API introduction

The V1 version mainly exposes two APIs:

  1. registerApplication() , register the sub-app.
  2. start() is called after all sub-applications are registered, and loadApps() will be executed internally to load the sub-application.

The parameters received by registerApplication(Application) are as follows:

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

a complete example

Now let's look at a more complete example (the code is in the examples directory of the V1 branch):

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

The demonstration effect is as follows:
请添加图片描述

summary

The code of the V1 version is only more than 100 lines after being packaged. If you just want to understand the core principles of the micro-frontend, you can just look at the source code of the V1 version.

V2 version

The implementation of the V1 version is still very simple, and the applicable business scenarios are limited. As can be seen from the example of the V1 version, it requires the sub-application to load all the resources in advance (or package the entire sub-application into an NPM package and import it directly), so that the sub-application can render normally when the mount() method of the sub-application is executed.

For example, let's say we start a vue application in the development environment. So how to introduce the resources of this vue sub-application into the main application? First exclude the form of NPM package, because every time you modify the code, you have to package it, which is unrealistic. The second way is to manually introduce the resources of the sub-application into the main application. For example, the entry resource of the vue sub-application is:

在这里插入图片描述
Then we can introduce this when registering the sub-application:

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

This method is also unreliable. Every time the entry resource file of the sub-application changes, the code of the main application must also change. Fortunately, we have a third way, that is, when registering the sub-application, write the entry URL of the sub-application, and the micro-frontend is responsible for loading the resource file.

registerApplication({
    // 子应用入口 URL
    pageEntry: 'http://localhost:8081'
    // ...
})

"Automatically" load resource files

Now let's see how to automatically load the entry file of the sub-application (only the first time the sub-application is loaded):

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

The logic of the above code:

  1. Use ajax to request the content of the entry URL of the sub-application to get the HTML of the sub-application
  2. Extract the content or URL of script style in HTML, if it is a URL, use ajax to pull the content again. Finally get all the contents of script style on the entry page
  3. Add all styles to document.head , script code is executed directly
  4. Assign the HTML content of the remaining body part to the DOM to be mounted by the sub-application.

The following describes in detail how these four steps are done.

1. Pull HTML content

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

The code logic is very simple, use ajax to initiate a request and get the HTML content.
在这里插入图片描述
The above picture is the HTML content of a vue sub-application. The arrow points to the resource to be extracted, and the content of the box mark is assigned to the DOM mounted by the sub-application.

2. Parse HTML and extract the content of the style script tag

This requires the use of an API DOMParser , which parses an HTML string directly and does not need to hook into the document object.

const domparser = new DOMParser()
const doc = domparser.parseFromString(html, 'text/html')

The function extractScriptsAndStyles(node: Element, app: Application) for extracting tags has a lot of code, so I won't post the code here. The main function of this function is to recursively traverse the DOM tree generated above and extract all the style script tags in it.

3. Add the style tag and execute the script script content

This step is relatively simple, add all the extracted style tags to 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)
        }
    })
}

The js script code is directly wrapped in an anonymous function for execution:

export function executeScripts(scripts: string[]) {
    try {
        scripts.forEach(code => {
            new Function('window', code).call(window, window)
        })
    } catch (error) {
        throw error
    }
}

Fourth, assign the HTML content of the remaining body part to the DOM to be mounted by the sub-application

In order to ensure the normal execution of the sub-application, the content of this part needs to be saved. Then each time before the sub-application mount() , assign it to the mounted DOM.

// 保存 HTML 代码
app.pageBody = doc.body.innerHTML

// 加载子应用前赋值给挂载的 DOM
app.container.innerHTML = app.pageBody
app.mount()

Now we can easily load sub-applications, but there are still some things that need to be modified in sub-applications.

What the sub-app needs to do

In the V1 version, there is a loadApp() method when registering sub-applications. The micro-frontend framework will execute this method when the sub-application is loaded for the first time, so as to obtain the three methods exposed by the sub-application. Now that the pageEntry function is implemented, we don't need to write this method in the main application, because it is no longer necessary to introduce sub-applications in the main application.

But we have to let the micro-frontend framework get the method exposed by the sub-application, so we can expose the method of the sub-application in another way:

// 每个子应用都需要这样暴露三个 API,该属性格式为 `mini-single-spa-${appName}`
window['mini-single-spa-vue'] = {
    bootstrap,
    mount,
    unmount
}

In this way, the micro-frontend can also get the methods exposed by each sub-application, so as to realize the function of loading and unloading the sub-application.

In addition, the sub-app has to do two things:

  1. Configure cors to prevent cross-domain problems (due to the different domain names of the main application and sub-applications, cross-domain problems will occur)
  2. Configure the resource publishing path

If the sub-application is developed based on webpack, it can be configured like this:

module.exports = {
    devServer: {
        port: 8001, // 子应用访问端口
        headers: {
            'Access-Control-Allow-Origin': '*'
        }
    },
    publicPath: "//localhost:8001/",
}

a complete example

The sample code is in the examples directory.

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 version

The V3 version mainly adds the following two functions:

  1. Isolate child application window scope
  2. Isolate child app element scope

Isolate child application window scope

Under the V2 version, the main application and all sub-applications share a window object, which leads to the problem of overlapping data with each other:

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

To avoid this, we can use Proxy to proxy access to the child application's window object:

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

As can be seen from the above code, a proxy is used to proxy an empty object, and then the proxy object is used as the window object of the sub-application:

  1. When the code in the sub-application accesses the window.xxx property, it will be intercepted by this proxy object. It will first check whether the proxy window object of the child application has this property. If it cannot find it, it will look for it from the parent application, that is, in the real window object.
  2. When the code in the sub-application modifies the window property, it is directly modified on the sub-application's proxy window object.

So the question is, how to let the code in the sub-application read/modify the window, and let them access the proxy window object of the sub-application?

As mentioned in the V2 version just now, the micro front-end framework will pull js resources instead of sub-applications and execute them directly. We can use the with statement to wrap the code when executing the code, so that the window of the sub-application points to the proxy object:

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

Clear child app window scope on uninstall

When the child application is uninstalled, its window proxy object needs to be cleared. Otherwise, the next time the sub-application is reloaded, its window proxy object will store the last loaded data. There is a line of code this.injectKeySet.add(key) in the code that just created the Proxy. This injectKeySet is a Set object, which stores the newly added properties of each window proxy object. So when uninstalling, you only need to traverse this Set and delete the corresponding key on the window proxy object:

for (const key of injectKeySet) {
    Reflect.deleteProperty(microAppWindow, key as (string | symbol))
}

Record bound global events and timers, and clear them when uninstalling

Usually, a child application will not only modify the properties on the window, but also bind some global events on the window. So we need to log these events and clear them when the child app is uninstalled. In the same way, all kinds of timers are the same, and the unexecuted timers need to be cleared when uninstalling.

The following code is part of the key code for recording events and timers:

// 部分关键代码
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)
}

The following is the key code to clear events and timers:

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

Cache sub-app snapshots

As mentioned earlier, the sub-application will execute the mount() method every time it is loaded. Since each js file will only be executed once, the code before executing the mount() method will not be executed again the next time it is reloaded.

for example:

window.name = 'test'

function bootstrap() { // ... }
function mount() { // ... }
function unmount() { // ... }

The above is the code of the entry file of the sub-application. When the js code is executed for the first time, the sub-application can read the value of the attribute window.name . However, when the sub-application is uninstalled, the attribute name will be cleared. So the next time the sub-application is loaded, it will not be able to read this property.

To solve this problem, we can cache the properties and events of the current sub-application window proxy object to generate a snapshot when the sub-application is initialized (after all entry js files are pulled and executed). The next time the sub-app reloads, restore the snapshot back to the sub-app.

Part of the code to generate the snapshot:

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

Part of the code to restore the snapshot:

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

Isolate child app element scope

When we use document.querySelector() or other APIs that query the DOM, we will query on the document object of the entire page. If you query like this on the sub-application, there is a good chance that you will find DOM elements outside the scope of the sub-application. To solve this problem, we need to rewrite the DOM API of the query class:

// 将所有查询 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) {
    // ...
}

Limit the scope of the query to the DOM of the sub-application mount container. In addition, the rewritten API needs to be restored when the sub-app is uninstalled:

Document.prototype.querySelector = originalQuerySelector
Document.prototype.querySelectorAll = originalQuerySelectorAll
// ...

In addition to querying the DOM to limit the scope of sub-applications, styles also limit the scope. Suppose there is such a style on the vue application:

body {
    color: red;
}

When it is loaded as a sub-app, this style needs to be changed to:

/* body 被替换为子应用挂载 DOM 的 id 选择符 */
#app {
    color: red;
}

The implementation code is also relatively simple, you need to traverse each css rule, and then replace the body and html strings inside:

const re = /^(\s|,)?(body|html)\b/g
// 将 body html 标签替换为子应用挂载容器的 id
cssText.replace(re, `#${app.container.id}`)

V4 version

The V3 version implements window scope isolation and element isolation. In the V4 version, we will implement sub-application style isolation.

first edition

We all know that the document.createElement() API is used to create a DOM element, so we can write the name of the current sub-application as an attribute to the DOM when creating a DOM element:

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
}

In this way, all style tags will have the name attribute of the current sub-application when they are created. We can remove all style tags of the current sub-app when the sub-app is uninstalled, and re-add these tags to document.head when it is mounted again. This achieves style isolation between different sub-applications.

Code that removes all style tags from the sub-app:

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[]
}

After the style scope isolation of the first version is completed, it only works for scenarios where only one sub-application is loaded at a time. For example, the a sub-application is loaded first, and then the b sub-application is loaded after unloading. When a sub-app is uninstalled, its styles are also uninstalled. If multiple sub-apps are loaded at the same time, the style isolation of the first version does not work.

second edition

Because the DOM element under each sub-application has a single-spa-name attribute with its own name as the value (if you don't know where this name comes from, please read the description of the first edition).

在这里插入图片描述
So we can add the sub-application name to each style of the sub-application, that is, add this style:

div {
    color: red;
}

Change it to:

div[single-spa-name=vue] {
    color: red;
}

In this way, the scope of the style is limited to the DOM mounted by the corresponding sub-application.

Add scope to styles

Now let's take a look at how to add a scope:

/**
 * 给每一条 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
 */

There are mainly the above five situations.

Normally, each css selector is a css rule, which can be obtained by style.sheet.cssRules :

在这里插入图片描述
After getting each css rule, we can rewrite them, and then rewrite them and mount them under 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}`)
}

The core code is on getNewSelectorText() , this function adds [single-spa-name=${app.name}] to each css rule. This limits the style scope to the corresponding sub-application.

Effect demonstration

You can compare the two pictures below. This example loads two sub-applications of vue and react at the same time. The font of the vue sub-app in the first picture is affected by the style of the react sub-app. The second picture is the effect picture with style scope isolation added. You can see that the style of the vue sub-application is normal and has not been affected.

在这里插入图片描述

在这里插入图片描述

V5 version

The V5 version mainly adds a function of global data communication. The design ideas are as follows:

  1. All applications share a global object window.spaGlobalState , and all applications can monitor this global object. Whenever an application modifies it, the change event will be triggered.
  2. This global object can be used to subscribe/publish events, and events can be freely sent and received between applications.

The following is part of the key code that implements the first requirement:

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

The following is part of the key code that implements the second requirement:

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

The above two pieces of code have one thing in common, that is, when saving the listener callback function, it needs to be associated with the corresponding sub-application. When a sub-application is uninstalled, its associated callback function needs to be cleared as well.

Global data modification example code :

// 父应用
window.spaGlobalState.set('msg', '父应用在 spa 全局状态上新增了一个 msg 属性')
// 子应用
window.spaGlobalState.onChange((state, operator, key) => {
    alert(`vue 子应用监听到 spa 全局状态发生了变化: ${JSON.stringify(state)},操作: ${operator},变化的属性: ${key}`)
})

在这里插入图片描述

Global event example code :

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

在这里插入图片描述

Summarize

So far, the technical points of a simple micro front-end framework have been explained. It is strongly recommended that you run the demo while reading the documentation, which can help you better understand the code.

If you think my article is well written, you can also take a look at some of my other technical articles or projects:


谭光志
6.9k 声望13.1k 粉丝