微前端 - 將微服務理念延伸到前端開發中

翻譯自 micro-frontends.org/

本文描述了採用不同 JavaScript 技術框架的多個團隊中協同構建一個現代化前端 Web 應用所需要的技術、策略和方法。

什麼是微前端?

微前端這個術語最初來自 2016 年的 ThoughtWorks 技術雷達[ thoughtworks.com/radar/ ],它將微服務的概念擴展到了前端領域。目前的趨勢是構建一個功能豐富且強大的前端應用,即單頁面應用(SPA),其本身一般都是建立在一個微服務架構之上。前端層通常由一個單獨的團隊開發,隨著時間的推移,會變得越來越龐大而難以維護。這就是傳說中的前端巨無霸(Frontend Monolith) [ youtube.com/watch? ]。

微前端背後的理念是將一個網站或者 Web App 當成特性的組合體,每個特性都由一個獨立的團隊負責。每個團隊都有擅長的特定業務領域或是它關心的任務。這裡,一個團隊是跨職能的,它可以端到端,從資料庫到用戶界面完整的開發它所負責的功能。

然而,這個概念並不新鮮,過去它叫針對垂直系統的前端一體化獨立系統。不過微前端顯然是一個更加友好並且不那麼笨重的術語。

一體化的前端

垂直化組織方式

什麼是現代化前端應用

在介紹中我使用了措辭「構建一個現代化前端應用」,讓我們先給出一些這個術語有關的設定。

從一個更廣泛的角度來看,Aral Balkan 曾寫過一個相關的博客,他把這個概念叫做文檔-應用連續統一體。他提出了一個滑動比例尺的概念,在比例尺的最左邊是一個網站,由靜態文檔構成,通過鏈接相互連接;最右邊是一個純行為驅動的,幾乎沒內容的應用程序,比如在線圖片編輯器。

如果你把你的項目定位在這個範圍的左側,那在 Web 伺服器級別的集成會比較合適。在這個模型中,伺服器會收集頁面中各個組件的內容並將其 HTML 字元串連接起來返回給用戶。內容更新則採用從服務端重新載入的方式或者通過 ajax 進行部分替換。Gustaf Nilsson Kotte 針對這個主題寫過一篇綜合性的文章。

當用戶界面需要提供及時反饋時,即使採用不可靠連接,一個純粹的服務端渲染網站也不夠用。為了實現 Optimistic UISkeleton Screens 這樣的技術你需要在設備本身對 UI 進行更新。Google 提出的 PWA 巧妙的描述了這種兼顧各方的做法(漸進增強),同時提供 App 一樣的性能體驗。這種類型的應用在上面的比例尺中位於文檔-應用連續統一體中間的某個地方。在這裡純粹的服務端方案已經不再夠用,我們必須將主要邏輯放到瀏覽器中,這正是本文會重點描述的。

微前端背後的核心理念

技術無關

每一個團隊在選擇和升級他們的技術棧時應該能夠做到不需要和其他團隊進行對接。Custom Elements 是一個隱藏實現細節的非常好的方法,同時能夠對外提供一個統一介面。

隔離團隊代碼

即使所有的團隊都使用同樣的框架,也不要共享一個運行時。構建獨立的應用,不要依賴於共享狀態或全局變數。

建立各團隊的前綴

當隔離已經不可能時要商定一個命名規範。對 CSS、Events、Local Storage 和 Cookie 建立命名空間來避免碰撞並聲明所有權。

本地瀏覽器特性優先於自定義 API

採用瀏覽器事件進行數據溝通而不是構建一個全局的發布者-訂閱者系統。如果你確實需要構建一個跨團隊的 API,那就確保它越簡單越好。

構建自適應網站

即使 JavaScript 執行失敗或是根本沒有執行,你的特性也應該是能夠使用的。採用通用渲染或漸進式增強來提高可感知的性能。

DOM 就是 API

自定義元素 Custom Elements 面向 Web 組件規範中互操作方面,在瀏覽器中是一個適用於功能集成的基本元素。每個團隊採用自己選擇的 Web 技術構建他們的組件,並將它們封裝到一個 自定義元素 中(比如 <order-minicart></order-minicart> )。這個特定元素的 DOM 聲明(標籤名、屬性和事件)對於其他團隊來說體現為一個協定或者叫公共 API。這樣做的好處是其他人可以使用這個組件及其功能而不需要知道實現細節,他們只需要能夠和 DOM 交互即可。

但僅僅自定義元素是不能滿足解決方案的所有需求的。為了處理漸進增強、通用渲染或路由我們還需要軟體的其他部分。

本文分為兩部分。首先我們會介紹頁面組合(Page Composition) —— 如何使用不同團隊提供的組件組合成一個頁面。然後我們會給出一些示例展示客戶端頁面轉化(Page Transition)的實現。

頁面組合

除了採用不同框架編寫的客戶端或服務端代碼集成,還有很多副主題需要討論:隔離 js的機制、規避 CSS 衝突、按需載入資源、不同團隊共享公共資源、處理數據獲取和思考提供給用戶的載入狀態。我們將會依次討論這些主題。

基本原型

如下的拖拉機模型商店的產品頁面將會作為後續示例的基礎。

這個頁面主要功能是通過一個變數選擇器在三個不同拖拉機模型之間進行選擇轉換,變數改變時產品圖片、名稱、價格和推薦都會更新。還有一個購買按鈕,點擊後會將選中的模型添加到購物車中,同時頂部的迷你購物車也會相應更新。

所有的 HTML 頁面都通過純 JavaScript和 ES6 模板字元串在客戶端生成,沒有任何依賴。代碼使用一個簡單的狀態/標記分離方式,一旦有變化整個 HTML 頁面都會重新渲染 —— 沒有炫酷的 DOM 對比功能,也暫時沒有通用渲染。當然也沒有團隊分離 —— 所有代碼都在一個 js/css 文件中。

客戶端集成

在如下示例中,這個頁面被分隔成不同的組件和片段,分別被三個不同的團隊負責。交易組(藍色)負責所有跟付賬流程有關的事情 —— 也就是購買按鈕迷你購物車推薦組(綠色)負責頁面中的產品推薦部分。頁面本身則由產品組(紅色)負責。

產品組決定哪個功能點被採用以及該功能在頁面布局的位置。頁面包含的信息可以由產品組自身提供,比如產品名稱、圖片和可採用的參數,但還可以包括其他團隊提供的片段(自定義元素)。

如何創建一個自定義元素

讓我們把購買按鈕作為一個示例。產品組簡單的將 <blue-buysku="t_porsche"></blue-buy> 加入到頁面中期望的位置就可以使用這個按鈕了。要讓這個按鈕起作用,交易組還需要在頁面中註冊元素 blue-buy。

class BlueBuy extends HTMLElement {n constructor() {n super();n this.innerHTML = ` < button type = "button" > buyn for 66,n 00€ < /button>`;n}n disconnectedCallback() { ... }n}nwindow.customElements.define(blue-buy, BlueBuy);/n

現在每當瀏覽器遇到一個新的 blue-buy 標籤時,都會調用這個構造器。其中, this 是這個自定義元素 DOM 根節點的引用。所有標準 DOM 元素的屬性和方法都可以使用,比如 innerHTML 或 getAttribute()。

根據標準文檔的定義,當命名自定義元素時唯一的需求是名稱中必須包含一個破折號 - 以確保和未來新的 HTML 標籤進行兼容。在後面的示例中則使用了 [team_color]-[feature] 命名規範。團隊命名空間預防了碰撞,這種方法讓一個功能點的權責變得更分明:只要看看 DOM 就知道了。

父子元素通信 / DOM 修改

當用戶在變數選擇器中選擇了另外一個拖拉機時,購買按鈕必須相應的進行更新。要達到這種效果,產品組只需要從 DOM 中移除相應元素,並插入一個新的。

container.innerHTML;n// => <blue-buy sku="t_porsche">...</blue-buy>ncontainer.innerHTML = <blue-buy sku="t_fendt"></blue-buy>;n

老元素的 disconnectedCallback 方法會被同步調用進行一些清理資源的操作比如移除事件監聽器。然後新創建的 t_fendt 元素的 constructor 會被調用。

另外一個性能更好的選擇是僅僅更新現有元素的 sku 屬性。

document.querySelector(blue-buy).setAttribute(sku, t_fendt);n

如果產品組使用了以 DOM 對比為特色的模板引擎,比如 React,那它的演算法就會自動完成上述功能。

要支持這種效果,自定義元素可以實現 attributeChangedCallback 並指定一個 observedAttributes 列表來觸發這個回調。

const prices = {n t_porsche: 66,00 €,n t_fendt: 54,00 €,n t_eicher: 58,00 €,n};nnclass BlueBuy extends HTMLElement {n static get observedAttributes() {n return [sku];n }n constructor() {n super();n this.render();n }n render() {n const sku = this.getAttribute(sku);n const price = prices[sku];n this.innerHTML = ` < button type = "button" > buyn for $ {n pricen } < /button>`;n}n attributeChangedCallback(attr, oldValue, newValue) {nthis.render();n}n disconnectedCallback() {...}n}nwindow.customElements.define(blue-buy, BlueBuy);/n

為避免重複,引入一個 render() 方法並在 constructor 和 attributeChangedCallback 中調用。這個方法收集需要的數據,並填充新標籤的 innerHTML 屬性。當決定在自定義元素中採用一個更加成熟的模板引擎或框架時,這裡便是初始化代碼所呆的地方。

瀏覽器支持

上例採用了 Custom Element 規範 V1 版,目前已經在 Chrome, Safari 和 Opera 中得到支持。但是通過 document-register-element 這個輕量級且經過大量測試的 polyfill 可以讓該特性在所有瀏覽器中運行。在底層,它使用了廣泛支持的 Mutation Observer API,所以並沒有在背後使用 DOM 樹監聽這種侵入式的 hack 方法。

框架兼容性

因為自定義元素 Custom Element 是一個 Web 標準,所有的主流 JavaScript 框架都支持,比如 Angular、React、Preact、Vue 或 Hyperapp。但深入到細節時,就會發現有些框架依然存在實現上的問題。可以訪問 Custom Elements Everywhere 這個兼容性測試套件,Rob Dodson 把沒有解決的問題都高亮顯示了。

子父元素或兄弟元素通信 / DOM 事件

然而,對於所有的交互來說從上至下傳遞屬性是不夠的。在我們的示例中,當用戶對購買按鈕執行一次點擊事件時,迷你購物車應該刷新。

上面這兩個片段都由交易組(藍色)維護的,所以為了達到迷你購物車和按鈕通信的效果他們可以構建一種內建的 JavaScript API 進行通信。但這樣就需要組件實例之間相互了解,同時也違背了隔離的原則。

一種更加乾淨的方法是採用發布者訂閱者機制:一個組件可以發布信息,其他組件則訂閱指定的主題(topic)。幸運的是瀏覽器內建了這個特性,這也正是 click、 select、 mouseover 等瀏覽器事件的工作機制。除了這些本地事件,還有一種可能性是通過 newCustomEvent(...) 來創建更加高級別的事件。事件總是綁定到它們創建或者分配的 DOM 節點上,大部分本地事件也支持冒泡的特性,這讓監聽 DOM 中特定子樹節點的所有事件成為可能。如果你想要監聽頁面上的所有事件,將事件監聽器附加到 window 元素上就 OK 了。如下是本示例中 blue:basket:changed 事件創建的大概樣子:

class BlueBuy extends HTMLElement { [...] connectedCallback() { [...] this.render();n this.firstChild.addEventListener(click, this.addToCart);n }n addToCart() {n // maybe talk to an apin this.dispatchEvent(new CustomEvent(blue:basket:changed, {n bubbles: true,n }));n }n render() {n this.innerHTML = ` < button type = "button" > buy < /button>`;n}n disconnectedCallback() {nthis.firstChild.removeEventListener(click, this.addToCart);n}n}/n

現在迷你購物車可以在 window 對象上訂閱這個事件了,在需要刷新數據時它就會得到通知。

class BlueBasket extends HTMLElement {n connectedCallback() { [...] window.addEventListener(blue:basket:changed, this.refresh);n }n refresh() {n // fetch new data and render itn }n disconnectedCallback() {n window.removeEventListener(blue:basket:changed, this.refresh);n }n}n

採用這種方法實現時,迷你購物車片段增加了一個不在它範圍之內(window)的 DOM 元素監聽器。對於大部分應用來說,這個做法沒有什麼問題,但是如果你不太滿意這種做法,還可以讓頁面自身(產品組)去監聽這個事件,並通過調用 DOM 元素的 refresh() 方法來通知迷你購物車。

// page.jsnconst $ = document.getElementsByTagName;nn$(blue-buy)[0].addEventListener(blue:basket:changed, function() {n $(blue-basket)[0].refresh();n});n

命令式調用 DOM 方法其實相當罕見,但比如在 video 元素 API 中就有這種做法。如果可能的話,還是應該推薦這種命令式的方法(屬性更改)。

服務端渲染 / 通用渲染

在瀏覽器中採用自定義元素 Custom Elements 來集成組件是個絕好的做法。但實際在構建一個 Web 中可訪問的站點時,很可能是初次載入性能才是關鍵點,在所有的 JS 框架全部載入並執行之前用戶只會看到白屏。另外,還有一個值得思考的是如果 JavaScript 執行失敗或者被阻塞時網站會發生什麼。Jeremy Keith 在他的 ebook/播客 Resilient Web Design 中解釋了這個問題的重要性。所以能夠在服務端渲染核心內容才是關鍵。不幸的是 Web 組件規範根本沒有討論服務端渲染。JavaScript 沒有,Custom Elements 也沒有:(

自定義元素 + 服務端包含(Includes) = ??

為了引入服務端渲染,前面的示例進行了重構。每個團隊都有他們自己的 express 伺服器,自定義元素的 render() 方法也都通過 url 來進行訪問。

$ curl http://127.0.0.1:3000/blue-buy?sku=t_porschen<button type="button">buy for 66,00 </button>n

自定義元素的標籤名被用作路徑名,屬性名成為了查詢參數。這樣為每個組件用服務端渲染內容的方法就有了。再配合上 <blue-buy> 自定義元素,一種非常接近於通用 Web 組件的東西就出來了:

<blue-buy sku="t_porsche">n<!--#include virtual="/blue-buy?sku=t_porsche" -->n</blue-buy>n

#include 注釋是服務端包含 Server Side Includes 的一部分,這個功能在大部分 Web 伺服器中都支持。沒錯,這個就是很早以前我們在網站中嵌入當前日期所採用的同樣技術。也有幾個其他可選技術比如 ESI、nodesi、compoxure 和 tailor,但是對於我們的項目 SSI 已經被證明是一個簡單同時也相當穩定的解決方案。

在 Web 伺服器將完整的頁面發送到瀏覽器之前 #include 注釋被替換為 /blue-buy?sku=t_porsche 的返回值。在 Nginx 中配置如下:

upstream team_blue {n server team_blue: 3001;n}nupstream team_green {n server team_green: 3002;n}nupstream team_red {n server team_red: 3003;n}nnserver {n listen 3000;n ssi on;nn location / blue {n proxy_pass http: //team_blue;n }n location / green {n proxy_pass http: //team_green;n }n location / red {n proxy_pass http: //team_red;n }n location / {n proxy_pass http: //team_red;n }n}n

指令 ssi:on; 用來開啟 SSI 功能, upstream 和 location 塊用來確保每個團隊的 url 都會被正確分配到對應的服務,比如以 /blue 開頭的 url 會被路由到相應的應用服務( team_blue:3001)。另外, / 路由被映射到負責首頁和產品頁的產品組(紅色)。

下面的動畫演示了在一個 JavaScript 被禁用的瀏覽器中拖拉機商店使用情況。

變數選擇按鈕現在是一個真實的鏈接了,每一次點擊都會讓整個頁面重新載入。右邊的終端展示了一個請求如何被路由到產品組的流程,產品組則控制整個產品頁,裡面的標記則由推薦組和交易組的內容片段來提供。

當打開啟用 JavaScript 的開關後,在服務端日誌消息中只有第一條請求才會顯示。所有後續的拖拉機變化邏輯都在客戶端處理了,就和前面第一個示例一樣。在後面的示例中,產品數據將會從 JavaScript 代碼中被抽離出來,並在需要的時候通過一個 REST API 進行載入。

你可以在本機運行這個代碼。只需要安裝 Docker Compose[ docs.docker.com/compose ]。

git clone https://github.com/neuland/micro-frontends.gitncd micro-frontends/2-composition-universalndocker-compose up --buildn

Docker 會在 3000 埠啟動 Nginx,並為每個團隊構建 node.js 鏡像。當你在瀏覽器中打開 127.0.0.1:3000/ 時應該會看到一個紅色的拖拉機。通過 docker-compose 給出的組合日誌可以很輕鬆的看到網路中發生了什麼。不好的是目前還不能控制輸出信息的顏色,所以你不得不接受一個事實,那就是藍色的交易組可能被高亮成綠色 :)

src 中的文件會被映射到獨立的容器中,當你進行代碼更改後 node 應用會重啟。修改 nginx.conf 需要重啟 docker-compose 才能生效。然後你就盡情瞎搞並提供反饋吧。

數據獲取 & 載入狀態

待續...

關注 Github Repo[ github.com/neuland/micr ] 來獲取通知

參考資源

  • [ speakerdeck.com/naltati ]
  • [ medium.com/@tomsoderlun ]
  • [ custom-elements-everywhere.com ]
  • [ manufactum.com/ ]

推薦閱讀:

想從零開始學習 HTML5 和 CSS,請問有沒有比較好的建議?比如學習什麼語言,有沒有好的書或者教程推薦等等?
關於vertical-align:top問題?
Webstorm 的 Tab 鍵怎樣調整縮進值?
不可或缺的柯里化

TAG:前端开发 | Web服务器 | API |