Web components其实就是可重用的组件,就像Vue或者React等前端框架支持组件化开发一样,只不过依赖浏览器厂商来实现这些标准,从而原生支持自定义组件。而不是在浏览器里有一个Vue或者React的运行时。因此是跨平台、与框架无关的可扩展组件。可以用来开发跨团队共享的UI组件,就可以不用限定consumer使用的框架类型和代码运行的平台。
分为以下三篇,会陆续更新:
- Web Components系列文章(一) - 基本概念和用法
- Web Components系列文章(二) - 与现有前端框架集成
- Web Components系列文章(三) - 相关开源库介绍(Stencil和Lit)
可以先直接跳到 完整的Button组件例子,有个整体的代码结构印象。
Web components主要包括以下三个规范,浏览器实现这三个规范,而程序员调用规范规定的API:
Custom elements
即使用vanilla JS/HTML/CSS来创建自定义html标签,内部封装好逻辑和UI,就像其他内置的HTML标签一样可直接引用。同时,提供了生命周期钩子函数,以便在适当的时机进行调用。
用法
1. 注册自定义元素
// MyButton.js
window.customElements.define(tagName, constructor, options)
- tagName:组件名称,采用kebab-case规则,且必须带
-
。 - constructor:在其中对组件进行具体定义。采用es6 class的方式定义,如果不需要对现有的html标签进行扩展则继承
HTMLElement
,以确保其拥有内置HTML标签的所有默认方法和属性:
// MyButton.js
class MyButton extends HTMLElement {
constructor() {
super()
// do something
}
}
customElements.define('my-button', MyButton)
- options:可选,目前只有一个选项
extends
,当创建的元素需要继承某个已有的内置元素时。此时需要extends
特定的标签类,例如HTMLButtonElement
。
// MyButton.js
// 可先暂时不用关心具体的实现,先了解整体用法,后面会具体讲如何定义一个自定义元素
class MyButton extends HTMLButtonElement {
constructor() {
super()
// do something
}
}
customElements.define('my-button', MyButton, {extends: 'button'})
2. 消费自定义元素
可直接像内置的HTML元素一样使用,在html模板中:
<!-- index.html -->
<!-- ... -->
<body>
<my-button label="Click me"></my-button>
</body>
或者JS中使用:
document.createElement('my-button')
document.querySelector('my-button')
// ...
如果使用继承了内置元素的自定义组件,可以这么使用: 在html中使用:
<button is="my-button">click</button>
在js中使用:
document.createElement('button', {is: 'my-button'})
生命周期钩子
自定义组件提供了以下钩子,能够让我们在适当的时机进行相应操作,每个钩子就是一个函数:
关于监听attributes
的简要说明:
// 1. 该静态方法中返回需要观察的attributes名
static get observedAttributes() {return ['attr1', 'attr2']; }
// 2. 当观察的attributes发生变动时触发该钩子
attributeChangedCallback(attrName, oldValue, newValue) {
// do something
switch(attrName) {
case 'attr1':
//...
}
}
补充说明:
- 当用户初次使用标签并传入属性时,
attributeChangedCallback
就会被调用一次 attributeChangedCallback
只能监听attributes
,无法监听元素property的变化- 所有钩子都是同步的
关于attribute和property
简要说明一下两者的区别:
- attribute: HTML元素上的属性,只接受字符串,例如下面的label就是一个attribute:
<my-button label="click me"></my-button>
要传递引用类型的数据或者绑定方法,需通过property:
- property: 从JS层面来说每个html标签都是一个JS对象:
const myButton = document.querySelector('my-button');
myButton.label = 'Click Me!';
Reflecting properties to attributes
这个的意思是什么呢,就是当通过JS修改property后,最好将修改同步到attribute:
<!-- index.html -->
<my-button disabled></my-button>
// MyButton.js
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(val) {
// Reflect the value of `disabled` as an attribute.
if (val) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
当通过JS修改property:
myButton.disabled = false
html变为:
<my-button disabled></my-button>
这么做的原因主要是可以保持DOM的呈现和JS State同步。
在监听属性变化的回调可以直接根据property:
// Only called for the disabled and open attributes due to observedAttributes
attributeChangedCallback(name, oldValue, newValue) {
// When the drawer is disabled, update keyboard/screen reader behavior.
if (this.disabled) {
} else {
}
// TODO: also react to the open attribute changing.
}
注意:如果是引用类型的数据,则完全没有必要再同步到attribute上了。可以直接在setter中进行更新渲染等操作而不是通过attributeChangedCallback
。
兼容性
可见所有IE浏览器不支持,不过全球份额也只有0.X%。pollyfill: webcomponentsjs
Shadow DOM
其主要作用是提供了CSS和DOM的隔离。通过一组JS API能创建一棵shadow tree,可插入到当前html节点中。Shadow tree内部的css与文档其他部分是完全隔离的,不会相互影响。因此内部也能用更通用的CSS选择器,而不必担心命名冲突,避免全局污染。
另外shadow tree中的元素通过document.querySelector
无法被获取到,只能通过先拿到shadow tree的根节点shadowRoot
,用shadowRoot.querySelector
这种方式访问。
用法
1. 创建Shadow DOM Tree
const shadowRoot = element.attachShadow({mode: 'open'})
这个element可以是自定义组件:
// ...
constructor() {
super()
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(someTemplateDom.content.cloneNode(true));
}
也可以是其他内置的HTML标签。该方法在自定义元素内部挂载了一棵Shadow Tree,并且返回对根节点ShadowRoot的引用。shadowRoot继承了Node
,因此拥有普通htmlNode节点的属性和方法。此外还有一些自己的一些属性和方法:MDN shadowRoot
PS: 并非所有标签下都能挂载Shadow DOM,这里给出了一个可以在下面挂载Shadow DOM的元素列表:MDN element.attachShadow() 。
mode用来控制外部是否能过获取到shadowRoot,从而对shadow tree内部进行节点访问。
- open: 表示Shadow Tree之外的JS代码能访问到其内部的元素,即通过
element.shadowRoot
能获取到shadowRoot的引用。 - closed:反之。当调用
element.shadowRoot
时返回null
。
2. 给ShadowRoot添加子节点
就像其他标签节点一样可以通过appendChild
添加:
this._shadowRoot.appendChild(someTemplateDom.content.cloneNode(true));
浏览器中:
兼容性
- pollyfill: shadydom and shadycss
HTML template
用法
提供了组件模板以复用。另外还包含slot功能,可以给slot指定名字变成具名slot,用过Vue的同学会很熟悉了,就跟Vue里slot的用法一毛一样:
<template id="myTpl">
<style>
p {
color: pink;
}
</style>
<p class="name">Name</p>
<slot></slot>
</template>
这段html不会被渲染但是能通过DOM API获取:
// ...
constructor() {
super()
const templateContent = document.querySelector('#tpl-id').content
// 由于模板需复用,在将模板内容添加到节点里时,需cloneNode
const shadowRoot = this.attachShadow({mode: 'open'})
shdowRoot.appendChild(templateContent.cloneNode(true))
}
兼容性
关于样式
:host-context(<selector>)
当祖先元素中匹配到selector
,则会应用对应的样式,常用于主题色:
<body class="darktheme">
<my-comp></my-comp>
</body>
// my-comp.js
:host-context(.darktheme) {
color: #fff;
background: black;
}
在组件内部加好css varible hook,在组件外部定义的CSS varibles能可覆盖组件内定义的值:
<!-- index.html -->
my-comp {
--main-text-color: #fff;
}
<!-- my-comp -->
:host {
--main-text-color: #eee; // 如果组件外部没有定义,将采用该值。外部的my-comp标签权重大于:host
}
div {
color: var(--main-text-color);
}
此时,需要组件开发者通过文档或者其他形式,告知使用者支持哪些变量。
完整的Dialog组件例子
下面是一个简单的自定义Dialog组件例子:
const myDialogTpl = document.createElement('template');
myDialogTpl.innerHTML = `
<style>
:host {
display: block;
}
.my-dialog {
display: none;
}
:host([visible]) .my-dialog {
display: block;
}
.mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, .4);
z-index: 999;
}
.content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
z-index: 1000;
background: #fff;
border: 1px solid #a1a1a1;
box-shadow: 0 2px 4px 0 rgba(0,0,0, 0.05), 0 2px 8px 0 rgba(161,161,161, 0.4);
}
.dialog-hd {
position: relative;
padding: 20px 16px 16px 16px;
border-bottom: 1px solid #ddd;
}
.title {
font-size: 18px;
font-weight: 600;
text-align: center;
}
.btn-close {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAMKADAAQAAAABAAAAMAAAAADbN2wMAAAB/ElEQVRoBe2Yv26DMBDGU6BD5kwwRTxA9+xdsmSr+hZ9mL5Etw6Zs0d9CcasMCBlSihfhSXLCdzZvkioOi8QfH++39lgx4uFNq2AVkAroBXQCjAqsNlsluv1el+W5SvDPMgEsZEDubgBnjiGCHg6nfa9LcSf0zTdVVV14PhybSD+crkgB8QfiqLYHY/HM+VPAjjiTTxRCEe8ycGCSI312DXLsu++b+v0P3dd97ZarX7quq6cPq+fI+IRo2zb9qVpmq+pgMlUJ/qSJPnsL/eGcokhhwAqxlj/hHi4nIfcY+5/z8kpBCsqUcg7IRWTBSANISUeutgAUhCS4r0BYiGkxQcBhEI8QnwwgC/Eo8RHAXAhYGetsPhpt+gF0esltjObe6q6g929vU20eMSOBkAQAgImbhMRj6AiAAjkASEmXhSACSEqHjnJvRCM5tx0CmF0POa/GUyxqRQ9AoR4sw2f52eUEo9tNko+y4WMI978b/axNXOMew2aQiGCQnw4EN4AMUJifMdgvAAkBEjEsGHYAJKJJWOxACQTmupJxSQBpBIZ4fZVIja5F7perx990ocsRPjMDmuFWfBsvuWQ2352c08C5Hn+3nu556BiW4EJiMOQ+0a0/YCcQjB2zkfFxNtCnOnEOhe1/cl7QMzxeJ0UrgZaAa2AVkAr8K8r8AsyeML+4ho6NwAAAABJRU5ErkJggg==') no-repeat center center;
background-size: 100% 100%;
}
.dialog-bd {
padding: 20px 16px;
border-bottom: 1px solid #ddd;
}
.dialog-ft {
display: flex;
}
.btn {
display: block;
flex: 1;
height: 48px;
line-height: 48px;
text-align: center;
font-size: 18px;
background: none;
border: 0;
}
.btn.hidden {
display: none;
}
.btn-primary {
color: #0088c6;
}
.btn-secondary {
color: rgba(0, 0, 0, .6);
}
</style>
<div class="my-dialog">
<div class="mask"></div>
<div class="content">
<div class="dialog-hd">
<div class="title"></div>
<span class="btn-close"></span>
</div>
<div class="dialog-bd">
<slot></slot>
</div>
<div class="dialog-ft">
<button class="btn btn-secondary"></button>
<button class="btn btn-primary"></button>
</div>
</div>
</div>
`;
class MyDialog extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(myDialogTpl.content.cloneNode(true));
this.$dialog = this._shadowRoot.querySelector('.my-dialog');
this.$title = this._shadowRoot.querySelector('.title');
this.$confirmBtn = this._shadowRoot.querySelector('.btn-primary');
this.$cancelBtn = this._shadowRoot.querySelector('.btn-secondary');
this.$close = this._shadowRoot.querySelector('.btn-close');
this.bindEvent();
}
get dialogTitle() {
return this.getAttribute('dialogTitle');
}
// Reflecting a property to an attribute
set dialogTitle(value) {
this.setAttribute('dialogTitle', value);
}
get visible() {
return this.hasAttribute('visible');
}
set visible(value) {
if (value) {
this.setAttribute('visible', '');
} else {
this.removeAttribute('visible')
}
}
get hideConfirmBtn() {
return this.hasAttribute('hideConfirmBtn');
}
set hideConfirmBtn(value) {
value
? this.setAttribute('hideConfirmBtn', value)
: this.removeAttribute('hideConfirmBtn');
}
get confirmText() {
return this.getAttribute('confirmText') || 'Confirm';
}
set confirmText(value) {
this.setAttribute('confirmText', value);
}
get hideCancelBtn() {
return this.hasAttribute('hideCancelBtn');
}
set hideCancelBtn(value) {
value
? this.setAttribute('hideCancelBtn')
: this.removeAttribute('hideCancelBtn');
}
get cancelText() {
return this.getAttribute('cancelText') || 'Cancel';
}
set cancelText(value) {
this.setAttribute('cancelText', value);
}
static get observedAttributes() {
return [
'title',
'visible',
'hideConfirmBtn',
'confirmText',
'hideCancelBtn',
'cancelText',
];
}
attributeChangedCallback(name, oldValue, newValue) {
this.render();
}
render() {
this.$title.innerHTML = this.dialogTitle;
this.$confirmBtn.innerHTML = this.confirmText;
this.hideConfirmBtn
? this.$confirmBtn.classList.add('hidden')
: this.$confirmBtn.classList.remove('hidden');
this.$cancelBtn.innerHTML = this.cancelText;
this.hideCancelBtn
? this.$cancelBtn.classList.add('hidden')
: this.$cancelBtn.classList.remove('hidden');
}
bindEvent() {
this.$close.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('close'));
});
this.$confirmBtn.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('confirm', {}));
this.dispatchEvent(new CustomEvent('close'));
});
this.$cancelBtn.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('cancel'));
this.dispatchEvent(new CustomEvent('close'));
});
}
}
window.customElements.define('my-dialog', MyDialog);
在html中的使用:
<body>
<my-button label="Open Dialog"></my-button>
<my-dialog dialogTitle="Welcome">
<p class="dialog-para">Hello world!</p>
</my-dialog>
<script src="components/MyButton.js"></script>
<script src="components/MyDialog.js"></script>
<script>
const myDialog = document.querySelector('my-dialog')
const myButton = document.querySelector('my-button')
myButton.addEventListener('clicked', (event) => {
console.log('event', event)
myDialog.visible = true
})
myDialog.addEventListener('confirm', () => {
console.log('Trigger Dialog Confirm')
})
myDialog.addEventListener('cancel', () => {
console.log('Trigger Dialog Cancel')
})
myDialog.addEventListener('close', () => {
myDialog.visible = false
})
</body>
其中:host
表示自定义组件本身。可通过:host(selector)
进行进一步匹配,例如:
:host(:hover)
:host([disabled])
git传送门:my-web-component 。这里上传了更完整的代码,还包括Button和Dropdown组件,其中Dropdown组件给出了如何处理引用类型的数据。