To focus on the core implementation, skip to section 4: Execution Flow.
The commands in this article only work on shell-enabled systems, such as Mac, Ubuntu, and other Linux distributions. Not available on Windows, if you want to execute the command in Windows, please use git command window (need to install Git) or Linux subsystem (not supported under Windows 10).
I. Initialization project
1. Initialize the project directory
cd ~ && mkdir my-single-spa && cd "The $_"
Copy the code
2. Initialize the NPM
# Initialize package.json file
npm init -y
Install dev dependencies
npm install @babel/core @babel/plugin-syntax-dynamic-import @babel/preset-env rollup rollup-plugin-babel rollup-plugin-commonjs rollup-plugin-node-resolve rollup-plugin-serve -D
Copy the code
Module Description:
The name of the module | instructions |
---|---|
@babel/core | The core library of the Babel compiler, responsible for the loading and execution of all Babel presets and plug-ins |
@babel/plugin-syntax-dynamic-import | Support the use ofimport() Dynamic import, currently inStage 4: finished The stage of |
@babel/preset-env | Presets: A collection of commonly used plug-ins provided for ease of development |
rollup | Javascript packaging tools are much purer than WebPack in terms of packaging |
rollup-plugin-babel | Enabling Rollup to support Babel allows developers to use advanced JS syntax |
rollup-plugin-commonjs | Convert commonJS modules to ES6 |
rollup-plugin-node-resolve | Have Rollup support NodeJS’s module resolution mechanism |
rollup-plugin-serve | Support dev serve, convenient debugging and development |
Configure Babel and rollup
Create the Babel. Config. Js
# to create the Babel. Config. Js
touch babel.config.js
Copy the code
Add content:
module.export = function (api) {
// Cache Babel configuration
api.cache(true); // Equivalent to api.cache.forever()
return {
presets: [['@babel/preset-env', {module: false}]],plugins: ['@babel/plugin-syntax-dynamic-import']}; };Copy the code
Create a rollup. Config. Js
# to create a rollup. Config. Js
touch rollup.config.js
Copy the code
Add content:
import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';
import commonjs from 'rollup-plugin-commonjs';
import serve from 'rollup-plugin-serve';
export default {
input: './src/my-single-spa.js'.output: {
file: './lib/umd/my-single-spa.js'.format: 'umd'.name: 'mySingleSpa'.sourcemap: true
},
plugins: [
resolve(),
commonjs(),
babel({exclude: 'node_modules/**'}),
// See serve command in script field of package.json file below
// The purpose is to start the plugin only when the serve command is executed
process.env.SERVE ? serve({
open: true.contentBase: ' '.openPage: '/toutrial/index.html'.host: 'localhost'.port: '10001'
}) : null]}Copy the code
4. Add script and Browserslist fields to package.json
{
"script": {
"build:dev": "rollup -c"."serve": "SERVE=true rollup -c -w"
},
"browserslist": [
"ie >=11"."last 4 Safari major versions"."last 10 Chrome major versions"."last 10 Firefox major versions"."last 4 Edge major versions"]}Copy the code
4. Add the project folder
mkdir -p src/applications src/lifecycles src/navigation src/services toutrial && touch src/my-single-spa.js && touch toutrial/index.html
Copy the code
By now, the folder structure for the entire project should be:
. ├ ─ ─ Babel. Config. Js ├ ─ ─ package - lock. Json ├ ─ ─ package. The json ├ ─ ─ a rollup. Config. Js ├ ─ ─ node_modules ├ ─ ─ toutrial | └ ─ ─ Index.├ ─ SRC ├─ Application ├─ My-single-sp.js ├─ Navigation ├─ servicesCopy the code
At this point, the project has been initialized, and then the core content, the preparation of the micro front-end framework.
2. Concepts related to APP
1. App requirements
The core of the micro front end is APP. The scenarios of the micro front end mainly include splitting the application into multiple apps to load, or combining multiple different applications as APPS to load together.
To better constrain app and behavior, each app must export complete lifecycle functions so that the micro-front-end framework can better track and control them.
// app1
export default {
/ / app started
bootstrap: [(a)= > Promise.resolve()],
/ / app mount
mount: [(a)= > Promise.resolve()],
/ / app unloading
unmount: [(a)= > Promise.resolve()],
// service update, only service is available
update: [(a)= > Promise.resolve()]
}
Copy the code
There are four life cycle functions: bootstrap, mount, unmount, and update. The lifecycle can pass in a function that returns a Promise or an array that returns a Promise function.
2. App status
In order to better manage the APP, states are specially added to the app. Each APP has a total of 11 states. The flow diagram of each state is as follows:
State description (APP and Service are collectively referred to as APP in the following table) :
state | instructions | Next state |
---|---|---|
NOT_LOADED | App not loaded, default state | LOAD_SOURCE_CODE |
LOAD_SOURCE_CODE | Load the APP module | NOT_BOOTSTRAPPED, SKIP_BECAUSE_BROKEN, LOAD_ERROR |
NOT_BOOTSTRAPPED | The APP module has been loaded, but has not been started yetbootstrap Life cycle function) |
BOOTSTRAPPING |
BOOTSTRAPPING | The implementation of the appbootstrap In a lifecycle function (executed only once) |
SKIP_BECAUSE_BROKEN |
NOT_MOUNTED | The appbootstrap orunmount Life cycle function successfully executed, waiting to executemount Life cycle function (can be executed multiple times) |
MOUNTING |
MOUNTING | The implementation of the appmount Life cycle function |
SKIP_BECAUSE_BROKEN |
MOUNTED | The appmount orThe update (unique) service Vue’s $mount() or ReactDOM’s render() can be executed. |
UNMOUNTING, UPDATEING |
UNMOUNTING | The appunmount $deStory () or unmountComponentAtNode() for ReactDOM |
SKIP_BECAUSE_BROKEN, NOT_MOUNTED |
UPDATEING | Service update,Only Service has this state, app does not |
SKIP_BECAUSE_BROKEN, MOUNTED |
SKIP_BECAUSE_BROKEN | App encountered an error when changing state, if app state changed toSKIP_BECAUSE_BROKEN And the app willblocking , does not change to the next state |
There is no |
LOAD_ERROR | Loading error, meaning app cannot be used | There is no |
Load, mount, unmount Conditions Determine the App to be loaded:
Determine the App to be mounted:
Determine the App to be unmounted:
3. Processing of APP life cycle functions and timeout
App lifecycle functions can be passed in arrays or functions, but they must return a Promise. For ease of handling, we will determine that if the passed function is not an Array, we will wrap the passed function with an Array.
export function smellLikeAPromise(promise) {
if (promise instanceof Promise) {
return true;
}
return typeof promise === 'object' && promise.then === 'function' && promise.catch === 'function';
}
export function flattenLifecyclesArray(lifecycles, description) {
if (Array.isArray(lifecycles)) {
lifecycles = [lifecycles]
}
if (lifecycles.length === 0) {
lifecycles = [(a)= > Promise.resolve()];
}
/ / processing lifecycles
return props= > new Promise((resolve, reject) = > {
waitForPromise(0);
function waitForPromise(index) {
let fn = lifecycles[index](props);
if(! smellLikeAPromise(fn)) { reject(`${description} at index ${index} did not return a promise`);
return;
}
fn.then((a)= > {
if (index >= lifecycles.length - 1) {
resolve();
} else{ waitForPromise(++index); } }).catch(reject); }}); }/ / sample
app.bootstrap = [
(a)= > Promise.resolve(),
() => Promise.resolve(),
() => Promise.resolve()
];
app.bootstrap = flattenLifecyclesArray(app.bootstrap);
Copy the code
The specific process is shown in the figure below:
Consider: How do you write reduce? Are there any problems we should pay attention to?
For app usability, we will also add timeout handling to each app lifecycle function.
/ / flattenedLifecyclesPromise to flatten the processed after the step function of life cycle
export function reasonableTime(flattenedLifecyclesPromise, description, timeout) {
return new Promise((resolve, reject) = > {
let finished = false;
flattenedLifecyclesPromise.then((data) = > {
finished = true;
resolve(data)
}).catch(e= > {
finished = true;
reject(e);
});
setTimeout((a)= > {
if (finished) {
return;
}
let error = `${description} did not resolve or reject for ${timeout.milliseconds} milliseconds`;
if (timeout.rejectWhenTimeout) {
reject(new Error(error));
} else {
console.log(`${error} but still waiting for fulfilled or unfulfilled`);
}
}, timeout.milliseconds);
});
}
/ / sample
reasonableTime(app.bootstrap(props), 'app bootstraping', {rejectWhenTimeout: false.milliseconds: 3000})
.then((a)= > {
console.log('App started successfully');
console.log(app.status === 'NOT_MOUNTED'); // => true
})
.catch(e= > {
console.error(e);
console.log('App startup failed');
console.log(app.status === 'SKIP_BECAUSE_BROKEN'); // => true
});
Copy the code
3. Route interception
There are two kinds of apps in the micro front end: one is changed according to Location, which is called APP. The other is pure Feature level, called service.
To mount and unmount apps dynamically as Location changes, we need to uniformly intercept the browser’s location-related operations. In addition, to reduce collisions when using view frameworks such as Vue and React, we need to ensure that the micro front end must be the first to handle location-related events, followed by the Router processing of frameworks such as Vue and React.
Why does the microfront-end framework have to be the first to perform a Location change? How to be “first”?
Because the micro-front-end framework needs to mount or unmount the APP according to Location. Then the Vue or React used inside the app starts to actually do the follow-up work. This minimizes useless (redundant) operations inside the APP Vue or React.
Native location-related event interception (hijack) is uniformly controlled by the micro-front-end framework so that it is always executed first.
const HIJACK_EVENTS_NAME = /^(hashchange|popstate)$/i;
const EVENTS_POOL = {
hashchange: [].popstate: []};function reroute() {
// Invoke is used to load, mount, and unmout apps that meet the criteria
// See "Load, mount, unmount conditions" in the app status section at the top of the article.
invoke([], arguments)}window.addEventListener('hashchange', reroute);
window.addEventListener('popstate', reroute);
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, handler) {
if (eventName && HIJACK_EVENTS_NAME.test(eventName) && typeof handler === 'function') {
EVENTS_POOL[eventName].indexOf(handler) === - 1 && EVENTS_POOL[eventName].push(handler);
}
return originalAddEventListener.apply(this.arguments);
};
window.removeEventListener = function (eventName, handler) {
if (eventName && HIJACK_EVENTS_NAME.test(eventName)) {
let eventsList = EVENTS_POOL[eventName];
eventsList.indexOf(handler) > - 1 && (EVENTS_POOL[eventName] = eventsList.filter(fn= >fn ! == handler)); }return originalRemoveEventListener.apply(this.arguments);
};
function mockPopStateEvent(state) {
return new PopStateEvent('popstate', {state});
}
The pushState and replaceState methods do not trigger the onPopState event, so reroute should be executed here even if reroute was executed onPopState.
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
window.history.pushState = function (state, title, url) {
let result = originalPushState.apply(this.arguments);
reroute(mockPopStateEvent(state));
return result;
};
window.history.replaceState = function (state, title, url) {
let result = originalReplaceState.apply(this.arguments);
reroute(mockPopStateEvent(state));
return result;
};
// Execute this function after the load, mount, and unmout operations to ensure that the microfront logic is always executed first. The Vue or React-related Router in the App will then receive the Location event.
export function callCapturedEvents(eventArgs) {
if(! eventArgs) {return;
}
if (!Array.isArray(eventArgs)) {
eventArgs = [eventArgs];
}
let name = eventArgs[0].type;
if(! HIJACK_EVENTS_NAME.test(name)) {return;
}
EVENTS_POOL[name].forEach(handler= > handler.apply(window, eventArgs));
}
Copy the code
Iv. Execution Process (core)
The execution sequence of the whole micro-front-end framework is similar to that of the JS event loop, and the general execution process is as follows:
trigger
The trigger timing of the whole system can be divided into two types:
- Browser trigger: The browser Location changes, intercepts onHashchange and onPopState events, and mocks the pushState() and replaceState() methods of browser history.
- Manual trigger: Manually invoke the framework’s
registerApplication()
orstart()
Methods.
Modify queue (changesQueue)
Each trigger that passes the trigger time is stored in a changesQueue queue, which, like an event queue in an event loop, waits silently to be processed. If the changesQueue is empty, the loop stops until the next trigger.
Unlike a JS event loop queue, where all changes in the current loop are executed in batches, a JS event loop executes one by one.
“Event” loop
At the beginning of each cycle, the entire micro-front-end framework is determined to be started.
Not started: The App that needs to be loaded is loaded according to the rule (see “Determining apps that need to be loaded” above), and the internal Finish method is called when the load is complete.
Has started: According to the rules, the apps that need to be unmounted, loaded and mounted because the current conditions do not meet are obtained. The LOAD and mounted apps are merged together for de-weight first, and the unmout is completed before unified mount. The internal Finish method is called after the mount execution is complete.
The micro-front-end framework can be started by calling mysingespa.start ().
As you can see from the above, the internal Finish method is eventually called regardless of whether the current state of the micro-front-end framework is not started or started. Internally, the finish method simply determines whether the current changesQueue is empty, restarts the next loop if it is not, and terminates the loop if it is empty and exits the process.
function finish() {
// Successfully mount the app
let resolveValue = getMountedApps();
Pendings is the alias of a batch of Changesqueues stored during the last iteration of the loop
// This is the backup variable that calls the invoke method below
if (pendings) {
pendings.forEach(item= > item.success(resolveValue));
}
// Mark the end of the loop
loadAppsUnderway = false;
// The length of the changesQueue is not 0
if (pendingPromises.length) {
const backup = pendingPromises;
pendingPromises = [];
// Pass the "modify queue" to the invoke method and start the next loop
return invoke(backup);
}
// If changesQueue is empty, the loop terminates and the mounted app is returned
return resolveValue;
}
Copy the code
The location events
In addition, each time the loop terminates, the intercepted location event is triggered. This ensures that the location trigger of the micro-front-end framework is always executed first, while the Vue or React Router is always executed later.
The last
Micro front-end framework warehouse address: github.com/YataoZhang/…