WebComponents自定义元素实践指南

组件定义: 组件是对数据和方法的简单封装,是软件中具有相对独立功能、接口由契约指定、和语境有明显依赖关系、可独立部署、可组装的软件实体。

一个优秀的组件应该保证:

  • 功能内聚
  • 样式统一
  • 与父元素仅通过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);
}

效果如下,自定义组件内样式会跟随全局样式规则渲染,类名为.morespan元素呈现圆形并有白色背景

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设置关联表单,通过ElementInternalssetFormValue(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

参考:

相关推荐
alphardex8 天前
现代 Web 的视觉组件探索
前端·html·web components
CF14年老兵1 个月前
深入理解 React 的 useContext Hook:权威指南
前端·react.js·web components
LeeAt1 个月前
还在为移动端项目组件发愁?快来试试React Vant吧!
前端·web components
饮茶三千5 个月前
基于 Web Components 封装下拉树组件 select-tree
前端·web components
问道飞鱼6 个月前
【前端知识】基于Lit的复杂组件开发
前端·web components·lit
**之火10 个月前
Web Components 是什么
前端·web components
JefferyXZF1 年前
Stencil web component 组件库样式主题设计
前端·web components
Amd7941 年前
探索Web Components
性能优化·生命周期·web components·组件化·高级设计·封装性·原生dom
JefferyXZF1 年前
Stencil 搭建 Web Component 组件库项目(工作原理解析)
前端框架·web components