阅读 758

Web Components 全知道

基础概念

Web 组件之路

在早期还没有形成 Web 前端工程师这个岗位的时候,组件化的需求就一直存在。由于在Node.js、ES6出现之前,前端并没有很好的解决代码模块化的问题,所以出现了很多组件化方案,当然这些方案和构建工具都有密不可分的关系。

服务端组件

一般用于早期前后端未分离的项目,依赖于构建工具(比如百度的 FIS)的打包功能和HTML模板语言(比如 Freemarker)的引用功能实现的组件化。比如下图中的例子:

浏览器端组件

从早期的 JQuery 开始,提出了一种相对可行的组件方式:写成 CSS 和 JS 两类文件,HTML 写在 JS中(或者把 CSS 也写在 JS 文件中),然后往 window 或 $ 等全局对象上挂载相应的函数。以 script 或 link 标签的形式对其引用。比如下图就是一个 star 数量上百的项目结构以及调用方式。

这种组件化的坏处显然是很多的:代码依赖完全靠说明文档,必须按照顺序引用,污染全局变量等。后来 Node.js 的出现虽然带来了 Grunt、Gulp 等工具,能够将文件合并、压缩,但本质上并未发生改变。

直到 webpack 这类模块打包工具的出现把前端组件化的进程又推进了一步。以 JS/TS 文件为主体,支持多种文件的引用,同时配合上各种插件,可以解析写带有 HTML、CSS 代码的组件。基于此不同前端框提出了自己的组建编写风格,虽然提升了开发效率,但也带来一些问题:

  • 项目迁移成本、学习成本
  • 无聊的争论与骂战
  • 组件无法跨框架做到通用

Web Components 简史

HTML Components(HTCs)

1998年微软提出了革命性的技术—— HTML Components ,意图用 HTML 组件来取代 ActiveX 组件,这是最早的 Web Components 雏形,只经历了 IE5~IE9 5个版本就退出了历史舞台,在2011年 IE10 推出的时候被微软官方宣布弃用。

由于历史久远,并没有找到其示例代码,不过微软官网仍然保留了其文档,有兴趣的读者可以自行查看。

XML Binding Language(XBL)

在2001年火狐又提出了一种基于 XML 的语言—— XBL,虽然只能在Firefox浏览器上运行。但幸运的是,W3C委员会在2007年通过了 XBL 2 版本。即使如此,直到2012年被废弃时也只有Firefox浏览器支持XBL。

Web Components

2013年 Chrome 和 Opera 又联合提出了推出的 V0 版本的 Web Components 规范,在2016推进到了 V1 版本。虽然到目前为止也没有被 W3C 采纳为正式规范,但浏览器的支持情况还不错。FireFox、Chrome、Opera已全部支持,Safari也大部分支持,Edge都换成webkit内核了,离全面支持应该也不远了。如果搭配 Polyfills 则不存在这个问题了。

Web Components 构成

Web Components 由三项主要技术组成。

Custom elements(自定义元素)

对于拥有较强容错能力的浏览器而言,支持自定义元素这个事情并不神奇,随便写个标签就能渲染出来,还可以为它添加样式和行为。

值得注意的是,从 HTML5 规范开始,提倡我们不要使用单个单词作为自定义元素,这样做是为了避免和默认元素冲突,而要短横线相连的多个单词作为元素名称。

比如 panel 是不符合规范的,my-panel 是符合规范的。

Web Components 中的自定义元素与此有些区别,W3C 为此专门定义了一个标准,它和我们熟知的各种框架组件非常接近,可以以类的方式定义,有独立的状态、生命周期。

这些特性确实很好,但需要在组件中频繁操作 DOM 就变得不方便了。

所以 实际使用时最好添加事件绑定和属性绑定 ,可以借助Polymer这类第三方库实现,示例代码如下:

class XCustom extends PolymerElement {
  static get template(){
    return html`
      <button icon="[[toggleIcon]]" on-click="handleClick">Kick Me</button>
    `;
  }
  // 数据绑定
  static get properties () {
    return {
      toggleButton: {
        type: String
      }
    };
  }
  // 事件绑定
  handleClick() {
    console.log('Ow!');
  }
}
customElements.define('x-custom', XCustom);
复制代码

或者自行编写组件基类来实现,我们项目中便是自行编写的组件基类,实现了很多功能,比如事件绑定和数据绑定。代码较多,这里就不贴出来了。

Shadow DOM(影子DOM)

为了保证组件的隔离性,Web Components 组件借助了 Shadow DOM 技术。它和DOM 基本相似,可以简单地理解为被隔离的 DOM。区别在于:

  • Shadow DOM 元素也呈树状结构,多个 Shadow DOM 元素构成一颗 Shadow tree 中,Shadow tree 必须添加到一个普通的 DOM 元素上(input 这类元素不支持),这个父元素称之为 Shadow host,Shadow tree 的根节点称之为 Shadow root。
  • 访问 Shadow DOM 的时候必须通过 Shadow tree 中的 Shadow root 元素。
  • 创建 Shadow tree 只能通过 attachShadow 函数。

Shadow DOM 的根本作用前面已经提到,就是隔离组件,为组件创建独立的"作用域"。怎么隔离呢?

两个方面:DOM选择器和CSS样式选择器。

具体地说就是通过 XX.querySelector 或 XX.getElementById 这类 DOM 选择器来访问 Shadow DOM 时,只有同为 Shadow tree 中的父元素才可以,外部的普通 DOM 无法访问。

对于样式而言,Shadow DOM 不再继承 DOM tree 中的样式,需要重新定义或引用。

除此之外 Shadow DOM 并没有带来性能提升以及其它好处,反而由于这个特性带来了一个比较麻烦的问题: 样式的继承

上面的代码对 span 标签样式进行了设置,但对 Shadow DOM 中的 span 失效了。

两个解决方法:

  1. 在 Shadow DOM 中添加 link 标签,引入公共样式文件,这样会有个小小的问题:浏览器会重复请求 css 文件。
  2. 通过构建工具在 Shadow DOM 中创建 style标签并写入公共样式,这样带来的小问题就是会在页面上写入大量 CSS 代码,给调试带来一些障碍。

HTML templates(HTML模板)

模板技术引入了两个重要的元素 template 和 slot 。

template 元素的内容就是 HTML 模板,简单地理解它就约等于

,好处是方便复用,但就实际项目情况而言,这种基于 HTML 模板的复用比较少,即使要复用也是包装成无逻辑的组件再重用。

slot 元素适用于 消息框、tab 页等组件,让调用者可以传入 HTML 代码或组件,动态修改子组件内容,大大增加了组件的灵活性(用过 Vue 插槽功能的都知道🤭)

小结

结合这一节内容我们可以发现无论是大型浏览器厂商,还是 Web 工程师,一直都在致力解决前端组件化的问题。

就目前的状况来看,Web Components 的前景喜忧参半。

一方面浏览器已经基本达成共识支持 Web Components,似乎原生组件会成为趋势和共同标准。另一方面原生组件和目前已经广泛使用的前端框架的组件化方案相比,在开发效率上还有一些差距。

生态环境

前端框架对 Web Components 的支持

首先需要明确的是,在借助框架编写的组件中,直接引用 Web Components 组件肯定是可行(只要浏览器支持Web Components),但数据的传递和事件的调用会存在一些麻烦。

Vue 对 Web Components 的支持

对于 Web Components , Vue 实现了部分 Web Components 规范,支持自定义元素功能实现了模板功能的 Slot API 与 is 特性。使用 vue-cli 工具还可以将 Vue 组件打包成为 Web Components 组件。

在 Vue 组件中引入 Web Components 组件时最好配置 Vue.config.ignoredElements 属性,避免编译时抛出警告,例如:

Vue.config.ignoredElements = [
  'my-custom-web-component',
  'another-web-component',
  // 用一个 `RegExp` 忽略所有"ion-"开头的元素
  // 仅在 2.5+ 支持
  /^ion-/
]
复制代码

而在 Web Components 组件中引入 Vue 组件则更需要借助模块 @vue/web-component-wrapper 将 Vue 组件重新封装。部分代码如下:

import Vue from 'vue';
import wrap from '@vue/web-component-wrapper';
const Test = {
  props: ['title' , 'arr', 'data'],
  methods:{
    add: function() {
      this.$emit('onadd')
    }
  }
}
const CustomElement = wrap(Vue, Test);
window.customElements.define('my-web-components', CustomElement);
export default Test;
复制代码

整体思路是将 Vue 组件中的 template 抽取出来,添加到创建的 shadowRoot 中。

转换后的 Web Components 组件会通过监听元素属性变化来实现事件绑定,这种方式和 Vue 组件的对象属性监听方式相比,有很多弊端,比如元素值暴露在HTML上,不能很好地处理对象、数组等复杂数据类型。

React 对 Web Components 的支持

借助 lit-element 模块可以将 React 组件转换成 Web Components 组件,下面是部分代码:

import { LitElement, html } from 'lit-element';
export class DemoWcCard extends LitElement {
  static get properties() {
    return {
      header: { type: String }
    };
  }
  render() {
    return html`
      <div>
        <div>web components子组件</div>
        ${this.header}
        // @click lit-element中的点击事件 与vue有点类似
        <button @click=${this.toggle}></button>
      </div>
    `;
  }
}
customElements.define('wc-demo', DemoWcCard);
复制代码

其中 html 函数会返回一个包含我们当前组件元素的 template,这个 template 会被添加到新创建的 shadowRoot 中。

在 lit-element 中定义的 properties 是一个获取初始属性的方法,所有传入的属性需在 properties 中先初始化。

lit-element 如何进行数据绑定及更新?通过 Object.defineProperty 来监听当前自定义元素的原型属性发生更改时触发对应的属性进行重新赋值,然后调用相应的函数更新视图。

Angular 对 Web Components 的支持

和前面两者相比,Angular 对 Web Components 的支持是最好的,将 Angular 组件编译成 Web Components 组建时不需要额外引入其它模块,只需要在装饰器 Component 中将参数 encapsulation 的值设置为 ViewEncapsulation.ShadowDom 即可。

部分示例代码如下:

import { Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
@Component({
  selector: 'my-popup', // 子组件的名字
  template: `
    <span>Popup: {{message}}</span>
    <button (click)="test.emit()">+</button>
  `,
  styles: [``],
  encapsulation: ViewEncapsulation.ShadowDom // 默认为Emulated时不会有shadowRoot这一层, 设置为ViewEncapsulation.ShadowDom就会把popup包裹在shadowRoot里面
})
export class PopupComponent {
  @Input() message;
  @Output() test = new EventEmitter();
}
复制代码

Vue 和 React 采用的是监听 Web Components 组件实例的事件来实现父组件函数调用,而 Angular 则会创建一个事件对象,通过监听这个事件对象来实现。

服务于 Web Components 的第三方库

Hybrids

一个基于普通对象和纯函数实现的用来创建 Web Components 组件的强声明式 UI 库。听起来有些拗口,其实核心概念就是无状态。

举个例子就很容易理解了,官方以及大多数第三方库提供的 web components 组件定义方式都是基于类来实现的,类似下面的代码:

class SimpleCounter extends HTMLElement {
  constructor() {
    super();
    this.count = 0;
    this.attachShadow({mode: 'open'});
    this.parentNode.appendChild(this.shadowRoot);
    this.shadowRoot.innerHTML = `<button>Count: ${this.count}</button>`;
    this.shadowRoot.querySelector('button').addEventListener('click', this.increaseCount.bind(this));
  }
  increaseCount() {
    this.count++;
    this.shadowRoot.querySelector('button').textContent = `Count: ${this.count}`;
  }
}
customElements.define('simple-counter', SimpleCounter);
复制代码

很多组件都使用了类对象的方式,缺点就是组件内部属性和函数耦合度比较高,也就是会用到大量的this。

而使用 Hybrids 则抛弃了 class 和 this ,通过普通对象和纯函数来定义组件:

import { html, define } from 'hybrids';
export function increaseCount(host) {
  host.count += 1;
}
export const SimpleCounter = {
  count: 0,
  render: ({ count }) => html`<button onclick="${increaseCount}">Count: ${count}</button>`,
};
define('simple-counter', SimpleCounter);
复制代码

Slim

采用 TypeScript 编写,基于 ES6 类继承机制,为 Web Compoennts 组件提供数据绑定以及其它可扩展能力(指令、生命周期、重复元素、插件等)。

下面是示例代码为 Web Components 组件实现了重复元素渲染以及数据绑定。

@tag("my-tag")
@template(`
<ul>
  <li s:repeat="items as item" bind>{{item}}</li>
</ul>
`)
class MyTag extends Slim {
  onBeforeCreated() {
    this.items = ["Banana", "Orange", "Apple"]
  }
}
复制代码

Skate/LitElement

这两个开源库都采用 TypeScript 编写,都依赖了lit-html(一个HTML模板库),都是支持以类似 React 组件的风格编写 Web Components 组件,或者说是将 Web Components 组件转换成 React 组件。区别在于后者支持以装饰器的方式编写。

// 使用 Skate
import Element, { h } from '@skatejs/element-lit-html';
class Hello extends Element {
  render() {
    return h`Hello, <slot></slot>!`;
  }
}
customElements.define('x-hello', Hello);
复制代码

Polymer Library & Polymer Elements

Polymer Library 看名字像是一个轻量级的库,但实际上更像是一个 Web Components 框架:既提供了支持以类的方式定义 Web Components 组件,也提供了命令行工具 polymer-cli 来创建、编译项目。

Polymer Elements 则是基于 Polymer Library 开发的 Web Components 组件库(类似 JQuery-ui、ant design、element-ui),目前已有92个组件,可以按需引用。

Polyfills

支持最新的 Web Components v1 规范,能够解决兼容性问题,编译后的代码能成在不支持 Web Components 特性的老浏览器上运行。

小结

前端框架对 Web Components 的态度也比较友好,都提供了编译生成 Web Components 的方式。但在目前 Web Components 相对于框架没有明显优势的情况下逐渐成为主流的前端组件化方案,除了修改浏览器内核为 Web Components 提供更强大的 API 之外,一种可能的方式也许是让 Web Components 成为主流前端框架的语法糖,也就是做到一旦代码编写完成,可以编译成各个框架的组件,但并没有找到能将 Web Components 组件变成框架组件工具。

实际上基于 Web Components 开发的第三方组件远不止文中提到这些,但就成熟度而言和主流前端框架还有些差距,所以使用起来对开发效率不要有太高的期待。

使用经验

《抛开 Vue、React、JQuery 这类第三方js,我们该怎么写代码?》一文中提到,我们团队曾经尝试过不使用任何第三方依赖的情况下,使用 Web Components 进行开发。所以下面结合实际开发经验分享一些开发过程中碰到的关于 Web Components 的问题。

组件通信

传递数据给子组件

Web Components 组件传值方式有两种:

第一种方式是通过 DOM 属性。一些简单的 Web Components 示例代码以及框架都是用这种方式,优点就是实现简单,缺点就是需要频繁操作 DOM ,并且不支持复杂对象的传递。不推荐。

<!-- html -->
<parent-wc/>

// js
const parentTemplate = `<child-wc/>`;
class ParentWc extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const tpl = document.createElement('template')
    tpl.innerHTML = parentTemplate
    this.shadowRoot.appendChild(tpl.content.cloneNode(true))
    setTimeout(() => {
      this.shadowRoot.querySelector('child-wc').setAttribute('text', 'son')
    }, 500)
  }
}
customElements.define('parent-wc', ParentWc)
const childTemplate = ` <p>child</p>`;
class ChildWc extends HTMLElement {
  // 必须先生命需要监听的属性
  static observedAttributes = ['text']
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const tpl = document.createElement('template')
    tpl.innerHTML = childTemplate
    this.shadowRoot.appendChild(tpl.content.cloneNode(true))
  }
  // 监听属性变化
  attributeChangedCallback(name, oldValue, newValue) {
    if(name==='text' & oldValue !== newValue) {
      this.shadowRoot.querySelector('p').textContent = newValue
    }
  }
}
customElements.define('child-wc', ChildWc)
复制代码

第二种方式通过组件实例属性。这是目前主流的传值方式,支持复杂对象的传递,实现起来也不算复杂,但要和数据绑定相结合会有一些代码量。我们项目中使用的就是这种方式,结合 Object.defineProperty 函数实现的传值和数据绑定。

const childTemplate = ` <p>child</p>`;
class ChildWc extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const tpl = document.createElement('template')
    tpl.innerHTML = childTemplate
    this.shadowRoot.appendChild(tpl.content.cloneNode(true))
    this.data = {text: 'child'}
    Object.defineProperty(this.data, 'text', {
      set: val => {
        this.shadowRoot.querySelector('p').textContent = JSON.stringify(val, null, 2)
      }
    })
  }
}
customElements.define('child-wc', ChildWc)

const parentTemplate = `<child-wc/>`;
class ParentWc extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const tpl = document.createElement('template')
    tpl.innerHTML = parentTemplate
    this.shadowRoot.appendChild(tpl.content.cloneNode(true))
    setTimeout(() => {
      this.shadowRoot.querySelector('child-wc').data.text = {name:'son'}
    }, 500)
  }
}
customElements.define('parent-wc', ParentWc)
复制代码

调用父组件方法

第一种方式是通过事件监听/冒泡,利用原生 CustomEvent 函数来创建自定义事件,然后在子组件实例上派发此事件以及数据,同时父组件进行监听。

这种实现方式可扩展性比较强,可以重复监听,可以借助 document 设置事件总线,进行跨组件全局通信。

缺点是代码量比较多,要解决的话可能需要自行编写类似 vue-loader 的插件,对代码进行简单的编译。

const childTemplate = ` <p>child</p>`;
class ChildWc extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const tpl = document.createElement('template')
    tpl.innerHTML = childTemplate
    this.shadowRoot.appendChild(tpl.content.cloneNode(true))
    this.data = {text: 'child'}
    Object.defineProperty(this.data, 'text', {
      set: val => {
        this.shadowRoot.querySelector('p').textContent = JSON.stringify(val, null, 2)
      }
    })
    setTimeout(() => {
      // 创建自定义事件
      const evt = new CustomEvent('auto',  {
        detail: {
          username: "davidwalsh"
        }
      })
      this.dispatchEvent(evt)
    }, 1000)
  }
}
customElements.define('child-wc', ChildWc)

const parentTemplate = `<child-wc/>`;
class ParentWc extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const tpl = document.createElement('template')
    tpl.innerHTML = parentTemplate
    this.shadowRoot.appendChild(tpl.content.cloneNode(true))
    const child = this.shadowRoot.querySelector('child-wc')
    // 监听子组件事件
    child.addEventListener('auto', o => {
      child.data.text = o.detail
    })
  }
}
customElements.define('parent-wc', ParentWc)
复制代码

第二种方式是直接调用父组件方法。这种方式需要获取父组件实例,这当然不是难事,使用起来简洁方便,目前项目中采用此方式。当然缺点就是可扩展性不够。

const childTemplate = ` <p>child</p>`;
class ChildWc extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const tpl = document.createElement('template')
    tpl.innerHTML = childTemplate
    this.shadowRoot.appendChild(tpl.content.cloneNode(true))
    this.data = {text: 'child'}
    Object.defineProperty(this.data, 'text', {
      set: val => {
        this.shadowRoot.querySelector('p').textContent = val
      }
    })
    setTimeout(() => {
      // 获取父组件实例,直接调用其方法
      this.getRootNode().host.say('Hello Baba')
    }, 500)
  }
}
customElements.define('child-wc', ChildWc)

const parentTemplate = `<child-wc/>`;
class ParentWc extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const tpl = document.createElement('template')
    tpl.innerHTML = parentTemplate
    this.shadowRoot.appendChild(tpl.content.cloneNode(true))
  }
  say(txt) {
    const child = this.shadowRoot.querySelector('child-wc')
    child.data.text = txt
  }
}
customElements.define('parent-wc', ParentWc)
复制代码

组件性能

Web Components 到底性能如何?我们以 Polymer 2 为例,与主流前端框架进行对比分析。

从下图对比测试结果我们不难发现,Polymer 的 性能相对其它主流框架并不占优势 ,批量操作元素以及冷启动时速度都处于倒数一二的水平,只有 内存占用上可以扳回一局 。也可以从侧面看出主流框架花了不少功夫在性能优化上,从内存占用情况又可以进一步推测其可能使用了空间换时间的手法,

截至写完本文为止,Polymer 已经推出了 3.0 版本,不知性能是否有提升,但从官方描述中没有找到相关介绍,只说 3.0 最大升级是采用了 ES6 规范进行编写。

组件测试

Web Components Test(WCT) 可通过 web-component-tester 和 wct-mocha 模块编写,Polymer-cli 创建的项目已经集成了单元测试框架以及示例代码。只是测试代码默认都写成了 html 文件。

<body>
    <test-fixture id="BasicTestFixture">
      <template>
        <poly-poly></poly-poly>
      </template>
    </test-fixture>
    <test-fixture id="ChangedPropertyTestFixture">
      <template>
        <poly-poly prop1="new-prop1"></poly-poly>
      </template>
    </test-fixture>
    <script type="module">
      suite('poly-poly', () => {
        test('instantiating the element with default properties works', () => {
          const element = fixture('BasicTestFixture');
          assert.equal(element.prop1, 'poly-poly');
          const elementShadowRoot = element.shadowRoot;
          const elementHeader = elementShadowRoot.querySelector('h2');
          assert.equal(elementHeader.innerHTML, 'Hello poly-poly!');
        });
        test('setting a property on the element works', () => {
          // Create a test fixture
          const element = fixture('ChangedPropertyTestFixture');
          assert.equal(element.prop1, 'new-prop1');
          const elementShadowRoot = element.shadowRoot;
          const elementHeader = elementShadowRoot.querySelector('h2');
          assert.equal(elementHeader.innerHTML, 'Hello new-prop1!');
        });
      });
    </script>
  </body>
复制代码

虽然有测试代码,但并没有提供执行类似 npm run test 这样的命令。

测试工具使用的是 wct-mocha 和 web-component-tester,web-component-tester 需要额外配置才能使用,可以在根目录下创建配置文件 wct-conf.js(on) 。

module.exports = {
  "verbose": true,
  "plugins": {
    "local": {
      "browsers": ["chrome"]
    }
  }
}
复制代码

同时需要安装 Java 并配置环境变量,不然会报错。

执行测试代码可以通过 polymer test 或者 wct test --npm 命令。

执行时会启动配置的浏览器,例如我配置的是本地的 Chrome 浏览器。不过打开的时候会一闪而逝,如果希望不自动关闭浏览器的话需要加上 -p 参数。

虽然右上角有疑似覆盖率的指标,但并不能查看覆盖率的具体情况。

有几个基于 istanbul 的插件,但在实际使用中并未生成测试覆盖率相关报告和文件,有待开发者进一步完善。

小结

就开发效率而言,可以考虑选则 polymer ,提供了一些开箱即用的脚手架项目,但在TypeScript 、 测试覆盖率的支持上并非完美,仍需要自行调整。

对于能力比较强的团队也可以考虑自己编写框架代码,在组件通信以及性能方面需要多考虑。

原文链接:tech.gtxlab.com/web-compone…


参考链接: