Web Components主要由三项主要技术组成
- Custom elements
- Shadow DOM
- HTML templates and slots
Custom elements
HTML自定义标签, 可以将页面功能封装为自定义标签来进行复用而并非疯狂CV(复制粘贴)
使用
使用customElements.define(name, customClass, options?)方法来注册一个自定义元素
- name: 自定义元素名, 必须使用
-进行分隔以便于原生标签名称进行区分, 如custom-span - customClass: 自定义类, 用于编写自定义元素逻辑
- options: 非必须参数, 包含一个属性
extends,extends表明该元素继承自哪个内置元素
根据注册方法中options的extends参数的有无, 可以将自定义元素分为两类
- Autonomous custom elements: 独立自定义元素, 不依赖任何内置元素(即, 没有extends)
- Customized built-in elements: 依赖于内置元素的自定义元素(即extends了内置元素)
比如我们创建一个简单的可以用于显示大写字母的自定义标签uppercase-span, 继承自内置的span标签
class UppercaseSpan extends HTMLSpanElement {
constructor () {
super() // 必须调用
this.innerHTML = this.innerHTML.toUpperCase()
}
}
customElements.define('uppercase-span', UppercaseSpan, { extends: 'span' })
<uppercase-span>this sentence will be convert to uppercase</uppercase-span>
<!-- 或者使用下面的形式👇 -->
<span is="uppercase-span">this sentence will be convert to uppercase</span>
生命周期钩子函数
自定义元素具有4个生命周期钩子
connectedCallback: 当 custom element 首次被插入文档DOM时disconnectedCallback: 当 custom element 从文档DOM中删除时adoptedCallback: 当 custom element 被移动到新的文档时attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时, 接收参数有3个, 分别为属性名称(name), 旧值(oldVal), 新值(newVal)
该钩子函数需要配合observedAttributes属性使用, 否则无法监听
class UppercaseSpan extends HTMLElement {
constructor () {
super()
}
static get observedAttributes () {
return ['text']
}
connectedCallback () {
console.log('自定义元素首次被插入到文档DOM中')
this.convert()
}
disconnectedCallback () {
console.log('自定义元素从文档DOM中删除')
}
adoptedCallback () {
console.log('自定义元素被移动到新的文档')
}
attributeChangedCallback (name, oldVal, newVal) {
console.log('自定义元素增加、删除、修改自身属性', name, oldVal, newVal)
if (oldVal !== newVal) this.convert()
}
convert () {
// this.span.innerHTML = this.getAttribute('text').toUpperCase()
const text = this.getAttribute('text') || ''
this.innerHTML = `${text} -> ${text.toUpperCase()}`
}
}
Shadow DOM
Shadow DOM 允许我们将隐藏的DOM树添加到常规的DOM树中
Shadow DOM以 shadow root 为起始根节点, 在该节点内进行内容填充.
特有术语
- Shadow host: 一个常规的DOM节点, Shadow DOM会被添加到该节点下(相当于Shadow DOM寄生于该节点)
- Shadow Tree: Shadow DOM内部的DOM树
- Shadow boundary: Shadow DOM结束的地方
- Shadow root: Shadow tree的根节点
优势
- Shadow DOM是独立的DOM
document.querySelector()等DOM查询方法无法获取到Shadow DOM内的元素 - 具有CSS作用域(scoped CSS)
在Shadow DOM内部的CSS定义不会影响外部的元素样式 - 基于第二点, 在class或id起名的时候就会减少很多负担, 同时可以使用一些简单的选择器而不必担心冲突
- 组件化, Shadow DOM是实现WebComponent的主要技术之一, 这样就可以进行一些原生的web组件开发而达到复用的效果
用法
使用Element.attachShadow(options)来为对应Element添加一个shadow root.options对象有一个mode属性, 可选值为
- open: 可以从外部获取元素的
shadowRoot属性. (Element.shadowRoot为#shadow-root (open)) - closed: 不可以从外部获取元素的
shadowRoot属性. (Element.shadowRoot为null)
注: 自闭合标签无法添加shadow DOM, 如img
const divEl = document.querySelector('.demo-1')
divEl.attachShadow({ mode: 'open' })
Template and slots
当我们遇到重复的HTML结构的时候可以使用template来进行结构复用, 但是有时候我们需要改变模板中的部分值进行复用, 这时候我们可以改造template, 为其添加slot插槽来提高其灵活度
<template id="demo-template">
<p>this tag is from demo-template</p>
</template>
<script>
const template = document.querySelector('#demo-template')
document.body.appendChild(template.content)
</script>
template通常搭配Web Component一起使用
<template id="demo-template">
<p>this tag is from demo-template</p>
<slot name="my-slot">
<!-- 在未设置插槽的时候显示的默认值 -->
<span>this is default slot span tag</span>
</slot>
</template>
<!-- 未使用插槽 -->
<my-section></my-section>
<my-section>
<button>Slot Button</button>
</my-section>
<script>
customElements.define('my-section', class MySection extends HTMLElement {
constructor () {
super()
const template = document.querySelector('#demo-template')
const templateContent = template.content
const shadowRoot = this.attachShadow({ mode: 'open' }).appendChild(templateContent)
}
})
</script>
例
我们结合使用Custom Element, Shadow DOM与 Template来写一个简单的卡片组件custom-card
-
接受
title参数来显示标题 -
接受
plain来改变样式 -
接受插槽来自定义卡片内容
See the Pen Web Components Demo by Howe (@lihowe) on CodePen.
- 模板
template
<template id="card">
<div class="card">
<div class="card-title">
<span class="title-content"></span>
</div>
<div class="card-body">
<slot name="card-body">
<span>这是默认卡片内容</span>
</slot>
</div>
</div>
<style>
.card {
border-radius: 4px;
overflow: hidden;
width: 300px;
border: 1px solid #e4e4e4;
box-shadow: 0 0 4px #d0d0d0;
color: rgb(94, 94, 94);
margin-bottom: 10px;
}
.card.plain {
box-shadow: none;
}
.card .card-title {
font-weight: bold;
line-height: 1.5;
}
.card .card-title.with-title {
border-bottom: 1px solid #e4e4e4;
padding: 5px;
}
.card .card-body {
padding: 10px;
}
</style>
</template>
- 元素逻辑
class CustomCard extends HTMLElement {
static get observedAttributes () {
return ['title', 'plain']
}
constructor () {
super()
const template = this._getEl('#card')
const templateContent = template.content
this.attachShadow({ mode: 'open' }).appendChild(templateContent.cloneNode(true))
}
attributeChangedCallback (name, oldVal, newVal) {
const fnMapping = {
title: this.handleTitleChange,
plain: this.handlePlainChange
}
if (oldVal !== newVal) {
fnMapping[name](newVal)
}
}
handleTitleChange = (val) => {
if (!val) val = this.getAttribute('title')
const titleEl = this._getSelfEl('.title-content')
if (val) this._getSelfEl('.card-title').classList.add('with-title')
titleEl.innerText = val
}
handlePlainChange = (flag) => {
if (flag == '') flag = true
const body = this._getSelfEl('.card')
console.log('enter handle plain change, flag is ', flag, body)
body.classList[flag ? 'add': 'remove']('plain')
}
/**
* @param {String} selector
* @returns {HTMLElement}
*/
_getEl (selector) {
return document.querySelector(selector)
}
/**
* @param {String} selector
* @returns {HTMLElement}
*/
_getSelfEl (selector) {
return this.shadowRoot.querySelector(selector)
}
}
customElements.define('custom-card', CustomCard)
- 测试
<custom-card title="测试卡片" class="custom-card">
<div slot="card-body">
<input type="text" id="title-input" placeholder="请输入卡片标题进行修改">
<button id="btn_change-title">改变标题</button>
</div>
</custom-card>
<custom-card title="默认卡片"></custom-card>
<custom-card>
这段文字不会渲染
<div slot="card-body">这是使用了文字插槽的无title卡片</div>
</custom-card>
<custom-card plain>
<div slot="card-body">这是plain卡片</div>
</custom-card>
<script>
const changeTitleButton = document.querySelector('#btn_change-title')
changeTitleButton.addEventListener('click', () => {
const card = document.querySelector('custom-card')
card.setAttribute('title', document.querySelector('#title-input').value)
})
</script>