微前端的設計理念與實踐初探

王下邀月熊發表於2018-08-12

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

微前端

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

image

在某些場景下,微前端也包含了對於系統的縱向切分;即不同的團隊會負責系統中某個特性/模組,從資料庫、服務端到使用者介面完整的流線。每個團隊會更多地著眼於業務模型與特點。獨立並不意味著完全的切割,各個特性/模組之間的共現元件可以通過 NPM/Git Submodule 等方式進行協同開發與複用。微前端的落地,需要考慮到產品研發與釋出的完整生命週期;我們會關注如何保證各個團隊的獨立開發與靈活的技術棧選配,如何保證程式碼風格、程式碼規範的一致性,如何合併多個獨立的前端應用,如何在執行時對多個應用進行有效治理,如何保障多應用的體驗一致性,如何保障個應用的可測試與可依賴性等方面。具體而言,我們可能從應用組合、應用隔離、應用協調與治理、開發環境等幾個方面進行考慮:

  • 應用組合:

    • 組合時機,在構建時組合,還是在執行時組合
    • 應用路由,如何根據 URL 載入/導航到不同的頁面,如何根據子應用介面的變化切換 URL
    • 應用載入,確定載入應用的版本,依賴於框架的載入機制,還是採用 AMD 或者 SystemJS 非同步載入
  • 應用隔離:

    • 應用容錯,某個應用的崩潰不應影響到其他應用或容器應用;
    • 樣式隔離,避免 CSS 相互汙染
    • DOM 隔離,避免子應用操作非自身作用域內的結點
  • 應用協調與治理:

    • 統一配置與切換,主題,利用 CSS Variables 等方式動態換膚
    • 應用的生命週期,規範化子應用的生命週期,並且在不同生命週期中執行不同的操作
    • 資料共享,子應用間資料共享
    • 服務共享,跨應用資料共享與服務呼叫
    • 元件共享,可能將某個純介面元件或者業務元件以外掛(Plugin)或者部件(Widget)的方式共享出去;提供某個計算能力。
  • 開發環境:

    • 跨技術棧支援
    • 統一的構建流程與規範
    • 打樁、埋點與 Hijack

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

微服務

? 更多關於微服務的討論參考微服務理念、架構與實踐速覽

微服務是一個簡單而泛化的概念,不同的行業領域、技術背景、業務架構對於微服務的理解與實踐也是不一致的。與微服務相對的,即是單體架構的巨石型(Monolithic)應用,典型的即是將所有功能都部署在一個 Web 容器中執行的系統。雖然很多的文章對於巨石型應用頗多詬病,但並不意味著其就真的一無是處,畢竟微服務本身也是有代價的。除了組織的結構之外,微服務往往還要求組織具備快速的環境提供(Rapid Provisioning)與雲開發、基本的監控(Basic Monitoring)、快速的應用釋出(Rapid Application Deployment)、DevOps 等能力。

image

微服務應用往往由多個粒度較小,版本獨立,有明確邊界並可擴充套件的服務構成,各個服務之間通過定義好的標準協議相互通訊。在構建微服務架構時,模組化(Modularity)和分而治之(Divide & Conquer)是基本的思路。然後需要考慮單一職責(Single Responsibility)原則,即一個服務應當承擔儘可能單一的職責,服務應基於有界的上下文(Bounded Context),通常是邊界清晰的業務領域構建。從系統衍化的角度,在系統早期流量較少時,只需一個應用將所有功能都部署在一起,以減少部署節點和成本。隨著流量逐步增大,我們過渡為了包含多個相互隔離應用的垂直應用架構;即是將不同職能的模組分成不同的服務,也逐步開始了微服務化的步伐。接下來,隨著垂直應用越來越多,應用之間互動不可避免,將核心業務抽取出來,作為獨立的服務,逐漸形成穩定的服務中臺。

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

? 圖片源於 Awesome-MindMap/MicroService-MindMap

microservice

瀏覽器硬隔離

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

iFrame

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

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

? 詳細解讀參閱 DOM CheatSheet

  • 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 程式碼。子樹之間可以相互巢狀,對其中的內容進行了封裝,有選擇性的進行渲染。這就意味著我們可以插入文字、重新安排內容、新增樣式等等。其結構示意如下:

image

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

<html>
  <head></head>
  <body>
    <p id="hostElement"></p>
    <script>
      // 建立 shadow DOM
      var shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'});
      // 給 shadow DOM 新增文字
      shadow.innerHTML = '<p>Here is some new text</p>';
      // 新增CSS,將文字變紅
      shadow.innerHTML += '<style>p { color: red; }</style>';
    </script>
  </body>
</html>
複製程式碼

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

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

class App extends React.Component {
  render() {
    return <div onClick={() => alert('I have been clicked')}>Click me</div>;
  }
}

const proto = Object.create(HTMLElement.prototype, {
  attachedCallback: {
    value: function() {
      const mountPoint = document.createElement('span');
      const shadowRoot = this.createShadowRoot();
      shadowRoot.appendChild(mountPoint);
      ReactDOM.render(<App />, mountPoint);
      retargetEvents(shadowRoot);
    }
  }
});
document.registerElement('my-custom-element', { prototype: proto });
複製程式碼

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

單體應用軟隔離

與硬隔離相對的,筆者稱為單體應用軟隔離,其更多地依賴於應用框架或者開發構建流程,來實現容錯與樣式、DOM 等隔離。單體應用軟隔離又可以從應用的組合時機與技術棧的支援情況這兩個維度,劃分不同的解決方案。對於需要支援不同技術棧(React, Angular, Vue.js, etc.)的場景,我們往往需要徹底的類後端微服務化,每個前端應用都是獨立的服務化應用,而宿主應用則提供統一的應用管理和啟動機制;此時若需要解決資源重複載入、冗餘的問題,則需要依賴統一構建或者由宿主應用提供公共依賴庫,子應用打包時僅打包自身或非公用庫程式碼。如果是相同技術棧的場景,那麼我們可以方便地利用框架本身的懶載入能力,在開發階段以模組劃分為微應用進行開發,構建時以單體應用的形式構建,在執行時是以應用模組的形式存在。

image

? 本部分會隨著筆者的實踐逐步完善豐富,可以保持關注 Web 開發導論 或者 Ueact

Application Composition | 應用組合

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

? 圖片源自 Building application in a "Microfrontends" way

image

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

image

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

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

? 完整程式碼參考 fe-boilerplate/micro-frontend

window.customElements.define(
  'react-app',
  class ReactApp extends HTMLElement {
    ...
    render() {
      render(<App title={this.title} />, this);
    }
    ...
  }
);
複製程式碼

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

<react-app title="React Separate Running App" />
複製程式碼

在單體應用中,框架將路由指定到對應的元件或者內部服務中;而微前端中,我們需要將應用內的元件呼叫變成了更細粒度的應用間元件呼叫,即原先我們只是將路由分發到應用的元件執行,現在則需要根據路由來找到對應的應用,再由應用分發到對應的元件上。具體的實踐中,可能宿主應用使用 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();
複製程式碼

相關文章