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:
- Support sub-apps for different frameworks ( v1 branch)
- Support for sub-app HTML entry ( v2 branch)
- Support sandbox function, sub-application window scope isolation, element isolation ( v3 branch)
- Support for sub-app style isolation ( v4 branch)
- 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:
- Monitor page URL changes and switch sub-applications
- 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:
- Override window.history.pushState()
- Override window.history.replaceState()
- Listen popstate event
- 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:
- Uninstall all deactivated sub-apps
- Initialize all newly registered sub-applications
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 callingregisterApplication()
register a child application, its state defaults tobootstrap
, and the next transition state ismount
.mount
, the state after the sub-application is successfully mounted, its next transition state isunmount
.unmount
, the state after the sub-app is uninstalled successfully, its next transition state ismount
, 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:
- The return value of
activeRule()
istrue
. For example, the URL changes from/
to/vue
. At this time, the sub-application vue is activated (assuming its activation rule is/vue
). - The sub-app state must be
bootstrap
orunmount
in order to transition to themount
state. If already inmount
state andactiveRule()
return value istrue
, 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:
- The return value of
activeRule()
isfalse
. For example, the URL changes from/vue
to/
. At this time, the sub-application vue is inactive (assuming its activation rule is/vue
). - 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 frommount
tounmount
.
API introduction
The V1 version mainly exposes two APIs:
registerApplication()
, register the sub-app.start()
is called after all sub-applications are registered, andloadApps()
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:
- Use ajax to request the content of the entry URL of the sub-application to get the HTML of the sub-application
- 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 ofscript
style
on the entry page - Add all styles to
document.head
,script
code is executed directly - 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:
- 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)
- 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:
- Isolate child application window scope
- 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:
- 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. - 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:
- All applications share a global object
window.spaGlobalState
, and all applications can monitor this global object. Whenever an application modifies it, thechange
event will be triggered. - 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:
- takes you to get started with front-end engineering
- Principle analysis of some technical points of the visual drag and drop component library
- Front-end performance optimization 24 suggestions (2020)
- Principle analysis of some technical points of the front-end monitoring SDK
- Teach you how to write a scaffolding
- Computer System Elements - Building a Modern Computer from
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。