从头开始创建自定义元素

Avatar of Caleb Williams
Caleb Williams

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费信用额度!

上一篇文章中,我们通过创建文档中存在的但只有在需要时才会渲染的 HTML 模板,亲身体验了 Web Components。

接下来,我们将继续我们的探索,为下面的对话框组件创建自定义元素版本,该组件目前仅使用HTMLTemplateElement

因此,让我们继续前进,创建一个实时使用我们的template#dialog-template元素的自定义元素。

文章系列

  1. Web Components 入门
  2. 创建可重用的 HTML 模板
  3. 从零开始创建自定义元素(本文
  4. 使用 Shadow DOM 封装样式和结构
  5. Web Components 的高级工具

创建自定义元素

Web Components 的核心是自定义元素customElements API 为我们提供了一种定义自定义 HTML 标签的途径,这些标签可以在包含定义类的任何文档中使用。

可以把它想象成 React 或 Angular 组件(例如<MyComponent>),但没有 React 或 Angular 的依赖关系。原生自定义元素看起来像这样:<one-dialog>。更重要的是,可以将其视为一个标准元素,可以在您的 React、Angular、Vue、[本周您感兴趣的框架] 应用程序中使用,而无需太多麻烦。

本质上,自定义元素包含两个部分:一个标签名称和一个扩展内置HTMLElement类的。我们自定义元素的最基本版本如下所示

class OneDialog extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<h1>Hello, World!</h1>`;
  }
}

customElements.define('one-dialog', OneDialog);

在整个自定义元素中,this值是对自定义元素实例的引用。

在上面的示例中,我们定义了一个新的符合标准的 HTML 元素,<one-dialog></one-dialog>。它目前什么也没做…… 暂时。现在,在任何 HTML 文档中使用<one-dialog>标签都会创建一个包含读取“Hello, World!”的<h1>标签的新元素。

我们肯定想要更强大的功能,而且我们很幸运。在 上一篇文章中,我们研究了如何为我们的对话框创建模板,并且由于我们将能够访问该模板,因此让我们在我们的自定义元素中使用它。我们在那个例子中添加了一个脚本标签来做一些对话框的魔法。现在让我们先移除它,因为我们将把我们的逻辑从 HTML 模板移动到自定义元素类内部。

class OneDialog extends HTMLElement {
  connectedCallback() {
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
  }
}

现在,我们的自定义元素(<one-dialog>)已定义,并且浏览器被指示在调用自定义元素的位置渲染 HTML 模板中包含的内容。

我们的下一步是将我们的逻辑移到我们的组件类中。

自定义元素生命周期方法

与 React 或 Angular 类似,自定义元素也具有生命周期方法。您已经被动地了解了connectedCallback,当我们的元素添加到 DOM 时会调用此方法。

connectedCallback与元素的constructor是分开的。构造函数用于设置元素的基本结构,而connectedCallback通常用于向元素添加内容、设置事件侦听器或以其他方式初始化组件。

事实上,根据设计,构造函数不能用于修改或操作元素的属性。如果我们要使用document.createElement创建对话框的新实例,则会调用构造函数。元素的使用者会期望一个没有插入属性或内容的简单节点。

createElement函数没有配置将返回的元素的选项。因此,构造函数不应具有修改其创建的元素的能力。这使我们能够使用connectedCallback来修改我们的元素。

对于标准的内置元素,元素的状态通常由元素上存在的属性以及这些属性的值来反映。对于我们的示例,我们将查看一个属性:[open]。为此,我们需要监视该属性的变化,并且我们需要attributeChangedCallback来做到这一点。每当元素构造函数的observedAttributes之一更新时,都会调用此第二个生命周期方法。

这听起来可能令人生畏,但语法非常简单

class OneDialog extends HTMLElement {
  static get observedAttributes() {
    return ['open'];
  }
  
  attributeChangedCallback(attrName, oldValue, newValue) {
    if (newValue !== oldValue) {
      this[attrName] = this.hasAttribute(attrName);
    }
  }
  
  connectedCallback() {
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
  }
}

在我们的例子中,我们只关心属性是否设置,我们不关心值(这类似于输入上的 HTML5required属性)。当此属性更新时,我们更新元素的open属性。属性存在于 JavaScript 对象上,而属性存在于 HTMLElement 上,此生命周期方法帮助我们保持两者同步。

我们将更新程序包装在attributeChangedCallback内的条件检查中,以查看新值和旧值是否相等。我们这样做是为了防止程序内部出现无限循环,因为稍后我们将创建属性 getter 和 setter,它们将通过在元素属性更新时设置元素的属性来保持属性和属性同步。attributeChangedCallback执行相反的操作:在属性更改时更新属性。

现在,作者可以消费我们的组件,并且open属性的存在将决定对话框是否默认打开。为了使它更具动态性,我们可以向元素的 open 属性添加自定义 getter 和 setter

class OneDialog extends HTMLElement {
  static get boundAttributes() {
    return ['open'];
  }
  
  attributeChangedCallback(attrName, oldValue, newValue) {
    this[attrName] = this.hasAttribute(attrName);
  }
  
  connectedCallback() {
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
  }
  
  get open() {
    return this.hasAttribute('open');
  }
  
  set open(isOpen) {
    if (isOpen) {
      this.setAttribute('open', true);
    } else {
      this.removeAttribute('open');
    }
  }
}

我们的 getter 和 setter 将使open属性(在 HTML 元素上)和属性(在 DOM 对象上)的值保持同步。添加open属性会将element.open设置为true,并将element.open设置为true会添加open属性。我们这样做是为了确保元素的状态通过其属性反映出来。这在技术上不是必需的,但被认为是编写自定义元素的最佳实践。

确实不可避免地导致了一些样板代码,但是通过循环遍历观察到的属性列表并使用Object.defineProperty,创建一个保持这些属性同步的抽象类是一项相当简单的任务。

class AbstractClass extends HTMLElement {
  constructor() {
    super();
    // Check to see if observedAttributes are defined and has length
    if (this.constructor.observedAttributes && this.constructor.observedAttributes.length) {
      // Loop through the observed attributes
      this.constructor.observedAttributes.forEach(attribute => {
        // Dynamically define the property getter/setter
        Object.defineProperty(this, attribute, {
          get() { return this.getAttribute(attribute); },
          set(attrValue) {
            if (attrValue) {
              this.setAttribute(attribute, attrValue);
            } else {
              this.removeAttribute(attribute);
            }
          }
        }
      });
    }
  }
}

// Instead of extending HTMLElement directly, we can now extend our AbstractClass
class SomeElement extends AbstractClass { /* Omitted */ }

customElements.define('some-element', SomeElement);

上面的例子并不完美,它没有考虑到像open这样的属性的可能性,这些属性没有分配给它们的值,但仅依赖于属性的存在。制作此版本的完美版本超出了本文的范围。

既然我们知道对话框是否打开,让我们添加一些逻辑来实际执行显示和隐藏操作

class OneDialog extends HTMLElement {  
  /** Omitted */
  constructor() {
    super();
    this.close = this.close.bind(this);
    this._watchEscape = this._watchEscape.bind(this);
  }
  
  set open(isOpen) {
    this.querySelector('.wrapper').classList.toggle('open', isOpen);
    this.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
    if (isOpen) {
      this._wasFocused = document.activeElement;
      this.setAttribute('open', '');
      document.addEventListener('keydown', this._watchEscape);
      this.focus();
      this.querySelector('button').focus();
    } else {
      this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
      this.removeAttribute('open');
      document.removeEventListener('keydown', this._watchEscape);
      this.close();
    }
  }
  
  close() {
    if (this.open !== false) {
      this.open = false;
    }
    const closeEvent = new CustomEvent('dialog-closed');
    this.dispatchEvent(closeEvent);
  }
  
  _watchEscape(event) {
    if (event.key === 'Escape') {
        this.close();   
    }
  }
}

这里有很多内容,但让我们逐步了解。我们首先获取包装器并根据isOpen切换.open类。为了保持元素的可访问性,我们还需要切换aria-hidden属性。

如果对话框已打开,那么我们想要保存对先前聚焦元素的引用。这是为了符合可访问性标准。我们还在文档中添加了一个名为watchEscape的键盘按下监听器,我们在构造函数中将其绑定到元素的this上,其模式类似于 React 如何在类组件中处理方法调用。

我们这样做不仅是为了确保this.close的正确绑定,还因为Function.prototype.bind返回一个带有绑定调用站点的函数实例。通过在构造函数中保存对新绑定方法的引用,我们就可以在断开对话框连接时移除该事件(稍后会详细介绍)。最后,我们将焦点放在我们的元素上,并在我们的 Shadow Root 中将焦点设置在正确的元素上。

我们还创建了一个用于关闭对话框的实用程序方法,该方法分派一个自定义事件,提醒某个监听器对话框已关闭。

如果元素已关闭(即!open),我们检查以确保this._wasFocused属性已定义并具有focus方法,并调用该方法以将用户的焦点返回到常规 DOM。然后我们删除事件侦听器以避免任何内存泄漏。

说到清理工作,这带我们来到了另一个生命周期方法:disconnectedCallbackdisconnectedCallbackconnectedCallback的反向,即在元素从 DOM 中移除后调用此方法,并允许我们清理附加到元素的任何事件侦听器或MutationObservers

碰巧我们还有几个事件侦听器需要连接

class OneDialog extends HTMLElement {
  /* Omitted */
  
  connectedCallback() {    
    this.querySelector('button').addEventListener('click', this.close);
    this.querySelector('.overlay').addEventListener('click', this.close);
  }
  
  disconnectedCallback() {
    this.querySelector('button').removeEventListener('click', this.close);
    this.querySelector('.overlay').removeEventListener('click', this.close);
  }  
}

现在,我们有一个运行良好、大部分可访问的对话框元素。我们可以做一些抛光工作,例如捕获元素上的焦点,但这超出了我们在这里尝试学习的内容的范围。

还有一个生命周期方法不适用于我们的元素,即adoptedCallback,当元素被采用到 DOM 的另一个部分时,它会触发。

在下面的示例中,您现在将看到我们的模板元素正在被标准的<one-dialog>元素使用。

另一件事:非表现性组件

我们目前创建的<one-template>是一个典型的自定义元素,因为它包含了标记和行为,并在元素包含到文档中时插入到文档中。但是,并非所有元素都需要视觉呈现。在 React 生态系统中,组件通常用于管理应用程序状态或其他一些主要功能,例如react-redux中的<Provider />

让我们想象一下,我们的组件是一系列工作流程中的对话框的一部分。当一个对话框关闭时,下一个对话框应该打开。我们可以创建一个包装组件,监听我们的dialog-closed事件并逐步完成工作流程。

class DialogWorkflow extends HTMLElement {
  connectedCallback() {
    this._onDialogClosed = this._onDialogClosed.bind(this);
    this.addEventListener('dialog-closed', this._onDialogClosed);
  }

  get dialogs() {
    return Array.from(this.querySelectorAll('one-dialog'));
  }

  _onDialogClosed(event) {
    const dialogClosed = event.target;
    const nextIndex = this.dialogs.indexOf(dialogClosed);
    if (nextIndex !== -1) {
      this.dialogs[nextIndex].open = true;
    }
  }
}

此元素没有任何表现逻辑,但充当应用程序状态的控制器。稍微努力一下,我们就可以使用一个自定义元素重新创建一个类似 Redux 的状态管理系统,它可以在与 React 的 Redux 包装器相同的范围内管理整个应用程序的状态。

深入了解自定义元素

现在我们对自定义元素有了相当好的理解,并且我们的对话框也开始成形了。但它仍然存在一些问题。

请注意,我们不得不添加一些 CSS 来重新设置对话框按钮的样式,因为我们元素的样式干扰了页面上的其他部分。虽然我们可以利用命名策略(如 BEM)来确保我们的样式不会与其他组件发生冲突,但有一种更友好的方法来隔离样式。剧透!它是 Shadow DOM,这就是我们将在本系列 Web Components 的下一部分中要探讨的内容。

我们还需要做的一件事是为每个组件定义一个新的模板,或者找到某种方法来切换对话框的模板。就目前而言,每个页面只能有一种对话框类型,因为使用的模板必须始终存在。因此,我们需要某种方法来注入动态内容或一种方法来交换模板。

在下一篇文章中,我们将探讨如何通过使用 Shadow DOM 集成样式和内容封装来提高我们刚刚创建的<one-dialog>元素的可用性。

文章系列

  1. Web Components 入门
  2. 创建可重用的 HTML 模板
  3. 从零开始创建自定义元素(本文
  4. 使用 Shadow DOM 封装样式和结构
  5. Web Components 的高级工具