微前端在美團外賣的實踐

微前端是微服務理念在前端的應用。之前美美給大家介紹過微前端在美團 HR 系統和美團閃購的實踐文章。

今天的文章來自美團外賣廣告團隊,他們參考業界優秀方案,同時也深度結合了廣告端實際業務的情況,提出了基於 React 的中心路由基座式微前端方案。

背景

微前端是一種利用微件拆分來達到工程拆分治理的方案,可以解決工程膨脹、開發維護困難等問題。隨着前端業務場景越來越複雜,微前端這個概念最近被提起得越來越多,業界也有很多團隊開始探索實踐並在業務中進行了落地。可以看到,很多團隊也遇到了各種各樣的問題,但各自也都有着不同的處理方案。誠然,任何技術的實現都要依託業務場景纔會變得有意義,所以在闡述美團外賣廣告團隊的微前端實踐之前,我們先來簡單介紹一下外賣商家廣告端的業務形態。目前,我們開發和維護的系統主要包括三端:

如上圖所示,原始解決方案的三端由各自獨立開發和維護,各自包含所有的業務線,而我們的業務開發情況是:

在這種特殊的業務場景下,就會出現一個有關開發效率的抉擇問題。即我們希望能複用的部分只開發一次,而不是三次。那麼接下來,就有兩個問題擺在我們面前:

我們這裏重點看一下物理層面的複用,即:如何在物理空間上使得各自獨立的三端系統(不同倉庫)引入我們的複用層?我們嘗試了 NPM 包、Git subtree 等類 “共享文件” 的方式後發現,最有效率的複用方式是把三個系統放在一個倉庫裏,去消除物理空間上的隔離,而不是去連接不同的物理空間。當然,我們三端系統的技術棧是一致的,所以就進行了如下圖的改造:

可以看到,當我們把三端系統放在一個倉庫中時,通過 common 文件夾提供了物理層面可複用的土壤,不再需要 “共享文件” 式地進行頻繁地拉取操作,直接引用複用即可。不過,在帶來物理層面複用效率提升的同時,也加速了整個工程出現了爆炸式發展的問題,隨着產品線從最初的幾個發展到現在的幾十個之多,工程管理成本也在迅速增長。具體來說,包括如下四個方面:

如下圖所示,具體地說明了原有架構存在的問題。爲了要解決這些問題,我們意識到需要拆分這些應用,即進行工程優化的常規手段進行 “分治”。那麼要怎麼拆呢?自然而然地我們就想到了微前端的概念。也從這個概念出發,我們參考業界優秀方案,同時也深度結合了廣告端實際業務的開發情況,對現有工程進行了微前端的實踐與落地。

需求分析

結合現有工程的狀況,我們進行了深度的分析。不過,在進行微前端方案確定前,我們先確定了需求點及期望收益,如下表所示:

方案選擇

經過以上的需求分析,我們調研了業界及公司周邊的微前端方案,並總結了以下幾種方案以及它們各自主要的特點:

通過對各個方案特點進行分析,我們將重點關注項進行了對比,如下表所示:

經過上面的調研對比之後,我們確定採用了特定中心路由基座式的開發方案,並命名爲:基於 React 的中心路由基座式微前端。這種方案的優點包括以下幾個方面:

微前端實踐概覽

通過對方案的分析及技術方向上的梳理,我們確定了微前端的整體方案,如下圖所示:

可以看到,整個方案非常簡單明確,即按照業務線進行了路由級別的拆分。整個系統可分爲兩個部分:

基座工程和子工程聯繫起來的橋樑則是子工程的入口文件地址和路由地址的映射信息。這些映射信息可以讓基座工程準確地發現子工程資源的路徑從而進行加載。

微前端架構下的業務變化

經過微前端實踐的改造,我們的業務在結構上發生瞭如下的變化:

如上圖所示,我們進行了微前端式的業務線拆分:

新的拆分使得子工程能夠按照業務線進行劃分,獨立維護。在解決複用層的同時保證了子工程大小可控,即子工程只有單個業務線的代碼。而單個業務線的複雜度並不高,也降低了工程維護的複雜度。

採用微前端拆分的方案,使得我們的業務不僅在縱向上保有了複用的能力,更重要的是擁有了橫向擴展的能力,無論產品業務線如何膨脹,我們都可以更輕鬆地應對。那麼爲了實現以上的能力,我們做了哪些工作呢?下文我們會詳細進行說明。

基於 React 技術棧的中心路由基座式微前端

微前端拆分的方案,我們命名爲:基於 React 技術棧的中心路由基座式微前端。在具體實現上,我們會分爲動態化方案路由配置信息設計子工程接口設計複用方案設計和流程方案設計等幾個模塊來逐一進行說明。

動態化方案

首先,我們需要路由的管理方案,使得子工程之間有能力互通切換。其次,我們需要 Store 層的方案,讓子工程有能力使用全局 Store。並且,我們還需要 CSS 的加載方案,來加載子工程的樣式佈局。下面來詳細說明這三個方案。

動態路由

動態路由方案是想要進行路由級別的拆分,首先我們要確定用什麼來管理路由?很多實現方案傾向於使用特製路由來管理模塊。例如開源框架 Single-Spa,實現了自己的一套路由監聽來切換子工程,並且需要子工程實現特定的註冊、掛載、卸載等接口來完成子工程和基座工程的動態對接,還需要特定的模塊管理系統,例如 systemjs 來輔助完成這一過程。毋庸置疑,這對我們原有工程的改造成本很大,還需要添加額外庫,進而造成包體積大小上的開銷。並且子工程的開發者需要熟悉這些特定的接口,學習成本也比較高。顯然,這對於我們的業務場景和需求來說很不划算。

那麼,我們選擇什麼來做路由管理呢?最終我們使用了 React-Router,這樣能夠保持我們原來的技術棧不變,同時對於工程的侵入也是最低,幾乎可以忽略不計。此外,React-Router 完全可以滿足我們的需求,而且自動會幫助我們管理頁面的加載與卸載,而不是每次切換路由都重新初始化整個子應用,所以在加載速度體驗上也是最優的,跟單頁應用的體驗一致。

在實現上也很簡單,如下圖所示:

上面這個流程圖,展示了我們在基座工程中切換到子工程路由時,加載子工程並進行展示的過程。這裏的重點步驟是加載子工程入口文件,並動態註冊子工程路由的過程。由於我們使用的是 React-Router,顯然要使用其提供的動態能力來完成。這一過程也非常輕量,由於 React-Router 從版本 4 開始有了 “破壞級” 的升級,於是我們就調研了兩種方式進行動態加載路由(目前我們使用的是 React-Router 版本 5),如下表所示:

React-Router 版本 3 中,實現的基本代碼思路如下:

// react-router V3 用於接收子工程的路由
export default () =(
    <Route
        path="/subapp"
        getChildRoutes={(location: any, cb: any) ={
            const { pathname } = location.location;
            // 取路徑中標識子工程前綴的部分, 例如 '/subapp/xxx/index' 其中xxx即路由唯一前綴
            const id = pathname.split('/')[2];
            const subappModule = (subAppMapInfo as any)[id];
            if (subappModule) {
                if (subappRoutes[id]) {
                    // 如果已經加載過該子工程的模塊,則不再加載,直接取緩存的routes
                    cb(null, [subappRoutes[id]]);
                    return;
                }
                // 如果能匹配上前綴則加載相應子工程模塊
                currentPrefix = id;
                loadAsyncSubapp(subappModule.js)
                    .then(() ={
                        // 加載子工程完成
                        cb(null, [subappRoutes[id]]);
                    })
                    .catch(() ={
                        // 如果加載失敗
                        console.log('loading failed');
                    });
            } else {
                // 可以重定向到首頁去
                goBackToIndex();
            }
        }}
    />
);

而在 React-Router 版本 4 中,實現的基本代碼思路如下:

export const AyncComponent: React.FC<{ hotReload?: number; } & RouteComponentProps> = ({ location, hotReload }) ={
    // 子工程資源是否加載完成
    const [ayncLoaded, setAyncLoaded] = useState(false);
    // 子工程url配置信息是否加載完成
    const [subAppMapInfoLoaded, setSubAppMapInfoLoaded] = useState(false);
    const [ayncComponent, setAyncComponent] = useState(null);
    const { pathname } = location;
    // 取路徑中標識子工程前綴的部分, 例如 '/subapp/xxx/index' 其中xxx即路由唯一前綴
    const id = pathname.split('/')[2];
    useEffect(() ={
        // 如果沒有子工程配置信息, 則請求
        if (!subAppMapInfoLoaded) {
            fetchSubappUrlPath(id).then((data) ={
                subAppMapInfo = data;
                setSubAppMapInfoLoaded(true);
            }).catch((url: any) ={
                // 失敗處理
                goBackToIndex();
            });
            return;
        }
        const subappModule = (subAppMapInfo as any)[id];
        if (subappModule) {
            if (subappRoutes[id]) {
                // 如果已經加載過該子工程的模塊,則不再加載,直接取緩存的routes
                setAyncLoaded(true);
                setAyncComponent(subappRoutes[id]);
                return;
            }
            // 如果能匹配上前綴則加載相應子工程模塊
            // 如果請求成功,則觸發JSONP鉤子window.wmadSubapp
            currentPrefix = id;
            setAyncLoaded(false);
            const jsUrl = subappModule.js;
            loadAsyncSubapp(jsUrl)
                .then(() ={
                    // 加載子工程完成
                    setAyncComponent(subappRoutes[id]);
                    setAyncLoaded(true);
                })
                .catch((urlList) ={
                    // 如果加載失敗
                    setAyncLoaded(false);
                    console.log('loading failed...'); 
                });
        } else {
            // 可以重定向到首頁去
            goBackToIndex();
        }
    }[id, subAppMapInfoLoaded, hotReload]);
    return ayncLoaded ? ayncComponent : null;
};

可以看到,這種方式實現起來非常簡單,不需要額外依賴,同時滿足了我們 “拆分” 的訴求。

動態 Store

對於 Store 層,我們原工程使用的是 Redux,子工程通過路由動態註冊進來天然就可以訪問到全局 Store,所以對於 Store 的訪問能夠自動支持。那麼,如果子工程想要註冊自己的全局 Store 該怎麼辦呢?而且我們還用了 redux-saga 來作爲異步處理方案。redux-saga 如何動態註冊呢?還是利用它們各自的 API 就可以達到我們的目的?從下圖中可以看到,支持動態 Store 也是花費很小的改造成本就可以完成。

動態 CSS

同樣的對應子工程的樣式佈局,我們也需要通過某種途徑加載到基座工程中來。這個很自然地用異步加載 CSS 文件通過 style 標籤注入來完成,不過這裏需要注意兩個問題:

一個問題是,加載子工程的 JS 入口文件和 CSS 文件可以同時發起請求,但是需要保證 CSS 文件加載完成後再進行 JS 入口文件的路由註冊。因爲如果路由先註冊了頁面就會顯示出來,如果這時 CSS 文件還沒有加載完畢,就會出現頁面樣式閃動的問題。我們通過先加載 CSS 再加載 JS 的策略來避免這個問題的發生。

另一個問題是,怎麼保證子工程的 CSS 不會和其他子工程衝突。我們利用 PostCSS 插件在編譯子工程時,按照分配給子工程的唯一業務線標識,爲每一組 CSS 規則生成了命名空間來解決這個問題。而子業務線開發者是沒有感知的,可以沒有 “心智負擔” 地書寫子工程的樣式。

路由配置信息方案

在動態加載方案確定之後,基座工程怎麼才能知道子工程的資源路徑,進而加載對應的 JS 和 CSS 資源呢?我們需要一組映射信息。如下圖所示,業務線唯一標識爲 Key,相應的靜態資源地址爲 Value。這樣的話,當基座工程切換到子工程時就可以拉取這個配置信息,在路由切換時準確地找到對應的子工程,進而進行後續的資源加載過程。這裏可能會遇到的一個問題,即如果 JS 和 CSS 過大,是否能進行拆分?

根據我們業務的實際情況,目前靜態資源的大小是可控的,無需註冊多個,單一入口地址完全能夠滿足我們的業務需求,並且由於我們的改造完全基於現有技術棧。如果業務很複雜,完全可以在子工程中通過 webpack 的動態 import 進行路由懶加載,也就是說,子工程完全可以按照路由再次切分成 chunks 來減少 JS 的包體積。至於 CSS 本身就很小,長期也不會有進行切分的需要。

子工程接口方案

子工程需要暴露它要註冊給基座工程的對象,來進行基座工程加載子工程的過程。在子工程入口文件中定義 registerApp 來傳遞註冊的對象,主要代碼如下:

import reducers from 'common/store/labor/reducer';
import sagas from 'common/store/labor/saga';
import routes from './routes/index';
function registerApp(dep: any = {}): any {
    return {
        routes, // 子工程路由組件
        reducers, // 子工程Redux的reducer
        sagas, // 子工程的Redux副作用處理saga
    };
}
export default registerApp

我們這裏暴露了子工程的三個對象:這裏最重要的就是 routes 路由組件,就是在寫 React-Router(版本 4 及以上)的路由。子工程開發者只需要配置 routes 對象即可,沒有任何學習成本,其代碼如下:

/**
 * 子工程路由註冊說明
 * 如註冊的路由如下:
 * path: 'index'
 * 路由前綴會被追加上,路由前綴規則見變量urlPrefix
 * 在主工程的訪問路勁爲:/subapp/${工程註冊名稱}/index
 */
const urlPrefix = `/subapp/${microConfig.name}/`;
const routes = [
    {
        path: 'index',
        component: IndexPage,
    },
];
const AppRoutes = () =(
    <Switch>
        {
            routes.map(item =(
                <Route
                    key={item.path}
                    exact
                    path={`${urlPrefix}${item.path}`}
                    component={item.component}
                />
            ))
        }
        <Redirect to="/" />
    </Switch>
);
export default AppRoutes;

除了上方的 routes 對象,還剩下兩個接口對象是:reducers 和 sagas,用於動態註冊全局 Store 相關的數據和副作用處理。這兩個接口我們在子工程中暫時沒有開放,因爲按照業務線拆分過後,由於業務線間獨立性很強,全局 Store 的意義就不大了。我們希望子工程可以自行處理自己的 Store,即每個業務線維護自己的 Store,這裏就不再展開進行說明了。

複用方案

基座工程除了路由管理之外,還作爲共享層共享全局的基建,例如框架基本庫、業務組件等。這樣做的目的是,子業務線間如果有相同的依賴,切換的時候就不會出現重複加載的問題。例如下面的代碼,我們把 React 相關庫都以全局的方式導出,而子工程加載的時候就會以 external 的形式加載這些庫,這樣子工程的開發者不需要額外的第三方模塊加載器,直接引用即可,和平時開發 React 應用一致,沒有任何學習成本。而和各個業務都相關的公用組件等,我們會放到 wmadMicro 的全局命名空間下進行管理。主要代碼如下:

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as ReactRouterDOM from 'react-router-dom';
import * as Axios from 'axios';
import * as History from 'history';
import * as ReactRedux from 'react-redux';
import * as Immutable from 'immutable';
import * as ReduxSagaEffects from 'redux-saga/effects';
import Echarts from 'echarts';
import ReactSlick from 'react-slick';

function registerGlobal(root: any, deps: any) {
    Object.keys(deps).forEach((key) ={
        root[key] = deps[key];
    });
}
registerGlobal(window, {
    // 在這裏註冊暴露給子工程的全局變量
    React,
    ReactDOM,
    ReactRouterDOM,
    Axios,
    History,
    ReactRedux,
    Immutable,
    ReduxSagaEffects,
    Echarts,
    ReactSlick,
});
export default registerGlobal;

流程方案

在確定了程序拆分運行的整體銜接之後,我們還要確定開發方案部署方案以及回滾方案。我們如何開始開發一個子工程?以及我們如何部署我們的子工程?

開發流程

有兩種開發方案可以滿足獨立開發的目的:第一種是提供一個基座工程的 Dev 環境,子工程在本地啓動後在 Dev 環境進行開發,這種開發方式要求有一套基座工程的更新機制,例如基座工程更新後要同步部署到 Dev 環境。第二種是子工程開發者拉取基座工程到本地並啓動本地開發環境,然後拉取子工程到本地,再啓動子工程本地開發環境進行開發,這種開發方式是目前我們使用的方式。如下圖所示,我們提供了子工程腳手架來快速創建子工程,開發者無需做任何配置和額外學習成本,就可以像開發 React 應用一樣進行開發。

熱更新

在開發過程中,我們希望我們的開發體驗和開發單頁應用的體驗一致,也要支持熱更新。由於我們的拆分,實際上有兩個服務,即基座和子工程,所以我們以上圖的方式完成了熱更新的支持:在子工程的 module.hot 中通過再次觸發基座工程中的 JSONP 鉤子來通知基座工程,來再次觸發 renderApp 達到子工程更新代碼則頁面熱刷新的目的。主要代碼如下:

// 在子工程入口文件
import routes from './routes/index';
function registerApp(dep: any = {}): any {
    return {
        routes,
    };
}
if ((module as any).hot) {
    (module as any).hot.accept('./routes/index'()any ={
        window.wmadSubapp(registerApp, true); // 支持子工程熱加載的信息傳遞
    });
}
export default registerApp

Mock 數據

子工程目前 Mock 數據的方式有三種:一是在基座本地 Mock,這種 Mock 方式天然支持,因爲基座工程基於外賣工程化 Nine 腳手架進行開發,本身支持本地 Mock。二是支持子工程本地 Mock。三是使用公共 Mock 服務 YAPI。目前子工程開發的 Mock 功能結合第一種方式和第三種方式進行。

部署方案

最後是部署方案,我們達成了獨立部署上線的目的,即子工程發佈不需要基座工程的參與。之前所有子業務線都在一個工程中,打包速度隨着業務線的膨脹變得越來越慢,而如下的方案使得子工程的開發和部署完全獨立,單個業務線的打包速度會非常快,從之前的分鐘級別降到了秒級別。如下圖所示,子工程部署只需要把子工程打包,並在上傳 CDN 之後,把配置信息更新即可,因爲配置信息中有子工程新的資源地址,這樣就達到了發佈上線的目的。

整個部署過程我們是託管到 Talos(美團內部自研的部署工具)上的,配置信息我們是託管到 Portm(美團內部自研的文件存儲)上的(通過我們開發的 Talos 的插件 UpdatePubInfo-To-Portm 來更新我們的配置信息)。在靜態資源上傳到 CDN 之後,就可以更新配置信息,供主工程調用,也就完成了子工程上線的過程。利用美團現有服務,我們很迅速地完成了子工程單獨部署上線的整個流程。

回滾方案

在部署方案中,我們通過 Talos 進行部署,它本身就帶有回滾功能。得益於子工程的發佈和普通工程的發佈並沒什麼本質不同,都是將靜態資源放置到 CDN 上,通過靜態資源的的 contenthash 值來區分不同版本,所以回滾的時候,Talos 取到上個版本(或者某個前版本)的靜態資源,再通過 Portm 更新我們的配置信息即可完成。整個過程和普通工程沒有區別,發版人員只需簡單地點下回滾按鈕即可。

監控方案

改變了原有的開發模式後,我們還對幾個關鍵節點進行了監控報警的埋點。利用美團 CAT(已經在 GitHub 上開源)和天網(美團內部的監控系統),我們分別在子工程的配置信息、靜態資源加載等節點上進行了埋點上報,統計子工程加載成功率,及時發現可能出現的子工程切換問題。具體情況如下圖所示:

上方左圖是按照端維度進行統計的示例,上方右圖是 PC 端按照產品線統計加載成功數的示例。默認都是統計當天的數據,顯示‘-’的表明當前沒有數據。對資源加載的監控目前有三種類型:JSON、JS 和 CSS,資源加載失敗的統計也包含這三種類型。天網的監控按照分鐘級進行,每分鐘內如果有加載失敗就會發出報警,偶爾的報警可能是用戶網絡的問題,如果出現大批量的報警就要引起重視了。

總結

以上就是微前端在外賣商家廣告端的實踐過程。總的來說,我們完成了以下的目標:

目前在美團廣告端,以微前端模式上線的子業務線已經有很多個。另外還有多個正在開發的微前端子工程,剩餘在主工程中的子業務線後續也可以無痛遷移出來成爲子工程。我們內部也在此過程中搜集了不少意見反饋,未來繼續在實踐中進行思考和完善。在此過程中,我們深知還有很多做得不夠完善甚至存在問題的地方,歡迎大家跟我們進行交流,幫我們提出寶貴意見或者給予指導。當然也歡迎大家加入我們團隊(文末有招聘信息),一起共建。

作者簡介

張嘯、魏瀟、天堯,均爲美團外賣前端團隊研發工程師。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5X-IRvH60x8HjvTgEw7O5Q