Web-组件入门指南-全-
Web 组件入门指南(全)
原文:
zh.annas-archive.org/md5/dd49e53e4e51c0e7acc9c25d8ac912bb译者:飞龙
前言
本书涵盖了关于一种名为网络组件的网络技术的信息。网络组件是提供用户以组件驱动开发方法的标准。它还提供了封装,允许您在不依赖任何库的情况下使用组件驱动方法。
本书面向的对象
本书面向的是那些听说过网络组件,但不知道从何入手的开发者。本书也面向那些知道网络组件是什么,但仍然害怕在生产中使用它们的中级和高级开发者。本书也面向那些希望通过研究网络组件来增加知识和技能的前端工程师。
您也可以使用本书来了解和学习组件驱动方法。如果您来自 React/Angular/Polymer 背景,本书将向您展示如何使用纯 JavaScript(无需任何库)实现大多数功能。
本书涵盖的内容
第一章,网络组件基础和规范,讨论了网络组件的概念及其相关的规范。
第二章,网络组件生命周期回调方法,涵盖了与网络组件相关的各种生命周期回调方法。
第三章,通用网络组件,探讨了各种概念,例如样式和可访问性,这些概念可以增加网络组件的可用性。
第四章,构建可重用网络组件,解释了可重用性的概念,并探讨了插槽的概念。
第五章,管理状态和属性,讨论了状态和属性的概念,以及如何通过属性、属性和自定义事件处理器来实现这些概念。
第六章,使用网络组件构建单页应用,探讨了仅使用网络组件创建完整单页网络应用的过程。
第七章,使用 Polymer 和 Stencil 实现网络组件,涵盖了不同的库和框架与纯网络组件的不同之处。
第八章,将网络组件与网络框架集成,探讨了如何将纯网络组件集成到包含其他库的现有项目中。
本书还包含大量示例——超过 50 个网络组件示例——这些示例可供初学者和高级用户参考。
为了充分利用本书
为了充分利用本书,您需要遵守以下要求:
-
您需要对 Web 开发技术(如 HTML、CSS 和 JavaScript)有基本的了解。
-
您应该能够在一个浏览器中打开网站,并使用开发者工具进行调试。
-
您应该熟悉 GitHub,因为它拥有您需要的所有代码文件。
-
您应该能够通过终端或命令提示符使用 Node.js (
nodejs.org/en/)。 -
您应该能够使用文本编辑器。
下载示例代码文件
您可以从您的 www.packt.com 账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问 www.packt.com/support 并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在 www.packt.com 登录或注册。
-
选择支持选项卡。
-
点击代码下载与勘误。
-
在搜索框中输入书名,并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本解压缩或提取文件夹:
-
适用于 Windows 的 WinRAR/7-Zip
-
适用于 Mac 的 Zipeg/iZip/UnRarX
-
适用于 Linux 的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Getting-Started-with-Web-Components。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包可供选择,请访问 github.com/PacktPublishing/。查看它们!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载: static.packt-cdn.com/downloads/9781838649234_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理。以下是一个示例:“寻找具有显示喜欢或不喜欢歌曲功能的音乐播放器的用户最终会使用 <music-player> 组件而不是其他东西。”
代码块设置如下:
class myClass {
constructor() {
// do stuff
}
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
class HelloWorld extends HTMLElement {
constructor() {
super();
// do magic here
this.innerText = 'Hello World';
}
}
任何命令行输入或输出都按以下方式编写:
$ py -m http.server
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“输入你的 NPM 包名并点击发布按钮。”
警告或重要注意事项看起来像这样。
技巧和窍门看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 邮箱联系我们。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一情况。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式发现了我们作品的非法副本,我们将不胜感激,如果您能向我们提供位置地址或网站名称。请通过发送链接至 copyright@packt.com 与我们联系。
如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需更多关于 Packt 的信息,请访问 packt.com。
第一章:Web Components 基础与规范
欢迎来到 Web Components 的世界。
如其名所示,Web Components 是可以在网站的不同部分重复使用的组件,同时保持封装性。它们甚至可以发布到网络上,并通过简单的导入在其他网站上使用。本书涵盖了关于 Web Components 所有的知识。包括它们由什么组成,如何使用以及适用场景。本书还涵盖了广泛的中高级概念,例如最佳实践以及将 Web Components 与其他技术集成。
在本章中,我们将讨论 Web Components 是什么,以及如何在浏览各种网站时识别它们。我们还将讨论构成 Web Components 的规范,以及详细的示例。你将能够理解自定义元素是什么,以及如何自己创建一个。你将能够借助阴影 DOM 封装你的 Web Components,并且可以使用模板来实现可重用性。
虽然本章只讨论了 Web Components 的基础知识,但到本章结束时,你将能够从头开始创建自己的 Web Components,并理解与之相关的规范。
在本章中,我们将涵盖以下主题:
-
Web Components
-
Web Component 规范
技术要求
为了运行代码,你需要一个简单的服务器,比如 Python 的 SimpleHTTPServer。为了在浏览器中查看代码,首先启动服务器。在 Mac 上,使用以下命令:
py -m SimpleHTTPServer
在 Windows 上,请在包含代码的文件夹中使用以下命令:
py -m http.server
然后你只需简单地访问 localhost:8080。它会在那个文件夹中为你运行 index.html。
Web Components
假设你有一部带触摸屏的手机。这个触摸屏是手机的一个组件,与电路板和电池等各个组件协同工作。我们中很少有人知道手机屏幕是如何单独工作的,但我们都能轻松地操作手机。同样,Web Components 也是网站复杂的构建块,它们使得网站对所有用户都变得可导航。
更重要的是,今天世界上数以百万计的手机屏幕在很大程度上基于只有少数几种设计。从根本上说,移动组件背后的技术是可重用和可适应的,同样的原则也适用于 Web Components。
上述点展示了组件方法在创建更好的产品中的有用性。现在,你可能正在想,为什么我们需要研究网页上的组件概念呢?好吧,我想让你回忆一下你最近访问的最后五个网站。这五个网站可能有一些共同点。其中一些是页眉、页脚、某种菜单以及广告部分。从功能的角度来看,所有这些功能都在做同样的事情。唯一不同的是外观和感觉。
让我们考虑另一个用例,其中网站域名相同,但在该域名上运行着多个 Web 应用。
我们都使用过 Google,或者至少使用过两个或三个 Google 服务。如果我们观察,在任何 Google 服务/网站上,右上角都有一个部分。这是你的账户信息,带有你的个人头像。它显示了你的登录账户列表:

当你从一个服务切换到另一个服务时,你将能够看到一个类似的账户信息卡片。想象一下,能够将这个功能转换成一个 HTML 标签<account-info>,并且能够在不同的服务上重复使用它。这可以通过 Web 组件的帮助来实现。
Web 组件是一组规范,允许用户创建具有特定外观和感觉的功能,并且可以以这种方式重复使用,使得所有这些功能都被封装起来。
就像前面的例子<account-info>一样,一个 Web 组件允许你将你的功能放入一个自定义的名称中,这个名称可以通过一个 HTML 标签来表示,然后封装其功能。这种封装使得其易于分发,并且可以非常容易地重复使用。
总的来说,一个 Web 组件允许你创建一个可重复使用的自定义 HTML 标签,其功能被封装,从用户那里隔离出来。
既然我们已经知道了什么是 Web 组件以及它们能做什么,让我们来谈谈 Web 组件规范。
Web 组件规范
就像任何技术一样,Web 组件也有需要遵循的一套规范,以实现与之相关的功能。
Web 组件规范包含以下部分:
-
自定义元素:能够创建自定义的 HTML 标签,并确保浏览器理解如何使用这个 HTML 标签
-
阴影 DOM:能够封装组件内容,使其从 DOM 的其他部分隔离出来
-
模板:能够创建一个可重复使用的 DOM 结构,可以即时修改并输出所需的结果
这三个规范,当一起使用时,提供了一种创建自定义 HTML 标签的方法,该标签可以输出所需的结果(DOM 结构),并且可以将其封装,使其可以重复使用。
既然我们已经了解了这些规范以及它们的作用,让我们逐一深入探讨它们,并尝试查看它们的 JavaScript API。
自定义元素
自定义元素规范允许你创建一个自定义 HTML 标签,该标签可以作为其自身的 HTML 标签在页面上使用。为了实现这一点,我们首先需要编写一个与该 HTML 元素相关的功能类,然后我们需要注册它,以便浏览器理解这个 HTML 标签是什么,以及如何在页面上使用它。
如果你是一个对 JavaScript 中的类概念新手,以下是如何创建一个类的方法:
class myClass {
constructor() {
// do stuff
}
}
很简单,对吧?让我们使用相同的类结构来创建我们的自定义元素类,比如说HelloWorld:
class HelloWorld extends HTMLElement {
constructor() {
// very important
// needed in every constructor
// which extends another class
super();
// do magic here
this.innerText = 'Hello World';
}
}
在前面的代码中,我们的自定义元素类被命名为HelloWorld,它从HTMLElement类扩展,这代表了 HTML 元素在页面上的工作方式。因此,HelloWorld现在知道点击事件是什么,CSS 是什么,等等,仅仅通过扩展HTMLElement。
在这个类中,我们有一个constructor()方法,它会在创建这个类的新实例时立即被调用。为了正确实例化扩展类的属性,需要调用super()函数。
上述代码只是创建了一个元素类定义。我们仍然需要注册这个元素。我们可以通过编写以下代码来完成:
customElements.define('hello-world', HelloWorld);
它所做的就是通过在customElements接口中使用define()接口定义类HelloWorld来注册这个类;hello-world是将在页面上可用的自定义元素的名称。
一旦定义了它,你就可以通过简单地写下以下 HTML 标签来使用自定义元素:
<hello-world><hello-world>
当这段代码在浏览器上运行时,它将渲染文本,Hello World。
最终代码:codepen.io/prateekjadhwani/pen/jJZmyy。
自定义元素的类型
现在你已经了解了如何注册自定义元素,是时候更深入地了解自定义元素的类型了。根据需求类型,我们可以创建两种类型的自定义元素:
- 自主自定义元素:任何可以独立使用,而不依赖于其他 HTML 元素的元素都可以被认为是自主自定义元素。从技术角度讲,任何扩展
HTMLElement的自定义元素都是自主自定义元素。
让我们再举一个自主自定义元素的例子。让我们创建一个SmileyEmoji元素,它显示一个笑脸表情符号。它看起来是这样的:
class SmileyEmoji extends HTMLElement {
constructor() {
super();
// let's set the inner text of
// this element to a smiley
this.innerText = '';
}
}
customElements.define('smiley-emoji', SmileyEmoji);
这将注册smiley-emoji自定义元素,可以使用如下方式:
<smiley-emoji></smiley-emoji>
- 自定义内置元素:这种类型的自定义元素可以扩展现有 HTML 标签的功能。让我们创建一个扩展
HTMLSpanElement而不是HTMLElement的自定义元素。它的功能是,比如说,需要在自定义元素的末尾添加一个笑脸表情符号:
class AddSmiley extends HTMLSpanElement {
constructor() {
super();
// lets append a smiley
// to the inner text
this.innerText += '';
}
}
customElements.define('add-smiley', AddSmiley, { extends: 'span' });
现在,如果你有以下的 HTML,这将把笑脸添加到文本Hello World的末尾:
<span is="add-smiley">Hello World</span>
最终代码:codepen.io/prateekjadhwani/pen/RdQarm。
尝试在浏览器、CodePen 或 JSFiddle 上运行自主自定义元素和自定义内置元素的代码。类和注册代码将在 JavaScript 块中,其余的将在 HTML 块中。
注意 <smiley-emoji> 和 <add-smiley> 自定义元素注册代码的差异。第二个使用一个额外的参数来指定它扩展的内容。
您可以使用以下代码检查自定义元素是否已经定义:
customElements.get('smiley-emoji');
如果它尚未注册,则它将返回 undefined,如果已注册,则返回类定义。这对于大型项目来说是一个非常有用的语句,因为注册已注册的自定义元素将破坏代码。
最终代码:codepen.io/prateekjadhwani/pen/moXPBd。
阴影 DOM
这是 Web Components 的第二个规范,它负责封装。CSS 和 DOM 都可以被封装,从而隐藏在页面的其余部分。阴影 DOM 所做的是让您创建一个新的根节点,称为阴影根,它从页面的正常 DOM 中隐藏。
然而,在我们深入阴影 DOM 的概念之前,让我们先看看一个正常的 DOM 是什么样子。任何带有 DOM 的页面都遵循树状结构。这里我有一个非常简单的页面的 DOM 结构:

在前面的图片中,您可以看到#document是此页面的根节点。
您可以在浏览器控制台中输入document.querySelector('html').getRootNode()来找到页面的根节点。
如果您尝试在浏览器控制台中使用document.querySelector('html').childNodes获取 HTML 标签的子节点,那么您可以看到以下截图:

最终代码:codepen.io/prateekjadhwani/pen/aMqBLa。
这表明 DOM 遵循树状结构。您可以通过点击节点名称旁边的箭头进一步深入这些节点。就像我在截图中所展示的那样,任何人都可以通过展开节点进入特定的节点并更改这些值。为了实现这种封装,发明了阴影 DOM 的概念。
阴影 DOM 所做的是让您创建一个新的根节点,称为阴影根,它从页面的正常 DOM 中隐藏。这个阴影根可以包含任何 HTML 内容,并且可以像任何正常的 HTML DOM 结构一样工作,具有事件和 CSS。但是,这个阴影根只能由连接到 DOM 的阴影宿主访问。
例如,假设在前面示例中的 <p> 标签内不是文本,而是一个连接到阴影根的阴影宿主。页面源代码将如下所示:

此外,如果你尝试获取这个 <p> 标签的子节点,你会看到如下内容:

注意到在阴影根中有一个 <span> 标签。即使这个 <span> 标签位于 <p> 标签内部,阴影根也不允许 JavaScript API 修改它。这就是阴影 DOM 如何封装其内部的代码。
最终代码:codepen.io/prateekjadhwani/pen/LaQxEY。
现在我们已经知道了阴影 DOM 是做什么的,让我们来看看代码,学习如何创建我们自己的阴影 DOM。
假设我们有一个具有类名 entry 的 DOM。它看起来是这样的:
<div class="entry"></div>
为了在这个 div 中创建一个阴影 DOM,我们首先需要获取这个 .entry div 的引用,然后我们需要将其标记为阴影根,然后将内容附加到这个阴影根上。所以,下面是创建 shadowRoot 并向其中添加内容的 JavaScript 代码:
// get the reference to the div
let shadowRootEl = document.querySelector('.entry');
// mark it as a shadow root
let shadowRoot = shadowRootEl.attachShadow({mode: 'open'});
// create a random span tag
// that can be appended to the shadow root
let childEl = document.createElement('span');
childEl.innerText = "Hello shadow DOM";
// append the span tag to shadow root
shadowRoot.appendChild(childEl);
最终代码:codepen.io/prateekjadhwani/pen/JzpWYE。
很简单,对吧?记住,我们仍在讨论阴影 DOM 规范。我们还没有开始在自定义元素中实现它。让我们回顾一下 hello-world 自定义元素的定义。它看起来是这样的:
class HelloWorld extends HTMLElement {
constructor() {
super();
// do magic here
this.innerText = 'Hello World';
}
}
customElements.define('hello-world', HelloWorld);
注意到文本 Hello World 目前正被添加到正常 DOM 中。我们可以使用之前在本自定义元素中讨论过的相同的阴影 DOM 概念。
首先,我们需要获取我们想要附加阴影根的节点的引用。在这种情况下,让我们通过以下代码将自定义元素本身作为阴影宿主:
let shadowRoot = this.attachShadow({mode: 'open'});
现在,我们既可以添加一个文本节点,也可以创建一个新的元素并将其附加到这个 shadowRoot 上:
// add a text node
shadowRoot.append('Hello World');
最终代码:codepen.io/prateekjadhwani/pen/LaQyPB。
模板
到目前为止,我们只创建了需要一行或最多两行 HTML 代码的自定义元素和阴影 DOM。如果我们转向现实生活中的例子,HTML 代码可以超过两行。它可以是从几个嵌套的 div 到图片和段落——你明白我的意思。模板规范提供了一种在浏览器中保留 HTML 而不实际在页面上渲染它的方法。让我们看看一个小型的模板示例:
<template id="my-template">
<div class="red-border">
<p>Hello Templates</p>
<p>This is a small template</p>
</div>
</template>
你可以在 <template> 标签内编写一个模板,并给它一个标识符,就像我通过 id 做的那样。你可以把它放在页面的任何地方;这无关紧要。我们可以通过 JavaScript API 获取其内容,然后克隆它并将其放入任何 DOM 中,就像我在下面的示例中展示的那样:
// Get the reference to the template
let templateReference = document.querySelector('#my-template');
// Get the content node
let templateContent = templateReference.content;
// clone the template content
// and append it to the target div
document.querySelector('#target')
.appendChild(templateContent.cloneNode(true));
同样,页面上可以有任意数量的模板,这些模板可以被任何 JavaScript 代码使用。
最终代码:codepen.io/prateekjadhwani/pen/ZPxOeq。
现在我们使用带有阴影 DOM 的相同模板。我们将保持模板不变。JavaScript 代码的更改将类似于以下内容:
// Get the reference to the template
let templateReference = document.querySelector('#my-template');
// Get the content node
let templateContent = templateReference.content;
// Get the reference to target DOM
let targetDOM = document.querySelector('#target');
// add a shadow root to the target reference DOM
let targetShadowRoot = targetDOM.attachShadow({mode: 'open'});
// clone the template content
// and append it to the target div
targetShadowRoot.appendChild(templateContent.cloneNode(true));
我们正在做与上一个示例中相同的事情,但不是直接将代码附加到目标div,而是首先将一个阴影根附加到目标div,然后附加克隆的模板内容。
最终代码:codepen.io/prateekjadhwani/pen/moxroz。
我们应该能够在使用阴影 DOM 的自定义元素中使用完全相同的概念。让我们试一试。
让我们编辑模板的id并将其命名为hello-world-template:
<template id="hello-world-template">
<div>
<p>Hello Templates</p>
<p>This is a small template</p>
</div>
</template>
我们将遵循与上一个示例中相同的方法。我们将从模板引用中获取模板内容,克隆它,并将其附加到自定义元素中,使自定义元素的代码看起来如下所示:
class HelloWorld extends HTMLElement {
constructor() {
super();
// Get the reference to the template
let templateReference = document.querySelector('#hello-world-template');
// Get the content node
let templateContent = templateReference.content;
let shadowRoot = this.attachShadow({mode: 'open'});
// add a text node
shadowRoot.append(templateContent.cloneNode(true));
}
}
customElements.define('hello-world', HelloWorld);
现在我们可以简单地使用以下代码在我们的页面中调用 HTML 标签:
<hello-world></hello-world>
如果我们在开发者工具中检查 DOM 结构,我们会看到以下内容:

最终代码:codepen.io/prateekjadhwani/pen/ywKgBp。
模块加载器 API
模块加载器 API 不是 Web 组件规范的一部分,但它确实是创建和使用多个类时非常有用的知识。正如其名所示,这个规范允许用户加载模块。也就是说,如果您有一系列类,您可以使用模块加载器将这些类加载到网页中。
如果您的构建过程涉及使用 WebPack 或 Gulp 或任何其他允许您直接或间接导入模块的工具,请随意跳过本节。
让我们从基础知识开始。假设我们的index.html如下所示:
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
</head>
<body>
<p>Placeholder for Random Number</p>
</body>
</html>
我们可以看到在这个 HTML 文件中有一个<p>标签。现在,假设我们有一个名为AddNumber的类,其目的是向这个<p>标签添加一个介于 0 和 1 之间的随机数。这将使代码看起来如下所示:
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
</head>
<body>
<p>Placeholder for Random Number</p>
<script type="text/javascript">
class AddNumber {
constructor() {
document.querySelector('p').innerText = Math.random();
}
}
new AddNumber();
</script>
</body>
</html>
简单吗?如果您在浏览器中打开页面,您将简单地看到一个随机数,如果您检查页面,您将看到随机数替换了<p>标签内的文本。
如果我们选择将其存储在 JavaScript 文件中,我们可以尝试使用以下代码导入它,其中addNumber.js是文件的名称:
<script type="text/javascript" src="img/addNumber.js"></script>
现在,假设您有一个randomNumberGenerator函数而不是Math.random()方法。代码将如下所示:
class AddNumber {
constructor() {
// let's set the inner text of
// this element to a smiley
document.querySelector('p').innerText = randomNumberGenerator();
}
}
function randomNumberGenerator() {
return Math.random();
}
new AddNumber();
我们还希望用户能够创建一个新的AddNumber类对象,而不是我们在文件中创建它。我们不希望用户知道randomNumberGenerator是如何工作的,因此我们希望用户只能创建AddNumber的对象。这样,我们就了解了模块的工作方式。作为模块的创建者,我们决定用户可以使用哪些功能以及他们不能使用哪些功能。
我们可以使用export关键字来帮助用户选择他们可以使用的内容。这样代码看起来会像这样:
//addNumber.js
export default class AddNumber {
constructor() {
document.querySelector('p').innerText = randomNumberGenerator();
}
}
function randomNumberGenerator() {
return Math.random();
}
当这个文件被导入时(注意我们还没有讨论导入),用户只能使用AddNumber类。randomNumberGenerator函数对用户不可用。
类似地,如果你有另一个包含两个其他函数的文件,比如add()和subtract(),你可以像下面这样导出它们:
// calc.js
export function add(x, y) {
return x + y;
}
export function subtract(x, y) {
return x - y;
}
使用导入关键字可以轻松地导入模块。在本节中,我们将讨论type="module"属性。
在 HTML 文件index.html内部,我们可以使用type=module而不是type=text/javascript来告诉浏览器我们正在导入的文件是一个模块。当我们尝试导入addNumber.js文件时,它将看起来像这样:
<script type="module" >
import AddNumberWithNewName from './addNumber.js';
new AddNumberWithNewName();
</script>
如果我们从calc.js模块导入函数,它看起来会是这样:
<script type="module" >
import {add, subtract} from './calc.js';
console.log(add(1,5));
</script>
注意我们如何可以更改使用export default导出的AddNumber模块的名称,以及我们如何必须使用与使用export导出的函数相同的名称。
命名导出与默认导出
在前面的例子中,即addNumber.js和calc.js,我们看到了有两种方式可以导出内容:export和export default。理解它的最简单方式如下:当一个文件使用不同的名称导出多个内容,并且这些名称在导入后不能更改时,它是一个命名导出,而当我们只从一个模块文件中导出一个内容,并且这个名称在导入后可以被更改成任何名称时,它是一个默认导出。
使用导入的自定义元素
假设我们需要创建一个 Web 组件,它执行一个非常简单的任务,即在内部显示一个标题和一段段落,并且自定义元素的名称应该是<revamped-paragraph>。这个 Web 组件的定义看起来会是这样:
//revampedParagraph.js
export default class RevampedParagraph extends HTMLElement {
constructor() {
super();
// template ref and content
let templateReference = document.querySelector('#revamped-paragraph-template');
let template = templateReference.content;
// adding html from template
this.append(template.cloneNode(true));
}
}
我们的index.html文件,导入此模块的文件,将看起来像这样:
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<title>Revamped Paragraph</title>
<!--
Notice how we use type="module"
-->
<script type="module">
// imports object from the module
// and names it as RevampedParagraph
// You can name it anything you want
// since it is a default export
import RevampedParagraph from './revampedParagraph.js';
// We are now defining the custom element
customElements.define('revamped-paragraph', RevampedParagraph);
</script>
</head>
<body>
<revamped-paragraph></revamped-paragraph>
<!--
Template for
Revamped Paragraph
-->
<template id="revamped-paragraph-template">
<h1>Revamped Paragraph</h1>
<p>This is the default paragraph inside
the revamped-paragraph element</p>
</template>
</body>
</html>
注意模板是如何成为我们 HTML 的一部分,以及它在模块导入时是如何被使用的。我们将在下一章学习从 Web 组件的实际注册到它们从页面上移除时发生的一切步骤,其中我们将学习生命周期方法。但就目前而言,我们需要查看更多示例来了解如何创建 Web 组件。
让我们看看另一个例子。在这个例子中,我们需要在index.html文件中导入多个 Web 组件。组件如下:
-
学生出勤表组件:一个显示索引号、学生姓名和复选框中出勤情况的表格。这些数据是从
student.json文件中获取的。 -
信息横幅组件:此组件的目的是显示这些学生所在学校的电话号码和地址。
-
时间槽组件:一个允许用户在三个时间组之间选择课程时间槽的组件。
让我们从第一个开始,即<student-attendance-table>组件。我们首先需要确定它需要什么。在我看来,这些是它需要的:
-
对
student.json文件的fetch调用。 -
字符串每一行的模板。我将在这里使用模板字符串。
-
当它正在打电话时,显示默认文本“正在加载...”,当获取学生列表失败时,显示文本“无法检索学生列表”。
这就是我们的student.json文件看起来像这样:
[
{
"name": "Robert De Niro",
"lastScore": 75
},
{
"name": "Jack Nicholson",
"lastScore": 87
},
{
"name": "Marlon Brando",
"lastScore": 81
},
{
"name": "Tom Hanks",
"lastScore": 62
},
{
"name": "Leonardo DiCaprio",
"lastScore": 92
}
]
这就是 Web 组件的定义看起来像这样:
// StudentAttendanceTable.js
export default class StudentAttendanceTable extends HTMLElement {
constructor() {
super();
// we simply called another method
// inside the class
this.render();
}
render() {
// let put our loading text first
this.innerText = this.getLoadingText();
// let's start our fetch call
this.getStudentList();
}
getStudentList() {
// lets use fetch api
// https://developer.mozilla.org/en-US/docs/Web
// /API/Fetch_API/Using_Fetch
fetch('./student.json')
.then(response => {
// converts response to json
return response.json();
})
.then(jsonData => {
this.generateTable(jsonData);
})
.catch(e => {
// lets set the error message for
// the user
this.innerText = this.getErrorText();
// lets print out the error
// message for the devs
console.log(e);
});
}
generateTable(names) {
// lets loop through names
// with the help of map
let rows = names.map((data, index) => {
return this.getTableRow(index, data.name);
});
// creating the table
let table = document.createElement('table');
table.innerHTML = rows.join('');
// setting the table as html for this component
this.appendHTMLToShadowDOM(table);
}
getTableRow(index, name) {
let tableRow = `<tr>
<td>${index + 1}</td>
<td>${name}</td>
<td>
<input type="checkbox" name="${index}-attendance"/>
</td>
</tr>`;
return tableRow;
}
appendHTMLToShadowDOM(html) {
// clearing out old html
this.innerHTML = '';
let shadowRoot = this.attachShadow({mode: 'open'});
// add a text node
shadowRoot.append(html);
}
getLoadingText() {
return `loading..`;
}
getErrorText() {
return `unable to retrieve student list.`;
}
}
注意函数getLoadingText()和getErrorText()。它们的目的仅仅是返回一个文本。然后render()方法首先调用getLoadingText()方法,然后使用getStudentList()调用进行调用以从student.json文件中获取学生列表。
一旦获取到学生列表,它就会被传递到generateTable()方法,其中每个name和其index都会传递到getTableRow()方法以生成行,然后返回以成为表格的一部分。一旦表格形成,它就会被传递到appendHTMLToShadowDOM()方法,以便添加到组件的 shadow DOM 中。
是时候查看<information-banner>组件了。由于这个组件只需要显示他们学习的学校的电话号码和地址,我们可以使用<template>并使其工作。这是它的样子:
//InformationBanner.js
export default class InformationBanner extends HTMLElement {
constructor() {
super();
// we simply called another method
// inside the class
this.render();
}
render() {
// Get the reference to the template
let templateReference = document.querySelector('#information-banner-template');
// Get the content node
let templateContent = templateReference.content;
let shadowRoot = this.attachShadow({mode: 'open'});
// add the template text to the shadow root
shadowRoot.append(templateContent.cloneNode(true));
}
}
此外,information-banner-template看起来像这样:
<template id="information-banner-template">
<div>
<a href="tel:1234567890">Call: 1234567890</a>
<div>
<p>Just Some Random Street</p>
<p>Town</p>
<p>State - 123456</p>
</div>
</div>
</template>
如您所见,它与我们在前几节中讨论的自定义元素没有太大区别。
让我们继续到最后一个自定义元素,即<time-slot>组件。由于它也涉及预设的时间段数量,我们可以使用<template>标签来完成我们的工作。
模板看起来可能像这样:
<template id="time-slot-template">
<div>
<div>
<input type="radio" name="timeslot" value="slot1" checked> 9:00
AM - 11:00 AM
</div>
<div>
<input type="radio" name="timeslot" value="slot2"> 11:00 AM -
1:00 PM
</div>
<div>
<input type="radio" name="timeslot" value="slot3"> 1:00 PM - 3:00
PM
</div>
</div>
</template>
<time-slot>组件的定义看起来像这样:
// TimeSlot.js
export default class TimeSlot extends HTMLElement {
constructor() {
super();
// we simply called another method
// inside the class
this.render();
}
render() {
// Get the reference to the template
let templateReference = document.querySelector('#time-slot-
template');
// Get the content node
let templateContent = templateReference.content;
let shadowRoot = this.attachShadow({mode: 'open'});
// add the template text to the shadow root
shadowRoot.append(templateContent.cloneNode(true));
}
}
它与上一个组件相同。
现在我们已经编写了 Web 组件,是时候看看包含所有这些组件的index.html文件了。这是它的样子:
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<title>Student Page</title>
<!--
Notice how we use type="module"
-->
<script type="module">
// importing the first custom element
import StudentAttendanceTable from './StudentAttendanceTable.js';
// importing the second custom element
import InformationBanner from './InformationBanner.js';
// importing the third custom element
import TimeSlot from './TimeSlot.js';
customElements.define('student-attendance-table',
StudentAttendanceTable);
customElements.define('information-banner', InformationBanner);
customElements.define('time-slot', TimeSlot);
</script>
</head>
<body>
<time-slot></time-slot>
<student-attendance-table></student-attendance-table>
<information-banner></information-banner>
<template id="information-banner-template">
<div>
<a href="tel:1234567890">Call: 1234567890</a>
<div>
<p>Just Some Random Street</p>
<p>Town</p>
<p>State - 123456</p>
</div>
</div>
</template>
<template id="time-slot-template">
<div>
<div>
<input type="radio" name="timeslot" value="slot1" checked>
9:00 AM - 11:00 AM
</div>
<div>
<input type="radio" name="timeslot" value="slot2"> 11:00 AM -
1:00 PM
</div>
<div>
<input type="radio" name="timeslot" value="slot3"> 1:00 PM -
3:00 PM
</div>
</div>
</template>
</body>
</html>
如您所见,一个type="module"的<script>标签可以一起导入这三个组件,并注册自定义元素,这些元素可以在<body>标签中使用。
摘要
在本章中,我们讨论了 Web 组件以及我们如何在日常的网页访问中识别它们。我们还讨论了与 Web 组件相关的规范,这使得进一步理解变得更加容易。我们探讨了自定义元素以及如何创建我们自己的自定义元素。我们还讨论了阴影 DOM 以及它为我们的 Web 组件提供的一定程度的封装。然后我们讨论了模板以及它们如何在 Web 组件内部提供可重用性。此外,我们还探讨了模块以及它们如何让您动态创建和添加 Web 组件。
我们深入探讨了使用详细代码示例创建 Web 组件。通过这种方式,我们应该能够从头开始创建一个简单的 Web 组件而不会遇到任何问题。
在下一章中,我们将探讨如何利用生命周期方法让我们的 Web 组件做得更多。
第二章:Web 组件生命周期回调方法
在上一章中,我们讨论了如何使用纯 JavaScript 和 HTML5 创建 Web 组件。我们讨论了创建 Web 组件概念所需的规范。在本章中,我们将讨论生命周期事件及其相关的回调方法。生命周期事件是在 Web 组件的生命周期中发生的事件。本章将处理这些事件以及如何通过回调方法访问它们。
在本章中,我们将涵盖以下主题:
-
生命周期回调方法的概述
-
Web 组件中当前可用的生命周期回调方法
生命周期回调方法的概述
生命周期事件是在 Web 组件达到执行某个阶段时在组件内部触发的事件。这些阶段反映了创建 Web 组件的整体过程,并且可以通过生命周期回调方法来控制。生命周期回调方法是当 Web 组件经过这些生命周期事件时被调用的钩子或接口。
让我用一个例子来解释这一点。假设你有一双你想穿的鞋。这双鞋的生命周期可能有一些相关的事件。比如说,你想穿上它。你把脚伸进去,系上鞋带。这会触发一个名为 lacesTied() 的事件。现在,作为穿着这双鞋的用户,你可以选择对其做出反应。你可以编写一个条件块来检查 lacesTiedStrength > 100 或 shoeSize < requiredShoeSize。这取决于你想做什么。同样,与 Web 组件相关联的生命周期回调方法可以帮助用户捕捉到某些执行状态,并有效地编写代码。
生命周期回调方法的类型
目前 Web 组件有四个生命周期回调可用。具体如下:
-
connectedCallback() -
disconnectedCallback() -
adoptedCallback() -
attributeChangedCallback()
让我们详细讨论它们。
connectedCallback()
这个接口/回调函数会在每次将一个 Web 组件的副本添加到 DOM 中时被调用。当需要在组件内部初始化与 DOM 相关的事件或状态管理(见第五章,管理状态和属性),或需要进行任何类型的初始化或预检查时,这非常有用。
让我们来看一个例子。在第一章,Web 组件基础和规范中,我们讨论了一个 <student-attendance-table> 组件,其中 Web 组件会向 student.json 文件发起 fetch 调用,以检索出勤数据,然后以表格的形式显示这些数据。
正确编写该 Web 组件的方法是在 StudentAttendenceTable 类的定义中添加一个 connectedCallback() 方法,然后在回调函数内部进行获取调用。
这就是我们的代码会看起来像这样:
// StudentAttendanceTable.js
export default class StudentAttendanceTable extends HTMLElement {
constructor() {
super();
this.innerText = this.getLoadingText();
}
connectedCallback() {
// let's start our fetch call
this.getStudentList();
}
getStudentList() {
// lets use fetch api
// https://developer.mozilla.org/en-US/docs/Web
// /API/Fetch_API/Using_Fetch
fetch('./student.json')
.then(response => {
// converts response to json
return response.json();
})
.then(jsonData => {
this.generateTable(jsonData);
})
.catch(e => {
// lets set the error message for
// the user
this.innerText = this.getErrorText();
// lets print out the error
// message for the devs
console.log(e);
});
}
generateTable(names) {
// lets loop through names
// with the help of map
let rows = names.map((data, index) => {
return this.getTableRow(index, data.name);
});
// creating the table
let table = document.createElement('table');
table.innerHTML = rows.join('');
// setting the table as html for this component
this.appendHTMLToShadowDOM(table);
}
getTableRow(index, name) {
let tableRow = `<tr>
<td>${index + 1}</td>
<td>${name}</td>
<td>
<input type="checkbox" name="${index}-attendance"/>
</td>
</tr>`;
return tableRow;
}
appendHTMLToShadowDOM(html) {
// clearing out old html
this.innerHTML = '';
let shadowRoot = this.attachShadow({mode: 'open'});
// add a text node
shadowRoot.append(html);
}
getLoadingText() {
return `loading..`;
}
getErrorText() {
return `unable to retrieve student list.`;
}
}
如您在代码中所见,我们现在在 connectedCallback() 方法中调用获取学生列表。这确保了代码在 Web 组件附加到网页后执行。
使用 connectedCallback 有帮助的另一个例子是事件处理。假设我们有一个显示自定义按钮的 Web 组件。这个按钮的目的是在它旁边显示一些文本,说明按钮被点击的次数。如果我们尝试在不使用 connectedCallback 的情况下使用它,它看起来可能就像这样:
// CustomButton.js
export default class CustomButton extends HTMLElement {
constructor() {
super();
// Initializing an initial state
this.timesClicked = 0;
let template = `
<button>Click Me</button>
<span>${this.getTimesClicked()}</span>
`;
this.innerHTML = template;
}
connectedCallback() {
// adding event handler to the button
this.querySelector('button')
.addEventListener('click', (e) => {
this.handleClick(e);
});
}
handleClick() {
// updating the state
this.timesClicked++;
this.querySelector('span')
.innerText = this.getTimesClicked();
}
getTimesClicked() {
return `Times Clicked: ${this.timesClicked}`;
}
}
相关的 HTML 会看起来像这样:
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<title>Connected Callback Example</title>
<!--
Notice how we use type="module"
-->
<script type="module">
/// importing the first custom element
import CustomButton from './CustomButton.js';
customElements.define('custom-button', CustomButton);
</script>
</head>
<body>
<custom-button></custom-button>
</body>
</html>
注意到在 connectedCallback() 方法中绑定了一个事件监听器。我们将在第五章 [0330b4ac-5fed-441c-8747-ef9f14f91418.xhtml],管理状态和属性 中详细讨论事件监听器,但现在;我们可以用代码作为例子。前面的代码确保只有在 DOM 可用后,才将点击事件绑定到按钮上。这可以防止我们创建与事件相关的错误,我相信这肯定发生在我们每个人身上。
disconnectedCallback()
就像在将 Web 组件添加到 DOM 时需要执行某些操作一样,在组件从 DOM 中移除后也需要执行某些操作。这种情况最常见的例子又是事件处理器。事件处理器消耗内存,当与它们关联的 DOM 被移除时,事件处理器仍然在页面上监听事件,仍然消耗内存。回调函数 disconnectedCallback() 为 Web 组件提供了一种编写代码来处理这些情况的方法。
让我们来看看 <custom-button> 元素,以及我们如何使用 disconnectedCallback() 来移除附加的事件:
// CustomButton.js
export default class CustomButton extends HTMLElement {
constructor() {
super();
// Initializing an initial state
this.timesClicked = 0;
let template = `
<button>Click Me</button>
<span>${this.getTimesClicked()}</span>
`;
this.innerHTML = template;
}
connectedCallback() {
// adding event handler to the button
this.querySelector('button')
.addEventListener('click', this.handleClick.bind(this));
}
disconnectedCallback() {
console.log('We are inside disconnectedCallback');
// adding event handler to the button
this.querySelector('button')
.removeEventListener('click', this.handleClick);
}
handleClick() {
// updating the state
this.timesClicked++;
this.querySelector('span')
.innerText = this.getTimesClicked();
}
getTimesClicked() {
return `Times Clicked: ${this.timesClicked}`;
}
}
如果你查看 disconnectedCallback() 方法,我们会看到一个 console.log 语句和移除事件的代码。当你在一个页面上运行这个 Web 组件时,你可以手动移除组件,并看到 disconnectedCallback() 会自动被调用。我更喜欢去开发者控制台,输入以下代码来观察这个过程:
document.querySelector('custom-button').remove();
这将从页面上移除第一个 <custom-button> 实例,从而触发 disconnectedCallback() 方法。
移除事件处理器只是其中一种用途。在从 DOM 中移除 Web 组件之前,可能需要执行任何数量的用例。
adoptedCallback()
当 Web 组件从一个父元素移动到另一个父元素时,这个回调会被触发。
就像我们有 connectedCallback 和 disconnectedCallback 一样,我们可以这样编写 adoptedCallback:
adoptedCallback() {
console.log('I am adopted');
}
attributeChangedCallback()
由于所有自定义元素都像任何其他 HTML 元素一样行动和表现,它们也有能力在其内部拥有属性。我们将在下一章讨论属性,但现在,让我们假设我们有一个名为 <my-name> 的自定义元素,其目的是显示文本“Hello, my name is John Doe”。
因此,这个 Web 组件的定义可能如下所示:
// MyName.js
export default class MyName extends HTMLElement {
constructor() {
super();
this.innerText = 'Hello, my name is John Doe';
}
}
现在,假设我们想要一个不同的名称。对于每个不同的名称,我们都需要为自定义元素定义一个不同的定义,从而创建一个完全不同的 Web 组件。为了解决这个问题,我们可以使用属性。我们可以在该元素的 HTML 标签内的属性中传递名称,使其看起来像这样:
<my-name fullname="John Doe"></my-name>
但是,为了使 Web 组件使用提供的属性,我们首先要求它观察某些属性,我们可以像这样提供一个数组:
static get observedAttributes() {
return ['fullname'];
}
在这里,我们只是将要观察 fullname。你可以根据需求添加更多。我们将在接下来的章节中深入探讨属性。
一旦我们开始观察这些属性,我们就可以使用 attributeChangedCallback() 方法根据需求对自定义元素进行必要的更改。我只是在下面的回调中更新了名称:
attributeChangedCallback(name, oldValue, newValue) {
if (name == 'fullname') {
this.innerText = 'Hello, my name is ' + newValue;
}
}
如你所见,attributeChangedCallback() 接收三个参数:name,即更改的属性的名称,以及 oldValue 和 newValue,分别是更改前后的值。
在前面的代码中,我们只是检查属性的名称是否为 fullname,并将文本更新为更新的名称。
完整的组件代码如下所示:
// MyName.js
export default class MyName extends HTMLElement {
constructor() {
super();
this.innerText = 'Hello, my name is NO NAME';
}
static get observedAttributes() {
return ['fullname'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name == 'fullname') {
this.innerText = 'Hello, my name is ' + newValue;
}
}
}
与其相关的 index.html 文件如下所示:
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<title>Attribute Changed Callback Example</title>
<!--
Notice how we use type="module"
-->
<script type="module">
/// importing the first custom element
import MyName from './MyName.js';
customElements.define('my-name', MyName);
</script>
</head>
<body>
<my-name fullname="John Doe"></my-name>
</body>
</html>
如你所见,我们并没有在前面章节中做任何不同的事情。
摘要
在本章中,我们讨论了生命周期回调方法是什么以及它们的作用。我们讨论了 connectedCallback()、disconnectedCallback()、adoptedCallback() 和 attributeChangedCallback()。我们探讨了如何使用这些回调及其实际用途的多种示例。
在下一章中,我们将探讨如何使用 CSS 来美化我们的 Web 组件,然后我们将讨论黄金标准清单及其目的。
第三章:通用 Web 组件
在上一章中,我们讨论了 Web 组件的各种生命周期回调方法。在本章中,我们将探讨 Web 组件的样式化,并给出很多示例。样式在 Web 组件的外观和感觉中起着至关重要的作用。我们还将探讨可访问性对 Web 组件的影响,并理解金标准清单的意义以及这个金标准清单如何使 Web 组件极其易用。
在本章中,我们将涵盖以下主题:
-
Web 组件的样式化
-
Web 组件的可访问性
-
金标准清单
Web 组件的样式化
在前面的章节中,我们探讨了使用 shadow DOM 进行封装的定制元素和不使用 shadow DOM 的定制元素。我们将为这两种类型的 Web 组件添加样式。
假设我们有一个名为 <company-header> 的 Web 组件。为了简化,这个头部组件需要在左侧有一个图标,并且这个图标需要有一个圆形的边框;这个图标需要是一个链接;页面的名称应该紧挨着图标,然后应该在最右侧再有两个其他链接,比如主页和关于我们。

这是在 index.html 文件中的使用方式:
<company-header
icon="icon.png"
page-name="My Page">
</company-header>
如果你有点冒险精神,我希望你暂时停止阅读,并根据你在前几章中获得的知识编写这个组件的完整代码。一旦你完成了,请随意继续阅读。
现在,根据为组件提供的要求信息,我们的 index.html 文件可能看起来像这样:
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<title>Custom header</title>
<!--
Notice how we use type="module"
-->
<script type="module">
import CompanyHeader from './CompanyHeader.js';
// We are now defining the custom element
customElements.define('company-header', CompanyHeader);
</script>
</head>
<body>
<company-header
icon="icon.png"
page-name="My Page">
</company-header>
</body>
</html>
如您所见,我们除了调用 <company-header> 组件的方式外,没有做任何不同的事情。让我们看看 CompanyHeader.js 文件。请注意,在这个例子中,我们将不使用 shadow DOM 来使用 Web 组件:
// CompanyHeader.js
export default class CompanyHeader extends HTMLElement {
constructor() {
// We are not even going to touch this.
super();
// Lets provide a default icon
this.icon = 'newicon.jpeg';
// Then lets render the template
this.render();
}
render() {
this.innerHTML = this.getTemplate();
}
// Lets get icon and page-name from attributes
static get observedAttributes() {
return ['icon', 'page-name'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name == 'icon') {
this.icon = newValue;
}
if (name == 'page-name') {
this.pageName = newValue;
}
// Lets re-render after getting the new attributes
this.render();
}
getTemplate() {
return `
<a href="/">
<img class="icon" src="img/${this.icon}" />
</a>
<h1 class="heading">${this.pageName}</h1>
<div>
<a class="header-links" href="/home.html">Home</a>
<a class="header-links" href="/aboutus.html">About Us</a>
</div>
`;
}
}
constructor() 接口负责确保 icon 被设置为默认文件,并且组件能够无任何问题地正确渲染。我们也在上一节中学习了 attributeChangedCallback(),所以 get observedAttributes() 简单地创建了一个用于监听变化的属性列表。
此外,attributeChangedCallback() 确保更改后的属性值被正确使用。getTemplate() 方法简单地返回一个 ES6 模板字符串,它可以被设置为 Web 组件的 innerHTML。
现在我们的 Web 组件运行良好,让我们为这个组件添加样式。在 index.html 文件中,我们可以创建一个 <style> 标签并在其中添加我们的样式:
<style>
company-header {
display: flex;
background: #44afdc;
align-items: center;
padding: 0 10px;
}
.icon {
width: 50px;
height: 50px;
border-radius: 50%;
}
.heading {
flex: 1;
color: white;
padding-left: 20px;
}
.header-links {
text-decoration: none;
padding: 20px;
color: white;
}
</style>
这样,我们就可以将样式直接附加到 <company-header> 元素上。
然而,现在我们可能遇到了一个问题。可能存在一些其他带有.heading类名的div,并且这个 CSS 可能会扩散到那个其他类。您可能会争辩说我们应该通过在 CSS 前添加company-header来对 CSS 进行命名空间,使其看起来像下面这样:
<style>
company-header {
display: flex;
background: #44afdc;
align-items: center;
padding: 0 10px;
}
company-header .icon {
width: 50px;
height: 50px;
border-radius: 50%;
}
company-header .heading {
flex: 1;
color: white;
padding-left: 20px;
}
company-header .header-links {
text-decoration: none;
padding: 20px;
color: white;
}
</style>
这可能稍微解决了问题,但并没有完全解决。它仍然没有解决.heading类 CSS 扩散到company-header中的.heading类,然后被company-header命名空间标题类覆盖的问题。因此,出现了我们的阴影 DOM 规范。
让我们尝试编写一个带有阴影 DOM 的 Web 组件:
// CompanyHeader.js
export default class CompanyHeader extends HTMLElement {
constructor() {
// We are not even going to touch this.
super();
// Lets provide a default icon
this.icon = 'newicon.jpeg';
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
// Then lets render the template
this.render();
}
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
// Lets get icon and page-name from attributes
static get observedAttributes() {
return ['icon', 'page-name'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name == 'icon') {
this.icon = newValue;
}
if (name == 'page-name') {
this.pageName = newValue;
}
// Lets re-render after getting the new attributes
this.render();
}
getTemplate() {
return `
<a href="/">
<img class="icon" src="img/${this.icon}" />
</a>
<h1 class="heading">${this.pageName}</h1>
<div>
<a class="header-links" href="/home.html">Home</a>
<a class="header-links" href="/aboutus.html">About Us</a>
</div>
`;
}
}
如您所见,大部分代码是相同的,除了我们创建一个阴影根并在其中添加我们的 HTML 的部分。
如果你现在运行代码,你会看到我们的样式在 Web 组件内部不适用。所以,CSS 不再扩散。但是,为了恢复我们的 CSS,我们需要将其作为模板的一部分添加,使其看起来像这样:
getTemplate() {
return `
<a href="/">
<img class="icon" src="img/${this.icon}" />
</a>
<h1 class="heading">${this.pageName}</h1>
<div>
<a class="header-links" href="/home.html">Home</a>
<a class="header-links" href="/aboutus.html">About Us</a>
</div>
<style>
:host {
display: flex;
background: #44afdc;
align-items: center;
padding: 0 10px;
}
.icon {
width: 50px;
height: 50px;
border-radius: 50%;
}
.heading {
flex: 1;
color: white;
padding-left: 20px;
}
.header-links {
text-decoration: none;
padding: 20px;
color: white;
}
</style>
`;
}
现在,我们的组件有了所有的 CSS 看起来很漂亮,但:host是什么?
虽然我们可以通过选择器在阴影根内部添加 CSS,但我们没有与阴影根本身关联的选择器,它充当 HTML 的容器。因此,我们可以通过:host选择器将 CSS 附加到这个阴影根。
让我们尝试另一个带有样式的示例。假设我们有一个设计 Web 组件的需求,让您通过登录表单登录。它需要一个蓝色的背景,当登录成功时,它应该变成绿色调。为了简单起见,用户名-密码检查将简单地执行Math.random()操作,如果这个值大于0.5,则登录成功:

让我们来看看代码。除了包括这个新组件之外,我们的index.html没有其他变化:
<script type="module">
import CompanyLogin from './CompanyLogin.js';
// We are now defining the custom element
customElements.define('company-login', CompanyLogin);
</script>
这个CompanyLogin类的定义在上面的代码中显示。
让我们来看看我们的 HTML 模板。我们想要一个用户名文本字段,一个密码文本字段,以及一个可以点击的按钮:
getTemplate() {
return `
<input type="text" name="username" placeholder="Username"/>
<input type="password" name="password" placeholder="Password"/>
<button type="submit" class="login-button">Login</button>
`;
}
然后我们需要将这个模板 HTML 添加到我们的阴影根中:
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
this.shadowObj.innerHTML = this.getTemplate();
我们还需要一种方法来通知我们的 Web 组件何时按钮被点击:
connectedCallback() {
this.shadowObj.querySelector('button')
.addEventListener('click', (e) => this.handleLogin(e));
}
我们在这里选择了connectedCallback()来处理事件,因为我们只需要在 HTML 在页面上时绑定事件。我们可以在handleLogin()方法内部处理我们的点击事件:
handleLogin(e) {
this.username = this.shadowObj.querySelector('[name=username]').value;
this.password = this.shadowObj.querySelector('[name=password]').value;
// Do what ever you want with these values
console.log(this.username, this.password);
// We will do things as per our requirement
let loginSuccess = Math.random();
if(loginSuccess > 0.5) {
this.classList.add('login-success');
} else {
this.classList.add('login-failure');
}
}
如您所见,我们只是打印了用户名和密码的值。我选择添加一个打印示例,以便您了解这些值非常容易阅读。我们还在根据Math.random()函数更改实际 Web 组件的类名。
现在我们有一个工作的组件,我们可以开始工作在 CSS 上了。我们可以在我们的模板中添加我们的 CSS:
<style>
:host {
background: #68afe8;
padding: 20px;
display: flex;
flex-direction: column;
width: 400px;
margin: 0 auto;
}
:host(.login-failure) {
background: #f35353;
}
:host(.login-success) {
background: #499c19;
}
input {
margin-top: 5px;
padding: 10px;
height: 30px;
font-size: 15px;
border: none;
border-radius: 5px;
}
button {
margin-top: 15px;
padding: 10px;
font-size: 15px;
border: none;
height: 50px;
border-radius: 5px;
cursor: pointer;
}
</style>
我们再次使用 :host 选择器作为向我们的 Web 组件的 shadow root 添加 CSS 的方式。在 handleLogin() 函数中,我们向我们的 Web 组件添加 CSS 类。我们可以通过使用 :host(<selector>) 来定位这些类,就像我们在前面的代码中所做的那样。
当我们在浏览器上运行我们的 Web 组件时,它看起来是这样的:

根据 random() 函数,我们可以得到一个带有绿色背景的背景,表示登录成功,或者带有红色背景的背景。
Web 组件的可访问性
可访问性在 Web 开发中起着至关重要的作用。我们的用户可能只能使用键盘,他们可能在使用屏幕阅读器,或者可能是色盲。确保我们的用户在所有情况下都感到舒适是制作一个良好网站的关键。同样,创建一个良好的 Web 组件也包括使其可访问。
当你创建一个 Web 组件时,你需要确保你的 Web 组件至少在某种程度上是可访问的。例如,图片应该始终有 alt 文本。链接应该始终有 alt 文本。输入字段应该有适当的 aria-labels。应该有足够的颜色对比度。Tab 顺序应该是正确的,等等。
既然我们知道如何使组件可访问,让我们看看一个小例子。假设要求是创建一个 <header-image> 组件,它显示一个全宽图片。为了确保这个组件是可访问的,使用的图片应该有 alt 文本。
让我们看看这个组件的 getTemplate() 函数:
getTemplate() {
return `
<img src="img/${this.getAttribute('src')}"
alt="${this.getAttribute('alt')}"/>
${this.handleErrors()}
<style>
img {
width: 400px;;
}
</style>
`;
}
在这里,我们向图片标签添加一个 alt 属性,并且我们从 Web 组件本身获取这个 alt 文本:
<header-image alt="Blue Clouds"
src="img/clouds-sky-header.jpg">
</header-image>
我们还有一个名为 handleErrors() 的错误处理函数,它确保告诉用户组件缺少 alt 文本:
handleErrors() {
if(!this.getAttribute('alt')) {
return `
<div class="error">Missing Alt Text</div>
<style>
.error {
color: red;
}
</style>
`;
}
return ``;
}
当组件缺少 alt 文本时,这将在红色中显示“缺少 Alt 文本”错误消息。我们可以以同样的方式解决其他可访问性问题。
金标准清单
在前面的章节中,我们已经创建了 Web 组件,但除了可访问性之外,没有其他章节告诉我们什么定义了一个好的组件。所以,让我们来谈谈它。金标准清单是一个工作草案(见 github.com/webcomponents/gold-standard/wiki),它告诉 Web 组件的创建者为了创建一个良好且可重用的组件应该注意哪些事情。
让我们谈谈我个人认为重要的几点:
-
Web 组件应该是可访问的。为了确保 Web 组件在所有屏幕上都能工作,我们需要确保组件覆盖所有可访问性方面。
-
事件绑定应该在
connectedCallback()中进行。这确保了绑定事件的 DOM 始终存在,从而减少了错误数量。 -
事件绑定应该在
disconnectedCallback()中移除,从而释放不再需要的内存。 -
组件应该具有默认样式,并且具有良好的对比颜色。这将确保组件在所有时候都能被正确地看到。
-
组件还应遵循响应式设计。为了确保我们的组件能够在所有屏幕尺寸上工作而不会出现布局问题,我们应该确保我们为 Web 组件关联了响应式 CSS。
-
组件应该能够暴露事件。我们将在第五章中讨论事件,管理状态和属性,但这个要点的主要收获是,如果你正在构建一个需要通知其他组件状态变化的组件,它应该通过暴露事件回调来通知它们。
尽管黄金标准清单包含了许多非常好的观点,但我认为这六个观点足以使组件在可重用性方面相当不错。如果你对其他观点感兴趣,这里是可以查看完整黄金标准清单的链接:github.com/webcomponents/gold-standard/wiki。
摘要
在本章中,我们探讨了不同的样式化 Web 组件的方法,了解了可访问性以及如何在创建更完整的 Web 组件中使用它,然后探讨了黄金标准清单,它为创建良好的 Web 组件提供了指南。
在下一章中,我们将探讨可重用性以及如何在 Web 组件中使用它,以及这些 Web 组件如何发布到网络上以实现最大程度的可重用性。
第四章:构建可重用 Web 组件
在上一章中,我们讨论了为我们的 Web 组件添加样式,以及了解可访问性在创建良好组件中的重要作用。然后我们讨论了黄金标准清单并探讨了单元测试。
在本章中,我们将深入探讨可重用性以及我们如何将我们的 Web 组件发布到平台上以实现最大限度的可重用性。我们还将涵盖一些关于响应式 Web 组件的例子。我们已经探讨了样式,虽然响应式 CSS 的概念并不新颖,但响应式样式的使用可以使组件看起来更好,更具可重用性。
在本章中,我们将涵盖以下主题:
-
可重用性的概念
-
响应式 Web 组件
-
发布 Web 组件
-
扩展 Web 组件 - 插槽
可重用性的概念
在我们深入探讨 Web 组件的可重用性概念之前,让我们通过一个例子来看看什么是可重用性。让我们以一个操作系统为例,比如 Windows 10。我们都知道有很多电脑。有些硬件相同,有些不同。但使这款软件(Windows 10)可重用的原因是它能够在不同场景下反复使用而不会出现任何问题。同样的事情也可以使 Web 组件工作。
是的,Web 组件可以被制作得极具可重用性。假设我们有一个 Web 组件<custom-header>。正如其名称所示,它是一个标题。并且我们访问过的几乎所有网站都有一个或另一个相同功能的标题版本。其功能如下:
-
显示标志。
-
点击标志应将用户带到
index.html。 -
显示公司名称。
-
显示用户统计数据,即如果用户未登录,显示登录下拉菜单。如果用户已登录,显示与账户相关的链接。
-
显示帮助链接。
-
显示“关于我们”和“联系我们”链接。
-
为标题提供默认背景。
-
标题可以固定在顶部。
这些点都向我们展示了如何自定义和实现标题的各种方式。我们可以在设计 Web 组件时将这些点很好地转换为属性列表:
<custom-header
logo-url="icon.png"
logo-alt-text="Company X logo"
company-name-text="Company X"
is-logged-in="user23411"
help-link="/help.html"
help-link-text="Help and Support"
contact-us-link="/contact.html"
contact-us-alt-text="Contact Us"
background-color="#000000"
text-color="#ffffff"
is-sticky="true">
</custom-header>
或者,如果用户未登录,它可以是空的,如下所示:
<custom-header
logo-url="icon.png"
logo-alt-text="Company X logo"
company-name-text="Company X"
is-logged-in=""
help-link="/help.html"
help-link-text="Help and Support"
contact-us-link="/contact.html"
contact-us-alt-text="Contact Us"
background-color="#000000"
text-color="#ffffff"
is-sticky="true">
</custom-header>
所有这些属性使 Web 组件极具可重用性。使用此组件的人只需导入它并提供这些属性的值,而无需编写任何额外的代码。
让我们来看看另一个,但更复杂的例子。假设你正在构建一个音乐播放器。一个 Web 组件<music-player>可能有一系列属性使其极具可重用性。常见的属性如下:
-
歌曲名称
-
歌曲的 URL
-
播放器颜色/对比度选项
-
是否正在播放,以判断播放器是否正在播放
-
加载时播放,以判断播放器是否应在加载时开始播放
-
显示或隐藏播放列表
不常见的例子可能如下:
-
播放器大小可以设置为大型、中型或小型
-
底部粘性或顶部粘性,就像
Soundcloud一样 -
喜欢或不喜欢歌曲
了解这些属性并能够实现它们在创建可重用组件中起着非常重要的作用。一个寻找具有显示喜欢或不喜欢歌曲功能的音乐播放器的用户最终会使用上面提到的 <music-player> 组件而不是其他东西。
可重用性的概念可以也应该应用到所有 Web 组件中。这不仅允许 Web 组件在更多场景中使用,还使得它更容易维护,因为我们确保它在更多场景中都能正常工作。
响应式 Web 组件
在上一章中,我们讨论了为我们的 Web 组件添加样式以使其看起来更美观。这次,我们将从可重用性的角度来探讨。如果试图重用我们的 Web 组件的人决定将其用于内联标签,或者如果 Web 组件被用作全宽组件,会发生什么?让我们看看我们的 Web 组件在不同盒模型中的显示方式以及它在不同屏幕尺寸下的外观。
构建 <profile-info> Web 组件
让我们看看一个示例 Web 组件。假设我们有一个名为 <profile-info> 的 Web 组件。这个 Web 组件的目的是显示员工的信息。这些信息可能包括姓名、职位、ID 号码、个人照片以及表示员工是全职员工、兼职员工还是承包商的卡片背景颜色。
这个 <profile-info> 组件的 HTML 结构看起来可能像这样:
<profile-info
name="John Doe"
designation="Frontend Engineer - Marketing"
id-number="PRC-1455"
picture-src="img/john-doe.png"
employee-type="ft">
</profile-info>
从属性列表中,我们可以看到它需要一个名称、职位、ID 号码、图片链接和员工类型。这就是它在功能方面需要做的事情。在外观和感觉方面,它需要看起来像一张带有圆形个人照片的卡片,以及所有其他剩余信息。并且根据屏幕分辨率,它应该是全宽的,或者如果屏幕是移动设备,它应该以卡片的形式显示。
让我们在 Web 组件上采用移动优先的方法,并开始编写代码:
export default class ProfileInfo extends HTMLElement {
constructor() {
// We are not even going to touch this.
super();
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
// Then lets render the template
this.render();
}
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
// Show HTML Here
}
}
这是我们在前几章中介绍的最基本的部分。我们只是在创建组件骨架并确保从 getTemplate() 方法检索到 shadow DOM 的 HTML。
对于移动视图,卡片应该看起来像这样:

HTML 结构看起来可能像这样:
getTemplate() {
return `
<div class="profile-info__container">
<img class="profile-info__picture"
src="img/${this.getAttribute('picture-src')}" />
<div class="profile-info__text">
<div class="profile-info__name">
${this.getAttribute('name')}
</div>
<div class="profile-info__designation">
${this.getAttribute('designation')}
</div>
<div class="profile-info__id-number">
${this.getAttribute('id-number')}
</div>
</div>
</div>
`;
}
如果你看看类名,它们都在使用 BEM 模式。
如果你不知道 BEM 是什么,BEM 的全称是 Block Element Modifier。它是一种使用逻辑和可重用 CSS 类的方法,用于模块化 HTML 块。如果你想了解更多,请随时查看以下链接:getbem.com/。
现在,让我们看看构建这个卡片所需的 CSS。我将 CSS 包装在另一个名为 getStyles() 的方法中,然后将其包含在 getTemplate() 方法中:
getStyles() {
return `
<style>
:host {
display: block;
font-family: sans-serif;
}
:host(.profile-info__emp-type-ft) {
background-color: #7bb57b;
}
:host(.profile-info__emp-type-pt) {
background-color: #ffc107;
}
:host(.profile-info__emp-type-ct) {
background-color: #03a9f4;
}
.profile-info__container {
display: flex;
color: white;
flex-direction: column;
text-align: center;
}
.profile-info__picture {
border-radius: 50%;
width: 80vw;
height: 80vw;
margin: 10px auto;
}
.profile-info__text {
padding: 10px;
flex: 1;
}
.profile-info__name {
font-size: 28px;
}
.profile-info__designation {
font-size: 22px;
margin-top: 10px;
}
.profile-info__id-number {
margin-top: 10px;
}
</style>
`;
}
让我们在 getTemplate() 方法中添加 getStyle() 方法:
getTemplate() {
return `
...
...
...
${this.getStyles()}
`;
}
如果您查看 getStyles() 方法,我们有这些类:
-
:host(.profile-info__emp-type-ft), -
:host(.profile-info__emp-type-pt), 和 -
:host(.profile-info__emp-type-ct).
这些根据员工类型(即全职、兼职或承包商)分别更改卡片的颜色。
但我们仍然没有办法添加这些类,因此我们创建了一个新的函数 updateCardBackground(),它将负责将相关类添加到 Web 组件中。然后我们将在 render() 方法中调用这个类:
updateCardBackground() {
this.classList.add(`profile-info__emp-type-${this.getAttribute('employee-type')}`);
}
render() {
this.shadowObj.innerHTML = this.getTemplate();
this.updateCardBackground();
}
它只是在获取员工类型并将其添加到宿主类的名称中。
因此,如果员工是全职,则类变为 .profile-info__emp-type-ft;如果员工是兼职,则类变为 .profile-info__emp-type-pt;如果员工是承包商,则类变为 .profile-info__emp-type-ct。注意它如何从属性中获取员工类型并将其附加到字符串 .profile-info__emp-type- 的末尾。
现在我们已经完成了组件的创建和样式设置,让我们添加用于更大屏幕的 CSS,比如平板电脑和桌面。为了简单起见,我们将为桌面和平板电脑使用相同的 CSS。所以,让我们将以下 CSS 添加到我们的 getStyles() 方法中:
@media screen and (min-width: 650px) {
.profile-info__container {
flex-direction: row;
text-align: left;
}
.profile-info__picture {
width: 100px;
height: 100px;
margin: 10px;
}
}
这确保了 Web 组件看起来像是从联系人簿中来的,就像我们在桌面上看到的那样。并且它只会在屏幕尺寸超过 650px 时显示:

如果您正在与这个教程一起构建 Web 组件,请尝试更改屏幕宽度。
您可以使用类似的方法处理任何 Web 组件,并确保它在从一种屏幕尺寸移动到另一种屏幕尺寸时看起来很好。
发布 Web 组件
无论您是在为公司、副项目还是开源项目开发 Web 组件,您都可以通过在网络上发布您的 Web 组件,非常容易地使其可供其他人或团队成员使用。
但在您发布之前,您需要确保以下步骤已完成:
-
您的组件可以通过
npm进行安装 -
在您的组件存储库中有一个适当的 README 文件,其中包含使用步骤和可以更改的属性
-
一个包含
index.html文件的示例工作文件夹
让我们为 npm 准备我们的文件。为此,让我们在 repo 目录中使用终端快速执行 npm init。我将使用我们在上一节中讨论的 <profile-info> 组件。这将生成一个类似这样的 package.json 文件:
{
"name": "profile-info",
"version": "0.0.1",
"description": "A webcomponent that shows information about an employee in the form of a profile card.",
"main": "ProfileInfo.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"webcomponent",
"component",
"profile",
"info",
"employee"
],
"author": "Prateek Jadhwani",
"license": "ISC",
"repository": "https://github.com/prateekjadhwani/profile-info"
}
然后,我们在组件目录中创建一个 ReadMe.md 文件。并在顶部添加以下文本:
[](https://www.webcomponents.org/element/owner/my-element)
我添加了以下内容:
[](https://www.webcomponents.org/element/prateekjadhwani/profile-info)
这将在 webcomponents.org 网站上创建一个发布徽章,供访问你的 GitHub 或 GitLab 页面的人查看。它看起来像这样:

完成此操作后,我们可以设置我们的 Readme.md 文件以创建一个演示部分。
你可以在 Readme.md 文件中添加有关属性的信息,以及如何使用它,如下所示:
```html
<profile-info
name="John Doe"
designation="Frontend Engineer - Marketing"
id-number="PRC-1455"
picture-src="img/john-doe.png"
employee-type="ft">
</profile-info>
```js
现在你已经准备好在 NPM JS 网站上发布你的 Web 组件了。只需输入 npm publish,它就会将你的代码推送到网站。
你可以在www.npmjs.com/package/profile-info:找到这个 <profile-info> Web 组件。

完成这些后,你可以简单地访问www.webcomponents.org/publish,滚动到“Ready To Publish?”部分,然后输入你的 npm 包名并点击发布按钮:

这将发布你的 Web 组件,任何人都可以使用。现在你的组件可以在整个网络中分发。
扩展 Web 组件 – 插槽
到目前为止,我们使用的是没有 HTML 内容的 Web 组件。也就是说,当我们导入我们创建的 Web 组件的 HTML 标签时,我们从未在它里面放置任何其他 HTML 标签,例如:
<custom header>
<!-- no html here -->
</custom-header>
这对我们正在创建的 Web 组件造成了巨大的限制。在 <custom header> 组件中,我们未能添加动态链接。你可以争论说,我们可以将链接数据以属性的形式放置,然后我们可以在组件定义内部运行循环来构建链接。但如果我们想用按钮而不是链接呢?如果我们想显示用户积分而不是按钮呢?所以,不能做这些事情是一个限制。
在本节中,我们将扩展我们对 Web 组件的知识,并使用插槽的概念将 HTML 内容放入我们的 Web 组件中。插槽是任何可以放置在 Web 组件内部的 HTML 标记的占位符。插槽可以有一个名称,并且这个插槽可以包含 HTML 或纯文本,这些可以在我们的组件中使用。
我们将从我们已经工作的 Web 组件 <company-header> 开始。该组件的 getTemplate() 方法如下:
getTemplate() {
return `
<a href="/">
<img class="icon" src="img/${this.getAttribute('icon')}" />
</a>
<h1 class="heading">${this.getAttribute('page-name')}</h1>
<div>
<a class="header-links" href="/home.html">Home</a>
<a class="header-links" href="/aboutus.html">About Us</a>
</div>
`;
}
在前面的代码中,我们可以看到有两个链接,Home和About Us。如果我们想添加另一个链接,我们需要修改 Web 组件的定义,这反过来又会给维护带来问题,并且每次我们想要添加新链接时,我们都必须创建一个新的版本。
为了解决这个问题,我们将使用槽位。我们将用名为 other-links 的槽位替换包含链接的整个 div。让我们看看代码:
getTemplate() {
return `
<a href="/">
<img class="icon" src="img/${this.getAttribute('icon')}" />
</a>
<h1 class="heading">${this.getAttribute('page-name')}</h1>
<div>
<slot name="other-links"></slot>
</div>
`;
}
这样,我们就可以创建一个槽位,当我们使用 Web 组件时可以填充它:
<company-header icon="icon.png" page-name="My Page">
<ul slot="other-links" class="header-links__container">
<li>
<a class="header-links" href="/home.html">Home</a>
</li>
<li>
<a class="header-links" href="/aboutus.html">About Us</a>
</li>
</ul>
</company-header>
在这里,我们用具有 slot 属性且值为 other-links 的 <ul> 标签填充槽位。你可以在该槽位内放置任何 HTML。你甚至可以用纯文本替换它。
在 Web 组件中可以有任意数量的槽位。这完全取决于你的用例。但,让我们看看另一个例子,其中我们使用了三个槽位。
创建 Web 组件
让我们创建一个名为 <my-article> 的 Web 组件,它有三个槽位:author、article-heading 和 article。Web 组件的定义如下,从模板开始:
getTemplate() {
return `
<h1 class="article-heading">
<slot name="article-heading"></slot>
</h1>
<div class="article-author">
<slot name="author"></slot>
</div>
<div class="article-content">
<slot name="article"></slot>
</div>
${this.getStyle()}
`;
}
如你所见,有三个槽位。一个用于标题,一个用于作者姓名,一个用于内容。getStyle() 方法看起来像这样:
getStyle() {
return `
<style>
:host {
display: block;
background: #e4f4fb;
padding: 10px;
}
.article-heading {
text-align: right;
text-transform: lowercase;
font-size: 50px;
margin-bottom: 0;
}
.article-author {
text-align: right;
text-transform: lowercase;
font-style: italic;
font-size: 22px;
padding-bottom: 20px;
border-bottom: 2px solid black;
}
.article-content {
line-height: 1.5;
margin-top: 20px;
}
.article-content::first-letter {
font-size: 50px;
line-height: 0;
}
</style>
`;
}
当我们尝试使用它时,它看起来像这样:
<my-article>
<span slot="article-heading">A random article</span>
<span slot="author">Prateek Jadhwani</span>
<div slot="article">
<p>This is a demo paragraph</p>
<p>This is another demo paragraph</p>
</div>
</my-article>
我们当然可以在调用 <my-article> 标签时改变槽位的顺序,它仍然会按照定义工作。输出将看起来像这样:

如你所见,我们不必担心内容的样式,只要 Web 组件内部的类负责样式即可。这也表明,使用多少个槽位取决于用户。
摘要
在本章中,我们讨论了可重用性以及如何确保我们的 Web 组件尽可能可重用。我们还探讨了如何将响应性融入 Web 组件以实现最大重用。
我们学习了如何在互联网上发布我们的 Web 组件,以及使用槽位让动态内容进入 Web 组件的新方法。
在下一章中,我们将探讨状态管理、属性以及事件处理在 Web 组件中的工作方式。
第五章:管理状态和属性
在上一章中,我们讨论了可重用性和如何将应用程序发布到互联网上。我们还探讨了插槽以及它们如何有助于扩展我们的 Web 组件。
在本章中,我们将探讨状态管理。状态管理让您能够跟踪 Web 组件所处的状态。这是一个非常有用的技术。我们还将探讨属性,以创建更好的 Web 组件。我们自从 第二章,“Web 组件生命周期回调方法”以来一直在使用属性。但在本章中,我们将从状态管理的角度来探讨它。然后我们将探讨事件和事件管理,以及这些事件如何用来通知用户 Web 组件的状态。
在本章中,我们将涵盖以下主题:
-
状态管理的介绍
-
管理属性和属性
-
事件处理
状态管理的介绍
任何可以用来管理用户界面(UI)状态的东西都可以被认为是状态管理。我们在我们每天使用的几乎每个网站上都能看到状态管理的例子。你使用 Gmail 或任何其他电子邮件服务。电子邮件有“已读”或“未读”的状态。如果你在 Spotify 上播放歌曲,你正在听的歌曲有“喜欢”或“不喜欢”的状态。基于这些状态,UI 可以以不同的方式显示。
Web 组件采用类似的方法。我们可以在 Web 组件内部使用一个变量来跟踪状态。假设我们想要创建一个 Web 组件,用来告诉用户他们所使用的设备是否在线。那么,这里的状态将是 isOnline,其值可以是 online 或 offline。那么,让我们开始吧。
让我们称这个组件为 <online-checker>,并且假设它的状态由一个内部变量 _isOnline 管理。这个组件的定义可能看起来像这样:
export default class OnlineChecker extends HTMLElement {
constructor() {
// We are not even going to touch this.
super();
this._isOnline = false;
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
}
}
在这里,我们将 _isOnline 的初始值设置为 false,因为我们不知道我们是否在线。
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<span class="online-status online-${this._isOnline ? 'true' : 'false'}"></span>
<span>${this._isOnline ? 'Online' : 'Offline'}</span>
${this.getStyle()}
`;
}
render() 方法与我们的前例相同,没有什么特别之处。特别之处在于 getTemplate() 方法。在这里,我们根据 _isOnline 变量添加一个类 online-true 或 online-false。我们也在相同的基础上添加文本 online 或 offline。
getStyle() 方法看起来像这样:
getStyle() {
return `
<style>
:host {
display: inline-block;
border: 1px solid #cac6c6;
padding: 10px;
border-radius: 5px;
}
.online-status {
height: 10px;
width: 10px;
border-radius: 50%;
display: inline-block;
}
.online-true {
background-color: green;
}
.online-false {
background-color: red;
}
</style>
`;
}
类 .online-true 显示一个绿色圆圈,而 .online-false 显示红色。
我们还没有添加检查浏览器是否在线的代码。所以,让我们添加它:
connectedCallback() {
this.isOnline = navigator.onLine;
this.render();
}
set isOnline(value) {
if(value !== this._isOnline) {
this._isOnline = value;
this.render();
}
}
get isOnline(){
return this._isOnline;
}
在这里,我们使用 connectedCallback() 来查看我们是否在线。我们使用 connectedCallback() 是因为我们想要确保这段代码在 Web 组件在页面上时触发。
get isOnline() 和 set isOnline() 方法为组件创建了一个属性,可以在组件外部使用。所以,假设你有这样的代码:
document.querySelector('online-checker').isOnline;
这将根据isOnline属性返回true或false。
因此,我们在_isOnline变量中跟踪浏览器的在线或离线状态,并通过isOnline属性提供这个值:

这是对 Web 组件内部属性的一个非常简单的介绍。我们将在接下来的章节中查看更多示例。
属性和属性
我们从第一章开始就在玩弄属性了。我们也简要概述了属性以及它们如何与状态管理一起工作,以提供更完整的 Web 组件。
但这两者之间究竟有什么确切的区别呢?如果你是一名前端开发者,在你的职业生涯中一定创建过表单。我们将通过一个例子来查看一个<input>标签:
<input type="text" value="default value" />
如果你仔细看,我们会发现有一个名为value的属性给它赋予了一些默认值。所以如果你想获取这个<input>标签的值,你可以使用以下代码来获取它:
document.querySelector('input').getAttribute('value');
因此,你直接引用这个<input>标签的属性来获取值。但还有一种方法可以获取这个值。如下所示:
document.querySelector('input').value;
这次,我们从<input>标签的value属性中获取值。
现在的问题是,区别在哪里?区别在于是否将其显示为属性。总会有一些你可能不希望显示在 HTML 代码中的值。它可能太长了,比如音乐播放器 Web 组件中的播放列表,其中列表包含歌曲名称和 URL 的 JSON 风格数据结构,或者像税务注册组件中的税务 ID 号(如 SSN),其中数据过于敏感,不适合作为属性。
让我们通过一个例子来尝试理解这一点。假设我们有一个名为<student-list>的 Web 组件,其中包含一个用于输入学生姓名的输入字段和一个允许你将学生添加到学生列表中的按钮。这个组件看起来是这样的:
constructor() {
// We are not even going to touch this.
super();
// Initially, the list is empty
this._list = [];
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
this.render();
}
在这里,我们正在_list变量中管理学生列表。其余的与平常一样:
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<div class="student-list__form">
<input type="text" name="student-name"
class="student-list__input"
placeholder="Enter Student Name here"/>
<button class="js-addButton student-list__add-button">Add Student</button>
</div>
<div class="student-list__student-container">
<div class="student-list__student-container-heading">Student List</div>
<div class="student-list__student-list">
${this.getStudents()}
</div>
</div>
${this.getStyle()}
`;
}
如你所见,我们有一个输入字段、一个按钮,以及一个div student-list__student-list来将我们的学生以列表的形式放入表单中:
getStudents() {
return this._list.map((item, num) => {
return `<div class="student-list__student">${num + 1}. ${item}</div>`;
}).join('');
}
这个getStudents()方法通过运行我们在constructor()方法中声明的_list变量来显示学生。在我们继续这个 Web 组件的其他部分之前,让我们看看我们的样式:
getStyle() {
return `
<style>
:host {
display: block;
}
.student-list__form {
display: flex;
align-items: center;
}
.student-list__input {
height: 44px;
margin: 0 25px;
width: 300px;
border-radius: 10px;
border-width: 1px;
font-size: 18px;
padding: 0 20px;
}
.student-list__add-button {
height: 50px;
width: 200px;
border-radius: 5px;
display: inline-block;
border: 1px solid #cac6c6;
}
.student-list__student-container {
margin-top: 50px;
border-top: 1px solid black;
padding-top: 50px;
font-size: 25px;
}
.student-list__student-container-heading {
margin-bottom: 20px;
}
.student-list__student {
padding: 10px;
margin-bottom: 10px;
border-bottom: 1px solid #bfbfbf;
}
</style>
`;
}
这只是基本的 CSS,没有什么复杂的。现在,让我们给我们的按钮添加一个事件监听器,以便它可以将学生添加到我们的_list变量中:
connectedCallback() {
// what should happen when the button is clicked
this.shadowObj.querySelector('.js-addButton')
.addEventListener("click", (e) => {
this.handleAdd(e);
});
}
handleAdd() {
let value = this.shadowObj.querySelector('input[name=student-name]').value;
this._list.push(value);
this.renderList();
}
renderList() {
this.shadowObj.querySelector('.student-list__student-list').innerHTML
= this.getStudents();
}
这里,我们给 .js-addButton 按钮添加了一个点击事件监听器。当用户点击按钮时,它会获取输入字段的值,并将其推送到我们的 _list 变量。之后,我们只是重新渲染列表;换句话说,我们不是从头开始设置组件的内部 HTML,而是简单地更改需要更新的部分的 HTML。
但如果用户想查看学生列表或从组件中获取它呢?为此,让我们为我们的用户添加一个名为 students 的属性:
set students (value) {
this._list = value;
this.renderList();
}
get students (){
return this._list;
}
这样,用户可以通过以下代码获取学生列表:
document.querySelector('student-list').students;
这将使用数组的形式向用户展示所有已添加的学生:

但现在你可能正在想,如果我们想将这个功能放在属性中会怎样?答案是肯定的,我们可以做到。我们可以更新我们的 handleAdd() 方法,使其类似于以下内容:
handleAdd() {
let value = this.shadowObj.querySelector('input[name=student-name]').value;
this._list.push(value);
this.setAttribute("students", this._list);
this.renderList();
}
这将使列表在名为 students 的属性中可用。但这个属性看起来是这样的:

你真的想让用户手动解析字符串来获取一个数组吗?如果这个数据更复杂一点呢?用户知道需要解析什么吗?为了解决这些复杂问题,我们使用属性。
希望这个用例能帮助你决定在属性中放置什么,在属性中放置什么。
事件处理
到目前为止,我们只关注了 Web 组件内部的按钮点击事件。本节从不同的角度处理事件处理器。
假设我们有一个 Web 组件 <custom-clicker>,它有一个按钮和一个显示按钮被点击次数的数字。让我们看看这个 Web 组件的定义:
constructor() {
// We are not even going to touch this.
super();
// Initially, the list is empty
this._num = 0;
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
this.render();
}
我们将 _num 的值设置为 0。其余的与平常一样:
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<div class="custom-clicker__container">
<div class="custom-clicker_num">${this.getTimesClicked()}</div>
<button class="js-button custom-clicker__button">Click Me</button>
</div>
${this.getStyle()}
`;
}
render() 和 getTemplate() 方法几乎相同。我们只是展示了通过 getTimesClicked() 方法获取的文本以及一个写着 点击我 的按钮:
getTimesClicked() {
return `${this._num} times clicked.`;
}
这里,我们只是获取 _num 的值并添加一些信息性文本。getStyle() 方法看起来像这样:
getStyle() {
return `
<style>
:host {
display: block;
}
.custom-clicker__button {
height: 50px;
width: 200px;
border-radius: 5px;
display: inline-block;
border: 1px solid #cac6c6;
}
</style>
`;
}
我们还希望当用户点击按钮时增加 _num 的值:
connectedCallback() {
// what should happen when the button is clicked
this.shadowObj.querySelector('.js-button')
.addEventListener("click", (e) => {
this.handleClick(e);
});
}
handleClick() {
this._num++;
this.shadowObj.querySelector('.custom-clicker_num').innerHTML
= this.getTimesClicked();
}
当用户点击按钮时,我们只是调用 handleClick() 方法。然后我们简单地将 _num 变量加 1 并更新 .custom-clicker__num div。
现在,我们想让用户知道按钮被点击时的值。我们可以通过自定义事件来实现这一点,使用 dispatchEvent():
handleClick() {
this._num++;
this.shadowObj.querySelector('.custom-clicker__num').innerHTML
= this.getTimesClicked();
this.dispatchEvent(new CustomEvent('change', {
detail: {
num: this._num,
},
bubbles: true,
}));
}
这会通知监听器 num 变量的变化,并且可以通过以下代码进行监听:
<custom-clicker onchange="handleChange(event.detail)"></custom-clicker>
<script type="text/javascript">
function handleChange(e) {
console.log(e);
}
</script>
或者,我们可以使用以下代码:
<custom-clicker></custom-clicker>
<script type="text/javascript">
document.querySelector('custom-clicker').addEventListener('change', (e) => {
console.log(e.detail);
});
</script>
我们可以用 e.detail.num 变量做任何我们想做的事情。
这样,我们可以添加任意数量的自定义事件来通知用户 Web 组件的任何变化。需要传递的信息可以放在detail对象中。
摘要
在本章中,我们探讨了状态管理的各个方面。我们讨论了如何使用属性和属性来增强 Web 组件。最后,我们为我们的 Web 组件创建了自定义事件。
在下一章中,我们将使用到目前为止所学到的所有概念创建一个完整的单页 Web 应用。我们将创建页面级别的 Web 组件,实现路由以及更多功能。
第六章:使用 Web Components 构建单页应用
到目前为止,我们一直将 Web Components 作为一个独立实体来使用。但 Web Components 可以用来构建更复杂的东西。在本章中,我们将使用 Web Components 来构建一个单页网络应用。
在本章中,我们将涵盖以下主题:
-
理解项目需求
-
确定可重用 Web 组件
-
配置启动项目和 API
-
应用组件
-
其他组件
-
实现路由
-
启用分析
理解项目需求
当涉及到单页网络应用时,它可以是从一页到一千页,你可以在网络应用上显示的内容。但为了这个网络应用的简单性,我们将将其限制在最多三页。我们将尝试创建的项目是一个 GIF 收藏网络应用。
我们都上网过,见过梗图和 GIF 是如何传播的。在这个网络应用中,我们将构建一个类似 GIF 库的东西。这个网络应用的目的让用户看到一系列热门 GIF,搜索特定主题,或者随机查看一个 GIF。
我们还将使用 GIPHY API 来获取 GIF。这样,我们就不必担心手动在网上扫描 GIF 了。
现在我们对我们网络应用及其背后的目的有了基本了解,让我们看看我们如何将这个需求转换成一组可重用的 Web Components。
确定可重用 Web 组件
我们旨在创建的 Web 应用的首页可能看起来像这样:

本页展示了顶部有一个标题栏,一个输入字段和一个按钮,可以用来搜索字符串,以及一组结果。当我们把这个页面分解成一组组件时,组件列表看起来可能像这样:
-
标题组件:一个可以在所有页面上使用的标题栏。它需要固定在顶部,点击链接应该改变 URL。
-
GIF 封面组件:一个组件,它接受一个 URL 作为属性并显示它。它还可以有一个高度限制。
-
搜索栏组件:一个负责从用户那里获取输入并使用 API 搜索字符串的组件。搜索完成后,它通过自定义事件返回结果。
-
搜索容器:一个包含搜索栏组件的组件,并将根据搜索栏得到的结果显示 GIF 封面组件。
让我们看看热门页面。这个页面应该做的事情,就像搜索页面一样,是展示一系列 GIF,但不是让用户搜索特定的字符串,而是需要展示热门 GIF。你应该能在 Giphy 网站上找到类似的东西:giphy.com/trending-gifs。
这就是它的样子:

如你所见,它与搜索页面看起来并没有太大的不同。让我们将页面分解为 Web Components:
-
标题组件:与之前相同
-
GIF 封面:与上一个页面中用来显示 GIF 的相同组件
-
显示趋势组件:容器组件,将调用 API 获取趋势 GIF 并创建 GIF 封面组件集合
总的来说,我们将在这个页面上使用三个组件。
让我们看看最后一页。这个页面负责显示随机生成的 GIF,它看起来是这样的:

如你所见,页面顶部有一个标题,一个随机的 GIF,以及一个获取另一个随机 GIF 的按钮。让我们将其分解为 Web Components:
-
标题组件:与之前相同。
-
GIF 封面:与上一个相同,但我们不会看到很多。
-
显示随机组件:一个负责调用 API 获取随机 GIF 的组件。它还需要有一个按钮,当点击时需要再次触发 API。
现在我们知道了该项目所需的 Web Components,让我们开始工作。
配置入门项目和 API
入门项目是最简约的项目,它为单页网页应用进行了配置。你可以从 Starter Project 目录下载它,并通过以下链接将其放置在电脑上的任何位置:github.com/PacktPublishing/Getting-Started-with-Web-Components/tree/master/Chapter06
预先条件
在开始使用此项目之前,请确保你的电脑上已安装 Node.js。你可以从 Node.js 网站(nodejs.org/en/)安装它,或者如果你想的话,可以使用 Homebrew(brew.sh/)来安装它。
设置项目
一旦安装完 Node.js,你将需要安装一些使项目能够正常工作而无需在我们这一端进行大量手动配置的包。所有包都已经指定在 package.json 文件中。如果你想的话,可以随意查看这个文件的内容。最重要的包是 webpack,它将被用来打包我们的代码,以便在服务器上提供服务。另一个重要的包是 node-sass。它将帮助我们用 SCSS 编写代码。
我假设你稍微了解一些 SCSS。它主要是 CSS,但如果你有疑问,可以随意查看 SCSS 文档(sass-lang.com/documentation/syntax)。
你可以通过在终端中输入以下步骤来安装相关的包:
cd Chapter\ 06/Starter\ Project/
npm install
这将安装该项目所需的所有包。不过,根据你的网络连接速度,可能需要几分钟。
运行启动项目
现在我们已经安装了所有依赖项,是时候运行启动项目并看看它的样子了。
要启动项目,请在终端中运行以下命令:
npm start
这将显示以下输出:
> node webpack.dev.server
Hash: ecc08467bc66f8944b6b
Version: webpack 3.12.0
Time: 1284ms
Asset Size Chunks Chunk Names
bundle.js 19.3 kB 0 [emitted] main
[0] ./src/index.js 131 bytes {0} [built]
[1] ./src/styles.scss 1.13 kB {0} [built]
[2] ./node_modules/css-loader!./node_modules/sass-loader/lib/loader.js!./src/styles.scss 225 bytes {0} [built]
[3] ./node_modules/css-loader/lib/css-base.js 2.26 kB {0} [built]
[4] ./node_modules/style-loader/lib/addStyles.js 8.7 kB {0} [built]
[5] ./node_modules/style-loader/lib/urls.js 3.01 kB {0} [built]
[6] ./src/components/my-app/index.js 541 bytes {0} [built]
webpack: Compiled successfully.
这意味着 webpack 已经从六个列出的文件中创建了一个 bundle.js 文件。然后,你只需打开浏览器并访问以下 URL:http://localhost:3000。
这将显示带有文本“我的应用”的启动项目。
API 调用的先决条件
作为用户,你需要注册一个 API 密钥,这可以通过遵循以下步骤完成:
-
前往以下网址并注册一个免费账户:
developers.giphy.com -
一旦你完成了账户的创建,可以通过点击顶部的“创建新应用”按钮来创建一个应用,如下所示:

- 一旦你完成了应用的创建,你将被带到仪表板页面,在那里你可以看到你注册的应用以及所需的 API 密钥,如下面的截图所示:

如果你方便的话,可以查看 API 文档:developers.giphy.com/docs/。
但你不必过分担心 API;当我们谈到组件时,我们会讨论这个问题。
应用组件
在我们查看 <my-app> 组件之前,让我们看看当你访问 localhost:3000 时会发生什么。index.html 文件会运行。index.html 的内容看起来像这样:
<html>
<head>
<title>My App</title>
</head>
<body>
<my-app></my-app>
<script src="img/bundle.js"></script>
</body>
</html>
如你所见,它正在尝试渲染 <my-app> 组件。但它试图从 bundle.js 文件中获取 MyApp 的定义。正如之前讨论的,这个 bundle.js 文件是页面所需的所有组件的集合,将被 <my-app> 组件所需要。这个 bundle.js 文件是由 webpack 创建的。而 bundle.js 文件的配置可以在 webpack.config.js 文件中找到,看起来像这样:
entry: './src/index.js',
被选中的入口文件是 /src/index.js 文件。但再次问一下,bundle.js 部分是从哪里来的?如果你查看 webpack.config.js 文件的最底部,你会看到类似以下的内容:
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
在这里,我们确保入口文件 /src/index.js 中的一切都被写入到 bundle.js 文件中。如果你有 webpack 的经验,你当然可以修改这个文件。但为了简单起见,我们将保持原样。
让我们看看 /src/index.js 文件:
import './styles.scss';
import MyApp from './components/my-app';
customElements.define('my-app', MyApp);
我们在这里看到的是,它正在导入一个 styles.scss 文件,这个文件可以用来存储我们的全局样式,然后它从 /components/my-app 文件夹中导入我们的 MyApp 类。然后,它定义了自定义元素。这是我们已经在 第一章 中探讨过的内容,Web Components 基础和规范。
如果我们查看 MyApp 类,我们会发现其中没有什么比之前章节学到的内容更复杂。
constructor() 方法并无不同:
constructor() {
// We are not even going to touch this.
super();
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
this.render();
}
render() 方法同样很简单:
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getStyle() 和 getTemplate() 方法也是一样的;与之前学到的没有区别:
getTemplate() {
return `
<div>
My App
</div>
${this.getStyle()}
`;
}
getStyle() {
return `
<style>
:host {
display: block;
}
</style>
`;
}
通过这里的代码,我们可以理解应用组件是如何工作的,以及它为什么是使我们的单页 Web 应用工作的最重要的 Web 组件。
函数式组件
现在我们已经知道了 <my-app> 组件的样貌以及它需要什么来工作,让我们开始编写本章开头讨论的组件。
<gif-cover> 网页组件
如前所述,这个网页组件的目的是显示一个 GIF。从截图来看,它是项目中可重用性最高的组件之一。所以,让我们开始编写它的代码:
export default class GifCover extends HTMLElement {
constructor() {
// We are not even going to touch this.
super();
// lets get the url from attribute
this.url = this.getAttribute('url');
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
this.render();
}
...
}
在 constructor() 中,我们使用 this.url 从属性中获取 URL。我们将使用这个 URL 作为图像的源,如下面的代码所示:
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<div>
<img class="gif-cover__image"
src="img/${this.url}" />
</div>
${this.getStyle()}
`;
}
我们还需要为这个组件添加样式;我们可以通过添加以下内容来实现:
getStyle() {
return `
<style>
:host {
display: block;
}
.gif-cover__image {
height: 150px;
}
</style>
`;
}
如你所见,我们对这个组件施加的唯一限制是图像高度。如果你不喜欢,完全可以去掉它。
当我们的 <gif-cover> 网页组件完成时,我们可以继续编写另一个网页组件。
<search-bar> 网页组件
如果我们查看搜索页面,我们会看到一个搜索栏。输入字段和搜索按钮是这个 <search-bar> 组件的一部分,并负责发起 API 调用。
我们将使用的是 GIPHY 搜索端点 API,https://api.giphy.com/v1/gifs/search。
前面的链接是一个 API 链接。你不能直接访问它,但如果你有一个密钥,你可以用它来获取数据。
你需要提供你的密钥,这可以从你的仪表板中获取。你可能还想看看这里的文档:developers.giphy.com/docs/#operation--gifs-search-get。
当你调用这个 API 时,它将返回一个对象数组,每个对象代表一个 GIF 及其元数据。
现在我们知道了要使用哪个 API,让我们看看代码:
export default class SearchBar extends HTMLElement {
constructor() {
// We are not even going to touch this.
super();
this.key = 'YOUR-KEY';
this.searchUrl = 'https://api.giphy.com/v1/gifs/search';
this.showlimit = 20;
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
this.render();
}
...
...
}
constructor() 方法包含了你的密钥(你将在 GIPHY 仪表板中获取),搜索 URL,即 API URL,以及每次调用显示的限制或数量。让我们看看 render() 方法:
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<div class="search-bar__container">
<input type="text"
class="search-bar__search-field"
placeholder="Enter Search Text Here">
<button class="search-bar__button">Search</button>
</div>
${this.getStyle()}
`;
}
这没有什么不寻常的。我们只有一个文本字段和一个按钮。样式看起来大致如下:
getStyle() {
return `
<style>
:host {
display: block;
}
.search-bar__container {
display: flex;
}
.search-bar__search-field {
flex: 1;
margin: 10px;
height: 50px;
font-size: 18px;
padding: 10px;
border-radius: 5px;
border: none;
color: #8e8e8e;
}
.search-bar__button {
margin: 10px;
width: 200px;
border: none;
font-size: 18px;
color: #5f5f5f;
cursor: pointer;
}
.search-bar__button:hover {
background: #68f583;
}
</style>
`;
}
除了基本的渲染外,我们还需要为按钮添加一个点击事件,以便它可以调用 API:
connectedCallback() {
this.shadowObj.querySelector('button')
.addEventListener('click', (e) => {
this.handleSearch();
});
}
这样,当用户点击按钮时,它将触发 handleSearch() 方法,其代码如下:
handleSearch() {
let value = this.shadowObj.querySelector('input').value;
fetch(`${this.searchUrl}?api_key=${this.key}&q=${value}&limit=${this.showlimit}`)
.then(response => response.json())
.then((jsonResponse) => {
this.dispatchDataInEvent(jsonResponse.data);
});
}
在这里,在 handleSearch() 函数中,我们首先获取输入字段的值。这是用户输入的值。然后,我们通过连接 API URL 来调用 API。URL 看起来如下:
`${this.searchUrl}?api_key=${this.key}&q=${value}&limit=${this.showlimit}`
这将从 searchUrl 变量获取 URL,从 key 变量获取键。value 从输入字段获取。limit 从 showlimit 变量获取。
一旦调用完成,并且承诺解决,它将调用 dispatchDataInEvent() 方法:
dispatchDataInEvent(data) {
this.dispatchEvent(new CustomEvent('search-complete', {
detail: {
data: data,
},
bubbles: true,
}));
}
这个 dispatchDataInEvent() 方法将负责通知父 Web 组件在调用后获得的新数据。
现在我们已经创建了可以在 <search-container> 组件中重用的 Web 组件,让我们看看 <search-container>。
<search-container> Web 组件
由于 <search-container> 组件将使用 <gif-cover> 和 <search-bar> 组件,我们的组件轮廓看起来大致如下:
import SearchBar from '../search-bar';
import GifCover from '../gif-cover';
export default class SearchContainer extends HTMLElement {
...
...
...
}
我们只是导入在这个组件中将使用的 Web 组件的类。这基本上和我们在 index.html 文件中使用的是完全相同的事情。
让我们看看 constructor() 方法:
constructor() {
// We are not even going to touch this.
super();
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
this.registerOtherComponents();
this.render();
}
这里,我们有一个 registerOtherComponents() 方法,我们在调用 render() 方法之前调用它。这也是我们第一次在另一个自定义元素内部注册自定义元素:
registerOtherComponents() {
if (typeof customElements.get('search-bar') === 'undefined') {
customElements.define('search-bar', SearchBar);
}
if (typeof customElements.get('gif-cover') === 'undefined') {
customElements.define('gif-cover', GifCover);
}
}
在这里,我们首先检查组件是否已经被注册。如果尚未注册,那么就进行注册。通常,如果浏览器尝试两次注册一个自定义元素,它会抛出一个错误信息。这个检查是为了解决这个问题。
一旦我们完成 Web 组件的注册,就到了渲染的时候:
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<div class="search-container__container">
<search-bar></search-bar>
<div class="search-container__images">
<p>Try Searching for a tag in the search bar</p>
</div>
</div>
${this.getStyle()}
`;
}
这里,我们正在渲染 <search-bar> 组件,但我们没有看到 <gif-cover> 组件。这是因为 <gif-cover> 组件只有在从 <search-bar> 组件检索数据时才显示,这是在 <search-bar> 组件派发 search-complete 事件时完成的。让我们看看 connectedCallback() 回调以添加此事件处理器:
connectedCallback() {
this.shadowObj.querySelector('search-bar')
.addEventListener('search-complete', (e) => {
this.handleSearchData(e.detail.data);
});
}
这里,我们正在寻找 <search-bar> 元素并添加一个事件监听器。当该事件发生时,它将触发 handleSearchData() 方法并将相关数据传递给它:
handleSearchData(data) {
data = data.map((val, index) => {
return `
<gif-cover url=${val.images.downsized_medium.url}></gif-cover>
`;
}).join('');
this.shadowObj.querySelector('.search-container__images')
.innerHTML = data;
}
就像前几章中的学生名单示例一样,这里我们正在创建一个 HTML 集合,包含从data数组中获取的<gif-cover> Web Components 的 URL,然后将这个 HTML 附加到search-container__images div中。这也会确保当用户搜索其他内容时,用新数据替换<gif-cover>。
此外,getStyles()方法也很重要。它看起来是这样的:
getStyle() {
return `
<style>
:host {
display: block;
}
.search-container__container {
display: block;
padding: 10px;
}
.search-container__images {
display: flex;
padding: 10px;
flex-wrap: wrap;
box-sizing: border-box;
justify-content: space-evenly;
}
gif-cover {
flex-basis: 10%;
padding: 5px;
}
</style>
`;
}
现在我们已经将<search-container> Web Component 设置好了,让我们将其添加到<my-app>组件中,如下所示:
getTemplate() {
return `
<search-container></search-container>
${this.getStyle()}
`;
}
此外,别忘了按照以下方式注册组件:
if (typeof customElements.get('search-container') === 'undefined') {
customElements.define('search-container', SearchContainer);
}
这样,我们可以确保SearchContainer只初始化一次。
随意运行代码,看看你是否能看到搜索栏;点击搜索按钮将返回一些结果。
让我们来看看<show-trending>组件。
<show-trending> Web Component
<show-trending> Web Component 的目的首先是对 API 进行调用,然后显示最热门的 GIF。对于这个组件,我们将使用的 API 是,https://api.giphy.com/v1/gifs/trending。
就像之前的 API 一样,这个 API 也返回一个包含 URL 和其他元数据的对象数组。要查看此 API 的文档,请访问此链接:developers.giphy.com/docs/#operation--gifs-trending-get。
既然我们已经知道了 API 的工作方式,让我们来看看<show-trending> Web Component 的代码:
export default class ShowTrending extends HTMLElement {
constructor() {
// We are not even going to touch this.
super();
this.key = 'YOUR_KEY';
this.url = 'https://api.giphy.com/v1/gifs/trending';
this.showlimit = 20;
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
this.registerOtherComponents();
this.render();
}
...
...
}
这里,就像<search-bar>组件一样,我们有一个key变量用于YOUR_KEY,这是存储 API 调用的 URL,以及showlimit变量用于设置从 API 调用中可以获取的最大数据量。
我们已经看到了registerOtherComponents()方法应该如何工作,如下所示:
registerOtherComponents() {
if (typeof customElements.get('gif-cover') === 'undefined') {
customElements.define('gif-cover', GifCover);
}
}
此外,别忘了导入GifCover组件:
import GifCover from '../gif-cover';
让我们来看看render()方法:
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<div class="show-trending__container">
<h2 class="show-trending__heading">Trending Gifs</h2>
<div class="show-trending__images"></div>
</div>
${this.getStyle()}
`;
}
这里,我们只有一个show-trending__images div,一旦进行 API 调用,它将包含<gif-cover> Web Components。
getStyles()方法看起来像这样:
getStyle() {
return `
<style>
:host {
display: block;
}
.show-trending__heading {
text-align: center;
}
.show-trending__images {
display: flex;
padding: 10px;
flex-wrap: wrap;
box-sizing: border-box;
justify-content: space-evenly;
}
gif-cover {
flex-basis: 10%;
padding: 5px;
}
</style>
`;
}
现在我们已经设置了组件,是时候确保组件能够进行 API 调用了:
connectedCallback() {
this.makeApiCall();
}
makeApiCall() {
fetch(`${this.url}?api_key=${this.key}&limit=${this.showlimit}`)
.then(response => response.json())
.then((jsonResponse) => {
this.handleTrendingData(jsonResponse.data);
});
}
我们所做的是在组件连接和 DOM 添加到页面时进行调用。一旦我们从fetch调用中获取了数据,我们就将此数据传递给handleTrendingData()方法:
handleTrendingData(data) {
data = data.map((val, index) => {
return `
<gif-cover url=${val.images.downsized_medium.url}></gif-cover>
`;
}).join('');
this.shadowObj.querySelector('.show-trending__images')
.innerHTML = data;
}
如您所见,这个handleTrendingData()方法负责创建<gif-cover> Web Components,为它们提供 GIF URL,并将它们添加到show-trending__images div 中。
就像<search-container>组件一样,你可以在<my-app>组件内部测试<show-trending>组件。
<show-random> Web Component
就像<show-trending> Web Component 一样,这是一个容器 Web Component。这意味着它将以嵌套的方式使用其他组件。它将使用的组件是<gif-cover>。让我们看看它的结构:
import GifCover from '../gif-cover';
export default class ShowRandom extends HTMLElement {
...
...
...
}
而 constructor() 方法看起来像这样:
constructor() {
// We are not even going to touch this.
super();
// the key required for api
this.key = 'YOUR_KEY';
// the url used to get the random gif
this.url = 'https://api.giphy.com/v1/gifs/random';
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
this.registerOtherComponents();
this.render();
}
这里,我们使用的 API 是,https://api.giphy.com/v1/gifs/random。
与之前的 API 不同,这个 API 每次只输出一个对象。这个对象将包含与 GIF 相关的 URL 和其他元数据。如果您需要更多关于它的信息,请随时参考文档:developers.giphy.com/docs/#operation--gifs-random-get
registerOtherComponents() 方法与之前的一个完全相同:
registerOtherComponents() {
// lets register other components used
if (typeof customElements.get('gif-cover') === 'undefined') {
customElements.define('gif-cover', GifCover);
}
}
此外,render() 方法看起来像这样:
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<div class="show-random__container">
<div class="show-random__images"></div>
<button class="show-random__button">Get Another Random
Image</button>
</div>
${this.getStyle()}
`;
}
在这里,我们看到有一个用于显示随机图片的 div,show-random__images,以及在其下方的一个按钮。getStyle() 方法看起来像这样:
getStyle() {
return `
<style>
:host {
display: block;
}
.show-random__container {
text-align: center;
}
.show-random__images {
display: flex;
padding: 10px;
flex-wrap: wrap;
box-sizing: border-box;
justify-content: space-evenly;
}
.show-random__button {
margin: 10px;
border: none;
font-size: 18px;
color: #5f5f5f;
cursor: pointer;
padding: 10px;
}
gif-cover {
flex-basis: 10%;
padding: 5px;
}
</style>
`;
}
现在组件已经设置好了,让我们发起 API 调用:
connectedCallback() {
this.handleRandom();
}
handleRandom() {
fetch(`${this.url}?api_key=${this.key}`)
.then(response => response.json())
.then((jsonResponse) => {
this.handleTrendingData(jsonResponse.data);
});
}
这个 handleRandom() 函数负责进行 API 调用,当数据被检索时,它将数据传递给 handleTrendingData() 方法:
handleTrendingData(data) {
this.shadowObj.querySelector('.show-random__images')
.innerHTML = `
<gif-cover url=${data.image_url}></gif-cover>
`;
}
我们还需要确保当按钮被点击时图片会刷新。因此,我们可以在 connectedCallback() 方法内添加此事件监听器以使其工作:
connectedCallback() {
this.handleRandom();
this.shadowObj.querySelector('button')
.addEventListener('click', (e) => {
this.handleRandom();
});
}
这样,每次按钮被点击时,它都会再次触发 handleRandom() 方法。
<my-app> 组件
就像 <show-trending> 和 <search-container> 组件一样,您可以通过在 <my-app> 组件内添加 <show-random> 组件来测试 <show-random> Web 组件。但如果你想要集成所有这些,我有一个更好的选择。
让我们看看 <my-app> 组件。如果我们想包含这三个组件,我们首先需要引入它:
import SearchContainer from '../search-container';
import ShowTrending from '../show-trending';
import ShowRandom from '../show-random';
export default class MyApp extends HTMLElement {
...
...
...
}
现在我们已经引入了这些组件,让我们注册这些自定义元素:
registerOtherComponents() {
if (typeof customElements.get('search-container') === 'undefined') {
customElements.define('search-container', SearchContainer);
}
if (typeof customElements.get('show-trending') === 'undefined') {
customElements.define('show-trending', ShowTrending);
}
if (typeof customElements.get('show-random') === 'undefined') {
customElements.define('show-random', ShowRandom);
}
}
我们还可以添加一个 showSection 变量来跟踪在什么时间显示哪个组件:
constructor() {
...
// to show what section
this.shownSection = 1;
...
}
我们最初将其设置为 1 的值,这样就可以默认显示 <search-container>。
为了让它工作,我们将稍微修改一下 getTemplate() 方法,使其看起来像这样:
getTemplate() {
return `
<div class="app-section">
${this.getSection(this.shownSection)}
</div>
${this.getStyle()}
`;
}
getSection(section) {
switch(section) {
case 1:
return `
<search-container></search-container>
`;
case 2:
return `
<show-trending></show-trending>
`;
case 3:
return `
<show-random></show-random>
`;
}
}
这样,您可以通过更改 showSection 的值来手动测试页面。
现在我们已经创建了一种方法,可以通过更改变量 showSections 的值来显示不同的页面级组件,我们现在可以专注于这些页面级组件的路由方面。而不是手动更改页面编号,现在是时候通过实现路由来自动化页面更改的概念了。
实现路由
到目前为止,我们一直在手动更改代码来查看单页 Web 应用程序的不同页面。此外,我们还没有讨论过头部组件。在本节中,我们将查看头部组件,根据链接更新 URL,并确保我们的页面视图根据点击的链接进行更改。
因此,让我们看看 <custom-header> 组件:
constructor() {
// We are not even going to touch this.
super();
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
this.render();
}
constructor() 方法很简单。让我们看看 render() 方法:
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<ul class="custom-header__ul">
<li class="custom-header__li">
<a href="#search">Search</a>
</li>
<li class="custom-header__li">
<a href="#trending">Trending</a>
</li>
<li class="custom-header__li">
<a href="#random">Random</a>
</li>
</ul>
${this.getStyle()}
`;
}
如您所见,我们有三个链接:搜索、趋势和随机。点击这些链接也会改变 URL 的 hash 值:
getStyle() {
return `
<style>
:host {
display: block;
top: 0;
background: #46cff3;
position: sticky;
height: 75px;
}
.custom-header__ul {
display: flex;
margin: 0;
justify-content: flex-end;
height: 100%;
}
.custom-header__li {
align-self: center;
list-style-type: none;
margin-right: 25px;
}
.custom-header__li a {
text-decoration: none;
color: white;
font-size: 25px;
}
</style>
`;
}
样式也很简单。
让我们看看点击事件的处理器。对于路由,我们需要通知<my-app>Web 组件(这个custom-header组件将要被使用的地方)关于点击事件或用户点击了哪个链接:
connectedCallback() {
this.shadowObj.querySelectorAll('.custom-header__li a')
.forEach((aTag, index) => {
aTag.addEventListener('click', (e) => {
this.handleClick(index);
});
});
}
在这里,我们只是将点击事件绑定到所有链接上,并确保触发handleClick()方法,以及链接的index:
handleClick(index) {
this.dispatchEvent(new CustomEvent('custom-header-clicked', {
detail: {
data: index + 1,
},
bubbles: true,
}));
}
这个handleClick()方法只是将这个索引值传递给父组件,尝试监听custom-header-clicked事件。
在<my-app>组件内部,定义也会相应更新。例如,我们需要导入CustomHeader类:
import CustomHeader from '../custom-header';
我们还需要通过添加以下行来更新registerOtherComponents()方法:
if (typeof customElements.get('custom-header') === 'undefined') {
customElements.define('custom-header', CustomHeader);
}
这也将更新getTemplate()方法,使其看起来像这样:
getTemplate() {
return `
<custom-header></custom-header>
<div class="app-section">
${this.getSection(this.shownSection)}
</div>
${this.getStyle()}
`;
}
在这里,我们只是添加了<custom-header>元素。我们还需要通过这个元素捕获事件发射器:
connectedCallback() {
this.shadowObj.querySelector('custom-header')
.addEventListener('custom-header-clicked', (e) => {
let newShownSection = e.detail.data;
if(newShownSection !== this.shownSection) {
this.shownSection = newShownSection;
this.reRenderAppSection();
}
})
}
我们正在向custom-header元素添加事件监听器,并确保showSection的最后一个值不等于新值。如果不是,则更新值并调用reRenderAppSection()方法:
reRenderAppSection() {
this.shadowObj.querySelector('.app-section').innerHTML =
this.getSection(this.shownSection);
}
这个reRenderAppSection()方法只是根据showSection变量更新视图。
现在,您可以通过点击页眉链接在浏览器中测试它,并观察页面如何变化。您还可以看到地址栏中的 URL 变化。尽管这一切听起来都很完整,但我还想添加一个额外的功能到路由中。
注意地址栏中显示的 URL。如果您将带有趋势 hash 的 URL 发送给某人,它会打开趋势页面吗?答案是不会。对于带有随机 hash 的 URL 也是如此。它不会工作。我们需要添加额外的代码:
handleURL() {
switch(window.location.hash) {
case '#search':
this.shownSection = 1;
break;
case '#trending':
this.shownSection = 2;
break;
case '#random':
this.shownSection = 3;
break;
default:
this.shownSection = 1;
break;
}
this.render();
}
你可以在constructor()方法中调用这个handleURL()方法,并观察其工作过程。它会更新showSection变量的值,这样getTemplate()中的getSection()方法就知道要渲染哪一页。
现在应用已经构建完成,让我们尝试添加一些额外的功能,使我们的单页 Web 应用更加有用。
启用分析
分析在了解用户访问您的网站以及他们在特定页面上停留了多长时间方面发挥着重要作用。在本节中,我们将使用 Google Analytics 来跟踪网站上的用户交互。这是那些即使在 Web 组件之外也能轻松完成的事情之一。
为了开始使用分析工具,我们需要做以下几步:
-
前往
analytics.google.com/,然后点击管理员按钮创建一个新的属性。你将被带到“新建属性”页面。 -
然后,您就可以开始填写页面上的表单:

- 一旦您填写了详细信息,并点击了获取跟踪 ID 按钮,您将被带到下一页:

-
您可以将文本区域中的代码复制并粘贴到您的
index.html文件中。 -
就这些了。现在,您可以直接访问以下 URL,查看用户是如何访问您的网站的:
analytics.google.com。 -
您将被带到一页,您可以查看用户是如何访问您的页面的:

然后,您可以使用这些数据来了解用户在您的页面上做了什么,以及他们在网站上停留了多长时间。
摘要
在本章中,我们仅使用 Web Components 创建了一个单页 Web 应用。我们学习了如何将页面分解为原子和容器 Web 组件。我们学习了如何以战略性的方式包含原子组件,以便它们可以以高效的方式重用。我们还探讨了路由以及如何用它来跟踪用户所在的页面。最后,我们讨论了如何将分析集成到我们的单页 Web 应用中,以及它是如何用来理解不同类型用户的。现在,您应该能够根据上述概念创建任何单页 Web 应用而不会遇到任何问题。
在下一章中,我们将探讨 Polymer 和 Stencil JS,这些是使用 Web Components 的库,以及代码与我们迄今为止所学的内容有何不同。
第七章:使用 Polymer 和 Stencil 实现 Web Components
到目前为止,我们使用纯 JavaScript 和没有依赖项构建了组件。但有时,公司会做出决定使用可以帮助简化工作流程的库。在本章中,我们将探讨两个不同的库:Polymer 和 Stencil。在幕后,这两个库都使用了 Web Components,但它们都带有自己的特性。让我们深入了解这些库。
在本章中,我们将涵盖以下主题:
-
Polymer
-
Stencil
Polymer
Polymer 是一个库,它允许你以非常简单的方式创建自定义元素。它自带了一组功能,可以用来创建 shadow DOM,添加事件,以及使用属性和属性,就像我们在前面的章节中学到的那样。
你可以在以下 URL 找到 Polymer 项目:Polymer-library.Polymer-project.org/。
纯 Web Components 和 Polymer 之间一个主要的不同之处在于,Polymer 自带了自己的数据系统。这意味着你可以根据这些数据进行各种计算和操作,以影响组件。你可以观察属性和属性的变化,甚至可以进行双向数据绑定,这是纯 Web Components 所缺少的。这些特性的存在有助于许多用例,并有助于使开发者的生活更加轻松。
我们现在将详细探讨如何使用 Polymer。
Polymer 中的 Hello World
与纯 Web Components 不同,Polymer 是一个需要安装的库。但在我们安装库之前,我们将需要 Polymer 的命令行界面(CLI),它自带服务器和测试框架。我们可以使用以下命令来安装它:
npm install -g Polymer-cli
一旦安装,你可以使用以下命令来检查是否已安装:
Polymer --version
现在 Polymer 已安装,让我们尝试使用这个库创建一个<hello-world>组件。让我们创建一个名为HelloWorld的文件夹,然后创建一个名为index.html的文件。该文件的内容可以非常基础,如下所示:
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Hello World</title>
</head>
<body>
<p>You stuff goes here.</p>
</body>
</html>
为了运行此文件,我们不会使用SimpleHTTPServer,而是使用由Polymer-cli本身提供的服务器。我们可以使用以下命令来运行服务器:
Polymer serve
一旦运行此命令,你应该在终端中看到以下控制台输出:
info: Files in this directory are available under the following URLs
applications: http://127.0.0.1:8081
reusable components: http://127.0.0.1:8081/components/Polymer/
这表明服务器正在运行,你可以看到index.html文件正在http://127.0.0.1:8081/上运行。
现在我们服务器正在运行,Polymer 也已安装,让我们开始编写我们的<hello-world> Polymer 组件。
由于我们将使用Polymer库,让我们为这个项目安装这个库:
npm i @Polymer/Polymer
此外,让我们创建一个名为hello-world.js的文件。该文件的如下内容:
import { PolymerElement, html } from '@Polymer/Polymer/Polymer-element.js';
class HelloWorld extends PolymerElement {
...
...
...
}
customElements.define('hello-world', HelloWorld);
在这里,我们只是简单地导入我们刚刚使用npm命令安装的Polymer库。此外,我们不是使用HTMLElement,而是使用PolymerElement。然后,我们将这个类注册为一个自定义元素。
类的定义将比原生的 Web Components 稍微复杂一些:
class HelloWorld extends PolymerElement {
constructor() {
super();
}
static get template() {
return html`
<p>Hello World</p>
`;
}
}
这里,我们有constructor()方法,就像原生的 Web Components 一样,但super()方法调用中没有props。此外,我们没有手动调用render()方法。相反,代码是从template()属性自动渲染的。
我们还需要记住,与原生的 Web Components 不同,我们不是手动为 Polymer 元素创建 shadow DOM。在这里,我们使用Polymer库中的html对象将html贴到元素的 shadow DOM 上。
现在组件已经创建,我们可以在index.html文件中以以下方式使用它:
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Hello World</title>
<script type="module" src="img/hello-world.js"></script>
</head>
<body>
<hello-world></hello-world>
</body>
</html>
这里,我们只是导入hello-world.js文件,然后直接使用<hello-world>元素。
如你所见,Polymer库在节省代码行数方面非常有帮助。而且,通过这个例子,你现在知道如何创建一个 Polymer 元素。你还可以尝试创建我们在前几章中学到的其他元素。
Polymer 中的嵌套元素
在前几章中,我们探讨了如何在其他 Web Components 内部使用不同的 Web Components 来创建复杂和嵌套的组件。这种方法不仅限于原生的 Web Components。我们也可以在 Polymer 组件内部使用它。
假设我们还有一个类似这样的PolymerElement:
// second-element.js
import { PolymerElement, html } from '@Polymer/Polymer/Polymer-element.js';
class SecondElement extends PolymerElement {
constructor() {
super();
}
static get template() {
return html`
<style>
p {
color: red;
}
</style>
<p>This is the second element</p>
`;
}
}
customElements.define('second-element', SecondElement);
如你所见,它不是一个复杂的元素。实际上,它看起来非常像<hello-world>元素。现在,假设我们想在<hello-world>元素中包含这个<second-element>。我们可以通过以下方式更改<hello-world>代码来实现:
import { PolymerElement, html } from '@Polymer/Polymer/Polymer-element.js';
import './second-element.js';
class HelloWorld extends PolymerElement {
constructor() {
super();
}
static get template() {
return html`
<p>Hello World</p>
<second-element></second-element>
`;
}
}
customElements.define('hello-world', HelloWorld);
这里,你可以看到我们正在从./second-element.js文件导入代码。我们只是通过<second-element>HTML 代码简单地使用它。就这么简单。
此外,如果你注意到<second-element>类的定义,你会发现我们也使用了一个样式标签。我们可以利用我们在前几章中学到的所有样式。
Polymer 中的属性和属性
在前几章中,我们看到了属性和属性如何帮助我们使 Web Components 变得更好。它们有助于传递数据,以及跟踪元素的状态。同样,我们也可以为使用 Polymer 构建的元素做同样的事情。
让我们看看属性在 Polymer 中是如何工作的。假设我们有一个显示文本“Hello, Prateek”的元素,其中字符串Prateek是一个变量。代码看起来可能像这样:
import { PolymerElement, html } from '@Polymer/Polymer/Polymer-element.js';
class HelloString extends PolymerElement {
constructor() {
super();
}
static get properties() {
return {
name: {
type: String,
value: 'No Name Provided Yet'
}
};
}
static get template() {
return html`
<p>Hello, [[name]]</p>
`;
}
}
customElements.define('hello-string', HelloString);
在这里,我们添加的唯一额外内容是properties获取函数。属性的名称是name,默认值是'No Name Provided Yet'。当您使用元素时,您可以简单地调用元素,如下所示:
<hello-string></hello-string>
这将显示文本“Hello, No Name Provided Yet”。或者,您可以通过添加一个属性来提供名称,如下所示:
<hello-string name="Prateek"></hello-string>
这将显示文本“Hello, Prateek”。您还可以添加多个属性。例如,您可以添加lastname或age作为属性。
但如果您有嵌套组件,并且这个文本是从父组件传来的变量呢?让我们看看这段代码会是什么样子:
import { PolymerElement, html } from '@Polymer/Polymer/Polymer-element.js';
import './hello-string.js';
class StudentName extends PolymerElement {
constructor() {
super();
}
static get properties() {
return {
name: {
type: String,
value: 'John Doe'
}
};
}
static get template() {
return html`
<hello-string name="[[name]]"></hello-string>
`;
}
}
customElements.define('student-name', StudentName);
在这里,我们将name属性作为变量传递给<hello-string>元素中的属性。整个过程被称为数据绑定。如果您想了解更多关于 Polymer 中的数据绑定信息,可以访问以下链接:Polymer-library.Polymer-project.org/3.0/docs/devguide/data-binding。
在这些概念的帮助下,您应该能够轻松地创建 Polymer 元素。
Stencil
Stencil 是一个 Web 组件的编译器。它使用 TypeScript 和 JSX 来创建 Web 组件。它甚至包含了许多在 vanilla Web 组件中缺失的功能,可以用来制作好的单页 Web 应用。
让我们通过一个<hello-world>组件来更好地了解 Stencil 能做什么。这个组件需要一点 TypeScript 和 JSX 的理解。如果您在任何时候想查看文档,可以在这里找到:Stencil-project.org/docs。
-
TypeScript:
www.typescriptlang.org/.
我会尽量使我的代码尽可能简单,这样您就不必查看文档。现在我们已经解决了这个问题,让我们使用 Stencil 创建一个 hello-world 组件。
Hello World Stencil 组件
Stencil 提供了许多功能来构建组件。让我们首先设置文件夹来编写组件。您可以在终端中输入以下命令来完成此操作:
npm init stencil
您将看到一系列选项,从中您可以选择组件。在选择组件选项后,您可以自由地为项目输入名称。我选择了stenciljs-app。这将打印出类似以下内容的输出:
 Pick a starter › component
 Project name › stenciljs-app
 All setup in 8.19 s
Next steps:
$ cd stenciljs-app
$ npm start
Further reading:
- https://github.com/ionic-team/stencil-component-starter
这将创建一个包含默认组件的起始项目。您可以通过输入以下命令来运行项目:
cd stenciljs-app
npm start
这将在浏览器中的localhost:3333上运行stenciljs-app项目。它还将显示默认组件<my-component>作为输出的一部分。从技术上讲,这是我们在项目中默认提供的<hello-world>组件。但我们将从头开始创建自己的<hello-world>组件。
为了创建我们的 <hello-world> 组件,我们首先需要完成一些先决条件。这些如下:
-
在
src/components文件夹内创建一个名为hello-world的文件夹。 -
在这个
hello-world文件夹内创建一个名为hello-world.tsx的文件。我们使用.tsx扩展名,因为它是一个 TypeScript 文件。Stencil 会将此文件编译为.js文件。我们不需要担心这一点。 -
在
hello-world文件夹内创建另一个名为hello-world.css的文件。这是我们将会为这个组件编写 CSS 的地方。
现在我们已经完成了 <hello-world> 组件的设置,让我们开始为其编写代码。这就是 hello-world.tsx 的样子:
import { Component, h } from '@stencil/core';
@Component({
tag: 'hello-world',
styleUrl: 'hello-world.css',
shadow: true
})
export class HelloWorld {
render() {
return (
<div>Hello World</div>
);
}
}
在第一行,我们正在从 stencil 库中导入 Component 和 h 对象。当我们谈论技术术语时,我们将称之为 @Component 装饰器。正如我们所见,我们只是声明了组件的标签、它需要的 CSS 样式以及组件是否需要在阴影 DOM 中渲染。在 HelloWorld 类中,我们只是返回这个组件的 JSX。如果你来自 React 背景,那么这应该非常简单。但如果你是 JSX 新手,为了简单起见,你可以将其视为在 JavaScript 中编写 HTML 的方式。
因此,我们已经创建了我们的第一个 Stencil 组件。现在,为了在网页上看到它,你只需在 src 目录下的 index.html 文件中添加 <hello-world> 标签。Stencil 会自动将其拾取,创建其包含文件,并为你编译。你只需要刷新页面。
现在我们已经知道了如何创建一个 Stencil 组件,接下来让我们深入下一节,学习如何创建嵌套的 Stencil 组件。
嵌套的 Stencil 组件
在上一节中,我们探讨了 @Component 装饰器以及它是如何帮助创建 Stencil 组件的。在本节中,我们将使用另一个名为 @Prop 装饰器的装饰器来声明将作为属性传递给其他组件的变量。
让我们创建一个显示学生列表的元素,称为 <student-list>。在 Stencil 中,它看起来像这样:
import { Component, h } from '@stencil/core';
@Component({
tag: 'student-list',
styleUrl: 'student-list.css',
shadow: true
})
export class StudentList {
render() {
return <div>
<div>Student List is as follows: </div>
<student-name class="student-list__student" first="John" last="Doe"></student-name>
<student-name class="student-list__student" first="Tom" last="Hanks"></student-name>
</div>;
}
}
这里,我们正在做与 <hello-world> 组件中相同的事情。我们只是导入 stencil 库,然后在 @Component 装饰器中设置组件的名称和 CSS 样式。然后,在类中,我们有一个名为 <student-name> 的组件,它将名字和姓氏作为属性。
让我们看看 <student-name> 组件的定义:
import { Component, Prop, h } from '@stencil/core';
@Component({
tag: 'student-name',
styleUrl: 'student-name.css',
shadow: true
})
export class StudentName {
@Prop({reflectToAttr: true}) first: string;
@Prop() last: string;
private getFullName(): string {
return `${this.first} ${this.last}`;
}
render() {
return <div>Student Name: {this.getFullName()}</div>;
}
}
在这里,如果我们查看 StudentName 类,我们可以看到我们正在使用 @Prop 装饰器。借助这个 @Prop 装饰器,我们定义了两个属性:first 和 last。first 属性还将 reflectToAttr 设置为 true,这意味着当它在 <student-list> 组件内部调用时,这个属性可以被视为一个属性:

在这里,我们可以看到这个组件的阴影 DOM 中首先出现的是属性。但由于我们没有将 reflectToAttr 设置为 true 对于 last 属性,它不会反映在属性中。
此外,如果你注意到 <student-list> 组件的定义,我们没有导入 <student-name> 组件。我们只是开始使用这个元素。Stencil 足够智能,能够捕捉这些变化并自动将它们包含在文件中。这样,我们就可以创建嵌套元素而不用担心导入。
既然我们已经知道了如何使用 Stencil 创建嵌套组件,让我们来看看实现我们试图创建的网页性能的一种方法。
Stencil 组件的预渲染
当我们谈论渲染单页 Web 应用时,我们基本上是将所有资源发送到页面上,然后让客户端进行所有计算以构建页面。这是一个计算密集型的过程,可能会导致网站首次有意义的绘制时间更长。
为了解决这个问题,Stencil 默认开启了预渲染功能。预渲染允许服务器在构建时生成静态的 HTML、CSS 和 JavaScript 文件,然后可以用该页面的数据进行激活。这使用户能够更快地看到页面,让搜索引擎爬虫更快地浏览网站以进行 SEO,即使在客户端禁用了 JavaScript,用户也能看到页面。
摘要
在本章中,我们学习了如何创建 Polymer 和 Stencil 组件。我们探讨了这些组件如何嵌套以形成更复杂的元素。我们还研究了如何在 Polymer 和 Stencil 组件中传递属性和属性到子组件。
我们还研究了 Stencil 的预渲染功能以及它是如何被用来提高网站性能的。
在下一章中,我们将探讨纯 Web 组件如何在各种其他库和框架中使用。
第八章:将 Web 组件与 Web 框架集成
在前面的章节中,我们要么从头开始创建 Web 组件,要么使用库来创建 Web 组件。我们甚至只用 Web 组件创建了一个单页 Web 应用程序。但如果我们有一个现有的项目怎么办?如果这是一个单体前端 Web 应用程序,我们需要在这个项目中使用 Web 组件的方法怎么办?如果我们想用一个 Web 组件实现一个快速原型化的功能而不需要太多开销怎么办?这可以在时间和金钱上节省大量的努力。
这一章仅针对此类场景;在这里,我们将探讨我们可以如何使用 Web 组件在现有项目中。
顺便说一句,这一章是为高级用户准备的。
我假设你已经使用过 React、Angular 或 Vue,并且你正在寻找将 Web 组件包含到已经使用这些技术之一或多个的 Web 应用程序中的方法。我也假设你知道如何运行这些 Web 应用程序。然而,为了简化,我们将查看使用这些技术中最简单的组件,以及如何将两个 Vanilla Web 组件包含在这些技术中的任何一种中。
在这一章中,我们将看到以下主题:
-
与现有项目的集成
-
在 React 中集成 Web 组件
-
在 Angular 中集成 Web 组件
-
在 Vue 中集成 Web 组件
<header-image> Web 组件
假设我们有一个名为 <header-image> 的 Web 组件,其目的是显示一个图像,并且在鼠标悬停时,它应该能够显示一个文本,显示图像的简要描述。这个 Web 组件的定义可能看起来像这样:
export default class HeaderImage extends HTMLElement {
constructor() {
// We are not even going to touch this.
super();
// lets create our shadow root
this.shadowObj = this.attachShadow({mode: 'open'});
// Then lets render the template
this.render();
}
static get observedAttributes() {
return ['src', 'alttext'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name == 'src') {
this.src = newValue;
this.render();
}
if (name == 'alttext') {
this.alt = newValue;
this.render();
}
}
...
...
}
如你所见,我们在构造函数中简单地调用了 super() 方法。然后我们为组件创建了一个 shadow root,并调用了 render() 方法。我们还确保通过属性传入的任何更改都会重新渲染 Web 组件,以反映与这些属性相关的更新。
关于 render() 方法,它看起来可能如下所示:
render() {
this.shadowObj.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<img src="img/${this.getAttribute('src')}"
alt="${this.getAttribute('alt')}"/>
${this.handleErrors()}
<style>
img {
width: 400px;;
}
</style>
`;
}
在这里,我们在 shadow root 的 HTML 中添加了一个图像。此外,我们还通过 handleErrors() 方法启用了错误处理:
handleErrors() {
if(!this.getAttribute('alt')) {
return `
<div class="error">Missing Alt Text</div>
<style>
.error {
color: red;
}
</style>
`;
}
return ``;
}
这个 handleErrors() 方法寻找缺失的属性 alt,并输出一个错误消息,要求用户输入 alttext。
我们可以使用以下 HTML 使用这个 Web 组件:
<header-image alttext="Blue Clouds"
src="img/clouds-sky-header-2069-1024x300.jpg"></header-image>
现在我们已经知道了我们的 Web 组件的样子,让我们尝试在现有项目中使用它。我们将从一个使用 React 的现有项目开始。
在 React 中集成 Web 组件
假设我们有一个 React 应用程序。我将使用 React 提供的启动应用程序。你可以在你的更复杂的应用程序中自由尝试这个功能。进行此操作的步骤将完全相同。
设置 React 项目
如果你有自己的应用程序,你不需要通过这一部分。
您可以使用以下链接来设置项目:facebook.github.io/create-react-app/。
设置完成后,您将得到一个可以使用以下命令运行的项目:
npm start
添加 React 组件
为了简化,我添加了一个 React 组件。这个 React 组件将模拟一个负责包含<header-image>网络组件的真实生活场景。让我们把这个 React 组件称为MainBody;其定义看起来可能如下:
import React, { Component } from 'react';
export default class MainBody extends Component {
render() {
return (
<div>
<p>This is the main body</p>
</div>
);
}
}
如您所见,它只显示一行文本,没有其他内容。如果您有一个更复杂的组件,步骤将是相同的。至于入门级应用,我们首先在App组件中包含这个MainBody组件,如下所示:
import React from 'react';
import logo from './logo.svg';
import './App.css';
import MainBody from './main-body/main-body.js';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
<MainBody />
</header>
</div>
);
}
export default App;
在这里,我们只是导入MainBody组件,并在App组件中直接使用它。
在 React 组件中集成 Vanilla Web Component
为了在MainBody React 组件中使用<header-image>组件,我们将在MainBody组件中添加一些内容:
import React, { Component } from 'react';
import HeaderImage from '../web-components/header-image/header-image.js';
export default class MainBody extends Component {
constructor() {
super();
this.state = {
src: 'https://www.freewebheaders.com/wordpress/wp-content/gallery/clouds-sky/clouds-sky-header-2069-1024x300.jpg',
altText: 'Blue Clouds'
}
}
componentDidMount() {
customElements.define('header-image', HeaderImage);
}
render() {
return (
<div>
<p>This is the main body</p>
<header-image alttext={this.state.altText}
src={this.state.src}>
</header-image>
</div>
);
}
}
在这里,我们从其相应位置导入<header-image>网络组件,然后在生命周期回调方法componentDidMount()中注册自定义元素。然后,我们尝试通过状态变量将alt和src发送到<header-image>组件。
对于所有试图使用任何 Vanilla Web Component 的 React 组件,步骤都是相同的。现在我们已经了解了如何在 React 项目中使用 Web Component,让我们看看它在一个 Angular 应用程序中的样子。
在 Angular 中集成 Web 组件
假设我们有一个已经存在的 Angular 应用程序。这可能是一个完整的项目或入门级应用,我们想在 Angular 组件中使用<header-image>网络组件。我们将从设置开始。
设置 Angular 项目
假设我们想要从一个入门级应用开始。我们可以按照以下网址提供的步骤来安装和运行入门级应用:angular.io/guide/quickstart。
Angular 默认不支持 Vanilla Web Components,因此在我们开始使用 Web Components 之前,我们需要告诉 Angular 我们想要使用一个网络组件。我们可以在app.module.ts文件中添加以下代码来实现:
...
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
...
...
@NgModule({
...
...
schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
})
这是告诉 Angular 预期一个不是使用 Angular 构建的自定义元素。
与 Angular 集成
现在,假设我们有一个名为app-main-body的组件,它是使用 Angular 构建的(文件:main-body.component.ts),其外观大致如下:
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-main-body',
templateUrl: './main-body.component.html',
styleUrls: ['./main-body.component.css']
})
export class MainBodyComponent implements OnInit {
src: string ;
altText: string;
constructor() {
this.src = 'https://www.freewebheaders.com/wordpress/wp-content/gallery/clouds-sky/clouds-sky-header-2069-1024x300.jpg';
this.altText = 'Blue Clouds';
}
ngOnInit() {
}
}
如果我们想在这里包含<header-image>网络组件,我们可以简单地添加以下代码:
...
import HeaderImage from '../web-components/header-image/header-image.js';
...
export class MainBodyComponent implements OnInit {
...
...
ngOnInit() {
customElements.define('header-image', HeaderImage);
}
}
在这里,我们只是导入组件定义,然后在ngOnInit()回调方法内部注册自定义元素。如果我们查看模板文件main-body.component.html,Web 组件可以按照以下代码所示包含在内:
<p>
main-body works!
</p>
<header-image attr.src={{src}} attr.alttext="{{alttext}}"></header-image>
在这里,我们将src和altText作为属性值传递给<header-image>组件。这样,我们就可以在 Angular 项目中使用在 Angular 之外构建的 Web Components。
既然我们已经知道了如何在 Angular 项目中使用 Vanilla Web 组件,那么让我们看看如何在 Vue 组件中使用 Web Components。
在 Vue 中集成 Web Components
Vue 是那些增长速度极快的库之一,因此我认为如果我们看到如何在 Vue 组件中包含 Web 组件,那将是一件好事。
假设我们有一个看起来像这样的<main-body> Vue 组件:
Vue.component('main-body', {
props: ['src', 'alt'],
template: `
<p>This is the main body</p>
`,
})
如你所见,它所做的只是显示文本,就像 Angular 和 React 中的主体组件一样。假设我们想要将<header-image> Web 组件包含到这个<main-body>组件中。这将使<main-body>组件看起来像这样:
import HeaderImage from '../web-components/header-image/header-image.js';
Vue.component('main-body', {
props: ['src', 'alt'],
template: `
<p>This is the main body</p>
<header-image src="img/{{src}}" alttext="{{alt}}"></header-image>
`,
created: function() {
customElements.define('header-image', HeaderImage);
}
})
在这里,我们只是导入HeaderImage组件,并在created()回调方法内部注册这个 Web 组件。正如你所见,在 Vue 组件中使用 Web 组件非常简单,可以通过插值将属性值传递给 Web 组件,就像之前代码中展示的那样。
使用本节中提到的过程,我们可以将现有的 Web 组件添加到任何 Vue 项目中。
摘要
在本章中,我们探讨了如何将 Web Components 集成到已经使用前端世界中最著名的库/框架的一些现有项目中。我们学习了如何将使用 Vanilla JavaScript 构建的现有 Web 组件添加到 React、Angular 或 Vue 项目中。本章中学到的技术可以用于任何框架或库,以及任何类型的现有项目。在现有项目中包含 Web Components 也是快速原型化功能的好用例,组件一旦完成工作就可以立即移除。
我希望这一章能帮助你创建更好的 Web 应用程序,无论它们是否使用 Web Components。


浙公网安备 33010602011771号