如何基於 WebComponents 封裝 UI 元件庫
前言
作為一名前端攻城獅,相信大家也都在關注著前端的一些新技術,近些年來前端元件化開發已為常態,我們經常把重用性搞的模組抽離成一個個的元件,來達到複用的目的,這樣減少了我們的維護成本,提高了開發的效率。但是都有一個缺點離不開框架本身,因為我們瀏覽器本身解析不了那些元件。那麼有沒有一種技術也可以達到這種效果呢?答案就是今天的主角 Web Components。
Web Components 是一套不同的技術,允許您建立可重用的定製元素(它們的功能封裝在您的程式碼之外)並且在您的web應用中使用它們。 目前 W3C 也在積極推動,並且瀏覽器的支援情況還不錯。FireFox、Chrome、Opera 已全部支援,Safari 也大部分支援,Edge 也換成 webkit 核心了,離全面支援應該也不遠了。當然社群也有相容的解決方案 webcomponents/polyfills 。
WebComponents 三要素和生命週期
Button 元件示例
首先我們就從一個最簡單的 Button 元件開始,我們可以通過在元件中傳入 type 來改變按鈕的樣式,並且動態監聽了資料的變化。
// html <cai-button type="primary"> <span slot="btnText"> 按鈕 </span> </cai-button> <template id="caiBtn"> <style> .cai-button { display: inline-block; padding: 4px 20px; font-size: 14px; line-height: 1.5715; font-weight: 400; border: 1px solid #1890ff; border-radius: 2px; background-color: #1890ff; color: #fff; box-shadow: 0 2px #00000004; } .cai-button-warning { border: 1px solid #faad14; background-color: #faad14; } .cai-button-danger { border: 1px solid #ff4d4f; background-color: #ff4d4f; } </style> <div class="cai-button"> <slot name="btnText"></slot> </div> </template> <script> const template = document.getElementById("caiBtn"); class CaiButton extends HTMLElement { constructor() { super() this._type = { primary: 'cai-button', warning: 'cai-button-warning', danger: 'cai-button-danger', } // 開啟shadow dom const shadow = this.attachShadow({ mode: 'open' }) const type = this const content = template.content.cloneNode(true) // 克隆一份 防止重複使用 汙染 // 把響應式資料掛到this this._btn = content.querySelector('.cai-button') this._btn.className += ` ${this._type[type]}` shadow.appendChild(content) } static get observedAttributes() { return ['type'] } attributeChangedCallback(name, oldValue, newValue) { this[name] = newValue; this.render(); } render() { this._btn.className = `cai-button ${this._type[this.type]}` } } // 掛載到window window.customElements.define('cai-button', CaiButton) </script>
三要素、生命週期和示例的解析
-
Custom elements(自定義元素):一組 JavaScript API,允許您定義 custom elements 及其行為,然後可以在您的使用者介面中按照需要使用它們。在上面例子中就指的是我們的自定義元件,我們通過
class CaiButton extends HTMLElement {}
定義我們的元件,通過window.customElements.define('cai-button', CaiButton)
掛載我們的已定義元件。 -
Shadow DOM(影子 DOM ):一組 JavaScript API,用於將封裝的“影子” DOM 樹附加到元素(與主文件 DOM 分開呈現)並控制其關聯的功能。通過這種方式,您可以保持元素的功能私有,這樣它們就可以被指令碼化和樣式化,而不用擔心與文件的其他部分發生衝突。使用
const shadow = this.attachShadow({mode : 'open'})
在 WebComponents 中開啟。 -
HTML templates(HTML模板)slot :template 可以簡化生成dom元素的操作,我們不再需要 createElement 每一個節點。slot 則和 Vue 裡面的 slot 類似,只是使用名稱不太一樣。
內部生命週期函式
-
connectedCallback
: 當 WebComponents 第一次被掛在到 dom 上是觸發的鉤子,並且只會觸發一次。類似 Vue 中的 mounted React 中的 useEffect(() => {}, []),componentDidMount。 -
disconnectedCallback
: 當自定義元素與文件 DOM 斷開連線時被呼叫。 -
adoptedCallback
: 當自定義元素被移動到新文件時被呼叫。 -
attributeChangedCallback
: 當自定義元素的被監聽屬性變化時被呼叫。上述例子中我們監聽了 type 的變化,使 button 元件呈現不同狀態。 雖然 WebComponents 有三個要素,但卻不是缺一不可的,WebComponents 藉助 shadow dom 來實現樣式隔離,藉助 templates 來簡化標籤的操作。
在這個例子用我們使用了 slot 傳入了倆個標籤之間的內容,如果我們想要不使用 slot 傳入標籤之間的內容怎麼辦?
我們可以通過 innerHTML 拿到自定義元件之間的內容,然後把這段內容插入到對應節點即可。
元件通訊
瞭解上面這些基本的概念後,我們就可以開發一些簡單的元件了,但是如果我們想傳入一些複雜的資料型別(物件,陣列等)怎麼辦?我們只傳入字串還可以麼?答案是肯定的!
傳入複雜資料型別
使用我們上面的 button,我們不僅要改變狀態,而且要想要傳入一些配置,我們可以通過傳入一個 JSON 字串
// html <cai-button id="btn"> </cai-button> <script> btn.setAttribute('config', JSON.stringify({icon: '', posi: ''})) </script> // button.js class CaiButton extends HTMLElement { constructor() { xxx } static get observedAttributes() { return ['type', 'config'] // 監聽config } attributeChangedCallback(name, oldValue, newValue) { if(name === 'config') { newValue = JSON.parse(newValue) } this[name] = newValue; this.render(); } render() { } } window.customElements.define('cai-button', CaiButton) })()
這種方式雖然可行但卻不是很優雅。
- 對於使用者說:我用你個元件你還要讓我把所有的複雜型別都轉換成字串?
- 對於開發元件者來說:我為什麼要每次都 JSON.parse() 一下?
- HTML 中會有很長的資料。
因此我們需要換一個思路,我們上面使用的方式都是 attribute 傳值,資料型別只能是字串,那我們可以不用它傳值嗎?答案當然也是可以的。和 attribute 形影不離還有我們 js 中的property,它指的是 dom 屬性,是js物件並且支援傳入複雜資料型別。
// table元件 demo,以下為虛擬碼 僅展示思路 <cai-table id="table"> </cai-table> table.dataSource = [{ name: 'xxx', age: 19 }] table.columns = [{ title: '', key: '' }]
這種方式雖然解決上述問題,但是又引出了新的問題--自定義元件中沒有辦法監聽到這個屬性的變化,那現在我們應該怎麼辦? 或許從一開始是我們的思路就是錯的,顯然對於資料的響應式變化是我們原生 js 本來就不太具備的能力,我們不應該把使用過的框架的思想過於帶入,因此從元件使用的方式上我們需要做出改變,我們不應該過於依賴屬性的配置來達到某種效果,因此改造方法如下。
<cai-table thead="Name|Age"> <cai-tr> <cai-td>zs</cai-td> <cai-td>18</cai-td> </cai-tr> <cai-tr> <cai-td>ls</cai-td> <cai-td>18</cai-td> </cai-tr> </cai-table>
我們把屬於 HTML 原生的能力歸還,而是不是採用配置的方式,就解決了這個問題,但是這樣同時也決定了我們的元件並不支援太過複雜的能力。
狀態的雙向繫結
上面講了資料的單向繫結,元件狀態頁面也會隨之更新,那麼我們怎麼實現雙向繫結呢?
接下來我們封裝一個 input 來實現雙向繫結。
<cai-input id="ipt" :value="data" @change="(e) => { data = e.detail }"></cai-input> // js (function () { const template = document.createElement('template') template.innerHTML = ` <style> .cai-input { } </style> <input type="text" id="caiInput"> ` class CaiInput extends HTMLElement { constructor() { super() const shadow = this.attachShadow({ mode: 'closed' }) const content = template.content.cloneNode(true) this._input = content.querySelector('#caiInput') this._input.value = this.getAttribute('value') shadow.appendChild(content) this._input.addEventListener("input", ev => { const target = ev.target; const value = target.value; this.value = value; this.dispatchEvent(new CustomEvent("change", { detail: value })); }); } get value() { return this.getAttribute("value"); } set value(value) { this.setAttribute("value", value); } } window.customElements.define('cai-input', CaiInput) })()
- 這樣就封裝了一個簡單雙向繫結的 input 元件,程式碼中 get/set 和 observedAttributes / attributeChangedCallback 前者是監聽單個,後者可以監聽多個狀態改變並做出處理。
- 這裡面核心的一步是 我們監聽了這個表單的input事件,並且在每次觸發 input 事件的時候觸發 自定義的 change 事件 ,並且把輸入的引數回傳。
- 那我們應該怎麼使用呢? 以 vue 為例子,vue 的雙向繫結 v-model 其實是一個語法糖, 我們的元件則沒有辦法使用這個語法糖,與 v-model 不簡化寫法類似
<cai-input :value="data" @change="(e) => { data = e.detail }">
封裝我們自己的元件庫
設計目錄結構
第一步:要有一個優雅的組價庫我們首先要設計一個優雅的目錄結構 設計目錄結構如下
. └── cai-ui ├── components // 自定義元件 | ├── Button | | ├── index.js | └── ... └── index.js. // 主入口
獨立封裝
獨立封裝我們的元件,由於我們元件庫中元件的引入,我們肯定是需要把每個元件封裝到單獨檔案中的。
在我們的 Button/index.js 中寫入如下:
(function () { const template = document.createElement('template') template.innerHTML = ` <style> /* css和上面一樣 */ </style> <div class="cai-button"> <slot name="text"></slot> </div> ` class CaiButton extends HTMLElement { constructor() { super() // 其餘和上述一樣 } static get observedAttributes() { return ['type'] } attributeChangedCallback(name, oldValue, newValue) { this[name] = newValue; this.render(); } render() { this._btn.className = `cai-button ${this._type[this.type]}` } } window.customElements.define('cai-button', CaiButton) })()
封裝到元件到單獨的 js 檔案中
全部匯入和按需匯入
- 支援全部匯入,我們通過一個 js 檔案全部引入元件
// index.js import './components/Button/index.js' import './components/xxx/xxx.js'
- 按需匯入我們只需要匯入元件的js檔案即可如
import 'cai-ui/components/Button/index.js'
自定義配置主題
支援主題色可配置 我們只需把顏色寫成變數即可,改造如下:
(function () { const template = document.createElement('template') template.innerHTML = ` <style> /* 多餘省略 */ .cai-button { border: 1px solid var(--primary-color, #1890ff); background-color: var(--primary-color, #1890ff); } .cai-button-warning { border: 1px solid var(--warning-color, #faad14); background-color: var(--warning-color, #faad14); } .cai-button-danger { border: 1px solid var(--danger-color, #ff4d4f); background-color: var(--danger-color, #ff4d4f); } </style> <div class="cai-button"> <slot name="text"></slot> </div> ` // 後面省略... })()
這樣我們就能在全域性中修改主題色了。 案例地址
在原生、Vue 和 React 中優雅的使用
在原生 HTML 中應用:
<script type="module"> import '//cai-ui'; </script> <!--or--> <script type="module" src="//cai-ui"></script> <cai-button type="primary">點選</cai-button> <cai-input id="caiIpt"></cai-button> <script> const caiIpt = document.getElementById('caiIpt') /* 獲取輸入框的值有兩種方法 * 1. getAttribute * 2. change 事件 */ caiIpt.getAttribute('value') caiIpt.addEventListener('change', function(e) { console.log(e); // e.detail 為表單的值 }) </script>
在 Vue 2x 中的應用:
// main.js import 'cai-ui'; <template> <div id="app"> <cai-button :type="type"> <span slot="text">哈哈哈</span> </cai-button> <cai-button @click="changeType"> <span slot="text">哈哈哈</span> </cai-button> <cai-input id="ipt" :value="data" @change="(e) => { data = e.detail }"></cai-input> </div> </template> <script> export default { name: "App", components: {}, data(){ return { type: 'primary', data: '', } }, methods: { changeType() { console.log(this.data); this.type = 'danger' } }, }; </script>
在 Vue 3x 中的差異:
在最近的 Vue3 中,Vue 對 WebComponents 有了更好的支援。Vue 在 Custom Elements Everywhere 測試中獲得了 100% 的完美分數 。但是還需要我們做出如下配置:
跳過 Vue 本身對元件的解析
custom Elements 的風格和 Vue 元件很像,導致 Vue 會把自定義(非原生的 HTML 標籤)標籤解析並註冊為一個 Vue 元件,然後解析失敗才會再解析為一個自定義元件,這樣會消耗一定的效能並且會在控制檯警告,因此我們需要在構建工具中跳過這個解析:
// vite.config.js import vue from '@vitejs/plugin-vue' export default { plugins: [ vue({ template: { compilerOptions: { // 將所有包含短橫線的標籤作為自定義元素處理 isCustomElement: tag => tag.includes('-') } } }) ] }
元件的具體使用方法和 Vue 2x 類似。
在 React 中的應用
import React, { useEffect, useRef, useState } from 'react'; import 'cai-ui' function App() { const [type, setType] = useState('primary'); const [value, setValue] = useState(); const iptRef = useRef(null) useEffect(() => { document.getElementById('ipt').addEventListener('change', function(e) { console.log(e); }) }, []) const handleClick = () => { console.log(value); setType('danger') } return ( <div className="App"> <cai-button type={type}> <span slot="text">哈哈哈</span> </cai-button> <cai-button onClick={handleClick}> <span slot="text">點選</span> </cai-button> <cai-input id="ipt" ref={iptRef} value={value} ></cai-input> </div> ); } export default App;
Web Components 觸發的事件可能無法通過 React 渲染樹正確的傳遞。 你需要在 React 元件中手動新增事件處理器來處理這些事件。 在 React 使用有個點我們需要注意下,WebComponents 元件我們需要新增類時需要使用 claas 而不是 className
總結現階段的劣勢
看完這篇文章大家肯定會覺得為什麼 WebComponents 實現了一份程式碼多個框架使用,卻還沒有霸佔元件庫的市場呢?我總結了一下幾點:
-
更加偏向於 UI 層面,與現在資料驅動不太符,和現在的元件庫能力上相比功能會比較弱,使用場景相對單一。
-
相容性還有待提升:這裡不僅僅指的是瀏覽器的相容性,還有框架的相容性,在框架中使用偶爾會發現意外的“驚喜”,並且寫法會比較複雜。
-
如果不借助框架開發的話,寫法會返璞歸真,HTML CSS JS 會糅合在一個檔案,html CSS 都是字串的形式 ,沒有高亮,格式也需要自己調整,對於開發人員來說還是難受的。
-
單元測試使用繁瑣:單元測試是元件庫核心的一項,但是在 WebComponents 中使用單元測試十分複雜。
參考文件:
❉ 作者介紹 ❉

- 淺談低程式碼平臺遠端元件載入方案
- 前端富文字基礎及實現
- 淺談前端埋點&監控
- 如何讓 x == 1 && x == 2 && x == 3 等式成立
- 淺析 path 常用工具函式原始碼
- web components-LitElement實踐
- 模組聯邦淺析
- 效能優化——圖片壓縮、載入和格式選擇
- 如何基於 WebComponents 封裝 UI 元件庫
- CDP 遠端除錯方案
- 如何落地一個智慧機器人
- Form 資料形式配置化設計
- Lerna 執行流程剖析
- Decorator 裝飾器
- 淺析snabbdom中vnode和diff演算法
- 函數語言程式設計(FP)
- 如何利用 SCSS 實現一鍵換膚
- 淺析FormData
- Flutter For Web 編譯的兩種方案
- Web 多執行緒開發利器 Comlink 的剖析與思考