元件化是前端發展的一個重要方向,它一方面提高開發效率,另一方面降低維護成本。主流的 Vue.js、React 及其延伸的 Ant Design、uniapp、Taro 等都是元件框架。
Web Components 是一組 Web 原生 API 的總稱,允許我們建立可重用的自定義元件,並在我們 Web 應用中像使用原生 HTML 標籤一樣使用。目前已經很多前端框架/庫支援 Web Components。
本文將帶大家回顧 Web Components 核心 API,並從 0 到 1 實現一個基於 Web Components API 開發的業務元件庫。
最終效果:https://blog.pingan8787.com/exe-components/demo.html
倉庫地址:https://github.com/pingan8787/Learn-Web-Components
一、回顧 Web Components
在前端發展歷史中,從剛開始重複業務到處複製相同程式碼,到 Web Components 的出現,我們使用原生 HTML 標籤的自定義元件,複用元件程式碼,提高開發效率。通過 Web Components 建立的元件,幾乎可以使用在任何前端框架中。
1. 核心 API 回顧
Web Components 由 3 個核心 API 組成:
- Custom elements(自定義元素):用來讓我們定義自定義元素及其行為,對外提供元件的標籤;
- Shadow DOM(影子 DOM):用來封裝元件內部的結構,避免與外部衝突;
- HTML templates(HTML 模版):包括
<template>
和<slot>
元素,讓我們可以定義各種元件的 HTML 模版,然後被複用到其他地方,使用過 Vue/React 等框架的同學應該會很熟悉。
另外,還有 HTML imports,但目前已廢棄,所以不具體介紹,其作用是用來控制元件的依賴載入。
2. 入門示例
接下來通過下面簡單示例快速瞭解一下如何建立一個簡單 Web Components 元件。
- 使用元件
<!DOCTYPE html>
<html lang="en">
<head>
<script src="./index.js" defer></script>
</head>
<body>
<h1>custom-element-start</h1>
<custom-element-start></custom-element-start>
</body>
</html>
- 定義元件
/**
* 使用 CustomElementRegistry.define() 方法用來註冊一個 custom element
* 引數如下:
* - 元素名稱,符合 DOMString 規範,名稱不能是單個單詞,且必須用短橫線隔開
* - 元素行為,必須是一個類
* - 繼承元素,可選配置,一個包含 extends 屬性的配置物件,指定建立的元素繼承自哪個內建元素,可以繼承任何內建元素。
*/
class CustomElementStart extends HTMLElement {
constructor(){
super();
this.render();
}
render(){
const shadow = this.attachShadow({mode: 'open'});
const text = document.createElement("span");
text.textContent = 'Hi Custom Element!';
text.style = 'color: red';
shadow.append(text);
}
}
customElements.define('custom-element-start', CustomElementStart)
上面程式碼主要做 3 件事:
- 實現元件類
通過實現 CustomElementStart
類來定義元件。
- 定義元件
將元件的標籤和元件類作為引數,通過 customElements.define
方法定義元件。
- 使用元件
匯入元件後,跟使用普通 HTML 標籤一樣直接使用自定義元件 <custom-element-start></custom-element-start>
。
隨後瀏覽器訪問 index.html
可以看到下面內容:
3. 相容性介紹
在 MDN | Web Components 章節中介紹了其相容性情況:
- Firefox(版本63)、Chrome和Opera都預設支援Web元件。
- Safari支援許多web元件特性,但比上述瀏覽器少。
- Edge正在開發一個實現。
關於相容性,可以看下圖:
圖片來源:https://www.webcomponents.org/
這個網站裡面,有很多關於 Web Components 的優秀專案可以學習。
4. 小結
這節主要通過一個簡單示例,簡單回顧基礎知識,詳細可以閱讀文件:
二、EXE-Components 元件庫分析設計
1. 背景介紹
假設我們需要實現一個 EXE-Components 元件庫,該元件庫的元件分 2 大類:
- components 型別
以通用簡單元件為主,如exe-avatar
頭像元件、 exe-button
按鈕元件等;
- modules 型別
以複雜、組合元件為主,如exe-user-avatar
使用者頭像元件(含使用者資訊)、exe-attachement-list
附件列表元件等等。
詳細可以看下圖:
接下來我們會基於上圖進行 EXE-Components 元件庫設計和開發。
2. 元件庫設計
在設計元件庫的時候,主要需要考慮以下幾點:
- 元件命名、引數命名等規範,方便元件後續維護;
- 元件引數定義;
- 元件樣式隔離;
當然,這幾個是最基礎需要考慮的點,隨著實際業務的複雜,還需要考慮更多,比如:工程化相關、元件解耦、元件主題等等。
針對前面提到這 3 點,這邊約定幾個命名規範:
- 元件名稱以
exe-功能名稱
進行命名,如exe-avatar
表示頭像元件; - 屬性引數名稱以
e-引數名稱
進行命名,如e-src
表示src
地址屬性; - 事件引數名稱以
on-事件型別
進行命名,如on-click
表示點選事件;
3. 元件庫元件設計
這邊我們主要設計 exe-avatar
、exe-button
和 exe-user-avatar
三個元件,前兩個為簡單元件,後一個為複雜元件,其內部使用了前兩個元件進行組合。這邊先定義這三個元件支援的屬性:
這邊屬性命名看著會比較複雜,大家可以按照自己和團隊的習慣進行命名。
這樣我們思路就清晰很多,實現對應元件即可。
三、EXE-Components 元件庫準備工作
本文示例最終將對實現的元件進行組合使用,實現下面「使用者列表」效果:
體驗地址:https://blog.pingan8787.com/exe-components/demo.html
1. 統一開發規範
首先我們先統一開發規範,包括:
- 目錄規範
- 定義元件規範
- 元件開發模版
元件開發模版分 index.js
元件入口檔案和 template.js
元件 HTML 模版檔案:
// index.js 模版
const defaultConfig = {
// 元件預設配置
}
const Selector = "exe-avatar"; // 元件標籤名
export default class EXEAvatar extends HTMLElement {
shadowRoot = null;
config = defaultConfig;
constructor(){
super();
this.render(); // 統一處理元件初始化邏輯
}
render() {
this.shadowRoot = this.attachShadow({mode: 'closed'});
this.shadowRoot.innerHTML = renderTemplate(this.config);
}
}
// 定義元件
if (!customElements.get(Selector)) {
customElements.define(Selector, EXEAvatar)
}
// template.js 模版
export default config => {
// 統一讀取配置
const { avatarWidth, avatarRadius, avatarSrc } = config;
return `
<style>
/* CSS 內容 */
</style>
<div class="exe-avatar">
/* HTML 內容 */
</div>
`
}
2. 開發環境搭建和工程化處理
為了方便使用 EXE-Components 元件庫,更接近實際元件庫的使用,我們需要將元件庫打包成一個 UMD 型別的 js 檔案。這邊我們使用 rollup 進行構建,最終打包成 exe-components.js
的檔案,使用方式如下:
<script src="./exe-components.js"></script>
接下來通過 npm init -y
生成 package.json
檔案,然後全域性安裝 rollup 和 http-server(用來啟動本地伺服器,方便除錯):
npm init -y
npm install --global rollup http-server
然後在 package.json
的 script
下新增 "dev"
和 "build"
指令碼:
{
// ...
"scripts": {
"dev": "http-server -c-1 -p 1400",
"build": "rollup index.js --file exe-components.js --format iife"
},
}
其中:
"dev"
命令:通過 http-server 啟動靜態伺服器,作為開發環境使用。新增-c-1
引數用來禁用快取,避免重新整理頁面還會有快取,詳細可以看 http-server 文件;"build"
命令:將 index.js 作為 rollup 打包的入口檔案,輸出exe-components.js
檔案,並且是 iife 型別的檔案。
這樣就完成簡單的本地開發和元件庫構建的工程化配置,接下來就可以進行開發了。
四、EXE-Components 元件庫開發
1. 元件庫入口檔案配置
前面 package.json
檔案中配置的 "build"
命令,會使用根目錄下 index.js
作為入口檔案,並且為了方便 components 通用基礎元件和 modules 通用複雜元件的引入,我們建立 3 個 index.js
,建立後目錄結構如下:
三個入口檔案內容分別如下:
// EXE-Components/index.js
import './components/index.js';
import './modules/index.js';
// EXE-Components/components/index.js
import './exe-avatar/index.js';
import './exe-button/index.js';
// EXE-Components/modules/index.js
import './exe-attachment-list/index.js.js';
import './exe-comment-footer/index.js.js';
import './exe-post-list/index.js.js';
import './exe-user-avatar/index.js';
2. 開發 exe-avatar 元件 index.js 檔案
通過前面的分析,我們可以知道 exe-avatar
元件需要支援引數:
- e-avatar-src:頭像圖片地址,例如:./testAssets/images/avatar-1.png
- e-avatar-width:頭像寬度,預設和高度一致,例如:52px
- e-button-radius:頭像圓角,例如:22px,預設:50%
- on-avatar-click:頭像點選事件,預設無
接著按照之前的模版,開發入口檔案 index.js
:
// EXE-Components/components/exe-avatar/index.js
import renderTemplate from './template.js';
import { Shared, Utils } from '../../utils/index.js';
const { getAttributes } = Shared;
const { isStr, runFun } = Utils;
const defaultConfig = {
avatarWidth: "40px",
avatarRadius: "50%",
avatarSrc: "./assets/images/default_avatar.png",
onAvatarClick: null,
}
const Selector = "exe-avatar";
export default class EXEAvatar extends HTMLElement {
shadowRoot = null;
config = defaultConfig;
constructor(){
super();
this.render();
}
render() {
this.shadowRoot = this.attachShadow({mode: 'closed'});
this.shadowRoot.innerHTML = renderTemplate(this.config);// 生成 HTML 模版內容
}
// 生命週期:當 custom element首次被插入文件DOM時,被呼叫。
connectedCallback() {
this.updateStyle();
this.initEventListen();
}
updateStyle() {
this.config = {...defaultConfig, ...getAttributes(this)};
this.shadowRoot.innerHTML = renderTemplate(this.config); // 生成 HTML 模版內容
}
initEventListen() {
const { onAvatarClick } = this.config;
if(isStr(onAvatarClick)){ // 判斷是否為字串
this.addEventListener('click', e => runFun(e, onAvatarClick));
}
}
}
if (!customElements.get(Selector)) {
customElements.define(Selector, EXEAvatar)
}
其中有幾個方法是抽取出來的公用方法,大概介紹下其作用,具體可以看原始碼:
renderTemplate
方法
來自 template.js 暴露的方法,傳入配置 config,來生成 HTML 模版。
getAttributes
方法
傳入一個 HTMLElement 元素,返回該元素上所有屬性鍵值對,其中會對 e-
和 on-
開頭的屬性,分別處理成普通屬性和事件屬性,示例如下:
// input
<exe-avatar
e-avatar-src="./testAssets/images/avatar-1.png"
e-avatar-width="52px"
e-avatar-radius="22px"
on-avatar-click="avatarClick()"
></exe-avatar>
// output
{
avatarSrc: "./testAssets/images/avatar-1.png",
avatarWidth: "52px",
avatarRadius: "22px",
avatarClick: "avatarClick()"
}
runFun
方法
由於通過屬性傳遞進來的方法,是個字串,所以進行封裝,傳入 event
和事件名稱作為引數,呼叫該方法,示例和上一步一樣,會執行 avatarClick()
方法。
另外,Web Components 生命週期可以詳細看文件:使用生命週期回撥函式。
3. 開發 exe-avatar 元件 template.js 檔案
該檔案暴露一個方法,返回元件 HTML 模版:
// EXE-Components/components/exe-avatar/template.js
export default config => {
const { avatarWidth, avatarRadius, avatarSrc } = config;
return `
<style>
.exe-avatar {
width: ${avatarWidth};
height: ${avatarWidth};
display: inline-block;
cursor: pointer;
}
.exe-avatar .img {
width: 100%;
height: 100%;
border-radius: ${avatarRadius};
border: 1px solid #efe7e7;
}
</style>
<div class="exe-avatar">
<img class="img" src="${avatarSrc}" />
</div>
`
}
最終實現效果如下:
開發完第一個元件,我們可以簡單總結一下建立和使用元件的步驟:
4. 開發 exe-button 元件
按照前面 exe-avatar
元件開發思路,可以很快實現 exe-button
元件。
需要支援下面引數:
- e-button-radius:按鈕圓角,例如:8px
- e-button-type:按鈕型別,例如:default, primary, text, dashed
- e-button-text:按鈕文字,預設:開啟
- on-button-click:按鈕點選事件,預設無
// EXE-Components/components/exe-button/index.js
import renderTemplate from './template.js';
import { Shared, Utils } from '../../utils/index.js';
const { getAttributes } = Shared;
const { isStr, runFun } = Utils;
const defaultConfig = {
buttonRadius: "6px",
buttonPrimary: "default",
buttonText: "開啟",
disableButton: false,
onButtonClick: null,
}
const Selector = "exe-button";
export default class EXEButton extends HTMLElement {
// 指定觀察到的屬性變化,attributeChangedCallback 會起作用
static get observedAttributes() {
return ['e-button-type','e-button-text', 'buttonType', 'buttonText']
}
shadowRoot = null;
config = defaultConfig;
constructor(){
super();
this.render();
}
render() {
this.shadowRoot = this.attachShadow({mode: 'closed'});
}
connectedCallback() {
this.updateStyle();
this.initEventListen();
}
attributeChangedCallback (name, oldValue, newValue) {
// console.log('屬性變化', name)
}
updateStyle() {
this.config = {...defaultConfig, ...getAttributes(this)};
this.shadowRoot.innerHTML = renderTemplate(this.config);
}
initEventListen() {
const { onButtonClick } = this.config;
if(isStr(onButtonClick)){
const canClick = !this.disabled && !this.loading
this.addEventListener('click', e => canClick && runFun(e, onButtonClick));
}
}
get disabled () {
return this.getAttribute('disabled') !== null;
}
get type () {
return this.getAttribute('type') !== null;
}
get loading () {
return this.getAttribute('loading') !== null;
}
}
if (!customElements.get(Selector)) {
customElements.define(Selector, EXEButton)
}
模版定義如下:
// EXE-Components/components/exe-button/tempalte.js
// 按鈕邊框型別
const borderStyle = { solid: 'solid', dashed: 'dashed' };
// 按鈕型別
const buttonTypeMap = {
default: { textColor: '#222', bgColor: '#FFF', borderColor: '#222'},
primary: { textColor: '#FFF', bgColor: '#5FCE79', borderColor: '#5FCE79'},
text: { textColor: '#222', bgColor: '#FFF', borderColor: '#FFF'},
}
export default config => {
const { buttonRadius, buttonText, buttonType } = config;
const borderStyleCSS = buttonType
&& borderStyle[buttonType]
? borderStyle[buttonType]
: borderStyle['solid'];
const backgroundCSS = buttonType
&& buttonTypeMap[buttonType]
? buttonTypeMap[buttonType]
: buttonTypeMap['default'];
return `
<style>
.exe-button {
border: 1px ${borderStyleCSS} ${backgroundCSS.borderColor};
color: ${backgroundCSS.textColor};
background-color: ${backgroundCSS.bgColor};
font-size: 12px;
text-align: center;
padding: 4px 10px;
border-radius: ${buttonRadius};
cursor: pointer;
display: inline-block;
height: 28px;
}
:host([disabled]) .exe-button{
cursor: not-allowed;
pointer-events: all;
border: 1px solid #D6D6D6;
color: #ABABAB;
background-color: #EEE;
}
:host([loading]) .exe-button{
cursor: not-allowed;
pointer-events: all;
border: 1px solid #D6D6D6;
color: #ABABAB;
background-color: #F9F9F9;
}
</style>
<button class="exe-button">${buttonText}</button>
`
}
最終效果如下:
5. 開發 exe-user-avatar 元件
該元件是將前面 exe-avatar
元件和 exe-button
元件進行組合,不僅需要支援點選事件,還需要支援插槽 slot 功能。
由於是做組合,所以開發起來比較簡單~先看看入口檔案:
// EXE-Components/modules/exe-user-avatar/index.js
import renderTemplate from './template.js';
import { Shared, Utils } from '../../utils/index.js';
const { getAttributes } = Shared;
const { isStr, runFun } = Utils;
const defaultConfig = {
userName: "",
subName: "",
disableButton: false,
onAvatarClick: null,
onButtonClick: null,
}
export default class EXEUserAvatar extends HTMLElement {
shadowRoot = null;
config = defaultConfig;
constructor() {
super();
this.render();
}
render() {
this.shadowRoot = this.attachShadow({mode: 'open'});
}
connectedCallback() {
this.updateStyle();
this.initEventListen();
}
initEventListen() {
const { onAvatarClick } = this.config;
if(isStr(onAvatarClick)){
this.addEventListener('click', e => runFun(e, onAvatarClick));
}
}
updateStyle() {
this.config = {...defaultConfig, ...getAttributes(this)};
this.shadowRoot.innerHTML = renderTemplate(this.config);
}
}
if (!customElements.get('exe-user-avatar')) {
customElements.define('exe-user-avatar', EXEUserAvatar)
}
主要內容在 template.js 中:
// EXE-Components/modules/exe-user-avatar/template.js
import { Shared } from '../../utils/index.js';
const { renderAttrStr } = Shared;
export default config => {
const {
userName, avatarWidth, avatarRadius, buttonRadius,
avatarSrc, buttonType = 'primary', subName, buttonText, disableButton,
onAvatarClick, onButtonClick
} = config;
return `
<style>
:host{
color: "green";
font-size: "30px";
}
.exe-user-avatar {
display: flex;
margin: 4px 0;
}
.exe-user-avatar-text {
font-size: 14px;
flex: 1;
}
.exe-user-avatar-text .text {
color: #666;
}
.exe-user-avatar-text .text span {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
exe-avatar {
margin-right: 12px;
width: ${avatarWidth};
}
exe-button {
width: 60px;
display: flex;
justify-content: end;
}
</style>
<div class="exe-user-avatar">
<exe-avatar
${renderAttrStr({
'e-avatar-width': avatarWidth,
'e-avatar-radius': avatarRadius,
'e-avatar-src': avatarSrc,
})}
></exe-avatar>
<div class="exe-user-avatar-text">
<div class="name">
<span class="name-text">${userName}</span>
<span class="user-attach">
<slot name="name-slot"></slot>
</span>
</div>
<div class="text">
<span class="name">${subName}<slot name="sub-name-slot"></slot></span>
</div>
</div>
${
!disableButton &&
`<exe-button
${renderAttrStr({
'e-button-radius' : buttonRadius,
'e-button-type' : buttonType,
'e-button-text' : buttonText,
'on-avatar-click' : onAvatarClick,
'on-button-click' : onButtonClick,
})}
></exe-button>`
}
</div>
`
}
其中 renderAttrStr
方法接收一個屬性物件,返回其鍵值對字串:
// input
{
'e-avatar-width': 100,
'e-avatar-radius': 50,
'e-avatar-src': './testAssets/images/avatar-1.png',
}
// output
"e-avatar-width='100' e-avatar-radius='50' e-avatar-src='./testAssets/images/avatar-1.png' "
最終效果如下:
6. 實現一個使用者列表業務
接下來我們通過一個實際業務,來看看我們元件的效果:
其實實現也很簡單,根據給定資料,然後迴圈使用元件即可,假設有以下使用者資料:
const users = [
{"name":"前端早早聊","desc":"幫 5000 個前端先跑 @ 前端早早聊","level":6,"avatar":"qdzzl.jpg","home":"https://juejin.cn/user/712139234347565"}
{"name":"來自拉夫德魯的碼農","desc":"誰都不救我,誰都救不了我,就像我救不了任何人一樣","level":2,"avatar":"lzlfdldmn.jpg","home":"https://juejin.cn/user/994371074524862"}
{"name":"黑色的楓","desc":"永遠懷著一顆學徒的心。。。","level":3,"avatar":"hsdf.jpg","home":"https://juejin.cn/user/2365804756348103"}
{"name":"captain_p","desc":"目的地很美好,路上的風景也很好。今天增長見識了嗎","level":2,"avatar":"cap.jpg","home":"https://juejin.cn/user/2532902235026439"}
{"name":"CUGGZ","desc":"文章聯絡微信授權轉載。微信:CUG-GZ,新增好友一起學習~","level":5,"avatar":"cuggz.jpg","home":"https://juejin.cn/user/3544481220801815"}
{"name":"政採雲前端團隊","desc":"政採雲前端 ZooTeam 團隊,不摻水的原創。 團隊站點:https://zoo.team","level":6,"avatar":"zcy.jpg","home":"https://juejin.cn/user/3456520257288974"}
]
我們就可以通過簡單 for 迴圈拼接 HTML 片段,然後新增到頁面某個元素中:
// 測試生成使用者列表模版
const usersTemp = () => {
let temp = '', code = '';
users.forEach(item => {
const {name, desc, level, avatar, home} = item;
temp +=
`
<exe-user-avatar
e-user-name="${name}"
e-sub-name="${desc}"
e-avatar-src="./testAssets/images/users/${avatar}"
e-avatar-width="36px"
e-button-type="primary"
e-button-text="關注"
on-avatar-click="toUserHome('${home}')"
on-button-click="toUserFollow('${name}')"
>
${
level >= 0 && `<span slot="name-slot">
<span class="medal-item">(Lv${level})</span>
</span>`}
</exe-user-avatar>
`
})
return temp;
}
document.querySelector('#app').innerHTML = usersTemp;
到這邊我們就實現了一個使用者列表的業務,當然實際業務可能會更加複雜,需要再優化。
五、總結
本文首先簡單回顧 Web Components 核心 API,然後對元件庫需求進行分析設計,再進行環境搭建和開發,內容比較多,可能沒有每一點都講到,還請大家看看我倉庫的原始碼,有什麼問題歡迎和我討論。
寫本文的幾個核心目的:
- 當我們接到一個新任務的時候,需要從分析設計開始,再到開發,而不是盲目一上來就開始開發;
- 帶大家一起看看如何用 Web Components 開發簡單的業務元件庫;
- 體驗一下 Web Components 開發元件庫有什麼缺點(就是要寫的東西太多了)。
最後看完本文,大家是否覺得用 Web Components 開發元件庫,實在有點複雜?要寫的太多了。
沒關係,下一篇我將帶大家一起使用 Stencil 框架開發 Web Components 標準的元件庫,畢竟整個 ionic 已經是使用 Stencil 重構,Web Components 大勢所趨~!