组件化是前端倒退的一个重要方向,它一方面进步开发效率,另一方面升高保护老本。支流的 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 件事:

  1. 实现组件类

通过实现 CustomElementStart 类来定义组件。

  1. 定义组件

将组件的标签和组件类作为参数,通过 customElements.define 办法定义组件。

  1. 应用组件

导入组件后,跟应用一般 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. 小结

这节次要通过一个简略示例,简略回顾基础知识,具体能够浏览文档:

  • 应用 custom elements
  • 应用 shadow DOM
  • 应用 templates and slots

二、EXE-Components 组件库剖析设计

1. 背景介绍

假如咱们须要实现一个 EXE-Components 组件库,该组件库的组件分 2 大类:

  1. components 类型

通用简略组件为主,如exe-avatar头像组件、 exe-button按钮组件等;

  1. modules 类型

简单、组合组件为主,如exe-user-avatar用户头像组件(含用户信息)、exe-attachement-list附件列表组件等等。

具体能够看下图:

接下来咱们会基于上图进行 EXE-Components 组件库设计和开发。

2. 组件库设计

在设计组件库的时候,次要须要思考以下几点:

  1. 组件命名、参数命名等标准,不便组件后续保护;
  2. 组件参数定义;
  3. 组件款式隔离;

当然,这几个是最根底须要思考的点,随着理论业务的简单,还须要思考更多,比方:工程化相干、组件解耦、组件主题等等。

针对后面提到这 3 点,这边约定几个命名标准:

  1. 组件名称以 exe-性能名称 进行命名,如 exe-avatar示意头像组件;
  2. 属性参数名称以 e-参数名称 进行命名,如 e-src 示意 src 地址属性;
  3. 事件参数名称以 on-事件类型 进行命名,如 on-click示意点击事件;

3. 组件库组件设计

这边咱们次要设计 exe-avatarexe-buttonexe-user-avatar三个组件,前两个为简略组件,后一个为简单组件,其外部应用了前两个组件进行组合。这边先定义这三个组件反对的属性:

这边属性命名看着会比较复杂,大家能够依照本人和团队的习惯进行命名。

这样咱们思路就清晰很多,实现对应组件即可。

三、EXE-Components 组件库筹备工作

本文示例最终将对实现的组件进行组合应用,实现上面「用户列表」成果:

体验地址:https://blog.pingan8787.com/exe-components/demo.html

1. 对立开发标准

首先咱们先对立开发标准,包含:

  1. 目录标准

  1. 定义组件标准

  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 -ynpm install --global rollup http-server

而后在 package.jsonscript 下增加 "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.jsimport './components/index.js';import './modules/index.js';// EXE-Components/components/index.jsimport './exe-avatar/index.js';import './exe-button/index.js';// EXE-Components/modules/index.jsimport './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.jsimport 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.jsexport 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.jsimport 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.jsimport 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.jsimport { 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,而后对组件库需要进行剖析设计,再进行环境搭建和开发,内容比拟多,可能没有每一点都讲到,还请大家看看我仓库的源码,有什么问题欢送和我探讨。

写本文的几个外围目标:

  1. 当咱们接到一个新工作的时候,须要从剖析设计开始,再到开发,而不是自觉一上来就开始开发;
  2. 带大家一起看看如何用 Web Components 开发简略的业务组件库;
  3. 体验一下 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.