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

🎗 本文節選自 Web 開發導論/微前端與大前端,着眼闡述了微服務與微前端的設計理念以及微服務的潛在可行方案,須要致敬的是,本文的不少考慮借鑑了 Phodal 關於微前端的系列討論以及 Web Architecture Links 中聲明的其餘文章,此外結合了本身淺薄的考量與實踐體悟,框架代碼能夠參閱 Ueact/micro-frontendcss

微前端

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

image

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

  • 應用組合:react

    • 組合時機,在構建時組合,仍是在運行時組合
    • 應用路由,如何根據 URL 加載/導航到不一樣的頁面,如何根據子應用界面的變化切換 URL
    • 應用加載,肯定加載應用的版本,依賴於框架的加載機制,仍是採用 AMD 或者 SystemJS 異步加載
  • 應用隔離:git

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

    • 統一配置與切換,主題,利用 CSS Variables 等方式動態換膚
    • 應用的生命週期,規範化子應用的生命週期,而且在不一樣生命週期中執行不一樣的操做
    • 數據共享,子應用間數據共享
    • 服務共享,跨應用數據共享與服務調用
    • 組件共享,可能將某個純界面組件或者業務組件以插件(Plugin)或者部件(Widget)的方式共享出去;提供某個計算能力。
  • 開發環境:數據庫

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

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

微服務

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

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

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();
複製代碼
相關文章
相關標籤/搜索