4

如何基于 WebComponents 封装 UI 组件库

 1 year ago
source link: https://www.zoo.team/article/web-components
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

文章目录

  • WebComponents 三要素和生命周期

    • Button 组件示例

    • 三要素、生命周期和示例的解析

    • 传入复杂数据类型

    • 状态的双向绑定

  • 封装我们自己的组件库

    • 设计目录结构

    • 全部导入和按需导入

    • 自定义配置主题

  • 在原生、Vue 和 React 中优雅的使用

    • 在原生 HTML 中应用:

    • 在 Vue 2x 中的应用:

    • 在 Vue 3x 中的差异:

      • 跳过 Vue 本身对组件的解析

    • 在 React 中的应用

  • 总结现阶段的劣势

  • 参考文档:

如何基于 WebComponents 封装 UI 组件库

2022-05-09 发布于 前端工程化 · 阅读量:183

责任小编:加分

作为一名前端攻城狮,相信大家也都在关注着前端的一些新技术,近些年来前端组件化开发已为常态,我们经常把重用性搞的模块抽离成一个个的组件,来达到复用的目的,这样减少了我们的维护成本,提高了开发的效率。但是都有一个缺点离不开框架本身,因为我们浏览器本身解析不了那些组件。那么有没有一种技术也可以达到这种效果呢?答案就是今天的主角 Web Components。

Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。 目前 W3C 也在积极推动,并且浏览器的支持情况还不错。FireFox、Chrome、Opera 已全部支持,Safari 也大部分支持,Edge 也换成 webkit 内核了,离全面支持应该也不远了。当然社区也有兼容的解决方案 webcomponents/polyfills 。

WebComponents 三要素和生命周期

Button 组件示例

首先我们就从一个最简单的 Button 组件开始,我们可以通过在组件中传入 type 来改变按钮的样式,并且动态监听了数据的变化。

// html <cai-button type="primary"> <span slot="btnText"> </span> </cai-button> <template id="caiBtn"> <style> .cai-button { display: inline-block; padding: 4px 20px; font-size: 14px; line-height: 1.5715; font-weight: 400; border: 1px solid #1890ff; border-radius: 2px; background-color: #1890ff; color: #fff; box-shadow: 0 2px #00000004; .cai-button-warning { border: 1px solid #faad14; background-color: #faad14; .cai-button-danger { border: 1px solid #ff4d4f; background-color: #ff4d4f; </style> <div class="cai-button"> <slot name="btnText"></slot> </div> </template> <script> const template = document.getElementById("caiBtn"); class CaiButton extends HTMLElement { constructor() { super() this._type = { primary: 'cai-button', warning: 'cai-button-warning', danger: 'cai-button-danger', // 开启shadow dom const shadow = this.attachShadow({ mode: 'open' const type = this const content = template.content.cloneNode(true) // 克隆一份 防止重复使用 污染 // 把响应式数据挂到this this._btn = content.querySelector('.cai-button') this._btn.className += ` ${this._type[type]}` shadow.appendChild(content) static get observedAttributes() { return ['type'] attributeChangedCallback(name, oldValue, newValue) { this[name] = newValue; this.render(); render() { this._btn.className = `cai-button ${this._type[this.type]}` // 挂载到window window.customElements.define('cai-button', CaiButton) </script>

三要素、生命周期和示例的解析

  • Custom elements(自定义元素): 一组 JavaScript API,允许您定义 custom elements 及其行为,然后可以在您的用户界面中按照需要使用它们。在上面例子中就指的是我们的自定义组件,我们通过class CaiButton extends HTMLElement {} 定义我们的组件,通过window.customElements.define('cai-button', CaiButton)挂载我们的已定义组件。

  • Shadow DOM(影子 DOM ):一组 JavaScript API,用于将封装的“影子” DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。使用const shadow = this.attachShadow({mode : 'open'})在 WebComponents 中开启。

  • HTML templates(HTML模板)slot :template 可以简化生成dom元素的操作,我们不再需要 createElement 每一个节点。slot 则和 Vue 里面的 slot 类似,只是使用名称不太一样。

    内部生命周期函数

  • connectedCallback: 当 WebComponents 第一次被挂在到 dom 上是触发的钩子,并且只会触发一次。类似 Vue 中的 mounted React 中的 useEffect(() => {}, []),componentDidMount。

  • disconnectedCallback: 当自定义元素与文档 DOM 断开连接时被调用。

  • adoptedCallback: 当自定义元素被移动到新文档时被调用。

  • attributeChangedCallback: 当自定义元素的被监听属性变化时被调用。上述例子中我们监听了 type 的变化,使 button 组件呈现不同状态。 虽然 WebComponents 有三个要素,但却不是缺一不可的,WebComponents 借助 shadow dom 来实现样式隔离,借助 templates 来简化标签的操作。

在这个例子用我们使用了 slot 传入了俩个标签之间的内容,如果我们想要不使用 slot 传入标签之间的内容怎么办?

我们可以通过 innerHTML 拿到自定义组件之间的内容,然后把这段内容插入到对应节点即可。

了解上面这些基本的概念后,我们就可以开发一些简单的组件了,但是如果我们想传入一些复杂的数据类型(对象,数组等)怎么办?我们只传入字符串还可以么?答案是肯定的!

传入复杂数据类型

使用我们上面的 button,我们不仅要改变状态,而且要想要传入一些配置,我们可以通过传入一个 JSON 字符串

// html <cai-button id="btn"> </cai-button> <script> btn.setAttribute('config', JSON.stringify({icon: '', posi: ''})) </script> // button.js class CaiButton extends HTMLElement { constructor() { static get observedAttributes() { return ['type', 'config'] // 监听config attributeChangedCallback(name, oldValue, newValue) { if(name === 'config') { newValue = JSON.parse(newValue) this[name] = newValue; this.render(); render() { window.customElements.define('cai-button', CaiButton)

这种方式虽然可行但却不是很优雅。

  • 对于使用者说:我用你个组件你还要让我把所有的复杂类型都转换成字符串?
  • 对于开发组件者来说:我为什么要每次都 JSON.parse() 一下?
  • HTML 中会有很长的数据。 upload_1b96a1b67c9e1c2757f693dab22e90ff.png

因此我们需要换一个思路,我们上面使用的方式都是 attribute 传值,数据类型只能是字符串,那我们可以不用它传值吗?答案当然也是可以的。和 attribute 形影不离还有我们 js 中的property,它指的是 dom 属性,是js对象并且支持传入复杂数据类型。

// table组件 demo,以下为伪代码 仅展示思路 <cai-table id="table"> </cai-table> table.dataSource = [{ name: 'xxx', age: 19 }] table.columns = [{ title: '', key: '' }]

这种方式虽然解决上述问题,但是又引出了新的问题--自定义组件中没有办法监听到这个属性的变化,那现在我们应该怎么办? 或许从一开始是我们的思路就是错的,显然对于数据的响应式变化是我们原生 js 本来就不太具备的能力,我们不应该把使用过的框架的思想过于带入,因此从组件使用的方式上我们需要做出改变,我们不应该过于依赖属性的配置来达到某种效果,因此改造方法如下。

<cai-table thead="Name|Age"> <cai-tr> <cai-td>zs</cai-td> <cai-td>18</cai-td> </cai-tr> <cai-tr> <cai-td>ls</cai-td> <cai-td>18</cai-td> </cai-tr> </cai-table>

我们把属于 HTML 原生的能力归还,而是不是采用配置的方式,就解决了这个问题,但是这样同时也决定了我们的组件并不支持太过复杂的能力。

状态的双向绑定

上面讲了数据的单向绑定,组件状态页面也会随之更新,那么我们怎么实现双向绑定呢?

接下来我们封装一个 input 来实现双向绑定。

<cai-input id="ipt" :value="data" @change="(e) => { data = e.detail }"></cai-input> (function () { const template = document.createElement('template') template.innerHTML = ` <style> .cai-input { </style> <input type="text" id="caiInput"> class CaiInput extends HTMLElement { constructor() { super() const shadow = this.attachShadow({ mode: 'closed' const content = template.content.cloneNode(true) this._input = content.querySelector('#caiInput') this._input.value = this.getAttribute('value') shadow.appendChild(content) this._input.addEventListener("input", ev => { const target = ev.target; const value = target.value; this.value = value; this.dispatchEvent(new CustomEvent("change", { detail: value })); get value() { return this.getAttribute("value"); set value(value) { this.setAttribute("value", value); window.customElements.define('cai-input', CaiInput)
  • 这样就封装了一个简单双向绑定的 input 组件,代码中 get/set 和 observedAttributes / attributeChangedCallback 前者是监听单个,后者可以监听多个状态改变并做出处理。
  • 这里面核心的一步是 我们监听了这个表单的input事件,并且在每次触发 input 事件的时候触发自定义的 change 事件,并且把输入的参数回传。
  • 那我们应该怎么使用呢? 以 vue 为例子,vue 的双向绑定 v-model 其实是一个语法糖, 我们的组件则没有办法使用这个语法糖,与 v-model 不简化写法类似 <cai-input :value="data" @change="(e) => { data = e.detail }">

封装我们自己的组件库

设计目录结构

第一步:要有一个优雅的组价库我们首先要设计一个优雅的目录结构 设计目录结构如下

└── cai-ui ├── components // 自定义组件 | ├── Button | | ├── index.js | └── ... └── index.js. // 主入口

独立封装我们的组件,由于我们组件库中组件的引入,我们肯定是需要把每个组件封装到单独文件中的。

在我们的 Button/index.js 中写入如下:

(function () { const template = document.createElement('template') template.innerHTML = ` <style> /* css和上面一样 */ </style> <div class="cai-button"> <slot name="text"></slot> </div> class CaiButton extends HTMLElement { constructor() { super() // 其余和上述一样 static get observedAttributes() { return ['type'] attributeChangedCallback(name, oldValue, newValue) { this[name] = newValue; this.render(); render() { this._btn.className = `cai-button ${this._type[this.type]}` window.customElements.define('cai-button', CaiButton)

封装到组件到单独的 js 文件中

全部导入和按需导入

  1. 支持全部导入,我们通过一个 js 文件全部引入组件
  2. // index.js
import './components/Button/index.js' import './components/xxx/xxx.js' 按需导入我们只需要导入组件的js文件即可如import 'cai-ui/components/Button/index.js'

自定义配置主题

支持主题色可配置 我们只需把颜色写成变量即可,改造如下:

(function () { const template = document.createElement('template') template.innerHTML = ` <style> /* 多余省略 */ .cai-button { border: 1px solid var(--primary-color, #1890ff); background-color: var(--primary-color, #1890ff); .cai-button-warning { border: 1px solid var(--warning-color, #faad14); background-color: var(--warning-color, #faad14); .cai-button-danger { border: 1px solid var(--danger-color, #ff4d4f); background-color: var(--danger-color, #ff4d4f); </style> <div class="cai-button"> <slot name="text"></slot> </div> // 后面省略...

这样我们就能在全局中修改主题色了。 案例地址

在原生、Vue 和 React 中优雅的使用

在原生 HTML 中应用:

<script type="module"> import '//cai-ui'; </script> <!--or--> <script type="module" src="//cai-ui"></script> <cai-button type="primary">点击</cai-button> <cai-input id="caiIpt"></cai-button> <script> const caiIpt = document.getElementById('caiIpt') /* 获取输入框的值有两种方法 * 1. getAttribute * 2. change 事件 caiIpt.getAttribute('value') caiIpt.addEventListener('change', function(e) { console.log(e); // e.detail 为表单的值 </script>

在 Vue 2x 中的应用:

// main.js import 'cai-ui'; <template> <div id="app"> <cai-button :type="type"> <span slot="text">哈哈哈</span> </cai-button> <cai-button @click="changeType"> <span slot="text">哈哈哈</span> </cai-button> <cai-input id="ipt" :value="data" @change="(e) => { data = e.detail }"></cai-input> </div> </template> <script> export default { name: "App", components: {}, data(){ return { type: 'primary', data: '', methods: { changeType() { console.log(this.data); this.type = 'danger' </script>

在 Vue 3x 中的差异:

在最近的 Vue3 中,Vue 对 WebComponents 有了更好的支持。Vue 在 Custom Elements Everywhere 测试中获得了 100% 的完美分数。但是还需要我们做出如下配置:

跳过 Vue 本身对组件的解析

custom Elements 的风格和 Vue 组件很像,导致 Vue 会把自定义(非原生的 HTML 标签)标签解析并注册为一个 Vue 组件,然后解析失败才会再解析为一个自定义组件,这样会消耗一定的性能并且会在控制台警告,因此我们需要在构建工具中跳过这个解析:

// vite.config.js import vue from '@vitejs/plugin-vue' export default { plugins: [ vue({ template: { compilerOptions: { // 将所有包含短横线的标签作为自定义元素处理 isCustomElement: tag => tag.includes('-')

组件的具体使用方法和 Vue 2x 类似。

在 React 中的应用

import React, { useEffect, useRef, useState } from 'react'; import 'cai-ui' function App() { const [type, setType] = useState('primary'); const [value, setValue] = useState(); const iptRef = useRef(null) useEffect(() => { document.getElementById('ipt').addEventListener('change', function(e) { console.log(e); }, []) const handleClick = () => { console.log(value); setType('danger') return ( <div className="App"> <cai-button type={type}> <span slot="text">哈哈哈</span> </cai-button> <cai-button onClick={handleClick}> <span slot="text">点击</span> </cai-button> <cai-input id="ipt" ref={iptRef} value={value} ></cai-input> </div> export default App;

Web Components 触发的事件可能无法通过 React 渲染树正确的传递。 你需要在 React 组件中手动添加事件处理器来处理这些事件。 在 React 使用有个点我们需要注意下,WebComponents 组件我们需要添加类时需要使用 claas 而不是 className

总结现阶段的劣势

看完这篇文章大家肯定会觉得为什么 WebComponents 实现了一份代码多个框架使用,却还没有霸占组件库的市场呢?我总结了一下几点:

  • 更加偏向于 UI 层面,与现在数据驱动不太符,和现在的组件库能力上相比功能会比较弱,使用场景相对单一。

  • 兼容性还有待提升:这里不仅仅指的是浏览器的兼容性,还有框架的兼容性,在框架中使用偶尔会发现意外的“惊喜”,并且写法会比较复杂。

  • 如果不借助框架开发的话,写法会返璞归真,HTML CSS JS 会糅合在一个文件,html CSS 都是字符串的形式 ,没有高亮,格式也需要自己调整,对于开发人员来说还是难受的。

  • 单元测试使用繁琐:单元测试是组件库核心的一项,但是在 WebComponents 中使用单元测试十分复杂。

    参考文档:

  • WebComponents | MDN

  • Vue 3.0 官方文档

  • React 官方文档

❉ 作者介绍 ❉
%E6%B5%B7%E7%BB%B5.png

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK