Web 组件的高级工具

Avatar of Caleb Williams
Caleb Williams

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 $200 免费积分!

在本五部分系列文章的过去四篇文章中,我们对构成 Web 组件标准的技术进行了 广泛的了解。 首先,我们研究了 如何创建可在以后使用 的 HTML 模板。 其次,我们深入研究了 从头开始创建我们自己的自定义元素。 之后,我们将元素的样式和选择器封装到影子 DOM 中,这样我们的元素就完全独立了。

我们通过创建自己的自定义模态对话框探索了这些工具的强大功能,该元素可以在大多数现代应用程序上下文中使用,无论底层框架或库是什么。 在本文中,我们将介绍如何在各种框架中使用我们的元素,并了解一些高级工具,以真正提高您的 Web 组件技能。

系列文章

  1. Web 组件简介
  2. 创建可复用的 HTML 模板
  3. 从头开始创建自定义元素
  4. 使用 Shadow DOM 封装样式和结构
  5. Web 组件的高级工具(本文)

框架无关

我们的对话框组件在几乎任何框架中都能很好地工作,甚至没有框架也能工作。(当然,如果禁用了 JavaScript,那么整个操作就毫无意义。)Angular 和 Vue 将 Web 组件视为一等公民:这些框架的设计考虑到了 Web 标准。React 的观点稍微坚定一些,但并不难以集成。

Angular

首先,让我们看一下 Angular 如何处理自定义元素。 默认情况下,Angular 遇到无法识别的元素(即默认浏览器元素或 Angular 定义的任何组件)时会抛出模板错误。 通过包含 CUSTOM_ELEMENTS_SCHEMA 可以更改此行为。

…允许 NgModule 包含以下内容

  • 使用连字符命名法(-)命名的非 Angular 元素。
  • 使用连字符命名法(-)命名的元素属性。 连字符命名法是自定义元素的命名约定。

Angular 文档

使用此模式就像将其添加到模块一样简单

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
  /** Omitted */
  schemas: [ CUSTOM_ELEMENTS_SCHEMA ]
})
export class MyModuleAllowsCustomElements {}

就是这样。 完成此操作后,Angular 将允许我们在任何地方使用我们的自定义元素,并使用标准的属性和事件绑定。

<one-dialog [open]="isDialogOpen" (dialog-closed)="dialogClosed($event)">
  <span slot="heading">Heading text</span>
  <div>
    <p>Body copy</p>
  </div>
</one-dialog>

Vue

Vue 对 Web 组件的兼容性比 Angular 更好,因为它不需要任何特殊配置。 注册元素后,就可以使用 Vue 的默认模板语法。

<one-dialog v-bind:open="isDialogOpen" v-on:dialog-closed="dialogClosed">
  <span slot="heading">Heading text</span>
  <div>
    <p>Body copy</p>
  </div>
</one-dialog>

但是,Angular 和 Vue 的一个需要注意的地方是它们的默认表单控件。 如果我们希望在具有表单控件的自定义元素上使用诸如 Angular 中的响应式表单或 [(ng-model)] 或 Vue 中的 v-model 之类的东西,我们需要设置管道,这超出了本文的范围。

React

React 比 Angular 稍微复杂一些。 React 的虚拟 DOM 有效地将 JSX 树渲染为一个大型对象。 因此,React 不是像 Angular 或 Vue 那样直接修改 HTML 元素的属性,而是使用对象语法来跟踪需要对 DOM 进行的更改并批量更新它们。 在大多数情况下,这可以正常工作。 我们对话框的 open 属性绑定到其属性,并且会对更改的 props 响应得很好。

问题出现在我们开始查看对话框关闭时分派的 CustomEvent 时。 React 通过其 合成事件系统 为我们实现了一系列原生事件监听器。 不幸的是,这意味着像 onDialogClosed 这样的控件实际上不会为我们的组件附加事件监听器,因此我们必须找到其他方法。

在 React 中添加自定义事件监听器的最明显方法是使用 DOM refs。 在此模型中,我们可以直接引用我们的 HTML 节点。 语法有点冗长,但效果很好

import React, { Component, createRef } from 'react';

export default class MyComponent extends Component {
  constructor(props) {
    super(props);
    // Create the ref
    this.dialog = createRef();
    // Bind our method to the instance
    this.onDialogClosed = this.onDialogClosed.bind(this);

    this.state = {
      open: false
    };
  }

  componentDidMount() {
    // Once the component mounds, add the event listener
    this.dialog.current.addEventListener('dialog-closed', this.onDialogClosed);
  }

  componentWillUnmount() {
    // When the component unmounts, remove the listener
    this.dialog.current.removeEventListener('dialog-closed', this.onDialogClosed);
  }

  onDialogClosed(event) { /** Omitted **/ }

  render() {
    return <div>
      <one-dialog open={this.state.open} ref={this.dialog}>
        <span slot="heading">Heading text</span>
        <div>
          <p>Body copy</p>
        </div>
      </one-dialog>
    </div>
  }
}

或者,我们可以使用无状态函数式组件和钩子

import React, { useState, useEffect, useRef } from 'react';

export default function MyComponent(props) {
  const [ dialogOpen, setDialogOpen ] = useState(false);
  const oneDialog = useRef(null);
  const onDialogClosed = event => console.log(event);

  useEffect(() => {
    oneDialog.current.addEventListener('dialog-closed', onDialogClosed);
    return () => oneDialog.current.removeEventListener('dialog-closed', onDialogClosed)
  });

  return <div>
      <button onClick={() => setDialogOpen(true)}>Open dialog</button>
      <one-dialog ref={oneDialog} open={dialogOpen}>
        <span slot="heading">Heading text</span>
        <div>
          <p>Body copy</p>
        </div>
      </one-dialog>
    </div>
}

这还不错,但您会看到,重用此组件很快就会变得很麻烦。 幸运的是,我们可以导出一个使用相同工具包装我们自定义元素的默认 React 组件。

import React, { Component, createRef } from 'react';
import PropTypes from 'prop-types';

export default class OneDialog extends Component {
  constructor(props) {
    super(props);
    // Create the ref
    this.dialog = createRef();
    // Bind our method to the instance
    this.onDialogClosed = this.onDialogClosed.bind(this);
  }

  componentDidMount() {
    // Once the component mounds, add the event listener
    this.dialog.current.addEventListener('dialog-closed', this.onDialogClosed);
  }

  componentWillUnmount() {
    // When the component unmounts, remove the listener
    this.dialog.current.removeEventListener('dialog-closed', this.onDialogClosed);
  }

  onDialogClosed(event) {
    // Check to make sure the prop is present before calling it
    if (this.props.onDialogClosed) {
      this.props.onDialogClosed(event);
    }
  }

  render() {
    const { children, onDialogClosed, ...props } = this.props;
    return <one-dialog {...props} ref={this.dialog}>
      {children}
    </one-dialog>
  }
}

OneDialog.propTypes = {
  children: children: PropTypes.oneOfType([
      PropTypes.arrayOf(PropTypes.node),
      PropTypes.node
  ]).isRequired,
  onDialogClosed: PropTypes.func
};

…或再次作为无状态函数式组件

import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';

export default function OneDialog(props) {
  const { children, onDialogClosed, ...restProps } = props;
  const oneDialog = useRef(null);
  
  useEffect(() => {
    onDialogClosed ? oneDialog.current.addEventListener('dialog-closed', onDialogClosed) : null;
    return () => {
      onDialogClosed ? oneDialog.current.removeEventListener('dialog-closed', onDialogClosed) : null;  
    };
  });

  return <one-dialog ref={oneDialog} {...restProps}>{children}</one-dialog>
}

现在,我们可以将我们的对话框原生地用在 React 中,但仍然保持所有应用程序中相同的 API(如果需要,仍然可以删除类)。

import React, { useState } from 'react';
import OneDialog from './OneDialog';

export default function MyComponent(props) {
  const [open, setOpen] = useState(false);
  return <div>
    <button onClick={() => setOpen(true)}>Open dialog</button>
    <OneDialog open={open} onDialogClosed={() => setOpen(false)}>
      <span slot="heading">Heading text</span>
      <div>
        <p>Body copy</p>
      </div>
    </OneDialog>
  </div>
}

高级工具

有一些很棒的工具可以用来编写您自己的自定义元素。 在 npm 上搜索 会发现大量用于创建高度反应式自定义元素的工具(包括我自己的宠物项目),但目前最受欢迎的工具是来自 Polymer 团队的 lit-html,更具体地说,对于 Web 组件来说是 LitElement

LitElement 是一个自定义元素基类,它提供了一系列 API 来执行我们迄今为止讨论的所有操作。 它可以在浏览器中运行,无需构建步骤,但是如果您喜欢使用面向未来的工具(如装饰器),也有一些工具可以满足您的需求。

在深入研究如何使用 lit 或 LitElement 之前,请花点时间熟悉 带标签的模板字面量,这是一种在 JavaScript 中对模板字面量字符串调用的特殊函数。 这些函数接收字符串数组和插值值的集合,并且可以返回您可能想要的任何东西。

function tag(strings, ...values) {
  console.log({ strings, values });
  return true;
}
const who = 'world';

tag`hello ${who}`; 
/** would log out { strings: ['hello ', ''], values: ['world'] } and return true **/

LitElement 为我们提供了传递给该值数组的任何内容的实时动态更新,因此当属性更新时,元素的 render 函数将被调用,并且生成的 DOM 将重新渲染。

import { LitElement, html } from 'lit-element';

class SomeComponent {
  static get properties() {
    return { 
      now: { type: String }
    };
  }

  connectedCallback() {
    // Be sure to call the super
    super.connectedCallback();
    this.interval = window.setInterval(() => {
      this.now = Date.now();
    });
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    window.clearInterval(this.interval);
  }

  render() {
    return html`<h1>It is ${this.now}</h1>`;
  }
}

customElements.define('some-component', SomeComponent);

查看 CodePen 上 Caleb Williams (@calebdwilliams) 的
LitElement now 示例

CodePen 上。

您会注意到,我们必须使用 static properties getter 来定义我们希望 LitElement 监视的任何属性。 使用该 API 告诉基类在组件属性发生更改时调用 render。 render 反过来只会更新需要更改的节点。

因此,对于我们的对话框示例,使用 LitElement 将是这样的

查看 CodePen 上 Caleb Williams (@calebdwilliams) 的
使用 LitElement 的对话框示例

CodePen 上。

lit-html 有几种变体,包括 Haunted,这是一个针对 Web 组件的 React 钩子式库,它还可以使用 lit-html 作为基础来利用虚拟组件。

归根结底,大多数现代 Web 组件工具都是 LitElement 的各种变体:一个从我们的组件中抽象出通用逻辑的基类。 其他变体包括 StencilSkateJSAngular ElementsPolymer

下一步

Web Components 标准仍在不断发展,新功能正在不断讨论并添加到浏览器中。 很快,Web Component 作者将拥有与 Web 表单进行高级交互的 API(包括这些入门文章范围之外的其他元素内部信息),例如原生 HTML 和 CSS 模块导入、原生模板实例化和更新控件,以及更多可以跟踪在 GitHub 上的 W3C/web components 问题看板 上。

这些标准已准备好使用适用于传统浏览器和 Edge 的适当 polyfill 集成到我们今天的项目中。 虽然它们可能无法取代您选择的框架,但它们可以与它们一起使用以增强您和您组织的工作流程。