# Web Components

[toc]

# 1、基础介绍

Web Component 是一套不同的技术,允许你创建可重用的定制元素(它们的功能封装在你的代码之外)并且在你的 web 应用中使用它们。

作为开发者,我们都知道尽可能多的重用代码是一个好主意。这对于自定义标记结构来说通常不是那么容易 — 想想复杂的 HTML(以及相关的样式和脚本),有时您不得不写代码来呈现自定义 UI 控件,并且如果您不小心的话,多次使用它们会使您的页面变得一团糟。

Web Components 旨在解决这些问题 — 它由三项主要技术组成,它们可以一起使用来创建封装功能的定制元素,可以在你喜欢的任何地方重用,不必担心代码冲突。

  • Custom element(自定义元素):一组 JavaScript API,允许您定义 custom elements 及其行为,然后可以在您的用户界面中按照需要使用它们。
  • Shadow DOM(影子 DOM):一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
  • HTML template(HTML 模板):<template><slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

实现 web component 的基本方法通常如下所示:

  1. 创建一个类或函数来指定 web 组件的功能,如果使用类,请使用 ECMAScript 2015 的类语法 (参阅 (opens new window)获取更多信息)。
  2. 使用 CustomElementRegistry.define() 方法注册您的新自定义元素,并向其传递要定义的元素名称、指定元素功能的类、以及可选的其所继承自的元素。
  3. 如果需要的话,使用 Element.attachShadow()方法将一个 shadow DOM 附加到自定义元素上。使用通常的 DOM 方法向 shadow DOM 中添加子元素、事件监听器等等。
  4. 如果需要的话,使用 <template><slot> 定义一个 HTML 模板。再次使用常规 DOM 方法克隆模板并将其附加到您的 shadow DOM 中。
  5. 在页面任何您喜欢的位置使用自定义元素,就像使用常规 HTML 元素那样。

# 2、简单示例

<body>
  <work-count class="word-count-container"></work-count>
  <script>
    class WordCount extends HTMLElement {
      constructor() {
        super();
        const p = document.createElement('p');
        p.textContent = 'word count';
        
        // 这里的this就是自定义元素实例,可以把他看做一个常规的DOM元素
        this.appendChild(p);
      }
    }
    window.customElements.define('work-count', WordCount);
  </script>
</body>

渲染结果

<work-count class="word-count-container">
  <p>word count</p>
</work-count>

CustomElementRegistry.define() 方法用来注册一个 custom element,该方法接受以下参数:

  • 表示所创建的元素名称的符合 [DOMString]标准的字符串。注意,custom element 的名称不能是单个单词,且其中[必须要有短横线]
  • 用于定义元素行为的类。
  • 可选参数,一个包含 extends 属性的配置对象,是可选参数。它指定了所创建的元素继承自哪个内置元素,可以继承任何内置元素。

# 2.1 组件生命周期

上面只是一个简单的例子,我们能做的不只这些。在构造函数中,我们可以设定一些生命周期的回调函数,在特定的时间,这些回调函数将会被调用。

  • connectedCallback:当 custom element 首次被插入文档 DOM 时,被调用。
  • disconnectedCallback:当 custom element 从文档 DOM 中删除时,被调用。
  • adoptedCallback:当 custom element 被移动到新的文档时,被调用。
  • attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时,被调用。
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <custom-square id="CustomSquareRef" l="100" c="red"></custom-square>
    <script>
      class CustomSquare extends HTMLElement {
        static get observedAttributes() {
          return ['c', 'l'];
        }
        constructor() {
          super();

          const div = document.createElement('div');
          const style = document.createElement('style');
          div.textContent = 'square';

          this.appendChild(style);
          this.appendChild(div);
        }
        connectedCallback() {
          console.log('connectedCallback');
          updateStyle(this);
        }
        /* disconnectedCallback()和adoptedCallback()回调函数只是简单地将消息发送到控制台,提示我们元素什么时候从 DOM 中移除、或者什么时候移动到不同的页面: */
        disconnectedCallback() {}
        adoptedCallback() {}
        /* 每当元素的属性变化时,`attributeChangedCallback()`回调函数会执行。正如它的属性所示,我们可以查看属性的名称、旧值与新值,以此来对元素属性做单独的操作。
        需要注意的是,如果需要在元素属性变化后,触发attributeChangedCallback()回调函数,你必须监听这个属性。
        这可以通过定义observedAttributes() get 函数来实现,observedAttributes()函数体内包含一个 return 语句,返回一个数组,包含了需要监听的属性名称:
        */
        attributeChangedCallback(name, oldValue, newValue) {
          console.log('attributeChangedCallback');
          console.log(name, oldValue, newValue);
          updateStyle(this);
        }
      }

      function updateStyle(el) {
        const style = el.querySelector('style');
        style.textContent = `
            div {
                width: ${el.getAttribute('l')}px;
                height: ${el.getAttribute('l')}px;
                background-color: ${el.getAttribute('c')};
            }
        `;
      }

      window.customElements.define('custom-square', CustomSquare);
      const CustomSquareRef = document.querySelector('#CustomSquareRef');
      setTimeout(() => {
        CustomSquareRef.setAttribute('l', '200');
        CustomSquareRef.setAttribute('c', 'green');
      }, 1000);
    </script>
  </body>
</html>

# 3、自定义元素类型

两种 custom elements:

  • Autonomous custom elements 是独立的元素,它不继承其他内建的 HTML 元素。你可以直接把它们写成 HTML 标签的形式,来在页面上使用。例如 <popup-info>,或者是document.createElement("popup-info")这样。

     class CustomSquare extends HTMLElement {}
    
  • Customized built-in elements 继承自基本的 HTML 元素。在创建时,你必须指定所需扩展的元素,使用时,需要先写出基本的元素标签,并通过 is (opens new window) 属性指定 custom element 的名称。例如<p is="word-count">, 或者 document.createElement("p", { is: "word-count" })

    <body>
      <!-- 1、使用时,需要使用is属性 -->
      <p is="work-count"></p>
      <script>
        // 2、类继承自 HTMLParagraphElement
        class WordCount extends HTMLParagraphElement {
          constructor() {
            super();
            const p = document.createElement('p');
            p.textContent = 'word count';
            this.appendChild(p);
          }
          connectedCallback() {
            console.log('connectedCallback');
          }
          attributeChangedCallback() {
            console.log('attributeChangedCallback');
          }
        }
        // 3、第三个参数表明自己继承自p
        window.customElements.define('work-count', WordCount, { extends: 'p' });
      </script>
    </body>
    

# 4、shadow DOM

Web components 的一个重要属性是封装——可以将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,保证不同的部分不会混在一起,可使代码更加干净、整洁。其中,Shadow DOM 接口是关键所在,它可以将一个隐藏的、独立的 DOM 附加到一个元素上。

Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中——它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM 元素一样。

这里,有一些 Shadow DOM 特有的术语需要我们了解:

  • Shadow host:一个常规 DOM 节点,Shadow DOM 会被附加到这个节点上。
  • Shadow tree:Shadow DOM 内部的 DOM 树。
  • Shadow boundary:Shadow DOM 结束的地方,也是常规 DOM 开始的地方。
  • Shadow root: Shadow tree 的根节点。

你可以使用同样的方式来操作 Shadow DOM,就和操作常规 DOM 一样——例如添加子节点、设置属性,以及为节点添加自己的样式(例如通过 element.style 属性),或者为整个 Shadow DOM 添加样式(例如在 `` (opens new window) 元素内添加样式)。不同的是,Shadow DOM 内部的元素始终不会影响到它外部的元素(除了 :focus-within (opens new window)),这为封装提供了便利。

注意,不管从哪个方面来看,Shadow DOM 都不是一个新事物——在过去的很长一段时间里,浏览器用它来封装一些元素的内部结构。以一个有着默认播放控制按钮的 `` (opens new window) 元素为例。你所能看到的只是一个 <video> 标签,实际上,在它的 Shadow DOM 中,包含了一系列的按钮和其他控制器。Shadow DOM 标准允许你为你自己的元素(custom element)维护一组 Shadow DOM。

# 4.1 基本用法

可以使用 Element.attachShadow() (opens new window) 方法来将一个 shadow root 附加到任何一个元素上。它接受一个配置对象作为参数,该对象有一个 mode 属性,值可以是 open 或者 closed

let shadow = elementRef.attachShadow({ mode: "open" });
let shadow = elementRef.attachShadow({ mode: "closed" });

open 表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM,例如使用 Element.shadowRoot (opens new window) 属性:

let myShadowDom = myCustomElem.shadowRoot;

如果你将一个 Shadow root 附加到一个 Custom element 上,并且将 mode 设置为 closed,那么就不可以从外部获取 Shadow DOM 了——myCustomElem.shadowRoot 将会返回 null。浏览器中的某些内置元素就是如此,例如<video>,包含了不可访问的 Shadow DOM。

如果你想将一个 Shadow DOM 附加到 custom element 上,可以在 custom element 的构造函数中添加如下实现(目前,这是 shadow DOM 最实用的用法):

<body>
  <style>
    p {
      color: red;
    }
  </style>
  <p>outer paragraph</p>
  <work-count class="word-count-container"></work-count>
  <script>
    class WordCount extends HTMLElement {
      constructor() {
        super();
        const shadow = this.attachShadow({ mode: 'closed' });
        // 这里的p元素并不会被外部的样式所影响
        const p = document.createElement('p');
        p.textContent = 'word count';
        shadow.appendChild(p);
      }
    }
    window.customElements.define('work-count', WordCount);
  </script>
</body>

# 4.2 为 shadow DOM 添加样式

# 4.2.1 通过style标签添加

class WordCount extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'closed' });
    // 这个样式是私有的, 不会影响外部的元素
    const style = document.createElement('style');
    style.textContent = `
      p {
          color: green;
          font-size: 20px;
      }
    `;
    const p = document.createElement('p');
    p.textContent = 'word count';
    shadow.appendChild(style);
    shadow.appendChild(p);
  }
}
window.customElements.define('work-count', WordCount);

# 4.2.2 通过link引用外部样式

class WordCount extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'closed' });
    // 这个样式是私有的, 不会影响外部的元素
    const linkElem = document.createElement('link');
    linkElem.setAttribute('rel', 'stylesheet');
    linkElem.setAttribute('href', 'style.css');
    const p = document.createElement('p');
    p.classList.add('p-count');
    p.textContent = 'word count';
    shadow.appendChild(linkElem);
    shadow.appendChild(p);
  }
}
window.customElements.define('work-count', WordCount);

WARNING

请注意,因为link元素不会打断 shadow root 的绘制,因此在加载样式表时可能会出现未添加样式内容(FOUC),导致闪烁。

# 5、使用 templates and slots

使用 templateslot元素可以创建一个可以用来灵活填充 Web 组件的 shadow DOM 的模板。

当您必须在网页上重复使用相同的标记结构时,使用某种模板而不是一遍又一遍地重复相同的结构是有意义的。以前这是可行的,但 HTML template 元素使它更容易实现 (这在现代浏览器中得到了很好的支持)。此元素及其内容不会在 DOM 中呈现,但仍可使用 JavaScript 去引用它。

<template id="MyCard">
  <div>username:</div>
</template>
<script>
  const myCardTemplate = document.querySelector('#MyCard');
  const content = myCardTemplate.content;
  document.body.appendChild(content);
</script>

# 5.1 在Web Components 中使用

模板(Template)本身就是有用的,而与 web 组件(web component)一起使用效果更好。

<body>
  <hello-world></hello-world>
  <hello-world />
  <template id="hello">
    <h1>hello, world!</h1>
  </template>
  <script>
    window.customElements.define(
      'hello-world',
      class extends HTMLElement {
        constructor() {
          super();
          const helloTemp = document.querySelector('#hello');
          const content = helloTemp.content.cloneNode(true);
          const shadowRoot = this.attachShadow({ mode: 'closed' });
          shadowRoot.appendChild(content);
        }
      }
    );
  </script>
</body>

要注意的关键是我们使用 Node.cloneNode() (opens new window) 方法添加了模板的拷贝到阴影的根结点上。

因为我们添加了模板的内容到 shadow DOM,所以我们可以加入一些样式信息到模板的 style 标签里,这些样式信息稍后会封装到自定义的元素中。如果只给它添加到一个标准的 DOM 中是不起作用的。

  <body>
    <hello-world></hello-world>
    <hello-world />
    <template id="hello">
      <style>
        h1.title {
          color: red;
          margin: 0;
          text-align: center;
        }
      </style>
      <h1 class="title">hello, world!</h1>
    </template>
    <script>
      window.customElements.define(
        'hello-world',
        class extends HTMLElement {
          constructor() {
            super();
            const helloTemp = document.querySelector('#hello');
            const content = helloTemp.content.cloneNode(true);

            const shadowRoot = this.attachShadow({ mode: 'closed' });
            shadowRoot.appendChild(content);
          }
        }
      );
    </script>
  </body>

# 5.1.1 在template中使用style

<template id="hello">
  <style>
    :host {
		  display: block;
		  background-color: #ccc;
		}
    h1.title {
      color: red;
      margin: 0;
      text-align: center;
    }
  </style>
  <h1 class="title">
    <slot>hello, world!</slot>
  </h1>
</template>

这里面的style只会对内部元素生效, 不会影响外部的DOM。

TIP

上面代码中,<template>样式里面的:host伪类,指代自定义元素本身。

# 5.2 使用槽 (slots) 添加灵活度

尽管到这一步已经挺好了,但是元素仍旧不是很灵活。我们只能在里面放一点文本,甚至没有普通的 p 标签管用!

插槽由其name属性标识,并且允许您在模板中定义占位符,当在标记中使用该元素时,该占位符可以填充所需的任何 HTML 标记片段。

查看下面的例子

<hello-world>
  <span slot="text">HELLO, WORLD!</span>
</hello-world>

<hello-world />

<template id="hello">
  <style>
    h1.title {
      color: red;
      margin: 0;
      text-align: center;
    }
  </style>
  <h1 class="title">
    <slot name="text">hello, world!</slot>
  </h1>
</template>
<script>
  window.customElements.define(
    'hello-world',
    class extends HTMLElement {
      constructor() {
        super();
        const helloTemp = document.querySelector('#hello');
        const content = helloTemp.content.cloneNode(true);
        const shadowRoot = this.attachShadow({ mode: 'closed' });
        shadowRoot.appendChild(content);
      }
    }
  );
</script>

在模板中定义slot标签<slot name="text">hello, world!</slot>。 使用的时候填写填充内容<span slot="text">HELLO, WORLD!</span>

不设置slot的name属性的话, 就是默认插槽。

TIP

将能被插入到槽中的元素视为 Slotable (en-US) (opens new window); 称已经插入到槽中的元素为 slotted.

# 6、传递attr给组件

除了可以通过slot来动态添加自定义内容时,通过元素的attr属性也能达到部分动态效果。

<person-card username="xdyuan" sex=""></person-card>
// 获取内部的username元素
const userNameEl = content.querySelector('.username');
// 通过this.getAttribute('username')获取外部attr
userNameEl.textContent = this.getAttribute('username') || '';

完整例子如下

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <template id="person_temp">
      <style>
        .username {
          color: red;
        }
      </style>
      <h4>人员基本信息</h4>
      <ul>
        <li class="username"></li>
        <li class="sex"></li>
      </ul>
    </template>
    <person-card username="xdyuan" sex=""></person-card>
    <script>
      class PersonCard extends HTMLElement {
        constructor() {
          super();
          const content = document.querySelector('#person_temp').content.cloneNode(true);

          // 获取内部的username元素
          const userNameEl = content.querySelector('.username');
          // 通过this.getAttribute('username')获取外部attr
          userNameEl.textContent = this.getAttribute('username') || '';
          const sexEl = content.querySelector('.sex');
          sexEl.textContent = this.getAttribute('sex') || '';

          const shadowRoot = this.attachShadow({ mode: 'closed' });
          shadowRoot.appendChild(content);
        }
      }
      window.customElements.define('person-card', PersonCard);
    </script>
  </body>
</html>

配合生命周期, 查看2.1章节的demo。可以做到当外部attr变更时, 同步渲染变动的内容。

查看下面的例子, 我们使用一个get属性get label(), 来保证我们每次都获取到最新的label属性值。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <template id="button_temp">
      <button class="xd-button"></button>
    </template>

    <my-button label="click me"></my-button>

    <script>
      class MyButton extends HTMLElement {
        constructor() {
          super();
          const content = document.querySelector('#button_temp').content.cloneNode(true);

          const shadowRoot = this.attachShadow({ mode: 'closed' });
          this._shadowRoot = shadowRoot;
          this._shadowRoot.appendChild(content);

          this.$button = this._shadowRoot.querySelector('.xd-button');
        }
        get label() {
          return this.getAttribute('label') || '按钮';
        }
        static get observedAttributes() {
          return ['label'];
        }
        attributeChangedCallback(name, oldVal, newVal) {
          this.render();
        }
        render() {
          this.$button.textContent = this.label;
        }
      }
      window.customElements.define('my-button', MyButton);

      const btn = document.querySelector('my-button');
      setTimeout(() => {
        btn.setAttribute('label', 'new click');
      }, 1000);
    </script>
  </body>
</html>

不过这种往dom元素添加attr属性的方式在遇到需要传递数组和对象时,就不是很合适。通常我们可以采用下面的办法

get list() {
  return this.getAttribute('list');
}
set list(newList) {
  this.setAttribute('list', newList);
}

const btn = document.querySelector('my-button');
btn.list = [1, 2];

# 7、与用户互动

给封装的元素添加用户交互,可以通过attr或者prop的方式传递一个函数进去。但是最佳实践是通过addEventListener给组件添加事件,在组件内部可以通过CustomEvent触发事件。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <template id="button_temp">
      <style>
        .my-button {
          display: inline-block;
          line-height: 1;
          white-space: nowrap;
          cursor: pointer;
          background: #fff;
          border: 1px solid #dcdfe6;
          color: #606266;
          -webkit-appearance: none;
          text-align: center;
          box-sizing: border-box;
          outline: none;
          margin: 0;
          transition: 0.1s;
          font-weight: 500;
          -moz-user-select: none;
          -webkit-user-select: none;
          -ms-user-select: none;
          padding: 12px 20px;
          font-size: 14px;
          border-radius: 4px;
        }
        .my-button:hover,
        .my-button:focus {
          color: #409eff;
          border-color: #c6e2ff;
          background-color: #ecf5ff;
        }

        .my-button:active {
          color: #3a8ee6;
          border-color: #3a8ee6;
          outline: none;
        }
        .my-button.my-button--disabled {
          color: #c0c4cc;
          cursor: not-allowed;
          background-image: none;
          background-color: #fff;
          border-color: #ebeef5;
        }
      </style>
      <button class="my-button">
        <slot></slot>
      </button>
    </template>
    <my-button id="my_button">点我</my-button>
    <script>
      class MyButton extends HTMLElement {
        constructor() {
          super();
          const _this = this;
          const content = document.querySelector('#button_temp').content.cloneNode(true);

          const shadowRoot = this.attachShadow({ mode: 'closed' });
          shadowRoot.appendChild(content);

          this.$clickHandler = this.getAttribute('on-click');

          this.$buttonEl = shadowRoot.querySelector('.my-button');

          this.$onClick = function (e) {
            const clickEvent = new CustomEvent('on-click', {
              detail: e,
              bubbles: true,
              cancelable: true
            });
            // window[_this.$clickHandler](e);
            _this.dispatchEvent(clickEvent);
          };
          this.$buttonEl.addEventListener('click', this.$onClick);
        }
        disconnectedCallback() {
          this.$buttonEl.removeEventListener('click', this.$onClick);
        }
      }
      window.customElements.define('my-button', MyButton);

    </script>
    <script>
      const my_button = document.querySelector('#my_button');
      my_button.addEventListener('on-click', e => {
        console.log(e);
      });

    </script>
  </body>
</html>

# 8、尝试封装一个下拉框组件

MySelect.js

const template = document.createElement('template');
template.innerHTML = `
<style>
  .my-select {
    position: relative;
    width: 100%;
    height: 30px;
    box-sizing: border-box;
  }
  .trigger-btn {
    width: 100%;
    height: 100%;
    border-radius: 2px;
    border: 1px solid #eee;
    box-sizing: border-box;
    font-size: 16px;
    line-height: 30px;
    padding: 0 8px;
  }
  .select__input {
    display: block;
    height: 0;
    position: absolute;
    overflow: hidden;
    border: none;
    outline: none;
    padding: 0;
  }
  .my-select--focus .trigger-btn {
    border-color: #409eff;
    box-sizing: border-box;
  }
  .select-options-wrapper {
    position: absolute;
    box-sizing: border-box;
    left: 0;
    right: 0;
    top: 34px;
    margin: 0;
    padding: 0;
    list-style: none;
    overflow: auto;
    max-height: 0px;
    transition: max-height 0.5s ease;
    border-radius: 2px;
    box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
  }
  .select-item {
    height: 28px;
    box-sizing: border-box;
    padding: 0 8px;
    /* transition: all 0.3s; */
    background-color: #fff;
    color: #000;
    font-size: 14px;
    line-height: 28px;
  }
  .select-item.select-item--active {
    color: #409eff;
  }
  .select-item:hover {
    background-color: #eee;
  }
</style>
<div class="my-select">
  <div class="trigger-btn"></div>
  <input class="select__input" type="text" />
  <ul class="select-options-wrapper"></ul>
</div>`;
class MySelect extends HTMLElement {
  constructor() {
    super();
    const content = template.content.cloneNode(true);
    console.log(template, content);

    const shadowRoot = this.attachShadow({ mode: 'closed' });
    this._shadowRoot = shadowRoot;
    this._shadowRoot.appendChild(content);

    this.collapse = false;
    this.optionElList = [];

    this.$innerInput = this._shadowRoot.querySelector('.select__input');
    this.inputBlurHandler = e => {
      setTimeout(() => {
        this.collapse = false;
        this.triggerOptionsWrapper(this.collapse);
      }, 100);
    };
    this.$innerInput.addEventListener('blur', this.inputBlurHandler);

    this.$optionsWrapper = this._shadowRoot.querySelector('.select-options-wrapper');
    this.transitionendHandler = e => {
      e.stopPropagation();
    };
    this.$optionsWrapper.addEventListener('transitionend', this.transitionendHandler);

    this.$triggerBtn = this._shadowRoot.querySelector('.trigger-btn');
    this.triggerBtnClickHandler = e => {
      this.collapse = !this.collapse;
      this.triggerOptionsWrapper(this.collapse);
    };
    this.$triggerBtn.addEventListener('click', this.triggerBtnClickHandler);

    this.selectItemClickHandler = e => {
      e.stopPropagation();
      const liEl = e.target;
      const value = liEl.dataset.value;
      const changeEvent = new CustomEvent('on-change', {
        detail: value,
        bubbles: true,
        cancelable: true
      });
      // window[_this.$clickHandler](e);
      this.dispatchEvent(changeEvent);
      // this.triggerBtnClickHandler();
    };
    this.$optionsWrapper.addEventListener('click', this.selectItemClickHandler);
  }
  get options() {
    const optionsStr = this.getAttribute('options') || '[]';
    return JSON.parse(optionsStr);
  }
  get value() {
    return this.getAttribute('value') || '[]';
  }
  static get observedAttributes() {
    return ['options', 'value'];
  }
  connectedCallback() {}
  disconnectedCallback() {
    this.$optionsWrapper.removeEventListener('transitionend', this.transitionendHandler);
    this.$optionsWrapper.removeEventListener('click', this.selectItemClickHandler);
    this.$triggerBtn.removeEventListener('click', this.triggerBtnClickHandler);
  }
  attributeChangedCallback(name, oldVal, newVal) {
    console.log('attributeChangedCallback', name, oldVal, newVal);
    if (name === 'options') {
      this.renderOptions();
    }
    if (name === 'value') {
      this.setActiveLiEl();
    }
  }
  renderOptions() {
    this.optionElList = [];
    this.options.forEach(option => {
      const { label, value } = option;
      const li = document.createElement('li');
      li.classList.add('select-item');
      li.setAttribute('data-value', value);
      li.setAttribute('data-label', label);
      li.textContent = label;

      this.optionElList.push(li);
      this.$optionsWrapper.appendChild(li);
    });
    this.setActiveLiEl();
  }
  setActiveLiEl() {
    this.optionElList.forEach(liEl => {
      liEl.classList.remove('select-item--active');
      const value = liEl.dataset.value;
      if (value == this.value) {
        liEl.classList.add('select-item--active');
        this.$triggerBtn.textContent = liEl.dataset.label;
      }
    });
  }
  triggerOptionsWrapper(status) {
    console.log('triggerOptionsWrapper', status);
    console.trace();
    if (status) {
      this.$optionsWrapper.style.maxHeight = '140px';
      // this.$optionsWrapper.style.overflow = 'hidden';
      this.$innerInput.focus();
    } else {
      this.$optionsWrapper.style.maxHeight = '0';
    }
  }
}
window.customElements.define('my-select', MySelect);

使用

<div style="width: 300px">
  <my-select
    id="select1"
    value=""
    options='[{"value":1,"label":"11"},{"value":2,"label":"22"},{"value":3,"label":"33"},{"value":4,"label":"44"},{"value":5,"label":"55"},{"value":6,"label":"66"}]'
  >
  </my-select>
</div>
<script src="./MySelect.js"></script>
<script>
  const select1 = document.querySelector('#select1');
  let selectValue = '2';
  select1.setAttribute('value', selectValue);
  select1.addEventListener('on-change', event => {
    selectValue = event.detail;
    select1.setAttribute('value', selectValue);
  });
</script>

ruanyifeng (opens new window)MDN (opens new window)web-components-tutorial (opens new window)https://web.dev/custom-elements-v1/ (opens new window)

上次更新: 1/22/2025, 9:39:13 AM