Web Components 入门实战(下篇)

2,848 阅读7分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

上一篇实战中,我们完成了下拉菜单自定义组件的触发对象部分。

我们再简单回忆一下自定义组件的触发对象 自定义按钮 button 的实现过程:

  • 第一步:通过Custom Elements 和 template 实现自定义元素的样式和结构。
  • 第二步:通过attributeChangedCallbackobservedAttributes实现属性的传递,在通过gettersetter实现属性的映射。
  • 最后一步:响应用户操作,为自定义元素注册事件。

自定义按钮 button 的实现整个过程,不是很复杂,这里也贴一下上篇文章的实现代码。

const template = document.createElement('template');

template.innerHTML = `
<style>
  .container {
    padding: 8px;
  }

  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: .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;
    color: #fff;
    background-color: #409eff;
    border-color: #409eff;
  }
  </style>

  <div class="container">
    <button>default text</button>
  </div>
`;

class Button extends HTMLElement {
  constructor() {
    super();

    this._shadowRoot = this.attachShadow({ mode: 'open' });
    this._shadowRoot.appendChild(template.content.cloneNode(true));

    this.$container = this._shadowRoot.querySelector('.container');
    this.$button = this._shadowRoot.querySelector('button');

    this.$button.addEventListener('click', () => {
      this.dispatchEvent(
        new CustomEvent('onCustomClick', {
          detail: '设置的点击回调被触发~~~',
        })
      );
    });
  }
  get text() {
    return this.getAttribute('text');
  }

  set text(value) {
    this.setAttribute('text', value);
  }

  static get observedAttributes() {
    return ['text'];
  }

  attributeChangedCallback(name, oldVal, newVal) {
    this.render();
  }

  render() {
    this.$button.innerHTML = this.text;
  }
}

window.customElements.define('my-button', Button);

自定义按钮元素对于下拉组件来说有点无关紧要,因为它没有实现任何特殊行为。我们也可以可以使用带有 CSS 样式的普通 HTML 按钮元素。但是,自定义按钮元素通过一个简单的示例帮助我们掌握了 Web Component 的概念。这就是为什么我会将实战的自定义按钮元素单独分为一篇,目的也就是打好基础。

这篇文章我们接着完成下拉菜单。我们想要的效果是这样的。

现在只有自定义按钮元素,所以这篇实战将下拉部分触发对象结合起来,完成整个下拉菜单

设计

下拉菜单,功能很简单,就是将动作或菜单折叠到下拉菜单中。

整体设计

整个下拉菜单就两部分组成,一个是触发对象 button,一个是下拉的列表。我们要实现的功能也很清晰:

  • click 或者 hover button,展示下拉的列表。
  • 点击下拉列表选项将选中的项展示到 button 中,并关闭下拉列表。
  • 下拉项目被选中将选中项数据回调到外部。

API 设计

  • 展示的 label:label
  • 默认选中,如果没有,默认以列表第一项展示:option
  • 下拉列表数组:options。
<web-dropdown
  label="下拉菜单组件"
  option="1"
  options='[{ "label": "Option 1", "value": 1 }, { "label": "Option 2", "value": 2 }]'
></web-dropdown>

功能设计很简单,API 也很简单,话不多说,开始来完成我们的下拉组件。

Dropdown

首先我们先搭起一个整体的架构,创建一个 Dropdown 类,这个类就是我们下拉菜单的总类,它将 button 和下拉列表包含。

import './button.js';

const template = document.createElement('template');
template.innerHTML = `
  <div class="dropdown">
    <span class="label">Label</span>
    <my-button></my-button>
    <div class="dropdown-list-container">
      <ul class="dropdown-list"></ul>
    </div>
  </div>
`;

class Dropdown extends HTMLElement {
  constructor() {
    super();

    this._ropdownRoot = this.attachShadow({ mode: 'open' });
    this._ropdownRoot.appendChild(template.content.cloneNode(true));

    this.$label = this._ropdownRoot.querySelector('.label');
    this.$button = this._ropdownRoot.querySelector('my-button');
    this.$dropdown = this._ropdownRoot.querySelector('.dropdown');
    this.$dropdownList = this._ropdownRoot.querySelector('.dropdown-list');
  }
}

window.customElements.define('road-dropdown', Dropdown);

整体来说,很简单,结构也很清楚。效果如下:

属性映射监听

接着为 Dropdwon 组件 API 属性的监听。

使用 getter 方法将属性反映到 property。通过元素的 setter 方法通过将元素的属性设置为反射的属性值,确保将属性反射到属性。

然后通过结合attributeChangedCallbackobservedAttributes实现属性的自定义监听。observedAttributes用于定义我们简要监听的属性,attributeChangedCallback用于属性改变之后的回调。

class Dropdown extends HTMLElement {
  ...
  static get observedAttributes() {
    return ['label', 'option', 'options'];
  }

  get label() {
    return this.getAttribute('label');
  }

  set label(value) {
    this.setAttribute('label', value);
  }

  get option() {
    return this.getAttribute('option');
  }

  set option(value) {
    this.setAttribute('option', value);
  }

  get options() {
    return JSON.parse(this.getAttribute('options'));
  }

  set options(value) {
    this.setAttribute('options', JSON.stringify(value));
  }

  attributeChangedCallback(name, oldVal, newVal) {
    this.render();
  }
  render() {

  }
}

这里需要注意的是,在 web components 中自定义元素的属性类型是对象时,需要通过 JSON 来进行传递,这样一来我们在getter 方法 setter 方法中也需要进行 JSON 的转义。

现在我们在 Dropdown 类中就可以接收到组件传过来的参数了。只要我们监听的属性发生改变都能触发attributeChangedCallback,所以我们可以在这里进行我们自定义的操作。并且我们也将我们的属性挂载到了当前的 this ,在记下来的操作我们就可以很方便的进行参数的调用和赋值了。

页面初始化

到目前为止,尚未使用所有传递的属性。我们只是用一个空的渲染方法对它们做出反应。让我们通过将它们分配给下拉和按钮元素来使用它们,我们接收到参数之后,就需要将我们的一些默认参数赋值到页面上中,比如:

  • label 赋值。
  • 按钮的默认文案展示。如果有 option 参数就赋值 option,如果没有就用下拉列表的第一条文案作为按钮的默认文案。
class Dropdown extends HTMLElement {
  ...
  attributeChangedCallback(name, oldVal, newVal) {
    this.render();
  }

  render() {
    if (!Array.isArray(this.options)) {
      console.warn('Options must be an array...')
      return;
    };

    this.$label.innerHTML = this.label;
    this.$button.setAttribute(
      'text',
      (this.options.find((item) => 
        item.value === parseInt(this.option)) || (this.options &&  this.options[0])).label
    );
  }
}

渲染下拉列表

接着我们将自定义参数的中列表数据渲染成我们的 DOM 结构。操作很简单,循环列表,创建下拉元素,添加到下拉容器中。

下拉菜单从外部获取其标签作为要设置为内部 HTML 的属性,而按钮现在将任意标签设置为属性。我们稍后将根据从下拉列表中选择的选项设置此标签。此外,我们可以使用这些选项来为我们的下拉菜单呈现实际的可选项目:

class Dropdown extends HTMLElement {
  ...
  attributeChangedCallback(name, oldVal, newVal) {
    this.render();
  }

  render() {
    ...
    this.options.forEach((item) => {
      let option = item;
      let $option = document.createElement('li');

      this.$dropdownList.innerHTML = '';
      $option.innerHTML = option.label;
      this.$dropdownList.appendChild($option);
    });
  }
}

默认设置了下拉列表元素样式 display: none

下拉列表的 DOM 渲染完成之后,我们就要想着如何通过触发对象 button 来控制我们下拉列表的显示和影藏。

下拉列表的显示和影藏

我们想要展示下拉列表就需要结合 button。需要给 button 注册事件。我们在之前的 button 实现中,借助dispatchEvent()new CustomEvent()web-button实现了一个自定义事件,并对外监听。

然后我们需要在 Dropdown 组件中来监听我们注册的这个自定义事件的触发。但是这里需要注意,在Dropdown 组件中监听的事件名称要和 web-button自定义的事件名称一一对应。我们这 button 中定义的事件名称是onCustomClick。所以在 Dropdown 组件组件要监听的事件名称也是onCustomClick

this.$button.addEventListener(
  'onCustomClick',
  (event) => {
    console.log('哎呀,我被点击了...');
    console.log(event);
  }
);

现在已经完成了触发对象事件的监听,接着就是将我们的下拉列表展示出来。列表的显示和影藏,必须由一个状态来记录。我们定义一个状态this.open,默认是不展示。

this.open = false;

接着在监听事件的回调中,我们来控制这个状态。我们抽离一个专用于切换显示影藏状态的方法toggleOpen

class Dropdown extends HTMLElement {
  constructor() {
    super();
  	...
    this.open = false;
    this.$button.addEventListener(
      'onCustomClick',
      this.toggleOpen
    );
  }

  toggleOpen(event) {
    this.open = !this.open;
  }
}

只有状态的切换肯定是不够的,还需要将状态装换为能控制 DOM 显示和影藏的操作。我们这里用一个最简单的版本来控制 DOM 的显示和影藏,那就是display: nonedisplay: flexnone表示默认状态下的下拉列表影藏。flex表示切换状态下的展示。

当我们切换到展示状态时,我们给 DOM 添加一个 class,class 就包含了列表的样式display: flex

template.innerHTML = `
  <style>
    ...

    .dropdown.open .dropdown-list {
      display: flex;
      flex-direction: column;
    }
    
    ...
  </style>
  <div class="dropdown">
    <span class="label">Label</span>
    <my-button></my-button>
    <div class="dropdown-list-container">
      <ul class="dropdown-list"></ul>
    </div>
  </div>
`;

class Dropdown extends HTMLElement {
  constructor() {
    super();
  	...
    this.open = false;
    this.$button.addEventListener(
      'onCustomClick',
      this.toggleOpen
    );
  }

  toggleOpen(event) {
    this.open = !this.open;
    this.open ? this.$dropdown.classList.add('open') : this.$dropdown.classList.remove('open');
  }
}

这样就完成了下拉菜单中下拉列表的显示和影藏。我们在浏览器跑起来看看。

咦,报错了。 Cannot read properties of undefined (reading 'classList')

原来是一个典型的问题,this 指向的问题,dom 元素进行事件绑定,不管是 dom0 级事件还是 dom2 级事件,当事件行为触发,绑定的方法执行,方法中的 this 就是当前 dom 元素本身。

<body>
  <script>
    document.onclick = function() {
      console.log(this); // this -> document 对象
    }
    document.addEventListener('click', function () {
      console.log(this); // this -> document 对象
    });
  </script>
</body>

这里点击元素时,方法中执行的 this,应该是web-button这个 dom 元素本身。

所以我们这里修正一下当前点击切换方法中的 this 指向。

this.$button.addEventListener(
  'onCustomClick',
  this.toggleOpen.bind(this)
);

修正完,我们再来看看效果。

注意下拉列表中的样式是默认已经处理好的样式。我这里就不贴样式出来了。

到这里,我们可以通过单击我们的自定义按钮来打开和关闭自定义下拉列表。这是我们自定义元素的第一个真正的内部行为,它本来可以在 React 或 Angular 等框架中实现。现在,我们自己可以简单地使用这个 Web 组件并期待它的这种行为。

下拉项的默认选中

下拉列表已经能伸缩自如了。接着我们要让我们传入的 option参数起作用,默认选中。只要选项属性与列表中的选项匹配,我们就可以在我们的渲染方法中设置一个 DOM 的新的样式类,来进行选中标记。有了这个新样式,并在下拉列表中的一个选项上动态设置样式,我们可以看到该功能确实有效:

const template = document.createElement('template');

template.innerHTML = `
  <style>
    .dropdown-list li.selected {
      color: #66b1ff;
      font-weight: 700;
      z-index: 99;
    }
  
    .dropdown-list li:hover {
      color: #66b1ff;
      background-color: #ecf5ff;
    }
  
    .dropdown-list li:active,
    .dropdown-list li:hover,
    .dropdown-list li.selected {
      border-right: none;
      border-left: none;
      border-width: 1px;
    }
  </style>
`;
class Dropdown extends HTMLElement {
  ...

  render() {
    ...
    this.options.forEach((item) => {
      ...

      if (this.option && this.option === key) {
        $option.classList.add('selected');
      }

      ...
    });
  }
}

下拉项的选中更新

我们对自定义下拉列表的内部行为有效。我们可以打开和关闭它,接着我们需要通过从下拉列表中选择一个选项来设置一个新选项。

class Dropdown extends HTMLElement {
  ...

  render() {
    ...
    this.options.forEach((item) => {
    	...
      $option.addEventListener('click', () => {
        this.option = option.value;
        this.toggleOpen();
      });

      ...
    });
  }
}

下拉项选中更新通知

我们需要再次向外界提供一个 API(例如自定义事件),以通知他们有关更改的选项。因此,为每个列表项单击分派一个自定义事件,用以回调我们那个下拉项被选中了。这里还是借助dispatchEvent()new CustomEvent() 为每一个下拉项实现了一个自定义事件。

class Dropdown extends HTMLElement {
  ...

  render() {
    ...
    this.options.forEach((item) => {
    	...
      $option.addEventListener('click', () => {
        this.option = option.value;
        this.toggleOpen();
        this.dispatchEvent(
          new CustomEvent('onOptionChange', { 
            detail: { detail: { ...option, option, options: this.options } }
          })
        );
      });
      ...
    });
  }
}

最后,当使用下拉菜单作为 Web 组件时,您可以为自定义事件添加一个事件侦听器以获取有关更改的通知。

<web-dropdown
  label="下拉菜单组件"
  option="1"
  options='[{ "label": "Option 1", "value": 1 }, { "label": "Option 2", "value": 2 }]'
  ></web-dropdown>
<script>
  document
    .querySelector('web-dropdown')
    .addEventListener('onOptionChange', (event) =>  {
      console.log(event.detail)
    });
</script>

看看效果。

到这里,我们已经创建了一个完全封装的下拉组件作为具有自己的结构、样式和行为的 Web Component。这里贴一下完整的代码。

<!DOCTYPE html>
<html>
  <head>
    <title>Dropdown with Web Components</title>
  </head>
  <body>
    <web-dropdown
      label="下拉菜单组件"
      option="1"
      options='[{ "label": "Option 1", "value": 1 }, { "label": "Option 2", "value": 2 }]'
      onOptionChange=
    ></web-dropdown>

    <script>
      document
        .querySelector('web-dropdown')
          .addEventListener('onOptionChange', (event) =>  {
            console.log(event.detail);
          });
    </script>
  </body>
</html>
import './button.js';

const template = document.createElement('template');

template.innerHTML = `
  <style>
  .dropdown {
    box-sizing: border-box;
    padding: 3px 8px 8px;
    cursor: pointer;
  }

  .dropdown.open .dropdown-list {
    display: flex;
    flex-direction: column;
  }

  .label {
    display: block;
    margin-bottom: 5px;
    font-size: 16px;
    font-weight: normal;
    line-height: 16px;
  }

  button {
    width: 100%;
    position: relative;
    padding-right: 45px;
    padding-left: 8px;
    font-size: 16px;
    font-weight: 600;
    text-align: left;
    white-space: nowrap;
  }

  .dropdown-list-container {
    position: relative;
  }

  .dropdown-list {
    position: absolute;
    width: 100%;
    display: none;
    max-height: 192px;
    overflow-y: auto;
    margin: 4px 0 0;
    padding: 0;
    background-color: #ffffff;
    border: 1px solid #a1a1a1;
    box-shadow: 0 2px 4px 0 rgba('0,0,0', 0.05), 0 2px 8px 0 rgba('161,161,161', 0.4);
    list-style: none;
  }

  .dropdown-list li {
    display: flex;
    align-items: center;
    margin: 4px 0;
    padding: 0 7px;
    border-right: none;
    border-left: none;
    border-width: 0;
    font-size: 16px;
    flex-shrink: 0;
    height: 40px;
  }

  .dropdown-list li:not(.selected) {
    box-shadow: none;
  }

  .dropdown-list li.selected {
    color: #66b1ff;
    font-weight: 700;
    z-index: 99;
  }

  .dropdown-list li:hover {
    color: #66b1ff;
    background-color: #ecf5ff;
  }

  .dropdown-list li:active,
  .dropdown-list li:hover,
  .dropdown-list li.selected {
    border-right: none;
    border-left: none;
    border-width: 1px;
  }

  .dropdown-list li:focus {
    border-width: 2px;
  }

  .dropdown-list li:disabled {
    color: rgba('0,0,0', 0.6);
    font-weight: 300;
  }
  </style>
  <div class="dropdown">
    <span class="label">Label</span>
    <my-button></my-button>
    <div class="dropdown-list-container">
      <ul class="dropdown-list"></ul>
    </div>
  </div>
`;

class Dropdown extends HTMLElement {
  constructor() {
    super();

    this._ropdownRoot = this.attachShadow({ mode: 'open' });
    this._ropdownRoot.appendChild(template.content.cloneNode(true));
    this.$label = this._ropdownRoot.querySelector('.label');
    this.$button = this._ropdownRoot.querySelector('my-button');
    this.$dropdown = this._ropdownRoot.querySelector('.dropdown');
    this.$dropdownList = this._ropdownRoot.querySelector('.dropdown-list');

    this.open = false;

    this.$button.addEventListener(
      'onCustomClick',
      this.toggleOpen.bind(this)
    );
  }

  toggleOpen(event) {
    this.open = !this.open;

    this.open
      ? this.$dropdown.classList.add('open') : this.$dropdown.classList.remove('open');
  }

  static get observedAttributes() {
    return ['label', 'option', 'options'];
  }

  get label() {
    return this.getAttribute('label');
  }

  set label(value) {
    this.setAttribute('label', value);
  }

  get option() {
    return this.getAttribute('option');
  }

  set option(value) {
    this.setAttribute('option', value);
  }

  get options() {
    return JSON.parse(this.getAttribute('options'));
  }

  set options(value) {
    this.setAttribute('options', JSON.stringify(value));
  }

  attributeChangedCallback(name, oldVal, newVal) {
    this.render();
  }

  render() {
    if (!Array.isArray(this.options)) {
      console.warn('Options must be an array...');
      return;
    }

    this.$label.innerHTML = this.label;
    this.$button.setAttribute(
      'text',
      (
        this.options.find(
          (item) => item.value === parseInt(this.option)
        ) ||
        (this.options && this.options[0])
      ).label
    );

    this.$dropdownList.innerHTML = '';

    this.options.forEach((item) => {
      let option = item;
      let $option = document.createElement('li');

      if (this.option && this.option == option.value) {
        $option.classList.add('selected');
      }

      $option.addEventListener('click', () => {
        this.option = option.value;
        this.toggleOpen();
        this.dispatchEvent(
          new CustomEvent('onOptionChange', { detail: { ...option, option, options: this.options } })
        );
      });

      $option.innerHTML = option.label;
      this.$dropdownList.appendChild($option);
    });
  }
}

window.customElements.define('web-dropdown', Dropdown);

整个下拉组件功能比较简单,当然你可以按照本文的实战教程为这个组件提交更多的 API ,整体的设计都是一样的。

总结

通过上下两篇实战,我们从 0 ~ 1 实现了一个下拉组件。整个实战回过头来看都是基础知识的实践,没有特别难的地方。之后,我会将实现的下拉的组件提取成一个组件项目发布,在我们的项目中应用起来。

最后,我希望从这个 Web Components 教程中你可以学到了很多东西。

如果文中有什么问题或者错误,请在评论区告诉我。

如果你觉得这篇文章对你有帮助,点个赞吧。

如果你对 Web Components 感兴趣,关注我的 Web Components 专栏吧。