[译] 从 0 创建自定义元素-阿里云开发者社区

开发者社区> 开发与运维> 正文

[译] 从 0 创建自定义元素

简介: 在上一篇文章,我们在文档中创建了 HTML 模板,希望它们在需要时才呈现,这让我们开始接触 Web 组件。 接下来,我们将继续创建对话框组件的自定义元素版本,该自定义元素版本目前仅使用 HTMLTemplateElement。

原文地址:Creating a Custom Element from Scratch
原文作者:Caleb Williams
译文出自:掘金翻译计划
本文永久链接:github.com/xitu/gold-m…
译者:Seven
校对者:portandbridge, wznonstop


在上一篇文章,我们在文档中创建了 HTML 模板,希望它们在需要时才呈现,这让我们开始接触 Web 组件。
接下来,我们将继续创建对话框组件的自定义元素版本,该自定义元素版本目前仅使用 HTMLTemplateElement。
请在 CodePen 上查看由 Caleb Williams (@calebdwilliams) 创建的带有脚本的模板对话框 Demo。
因此,下一步我们将创建一个自定义元素,该元素实时使用我们的 template#dialog-template 元素。

添加一个自定义元素

Web 组件的基础元素是自定义元素。该 customElements 的 API 为我们提供了创建自定义 HTML 标签的途径,这些标签可以在包含定义类的任何文档中使用。
可以把它想象成 React 或 Angular 组件(例如 ),但实际上它不依赖于 React 或 Angular。原生自定义组件是这样的:。更重要的是,将它视为一个标准元素,可以在你的 React、Angular、Vue、[insert-framework-you’re-interested-in-this-week] 应用中使用,而不必大惊小怪。
从本质上讲,一个自定义元素分为两个部分组成:一个标签名称和一个 Class 类扩展内置 HTMLElement 类。我们自定义元素的简易 demo 版本如下所示:

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

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

注意:在整个自定义元素中,this 值是对自身自定义元素实例的引用。
在上面的示例中,我们定义了一个符合标准的新 HTML 元素,。它现在暂时还做不了什么...,在任何 HTML 文档中使用 标签将会创建一个带着

标签显示 “Hello, World!” 的新元素。
我们肯定想把它做的更 NB,很幸运。在上一篇文章中,我们为弹出框创建模板,并且能够拿到模板,让我们在自定义元素中使用它。我们在该示例中添加了一个 script 标签来执行一些对话框魔术。我们暂时删除它,因为我们将把逻辑从 HTML 模板移到自定义元素类中。

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

现在,定义了自定义元素()并指示浏览器呈现包含在调用自定义元素的 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);
  }
}

我们将 updater 包含在 attributeChangedCallback 内部的条件检查中,以查看新值和旧值是否相等。我们这样做是为了防止程序中出现无限循环,因为稍后我们将创建一个 getter 和 setter 属性,它将通过在元素的属性(property)更新时设置元素的属性(attribute)来保持属性(attribute)和属性(property)的同步。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 将保证(HTML 元素节点上)的 open 特性和属性(在 DOM 对象上)的值同步。添加 open 特性会将 element.open 设置为 true,同理,将 element.open 设置为 true 会添加 open 属性。我们这样做是为了确保元素的状态由其属性反映出来。虽然在技术层面上不一定需要,但被认为是创建自定义元素的最优办法。
虽然这难免引入一些样板文件,但是通过循环观察到的属性列表并使用 Object.defineProperty 创建一个保持这些属性同步的抽象类是一项相当简单的任务。

class AbstractClass extends HTMLElement {
  constructor() {
    super();
    // 检查观察到的属性是否已定义并具有长度
    if (this.constructor.observedAttributes && this.constructor.observedAttributes.length) {
      // 通过观察到的属性进行循环
      this.constructor.observedAttributes.forEach(attribute => {
        // 动态定义 getter/setter 原型
        Object.defineProperty(this, attribute, {
          get() { return this.getAttribute(attribute); },
          set(attrValue) {
            if (attrValue) {
              this.setAttribute(attribute, attrValue);
            } else {
              this.removeAttribute(attribute);
            }
          }
        }
      });
    }
  }
}

// 我们可以扩展抽象类,而不是直接扩展 HTMLElement
class SomeElement extends AbstractClass { /** 省略 **/ }

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

上面的例子并不完美,它没有考虑实现像 open 这样的属性的可能性,这些属性没有被赋值,而仅仅依赖于属性的存在。做一个完美的版本将超出本文的范围。
现在我们已经知道我们的对话框是否打开了,让我们添加一些逻辑来实际地进行显示和隐藏:

class OneDialog extends HTMLElement {  
  /** 省略 */
  constructor() {
    super();
    this.close = this.close.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();   
    }
  }
}

如果对话框已经打开了,那么我们希望保存对先前聚焦元素的引用。这是为了考虑可访问性标准。我们还将一个 keydown 监听器添加到名为 WatEscape 的文档中,该文档在构造函数中绑定元素的 this,其模式类似于 React 处理类组件中的方法调用的方式。
我们这样做不仅是为了确保正确绑定 this.close,还因为 Function.prototype.bind 返回带绑定调用栈的函数的实例。通过在构造函数中保存对新绑定方法的引用,我们可以在对话框断开时删除事件(稍后将详细介绍)。最后,我们将注意力集中在元素上,并将焦点设置在 shadow root 中的适当元素上。

我们还创建了一个很好的小实用工具方法来关闭我们的对话框,它分派一个自定义事件来通知某个监听器对话框已经关闭。

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

说到为自己的代码做好清理善后,就自然也要说下我们采用了另一种生命周期方法:disconnectedCallback。disconnectedCallback 与 connectedCallback 相反,因为一旦从 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 的另一部分时触发。
在下面的示例中,您将看到我们的模板元素正被一个标准元素 所使用。
请在 CodePen 上查看由 Caleb Williams (@calebdwilliams) 创建的对话框组件使用模板 Demo。

另一个概念:非演示组件

到目前为止,我们创建的 是一个典型的自定义元素,它包含了当元素包含在文档中时被插入到文档中的标记和行为。然而,并不是所有的元素都需要直观地呈现。在 React 生态系统中,组件通常用于管理应用程序状态或其他一些主要功能,像react-redux 里的 。

让我们想象一下,我们的组件是工作流中一系列对话框的一部分。当一个对话框关闭时,下一个对话框应该打开。我们可以创建一个容器组件来监听我们的 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 合并样式和内容封装来提高我们刚刚创建的 元素的可用性。


作者:ANFOUNNYSOUL
链接:https://juejin.im/post/5cb2b5465188257abd66c354
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
开发与运维
使用钉钉扫一扫加入圈子
+ 订阅

集结各类场景实战经验,助你开发运维畅行无忧

其他文章