前言
本文将介绍一些 Web Components 的相关知识,最后通过一个组件封装的案例讲解带你入门 Web Components。
什么是 Web Components
MDN:Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。
Web Components 不是单一的某个规范,而是由3组不同的技术标准组成的组件模型,它旨在提升组件封装和代码复用能力。
- Custom Elements - 自定义元素
- Shadow DOM - 影子 DOM
- HTML Template - HTML 模版
下面咱们通过一个简单的例子来逐步了解 Web Components 的知识以及使用。
一个简单的例子
创建模版
<body>
<!-- 创建模版 -->
<template id="my-template">
<p>hello~<slot name="user"></slot></p>
</template>
</body>
这可以看作是我们的组件内容,由template包裹,此时该标签及其内部元素是不可见的,还需要我们用 JavaScript 去操作并将其添加到 DOM 里才能看得见。
可以看到,除了template标签之外还有slot标签,对于用惯了框架的我们应该对它不陌生了,插槽有助于提升我们在开发中的灵活度,用法跟我们平时的使用差不多,这里就不多赘述了。
定义组件的类对象
<body>
<!-- 模版 -->
<template id="my-template">
<p>hello~<slot name="user"></slot></p>
</template>
<script>
// 定义类对象
class HelloComponent extends HTMLElement {
// 构造函数
constructor() {
// 必须先调用 super 方法
super();
// 获取<template>
const template = document.getElementById('my-template');
// 创建影子 DOM 并将 template 添加到其子节点下
const _shadowRoot = this.attachShadow({ mode: 'open' });
const content = template.content.cloneNode(true)
_shadowRoot.appendChild(content);
}
}
// 自定义标签
window.customElements.define('hello-component', HelloComponent);
</script>
</body>
类 HelloComponent 继承自 HTMLElement,我们在构造函数中去挂载 DOM 元素。
获取
<template>节点以后,克隆了它的所有子元素,这是因为可能有多个自定义元素的实例,这个模板还要留给其他实例使用,所以不能直接移动它的子元素。
shadow DOM
上面我们用 attachShadow 方法创建了一个 shadow DOM,影子 DOM,如其名一样,它可以将一个隐藏的、独立的 DOM 附加到一个元素上。
参数 mode 有两个可选值:open 和 closed。
open 表示可以通过页面内的 JavaScript 方法来获取 shadow DOM。
let shadowroot = element.shadowRoot;
相反地,closed 表示不可以从外部获取 shadow DOM,此时 element.shadowRoot 将返回null。
shadow DOM 其实离我们很近,HTML一些内置的标签就包含了 shadow DOM,如input、video等。
在开发者工具中,打开设置面板,勾选 Show user agent shadow DOM。
查看 input 元素。
可以看到,input 多了一些我们平时没注意到的东西。现在你应该知道 input 的内容和 placeholder 从哪里来了,同样的,video 默认的那些播放按钮也都是藏在这里面的。
自定义标签
window.customElements.define('hello-component', HelloComponent)
window.customElements.define 方法的第一个参数是自定义标签的名称,第二个参数是用于定义元素行为的类对象。
注意:自定义标签的名称不能是单个单词,且必须有短横线。
使用
<body>
<!-- 模版 -->
<template id="my-template">
<p>hello~<slot name="user"></slot></p>
</template>
<script>
// 定义类对象
class HelloComponent extends HTMLElement {
// 构造函数
constructor() {
// 必须先调用 super 方法
super();
// 获取<template>
const template = document.getElementById('my-template');
// 创建影子 DOM 并将 template 添加到其子节点下
const _shadowRoot = this.attachShadow({ mode: 'open' });
const content = template.content.cloneNode(true)
_shadowRoot.appendChild(content);
}
}
// 自定义标签
window.customElements.define('hello-component', HelloComponent);
</script>
<!-- 使用 -->
<hello-component>
<span slot="user">张三</span>
</hello-component>
</body>
生命周期
除了构造函数之外,custom elements 还有4个生命周期函数:
connectedCallback:当 custom element首次被插入文档DOM时,被调用。disconnectedCallback:当 custom element从文档DOM中删除时,被调用。adoptedCallback:当 custom element被移动到新的文档时,被调用。attributeChangedCallback: 当 custom element增加、删除、修改自身属性时,被调用。
class HelloComponent extends HTMLElement {
// 构造函数
constructor() {
super();
}
connectedCallback() {
console.log('当自定义元素第一次被连接到文档DOM时被调用')
}
disconnectedCallback() {
console.log('当自定义元素与文档DOM断开连接时被调用')
}
adoptedCallback() {
console.log('当自定义元素被移动到新文档时被调用')
}
attributeChangedCallback() {
console.log('当自定义元素的一个属性被增加、移除或更改时被调用')
}
}
实战:封装一个商品卡片组件
我们期望的使用方式:
<body>
<!-- 引入 -->
<script type="module">
import './goods-card.js'
</script>
<!-- 使用 -->
<goods-card
img="https://img1.baidu.com/it/u=2613325730,275475287&fm=224&fmt=auto&gp=0.jpg"
goodsName="跑鞋"
></goods-card>
</body>
组件内容实现
// goods-card.js
class GoodsCard extends HTMLElement {
constructor() {
super();
const template = document.createElement('template')
template.innerHTML = `
<style>
.goods-card-container {
width: 200px;
border: 1px solid #ddd;
}
.goods-img {
width: 100%;
height: 200px;
}
.goods-name {
padding: 10px 4px;
margin: 0;
text-align: center;
}
.add-cart-btn {
width: 100%;
}
</style>
<div class="goods-card-container">
<img class="goods-img" />
<p class="goods-name"></p>
<button class="add-cart-btn">加入购物车</button>
</div>
`;
const _shadowRoot = this.attachShadow({ mode: 'open' })
const content = template.content.cloneNode(true)
_shadowRoot.appendChild(content)
}
}
window.customElements.define('goods-card', GoodsCard)
现在我们已经成功把 DOM 渲染出来了,只不过现在商品图片和商品名称还是空的,接下来我们需要去获取父组件传递过来的值并将其展示在视图上。
class GoodsCard extends HTMLElement {
constructor() {
super();
// ...省略部分代码
// 获取并更新视图
const _goodsNameDom = _shadowRoot.querySelector('.goods-name')
const _goodsImgDom = _shadowRoot.querySelector('.goods-img')
_goodsNameDom.innerHTML = this.name
_goodsImgDom.src = this.img
}
get name() {
return this.getAttribute('name')
}
get img() {
return this.getAttribute('img')
}
}
到这里我们就把一个商品卡片渲染出来了。
响应式视图更新
<goods-card
img="http://zs-oa.oss-cn-shenzhen.aliyuncs.com/zsoa/goods/v1v2/1021161140018/spu1/349334579590860800.jpg"
name="跑鞋"
></goods-card>
<script type="module">
import './goods-card.js'
</script>
<script>
setTimeout(() => {
document.querySelector('goods-card').setAttribute('name', '篮球鞋')
}, 2000);
</script>
这里我们会发现一个问题,如果组件的传值发生了变化,此时视图上是不会更新的,商品名称仍然显示跑鞋而不是篮球鞋。
这时候我们需要用到前面讲到的生命周期,用attributeChangedCallback来解决这个问题,对代码进行改造。
class GoodsCard extends HTMLElement {
constructor() {
super();
// ...省略部分代码
const _shadowRoot = this.attachShadow({ mode: 'open' })
const content = template.content.cloneNode(true)
_shadowRoot.appendChild(content)
this._goodsNameDom = _shadowRoot.querySelector('.goods-name')
this._goodsImgDom = _shadowRoot.querySelector('.goods-img')
}
attributeChangedCallback(key, oldVal, newVal) {
console.log(key, oldVal, newVal)
this.render()
}
static get observedAttributes() {
return ['img', 'name']
}
get name() {
return this.getAttribute('name')
}
get img() {
return this.getAttribute('img')
}
render() {
this._goodsNameDom.innerHTML = this.name
this._goodsImgDom.src = this.img
}
}
通过attributeChangedCallback,我们可以在属性发生改变时执行 render 方法从而让视图得到更新。
这里还有一个陌生的函数observedAttributes,该函数是与attributeChangedCallback配套使用的,如果没写observedAttributes或属性值发生改变的参数名称没有在observedAttributes函数返回的数组里,attributeChangedCallback是不会触发。
static get observedAttributes() {
return ['img']
}
// 不会执行,因为商品名称 name 没有在 observedAttributes 返回的数组里
attributeChangedCallback(key, oldVal, newVal) {
console.log(key, oldVal, newVal)
this.render()
}
事件交互
最后我们来给按钮添加一个点击事件,这是一个很必要的需求,方便组件调用者去处理自己的逻辑。
constructor() {
// ...省略部分代码
this._button = _shadowRoot.querySelector('.add-cart-btn')
this._button.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('onButton', { detail: 'button' }))
})
}
<body>
<script>
document.querySelector('goods-card').addEventListener('onButton', (e) => {
console.log('添加购物车', e.detail) // button
})
</script>
</body>
完整代码
<body>
<!-- 使用 -->
<goods-card
img="https://img1.baidu.com/it/u=2613325730,275475287&fm=224&fmt=auto&gp=0.jpg"
name="跑鞋"
></goods-card>
<!-- 引入 -->
<script type="module">
import './goods-card.js'
</script>
<script>
document.querySelector('goods-card').addEventListener('onButton', (e) => {
console.log('按钮事件', e.detail)
})
</script>
</body>
// goods-card.js
class GoodsCard extends HTMLElement {
constructor() {
super();
const template = document.createElement('template')
template.innerHTML = `
<style>
.goods-card-container {
width: 200px;
border: 1px solid #ddd;
}
.goods-img {
width: 100%;
height: 200px;
}
.goods-name {
padding: 10px 4px;
margin: 0;
text-align: center;
}
.add-cart-btn {
width: 100%;
}
</style>
<div class="goods-card-container">
<img class="goods-img" />
<p class="goods-name"></p>
<button class="add-cart-btn">加入购物车</button>
</div>
`;
const _shadowRoot = this.attachShadow({ mode: 'open' })
const content = template.content.cloneNode(true)
_shadowRoot.appendChild(content)
this._goodsNameDom = _shadowRoot.querySelector('.goods-name')
this._goodsImgDom = _shadowRoot.querySelector('.goods-img')
this._button = _shadowRoot.querySelector('.add-cart-btn')
this._button.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('onButton', { detail: 'button' }))
})
}
attributeChangedCallback(key, oldVal, newVal) {
console.log(key, oldVal, newVal)
this.render()
}
static get observedAttributes() {
return ['img', 'name']
}
get name() {
return this.getAttribute('name')
}
get img() {
return this.getAttribute('img')
}
render() {
this._goodsNameDom.innerHTML = this.name
this._goodsImgDom.src = this.img
}
}
window.customElements.define('goods-card', GoodsCard);
最后
以上,通过一个组件的封装,相信你已经对Web Components有一定的认识和了解了,当然,本文只是带你入门,讲解一些基础的开发实践知识,并非Web Components的全部,Web Components各个部分还有很多值得深入的地方,感兴趣的同学可自行深入了解一下。
感谢
本次分享到这里就结束了,感谢你的阅读,如果对你有什么帮助的话,欢迎点赞支持一下❤️。
如果有什么错误或不足,欢迎评论区指正、交流❤️。
参考资料: