四、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 大势所趋~!