原文:Building Native Web Components
协议:CC BY-NC-SA 4.0
一、制作您的第一个 Web 组件
欢迎构建您的第一个 web 组件。本章讨论了创建第一个 web 组件所需的各种工具、技术、设计和开发概念。您将学习什么是 web 组件,以及什么是 web 浏览器支持、设计系统和组件驱动开发(CDD)。
什么是 Web 组件?
在高层次上,Web 组件是孤立的部分(块的种类),用户界面(UI)可以通过属性和事件(来自这些块的输入和输出)与其他元素进行通信。以<video>
元素为例。我们可以在浏览器的任何技术中使用这个元素,我们可以传递像width
和height
这样的属性,并监听像onclick
这样的事件。
更严格地说,我们可以说 web 组件是一组 web 平台 API(应用编程接口),允许我们构建 HTML 标签,这些标签将跨现代 Web 浏览器工作,并可以与任何 JavaScript 技术(React、Angular、Vue.js 等)一起使用。).
Web 组件有四个主要规范:
自定义元素
影子天赋
是模块
HTML 模板
我将在接下来的章节中更深入地讨论这些规范。
Web 组件的历史
目前,Web 组件在前端环境中无处不在。三个最流行的框架(Angular、React 和 Vue.js)使用 Web 组件作为其架构的一部分。情况并不总是如此。Web 组件是随着时间一点点发展起来的。第一个重大进展是在 2010 年用 AngularJS ( https://angularjs.org
)实现的,这是一个框架,它引入了指令的概念,作为一种创建自己的标签的方法,用它们自己的特性来构建 ui。后来,在 2011 年,亚历克斯·罗素在 Fronteers 会议上发表了题为“Web 组件和模型驱动的视图”的演讲,阐述了一些现在普遍使用的关键概念和想法。12013 年,谷歌通过 Polymer 向前迈出了又一大步,这是一个基于 web 组件(使用 web APIs)的库,它已经成为一个为更好的 Web 构建库、工具和标准的工具。
为什么要使用 Web 组件?
今天,所有前端开发人员都面临着两个重大问题,它们会消耗公司的精力、时间和财务。这些如下。
遗产
遗留是软件开发中的一个众所周知的问题,指的是必须在某个时候更新的旧代码库,以便与新的 JavaScript 项目和工具一起操作。
框架变动
JavaScript 的工具及其框架生态系统正在快速变化。为一个新项目选择正确的框架可能会令人紧张和疲惫,因为我们无法猜测这个框架会持续多久。这种相关性问题以及它如何影响对一组可能很快过时的工具的培训和开发的投资,被称为框架变动。
请记住,web 组件是一组 Web 平台规范。因此,它们可能会在 web 浏览器中使用很长一段时间,并提供许多好处,包括:
Web 组件是可重用的,并且在框架之间工作。
Web 组件可以在所有主流的 web 浏览器中运行。
Web 组件易于维护,并为未来做好了准备,这主要是因为它们基于 web 平台规范。
Web 组件生态系统的基本概念
在本书中,我将会用到一些与技术、方法或模式相关的术语,当我们在 web 应用中使用 Web 组件时,我们可以应用这些术语。这些如下。
设计系统
设计系统是可重用组件、指南和工具的目录或集合,允许组织中的团队构建数字产品以更有效地工作,并为他们的所有产品应用一致的品牌。这种方法的一些例子如下:
谷歌:材质设计( https://material.io
)
土坯:光谱( https://spectrum.adobe.com
)
Ionic : Ionic 框架( https://ionicframework.com/docs
)
组件驱动开发
组件驱动开发意味着通过构建独立的组件来设计软件应用。每个组件都有一个接口或 API 来与系统的其余部分进行通信。使用这种方法的一些优点是
更快的开发:将开发分成组件允许你用小范围和小目标构建模块化的部分。这意味着您可以更快地开发,并更快地让测试部分在其他系统中重用。
更简单的维护:当您必须添加或更新应用的功能时,您只需更新组件,而不必重构应用中更重要的部分。
可重用性:模块化组件允许可重用的功能,并且可以扩展来构建多个应用,消除了反复重写它们的需要。
测试驱动开发(TDD) :实现单元测试来验证每个模块化组件的功能变得更加容易。
更好地理解系统:当系统由模块化组件组成时,它变得更容易掌握、理解和操作。
对 Web 组件的浏览器支持
在撰写本文时(2020 年初),所有主流 web 浏览器都支持 Web 组件(见图 1-1 )。
图 1-1
支持 Web 组件主要规范的主流浏览器
入门指南
要开始使用 Web 组件构建应用,您必须了解并安装一些技术和工具。
cmder(仅适用于 Windows)
cmder
是一个 Windows 的终端模拟器。默认情况下,Windows 操作系统附带一个对开发没有用的终端(命令提示符)。这就是为什么我们需要cmder
,这是一个模拟器,我们可以使用它在我们的终端中流畅地运行命令。
要访问该仿真器,请转到cmder.net
并下载最新版本。
将文件解压到您的C:/
位置。
进入系统属性➤环境变量,编辑路径变量,如图 1-2 。
图 1-2
系统属性中的环境变量首选项
将cmder
位置添加到Path
变量中,如图 1-3 。
图 1-3
Path
系统属性中的变量首选项
从选择命令提示符运行cmder
,测试环境变量(图 1-4 )。
图 1-4
从选择命令提示符运行cmder
Node.js
Node.js 是一个 JavaScript 运行时环境。大多数使用 JavaScript 的项目使用 Node 来安装依赖项并创建脚本来自动化开发工作流。
您必须在计算机上安装节点。可以从 https://nodejs.org/en/
下载。
下载安装程序后,运行它并按照说明进行操作。
给麦克的
如果您使用的是 Mac,请遵循图 1-5 中所示的 Node.js 的安装说明。
图 1-5
Node.js Mac 安装
然后打开你的终端运行$node –v
。如果一切正常,您将在您的终端中看到节点版本,如图 1-6 所示。
图 1-6
终端中的节点版本
对于 Windows
要安装 Node.js for Windows,请遵循图 1-7 中所示的安装程序说明。
图 1-7
Node.js Windows 安装
然后,当你完成后,打开cmder
并运行$ node –v
。
新公共管理
当你安装 Node.js 的时候,你也安装了npm
。npm
是 Node.js 的包管理器,允许用户在他们的 JavaScript 项目中安装依赖项和运行小脚本。
适用于 Mac 和 Windows
通过$ npm –v
检查终端上运行的npm
版本。如果一切正常,你会在你的终端看到npm
版本,如图 1-8 。
图 1-8
npm
终端中的版本
谷歌 Chrome
Chrome 是一个网页浏览器,它为网页组件提供了出色的支持,并包括 Chrome DevTools,这是一个为开发者提供的便利功能。你可以从 www.google.com/chrome/
下载安装 Chrome。
适用于 Mac 和 Windows
要安装 Chrome for Mac 和 Windows,请运行安装程序并遵循相关步骤。Chrome 安装成功后,打开它,你会看到一个欢迎屏幕(图 1-9 )。
图 1-9
谷歌浏览器安装
Chrome DevTools(铬 DevTools)
Chrome DevTools 是谷歌 Chrome 浏览器中包含的一套网络开发工具。作为一名开发者,这个工具可以帮助你诊断应用中的问题,并使它变得更快。要打开,按 Command+Option+J (Mac)或 Control+Shift+J (Windows、Linux、Chrome OS),直接跳到控制台面板(图 1-10 )。
图 1-10
Google Chrome DevTools
灯塔
Lighthouse 是一个开源的自动化工具,用于提高网页质量。Lighthouse 可以在 Chrome 的 DevTools 中找到。 2 进入审计页签访问(图 1-11 )。
图 1-11
Google chrome devtools audit tab(Google chrome devtools 审核选项卡)
某视频剪辑软件
本书中的一些例子将使用 Vue.js 框架。Vue.js 是一个简单明了的 JavaScript 框架。Vue 主要面向视图层,但是您可以添加您需要的内容,并使用其生态系统中的所有工具构建强大的渐进式 web 应用。
在你的项目中使用 Vue 真的很简单。您只需在您的index.html
中添加以下内容,如清单 1-1 所示。
<!-- development version, includes helpful console warnings --><script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>Listing 1-1Adding Vue from the cdn Development Version
或者添加产品版本,如清单 1-2 所示。
<!-- production version, optimized for size and speed --><script src="https://cdn.jsdelivr.net/npm/vue"></script>Listing 1-2Adding Vue from the cdn Production Version
CLI 视图
Vue CLI 是一个用于快速 Vue.js 开发的完整系统。多亏了这个工具,我们可以在处理 Webpack、EsLint 和其他工具时避免一些额外的工作,并专注于在我们的应用中构建业务逻辑。您必须在您的终端中运行以下命令,将它安装到您的系统中:$npm install -g @vue/cli
。
如果一切正常,您将在您的终端中看到 Vue CLI 版本,如图 1-12 所示。
图 1-12
终端中的 Vue CLI 版本
饭桶
Git 是一个版本控制系统,旨在处理我们项目中的不同变更。我们将使用 Git 来操作我们的 web 应用项目,并处理每章中概述的连续步骤。可以从 https://git-scm.com/downloads
下载安装 Git。
适用于 Mac 和 Windows
要安装,请运行安装程序并按照步骤操作。完成后,打开cmder/terminal
并运行$ git –version
。
如果一切正常,您将在您的终端中看到 Git 版本,如图 1-13 所示。
图 1-13
终端中的 Git 版本
Firebase(火力基地)
Firebase 是一个云服务,可以帮助你自动化后端开发。您可以将 Firebase 理解为一个无需后端知识就可以保存数据、资产和验证用户身份的地方。Firebase 很强大,谷歌也支持它。对于我们的项目,您必须通过$npm install -g firebase-tools
在您的终端中安装 Firebase CLI。
此外,您必须在 https://firebase.google.com/
注册并创建一个新项目。我创建了项目“新闻-书籍-网页组件”(图 1-14 )。我将使用这个项目来连接和发布本书涵盖的所有功能。
图 1-14
Firebase web 控制台项目概述
在本书中,我们将使用认证、数据库和托管来增强我们的应用。
Firebase 认证
Firebase Authentication 是一项服务,允许我们在应用中使用身份验证系统,以处理安全和服务器相关的问题。
您可以通过开发➤认证从您的 web 控制台( https://console.firebase.google.com
)访问 Firebase 认证(图 1-15 )。
图 1-15
Firebase web 控制台身份验证
Firebase 数据库
Firebase Database 是一项服务,我们可以通过它添加一个远程数据库来保存我们的用户数据。此外,它是我们应用中处理实时信息的一个极好的选项,这意味着我们可以从移动或桌面设备打开我们的应用,并且会显示相同的信息。
您可以在开发➤数据库中的 web 控制台( https://console.firebase.google.com
)中找到 Firebase 数据库(图 1-16 )。
图 1-16
Firebase web 控制台数据库
Firebase 托管
Firebase Hosting 是一个托管服务,你可以用它来服务你所有的静态文件,连接你的域,并快速获得一个 SSL 证书。它也易于部署。
你可以通过开发➤主机从你的网络控制台( https://console.firebase.google.com
)找到 Firebase 主机(图 1-17 )。
图 1-17
Firebase web 控制台托管
Visual Studio 代码
Visual Studio Code 是一个免费的代码编辑器,它通过一组集成的工具以及通过插件扩展它们的可能性来帮助开发。可以从 https://code.visualstudio.com/
下载 Visual 工作室代码。
适用于 Mac 和 Windows
要安装 Visual Studio 代码,只需运行安装程序并按照步骤操作。然后从应用/程序列表中打开 Visual Studio 代码。
有许多代码编辑器可用,但我们在本书中打算使用 Visual Studio 代码,主要是因为它是免费的,工作流畅,并且有一个大的插件生态系统。
开发我们的第一个 Web 组件
现在我们将创建我们的第一个 web 组件,一个我们称之为vanilla-placeholder-component
的占位符。有了这个组件,你可以用红色背景和单词“placeholder”填充网页上的块,如图 1-18 所示。
图 1-18
占位符组件
这个组件在我们的 HTML 中的基本用法如清单 1-3 所示。
<vanilla-placeholder-content></vanilla-placeholder-content>Listing 1-3Using vanilla-placeholder-component
我们可以添加一些属性,如清单 1-4 所示。
<vanilla-placeholder-content height="100px" width="50px"></vanilla-placeholder-content>Listing 1-4Using vanilla-placeholder-component with Attributes
我们的组件接受高度和宽度属性来定制大小,但是如果我们不提供该信息,我们将默认为两者指定 100 像素。
首先我们必须创建一个文件index.html
并用一个基本结构填充它,如清单 1-5 所示。
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Demo - vanilla-placeholder</title></head><body></body></html>Listing 1-5index.html—Basic Structure
有了这段代码,我们就有了一个正文中什么都没有的基本 HTML 页面。因此,我们将在</body>
之前添加一些带有<script></script>
标签的 JavaScript,并且我们将添加基本结构来创建一个定制组件,如清单 1-6 所示。
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Demo - vanilla-placeholder</title></head><body><script>class VanillaPlaceholderContent extends HTMLElement { constructor() {}}customElements.define('vanilla-placeholder-content', VanillaPlaceholderContent);</script></body></html>Listing 1-6Adding a Custom Component in index.html
这样,我们将定义我们的标签<vanilla-placeholder-content>
并创建一个继承自HTMLElement
( https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
)的 JavaScript 类,并给我们定义组件的机会。
最后,我们将向VanillaPlaceholderContent
类添加一些代码,如清单 1-7 所示。
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Demo - vanilla-placeholder</title></head><body><script>class VanillaPlaceholderContent extends HTMLElement { constructor() { super(); const placeholder = document.createElement('template'); const height = this.getAttribute('height') || '100px'; const width = this.getAttribute('width') || '100px'; placeholder.innerHTML = VanillaPlaceholderContent.template(height, width); this.appendChild(document.importNode(placeholder.content, true)); } static template (height, width) { return ` <style> .placeholder { background-color: red; width: ${height}; height: ${width}; } </style> <div class="placeholder">Placeholder</div>`; }}customElements.define('vanilla-placeholder-content', VanillaPlaceholderContent);</script></body></html>Listing 1-7Adding Component Logic to vanilla-placeholder-component
一般来说,我们使用constructor()
来初始化我们的组件,使用this.getAttribute('')
,我们检查我们是否得到了一些属性,比如高度和宽度。接下来,我们使用template()
方法来创建我们的元素和样式,最后,我们使用this.appendChild(document.importNode(placeholder.content, true));
将它们添加到我们的 UI 中。
我们可以在网络浏览器中看到结果(图 1-19 )。
图 1-19
web 浏览器中的占位符组件
如果有些事情暂时难以理解,也不要担心。在接下来的章节中,你将会学到更多关于这个 API 的知识,以及它为什么有用。
您可以在$git checkout chap-1
访问本书(
https://github.com/carlosrojaso/apress-book-web-components
)
的源代码。
摘要
在本章中,您学习了以下内容:
什么是 Web 组件,当前主流浏览器的支持是什么
什么是设计系统,我们可以在网上找到一些例子
什么是组件驱动开发(CDD ),在我们的软件应用中使用这种方法有什么好处
Footnotes 1亚历克斯·罗素,“Web 组件和模型驱动视图”,前端 https://fronteers.nl/congres/2011/sessions/web-components-and-model-driven-views-alex-russell
,2020 年 9 月 28 日访问。
2
谷歌开发者,“灯塔”, https://developers.google.com/web/tools/lighthouse/
,2020 年 9 月 28 日访问。
二、自定义元素
在本章中,我们将探索 Web 组件集中的自定义元素规范。您将了解什么是定制元素,如何创建它们,以及定制元素的生命周期是什么。然后,我们将为我们的集合构建一个新的 web 组件。
什么是自定义元素?
自定义元素是一种机制,web 开发人员可以使用它来创建新的 HTML 标记。我们可以使用CustomElementRegistry
对象来创建我们的标签。例如,我们可以定义一个random-icon-placeholder
,如清单 2-1 所示。
class randomIconPlaceholder extends HTMLElement { constructor(){...}}customElements.define('random-icon-placeholder', randomIconPlaceholder);Listing 2-1Defining a Web Component with CustomElements
这里,我们使用小写的名称,由连字符(kebab-case)分隔,这是为自定义标记指定名称所必需的。此外,我们正在使用一个从HTMLElement
扩展而来的类。HTMLElement
是 HTML 中的主对象,文档对象模型(DOM)中的任何元素都会继承它的属性。(你可以在 https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement
找到更多信息)。)
有两种类型的自定义元素:自主的和自定义的。一个自治的定制元素不会继承另一个标准的 HTML 元素,比如<p>
、<a>
、<br>
等等。我们可以说上一个例子中的<random-icon-placeholder>
(清单 2-1 )是一个自治的定制元素。
定制的内置元素从另一个标准 HTML 元素继承而来。例如,我们可以定义一个元素来扩展<p>
元素,如清单 2-2 所示。
class randomParagraphSizePlaceholder extends HTMLParagraphElement { constructor(){...}}customElements.define('random-paragraph-size-placeholder',randomParagraphSizePlaceholder, {extends: p});Listing 2-2Defining a Customized CustomElement
现在我们可以在 HTML 文档中使用这个元素,如清单 2-3 所示。
<p is="random-paragraph-size-placeholder">Some text</p>Listing 2-3Using a Customized CustomElement
你可以在 https://html.spec.whatwg.org/multipage/indices.html#element-interfaces
找到要继承的接口列表。
自定义元素的生命周期挂钩
当我们定义一个定制元素时,我们可以使用生命周期挂钩在组件生命周期的特定时刻运行代码。在我们的自定义元素中有四个主要的时刻可以使用。
constructor
:创建或升级元素实例时触发。它对于初始化变量、添加事件侦听器或创建影子 DOM 非常有用。
connectedCallback
:每次在文档中追加自定义元素时触发。这将在每次移动节点时发生,并且可能在元素的内容被完全解析之前发生。
attributeChangedCallback (attrName, oldVal, newVal)
:每次添加、删除或更改定制元素的属性时都会调用这个函数。注意到变化的观察属性是用static get observedAttributes
方法指定的。
disconnectedCallback
:每次自定义元素从文档的 DOM 断开时调用。
我们可以在图 2-1 中看到 Web 组件生命周期的所有前述方法。
图 2-1
Web 组件生命周期
构建自定义元素
为了学习如何使用customElements
对象和生命周期挂钩,我们将创建randomParagraphSizePlaceholder
组件。这个简单的组件生成一个 12 到 50px 之间的随机数,并接收属性'text'
。
要在我们的 HTML 文档中使用这个组件,我们必须调用<random-paragraph-size-placeholder>
,如清单 2-4 所示。
<random-paragraph-size-placeholder text="My Personal Text"></random-paragraph-size-placeholder>Listing 2-4Using random-paragraph-size-placeholder
接下来,我们必须为自治的定制元素创建一个通用结构,如清单 2-5 所示。
class RandomParagraphSizePlaceholder extends HTMLElement { constructor(){...}}customElements.define('random-paragraph-size-placeholder', RandomParagraphSizePlaceholder);Listing 2-5Declaring random-paragraph-size-placeholder Component
这样,web 浏览器就知道我们想要注册一个新的 HTML 标签。
稍后,我们将添加生命周期挂钩作为方法,我们将添加console.log()
,以了解方法何时被触发。(参见清单 2-6 。)
class RandomParagraphSizePlaceholder extends HTMLElement { constructor(){ console.log(`contructor.`) } connectedCallback() { console.log(`connectedCallback hook`); } disconnectedCallback() { console.log(`disconnectedCallback hook`); } attributeChangedCallback(attrName, oldVal, newVal) { console.log(`attributeChangedCallback hook`); }}customElements.define('random-paragraph-size-placeholder', RandomParagraphSizePlaceholder);Listing 2-6Definingrandom-paragraph-size-placeholder Component
为了让attributeChangedCallback()
方法正确工作,我们必须添加静态方法observedAttributes()
并返回我们想要观察的属性。在本例中,我们只有'text'
属性,如清单 2-7 所示。
static get observedAttributes() { return ['text']; }Listing 2-7Adding Text to Be Observed in attributeChangedCallback
接下来,我们将在构造函数中添加基本逻辑,生成随机数并将它们发送给template
方法,如清单 2-8 所示。
constructor() { console.log(`constructor hook`); super(); const placeholder = document.createElement('template'); const myText = this.getAttribute('text') || 'Loren Ipsum'; const randomSize = Math.floor((Math.random() * (50 - 12 + 1)) + 12); placeholder.innerHTML = RandomParagraphSizePlaceholder.template(myText, randomSize); this.appendChild(document.importNode(placeholder.content, true)); }Listing 2-8Adding the Logic to Initialize random-paragraph-size-placeholder
总之,代码将如清单 2-9 所示。
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Demo - random-paragraph-size-placeholder</title></head><body><div id="parent"> <random-paragraph-size-placeholder text="My Personal Text"></random-paragraph-size-placeholder></div><button id="myButton" οnclick="removeElement()">Remove Element</button><script>class RandomParagraphSizePlaceholder extends HTMLElement { constructor() { console.log(`constructor hook`); super(); const placeholder = document.createElement('template'); const myText = this.getAttribute('text') || 'Loren Ipsum'; const randomSize = Math.floor((Math.random() * (50 - 12 + 1)) + 12); placeholder.innerHTML = RandomParagraphSizePlaceholder.template(myText, randomSize); this.appendChild(document.importNode(placeholder.content, true)); } static get observedAttributes() { return ['text']; } set text(val) { if (val) { this.setAttribute(`text`, val); } else { this.setAttribute(`text`, ``); } } get text() { return this.getAttribute('text'); } connectedCallback() { console.log(`connectedCallback hook`); } disconnectedCallback() { console.log(`disconnectedCallback hook`); } attributeChangedCallback(attrName, oldVal, newVal) { console.log(`attributeChangedCallback hook`); console.log(`attrName`, attrName); console.log(`oldVal`, oldVal); console.log(`newVal`, newVal); } static template (myText, randomSize) { return ` <div style="font-size:${randomSize}px">${myText}</div>`; }}customElements.define('random-paragraph-size-placeholder', RandomParagraphSizePlaceholder);const element = document.querySelector('random-paragraph-size-placeholder');function removeElement() { const parentElement = document.getElementById('parent') parentElement.removeChild(element); const myButton = document.getElementById('myButton'); myButton.disabled = true;}</script></body></html>Listing 2-9Final Code for random-paragraph-size-placeholder
您还会注意到,我添加了一个额外的函数removeElement
,来看看当我从 DOM 中移除组件时disconnectedCallback()
是如何被触发的。您可以在$git checkout chap-2
获取这本书的代码( https://github.com/carlosrojaso/apress-book-web-components
)。
摘要
在本章中,您学习了以下内容:
什么是CustomElementRegistry
对象以及如何使用它
自定义元素的两种主要类型是什么
什么是生命周期挂钩,什么时候触发
三、HTML 模板
在这一章中,我们将研究 HTML 模板,Web 组件集中的另一个规范。您将学习什么是 HTML 模板,以及如何在 web 组件中使用 HTML 模板。然后,我们将为我们的集合构建一个新的 web 组件。
什么是 HTML 模板?
HTML 模板规范定义了<template>
元素,以创建在我们的定制元素中不使用的标记片段,直到我们稍后在运行时激活它们。这些片段可以通过脚本克隆并插入到 HTML 中。
<template>
中的内容具有以下属性:
内容只有在激活后才会呈现。<template>
中的标记是隐藏的,不会呈现。
内容不会有副作用。脚本、图像和媒体标签在激活之前不会运行。
该内容将不被视为在文档对象模型(DOM)中。使用getElementById()
或querySelector()
不会返回模板的子节点。
清单 3-1 中说明了使用<template>
的基本方法。
<template id="my-error-message"> <p> Some error messages. </p></template>Listing 3-1Basic Example of Using <template>
我的段落在 DOM 中是隐藏的,如图 3-1 所示。
图 3-1
使用模板在网页中隐藏段落
如果我想显示我的内容,我必须用代码激活它,如清单 3-2 所示。
let myTemplate = document.getElementById('my-error-message');let myContent = myTemplate.content;document.body.appendChild(myContent);Listing 3-2Activating Content in <template>
这样,我们就可以激活我们文档中的内容,如图 3-2 所示。
图 3-2
激活网页中的template
时间
除了<template>
,我们还可以在内容中利用<slot>
。插槽允许你在模板中定义占位符,如图 3-3 所示。结合其他 Web 组件规范,插槽在元素内插入标记时非常有用。
清单 3-3 中概述了使用<slot>
的基本方法。
图 3-3
用消息填充error-component
中的槽
<p> <slot>This is a default message</slot></p>Listing 3-3Using <slot>
用
现在,我们将创建一个组件来处理应用中的错误和警告消息。用这个组件,你可以发送一种错误或警告消息,以及你想在<error-component></error-component>
之间显示的消息。如果您正在发送错误消息,您将会在红色背景下看到该消息。黄色背景下将显示一条警告消息。
首先,我们将为组件创建基本结构,如清单 3-4 所示。
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Demo - error-component</title></head><body><script>class ErrorComponent extends HTMLElement { constructor() { super(); }}customElements.define('error-component', ErrorComponent);</script></body></html>Listing 3-4Basic Structure of error-component
现在我们将创建静态方法template()
,用它我们将生成我们的标记,如清单 3-5 所示。
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Demo - error-component</title></head><body><script>class ErrorComponent extends HTMLElement { constructor() { super(); } static template () { return ` <template class="warning-type"> <style> .warning { background-color: yellow; padding: 15px; color: black; } </style> <div class="warning"> <slot>Error component<slot> </div> </template> <template class="error-type"> <style> .error { background-color: red; padding: 15px; color: black; } </style> <div class="error"> <slot>Error component<slot> </div> </template> <template class="none-type"> <style> .none { background-color: gray; padding: 15px; color: black; } </style> <div class="none"> <slot>Error component<slot> </div> </template> `; }}customElements.define('error-component', ErrorComponent);</script></body></html>Listing 3-5Adding the template() Method
在我们的标记中,我们有三个<template>
块——每个块对应一种我们可以接收的消息:错误、警告和无。此外,我们在每个标签中添加了<slot>
,它将接受我们在标签之间传递的值,如清单 3-6 所示。
<error-component>Value that the slot going to take</error-component>Listing 3-6Passing Error Messages with Slots
最后,我们将使用生命周期挂钩connectedCallback()
来处理选择使用哪个模板的逻辑,如清单 3-7 所示。
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Demo - error-component</title></head><body><script>class ErrorComponent extends HTMLElement { constructor() { super(); } connectedCallback() { this.root = this.attachShadow({mode: 'open'}); this.templates = document.createElement('div'); this.container = document.createElement('div'); this.root.appendChild(this.templates); this.root.appendChild(this.container); this.templates.innerHTML = ErrorComponent.template(); const kind = this.getAttribute(`kind`) || `none`; const template = this.templates.querySelector(`template.${kind}-type`); if (template) { const clone = template.content.cloneNode(true); this.container.innerHTML = ''; this.container.appendChild(clone); } } static template () { return ` <template class="warning-type"> <style> .warning { background-color: yellow; padding: 15px; color: black; } </style> <div class="warning"> <slot>Error component<slot> </div> </template> <template class="error-type"> <style> .error { background-color: red; padding: 15px; color: black; } </style> <div class="error"> <slot>Error component<slot> </div> </template> <template class="none-type"> <style> .none { background-color: gray; padding: 15px; color: black; } </style> <div class="none"> <slot>Error component<slot> </div> </template> `; }}customElements.define('error-component', ErrorComponent);</script></body></html>Listing 3-7Initializing Properties in connectedCallback()
这里,我们使用'this'
在组件中进行引用,并且使用方法connectedCallback()
来初始化这些属性。
我们也在使用影子 DOM 'this.attachShadow({mode: 'open'});'
。影子 DOM 是下一章的主题,但是你可以把这里的这个看作是特定于我们的组件的受保护的 DOM 树。
在这个逻辑中,我们获得了'kind'
属性,并呈现了正确的<template>
,无论它是错误、警告还是无。结果如图 3-4 所示。
图 3-4
使用 Chrome 上的错误组件
您可以在$git checkout chap-3
通过回购( https://github.com/carlosrojaso/apress-book-web-components
)获取相关代码。
摘要
在本章中,您学习了以下内容:
什么是<template>
以及如何在我们的 web 组件中使用它
什么是<slot>
以及如何在我们的 web 组件中使用它
如何创建 web 组件来处理错误和警告
四、影子 DOM
在这一章中,你将会熟悉 Shadow DOM,Web 组件集合中的另一个规范。您将了解什么是 Shadow DOM 以及如何在 web 组件中使用它。接下来,我们将为我们的集合构建一个新的 web 组件。
什么是影子 DOM?
影子 DOM 规范定义了一种封装 web 组件的机制。我们在 web 组件内部创建的标记和样式保护它免受外部 DOM 操作和全局 CSS 规则的影响。
例如,考虑 HTML 5 <video>
标签。如果我们想要一个视频播放器,我们创建类似于清单 4-1 所示的东西。
<video width="640" height="480" controls> <source src="myVideo.mp4" type="video/mp4"> Your browser does not support the video tag.</video>Listing 4-1Using video tag
然而,当你查看网页浏览器中呈现的内容时(图 4-1 ,你可以看到 CSS 样式、div 和输入的复杂组合,它们被封装用于外部修改,并且你只能看到标签<video>
。这就是影子王国的力量。
图 4-1
<video>
谷歌浏览器中的标签
以下是影子 DOM 的一些好处:
影子 DOM 创建了一个独立的 DOM,允许我们在 web 组件中操作 DOM,而不用担心外部节点。
Shadow DOM 创建了一个作用域 CSS,这意味着我们可以创建更多的通用规则,而不用担心命名冲突。
清单 4-2 显示了使用影子 DOM 的基本方法。你可能记得我们在第三章中使用了这个方法,在清单 3-7 中,我们给我们的 web 组件添加了一个阴影 DOM,来激活/停用我们例子中的模板。
let shadowElement = element.attachShadow({mode: 'open'});Listing 4-2Attaching a Shadow DOM
前面代码片段中的attachShadow()
接收一个可以是'open'
或'closed'
的模式。'open'
表示可以从主上下文访问影子 DOM,'closed'
表示不能。
阴影根
影子根是我们的影子 DOM 创建的 DOM 中的根节点(图 4-2 )。
图 4-2
影子 DOM 中的影子根节点
阴影树
影子树就是我们的影子 DOM 创建的 DOM 树(图 4-3 )。
图 4-3
阴影中的阴影树
阴影边界
阴影 DOM 结束和全局 DOM 继续的界限(图 4-4 )是阴影边界。
图 4-4
我们的 web 应用 DOM 中的阴影边界
影子主机
影子主机是影子 DOM 附加到的全局 DOM 节点(图 4-5 )。
图 4-5
我们的 web 应用 DOM 中的影子主机
构建社会共享组件
为了使用 Shadow DOM,我们将构建一个名为<social-share-component>
的简单组件,为我们的应用添加社交网络链接。这个组件接收两个参数,'socialNetwork'
和'user'
,其中'tw'
表示 Twitter,'fb'
表示脸书。首先,我们将定义我们的组件,如清单 4-3 所示。
class SocialShareComponent extends HTMLElement {}customElements.define('social-share-component', SocialShareComponent);Listing 4-3Defining SocialShareComponent
接下来,我们将在组件中定义一些 getters 和 setters,来处理'socialNetwork'
和'user'
参数,如清单 4-4 所示。
class SocialShareComponent extends HTMLElement { get socialNetwork() { return this.getAttribute('socialNetwork') || 'tw'; } set socialNetwork(newValue) { this.setAttribute('socialNetwork', newValue); } get user() { return this.getAttribute('user') || 'none'; } set user(newValue) { this.setAttribute('user', newValue); }}customElements.define('social-share-component', SocialShareComponent);Listing 4-4Defining SocialShareComponent
此外,我们将定义一些静态方法,向我们的组件添加标记和样式,如清单 4-5 所示。
class SocialShareComponent extends HTMLElement { get socialNetwork() { return this.getAttribute('socialNetwork') || 'tw'; } set socialNetwork(newValue) { this.setAttribute('socialNetwork', newValue); } get user() { return this.getAttribute('user') || 'none'; } set user(newValue) { this.setAttribute('user', newValue); } static twTemplate(user) { return ` ${SocialShareComponent.twStyle()} <span class="twitter-button"> <a href="https://twitter.com/${user}"> Follow @${user} </a> </span>`; } static twStyle() { return ` <style> a { height: 20px; padding: 3px 6px; background-color: #1b95e0; color: #fff; border-radius: 3px; font-weight: 500; font-size: 11px; font-family:'Helvetica Neue', Arial, sans-serif; line-height: 18px; text-decoration: none; } a:hover { background-color: #0c7abf; } span { margin: 5px 2px; } </style>`; } static fbTemplate(user) { return ` ${SocialShareComponent.fbStyle()} <span class="facebook-button"> <a href="https://facebook.com/${user}"> Follow @${user} </a> </span>`; } static fbStyle() { return ` <style> a { height: 20px; padding: 3px 6px; background-color: #4267b2; color: #fff; border-radius: 3px; font-weight: 500; font-size: 11px; font-family:'Helvetica Neue', Arial, sans-serif; line-height: 18px; text-decoration: none; } a:hover { background-color: #0c7abf; } span { margin: 5px 2px; } </style>`; }}customElements.define('social-share-component', SocialShareComponent);Listing 4-5Static Methods to Add Markup and Styles
最后,我们将构建我们的constructor()
方法,如清单 4-6 所示,用它我们将在组件的根中附加阴影 DOM,并在它后面附加一个 div 元素,它将作为我们的标记和样式的容器。
class SocialShareComponent extends HTMLElement { constructor() { super(); this.root = this.attachShadow({mode: 'open'}); this.container = document.createElement('div'); this.root.appendChild(this.container); switch(this.socialNetwork) { case 'tw': this.container.innerHTML = SocialShareComponent.twTemplate(this.user); break; case 'fb': this.container.innerHTML = SocialShareComponent.fbTemplate(this.user); break; } } get socialNetwork() { return this.getAttribute('socialNetwork') || 'tw'; } set socialNetwork(newValue) { this.setAttribute('socialNetwork', newValue); } get user() { return this.getAttribute('user') || 'none'; } set user(newValue) { this.setAttribute('user', newValue); } static twTemplate(user) { return ` ${SocialShareComponent.twStyle()} <span class="twitter-button"> <a href="https://twitter.com/${user}"> Follow @${user} </a> </span>`; } static twStyle() { return ` <style> a { height: 20px; padding: 3px 6px; background-color: #1b95e0; color: #fff; border-radius: 3px; font-weight: 500; font-size: 11px; font-family:'Helvetica Neue', Arial, sans-serif; line-height: 18px; text-decoration: none; } a:hover { background-color: #0c7abf; } span { margin: 5px 2px; } </style>`; } static fbTemplate(user) { return ` ${SocialShareComponent.fbStyle()} <span class="facebook-button"> <a href="https://facebook.com/${user}"> Follow @${user} </a> </span>`; } static fbStyle() { return ` <style> a { height: 20px; padding: 3px 6px; background-color: #4267b2; color: #fff; border-radius: 3px; font-weight: 500; font-size: 11px; font-family:'Helvetica Neue', Arial, sans-serif; line-height: 18px; text-decoration: none; } a:hover { background-color: #0c7abf; } span { margin: 5px 2px; } </style>`; }}customElements.define('social-share-component', SocialShareComponent);Listing 4-6Adding a constructor()in SocialShareComponent
这里我们使用switch()
来处理我们需要使用的标记和样式,这取决于'socialNetwork'
参数。结果如图 4-6 所示。
图 4-6
在谷歌浏览器中使用social-share-component
您可以在$git checkout chap-4
获取这本书的代码( https://github.com/carlosrojaso/apress-book-web-components
)。
摘要
在本章中,您学习了以下内容:
什么是影子 DOM 以及如何在我们的 web 组件中使用它
什么是影像根、影像树、影像边界和影像宿主
如何使用 Shadow DOM 创建一个 web 组件,在我们的 web 应用中添加社交网络
五、ES 模块
在这一章中,我将讨论 ES 模块,Web 组件集中的另一个规范。您将学习什么是 es 模块,以及如何在 web 组件中使用 ES 模块。然后,我们将为我们的集合构建一个新的 web 组件。
什么是 ES 模块?
ES 模块规范定义了一种机制,通过该机制可以在我们的项目中通过不同的文件共享变量和函数。ES6 现在提供了 ES 模块。在此之前,如果您想要共享某个东西,您可以将它添加到全局上下文中,并使它无论是否被使用都可用。考虑清单 5-1 中的代码。
var pi = 3.1415;var euler = 2.7182;function getCircumference(radius) { return 2 * pi * radius;}function getCalcOneYear(interestRate, currentVal) { return currentVal * (euler ** interestRate);}console.log(getCircumference(2)); // 12.566console.log(getCalcOneYear(0.3, 100)); // 134\. 98466170045035Listing 5-1Using Constants in main.js
在main.js
中,我们有两个值pi
和euler
,它们是函数getCircumference
和getCalcOneYear
所需要的。但是如果我们在应用的不同地方的不同函数中需要pi
和euler
呢?
为了更容易地共享这些值,我们可以创建一个新文件math-constants.js
,并使用'export'
,告诉 JavaScript 我们可以导入该值。如清单 5-2 所示。
export const pi = 3.1415;export const euler = 2.7182;Listing 5-2Exporting Values in file math-constants.js
现在我们可以在其他文件中使用这些值,在 HTML 文件中使用type="module"
,如清单 5-3 ,或者在 JS 文件中使用import
,如清单 5-4 。
import {pi, euler} from "./math-constants.js";Listing 5-4Using ES Modules in JS Scripts
<script type="module" src="./math-constants.js"></script>Listing 5-3Using ES Modules in HTML
图 5-1 提供了 ES 模块的图形视图。
图 5-1
ES 模块的图形表示
构建 MathOperationsComponent 组件
为了练习使用 Shadow DOM,我们将构建一个名为<math-operations-component>
的简单组件,在我们的应用中添加社交网络链接。该组件接收两个参数,operation'
和initialValue'
,其中,getCircumference'
接收半径为的圆周;getCalcOneYear
获取一年的复利,有两个参数,利率和当前值;而’getLog2'
返回2
的自然对数值。
首先,我们将定义我们的组件,如清单 5-5 所示。
class MathOperationsComponent extends HTMLElement { constructor() { }}customElements.define('math-operations-component', MathOperationsComponent);Listing 5-5Defining MathOperationsComponent
在与index.html
相同的层中创建一个新的math-constants.js
文件,并创建常量pi
、euler
和ln2
,使用'export'
允许将这些值作为一个模块使用,如清单 5-6 所示。
export const pi = 3.1415;export const euler = 2.7182;export const ln2 = 0.693;Listing 5-6Defining MathOperationsComponent
现在,在文件index.html
中,我们必须添加一些东西,以便使用组件中的模块,如清单 5-7 所示。
<script type="module">import {pi, euler, ln2} from './math-constants.js';class MathOperationsComponent extends HTMLElement { constructor() { }}customElements.define('math-operations-component', MathOperationsComponent);</script>Listing 5-7Using Modules in MathOperationsComponent
前面代码片段中的第一个是我们代码中模块的脚本标记中的type="module"
。第二个是“导入”,用于我们在math-constants.js
中声明的常量。
现在,在我们的组件中,我们将创建getCircumference
、getCalcOneYear
和getLN2
,以返回我们在调用中发送的参数所需的值。这显示在清单 5-8 中。
getCircumference(radius) { return 2 * pi * radius; } getCalcOneYear(interestRate, currentVal) { return currentVal * (euler ** interestRate); } getLN2() { return ln2; }Listing 5-8Using Modules in MathOperationsComponent
注意,这里我们使用的是从模块中导入的常量。
最后,我们在constructor()
中添加逻辑,以处理我们在组件的'operation'
和'initialValue'
属性中发送的参数,并为我们希望在文档中显示信息的模板和样式创建方法,如清单 5-9 所示。
<script type="module">import {pi, euler, ln2} from './math-constants.js';class MathOperationsComponent extends HTMLElement { constructor() { super(); this.root = this.attachShadow({mode: 'open'}); this.container = document.createElement('div'); this.root.appendChild(this.container); switch(this.getAttribute('operation')) { case 'getCircumference': const radius = this.getAttribute('initialValue'); this.container.innerHTML = MathOperationsComponent.getTemplate(this.getCircumference(radius)); break; case 'getCalcOneYear': const [interestRate, currentVal] = this.getAttribute('initialValue').split(','); this.container.innerHTML = MathOperationsComponent.getTemplate(this.getCalcOneYear(interestRate, currentVal)); break; case 'getLog2': this.container.innerHTML = MathOperationsComponent.getTemplate(this.getLN2()); break; } } getCircumference(radius) { return 2 * pi * radius; } getCalcOneYear(interestRate, currentVal) { return currentVal * (euler ** interestRate); } getLN2() { return ln2; } static getTemplate(value) { return ` ${MathOperationsComponent.getStyle()} <div> ${value} </div> `; } static getStyle() { return ` <style> div { padding: 5px; background-color: yellow; color; black; } </style>`; }}customElements.define('math-operations-component', MathOperationsComponent);</script>Listing 5-9Using Modules in MathOperationsComponent
为了运行我们的代码,由于浏览器和模块的安全策略,我们必须使用静态服务器。我们可以初始化一个从项目根目录运行的节点应用
$npm init
以及回答终端中的问题。
后来,在package.json
文件中,我添加了两件事,如清单 5-10 所示。
..."scripts": { "start": "serve", ... },"devDependencies": { "serve": "¹¹.3.2" }...Listing 5-10Adding dependencies and npm Script in Package.json
这条指令将在我们的项目中安装'serve'
包,并使用'npm run start'
运行一个本地服务器。
然后,要运行我们的示例,您必须进入我们的math-operations-component
文件夹并运行
$npm install
稍后,运行
$npm run start
现在转到http://localhost:5000
,就这样。您可以看到我们的组件正在运行(图 5-2 )。
图 5-2
在本地服务器上使用 Google Chrome 中的 ES 模块
你可以在$git checkout chap-5
访问这本书的源代码( https://github.com/carlosrojaso/apress-book-web-components
)。
摘要
在本章中,您学习了以下内容:
什么是 ES 模块以及如何在 web 组件中使用它们
如何创建 ES 模块
如何使用 ES 模块创建一个 web 组件,在我们的 web 应用中添加数学函数
六、组件架构
在本章中,你将学习如何设计组件,并使它们在 web 应用中协同工作。我们将把我们的 web 应用连接到应用编程接口(API ),并为我们的组件定义数据流。
我们的 NoteApp 应用
我们将创建一个简单的 notes 应用,允许用户做笔记,如图 6-1 所示。在用户添加信息后,当用户单击按钮时,将使用带有“标题”和“描述”的模态和表单创建一个新的注释。我们必须将这些信息添加到一个注释列表中,向我们显示用户过去创建的所有注释,并从该列表中删除注释。
图 6-1
NoteApp 模型
现在,我们可以利用这个初始模型,考虑如何将元素分割成小块,这将使我们的开发更容易,并更好地面向将来可以使用的组件。
在图 6-2 中,你可以看到我们有三个主要组件(simple-form-modal-component
、note-list-component
和note-list-item-component
),我们可以在app.js
的逻辑中使用它们来实现我们的目标,即拥有一个注释列表、删除注释的能力以及允许添加新注释。
图 6-2
识别我们应用中的组件
定义好组件后,我们可以考虑组件的层次结构(如图 6-3 ),看看它们之间的关系。
图 6-3
识别应用中组件的层次结构
我们可以看到主要元素是app.js
。我们将添加两个兄弟组件,simple-form-modal-component
和note-list-component
,在note-list-component
中,我们将有几个note-list-item-component
元素是note-list-component
的子元素。清楚地理解这种关系将有助于我们在接下来的步骤中做出其他的架构决策。
Web 组件之间的通信
当我们使用组件时,通常我们需要一种方法在父母和孩子之间发送和接收数据(如图 6-4 所示),以更新或发送用户或其他组件在我们应用的业务逻辑中的某个时刻所做的更改的通知。为此,我们使用属性获取数据,并使用事件将数据发送给其他组件。
图 6-4
识别组件之间的通信
在图 6-5 中,你可以看到我们将使用idx
和note
属性定义来自note-list-component
的传递数据,并使用delEvent
事件接收数据。
图 6-5
设计note-list-component
和note-list-item-component
之间的通信机制
这意味着在我们的NoteListComponent
中,我们将创建我们需要的<note-list-item-component>
元素,并传递一个对象note
和一个数字idx
,如清单 6-1 所示。
class NoteListComponent extends HTMLElement { constructor() { } render() { return ` <note-list-item-component note='${JSON.stringify(note)}' idx='${idx}'></note-list-item-component>`; }}customElements.define('note-list-component', NoteListComponent);Listing 6-1Defining NoteListComponent
我们将使用JSON.stringify()
来正确传递对象note
,并使用JSON.parse()
来接收子组件中的数据。你可以在 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
和 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse
了解更多这些方法。
现在,在我们的NotelistItemComponent
中,我们将创建一个自定义事件,NoteListComponent
可以监听并知道用户何时想要从列表中删除一个注释,如清单 6-2 所示。
class NoteListItemComponent extends HTMLElement { constructor() { } handleDelete() { this.dispatchEvent(new CustomEvent('delEvent', {bubbles: true, detail: {idx: this.idx}})); }}customElements.define('note-list-item-component', NoteListItemComponent);Listing 6-2Adding a Custom Event in NoteListItemComponent
这里,我们创建了一个handleDelete()
方法,它使用CustomEvent()
创建一个事件,并在detail
中发送我们需要的数据。通过这种方式,父节点将知道需要从列表中删除什么项目。你可以在 https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent
中了解更多关于CustomEvent
的信息。
通常,有了属性和事件,我们可以在大多数中小规模的场景中处理组件之间的通信。尽管如此,如果你的应用更复杂,组件之间的交互更复杂,你将需要一个事件总线,你可以在 www.npmjs.com/package/js-event-bus
找到,或者像 Redux 这样的模式,你可以在 https://github.com/reduxjs/redux
找到。
notes list component
在上一节中,您了解了如何让我们的 web 组件进行通信。现在我们将构建这些组件。首先,处理注释的更自然的方式是接收一组注释,并为每个元素创建一个条目。然后,我们将获取属性notes
并使用方法JSON.parse()
将其转换成一个对象。我们使用_notes
,以避免与组件中的设置器发生冲突,并在每次更新时再次呈现所有注释(参见清单 6-3 )。
class NoteListComponent extends HTMLElement { constructor() { super(); this._notes = JSON.parse(this.getAttribute('notes')) || []; this.root = this.attachShadow({mode: 'open'}); this.root.innerHTML = this.render(); } render() { let noteElements = ''; this._notes.map( (note, idx) => { noteElements += ` <note-list-item-component note='${JSON.stringify(note)}' idx='${idx}'></note-list-item-component>`; } ); return ` ${noteElements}`; } get notes(){ return this._notes; } set notes(newValue) { this._notes = newValue; this.root.innerHTML = this.render(); }}customElements.define('note-list-component', NoteListComponent);Listing 6-3getter and setter in NoteListComponent
此外,我们在 render 方法中使用了一个map()
,迭代 object notes 并创建我们需要的每个项目,并用项目列表更新 Shadow DOM。
记住,每当我们需要从列表中删除一个项目时,我们都会收到一个delEvent
事件。因此,我们必须在这里处理这种行为,为该事件添加一个监听器,并从列表中删除该元素,如清单 6-4 所示。
class NoteListComponent extends HTMLElement { constructor() { super(); this._notes = JSON.parse(this.getAttribute('notes')) || []; this.root = this.attachShadow({mode: 'open'}); this.root.innerHTML = this.render(); this.handleDelEvent = this.handleDelEvent.bind(this); } connectedCallback() { this.root.addEventListener('delEvent', this.handleDelEvent); } disconnectedCallback () { this.root.removeEventListener('delEvent', this.handleDelEvent); } handleDelEvent(e) { this._notes.splice(e.detail.idx, 1); this.root.innerHTML = this.render(); } render() { let noteElements = ''; this._notes.map( (note, idx) => { noteElements += ` <note-list-item-component note='${JSON.stringify(note)}' idx='${idx}'></note-list-item-component>`; } ); return ` ${noteElements}`; } get notes(){ return this._notes; } set notes(newValue) { this._notes = newValue; this.root.innerHTML = this.render(); }}customElements.define('note-list-component', NoteListComponent);Listing 6-4Adding a Listener in NoteListComponent
这里,我们在connectedCallback()
中添加监听器,在disconnectedCallback()
中移除监听器,以避免在移除组件时出现不必要的监听器。此外,我们在构造函数中使用bind()
,在构造函数中表明我们正在定义handleDelEvent()
,并确保当我们想要在组件中传递方法时,它不会变得未定义。经过这些修改,我们的组件就完成了。
NoteListItemComponent
现在,NoteListComponent
创建了一个包含<note-list-item-component>
元素的列表,并传递了一个note
对象和一个idx
数字,该数字相当于音符数组中的位置。我们将在NoteListItemComponent
中添加 getters 和 setters,并在constructor()
中初始化这些属性。记住,笔记是一个对象,使用JSON.parse()
,我们将把这些数据转换成一个对象(参见清单 6-5 )。
class NoteListItemComponent extends HTMLElement { constructor() { super(); this._note = JSON.parse(this.getAttribute('note')) || {}; this.idx = this.getAttribute('idx') || -1; this.root = this.attachShadow({mode: 'open'}); } get note() { return this._note; } set note(newValue) { this._note = newValue; } get idx() { return this._idx; } set idx(newValue) { this._idx = newValue; } handleDelete() { this.dispatchEvent(new CustomEvent('delEvent', {bubbles: true, detail: {idx: this.idx}}));}customElements.define('note-list-item-component', NoteListItemComponent);Listing 6-5Adding Getters and Setters in NoteListItemComponent
我们将添加这个组件的模板和样式,为注释获得一个好的项目(参见清单 6-6 )。
class NoteListItemComponent extends HTMLElement { constructor() { super(); this._note = JSON.parse(this.getAttribute('note')) || {}; this.idx = this.getAttribute('idx') || -1; this.root = this.attachShadow({mode: 'open'}); this.root.innerHTML = this.getTemplate(); } get note() { return this._note; } set note(newValue) { this._note = newValue; } get idx() { return this._idx; } set idx(newValue) { this._idx = newValue; } handleDelete() { this.dispatchEvent(new CustomEvent('delEvent', {bubbles: true, detail: {idx: this.idx}})); getStyle() { return ` <style> .note { background-color: #ffffcc; border-left: 6px solid #ffeb3b; } div { margin: 5px 0px 5px; padding: 4px 12px; } </style> `; } getTemplate() { return` ${this.getStyle()} <div class="note"> <p><strong>${this._note.title}</strong> ${this._note.description}</p><br/> <button type="button" id="deleteButton">Delete</button> </div>`; }}customElements.define('note-list-item-component', NoteListItemComponent);Listing 6-6Adding the Template and Styles in NoteListItemComponent
有了这些改进,我们的<note-list-item-component>
将看起来像图 6-6 。
图 6-6
note-list-item
谷歌浏览器中的组件
最后,我们将为触发delEvent
事件的删除按钮添加一个事件监听器。与NoteListComponent
一样,我们必须在connectedCallback()
中添加监听器,并在disconnectedCallback()
中移除它,如清单 6-7 所示。
class NoteListItemComponent extends HTMLElement { constructor() { super(); this._note = JSON.parse(this.getAttribute('note')) || {}; this.idx = this.getAttribute('idx') || -1; this.root = this.attachShadow({mode: 'open'}); this.root.innerHTML = this.getTemplate(); this.handleDelete = this.handleDelete.bind(this); } connectedCallback() { this.delBtn.addEventListener('click', this.handleDelete); } disconnectedCallback () { this.delBtn.removeEventListener('click', this.handleDelete); } get note() { return this._note; } set note(newValue) { this._note = newValue; } get idx() { return this._idx; } set idx(newValue) { this._idx = newValue; } handleDelete() { this.dispatchEvent(new CustomEvent('delEvent', {bubbles: true, detail: {idx: this.idx}})); getStyle() { return ` <style> .note { background-color: #ffffcc; border-left: 6px solid #ffeb3b; } div { margin: 5px 0px 5px; padding: 4px 12px; } </style> `; } getTemplate() { return` ${this.getStyle()} <div class="note"> <p><strong>${this._note.title}</strong> ${this._note.description}</p><br/> <button type="button" id="deleteButton">Delete</button> </div>`; }}customElements.define('note-list-item-component', NoteListItemComponent);Listing 6-7Adding an Event Listener in NoteListItemComponent
现在当用户点击删除按钮时,NoteListItemComponent
将发送自定义事件,并且NoteListComponent
将知道必须从列表中删除的项目是什么。
SimpleFormModalComponent
准备好NoteListComponent
和NoteListItemComponent
后,我们现在需要一种简单的方法在我们的应用中添加新的笔记。这就是为什么我们要创建SimpleFormModalComponent
,一个允许用户输入标题和描述的表单。该组件将与app.js
通信,我们将使用一个 open 属性,以了解何时显示或隐藏模态,以及用户何时在表单中插入数据。我们将通过自定义事件addEvent
传递该数据,如图 6-7 所示。
图 6-7
识别simple-form-modal-component
和app.js
之间的通信
我们将开始定义我们的组件,添加 setters 和 getters,如清单 6-8 所示。
class SimpleFormModalComponent extends HTMLElement { constructor() { super(); this.root = this.attachShadow({mode: 'open'}); this.container = document.createElement('div'); this.container.innerHTML = this.getTemplate(); this.root.appendChild(this.container.cloneNode(true)); this._open = this.getAttribute('open') || false; } get open() { return this._open; } set open(newValue) { this._open = newValue; }}customElements.define('simple-form-modal-component', SimpleFormModalComponent);Listing 6-8Adding Getters and Setters in SimpleFormModalComponent
现在我们将添加显示和隐藏组件所需的模板和样式(参见清单 6-9 )。
class SimpleFormModalComponent extends HTMLElement { constructor() { super(); this.root = this.attachShadow({mode: 'open'}); this.container = document.createElement('div'); this.container.innerHTML = this.getTemplate(); this.root.appendChild(this.container.cloneNode(true)); this._open = this.getAttribute('open') || false; } get open() { return this._open; } set open(newValue) { this._open = newValue; } getTemplate() { return ` ${this.getStyle()} <div id="myModal" class="modal"> <div class="modal-content"> <form id="myForm"> <label for="ftitle">Title:</label><br> <input type="text" id="ftitle" name="ftitle"><br> <label for="fdesc">Description:</label><br> <textarea id="fdesc" name="fdesc" rows="4" cols="50"></textarea><br/> <button type="button" id="addBtn">Add</button><button type="button" id="closeBtn">Close</button> </form> </div> </div>`; } getStyle() { return ` <style> .modal { display: none; position: fixed; z-index: 1; padding-top: 100px; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgb(0,0,0); background-color: rgba(0,0,0,0.4); } .modal-content { background-color: #fefefe; margin: auto; padding: 20px; border: 1px solid #888; width: 50%; } .close { color: #aaaaaa; float: right; font-size: 28px; font-weight: bold; } .close:hover, .close:focus { color: #000; text-decoration: none; cursor: pointer; } </style>`; }}customElements.define('simple-form-modal-component', SimpleFormModalComponent);Listing 6-9Adding the Template and Styles in SimpleFormModalComponent
通过这种增强,我们将展示一个有两个输入和两个按钮的表单。modal
类将元素放在所有东西的顶部,并用透明的灰色背景显示在窗口的中间。此外,因为默认显示是"none"
,这意味着当我们将属性设置为"block"
时,我们将隐藏该元素并使其可见。为了处理这种行为,我们将创建方法showModal()
并在 open 属性的 setter 中触发它,如清单 6-10 所示。
class SimpleFormModalComponent extends HTMLElement { constructor() { super(); this.root = this.attachShadow({mode: 'open'}); this.container = document.createElement('div'); this.container.innerHTML = this.getTemplate(); this.root.appendChild(this.container.cloneNode(true)); this._open = this.getAttribute('open') || false; this.modal = this.root.getElementById("myModal"); } get open() { return this._open; } set open(newValue) { this._open = newValue; this.showModal(this._open); } showModal(state) { if(state) { this.modal.style.display = "block"; } else { this.modal.style.display = "none" } } getTemplate() { return ` ${this.getStyle()} <div id="myModal" class="modal"> <div class="modal-content"> <form id="myForm"> <label for="ftitle">Title:</label><br> <input type="text" id="ftitle" name="ftitle"><br> <label for="fdesc">Description:</label><br> <textarea id="fdesc" name="fdesc" rows="4" cols="50"></textarea><br/> <button type="button" id="addBtn">Add</button><button type="button" id="closeBtn">Close</button> </form> </div> </div>`; } getStyle() { return ` <style> .modal { display: none; position: fixed; z-index: 1; padding-top: 100px; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgb(0,0,0); background-color: rgba(0,0,0,0.4); } .modal-content { background-color: #fefefe; margin: auto; padding: 20px; border: 1px solid #888; width: 50%; } .close { color: #aaaaaa; float: right; font-size: 28px; font-weight: bold; } .close:hover, .close:focus { color: #000; text-decoration: none; cursor: pointer; } </style>`; }}customElements.define('simple-form-modal-component', SimpleFormModalComponent);Listing 6-10Adding showModal() in SimpleFormModalComponent
最后,我们将为按钮创建事件,并在用户添加注释时发送一个自定义事件(参见清单 6-11 )。
class SimpleFormModalComponent extends HTMLElement { constructor() { super(); this.root = this.attachShadow({mode: 'open'}); this.container = document.createElement('div'); this.container.innerHTML = this.getTemplate(); this.root.appendChild(this.container.cloneNode(true)); this._open = this.getAttribute('open') || false; this.modal = this.root.getElementById("myModal"); this.addBtn = this.root.getElementById("addBtn"); this.closeBtn = this.root.getElementById("closeBtn"); this.handleAdd = this.handleAdd.bind(this); this.handleCancel = this.handleCancel.bind(this); } connectedCallback() { this.addBtn.addEventListener('click', this.handleAdd); this.closeBtn.addEventListener('click', this.handleCancel); } disconnectedCallback () { this.addBtn.removeEventListener('click', this.handleAdd); this.closeBtn.removeEventListener('click', this.handleCancel); } get open() { return this._open; } set open(newValue) { this._open = newValue; this.showModal(this._open); } handleAdd() { const fTitle = this.root.getElementById('ftitle'); const fDesc = this.root.getElementById('fdesc'); this.dispatchEvent(new CustomEvent('addEvent', {detail: {title: fTitle.value, description: fDesc.value}})); fTitle.value = ''; fDesc.value = ''; this.open = false; } handleCancel() { this.open = false; } showModal(state) { if(state) { this.modal.style.display = "block"; } else { this.modal.style.display = "none" } } getTemplate() { return ` ${this.getStyle()} <div id="myModal" class="modal"> <div class="modal-content"> <form id="myForm"> <label for="ftitle">Title:</label><br> <input type="text" id="ftitle" name="ftitle"><br> <label for="fdesc">Description:</label><br> <textarea id="fdesc" name="fdesc" rows="4" cols="50"></textarea><br/> <button type="button" id="addBtn">Add</button><button type="button" id="closeBtn">Close</button> </form> </div> </div>`; } getStyle() { return ` <style> .modal { display: none; position: fixed; z-index: 1; padding-top: 100px; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgb(0,0,0); background-color: rgba(0,0,0,0.4); } .modal-content { background-color: #fefefe; margin: auto; padding: 20px; border: 1px solid #888; width: 50%; } .close { color: #aaaaaa; float: right; font-size: 28px; font-weight: bold; } .close:hover, .close:focus { color: #000; text-decoration: none; cursor: pointer; } </style>`; }}customElements.define('simple-form-modal-component', SimpleFormModalComponent);Listing 6-11Adding Events in SimpleFormModalComponent
这个代码类似于我们为NoteListComponent
和NoteListItemComponent
做的代码。我们在connectedCallback()
中添加监听器,在disconnectedCallback()
中删除它,并在handleAdd()
中发送定制事件。现在我们有了一个模态(见图 6-8 )。
图 6-8
simple-form-modal-component
在谷歌浏览器中
添加 API
通常,当您使用 web 应用时,您必须将应用连接到服务。我们将使用 API Rest 来连接开放的 API https://jsonplaceholder.typicode.com/
。这个 API 拥有所有可以在实际应用中使用的 HTTP 方法(GET
、POST
、PUT
、PATCH
、DELETE
)。
调用 API 方法在我们应用的所有部分都很常见。因此,我们可能会在几个组件中重用这些方法。这就是为什么我们要构建一个带有调用的小模块,并且在将来,只导入它并使用我们需要的函数(参见清单 6-12 )。
const apiUrl= 'https://jsonplaceholder.typicode.com';export const notesDataApi = { createTask(task) { return fetch(`${apiUrl}/posts/`, { method: 'POST', body: JSON.stringify(task), headers: { "Content-type": "application/json; charset=UTF-8" } }); }, deleteTask(id) { return fetch(`${apiUrl}/posts/${id}`, {method: 'DELETE'}); }, getTasks(id) { return fetch(`${apiUrl}/users/${id}/posts`); }};Listing 6-12Creating notes-data-api.js
在本模块中,我们将创建创建、获取和删除任务的函数。这些函数将使用 Mozilla ( https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
)向 API 发出 HTTP 请求,并返回我们需要的数据。
现在我们已经有了应用所需的所有小部分,我们将把所有的东西放在一起。首先,我们将创建一个index.html
并调用我们正在使用的所有组件和模块,如清单 6-13 所示。
<!DOCTYPE html><html><head> <meta name="viewport" content="width=device-width, initial-scale=1"> <script async src="./simple-form-modal-component/simple-form-modal-component.js"></script> <script async type="module" src="./note-list-component/note-list-component.js"></script> <script async src="./note-list-item-component/note-list-item-component.js"></script> <link rel="stylesheet" type="text/css" href="./style.css"></head><body> <h2>Notes App</h2> <button class="fab" id="myBtn">+</button> <simple-form-modal-component></simple-form-modal-component> <note-list-component></note-list-component></body></html>Listing 6-13Creating index.html
在index.html
中,我们添加了一个按钮,当用户必须添加新的笔记时,它将显示模态,我们将为index.html
创建一个小逻辑app.js
,它将连接所有的部分(参见清单 6-14 )。
import { notesDataApi } from './utils/notes-data-api.js';const formModal = document.querySelector('simple-form-modal-component');const noteList = document.querySelector('note-list-component');notesDataApi.getTasks(1) .then((res) => res.json()) .then((items) => { const allNotes = items.map((item)=>({title: item.title, description: item.body})); noteList.notes = allNotes; });formModal.addEventListener('addEvent', function(e) { let notes = noteList.notes; notes.push({"title": e.detail.title, "description": e.detail.description}); noteList.notes = notes;});const myBtn = document.getElementById('myBtn');myBtn.addEventListener('click', function() { formModal.open = !formModal.open;});Listing 6-14Creating app.js
这里,我们为在 index.html 中添加的按钮添加登录。该按钮将向SimpleFormModalComponent
传递一个状态,以显示或隐藏模态。我们正在为定制事件addEvent
添加一个监听器,以获取新便笺的数据,并将该数据传递给NoteListComponent
。我们使用note-data-api
模块获取 API 中的所有注释,并将这些数据发送给NoteListComponent
,默认情况下填充虚拟注释。最后,我们将添加一个样式文件,以改善我们的按钮在index.html
中的外观,如清单 6-15 所示。
.fab { width: 50px; height: 50px; background-color: black; border-radius: 50%; border: 1px solid black; transition: all 0.1s ease-in-out; font-size: 30px; color: white; text-align: center; line-height: 50px; position: fixed; right: 20px; bottom: 20px;}.fab:hover { box-shadow: 0 6px 14px 0 #666; transform: scale(1.05);}Listing 6-15Creating style.css
现在我们的 NoteApp 完成了,看起来如图 6-9 所示。
图 6-9
谷歌浏览器中的 NoteApp
你可以在$git checkout chap-6
访问这本书的源代码( https://github.com/carlosrojaso/apress-book-web-components
)。
摘要
在本章中,您学习了
如何在真实的 web 应用中设计组件
如何让所有组件与属性和自定义事件一起工作
如何将我们的应用连接到 API