一線大廠前端怎麼作?百度資深前端工程師,帶你手寫微前端框架

前言

關注核心實現請直接跳至 第四小節:執行流程。html

本文中的命令僅適用於支持shell的系統,如Mac、烏班圖及其餘linux發行版。不適用於windows,若是想在windows下執行文章中的命令請使用git命令窗口(需安裝git)或linux子系統(win10如下不支持)。

1、初始化工程前端

一、初始化工程目錄node

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

二、初始化npm環境linux

# 初始化package.json文件
npm init -y
# 安裝dev依賴
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

模塊說明:git

一線大廠前端怎麼作?百度資深前端工程師,帶你手寫微前端框架

模塊說明程序員

三、配置babel和rollupshell

建立babel.config.jsnpm

# 建立babel.config.js
touch babel.config.js

添加內容:json

module.export = function (api) {
    // 緩存babel的配置
    api.cache(true); // 等同於api.cache.forever()
    return {
        presets: [
            ['@babel/preset-env', {module: false}]
        ],
        plugins: ['@babel/plugin-syntax-dynamic-import']
    };
};

建立rollup.config.jsbootstrap

# 建立rollup.config.js
touch rollup.config.js

添加內容:

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/**'}),
        // 見下方的package.json文件script字段中的serve命令
        // 目的是隻有執行serve命令時才啓動這個插件
        process.env.SERVE ? serve({
            open: true,
            contentBase: '',
            openPage: '/toutrial/index.html',
            host: 'localhost',
            port: '10001'
        }) : null
    ]
}

四、在package.json中添加script和browserslist字段

{
    "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"
    ]
}

五、添加項目文件夾

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

到目前爲止,整個項目的文件夾結構應該是:

.
├── 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

到此,項目就已經初始化完畢了,接下來開始核心的內容,微前端框架的編寫。

2、app相關概念

一、app要求

微前端的核心爲app,微前端的場景主要是:將應用拆分爲多個app加載,或將多個不一樣的應用當成app組合在一塊兒加載。

爲了更好的約束app和行爲,要求每一個app必須向外export完整的生命週期函數,使微前端框架能夠更好地跟蹤和控制它們。

// app1
export default {
    // app啓動
    bootstrap: [() => Promise.resolve()],
    // app掛載
    mount: [() => Promise.resolve()],
    // app卸載
    unmount: [() => Promise.resolve()],
    // service更新,只有service纔可用
    update: [() => Promise.resolve()]
}
生命週期函數共有4個:bootstrap、mount、unmount、update。 生命週期能夠傳入 返回Promise的函數也能夠傳入 返回Promise函數的數組。

二、app狀態

爲了更好的管理app,特意給app增長了狀態,每一個app共存在11個狀態,其中每一個狀態的流轉圖以下:

一線大廠前端怎麼作?百度資深前端工程師,帶你手寫微前端框架

狀態說明(app和service在下表統稱爲app):

一線大廠前端怎麼作?百度資深前端工程師,帶你手寫微前端框架

load、mount、unmount條件 判斷須要被加載(load)的App:

一線大廠前端怎麼作?百度資深前端工程師,帶你手寫微前端框架

判斷須要被掛載(mount)的App:

一線大廠前端怎麼作?百度資深前端工程師,帶你手寫微前端框架

判斷須要被卸載(unmount)的App:

一線大廠前端怎麼作?百度資深前端工程師,帶你手寫微前端框架

三、app生命週期函數和超時的處理

app的生命週期函數何以傳入數組或函數,可是它們都必須返回一個Promise,爲了方便處理,因此咱們會判斷:若是傳入的不是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()];
    }
    // 處理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(() => {
                if (index >= lifecycles.length - 1) {
                    resolve();
                } else {
                    waitForPromise(++index);
                }
            }).catch(reject);
        }
    });
}

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

具體的流程以下圖所示:

一線大廠前端怎麼作?百度資深前端工程師,帶你手寫微前端框架

思考:若是用reduce的話怎麼寫?有什麼須要注意的問題麼?

爲了app的可用性,咱們還講給每一個app的生命週期函數增長超時的處理。

// flattenedLifecyclesPromise爲通過上一步flatten處理過的生命週期函數
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);
    });
}

// 示例
reasonableTime(app.bootstrap(props), 'app bootstraping', {rejectWhenTimeout: false, milliseconds: 3000})
    .then(() => {
        console.log('app 啓動成功了');
        console.log(app.status === 'NOT_MOUNTED'); // => true
    })
    .catch(e => {
        console.error(e);
        console.log('app啓動失敗');
        console.log(app.status === 'SKIP_BECAUSE_BROKEN'); // => true
    });

3、路由攔截

微前端中app分爲兩種:一種是根據Location進行變化的,稱之爲app。另外一種是純功能(Feature)級別的,稱之爲service。

若是要實現隨Location的變化動態進行mount和unmount那些符合條件的app,咱們就須要對瀏覽器的Location相關操做作統一的攔截。另外,爲了在使用Vue、React等視圖框架時下降衝突,咱們須要保證微前端必須是第一個處理Location的相關事件,而後纔是Vue或React等框架的Router處理。

爲何Location改變時,微前端框架必定要第一個執行相關操做哪?如何保證"第一個"?

由於微前端框架要根據Location來對app進行mount或unmount操做。而後app內部使用的Vue或React纔開始真正進行後續工做,這樣能夠最大程度減小app內部Vue或React的無用(冗餘)操做。

對原生的Location相關事件進行攔截(hijack),統一由微前端框架進行控制,這樣就能夠保證老是第一個執行。

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

function reroute() {
    // invoke主要用來load、mount、unmout知足條件的app
    // 具體條件請看文章上方app狀態小節中的"load、mount、unmount條件"
    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});
}

// 攔截history的方法,由於pushState和replaceState方法並不會觸發onpopstate事件,因此咱們即使在onpopstate時執行了reroute方法,也要在這裏執行下reroute方法。
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;
};

// 再執行完load、mount、unmout操做後,執行此函數,就能夠保證微前端的邏輯老是第一個執行。而後App中的Vue或React相關Router就能夠收到Location的事件了。
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));
}

4、執行流程(核心)

整個微前端框架的執行順序和js事件循環類似,大致執行流程以下:

一線大廠前端怎麼作?百度資深前端工程師,帶你手寫微前端框架

觸發時機

整個系統的觸發時機分爲兩類:

  • 瀏覽器觸發:瀏覽器Location發生改變,攔截onhashchange和onpopstate事件,並mock瀏覽器history的pushState()和replaceState()方法。
  • 手動觸發:手動調用框架的registerApplication()或start()方法。

修改隊列(changesQueue)

每經過觸發時機進行一次觸發操做,都會被存放到changesQueue隊列中,它就像事件循環的事件隊列同樣,靜靜地等待被處理。若是changesQueue爲空,則中止循環直至下一次觸發時機到來。

和js事件循環隊列不一樣的是,changesQueue是當前循環內的全部修改(changes)會綁成一批(batch)同時執行,而js事件循環是一個一個地執行。

"事件"循環

在每一次循環的開始階段,會先判斷整個微前端的框架是否已經啓動。

未啓動:根據規則(見上文的『判斷須要被加載(load)的App』)加載須要被加載的app,加載完成以後調用內部的finish方法。

已啓動:根據規則獲取當前由於不知足條件而須要被卸載(unmount)的app、須要被加載(load)的app以及須要被掛載(mount)的app,將load和mount的app先合併在一塊兒進行去重,等unmout完成以後再統一進行mount。而後再等到mount執行完成以後就會調用內部的finish方法。

能夠經過調用mySingleSpa.start()來啓動微前端框架。

經過上文咱們能夠發現不論是當前的微前端框架的狀態是未啓動或已啓動,最終都會調用內部的finish方法。其實,finish方法的內部很簡單,判斷當前的changesQueue是否爲空,若是不爲空則從新啓動下一次循環,若是爲空則終止終止循環,退出整個流程。

function finish() {
    // 獲取成功mount的app
    let resolveValue = getMountedApps();
    
    // pendings是上一次循環進行時存儲的一批changesQueue的別名
    // 其實就是下方調用invoke方法的backup變量
    if (pendings) {
        pendings.forEach(item => item.success(resolveValue));
    }
    // 標記循環已結束
    loadAppsUnderway = false;
    // 發現changesQueue的長度不爲0
    if (pendingPromises.length) {
        const backup = pendingPromises;
        pendingPromises = [];
        // 將『修改隊列』傳入invoke方法,並開啓下一次循環
        return invoke(backup);
    }
    
    // changesQueue爲空,終止循環,返回已mount的app
    return resolveValue;
}

location事件

另外在每次循環終止時都會將已攔截的location事件進行觸發,這樣就能夠保證上文說的微前端框架的location觸發時機老是首先被執行,而Vue或React的Router老是在後面執行。

最後

關於微前端框架倉庫地址如何獲取,關注公衆號:【fkdcxy,瘋狂的程序員丶】 便可免費獲取!

若是你以爲本文對有幫助,記得點贊+轉發 分享給他人,看完不點讚的都是(流氓 /(ㄒoㄒ)/~~

對於本文你有其餘的看法或想法歡迎評論區留言,謝謝!

相關文章
相關標籤/搜索