跳至主要内容

[book] 決戰!微前端架構(Micro Frontends in Action)

微前端的概念

微前端不是一個實際技術,而是一種新的組織及架構方法 — 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>

頁面區塊出錯處理:連線逾時

使用 SSI 有一個缺點是,只要有其中一個 server 的回應速度過慢,就會拖慢使用者看到整個頁面的時間。因此,所有團隊都需要監控自家頁面區塊的回應時間。

部分 SSI,部分 lazy load

一般來說,可以針對網頁上重要部分採用 SSI,例如,網頁上方或 viewport 內容;而網頁下方或較不重要的區塊,則再透過 AJAX 來延遲載入(lazy load),如此可以縮短 TTFB 的時間,有可以避免上述的缺點。

處理這個問題的其中一個方式是使用 nginx 提供的 proxy_read_timeout 功能,一旦這個 upstream 超過所定義的最長等待時間(例如,500ms),在一定時間內就不會再對它發送請求(預設時 10s 內,不會再次對這個 upstream 發送請求):

設定連線嘗試次數與再次嘗試連線的等待時間

在 nginx 中可以透過 max_fails 設定連線失敗幾次算失敗(預設 1 次),以及 fail_timeout 來設定連線逾時後下次再嘗試連線的等待時間(預設 10s)。

# 06_timeouts/webserver/nginx.conf
# ...
http {
#...

server {
listen 3000;
ssi on;
#...

location /inspire/ {
proxy_pass http://team_inspire;
# 針對所有以 /inspire/ 開頭的請求,一旦請求等待超過這個時間
# 則視這個 upstream (team_inspire) 無效,一定時間內不會再對它發送請求
proxy_read_timeout 500ms;
}
}
}

頁面區塊出錯處理:備援內容

另外,Nginx 也提供當請求失敗時要顯示備援內容的方式,可以透過 stub 這個屬性:

<html>
<head>
<title>Eicher Diesel 215/16</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_banner">
<!-- 用空區塊當作備援 -->
<!--# block name="near_you_fallback" --><!--# endblock -->

<!-- 如果沒有失敗的話,要放入的內容 -->
<!--#include virtual="/inspire/fragment/near_you/eicher" stub="near_you_fallback" -->
</div>
<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">
<!-- 要顯示的備援內容 -->
<!--# block name="reco_fallback" -->
<a href="/recommendations/eicher"> Show Recommendations </a>
<!--# endblock -->

<!-- 如果沒有失敗的話,要放入的內容 -->
<!--#include virtual="/inspire/fragment/recommendations/eicher" stub="reco_fallback" -->
</aside>
</body>
</html>

在後端應用程式內做整合

前面提到,我們可以透過 Nginx 在網路伺服器內(Web Server)做整合,相似地,我們也可以在「應用程式內」(後端伺服器)做整合,例如在 Node.js 的 Express Server 中,透過 Tailor 或 Podium 這類第三方工具來達到整合。

使用 Web Component 在客戶端做整合

透過 Web Component 的技術可以使用自定義元素(custom element)並透過 shadow DOM 達到樣式隔離的效果,這是屬於在客戶端做的整合。

// 09_shadow_dom/team-checkout/static/fragment.js
class CheckoutBuy extends HTMLElement {
connectedCallback() {
const sku = this.getAttribute('sku');

// 使用 Shadow DOM 達到 CSS 隔離
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
/* ... */
</style>
<button type="button">buy for $${prices[sku]}</button>
`;
this.shadowRoot.querySelector('button').addEventListener('click', () => {
alert('Thank you ❤️');
});
}
disconnectedCallback() {
this.shadowRoot.querySelector('button').removeEventListener('click');
}
}

// 定義元件 <checkout-buy sku="xxx"></checkout-buy>
window.customElements.define('checkout-buy', CheckoutBuy);

溝通模式

重要的原則

資料傳遞的原則:

  • 往下傳遞屬性、往上傳遞事件(props down, events up)

下面這些情況都可能導致不同元件/微前端團隊產生嚴重的耦合:

  • 不應該直接去選取或操作來自其他區塊/元件的 DOM 元素,例如,頁面區塊不應該去選取主要頁面或其他頁面區塊的 DOM 元素
  • 不應該在不同區塊共用資料狀態,例如,不同微前端區塊間的 redux store 互相共用。你可能會為了避免重複載入資料而在不同微前端區塊間共用資料狀態,但這會帶來耦合,讓程式難以變更、降低穩健度,並且被濫用當成跨團隊溝通的工具。
  • 不同微前端團隊,應該只呼叫自己團隊後端的 API,而不應該呼叫其他團隊後端提供的 API。這樣的耦合會增加團隊間的相依性,未來如果要更新或測試,變成也需要依賴其他團隊的成員在場。
  • 事件應該只用來發送通知,而非資料傳遞
    • 如果想要透過事件來在不同區塊間交換資料需要非常小心,請儘量讓 payload 夾帶的資料越少越好。
    • 讓元件收到通知再從自己的團隊內取得資料,而不是從事件中獲得資料
  • 雖然使用共享的工具、套件也可以讓不同元件間溝通,但應該要盡可能減少共用的套件,盡可能使用瀏覽器內建的標準 API。

其他:

  • 如果不同團隊間需要經常溝通,有可能是團隊的邊界設計不良
  • 使用事件或 Broadcast Channel API 時,需要留意接受此事件的區塊有可能還沒載入完全

頁面對頁面

頁面和頁面間溝通最簡單的方式就是透過 URL 的 query string,如此,該頁面底下的所有元件都可以取得 URL 上的資訊。

給所有區塊的 content information

全域的 Context Information 像是幣別、語言、國家、登入狀態等,可以透過 Cookie、HTTP header、全域的 JavaScript API 來達到。

主要頁面對頁面區塊

一般來說,透過更新在主要頁面的元素屬性(element attributes)來更新區塊頁面的渲染(render)內容是最容易的,這樣的方式就類似我們更新 React 元件的 props,來讓該元件重新渲染。

範例程式碼

<!-- 10_parent_child_communication/team-decide/product/eicher.html -->

<!-- ... -->
<checkout-buy sku="eicher"></checkout-buy>

<script>
(function editions() {
// ...
platinum.addEventListener('change', (e) => {
const edition = e.target.checked ? 'platinum' : 'standard';

image.src = image.src.replace(/(standard|platinum)/, edition);
buyButton.setAttribute('edition', edition);
});
})();
</script>
// 10_parent_child_communication/team-checkout/static/fragment.js

class CheckoutBuy extends HTMLElement {
static get observedAttributes() {
return ['sku', 'edition'];
}
connectedCallback() {
this.render();
}

// 當 attributes/props 更新的時候,重新渲染元件
attributeChangedCallback() {
this.render();
}

render() {
// ...
}
}

頁面區塊對主頁面

使用事件來通知主頁面是避免不同區塊間耦合的好方法,自定義的事件可以用類似的名稱 [team_prefix]:[event_name]

// 11_child_parent_communication/team-checkout/static/fragment.js
class CheckoutBuy extends HTMLElement {
// ...
render() {
// ...

this.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('checkout:item_added'));
});
}
}
// 11_child_parent_communication/team-decide/static/page.js
(function editions() {
// ...

buyButton.addEventListener('checkout:item_added', (e) => {
element.classList.add('decide_product--confirm');
});
})();

頁面區塊對頁面區塊

結合上述兩個方法(不推薦)

雖然可以結合上述兩種做法(頁面區塊發送 event,主頁面更新區塊元件的 attributes )來達到頁面區塊和頁面區塊間的溝通,但這種作法會使用主要區塊和頁面區塊這兩個團隊需要先進行溝通。主要頁面需要知道要監聽區塊頁面的那個事件,同時在收到事件後,需要在更新另一個區塊頁面的 attributes,這會導致為了如果要修改,就需要額外的溝通成本。

使用自定義事件事件

同樣的可以使用自定義事件,一個區塊頁面把事件派送出去後,由於事件會冒泡的緣故,可以在另一個區塊頁面透過 windows 來監聽這個事件。如此主要頁面就可以不需要知道兩個區塊頁面間是如何溝通的:

// 12_fragment_fragment_communication/team-checkout/static/fragment.js

class CheckoutBuy extends HTMLElement {
// ...
render() {
// ...
this.querySelector('button').addEventListener('click', () => {
// 從 checkout-but 中發送事件
this.dispatchEvent(
new CustomEvent('checkout:item_added', {
bubbles: true,
detail: { sku, edition },
}),
);
});
}
}

class CheckoutMinicart extends HTMLElement {
connectedCallback() {
this.items = [];

// 在 checkout-minicart 中透過 windows 監聽事件
window.addEventListener('checkout:item_added', (e) => {
this.items.push(e.detail);
this.render();
});
this.render();
}
render() {
// ...
}
}

使用 Broadcast Channel API

提示

使用 Broadcast Channel,只要在同一個 origin 下,註冊相同的 channel name,一樣可以收到訊息。

除了使用自定義事件之外,另一個方法是使用 BroadcastChannel API:

// team-checkout.js
const channel = new BroadcastChannel('tractor_channel');
const buyButton = document.querySelector('button');
buyButton.addEventListenter('click', () => {
channel.postMessage({
type: 'checkout:item_added',
sku: 'fendt',
});
});
// team-decide.js
const channel = new BroadcastChannel('tractor_channel');
channel.onmessage = function (e) {
if (e.data.type === 'checkout:item_added') {
// ...
}
};

客戶端路由(Client-side routing)和 App Shell

導覽可以分成「硬導覽」和「軟導覽」。在微前端的架構下,可以根據自己的需要做出不同的設計,可以是:

  • 每一個頁面間的切換都用硬導覽
  • 團隊內的頁面切換用軟導覽;團隊間的頁面切換用硬導覽
  • 所有頁面間部分團隊都用軟導覽:這種作法會需要有一個 App Shell,目的是將請求的網址轉給對應的團隊
不要拿 App Shell 拿共享資料狀態

App Shell 的主要功能「不是」用來進行不同團隊間的資料狀態共享,而是確保各團隊的應用程式可以透過「軟導覽」來進行切換。換句話說,它負責判斷在不同的網址下,應該渲染哪個團隊提供的元件。因此,App Shell 應當保持精簡,避免共用資料狀態

平面式路由的 App Shell

一種作法是把所有路由的定義都放在 App Shell 中,在 App Shell 內會去監聽網址的變動,選出對應要呈現的畫面並渲染出來:

<!-- 13_client_side_flat_routing/app-shell/index.html -->
<head>
<title>The Tractor Store</title>
<!-- 使用 history 這個套件來管理和監聽 history 的變化 -->
<script src="https://unpkg.com/history@4.9.0"></script>

<!-- 在這裡載入每一個頁面需要用到的 JavaScript -->
<script src="http://localhost:3001/pages.js" async></script>
<script src="http://localhost:3002/pages.js" async></script>
<script src="http://localhost:3003/pages.js" async></script>
<link href="/app-shell.css" rel="stylesheet" />
</head>
<!-- ... -->

<body>
<div id="app-shell">
<!-- #app-content 的內容會根據不同的內容調整 -->
<div id="app-content"><span>content not loaded yet</span></div>
</div>

<script type="module">
const appContent = document.querySelector('#app-content');

// 設定 pathname 和元件名稱的對應關係,這裡的元件名稱會是「Web Component」的標籤名稱
const routes = {
'/': 'inspire-home',
'/product/porsche': 'decide-product-porsche',
'/product/fendt': 'decide-product-fendt',
'/product/eicher': 'decide-product-eicher',
'/checkout/cart': 'checkout-cart',
'/checkout/pay': 'checkout-pay',
'/checkout/success': 'checkout-success',
};

// 當 history 發生變化時,執行 updatePageComponent
const appHistory = window.History.createBrowserHistory();
appHistory.listen(updatePageComponent);

// 當頁面初次載入時,執行 updatePageComponent
updatePageComponent(window.location);

// 攔截被點擊的連結,並且使用 history 來更新 URL,以此做到不重新載入頁面的效果(軟導覽)
document.addEventListener('click', (e) => {
if (e.target.nodeName === 'A') {
const href = e.target.getAttribute('href');
appHistory.push(href);
e.preventDefault();
}
});

function findComponentName(pathname) {
return routes[pathname];
}

/**
* 根據 pathname 來決定要顯示哪一個元件
**/
function updatePageComponent(location) {
const next = findComponentName(location.pathname);
const current = appContent.firstChild;
if (current.nodeName.toLowerCase() !== next) {
const newComponent = document.createElement(next);
// 呼叫舊元件的 disconnectedCallback,同時,呼叫新元件的 connectedCallback
appContent.replaceChild(newComponent, current);
}
}
</script>
</body>

這裡,每一個 pathname 對應到的會是 Web Component 的標籤名稱,例如:

// 13_client_side_flat_routing/team-inspire/pages.js
class InspireHome extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<h1>Welcome Home!</h1>
<strong>Here are three tractors:</strong>
<ul>
<li><a href="/product/eicher">Eicher</a></li>
<li><a href="/product/porsche">Porsche</a></li>
<li><a href="/product/fendt">Fendt</a></li>
</ul>
`;
}
}

window.customElements.define('inspire-home', InspireHome);

使用平面式路由需要把所有的路由都定義在 App Shell 裡,一旦任何微前端專案內的路由更新時,就需要來更新 App Shell,某種程度來說,這也造成了 App Shell 和專案間的耦合,一旦專案的路由更新,App Shell 就必須連帶重新部署。

嵌套式路由(雙層路由)

為了降低 App Shell 與功能團隊之間的耦合性,我們可以採用嵌套路由的方式。簡單來說,App Shell 只負責指定哪個團隊負責哪個路由,當進入該團隊的路由後,團隊會有自己的一套路由定義,來指定不同路徑應該展示哪些頁面。

如此,App Shell 內所定義的路由規則會被精簡到最低程度。

實作範例

App shell 的程式碼和平面式路由基本一樣,只是更精簡了路由規則:

App Shell
<!-- 14_client_side_two_level_routing/app-shell/index.html -->
<!-- ... -->

<body>
<!-- ... -->
<script type="module">
console.log('app-shell init');

const appContent = document.querySelector('#app-content');

// 只定義這個前綴要交由哪個團隊來處理
const routes = {
'/product/': 'decide-pages',
'/checkout/': 'checkout-pages',
'/': 'inspire-pages',
};

function findComponentName(pathname) {
const prefix = Object.keys(routes).find(
(key) =>
// 改用 startsWith 做匹配
pathname.startsWith(key),
);
return routes[prefix];
}
// ...
</script>
<!-- ... -->
</body>

進到特定團隊後,再把完整的路徑和元件間做配對:

Team Page
// 14_client_side_two_level_routing/team-checkout/pages.js

// 這裡定義細部的路徑和要對應顯示的 HTML
const routes = {
'/checkout/cart': () => `
<a href="/">&lt; home</a> -
<a href="/checkout/pay">pay &gt;</a>
<h1>🛒 Cart</h1>
<a href="/product/eicher">
Eicher Diesel 215/16<br>
<img src="https://mi-fr.org/img/eicher.svg" width="100">
</a>`,
'/checkout/pay': () => `
<a href="/checkout/cart">&lt; cart</a> -
<a href="/checkout/success">buy now &gt;</a>
<h1>💵 Pay</h1>`,
'/checkout/success': () => `
<a href="/">home &gt;</a>
<h1>🥳 Success</h1>`,
};

class CheckoutPages extends HTMLElement {
connectedCallback() {
// 初次 render
this.render(window.location);

// 到 history 改變時更新顯示的內容
this.unlisten = window.appHistory.listen((location) => {
this.render(location);
});
}

render(location) {
// 找出對應該 pathname 下要顯示的 HTML 內容
const route = routes[location.pathname];
if (route) {
this.innerHTML = route();
}
}

disconnectedCallback() {
this.unlisten();
}
}

window.customElements.define('checkout-pages', CheckoutPages);

使用 Single Page Application(SPA)

如果不同的專案是不同的 SPA 框架,App Shell 的建立可以參考 single-spa 這個工具:

App Shell
<!-- 15_single_spa/app-shell/index.html -->
<html>
<head>
<!-- ... -->
<!-- 載入 single-spa 這個工具 -->
<script src="/single-spa.js"></script>
</head>

<body>
<div id="app-shell">
<!-- 給不同專案當作 mount 的 DOM element -->
<div id="app-inspire"></div>
<div id="app-decide"></div>
<div id="app-checkout"></div>

<script type="module">
// 使用 singleSpa 這個工具
singleSpa.registerApplication(
// 名稱
'inspire',

// loadingFn
() => import('http://localhost:3002/pages.min.js'),

// activityFn,網址有變更時就會呼叫此函式,若為 true 則會啟用這個 SPA
({ pathname }) => pathname === '/',
);

singleSpa.registerApplication(
'decide',
() => import('http://localhost:3001/pages.min.js'),
({ pathname }) => pathname.startsWith('/product/'),
);
singleSpa.registerApplication(
'checkout',
() => import('http://localhost:3003/pages.min.js'),
({ pathname }) => pathname.startsWith('/checkout/'),
);

singleSpa.start();
</script>
<!-- ... -->
</div>
</body>
</html>

每個團隊中,可以使用 single-spa 提供的 adapter,這個 adapter 可以將框架提供的元件:

Team Page
// 15_single_spa/team-decide/pages.jsx
// ...
import { BrowserRouter as Router, Route, Link } from "react-router-dom";
import singleSpaReact from "single-spa-react";

const products = {
porsche: { name: "Porsche-Diesel Master 419", img: "porsche.svg" },
fendt: { name: "Fendt F20 Dieselroß", img: "fendt.svg" },
eicher: { name: "Eicher Diesel 215/16", img: "eicher.svg" },
};

const App = () => (
// 這裡使用 react-router-dom 提供的 API 來建立嵌套式路由
<Router>
<Route path="/product/:sku" component={Product} />
</Router>
);

const Product = ({ match }) => {
const { name, img } = products[match.params.sku];
return (
<div>
<!-- ... -->
</div>
);
};

// 使用 single-spa 提供的 React Adapter
const reactLifeCycles = singleSpaReact({
React,
ReactDOM,
rootComponent: App,
domElementGetter: () => document.getElementById("app-decide"),
});

// 這些 export 的方法會在 App Shell 中提供個 singleSpa.registerApplication 使用
// 可以把這三個方法想成是 Web Component 的 constructor、connectedCallback 和 disconnectedCallback
export const { bootstrap, mount, unmount } = reactLifeCycles;

App Shell 的功能和注意事項

  • App Shell 中不應該包含任何商業邏輯
  • App Shell 應該保持精簡,只要能正常運行,不需添加新功能
  • 保持系統低耦合度的良好指標是:當一個團隊部署一項功能時,App Shell(或另一個團隊)不需要做出連動的改變
  • 別把元件的狀態搬到 App Shell,雖然不必重新打 API 來載入相同的資料聽起來好像合理,當這會導致微前端個體間形成高度耦合

App Shell 中適合包含

  • 各團隊都需要用到的 context information,例如(上下文資訊、語言、國家)
  • 放在 <head> 中的要載入的程式碼或 <meta />
  • Authentication
  • Monitor Tools
不同團隊間的溝通

不要為了減少 API 請求而把資料放在 App Shell 中共享,這會使用團隊間產生強烈的耦合。

通用渲染(Universal Rendering, Isomorphic JavaScript)

通用渲染(universal rendering)、同構 JavaScript(isomorphic JavaScript)指的都是讓「同一套 JavaScript 檔案可以同時運行在不同地方」,這裏特別指的是同時在伺服器端和客戶端運行,概念也就類似於伺服器端渲染(server-side rendering, SSR)。

具體來說,當使用者初次發送請求時,Server 會把不同微前端團隊的 HTML 組合起來,會傳給 Client,因此可以可以立即看到完整的畫面,再次同時 Client 會下載對應的 JavaScript 檔,進行 hydrate 的動作,在這之後進行的都是客戶端渲染。

實作範例

在伺服器端,我們可以借助 nginx 提供的 SSI 功能,來將不同微前端專案的 HTML 組合起來:

<!-- 16_universal/team-decide/product/eicher.html -->
<html>
<head>
<title>Eicher Diesel 215/16</title>
<!-- 載入不同微前端團隊提供的 CSS -->
<link href="/decide/static/page.css" rel="stylesheet" />
<link href="/inspire/static/fragment.css" rel="stylesheet" />
<link href="/checkout/static/fragment.css" rel="stylesheet" />
</head>

<body>
<div class="decide_layout">
<!-- ... -->
<div class="decide_details">
<!-- ... -->

<!-- 同時使用 Web Component 和 SSI -->
<!-- Web Component 在 Client 端發生作用 -->
<!-- SSI 則在 Server 端發生作用 -->
<checkout-buy sku="eicher">
<!--#include virtual="/checkout/fragment/buy/eicher" -->
</checkout-buy>
</div>
<aside class="decide_recos">
<!--#include virtual="/inspire/fragment/recommendations/eicher" -->
</aside>
<div class="decide_summary">
<checkout-minicart>
<!--#include virtual="/checkout/fragment/minicart" -->
</checkout-minicart>
</div>
</div>

<!-- 載入不同微前端團隊提供的 CSS -->
<script src="/decide/static/page.js" async></script>
<script src="/inspire/static/fragment.js" async></script>
<script src="/checkout/static/fragment.js" async></script>
</body>
</html>

由於有使用 nginx 的 SSI 功能,所以針對 <checkout-buy>使用者收到的 HTML 會是:

<checkout-buy sku="eicher">
<!-- /checkout/fragment/buy/eicher.html -->
<button type="button">buy for $66</button>
</checkout-buy>

這時候這個按鈕還是沒有功能的,需要等到 <checkout-buy> hydrate 後,使用者才能對其進行操作。由於在 /checkout/static/fragment.js 已經針對這個 Custom Component 寫好功能,因此當執行到這段 JavaScript 檔時,這裡的購買按鈕自然會被 hydrate:

// 16_universal/team-checkout/checkout/static/fragment.js
// ...

class CheckoutBuy extends HTMLElement {
// ...
connectedCallback() {
/* ... */
}
attributeChangedCallback() {
/* ... */
}
render() {
// ...
this.innerHTML = `
<button type="button">buy for $${prices[sku][edition]}</button>
`;
// ...
}
}

window.customElements.define('checkout-buy', CheckoutBuy);

設計系統與微前端

團隊建立

一般來說開發團隊可以分成「中央模型」和「聯合模型」:

  • 中央模型:有專責的設計系統開發團隊來負責打造設計系統,產品團隊則只負責使用
  • 聯合模型:沒有專責的設計系統團隊,設計系統是由產品團隊的成員共同維護開發

中央模型和聯合模型並不是二分法,也不是一旦決定這樣做就不會改變。具體來說,其中一種蠻有效率的做法是,在專案初期有一個專責的設計系統開發團隊來打造設計系統(中央模型),待設計系統開發穩定,基礎設施建立好、基本元件建構好後,則可以讓這個團隊的開發者回歸到產品團隊中,後續的維護和新元件的打造則根據產品團隊的需求,各自開發、打造與維護(聯合模型)。

整合部署方式

一般來說,設計系統的部署可以分成「執行期間整合」和「帶版本號的整合」:

執行期整合

直接引用一個不帶版本號的全域樣式,所以設計系統的更新由「設計系統」團隊直接進行更新,產品團隊無法選擇使用的版本,而是直接使用套件提供的樣式。

這種作法的缺點包含:

  • 無法分離測試:微前端個體都相依於設計系統,無法獨立部署不同的版本,一旦樣式庫更新,微前端團隊的 App 也會被迫被改變。

帶版本號的整合

這種作法就像直接下載安裝 npm 套件一樣,它的好處包括:

  • 套件本身可以持續升級而不會影響到使用的團隊;不同團隊可以根據需求安裝不同的版本
  • 不會打包未使用的程式碼

缺點則包含:

  • 冗餘:使用者可能會重複下載相同的程式碼
  • 沒辦法強迫不同團隊同時進行升級,因此有機會因為版本的關係而讓使用者看到不一致的樣式

原則:擁抱改變

  • 設計可被重複使用的模組、樣式:需求、樣式一定會發生改變,不要排斥改變,而是要接受改變,並提早做好準備
  • 少,但是更好:中央元件盡可能簡單,讓其他團隊根據不同的需求在去做客製化、複雜化

團隊與界線

微前端架構能帶來最顯著的優勢,事實上是在「組織」方面。微前端的重點不在軟體,而是軟體設計及開發人員。

界定團隊邊界

界定團隊邊界的方法包含:

  • Domain-Driven Design, DDD
  • 以使用者為中心,根據使用者的需求進行區分
  • 根據現有頁面結構

全端團隊(Full Stack Team)

當微前端的範圍涵蓋全端時,才會完全發揮潛能

  • 微前端團隊
  • 全端團隊
  • 完全獨立自主的團隊

知識共享

  • 前端團隊
  • 學習跨職能技術
  • 展示工作成果

技術多樣性

  • 可以但不是一定:在微前端架構中,每個團隊都可以選擇使用不同的技術,但可以不代表一定要這麼做
  • 非強制性:建立 Guideline、Toolbox、藍圖,但這是一份指引而不是鐵則,目的是讓其他團隊可以清楚知道不同團隊間使用過那些技術,需要的話可以方便討論。如此,才能保有不同團隊間的創新和實驗精神。
  • 別害怕複製貼上:
    • 維護共享的程式碼並不是一件容易的事:「只有在你願意為它作為成功的(內部)開源專案時,才應該這麼做」。千萬別低估共享函示庫可能為組織帶來的額外負擔