- 发布于
Web Components
- Authors
- Name
- 田中原
Web Components
Web Components是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。Mdn Web Components
- HTML template
- Shadow DOM
- Custom Elements
- HTML imports(已废弃)
Html template
- html标签中的模板元素
- template标签中的任何元素都不会被加载运行,比如img、script、style都不会生效
- 当元素中的内容被添加到dom中才会开始加载执行
- 判断是否支持template
'content' in document.createElement('template')
<template id="abc">
<script>
// 只有当这里的script标签被append到dom上才会执行
console.log(123)
</script>
</template>
Shadow Dom
Shadow DOM 是DOM上的一个子树,它与dom一样是一颗完整的树。重点在于shadow dom 创建了一块私人空间,html dom 与 shadow dom的作用域是完全隔离的。dom上的css无法影响到shadow dom中的元素,反之,亦然。
Shadow DOM 必须附加在一个元素上,可以是HTML文件中的一个元素,也可以是脚本中创建的元素;可以是原生的元素,如<div>、<p>
也可以是自定义元素。
浏览器中的
video
、select
、input
shadowRoot
shadowRoot 是 shadow dom 上的根节点,类似于dom上的document
,但它没有那么多属性,下面是2个独有的属性:
mode
只读
host
只读
: shadow dom最外层的dom元素
shadowRoot.mode 表示shadow元素的开放模式。在初始化方法中attachShadow
传递该值
attachShadow({ mode: 'open' }) // 表示开放状态
open
表示该shadow元素在dom中是开放的,其根节点可以再次被访问,并且可以再次操作内部元素。closed
表示该shadow元素在dom中是闭合的,无法访问其中的元素了。
初始化一个shadowRoot:
<div id="shadow"></div>
const shadowRoot = document.querySelector('#shadow').attachShadow({ mode: 'open' })
shadowRoot.innerHTML = '<p>I am p tag</p>'
// 被初始化为open状态后,还可以再次获得
document.querySelector('#shadow').shadowRoot
需要注意,当初始化的时候设置mode=closed
时候:
// 只有当前变量 shadowRoot 可以访问该元素
let shadowRoot = document.querySelector('#shadow').attachShadow({ mode: 'closed' })
// 当丢失对该对象的引用后,那再也无法获取到了
shadowRoot = null
// 无法获得了
document.querySelector('#shadow').shadowRoot // null
slot
shadow dom 也有slot
元素,用法和vue一模一样,只不过没有相关的API可以使用。
Custom Elements
html原生标签自定义工具。Custom Elements 的核心,实际上就是利用 JavaScript 中的对象继承,去继承 HTML 原生的 HTMLElement
类(或是具体的某个原生 Element 类,比如 HTMLButtonElement
),然后自己编写相关的生命周期函数,处理成员属性以及用户交互的事件。与 vue
、react
组件有点类似。
使用自定义元素需要使用 customElements
属性
// 定义一个组件
window.customElements.define('componentName', 'class 实例')
使用define
函数来定义自定义组件,这样就可以直接在html中使用该标签,就和div、span性质一样了。
// 使用class的写法直接继承 HTMLElement
window.customElements.define(
'my-component',
class extends HTMLElement {
// 构造函数,可以在这里做一些初始化的操作,就像vue中的creatd
constructor() {
super()
// 在这里做一些变量初始化,shadow dom 初始等操作
}
}
)
使用 customElements.whenDefined
用于检测组件是否被定义,返回结果是一个Promise
, 如果是pending
表示还未被注册,resolved
则表示已被注册了,那就需要换个其他牛逼的名字了
既然是自定义组件,那肯定要有生命周期了。它并没有react和vue的生命周期钩子那么多,只有4个:
- connectedCallback
- disconnectedCallback
- attributeChangedCallback
- adoptedCallback
connectedCallback
每次将自定义元素附加到文档连接元素时调用。每次移动节点时都会发生这种情况,并且可能在元素的内容被完全解析之前发生。
有点类似于 mounted
、componentDidMoun
, 这时候组件已经在dom中被渲染完毕了。但当组件位置在dom节点中发生变化后,都会调用这个方法。可以在这里做一些初始化的操作。
disconnectedCallback
组件离开dom,在dom上找不到了。类似于 destroyed
。一些销毁操作可以在这里处理。
#####attributeChangedCallback
监听属性的变化。类似 watch
只不过只能监听父级传递过来的属性,不能监听内部属性的。
监听属性变化还还需另一个方法,来指定哪些属性需要监听:
static get observedAttributes() { return ['value', 'name'] } // 表示监听 value 与 name 的变化
adoptedCallback
组件被移动的时候会触发这个钩子,文档上是说将组件从一个document中移动到另一个document中的时候。。。 使用 adoptNode
方法移动元素的时候才会触发。
两个document的情况应该是指有 iframe
的时候。
相关轮子
三者结合实操
下面是一个小demo,写的很糙,功能也很简单。
;(function () {
const templateEl = document.createElement('template')
templateEl.innerHTML = `<div size="default" id="container">
<span id="minus" class="left">
<slot name="minus">-</slot>
</span>
<span id="plus" class="right">
<slot name="plus">+</slot>
</span>
<input type="text" id="value">
</div>
<style>
* {
box-sizing: border-box;
}
div {
display: inline-block;
position: relative;
}
input {
width: 100%;
height: 100%;
outline: none;
color: #606266;
border: 1px solid #dcdfe6;
text-align: center;
padding: 0;
border-radius: 4px;
}
span {
display: inline-block;
position: absolute;
text-align: center;
color: #606266;
cursor: pointer;
background: #f5f7fa;
user-select: none;
}
span[disabled='true'] {
color: #c0c4cc;
cursor: not-allowed;
}
span:hover {
color: #409eff;
}
span:hover~input{
border: 1px solid #409eff;
}
.left {
left: 1px;
top: 1px;
border-radius: 4px 0 0 4px;
border-right: 1px solid #dcdfe6;
}
.right {
right: 1px;
top: 1px;
border-radius: 0 4px 4px 0;
border-left: 1px solid #dcdfe6;
}
div[size='default'] {
width: 180px;
height: 40px;
}
div[size='default'] span {
width: 41px;
line-height: 38px;
}
div[size='default'] input {
font-size: 14px;
padding: 0 50px;
}
div[size='medium'] {
width: 200px;
height: 34px;
}
div[size='medium'] span {
width: 36px;
line-height: 32px;
}
div[size='medium'] input {
font-size: 14px;
padding: 0 43px;
}
div[size='small'] {
width: 130px;
height: 32px;
}
div[size='small'] span {
width: 32px;
line-height: 30px;
}
div[size='small'] input {
font-size: 13px;
padding: 0 39px;
}
div[size='mini'] {
width: 130px;
height: 26px;
}
div[size='mini'] span {
width: 26px;
line-height: 24px;
}
div[size='mini'] input {
font-size: 12px;
padding: 0 35px;
}
</style>
`
window.customElements.define(
'my-counter',
class extends HTMLElement {
constructor() {
super()
this.SIZE = {
default: 'default',
medium: 'medium',
small: 'small',
mini: 'mini',
}
this.watch = {
size: (oldVal, newVal) => {
// 设置size
this._container.setAttribute('size', newVal || 'default')
},
count: (oldVal, newVal) => {
if (newVal >= this.data.max) {
// 禁用 加号 按钮
}
if (newVal <= this.data.min) {
// 禁用 减号 按钮
}
},
}
this.data = new Proxy(
{},
{
// get (...params) {
// return Reflect.get(...params)
// },
set: (target, key, value, reciver) => {
if (this.watch[key]) {
// oldValue: target[key], new Value: value
this.watch[key](target[key], value)
}
return Reflect.set(target, key, value, reciver)
},
}
)
// 获取模板
const template = templateEl.content.cloneNode(true)
// 外层容器
this._container = template.querySelector('#container')
// 计算器数值
this.data.count = +this.getAttribute('value') || 0
this.data.max = +this.getAttribute('max') || Number.MAX_SAFE_INTEGER
this.data.min = +this.getAttribute('min') || Number.MIN_SAFE_INTEGER
this.data.size = this.SIZE[this.getAttribute('size')]
// input
this._input = template.querySelector('#value')
this._input.value = this.data.count
// 减号
const minus = template.querySelector('#minus')
// 加号
const plus = template.querySelector('#plus')
// input 事件防止输入不规范数据
this._input.addEventListener('input', (_) => {
this.changeValue(this._input.value)
})
minus.onclick = () => {
this.minus()
}
plus.onclick = () => {
this.plus()
}
this.shadow = this.attachShadow({ mode: 'open' })
this.shadow.appendChild(template)
}
// 渲染完毕的回调
connectedCallback() {}
// dom上移除的回调
disconnectedCallback() {}
adoptedCallback() {
console.log('adoptedCallback')
}
// 监听属性变化
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'size':
this.data.size = newValue
break
case 'value':
if (this.data.count + '' !== newValue) {
this._input.value = this.data.count = newValue
}
break
}
}
// 指定监听哪些属性
static get observedAttributes() {
return ['value', 'size']
}
// 点击减号的回调事件
minus() {
this.changeValue(--this.data.count)
}
// 点击加号的回调事件
plus() {
this.changeValue(++this.data.count)
}
changeValue(value) {
this._input.value = value = this.matchNumber(value)
this.setAttribute('value', value)
this.dispatchEvent(
new CustomEvent('change', {
detail: { value: value },
})
)
}
// 控制最后输入的是一个数字或者'.'
matchNumber(value) {
return +(value + '').match(/[\d\.]*/g)[0]
}
}
)
})()
<my-counter value="2" size="medium"></my-counter>
<my-counter value="3" size="small">
<span slot="plus">加</span>
</my-counter>
<my-counter value="4" size="mini"></my-counter>
因为js异步加载的问题,页面会出现先展示组件后注册的问题。自定义组件在注册后会css有一个:defined
伪类,在未注册的时候是没有的,可以根据这个属性来控制:
my-counter:not(:defined) {
display: none;
}