- 原文地址:Creating a Custom Element from Scratch
- 原文做者:Caleb Williams
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:Seven
- 校对者:portandbridge, wznonstop
在上一篇文章,咱们在文档中建立了 HTML 模板,但愿它们在须要时才呈现,这让咱们开始接触 Web 组件。css
接下来,咱们将继续建立对话框组件的自定义元素版本,该自定义元素版本目前仅使用 HTMLTemplateElement
。html
请在 CodePen 上查看由 Caleb Williams (@calebdwilliams) 建立的带有脚本的模板对话框 Demo。前端
所以,下一步咱们将建立一个自定义元素,该元素实时使用咱们的 template#dialog-template
元素。node
Web 组件的基础元素是自定义元素。该 customElements
的 API 为咱们提供了建立自定义 HTML 标签的途径,这些标签能够在包含定义类的任何文档中使用。react
能够把它想象成 React 或 Angular 组件(例如 <MyCard />
),但实际上它不依赖于 React 或 Angular。原生自定义组件是这样的:<my-card></my-card>
。更重要的是,将它视为一个标准元素,能够在你的 React、Angular、Vue、[insert-framework-you’re-interested-in-this-week] 应用中使用,而没必要大惊小怪。android
从本质上讲,一个自定义元素分为两个部分组成:一个标签名称和一个 Class 类扩展内置 HTMLElement
类。咱们自定义元素的简易 demo 版本以下所示:ios
class OneDialog extends HTMLElement {
connectedCallback() {
this.innerHTML = `<h1>Hello, World!</h1>`;
}
}
customElements.define('one-dialog', OneDialog);
复制代码
注意:在整个自定义元素中,this 值是对自身自定义元素实例的引用。git
在上面的示例中,咱们定义了一个符合标准的新 HTML 元素,<one-dialog></one-dialog>
。它如今暂时还作不了什么...,在任何 HTML 文档中使用 <one-dialog>
标签将会建立一个带着 <h1>
标签显示 “Hello, World!” 的新元素。github
咱们确定想把它作的更 NB,很幸运。在上一篇文章中,咱们为弹出框建立模板,而且可以拿到模板,让咱们在自定义元素中使用它。咱们在该示例中添加了一个 script 标签来执行一些对话框魔术。咱们暂时删除它,由于咱们将把逻辑从 HTML 模板移到自定义元素类中。web
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);
}
}
复制代码
在上面的例子中,咱们只关心属性是否设置,咱们不关心具体的值(这相似于 HTML5 input 输入框上的 required
属性)。更新此属性时,咱们更新元素的 open
属性。属性(property)存在于 JavaScript 对象上,HTML Elements 也具备属性(attribute);这个生命周期方法能够帮助咱们让两种属性保持同步。
咱们将 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();
}
}
}
复制代码
这里发生了不少事情,让咱们来梳理一下。咱们要作的第一件事就是获取咱们的容器,在 isOpen
的基础上切换 .open
类。为了使咱们的元素能够访问,咱们还须要切换 aria-hidden
属性。
若是对话框已经打开了,那么咱们但愿保存对先前聚焦元素的引用。这是为了考虑可访问性标准。咱们还将一个 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 的另外一部分时触发。
在下面的示例中,您将看到咱们的模板元素正被一个标准元素 <one-dialog>
所使用。
请在 CodePen 上查看由 Caleb Williams (@calebdwilliams) 建立的对话框组件使用模板 Demo。
到目前为止,咱们建立的 <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>
元素的可用性。
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。