Recently, I have read the source code of several micro front-end frameworks (single-SPA, Qiankun, micro-App), and I feel I have gained a lot. So I decided to build a mini wheel to deepen my understanding of what I had learned.

The wheel will be divided into five versions, gradually implementing a minimum usable microfront-end framework:

  1. Support for sub-applications of different frameworks (V1 branch)
  2. Support sub-application HTML entry (V2 branch)
  3. Support sandbox function, child application window scope isolation, element isolation (V3 branch)
  4. Support for sub-application style isolation (V4 branch)
  5. Support data communication between applications (Main branch)

Each version of the code builds on the previous version, so the V5 version of the code is final.

Github project address: github.com/woai3c/mini…

V1

The V1 version is intended to implement a minimal front-end framework, as long as it can load and unload child applications properly. If the V1 version is broken down, it mainly consists of the following two features:

  1. Listen for page URL changes and switch sub applications
  2. Determine whether to load or uninstall the subapplication based on the current URL and the triggering rules of the subapplication

Listen for page URL changes and switch sub applications

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

// Switch to the vue child application when location.pathname is prefixed with /vue
https://www.example.com/vue/xxx
// Switch to the react child when location.pathname is prefixed with /react
https://www.example.com/react/xxx
Copy the code

This can be done by overriding two apis and listening for two events:

  1. Rewrite the window. The history. PushState ()
  2. Rewrite the window. The history. ReplaceState ()
  3. Listen for popState events
  4. Listen for the Hashchange event

The pushState() and replaceState() methods modify the browser’s history stack, so we can rewrite these apis. When the two apis are called by the SPA application, it indicates that the URL has changed. At this time, we can determine whether to load and uninstall the sub-application according to the current changed URL.

// The browser URL changes from https://www.xxx.com to https://www.xxx.com/vue after executing the following code
window.history.pushState(null.' '.'/vue')
Copy the code

The popState event is triggered when the user manually clicks the forward and back buttons on the browser, so you need to listen for this event. Similarly, you need to listen for the Hashchange event.

The code for this 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)
        // Load or uninstall the app based on the current URL
        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)}Copy the code

The loadApps() method is called every time the URL changes. This method switches the state of the child app based on the current URL and the child app’s trigger rules:

export async function loadApps() {
	// Uninstall all deactivated child applications first
    const toUnMountApp = getAppsWithStatus(AppStatus.MOUNTED)
    await Promise.all(toUnMountApp.map(unMountApp))
    
    // Initializes all newly registered child applications
    const toLoadApp = getAppsWithStatus(AppStatus.BEFORE_BOOTSTRAP)
    await Promise.all(toLoadApp.map(bootstrapApp))

    const toMountApp = [
        ...getAppsWithStatus(AppStatus.BOOTSTRAPPED),
        ...getAppsWithStatus(AppStatus.UNMOUNTED),
    ]
    // Load all subapplications that match the criteria
    await toMountApp.map(mountApp)
}
Copy the code

The logic of this code is also relatively simple:

  1. Uninstall all deactivated child applications
  2. Initialize all newly registered child applications
  3. Load all subapplications that match the criteria

Determine whether to load or uninstall the subapplication based on the current URL and the triggering rules of the subapplication

In order to support subapplications of different frameworks, it is specified that subapplications must expose the bootstrap() mount() unmount() methods. The bootstrap() method fires only once when the child application is loaded for the first time, and the other two methods fire every time the child application is loaded or unloaded.

Call the child’s mount() method when the URL meets the loading criteria, regardless of which child is registered, and leave it to the child to render properly. The unmount() method of the child application is called when the unload condition is met.

registerApplication({
    name: 'vue'.// This method is executed when the initializer is applied
    loadApp() { 
    	return {
			mount() {                
            	// Mount the child application
            	app.mount('#app')},unmount() {
                // Unmount the child application
                app.unmount()
            },
		}
	},
	// If a string is passed in, it is converted to a function with a location argument
	// activeRule: '/vue' will be converted to (location) => location. Pathname === '/vue'
    activeRule: (location) = > location.hash === '#/vue'
})
Copy the code

Here is a simple example of registering a child application, where the activeRule() method is used to determine whether the child application is active (returning true means active). Whenever the page URL changes, the microfront-end framework calls loadApps() to determine whether each subapplication is active, and then triggers loading and unloading of the subapplications.

When to load and uninstall child applications

First, we divide the states of sub-applications into three types:

  • bootstrap, the callregisterApplication()After registering a child application, its status defaults tobootstrap, the next transition state ismount.
  • mount, the state of the child application after it is successfully mounted, and its next transition state isunmount.
  • unmount, the status of the child application after uninstallation, and its next transition status ismountThat is, the uninstalled application can be loaded again.

Now let’s see when a child app is loaded. When the page URL changes, the child app needs to be loaded if it meets the following two conditions:

  1. activeRule()The return value oftrue, for example, URL from/into/vue, then the child application VUE is in the active state (assuming its activation rule is/vue).
  2. The sub-application status must bebootstrapunmount, so as tomountState transitions. If you are already inmountState andactiveRule()The return value istrue, no processing is done.

If the sub-application meets the following conditions after the URL of the page is changed, uninstall the sub-application:

  1. activeRule()The return value offalse, for example, URL from/vueinto/, then the child application VUE is inactivated (assume that its activation rule is/vue).
  2. The sub-application status must bemountThat is, the child application must be in the loaded state (if it is in any other state, nothing is done). And then the URL changes and deactivates, so it needs to be uninstalled, and the state goes frommountintounmount.

The API is introduced

V1 mainly exposes two apis:

  1. registerApplication(), register child applications.
  2. start()Called after all child applications are registered, which is executed internallyloadApps()Load the child application.

RegisterApplication (Application) receives the following parameters:

interface Application {
    // Name of the child application
    name: string

    /** * Activation rules, such as passing in /vue, activate the current child application when the url path changes to /vue. * If activeRule is a function, location is passed as an argument, and activeRule(location) returns true to activate the current child application. * /
    activeRule: Function | string

    // A custom parameter passed to the child application
    props: AnyObject

    /** * loadApp() must return a Promise, resolve() must return an object: * {* bootstrap: () => Promise
      
        * mount: (props:) AnyObject) => Promise
       
         * unmount: (props: AnyObject) => Promise
        
          * } */
        
       
      
    loadApp: () = > Promise<any>}Copy the code

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'.// Label name
                            this.text  // Label content
                        )
                    },
                })
                
                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()
Copy the code

The demo effect is as follows:

summary

V1 version of the code packaged only more than 100 lines, if you just want to understand the core principle of the micro front end, just look at the V1 version of the source code can be.

V2 version

The implementation of the V1 version is very rudimentary and can be applied to limited business scenarios. As you can see from the V1 example, it requires the child application to load all the resources in advance (or pack the entire child application into an NPM package and import it directly) so that the mount() method of the child application can be rendered properly.

As an example, suppose we start a VUE application in our development environment. So how do you introduce the resources of the VUE child into the main application? Rule out the NPM package first, because it is impractical to package every change to the code. The second way is to manually import child application resources into the master application. For example, the entry resources of the vUE sub-application are:

So we can introduce this when registering a child 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'
})
Copy the code

This approach is also unreliable. Every time the entry resource file of the child application changes, the main application code must also change. Fortunately, we have a third way, that is, when registering the child application, the entry URL of the child application is written, and the micro front end is responsible for loading the resource file.

registerApplication({
	// Subapp entry URL
    pageEntry: 'http://localhost:8081'
    // ...
})
Copy the code

Load resource files automatically

Now let’s look at how to automatically load the entry file of the child application (only when the child is loaded for the first time) :

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)
        
        // Extract the HTML content of the body part after script style
        app.pageBody = doc.body.innerHTML

        let isStylesDone = false, isScriptsDone = false
        // Load the content of the style script
        Promise.all(loadStyles(styles))
        .then(data= > {
            isStylesDone = true
            // Add the style to the document.head tag
            addStyles(data as string[])
            if (isScriptsDone && isStylesDone) resolve()
        })
        .catch(err= > reject(err))

        Promise.all(loadScripts(scripts))
        .then(data= > {
            isScriptsDone = true
            // Execute script content
            executeScripts(data as string[])
            if (isScriptsDone && isStylesDone) resolve()
        })
        .catch(err= > reject(err))
    })
}
Copy the code

The logic of the above code:

  1. Use Ajax request sub-application entry URL content, get sub-application HTML
  2. Extract the HTMLscript styleIf it is a URL, the content is pulled again using Ajax. Finally get all of the entry pagescript styleThe content of the
  3. Add all styles todocument.headNext,scriptDirect code execution
  4. Assign the rest of the BODY HTML content to the DOM to be mounted by the child application.

Here’s how to do these four steps in more detail.

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()
    })
}
Copy the code

The code logic is simple: make a request using Ajax and get the HTML content.The image above shows the HTML content of a VUE child application. The arrows point to the resource to extract and the contents of the box marker are assigned to the DOM mounted by the child application.

Second, parse HTML and extract style script tag content

This requires the use of an API, DOMParser, which parses an HTML string directly and does not need to be attached to a Document object.

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

ExtractScriptsAndStyles (Node: Element, app: Application) is a function that extracts labels. 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 style tag, execute script content

This step is relatively simple. Add all 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)
        }
    })
}
Copy the code

The javascript code executes directly inside an anonymous function:

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

4. Assign the rest of the BODY HTML content to the DOM to be mounted by the child application

To ensure the normal execution of sub-applications, you need to save this part of the content. Then each time the child application mounts (), it assigns the value to the mounted DOM.

// Save the HTML code
app.pageBody = doc.body.innerHTML

// Assign a value to the mounted DOM before loading the child application
app.container.innerHTML = app.pageBody
app.mount()
Copy the code

Now that we can easily load the child application, there are a few things we need to change about the child application.

What the child application needs to do

In version V1, there is a loadApp() method for registering child applications. The microfront framework executes this method the first time it loads the child application to get the three methods exposed by the child application. Now that pageEntry is implemented, we don’t need to write this method in the main app, because we don’t need to introduce children in the main app.

However, the micro front-end framework needs to get the method of exposing the child application, so we can expose the child application method in another way:

// Each child application needs to expose three apis in this way. This property is in the format of 'mini-single-spa-${appName}'
window['mini-single-spa-vue'] = {
    bootstrap,
    mount,
    unmount
}
Copy the code

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

In addition, the child application has to do two things:

  1. Configure CORS to prevent cross-domain problems. (Cross-domain problems may occur because the domain names of the primary and sub-applications are different.)
  2. Configure the resource publishing path

If the child application is developed based on WebPack, it can be configured as follows:

module.exports = {
    devServer: {
        port: 8001.// The sub-application accesses the port
        headers: {
            'Access-Control-Allow-Origin': The '*'}},publicPath: "//localhost:8001/",}Copy the code

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()
Copy the code

V3 version

The following two functions are added to V3:

  1. Isolate the window scope of the child application
  2. Isolate the child application element scope

Isolate the window scope of the child application

Under version V2, the main application and all its children share the same window object, which causes overwriting data:

// First load a subapplication
window.name = 'a'
// Load the b subapplication
window.name = 'b'
// The name of a child application is window.name
console.log(window.name) // b
Copy the code

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

app.window = new Proxy({}, {
    get(target, key) {
        if (Reflect.has(target, key)) {
            return Reflect.get(target, key)
        }
        
        const result = originalWindow[key]
        // The window native method's this reference must be run on the window, otherwise "TypeError: Illegal Invocation "will be reported.
        // 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)
    }
})
Copy the code

As you can see from the code above, we use Proxy to Proxy an empty object, and then use the Proxy object as the window object of the child application:

  1. Code access in the child applicationwindow.xxxProperty is intercepted by the proxy object. It looks for the property in the child application’s proxy Window object, and if it doesn’t find it, it looks for it in the parent application, in the real Window object.
  2. When code in the child application modifies the window property, it does so directly on the child application’s proxy Window object.

So, how do I get code from the child application to read/modify the window object when it accesses the child application’s proxy window object?

As mentioned earlier in version V2, the micro-front-end framework pulls js resources instead of child applications and executes them directly. We can wrap the code with the with statement when executing the code and make the window of the child application point to the proxy object:

export function executeScripts(scripts: string[], app: Application) {
    try {
        scripts.forEach(code= > {            
            // ts uses with, so you need to wrap it like this
            // Point the child's js code global window environment to the proxy environment 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
    }
}
Copy the code

Clear the window scope of the child application on uninstall

When the child application is uninstalled, its Window proxy object needs to be cleaned. Otherwise, the next time the child application reloads, its Window proxy object will hold the data from the previous load. Add (key). This injectKeySet is a Set object that holds new properties for each window Proxy object. So when uninstalling, just iterate through the Set and delete the corresponding key from the window proxy object:

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

Records bound global events and timers, which are cleared when uninstalled

Typically, in addition to modifying properties on the window, a child application will also bind global events to the window. So we need to log these events and clear them when uninstalling the child application. The same applies to all timers. You need to clear unused timers during uninstallation.

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

// Some key code
microAppWindow.setTimeout = function setTimeout(callback: Function, timeout? :number | undefined. args:any[]) :number {
    consttimer = 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)
}
Copy the code

Here is the key code for clearing 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)
    }
}
Copy the code

Cache child application snapshots

As mentioned earlier, the mount() method is executed each time the child application is loaded. Since each JS file is executed only once, the code before the mount() method is not executed again the next time the child application is reloaded.

Here’s an example:

window.name = 'test'

function bootstrap() { // ... }
function mount() { // ... }
function unmount() { // ... }
Copy the code

Above is the code for the child application entry file. The child application can read the value of the window.name property when executing the js code for the first time. However, the name property is cleared when the child application is uninstalled. So the next time the child application loads, it won’t be able to read this property.

To solve this problem, we can cache the properties and events of the current child application window proxy object during the child application initialization (after all entry JS files are pulled and executed) to generate a snapshot. The next time the child application reloads, the snapshot is restored to the child application.

Part of the code for generating a snapshot:

const { windowSnapshot, microAppWindow } = this
const recordAttrs = windowSnapshot.get('attrs')!
const recordWindowEvents = windowSnapshot.get('windowEvents')!

// Cache the window attribute
this.injectKeySet.forEach(key= > {
    recordAttrs.set(key, deepCopy(microAppWindow[key]))
})

// Cache the window event
this.windowEventMap.forEach((arr, type) = > {
    recordWindowEvents.set(type, deepCopy(arr))
})
Copy the code

Part of the code for restoring a 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)
    }
})
Copy the code

Isolate the child application element scope

When we use Document.querySelector () or any other API to query the DOM, we always query the document object on the entire page. If you do the same on a child application, you are likely to find DOM elements outside the scope of the child application. To solve this problem, we need to rewrite the DOM API of the query class:

// Limit the scope of all DOM queries to the DOM container mounted by the child application
Document.prototype.querySelector = function querySelector(this: Document, selector: string) {
    const app = getCurrentApp()
    if(! app || ! selector || isUniqueElement(selector)) {return originalQuerySelector.call(this, selector)
    }
	// limit the scope of the query to the DOM of the child application mount container
    return app.container.querySelector(selector)
}

Document.prototype.getElementById = function getElementById(id: string) {
    // ...
}
Copy the code

Limit the scope of the query to the DOM of the child application mount container. In addition, the overwritten API also needs to be restored when the child application is uninstalled:

Document.prototype.querySelector = originalQuerySelector
Document.prototype.querySelectorAll = originalQuerySelectorAll
// ...
Copy the code

In addition to querying the DOM to limit the scope of subapplications, styles also need to limit the scope. Suppose a vUE application has a style like this:

body {
	color: red;
}
Copy the code

When it is loaded as a child application, this style needs to be modified to:

/* Body is replaced with the id selector */ for the child application to mount the DOM
#app {
	color: red;
}
Copy the code

The implementation code is also relatively simple. It needs to iterate through each CSS rule and replace the body and HTML strings inside:

const re = /^(\s|,)? (body|html)\b/g
// Replace the body HTML tag with the id of the child application's mount container
cssText.replace(re, ` #${app.container.id}`)
Copy the code

V4 version

V3 implements Window scope isolation, element isolation, and V4 implements sub-application style isolation.

The first edition

We all know that we use the document.createElement() API to create DOM elements, so we can write the name of the current child to the DOM as an attribute:

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
}
Copy the code

All style tags are created with the name attribute of the current child application. We can remove all the style tags of the current child app when the child app is unmounted and add them back to document.head when the child app is remounted. This enables style isolation between different child applications.

Remove all style tags from the child 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[]
}
Copy the code

When the first version of style scoping isolation was complete, it only worked for scenarios where only one child application was loaded at a time. For example, load subapplication A and then load subapplication B. When you uninstall a child application, you will also uninstall its style. If multiple child applications are loaded at the same time, the first version of style isolation does not work.

The second edition

Since each DOM element under the child app has a single-SPa-name attribute with its own name as its value (if you don’t know where this name came from, check out the description in the first edition).

So we can add a child application name to each style of the child application, that is, the style will look like this:

div {
	color: red;
}
Copy the code

To:

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

This limits the scope of the style to the DOM mounted by the corresponding child application.

Add scope to the style

Now let’s see how to add scope:

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 {} - > # ${child application mounted container id} [single - spa - name = ${app. Name}] {} * 5.@media @supports Special handling, other rules return cssText */ directly
Copy the code

There are mainly five cases above.

In general, each CSS selector is a CSS rule, which can be obtained from style.sheet.cssrules:

Once we have each CSS rule, we can rewrite it and then rewrite it and mount it todocument.headBottom:

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(' ')})// If the container mounted by the child application does not have an ID, a random ID will be generated
    let id = app.container.id
    if(! id) { id ='single-spa-id-' + count++
        app.container.id = id
    }

    // Replace the body HTML tag with the id of the child application's mount container
    return arr.join(', ').replace(re, ` #${id}`)}Copy the code

The core code is on getNewSelectorText(), which appends each CSS rule to [single-spa-name=${app.name}]. This limits the style scope to the appropriate child application.

Results demonstrate

Compare the two images below. This example loads both vue and React. Part of the font in the first image of the Vue child is affected by the style of the React child. The second image is a rendering with style scope isolation added. You can see that the style of the VUE child application is normal and not affected.

The V5 version

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

  1. All applications share a global objectwindow.spaGlobalState, all applications can listen on this global object, and it is triggered whenever an application modifies itchangeEvents.
  2. This global object can be used for event subscription/publishing, and applications can send and receive events freely.

Here are some key pieces of code that fulfill the first requirement:

export default class GlobalState extends EventBus {
    private state: AnyObject = {}
    private stateChangeCallbacksMap: Map<string.Array<Callback>> = new Map(a)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) = > {
            /** * If you click on another child or parent app to trigger a global data change, the currently open child app will get app null * so you need to use activeRule to determine whether the current child app is running */
            const app = getApp(appName) as Application
            if(! (isActive(app) && app.status === AppStatus.MOUNTED))return
            callbacks.forEach(callback= > callback(this.state, operator, key))
        })
    }
}
Copy the code

Here are some key pieces of code that fulfill the second requirement:

export default class EventBus {
    private eventsMap: Map<string, Record<string.Array<Callback>>> = new Map(a)on(event: string, callback: Callback) {
        if(! isFunction(callback)) {throw Error(`The second param The ${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) = > {
            /** * If you click on another child or parent app to trigger a global data change, the currently open child app will get app null * so you need to use activeRule to determine whether the current child app is running */
            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) } } } }) } }Copy the code

The two pieces of code have one thing in common: they need to be associated with the corresponding child application when saving the listener callback function. When a child application is uninstalled, its associated callback function needs to be removed.

Example code for global data modification:

/ / the parent application
window.spaGlobalState.set('msg'.'Parent app adds MSG attribute to SPA global state')
/ / application
window.spaGlobalState.onChange((state, operator, key) = > {
    alert(The vue child is listening for a change in the SPA global state:The ${JSON.stringify(state)}Operation:${operator}, the property of change:${key}`)})Copy the code

Sample code for global events:

/ / the parent application
window.spaGlobalState.emit('testEvent'.'The parent app sent a global event: testEvent')
/ / application
window.spaGlobalState.on('testEvent'.() = > alert('Vue child listening to parent sending a global event: testEvent'))
Copy the code

conclusion

At this point, the technical essentials of a simple micro-front-end framework have been explained. It is highly recommended that you run the demo while looking at the documentation. This will help you understand the code better.

If you think my article is good, take a look at some of my other technical articles or projects:

  • Introduces you to front-end engineering
  • Visual drag component library some technical points of principle analysis
  • 24 Suggestions for Front-end Performance Optimization (2020)
  • Analysis of some technical key points of front-end monitoring SDK
  • Teach you how to write a scaffold
  • Elements of computer Systems – Building a modern computer from scratch