微前端設計理念與實踐

作者|王下邀月熊

編輯|覃雲

來源 | 前端之巔

本文節選自 Web 開發導論 / 微前端與大前端,着眼闡述了微服務與微前端的設計理念以及微服務的潛在可行方案,需要致敬的是,本文的很多考慮借鑑了 Phodal 關於微前端的系列討論以及 Web Architecture Links 中聲明的其他文章,此外結合了自己淺薄的考量與實踐體悟,框架代碼可以參閱 Ueact/micro-frontend。(https://github.com/wxyyxc1992/Ueact)

微前端

微服務與微前端,都是希望將某個單一的單體應用,轉化爲多個可以獨立運行、獨立開發、獨立部署、獨立維護的服務或者應用的聚合,從而滿足業務快速變化及分佈式多團隊並行開發的需求。如康威定律 (Conway’s Law) 所言,設計系統的組織,其產生的設計和架構等價於組織間的溝通結構;微服務與微前端不僅僅是技術架構的變化,還包含了組織方式、溝通方式的變化。微服務與微前端原理和軟件工程,面向對象設計中的原理同樣相通,都是遵循單一職責 (Single Responsibility)、關注分離 (Separation of Concerns)、模塊化 (Modularity) 與分而治之 (Divide & Conquer) 等基本的原則。

微前端設計理念與實踐

在某些場景下,微前端也包含了對於系統的縱向切分;即不同的團隊會負責系統中某個特性 / 模塊,從數據庫、服務端到用戶界面完整的流線。每個團隊會更多地着眼於業務模型與特點。獨立並不意味着完全的切割,各個特性 / 模塊之間的共現組件可以通過 NPM/Git Submodule 等方式進行協同開發與複用。

微前端的落地,需要考慮到產品研發與發佈的完整生命週期;我們會關注如何保證各個團隊的獨立開發與靈活的技術棧選配,如何保證代碼風格、代碼規範的一致性,如何合併多個獨立的前端應用,如何在運行時對多個應用進行有效治理,如何保障多應用的體驗一致性,如何保障個應用的可測試與可依賴性等方面。具體而言,我們可能從應用組合、應用隔離、應用協調與治理、開發環境等幾個方面進行考慮:

  1. 應用組合:
  • 組合時機,在構建時組合,還是在運行時組合
  • 應用路由,如何根據 URL 加載 / 導航到不同的頁面,如何根據子應用界面的變化切換 URL
  • 應用加載,確定加載應用的版本,依賴於框架的加載機制,還是採用 AMD 或者 SystemJS 異步加載
  1. 應用隔離:
  • 應用容錯,某個應用的崩潰不應影響到其他應用或容器應用
  • 樣式隔離,避免 CSS 相互污染
  • DOM 隔離,避免子應用操作非自身作用域內的結點
  1. 應用協調與治理:
  • 統一配置與切換,主題,利用 CSS Variables 等方式動態換膚
  • 應用的生命週期,規範化子應用的生命週期,並且在不同生命週期中執行不同的操作
  • 數據共享,子應用間數據共享
  • 服務共享,跨應用數據共享與服務調用
  • 組件共享,可能將某個純界面組件或者業務組件以插件 (Plugin) 或者部件 (Widget) 的方式共享出去;提供某個計算能力。
  1. 開發環境:
  • 跨技術棧支持
  • 統一的構建流程與規範
  • 打樁、埋點與 Hijack

此外值得一提的是,微前端化本身是爲了保證系統的持續集成與快速迭代,那麼對於各個子模塊與系統本身的可用性與穩定性勢必會帶來挑戰,這就要求我們在設計微前端解決方案時,考慮持續構建的時機與對應的測試方案;除了標準的單元測試、集成測試、端到端測試之外,我們還需要保證模塊的依賴一致性與功能模塊的可生成性;關於此部分的詳細討論參閱 Web 自動化測試概述。(https://github.com/wxyyxc1992/Awesome-CheatSheet/blob/master/Web/DevPractices/Test/Web-Test-CheatSheet.md)

微服務

微服務是一個簡單而泛化的概念,不同的行業領域、技術背景、業務架構對於微服務的理解與實踐也是不一致的。與微服務相對的,即是單體架構的巨石型 (Monolithic) 應用,典型的即是將所有功能都部署在一個 Web 容器中運行的系統。

雖然很多的文章對於巨石型應用頗多詬病,但並不意味着其就真的一無是處,畢竟微服務本身也是有代價的。除了組織的結構之外,微服務往往還要求組織具備快速的環境提供 (Rapid Provisioning) 與雲開發、基本的監控 (Basic Monitoring)、快速的應用發佈 (Rapid Application Deployment)、DevOps 等能力。

微前端設計理念與實踐

微服務應用往往由多個粒度較小,版本獨立,有明確邊界並可擴展的服務構成,各個服務之間通過定義好的標準協議相互通信。

在構建微服務架構時,模塊化 (Modularity) 和分而治之 (Divide & Conquer) 是基本的思路。然後需要考慮單一職責 (Single Responsibility) 原則,即一個服務應當承擔儘可能單一的職責,服務應基於有界的上下文 (Bounded Context),通常是邊界清晰的業務領域構建。從系統衍化的角度,在系統早期流量較少時,只需一個應用將所有功能都部署在一起,以減少部署節點和成本。

隨着流量逐步增大,我們過渡爲了包含多個相互隔離應用的垂直應用架構;即是將不同職能的模塊分成不同的服務,也逐步開始了微服務化的步伐。接下來,隨着垂直應用越來越多,應用之間交互不可避免,將核心業務抽取出來,作爲獨立的服務,逐漸形成穩定的服務中臺。

基於這些思考,我們可以將微服務中的挑戰與關注點,劃分爲以下方面:

微前端設計理念與實踐

瀏覽器硬隔離

組合與隔離,本就是一體兩面,往往某種組合方案就自然解決了隔離的痛點,而某種隔離方案又會限制組合的方式。

筆者首先從硬 / 軟隔離的角度來對方案進行分類,服務端路由分發與 iFrame 是典型的基於瀏覽器的硬隔離方案,其天然支持多技術棧、多源的靈活組合,不過其在應用協調與治理方面需要投入較大的精力。Web Components 及其衍生方案同樣能帶來瀏覽器級別的隔離與鬆散的應用協調,但是較差的瀏覽器兼容性也限制了其應用場景。

iFrame

iFrame 可以創建一個全新的獨立的宿主環境,iFrame 的頁面和父頁面是分開的,作爲獨立區域而不受父頁面的 CSS 或者全局的 JavaScript 影響。iFrame 的不足或缺陷也非常明顯,其會進行資源的重複加載,佔用額外的內存;其會阻塞主頁面的 onload 事件,和主頁面共享連接池,而瀏覽器對相同域的連接有限制,所以會影響頁面的並行加載。

iFrame 的改造門檻較低,但是從功能需求的角度看,其無法提供 SEO,並且需要我們自定義應用管理與應用通訊機制。iFrame 的應用管理不僅要關注其加載與生命週期,還需要考慮到瀏覽器縮放等場景下的界面重適配問題,以提供用戶一致的交互體驗;這裏我們再簡要討論下同源場景中的跨界面通訊解決方案。

詳細解讀參閱 DOM CheatSheet:

https://github.com/wxyyxc1992/Awesome-CheatSheet/blob/master/Web/Syntax/DOM-CheatSheet.md

BroadcastChannel

BroadcastChannel 能夠用於同源不同頁面之間完成通信的功能。它與 window.postMessage 的區別就是,BroadcastChannel 只能用於同源的頁面之間進行通信,而 window.postMessage 卻可以用於任何的頁面之間;BroadcastChannel 可以認爲是 window.postMessage 的一個實例,它承擔了 window.postMessage 的一個方面的功能。

const channel = new BroadcastChannel('channel-name');

channel.postMessage('some message');

channel.postMessage({ key: 'value' });

channel.onmessage = function(e) {

const message = e.data;

};

channel.close();

SharedWorker API

Shared Worker 類似於 Web Workers,不過其會被來自同源的不同瀏覽上下文間共享,因此也可以用作消息的中轉站。

// main.js

const worker = new SharedWorker('shared-worker.js');

worker.port.postMessage('some message');

worker.port.onmessage = function(e) {

const message = e.data;

};

// shared-worker.js

const connections = [];

onconnect = function(e) {

const port = e.ports[0];

connections.push(port);

};

onmessage = function(e) {

connections.forEach(function(connection) {

if (connection !== port) {

connection.postMessage(e.data);

}

});

};

Local Storage

localStorage 是常見的持久化同源存儲機制,其會在內容變化時觸發事件,也就可以用作同源界面的數據通信。

localStorage.setItem('key', 'value');

window.onstorage = function(e) {

const message = e.newValue; // previous value at e.oldValue

};

Web Components && Shadow DOM

Web Components 的目標是減少單頁應用中隔離 HTML,CSS 與 JavaScript 的複雜度,其主要包含了 Custom Elements, Shadow DOM, Template Element,HTML Imports,Custom Properties 等多個維度的規範與實現。Shadow DOM 它允許在文檔(document)渲染時插入一棵 DOM 元素子樹,但是這棵子樹不在主 DOM 樹中。

因此開發者可利用 Shadow DOM 封裝自己的 HTML 標籤、CSS 樣式和 JavaScript 代碼。子樹之間可以相互嵌套,對其中的內容進行了封裝,有選擇性的進行渲染。這就意味着我們可以插入文本、重新安排內容、添加樣式等等。其結構示意如下:

微前端設計理念與實踐

簡單的 Shadow DOM 創建方式如下:

// 創建 shadow DOM

var shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'});

// 給 shadow DOM 添加文字

shadow.innerHTML = '

Here is some new text

';

// 添加 CSS,將文字變紅

shadow.innerHTML += '';

我們也可以將 React 應用封裝爲 Custom Element 並且封裝到 Shadow DOM 中:

import React from 'react';

import retargetEvents from 'react-shadow-dom-retarget-events';

class App extends React.Component {

render() {

return

alert('I have been clicked')}>Click me
;

}

}

const proto = Object.create(HTMLElement.prototype, {

attachedCallback: {

value: function() {

const mountPoint = document.createElement('span');

const shadowRoot = this.createShadowRoot();

shadowRoot.appendChild(mountPoint);

ReactDOM.render(, mountPoint);

retargetEvents(shadowRoot);

}

}

});

document.registerElement('my-custom-element', { prototype: proto });

Shadow DOM 的兼容性較差,僅在 Chrome 較高版本瀏覽器中可以使用。

單體應用軟隔離

與硬隔離相對的,筆者稱爲單體應用軟隔離,其更多地依賴於應用框架或者開發構建流程,來實現容錯與樣式、DOM 等隔離。

單體應用軟隔離又可以從應用的組合時機與技術棧的支持情況這兩個維度,劃分不同的解決方案。對於需要支持不同技術棧 (React, Angular, Vue.js, etc.) 的場景,我們往往需要徹底的類後端微服務化,每個前端應用都是獨立的服務化應用,而宿主應用則提供統一的應用管理和啓動機制;此時若需要解決資源重複加載、冗餘的問題,則需要依賴統一構建或者由宿主應用提供公共依賴庫,子應用打包時僅打包自身或非公用庫代碼。

如果是相同技術棧的場景,那麼我們可以方便地利用框架本身的懶加載能力,在開發階段以模塊劃分爲微應用進行開發,構建時以單體應用的形式構建,在運行時是以應用模塊的形式存在。

微前端設計理念與實踐

Application Composition | 應用組合

典型的應用組合方式分爲構建時 (Build Time) 組合與運行時 (Runtime) 組合,如下圖所示即是典型的構建時組合方案:

微前端設計理念與實踐

構建時組合的優勢在於能夠進行較好地依賴管理,抽取公共模塊,減少最終的包體大小,不過其最終的產出仍是單體應用,各個應用模塊無法進行獨立部署。與之相對的,運行時組合能夠保障真正地獨立開發與獨立部署:

微前端設計理念與實踐

運行時組閤中,我們可以選擇在使用 Tailor 這樣的工具進行服務端組合 (SSI),也可以使用 JSPM, SystemJS 這樣的動態導入工具,進行客戶端組合。運行時組合同時能提供按需加載的特性,優化首頁的加載速度。不過運行時組合可能重複加載依賴項(通過瀏覽器緩存或 HTTP2 適度解決),並且不同於 iFrame 的硬隔離,運行時組合仍可能面臨難以預料的第三方依賴衝突。

React 這樣的聲明式組件框架,天然就支持應用的組合,我們可以傳入渲染錨點以進行應用組合,也可以將不同框架的應用封裝爲 Web Components。首先我們可以將 React 應用定義爲自定義元素:

window.customElements.define(

'react-app',

class ReactApp extends HTMLElement {

...

render() {

render(, this);

}

...

}

);

然後在前端中直接使用該自定義元素:

在單體應用中,框架將路由指定到對應的組件或者內部服務中;而微前端中,我們需要將應用內的組件調用變成了更細粒度的應用間組件調用,即原先我們只是將路由分發到應用的組件執行,現在則需要根據路由來找到對應的應用,再由應用分發到對應的組件上。

具體的實踐中,可能宿主應用使用 Hash Router 已經佔用了 Hash 標記位,那麼就需要爲子應用提供專屬的查詢鍵,來進行子應用內跳轉。

應用隔離與治理

在 React 中可以使用 ErrorBoundary, 來限制應用崩潰的影響;如果是自定義的應用加載器,也可以實現 Promise 容錯方案。Redux 可以考慮在宿主應用創建統一的 Store,每個應用中按照命名空間劃分使用子狀態空間:

const subConnect = subAppName => (mapStateToProps, mapDispatchToProps) =>

connect(

state => mapStateToProps({ ...state[subAppName] }, state),

mapDispatchToProps

);

對於 Action 可以使用命名空間形式:

`app/service-name/action`;

而對於應用治理方面,single-spa 或者 ueact-component 都定義了跨框架的組件生命週期,譬如在 single-spa 中,可以將 React 生命週期歸一化:

const reactLifecycles = singleSpaReact({

React,

ReactDOM,

rootComponent,

domElementGetter: () => document.getElementById('main-content')

});

export const bootstrap = [reactLifecycles.bootstrap];

export const mount = [reactLifecycles.mount];

export const unmount = [reactLifecycles.unmount];

然後將其導出爲單一應用並且異步加載:

// src/index.js

import { registerApplication, start } from 'single-spa';

registerApplication(

// Name of our single-spa application

'root',

// Our loading function

() => import('./root.app.js'),

// Our activity function

() => true

);

start();

原文鏈接

https://zhuanlan.zhihu.com/p/41879781

相關文章