從 0 到 1 上手 Web Components 業務組件庫開發
組件化是前端發展的一個重要方向,它一方面提高開發效率,另一方面降低維護成本。主流的 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 正在開發一個實現。
關於兼容性,可以看下圖:
這個網站裏面,有很多關於 Web Components 的優秀項目可以學習。
4. 小結
這節主要通過一個簡單示例,簡單回顧基礎知識,詳細可以閱讀文檔:
-
使用 custom elements
-
使用 shadow DOM
-
使用 templates and slots
二、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 組件庫準備工作
本文示例最終將對實現的組件進行**「組合使用」**,實現下面「**「用戶列表」**」效果:
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>${userName}</span>
<span>
<slot ></slot>
</span>
</div>
<div class="text">
<span>${subName}<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>(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 大勢所趨~!
拓展閱讀
-
WEBCOMPONENTS.ORG Discuss & share web components
-
Web Components as Technology
-
Stenciljs - Build. Customize. Distribute. Adopt.
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/jz-dJMxE-_C7dIDdSK5HAA