How to do the front end of a large factory? Baidu senior front-end engineer, take you to write the micro front-end framework

Preface

Focus on the core implementation and skip to the fourth section: execution process.

The commands in this article apply only to shell enabled systems, such as Mac, Ubuntu, and other linux distributions. It is not applicable to windows. If you want to execute the commands in the article under windows, please use git command window (GIT needs to be installed) or linux subsystem (not supported under win10).

I. initialization project

1. Initialize project directory

cd ~ && mkdir my-single-spa && cd "$_"

2. Initialize the npm environment

# Initialize the package.json file
npm init -y
# Install dev dependency
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

Module description:

Module description

3. Configure babel and rollup

Create babel.config.js

# Create babel.config.js
touch babel.config.js

Add content:

module.export = function (api) {
    // Configuration of caching babel
    api.cache(true); // Equivalent to api.cache.forever()
    return {
        presets: [
            ['@babel/preset-env', {module: false}]
        ],
        plugins: ['@babel/plugin-syntax-dynamic-import']
    };
};

Create rollup.config.js

# Create rollup.config.js
touch rollup.config.js

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 the serve command in the script field of the package.json file below
        // The purpose is to start the plug-in only when the serve command is executed
        process.env.SERVE ? serve({
            open: true,
            contentBase: '',
            openPage: '/toutrial/index.html',
            host: 'localhost',
            port: '10001'
        }) : null
    ]
}

4. Add script and browserslist fields in 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"
    ]
}

5. Add project folder

mkdir -p src/applications src/lifecycles src/navigation src/services toutrial && touch src/my-single-spa.js && touch toutrial/index.html

So far, the folder structure of the whole project should be:

.
├── babel.config.js
├── package-lock.json
├── package.json
├── rollup.config.js
├── node_modules
├── toutrial
|   └── index.html
└── src
    ├── applications
    ├── lifecycles
    ├── my-single-spa.js
    ├── navigation
    └── services

At this point, the project has been initialized, and then the core content, micro front-end framework, is written.

II. app related concepts

1. app requirements

The core of the micro front-end is app. The main scene of the micro front-end is to split the application into multiple apps to load, or to load multiple different applications together as apps.

In order to better constrain the app and behavior, it is required that each app must export a complete life cycle function, so that the microfront framework can better track and control them.

// app1
export default {
    // app boot
    bootstrap: [() => Promise.resolve()],
    // app mount
    mount: [() => Promise.resolve()],
    // app uninstall
    unmount: [() => Promise.resolve()],
    // Service update, only service is available
    update: [() => Promise.resolve()]
}

There are four life cycle functions: bootstrap, mount, unmount, and update. A lifecycle can pass in a function that returns Promise or an array that returns Promise.

2. app status

In order to better manage apps, state is added to apps. There are 11 states in each app. The flow chart of each state is as follows:

Status description (APP and service are collectively referred to as app in the following table):

load, mount and 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 function and timeout

Why are the life cycle functions of app passed in Array or function? But they must return a Promise. For convenience, we will judge that if the passed in is not Array, the passed in function will be wrapped with 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 = [() => Promise.resolve()];
    }
    // Handling lifecycle
    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(() => {
                if (index >= lifecycles.length - 1) {
                    resolve();
                } else {
                    waitForPromise(++index);
                }
            }).catch(reject);
        }
    });
}

// Example
app.bootstrap = [
    () => Promise.resolve(),
    () => Promise.resolve(),
    () => Promise.resolve()
];
app.bootstrap = flattenLifecyclesArray(app.bootstrap);

The specific process is as follows:

Thinking: how to write if you use reduce? Do you have any questions to pay attention to?

For the sake of APP availability, we also talk about adding timeout processing to each app's lifecycle function.

// Flattenedlifcyclespromise is the life cycle function processed by flatten in the previous step
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(() => {
            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);
    });
}

// Example
reasonableTime(app.bootstrap(props), 'app bootstraping', {rejectWhenTimeout: false, milliseconds: 3000})
    .then(() => {
        console.log('app Start up successful');
        console.log(app.status === 'NOT_MOUNTED'); // => true
    })
    .catch(e => {
        console.error(e);
        console.log('app Startup failure');
        console.log(app.status === 'SKIP_BECAUSE_BROKEN'); // => true
    });

3. Route interception

There are two kinds of APP in the micro front end: one is changed according to Location, which is called app. The other is feature level, which is called service.

If we want to dynamically mount and unmount the qualified app s with the change of Location, we need to block the Location related operations of the browser uniformly. In addition, in order to reduce conflicts when using Vue, React and other view frameworks, we need to ensure that the microfront must be the first to handle Location related events, and then the Router processing of Vue, React and other frameworks.

Why does the microfront framework have to be the first to perform relevant operations when the Location changes? How to guarantee "the first"?

Because the micro front-end framework needs to mount or unmount the app according to the Location. Then the Vue or React used inside the app will start the real follow-up work, which can minimize the useless (redundant) operation of Vue or React inside the app.

The Location related events are hijack ed and controlled by the micro front-end framework in a unified way, so that they are always executed first.

const HIJACK_EVENTS_NAME = /^(hashchange|popstate)$/i;
const EVENTS_POOL = {
    hashchange: [],
    popstate: []
};

function reroute() {
    // invoke is mainly used for app s with load, mount and unmount satisfying conditions.
    // For specific conditions, 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});
}

// Intercept the history method, because pushState and replaceState methods do not trigger the onpopustate event, so even if we execute the reroute method in onpopustate, we need to execute the reroute method here.
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;
};

// After the load, mount and unmount operations are completed, executing this function can ensure that the logic of the micro front end is always executed first. Then the Vue or React related Router in the App can 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));
}

IV. execution process (core)

The execution sequence of the whole micro front-end framework is similar to the js event cycle, and the general execution process is as follows:

Trigger time

The trigger timing of the whole system is divided into two categories:

  • Browser trigger: the browser Location changes, the onhashchange and onpopustate events are blocked, and the pushState() and replaceState() methods of the browser history are mock ed.
  • Manual trigger: manually call the registerApplication() or start() methods of the framework.

Change queue

Every time a trigger operation is performed through the trigger time, it will be stored in the changesQueue queue, which is like the event queue of the event cycle, waiting to be processed quietly. If changesQueue is empty, stop the loop until the next trigger time comes.

Unlike js event loop queue, changesQueue is that all changes in the current loop will be bound into a batch and executed at the same time, while js event loop is executed one by one.

Event loop

At the beginning of each cycle, we will judge whether the whole micro front-end framework has been started.

Not started: according to the rules (see above, "App" that needs to be loaded (load), load the app that needs to be loaded, and then invoke the internal finish method after loading is completed.

Started: according to the rules, get the apps that need to be unloaded (unmounted), loaded (loaded) and mounted (mounted) because the current conditions are not met. Merge the load and mount apps together for de duplication first, and then mount uniformly after unmount is completed. Then wait until the mount execution is complete and the internal finish method will be called.

You can start the microfront framework by calling mySingleSpa.start().

From the above we can see that whether the current micro front-end framework is not started or started, it will eventually call the internal finish method. In fact, the interior of the finish method is very simple. Judge whether the current changesQueue is empty. If it is not empty, restart the next cycle. If it is empty, terminate the termination cycle and exit the whole process.

function finish() {
    // Get the successful mount app
    let resolveValue = getMountedApps();
    
    // pendings are aliases of a batch of changesqueues stored during the last cycle
    // In fact, it is the backup variable of the invoke method called below.
    if (pendings) {
        pendings.forEach(item => item.success(resolveValue));
    }
    // Mark loop ended
    loadAppsUnderway = false;
    // Found changesQueue length is not 0
    if (pendingPromises.length) {
        const backup = pendingPromises;
        pendingPromises = [];
        // Transfer the modify queue to the invoke method and start the next cycle
        return invoke(backup);
    }
    
    // changesQueue is empty, terminate the loop and return the mount ed app
    return resolveValue;
}

location event

In addition, the intercepted location event will be triggered at the end of each loop, so as to ensure that the location triggering time of the micro front-end framework mentioned above is always executed first, and the Router of Vue or React is always executed later.

Last

About how to get the address of the micro front frame warehouse, pay attention to the public number: fkdcxy, crazy programmer, can get free!

If you think this article is helpful, please remember to like it + forward it to others. After reading it, all the people who don't like it are (hooligans / (hooligans)./~~

For this article you have other opinions or ideas welcome comments, thank you!

Keywords: node.js Vue React JSON npm

Added by wiztek2000 on Thu, 31 Oct 2019 05:05:51 +0200