前端微服務化解決方案

作者:Alili前端大暴炸的前端微服務化解決方案系列
鏈接:https://www.jianshu.com/u/2aa7a9ad33ad
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

近幾年,微服務架構在後端技術社區大紅大紫,它被認爲是IT軟件架構的未來技術方向.我們如何借鑑後端微服務的思想來構建一個現代化前端應用?
在這裏我提供一個可以在產品中真正可以落地的前端微服務解決方案.

微服務化後端前後端對比

後端微服務化的優勢:

  1. 複雜度可控: 體積小、複雜度低,每個微服務可由一個小規模開發團隊完全掌控,易於保持高可維護性和開發效率。
  2. 獨立部署: 由於微服務具備獨立的運行進程,所以每個微服務也可以獨立部署。
  3. 技術選型靈活: 微服務架構下,技術選型是去中心化的。每個團隊可以根據自身服務的需求和行業發展的現狀,自由選擇最適合的技術棧。
  4. 容錯: 當某一組建發生故障時,在單一進程的傳統架構下,故障很有可能在進程內擴散,形成應用全局性的不可用。
  5. 擴展: 單塊架構應用也可以實現橫向擴展,就是將整個應用完整的複製到不同的節點。

前端微服務化後的優勢:

  1. 複雜度可控: 每一個UI業務模塊由獨立的前端團隊開發,避免代碼巨無霸,保持開發時的高速編譯,保持較低的複雜度,便於維護與開發效率。
  2. 獨立部署: 每一個模塊可單獨部署,顆粒度可小到單個組件的UI獨立部署,不對其他模塊有任何影響。
  3. 技術選型靈活: 也是最具吸引力的,在同一項目下可以使用如今市面上所有前端技術棧,也包括未來的前端技術棧。
  4. 容錯: 單個模塊發生錯誤,不影響全局。
  5. 擴展: 每一個服務可以獨立橫向擴展以滿足業務伸縮性,與資源的不必要消耗;

我們何時需要前端微服務化?

  1. 項目技術棧過於老舊,相關技能的開發人員少,功能擴展喫力,重構成本高,維護成本高.
  2. 項目過於龐大,代碼編譯慢,開發體差,需要一種更高維度的解耦方案.
  3. 單一技術棧無法滿足你的業務需求

其中面臨的問題與挑戰

我們即將面臨以下問題:

  • 我們如何實現在一個頁面裏渲染多種技術棧?
  • 不同技術棧的獨立模塊之間如何通訊?
  • 如何通過路由渲染到正確的模塊?
  • 在不同技術棧之間的路由該如何正確觸發?
  • 項目代碼別切割之後,通過何種方式合併到一起?
  • 我們的每一個模塊項目如何打包?
  • 前端微服務化後我們該如何編寫我們的代碼?
  • 獨立團隊之間該如何協作?

技術選型

經過各種技術調研我們最終選擇的方案是基於 Single-SPA 來實現我們的前端微服務化.

Single-SPA

一個用於前端微服務化的JavaScript前端解決方案

使用Single-SPA之後,你可以這樣做:

  • (兼容各種技術棧)在同一個頁面中使用多種技術框架(React, Vue, AngularJS, Angular, Ember等任意技術框架),並且不需要刷新頁面.
  • (無需重構現有代碼)使用新的技術框架編寫代碼,現有項目中的代碼無需重構.
  • (更優的性能)每個獨立模塊的代碼可做到按需加載,不浪費額外資源.
  • 每個獨立模塊可獨立運行.

下面是一個微前端的演示頁面 (你可能需要科學的上網)
https://single-spa.surge.sh/

以上是官方例子,但是官方例子中並沒有解決一個問題.就是各種技術棧的路由實現方式大相徑庭,如何做到路由之間的協同?
後續文章會講解,如何解決這樣的問題.

單體應用對比前端微服務化

普通的前端單體應用

 

微前端架構

 

Single-SPA的簡單用法

1.創建一個HTML文件

<html>
<body>
    <div id="root"></div>
    <script src="single-spa-config.js"></script>
</body>
</html>

2.創建single-spa-config.js 文件

// single-spa-config.js
import * as singleSpa from 'single-spa';

// 加載react 項目的入口js文件 (模塊加載)
const loadingFunction = () => import('./react/app.js');

// 當url前綴爲 /react的時候.返回 true (底層路由)
const activityFunction = location => location.pathname.startsWith('/react');

// 註冊應用 
singleSpa.registerApplication('react', loadingFunction, activityFunction);

//singleSpa 啓動
singleSpa.start();

封裝React項目的渲染出口文件

我們把渲染react的入口文件修改成這樣,便可接入到single-spa

import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'
import RootComponent from './root.component'

if (process.env.NODE_ENV === 'development') {
  // 開發環境直接渲染
  ReactDOM.render(<RootComponent />, document.getElementById('root'))
}

//創建生命週期實例
const reactLifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: RootComponent
  domElementGetter: () => document.getElementById('root')
})

// 項目啓動的鉤子
export const bootstrap = [
  reactLifecycles.bootstrap,
]
// 項目啓動後的鉤子
export const mount = [
  reactLifecycles.mount,
]
// 項目卸載的鉤子
export const unmount = [
  reactLifecycles.unmount,
]

 

微前端的模塊加載器,主要功能爲:

  • 項目配置文件的加載
  • 項目對外接口文件的加載(消息總線會用到,後續會提)
  • 項目入口文件的加載

以上也是每一個單模塊,不可缺少的三部分

配置文件

我們實踐微前端的過程中,我們對每個模塊項目,都有一個對外的配置文件.
是模塊在註冊到singe-spa時候所用到的信息.

 

{
    "name": "name", //模塊名稱
    "path": "/project", //模塊url前綴
    "prefix": "/module-prefix/", //模塊文件路徑前綴
    "main": "/module-prefix/main.js", //模塊渲染出口文件
    "store": "/module-prefix/store.js",//模塊對外接口
    "base": true 
    // 當模塊被定性爲baseApp的時候,
    // 不管url怎麼變化,項目也是會被渲染的,
    // 使用場景爲,模塊職責主要爲整個框架的佈局或者一直被渲染,不會改變的部分
  }

當我們的模塊,有多種url前綴的時候,path也可以爲數組形式

 

{
    "path": ["/project-url-path1/","/project-url-path2/"], //項目url前綴
  }

配置自動化

我們每個模塊都有上面所描述的配置文件,當我們的項目多個模塊的時候,我們需要把所有模塊的配置文件聚合起來.
我這裏也有寫一個腳本.

micro-auto-config

使用方法:

npm install micro-auto-config -g

# 在項目根目錄,用pm2啓動該腳本,便可啓動這個項目的配置自動化
pm2 start micro-auto-config

大概思路是:當模塊部署,服務器檢測到項目文件發生改變,便開始找出所有模塊的配置文件,把他們合併到一起.
以數組包對象的形式輸出一個總體的新配置文件 project.config.js.
當我們一個模塊配置有更新,部署到線上的時候,項目配置文件會自動更新.

模塊加載器

這個文件直接引入到html中,也就是上一篇文章中的single-spa-config.js 升級版.
在加載模塊的時候,我們使用SystemJS作爲我們的模塊加載工具.

"use strict";
import '../libs/es6-promise.auto.min'
import * as singleSpa from 'single-spa'; 
import { registerApp } from './Register'

async function bootstrap() {
    // project.config.js 文件爲所有模塊的配置集合
    let projectConfig = await SystemJS.import('/project.config.js' )

    // 遍歷,註冊所有模塊
    projectConfig.projects.forEach( element => {
        registerApp({
            name: element.name,
            main: element.main,
            url: element.prefix,
            store:element.store,
            base: element.base,
            path: element.path
        });
    });
    
    // 項目啓動
    singleSpa.start();
}

bootstrap()

Register.js

import '../libs/system'
import '../libs/es6-promise.auto.min'
import * as singleSpa from 'single-spa';

// hash 模式,項目路由用的是hash模式會用到該函數
export function hashPrefix(app) {
    return function (location) {
        let isShow = false
        //如果該應用 有多個需要匹配的路勁
        if(isArray(app.path)){
            app.path.forEach(path => {
                if(location.hash.startsWith(`#${path}`)){
                    isShow = true
                }
            });
        }
        // 普通情況
        else if(location.hash.startsWith(`#${app.path || app.url}`)){
            isShow = true
        }
        return isShow;
    }
}

// pushState 模式
export function pathPrefix(app) {
    return function (location) {
        let isShow = false
        //如果該模塊 有多個需要匹配的路徑
        if(isArray(app.path)){
            app.path.forEach(path => {
                if(location.pathname.indexOf(`${path}`) === 0){
                    isShow = true
                }
            });
        }
        // 普通情況
        else if(location.pathname.indexOf(`${app.path || app.url}`) === 0){
            isShow = true
        }
        return isShow;
    }
}

// 應用註冊
export async function registerApp(params) {

    singleSpa.registerApplication(params.name, () => SystemJS.import(params.main), params.base ? (() => true) : pathPrefix(params));

}

//數組判斷 用於判斷是否有多個url前綴
function isArray(o){
    return Object.prototype.toString.call(o)=='[object Array]';
}

 

微前端的消息總線,主要的功能是搭建模塊與模塊之間通訊的橋樑.

黑盒子

問題1:

應用微服務化之後,每一個單獨的模塊都是一個黑盒子,
裏面發生了什麼,狀態改變了什麼,外面的模塊是無從得知的.
比如模塊A想要根據模塊B的某一個內部狀態進行下一步行爲的時候,黑盒子之間沒有辦法通信.這是一個大麻煩.

問題2

每一個模塊之間都是有生命週期的.當模塊被卸載的時候,如何才能保持後續的正常的通信?

ps. 我們必須要解決這些問題,模塊與模塊之間的通訊太有必要了.

打破壁壘

在github上single-spa-portal-example,給出來一解決方案.

基於Redux實現前端微服務的消息總線(不會影響在編寫代碼的時候使用其他的狀態管理工具).

大概思路是這樣的:
每一個模塊,會對外提供一個 Store.js.這個文件
裏面的內容,大致是這樣的.

import { createStore, combineReducers } from 'redux'

const initialState = {
  refresh: 0
}

function render(state = initialState, action) {
  switch (action.type) {
    case 'REFRESH':
      return { ...state,
        refresh: state.refresh + 1
      }
    default:
      return state
  }
}

// 向外輸出 Reducer
export const storeInstance = createStore(combineReducers({ namespace: () => 'base', render }))

對於這樣的代碼,有沒有很熟悉?
對,他就是一個普通的Reducer文件,
每一個模塊對外輸出的Store.js,就是一個模塊的Reducer.

Store.js 如何被使用?

我們需要在模塊加載器中,導出這個Store.js

於是我們對模塊加載器中的Register.js文件 (該文件在上一章出現過,不懂的同學可以往回看)

進行了以下改造:

import * as singleSpa from 'single-spa';

//全局的事件派發器 (新增)
import { GlobalEventDistributor } from './GlobalEventDistributor' 
const globalEventDistributor = new GlobalEventDistributor();


// hash 模式,項目路由用的是hash模式會用到該函數
export function hashPrefix(app) {
...
}

// pushState 模式
export function pathPrefix(app) {
...
}

// 應用註冊
export async function registerApp(params) {
    // 導入派發器
    let storeModule = {}, customProps = { globalEventDistributor: globalEventDistributor };

    // 在這裏,我們會用SystemJS來導入模塊的對外輸出的Reducer(後續會被稱作模塊對外API),統一掛載到消息總線上
    try {
        storeModule = params.store ? await SystemJS.import(params.store) : { storeInstance: null };
    } catch (e) {
        console.log(`Could not load store of app ${params.name}.`, e);
        //如果失敗則不註冊該模塊
        return
    }

    // 註冊應用於事件派發器
    if (storeModule.storeInstance && globalEventDistributor) {
        //取出 redux storeInstance
        customProps.store = storeModule.storeInstance;

        // 註冊到全局
        globalEventDistributor.registerStore(storeModule.storeInstance);
    }

    //當與派發器一起組裝成一個對象之後,在這裏以這種形式傳入每一個單獨模塊
    customProps = { store: storeModule, globalEventDistributor: globalEventDistributor };

    // 在註冊的時候傳入 customProps
    singleSpa.registerApplication(params.name, () => SystemJS.import(params.main), params.base ? (() => true) : pathPrefix(params), customProps);
}

全局派發器 GlobalEventDistributor

全局派發器,主要的職責是觸發各個模塊對外的API.

GlobalEventDistributor.js

export class GlobalEventDistributor {

    constructor() {
        // 在函數實例化的時候,初始一個數組,保存所有模塊的對外api
        this.stores = [];
    }

    // 註冊
    registerStore(store) {
        this.stores.push(store);
    }

    // 觸發,這個函數會被種到每一個模塊當中.便於每一個模塊可以調用其他模塊的 api
    // 大致是每個模塊都問一遍,是否有對應的事件觸發.如果每個模塊都有,都會被觸發.
    dispatch(event) {
        this.stores.forEach((s) => {
            s.dispatch(event)
        });
    }

    // 獲取所有模塊當前的對外狀態
    getState() {
        let state = {};
        this.stores.forEach((s) => {
            let currentState = s.getState();
            console.log(currentState)
            state[currentState.namespace] = currentState
        });
        return state
    }
}

在模塊中接收派發器以及自己的Store

上面提到,我們在應用註冊的時候,傳入了一個 customProps,裏面包含了派發器以及store.
在每一個單獨的模塊中,我們如何接收並且使用傳入的這些東西呢?

import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'
import RootComponent from './root.component'
import { storeInstance, history } from './Store'
import './index.less'


const reactLifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: (spa) => {
    // 我們在創建生命週期的時候,把消息總線傳入的東西,以props的形式傳入組件當中
    // 這樣,在每個模塊中就可以直接調用跟查詢其他模塊的api與狀態了
    return <RootComponent  store={spa.customProps.store.storeInstance} globalEventDistributor={spa.customProps.globalEventDistributor} />
  },
  domElementGetter: () => document.getElementById('root')
})

export const bootstrap = [
  reactLifecycles.bootstrap,
]

export const mount = [
  reactLifecycles.mount,
]

export const unmount = [
  reactLifecycles.unmount,
]

路由分發式微前端

從應用分發路由到路由分發應用

用這句話來解釋,微前端的路由,再合適不過來.

路由分發式微前端,即通過路由將不同的業務分發到不同的、獨立前端應用上。其通常可以通過 HTTP 服務器的反向代理來實現,又或者是應用框架自帶的路由來解決。
就當前而言,通過路由分發式的微前端架構應該是採用最多、最易採用的 “微前端” 方案。但是這種方式看上去更像是多個前端應用的聚合,即我們只是將這些不同的前端應用拼湊到一起,使他們看起來像是一個完整的整體。但是它們並不是,每次用戶從 A 應用到 B 應用的時候,往往需要刷新一下頁面。 -- 引用自phodal 微前端的那些事兒

模塊加載器那一章的示例代碼,已經非常充分了展示了路由分發應用的步驟.

在單頁面前端的路由,目前有兩種形式,
一種是所有主流瀏覽器都兼容多hash路由,
基本原理爲url的hash值的改變,觸發了瀏覽器onhashchange事件,來觸發組件的更新

還有一種是高級瀏覽器才支持的 History API,
window.history.pushState(null, null, "/profile/");的時候觸發組件的更新

 

// hash 模式,項目路由用的是hash模式會用到該函數
export function hashPrefix(app) {
    return function (location) {
        let isShow = false
        //如果該應用 有多個需要匹配的路勁
        if(isArray(app.path)){
            app.path.forEach(path => {
                if(location.hash.startsWith(`#${path}`)){
                    isShow = true
                }
            });
        }
        // 普通情況
        else if(location.hash.startsWith(`#${app.path || app.url}`)){
            isShow = true
        }
        return isShow;
    }
}

// pushState 模式
export function pathPrefix(app) {
    return function (location) {
        let isShow = false
        //如果該模塊 有多個需要匹配的路徑
        if(isArray(app.path)){
            app.path.forEach(path => {
                if(location.pathname.indexOf(`${path}`) === 0){
                    isShow = true
                }
            });
        }
        // 普通情況
        else if(location.pathname.indexOf(`${app.path || app.url}`) === 0){
            isShow = true
        }
        return isShow;
    }
}

// 應用註冊
export async function registerApp(params) {
    // 第三個參數爲,該模塊是否顯示
    singleSpa.registerApplication(params.name,  // 模塊名字
                                  () => SystemJS.import(params.main), // 模塊渲染的入口文件
                                  params.base ? (() => true) : pathPrefix(params) // 模塊顯示的條件
                                  );

}

路由分發應用

當url前綴,與配置中的url前綴保持一致的時候,
singleSpa會激活對應的模塊,然後把模塊內容渲染出來.

應用分發路由

在模塊被激活的時候,模塊會讀取url,再渲染到對的頁面.

這就是微前端路由的路由工作流程

微前端路由的挑戰

Hash路由

在目前所有支持spa的前端框架中,都支持了Hash路由.
Hash路由都工作大致原理就是: url的Hash值的改變,觸發了瀏覽器onhashchange事件,進而來觸發組件的更新.
所有的前端的框架,都是基於onhashchange來更新我們的頁面的.
當我們的架構使用微前端的話,如果選擇hash路由,便可以保證所有的前端技術框架的更新事件都是一致的.
所以使用Hash路由也是最省心的.如果不介意Hash路由中url的 # 字符,在微前端中使用Hash也是推薦的.

HTML5 History 路由

大家都知道,HTML5中History對象上新增了兩個API (pushState與replaceState).
在這兩個新API的作用下,我們也是可以做到頁面無刷新,並且更新頁面的.並且url上不需要出現#號.
保持了最高的美觀度(對於一些人來講).
當然現在幾乎所有的主流SPA技術框架都支持這一特性.
但是問題是,這兩個API在觸發的時候,是沒有一個全局的事件觸發的.
多種技術框架對History路由的實現都不一樣,就算是技術棧都是 React,他的路由都有好幾個版本.

那我們如何保證一個項目下,多個技術框架模塊的路由做到協同呢?

只有一個history

前提: 假設我們所有的項目用的都是React,我們的路由都在使用着同一個版本.

思路: 我們是可以這樣做的,在我們的base前端模塊(因爲他總是第一個加載,也是永遠都不會被銷燬的模塊)中的Store.js,
實例化一個React router的核心庫history,通過消息總線,把這個實例傳入到所有的模塊中.
在每個模塊的路由初始化的時候,是可以自定義自己的history的.把模塊的history重新指定到傳入的history.
這樣就可以做到,所有模塊的路由之間的協同了.
因爲當頁面切換的時候,history觸發更新頁面的事件,當所有模塊的history都是一個的時候,所有的模塊都會更新到正確的頁面.
這樣就保證了所有模塊與路由都協同.

如果你看不懂我在講什麼,直接貼代碼吧:

//Base前端模塊的 Store.js
import { createStore, combineReducers } from 'redux'

// react router 的核心庫 history
import createHistory from 'history/createBrowserHistory'

const history = createHistory()

// 傳出去
export const storeInstance = createStore(combineReducers({ namespace: () => 'base' ,history }))

 

// 應用註冊
export async function registerApp(params) {
    ...

    // history 直接引入進來,用systemjs直接導入實例
    try {
        storeModule = params.store ? await SystemJS.import(params.store) : { storeInstance: null };
    } catch (e) {
        ...
    }
    ...

    // 跟派發器一起放進 customProps 中
    customProps = { store: storeModule, globalEventDistributor: ... };


    // 在註冊的時候傳入 customProps
    singleSpa.registerApplication(params.name, 
                                () => SystemJS.import(params.main), 
                                params.base ? (() => true) : pathPrefix(params), 
                                customProps // 應用註冊的時候,history會包含在 customProps 中,直接注入到模塊中
                                );
}

 

// React main.js
import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'
import RootComponent from './root.component'

const reactLifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: (spa) => {
    // 在這裏,把history傳入到組件
    return <RootComponent  history={spa.customProps.history}/>
  },
  domElementGetter: () => document.getElementById('root')
})

...

 

// RootComponent
import React from 'react'
import { Provider } from 'react-redux' 
export default class RootComponent extends React.Component {
    render() {
        return <Provider store={this.state.store}>
            // 在這裏重新指定Router的history
          <Router history={this.props.history}>
            <Switch>
                ...
            </Switch>
          </Router>
        </Provider>
    }
}

以上就是讓所有模塊的路由協同,保證只有一個history的用法

多技術棧模塊路由協同

問題: 用上面的方式是可行的,但是遺憾的是,他的應用場景比較小,只能在單一技術棧,單一路由版本的情況下使用.
微前端最大的優勢之一就是自由選擇技術棧.
在一個項目中,使用多個適合不同模塊的技術棧.

思路: 我們其實是可以通過每一個模塊對外輸出一個路由跳轉到接口,基於消息總線的派發,讓每一個模塊渲染到正確的頁面.
比如 模塊A要跳轉到 /a/b/c ,模塊a先更新到/a/b/c路由的頁面,然後通過消息總線,告訴所有模塊,現在要跳轉到 /a/b/c了.
然後其他模塊,有/a/b/c這個路由都,就直接跳轉,沒有的就什麼都不做.

我們可以這樣做:

// Store.js
import { createStore, combineReducers } from 'redux'
import createHistory from 'history/createBrowserHistory'
const history = createHistory()

// 對外輸出一個to的接口,當一個模塊需要跳轉界面的時候,會向所有的模塊調用這個接口,
// 然後對應的模塊會直接渲染到正確的頁面
function to(state, action) {
  if (action.type !== 'to' ) return { ...state, path: action.path }
  history.replace(action.path)
  return { ...state, path: action.path }
}

export const storeInstance = createStore(combineReducers({ namespace: () => 'base', to }))

export { history }

微前端打包構建

微前端項目的打包,是有一些需要注意的點
以webpack爲例:

amd模塊

在之前的文章,我們有提到我們的加載器,是基於System.js來做的.
所以我們微前端的模塊最終打包,是要符合模塊規範的.
我們使用的是amd模塊規範來構建我們的模塊.

指定基礎路徑

因爲模塊打包後,調用模塊出口文件的,是模塊加載器.
爲了清晰的管理每個模塊,並且正確的加載到我們每一個模塊的資源,
我們給模塊的資源都指定一個publicPath.

下面給出一個簡單的 webpack 配置,這些配置我只是列出一些必要選項.
並不是一個完整的webpack配置,後續我會提供完整的微前端的Demo,提供大家參考
這些配置都是基於 create-react-app 的配置做的修改.
只要明白了配置的意圖,明白我們打包出來的最終是一個什麼樣的包,
不管打包工具以後怎麼變,技術棧怎麼變,最後都是可以對接到微前端中來.

這裏給出 project.json 的內容,便於後面的配置文件的閱讀

// project.json
{
    "name": "name", //模塊名稱
    "path": "/project", //模塊url前綴
    "prefix": "/module-prefix/", //模塊文件路徑前綴
    "main": "/module-prefix/main.js", //模塊渲染出口文件
    "store": "/module-prefix/store.js",//模塊對外接口
    "base": true // 是否爲baseapp
  }

 


// 引入項目配置文件,也是前面說的 模塊加載器必要文件之一
const projectConfig = require('./project.json')

let config = {
  entry: {
    main: paths.appIndexJs, //出口文件,模塊加載器必要文件之一
    store: paths.store // 對外api的reducer文件,模塊加載器必要文件之一
  },
  output: {
    path: paths.appBuild,
    filename: '[name].js?n=[chunkhash:8]',
    chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
    publicPath: projectConfig.prefix, //在output中指定模塊配置好的 publicPath
    libraryTarget: 'amd', //對外輸出 amd模塊,便於 system.js加載
    library: projectConfig.name, //模塊的名稱
  },
  },
  module: {
    rules: [
      {
        oneOf: [
          {
            test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
            // loader: 'happypack/loader?id=url',
            loaders: [{
              loader: require.resolve('url-loader'),
              options: {
                limit: 5000,
                name: 'static/media/[name].[hash:8].[ext]',
                publicPath: projectConfig.prefix, //我們需要在靜態文件的loader加上publicPath
              },
            }]
          },
          {
            test: /\.(js|jsx|mjs)$/,
            include: paths.appSrc,
            loader: 'happypack/loader?id=babel',
            options: {
                name: 'static/js/[name].[hash:8].[ext]',
                publicPath: projectConfig.prefix, //在靜態文件的loader加上publicPath
              },
          },
          {
            loader: require.resolve('file-loader'),
            exclude: [/\.(js|jsx|mjs)$/, /\.html$/, /\.json$/],
            options: {
              name: 'static/media/[name].[hash:8].[ext]',
              publicPath: projectConfig.prefix, //在靜態文件的loader加上publicPath
            },
          },
        ],
      },
    ],
  },
}

部署

前端單頁面的部署,不管怎麼自動化,工具怎麼變.
都是把打包好的靜態文件,放到服務器的正確位置下.
微前端的部署,是一個應用聚合的過程,我們如何把一個個模塊最後接入到一個完整的項目中的呢?

微前端應用完整目錄

一般會放在一個nginx配置好的靜態目錄裏,或者是其他web容器的一個靜態目錄.
看到這個目錄結構,你應該能理解爲什麼要額外的配置 publicPath 了吧.

├── index.html              // 首先瀏覽器會加載這個index.html,html裏面會引入一個bootstrap.js的文件
├── bootstrap.js            // 這個bootstrap.js是之前說的模塊加載器打包過後的代碼,
│                           // 模塊加載器會先加載 `project.config.js`,得到所有模塊的配置.
│                           // 然後纔開始加載每個項目中的main.js文件,註冊應用,注入store.js
│
├── project.config.js       // 這個文件存到是該項目的所有模塊的配置,是代碼自動生成的
│                           // 之前有提到過項目配置自動化,是這個項目中唯一動態的文件.
│                           // 目的是讓模塊的配置文件更新,或者新增新模塊的時候,模塊會自動掛載到項目中來
│                           // 他會遍歷每一個模塊的project.json文件,取出內容,合併到一起
│
├── projectA                // 模塊A目錄
│   ├── asset-manifest.json
│   ├── favicon.ico
│   ├── main.js             // 渲染用的出口文件
│   ├── manifest.json
│   ├── project.json        // 模塊的配置文件
│   ├── static
│   │   ├── js
│   │   │   ├── 0.86ae3ec3.chunk.js
│   │   └── media
│   │       └── logo.db0697c1.png
│   └── store.js            //對外輸出的store.js 文件
└── projectB                // 模塊B (重要文件的位置,與模塊A是一致的)
    ├── asset-manifest.json
    ├── main.js
    ├── manifest.json
    ├── project.json
    ├── static
    │   ├── js
    │   │   ├── 0.86ae3ec3.chunk.js
    │   └── media
    │       └── logo.db0697c1.png
    └── store.js

配置自動化

我們每個模塊都有上面所描述的配置文件,當我們的項目多個模塊的時候,我們需要把所有模塊的配置文件聚合起來.
我這裏也有寫一個腳本.

micro-auto-config

使用方法:

npm install micro-auto-config -g

# 在項目根目錄,用pm2啓動該腳本,便可啓動這個項目的配置自動化
pm2 start micro-auto-config --name 你的項目名稱-auto-config

這樣之後 project.config.js 就會自動生成,以及模塊變動之後也會重新生成.

動態入口

當有新的子模塊會掛載到項目中的時候,在UI中肯定需要一個新的入口進入子模塊的UI.
而這樣一個入口,是需要動態生成的.

例如:圖中左邊的菜單,不應該是代碼寫死的.而是根據每個模塊提供的數據自動生成的.

不然每次發佈新的模塊,我們都需要在最外面的這個框架修改代碼.這樣就談不上什麼獨立部署了.

靜態數據共享

想要達到上面所的效果,我們可以這樣做.

// ~/common/menu.js

import { isUrl } from '../utils/utils'
let menuData = [
  {
    name: '模塊1',
    icon: 'table',
    path: 'module1',
    rank: 1,
    children: [
      {
        name: 'Page1',
        path: 'page1',
      },
      {
        name: 'Page2',
        path: 'page2',
      },
      {
        name: 'Page3',
        path: 'page3',
      },
    ],
  }
]
let originParentPath = '/'
function formatter(data, parentPath = originParentPath, parentAuthority) {
    ...
}

// 在這裏,我們對外導出 這個模塊的菜單數據
export default menuData

 

// Store.js
import { createStore, combineReducers } from 'redux'
import menuDate from './common/menu'
import createHistory from 'history/createBrowserHistory'
const history = createHistory()
...

// 我們拿到數據之後,用一個reducer函數返回我們的菜單數據.
function menu() {
  return menuDate
}

...


// 最終以Store.js對外導出我們的菜單數據,在註冊的時候,每個應用都可以拿到這個數據了
export const storeInstance = createStore(combineReducers({ namespace: () => 'list', menu, render, to }))

當我們的Base模塊,拿到所有子模塊的菜單數據,把他們合併後,就可以渲染出正確的菜單了.

二次構建

進一步優化我們的微前端性能

在微前端這種形勢的架構,每個模塊都會輸出固定的文件,比如之前說的:

  • 項目配置文件
  • Store.js 文件
  • main.js 渲染入口文件

這三個,是微前端架構中每個模塊必要的三個文件.

在模塊加載器啓動整個項目的時候,都必須要加載所有模塊的配置文件與Store.js文件.
在前面的文章中有說 配置自動化的問題,這其實就是一種簡單的二次構建.
雖然每一個模塊的配置文件體積不是很大,但是每一個文件都會加載,是項目啓動的必要文件.
每一個文件都會佔一個http請求,每一個文件的阻塞都會影響項目的啓動時間.

所以,我們的Store.js也必須是要優化的.
當然如果我們的模塊數量不是很多的話,我們沒有優化的必要.但是一旦項目變得更加龐大,有好幾十個模塊.
我們不可能一次加載幾十個文件,我們必須要在項目部署之後,還要對整個項目重新再次構建來優化與整合我們的項目.

我們的Store.js 是一個amd模塊,所以我們需要一個合併amd模塊的工具.

Grunt or Gulp

像這樣的場景,用grunt,gulp這樣的任務管理工具再合適不過了.
不管這兩個工具好像已經是上個世紀的東西了,但是他的生態還是非常完善的.用在微前端的二次構建中非常合適.

例如Gulp:

const gulp = require('gulp');
const concat = require('gulp-concat');
 
gulp.task('storeConcat', function () {
    gulp.src('project/**/Store.js')
        .pipe(concat('Store.js')) //合併後的文件名
        .pipe(gulp.dest('project/'));
});

像這樣的優化點還有非常多,在項目發佈之後,在二次構建與優化代碼.
在後期龐大的項目中,是有很多空間來提升我們項目的性能的.

Demo

前端微服務化 Micro Frontend Demo

微前端模塊加載器

微前端Base App示例源碼

微前端子項目示例源碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章