作为一名前端攻城狮,相信大家也都在关注着前端的一些新技术,近些年来前端组件化开发已为常态,我们经常把重用性搞的模块抽离成一个个的组件,来达到复用的目的,这样减少了我们的维护成本,提高了开发的效率。但是都有一个缺点离不开框架本身,因为我们浏览器本身解析不了那些组件。那么有没有一种技术也可以达到这种效果呢?答案就是今天的主角 Web Components。
Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。 目前 W3C 也在积极推动,并且浏览器的支持情况还不错。FireFox、Chrome、Opera 已全部支持,Safari 也大部分支持,Edge 也换成 webkit 内核了,离全面支持应该也不远了。当然社区也有兼容的解决方案 webcomponents/polyfills 。
首先我们就从一个最简单的 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() {
- xxx
- }
- 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)
- })()
这种方式虽然可行但却不是很优雅。
因此我们需要换一个思路,我们上面使用的方式都是 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>
-
- // js
- (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)
- })()
<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 文件中
- // index.js
- import './components/Button/index.js'
- import './components/xxx/xxx.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>
- `
- // 后面省略...
- })()
这样我们就能在全局中修改主题色了。 案例地址
- <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>
- // 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>
在最近的 Vue3 中,Vue 对 WebComponents 有了更好的支持。Vue 在 Custom Elements Everywhere 测试中获得了 100% 的完美分数。但是还需要我们做出如下配置:
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 类似。
- 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 中使用单元测试十分复杂。
联系客服