本文描述了採用不同 JavaScript 技術框架的多個團隊中協同構建一個現代化前端 Web 應用所需要的技術、策略和方法。
什麼是微前端?
微前端這個術語最初來自 2016 年的 ThoughtWorks 技術雷達,它將微服務的概念擴充套件到了前端領域。目前的趨勢是構建一個功能豐富且強大的前端應用,即單頁面應用(SPA),其本身一般都是建立在一個微服務架構之上。前端層通常由一個單獨的團隊開發,隨著時間的推移,會變得越來越龐大而難以維護。這就是傳說中的前端巨無霸(Frontend Monolith)。
微前端背後的理念是將一個網站或者 Web App 當成特性的組合體,每個特性都由一個獨立的團隊負責。每個團隊都有擅長的特定業務領域或是它關心的任務。這裡,一個團隊是跨職能的,它可以端到端,從資料庫到使用者介面完整的開發它所負責的功能。
然而,這個概念並不新鮮,過去它叫針對垂直系統的前端一體化或獨立系統。不過微前端顯然是一個更加友好並且不那麼笨重的術語。
一體化的前端
垂直化組織方式
什麼是現代化前端應用
在介紹中我使用了措辭“構建一個現代化前端應用”,讓我們先給出一些這個術語有關的設定。
從一個更廣泛的角度來看,Aral Balkan 曾寫過一個相關的部落格,他把這個概念叫做文件-應用連續統一體。他提出了一個滑動比例尺的概念,在比例尺的最左邊是一個網站,由靜態文件構成,通過連結相互連線;最右邊是一個純行為驅動的,幾乎沒內容的應用程式,比如線上圖片編輯器。
如果你把你的專案定位在這個範圍的左側,那在 Web 伺服器級別的整合會比較合適。在這個模型中,伺服器會收集頁面中各個元件的內容並將其 HTML 字串連線起來返回給使用者。內容更新則採用從服務端重新載入的方式或者通過 ajax 進行部分替換。Gustaf Nilsson Kotte 針對這個主題寫過一篇綜合性的文章。
當使用者介面需要提供及時反饋時,即使採用不可靠連線,一個純粹的服務端渲染網站也不夠用。為了實現 Optimistic UI 或 Skeleton Screens 這樣的技術你需要在裝置本身對 UI 進行更新。Google 提出的 PWA 巧妙的描述了這種兼顧各方的做法(漸進增強),同時提供 App 一樣的效能體驗。這種型別的應用在上面的比例尺中位於文件-應用連續統一體中間的某個地方。在這裡純粹的服務端方案已經不再夠用,我們必須將主要邏輯放到瀏覽器中,這正是本文會重點描述的。
微前端背後的核心理念
- 技術無關
每一個團隊在選擇和升級他們的技術棧時應該能夠做到不需要和其他團隊進行對接。Custom Elements 是一個隱藏實現細節的非常好的方法,同時能夠對外提供一個統一介面。
- 隔離團隊程式碼
即使所有的團隊都使用同樣的框架,也不要共享一個執行時。構建獨立的應用,不要依賴於共享狀態或全域性變數。
- 建立各團隊的字首
當隔離已經不可能時要商定一個命名規範。對 CSS、Events、Local Storage 和 Cookie 建立名稱空間來避免碰撞並宣告所有權。
- 本地瀏覽器特性優先於自定義 API
採用瀏覽器事件進行資料溝通而不是構建一個全域性的釋出者-訂閱者系統。如果你確實需要構建一個跨團隊的 API,那就確保它越簡單越好。
- 構建自適應網站
即使 JavaScript 執行失敗或是根本沒有執行,你的特性也應該是能夠使用的。採用通用渲染或漸進式增強來提高可感知的效能。
DOM 就是 API
自定義元素 Custom Elements 面向 Web 元件規範中互操作方面,在瀏覽器中是一個適用於功能整合的基本元素。每個團隊採用自己選擇的 Web 技術構建他們的元件,並將它們封裝到一個 自定義元素 中(比如 \<order-minicart\>\)。這個特定元素的 DOM 宣告(標籤名、屬性和事件)對於其他團隊來說體現為一個協定或者叫公共 API。這樣做的好處是其他人可以使用這個元件及其功能而不需要知道實現細節,他們只需要能夠和 DOM 互動即可。
但僅僅自定義元素是不能滿足解決方案的所有需求的。為了處理漸進增強、通用渲染或路由我們還需要軟體的其他部分。
本文分為兩部分。首先我們會介紹頁面組合(Page Composition) —— 如何使用不同團隊提供的元件組合成一個頁面。然後我們會給出一些示例展示客戶端頁面轉化(Page Transition)的實現。
頁面組合
除了採用不同框架編寫的客戶端或服務端程式碼整合,還有很多副主題需要討論:隔離 js的機制、規避 CSS 衝突、按需載入資源、不同團隊共享公共資源、處理資料獲取和思考提供給使用者的載入狀態。我們將會依次討論這些主題。
基本原型
如下的拖拉機模型商店的產品頁面將會作為後續示例的基礎。
這個頁面主要功能是通過一個變數選擇器在三個不同拖拉機模型之間進行選擇轉換,變數改版時產品圖片、名稱、價格和推薦都會更新。還有一個購買按鈕,點選後會將選中的模型新增到購物車中,同時頂部的迷你購物車也會相應更新。
所有的 HTML 頁面都通過純 JavaScript和 ES6 模板字串在客戶端生成,沒有任何依賴。程式碼使用一個簡單的狀態/標記分離方式,一旦有變化整個 HTML 頁面都會重新渲染 —— 沒有炫酷的 DOM 對比功能,也暫時沒有通用渲染。當然也沒有團隊分離 —— 所有程式碼都在一個 js/css 檔案中。
客戶端整合
在如下示例中,這個頁面被分隔成不同的元件和片段,分別被三個不同的團隊負責。交易組(藍色)負責所有跟付賬流程有關的事情 —— 也就是購買按鈕和迷你購物車。推薦組(綠色)負責頁面中的產品推薦部分。頁面本身則由產品組(紅色)負責。
產品組決定哪個功能點被採用以及該功能在頁面佈局的位置。頁面包含的資訊可以由產品組自身提供,比如產品名稱、圖片和可採用的引數,但還可以包括其他團隊提供的片段(自定義元素)。
如何建立一個自定義元素
讓我們把購買按鈕作為一個示例。產品組簡單的將 <blue-buy sku="t_porsche"></blue-buy>
加入到頁面中期望的位置就可以使用這個按鈕了。要讓這個按鈕起作用,交易組還需要在頁面中註冊元素 blue-buy
。
1 2 3 4 5 6 7 8 |
class BlueBuy extends HTMLElement { constructor() { super(); this.innerHTML = `<button type="button">buy for 66,00 €</button>`; } disconnectedCallback() { ... } } window.customElements.define('blue-buy', BlueBuy); |
現在每當瀏覽器遇到一個新的 blue-buy
標籤時,都會呼叫這個構造器。其中,this
是這個自定義元素 DOM 根節點的引用。所有標準 DOM 元素的屬性和方法都可以使用,比如 innerHTML
或 getAttribute()
。
根據標準文件的定義,當命名自定義元素時唯一的需求是名稱中必須包含一個破折號 – 以確保和未來新的 HTML 標籤進行相容。在後面的示例中則使用了 [team_color]-[feature]
命名規範。團隊名稱空間預防了碰撞,這種方法讓一個功能點的權責變得更分明:只要看看 DOM 就知道了。
父子元素通訊 / DOM 修改
當使用者在變數選擇器中選擇了另外一個拖拉機時,購買按鈕必須相應的進行更新。要達到這種效果,產品組只需要從 DOM 中移除相應元素,並插入一個新的。
1 2 3 |
container.innerHTML; // => <blue-buy sku="t_porsche">...</blue-buy> container.innerHTML = '<blue-buy sku="t_fendt"></blue-buy>'; |
老元素的 disconnectedCallback
方法會被同步呼叫進行一些清理資源的操作比如移除事件監聽器。然後新建立的 t_fendt
元素的 constructor
會被呼叫。
另外一個效能更好的選擇是僅僅更新現有元素的 sku
屬性。
1 |
document.querySelector('blue-buy').setAttribute('sku', 't_fendt'); |
如果產品組使用了以 DOM 對比為特色的模板引擎,比如 React,那它的演算法就會自動完成上述功能。
要支援這種效果,自定義元素可以實現 attributeChangedCallback
並指定一個 observedAttributes
列表來觸發這個回撥。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
const prices = { t_porsche: '66,00 €', t_fendt: '54,00 €', t_eicher: '58,00 €', }; class BlueBuy extends HTMLElement { static get observedAttributes() { return ['sku']; } constructor() { super(); this.render(); } render() { const sku = this.getAttribute('sku'); const price = prices[sku]; this.innerHTML = `<button type="button">buy for ${price}</button>`; } attributeChangedCallback(attr, oldValue, newValue) { this.render(); } disconnectedCallback() {...} } window.customElements.define('blue-buy', BlueBuy); |
為避免重複,引入一個 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
等瀏覽器事件的工作機制。除了這些本地事件,還有一種可能性是通過 new CustomEvent(...)
來建立更加高階別的事件。事件總是繫結到它們建立或者分配的 DOM 節點上,大部分本地事件也支援冒泡的特性,這讓監聽 DOM 中特定子樹節點的所有事件成為可能。如果你想要監聽頁面上的所有事件,將事件監聽器附加到 window 元素上就 OK 了。如下是本示例中 blue:basket:changed
事件建立的大概樣子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class BlueBuy extends HTMLElement { [...] connectedCallback() { [...] this.render(); this.firstChild.addEventListener('click', this.addToCart); } addToCart() { // maybe talk to an api this.dispatchEvent(new CustomEvent('blue:basket:changed', { bubbles: true, })); } render() { this.innerHTML = `<button type="button">buy</button>`; } disconnectedCallback() { this.firstChild.removeEventListener('click', this.addToCart); } } |
現在迷你購物車可以在 window
物件上訂閱這個事件了,在需要重新整理資料時它就會得到通知。
1 2 3 4 5 6 7 8 9 10 11 12 |
class BlueBasket extends HTMLElement { connectedCallback() { [...] window.addEventListener('blue:basket:changed', this.refresh); } refresh() { // fetch new data and render it } disconnectedCallback() { window.removeEventListener('blue:basket:changed', this.refresh); } } |
採用這種方法實現時,迷你購物車片段增加了一個不在它範圍之內(window)的 DOM 元素監聽器。對於大部分應用來說,這個做法沒有什麼問題,但是如果你不太滿意這種做法,還可以讓頁面自身(產品組)去監聽這個事件,並通過呼叫 DOM 元素的 refresh()
方法來通知迷你購物車。
1 2 3 4 5 6 |
// page.js const $ = document.getElementsByTagName; $('blue-buy')[0].addEventListener('blue:basket:changed', function() { $('blue-basket')[0].refresh(); }); |
命令式呼叫 DOM 方法其實相當罕見,但比如在 video 元素 API 中就有這種做法。如果可能的話,還是應該推薦這種命令式的方法(屬性更改)。
服務端渲染 / 通用渲染
在瀏覽器中採用自定義元素 Custom Elements 來整合元件是個絕好的做法。但實際在構建一個 Web 中可訪問的站點時,很可能是初次載入效能才是關鍵點,在所有的 JS 框架全部載入並執行之前使用者只會看到白屏。另外,還有一個值得思考的是如果 JavaScript 執行失敗或者被阻塞時網站會發生什麼。Jeremy Keith 在他的 ebook/播客 Resilient Web Design 中解釋了這個問題的重要性。所以能夠在服務端渲染核心內容才是關鍵。不幸的是 Web 元件規範根本沒有討論服務端渲染。JavaScript 沒有,Custom Elements 也沒有:(
自定義元素 + 服務端包含(Includes) = ❤️
為了引入服務端渲染,前面的示例進行了重構。每個團隊都有他們自己的 express 伺服器,自定義元素的 render()
方法也都通過 url 來進行訪問。
1 2 |
$ curl http://127.0.0.1:3000/blue-buy?sku=t_porsche <button type="button">buy for 66,00 €</button> |
自定義元素的標籤名被用作路徑名,屬性名成為了查詢引數。這樣為每個元件用服務端渲染內容的方法就有了。再配合上 <blue-buy>
自定義元素,一種非常接近於通用 Web 元件的東西就出來了:
1 2 3 |
<blue-buy sku="t_porsche"> <!--#include virtual="/blue-buy?sku=t_porsche" --> </blue-buy> |
#include
註釋是服務端包含 Server Side Includes 的一部分,這個功能在大部分 Web 伺服器中都支援。沒錯,這個就是很早以前我們在網站中嵌入當前日期所採用的同樣技術。也有幾個其他可選技術比如 ESI、nodesi、compoxure 和 tailor,但是對於我們的專案 SSI 已經被證明是一個簡單同時也相當穩定的解決方案。
在 Web 伺服器將完整的頁面傳送到瀏覽器之前 #include
註釋被替換為 /blue-buy?sku=t_porsche
的返回值。在 Nginx 中配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
upstream team_blue { server team_blue:3001; } upstream team_green { server team_green:3002; } upstream team_red { server team_red:3003; } server { listen 3000; ssi on; location /blue { proxy_pass http://team_blue; } location /green { proxy_pass http://team_green; } location /red { proxy_pass http://team_red; } location / { proxy_pass http://team_red; } } |
指令 ssi: on;
用來開啟 SSI 功能,upstream
和 location
塊用來確保每個團隊的 url 都會被正確分配到對應的服務,比如以 /blue
開頭的 url 會被路由到相應的應用服務(team_blue:3001
)。另外,/
路由被對映到負責首頁和產品頁的產品組(紅色)。
下面的動畫演示了在一個 JavaScript 被禁用的瀏覽器中拖拉機商店使用情況。
變數選擇按鈕現在是一個真實的連結了,每一次點選都會讓整個頁面重新載入。右邊的終端展示了一個請求如何被路由到產品組的流程,產品組則控制整個產品頁,裡面的標記則由推薦組和交易組的內容片段來提供。
當開啟啟用 JavaScript 的開關後,在服務端日誌訊息中只有第一條請求才會顯示。所有後續的拖拉機變化邏輯都在客戶端處理了,就和前面第一個示例一樣。在後面的示例中,產品資料將會從 JavaScript 程式碼中被抽離出來,並在需要的時候通過一個 REST API 進行載入。
你可以在本機執行這個程式碼。只需要安裝 Docker Compose。
1 2 3 |
git clone https://github.com/neuland/micro-frontends.git cd micro-frontends/2-composition-universal docker-compose up --build |
Docker 會在 3000 埠啟動 Nginx,併為每個團隊構建 node.js 映象。當你在瀏覽器中開啟 http://127.0.0.1:3000/
時應該會看到一個紅色的拖拉機。通過 docker-compose
給出的組合日誌可以很輕鬆的看到網路中發生了什麼。不好的是目前還不能控制輸出資訊的顏色,所以你不得不接受一個事實,那就是藍色的交易組可能被高亮成綠色 :)
src
中的檔案會被對映到獨立的容器中,當你進行程式碼更改後 node 應用會重啟。修改 nginx.conf
需要重啟 docker-compose
才能生效。然後你就盡情瞎搞並提供反饋吧。
資料獲取 & 載入狀態
待續…
關注 Github Repo 來獲取通知
其他資源
- Slides: Micro Frontends by Michael Geers – JSUnconf.eu 2017
- Post: Micro frontends—a microservice approach to front-end web development Tom Söderlund 對這個主題進行了核心概念的講解並提供了連結
- Custom Elements Everywhere 確保框架和自定義元素是 BFFs(Backup For Frontend) 的
- 拖拉機可以在這裡買哦 manufactum.com :)