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:
- Support for sub-applications of different frameworks (V1 branch)
- Support sub-application HTML entry (V2 branch)
- Support sandbox function, child application window scope isolation, element isolation (V3 branch)
- Support for sub-application style isolation (V4 branch)
- 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:
- Listen for page URL changes and switch sub applications
- 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:
- Rewrite the window. The history. PushState ()
- Rewrite the window. The history. ReplaceState ()
- Listen for popState events
- 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:
- Uninstall all deactivated child applications
- Initialize all newly registered child applications
- 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 ismount
That 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:
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
).- The sub-application status must be
bootstrap
或unmount
, so as tomount
State transitions. If you are already inmount
State 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:
activeRule()
The return value offalse
, for example, URL from/vue
into/
, then the child application VUE is inactivated (assume that its activation rule is/vue
).- The sub-application status must be
mount
That 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 frommount
intounmount
.
The API is introduced
V1 mainly exposes two apis:
registerApplication()
, register child applications.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:
- Use Ajax request sub-application entry URL content, get sub-application HTML
- Extract the HTML
script
style
If it is a URL, the content is pulled again using Ajax. Finally get all of the entry pagescript
style
The content of the - Add all styles to
document.head
Next,script
Direct code execution - 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:
- Configure CORS to prevent cross-domain problems. (Cross-domain problems may occur because the domain names of the primary and sub-applications are different.)
- 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:
- Isolate the window scope of the child application
- 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:
- Code access in the child application
window.xxx
Property 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. - 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.head
Bottom:
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:
- All applications share a global object
window.spaGlobalState
, all applications can listen on this global object, and it is triggered whenever an application modifies itchange
Events. - 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