如何基於 WebComponents 封裝 UI 組件庫
如何基於 WebComponents 封裝 UI 組件庫
https://www.zoo.team/article/web-components
前言
作爲一名前端攻城獅,相信大家也都在關注着前端的一些新技術,近些年來前端組件化開發已爲常態,我們經常把重用性高的模塊抽離成一個個的組件,來達到複用的目的,這樣減少了我們的維護成本,提高了開發的效率。但是都有一個缺點離不開框架本身,因爲我們瀏覽器本身解析不了那些組件。那麼有沒有一種技術也可以達到這種效果呢?答案就是今天的主角 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 ></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 ></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 ></slot> </div>
`
// 後面省略...
})()
這樣我們就能在全局中修改主題色了。案例地址 (https://github.com/lovelts/cai-ui)
在原生、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% 的完美分數 (https://custom-elements-everywhere.com/libraries/vue/results/results.html)。但是還需要我們做出如下配置:
跳過 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 class>
<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 中使用單元測試十分複雜。
參考文檔:
-
WebComponents | MDN(https://developer.mozilla.org/en-US/docs/Web/Web_Components)
-
Vue 3.0 官方文檔 (https://v3.cn.vuejs.org/guide/web-components.html#vue-%E4%B8%8E-web-components)
-
React 官方文檔 (https://zh-hans.reactjs.org/docs/web-components.html)
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/UTGADsfmJXSg5zGik4Rb5g