内容
- Web组件在这里停留
- 什么是自定义元素?
- 让我们构建
- 组件生命周期
- 摘要
1. Web组件在这里留下来
Web组件是构建和组织UI代码的抽象。这是一组浏览器本机工具,您可以用来将HTML标记,CSS样式和JavaScript行为封装到可重复使用的组件中[1]。在处理复杂的状态和非平凡的整合时,组件模型是一个有用的抽象,在这个空间中有许多框架提供其独特的主张。 Web组件就是一种解决方案,但它是网络标准。抽象主要为我们提供了代码的作者,因此我们可以将来有效地管理和维护它。
浏览器为此提供的工具是:
- 自定义元素
- Shadow Dom
- HTML模板
在这篇文章中,我们将仔细研究什么是自定义元素,如何创建它们以及它们如何在引擎盖下工作。
2.什么是自定义元素?
自定义元素使您能够定义自己的HTML标签,并将标记和行为封装在其中。例如,如果您要构建一个计数器组件,则可以使用自定义元素来跟踪计数,并为其中的按钮单击侦听器,并给它一个有意义的名称。通过将其命名为标签,而不是像div
这样的现有HTML元素一个ID,您可以定义与该组件相关的逻辑相关的一个地方。它的行为大致相同,但在HTML级别具有更好的语义。
第一种方法是完全有效的,对于像计数器这样的简单组件,这足够了。与Web组件相比,它的JavaScript行还要少,但是,当组件的复杂性增长时,有必要使用JavaScript的其他行。将相关代码的分组组合在一起还减轻了维护负担的一部分,这是寻找可以管理该组件行为的代码库的所有不同部分。
我们需要哪些工具来构建自定义元素?由于Web组件是Web平台的原生,因此您不需要任何其他依赖项即可使您前进。有一些框架有助于删除一些样板,但是从本质上讲,构建您自己的自定义元素的工作流程是创建一个扩展HTMLElement
的类,并将其作为参数传递给带有标签名称的内置API customElements.define
。简单!
2.让我们建造
最好的学习方法是这样做,因此让我们开始构建和探索任何理论。为了跟随,您只需要一个文本编辑器和浏览器即可。您可能会从代码片段或本文的源代码中注意到我正在使用Astro和Typescript,但是您可以轻松地与普通的HTML和JavaScript一起跟随。这样做时,请直接在浏览器中打开HTML文件以查看您的页面正在操作,或者如果您真的想从静态文件服务器使用它,我建议CLI工具serve
[2]。使用该工具很简单:
serve <your-file>.html
在这篇文章中,我们将构建一个计时器组件,并且看起来像这样:
页面加载后,计数器将每秒开始增加。但是,我们将制作自己的Web组件,而不是具有“计时器” ID的div
,而是将其称为“ X-Timer”(我想称其为“计时器”,但自定义元素的规则之一是它需要至少要被连字符隔开的两个单词,因此您可以将它们与内置的HTML元素区分开[3],并且命名事物很难)。这将使最终标记看起来像这样:
我们希望组件具有以下内部标记,因此我们可以以增量计数值更新span
:
<p>
Count: <span>0</span>
</p>
在开始编码之前,让我们建立可以定义此内部标记的三种方式:
- 定义HTML中的所有标记,并使用JavaScript将
x-timer
的孩子元素挂钩 - 使用JavaScript创建HTML元素,然后将其添加到
x-timer
的内部,作为儿童元素 - 前两种方法的组合
对于我们的x-timer
组件,我们将在标记中定义Web组件本身,然后使用JavaScript定义其内部内容:
<article>
<h2>A single timer</h2>
<x-timer />
</article>
在我们的JavaScript中,我们可以定义组件,以便将其加载到文档中时,它将创建内部HTML元素并扩展到此:
<article>
<h2>A single timer</h2>
<x-timer>
<p>Count: <span>0</span></p>
</x-timer>
</article>
真棒!这给了我们一些脚手架和行动计划。
我们需要以下任何要定义的Web组件的结构:
class Timer extends HTMLElement {
constructor() {
super();
}
}
customElements.define("x-timer", Timer);
我们将Timer
命名为“ Timer
”,这扩展了一个HTMLElement
。我们希望我们的自定义标签被称为x-timer
,并且要使浏览器识别它,我们需要使用customElements.define
定义它,并给它给它一个类,以便知道该标签该怎么做。现在,让我们跳舞:
class Timer extends HTMLElement {
count: number;
constructor() {
super();
this.count = 0;
setInterval(() => {
console.log("Timer called");
this.count++;
}, 1000);
}
}
/* --snip -- */
我们已经在类中添加了一个属性,以跟踪计数状态,并在构造时用0初始化它。我们还使用JavaScript函数setInterval
定义了一个计时器,并延迟通过1000毫秒,以及一个回调,每次调用计时器并将count
属性的count
属性汇总到Console。这是整个。这是整个组件的机制将驱动计时器,但我们仍然需要将此计数放在屏幕上。
class Timer extends HTMLElement {
/* --snip -- */
constructor() {
/* --snip -- */
const countSpan = document.createElement("span");
const countParagraph = document.createElement("p");
countParagraph.textContent = "Count: ";
countSpan.textContent = this.count.toString();
countParagraph.appendChild(countSpan);
this.appendChild(countParagraph);
setInterval(() => {
/* --snip -- */
countSpan.textContent = this.count.toString();
}, 1000);
}
}
/* --snip -- */
应该做到的!我们已经在构造函数中定义了span
和p
,然后设置其初始文本内容。然后,我们将跨度附加到段落中,最后将段落加入父(我们的x-timer
组件),以创建组件的最终结构。在setInterval
回调中,我们添加了一行以更新跨度文本内容时,每当计数更改。当您在浏览器中查看此内容时,您应该像我们打算这样做一样看到计数器增加每一秒。添加样式是可选的,但是您现在应该拥有一个大致相似的页面:
我们还没有完成,因为我已经误导了您,并向您展示了可能出乎意料的代码。当我们在HTML中明确使用此自定义元素时,这恰好工作起作用,但是当您像这样动态创建它并尝试将其插入文档中时,它会以奇怪的方式破裂:
const timer = document.createElement("x-timer");
document.body.appendChild(timer);
我们会得到这样的奇怪错误:
Uncaught DOMException: Operation is not supported
要了解这里发生的事情,我们需要将其引入组件的生命周期。
3.组件生命周期
浏览器如何渲染Web组件?当属性更改或从文档中删除组件时,它如何更新?到目前为止,在我们的实施中,我们已经假设浏览器识别Web组件并将其渲染到屏幕上是同一事件。但是,它们是浏览器渲染管道中的不同事件,这对我们定义和管理组件的方式产生了影响。
浏览器的渲染管道可以概括为三个阶段[4]:
- HTML parsing
- 计算布局和计算绘画细节
- 将所有单个元素合成并最终在屏幕上绘制
当浏览器遇到Web组件时,它通过调用构造函数并继续解析文档的其余部分来实例化。由于尚未渲染,因此无法访问该文档。这就是为什么我们的组件行为是片状的原因,具体取决于我们直接使用它或以编程方式创建它。正确构建我们的组件的方法是将所谓的“生命周期方法”连接在一起。这些是在可用DOM操作时在渲染管道中稍后在渲染管道中调用的组件上的方法。这些方法是[3]:
connectedCallback
disconnectedCallback
attributeChangedCallback
adoptedCallback
这些回调是在布局和绘画阶段调用的。让我们重构我们的实施以利用connectedCallback
:
class Timer extends HTMLElement {
/* --snip -- */
countSpan: HTMLElement;
constructor() {
/* --snip -- */
this.countSpan = document.createElement("span");
setInterval(() => {
/* --snip -- */
this.countSpan.textContent = this.count.toString();
}, 1000);
}
connectedCallback() {
console.log("x-timer connected");
const countParagraph = document.createElement("p");
countParagraph.textContent = "Count: ";
this.countSpan.textContent = this.count.toString();
countParagraph.appendChild(this.countSpan);
this.appendChild(countParagraph);
}
}
/* --snip -- */
我们已经将与附加元素相关的所有逻辑移至connectedCallback
方法中的Web组件。我们还必须使countSpan
成为组件的属性,以便我们可以在构造函数的范围之外引用它。我们可以在构造函数中愉快地将其初始化,因为我们只是创建一个元素,而不是在任何DOM操作中使用它,直到它连接到文档。如果您使用这些更改刷新您的浏览器,您会发现它仍然有效,但已正确实现,以便甚至可以通过编程而没有任何打ic来创建它。
我们并不清楚。当我们的组件从文档中删除时会发生什么?通过查看我们的生命周期方法的名称,disconnectedCallback
方法似乎会被调用,但是我们需要对此做任何事情吗?在我们的实施中,只有通过导航或页面关闭而断开整个页面时,我们的组件才会断开连接。在这种情况下,我们无需担心手动断开或删除任何内容,我们可以依靠浏览器执行清理操作。当我们动态删除Web组件时,我们只需要担心它,但这不应该在组件设计中规定。我们应该构建我们的组件,以便可以像其他任何HTML元素一样使用它。那么,我们组件的哪一部分需要手动清理?当父组件被删除时,动态创建的段落和跨度将自动删除。组件的属性也会如此。浏览器的垃圾收集器将像任何JavaScript对象一样处理所有这些。那我们的计时器呢?
setInterval
通过接收回调功能和延迟来起作用。然后,它使用我们在每个执行之间定义的延迟反复执行回调。但是,此间隔在记忆中在哪里?它范围范围为其创建的上下文吗?因此,在我们的情况下,这是构造者还是班级?简短的答案是,它没有范围范围内的上下文,并且完全具有单独的执行上下文。
我喜欢将间隔视为生活在某些全球环境中,而setInterval
功能为我们提供了一种将事物推向它的方法(要更深入地了解它的工作原理,请参阅JavaScript Event Loop上的这篇文章)。这意味着我们创建的间隔默认情况下会超过组件,因为全局上下文将超过组件。这就是为什么setInterval
的完整签名还返回一个可以使用方法clearInterval
删除间隔的ID。
如果我们不删除间隔,它将继续在后台执行,并可能导致内存泄漏。当您导航到另一个页面或关闭选项卡时,浏览器的垃圾集合将开始启动,但是如果您打算构建长期寿命的应用程序,那么这将无限期地累积并将应用程序放在磨损中。我们需要稍微重新分配代码以说明间隔的清理:
class Timer extends HTMLElement {
/* --snip -- */
timerId: number;
constructor() {
/* --snip -- */
this.timerId = setInterval(/* --snip -- */);
}
/* --snip -- */
disconnectedCallback() {
console.log("x-timer disconnected");
clearInterval(this.timerId);
}
}
/* --snip -- */
我们已经将setInterval
返回的timerId
分配给了一个属性,因此我们可以在构造函数的范围之外引用它,并清除disconnectedCallback
中的间隔。如果我们重新运行页面,所有内容都应该看起来...相同。好吧,这有点反气候。
让我们创建一个全新的组件,其唯一的责任是添加新的计时器并在完成后清除所有计时器。我们可以使用此新组件与x-timer
进行修补:
要对其进行一些更改,让我们使用HTML定义组件的大多数内部设备,这样我们就可以看到使用JavaScript将其挂接到儿童元素中看起来看起来像什么。我们将动态添加的唯一元素是x-timer
,因此我添加了一个空的div
作为所有这些容器。那将使我们的html看起来像这样:
<article>
<h2>Dynamic timers</h2>
<x-timers>
<button aria-label="add-timer">Add timer</button>
<button aria-label="clear-timers">Clear timers</button>
<div aria-label="timers">
</div>
</x-timers>
</article>
注意:我已经使用
aria-label
来演示文档中选择元素的不同方法,但是您可以选择它们。
然后相应的组件定义看起来像这样:
class Timers extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
console.log("x-timers connected");
const timersDiv = this.querySelector("[aria-label='timers']");
const addTimerButton = this.querySelector("[aria-label='add-timer']");
const clearTimersButton = this.querySelector("[aria-label='clear-timers']");
// Default state
timersDiv.textContent = "No timers running";
clearTimersButton.addEventListener("click", () => {
// Return to default state
timersDiv.textContent = "No timers running";
});
addTimerButton.addEventListener("click", () => {
// Clear timers div if no timers present
if (timersDiv.textContent === "No timers running") {
timersDiv.textContent = null;
}
const xTimer = document.createElement("x-timer");
timersDiv.prepend(xTimer);
});
}
}
customElements.define("x-timers", Timers);
由于该组件不需要设置,因此所有逻辑都活在connectedCallback
中。现在,您应该拥有一个大致相似的页面:
您可以根据需要创建和清除任意数量的计时器,并且由于我们已将适当的清理逻辑添加到x-timer
中,一切都可以正常工作。现在,您可以尝试从x-timer
中的disconnectedCallback
删除clearInterval
时会发生什么。由于我们添加了一些有用的记录语句,因此您应该能够查看何时调用不同的方法。
概括
自定义元素是Web组件工具链的关键部分。它们使您能够定义自己的HTML标签,还可以让您定义组件的内部工作。通过查看浏览器解析和渲染html的方式,我们看到了为什么需要组件生命周期方法以及何时被调用。我们利用它来确保我们自己清理,以免在应用程序中创建内存泄漏。所有这些都为我们探索了Web组件工具链的功能和功能的功能。
在第2部分中,我们将探索Shadow dom和HTML模板。我们还将与React,Angular和Vue等巨头一起探索Web组件的位置。我们将尽可能客观地对待它,以了解您每种方法进行的真实,不可忽略的权衡。目的是为您提供工具和上下文,以便您可以正确解决问题。
也可以在我的GitHub上找到本文的所有源代码。您甚至可能会偷偷浏览一下我为第2部分
组合的演示和比较如果您想到我错过的或只是想取得联系,可以通过Mastodon通过Threads通过Twitter或LinkedIn与我联系。
。