尝试写一个复杂的 Web Component

2,705 阅读6分钟

看过一些介绍 Web Component 文章,也看了一些 Web Component 的示例,但都是一些入门的简单的内容。对于 Web Component 如何实现一个比较复杂的组件有些好奇。今天就自己动手,尝试自己写一个在业务中有被用到的组件。

前段时间笔者写过一篇《手把手教你写一个条件组合组件》的文章,今天就以这个组件为题,尝试使用 Web Component 来实现它。组件效果如图:
图中的“条件组”表示是括号,假设我们要写一个 Key1 > 0 && (Key2 < 20 || Key3 > 10) 这样的表达式,那么就可以用下图表示:

注:以上图片来源于《手把手教你写一个条件组合组件》,使用了 UI 库,本文使用的是原生 JS 故在控件的样式上会有差异。

Web Component 最大的特点就是可以创建自定义标签,基于此笔者最先想到的方案是将此组件拆分成<relation-tree><relation-group><relation-item> 这样三个标签。然后使用标签来构建组件的内容。结构分解如图:

Key1 > 0 && (Key2 < 20 || Key3 > 10) 这个表达式为例,三个标签的组合如下:

<relation-tree>
  <relation-item></relation-item>
  <relation-group>
    <relation-item></relation-item>
    <relation-item></relation-item>
  </relation-group>
</relation-tree>

为考虑复用性,我们将 Term 作为自定义部分以 <relation-item> 标签 children 的形式传入,故上面表达式的代码如下:

<relation-tree>
  <relation-item>
    <div class="term" slot="element-term">
      <span class="element">
        <select name="key" value="Key1" placeholder="请选择条件项">
          <option value="Key1">Key1</option>
          <option value="Key2">Key2</option>
          <option value="Key3">Key3</option>
        </select>
      </span>
      <span class="comparison">
        <select name="op" value=">" placeholder="请选择关系符">
          <option value="==">等于</option>
          <option value="!=">不等于</option>
          <option value=">">大于</option>
          <option value="<">小于</option>
        </select>
      </span>
      <span class="value">
        <input name="value" value="0" />
      </span>
    </div>
  </relation-item>
  <relation-group>
    <relation-item>
      <div class="term" slot="element-term">
        <span class="element">
          <select name="key" value="Key2" placeholder="请选择条件项">
            <option value="Key1">Key1</option>
            <option value="Key2">Key2</option>
            <option value="Key3">Key3</option>
          </select>
        </span>
        <span class="comparison">
          <select name="op" value="<" placeholder="请选择关系符">
            <option value="==">等于</option>
            <option value="!=">不等于</option>
            <option value=">">大于</option>
            <option value="<">小于</option>
          </select>
        </span>
        <span class="value">
          <input name="value" value="20" />
        </span>
      </div>
    </relation-item>
    <relation-item>
      <div class="term" slot="element-term">
        <span class="element">
          <select name="key" value="Key3" placeholder="请选择条件项">
            <option value="Key1">Key1</option>
            <option value="Key2">Key2</option>
            <option value="Key3">Key3</option>
          </select>
        </span>
        <span class="comparison">
          <select name="op" value=">" placeholder="请选择关系符">
            <option value="==">等于</option>
            <option value="!=">不等于</option>
            <option value=">">大于</option>
            <option value="<">小于</option>
          </select>
        </span>
        <span class="value">
          <input name="value" value="10" />
        </span>
      </div>
    </relation-item>
  </relation-group>
</relation-tree>

此方案有点类似于 table 标签的构成。此方案的优势是实现简单,在使用上接近于原生的 HTML;劣势是使用依然比较麻烦,无法做到数据驱动

代码实现

接下来分别来实现 <relation-tree><relation-group><relation-item> 三个标签的定义。

定义标签

relation-tree

在三个标签中 <relation-tree> 标签是最简单的,只是作为一个容器。不过考虑到使用的便捷性,笔者在 <relation-tree> 中默认加了 <relation-group> 标签。无需用户再手动输入,也就做到了 <relation-item> 可以直接放在 <relation-tree> 下面的效果。具体代码如下:

// 继承 HTMLElement 实现 RelationTree 组件功能
class RelationTree extends HTMLElement {
  constructor() {
    super();
    
    // 将 innerHTML 全部转移到 <relation-group> 下
    const content = this.innerHTML;
    const group = document.createElement('relation-group');
    group.innerHTML = content
		
    // 此处的 mode 必须使用 open,如果使用 closed 三个标签之间的联动关系将被阻隔
    this.attachShadow({ mode: 'open' }).appendChild(group);
  }
};

// 将 RelationTree 注册到 relation-tree 标签上
window.customElements.define('relation-tree', RelationTree);

<relation-tree> 就是一个最简单的 Web Component,具备一下三要素:

  1. 创建一个类或函数来指定web组件的功能,RelationTree 对象
  2. 使用 attachShadow 方法将 shadow DOM附加到自定义元素上,此处采用 open 模式
  3. 使用 customElements.define 方法注册新自定义元素到自定义标签上


relation-group

<relation-group> 包含了一些固定的 HTML 结构,故我们需要一个 template 来实现。template 内容放在 html 中,如下:

<template id="wc-template-relation-group">
  <div class="relational">
    <select class="sign">
      <option value="and"></option>
      <option value="and"></option>
    </select>
  </div>
  <div class="conditions">
    <!-- relation-group 标签内 children 的占位符 -->
    <slot name="relation-content"></slot>
    <div class="operators">
      <button class="add-term">加条件</button>
      <button class="add-group">加条件组</button>
    </div>
  </div>
</template>

功能定义与标签注册:

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

    const temp = document.getElementById('wc-template-relation-group').content;

    // 将模板 cloneNode 之后添加到 shadowRoot 中
    const shadowRoot = this.attachShadow({ mode: 'open' }).appendChild(temp.cloneNode(true));
    
    // 找到模板中定义的标签占位符,然后使用 children
    const slot = this.shadowRoot.querySelector('slot[name=relation-content]');
    for (let i = this.children.length - 1; i > -1; i--) {
      if (i === 0) {
        slot.replaceWith(this.children[i]);
      } else {
        slot.after(this.children[i]);
      }
    }    
  }
};

window.customElements.define('relation-group', RelationGroup);

<relation-group><relation-tree> 增加了模板的使用,slot 在这里是使用 js 去替换的,姿势有点歪。在 <relation-item> 将介绍标准的使用方式。

relation-item

模板定义:

<template id="wc-template-relation-item">
  <!-- 定义占位符 -->
  <slot name="element-term">请设置关系条件项内容</slot>
  <button class="delete">删除</button>
</template>

功能定义与标签注册:

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

    const template = document.getElementById('wc-template-relation-item');
    const templateContent = template.content;

    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.appendChild(templateContent.cloneNode(true));
  }
};

window.customElements.define('relation-item', RelationItem);

在模板中定义了 slot 占位符,而在后面的功能定义中并没有对此做任何处理。而其标准的使用方式是在 HTML 标签中进行替换定义,如下:

<relation-item>
  <!-- 通过 slot 属性标识此标签内容替换哪个占位符 -->
  <div class="term" slot="element-term">
    <span class="element">
      <select name="key" value="Key2" placeholder="请选择条件项">
        <option value="Key1">Key1</option>
        <option value="Key2">Key2</option>
        <option value="Key3">Key3</option>
      </select>
    </span>
    <span class="comparison">
      <select name="op" value="<" placeholder="请选择关系符">
        <option value="==">等于</option>
        <option value="!=">不等于</option>
        <option value=">">大于</option>
        <option value="<">小于</option>
      </select>
    </span>
    <span class="value">
      <input name="value" value="20" />
    </span>
  </div>
</relation-item>

至此三个标签的定义已经全部写完了,但我们的组件实现还未完成,它还缺少样式和事件的定义。

添加样式

Web Component 的样式可以定义在组件内部,这样可以避免同业务代码中的样式定义产生冲突。

relation-group 标签的样式需要定义在 RelationTree 中,因 RelationTree 没有模板,就把它加在功能定义中。加了样式定义以后的 RelationTree 代码如下:

class RelationTree extends HTMLElement {
  constructor() {
    super();
    const content = this.innerHTML;
    const group = document.createElement('relation-group');
    group.innerHTML = content

    const shadowRoot = this.attachShadow({ mode: 'open' }).appendChild(group);
    
    // 定义 relation-group 标签样式
    const style = document.createElement('style');
    style.innerHTML = `
    relation-group {
      display: flex;
    }`;
    this.shadowRoot.appendChild(style);
  }
};

其余的样式都可以放在 relation-group 模板中,增加了样式代码的模板如下:

<template id="wc-template-relation-group">
  <style>
    relation-group {
      display: flex;
      margin-bottom: 12px;
    }
    .relational {
      padding: 20px 8px 0 16px;
      border-right: 1px solid #d9d9d9;
    }
    .conditions {
      flex: auto;
    }
    .conditions > * {
      position: relative;
    }
    .conditions > *::before {
      content: "";
      display: inline-block;
      position: absolute;
      top: 0;
      left: 0px;
      width: 16px;
      height: 14px;
      border-bottom: 1px solid #d9d9d9;
      background-color: #fff;
    }
    .conditions > *:first-child::before {
      left: -1px;
      width: 17px;
    }
    .conditions > relation-group::before {
      top: 20px;
    }
    .conditions > div:last-child::before {
      top: inherit;
      bottom: 0;
      left: -1px;
      width: 17px;
      border-bottom: 0;
      border-top: 1px solid #d9d9d9;
    }
    relation-item {
      display: block;
      height: 30px;
      padding-left: 16px;
      margin-bottom: 12px;
    }
    .operators {
      padding-left: 16px;
    }
    .operators .btn {
      padding: 4px 12px;
      font-size: 12px;
    }
    .operators span {
      display: inline-block;
      margin-right: 8px;
    }
    .term {
      display: inline-block;
    }
  </style>
  <div class="relational">
    <select class="sign">
      <option value="and"></option>
      <option value="and"></option>
    </select>
  </div>
  <div class="conditions">
    <slot name="relation-content"></slot>
    <div class="operators">
      <button class="add-term">加条件</button>
      <button class="add-group">加条件组</button>
    </div>
  </div>
</template>

注:关于 CSS 的实现逻辑详见《手把手教你写一个条件组合组件》一文的“CSS样式”章节,此处不再赘述。

添加事件

最后给 “加条件”、“加条件组”、“删除”三个按钮加上事件,考虑到 HTML 标签有可能动态变化,我们在 RelationTree 中以事件代理的形式来实现:

class RelationTree extends HTMLElement {
  constructor() {
    super();
    const content = this.innerHTML;
    const group = document.createElement('relation-group');
    group.innerHTML = content

    const shadowRoot = this.attachShadow({ mode: 'open' }).appendChild(group);
    
    // 定义 relation-group 标签样式
    const style = document.createElement('style');
    this.shadowRoot.appendChild(style);
    this.shadowRoot.querySelector('style').innerHTML = `
    relation-group {
      display: flex;
    }`;
    
    
    const onitemadd = this.getAttribute('onitemadd'); // 加条件
    const ongroupadd = this.getAttribute('ongroupadd'); // 加条件组
    const onitemdelete = this.getAttribute('onitemdelete'); // 删除
    this.shadowRoot.addEventListener('click', (e) => {
      // 返回事件路径;当 attachShadow 的 mode 为 closed 时,不返回影子树种的节点
      const path = e.composedPath();
      const target = path && path[0];
      if (target?.classList?.contains('add-term')) {
        if (onitemadd && window[onitemadd]) {
          window[onitemadd](e, target);
        }
      } else if (target?.classList?.contains('add-group')) {
        if (ongroupadd && window[ongroupadd]) {
          window[ongroupadd](e, target);
        }
      } else if (target?.classList?.contains('delete')) {
        if (onitemdelete && window[onitemdelete]) {
          window[onitemdelete](e, target);
        }
      }
    });
  }
};

至此, <relation-tree><relation-group><relation-item> 三个标签的基本功能已经基本实现,使用最开始的方法即可出现如下图的效果:

以上方案仅做演示,很多细节未做完善,有兴趣的小伙伴可以继续编写。

总结

用以上三种标签实现之后可以配合其他框架(如 React)使用,将遍历和事件等逻辑封装在里面。笔者本来想尝试一下直接使用 Web Component 来实现数据驱动渲染的方式,但后来发现这个行不通,因为无法消除 HTML 和 JS 之间的隔阂。具体表现为笔者希望 Term (条件、操作符、值)部分的内容能使用组件者传入,如果使用 Template 的行事,那么条件就会是相对固定的。而实际场景中往往是根据接口数据来进行渲染的,而值也往往需要根据条件来做不同的渲染和限制的。所以 Template 的方式行不通,函数的方式倒是可以,但在 HTML 属性上函数只能使用全局函数,这个显然不够优雅。所以笔者最后放弃了这个尝试,如果读者有更好的建议希望不吝赐教。

参考资料:
developer.mozilla.org/zh-CN/docs/…
github.com/mdn/web-com…