This article is from OPPO Internet technology team, please note the author. At the same time, welcome to follow our official account: OPPO_tech, share with you OPPO cutting-edge Internet technology and activities.
A microfront end is a microservice that exists in a browser and is typically made up of many components that are rendered using frameworks like React, Vue, and Angular. Each microfront can be managed by a different team, with a choice of frameworks.
Each microfront has its own Git repository, package.json, and build tool configuration. Therefore, some megalithic applications can be separated into multiple independent modules and then combined together. Applications can be independently maintained and online without interference.
This paper introduces the principle of the micro front-end framework Qiankun and some practices of OPPO cloud in this way through some simplified codes.
Note: This article is used by defaultqiankun
Framework, and used in this articleqiankun
Version is:2.0.9
.
1. The former single-SPA of Qiankun
Qiankun is a micro-front-end implementation library based on Single-SPA. Before the birth of Qiankun, users usually used single-SPA to solve the problems of micro-front-end, so we will first learn about single-SPA.
Let’s start with an example and take a step-by-step look at what happens at each step.
import { registerApplication, start } from "single-spa";
registerApplication(
"foo".() = > System.import("foo"),
(location) = > location.pathname.startsWith("foo")); registerApplication({name: "bar".loadingFn: () = > import("bar.js"),
activityFn: (location) = > location.pathname.startsWith("bar")}); start();Copy the code
- AppName: string The name of the application will be registered and referenced in single-SPA and marked in development tools
- LoadingFn: () => must be a load function that returns either an application or a Promise
- ActivityFn: (location) => Boolean Method to determine whether the current application is active
- customProps? : Object Optional Transfer of user-defined parameters
1.1 Metadata processing
First, single-SPA normalizes the above data and adds state, which is eventually converted to a metadata array. For example, the above data is converted to:
[{
name: 'foo'.loadApp: () = > System.import('foo'),
activeWhen: location= > location.pathname.startsWith('foo'),
customProps: {},
status: 'NOT_LOADED'}, {name: 'bar'.loadApp: () = > import('bar.js'),
activeWhen: location= > location.pathname.startsWith('bar')
customProps: {},
status: 'NOT_LOADED'
}]
Copy the code
1.2 Route Hijacking
Single-spa internally hijacks the browser route, and all routing methods and routing events ensure that single-SPA is first entered for unified scheduling.
// We will trigger an app change for any routing events.
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
// Monkeypatch addEventListener so that we can ensure correct timing
const originalAddEventListener = window.addEventListener;
window.addEventListener = function(eventName, fn) {
if (typeof fn === "function") {
if(["hashchange"."popstate"].indexOf(eventName) >= 0 &&
!find(capturedEventListeners[eventName], (listener) = > listener === fn)
) {
capturedEventListeners[eventName].push(fn);
return; }}return originalAddEventListener.apply(this.arguments);
};
Copy the code
function patchedUpdateState(updateState, methodName) {
return function() {
const urlBefore = window.location.href;
const result = updateState.apply(this.arguments);
const urlAfter = window.location.href;
if(! urlRerouteOnly || urlBefore ! == urlAfter) { urlReroute(createPopStateEvent(window.history.state, methodName)); }}; }window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
Copy the code
The above is a condensed version of the hijacking code, and you can see that all the hijacking points to an exit function, urlReroute.
1.3 urlReroute unified processing function
Each time a route changes, it enters the same function for processing:
let appChangeUnderway = false,
peopleWaitingOnAppChange = [];
export async function reroute(pendingPromises = [], eventArguments) {
// Separate applications into different arrays according to different conditions
const {
appsToUnload,
appsToUnmount,
appsToLoad,
appsToMount,
} = getAppChanges();
// If a new route jump is made while the change is in progress, a queue is entered.
if (appChangeUnderway) {
return new Promise((resolve, reject) = > {
peopleWaitingOnAppChange.push({ resolve, reject, eventArguments });
});
}
// mark that the change is in progress,
appChangeUnderway = true;
await Promise.all(appsToUnmount.map(toUnmountPromise)); // Run unmount for the application to be unmounted
await Promise.all(appsToUnload.map(toUnloadPromise)); // The application to be destroyed should be destroyed first
await Promise.all(appsToLoad.map(toLoadPromise)); // Perform load first for the application to be loaded
await Promise.all(appsToBootstrap.map(toBootstrapPromise)); // Perform bootstrap for the bootstrap application
await Promise.all(appsMount.map(toMountPromise)); // Mount the application to be mounted
appChangeUnderway = false;
// If there are still route changes in the queued queue, a new reroute loop is performed
reroute(peopleWaitingOnAppChange);
}
Copy the code
Now let’s see what the grouping function is doing.
1.4 getAppChanges Application group changes
Each route change is first grouped according to the activeRule rule of the application.
export function getAppChanges() {
const appsToUnload = [],
appsToUnmount = [],
appsToLoad = [],
appsToMount = [];
apps.forEach((app) = > {
constappShouldBeActive = app.status ! == SKIP_BECAUSE_BROKEN && shouldBeActive(app);switch (app.status) {
case LOAD_ERROR:
case NOT_LOADED:
if (appShouldBeActive) appsToLoad.push(app);
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if(! appShouldBeActive) { appsToUnload.push(app); }else if (appShouldBeActive) {
appsToMount.push(app);
}
case MOUNTED:
if (!appShouldBeActive) appsToUnmount.push(app);
}
});
return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
Copy the code
1.5 Enumeration of status fields
Single-spa divides the status of applications
export const NOT_LOADED = "NOT_LOADED"; // Not yet loaded
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // Load the source code
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // Bootstrap is not yet loaded
export const BOOTSTRAPPING = "BOOTSTRAPPING"; / / the bootstrap
export const NOT_MOUNTED = "NOT_MOUNTED"; // The bootstrap is complete
export const MOUNTING = "MOUNTING"; / / in the mount
export const MOUNTED = "MOUNTED"; / / end of the mount
export const UPDATING = "UPDATING"; / / the updata
export const UNMOUNTING = "UNMOUNTING"; / / unmount to
export const UNLOADING = "UNLOADING"; / / unload
export const LOAD_ERROR = "LOAD_ERROR"; // Failed to load the source code
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; / / in the load, the bootstrap, mount and unmount to stage a script error
Copy the code
We can use official debugging tools during development to quickly see the status of each application after each route change:
Single-spa uses finite-state machine design ideas:
-
Things have multiple states, and can only be in one state at any time, not in multiple states;
-
An action can change the state of things. An action can be judged by conditions to change things to different states, but it cannot point to multiple states at the same time.
-
The total number of states is finite.
Other examples of finite state machines: Promises, traffic lights
1.6 Single-SPA event system
Browser-based native event system, no framework coupling, global out-of-box availability.
// Receive mode
window.addEventListener("single-spa:before-routing-event".(evt) = > {
const {
originalEvent,
newAppStatuses,
appsByNewStatus,
totalAppChanges,
} = evt.detail;
console.log(
"original event that triggered this single-spa event",
originalEvent
); // PopStateEvent | HashChangeEvent | undefined
console.log(
"the new status for all applications after the reroute finishes",
newAppStatuses
); // { app1: MOUNTED, app2: NOT_MOUNTED }
console.log(
"the applications that changed, grouped by their status",
appsByNewStatus
); // { MOUNTED: ['app1'], NOT_MOUNTED: ['app2'] }
console.log(
"number of applications that changed status so far during this reroute",
totalAppChanges
); / / 2
});
Copy the code
1.7 Highlights and disadvantages of single-SPA
Bright spot
-
All asynchronous programming, the load will need to provide to the user, the bootstrap, mount and unmount to promise should be used in asynchronous form processing, no matter synchronous and asynchronous can hold
-
By hijacking routes, you can determine whether an application needs to be switched each time a route changes, and then send the sub-application to respond to the route
-
The mount and unload functions of each application should be standardized, and no framework should be coupled. Sub-applications can be connected to the system as long as they implement corresponding interfaces
insufficient
-
The load method needs to know the entry file for the subproject
-
Integrating multiple application runtimes requires inter-project management of memory leaks and style contamination
-
There is no way to provide parent-child data communication
2. Qiankun appearance
In order to solve some shortcomings of single-SPA and retain the excellent concepts in single-SPA, Qiankun further expanded on the basis of single-SPA.
The following is the official capability diagram of Qiankun:
Let’s take a look at how Qiankun is used
import { registerMicroApps, start } from "qiankun";
registerMicroApps([
{
name: "react app".// app name registered
entry: "//localhost:7100".container: "#yourContainer".activeRule: "/yourActiveRule"}, {name: "vue app".entry: { scripts: ["//localhost:7100/main.js"]},container: "#yourContainer2".activeRule: "/yourActiveRule2",}]); start();Copy the code
Is it a bit like single-SPA registration?
2.1 Transfer of registration information to Single-SPA
Inside Qiankun, users’ app registration information will be packaged and sent to Single-SPA
import { registerApplication } from "single-spa";
export function registerMicroApps(apps) {
apps.forEach((app) = > {
const{ name, activeRule, loader = noop, props, ... appConfig } = app; registerApplication({ name,app: async () => {
loader(true);
const{ mount, ... otherMicroAppConfigs } =awaitloadApp( { name, props, ... appConfig }, frameworkConfiguration );return {
mount: [
async () => loader(true),
...toArray(mount),
async () => loader(false),],... otherMicroAppConfigs, }; },activeWhen: activeRule,
customProps: props,
});
});
}
Copy the code
You can see that the mount and unmount functions are returned by loadApp.
2.2 Implementation of loadApp
export async function loadApp(app, configuration) {
const { template, execScripts } = await importEntry(entry); // Access the HTML, JS, and CSS content of the application through the entry link of the application
const sandboxInstance = createSandbox(); // Create a sandbox instance
const global = sandboxInstance.proxy; // Get a sandbox global context
const mountSandbox = sandboxInstance.mount;
const unmountSandbox = sandboxInstance.unmount;
// Executes the js code for the subproject in this sandbox global context
const scriptExports = await execScripts(global);
// Get the bootstrap/mount/unmount exported by the subproject
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global
);
// Initialize the event module
const {
onGlobalStateChange,
setGlobalState,
offGlobalStateChange,
} = getMicroAppStateActions();
// The mount, unmount method passed to single-SPA is actually a function wrapped by Qiankun
return {
bootstrap,
mount: async () => {
awaitrender(template); // Render the template to the mount area
mountSandbox(); // Mount the sandbox
await mount({ setGlobalState, onGlobalStateChange }); // Call the mount function of the application
},
ummount: async() = > {await ummount(); // Call the applied ummount function
unmountSandbox(); // Uninstall the sandbox
offGlobalStateChange(); // Remove event listening
render(null); // Clear the render area}}; }Copy the code
2.3 importEntry implementation
Take a look at the use of importEntry, which is a separate package import-HTml-entry that parses HTML content and returns HTML, CSS, and JS separated content.
For example, the entry HTML of a subapplication is as follows
<! DOCTYPEhtml>
<html>
<head>
<meta charset="utf-8" />
<title>Here is the title</title>
<link rel="stylesheet" href="./css/admin.css" />
<style>
.div {
color: red;
}
</style>
</head>
<boyd>
<div id="wrap">
<div id="app"></div>
</div>
<script src="/static/js/app.12345.js"></script>
<script>
console.log("1");
</script>
</boyd>
</html>
Copy the code
After being loaded into the page by Qiankun, the HTML structure was finally generated as follows:
<meta charset="utf-8" />
<title>Here is the title</title>
<link rel="stylesheet" href="./css/admin.css" />
<style>
.div {
color: red;
}
</style>
<div id="wrap">
<div id="app"></div>
</div>
<! -- script /static/js/app.12345.js replaced by import-html-entry -->
<! -- inline scripts replaced by import-html-entry -->
Copy the code
Take a look at what importEntry returns
export function importEntry(entry, opts = {}) {...// Parse HTML procedure ignored
return {
// The content of the pure DOM element
template,
// A method to fetch
getExternalScripts: () = > getExternalScripts(scripts, fetch),
// a method that can receive a
getExternalStyleSheets: () = > getExternalStyleSheets(styles, fetch),
// an execution function that receives the global context. This execution method emulates the logic of the browser executing a script when the application loads
execScripts: (proxy) = >{}}}Copy the code
Looking at the implementation of getExternalScripts, which actually simulates the browser loading
// scripts is an array of urls for the
tag after parsing the HTML
export getExternalScripts(scripts, fetch = defaultFetch) {
return Promise.all(scripts.map(script= > {
return fetch(scriptUrl).then(response= > {
returnresponse.text(); })); }}))Copy the code
Then look at the implementation of execScripts, which can execute all
export async execScripts(proxy) {
// getExternalScripts above loads the contents of the
tag
const scriptsTexts = await getExternalScripts(scripts)
window.proxy = proxy;
// Emulate the browser and execute the script in sequence
for (let scriptsText of scriptsTexts) {
// Adjust the sourceMap address, otherwise sourceMap is invalid
const sourceUrl = '//# sourceURL=${scriptSrc}\n';
// Use iife to replace proxy with window and eval to execute this script
eval(`
;(function(window, self){
;${scriptText}
${sourceUrl}
}).bind(window.proxy)(window.proxy, window.proxy);
`;) }}Copy the code
2.4 Global variable contamination and memory leaks
Before we look at the sandbox feature, the sandbox is mainly used to solve the global variable pollution and memory leak problems of the program.
-
Global variable contamination: Multiple applications use a global variable of the same name, such as Vue.
-
Memory leak: MEMORY leak is the failure of a program to free memory that is no longer in use due to negligence or error. A memory leak is not the physical disappearance of memory, but rather the loss of control of memory before it is released due to a design error after an application allocates the memory.
Common memory leak scenarios:
-
Unexpected global variables
-
Leak to the global closure
-
DOM leak
-
The timer
-
EventListener
-
Console. log (Development environment)
-
Let’s take a look at how Qiankun could solve this problem.
2.5 How to use the sandbox
Using the logic of loadApp above, this article discusses the LegacySandbox sandbox.
export function createSandbox() {
const sandbox = new LegacySandbox();
// Contamination and leakage from the load or bootstrap phase
const bootstrappingFreers = patchAtBootstrapping();
let sideEffectsRebuilders = [];
return {
proxy: sandbox.proxy,
// The sandbox is mounted either from the bootstrap state or from unmount after waking up again
async mount() {
/ * -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - 1. Start/resume sandbox -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - * /
sandbox.active();
const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(
0,
bootstrappingFreers.length
);
// Reconstructing side effects of the bootstrap phase of the application, such as dynamically inserting CSS
sideEffectsRebuildersAtBootstrapping.forEach((rebuild) = > rebuild());
/ * -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 2. Open global side listen -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - * /
// Render sandbox starts to hijack all kinds of global listeners when bootstrap is started. Try not to have side effects such as event listeners/timers during bootstrap initialization. These side effects cannot be removed
mountingFreers = patchAtMounting(
appName,
elementGetter,
sandbox,
singular,
scopedCSS,
excludeAssetFilter
);
sideEffectsRebuilders = [];
},
// Restore the global state to the state before the application was loaded
async unmount() {
// Each Freers release returns a rebuild function, or an empty function if the Freers do not need to rebuild
sideEffectsRebuilders = [...bootstrappingFreers].map((free) = >free()); sandbox.inactive(); }}; }Copy the code
Take a look at the implementation of the LegacySandbox sandbox. The main purpose of the sandbox is to deal with global variable contamination and hijack all window operations by replacing them with a proxy.
class SingularProxySandbox {
// Global variables updated during sandbox
addedPropsMapInSandbox = new Map(a);// Global variables updated during sandbox
modifiedPropsOriginalValueMapInSandbox = new Map(a);// Keep a map of updated (new and modified) global variables for snapshot at any time
currentUpdatedPropsValueMap = new Map(a); sandboxRunning =true;
active() {
// Restore a snapshot of the last time the sandbox was run
this.currentUpdatedPropsValueMap.forEach((v, p) = > setWindowProp(p, v));
this.sandboxRunning = true;
}
inactive() {
// Change the value back when the sandbox is destroyed
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) = > setWindowProp(p, v));
// Empty the new value when the sandbox is destroyed
this.addedPropsMapInSandbox.forEach((_, p) = > setWindowProp(p, undefined.true));
this.sandboxRunning = false;
}
constructor(name) {
const proxy = new Proxy(window, {
set(_, p, value) {
// If the property does not exist in the current window object, the property is added
if (!window.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
// If the property exists in the current Window object and is not recorded in map, record the value of the property before modification and save it
} else if(! modifiedPropsOriginalValueMapInSandbox.has(p)) {const originalValue = window[p];
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
// This value is recorded as the latest snapshot regardless of whether it is added or modified
currentUpdatedPropsValueMap.set(p, value);
window[p] = value; }},get(_, p) {
return window[p]
},
})
}
}
Copy the code
In addition to the problem of global variable contamination, there are other leakage issues that need to be dealt with, which qiankun uses different patch functions to hijack.
// Handle leaks during the mount phase and the application run phase
export function patchAtMounting() {
return [
// Handle timer leaks
patchInterval(),
// Handle global event listening leaks
patchWindowListener(),
patchHistoryListener(),
// This is strictly not a leak. It listens for the dom structure of the dynamically inserted page (including script and style).
patchDynamicAppend(),
];
}
// Handle leaks generated during the Load and bootstrap phases
export function patchAtBootstrapping() {
return [patchDynamicAppend()];
}
Copy the code
An example of patch is as follows:
const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;
export default function patchInterval(global) {
let intervals = [];
global.clearInterval = (intervalId) = > {
intervals = intervals.filter((id) = >id ! == intervalId);return rawWindowClearInterval(intervalId);
};
global.setInterval = (handler, timeout, ... arg) = > {
constintervalId = rawWindowInterval(handler, timeout, ... args); intervals = [...intervals, intervalId];return intervalId;
};
// Returns the method to release these leaks
return function free() {
intervals.forEach((id) = > global.clearInterval(id));
global.setInterval = rawWindowInterval;
global.clearInterval = rawWindowClearInterval;
// Does this patch have any scenes that need to be reconstructed? If not, it is an empty function
return function rebuild() {};
};
}
Copy the code
The design of returning to cancel is subtle and can be found in VUE as well.
// The listener returns a method to cancel the listener, which returns a method to re-listen
const unwatch = this.$watch("xxx".() = > {});
const rewatch = unwatch(); // Pseudo code, actually no
Copy the code
Let’s look at the most complex patchDynamicAppend implementation, which handles scenarios where scripts and links are dynamically inserted into code.
const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
export default function patchDynamicAppend(mounting, proxy) {
let dynamicStyleSheetElements = [];
// hijack the insert function
HTMLHeadElement.prototype.appendChild = function(element) {
switch (element.tagName) {
case LINK_TAG_NAME:
// If the
case STYLE_TAG_NAME: {
dynamicStyleSheetElements.push(stylesheetElement);
return rawHeadAppendChild.call(appWrapperGetter(), stylesheetElement);
}
// If the
case SCRIPT_TAG_NAME: {
const { src, text } = element;
execScripts(null, [src ? src : `<script>${text}</script>`], proxy);
const dynamicScriptCommentElement = document.createComment(
src
? `dynamic script ${src} replaced by qiankun`
: "dynamic inline script replaced by qiankun"
);
returnrawHeadAppendChild.call( appWrapperGetter(), dynamicScriptCommentElement ); }}return rawHeadAppendChild.call(this, element);
};
// Free doesn't need to release anything, because the style element will disappear as the content area is cleared
return function free() {
// We need to rebuild the style element the next time we continue to mount the application
return function rebuild() {
dynamicStyleSheetElements.forEach((stylesheetElement) = > {
document.head.appendChild.call(appWrapperGetter(), stylesheetElement);
});
if (mounting) dynamicStyleSheetElements = [];
};
};
}
Copy the code
2.6 Parent-child Application Communication
The Qiankun implements a simple global data store that both parent and child applications can read and write to together as a supplement to single-SPA events.
let globalState = {};
export function getMicroAppStateActions(id, isMaster) {
return {
// Event change callback
onGlobalStateChange(callback, fireImmediately) {
deps[id] = callback;
const cloneState = cloneDeep(globalState);
if(fireImmediately) { callback(cloneState, cloneState); }},// Set the global status
setGlobalState(state) {
const prevGlobalState = cloneDeep(globalState);
Object.keys(deps).forEach((id) = > {
deps[id](cloneDeep(globalState), cloneDeep(prevGlobalState));
});
return true;
},
// Unregister the application dependencies
offGlobalStateChange() {
deletedeps[id]; }}; }Copy the code
2.7 About pre-request
Prerequest takes advantage of the point at which importEntry separates the acquisition and execution of resources to preload all child applications’ resources.
function prefetch(entry, opts) {
if(! navigator.onLine || isSlowNetwork) {// Don't prefetch if in a slow network or offline
return;
}
requestIdleCallback(async() = > {const { getExternalScripts, getExternalStyleSheets } = await importEntry(
entry,
opts
);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}
apps.forEach(({ entry }) = > prefetch(entry, opts));
Copy the code
The principle of Qiankun and Single-SPA is shared above. In general, Qiankun is more oriented to scenarios where some sub-projects are uncontrollable and developers do not deliberately deal with pollution and memory leakage, while Single-SPA is more pure as a routing controller, and all pollution and leakage problems need to be controlled by developers themselves.
3. OPPO cloud practice
OPPO cloud also found some experience to share in the practice of qiankun micro-front-end landing process.
3.1 About the sandbox
The sandbox in Qiankun is not a panacea
-
The sandbox has only one level of hijacking, and changes such as date.prototype. XXX will not be restored
-
The current sandbox function for global variables is to mask, not clear, and this part of memory is reserved after masking, will open up the ability to customize the sandbox later
-
For the concept of memory leaks, take a look at the concept of resident memory
Resident memory is an assistive program that can pretend to exit and still reside in memory, allowing you to run other applications that can be applied immediately when you respond, rather than having to spend time creating them again
-
Use traceless mode and do not use any Chrome extensions to troubleshoot memory problems. Production builds are also recommended
3.2 Extract the common library
-
Qiankun does not recommend shared dependency, fearing contamination of prototype chain and other issues. Single-spa recommends sharing large dependencies, which requires careful handling of contamination issues, and both recommend using WebPack’s external to share dependency libraries.
-
We also recommend sharing large public dependencies, using the External of Webpack to share dependent libraries. However, the library is loaded repeatedly for each sub-application, which saves the download time of the same library and ensures that there is no prototype chain pollution between different sub-applications. This is a compromise solution.
Refer to the link
-
qiankun
-
single-spa
-
The goal is the most complete micro front end solution