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

参考:

相关推荐
饮茶三千2 个月前
基于 Web Components 封装下拉树组件 select-tree
前端·web components
问道飞鱼3 个月前
【前端知识】基于Lit的复杂组件开发
前端·web components·lit
**之火7 个月前
Web Components 是什么
前端·web components
JefferyXZF10 个月前
Stencil web component 组件库样式主题设计
前端·web components
Amd7941 年前
探索Web Components
性能优化·生命周期·web components·组件化·高级设计·封装性·原生dom
JefferyXZF1 年前
Stencil 搭建 Web Component 组件库项目(工作原理解析)
前端框架·web components
JefferyXZF1 年前
Web Component 组件库有什么优势
前端·javascript·web components
炒米粉1 年前
组件库的新选择-Banana UI,一套原生跨框架的组件库
前端·web components