Web Components
Source
書籍:決戰!微前端架構
簡介
Web Components 是一個總稱,底下涵蓋三種 API:
- Custom Elements
- Shadow DOM
- HTML Templates
Custom Elements - 定義一個自訂元素
實作購買按鈕:
class CheckoutBuy extends HTMLElement {
connectedCallback() {
this.innerHTML = '<button>buy now</button>'
}
}
window.customElements.define('checkout-buy', CheckoutBuy)
以上實作效果:當 <checkout-buy>
被加入到 DOM 時,會自動執行 connectedCallback
,render 出一個簡單的按鈕。
透過屬性加入元素參數:
<checkout-buy sku="..."></checkout-buy>
const prices = {
porche: 66,
fendt: 54,
eicher: 58,
}
class CheckoutBuy extends HTMLElement {
connectedCallback() {
const sku = this.getAttribute('sku')
this.innerHTML = `
<button>buy for $${prices[sku]}</button>
`
}
}
綁定事件:
// ...
this.innerHTML = `
<button>buy for $${prices[sku]}</button>
`
this.querySelector('button').addEventListener('click', () => {
alert('Thank you!')
})
// ...
TIP
以上簡易版實作是用 innerHTML、addEventListener 這類標準的 DOM API,實務上可能會用框架來代替。 像是 Vue 中會產生 vDOM,跟實際的 DOM 做 diffing,決定要更新哪些部分。避免 JS 頻繁改寫 DOM 造成效能下降。
Shadow DOM
可以讓 DOM 的一部分子樹與頁面的其他部分分離。主要優點是,因為 Shadow DOM 內部樣式不會外洩,同樣外部訂一個 CSS,也不會再 Shadow DOM 內起作用,可以減少樣式污染的狀況。
建立一個 Shadow DOM 的 root
Shadow DOM 不一定要跟 custom elements 一起用,在 JS 中對一個 HTML 元素呼叫 .attachShadow() 就可以在這個元素上新建一個分離的 DOM 子樹。
class CheckoutBuy extends HTMLElement {
connectedCallback() {
const sku = this.getAttribute('sku')
this.attachShadow({ mode: 'open' }) // 建立一個 open 模式的 Shadow DOM
this.shadowRoot.innerHTML = 'buy ...'
}
}
window.customElements.define('checkout-buy', CheckoutBuy)
WARNING
mode: "closed" 模式可以對外部 DOM 隱藏 shadowRoot,進而避免其他 JS 程式對該 Shadow DOM 做更動。 但這也會導致輔助科技(如螢幕閱讀器)和搜尋引擎看不到內容。除非有特殊需求,不然一率建議採用 open 模式。
為樣式設定作用域
// ...
this.attachShadow({ mode: 'open' })
this.shadowRoot.innerHTML = `
<style>
button {}
button:hover {}
</style>
<button>buy for $${prices[sku]}</button>
`
// ...
WARNING
Shadow DOM 在 event bubbling 的行為會有所不同,使用時需注意。
使用者介面溝通
主頁面對頁面區塊
情境:在主頁面商品中切換商品版本,要能向下傳遞到頁面區塊(此處為購買按鈕),區塊才能更新狀態(改變購買按鈕上的顯示售價)。
- 決策團隊:負責主頁面商品
- 結帳團隊:負責購買按鈕
結帳團隊爲購買按鈕加入 edition 屬性:
<checkout-buy sku="fendt" edition="standard"></checkout-buy>
決策團隊會實作一個 checkbox 讓 user 決定是否升級版本(standard | platinum),並監聽 change 事件把對應的值更新到 edition 屬性上。
隨著屬性改變作更新
接著結帳團隊要讓 checkout-buy 這個 Web Component 可以隨著屬性改變作更新。
attributeChangeCallback(name, oldValue, newValue) 方法:當自定義元素有變更,這個方法就會被觸發。
// ...
class CheckoutBuy extends HTMLElement {
static get observedAttributes() { // 監聽 sku, edition 屬性變更
return ['sku', 'edition']
}
connectedCallback() {
this.render()
}
attributeChangedCallback() {
this.render()
}
render() {
const sku = this.getAttribute('sku')
const edition = this.getAttribute('edition') || 'standard'
this.innerHTML = `<button>...略</button>`
}
}
頁面區塊對主頁面
情境:在點擊購買按鈕後,主頁面會跳出「成功加入購物車」的動畫。
- 結帳團隊:負責購買按鈕按下後通知主頁面。
- 決策團隊:負責在收到通知時,讓主頁面跳出「成功加入購物車」動畫。
傳遞自定義事件
目標:在購買按鈕被點擊時,像外傳遞 checkout:item_added 自定義事件。
使用瀏覽器原生的 CustomEvents API
class CheckoutBuy extends HTMLElement {
// ...
render() {
// ...
this.innerHTML = `<button>...略</button>`
this.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('checkout:item_added'))
})
}
}
以上是結帳團隊負責的部分,接下來換決策團隊要來監聽這個事件,並觸發「成功加入購物車」動畫。
// 產品頁
const buyButton = document.querySelector('checkout-buy')
const animationElement = document.querySelector('animation-element')
buyButton.addEventListener('checkout:item_added', (e) => {
animationElement.classList.add('active')
})
頁面區塊對頁面區塊
情境:結帳團隊要開發一個迷你購物籃區塊,讓決策團隊可以嵌入到產品頁底部。 當客戶按下購買按鈕,在購物籃中加入新商品時,迷你購物籃也需要收到通知。
也就是說要讓頁面區塊 A 裡面(checkout-buy)的事件來觸發頁面區塊 B (迷你購物籃區塊)的更新。
目標:使用 broadcasting 的方式,透過發布/訂閱機制,讓迷你購物籃訂閱購買按鈕要發布的通知,且產品頁完全不需要知道他們之間的溝通。
有兩種方式可以達成 broadcasting 的功能:
- 透過 DOM 達成 broadcasting 效果
- 使用瀏覽器 Broadcast Channel API
透過 DOM 達成 broadcasting 效果
class CheckoutBuy extends HTMLElement {
// ...
render() {
// ...
this.innerHTML = `<button>...略</button>`
this.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('checkout:item_added', {
bubbles: true, // 因為要讓事件往上傳到 window,要把事件冒泡打開
detail: { sku, edition }, // 要透過事件傳出去的 payload
}))
})
}
}
class CheckoutMiniCart extends HTMLElement {
connectedCallback() {
this.items = [] // 初始化區域變數,記錄購物籃內的商品
window.addEventListener('checkout:item_added', (e) => {
this.items.push(e.detail) // 讀取事件 payload,更新到購物籃內
this.render() // 更新畫面
})
this.render()
}
render() {
// 透過 items render 迷你購物籃
this.innerHTML = `...略`
}
}
概念是把 CustomEvent 的 bubbles option 打開,讓區塊事件可以一路往上傳到 window,其他區塊透過 window.addEventListener 來接收通知。
TIP
範例中是使用 this.dispatchEvent,也可以換成 window.dispatchEvent,直接在 window 物件上發送事件,但缺點是找不到事件的源頭。
使用 Broadcast Channel API 達成 broadcasting 效果
讓同一個網域下的頁籤、視窗、iframe 都可以互相溝通
連接頻道:
const bc = new BroadcastChannel("test_channel");
發送訊息:
bc.postMessage("test message");
接收訊息:
javascriptbc.onmessage = (event) => { console.log(event) }