微前端架構是一種類似於微服務的架構,它將微服務的理念應用於瀏覽器端,即將 Web 應用由單一的單體應用轉變為多個小型前端應用聚合為一的應用。
由此帶來的變化是,這些前端應用可以獨立運行、獨立開發、獨立部署。以及,它們應該可以在共享組件的同時進行並行開發——這些組件可以通過 NPM 或者 Git Tag、Git Submodule 來管理。
注意:這里的前端應用指的是前后端分離的單應用頁面,在這基礎才談論微前端才有意義。
結合我最近半年在微前端方面的實踐和研究來看,微前端架構一般可以由以下幾種方式進行:
- 使用 HTTP 服務器的路由來重定向多個應用
- 在不同的框架之上設計通訊、加載機制,諸如 Mooa 和 Single-SPA
- 通過組合多個獨立應用、組件來構建一個單體應用
- iFrame。使用 iFrame 及自定義消息傳遞機制
- 使用純 Web Components 構建應用,Web Components詳見:http://www.ruanyifeng.com/blog/2019/08/web_components.html
- 結合 Web Components 構建
不同的方式適用於不同的使用場景,當然也可以組合一起使用。那么,就讓我們來一一了解一下,為以后的架構演進做一些技術鋪墊。
基礎鋪墊:應用分發路由 -> 路由分發應用
在一個單體前端、單體后端應用中,有一個典型的特征,即路由是由框架來分發的,框架將路由指定到對應的組件或者內部服務中。微服務在這個過程中做的事情是,將調用由函數調用變成了遠程調用,諸如遠程 HTTP 調用。而微前端呢,也是類似的,它是將應用內的組件調用變成了更細粒度的應用間組件調用,即原先我們只是將路由分發到應用的組件執行,現在則需要根據路由來找到對應的應用,再由應用分發到對應的組件上。
后端:函數調用 -> 遠程調用
在大多數的 CRUD 類型的 Web 應用中,也都存在一些極為相似的模式,即:首頁 -> 列表 -> 詳情:
- 首頁,用於面向用戶展示特定的數據或頁面。這些數據通常是有限個數的,並且是多種模型的。
- 列表,即數據模型的聚合,其典型特點是某一類數據的集合,可以看到盡可能多的數據概要(如 Google 只返回 100 頁),典型見 Google、淘寶、京東的搜索結果頁。
- 詳情,展示一個數據的盡可能多的內容。
如下是一個 Spring 框架,用於返回首頁的示例:
@RequestMapping(value="/") public ModelAndView homePage(){ return new ModelAndView("/WEB-INF/jsp/index.jsp"); }
對於某個詳情頁面來說,它可能是這樣的:
@RequestMapping(value="/detail/{detailId}") public ModelAndView detail(HttpServletRequest request, ModelMap model){ .... return new ModelAndView("/WEB-INF/jsp/detail.jsp", "detail", detail); }
那么,在微服務的情況下,它則會變成這樣子:
@RequestMapping("/name") public String name(){ String name = restTemplate.getForObject("http://account/name", String.class); return Name" + name; }
而后端在這個過程中,多了一個服務發現的服務,來管理不同微服務的關系。
前端:組件調用 -> 應用調用
在形式上來說,單體前端框架的路由和單體后端應用,並沒有太大的區別:依據不同的路由,來返回不同頁面的模板。
const appRoutes: Routes = [ { path: 'index', component: IndexComponent }, { path: 'detail/:id', component: DetailComponent }, ];
而當我們將之微服務化后,則可能變成應用 A 的路由:
const appRoutes: Routes = [ { path: 'index', component: IndexComponent }, ];
外加之應用 B 的路由:
const appRoutes: Routes = [ { path: 'detail/:id', component: DetailComponent }, ];
而問題的關鍵就在於:怎么將路由分發到這些不同的應用中去。與此同時,還要負責管理不同的前端應用。
路由分發式微前端
路由分發式微前端,即通過路由將不同的業務分發到不同的、獨立前端應用上。其通常可以通過 HTTP 服務器的反向代理來實現,又或者是應用框架自帶的路由來解決。
就當前而言,通過路由分發式的微前端架構應該是采用最多、最易采用的 “微前端” 方案。但是這種方式看上去更像是多個前端應用的聚合,即我們只是將這些不同的前端應用拼湊到一起,使他們看起來像是一個完整的整體。但是它們並不是,每次用戶從 A 應用到 B 應用的時候,往往需要刷新一下頁面。
在幾年前的一個項目里,我們當時正在進行遺留系統重寫。我們制定了一個遷移計划:
- 首先,使用靜態網站生成動態生成首頁
- 其次,使用 React 計划棧重構詳情頁
- 最后,替換搜索結果頁
整個系統並不是一次性遷移過去,而是一步步往下進行。因此在完成不同的步驟時,我們就需要上線這個功能,於是就需要使用 Nginx 來進行路由分發。
如下是一個基於路由分發的 Nginx 配置示例:
http { server { listen 80; server_name www.phodal.com; location /api/ { proxy_pass http://http://172.31.25.15:8000/api; } location /web/admin { proxy_pass http://172.31.25.29/web/admin; } location /web/notifications { proxy_pass http://172.31.25.27/web/notifications; } location / { proxy_pass /; } } }
在這個示例里,不同的頁面的請求被分發到不同的服務器上。
隨后,我們在別的項目上也使用了類似的方式,其主要原因是:跨團隊的協作。當團隊達到一定規模的時候,我們不得不面對這個問題。除此,還有 Angluar 跳崖式升級的問題。於是,在這種情況下,用戶前台使用 Angular 重寫,后台繼續使用 Angular.js 等保持再有的技術棧。在不同的場景下,都有一些相似的技術決策。
因此在這種情況下,它適用於以下場景:
- 不同技術棧之間差異比較大,難以兼容、遷移、改造
- 項目不想花費大量的時間在這個系統的改造上
- 現有的系統在未來將會被取代
- 系統功能已經很完善,基本不會有新需求
而在滿足上面場景的情況下,如果為了更好的用戶體驗,還可以采用 iframe 的方式來解決。
使用 iFrame 創建容器
iFrame 作為一個非常古老的,人人都覺得普通的技術,卻一直很管用。
HTML 內聯框架元素
<iframe>
表示嵌套的正在瀏覽的上下文,能有效地將另一個 HTML 頁面嵌入到當前頁面中。
iframe 可以創建一個全新的獨立的宿主環境,這意味着我們的前端應用之間可以相互獨立運行。采用 iframe 有幾個重要的前提:
- 網站不需要 SEO 支持
- 擁有相應的應用管理機制。
如果我們做的是一個應用平台,會在我們的系統中集成第三方系統,或者多個不同部門團隊下的系統,顯然這是一個不錯的方案。一些典型的場景,如傳統的 Desktop 應用遷移到 Web 應用:
如果這一類應用過於復雜,那么它必然是要進行微服務化的拆分。因此,在采用 iframe 的時候,我們需要做這么兩件事:
- 設計管理應用機制
- 設計應用通訊機制
加載機制。在什么情況下,我們會去加載、卸載這些應用;在這個過程中,采用怎樣的動畫過渡,讓用戶看起來更加自然。
通訊機制。直接在每個應用中創建 postMessage
事件並監聽,並不是一個友好的事情。其本身對於應用的侵入性太強,因此通過 iframeEl.contentWindow
去獲取 iFrame 元素的 Window 對象是一個更簡化的做法。隨后,就需要定義一套通訊規范:事件名采用什么格式、什么時候開始監聽事件等等。
有興趣的讀者,可以看看筆者之前寫的微前端框架:Mooa。
不管怎樣,iframe 對於我們今年的 KPI 怕是帶不來一絲的好處,那么我們就去造個輪子吧。
自制框架兼容應用
不論是基於 Web Components 的 Angular,或者是 VirtualDOM 的 React 等,現有的前端框架都離不開基本的 HTML 元素 DOM。
那么,我們只需要:
- 在頁面合適的地方引入或者創建 DOM
- 用戶操作時,加載對應的應用(觸發應用的啟動),並能卸載應用。
第一個問題,創建 DOM 是一個容易解決的問題。而第二個問題,則一點兒不容易,特別是移除 DOM 和相應應用的監聽。當我們擁有一個不同的技術棧時,我們就需要有針對性設計出一套這樣的邏輯。
盡管 Single-SPA 已經擁有了大部分框架(如 React、Angular、Vue 等框架)的啟動和卸載處理,但是它仍然不是適合於生產用途。當我基於 Single-SPA 為 Angular 框架設計一個微前端架構的應用時,我最后選擇重寫一個自己的框架,即 Mooa。
雖然,這種方式的上手難度相對比較高,但是后期訂制及可維護性比較方便。在不考慮每次加載應用帶來的用戶體驗問題,其唯一存在的風險可能是:第三方庫不兼容。
但是,不論怎樣,與 iFrame 相比,其在技術上更具有可吹牛逼性,更有看點。同樣的,與 iframe 類似,我們仍然面對着一系列的不大不小的問題:
- 需要設計一套管理應用的機制。
- 對於流量大的 toC 應用來說,會在首次加載的時候,會多出大量的請求
而我們即又要拆分應用,又想 blabla……,我們還能怎么做?
組合式集成:將應用微件化
組合式集成,即通過軟件工程的方式在構建前、構建時、構建后等步驟中,對應用進行一步的拆分,並重新組合。
從這種定義上來看,它可能算不上並不是一種微前端——它可以滿足了微前端的三個要素,即:獨立運行、獨立開發、獨立部署。但是,配合上前端框架的組件 Lazyload 功能——即在需要的時候,才加載對應的業務組件或應用,它看上去就是一個微前端應用。
與此同時,由於所有的依賴、Pollyfill 已經盡可能地在首次加載了,CSS 樣式也不需要重復加載。
常見的方式有:
- 獨立構建組件和應用,生成 chunk 文件,構建后再歸類生成的 chunk 文件。(這種方式更類似於微服務,但是成本更高)
- 開發時獨立開發組件或應用,集成時合並組件和應用,最后生成單體的應用。
- 在運行時,加載應用的 Runtime,隨后加載對應的應用代碼和模板。
應用間的關系如下圖所示(其忽略圖中的 “前端微服務化”):
這種方式看上去相當的理想,即能滿足多個團隊並行開發,又能構建出適合的交付物。
但是,首先它有一個嚴重的限制:必須使用同一個框架。對於多數團隊來說,這並不是問題。采用微服務的團隊里,也不會因為微服務這一個前端,來使用不同的語言和技術來開發。當然了,如果要使用別的框架,也不是問題,我們只需要結合上一步中的自制框架兼容應用就可以滿足我們的需求。
其次,采用這種方式還有一個限制,那就是:規范!規范!規范!。在采用這種方案時,我們需要:
- 統一依賴。統一這些依賴的版本,引入新的依賴時都需要一一加入。
- 規范應用的組件及路由。避免不同的應用之間,因為這些組件名稱發生沖突。
- 構建復雜。在有些方案里,我們需要修改構建系統,有些方案里則需要復雜的架構腳本。
- 共享通用代碼。這顯然是一個要經常面對的問題。
- 制定代碼規范。
因此,這種方式看起來更像是一個軟件工程問題。
現在,我們已經有了四種方案,每個方案都有自己的利弊。顯然,結合起來會是一種更理想的做法。
考慮到現有及常用的技術的局限性問題,讓我們再次將目光放得長遠一些。
純 Web Components 技術構建
在學習 Web Components 開發微前端架構的過程中,我嘗試去寫了我自己的 Web Components 框架:oan。在添加了一些基本的 Web 前端框架的功能之后,我發現這項技術特別適合於作為微前端的基石。
Web Components 是一套不同的技術,允許您創建可重用的定制元素(它們的功能封裝在您的代碼之外)並且在您的 Web 應用中使用它們。
它主要由四項技術組件:
- Custom elements,允許開發者創建自定義的元素,諸如 <today-news></today-news>。
- Shadow DOM,即影子 DOM,通常是將 Shadow DOM 附加到主文檔 DOM 中,並可以控制其關聯的功能。而這個 Shadow DOM 則是不能直接用其它主文檔 DOM 來控制的。
- HTML templates,即
<template>
和<slot>
元素,用於編寫不在頁面中顯示的標記模板。 - HTML Imports,用於引入自定義組件。
每個組件由 link
標簽引入:
<link rel="import" href="components/di-li.html"> <link rel="import" href="components/d-header.html">
隨后,在各自的 HTML 文件里,創建相應的組件元素,編寫相應的組件邏輯。一個典型的 Web Components 應用架構如下圖所示:
可以看到這邊方式與我們上面使用 iframe 的方式很相似,組件擁有自己獨立的 Scripts
和 Styles
,以及對應的用於單獨部署組件的域名。然而它並沒有想象中的那么美好,要直接使用純 Web Components 來構建前端應用的難度有:
- 重寫現有的前端應用。是的,現在我們需要完成使用 Web Components 來完成整個系統的功能。
- 上下游生態系統不完善。缺乏相應的一些第三方控件支持,這也是為什么 jQuery 相當流行的原因。
- 系統架構復雜。當應用被拆分為一個又一個的組件時,組件間的通訊就成了一個特別大的麻煩。
Web Components 中的 ShadowDOM 更像是新一代的前端 DOM 容器。而遺憾的是並不是所有的瀏覽器,都可以完全支持 Web Components。
結合 Web Components 構建
Web Components 離現在的我們太遠,可是結合 Web Components 來構建前端應用,則更是一種面向未來演進的架構。或者說在未來的時候,我們可以開始采用這種方式來構建我們的應用。好在,已經有框架在打造這種可能性。
就當前而言,有兩種方式可以結合 Web Components 來構建微前端應用:
- 使用 Web Components 構建獨立於框架的組件,隨后在對應的框架中引入這些組件
- 在 Web Components 中引入現有的框架,類似於 iframe 的形式
前者是一種組件式的方式,后者則像是在遷移未來的 “遺留系統” 到未來的架構上。
在 Web Components 中集成現有框架
現有的 Web 框架已經有一些可以支持 Web Components 的形式,諸如 Angular 支持的 createCustomElement,就可以實現一個 Web Components 形式的組件:
platformBrowser() .bootstrapModuleFactory(MyPopupModuleNgFactory) .then(({injector}) => { const MyPopupElement = createCustomElement(MyPopup, {injector}); customElements.define(‘my-popup’, MyPopupElement); });
在未來,將有更多的框架可以使用類似這樣的形式,集成到 Web Components 應用中。
集成在現有框架中的 Web Components
另外一種方式,則是類似於 Stencil 的形式,將組件直接構建成 Web Components 形式的組件,隨后在對應的諸如,如 React 或者 Angular 中直接引用。
如下是一個在 React 中引用 Stencil 生成的 Web Components 的例子:
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import registerServiceWorker from './registerServiceWorker'; import 'test-components/testcomponents'; ReactDOM.render(<App />, document.getElementById('root')); registerServiceWorker();
在這種情況之下,我們就可以構建出獨立於框架的組件。
同樣的 Stencil 仍然也只是支持最近的一些瀏覽器,比如:Chrome、Safari、Firefox、Edge 和 IE11
復合型
復合型,對就是上面的幾個類別中,隨便挑幾種組合到一起。