翻譯自 https://micro-frontends.org/javascript
本文描述了採用不一樣 JavaScript 技術框架的多個團隊中協同構建一個現代化前端 Web 應用所須要的技術、策略和方法。css
微前端這個術語最初來自 2016 年的 ThoughtWorks 技術雷達[ https://www.thoughtworks.com/radar/techniques/micro-frontends ],它將微服務的概念擴展到了前端領域。目前的趨勢是構建一個功能豐富且強大的前端應用,即單頁面應用(SPA),其自己通常都是創建在一個微服務架構之上。前端層一般由一個單獨的團隊開發,隨着時間的推移,會變得愈來愈龐大而難以維護。這就是傳說中的前端巨無霸(Frontend Monolith) [ https://www.youtube.com/watch?v=pU1gXA0rfwc ]。前端
微前端背後的理念是將一個網站或者 Web App 當成特性的組合體,每一個特性都由一個獨立的團隊負責。每一個團隊都有擅長的特定業務領域或是它關心的任務。這裏,一個團隊是跨職能的,它能夠端到端,從數據庫到用戶界面完整的開發它所負責的功能。java
然而,這個概念並不新鮮,過去它叫針對垂直系統的前端一體化或獨立系統。不過微前端顯然是一個更加友好而且不那麼笨重的術語。node
一體化的前端react
垂直化組織方式nginx
在介紹中我使用了措辭「構建一個現代化前端應用」,讓咱們先給出一些這個術語有關的設定。git
從一個更普遍的角度來看,Aral Balkan 曾寫過一個相關的博客,他把這個概念叫作文檔-應用連續統一體。他提出了一個滑動比例尺的概念,在比例尺的最左邊是一個網站,由靜態文檔構成,經過連接相互鏈接;最右邊是一個純行爲驅動的,幾乎沒內容的應用程序,好比在線圖片編輯器。github
若是你把你的項目定位在這個範圍的左側,那在 Web 服務器級別的集成會比較合適。在這個模型中,服務器會收集頁面中各個組件的內容並將其 HTML 字符串鏈接起來返回給用戶。內容更新則採用從服務端從新加載的方式或者經過 ajax 進行部分替換。Gustaf Nilsson Kotte 針對這個主題寫過一篇綜合性的文章。web
當用戶界面須要提供及時反饋時,即便採用不可靠鏈接,一個純粹的服務端渲染網站也不夠用。爲了實現 Optimistic UI 或 Skeleton Screens 這樣的技術你須要在設備自己對 UI 進行更新。Google 提出的 PWA 巧妙的描述了這種兼顧各方的作法(漸進加強),同時提供 App 同樣的性能體驗。這種類型的應用在上面的比例尺中位於文檔-應用連續統一體中間的某個地方。在這裏純粹的服務端方案已經再也不夠用,咱們必須將主要邏輯放到瀏覽器中,這正是本文會重點描述的。
技術無關
每個團隊在選擇和升級他們的技術棧時應該可以作到不須要和其餘團隊進行對接。Custom Elements 是一個隱藏實現細節的很是好的方法,同時可以對外提供一個統一接口。
隔離團隊代碼
即便全部的團隊都使用一樣的框架,也不要共享一個運行時。構建獨立的應用,不要依賴於共享狀態或全局變量。
創建各團隊的前綴
當隔離已經不可能時要商定一個命名規範。對 CSS、Events、Local Storage 和 Cookie 創建命名空間來避免碰撞並聲明全部權。
本地瀏覽器特性優先於自定義 API
採用瀏覽器事件進行數據溝通而不是構建一個全局的發佈者-訂閱者系統。若是你確實須要構建一個跨團隊的 API,那就確保它越簡單越好。
構建自適應網站
即便 JavaScript 執行失敗或是根本沒有執行,你的特性也應該是可以使用的。採用通用渲染或漸進式加強來提升可感知的性能。
自定義元素 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 { 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 中移除相應元素,並插入一個新的。
container.innerHTML; // => <blue-buy sku="t_porsche">...</blue-buy> container.innerHTML = '<blue-buy sku="t_fendt"></blue-buy>';
老元素的 disconnectedCallback 方法會被同步調用進行一些清理資源的操做好比移除事件監聽器。而後新建立的 t_fendt 元素的 constructor 會被調用。
另一個性能更好的選擇是僅僅更新現有元素的 sku 屬性。
document.querySelector('blue-buy').setAttribute('sku', 't_fendt');
若是產品組使用了以 DOM 對比爲特點的模板引擎,好比 React,那它的算法就會自動完成上述功能。
要支持這種效果,自定義元素能夠實現 attributeChangedCallback 並指定一個 observedAttributes 列表來觸發這個回調。
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 把沒有解決的問題都高亮顯示了。
然而,對於全部的交互來講從上至下傳遞屬性是不夠的。在咱們的示例中,當用戶對購買按鈕執行一次點擊事件時,迷你購物車應該刷新。
上面這兩個片斷都由交易組(藍色)維護的,因此爲了達到迷你購物車和按鈕通訊的效果他們能夠構建一種內建的 JavaScript API 進行通訊。但這樣就須要組件實例之間相互瞭解,同時也違背了隔離的原則。
一種更加乾淨的方法是採用發佈者訂閱者機制:一個組件能夠發佈信息,其餘組件則訂閱指定的主題(topic)。幸運的是瀏覽器內建了這個特性,這也正是 click、 select、 mouseover 等瀏覽器事件的工做機制。除了這些本地事件,還有一種可能性是經過 newCustomEvent(...) 來建立更加高級別的事件。事件老是綁定到它們建立或者分配的 DOM 節點上,大部分本地事件也支持冒泡的特性,這讓監聽 DOM 中特定子樹節點的全部事件成爲可能。若是你想要監聽頁面上的全部事件,將事件監聽器附加到 window 元素上就 OK 了。以下是本示例中 blue:basket:changed 事件建立的大概樣子:
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 對象上訂閱這個事件了,在須要刷新數據時它就會獲得通知。
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() 方法來通知迷你購物車。
// 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 也沒有:(
爲了引入服務端渲染,前面的示例進行了重構。每一個團隊都有他們本身的 express 服務器,自定義元素的 render() 方法也都經過 url 來進行訪問。
$ curl http://127.0.0.1:3000/blue-buy?sku=t_porsche <button type="button">buy for 66,00 €</button>
自定義元素的標籤名被用做路徑名,屬性名成爲了查詢參數。這樣爲每一個組件用服務端渲染內容的方法就有了。再配合上 <blue-buy> 自定義元素,一種很是接近於通用 Web 組件的東西就出來了:
<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 中配置以下:
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[ https://docs.docker.com/compose/install/ ]。
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[ https://github.com/neuland/micro-frontends ] 來獲取通知
若是你喜歡咱們的文章,關注咱們的公衆號和咱們互動吧。