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: finishedThe 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 yetbootstrapLife cycle function) BOOTSTRAPPING
BOOTSTRAPPING The implementation of the appbootstrapIn a lifecycle function (executed only once) SKIP_BECAUSE_BROKEN
NOT_MOUNTED The appbootstraporunmountLife cycle function successfully executed, waiting to executemountLife cycle function (can be executed multiple times) MOUNTING
MOUNTING The implementation of the appmountLife cycle function SKIP_BECAUSE_BROKEN
MOUNTED The appmountorThe update (unique) serviceVue’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_BROKENAnd 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:

  1. Browser trigger: The browser Location changes, intercepts onHashchange and onPopState events, and mocks the pushState() and replaceState() methods of browser history.
  2. Manual trigger: Manually invoke the framework’sregisterApplication()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/…