组件定义: 组件是对数据和方法的简单封装,是软件中具有相对独立功能、接口由契约指定、和语境有明显依赖关系、可独立部署、可组装的软件实体。
一个优秀的组件应该保证:
- 功能内聚
- 样式统一
- 与父元素仅通过Props通信
1. WebComponents
WebComponents 是一套不同的技术,允许你创建可重用的定制元素(它们的功能封装在你的代码之外)并且在你的 web 应用中使用它们。
目前支持WebComponents
的浏览器使用比例占比96%
左右,与Vue3用到的核心特性Proxy
占比相近,可见大部分浏览器兼容都能兼容WebComponents
特性。
另外如果有兼容性问题可以使用官方的PollyFill 解决



2. 第一个WebComponents
2.1 生命周期
官方说明:
connectedCallback()
:每当元素添加到文档中时调用。disconnectedCallback()
:每当元素从文档中移除时调用。adoptedCallback()
:每当元素被移动到新文档中时调用。该生命周期使用得比较少。attributeChangedCallback()
:在属性更改、添加、移除或替换时调用。
ts
class HelloWorldElement extends HTMLElement {
constructor() {
super();
//可以做一些初始化操作
this.style.fontWeight = 'bold';
this.style.display = 'block';
}
connectedCallback() {
console.log('自定义元素添加至页面。');
}
disconnectedCallback() {
console.log('自定义元素从页面中移除。');
}
adoptedCallback() {
console.log('自定义元素移动至新页面。');
}
//需要监听的属性名
static observedAttributes = ['color', 'size'];
//属性值改变
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
console.log(`属性 ${name} 已变更。`);
switch (name) {
case 'color':
this.style.color = newValue;
break;
case 'size':
this.style.fontSize = newValue === 'small' ? '12px' : newValue === 'large' ? '20px' : '16px';
break;
}
}
}
注意:
- 需要设置
observedAttributes
监听属性名数组才能在attributeChangedCallback
监听到属性变化。
ts
//第一种设置方式
static observedAttributes = ['color', 'size'];
//第二种设置方式
static get observedAttributes() {
return ["color", "size"];
}
-
attributeChangedCallback
:监听属性值改变,不论是否添加到页面都会触发,初始化和属性增删和值变化都会被监听到,类似于vue watch immediate开启
监听响应式数据变化。 -
connectedCallback
类似于vue mounted
生命周期,disconnectedCallback
类似于vue unmounted
生命周期,一些定时器,动作监听等需要在这里注销
2.2 注册自定义组件
自定义组件有两种类型:
- 独立自定义元素:继承自 HTML 元素基
HTMLElement
,并从头开始实现。 - 自定义内置元素:继承自标准的 HTML 元素,例如
HTMLParagraphElement
。它们的实现定义了标准元素的行为。
注意: 自定义组件注册名称必须使用小写字母
加横杠-
注册独立自定义元素
ts
class HelloWorldElement extends HTMLElement {
//...
}
//判断组件是否被注册
if (!window.customElements.get('hello-world')) {
//未注册组件名,则进行自定义组件注册
window.customElements.define('hello-world', HelloWorldElement);
}else{
console.log('hello-world组件名已注册')
}
注册自定义内置元素
自定义内置元素,class类要继承对应的内置元素,注册是也要配置继承对应的内置元素。
ts
class HelloWorldElement extends HTMLParagraphElement {
//...
}
//判断组件是否被注册
if (!window.customElements.get('hello-world')) {
//继承p元素
customElements.define('hello-world', HelloWorldElement, { extends: 'p' });
}else{
console.log('hello-world组件名已注册')
}
2.3 创建自定义元素
第一种方式:html字符串(仅适用于独立自定义元素)
ts
{
const content = document.createElement('div');
content.innerHTML = '<hello-world color="blue" size="large">PPPPP</hello-world>';
document.body.appendChild(content);
}
第二种方式:class实例(适用于独立自定义元素和自定义内置元素)
ts
const hello = new HelloWorldElement();
hello.innerHTML = 'PPPPP';
hello.setAttribute('size', 'large');
hello.setAttribute('color', 'blue');
document.body.appendChild(hello);
注意: 需要使用setAttribute
方法赋值属性才能触发attributeChangedCallback
生命周期。
第三种方式:document.createElement
方法创建(仅适用于独立自定义元素)
ts
const hello = document.createElement('hello-world');
hello.innerHTML = 'PPPPP';
hello.setAttribute('size', 'large');
hello.setAttribute('color', 'blue');
document.body.appendChild(hello);
第四种方式:内置元素is
属性(仅适用于自定义内置元素)
ts
const content = document.createElement('div');
content.innerHTML = '<p is="hello-world" color="blue" size="large">PPPPP</p>';
document.body.appendChild(content);
以上四种方式创建自定义元素,最终的效果都是一样的

3. 样式与Shadow DOM
3.1 自定义元素设置样式
自定义元素可以直接使用全局样式
css
custom-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
height: 40px;
line-height: 40px;
background: blue;
color: white;
padding: 0 10px;
cursor: pointer;
}
custom-button:hover {
background: rgb(3, 169, 244);
}
custom-button .more {
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
width: 20px;
background: rgba(255, 255, 255, 0.5);
}
ts
class CustomButton extends HTMLElement {
constructor() {
super();
const more = document.createElement('span');
more.className = 'more';
more.innerHTML = 'i';
this.appendChild(more);
}
}
customElements.define('custom-button', CustomButton);
{
const content = document.createElement('div');
content.innerHTML = '<custom-button >详情</custom-button>';
document.body.appendChild(content);
}
效果如下,自定义组件内样式会跟随全局样式规则渲染,类名为.more
的span
元素呈现圆形并有白色背景

3.2 Shadow DOM
影子节点
Shadow DOM
影子节点会与文档的主 DOM 树分开渲染,可以做到DOM节点隔离和样式隔离。
可以通过Element.attachShadow()
方法给自定义元素挂载影子根节点。
ts
class CustomButton extends HTMLElement {
constructor() {
super();
// 创建影子节点
this.attachShadow({ mode: 'open', delegatesFocus: true });
//影子根节点
const shadow = this.shadowRoot!;
const more = document.createElement('span');
more.className = 'more';
more.innerHTML = 'i';
shadow.appendChild(more);
}
}
attachShadow
可传的参数如下:
mode
模式:open
时可以通过外部js访问ShadowRoot
节点,closed
时则不可以。delegatesFocus
焦点委托:当shadowRoot内元素不可聚焦,则委托到父级可聚焦的元素,比如富文本编辑,点击内部自定义元素,则会聚焦到父级富文本编辑器。
可以看到内部的ShadowRoot
影子根节点样式与外部样式隔离,不再呈现上面全局样式的效果。并且自定义元素包裹的其他子元素也会被忽略,只渲染影子根节点内元素。

可以将自定义元素包裹的其他子元素转移到ShadowRoot
内
ts
class CustomButton extends HTMLElement {
constructor() {
super();
// 创建影子节点
this.attachShadow({ mode: 'open', delegatesFocus: true });
//影子根节点
const shadow = this.shadowRoot!;
//子元素
if (this.childNodes?.length) {
shadow.append(...Array.from(this.childNodes));
}
const more = document.createElement('span');
more.className = 'more';
more.innerHTML = 'i';
shadow.appendChild(more);
}
}

3.3 Shadow DOM
影子节点内使用局部样式
第一种方式: Shadow DOM
内添加style元素 Shadow DOM
内style元素的样式会作用于Shadow DOM
内所有元素
ts
const scopedStyle = document.createElement('style');
scopedStyle.innerHTML = `.more{
border-radius:50%;
display:inline-flex;
align-items:center;
justify-content:center;
height:20px;
width:20px;
color:blue;
background:white;
}`;
shadow.appendChild(scopedStyle);

第二种方式: Shadow DOM
应用样式规则表CSSStyleSheet
ts
const sheet = new CSSStyleSheet();
sheet.replaceSync(`.more{
border-radius:50%;
display:inline-flex;
align-items:center;
justify-content:center;
height:20px;
width:20px;
color:blue;
background:white;
}`);
shadow.adoptedStyleSheets = [sheet];
注意: CSSStyleSheet
样式规则表优先级更高,如果style元素和CSSStyleSheet
样式规则表同时存在,则优先使用CSSStyleSheet
样式规则表的样式进行渲染。
CSSStyleSheet
的方法
insertRule(cssString)
:插入一条样式规则,先插入的规则优先渲染
ts
const sheet = new CSSStyleSheet();
sheet.insertRule(`.more{
background:red;
color:blue;
}`);
sheet.insertRule(`.more{
background:green;
color:gray;
}`);
插入多条样式规则的情况,为什么同样的样式属性,先插入覆盖后插入的样式规则进行渲染?
通过打印CSSStyleSheet
样式规则表,可以看到后插入样式规则会排在前面,即CSSStyleSheet
样式规则表样式渲染也是读取按前后顺序执行,只不过插入的方式从头部插入,导致样式规则的排序是倒着的,那么先插入的样式规则就会优先渲染。
另外,插入的样式规则会累积,并且可以重复插入相同的样式规则


replaceSync(cssString)
:替换更新成最新样式,只保留一条
ts
//基于上面的insertRule代码再进行replaceSync
sheet.replaceSync(`.more{
background:gray;
color:white;
}`);
sheet.insertRule(`.more{
background:pink;
color:gray;
}`);
sheet.insertRule(`.more{
background:black;
color:white;
}`);
sheet.replaceSync(`.more{
background:orange;
color:yellow;
}`);
可以看到replaceSync
前面不论插入的多少条样式规则,都会被清空并替换成最新的样式规则,只保留最新的一条。


deleteRule(index)
:删除第index条样式规则
3.4 Shadow DOM
的一些伪类和伪类函数
我们编写自定义组件的时候,可能不仅仅想要对Shadow DOM
内部元素进行样式设置,有时还想对Shadow DOM
的宿主元素进行样式设置。
3.4.1 :host
给Shadow DOM
宿主组件添加样式
ts
class CustomTitle extends HTMLElement {
constructor() {
super();
// 创建影子节点
this.attachShadow({ mode: 'open', delegatesFocus: true });
//影子根节点
const shadow = this.shadowRoot!;
//样式设置
const sheet = new CSSStyleSheet();
sheet.replaceSync(/*css*/ `
* {
box-sizing: border-box;
}
:host{
font-size:18px;
font-weight:600;
display:block;
line-height:40px;
padding:0 20px;
color:#505050;
}
:host::after{
display:inline-block;
content:'>'
}
`);
shadow.adoptedStyleSheets = [sheet];
if (this.childNodes?.length) {
shadow.append(...Array.from(this.childNodes));
}
}
}
customElements.define('custom-title', CustomTitle);
{
const content = document.createElement('div');
content.innerHTML = '<custom-title >The Title</custom-title>';
document.body.appendChild(content);
}
可以看到ShadowRoot
的宿主custom-title
会应用上:host
的样式,并且设置宿主元素的伪元素类::after
也渲染了。

3.4.2 :host()
ShadowRoot
宿主选择器,根据宿主设置的不同属性、类名、状态等可以赋予对应样式.
宿主元素custom-title
,设置了类名、id、属性、状态等会呈现出不同效果,并可以叠加样式。
css
:host(:hover){
color:red;
}
:host(.border) {
border-left: solid 3px green;
}
:host([required='true']) {
background: yellow;
}
:host(#first) {
color: orange;
}
ts
{
const content = document.createElement('div');
content.innerHTML = '<custom-title class="border">The Title</custom-title>';
document.body.appendChild(content);
}
{
const content = document.createElement('div');
content.innerHTML = '<custom-title required="true">The Title</custom-title>';
document.body.appendChild(content);
}
{
const content = document.createElement('div');
content.innerHTML = '<custom-title id="first">The Title</custom-title>';
document.body.appendChild(content);
}
{
const content = document.createElement('div');
content.innerHTML = '<custom-title id="first" class="border" required="true">The Title</custom-title>';
document.body.appendChild(content);
}

3.4.3 :host-context()
ShadowRoot
宿主元素父级选择器,根据宿主的父级元素类型、不同属性、类名等可以赋予对应样式。
如果自定义元素custom-title
添加到p
元素内的样式设置。
css
:host-context(p){
text-decoration: underline;
}
:host-context(p:hover){
background:blue;
}
ts
{
const content = document.createElement('p');
content.innerHTML = '<custom-title >The Title</custom-title>';
document.body.appendChild(content);
}

3.4.4 ::part()
::part()
part属性选择器,给设置了part
属性某值的元素赋予对应的样式
写一个自定义tab元素
::part(tab)
设置part属性为包含tab值的样式,::part(active)
设置part属性为包含active值的样式,样式叠加,只能选择一个属性值,渲染优先级按前后顺序。
css
* {
box-sizing: border-box;
}
:host {
display: block;
height: 40px;
}
.tab-container {
overflow: auto hidden;
height: 100%;
color: gray;
font-size: 14px;
}
:host::part(tab) {
display: inline-flex;
padding: 0 20px;
cursor: pointer;
align-items: center;
justify-content: center;
height: 100%;
}
:host::part(active) {
transition: all ease 0.5s;
background: rgba(0, 0, 255, 0.3);
font-weight: bold;
color: blue;
}
创建自定义tab元素
ts
class CustomTabs extends HTMLElement {
container: HTMLDivElement;
isChangeTabs = false;
constructor() {
super();
// 创建影子根
this.attachShadow({ mode: 'open' });
const shadow = this.shadowRoot!;
const sheet = new CSSStyleSheet();
sheet.replaceSync(/*css*/ `/**上面的样式*/`);
shadow.adoptedStyleSheets = [sheet];
//tabs容器
this.container = document.createElement('div');
this.container.className = 'tab-container';
shadow.appendChild(this.container);
this.render();
}
//渲染tab
render(newIdx?: number) {
const active = newIdx !== undefined ? newIdx : Number(this.getAttribute('active') || 0);
if (this.container) {
//tab改变重新渲染
if (this.isChangeTabs) {
let tabs: string[] = [];
try {
tabs = JSON.parse(this.getAttribute('tabs') || '[]');
} catch (error) {}
this.container.innerHTML = tabs
.map((it, i) => `<div part="tab ${i == active ? 'active' : ''}" idx="${i}">${it}</div>`)
.join('');
this.isChangeTabs = false;
} else {
//active变化改变激活tab
const beforeActive = this.container.querySelector('[part="tab active"]');
if (beforeActive) {
beforeActive.part = 'tab';
}
const child = this.container.children[active];
if (child) {
child.part = 'tab active';
}
}
}
}
//监听属性值变化
static observedAttributes = ['active', 'tabs'];
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (name == 'tabs') {
//tab改变
this.isChangeTabs = true;
}
this.render();
}
}
customElements.define('custom-tabs', CustomTabs);
{
const content = document.createElement('div');
content.innerHTML = `<custom-tabs tabs='${JSON.stringify(['语文', '数学', '英语'])}' active="2" ></custom-tabs>`;
document.body.appendChild(content);
}
Element.part
属性可以直接赋值,或者通过setAttribute
赋值

4. WebComponents事件监听与分发
基于上面的自定义tab元素,使用tab容器做事件代理监听子级tab的点击动作
点击tab后,更新tabs的active属性,触发attributeChangedCallback
属性变化监听,并创建自定义事件CustomEvent
,通过dispatchEvent
方法分派事件。
注意: 要在自定义元素从页面移除前注销动作监听、定时器等,避免内存泄漏。
ts
class CustomTabs extends HTMLElement {
constructor() {
//....
//使用tab容器做事件代理
this.container.addEventListener('click', this.onClickTab.bind(this));
}
//从页面移除元素,注销监听
disconnectedCallback() {
this.container.removeEventListener('click', this.onClickTab.bind(this));
}
//点击切换tab,触发事件
onClickTab(ev: MouseEvent) {
const target = ev.target as HTMLElement;
if (target.getAttribute('idx')) {
//更新tab
const newIdx = target.getAttribute('idx') || '0';
//设置active属性,触发属性变化监听attributeChangedCallback
this.setAttribute('active', newIdx);
//创建自定义事件
const event = new CustomEvent('change', { detail: newIdx });
//分派事件
this.dispatchEvent(event);
}
}
}
自定义元素通过addEventListener
监听自定义事件。
ts
const tabs = new CustomTabs();
tabs.setAttribute('tabs', JSON.stringify(['语文', '数学', '英语']));
tabs.setAttribute('active', '1');
document.body.appendChild(tabs);
tabs.addEventListener('change', (ev: Event) => {
const target = ev.target as HTMLElement;
const event = ev as CustomEvent;
console.log('🚀 ~ index.ts ~ tabs.addEventListener:',ev, event.detail, target.getAttribute('active'));
});

这样自定义元素的动作交互就能形成闭环了~
5. template与slot
5.1 <slot>
插槽
插槽是web组件内部的占位符,预留位置,若自定义元素包裹的子元素有对应的元素则将其链接到该插槽位置,并重新排布ShadowRoot的DOM树进行渲染呈现。
5.1.1 默认插槽
在ShadowRoot内添加<slot>
插槽元素,默认会将自定义元素包裹的子元素渲染在<slot>
插槽元素的位置
ts
class CustomCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const shadow = this.shadowRoot!;
shadow.innerHTML = /*html*/`<div style="display:inline-block;border:solid 1px rgba(0,0,0,0.1);padding:20px;box-shadow:0 0 10px #ccc">
<slot></slot>
</div>`;
}
}
customElements.define('custom-card', CustomCard);
{
const card = new CustomCard();
card.innerHTML = 'Card Card';
document.body.appendChild(card);
}

注意:
- 插槽内添加内容,可以在自定义元素没有子元素时仍有内容显示。当然具名插槽也可显示默认内容。
html
<slot>No Data</slot>

5.1.2 具名插槽
在ShadowRoot内添加有name属性的<slot>
插槽元素,自定义元素包裹的子元素会渲染到具名插槽内。
ts
class CustomLayout extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const shadow = this.shadowRoot!;
shadow.innerHTML = /*html*/ `
<style>
:host{
display:flex;
}
:host>div{
flex:1;
}
.left{
text-align:left;
}
.center{
text-align:center;
}
.right{
text-align:right;
}
</style>
<div class='left'><slot name='left'></slot></div>
<div class='center'><slot name='center'></slot></div>
<div class='right'><slot name='right'></slot></div>`;
}
}
customElements.define('custom-layout', CustomLayout);
或者使用document.createElement('slot')
创建<slot>
插槽元素并添加
ts
const leftSlot = document.createElement('slot');
leftSlot.name = 'left';
shadow.appendChild(leftSlot);
const centerSlot = document.createElement('slot');
centerSlot.name = 'center';
shadow.appendChild(centerSlot);
const rightSlot = document.createElement('slot');
rightSlot.name = 'right';
shadow.appendChild(rightSlot);
自定义元素包裹的子元素会按照slot属性的值,在渲染时移动到对应的名称的<slot>
插槽元素内显示,并且包裹的子元素跟随外部样式配置进行渲染。
ts
const content = document.createElement('div');
content.innerHTML =/*html*/ `
<style>
.border{
display:inline-block;
border:solid 1px blue;
padding:10px;
}
#centerBody{
background:yellow;
}
</style>
<custom-layout>
<span slot='left' class="border">Left</span>
<div slot='center' id='centerBody'>Center</div>
<h1 slot='right'>Right</h1>
</custom-layout>`;
document.body.appendChild(content);

注意:
- 当自定义元素包裹多个相同的slot属性值的子元素时,会累加到对应
<slot>
插槽元素中
html
<custom-layout>
<span slot='left' class="border">Left</span>
<span slot='left' class="border">Left</span>
<div slot='center' id='centerBody'>Center</div>
<h1 slot='right'>Right</h1>
</custom-layout>

- 当
ShadowRoot
内出现重名<slot>
元素,则只会渲染首次出现的<slot>
插槽元素,后面重名的<slot>
插槽元素则会被忽略
html
<div class='left'><slot name='left'></slot></div>
<div class='center'><slot name='center'></slot></div>
<div class='right'><slot name='right'></slot></div>
<!--下面的插槽则会被忽略,但<div class='left'>还是会渲染,只不过是空-->
<div class='left'><slot name='left'></slot></div>

<slot>
插槽元素可以动态添加,对应slot属性值的元素也会对应渲染出来,可以通过此种方式控制显示隐藏
ts
class CustomLayout extends HTMLElement {
tempslot?: HTMLSlotElement;
constructor() {
super();
//...
//点击动态添加<slot>
this.addEventListener('click', this.addSlot.bind(this));
}
addSlot() {
//判断<slot>是否被添加
if (!this.tempslot) {
const shadow = this.shadowRoot!;
//动态添加<slot>
const tempslot = document.createElement('slot');
tempslot.name = 'tempSlot1';
this.tempslot = tempslot;
shadow.appendChild(tempslot);
} else {
//改变<slot>的name属性
this.tempslot.name = this.tempslot.name == 'tempSlot' ? 'tempSlot1' : 'tempSlot';
}
}
}
提前添加slot=tempSlot1
的子元素
html
<custom-layout>
<span slot='left' class="border">Left</span>
<div slot='center' id='centerBody'>Center</div>
<h1 slot='right'>Right</h1>
<h1 slot='tempSlot1' style="color:red">tempSlot1</h1>
</custom-layout>
点击后动态添加<slot name='tempSlot1'>
,通过改变<slot>
的name属性控制显隐

<slot>
的name属性改变也可以对slot属性的子元素进行切换控制,
ts
<custom-layout>
<span slot='left' class="border">Left</span>
<div slot='center' id='centerBody'>Center</div>
<h1 slot='right'>Right</h1>
<h1 slot='tempSlot1' style="color:red">tempSlot1</h1>
<h1 slot='tempSlot' style="color:orange">Hello</h1>
</custom-layout>

5.1.3 slotchange
事件与slot API
slotchange
事件 :监听<slot>
插槽元素name属性的变化
ts
tempslot.addEventListener('slotchange', (ev)=>{
console.log(ev)
});

<slot>
插槽元素assignedElements
方法:获取插槽内渲染的元素
即将要渲染的slot插槽元素
ts
<h1 slot='tempSlot1' style="color:red">tempSlot1<strong>HAHAHA</strong></h1>
ts
console.log(tempslot.assignedElements().map((el) => el.outerHTML));

- 同理
assignedNodes
方法:获取插槽内渲染的节点
ts
console.log(tempslot.assignedNodes().map((el) => el.outerHTML));
5.1.4 <slot>
插槽元素assign动态分配
<slot>
插槽元素assign方法 :当ShadowRoot
设置slotAssignment:'manual'
手动设置插槽渲染的元素,可以用<slot>
插槽元素assign
方法动态赋予元素。
ShadowRoot
设置slotAssignment
默认值是named
自动根据slot的name属性渲染插槽元素
自定义元素attachShadow
影子根节点ShadowRoot
的可以配置slotAssignment:'manual'
ts
class CustomCard1 extends HTMLElement {
titleSlot: HTMLSlotElement;
bodySlot: HTMLSlotElement;
constructor() {
super();
//slotAssignment定义slot手动配置
this.attachShadow({ mode: 'open', slotAssignment: 'manual' });
const shadow = this.shadowRoot!;
shadow.innerHTML = /*html*/ `<div style="display:inline-block;border:solid 1px rgba(0,0,0,0.1);padding:20px;box-shadow:0 0 10px #ccc">
<slot></slot>
<div style="background:yellow">
<slot></slot>
</div>
</div>`;
const slots = shadow.querySelectorAll('slot')!;
this.titleSlot = slots[0];
this.bodySlot = slots[1];
}
}
customElements.define('custom-card1', CustomCard1);
创建自定义元素,根据元素内的slot插槽动态分配元素进行渲染
ts
const card = new CustomCard1();
document.body.appendChild(card);
const title = document.createElement('h1');
title.innerHTML = 'TITLE';
//添加到自定义元素内
card.appendChild(title);
//插槽手动设置渲染元素
card.titleSlot.assign(title);
const cardBody = document.createElement('div');
cardBody.innerHTML = 'CARD BODY';
//添加到自定义元素内
card.appendChild(cardBody);
//插槽手动设置渲染元素
card.bodySlot.assign(cardBody);

注意:给插槽重复分配不同子元素,旧的会被覆盖,会按照最新的子元素渲染显示。
5.2 ::slotted()
插槽的slot
属性元素选择器,根据宿主的父级元素类型、不同属性、类名等可以赋予对应样式,与上面的::host()
,::host-context()
用法相似。
css
/**基于上面的CustomLayout*/
::slotted(*) {
font-weight: bold;
}
::slotted(h1) {
background: pink;
}
::slotted(.border) {
color: green;
}

5.3 template
模板
内容模板 (<template>
)元素是一种用于保存客户端内容机制,该内容在加载页面时不会呈现(template内的内容不会渲染),但可以在运行时使用js实例化添加到页面渲染。
5.3.1 复制template的内容,添加到页面渲染
ts
const template = document.createElement('template');
template.innerHTML = /*html*/ `<h1>Top</h1>
<h2>Center</h2>
<h3>Bottom</h3>`;
document.body.appendChild(template);
//复制template的内容
const clone = document.importNode(template.content, true);
document.body.appendChild(clone);
//添加复制的template的内容
document.body.appendChild(template.content.cloneNode(true));

自定义元素中应用template
,将模板中内容添加到shadowRoot
ts
class CustomInfoItem extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const shadow = this.shadowRoot!;
shadow.innerHTML = /*html*/ `<div style="line-height:32px;">
<label id="label" style="color:gray;margin-right:10px">
</label>
<span id="value" style="color:blue;">
</span></div>`;
const label = shadow.querySelector('#label')!;
const labelTemplate = document.querySelector('#label-template');
if (labelTemplate) label.appendChild((labelTemplate as HTMLTemplateElement).content.cloneNode(true));
const value = shadow.querySelector('#value')!;
const valueTemplate = document.querySelector('#value-template');
if (valueTemplate) {
value.appendChild((valueTemplate as HTMLTemplateElement).content.cloneNode(true));
}
}
}
customElements.define('custom-info-item', CustomInfoItem);
需保证对应的template要存在才能正确添加到自定义元素的ShadowRoot中,template的位置不受限制,只有可以通过js获取到实例即可
ts
const template = document.createElement('template');
template.id = 'label-template';
template.innerHTML = 'Hello';
document.body.appendChild(template);
const template1 = document.createElement('template');
template1.id = 'value-template';
template1.innerHTML = 'World';
document.body.appendChild(template1);
const infoItem = new CustomInfoItem();
document.body.appendChild(infoItem);

5.3.2 template
作为为ShadowRoot
使用
html
<p>
<template shadowrootmode="open">
<div style="line-height: 32px">
<label style="color: gray; margin-right: 10px"> <slot name="label"></slot>: </label>
<span style="color: blue">
<slot name="value"></slot>
</span>
</div>
</template>
<span slot="label">Label</span>
<span slot="value">Value</span>
</p>
要直接在html中写属性shadowrootmode="open"
的template的内容才能生效作为ShadowRoot,不能用innerHTML、append、document.createElement('template')
等方式添加动态属性shadowrootmode="open"
的template,会依旧解析为template,emmm~好鸡肋!

shadowrootmode="open"
的template可以设置:has-slotted
样式,插槽有内容渲染时的样式
css
:has-slotted {
color: red;
}
:has-slotted
优先级比较高,直接覆盖原来的样式了!

注意:浏览器的slot和template用法跟vue的有所不同
- vue会根据组件提前预留的slot插槽元素,对应寻找元素内的template元素,并将其内容渲染到插槽。
- 而template在浏览器中通过slot属性会整个元素渲染到slot插槽元素的位置,依旧不会渲染显示出来。
6. 表单组件
自定义元素通过attachInternals()
返回一个ElementInternals
对象,使其可以参与HTML表单。另外需要给自定义元素表单元素开启formAssociated = true
ts
class CustomInput extends HTMLElement {
//开启关联表单元素
static formAssociated = true;
internals: ElementInternals;
constructor() {
super();
this.attachShadow({ mode: 'open', delegatesFocus: true });
}
//获取关联表单
get form() {
return this.internals.form;
}
//设置表单字段名
set name(v: string) {
this.setAttribute('name', v);
}
get name() {
return this.getAttribute('name') || '';
}
}
6.1 自定义输入组件
组件布局
html
<div class="input-wrapper">
<input type='text' placeholder="${this.getAttribute('placeholder') || ''}" />
<span class='num'></span>
</div>
<div class="error-tip"></div>
输入组件样式,并设置错误时显示的样式
css
*{
box-sizing:border-box;
}
:host{
display:inline-block;
}
.input-wrapper{
display:inline-flex;
border-radius:4px;
height:32px;
border:solid 1px gray;
align-items:center;
padding:0 10px;
}
.input-wrapper.error{
border:solid 1px red;
}
.input-wrapper.error .num{
color:red;
}
input{
border:none;
background:transparent;
height:30px;
outline:none;
display:inline-block;
}
.num{
display:inline-block;
font-size:12px;
color:gray;
}
.error-tip{
padding:5px;
font-size:12px;
color:red;
}
实现自定义输入组件,formAssociated=true
设置关联表单,通过ElementInternals
的setFormValue(v)
设置关联表单的数据值
ts
class CustomInput extends HTMLElement {
static formAssociated = true;
internals: ElementInternals;
input: HTMLInputElement;
num: HTMLSpanElement;
tip: HTMLDivElement;
wrap: HTMLDivElement;
constructor() {
super();
//...
this.wrap = shadow.querySelector('.input-wrapper') as HTMLDivElement;
this.input = shadow.querySelector('input') as HTMLInputElement;
this.num = shadow.querySelector('.num') as HTMLSpanElement;
this.tip = shadow.querySelector('.error-tip') as HTMLDivElement;
//设置初始输入值
this.setInputVal(this.getAttribute('value') || '');
//...
}
//设置输入值
setInputVal(v: string) {
if (this.input) this.input.value = v;
this.updateNum();
//设置表单值
this.internals.setFormValue(v);
this.validate();
}
set value(v: string) {
this.setInputVal(v);
}
get value() {
return this.input?.value || '';
}
}
监听属性
ts
static observedAttributes = ['maxlength', 'placeholder', 'required'];
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
switch (name) {
case 'placeholder':
//文本占位符
this.input.placeholder = newValue;
break;
case 'maxlength':
//更新文本长度
this.updateNum();
break;
case 'required':
//表单验证
this.validate();
break;
}
}
监听输入事件,其中可以通过利用构造函数复制input事件,作为自定义输入组件的事件分发出去
ts
class CustomInput extends HTMLElement {
//...
constructor() {
super();
//...
//输入事件监听
this.input.addEventListener('input', this.onInputEvent.bind(this));
this.input.addEventListener('change', this.onInputEvent.bind(this));
}
//注销事件监听
disconnectedCallback() {
this.input.removeEventListener('input', this.onInputEvent.bind(this));
this.input.removeEventListener('change', this.onInputEvent.bind(this));
}
onInputEvent(e: Event) {
//输入值
const v = this.input.value;
//文本长度
this.updateNum();
//设置表单值
this.internals.setFormValue(v);
//表单验证
this.validate();
//分发事件,利用构造函数复制事件
//@ts-ignore
const clone = new e.constructor(e.type, e);
this.dispatchEvent(clone);
}
}
验证输入,并提示信息,设置显示错误时的样式
ts
//文本长度验证
updateNum() {
const v = this.input.value;
const len = Number(this.getAttribute('maxlength')) || 0;
this.num.innerHTML = `${v.length}/${len}`;
//超过长度则显示错误样式
if (v.length <= len) {
this.wrap.classList.remove('error');
} else {
this.wrap.classList.add('error');
}
}
//表单自带验证
validate() {
if (this.getAttribute('maxlength') && this.input.value.length > Number(this.getAttribute('maxlength'))) {
const text = `最多输入${this.getAttribute('maxlength')}个字符`;
this.tip.innerHTML = text;
this.tip.style.display = 'block';
this.internals.setValidity({ tooLong: true }, text, this.tip);
} else if (this.getAttribute('required') === 'true' && this.input.value === '') {
const text = this.getAttribute('placeholder') || '请输入';
this.tip.innerHTML = text;
this.tip.style.display = 'block';
this.internals.setValidity({ valueMissing: true }, text, this.tip);
} else {
this.internals.setValidity({});
this.tip.style.display = 'none';
}
//显示提示信息框
this.internals.reportValidity();
}
CustomInput
添加到form
ts
const cinput = new CustomInput();
cinput.setAttribute('placeholder', '请输入数值');
cinput.setAttribute('required', 'true');
cinput.setAttribute('maxlength', '10');
cinput.value = '1234';
cinput.name = 'money';
const form = document.createElement('form');
document.body.appendChild(form);
form.appendChild(cinput);


可以看到浏览器自带的错误验证提示
其中,浏览器自带表单验证提示可以通过ElementInternals.setValidity(flags, message, anchor)
方法设置
flags的可选值有
valueMissing
:开启了required
必填属性时,当input和textarea值为空时开启valueMissing
,显示提示typeMismatch
:当input的type为url或email,值不匹配该类型时,开启typeMismatch
,显示提示patternMismatch
:设置了pattern属性,且input的值与该pattern不符合时,开启patternMismatch
,显示提示。tooLong
:设置了maxlength属性,且input和textarea值超过长度,开启tooLong
,显示提示。tooShort
:设置了minlength属性,且input和textarea值小于长度,开启tooShort
,显示提示。rangeUnderflow
:设置了min属性,且input的值小于min,开启rangeUnderflow
,显示提示。rangeOverflow
:设置了max属性,且input的值大于max,开启rangeOverflow
,显示提示。stepMismatch
:设置了step属性,且input的值不能整除step,开启stepMismatch
,显示提示。badInput
:浏览器无法转换输入值
监听自定义输入组件,获取表单数据和表单校验结果
ts
//监听change事件
cinput.addEventListener('change', (e: Event) => {
console.log('🚀 ~ cinput.addEventListener ~ e:', e);
//获取表单数据
const formData = new FormData(form);
//获取表单值
console.log('🚀 ~ formData:', formData.get('money'));
//表单校验结果,是否通过校验
console.log('🚀 ~ Validity:', form.checkValidity());
});
form获取自定义输入组件的值,校验通过

form获取自定义输入组件的值,校验失败

6.2 自定义switch开关组件
自定义开关组件的样式,并设置选中时的样式
css
*{
box-sizing:border-box;
}
:host {
display: inline-flex;
height:20px;
width:40px;
background:white;
border-radius:10px;
overflow:hidden;
padding:2px;
background:gray;
}
:host::before {
display:flex;
align-items:center;
justify-content:flex-start;
height:16px;
width:16px;
border-radius:50%;
content: "";
background:white;
}
:host(:state(checked)){
background:dodgerblue;
justify-content:flex-end;
transition:all 0.5s ease;
}
实现CustomSwitch
自定义开关,其中开关是否勾选的状态,可以通过ElementInternals.states
设置。
ElementInternals.states
是一个自定义状态集CustomStateSet
,可以通过增删等操作管理状态值,并可以使用css:state()
自定义状态伪类函数,从而给组件不同状态设置不同样式。
ts
class CustomSwitch extends HTMLElement {
internals: ElementInternals;
//开启关联表单元素
static formAssociated = true;
constructor() {
super();
//...
this.addEventListener('click', this.onClick.bind(this));
}
onClick(e: Event) {
//切换开关状态
this.checked = !this.checked;
//分发事件
//@ts-ignore
const event = new Event('change', { detail: { checked: this.checked } });
this.dispatchEvent(event);
}
disconnectedCallback() {
this.removeEventListener('click', this.onClick.bind(this));
}
get checked() {
return this.internals.states.has('checked');
}
set checked(flag) {
//设置状态值
if (flag) {
this.internals.states.add('checked');
this.internals.setFormValue('on');
} else {
this.internals.states.delete('checked');
this.internals.setFormValue('off');
}
}
//判断状态语法是否可用
static isStateSyntaxSupported() {
return CSS.supports('selector(:state(checked))');
}
}
获取开关组件的表单值
ts
const form = document.createElement('form');
document.body.appendChild(form);
const switchEl = new CustomSwitch();
switchEl.name = 'Hello';
switchEl.checked = true;
form.appendChild(switchEl);
//监听change事件
switchEl.addEventListener('change', (e: Event) => {
console.log('🚀 ~ addEventListener ~ e:', e);
//获取表单数据
const formData = new FormData(form);
//获取表单值
console.log('🚀 ~ formData:', formData.get('Hello'));
//表单校验结果,是否通过校验
console.log('🚀 ~ Validity:', form.checkValidity());
});
form获取开关的false
值

form获取开关的true
值

7. GitHub地址
https://github.com/xiaolidan00/demo-vite-ts
参考:
- 字节跳动-国际化电商-S 项目团队《浅谈前端组件设计》
- MDN 使用自定义元素
- MDN ShadowRoot
- MDN Shadow DOM
- MDN
<slot>
元素 - MDN
<template>
元素 - MDN slot属性
- github web-components-examples
- github awesome-web-components
- MDN :host
- MDN :host()
- MDN :host-context()
- MDN :has-slotted()
- MDN part属性
- MDN Element.attachInternals方法
- MDN ElementInternals
- MDN input
- MDN CustomStateSet
- web-components-can-now-be-native-form-elements