原文地址: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
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。