【總結(jié)】- 從 0 到 1 上手 Web Components 業(yè)務組件庫開發(fā)
組件化是前端發(fā)展的一個重要方向,它一方面提高開發(fā)效率,另一方面降低維護成本。主流的 Vue.js、React 及其延伸的 Ant Design、uniapp、Taro 等都是組件框架。Web Components 是一組 Web 原生 API 的總稱,允許我們創(chuàng)建可重用的自定義組件,并在我們 Web 應用中像使用原生 HTML 標簽一樣使用。目前已經(jīng)很多前端框架/庫支持 Web Components。
本文將帶大家回顧 Web Components 核心 API,并從 0 到 1 實現(xiàn)一個基于 Web Components API 開發(fā)的業(yè)務組件庫。
最終效果:https://blog.pingan8787.com/exe-components/demo.html倉庫地址:https://github.com/pingan8787/Learn-Web-Components
一、回顧 Web Components
在前端發(fā)展歷史中,從剛開始重復業(yè)務到處復制相同代碼,到 Web Components 的出現(xiàn),我們使用原生 HTML 標簽的自定義組件,復用組件代碼,提高開發(fā)效率。通過 Web Components 創(chuàng)建的組件,幾乎可以使用在任何前端框架中。
1. 核心 API 回顧
Web Components 由 3 個核心 API 組成:
-
「Custom elements(自定義元素)」:用來讓我們定義「自定義元素」及其「行為」,對外提供組件的標簽; -
「Shadow DOM(影子 DOM)」:用來封裝組件內(nèi)部的結(jié)構(gòu),避免與外部沖突; -
「HTML templates(HTML 模版)」:包括 <template>和<slot>元素,讓我們可以定義各種組件的 HTML 模版,然后被復用到其他地方,使用過 Vue/React 等框架的同學應該會很熟悉。
另外,還有 HTML imports,但目前已廢棄,所以不具體介紹,其作用是用來控制組件的依賴加載。

2. 入門示例
接下來通過下面簡單示例快速了解一下「如何創(chuàng)建一個簡單 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
* 參數(shù)如下:
* - 元素名稱,符合 DOMString 規(guī)范,名稱不能是單個單詞,且必須用短橫線隔開
* - 元素行為,必須是一個類
* - 繼承元素,可選配置,一個包含 extends 屬性的配置對象,指定創(chuàng)建的元素繼承自哪個內(nèi)置元素,可以繼承任何內(nèi)置元素。
*/
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 件事:
-
實現(xiàn)組件類
通過實現(xiàn) CustomElementStart 類來定義組件。
-
定義組件
將組件的標簽和組件類作為參數(shù),通過 customElements.define 方法定義組件。
-
使用組件
導入組件后,跟使用普通 HTML 標簽一樣直接使用自定義組件 <custom-element-start></custom-element-start>。
隨后瀏覽器訪問 index.html 可以看到下面內(nèi)容:
3. 兼容性介紹
在 MDN | Web Components 章節(jié)中介紹了其兼容性情況:
Firefox(版本63)、Chrome和Opera都默認支持Web組件。 Safari支持許多web組件特性,但比上述瀏覽器少。 Edge正在開發(fā)一個實現(xiàn)。
關(guān)于兼容性,可以看下圖:
圖片來源:https://www.webcomponents.org/
這個網(wǎng)站里面,有很多關(guān)于 Web Components 的優(yōu)秀項目可以學習。
4. 小結(jié)
這節(jié)主要通過一個簡單示例,簡單回顧基礎(chǔ)知識,詳細可以閱讀文檔:
-
使用 custom elements -
使用 shadow DOM -
使用 templates and slots
二、EXE-Components 組件庫分析設(shè)計
1. 背景介紹
假設(shè)我們需要實現(xiàn)一個 EXE-Components 組件庫,該組件庫的組件分 2 大類:
-
components 類型
以「通用簡單組件」為主,如exe-avatar頭像組件、 exe-button按鈕組件等;
-
modules 類型
以「復雜、組合組件」為主,如exe-user-avatar用戶頭像組件(含用戶信息)、exe-attachement-list附件列表組件等等。
詳細可以看下圖:
接下來我們會基于上圖進行 EXE-Components 組件庫設(shè)計和開發(fā)。
2. 組件庫設(shè)計
在設(shè)計組件庫的時候,主要需要考慮以下幾點:
-
組件命名、參數(shù)命名等規(guī)范,方便組件后續(xù)維護; -
組件參數(shù)定義; -
組件樣式隔離;
當然,這幾個是最基礎(chǔ)需要考慮的點,隨著實際業(yè)務的復雜,還需要考慮更多,比如:工程化相關(guān)、組件解耦、組件主題等等。
針對前面提到這 3 點,這邊約定幾個命名規(guī)范:
-
組件名稱以 exe-功能名稱進行命名,如exe-avatar表示頭像組件; -
屬性參數(shù)名稱以 e-參數(shù)名稱進行命名,如e-src表示src地址屬性; -
事件參數(shù)名稱以 on-事件類型進行命名,如on-click表示點擊事件;
3. 組件庫組件設(shè)計
這邊我們主要設(shè)計 exe-avatar 、exe-button 和 exe-user-avatar三個組件,前兩個為簡單組件,后一個為復雜組件,其內(nèi)部使用了前兩個組件進行組合。這邊先定義這三個組件支持的屬性:
這邊屬性命名看著會比較復雜,大家可以按照自己和團隊的習慣進行命名。
這樣我們思路就清晰很多,實現(xiàn)對應組件即可。
三、EXE-Components 組件庫準備工作
本文示例最終將對實現(xiàn)的組件進行「組合使用」,實現(xiàn)下面「「用戶列表」」效果:
體驗地址:https://blog.pingan8787.com/exe-components/demo.html
1. 統(tǒng)一開發(fā)規(guī)范
首先我們先統(tǒng)一開發(fā)規(guī)范,包括:
-
目錄規(guī)范
-
定義組件規(guī)范
-
組件開發(fā)模版
組件開發(fā)模版分 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(); // 統(tǒng)一處理組件初始化邏輯
}
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 => {
// 統(tǒng)一讀取配置
const { avatarWidth, avatarRadius, avatarSrc } = config;
return `
<style>
/* CSS 內(nèi)容 */
</style>
<div class="exe-avatar">
/* HTML 內(nèi)容 */
</div>
`
}
2. 開發(fā)環(huán)境搭建和工程化處理
為了方便使用 EXE-Components 組件庫,更接近實際組件庫的使用,我們需要將組件庫打包成一個 UMD 類型的 js 文件。這邊我們使用 rollup 進行構(gòu)建,最終打包成 exe-components.js 的文件,使用方式如下:
<script src="./exe-components.js"></script>
接下來通過 npm init -y生成 package.json文件,然后全局安裝 rollup 和 http-server(用來啟動本地服務器,方便調(diào)試):
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 啟動靜態(tài)服務器,作為開發(fā)環(huán)境使用。添加-c-1參數(shù)用來禁用緩存,避免刷新頁面還會有緩存,詳細可以看 http-server 文檔; -
"build"命令:將 index.js 作為 rollup 打包的入口文件,輸出exe-components.js文件,并且是 iife 類型的文件。
這樣就完成簡單的本地開發(fā)和組件庫構(gòu)建的工程化配置,接下來就可以進行開發(fā)了。
四、EXE-Components 組件庫開發(fā)
1. 組件庫入口文件配置
前面 package.json 文件中配置的 "build" 命令,會使用根目錄下 index.js 作為入口文件,并且為了方便 components 通用基礎(chǔ)組件和 modules 通用復雜組件的引入,我們創(chuàng)建 3 個 index.js,創(chuàng)建后目錄結(jié)構(gòu)如下:
三個入口文件內(nèi)容分別如下:
// 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. 開發(fā) exe-avatar 組件 index.js 文件
通過前面的分析,我們可以知道 exe-avatar組件需要支持參數(shù):
-
e-avatar-src:頭像圖片地址,例如:./testAssets/images/avatar-1.png -
e-avatar-width:頭像寬度,默認和高度一致,例如:52px -
e-button-radius:頭像圓角,例如:22px,默認:50% -
on-avatar-click:頭像點擊事件,默認無
接著按照之前的模版,開發(fā)入口文件 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 模版內(nèi)容
}
// 生命周期:當 custom element首次被插入文檔DOM時,被調(diào)用。
connectedCallback() {
this.updateStyle();
this.initEventListen();
}
updateStyle() {
this.config = {...defaultConfig, ...getAttributes(this)};
this.shadowRoot.innerHTML = renderTemplate(this.config); // 生成 HTML 模版內(nèi)容
}
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 和事件名稱作為參數(shù),調(diào)用該方法,示例和上一步一樣,會執(zhí)行 avatarClick() 方法。
另外,Web Components 生命周期可以詳細看文檔:使用生命周期回調(diào)函數(shù)。
3. 開發(fā) 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>
`
}
最終實現(xiàn)效果如下:
開發(fā)完第一個組件,我們可以簡單總結(jié)一下創(chuàng)建和使用組件的步驟:
4. 開發(fā) exe-button 組件
按照前面 exe-avatar組件開發(fā)思路,可以很快實現(xiàn) exe-button 組件。需要支持下面參數(shù):
-
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. 開發(fā) exe-user-avatar 組件
該組件是將前面 exe-avatar 組件和 exe-button 組件進行組合,不僅需要支持「點擊事件」,還需要支持「插槽 slot 功能」。由于是做組合,所以開發(fā)起來比較簡單~先看看入口文件:
// 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)
}
主要內(nèi)容在 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. 實現(xiàn)一個用戶列表業(yè)務
接下來我們通過一個實際業(yè)務,來看看我們組件的效果:
其實實現(xiàn)也很簡單,根據(jù)給定數(shù)據(jù),然后循環(huán)使用組件即可,假設(shè)有以下用戶數(shù)據(jù):
const users = [
{"name":"前端早早聊","desc":"幫 5000 個前端先跑 @ 前端早早聊","level":6,"avatar":"qdzzl.jpg","home":"https://juejin.cn/user/712139234347565"}
{"name":"來自拉夫德魯?shù)拇a農(nóng)","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":"文章聯(lián)系微信授權(quán)轉(zhuǎn)載。微信:CUG-GZ,添加好友一起學習~","level":5,"avatar":"cuggz.jpg","home":"https://juejin.cn/user/3544481220801815"}
{"name":"政采云前端團隊","desc":"政采云前端 ZooTeam 團隊,不摻水的原創(chuàng)。 團隊站點:https://zoo.team","level":6,"avatar":"zcy.jpg","home":"https://juejin.cn/user/3456520257288974"}
]
我們就可以通過簡單 for 循環(huán)拼接 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="關(guān)注"
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;
到這邊我們就實現(xiàn)了一個用戶列表的業(yè)務,當然實際業(yè)務可能會更加復雜,需要再優(yōu)化。
五、總結(jié)
本文首先簡單回顧 Web Components 核心 API,然后對組件庫需求進行分析設(shè)計,再進行環(huán)境搭建和開發(fā),內(nèi)容比較多,可能沒有每一點都講到,還請大家看看我倉庫的源碼,有什么問題歡迎和我討論。寫本文的幾個核心目的:
-
當我們接到一個新任務的時候,需要從分析設(shè)計開始,再到開發(fā),而不是盲目一上來就開始開發(fā); -
帶大家一起看看如何用 Web Components 開發(fā)簡單的業(yè)務組件庫; -
體驗一下 Web Components 開發(fā)組件庫有什么缺點(就是要寫的東西太多了)。
最后看完本文,大家是否覺得用 Web Components 開發(fā)組件庫,實在有點復雜?要寫的太多了。沒關(guān)系,下一篇我將帶大家一起使用 Stencil 框架開發(fā) Web Components 標準的組件庫,畢竟整個 ionic 已經(jīng)是使用 Stencil 重構(gòu),Web Components 大勢所趨~!
拓展閱讀
-
WEBCOMPONENTS.ORG Discuss & share web components -
Web Components as Technology -
Stenciljs - Build. Customize. Distribute. Adopt.
往期推薦
大廠面試官:我理想中的前端
對話Svelte未來,Rust 編譯器?構(gòu)建大型應用?
收藏!史上最全 Vue 前端代碼風格指南
最后
歡迎加我微信,拉你進技術(shù)群,長期交流學習...
歡迎關(guān)注「前端Q」,認真學前端,做個專業(yè)的技術(shù)人...
點個在看支持我吧
