Web Components 探索之旅,出发!

954 阅读19分钟

image001.png

前言

随着开发项目的不断迭代,代码的复杂度日益提升,组件化早已是我们项目开发中必不可少的一环。基于组件化的思想将项目功能进一步拆分,抽离出可配置、可复用、可扩展的功能模块,使得我们的代码高内聚和低耦合,组件的应用也大大提高了我们的开发效率。目前,我们开发项目都是基于 Vue 或 React 等框架来进行,基本上很少会直接使用原生 JS 来进行开发。然而,不同的开发框架有着自己的开发规则,基于不同框架所开发的组件互相之间很难进行复用(vue&react),甚至不同版本的同一框架下开发的组件也存在差异导致无法复用(vue2&vue3),我们开发过程中所沉淀的组件通常只能在同版本框架下的项目之间进行复用;甚至于前端框架依旧会继续迭代和发展,当业界有更新更好的框架出现的时候,我们是否又只能推倒重来重新基于新框架重复开发组件。这个时候我们就需要思考是否能够摆脱框架的束缚来进行组件开发呢,答案是肯定的。

Untitled.png

第一种方案就是“Write components once, compile to every framework”,一套组件代码可以编译成适用于各种框架的代码。这种方案的代表就是由 Angular 创建者 Misko Hevery 编写的 mitosis 框架,其支持将编写的组件代码编译成 React、Vue 和 Angular 等不同框架下的代码。但是,这种方案归根结底代码最后还是需要基于框架,并没有真正摆脱框架,当我们有新的框架出现的时候,依旧需要重新编译处理。那么是否还有更加彻底摆脱框架的方案吗?当然,第二种方案就是“Write once, run anywhere”,一套组件代码能够直接在各种框架下使用,也就是本文所关注的浏览器原生支持的 Web Components,真正做到了回归原生

一、Web Components 是什么?

Web Components 是一个浏览器原生支持的组件化方案,其支持我们创建自定义可重用的元素,使用时不需加载任何额外的模块,其实我们一直在使用这项技术,input、video 和 audio 等就是原生的 Web Components,只是如今我们自己也可以使用这项技术去创造组件。Web Components 主要由三项技术组成:

  1. Custom Element:一组 JS API,允许定义 custom elements 及其行为;
  2. Shadow DOM:一组 JS API,用于将封装的影子 DOM 树附加到元素并控制关联的功能;
  3. HTML Template<template><slot> 元素编写不在页面中显示的模板可以作为自定义元素基础被重用。

为了方便理解这些内容,我们通过一个简单的 Web Components 示例来介绍。例如,我们想要开发一个 <info-card> 卡片组件,其支持展示自定义标题和描述文案,其中标题通过 title 属性传入,描述文案通过 desc 插槽传入,同时点击按钮能通过 custom-click 自定义事件来获取按钮的点击数,那么如何基于 Web Components 来开发这个组件呢?

使用

<info-card title="This is title">
	<span slot="desc">This is description! hello world!</span>
</info-card>

<script>
  document.querySelector('info-card').addEventListener('onCustomClick', (evt) => { console.log(evt, evt.detail.count) });
</script>

示例

Untitled 1.png

Step1、创建并注册自定义元素

创建一个继承自 HTMLElement 的类用于声明自定义元素的功能,并使用 customElements.define() 方法来注册自定义元素。

class InfoCard extends HTMLElement {
  constructor() {
    super();
    
    // 元素的功能代码写在这里
    ...
  }
}

customElements.define('info-card', InfoCard); // 注册自定义标签

注册完成后我们在浏览器中通过<info-card></info-card>标签即可直接使用,自定义元素根据是否继承自内建 HTML 元素可以分为两种:

  • autonomous custom elements 不继承自内建的 HTML 元素,使用customElements.define('info-card', InfoCard)注册后通过<info-card>document.createElement("info-card")使用;
  • customized built-in elements 继承自内建的 HTML 元素,使用customElements.define('info-card', InfoCard, {extends: 'p'})注册后通过<p is="info-card">document.createElement("p", { is: "info-card" })使用。

注意,如果我们在一个项目中注册同名的自定义标签则浏览器会报错,因此为了防止出现这样的情况,我们可以在注册自定义标签时通过 CustomElement.get() 方法是否返回指定名字的自定义元素的构造函数来判断是否已经存在使用指定名称的自定义元素。

Untitled 2.png

if (!customElements.get(tag)) {
  customElements.define(tag, MyCustomElement);
}

// 还可以通过 whenDefined 来捕获重复定义的报错
customElements.whenDefined(tag).then(() => {
  customElements.define(tag, MyCustomElement);
}).catch((err) => {
  console.log(err); // 会捕获重复定义的报错
});

Step2、附加 Shadow DOM

创建 Shadow DOM 并附加到自定义元素上。顾名思义,Shadow DOM 是隐藏的,标签内部的HTML 结构会存在于 #shdaow-root 上,而不会在真实的 dom 树中出现,这样可以更好地封装和隔离自定义标签的样式和行为,避免与其他元素发生冲突。

class InfoCard extends HTMLElement {
  constructor() {
    super();
    
    this.attachShadow({ mode: 'open' }); // 设置 open 表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM
    this.shadowRoot.innerHTML = 'Rendering from Shadow DOM';
  }
}

customElements.define('info-card', InfoCard);

Untitled 3.png

Untitled 4.png

mode 属性如果设置为 open,也就是允许在外部通过document.querySelector("info-card").shadowRoot来获取到 Shadow DOM;如果 mode 设置为 closed 则会返回值 null。

Step3、使用模版和插槽

通过 template 和 slot 将自定义内容插入到标记位置,并将其作为 shadow dom 的内容,使得组件具备模板/插槽的基本能力,能够更加灵活且更好地被复用。

<template id="info-card">
  <style>...</style>
  <div class="info-card-wrapper">
    <div class="title">This is title</div>
    <div class="desc">
      <slot name="desc"></slot>
    </div>
  </div>
</template>

<script>
  class InfoCard extends HTMLElement {
    constructor() {
      super();
      const template = document.getElementById('info-card').content;
      const shadow = this.attachShadow({ mode: 'open' });
      shadow.appendChild(template.cloneNode(true));
    }
  }
  customElements.define('info-card', InfoCard);
</script>

Untitled 5.png

除了使用 template 模版之外,我们还可以自定义一个 render 函数,利用模版字符串来避免通过原生的方法使用大量 JS 来创建节点,从而达到复用和便捷的目的。

const render = (title) => {
  return `
		<style>...</style>
    <div class="info-card-wrapper">
    <div class="title">This is title</div>
    <div class="desc"><slot name="desc"></slot></div>
    </div>
	`;
};
class InfoCard extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = render();
  }
}

customElements.define('info-card', InfoCard);

Step4、设置 CSS 样式

设置 CSS 样式有着非常多的方式,例如通过 JS 直接设置 style 属性、模版中直接在 style 标签里设置样式、通过 CSSStyleSheet 新建样式并设置到 adoptedStyleSheets 上、引入外部 CSS 文件等等。对于外部文件的引入,官方推荐利用浏览器原生的 import 方法将 css 文件当作模块引入,然后将内容设置到 adoptedStyleSheets 上。

import styles from 'index.css';

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

    const shadow = this.attachShadow({ mode: 'open' });
    
    const styles2 = new CSSStyleSheet();
    styles2.replaceSync(`
			:host {
				color: red;
			}
		`)
    shadow.adoptedStyleSheets = [...this.shadowRoot.adoptedStyleSheets, styles, styles2];
    
    // ...
  }
}

customElements.define('info-card', InfoCard);

在上述代码中有使用:host伪类,那么在选择器方面除此之外还有哪些在 web components 中常用的伪类与伪元素呢?具体示例详见 👉  codesandbox

  • :host 作用于 shadow host;
  • :host-context() 作用于 shadow host 仅当它是给定的选择器参数的后代;
  • :host() 作用于 shadow host 仅当它与选择器参数匹配;
  • :defined 作用于已定义的元素,常用于元素定义失败场景将其隐藏起来,避免页面错乱;
  • ::slotted 作用于插槽元素,注意匹配只能是插槽元素本身,而不是它的子元素 。
  • ……

⚠️ Shadow DOM 样式隔离是一把双刃剑,在我们享受样式隔离所带来的便利时,又面临着无法继承样式的问题,由于组件内不再继承 DOM tree 中的样式,因此我们需要重新定义或引用,例如重新引入外部使用的公共样式文件等。

Step5、响应式属性和状态

标题在通过 DOM title 属性传入,当属性值发生变换时需要同步更新视图,因此我们需要监听属性的变化。首先定义observedAttributes get 函数指定需要监听 title ,然后每当 title 变化时,attributeChangedCallback()回调函数会执行,最后添加一个属性 getter/setter 以提供对 title 属性访问,其通过getAttribute/setAttribute来同步组件上的 title 属性。

按钮点击数为组件内部的状态,当我们在视图上显示按钮点击数时,我们也需要使其具备响应性,当内容变化时能够同步更新视图。首先创建组件的内部状态 _count,并定义 count 属性的 getter 和 setter,用于访问和修改内部状态 _count,当 count 属性被修改时,setter 方法会更新内部状态并调用相应方法来更新视图。

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

		// 设置内部状态
    this._count = 0;

    const template = document.getElementById('info-card').content;
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.appendChild(template.cloneNode(true));
  }

  /**
   * 内部状态 count 响应式
   */
  get count() {
    return this._count;
  }

  set count(value) {
    this._count = value;
    // ...值更新可以执行一些操作
  }

	/**
   * 外部属性 title 响应式
   */
  static get observedAttributes() {
    return ['title'];
  }

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

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

  connectedCallback() {
    if (!this.title) {
      this.title = 'default title';
    }
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'title') {
      // ...值更新可以执行一些操作
    }
  }
}

上述代码中使用了 connectedCallback 和 attributeChangedCallback 两个生命周期回调函数,web components 有哪些生命周期呢?web components 主要有 4 个生命周期回调函数:

  • connectedCallback:当 custom element 首次被插入文档 DOM 时被调用。
  • disconnectedCallback:当 custom element 从文档 DOM 中删除时被调用。
  • adoptedCallback:当 custom element 被移动到新的文档时被调用。
  • attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时被调用。

💡 组件通信之父组件传值给子组件:内部状态(组件实例属性)同样也可以作为外部通信传值使用,直接在外部对其进行赋值变更即可。因此,Web Components 组件传值方式主要有两种,通过 DOM 属性传值和通过组件实例属性传值。具体示例详见 👉 codesandbox。通过 DOM 属性传值由于只支持字符串形式传递,因此对于复杂对象的传递需要通过JSON.parseJSON.stringify来进行处理。

Step6、自定义事件

最后是添加自定义事件,Web Components 的自定义事件可以借助 CustomEvent 和 dispatchEvent 来实现(详见创建和触发 events),其中 dispatchEvent 会向一个指定的事件目标派发一个 Event,CustomEvent 可通过 detail 属性传递自定义数据。

class InfoCard extends HTMLElement {
  constructor() {
    super();
    // ...
    this.$btn.addEventListener('click', () => {
      this.dispatchEvent(
        new CustomEvent('onCustomClick', {
          detail: this.count,
        })
      );
    });
  }
}

// info-card 上监听 onCustomClick 事件即可获取到

💡 组件通信之子组件调用父组件方法:除了上面这种“利用原生 CustomEvent 函数来创建自定义事件,然后在子组件实例上派发事件以及数据,同时在父组件上进行监听“的方法之外,还可以“直接获取父组件实例,然后直接调用父组件方法”。具体示例详见 👉 codesandbox

至此,相信大家对 Web Components 的使用有了一个简单的印象了,示例完整代码详见 👉 codesanbox 推荐阅读:Web Components 最佳实践

二、Web Components 有什么优缺点?

🌟 优势

  1. 原生支持。目前所有主流浏览器都支持 Web Components,浏览器原生支持也意味着可以在 Vue、React、Angular 等任何环境中去使用,开发能够更加灵活地构建和维护组件并轻松地集成到不同框架的项目中去。
  2. 独立封装。Web Components 通过 shadow DOM 创建内容,这意味着组件内部的样式和脚本不会影响到页面上其他元素的样式和脚本,避免了组件之间样式冲突的等问题。
  3. 面向未来。Web Components 无需随着技术栈更新而更新,框架不断会有新的出现,但标准永远只有一套,这极大降低组件研发/维护成本,同时随着浏览器更多底层能力的建设,Web Components 的功能也会越来越强大。
  4. 语义化好。Web Components 的自定义标签和属性能够更好地表达语义,不用详细阅读代码细节就能了解开发者的意图,不像 Vue 框架等开发的组件最终在浏览器呈现的内容仍然是内建标签。

🌟 缺点

  1. 学习成本。任何新的技术(虽然这也不算新技术😜)开发人员都需要花费一定的时间来学习和掌握它们,并且原生的开发方式相对于基于框架开发终究是比较麻烦的,所幸当前还是有非常多的辅助库能够来帮助我们更加方便地开发 Web Components。
  2. 生态不足。整体而言 Web Components 的研发生态系统远不如 React 和 Vue 等完善,发展也相对较缓慢,配套工具和资源较少,这就导致开发人员可能需要花费更多的时间和精力来构建和维护 Web Components。

💡 性能如何比较依赖于具体的使用场景,我们不能绝对地说 Web Components 性能较好,也不能直接说 Web Components 性能不好。但总体而言,Web Components 依旧有着一定的性能优势,具体可参考 2021 Web Components 技术趋势解读

三、Web Components 开发有什么库?

🌟 开发库

在《一、Web Components 是什么?》章节中的例子我们可以发现完全使用原生的方法来开发组件还是比较麻烦的,尤其当遇到组件较为复杂的时候,我们需要监听和处理大量的属性、对于 dom 的获取也主要是使用原生的方法等等。那么,我们是否能站在前人的肩膀上进行探索呢?答案当然是肯定的,Web Components 经过这么多年的发展,当前已经有非常多成熟的辅助库/框架/解决方案能够帮助我们更好地开发 Web Components 组件,如下 webcomponents.dev 中所提供的 Web Components 开发模版所示 👇(此外还有 QuarkcOpen-WC 等框架,也可参考 components.studio 中内容)

Untitled 6.png

Web Components 实现方式主要可以分为 3 大类:

  1. 【基于基础库】最直接的就是基于基础库开发,例如 lit、lighterhtml、uhtml 等;
  2. 【运行时封装】Vue/React 等框架由于发展较早,对于 Web Components 的支持主要是运行时封装:
  3. 【构建时编译】新兴前端框架很多都已支持构建时编译,例如 Stencil、Svelte、Solid 等。

🌟 组件库

以下列举了一些较为流行的 Web Components UI 组件库:

组件库概览图组件库概览图
Quark DesignUntitled 7.pngFAST DesignUntitled 8.png
VaadinUntitled 9.pngOMIUUntitled 10.png
Wired ElementsUntitled 11.pngsalesforce-LDSUntitled 12.png
ionicUntitled 13.pngshoelaceUntitled 14.png

四、Web Components 应用场景如何?

🌟 开发 UI 组件库

Untitled 15.png

Web Components 最被广泛应用的场景就是基于此开发公共的 UI 组件库,浏览器原生支持使得其无需考虑项目的技术栈,不论是 Vue2、Vue3、React,还是原生项目,统统都能一套搞定。基于 Web Components 打造一套设计系统,统一多产品线的视觉体验。当我们使用跨框架跨端的项目越多,基于 Web Components 开发的 UI 组件库收益也就越高。

🌟 老旧项目升级

Untitled 17.png

Web Components 原生支持的特性也导致其非常适合应用于老旧项目升级,对于一些非组件化的老旧应用,可以逐步使用 Web Components 来重构代码,将应用拆分为可重用组件,这可以让老旧应用更容易维护升级;对于一些框架版本较低的应用,直接升级框架版本工程量大且风险高,也可以逐步使用 Web Components 来进行组件替换。例如,Github 在 2018 年才将 jQuery 完全移除,但在 2017 年就已经逐步使用 Web Components 了。

🌟 微前端应用

Untitled 18.png

原生支持、不依赖框架和独立封装等特性都表明 Web Components 有能力以组件加载的方式将微应用整合在一起作为微前端的一种手段, Web Components 的 shadow DOM 可以使得微前端各个应用之间达到互相隔离的效果。例如,腾讯的无界微前端框架就主要基于 Web Components 来实现 CSS 样式隔离,无界会创建一个wujie自定义元素,然后将子应用的完整结构渲染在内部。

🌟 开放服务

Untitled 19.png

Web Components 的特性也推动了新的商业服务模式的探索,当一家提供商业化服务的公司在面对各种不同技术栈的客户需要接入产品使用的时候,基于 Web Components 开发一套通用的组件不失为一种选择,同时 Web Components 的原生性也让客户避免因使用服务而需要更换或升级技术体系。例如,客户关系管理规划与服务企业 Salesforce 就提供了一整套研发工具 LWC 让客户可以在自己的环境中进行组件库的开发、配置和部署。此外,当平台为了丰富平台生态允许用户自定义开发插件来进行使用的时候,除了可选择 iframe 来实现插件隔离之外,Web Components 也具备隔离性。

五、Web Components 当前现状怎样?

🌟 浏览器支持

浏览器兼容情况customElements.define 调用情况
Untitled 20.pngUntitled 21.png

自 2011 年 Alex Russel 首次提出了 Web Components 的概念以来,社区和浏览器厂商对 Web Components 的研究提案和技术支持进入了发展期,到如今已经有了非常大的进步。目前,各大主流浏览器均已支持 v1 Web Components(数据来源:caniuse),并且 customElements.define 在 Chrome 中至少调用一次的页面加载百分比统计显示至少 17%+ 的网站注册了一个自定义元素(数据来源:chromestatus),Web Components 正在被广泛使用。

🌟 业界使用

Untitled 22.png

Micrsoft 基于 FAST 库构建 Web Components 应用于非常多的产品线,包括 MSN、Edge、VS Code 等,近期热门的 New Bing 也是使用 FAST Web Components 构建; YouTube 是最早采用 Web Components 技术的应用之一,多年来一直使用这种技术构建其界面,在 Google 开源的官方 Google Web Components 库中就能找到 google-youtube 组件;Adobe 公司使用 Lit 库进行网页版 Photoshop的开发,整个应用中有非常多的自定义元素;Github 自 2017 年开始就已经大范围使用 Web Components,并且开源了一系列的 Web Components 组件github-elements,此外还开源了辅助库 Catalyst;SpaceX 研发人员 Sofian Hnaide 对于 Web Components 的应用表示龙飞船上的显示器带有 Chromium,且广泛使用 Web Components;国内哈啰平台前端团队于近期开源了一套面向未来的 Web Components 构建工具 quarkc,并且已在集团内部大量业务中应用;京东的跨端跨框架解决方案 Taro 的H5 端组件库就选择了基于 Stencil 框架来编写 Web Components;腾讯多年前就发布了 Web Components 组件框架 Omi,全面拥抱 Web Components......

💡 推荐阅读:

🌟 研发生态

Untitled 23.png

Untitled 24.png

在研发框架方面,目前基本上所有主流框架 Vue、React、Angular 等均都支持 Web Components(Custom Elements Everywhere),其中 Vue3 自 3.2 版本后支持单文件组件创建 Web Components。在研发工具方面,Web Component DevTools 协助我们在浏览器上更好地调试 Web Components,Lightning Web Components 插件支持我们在 VSCode IDE 上进行语法高亮,eslint-plugin-wc@open-wc/eslint-config 等库可以检查和格式化 Web Components 代码。在单元测试方面,许多 Web Components 框架和库都是标准的现代 Javascript 库,几乎可以使用任何 Javascript 测试框架来测试,包括 JestKarmaMocha、Jasmine 和 Web Test Runner 等,也有专门提供的测试框架,例如 polymer/web-component-tester@open-wc/testing 等。总体而言,Web Components 的研发生态还是稍不及我们所熟悉的 Vue 生态这么丰富以提高我们的研发效率和体验。

💡 推荐阅读:

🌟 标准现状

Untitled 25.png

上图是各种已发布、正在进行和计划中的 Web Components 相关标准的图示,主要包括组合和作用域、平台互操作性、渲染和性能、样式、包和分发、API 范式等六大类别,各内容的完整介绍详见 👉 2023 State of Web Components译文)。

总结

Web Components 技术为开发者提供了强大的原生方案来创建可重用、封装和高效的组件,可以真正做到摆脱框架进行组件开发,尽管其在开发效率上与目前已经广泛使用的前端框架 Vue/React 等相比还有一些差距,但随着所有主流浏览器均支持 Web Components 以及 Web Components 工具库等不断发展,Web Components 正在被越来越多的人和公司选择并使用。当然,Web Components 的出现并不是让大家直接就放弃使用框架来开发,Web Components 的出现也不是为了取代任何现有框架。正如 Vue 官网中所说“我们认为 Vue 和 Web Components 是互补的技术”,Web Components 与 Vue 等框架并不冲突,尽管两者确实存在一定程度的功能重叠,但是要构建一个实际的应用,还是需要具备更强大的能力,例如一个声明式的高效的模板系统、一个响应式的利于跨组件逻辑提取和重用的状态管理系统等,而 Web Components 的主要目的是从原生层面实现组件化,以解决组件复用共享等问题并作为底层特性应该使得组件能在任何生态中良好运行,同时保持 Web 生态的开放与统一。目前来说,Web Components 是无法取代 Vue/React 等框架,当然在未来,也说不准什么时候浏览器原生能力足够强大的时候,Vue 等框架也会像 jQuery 一样被浏览器的原生能力所替代呢 🙃 技多不压身,如果对 Web Components 感兴趣就赶紧行动吧!

参考资料