[book] 決戰!微前端架構(Micro Frontends in Action)
Micro Frontends in Action by Michael Geers @ Book / GitHub
微前端的概念
微前端不是一個實際技術,而是一種新的組織及架構方法 — Micro Frontends in Action。
微前端的重要原則
- 每一個團隊都應該使用描述性的隊名,能夠清楚描述這個團隊對使用者的用途,例如,促銷團隊、結帳團隊、註冊團隊
- 各團隊能獨立進行開發、測試和部署
- 獨立開發:各個微前端團隊間要盡可能不要產生耦合;把注意力放在真正會對使用者造成衝擊的瓶頸上,而不是一昧的砍掉重複的程式碼
- 獨立部署:微前端團隊應該要能在不須與其他團隊協調的情況下更新資源檔案(assets)
- 團隊間的契約:微前端個體的開發團隊必須要能夠確定,其他團隊能正確引用他們的檔案
- 追求高效能的架構設計是打從一開始就列為高優先,而不是事後才想到的問題。
問問自己,模組和模組之間要隔離到什麼程度才能算是符合微前端的原則?能夠獨立升級套用不同的框架版本?或者問問?為什麼不要建立一個大的 React App,由不同團隊維護同一專案中的不同塊程式碼就好?
前端整合
前端整合的三大面向:
-
路由與轉頁:把不同微前端的專案連結在同一個網站下
- 硬導覽:使用超連接
- 軟導覽:使用 App Shell
-
區塊組合:把不同微前端的專案拼接在一個頁面中
-
伺服器端整合
- Nginx SSI
- 在 Server App(例如,express)中做整合
-
客戶端整合:Web Component
-
iframe:提供最佳的技術分離,但運用大量的 iframe 會相當消耗資源
-
Ajax
-
-
區塊溝通
- 使用 query string:用在跨頁面間的資料傳遞
- 使用 attributes(類似 props):用在主要頁面傳入區塊元件
- 使用 custom event:用在區塊元件傳入主要頁面
- 使用 broadcast channel API:用在區塊元件傳到區塊元件
- 使用 window.postMessage:用在 iFrame
共用模組/共同議題
共用模組、共同議題應該要整個前端團隊一起討論,例如網頁效能、多語系、Design System、知識分享等等。
基礎設施所有權
即使採用微前端的架構,仍然會有共同的基礎架構,例如前端代理伺服器、錯誤報告、npm 套件管理、監控、日誌等。本書的作者並不建議有獨立「平台團隊(platform team)」來獨立處理這些共同的基礎架構,而是把這些功能分派給不同的前端團隊來維護。
本書作者並沒有說明為什麼他不建議有獨立的平台團隊,但我猜想可能是因為「獨立的平台團隊」將不再是使用者。開發者不是使用者的情況下,有可能做出來的東西並非使用者想要的,相反地,如果開發者本身就是使用者,那麼將更有可能設計出使用者真正需要的功能。
缺點
- 微前端架構其實一種冗餘系統,之所 以採取這種架構,是因為我們評估後認為冗餘系統產生的代價讓小於互相依賴帶來的負面影響(利大於弊)
- 不同團隊間可能有更多 duplicated 的程式碼
- 用戶端可能會請求重複的 CSS、JavaScript 以及發送重複的 API
雖然我們可以讓不同專案共用樣式檔或通用的方法,但也會帶來相當多的耦合。 然而微前端的精神就在於去除耦合,並維持團隊自主性, 因此必須謹慎思考檔案是否應該被共用? 還是最好 REPEAT?
整合方式
超連結進行整合
- 作法:直接使用超連結的方式,連結到兩個獨立不同的網站
- 優點: 低耦合、 高穩健度
- 缺點: 不同頁面網址可能不同; 只有一個超連結使用者,可能難以察覺
<!-- 01_pages_links/team-decide/product/eicher.html -->
<html>
<head>
<title>Eicher Diesel 215/16</title>
<link href="/static/page.css" rel="stylesheet" />
<link href="/static/outlines.css" rel="stylesheet" />
</head>
<body class="layout">
<h1 class="header">The Tractor Store</h1>
<div class="product">
<h2>Eicher Diesel 215/16</h2>
<img class="image" src="<https://mi-fr.org/img/eicher.svg>" />
</div>
<aside class="recos">
<a href="<http://localhost:3002/recommendations/eicher>"> Show Recommendations </a>
</aside>
</body>
</html>
透過 iframe 來組合頁面
- 作法:透過 iframe 將另一個專案的內容鑲嵌近來
- 優點:
- 低耦合、 高穩健度
- 不同專案間的程式和樣式不會互相干擾
- 缺點:
- 沒有可靠的方式自動調整 iframe 的高度,Parent 需要知道並定義 iframe 的高度,不然 iframe 可能會出現 scrollbar 或空白(這會再次帶來團隊間的耦合)
- 如果頁面中有類似 Popover、Dropdown、Tooltip 這類的元件,有可能會被遮蓋而無法正確顯示
- 每個 iframe 都會佔用 CPU 和記憶體,在同一個頁面使用多個 iframe 會拖累效能
- 不利於無障礙網頁設計
- 如果有使用第三方服務追蹤使用者行為或操作的話,iframe 當中的內容可能會無法正確被讀取
- 不利於 SEO
- 備註:
- 適合靜態版面,如果是 RWD,因為 iframe 的高度會隨著使用者裝置的大小而改變
- iframe 較適合作為內部平台使用的方案,因為這多半不需要考慮太多的效能、SEO、無障礙等。
<html>
<head>
<title>Eicher Diesel 215/16</title>
<link href="/static/page.css" rel="stylesheet" />
<link href="/static/outlines.css" rel="stylesheet" />
</head>
<body class="layout">
<h1 class="header">The Tractor Store</h1>
<div class="product">
<h2>Eicher Diesel 215/16</h2>
<img class="image" src="<https://mi-fr.org/img/eicher.svg>" />
</div>
<aside class="recos">
<iframe src="<http://localhost:3002/recommendations/eicher>" />
</aside>
</body>
</html>
使用 AJAX
載入 HTML
透過 fetch
的方式載入另一個專案的 html 載入進來,方法是用 innerHTML
注入原本的頁面中
避免 CSS 樣式衝突
因為所有的 DOM 最終都呈現在同一份 HTML 頁面用,因此要避免不同專案間的 CSS 名稱互相衝突。解決的方式可以在 class name 都固定加上專案名稱作為前綴(命名空間),或者也可以使用 shadow DOM(Web Component)的方式。
避免 JS 衝突
避免 JavaScript 在全域環境執行,造成變數衝突最簡單的作法就是可以使用 IIFE。
如果有需要在不同專案間共用某些變數,除了直接將變數定義在全域環境外,也可以把資料格式宣告在標記檔案內:
<script data-global-state type="application/json">
{ "name": "Aaron" }
</script>
如此,一樣可以在 IIFE 中取用到這個全域狀態:
(function () {
const globalStateContainer = document.querySelector('[data-inspire-state]');
const GLOBAL_STATE = JSON.parse(globalStateContainer.innerHTML);
})();
然而,如果有些狀態是存在瀏覽器中,例如 Cookies、localStorage、sessionStorage、custom event、meta 標籤等等,就還是會需要透過前綴(命名空間)來處理,以避免不同團隊間彼此衝突或覆蓋。
當你在開始一個新的微前端專案時,預先制定好全域命名空間的規則,將可以替所有人省下很多時間和麻煩。
優缺點
優點:
- 因為是把鑲嵌的頁面放到同一份 HTML 中,因此不會有 iframe 需要預先設定高度,Popover 元件無法正確顯示的問題;也不會有 A11y 或螢幕閱讀器無法爬取的問題。
缺點:
- 因為是非同步取得的頁面,內容延遲載入的時間更久,並且有可能導致頁面抖動的情況(資料載入後導致排版發生變化)
- 由於沒有明確的分離性,需要靠團隊彼此透過規範(例如,命名空間)來避免衝突
範例程式碼
<!-- 03_ajax/team-decide/product/eicher.html -->
<html>
<head>
<title>Eicher Diesel 215/16</title>
<link href="/static/page.css" rel="stylesheet" />
<link href="/static/outlines.css" rel="stylesheet" />
</head>
<body class="decide_layout">
<h1 class="decide_header">The Tractor Store</h1>
<div class="decide_product">
<h2 class="decide_headline">Eicher Diesel 215/16</h2>
<img class="decide_image" src="https://mi-fr.org/img/eicher.svg" />
</div>
<aside
class="decide_recos"
data-fragment="http://localhost:3002/fragment/recommendations/eicher"
>
<a href="http://localhost:3002/recommendations/eicher"> Show Recommendations </a>
</aside>
<script src="/static/page.js" async></script>
</body>
</html>
// 03_ajax/team-decide/static/page.js
(function () {
const element = document.querySelector('.decide_recos');
const url = element.getAttribute('data-fragment');
window
.fetch(url)
.then((res) => res.text())
.then((html) => {
element.innerHTML = html;
});
})();
<!-- 03_ajax/team-inspire/fragment/recommendations/eicher.html -->
<!-- 這裡的內容會被以 innerHTML 的方式注入 product 頁面 -->
<link href="http://localhost:3002/static/fragment.css" rel="stylesheet" />
<div class="inspire_fragment">
<h2 class="inspire_headline">Recommendations</h2>
<div class="inspire_recommendations">
<a href="http://localhost:3001/product/porsche">
<img src="https://mi-fr.org/img/porsche.svg" />
</a>
<a href="http://localhost:3001/product/fendt">
<img src="https://mi-fr.org/img/fendt.svg" />
</a>
</div>
</div>
使用 Nginx 做伺服器端路由
不 同的團隊 run 在不同的伺服器,使用 Nginx 作為伺服器端路由,讓這些請求都來自同一個 domain,但會根據不同的 request URL 轉派給不同的 server 來處理,這也是 reverse proxy 的概念。例如, /product
進來的請求,就請 localhost:3001
來處理;/recommendations
進來的請求,則請 localhost:3002
處理。
不同團隊雖然 server 不同,但都是屬於同一個 domain,因此網址可以使用相對路徑,同時也不需要分別處理 CORS、Authentication 等問題。
缺點
使用伺服器路由的其中一個缺點是,因為需要一個服務來作為共同端點的角色,例如 Nginx,一旦這個服務壞掉,整個網頁都將無法存取,會有單點故障的問題(single point of failure)。
範例程式碼
# 04_routing/webserver/nginx.conf
daemon off;
events {}
http {
# 定義兩個 upstream - team_decide 和 team_inspire
upstream team_decide {
server localhost:3001;
}
upstream team_inspire {
server localhost:3002;
}
log_format compact ':3000$uri $status';
server {
listen 3000;
# comment out on windows
access_log /dev/stdout compact;
# 當 /product/ 的請求進來時,以 team_decide 這個 upstream 來處理
location /product/ {
proxy_pass http://team_decide;
}
location /decide/ {
proxy_pass http://team_decide;
}
location /recommendations {
proxy_pass http://team_inspire;
}
location /inspire/ {
proxy_pass http://team_inspire;
}
}
}
使用 Nginx 作為伺服器端整合(SSI)
伺服器端整合(Server-Side Includes, SSI)指的是在 server 端就已經把整個頁面所需的 HTML 組好,因此頁面在抵達 client 是,就已經組裝完成,首次載入速度比起客戶端整合要快的多。
範例程式碼
在這個範例中 Nginx 同時扮演伺服器端路由以及伺服器端整合的角色。
# 05_ssi/webserver/nginx.conf
# ...
http {
#...
server {
listen 3000;
ssi on;
# ...
}
}
<!-- 05_ssi/team-decide/product/porsche.html -->
<html>
<head>
<title>Porsche-Diesel Master 419</title>
<link href="/decide/static/page.css" rel="stylesheet" />
<link href="/decide/static/outlines.css" rel="stylesheet" />
</head>
<body class="decide_layout">
<h1 class="decide_header">The Tractor Store</h1>
<div class="decide_product">
<h2 class="decide_headline">Porsche-Diesel Master 419</h2>
<img class="decide_image" src="https://mi-fr.org/img/porsche.svg" />
</div>
<aside class="decide_recos">
<!-- nginx 會把連結中的網頁內容放到這裡面 -->
<!--#include virtual="/inspire/fragment/recommendations/porsche" -->
</aside>
</body>
</html>
<!-- 05_ssi/team-inspire/inspire/fragment/recommendations/porsche.html -->
<link href="/inspire/static/fragment.css" rel="stylesheet" />
<div class="inspire_fragment">
<h2 class="inspire_headline">Recommendations</h2>
<div class="inspire_recommendations">
<a href="/product/fendt">
<img src="https://mi-fr.org/img/fendt.svg" />
</a>
<a href="/product/eicher">
<img src="https://mi-fr.org/img/eicher.svg" />
</a>
</div>
</div>