WebAPI-秘籍-全-
WebAPI 秘籍(全)
原文:
zh.annas-archive.org/md5/e5845603dfa85f10af04235a2964dc2d译者:飞龙
序言
自 1995 年末引入以来,JavaScript 已经走过了漫长的道路。在早期,内置于 Web 浏览器中的核心 API 有限。更先进的功能通常需要第三方 JavaScript 库,或者在某些情况下甚至需要浏览器插件。
Web API 是浏览器公开的一系列全局对象和函数。您的 JavaScript 代码可以使用这些对象与文档对象模型(DOM)交互,执行网络通信,与本机设备功能集成等等。
现代浏览器的威力
现代 Web API 对 Web 平台有两个重要优势:
不再需要插件
在过去,这些功能大部分只能供原生应用程序或笨重的浏览器插件使用。(还记得 ActiveX 和 Flash 吗?)
更少的第三方依赖
现代浏览器提供了大量以前需要第三方 JavaScript 库才能实现的功能。通常不再需要流行的库,如 jQuery、Lodash 和 Moment。
第三方库的缺点
第三方库可以帮助旧版浏览器或较新功能,但它们也有一些成本:
需要下载更多的代码
使用库会增加浏览器加载的 JavaScript 量。无论是与您的应用捆绑在一起还是从内容交付网络(CDN)单独加载,浏览器仍然需要下载它。这可能导致加载时间更长,移动设备的电池使用量更高。
增加的风险
即使是流行的开源库,也可能被放弃。当发现漏洞或安全问题时,并不能保证会有更新。通常情况下,浏览器由大公司支持(主要浏览器来自谷歌、Mozilla、苹果和微软),更可能修复这些问题。
这并不是说第三方库不好。它们也有许多好处,特别是在需要支持旧版浏览器时。就像软件开发中的其他一切一样,库的使用是一个平衡的过程。
本书适合谁
本书适合有一定 JavaScript 经验的软件开发者,希望在 Web 平台上获得最大收益。
它假设您对 JavaScript 语言本身有良好的了解:语法、语言特性和标准库函数。您还应了解用于构建交互式、基于浏览器的 JavaScript 应用程序的 DOM API 的工作原理。
本书中包含大量的配方,适合各种技能和经验水平的开发者。
本书内容概述
每章包含一系列配方——用于完成特定任务的代码示例。每个配方分为三个部分:
问题
描述配方解决的问题。
解决方案
包含实现配方解决方案的代码和解释。
讨论
关于主题的更深入讨论。本节可能包含额外的代码示例,并与其他技术进行比较。
代码示例和实时演示可在配套网站上找到,https://WebAPIs.info。
其他资源
网络的特性使其随时都在变化。在线上有许多出色的资源可帮助澄清可能出现的任何问题。
CanIUse.com
在撰写本书时,一些 API 仍在开发中或处于“实验”阶段。请注意查看使用这些 API 的食谱中的兼容性说明。对于大多数功能,您可以在 https://CanIUse.com 上查看最新的兼容性数据。您可以按功能名称搜索,并查看有关支持该 API 的浏览器版本以及特定浏览器版本的任何限制或注意事项的最新信息。
MDN Web Docs
MDN Web Docs 是所有网络相关内容的事实标准 API 文档。它详细涵盖了本书中所有的 API,以及诸如 CSS 和 HTML 等其他主题。文档中包含深入的文章、教程和 API 规范。
规范
如有疑问,特性或 API 的规范是权威资源。它们可能不是最令人兴奋的阅读材料,但在查找有关边缘情况或预期行为的详细信息时是一个很好的地方。
不同的 API 有不同的标准,但大多数可以在 Web 超文本应用技术工作组 (WHATWG) 或 万维网联盟 (W3C) 找到。
ECMAScript 的标准(指定 JavaScript 语言中的特性)由 Ecma 国际技术委员会 39 维护和开发,更为人所知的是 TC39。
本书使用的约定
本书使用以下排版约定:
Italic
指示新术语、网址、电子邮件地址、文件名和文件扩展名。
Constant width
用于程序清单,以及在段落内引用程序元素,例如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
Constant width bold
显示用户应按字面意思键入的命令或其他文本。
Constant width italic
显示应由用户提供的值或由上下文确定的值替换的文本。
Tip
此元素表示提示或建议。
Note
此元素表示一般性说明。
Warning
此元素表示警告或注意事项。
使用代码示例
补充资料(代码示例、练习等)可在 https://github.com/joeattardi/web-api-cookbook 下载。还请查看 配套网站,在那里本书中许多代码示例和食谱都扩展为完整的、实时的工作示例。
如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com。
这本书旨在帮助您完成工作。一般来说,如果书中提供了示例代码,您可以将其用于您的程序和文档。除非您复制了代码的重大部分,否则不需要联系我们。例如,编写一个使用这本书中的几个代码块的程序不需要许可。出售或分发 O’Reilly 书籍中的示例代码需要许可。回答一个问题时引用这本书并引用示例代码不需要许可。将大量示例代码从这本书中整合到您的产品文档中确实需要许可。
我们感激,但通常不需要归属。归属通常包括书名、作者、出版商和 ISBN。例如:“Web API 食谱 由 Joseph Attardi 著(O’Reilly 出版)。版权所有 2024 年 Joe Attardi, 978-1-098-15069-3。”
如果您认为您使用示例代码的情况超出了公平使用或上述许可,随时可以联系我们:permissions@oreilly.com。
O’Reilly 在线学习
注意
40 多年来,O’Reilly Media 提供技术和商业培训、知识和洞察力,以帮助公司成功。
我们的独特网络的专家和创新者通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供对实时培训课程、深入学习路径、互动编码环境和 O’Reilly 及其他 200 多个出版商的文本和视频的大量访问权限。有关更多信息,请访问 https://oreilly.com。
如何联系我们
请将有关本书的评论和问题通知给出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-889-8969(美国或加拿大)
-
707-827-7019(国际或当地)
-
707-829-0104(传真)
-
support@oreilly.com
我们有一本专门的网页,用来列出校正、示例和任何其他信息。您可以在 https://oreil.ly/web-api-cookbook 上访问此页面。
有关我们的书籍和课程的新闻和信息,请访问 https://oreilly.com。
找到我们在 LinkedIn 上:https://linkedin.com/company/oreilly-media
观看我们在 YouTube 上的节目:https://youtube.com/oreillymedia
致谢
首先,我衷心感谢我的家庭和朋友们的支持,尤其是我的妻子 Liz 和儿子 Benjamin,感谢他们忍受我的不断敲击键盘。当我处于高峰状态时,我习惯性地打字非常快速且大声。
感谢阿曼达·奎恩(Amanda Quinn),高级内容采购编辑,让我成为奥莱利(O’Reilly)的作者。多年来我读了无数本奥莱利的书,从未想过有朝一日我也会写自己的一本书。同时也要感谢路易丝·科里根(Louise Corrigan)介绍我认识阿曼达,并启动了整个过程(她几年前与我合作出版了我的第一本书!)。
特别感谢维吉尼亚·威尔逊(Virginia Wilson),高级开发编辑,她在整个书写过程中的指导至关重要,并定期会面以确保一切顺利进行。
我还要感谢本书的出色技术审阅者:马丁·道登(Martine Dowden)、沙尔克·尼斯林(Schalk Neethling)、莎拉·舒克(Sarah Shook)和亚当·斯科特(Adam Scott)。在他们宝贵的反馈下,这本书变得更加出色。
最后,我要向设计和开发这些现代 Web API 的团队们致以崇高的敬意。没有他们,这本书将无法问世!
第一章:异步 API
简介
本书涵盖的许多 API 都是异步的。当你调用其中一个函数或方法时,可能不会立即得到结果。不同的 API 有不同的机制,在准备好结果时将结果返回给你。
回调函数
最基本的异步模式是回调函数。这是一个你传递给异步 API 的函数。当工作完成时,它调用你的回调函数并传递结果。回调函数可以单独使用,也可以作为其他异步模式的一部分使用。
事件
许多浏览器 API 是基于事件的。事件是异步发生的事情。一些事件的例子包括:
-
按钮被点击了。
-
鼠标移动了。
-
网络请求完成了。
-
发生了错误。
事件有一个名称,比如 click 或 mouseover,以及一个包含有关事件发生的数据的对象。这可能包括点击了哪个元素或者 HTTP 状态码等信息。当你监听事件时,你提供一个回调函数,该函数接收事件对象作为参数。
实现事件的对象实现了 EventTarget 接口,该接口提供了 addEventListener 和 removeEventListener 方法。要监听元素或其他对象上的事件,你可以在其上调用 addEventListener,传递事件名称和处理函数。每次触发事件时都会调用回调函数,直到它被移除。可以通过调用 removeEventListener 手动移除侦听器,或者在许多情况下,当对象被销毁或从 DOM 中移除时,浏览器会自动移除侦听器。
Promises
许多较新的 API 使用 Promise。Promise 是一个从函数返回的对象,它是异步操作最终结果的占位符。与监听事件不同,你在 Promise 对象上调用 then。你将一个回调函数传递给 then,该函数最终将以结果作为其参数调用。为了处理错误,你可以将另一个回调函数传递给 Promise 的 catch 方法。
当操作成功完成时,Promise 将完成,当出现错误时,Promise 将拒绝。完成的值作为参数传递给 then 回调,拒绝的值作为参数传递给 catch 回调。
事件和 Promise 之间有一些关键区别:
-
事件处理程序会多次触发,而
then回调只会执行一次。你可以把Promise想象成一次性操作。 -
如果你在
Promise上调用then方法,你总会得到结果(如果有的话)。这与事件不同,如果事件在你添加监听器之前发生,那么该事件将会丢失。 -
Promise具有内置的错误处理机制。在事件中,通常需要监听错误事件来处理错误情况。
使用 Promises
问题
你想调用一个使用 Promise 的 API,并获取结果。
解决方案
调用Promise对象的then方法来处理回调函数中的结果。为了处理可能的错误,添加一个调用catch。
假设您有一个函数getUsers,它会发出网络请求来加载用户列表。此函数返回一个Promise,最终解析为用户列表(参见示例 1-1)。
示例 1-1. 使用基于Promise的 API
getUsers()
.then(
// This function is called when the user list has been loaded.
userList => {
console.log('User List:');
userList.forEach(user => {
console.log(user.name);
});
}
).catch(error => {
console.error('Failed to load the user list:', error);
});
讨论
从getUsers返回的Promise是一个带有then方法的对象。当用户列表加载完成时,执行传递给then的回调函数,并将用户列表作为其参数。
此Promise还具有用于处理错误的catch方法。如果在加载用户列表时出现错误,则调用传递给catch的回调函数以处理错误对象。根据结果只调用其中一个回调函数。
使用备用加载图片
问题
您希望加载一张图片以在页面上显示。如果加载图片时出现错误,您希望使用已知的良好图片 URL 作为备用。
解决方案
使用编程方式创建Image元素,并监听其load和error事件。如果触发了error事件,则用备用图片替换它。一旦请求的图片或占位图片加载完成,根据需要将其添加到 DOM 中。
为了更清晰的 API,您可以将其封装在一个Promise中。该Promise要么解析为要添加的Image,要么由于无法加载图片或备用图片而拒绝(参见示例 1-2)。
示例 1-2. 使用备用加载图片
/**
* Loads an image. If there's an error loading the image, uses a fallback
* image URL instead.
*
* @param url The image URL to load
* @param fallbackUrl The fallback image to load if there's an error
* @returns a Promise that resolves to an Image element to insert into the DOM
*/
function loadImage(url, fallbackUrl) {
return new Promise((resolve, reject) => {
const image = new Image();
// Attempt to load the image from the given URL
image.src = url;
// The image triggers the 'load' event when it is successfully loaded.
image.addEventListener('load', () => {
// The now-loaded image is used to resolve the Promise
resolve(image);
});
// If an image failed to load, it triggers the 'error' event.
image.addEventListener('error', error => {
// Reject the Promise in one of two scenarios:
// (1) There is no fallback URL.
// (2) The fallback URL is the one that failed.
if (!fallbackUrl || image.src === fallbackUrl) {
reject(error);
} else {
// If this is executed, it means the original image failed to load.
// Try to load the fallback.
image.src = fallbackUrl;
}
});
});
}
讨论
loadImage函数接受一个 URL 和一个备用 URL,并返回一个Promise。然后它创建一个新的Image,并将其src属性设置为给定的 URL。浏览器尝试加载图片。
有三种可能的结果:
成功情况
如果图片成功加载,则触发load事件。事件处理程序使用该图片解析Promise,然后可以将其插入到 DOM 中。
备用情况
如果图片加载失败,则触发error事件。错误处理程序将src属性设置为备用 URL,并尝试加载备用图片。如果成功,则load事件触发并使用备用Image解析Promise。
失败情况
如果无法加载图片或备用图片,则错误处理程序拒绝带有error事件的Promise。
每次加载错误时都会触发error事件。处理程序首先检查是否是备用 URL 加载失败。如果是,则表示原始 URL 和备用 URL 都无法加载。这是失败的情况,因此拒绝Promise。
如果不是备用 URL,则表示请求的 URL 加载失败。现在设置备用 URL 并尝试加载它。
这里检查的顺序很重要。如果缺少第一次检查,如果后备加载失败,错误处理程序将触发设置(无效)后备 URL、请求它并再次触发error事件的无限循环。
示例 1-3 展示了如何使用loadImage函数。
示例 1-3. 使用loadImage函数
loadImage('https://example.com/profile.jpg', 'https://example.com/fallback.jpg')
.then(image => {
// container is an element in the DOM where the image will go
container.appendChild(image);
}).catch(error => {
console.error('Image load failed');
});
链接Promise
问题
您希望按顺序调用多个基于Promise的 API。每个操作都依赖于前一个操作的结果。
解决方案
使用Promise链来顺序执行异步任务。想象一个博客应用程序,其中有两个 API,都返回Promise:
getUser(id)
根据给定的用户 ID 加载用户
getPosts(user)
加载给定用户的所有博客帖子
如果要加载用户的帖子,首先需要加载user对象——在加载用户详细信息之前无法调用getPosts。您可以通过将这两个Promise链在一起来实现,如示例 1-4 所示。
示例 1-4. 使用Promise链
/**
* Loads the post titles for a given user ID.
* @param userId is the ID of the user whose posts you want to load
* @returns a Promise that resolves to an array of post titles
*/
function getPostTitles(userId) {
return getUser(userId)
// Callback is called with the loaded user object
.then(user => {
console.log(`Getting posts for ${user.name}`);
// This Promise is also returned from .then
return getPosts(user);
})
// Calling then on the getPosts' Promise
.then(posts => {
// Returns another Promise that will resolve to an array of post titles
return posts.map(post => post.title);
})
// Called if either getUser or getPosts are rejected
.catch(error => {
console.error('Error loading data:', error);
});
}
讨论
Promise的then处理程序返回的值被包装在一个新的Promise中。这个Promise从then方法本身返回。这意味着then的返回值也是一个Promise,因此您可以链式调用另一个then。这就是如何创建Promise链的方式。
getUser返回一个解析为user对象的Promise。then处理程序调用getPosts并返回结果的Promise,然后再次从then返回,因此您可以再次调用then来获取最终结果,即帖子数组。
链的末尾调用catch来处理任何错误。这类似于try/catch块。如果链中的任何地方发生错误,catch处理程序将被调用,并且不会执行链的其余部分。
使用async和await关键字
问题
您正在使用返回Promise的 API,但希望代码以更线性或同步的方式阅读。
解决方案
使用await关键字与Promise一起,而不是在其上调用then(参见示例 1-5)。再次考虑来自“使用 Promise”的getUsers函数。此函数返回一个解析为用户列表的Promise。
示例 1-5. 使用await关键字
// A function must be declared with the async keyword
// in order to use await in its body.
async function listUsers() {
try {
// Equivalent to getUsers().then(...)
const userList = await getUsers();
console.log('User List:');
userList.forEach(user => {
console.log(user.name);
});
} catch (error) { // Equivalent to .catch(...)
console.error('Failed to load the user list:', error);
}
}
讨论
await是使用Promise的另一种语法。与使用接受结果作为其参数的回调函数调用then不同,表达式实际上“暂停”了函数余下的执行,并在Promise被履行时返回结果。
如果Promise被拒绝,await表达式会抛出被拒绝的值。这可以通过标准的try/catch块来处理。
并行使用Promise
问题
您希望使用Promise在并行执行一系列异步任务。
解决方案
收集所有Promise,并将它们传递给Promise.all。这个函数接受一个Promise数组,并等待它们全部完成。它返回一个新的Promise,一旦所有给定的Promise都完成,就会被实现;如果任何给定的Promise被拒绝,它就会被拒绝(参见示例 1-6)。
示例 1-6. 使用Promise.all加载多个用户
// Loading three users at once
Promise.all([
getUser(1),
getUser(2),
getUser(3)
]).then(users => {
// users is an array of user objects—the values returned from
// the parallel getUser calls
}).catch(error => {
// If any of the above Promises are rejected
console.error('One of the users failed to load:', error);
});
讨论
如果您有多个不依赖于彼此的任务,Promise.all是一个很好的选择。示例 1-6 调用了三次getUser,每次传递不同的用户 ID。它将这些Promise收集到一个数组中,并将其传递给Promise.all。所有三个请求并行运行。
Promise.all返回另一个Promise。一旦所有三个用户成功加载,这个新的Promise将会被实现,其包含一个包含已加载用户的数组。每个结果的索引对应于输入数组中Promise的索引。在这种情况下,它按顺序返回用户1、2和3的数组。
如果其中一个用户未能加载怎么办?也许其中一个用户 ID 不存在或者存在临时网络错误。如果Promise.all传递的任何Promise被拒绝,那么新的Promise将立即被拒绝。拒绝值与被拒绝的Promise的拒绝值相同。
如果其中一个用户加载失败,由Promise.all返回的Promise将被拒绝,并且会带有出错的错误。其他Promise的结果将会丢失。
如果您仍然希望获取任何已解决Promise的结果(或来自其他被拒绝Promise的错误),可以改用Promise.allSettled。使用Promise.allSettled,会返回一个新的Promise,与Promise.all类似。但是,这个Promise始终会在所有Promise都已处理完毕(无论是已解决还是已拒绝)后被实现。
如示例 1-7 所示,解决的值是一个数组,其中每个元素都有一个status属性。这个属性可以是fulfilled或rejected,具体取决于该Promise的结果。如果状态是fulfilled,则对象还具有一个value属性,即解决的值。另一方面,如果状态是rejected,则对象有一个reason属性,即被拒绝的值。
示例 1-7. 使用Promise.allSettled
Promise.allSettled([
getUser(1),
getUser(2),
getUser(3)
]).then(results => {
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('- User:', result.value.name);
} else {
console.log('- Error:', result.reason);
}
});
});
// No catch necessary here because allSettled is always fulfilled.
使用requestAnimationFrame来为元素添加动画效果
问题
您想要使用 JavaScript 以高效的方式对元素进行动画处理。
解决方案
使用requestAnimationFrame函数来安排动画更新以在规律的间隔运行。
假设您有一个要使用淡出动画隐藏的div元素。这通过调整不透明度,在requestAnimationFrame中使用的回调函数来完成(参见示例 1-8)。每个间隔的持续时间取决于动画的期望每秒帧数(FPS)。
示例 1-8. 使用requestAnimationFrame进行淡出动画
const animationSeconds = 2; // Animate over 2 seconds
const fps = 60; // A nice, smooth animation
// The time interval between each frame
const frameInterval = 1000 / fps;
// The total number of frames for the animation
const frameCount = animationSeconds * fps;
// The amount to adjust the opacity by in each frame
const opacityIncrement = 1 / frameCount;
// The timestamp of the last frame
let lastTimestamp;
// The starting opacity value
let opacity = 1;
function fade(timestamp) {
// Set the last timestamp to now if there isn't an existing one.
if (!lastTimestamp) {
lastTimestamp = timestamp;
}
// Calculate how much time has elapsed since the last frame.
// If not enough time has passed yet, schedule another call of this
// function and return.
const elapsed = timestamp - lastTimestamp;
if (elapsed < frameInterval) {
requestAnimationFrame(animate);
return;
}
// Time for a new animation frame. Remember this timestamp.
lastTimestamp = timestamp;
// Adjust the opacity value and make sure it doesn't go below 0.
opacity = Math.max(0, opacity - opacityIncrement)
box.style.opacity = opacity;
// If the opacity hasn't reached the target value of 0, schedule another
// call to this function.
if (opacity > 0) {
requestAnimationFrame(animate);
}
}
// Schedule the first call to the animation function.
requestAnimationFrame(fade);
讨论
这是使用 JavaScript 进行元素动画的一种良好且高效的方法,具有良好的浏览器支持。因为它是异步完成的,这种动画不会阻塞浏览器的主线程。如果用户切换到另一个标签页,动画会暂停,并且不会不必要地调用requestAnimationFrame。
当您使用requestAnimationFrame调度函数运行时,该函数在下一次重绘操作之前被调用。这种情况发生的频率取决于浏览器和屏幕刷新率。
在动画之前,示例 1-8 根据给定的动画持续时间(2 秒)和帧率(每秒 60 帧)进行一些计算。它计算出总帧数,并使用持续时间计算每帧的运行时间。如果您想要与系统刷新率不匹配的不同帧率,这将跟踪上次动画更新执行的时间,以维持目标帧率。
然后,根据帧数,计算每一帧中的透明度调整。
通过将其传递给requestAnimationFrame调用,调度fade函数。每次浏览器调用此函数时,都会传递一个时间戳。fade函数计算自上一帧以来经过的时间。如果尚未经过足够的时间,则不执行任何操作,并要求浏览器在下次再次调用时执行。
一旦经过足够的时间,它执行动画步骤。它获取计算的不透明度调整,并将其应用于元素的样式。根据确切的时机,这可能导致不透明度小于 0,这是无效的。使用Math.max修复此问题,以设置最小值为 0。
如果不透明度尚未达到 0,则需要执行更多的动画帧。它再次调用requestAnimationFrame来调度下一次执行。
作为此方法的替代方案,较新的浏览器支持 Web 动画 API,您将在第八章中了解它。该 API 允许您指定带有 CSS 属性的关键帧,并且浏览器会处理为您更新中间值。
将事件 API 封装在 Promise 中
问题
您想要封装一个基于事件的 API 以返回Promise。
解决方案
创建一个新的Promise对象,并在其构造函数中注册事件侦听器。当接收到等待的事件时,使用值解析Promise。类似地,如果发生错误事件,则拒绝Promise。
有时这被称为“将函数转换为 Promise”。示例 1-9 演示了如何将XMLHttpRequestAPI 转换为 Promise。
示例 1-9. 将XMLHttpRequestAPI 转换为 Promise
/**
* Sends a GET request to the specified URL. Returns a Promise that will resolve to
* the JSON body parsed as an object, or will reject if there is an error or the
* response is not valid JSON.
*
* @param url The URL to request
* @returns a Promise that resolves to the response body
*/
function loadJSON(url) {
// Create a new Promise object, performing the async work inside the
// constructor function.
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
// If the request is successful, parse the JSON response and
// resolve the Promise with the resulting object.
request.addEventListener('load', event => {
// Wrap the JSON.parse call in a try/catch block just in case
// the response body is not valid JSON.
try {
resolve(JSON.parse(event.target.responseText));
} catch (error) {
// There was an error parsing the response body.
// Reject the Promise with this error.
reject(error);
}
});
// If the request fails, reject the Promise with the
// error that was emitted.
request.addEventListener('error', error => {
reject(error);
});
// Set the target URL and send the request.
request.open('GET', url);
request.send();
});
}
示例 1-10 展示了如何使用转换为 Promise 的loadJSON函数。
Example 1-10. 使用loadJSON辅助函数
// Using .then
loadJSON('/api/users/1').then(user => {
console.log('Got user:', user);
})
// Using await
const user = await loadJSON('/api/users/1');
console.log('Got user:', user);
讨论
通过使用new运算符调用Promise 构造函数 来创建一个Promise。此函数接收两个参数,一个resolve函数和一个reject函数。
resolve 和 reject 函数是由 JavaScript 引擎提供的。在Promise构造函数内部,你可以执行异步工作并监听事件。当调用resolve函数时,Promise立即以该值解析。调用reject也同样工作——它会用错误拒绝Promise。
创建自己的Promise可以帮助处理这类情况,但通常情况下,你不需要像这样手动创建它们。如果一个 API 已经返回了Promise,你不需要再将其包装在自己的Promise中,直接使用即可。
第二章:使用 Web Storage API 进行简单持久化
介绍
Web Storage API 在用户的浏览器中本地持久化简单数据。即使在关闭并重新打开浏览器后,你仍然可以检索这些数据。
此 API 具有提供数据访问和持久性的Storage接口。你不直接创建 Storage 实例;有两个全局实例:window.localStorage 和 window.sessionStorage。它们唯一的区别在于数据保留的时长。
sessionStorage 数据与特定的浏览器会话相关联。如果页面重新加载,它会保留数据,但完全关闭浏览器会导致数据丢失。相同起源的不同标签页不共享相同的持久化数据。
另一方面,localStorage 在同一起源的所有标签页和会话中共享相同的存储空间。即使关闭浏览器后,浏览器仍保留这些数据。总体而言,如果你想存储一些暂时的或敏感的内容,并希望在关闭浏览器后销毁它们,会话存储是一个不错的选择。
在两种情况下,存储空间都是特定于给定起源的。
获取和设置项
Web Storage 只能存储字符串值。每个值都有一个键,可以用来查找它。API 很简单:
getItem(key)
返回与键绑定的字符串,如果键不存在则返回null。
setItem(key, value)
在给定键下存储一个字符串值。如果键已存在,你将覆盖它。
clear()
删除当前起源的所有存储数据。
缺点
Web Storage 非常有用,但也有一些缺点:
数据存储限制
Web Storage 只能存储字符串数据。你可以存储简单对象,但不能直接存储 —— 需要先将其转换为 JavaScript 对象表示法(JSON)字符串。
大小限制
每个起源对于存储都有限定的空间。在大多数浏览器中,这是 5 兆字节。如果一个起源的存储空间已满,如果尝试添加更多数据,浏览器将抛出异常。
安全问题
即使浏览器分别存储每个起源的数据,它仍然容易受到跨站点脚本(XSS)攻击的影响。攻击者可以通过 XSS 攻击注入代码,窃取本地持久化数据。注意存储敏感数据时的风险。
注意
本章中的所有示例都使用本地存储,但同样适用于会话存储,因为这两种对象都实现了相同的Storage接口。
检查 Web Storage 支持
问题
你想在使用本地存储之前检查其是否可用,以避免应用程序崩溃。你还希望处理本地存储可用但被用户设置阻止的情况。
解决方案
检查全局 window 对象的 localStorage 属性,以验证浏览器是否支持本地存储。如果检查通过,则本地存储可用(见 示例 2-1)。
示例 2-1。检查本地存储是否可用
/**
* Determines if local storage is available.
* @returns true if the browser can use local storage, false if not
*/
function isLocalStorageAvailable() {
try {
// Local storage is available if the property exists.
return typeof window.localStorage !== 'undefined';
} catch (error) {
// If window.localStorage exists but the user is blocking local
// storage, the attempt to read the property throws an exception.
// If this happens, consider local storage not available.
return false;
}
}
讨论
示例 2-1 中的函数处理了两种情况:如果本地存储根本不支持,以及如果它存在且没有被用户设置阻止。
它检查window.localStorage属性是否不是undefined。如果这个检查通过,这意味着浏览器支持本地存储。如果用户阻止了本地存储,仅仅引用window.localStorage属性就会抛出一个异常,错误信息显示为访问被拒绝。
通过用try/catch块包围属性检查,你还可以处理这种情况。当捕获到异常时,会考虑到本地存储不可用并返回false。
持久化字符串数据
问题
你想要将一个字符串值持久化到本地存储中,并在稍后读取它。
解决方案
使用localStorage.getItem和localStorage.setItem来读取和写入数据。示例 2-2 展示了如何使用本地存储来记住颜色选择器的值。
示例 2-2. 将数据持久化到本地存储
// A reference to the color picker input element
const colorPicker = document.querySelector('#colorPicker');
// Load the saved color, if any, and set it on the color picker.
const storedValue = localStorage.getItem('savedColor');
if (storedValue) {
console.log('Found saved color:', storedValue);
colorPicker.value = storedValue;
}
// Update the saved color whenever the value changes.
colorPicker.addEventListener('change', event => {
localStorage.setItem('savedColor', event.target.value);
console.log('Saving new color:', colorPicker.value);
});
讨论
当页面首次加载时,会检查本地存储是否已经保存了以前的颜色。如果使用一个不存在的键调用getItem,它会返回null。只有当返回值不为 null 或空时,才会在颜色选择器中设置该值。
当颜色选择器的值发生变化时,事件处理程序将新值保存到本地存储中。如果已经有一个保存的颜色,它会被覆盖。
持久化简单对象
问题
你有一个 JavaScript 对象,比如用户配置文件,你想要将其持久化到本地存储中。由于本地存储仅支持字符串值,因此你不能直接这样做。
解决方案
使用JSON.stringify将对象转换为 JSON 字符串后再保存它。在稍后加载该值时,使用JSON.parse将其转回对象,如示例 2-3 所示。
示例 2-3. 使用JSON.parse和JSON.stringify
/**
* Given a user profile object, serialize it to JSON and store it in local storage.
* @param userProfile the profile object to save
*/
function saveProfile(userProfile) {
localStorage.setItem('userProfile', JSON.stringify(userProfile));
}
/**
* Loads the user profile from local storage and deserializes the JSON back to
* an object. If there is no stored profile, an empty object is returned.
* @returns the stored user profile or an empty object.
*/
function loadProfile() {
// If there is no stored userProfile value, this will return null. In this case,
// use the default value of an empty object.
return JSON.parse(localStorage.getItem('userProfile')) || {};
}
讨论
直接将配置文件对象传递给localStorage.setItem不会产生预期的效果,如示例 2-4 所示。
示例 2-4. 尝试持久化一个数组
const userProfile = {
firstName: 'Ava',
lastName: 'Johnson'
};
localStorage.setItem('userProfile', userProfile);
// Prints [object Object]
console.log(localStorage.getItem('userProfile'));
保存的值是[object Object]。这是对配置文件对象调用toString的结果。
JSON.stringify接受一个对象,并返回表示该对象的 JSON 字符串。将用户配置文件对象传递给JSON.stringify将得到这个 JSON 字符串(为了可读性添加了空格):
{
"firstName": "Ava",
"lastName": "Johnson"
}
这种方法适用于像用户配置文件这样的对象,但是JSON 规范限制了可以序列化为字符串的内容。一般来说,这些是对象、数组、字符串、数字、布尔值和null。其他值,比如类实例或函数,不能以这种方式序列化。
持久化复杂对象
问题
你想要将一个无法直接序列化为 JSON 字符串的对象,例如用户配置文件中可能包含一个指定最后更新时间的Date对象,持久化到本地存储中。
解决方案
使用 JSON.stringify 和 JSON.parse 的 replacer 和 reviver 函数,为复杂数据提供自定义序列化。
考虑以下配置文件对象:
const userProfile = {
firstName: 'Ava',
lastName: 'Johnson',
// This date represents June 2, 2025.
// Months start with zero but days start with 1.
lastUpdated: new Date(2025, 5, 2);
}
如果你使用 JSON.stringify 序列化这个对象,生成的字符串将以 ISO 日期字符串形式包含 lastUpdated 日期(参见 示例 2-5)。
示例 2-5. 尝试序列化带有 Date 对象的对象
const json = JSON.stringify(userProfile);
生成的 JSON 字符串如下所示:
{
"firstName": "Ava",
"lastName": "Johnson",
"lastUpdated": '2025-06-02T04:00:00.000Z'
}
现在你有一个可以保存到本地存储的 JSON 字符串。然而,如果你使用这个 JSON 字符串调用 JSON.parse,生成的对象与原始对象略有不同。lastUpdated 属性仍然是一个字符串,而不是一个 Date 对象,因为 JSON.parse 不知道这应该是一个 Date 对象。
为了处理这些情况,JSON.stringify 和 JSON.parse 接受名为 replacer 和 reviver 的特殊函数。这些函数提供自定义逻辑,用于将非基本类型值转换为 JSON 以及从 JSON 转换回来。
使用 replacer 函数进行序列化
JSON.stringify 的 replacer 参数可以以几种不同的方式工作。MDN 提供了关于 replacer 函数的详细 文档。
replacer 函数接受两个参数:key 和 value(参见 示例 2-6)。JSON.stringify 首先以空字符串作为键,被字符串化的对象作为值调用此函数。你可以在这里通过调用 getTime() 将 lastUpdated 字段转换为 Date 对象的可序列化表示,getTime() 返回自纪元时(1970 年 1 月 1 日 UTC 午夜)以来的毫秒数。
示例 2-6. replacer 函数
function replacer(key, value) {
if (key === '') {
// First replacer call, "value" is the object itself.
// Return all properties of the object, but transform lastUpdated.
// This uses object spread syntax to make a copy of "value" before
// adding the lastUpdated property.
return {
...value,
lastUpdated: value.lastUpdated.getTime()
};
}
// After the initial transformation, the replacer is called once
// for each key/value pair.
// No more replacements are necessary, so return these as is.
return value;
}
你可以将这个 replacer 函数传递给 JSON.stringify,将对象序列化为 JSON,如 示例 2-7 所示。
示例 2-7. 使用 replacer 进行字符串化
const json = JSON.stringify(userProfile, replacer);
这将生成以下 JSON 字符串:
{
"firstName": "Ava",
"lastName": "Johnson",
"lastUpdated": 1748836800000
}
lastUpdated 属性中的数字是 2025 年 6 月 2 日的时间戳。
使用 reviver 函数进行反序列化
当你将这个 JSON 字符串传递给 JSON.parse 时,lastUpdated 属性仍然保持为一个数字(时间戳)。你可以使用 reviver 函数将这个序列化的数字值转换回一个 Date 对象。
JSON.parse 为 JSON 字符串中的每个属性调用 reviver 函数。对于每个键,函数返回的值是设置在最终对象中的值(参见 示例 2-8)。
示例 2-8. reviver 函数
function reviver(key, value) {
// JSON.parse calls the reviver once for each key/value pair.
// Watch for the lastUpdated key.
// Only proceed if there's actually a value for lastUpdated.
if (key === 'lastUpdated' && value) {
// Here, the value is the timestamp. You can pass this to the Date constructor
// to create a Date object referring to the proper time.
return new Date(value);
}
// Restore all other values as is.
return value;
}
要使用 reviver,将其作为第二个参数传递给 JSON.parse,如 示例 2-9 所示。
示例 2-9. 使用 reviver 进行解析
const object = JSON.parse(userProfile, reviver);
这将返回一个与我们最初的用户配置文件对象相等的对象:
{
firstName: 'Ava',
lastName: 'Johnson',
lastUpdated: [Date object representing June 2, 2025]
}
讨论
通过这种可靠的方法将对象转换为 JSON,并保持 Date 属性完整,你可以将这些值持久化到本地存储中。
此处展示的方法只是使用 replacer 函数处理的一种方式。除了 replacer 函数外,还可以在要序列化为字符串的对象上定义一个 toJSON 函数。结合工厂函数,就不需要 replacer 函数。
示例 2-10. 使用添加了 toJSON 函数的工厂
/**
* A factory function to create a user profile object,
* with the lastUpdated property set to today and a toJSON method
*
* @param firstName The user's first name
* @param lastName The user's last name
*/
function createUser(firstName, lastName) {
return {
firstName,
lastName,
lastUpdated: new Date(),
toJSON() {
return {
firstName: this.firstName,
lastName: this.lastName,
lastUpdated: this.lastUpdated.getTime();
}
}
}
}
const userProfile = createUser('Ava', 'Johnson');
使用 示例 2-10 中的对象调用 JSON.stringify 返回与之前相同的 JSON 字符串,lastUpdated 已适当转换为时间戳。
注意
对于使用 JSON.parse 解析字符串为对象的操作,没有类似 toJSON 的机制。如果使用此处展示的 toJSON 方法,仍然需要编写一个 reviver 函数,以正确反序列化用户配置文件字符串。
由于函数无法序列化,因此生成的 JSON 字符串不会有 toJSON 属性。无论你选择哪种方法,生成的 JSON 都是相同的。
监听存储更改
问题
当同源的另一个标签页更改本地存储时,你希望收到通知。
解决方案
在 window 对象上监听 storage 事件。当同一来源的其他标签页或会话对本地存储中的任何数据进行更改时,此事件会触发(参见 示例 2-11)。
示例 2-11. 监听来自另一个标签页的存储更改
// Listen for the 'storage' event. If another tab changes the
// 'savedColor' item, update this page's color picker with the new value.
window.addEventListener('storage', event => {
if (event.key === 'savedColor') {
console.log('New color was chosen in another tab:', event.newValue);
colorPicker.value = event.newValue;
}
});
考虑来自“持久化字符串数据”的持久性颜色选择器。如果用户同时打开了多个标签页,并在另一个标签页中更改颜色,您可以收到通知并更新本地内存中的数据副本,以保持所有内容同步。
注意
storage 事件不会在进行存储更改的标签页或页面上触发。它用于监听其他页面对本地存储所做的更改。
storage 事件指定了哪个键发生了更改以及新值是什么。它还包括旧值,以便进行比较。
讨论
storage 事件的主要用例是实时保持多个会话之间的同步。
注意
storage 事件仅在同一设备上的同一浏览器中的其他标签页和会话中触发。
即使你不监听 storage 事件,同一来源的所有会话仍然共享同一本地存储数据。如果在任何时候调用 localStorage.getItem,你仍将获得最新的值。storage 事件只是在此类更改发生时提供实时通知,以便应用程序可以更新本地数据。
查找所有已知键
问题
你想知道当前原点的本地存储中目前存在的所有键。
解决方案
使用 length 属性与 key 函数构建所有已知键的列表。Storage 对象没有直接返回键列表的函数,但可以通过以下方式构建这样的列表:
-
length属性返回键的数量。 -
给定索引,
key函数返回该索引处的键。
您可以结合 for 循环使用 示例 2-12 中显示的方法,构建所有键的数组。
示例 2-12. 构建键列表
/**
* Generates an array of all keys found in the local storage area
* @returns an array of keys
*/
function getAllKeys() {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
keys.push(localStorage.key(i));
}
return keys;
}
讨论
您还可以结合 length 属性和 key 函数执行其他类型的查询。例如,这可能是一个函数,它接受一个键的数组并返回一个包含这些键/值对的对象(参见 示例 2-13)。
示例 2-13. 查询一组键/值对的子集
function getAll(keys) {
const results = {};
// Check each key in local storage.
for (let i = 0; i < localStorage.length; i++) {
// Get the ith key. If the keys array includes this key, add it and its value
// to the results object.
const key = localStorage.key(i);
if (keys.includes(key)) {
results[key] = localStorage.getItem(key);
}
}
// results now has all key/value pairs that exist in local storage.
return results;
}
注意
使用 key 函数引用的键的排序在不同浏览器中可能不同。
移除数据
问题
您想从本地存储中删除一些或全部数据。
解决方案
根据需要使用 removeItem 和 clear 方法。
要从本地存储中删除特定键值对,请调用 localStorage.removeItem 并传入键(参见 示例 2-14)。
示例 2-14. 从本地存储中移除一个项目
// This is a safe operation. If the key doesn't exist,
// no exception is thrown.
localStorage.removeItem('my-key');
调用 localStorage.clear 来移除当前来源(origin)的本地存储中的 所有 数据,如 示例 2-15 中所示。
示例 2-15. 从本地存储中移除所有项目
localStorage.clear();
讨论
浏览器限制可以存储在 Web 存储中的数据量。通常限制约为 5 MB。为了避免空间不足并引发错误,应在不再需要时删除项目。根据您使用 Web 存储的情况,您还可以提供一种方法让用户清除存储的数据。考虑一个表情选择器,它在本地存储中存储最近选择的表情。您可以添加一个“清除最近使用”按钮,以删除这些项目。
第三章:URL 和路由
简介
大多数网页和应用程序都以某种方式处理 URL。这可能是像使用特定查询参数创建链接,或在单页应用程序(SPA)中基于 URL 进行路由等操作。
URL 只是遵循RFC 3986, “统一资源标识符(URI):通用语法”中定义的一些语法规则的字符串。URL 有几个组成部分,您可能需要解析或操作这些部分。使用正则表达式或字符串连接等技术并不总是可靠的。
今天,浏览器支持 URL API。此 API 提供了一个URL构造函数,可以创建、派生和操作 URL。最初此 API 功能有限,但后来的更新添加了类似URLSearchParams接口的实用工具,简化了构建和读取查询字符串的过程。
URL 的各个组成部分
当您使用表示有效 URL 的字符串调用URL构造函数时,生成的对象包含表示 URL 不同组成部分的属性。 图 3-1 显示了其中最常用的部分:
protocol(1)
对于 Web URL,通常为http:或https:(请注意包括冒号,但不包括斜杠)。其他协议也可能存在,如file:(用于本地文件,而不是服务器上托管的文件)或ftp:(FTP 服务器上的资源)。
hostname(2)
域名或主机名(example.com)。
pathname(3)
相对于根目录的资源路径,以斜杠开头(/admin/login)。
search(4)
任何查询参数。包括?字符(?username=sysadmin)。

图 3-1. 带有其组成部分突出显示的示例 URL
URL的其他一些部分包括:
hash
如果 URL 包含散列标识符,则返回散列部分(包括散列符号#)。这有时用于较旧的 SPA 的内部导航。对于 URL https://example.com/app#profile,hash的值将是#profile。
host
类似于hostname,但也包括端口号(如果指定),例如localhost:8443。
origin
URL 的来源。这通常包括协议、主机名和端口(如果指定)。
您可以通过调用其toString方法或访问其href属性来获取整个 URL 字符串。
如果将无效的 URL 字符串传递给URL构造函数,则会抛出异常。
解析相对 URL
问题
如果您有一个部分或相对 URL,例如/api/users,您想要将其解析为完整的绝对 URL,如https://example.com/api/users。
解决方案
创建URL对象,传递相对 URL 和所需的基本 URL,如示例 3-1 所示。
示例 3-1. 创建相对 URL
/**
* Given a relative path and a base URL, resolves a full absolute URL.
* @param relativePath The relative path for the URL
* @param baseUrl A valid URL to use as the base
*/
function resolveUrl(relativePath, baseUrl) {
return new URL(relativePath, baseUrl).href;
}
// https://example.com/api/users
console.log(resolveUrl('/api/users', 'https://example.com'));
没有第二个参数,URL构造函数会因为/api/users不是有效的 URL 而抛出错误。第二个参数是构造新 URL 的基础。它通过假定给定的路径相对于基本 URL 来构造 URL。
讨论
第二个参数必须是有效的 URL。根据第一个参数,应用有效相对 URL 的典型规则来构造最终 URL。
如果第一个参数以斜杠开头,则忽略基本 URL 的路径名,并且新 URL 相对于基本 URL 的根目录:
// https://example.com/api/v1/users
console.log(resolveUrl('/api/v1/users', 'https://example.com'));
// https://example.com/api/v1/users
// Note that /api/v2 is discarded due to the leading slash in /api/v1/users
console.log(resolveUrl('/api/v1/users', 'https://example.com/api/v2'));
否则,计算相对于基本 URL 的 URL:
// https://example.com/api/v1/users
console.log(resolveUrl('../v1/users/', 'https://example.com/api/v2'));
// https://example.com/api/v1/users
console.log(resolveUrl('users', 'https://example.com/api/v1/groups'));
如果第一个参数本身就是有效的 URL,则忽略基本 URL。
如果构造函数的第二个参数不是字符串,则在其上调用toString,并使用生成的字符串。这意味着你可以传递其他类似于URL的URL对象,甚至其他类似的对象。你甚至可以传递window.location(一个Location对象,其属性类似于URL)以生成当前来源(参见示例 3-2)的新 URL。
示例 3-2. 在相同来源上创建相对 URL
const usersApiUrl = new URL('/api/users', window.location);
从 URL 中删除查询参数
问题
您希望从 URL 中删除所有查询参数。
解决方案
创建一个URL对象并将其search属性设置为空字符串,如示例 3-3 中所示。
示例 3-3. 删除 URL 的查询参数
/**
* Removes all parameters from an input URL.
*
* @param inputUrl a URL string containing query parameters
* @returns a new URL string with all query parameters removed
*/
function removeAllQueryParameters(inputUrl) {
const url = new URL(inputUrl);
url.search = '';
return url.toString();
}
// Results in 'https://example.com/api/users'
removeAllQueryParams('https://example.com/api/users?user=sysadmin&q=user');
讨论
URL 中的查询参数以两种方式表示:使用search属性和searchParams属性。
search属性是一个单一字符串,包含所有查询参数以及前导的?字符。如果要删除整个查询字符串,可以将其设置为空字符串。
注意search属性被设置为空字符串。如果设置为null,则在查询字符串中得到字面字符串null(参见示例 3-4)。
示例 3-4. 错误地尝试删除所有查询参数
const url = new URL('https://example.com/api/users?user=sysadmin&q=user');
url.search = null;
console.log(url.toString()); // https://example.com/api/users?null
searchParams属性是一个URLSearchParams对象。它具有查看、添加和删除查询参数的方法。在添加查询参数时,它会自动处理编码字符。如果您只想删除一个查询参数,可以在该对象上调用delete,如示例 3-5 所示。
示例 3-5. 删除单个查询参数
/**
* Removes a single parameter from an input URL
*
* @param inputUrl a URL string containing query parameters
* @param paramName the name of the parameter to remove
* @returns a new URL string with the given query parameter removed
*/
function removeQueryParameter(inputUrl, paramName) {
const url = new URL(inputUrl);
url.searchParams.delete(paramName);
return url.toString();
}
console.log(
removeQueryParameter(
'https://example.com/api/users?user=sysadmin&q=user',
'q'
)
); // https://example.com/api/users?user=sysadmin
向 URL 添加查询参数
问题
您有一个现有的 URL,它可能已经包含一些查询参数,并且您想要添加额外的查询参数。
解决方案
使用URLSearchParams对象,可通过searchParams属性访问,以添加额外的参数(参见示例 3-6)。
示例 3-6. 添加额外的查询参数
const url = new URL('https://example.com/api/search?objectType=user');
url.searchParams.append('userRole', 'admin');
url.searchParams.append('userRole', 'user');
url.searchParams.append('name', 'luke');
// Prints
"https://example.com/api/search?objectType=user&userRole=admin&userRole=user
&name=luke"
console.log(url.toString());
讨论
此 URL 已经有一个查询参数(objectType=user)。代码使用解析后的 URL 的searchParams属性来追加更多查询参数。添加了两个userRole参数。使用append时,会添加新值并保留现有值。要替换所有同名参数的值为新值,可以使用set。
带有新参数的完整 URL 现在是:
https://example.com/api/search?objectType=user&userRole=admin&userRole=user
&name=luke
如果调用append时参数名存在但没有值,则会引发异常,如示例 3-7 所示。
示例 3-7. 尝试调用append而没有提供值
const url = new URL('https://example.com/api/search?objectType=user');
// TypeError: Failed to execute 'append' on 'URLSearchParams':
// 2 arguments required, but only 1 present.
url.searchParams.append('name');
该方法优雅地处理其他参数类型。如果未收到字符串值,则将该值转换为字符串(参见示例 3-8)。
示例 3-8. 添加非字符串参数
const url = new URL('https://example.com/api/search?objectType=user');
// The resulting URL has the query string:
// ?objectType=user&name=null&role=undefined
url.searchParams.append('name', null);
url.searchParams.append('role', undefined);
使用URLSearchParams自动添加查询参数会处理任何潜在的编码问题。如果要添加具有保留字符(如 RFC 3986 定义的)的参数,例如&或?,URLSearchParams会自动对这些字符进行编码,以确保有效的 URL。它使用百分比编码,即添加一个百分号后跟表示该字符的十六进制数字。例如,&变成%26,因为0x26是和号的十六进制代码。
您可以通过将包含一些保留字符的查询参数追加在一起来查看此编码的实际效果,如示例 3-9 所示:
示例 3-9. 在查询参数中编码保留字符
const url = new URL('https://example.com/api/search');
// Contrived example string demonstrating several reserved characters
url.searchParams.append('q', 'admin&user?luke');
最终的 URL 变为:
https://example.com/api/search?q=admin%26user%3Fluke
URL 中包含%26代替&,以及%3F代替?。这些字符在 URL 中具有特殊含义。?表示查询字符串的开始,&是参数之间的分隔符。
如示例 3-6 所示,多次使用相同键调用append会添加具有给定键的新查询参数。当您调用.append('userRole', 'user')时,它会添加参数userRole=user并保留先前的userRole=admin。URLSearchParams还有一个set方法。set也会添加查询参数,但行为不同。set会用新参数替换给定键下的所有现有参数(参见示例 3-10)。如果您再次使用set构造相同的 URL,则结果将不同。
示例 3-10. 使用set添加查询参数
const url = new URL('https://example.com/api/search?objectType=user');
url.searchParams.set('userRole', 'admin');
url.searchParams.set('userRole', 'user');
url.searchParams.set('name', 'luke');
使用set而不是append时,第二个userRole参数将覆盖第一个,最终的 URL 为:
https://example.com/api/search?objectType=user&userRole=user&name=luke
注意只有一个userRole参数——最后添加的那个。
读取查询参数
问题
您想解析并列出 URL 中的查询参数。
解决方案
使用URLSearchParams的forEach方法列出键和值(参见示例 3-11)。
示例 3-11. 读取查询参数
/**
* Takes a URL and returns an array of its query parameters
*
* @param inputUrl A URL string
* @returns An array of objects with key and value properties
*/
function getQueryParameters(inputUrl) {
// Can't use an object here because there may be multiple
// parameters with the same key, and we want to return all parameters.
const result = [];
const url = new URL(inputUrl);
// Add each key/value pair to the result array.
url.searchParams.forEach((value, key) => {
result.push({ key, value });
});
// Results are ready!
return result;
}
讨论
当列出 URL 上的查询参数时,任何百分号编码的保留字符都会解码回它们的原始值(参见示例 3-12)。
示例 3-12. 使用getQueryParameters函数
getQueryParameters('https://example.com/api/search?name=luke%26ben'); 
name 参数包含一个百分号编码的和字符 (%26)。
此代码打印具有原始未编码值的参数 name=luke%26ben:
name: luke&ben
forEach 遍历每个唯一的键/值对组合。即使 URL 具有多个具有相同键的查询参数,这也会分别打印每个唯一的键/值对。
创建一个简单的客户端路由器
问题
您有一个单页应用程序,并希望添加客户端路由。这使用户能够在不进行新网络请求的情况下在不同的 URL 之间导航,并在客户端上替换内容。
解决方案
使用 history.pushState 和 popstate 事件来实现一个简单的路由器。这个简单的路由器在 URL 匹配到已知路由时呈现模板的内容(参见示例 3-13)。
示例 3-13. 一个简单的客户端路由器
// Route definitions. Each route has a path and some content to render.
const routes = [
{ path: '/', content: '<h1>Home</h1>' },
{ path: '/about', content: '<h1>About</h1>' }
];
function navigate(path, pushState = true) {
// Find the matching route and render its content.
const route = this.routes.find(route => route.path === path);
// Be careful using innerHTML in a real app, which can be a security risk.
document.querySelector('#main').innerHTML = route.content;
if (pushState) {
// Change the URL to match the new route.
history.pushState({}, '', path);
}
}
有了这个路由器,您可以添加链接:
<a href="/">Home</a>
<a href="/about">About</a>
当您单击这些链接时,浏览器会尝试导航到新页面,向服务器发出请求。这可能导致 404 错误,这并不是您想要的。要使用客户端路由器,您需要拦截点击事件,并与来自示例 3-13 的路由器集成,如示例 3-14 所示。
示例 3-14. 添加点击处理程序以路由链接
document.querySelectorAll('a').forEach(link => {
link.addEventListener('click', event => {
// Prevent the browser from trying to load the new URL from the server!
event.preventDefault();
navigate(link.getAttribute('href'));
});
});
当您单击这些链接之一时,preventDefault 调用会阻止浏览器的默认行为(执行完整的页面导航)。相反,它获取 href 属性并将其传递给客户端路由器。如果找到匹配的路由,它会呈现该路由的内容。
要使其成为一个完整的解决方案,还有一个必要的部分。如果您单击其中一个客户端路由,然后单击浏览器的后退按钮,什么也不会发生。这是因为页面实际上没有导航,而只是从路由器中弹出先前的状态。为了处理这种情况,您还需要监听浏览器的 popstate 事件,并渲染正确的内容,如示例 3-15 所示。
示例 3-15. 监听 popstate 事件
window.addEventListener('popstate', () => {
navigate(window.location.pathname, false);
});
当用户单击后退按钮时,浏览器会触发 popstate 事件。这会将页面 URL 变回,并且您只需要查找匹配 URL 的路由的内容。在这种情况下,您不希望调用 pushState,因为这会添加一个新的历史状态,这可能并不是您想要的,因为您刚刚从堆栈中弹出了旧的历史状态。
讨论
此客户端路由器正在运行,但存在一个问题。如果您单击“关于”链接,然后单击刷新按钮,浏览器将发起新的网络请求,这可能导致 404 错误。要解决此问题,服务器需要配置为返回主 HTML 和 JavaScript 内容,而不管 URL 的路径名是什么。这将加载路由器代码,并使用window.location.pathname的值调用。如果一切配置正确,客户端路由处理程序将执行并呈现正确的内容。
当使用客户端路由时,页面之间的导航可能更快,因为不需要与服务器进行往返。这使得导航更加流畅和响应更快。当然也有缺点。为了支持快速页面过渡,通常需要预先加载大量额外的 JavaScript,因此初始页面加载可能会较慢。
匹配 URL 与模式
问题
您希望定义一组有效 URL 的模式,可以将 URL 与之匹配。您可能还希望提取 URL 路径的一部分。例如,给定 URL https://example.com/api/users/123/profile,您希望获取用户 ID(123)。
解决方案
使用 URL 模式 API 定义预期模式并提取所需部分。
注意
该 API 可能尚未得到所有浏览器的支持。请参阅CanIUse以获取最新的兼容性数据。
使用此 API,您可以创建一个URLPattern对象,该对象定义了一个可以用来匹配 URL 的模式(参见 Example 3-16)。它是使用定义要匹配的模式的字符串创建的。该字符串可以包含命名组,当与 URL 字符串匹配时,将会提取这些组。您可以按索引访问提取的值。这些组类似于正则表达式中的捕获组。
Example 3-16. 创建URLPattern
const profilePattern = new URLPattern({ pathname: '/api/users/:userId/profile' });
Example 3-16 展示了一个带有单个命名组userId的简单 URL 模式。该组名称之前有一个冒号字符。您可以使用此模式对象匹配 URL,并从中提取用户 ID。Example 3-17 探讨了一些不同的 URL 及其如何使用profilePattern的test方法进行测试。
Example 3-17. 测试模式对应的 URL
// The pattern won't match a pathname alone; it must be a valid URL.
console.log(profilePattern.test('/api/users/123/profile'));
// This URL matches because the pathname matches the pattern.
console.log(profilePattern.test('https://example.com/api/users/123/profile'));
// It also matches URL objects.
console.log(profilePattern.test(new URL
('https://example.com/api/users/123/profile')));
// The pathname must match exactly, so this won't match.
console.log(profilePattern.test('https://example.com/v1/api/users/123/profile'));
profilePattern指定了精确的路径名匹配,这就是为什么 Example 3-17 中的最后一个示例未能工作的原因。您可以定义一个不那么严格的版本,使用通配符(*)来匹配部分路径名。
Example 3-18. 使用模式中的通配符
const wildcardProfilePattern = new URLPattern
({ pathname: '/*/api/users/:userId/profile' });
// This matches now because the /v1 portion of the URL matches the wildcard.
console.log(wildcardProfilePattern.test
('https://example.com/v1/api/users/123/profile'));
您可以使用模式的exec方法获取有关匹配的更多数据。如果模式与 URL 匹配,exec将返回一个包含 URL 各部分匹配的对象。每个嵌套对象都有一个input属性,指示 URL 的哪个部分匹配,并且有一个groups属性,其中包含模式中定义的任何命名组。
您可以使用exec从匹配的 URL 中提取用户 ID,详见 Example 3-19。
示例 3-19. 提取用户 ID
const profilePattern = new URLPattern({ pathname: '/api/users/:userId/profile' });
const match = profilePattern.exec('https://example.com/api/users/123/profile');
console.log(match.pathname.input); // '/api/users/123/profile'
console.log(match.pathname.groups.userId); // '123'
讨论
虽然它目前尚未获得所有浏览器的完全支持,但这是一个非常灵活的 API。您可以为 URL 的任何部分定义模式,匹配输入并提取组。
第四章:网络请求
介绍
如今,几乎找不到不发送任何网络请求的 Web 应用程序。自 Web 2.0 以来,通过 Ajax(异步 JavaScript 和 XML)的新方法,Web 应用程序一直在发送异步请求以获取新数据,而无需重新加载整个页面。XMLHttpRequest API 开启了交互式 JavaScript 应用程序的新时代。尽管名称如此,XMLHttpRequest(有时称为 XHR)也可以处理 JSON 和表单数据负载。
XMLHttpRequest 改变了游戏规则,但其 API 使用起来可能很痛苦。最终,第三方库如 Axios 和 jQuery 添加了更简化的 API 来包装核心 XHR API。
2015 年,基于Promise的新 API Fetch 成为新标准,并逐渐获得浏览器支持。今天,Fetch 是从 Web 应用程序发起异步请求的标准方式。
本章探讨了 XHR 和 Fetch 以及一些其他用于网络通信的 API:
Beacons
一个简单的单向 POST 请求,非常适合发送分析数据。
服务器发送事件
用于接收实时事件的单向持久连接服务器
WebSockets
用于双向持久连接的服务器通信
使用 XMLHttpRequest 发送请求
问题
您想向公共 API 发送 GET 请求,并支持不实现 Fetch API 的旧版浏览器。
解决方案
使用 XMLHttpRequest API。XMLHttpRequest 是用于发起网络请求的异步、事件驱动 API。XMLHttpRequest 的一般用法是这样的:
-
创建一个新的
XMLHttpRequest对象。 -
为
load事件添加监听器,接收响应数据。 -
在请求上调用
open,传递 HTTP 方法和 URL。 -
最后,在请求上调用
send。这会触发发送 HTTP 请求。
示例 4-1 展示了如何使用 XHR 处理 JSON 数据的简单示例。
示例 4-1. 使用 XMLHttpRequest 进行 GET 请求
/**
* Loads user data from the URL /api/users, then prints them
* to the console
*/
function getUsers() {
const request = new XMLHttpRequest();
request.addEventListener('load', event => {
// The event target is the XHR itself; it contains a
// responseText property that we can use to create a JavaScript object from
// the JSON text.
const users = JSON.parse(event.target.responseText);
console.log('Got users:', users);
});
// Handle any potential errors with the request.
// This only handles network errors. If the request
// returns an error status like 404, the 'load' event still fires
// where you can inspect the status code.
request.addEventListener('error', err => {
console.log('Error!', err);
});
request.open('GET', '/api/users');
request.send();
}
讨论
XMLHttpRequest API 是一个事件驱动的 API。当接收到响应时,会触发load事件。在示例 4-1 中,load事件处理程序将原始响应文本传递给JSON.parse。它期望响应体是 JSON,并使用JSON.parse将 JSON 字符串转换为对象。
如果在加载数据时发生错误,将触发error事件。这处理连接或网络错误,但被视为“错误”的 HTTP 状态码(如 404 或 500)不会触发此事件。而是也触发load事件。
为了防止此类错误,您需要检查响应的status属性,以确定是否存在此类错误情况。可以通过引用event.target.status来访问它。
Fetch 已经得到长时间的支持,所以除非必须支持非常老旧的浏览器,否则大多数时候您将使用 Fetch API。
使用 Fetch API 发送 GET 请求
问题
你想要使用现代浏览器向公共 API 发送 GET 请求。
解决方案
使用 Fetch API。Fetch 是一个使用 Promise 的较新请求 API。它非常灵活,可以发送各种数据,但是 Example 4-2 发送一个基本的 GET 请求到 API。
Example 4-2. 使用 Fetch API 发送 GET 请求
/**
* Loads users by calling the /api/users API, and parses the
* response JSON.
* @returns a Promise that resolves to an array of users returned by the API
*/
function loadUsers() {
// Make the request.
return fetch('/api/users')
// Parse the response body to an object.
.then(response => response.json())
// Handle errors, including network and JSON parsing errors.
.catch(error => console.error('Unable to fetch:', error.message));
}
loadUsers().then(users => {
console.log('Got users:', users);
});
讨论
Fetch API 更加简洁。它返回一个Promise,解析为表示 HTTP 响应的对象。response 对象包含诸如状态码、头部和正文等数据。
要获取 JSON 响应正文,你需要调用响应的 json 方法。该方法从流中读取正文,并返回解析为对象的 JSON 正文的 Promise。如果响应正文不是有效的 JSON,则 Promise 将被拒绝。
响应还有其他方法以其他格式读取正文,例如 FormData 或纯文本字符串。
因为 Fetch 使用 Promise,你也可以使用 await,如 Example 4-3 所示。
Example 4-3. 使用 Fetch 和 async/await
async function loadUsers() {
try {
const response = await fetch('/api/users');
return response.json();
} catch (error) {
console.error('Error loading users:', error);
}
}
async function printUsers() {
const users = await loadUsers();
console.log('Got users:', users);
}
注意
记住,在函数中使用 await 之前,该函数必须带有 async 关键字。
使用 Fetch API 发送 POST 请求
问题
你想要向期望 JSON 请求正文的 API 发送 POST 请求。
解决方案
使用 Fetch API,指定方法(POST)、JSON 正文和内容类型(参见 Example 4-4)。
Example 4-4. 使用 Fetch API 发送 JSON 负载的 POST 请求
/**
* Creates a new user by sending a POST request to /api/users.
* @param firstName The user's first name
* @param lastName The user's last name
* @param department The user's department
* @returns a Promise that resolves to the API response body
*/
function createUser(firstName, lastName, department) {
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ firstName, lastName, department }),
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json());
}
createUser('John', 'Doe', 'Engineering')
.then(() => console.log('Created user!'))
.catch(error => console.error('Error creating user:', error));
讨论
Example 4-4 发送一些 JSON 数据的 POST 请求。调用 JSON.stringify 将用户对象转换为 JSON 字符串,这是将其作为正文与 fetch 一起发送所需的。你还需要设置 Content-Type 头部,以便服务器知道如何解释正文。
Fetch 还允许你发送其他内容类型作为正文。Example 4-5 展示了如何使用表单数据发送 POST 请求。
Example 4-5. 发送表单数据的 POST 请求
fetch('/login', {
method: 'POST',
body: 'username=sysadmin&password=password',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}
})
.then(response => response.json())
.then(data => console.log('Logged in!', data))
.catch(error => console.error('Request failed:', error));
使用 Fetch API 上传文件
问题
你想要使用 Fetch API 发送文件数据的 POST 请求。
解决方案
使用 <input type="file"> 元素,并将文件内容作为请求正文发送(参见 Example 4-6)。
Example 4-6. 使用 Fetch API 发送文件数据
/**
* Given a form with a 'file' input, sends a POST request containing
* the file data in its body.
* @param form the form object (should have a file input with the name 'file')
* @returns a Promise that resolves when the response JSON is received
*/
function uploadFile(form) {
const formData = new FormData(form);
const fileData = formData.get('file');
return fetch('https://httpbin.org/post', {
method: 'POST',
body: fileData
})
.then(response => response.json());
}
讨论
使用现代浏览器 API 上传文件并不复杂。<input type="file"> 通过 FormData API 提供文件数据,并包含在 POST 请求的正文中。浏览器会处理剩余部分。
发送信标
问题
你想要发送一个快速请求,而不必等待响应,例如发送分析数据。
解决方案
使用 Beacon API 以 POST 请求发送数据。使用 Fetch API 的常规 POST 请求可能在页面卸载之前无法完成。使用 beacon 更有可能成功(参见示例 4-7)。浏览器不等待响应,并且在用户离开您的站点时发送请求更有可能成功。
示例 4-7. 发送 beacon
const currentUser = {
username: 'sysadmin'
};
// Some analytics data we want to capture
const data = {
user: currentUser.username,
lastVisited: new Date()
};
// Send the data before unload.
document.addEventListener('visibilitychange', () => {
// If the visibility state is 'hidden', that means the page just became hidden.
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/api/analytics', data);
}
});
讨论
使用 XMLHttpRequest 或 fetch 调用时,浏览器等待响应并将其返回(带有事件或 Promise)。通常,您不需要等待响应来处理单向请求,例如发送分析数据。
而不是Promise,navigator.sendBeacon返回一个布尔值,指示是否已安排发送操作。没有进一步的事件或通知。
navigator.sendBeacon总是发送一个POST请求。如果您想发送多组分析数据,例如用户与页面交互的集合,请在用户与页面交互时将它们收集到数组中,然后将数组作为POST主体与 beacon 一起发送。
使用 Server-Sent Events 监听远程事件
问题
您希望从后端服务器接收通知,而无需重复轮询。
解决方案
使用 EventSource API 接收服务器推送的事件(SSE)。
要开始监听 SSE,请创建EventSource的新实例,并将 URL 作为第一个参数传递(参见示例 4-8)。
示例 4-8. 打开 SSE 连接
const events = new EventSource('https://example.com/events');
// Fired once connected
events.addEventListener('open', () => {
console.log('Connection is open');
});
// Fired if a connection error occurs
events.addEventListener('error', event => {
console.log('An error occurred:', event);
});
// Fired when receiving an event with a type of 'heartbeat'
events.addEventListener('heartbeat', event => {
console.log('got heartbeat:', event.data);
});
// Fired when receiving an event with a type of 'notice'
events.addEventListener('notice', event => {
console.log('got notice:', event.data);
})
// The EventSource leaves the connection open. If we want to close the connection,
// we need to call close on the EventSource object.
function cleanup() {
events.close();
}
讨论
EventSource必须连接到一个特殊的 HTTP 端点,该端点使用text/event-stream的Content-Type头保持连接开放。每当事件发生时,服务器可以通过打开的连接发送新消息。
注意
根据MDN的指出,强烈建议使用 HTTP/2 与 SSE。否则,浏览器对每个域名的EventSource连接数量施加严格限制。在这种情况下,最多只能有六个连接。
此限制不是每个标签页的限制;它是在给定域中所有标签页上强加的。
当EventSource通过持久连接接收事件时,它是纯文本。您可以从接收到的事件对象的data属性访问事件文本。以下是notice类型事件的示例:
event: notice
data: Connection established at 10:51 PM, 2023-04-22
id: 3
要监听此事件,请在EventSource对象上调用addEventListener('notice')。事件对象有一个data属性,其值是事件中以data:前缀开头的任意字符串值。
如果事件没有事件类型,您可以监听通用的message事件来接收它。
使用 WebSocket 实时交换数据
问题
您希望实时发送和接收数据,而无需反复使用 Fetch 请求轮询服务器。
解决方案
使用 WebSocket API 打开与后端服务器的持久连接(参见示例 4-9)。
示例 4-9. 创建 WebSocket 连接
// Open the WebSocket connection (the URL scheme should be ws: or wss:).
const socket = new WebSocket(url);
socket.addEventListener('open', onSocketOpened);
socket.addEventListener('message', handleMessage);
socket.addEventListener('error', handleError);
socket.addEventListener('close', onSocketClosed);
function onSocketOpened() {
console.log('Socket ready for messages');
}
function handleMessage(event) {
console.log('Received message:', event.data);
}
function handleError(event) {
console.log('Socket error:', event);
}
function onSocketClosed() {
console.log('Connection was closed');
}
注意
要使用 WebSocket,您的服务器必须具有可以连接的 WebSocket 启用的端点。MDN 有一个关于创建 WebSocket 服务器的深入分析。
一旦套接字触发 open 事件,您可以开始发送消息,如示例 4-10 所示。
示例 4-10. 发送 WebSocket 消息
// Messages are simple strings.
socket.send('Hello');
// The socket needs the data as a string, so you can use
// JSON.stringify to serialize objects to be sent.
socket.send(JSON.stringify({
username: 'sysadmin',
password: 'password'
}));
WebSocket 连接是双向连接。从服务器接收的数据触发 message 事件。您可以根据需要处理这些数据,甚至发送响应(参见示例 4-11)。
示例 4-11. 响应 WebSocket 消息
socket.addEventListener('message', event => {
socket.send('ACKNOWLEDGED');
});
最后,完成时,您可以通过在 WebSocket 对象上调用 close 来关闭连接。
讨论
WebSocket 很适合需要实时能力的应用程序,例如聊天系统或事件监控。WebSocket 端点具有 ws:// 或 wss:// 方案。这与 http:// 和 https:// 类似 —— 一个不安全,一个使用加密。
要启动 WebSocket 连接,浏览器首先向 WebSocket 端点发送 GET 请求。URL wss://example.com/websocket 的请求有效负载如下所示:
GET /websocket HTTP/1.1
Host: example.com
Sec-WebSocket-Key: aSBjYW4gaGFzIHdzIHBsej8/
Sec-WebSocket-Version: 13
Connection: Upgrade
Upgrade: websocket
这启动了 WebSocket 握手。如果成功,服务器将以状态码 101(协议切换)响应:
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: bm8gcGVla2luZywgcGxlYXNlIQ==
WebSocket 协议指定了一种算法,根据请求的 Sec-WebSocket-Key 生成 Sec-Websocket-Accept 头。客户端验证此值后,双向 WebSocket 连接激活,套接字触发 open 事件。
连接打开后,您可以通过在套接字对象上调用 send 来监听消息并发送消息。稍后,您可以通过在套接字对象上调用 close 来终止 WebSocket 会话。
第五章:IndexedDB
介绍
第二章涵盖了本地或会话存储的数据持久性。这对于字符串值和可序列化对象效果很好,但查询不理想,对象需要 JSON 序列化。IndexedDB 是现代浏览器中存在的一种更新、更强大的数据持久性机制。IndexedDB 数据库包含对象存储(类似于关系数据库中的表)。每个对象存储可以在特定属性上有索引,以便更高效地进行查询。它还支持更高级的概念,如版本控制和事务。
对象存储和索引
一个 IndexedDB 数据库有一个或多个对象存储。所有添加、删除或查询数据的操作都在对象存储上进行。对象存储是持久化在数据库中的 JavaScript 对象的集合。您可以在对象存储上定义索引。索引会将额外的信息存储到数据库中,让您可以通过索引的属性来查询对象。例如,假设您正在创建一个用于存储产品信息的数据库。每个产品都有一个键,可能是产品 ID 或 SKU 代码。这样可以让您快速搜索数据库中的特定产品。
如果您还想通过价格查询数据,可以在价格属性上创建一个索引。这使您可以通过价格查找对象。有了索引,您可以指定特定的价格或价格范围,并且索引可以快速找到这些记录。
键
存储在存储中的对象具有一个用于在该存储中唯一标识该对象的键。这类似于关系数据库表中的主键。在 IndexedDB 对象存储中有两种类型的键。
内联键是在对象本身上定义的。例如,这是一个带有内联键的待办事项:
{
// Here, id is the key.
id: 100,
name: 'Take out the trash',
completed: false
}
在这里,键是 id 属性。当向此类对象存储中添加待办事项时,它们必须定义一个 id 属性。此外,在创建对象存储时,您可以指定一个键路径为 id。键路径告诉 IndexedDB 在使用内联键时包含键的属性名称:
const todosStore = db.createObjectStore('todos', { keyPath: 'id' });
如果您想使用内联键并且不想担心维护唯一键,可以告诉 IndexedDB 使用自增键:
const todosStore = db.createObjectStore('todos',
{ keyPath: 'id', autoIncrement: true });
外联键不会存储在对象内部。在存储对象时,可以使用 add 或 put 指定一个独立的参数作为外联键。继续上面的例子,您也可以为待办事项使用外联键。这意味着键或 id 属性不会作为对象的一部分存储:
const todo = {
name: 'Take out the trash',
completed: false
};
// later, when adding the new to-do
todoStore.add(todo, 100);
事务
IndexedDB 操作使用事务。事务是一组一起执行以执行某些工作的数据库任务的逻辑分组。它们旨在保护数据库中数据的完整性。如果事务中的某个操作失败,整个事务将失败,并且任何已完成的工作都将回滚到事务之前的状态。
事务可以是只读的或读写的,具体取决于你想执行的操作类型。你可以通过调用 IndexedDB 数据库的transaction方法来创建一个事务。你需要传递应该参与此事务的任何对象存储的名称以及事务类型(readonly或readwrite)。
一旦你拥有了一个事务,就可以获取到你需要的对象存储的引用。从那里,你可以开始执行你的数据库操作。这些操作返回一个 IndexedDB 的request。在 IndexedDB 数据库中,所有读取和写入操作都需要一个事务。
请求
当你在事务中对对象存储执行操作时,你会得到一个实现了IDBRequest接口的请求对象,并且请求的工作异步开始。
当操作完成时,请求对象会触发一个包含结果的success事件。例如,查询操作的success事件包含查询到的对象。
图 5-1 展示了一个 IndexedDB 操作的一般流程:创建事务、打开对象存储、创建请求并监听事件。

图 5-1. IndexedDB 操作的各个部分
在数据库中创建、读取和删除对象
问题
你希望创建一个基本的 IndexedDB 数据库,可以在其中创建、读取和删除对象。例如,这可以是一个联系人列表数据库。
解决方案
创建一个只有一个对象存储的数据库,并定义创建/读取/删除操作。
要创建或打开数据库,请调用indexedDB.open(参见示例 5-1)。如果之前未创建过数据库,则会触发一个upgradeneeded事件。在该事件的处理程序中,你可以创建对象存储。当数据库打开并准备好使用时,会触发一个success事件。
示例 5-1. 打开数据库
/**
* Opens the database, creating the object store if needed.
* Because this is asynchronous, it takes a callback function, onSuccess. Once the
* database is ready, onSucces will be called with the database object.
*
* @param onSuccess A callback function that is executed when the database is ready
*/
function openDatabase(onSuccess) {
const request = indexedDB.open('contacts');
// Create the object store if needed.
request.addEventListener('upgradeneeded', () => {
const db = request.result;
// The contact objects will have an 'id' property that will
// be used as the key. When you add a new contact object, you don't need to
// set an 'id' property; the autoIncrement flag means that the database will
// automatically set an 'id' for you.
db.createObjectStore('contacts', {
keyPath: 'id',
autoIncrement: true
});
});
// When the database is ready for use, it triggers a 'success' event.
request.addEventListener('success', () => {
const db = request.result;
// Call the given callback with the database.
onSuccess(db);
});
// Always handle errors!
request.addEventListener('error', () => {
console.error('Error opening database:', request.error);
});
}
在渲染联系人之前,你需要从数据库中加载它们。为此,请使用一个readonly事务,并调用对象存储的getAll方法,该方法检索对象存储中的所有对象(参见示例 5-2)。
示例 5-2. 读取联系人
/**
* Reads the contacts from the database and renders them in the table.
* @param contactsDb The IndexedDB database
* @param onSuccess A callback function that is executed when the contacts are loaded
*/
function getContacts(contactsDb, onSuccess) {
const request = contactsDb
.transaction(['contacts'], 'readonly')
.objectStore('contacts')
.getAll();
// When the data has been loaded, the database triggers a 'success' event on the
// request object.
request.addEventListener('success', () => {
console.log('Got contacts:', request.result);
onSuccess(request.result);
});
request.addEventListener('error', () => {
console.error('Error loading contacts:', request.error);
});
}
添加联系人需要一个readwrite事务。将联系人对象传递给对象存储的add方法(参见示例 5-3)。
示例 5-3. 添加联系人
/**
* Adds a new contact to the database, then re-renders the table.
* @param contactsDb The IndexedDB database
* @param contact The new contact object to add
* @param onSuccess A callback function that is executed when the contact is added
*/
function addContact(contactsDb, contact, onSuccess) {
const request = contactsDb
.transaction(['contacts'], 'readwrite')
.objectStore('contacts')
.add(contact);
request.addEventListener('success', () => {
console.log('Added new contact:', contact);
onSuccess();
});
request.addEventListener('error', () => {
console.error('Error adding contact:', request.error);
});
}
对于删除联系人,你还需要一个readwrite事务(参见示例 5-4)。
示例 5-4. 删除联系人
/**
* Deletes a contact from the database, then re-renders the table.
* @param contactsDb The IndexedDB database.
* @param contact The contact object to delete
* @param onSuccess A callback function that is executed when the contact is deleted
*/
function deleteContact(contactsDb, contact, onSuccess) {
const request = contactsDb
.transaction(['contacts'], 'readwrite')
.objectStore('contacts')
.delete(contact.id);
request.addEventListener('success', () => {
console.log('Deleted contact:', contact);
onSuccess();
});
request.addEventListener('error', () => {
console.error('Error deleting contact:', request.error);
});
}
讨论
创建数据库时,调用indexedDB.open,这将创建一个打开数据库的请求。如果触发了upgradeneeded事件,你可以创建必要的对象存储。
对象存储中的每个对象必须具有唯一的键。如果尝试添加具有重复键的对象,则会收到错误。
其他操作的模式通常是相同的:
-
创建一个事务。
-
访问对象存储。
-
在对象存储上调用所需的方法。
-
监听
success事件。
这些函数中的每个都接受一个名为 onSuccess 的参数。由于 IndexedDB 是异步的,您需要等待操作完成。openDatabase 函数将数据库传递给 onSuccess 函数,在那里您可以将其保存到变量中以供以后使用(见 示例 5-5)。
示例 5-5. 使用 openDatabase 函数
let contactsDb;
// Open the database and do the initial contact list render.
// The success handler sets contactsDb to the new database object for later use,
// then loads and renders the contacts.
openDatabase(db => {
contactsDb = db;
renderContacts(contactsDb);
});
一旦设置了 contactsDb 变量,您可以将其传递给其他数据库操作。当您想要渲染联系人列表时,必须首先等待它们加载,因此您需要传递一个成功处理程序,该处理程序接收联系人对象并将它们渲染(见 示例 5-6)。
示例 5-6. 加载和渲染联系人
getContacts(contactsDb, contacts => {
// Contacts have been loaded, now render them.
renderContacts(contacts);
});
同样,在添加新联系人时,您必须等待新对象添加完成,然后加载和渲染更新后的联系人列表(见 示例 5-7)。
示例 5-7. 添加和重新渲染联系人
const newContact = { name: 'Connie Myers', email: 'cmyers@example.com' };
addContact(contactsDb, newContact, () => {
// Contact has been added, now load the updated list and render it.
getContacts(contactsDb, contacts => {
renderContacts(contacts);
})
});
如果您不想经常传递数据库引用,可以将数据库引用和函数封装在一个新对象中,如 示例 5-8 所示。
示例 5-8. 封装的数据库
const contactsDb = {
open(onSuccess) {
const request = indexedDB.open('contacts');
request.addEventListener('upgradeneeded', () => {
const db = request.result;
db.createObjectStore('contacts', {
keyPath: 'id',
autoIncrement: true
});
});
request.addEventListener('success', () => {
this.db = request.result;
onSuccess();
});
},
getContacts(onSuccess) {
const request = this.db
.transaction(['contacts'], 'readonly')
.objectStore('contacts')
.getAll();
request.addEventListener('success', () => {
console.log('Got contacts:', request.result);
onSuccess(request.result);
});
},
// Other operations follow similarly.
};
采用这种方法,您仍然需要回调来通知操作完成,但 contactsDb 对象会为您跟踪数据库引用(并避免全局变量!)。
升级现有数据库
问题
要更新现有数据库以添加新的对象存储。
解决方案
使用新的数据库版本。在处理 upgradeneeded 事件时,根据版本确定当前用户的数据库是否需要添加新的对象存储。
想象一下,您有一个带有 todos 对象存储的待办事项数据库。稍后,在应用程序的更新中,您希望添加一个新的 people 对象存储,以便可以将任务分配给人员。
现在,indexedDB.open 调用需要一个新的版本号。您可以将版本号增加到 2(见 示例 5-9)。
示例 5-9. 升级数据库
// todoList database is now at version 2
const request = indexedDB.open('todoList', 2);
// If the user's database is still at version 1, an 'upgradeneeded' event
// is triggered so that the new object store can be added.
request.addEventListener('upgradeneeded', event => {
const db = request.result;
// This event is also triggered when no database exists yet, so you still need
// to handle this case and create the to-dos object store.
// The oldVersion property specifies the user's current version of the database.
// If the database is just being created, the oldVersion is 0.
if (event.oldVersion < 1) {
db.createObjectStore('todos', {
keyPath: 'id'
});
}
// If this database has not yet been upgraded to version 2, create the
// new object store.
if (event.oldVersion < 2) {
db.createObjectStore('people', {
keyPath: 'id'
});
}
});
request.addEventListener('success', () => {
// Database is ready to go.
});
// Log any error that might have occurred. The error object is
// stored in the request's 'error' property.
request.addEventListener('error', () => {
console.error('Error opening database:', request.error);
});
讨论
当调用 indexedDB.open 时,您可以指定数据库版本。如果不指定版本,它将默认为 1。
每当打开数据库时,将在浏览器中当前的数据库版本(如果有)与传递给 indexedDB.open 的版本号进行比较。如果数据库尚不存在或版本不是最新的,则会触发 upgradeneeded 事件。
在 upgradeneeded 事件处理程序中,您可以检查事件的 oldVersion 属性,以确定浏览器当前的数据库版本。如果数据库尚不存在,则 oldVersion 为 0。
根据 oldVersion,您可以确定哪些对象存储和索引已存在以及哪些需要添加。
警告
如果尝试创建已存在的对象存储或索引,浏览器将抛出异常。在创建这些对象之前,请确保检查事件的 oldVersion 属性。
使用索引进行查询
问题
你希望根据除了键(通常称为“主键”)以外的属性值高效地查询数据。
解决方案
在该属性上创建一个索引,然后在该索引上进行查询。
考虑员工数据库的示例。每个员工都有姓名、部门和唯一的 ID 作为其键。您可能希望按特定部门筛选员工。
当触发upgradeneeded事件并创建对象存储时,您还可以在该对象存储上定义索引(参见示例 5-10)。示例 5-11 展示了如何通过定义的索引进行查询。
示例 5-10. 在创建对象存储时定义索引
/**
* Opens the database, creating the object store and index if needed.
* Once the database is ready, onSuccess will be called with the database object.
*
* @param onSuccess A callback function that is executed when the database is ready
*/
function openDatabase(onSuccess) {
const request = indexedDB.open('employees');
request.addEventListener('upgradeneeded', () => {
const db = request.result;
// New employee objects will be given an autogenerated
// 'id' property that serves as its key.
const employeesStore = db.createObjectStore('employees', {
keyPath: 'id',
autoIncrement: true,
});
// Create an index on the 'department' property called 'department'.
employeesStore.createIndex('department', 'department');
});
request.addEventListener('success', () => {
onSuccess(request.result);
});
}
示例 5-11. 通过部门索引查询员工
/**
* Gets the employees for a given department, or all employees
* if no department is given
*
* @param department The department to filter by
* @param onSuccess A callback function that is executed when the employees
* are loaded
*/
function getEmployees(department, onSuccess) {
const request = employeeDb
.transaction(['employees'], 'readonly')
.objectStore('employees')
.index('department')
.getAll(department);
request.addEventListener('success', () => {
console.log('Got employees:', request.result);
onSuccess(request.result);
});
request.addEventListener('error', () => {
console.log('Error loading employees:', request.error);
});
}
讨论
根据需要,IndexedDB 对象存储可以拥有多个索引。
此示例使用特定值来查询索引,但索引也可以查询一组键的范围。这些范围使用IDBKeyRange接口定义。范围根据其边界定义——它定义了范围的起点和终点,并返回该范围内的所有键。
IDBKeyRange 接口支持四种类型的边界:
IDBKeyRange.lowerBound
匹配从给定下界开始的键
IDBKeyRange.upperBound
匹配以给定上界结束的键
IDBKeyRange.bound
指定下界和上界
IDBKeyRange.only
指定单个键值
lowerBound、upperBound 和 bound 键范围还接受第二个布尔参数,用于指定范围是开放的还是闭合的。如果为 true,则被视为开放范围,并且排除范围本身。IDBKeyRange.upperBound(10) 匹配所有小于等于 10 的键,但 IDBKeyRange.upperBound(10, true) 匹配所有小于 10 的键,因为 10 本身被排除。键范围的边界不一定是数字,还可以是字符串和Date对象等其他对象类型。
使用游标搜索字符串值
问题
您希望查询 IndexedDB 对象存储中具有与模式匹配的字符串属性的对象。
解决方案
使用游标,检查每个对象的属性以查看是否包含给定的字符串。
想象一个员工列表应用程序。您希望搜索所有姓名包含输入文本的联系人。在这个例子中,假设数据库已经打开,并且对象存储称为employees。
游标在对象存储中遍历每个对象。它在每个对象处停止,您可以访问当前项和/或移动到下一个项。您可以检查联系人姓名是否包含查询文本,并将结果收集到数组中(见示例 5-12)。
示例 5-12. 使用游标搜索字符串值
/**
* Searches for employees by name
*
* @param name A query string to match employee names
* @param onSuccess Success callback that will receive the matching employees.
*/
function searchEmployees(name, onSuccess) {
// An array to hold all contacts with a name containing the query text
const results = [];
const query = name.toLowerCase();
const request = employeeDb
.transaction(['employees'], 'readonly')
.objectStore('employees')
.openCursor();
// The cursor request will emit a 'success' event for each object it finds.
request.addEventListener('success', () => {
const cursor = request.result;
if (cursor) {
const name = `${cursor.value.firstName} ${cursor.value.lastName}`
.toLowerCase();
// Add the contact to the result array if it matches the query.
if (name.includes(query)) {
results.push(cursor.value);
}
// Continue to the next record.
cursor.continue();
} else {
onSuccess(results);
}
});
request.addEventListener('error', () => {
console.error('Error searching employees:', request.error);
});
}
讨论
在对象存储上调用 openCursor 时,它会返回一个 IDBRequest 请求对象。它为存储中的第一个对象触发一个 success 事件。对于每个 success 事件,请求有一个 result 属性,该属性是游标对象本身。您可以通过其 value 属性访问游标当前指向的当前值。
成功处理程序检查当前对象的名字和姓氏字段,首先将它们转换为小写,以便进行不区分大小写的搜索。如果匹配,它将被添加到结果数组中。
处理完当前对象后,可以在游标上调用 continue。这会前进到下一个对象并触发另一个 success 事件。如果已经到达对象存储的末尾,并且没有剩余对象,request.result 将为 null。这时,您知道搜索已完成,并且已找到匹配的联系人。
在游标的每次迭代中,任何与搜索查询匹配的对象都将添加到 results 数组中。当游标完成时,此数组将传递给成功的回调函数。
对大数据集进行分页
问题
您希望将大型数据集分成具有偏移量和长度的页面。
解决方案
使用游标跳到请求页面的第一项并收集所需数量的项目(参见 示例 5-13)。
示例 5-13. 使用游标获取一页记录
/**
* Uses a cursor to fetch a single "page" of data from an IndexedDB object store
*
* @param db The IndexedDB database object
* @param storeName The name of the object store
* @param offset The starting offset (0 being the first item)
* @param length The number of items after the offset to return
*/
function getPaginatedRecords(db, storeName, offset, length) {
const cursor = db
.transaction([storeName], 'readonly')
.objectStore(storeName)
.openCursor();
const results = [];
// This flag indicates whether or not the cursor has skipped ahead to the
// offset yet.
let skipped = false;
request.addEventListener('success', event => {
const cursor = event.target.result;
if (!skipped) {
// Set the flag and skip ahead by the given offset. Next time around,
// the cursor will be in the starting position and can start collecting
// records.
skipped = true;
cursor.advance(offset);
} else if (cursor && result.length < length) {
// Collect the record the cursor is currently pointing to.
results.push(cursor.value);
// Continue on to the next record.
cursor.continue();
} else {
// There are either no records left, or the length has been reached.
console.log('Got records:', request.result);
}
});
request.addEventListener('error', () => {
console.error('Error getting records:', request.error);
});
}
讨论
您可能不希望从第一条记录开始——这就是 offset 参数的作用。第一次调用事件处理程序时,使用请求的偏移量调用 advance。这告诉游标跳到您想要的起始项目。严格来说,advance 并不会移动到指定的偏移量,而是从当前索引开始按给定数量前进。但对于这个例子来说,效果是相同的,因为它总是从索引零开始。
直到游标的下一次迭代,才能开始收集值。为了处理这个问题,有一个 skipped 标志,用于指示游标现在已经跳过。下一次通过时,将看到这个标志为 true,并且不会再尝试跳过。
游标一旦前进,会触发另一个 success 事件。现在游标指向要收集的第一个项目(假设还有剩余项目——如果没有更多对象,则 cursor 对象为 null)。它将当前值添加到结果数组中。最后,它在游标上调用 continue 以移动到下一个值。
此过程继续,直到结果数组达到请求的长度,或者对象存储中没有更多对象。如果 offset + length 大于对象存储中的对象数,就会发生这种情况。
一旦没有更多对象可收集,完整的结果页就准备好了。
使用 Promises 与 IndexedDB API
问题
您希望有一个基于 Promise 的 API 来操作 IndexedDB 数据库。
解决方案
创建Promise封装来处理 IndexedDB 请求。当请求触发success事件时,解析Promise。如果触发error事件,则拒绝它。
示例 5-14 创建一个围绕indexedDb.open函数的封装。它打开或创建数据库,并返回一个Promise,当数据库准备好时解析。
示例 5-14. 使用Promise创建数据库
/**
* Opens the database, creating the object store if needed.
* @returns a Promise that is resolved with the database, or rejected with an error
*/
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('contacts-promise');
// Create the object store if needed.
request.addEventListener('upgradeneeded', () => {
const db = request.result;
db.createObjectStore('contacts', {
keyPath: 'id',
autoIncrement: true
});
});
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => reject(request.error));
});
}
要从数据库加载一些数据,示例 5-15 提供了一个围绕getAll方法的封装。它请求数据,然后返回一个Promise,当数据加载完成时,解析为对象数组。
示例 5-15. 使用Promise从存储区获取对象
/**
* Reads the contacts from the database.
* @returns a Promise that is resolved with the contacts, or rejected with an error
*/
function getContacts() {
return new Promise((resolve, reject) => {
const request = contactsDb
.transaction(['contacts'], 'readonly')
.objectStore('contacts')
.getAll();
request.addEventListener('success', () => {
console.log('Got contacts:', request.result);
resolve(request.result);
});
request.addEventListener('error', () => {
console.error('Error loading contacts:', request.error);
reject(request.error);
});
});
}
现在您已经有一个返回Promise的 API,可以在处理数据库时使用then或async/await(参见示例 5-16)。
示例 5-16. 使用Promise封装的数据库
async function loadAndPrintContacts() {
try {
const db = await openDatabase();
const contacts = await getContacts();
console.log('Got contacts:', contacts);
} catch (error) {
console.error('Error:', error);
}
}
讨论
使用Promise API 和async/await可以避免传递成功处理程序回调的需求。正如示例 5-16 展示的那样,您还可以利用Promise链式操作来避免嵌套回调和事件处理程序。
第六章:观察 DOM 元素
引言
本章介绍了浏览器提供的三种用于监视 DOM 元素的观察者类型:MutationObserver、IntersectionObserver 和 ResizeObserver。这些观察者对象可以监视 DOM 元素并通知您某些变化或事件。
观察者是通过回调函数创建的。这个函数在页面中发生相关事件时被调用。它被调用时传入一个或多个条目,这些条目包含发生的事件信息。这只是创建观察者。要实际开始监视一个元素,你需要在观察者上调用observe,传入你想要观察的元素以及一个可选的选项集。
MutationObserver
MutationObserver 监视元素在 DOM 中的变化。你可以观察以下变化:
-
子元素
-
属性
-
文本内容
浏览器观察的内容在传递给observe函数的选项对象中定义。当观察一个元素时,您还可以给定一个可选的subtree选项。这将扩展到所有后代节点(而不仅仅是元素及其直接子元素)的监控,包括子元素、属性和/或文本内容。
当发生你感兴趣的变化时,你的回调函数会执行,并传递一个描述刚发生的变化的MutationEntry对象数组。
ResizeObserver
正如其名称所示,ResizeObserver 在元素大小更改时通知你。大小变化时,会调用你的回调函数,并提供有关已调整大小的信息。条目包含有关元素新大小的信息。
IntersectionObserver
IntersectionObserver 监视元素相对于视口的位置变化。视口可以是一个可滚动的元素或浏览器窗口本身。如果子元素的任何部分在可滚动区域内可见,则称为相交祖先元素。图 6-1 展示了可滚动页面上的元素。

图 6-1. 元素 1 未相交,元素 2 部分相交,元素 3 完全相交
IntersectionObserver 使用相交比率的概念——元素实际相交于根元素的比例。如果元素完全可见,则比率为 1. 如果完全不在屏幕上,则比率为 0. 如果正好一半可见一半不可见,则比率为 0.5. 传递给回调函数的条目具有指定当前相交比率的intersectionRatio属性。
当你创建IntersectionObserver时,还可以指定一个threshold。这定义了观察者何时触发。默认情况下,阈值为 0。这意味着只要元素部分可见,观察者就会触发,即使只有一个像素。阈值为 1 时,仅当元素完全可见时触发。
懒加载图像当滚动到视图中
问题
你想要推迟加载图像,直到其位置滚动到视图中。有时这被称为懒加载。
解决方案
在<img>元素上使用IntersectionObserver,并等待直到它与视口交集。一旦进入视口,设置src属性开始加载图像(参见示例 6-1)。
示例 6-1. 使用IntersectionObserver进行图像的懒加载
/**
* Observes an image element for lazy loading
*
* @param img A reference to the image DOM node
* @param url The URL of the image to load
*/
function lazyLoad(img, url) {
const observer = new IntersectionObserver(entries => {
// isIntersecting becomes true once the image enters the viewport.
// At that point, set the src URL and stop listening.
if (entries[0].isIntersecting) {
img.src = url;
observer.disconnect();
}
});
// Start observing the image element.
observer.observe(img);
}
讨论
当你创建一个IntersectionObserver时,需要给它提供一个回调函数。每当一个元素进入或退出视口时,观察者就会调用这个函数,并提供有关元素交集状态的信息。
观察者可能会观察多个元素,它们的交集状态可能同时发生变化,因此回调函数会传递一个元素数组。在示例 6-1 中,观察者只观察单个图像元素,所以数组只有一个元素。
如果多个元素同时进入(或离开)视口,每个元素都会有一个条目。
你想要检查isIntersecting属性来确定是否该加载图像。当元素变得部分可见时,该属性变为true。
最后,通过在观察者对象上调用observe来告诉观察者要观察哪个元素。这开始监视该元素。
一旦你滚动到足够的位置,使元素进入视口区域,观察者就会调用回调函数。回调函数设置图像的 URL,然后通过调用disconnect停止监听。回调函数停止监听是因为一旦图像加载完成,就不需要继续观察元素。
在IntersectionObserver出现之前,要做这件事情的选项并不多。一种选项是监听父元素的scroll事件,然后通过比较父元素和子元素的边界矩形来判断元素是否在视口中。
当然,这样做性能不佳。这也通常被认为是不良实践。你需要节流或防抖这个检查,以防止它在每次滚动操作时都运行。
将 IntersectionObserver 与 Promise 包装起来
问题
你想创建一个Promise,一旦一个元素进入视口,就会解析该Promise。
解决方案
将IntersectionObserver包装在Promise中。一旦元素与其父元素交集,就解析Promise(参见示例 6-2)。
示例 6-2. 将IntersectionObserver与Promise包装起来
/**
* Returns a Promise that is resolved once the given element enters the viewport
*/
function waitForElement(element) {
return new Promise(resolve => {
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
observer.disconnect();
resolve();
}
});
observer.observe(element);
});
}
讨论
当观察者使用指示元素交集的条目执行你的回调时,你可以解析Promise。
如 示例 6-3 所示,您可以使用此方法来延迟加载图像,类似于 “滚动到视图中时延迟加载图像”。
示例 6-3. 使用 waitForElement 辅助程序来延迟加载图像
function lazyLoad(img, url) {
waitForElement(img)
.then(() => img.src = url)
}
一旦解析了 Promise,调用代码可以确保元素位于视窗内。此时,lazyLoad 函数会在图像上设置 src 属性。
自动暂停和播放视频
问题
您在可滚动容器中有一个 <video> 元素。当视频正在播放时,如果它滚出视窗,您希望自动暂停它。
解决方案
使用 IntersectionObserver 监控视频元素。一旦它不再与视窗相交,就将其暂停。稍后,如果重新进入视窗,就恢复播放(见 示例 6-4)。
示例 6-4. 自动暂停和恢复视频
const observer = new IntersectionObserver(entries => {
if (!entries[0].isIntersecting) {
video.pause();
} else {
video.play()
.catch(error => {
// In case of a permission error autoplaying the video.
// This avoids an unhandled rejection error that could crash your app.
});
}
});
observer.observe(video);
讨论
此观察器监视 video 元素。一旦它滚出视窗,就会暂停播放。稍后,如果将其滚回视窗中,它将恢复播放。
动画化高度变化
问题
您有一个内容可能会更改的元素。如果内容更改,您希望高度可以平滑过渡。
解决方案
使用 MutationObserver 监控元素的子元素。如果元素添加、删除或更改任何子元素,则使用 CSS 过渡来平滑地动画化高度变化。由于无法对具有 auto 高度的元素进行动画处理,因此需要一些额外的工作来计算显式高度,以便在其中进行动画化(见 示例 6-5)。
示例 6-5. 因子元素更改而动画化元素高度
/**
* Watches an element for changes to its children. When the height changes
* due to child changes, animate the change.
* @param element The element to watch for changes
*/
function animateHeightChanges(element) {
// You can't animate an element with 'height: auto', so an explicit
// height is needed here.
element.style.height = `${details.offsetHeight}px`;
// Set a few CSS properties needed for the animated transition.
element.style.transition = 'height 200ms';
element.style.overflow = 'hidden';
/**
* This observer will fire when the element's child elements
* change. It measures the new height, then uses requestAnimationFrame
* to update the height. The height change will be animated.
*/
const observer = new MutationObserver(entries => {
// entries is always an array. There may be times where this array has multiple
// elements, but in this case, the first and only element is what you need.
const element = entries[0].target;
// The content has changed, and so has the height.
// There are a few steps to measure the new explicit height.
// (1) Remember the current height to use for the animation's starting point.
const currentHeightValue = element.style.height;
// (2) Set the height to 'auto' and read the offsetHeight property.
// This is the new height to set.
element.style.height = 'auto';
const newHeight = element.offsetHeight;
// (3) Set the current height back before animating.
element.style.height = currentHeightValue;
// On the next animation frame, change the height. This will
// trigger the animated transition.
requestAnimationFrame(() => {
element.style.height = `${newHeight}px`;
});
});
// Begin watching the element for changes.
observer.observe(element, { childList: true });
}
讨论
与其他观察器一样,创建 MutationObserver 时需要传递一个回调函数。当观察到的元素发生变化时(具体取决于传递给 observer.observe 的选项),观察器会调用此函数。当您的应用程序导致元素的子列表发生任何更改(添加、删除或修改元素)时,回调函数重新计算高度以适应新内容。
这里有很多事情要做,主要是因为浏览器不允许使用 height 为 auto 的元素进行动画处理。为了使动画正常工作,您必须使用显式值来设置起始和结束的高度。
在首次观察元素时,通过读取 offsetHeight 属性来计算其高度。然后,函数显式地在元素上设置此高度。这暂时处理了 height: auto 的问题。
当元素的子元素更改时,父元素不会自动调整大小,因为它现在具有显式设置的高度。观察器回调计算新高度。使用显式设置的高度后,offsetHeight 属性具有相同的值。
要测量新高度,必须首先将高度设置为自动。一旦完成这一步骤,offsetHeight就会给出新的高度值。但请记住,不能从height: auto进行动画。在更新高度之前,必须将其从自动设置回先前设置的状态。
现在您已经获得了新的高度。实际的高度更新放在requestAnimationFrame的调用中。
这种计算高度的方法增加了很多额外的代码。第八章介绍了 Web 动画 API,使得这些类型的动画变得不那么痛苦。
根据大小更改元素的内容
问题
您希望根据元素的大小在元素内显示不同的内容。例如,您可能希望处理元素非常宽的情况。
解决方案
对元素使用ResizeObserver,并在大小超过或低于您定义的阈值时更新内容(参见示例 6-6)。
示例 6-6. 更新元素大小变化时的内容
// Look up the element you want to observe.
const container = document.querySelector('#resize-container');
// Create a ResizeObserver that will watch the element for size changes.
const observer = new ResizeObserver(entries => {
// The observer fires immediately, so you can set the initial text.
// There's typically only going to be one entry in the array—the first element is
// the element you're interested in.
container.textContent = `My width is ${entries[0].contentRect.width}px`;
});
// Start watching the element.
observer.observe(container);
讨论
ResizeObserver在元素的大小变化时调用您传递的回调函数。观察者在首次观察到元素时也会立即调用它。
回调函数调用时会传递一个ResizeObserverEntry对象数组——在这里,因为只观察一个元素,通常只会有一个条目。entry对象有几个属性,包括contentRect,定义了元素的边界矩形。从那里您可以获取宽度。
因此,当元素调整大小时,观察者回调将更改其文本以指示当前宽度。
警告
使用ResizeObserver时要小心,确保回调中的代码不会再次触发观察者。如果在回调中更改元素并导致其大小再次改变,这样的回调可能会导致ResizeObserver回调的无限循环。
在元素滚动到视图中时应用过渡
问题
您有一些不会初始显示的内容。当内容进入视口时,您希望通过动画过渡显示它。例如,当图像滚动到视图中时,您希望通过淡入使其不透明度过渡。
解决方案
使用IntersectionObserver监视元素何时滚动到视图中。当元素滚动到视图中时,应用动画过渡(参见示例 6-7)。
示例 6-7. 当页面上的所有图像淡入视图时
const observer = new IntersectionObserver(entries => {
// There are multiple images per row, so there are multiple
// entries.
entries.forEach(entry => {
// Once the element becomes partially visible, apply the animated transition,
if (entry.isIntersecting) {
// The image is 25% visible, begin the fade-in transition.
entry.target.style.opacity = 1;
// No need to observe this element any further.
observer.unobserve(entry.target);
}
});
}, { threshold: 0.25 }); // Fires when images become 25% visible
// Observe all images on the page. Only images with the 'animate'
// class name will be observed, since you might not want to do this to
// all images on the page.
document.querySelectorAll('img.animate').forEach(image => {
observer.observe(image);
});
讨论
本示例使用IntersectionObserver的threshold选项。默认情况下,观察者在元素首次变为可见时触发(threshold为0)。但在这种情况下并不理想,因为您希望至少有足够的图像可见,以便用户注意到过渡效果。通过将threshold设置为0.25,观察者在图像至少 25%可见时才执行回调。
回调函数还会检查图像是否实际相交,即是否已变为可见。这是必要的,因为当观察者首次开始观察一个元素时,它会立即触发。在这种情况下,屏幕外的图像尚未相交,因此此检查防止它们过早变为可见。
如果条目相交,则可以设置新的样式以触发动画或过渡效果。在这种情况下,回调函数将图像的不透明度设置为 1。为使此效果生效,您需要先将不透明度设置为 0,并定义opacity属性的transition(参见示例 6-8)。
示例 6-8. 图像淡入效果的样式
img.animate {
opacity: 0;
transition: opacity 500ms;
}
使用这种样式,图像是不可见的。当观察者回调将不透明度设置为 1 时,过渡生效,您将看到图像淡入。
您只希望执行此动画一次,因此一旦图像可见,就不再需要观察它。您可以通过调用observer.unobserve并传递元素来清理以停止观察。
使用无限滚动
问题
您希望在用户滚动到列表底部时自动加载更多数据,而无需用户点击“加载更多”按钮。
解决方案
将元素放置在可滚动列表的末尾,并使用IntersectionObserver观察它。当元素开始相交时,加载更多数据(参见示例 6-9)。
示例 6-9. 使用IntersectionObserver实现无限滚动
/**
* Observes a placeholder element with an IntersectionObserver.
* When the placeholder becomes visible, more data is loaded.
*
* @param placeholder The Load More placeholder element
* @param loadMore A function that loads more data
*/
function observeForInfiniteScroll(placeholder, loadMore) {
const observer = new IntersectionObserver(entries => {
// If the placeholder becomes visible, it means the user
// has scrolled to the bottom of the list. In this case, time to
// load more data.
if (entries[0].isIntersecting) {
loadMore();
}
});
observer.observe(placeholder);
}
讨论
占位符元素可以显示“加载更多”,也可以视觉上隐藏。IntersectionObserver监视占位符元素。一旦进入视口,回调函数开始加载更多数据。使用此技术,用户可以不断滚动直至达到数据末尾。
您可以将此占位符设置为加载旋转器。当用户滚动到列表底部并触发新请求时,他们将在加载新数据时看到旋转器。这是准确的,因为使用默认阈值 0.0 时,观察者会在用户滚动到足够看到旋转器之前触发。此时,数据已经在加载,因此这不是一个人为的旋转器。
当观察者首次开始观察时,回调立即触发。如果列表为空,则占位符可见,触发代码加载第一页数据。
第七章:表单
简介
表单收集用户输入,并提交到远程 URL 或 API 端点。现代浏览器具有许多内置的表单输入类型,用于文本、数字、颜色等。表单是从用户获取输入的主要方式之一。
FormData
FormData API 提供了一个访问表单数据的数据模型。它使您无需查找单个 DOM 元素并获取其值。
更好的是,一旦您有了 FormData 对象,您可以直接将其传递给 Fetch API 来提交表单。在提交之前,您可以修改或添加 FormData 对象中的数据。
验证
为了防止用户发送无效数据,您可以(也应该)为您的表单添加客户端验证。这可能只是将字段标记为必填项,或者涉及协调多个表单值或调用 API 的更复杂验证逻辑。
在过去,开发人员通常需要借助 JavaScript 库来执行表单验证。这可能会因为数据重复而引起头痛;它存在于表单数据中以及验证库使用的内存对象中。
HTML5 添加了更多内置验证选项,例如:
-
将字段标记为必填项
-
在数字字段中指定最小和最大值
-
指定用于验证字段输入的正则表达式
这些选项用作 <input> 元素的属性。
浏览器显示基本的验证错误消息(参见图 7-1),但是其样式可能与您应用程序的设计不匹配。您可以使用约束验证 API 检查内置验证结果,还可以执行自定义验证逻辑并设置自己的验证消息。

图 7-1. Chrome 中的内置验证消息
要验证表单,您可以调用其 checkValidity 方法。表单内的所有字段都将被验证。如果所有字段都有效,则 checkValidity 返回 true。如果一个或多个字段无效,则 checkValidity 返回 false,并且每个无效字段都会触发一个 invalid 事件。您也可以通过在表单字段本身上调用 checkValidity 来检查特定元素。
每个表单字段都有一个 validity 对象,反映了当前的有效性状态。它有一个布尔值 valid,指示表单的整体有效性状态。该对象还有其他标志,告诉您验证错误的性质。
从本地存储填充表单字段
问题
您希望在本地存储中记住表单字段的值。例如,您可能希望记住登录表单中输入的用户名。
解决方案
在提交表单时,使用 FormData 对象获取字段值并将其设置在本地存储中(参见示例 7-1)。然后,在首次加载页面时,检查是否存在记住的值。如果找到值,则填充表单字段。
示例 7-1. 记住 username 字段
const form = document.querySelector('#login-form');
const username = localStorage.getItem('username');
if (username) {
form.elements.username.value = username;
}
form.addEventListener('submit', event => {
const data = new FormData(form);
localStorage.setItem('username', data.get('username'));
});
讨论
当你将表单传递给 FormData 构造函数时,它会填充表单的当前值。然后,你可以使用 get 方法检索所需的字段,并将其设置在本地存储中。
在加载时填充表单有些不同。FormData 对象不会与当前表单值同步保持;相反,它包含创建 FormData 对象时的表单值。反之亦然——如果你在 FormData 对象中设置了新值,它不会更新到表单本身。鉴于此,FormData 对象在填充表单时并不会有所帮助。Example 7-1 使用表单的 elements 属性查找 username 字段并设置其值。
使用 Fetch 和 FormData API 提交表单
问题
你希望使用 Fetch API 提交表单。可能是为了向表单提交添加额外信息,这些信息不会被浏览器包含,或者因为表单提交可能需要在内存中存储的 API 令牌而不是在表单中输入。
另一个你可能想这样做的原因是防止浏览器重定向到新页面,或导致完全页面刷新。
解决方案
创建一个包含要提交数据的 FormData 对象。添加额外所需的数据,然后使用 Fetch API 提交表单(参见 Example 7-2)。
示例 7-2. 使用 FormData API 添加数据
// In a real-world application, the API token would be stored somewhere and
// not hardcoded like this.
const apiToken = 'aBcD1234EfGh5678IjKlM';
form.addEventListener('submit', event => {
// Important: Stop the browser from automatically submitting the form.
event.preventDefault();
// Set up a FormData object and add the API token to it.
const data = new FormData(event.target);
data.set('apiToken', apiToken);
// Use the Fetch API to send this FormData object to the endpoint.
fetch('/api/form', {
method: 'POST',
body: data
});
});
讨论
通常,当你点击提交按钮时,浏览器会获取表单数据并自动提交。但在这里你不想这样做,因为你需要添加 API 令牌。
提交处理程序首先在submit事件上调用preventDefault。这样可以阻止浏览器执行默认的提交行为,从而可以提供自定义逻辑。这里的默认行为是完全的页面刷新,这可能不是你想要的。
你可以通过将表单对象传递给 FormData 构造函数来创建一个 FormData 对象。生成的对象将包含其中的现有表单数据,此时你可以添加额外的数据如 API 令牌。
最后,你可以使用 Fetch API 将 FormData 对象作为 POST 请求的主体。以这种方式提交表单时,主体不是 JSON,而是使用 multipart/form-data 内容类型提交到浏览器。
考虑一个表示你的表单数据的对象:
{
username: 'john.doe',
apiToken: 'aBcD1234EfGh5678IjKlM'
}
等效的请求体看起来像这样:
------WebKitFormBoundaryl6AuUOn9EbuYe9XO
Content-Disposition: form-data; name="username"
john.doe
------WebKitFormBoundaryl6AuUOn9EbuYe9XO
Content-Disposition: form-data; name="apiToken"
aBcD1234EfGh5678IjKlM
------WebKitFormBoundaryl6AuUOn9EbuYe9XO--
提交表单作为 JSON
问题
你希望将表单提交到一个期望 JSON 数据的终端点。
解决方案
使用 FormData API 将表单数据转换为 JavaScript 对象,并使用 Fetch API 将其作为 JSON 发送(参见 Example 7-3)。
示例 7-3. 使用 Fetch 将表单作为 JSON 提交
form.addEventListener('submit', event => {
// Important: Stop the browser from automatically submitting the form.
event.preventDefault();
// Create a new FormData containing this form's data, then add each
// key/value pair to the response body.
const data = new FormData(event.target);
const body = {};
for (const [key, value] of data.entries()) {
body[key] = value;
}
// Send the JSON body to the form endpoint.
fetch('/api/form', {
method: 'POST',
// The object must be converted to a JSON string.
body: JSON.stringify(body),
// Tell the server you're sending JSON.
headers: {
'content-type': 'application/json'
}
})
.then(response => response.json())
.then(body => console.log('Got response:', body));
});
讨论
这种方法类似于直接发送 FormData 对象。唯一的区别是你正在将表单数据转换为 JSON 并使用正确的 Content-Type 标头进行发送。
你可以通过创建一个新的空对象并迭代FormData中的键/值对来执行转换。每对键/值都会被复制到对象中。
这种方法的一个缺点是,你无法将其用于FormData,其中有多个值绑定到相同的键。当你有一个具有相同名称的复选框组时,就会出现这种情况;有多个具有相同键的条目。
你可以增强转换以检测这种情况,并设置一个值数组,就像示例 7-4 中所示。
示例 7-4. 处理数组形式的数值
/**
* Converts a form's data into an object that can be sent as JSON.
* @param form The form element
* @returns An object containing all the mapped keys and values
*/
function toObject(form) {
const data = new FormData(form);
const body = {};
for (const key of data.keys()) {
// Returns an array of all values bound to a given key
const values = data.getAll(key);
// If there's only one element in the array, set that element directly.
if (values.length === 1) {
body[key] = values[0];
} else {
// Otherwise, set the array
body[key] = values;
}
}
return body;
}
示例 7-4 使用FormData的getAll函数,该函数返回一个包含绑定到给定键的所有值的数组。这样你就可以将给定复选框组的所有值收集到一个数组中。
getAll始终返回一个数组。如果只有一个值,则它是一个只有一个元素的数组。toObject检查这种情况,如果数组只有一个元素,则将该元素用作结果对象中的单个值。否则,它使用值数组。
使表单字段为必填项
问题
你想要要求表单字段具有值,如果留空则引发验证错误。
解决方案
在<input>元素上使用required属性(参见示例 7-5)。
示例 7-5. 必填字段
<label for="username">Username</label>
<input type="text" name="username" id="username" required> 
required属性没有值。
讨论
当字段标记为required时,它必须具有值。如果字段为空,则其validity.valid属性为false,其validity.valueMissing属性为true。
只有当值为空字符串时,必填字段才被视为空。它不会修剪空格,因此由几个空格组成的值被视为有效。
限制数字输入
问题
你想要为数字输入指定一系列允许的值(<input type="number">)。
解决方案
使用min和max属性来指定允许的范围(参见示例 7-6)。这些值是包容的,意味着最小值和最大值本身也是允许的。
示例 7-6. 为数字字段指定范围
<label for="quantity">Quantity</label>
<input type="number" name="quantity" id="quantity" min="1" max="10">
讨论
如果数字输入的值低于最小值或高于最大值,则其validity.valid属性为false。如果低于最小值,则设置rangeUnderflow有效性标志。同样,如果超过最大值,则设置rangeOverflow标志。
当你将input的类型设置为number时,浏览器会添加一个微调控件——可点击的上下箭头,用于增加和减少值。这个微调控件强制执行最小和最大值——如果值已经达到最小值,则拒绝减少值;如果值已经达到最大值,则拒绝增加值。然而,用户仍然可以在字段中输入任何值。他们可以输入超出允许范围的数字,此时验证状态会相应地设置。
如果您希望对允许的值进行更精细的控制,还可以指定step值。这将限制允许的值,使增量必须是步长的倍数。考虑一个最小值为 0、最大值为 4、步长为 2 的输入。该字段的唯一可接受值将是 0、2 和 4。
指定验证模式
问题
您希望限制文本字段的值,使其符合特定的模式。
解决方案
使用input的pattern属性来指定正则表达式(参见示例 7-7)。除非其值与正则表达式匹配,否则该字段被视为无效。
示例 7-7. 限制字段仅包含字母数字字符
<label for="username">Enter a username</label>
<input type="text" pattern="[A-Za-z0-9]+" id="username" name="username">
username字段如果包含除了字母数字字符之外的任何内容则无效。当无效时,有效状态的patternMismatch标志被设置。
讨论
这是一种灵活的验证选项,仅次于使用自定义验证逻辑(参见“使用自定义验证逻辑”)。
提示
创建正则表达式以验证 URL 或电子邮件地址可能有些棘手。为处理这些情况,您可以将输入的type属性设置为url或email,浏览器将为您验证字段是否为有效的 URL 或电子邮件地址。
验证表单
问题
您希望管理表单验证过程并在 UI 中显示自己的错误消息。
解决方案
使用约束验证 API 和invalid事件来检测和标记无效字段。
有很多方法可以处理验证。一些网站过于急于显示错误消息,甚至在用户有机会输入值之前就显示错误消息。考虑一个类型为email的输入,除非输入有效的电子邮件地址,否则被视为无效。如果立即进行验证,用户在完成输入之前就会看到关于无效电子邮件地址的错误。
为了避免这种情况,这里展示的验证方法仅在两种情况下验证字段:
-
当表单提交时。
-
如果字段已被聚焦然后失去焦点。这些字段被视为已被“触摸”。
首先,您需要通过向表单添加novalidate属性来禁用浏览器的内置验证 UI,如示例 7-8 所示。
示例 7-8. 禁用浏览器验证 UI
<form id="my-form" novalidate>
<!-- Form elements go here -->
</form>
每个字段都需要一个占位符元素来包含错误消息,如示例 7-9 所示。
示例 7-9. 添加错误消息占位符
<div>
<label for="email">Email</label>
<input required type="email" id="email" name="email">
<div class="error-message" id="email-error"></div>
</div>
在这个例子中,通过 ID 将错误消息与输入字段关联起来。具有 ID 为email的字段具有 ID 为email-error的错误消息,name字段具有name-error的错误消息,依此类推。
使用这种验证方法,每个表单元素都监听三个事件:
invalid
当表单验证并且字段被标记为无效时触发。这会设置错误消息。
input
当字段值更改时触发。如果需要,执行重新验证,并在字段变为有效时清除错误消息。
blur
当字段失去焦点时触发。这会设置一个data-should-validate属性,标记字段为已触摸状态,随后在input事件处理程序中验证。
验证代码显示在示例 7-10 中。
示例 7-10. 设置表单字段的验证
/**
* Adds necessary event listeners to an element to participate in form validation.
* It handles setting and clearing error messages depending on the validation state.
* @param element The input element to validate
*/
function addValidation(element) {
const errorElement = document.getElementById(`${element.id}-error`);
/**
* Fired when the form is validated and the field is not valid.
* Sets the error message and style, and also sets the shouldValidate flag.
*/
element.addEventListener('invalid', () => {
errorElement.textContent = element.validationMessage;
element.dataset.shouldValidate = true;
});
/**
* Fired when user input occurs in the field. If the shouldValidate flag is set,
* it will recheck the field's validity and clear the error message if it
* becomes valid.
*/
element.addEventListener('input', () => {
if (element.dataset.shouldValidate) {
if (element.checkValidity()) {
errorElement.textContent = '';
}
}
});
/**
* Fired when the field loses focus, applying the shouldValidate flag.
*/
element.addEventListener('blur', () => {
// This field has been touched; it will now be validated on subsequent
// 'input' events.
// This sets the input's data-should-validate attribute to true in the DOM.
element.dataset.shouldValidate = true;
});
}
注意
本示例监听input事件。如果您的表单包含复选框或单选按钮,则可能需要根据浏览器,改为监听这些元素的change事件。请参阅来自 MDN 的关于input事件的文章:
对于带有
type=checkbox或type=radio的<input>元素,根据 HTML Living Standard 规范,input事件应在用户切换控件时触发。然而,从历史上看,这并非总是如此。检查兼容性,或者对于这些类型的元素,改为使用change事件。
要完成基本的验证框架,请为表单字段添加监听器,监听表单的submit事件,并触发验证(参见示例 7-11)。
示例 7-11. 触发表单验证
// Assuming the form has two inputs, 'name' and 'email'
addValidation(form.elements.name);
addValidation(form.elements.email);
form.addEventListener('submit', event => {
event.preventDefault();
if (form.checkValidity()) {
// Validation passed, submit the form
}
});
讨论
此代码设置了一个良好的基本验证框架,处理了浏览器的内置验证。在提交表单之前,它调用checkValidity,开始检查表单内的所有输入。对于任何未通过验证的输入,浏览器会触发invalid事件。为处理此事件,您可以在输入元素本身上监听invalid事件。然后,您可以呈现适当的错误消息。
用户一旦出现验证错误,您希望在字段变为有效时立即清除这些错误。这就是为什么addValidation监听input事件的原因—这在用户在输入框中键入内容时立即触发。从那时起,您可以立即重新检查输入的有效性。如果现在有效(checkValidity返回true),则可以清除错误消息。只有当data-should-validate属性设置为true时,输入才会重新验证。此属性在表单提交期间验证失败时添加,或者在元素失去焦点时。这可以防止用户完成输入之前出现验证错误。一旦字段失去焦点,它会在每次更改时重新验证。
使用自定义验证逻辑
问题
您希望执行一项不受约束验证 API 支持的验证检查。例如,您希望验证密码和密码确认字段具有相同的值。
解决方案
在调用表单的checkValidity方法之前执行自定义验证逻辑。如果自定义验证未通过,请调用输入框的setCustomValidity方法设置适当的错误消息。如果验证通过,请清除先前设置的任何验证消息(参见示例 7-12)。
Example 7-12. 使用自定义验证
/**
* Custom validation function that ensures the password and confirmPassword fields
* have the same value.
* @param form The form containing the two fields
*/
function validatePasswordsMatch(form) {
const { password, confirmPassword } = form.elements;
if (password.value !== confirmPassword.value) {
confirmPassword.setCustomValidity('Passwords do not match.');
} else {
confirmPassword.setCustomValidity('');
}
}
form.addEventListener('submit', event => {
event.preventDefault();
validatePasswordsMatch(form);
if (form.checkValidity()) {
// Validation passed, submit the form.
}
});
注意
如果你正在使用浏览器内置的验证 UI,需要在设置自定义有效性消息后调用表单字段的reportValidity方法。如果你自己处理验证 UI,则不需要这样做,但确保在适当的位置显示错误消息。
讨论
当你在具有非空字符串的元素上调用setCustomValidity时,该元素现在被认为是无效的。
validatePasswordsMatch函数检查password和confirmPassword字段的值。如果它们不匹配,则在confirmPassword字段上调用setCustomValidity设置验证错误消息。如果它们匹配,则将其设置为空字符串,这将重新标记字段为有效。
表单的提交处理程序在执行内置验证之前调用validatePasswordsMatch。如果validatePasswordsMatch检查失败,并设置了自定义有效性,则form.checkValidity会失败,并且在confirmPassword字段上会触发invalid事件,就像其他无效元素一样。
验证复选框组
问题
你希望强制要求在复选框组中至少选择一个复选框。在复选框上设置required属性在这里无效,因为它仅适用于单个输入,而不是整个组。浏览器检查是否选中了该输入,并导致验证错误,即使组中的其他复选框已被选中。
解决方案
使用FormData对象获取所有选中复选框的数组,并在数组为空时设置自定义验证错误。
在执行自定义验证时,使用FormData的getAll方法获取选中复选框值的数组(参见 Example 7-13)。如果数组为空,则表示没有选择复选框,这是一个验证错误。
Example 7-13. 验证复选框组
function validateCheckboxes(form) {
const data = new FormData(form);
// To avoid setting the validation error on multiple elements,
// choose the first checkbox and use that to hold the group's validation
// message.
const element = form.elements.option1;
if (!data.has('options')) {
element.setCustomValidity('Please select at least one option.');
} else {
element.setCustomValidity('');
}
}
为了将整个组的验证状态保持在一个地方,仅在第一个复选框上(假设名称为option1)设置自定义有效性消息。这个第一个复选框作为组的验证消息的容器是必要的,因为你只能在实际的<input>元素上设置有效性消息。
然后,监听invalid和change事件。在invalid事件上显示错误消息。在change事件(切换复选框时)上执行自定义验证,并在验证成功时清除错误消息(参见 Example 7-14)。
Example 7-14. 设置复选框验证
/**
* Adds necessary event listeners to an element to participate in form validation.
* It handles setting and clearing error messages depending on the validation state.
* @param element The input element to validate
* @param errorId The ID of a placeholder element that will show the error message
*/
function addValidation(element, errorId) {
const errorElement = document.getElementById(errorId);
/**
* Fired when the form is validated and the field is not valid.
* Sets the error message and style.
*/
element.addEventListener('invalid', () => {
errorElement.textContent = element.validationMessage;
});
/**
* Fired when user input occurs in the field.
* It will recheck the field's validity and clear the error message if it
* becomes valid.
*/
element.addEventListener('change', () => {
validateCheckboxes(form);
if (form.elements.option1.checkValidity()) {
errorElement.textContent = '';
}
});
}
最后,在检查表单有效性之前,为每个复选框字段添加验证,并在检查表单的有效性之前调用validateCheckboxes函数。Example 7-15 预期你有一个带有 ID checkbox-error 的元素。如果有复选框验证错误,则会在该元素上设置消息。
Example 7-15. 验证复选框表单
addValidation(form.elements.option1, 'checkbox-error');
addValidation(form.elements.option2, 'checkbox-error');
addValidation(form.elements.option3, 'checkbox-error');
form.addEventListener('submit', event => {
event.preventDefault();
validateCheckboxes(form);
console.log(form.checkValidity());
});
讨论
在组中复选框上使用required属性不会产生预期效果。这对于单个复选框非常有效,比如要求用户接受许可协议的复选框,但是在组中使用时,会使每个单独的复选框都变为必填,除非所有复选框都被选中。由于没有用于“复选框组”的 HTML 元素,您需要做一些额外的工作来获得所需的行为。
此示例选择组中的第一个复选框作为验证消息的“容器”。当用户切换任何复选框时,浏览器调用更改处理程序并查看是否选中任何复选框。如果选择数组为空,则表示存在错误。自定义有效性消息始终仅在第一个复选框上设置。这是为了确保消息始终在需要时显示和隐藏。
让我们看看如果您改为应用自定义有效性到更改复选框会发生什么。
如果没有选中任何选项并且用户提交表单,则现在每个复选框都有一个自定义有效性错误消息。现在,如果您选中其中一个复选框,复选框的change事件将触发并检查复选框组。现在已选择一个选项,因此清除自定义有效性消息。但是,其他复选框仍处于错误状态。这基本上等同于在所有复选框上设置required属性。
您可以通过在validateCheckboxes函数中为所有复选框设置验证消息来解决此问题,但选择一个并将其用作所有自定义验证消息的目标要少做些工作。整个组有一个单独的错误消息元素,用于显示验证错误。
注意
由于此示例管理其自己的验证消息,请确保在包含的表单上包含novalidate属性,以避免显示浏览器的默认验证 UI 和您的自定义验证错误。
异步验证字段
问题
您的自定义验证逻辑需要像进行网络请求这样的异步操作。例如,用户注册表单具有密码字段。注册表单必须调用 API 来验证输入的密码是否符合密码强度标准。
解决方案
执行网络请求,然后设置自定义有效性消息。在返回Promise的函数中执行此操作。在表单的提交处理程序中,在调用表单上的checkValidity之前等待此Promise。如果异步验证代码设置了自定义有效性消息,则由checkValidity触发的表单验证会处理它。
示例 7-16 包含验证逻辑本身。它调用密码强度检查 API 并相应地设置自定义有效性消息。
示例 7-16. 执行异步密码强度验证
/**
* Calls an API to validate that a password meets strength requirements.
* @param form The form containing the password field
*/
async function validatePasswordStrength(form) {
const { password } = form.elements;
const response = await fetch(`/api/password-strength?password=${password.value}`);
const result = await response.json();
// As before, remember to call reportValidity on the password field if you're using
// the built-in browser validation UI.
if (result.status === 'error') {
password.setCustomValidity(result.error);
} else {
password.setCustomValidity('');
}
}
警告
请确保只通过安全连接(HTTPS)发送密码。否则,您会将用户的密码以明文形式发送出去,这是一种危险的做法。
因为该函数标记为async,它返回一个Promise。您只需在表单的提交处理程序中await这个Promise,就像示例 7-17 中所示。
示例 7-17. async表单提交处理程序
form.addEventListener('submit', async event => {
event.preventDefault();
await validatePasswordStrength(form);
console.log(form.checkValidity());
});
如果密码不符合要求,在提交时会将该字段标记为无效。当字段发生更改时,可以重新运行验证逻辑,但这次是在blur事件而不是input事件上,就像您在同步自定义验证中所做的那样(参见示例 7-18)。
示例 7-18. 在blur上重新验证
form.elements.password.addEventListener('blur', async event => {
const password = event.target;
const errorElement = document.getElementById('password-error');
if (password.dataset.shouldValidate) {
await validatePasswordStrength(form);
if (password.checkValidity()) {
errorElement.textContent = '';
password.classList.remove('border-danger');
}
}
});
讨论
如果您在input事件上进行此检查,每次用户按键时都会发送一个网络请求。blur事件将重新验证推迟到字段失去焦点时。它再次调用验证 API 并检查新的有效性状态。
提示
您也可以使用验证函数的去抖动版本。这将在输入事件上重新验证,但只有当用户停止键入一段时间后才会进行验证。
这篇来自 freeCodeCamp 的文章详细介绍了如何创建一个去抖动函数。还有一些 npm 包可以创建一个函数的去抖动版本。
第八章:Web 动画 API
简介
现代 Web 浏览器中有几种不同的元素动画方式。第一章 中有一个使用 requestAnimationFrame API 手动为元素创建动画的示例(参见 “使用 requestAnimationFrame 进行元素动画”)。这样做可以提供很多控制,但代价高昂。它需要跟踪时间戳以计算帧率,并且必须在 JavaScript 中计算每个增量动画变化。
基于关键帧的动画
CSS3 引入了关键帧动画。您可以在 CSS 规则中指定起始样式、结束样式和持续时间。浏览器会自动插值或填充动画的中间帧。使用 @keyframes 规则定义动画,并通过 animation 属性使用。示例 8-1 定义了一个淡入动画。
示例 8-1。使用 CSS 关键帧动画
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.some-element {
animation: fade 250ms;
}
渐变淡入动画从不透明度 0 开始,到不透明度 1 结束。当动画运行时,浏览器在 250 毫秒内计算中间样式帧。动画在元素进入 DOM 或应用 some-element 类时开始。
使用 JavaScript 进行关键帧动画
Web 动画 API 允许您在 JavaScript 代码中使用关键帧动画。Element 接口具有 animate 方法,您可以在其中定义动画的关键帧和其他选项。示例 8-2 展示了使用 Web 动画 API 从 示例 8-1 转换的相同动画。
示例 8-2。使用 Web 动画 API 渐变淡入
const element = document.querySelector('.some-element');
element.animate([
{ opacity: 0 },
{ opacity: 1 }
], {
// Animate for 250 milliseconds
duration: 250
});
结果是相同的。元素在 250 毫秒内淡入。在这种情况下,动画由 element.animate 调用触发。
动画对象
当您调用 element.animate 时,将返回一个 Animation 对象。这允许您暂停、恢复、取消或反转动画。它还提供了一个 Promise,您可以使用它来等待动画完成。
要注意动画的属性。某些属性,如 height 或 padding,会影响页面的布局;对它们进行动画处理可能会导致性能问题,并且动画通常不够流畅。最佳的动画属性是 opacity 和 transform,因为它们不会影响页面布局,甚至可以由系统的 GPU 加速。
点击时应用“波纹”效果
问题
当点击按钮时,您希望在按钮内从用户点击的位置开始显示“波纹”动画。
解决方案
当点击按钮时,为“波纹”创建一个临时子元素。这个元素将被动画化。
首先,为波纹元素创建一些样式。按钮还需要应用一些样式(参见 示例 8-3)。
示例 8-3。按钮和波纹元素的样式
.ripple-button {
position: relative;
overflow: hidden;
}
.ripple {
background: white;
pointer-events: none;
transform-origin: center;
opacity: 0;
position: absolute;
border-radius: 50%;
width: 150px;
height: 150px;
}
在按钮的点击处理程序中,动态创建一个新的涟漪元素并将其添加到按钮,然后更新其位置并执行动画(参见 Example 8-4)。
示例 8-4. 执行涟漪动画
button.addEventListener('click', async event => {
// Create the temporary element for the ripple, set its class, and
// add it to the button.
const ripple = document.createElement('div');
ripple.className = 'ripple';
// Find the largest dimension (width or height) of the button and
// use that as the ripple's size.
const rippleSize = Math.max(button.offsetWidth, button.offsetHeight);
ripple.style.width = `${rippleSize}px`;
ripple.style.height = `${rippleSize}px`;
// Center the ripple element on the click location.
ripple.style.top = `${event.offsetY - (rippleSize / 2)}px`;
ripple.style.left = `${event.offsetX - (rippleSize / 2)}px`;
button.appendChild(ripple);
// Perform the ripple animation and wait for it to complete.
await ripple.animate([
{ transform: 'scale(0)', opacity: 0.5 },
{ transform: 'scale(2.5)', opacity: 0 }
], {
// Animate for 500 milliseconds.
duration: 500,
// Use the ease-in easing function.
easing: 'ease-in'
}).finished;
// All done, remove the ripple element.
ripple.remove();
});
讨论
涟漪元素是一个圆形,大小相对于按钮的大小。你通过动画其不透明度和比例变换来实现涟漪效果。
关于元素样式,这里有几个要注意的地方。首先,按钮本身的 position 设置为 relative。这样当设置涟漪的 absolute 位置时,它相对于按钮本身定位。
按钮还设置了 overflow: hidden。这会防止涟漪效果在按钮外部可见。
你可能还会注意到涟漪设置了 pointer-events: none。因为涟漪位于按钮内部,所以浏览器将任何点击事件委托给按钮。这意味着点击涟漪会触发新的涟漪,但位置不正确,因为它是基于涟漪内的点击位置而不是按钮内的点击位置。
解决这个问题最简单的方法是设置 pointer-events: none,这使得涟漪元素忽略点击事件。如果在涟漪动画正在进行时点击涟漪,点击事件会传递到按钮,这正是你希望的,以便下一个涟漪能正确定位。
接下来,涟漪代码设置了顶部和左侧位置,以便涟漪的中心位于你刚刚点击的地方。
然后涟漪被动画化。ripple.animate 返回的动画具有 finished 属性,这是一个 Promise,你可以等待它。一旦这个 Promise 解析完成,涟漪动画就完成了,你可以从 DOM 中移除元素。
如果在涟漪进行中点击按钮,将会启动另一个涟漪,并且它们将一起动画化 —— 第一个动画不会被打断。这对于常规的 CSS 动画来说更难实现。
启动和停止动画
问题
你希望能够以编程方式启动或停止动画。
解决方案
使用动画的 pause 和 play 函数(参见 Example 8-5)。
示例 8-5. 切换动画的播放状态
/**
* Given an animation, toggles the animation state.
* If the animation is running, it will be paused.
* If it is paused, it will be resumed.
*/
function toggleAnimation(animation) {
if (animation.playState === 'running') {
animation.pause();
} else {
animation.play();
}
}
讨论
从 element.animate 调用返回的 Animation 对象具有 playState 属性,你可以用它来确定动画当前是否正在运行。如果正在运行,它的值是字符串 running。其他值包括:
paused
动画正在运行,但在完成之前停止了。
finished
动画完成并停止了。
根据 playState 属性,toggleAnimation 函数调用 pause 或 play 来设置所需的动画状态。
动画 DOM 插入和移除
问题
你希望通过动画效果向 DOM 添加或删除元素。
解决方案
每个操作的解决方案略有不同。
对于添加一个元素,首先将元素添加到 DOM 中,然后立即运行动画(例如淡入效果)。因为只有在 DOM 中的元素才能被动画化,所以您需要在运行动画之前添加它(参见示例 8-6)。
示例 8-6. 使用动画显示元素
/**
* Shows an element that was just added to the DOM with a fade-in animation.
* @param element The element to show
*/
function showElement(element) {
document.body.appendChild(element);
element.animate([
{ opacity: 0 },
{ opacity: 1 }
], {
// Animate for 250 milliseconds.
duration: 250
});
}
要移除一个元素,您需要先运行动画(例如淡出)。一旦动画完成,立即从 DOM 中移除元素(参见示例 8-7)。
示例 8-7. 使用动画移除元素
/**
* Removes an element from the DOM after performing a fade-out animation.
* @param element The element to remove
*/
async function removeElement(element) {
// First, perform the animation and make the element disappear from view.
// The resulting animation's 'finished' property is a Promise.
await element.animate([
{ opacity: 1 },
{ opacity: 0 }
], {
// Animate for 250 milliseconds.
duration: 250
}).finished;
// Animation is done, now remove the element from the DOM.
element.remove();
}
讨论
当您在添加元素的同时运行动画时,它会从零不透明度开始动画,然后再开始渲染。这会产生您想要的效果——一个隐藏的元素淡入视图。
当您移除元素时,可以使用动画的finished Promise等待动画完成。在动画完全完成之前,不要将元素从 DOM 中移除,否则效果可能只运行部分并且元素会消失。
反向动画
问题
您想要取消正在进行的动画,例如悬停效果,并平稳地恢复到初始状态。
解决方案
使用Animation对象的reverse方法以反向播放。
您可以通过变量跟踪正在进行的动画。当您更改所需的动画状态,并且此变量具有值时,意味着另一个动画已在进行中,浏览器应该将其反转。
在悬停效果的示例中(参见示例 8-8),您可以在鼠标悬停在元素上时启动动画。
示例 8-8. 悬停效果
element.addEventListener('mouseover', async () => {
if (animation) {
// There was already an animation in progress. Instead of starting a new
// animation, reverse the current one.
animation.reverse();
} else {
// Nothing is in progress, so start a new animation.
animation = element.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(2)' }
], {
// Animate for 1 second.
duration: 1000,
// Apply the initial and end styles.
fill: 'both'
});
// Once the animation finishes, set the current animation to null.
await animation.finished;
animation = null;
}
});
当鼠标移开时,同样的逻辑也适用(参见示例 8-9)。
示例 8-9. 移除悬停效果
button.addEventListener('mouseout', async () => {
if (animation) {
// There was already an animation in progress. Instead of starting a new
// animation, reverse the current one.
animation.reverse();
} else {
// Nothing is in progress, so start a new animation.
animation = button.animate([
{ transform: 'scale(2)' },
{ transform: 'scale(1)' }
], {
// Animate for 1 second.
duration: 1000,
// Apply the initial and end styles.
fill: 'both'
});
// Once the animation finishes, set the current animation to null.
await animation.finished;
animation = null;
}
});
讨论
由于每种情况下关键帧相同(它们仅按其顺序不同),因此您可以拥有一个单一的动画函数来设置动画的direction属性。当鼠标悬停在元素上时,您希望以forward或正常方向运行元素。当鼠标离开时,您将运行相同的动画,但方向设置为reverse(参见示例 8-10)。
示例 8-10. 单一动画函数
async function animate(element, direction) {
if (animation) {
animation.reverse();
} else {
animation = element.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(2)' }
], {
// Animate for 1 second.
duration: 1000,
// Apply the end style after the animation is done.
fill: 'forward',
// Run the animation forward (normal) or backward (reverse)
// depending on the direction argument.
direction
});
// Once the animation finishes, set the variable to
// null to signal that there is no animation in progress.
await animation.finished;
animation = null;
}
}
element.addEventListener('mouseover', () => {
animate(element, 'normal');
});
element.addEventListener('mouseout', () => {
animate(element, 'reverse');
});
结果与以前相同。当您悬停在元素上时,由于scale(2)变换,它开始增大。如果您移开鼠标,则通过反转动画的方向开始缩小。
区别在于事件处理程序。它们都调用一个单一函数,使用不同的值作为动画的direction选项。
示例 8-8 将动画的fill模式设置为both。动画的填充模式决定了动画前后元素的样式。默认情况下,填充模式为none。这意味着当动画完成时,元素的样式会跳回到动画之前的状态。
在实践中,这意味着当您悬停在元素上时,它开始增长直到达到最终大小,但由于未设置填充模式,它立即跳回到原始大小。
除了 none 外,填充模式还有三个选项:
backward
在动画开始之前,元素的样式被设置为动画的起始关键帧。通常仅在使用动画延迟时适用,因为它定义了元素在延迟期间的样式。
forward
动画完成后,结束关键帧样式仍然被应用。
both
应用了 backward 和 forward 的规则。
在 Example 8-10 中的动画没有延迟,因此使用 forward 选项保留动画结束后的样式。
显示滚动进度指示器
问题
您想要在页面顶部显示一个随滚动移动的条形条。随着向下滚动,该条形条向右移动。
解决方案
通过创建一个 ScrollTimeline 并将其传递给元素的 animate 方法来使用与滚动链接的动画。为了使元素从左向右增长,您可以将 transition 属性从 scaleX(0) 动画到 scaleX(1)。
注意
这个 API 可能尚未被所有浏览器支持。请查看CanIUse获取最新的兼容性数据。
首先为进度条元素设置一些样式,如 Example 8-11 所示。
示例 8-11. 滚动进度条样式
.scroll-progress {
height: 8px;
transform-origin: left;
position: sticky;
top: 0;
transform: scaleX(0);
background: blue;
}
position: sticky 属性确保元素在向下滚动页面时保持可见。此外,其初始样式设置为 scaleX(0),有效地将其隐藏。没有这个设置,条形条会在瞬间出现全宽然后消失。这确保了直到滚动时才会看到条形条。
接下来,创建一个 ScrollTimeline 对象,并将其作为动画的 timeline 选项传递,如 Example 8-12 所示。
示例 8-12. 创建滚动时间线
const progress = document.querySelector('.scroll-progress');
// Create a timeline that's linked to the document's
// scroll position.
const timeline = new ScrollTimeline({
source: document.documentElement
});
// Start the animation, passing the timeline you just created.
progress.animate(
[
{ transform: 'scaleX(0)' },
{ transform: 'scaleX(1)' }
],
{ timeline });
现在您有了一个与滚动链接的动画。
讨论
动画的 时间线 是一个实现 AnimationTimeline 接口的对象。默认情况下,动画使用文档的默认时间线,即 DocumentTimeline 对象。这是一个与时钟上经过时间相关联的时间线。当您使用默认时间线启动动画时,它从初始关键帧开始向前运行,直到达到结束(或手动停止)。因为这种类型的时间线与经过的时间相关联,它具有定义的起始值,并且随着时间的推移不断增加。
然而,与滚动链接的 动画提供了一个与滚动位置相关联的时间线。当您滚动到顶部时,滚动位置为 0,动画保持在其初始状态。随着向下滚动,位置增加,动画前进。一旦您滚动到底部,动画达到结束。如果向上滚动,则动画反向运行。
ScrollTimeline被赋予一个源元素。在示例 8-12 中,源是文档元素(body标签)。您可以将任何可滚动元素作为源传递,并且ScrollTimeline使用该元素的滚动位置来确定当前进度。
在撰写本文时,DocumentTimeline在所有现代浏览器中都受支持,但ScrollTimeline则不受支持。在使用ScrollTimeline之前,请务必检查浏览器支持情况。
使元素弹跳
问题
您想要对一个元素应用短暂的弹跳效果。
解决方法
应用一系列动画,依次执行。使用动画的finished Promise来等待其完成,然后再运行下一个动画。
元素上下移动三次。每次通过translateY变换将元素向页面上移动,然后回到原始位置。第一次移动使元素反弹 40 像素,第二次移动使元素反弹 20 像素,第三次移动使元素反弹 10 像素。这样看起来像是重力每次减慢反弹速度。这可以通过for-of循环来实现(见示例 8-13)。
示例 8-13. 串行应用弹跳动画
async function animateBounce(element) {
const distances = [ '40px', '20px', '10px' ];
for (let distance of distances) {
// Wait for this animation to complete before continuing.
await element.animate([
// Start at the bottom.
{ transform: 'translateY(0)' },
// Move up by the current distance.
{ transform: `translateY(-${distance})`, offset: 0.5 },
// Back to the bottom
{ transform: 'translateY(0)' }
], {
// Animate for 250 milliseconds.
duration: 250,
// Use a more fluid easing function than linear
// (the default).
easing: 'ease-in-out'
}).finished;
}
}
讨论
本示例演示了 Web 动画 API 的一个优势:动态关键帧值。每次循环迭代都使用关键帧效果内部的不同distance值。
for-of循环遍历三个距离值(40px,20px 和 10px),依次对它们进行动画处理。在每次迭代中,它将元素向上移动给定的距离,然后再向下移动。关键在于最后一行,它引用了动画的finished属性。这确保了在当前动画完成之前不会开始下一个循环迭代。结果是动画依次串行运行,提供了弹跳效果。
您可能会想为什么本例中使用for-of循环而不是数组的forEach()方法。在诸如forEach之类的数组方法中使用await不会按预期工作。这些方法不是为异步使用而设计的。如果使用forEach调用,element.animate调用将会立即依次执行,结果只有最后一个动画会播放。使用for-of循环(普通的for循环也可以)与async/await一起按预期工作,并得到所需的结果。
同时运行多个动画
问题
您想要使用多个动画对一个元素应用多个变换。
解决方法
对元素多次调用animate,使用不同的变换关键帧。你还必须指定composite属性来组合这些变换,如示例 8-14 所示。
示例 8-14. 结合两个变换动画
// The first animation will move the element back and forth on the x-axis.
element.animate([
{ transform: 'translateX(0)' },
{ transform: 'translateX(250px)' }
], {
// Animate for 5 seconds.
duration: 5000,
// Run the animation forward, then run it in reverse.
direction: 'alternate',
// Repeat the animation forever.
iterations: Infinity,
// Slow to start, fast in the middle, slow at the end.
easing: 'ease-in-out'
});
// The second animation rotates the element.
element.animate([
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(360deg)' }
], {
// Animate for 3 seconds.
duration: 3000,
// Repeat the animation forever.
iterations: Infinity,
// Combine the effects with other running animations.
composite: 'add'
});
alternate方向意味着动画正向运行完成,然后反向运行完成。因为iterations设置为Infinity,所以动画会无限循环运行。
讨论
此效果的关键在于第二个动画中添加的composite属性。如果不指定composite: add,则仅会看到rotate变换,因为它会覆盖translateX变换。元素会旋转但不会水平移动。
实际上,这将两个变换组合成单个变换。但请注意,这些变换发生的速率不同。旋转持续三秒,而平移持续五秒。动画还使用不同的缓动函数。尽管有不同的选项,浏览器仍然平滑地组合这些动画。
显示加载动画
问题
在等待网络请求完成时,您希望向用户显示加载指示器。
解决方案
创建并设计加载指示器,然后对其应用无限旋转动画,直到由fetch返回的Promise解析。
为了产生流畅的效果,您可以首先应用一个淡入动画。一旦Promise解析完成,您可以将其淡出。
首先,创建一个加载器元素并定义一些样式,如示例 8-15 所示。
示例 8-15. 加载器元素
<style>
#loader {
width: 64px;
height: 64px;
/* Make a circle shape */
border-radius: 50%;
border-width: 10px;
border-style: solid;
border-color: skyblue blue skyblue blue;
/* Set the initial opacity so the animation that appears is smooth */
opacity: 0;
}
</style>
<div id="loader"></div>
加载器是一个具有交替边框颜色的环,如图 8-1 所示。

图 8-1. 风格化加载器
接下来,定义一个启动动画并等待Promise的函数,如示例 8-16 所示。
示例 8-16. 加载器动画
async function showLoader(promise) {
const loader = document.querySelector('#loader');
// Start the spin animation before fading in.
const spin = loader.animate([
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(360deg)' }
], { duration: 1000, iterations: Infinity });
// Since the opacity is 0, the loader isn't visible yet.
// Show it with a fade-in animation.
// The loader will continue spinning as it fades in.
loader.animate([
{ opacity: 0 },
{ opacity: 1 }
], { duration: 500, fill: 'both' });
// Wait for the Promise to resolve.
await promise;
// The Promise is done. Now fade the loader out.
// Don't stop the spin animation until the fade out is complete.
// You can wait by awaiting the 'finished' Promise.
await loader.animate([
{ opacity: 1 },
{ opacity: 0 }
], { duration: 500, fill: 'both' }).finished;
// Finally, stop the spin animation.
spin.cancel();
// Return the original Promise to allow chaining.
return promise;
}
现在可以将您的fetch调用作为参数传递给showLoader,如示例 8-17 所示。
示例 8-17. 使用加载器
showLoader(
fetch('https://example.com/api/users')
.then(response => response.json())
);
讨论
您不一定需要 Web 动画 API 来创建动画加载器——您可以使用纯 CSS 来实现。但正如这个示例所示,Web 动画 API 允许您结合多个动画。无限旋转动画继续运行,而淡入动画也在运行。这在常规 CSS 动画中有些棘手。
尊重用户的动画偏好
问题
如果用户已经配置操作系统减少动画,您希望减弱或禁用动画。
解决方案
使用window.matchMedia来检查prefers-reduced-motion媒体查询(参见示例 8-18)。
示例 8-18. 使用 prefers-reduced-motion 媒体查询
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// Reduced motion is not enabled, so animate normally.
} else {
// Skip this animation or run a less intense one.
}
讨论
这对可访问性非常重要。癫痫或前庭障碍的用户可能会因大型或快速移动的动画而引发癫痫、偏头痛或其他不良反应。
您不一定需要完全禁用动画;您可以选择使用更为微妙的动画效果。假设您正在显示一个具有很好视觉效果的弹跳效果元素,但对某些用户可能会造成迷惑。如果用户启用了减少动画选项,您可以改为提供简单的淡入动画。
第九章:Web Speech API
引言
在智能设备和助手的时代,您的语音已成为另一种常用的输入方式。无论您是在口述短信还是询问明天的天气预报,语音识别和合成正在成为应用开发中有用的工具。使用 Web Speech API,您可以让您的应用发出声音或监听用户的语音输入。
语音识别
Web Speech API 将语音识别带到浏览器中。一旦用户允许使用麦克风,它将监听语音。当它识别到一系列单词时,它将触发包含识别内容的事件。
注意
尽管不是所有浏览器都支持语音识别。查看CanIUse获取最新的兼容性数据。
在开始监听语音之前,您需要用户的许可。由于隐私设置,第一次尝试监听时,用户将被提示授予您的应用使用麦克风的权限(参见图 9-1)。

图 9-1。在 Chrome 中的麦克风权限请求
一些浏览器,如 Chrome,使用外部服务器分析捕获的音频以识别语音。这意味着在离线时语音识别将无法工作,并且可能引起隐私问题。
语音合成
Web Speech API 还提供语音合成。给定一些文本,它可以创建一个合成的语音来朗读文本。浏览器有一组内置的语音可以用来朗读您的内容。一旦选择了适合目标语言的语音,您可以自定义语音的音调和说话速度。
您可以结合语音识别和语音合成来创建对话式语音用户界面。它们可以监听问题或命令,并朗读输出或反馈。
浏览器支持
在撰写时,对 Web Speech API 的支持有些有限。
这个 API 的规范还添加了一些其他功能,一旦它们在浏览器中得到支持,将增强语音识别和合成的功能。
其中之一是自定义语法,它允许您通过指定要识别的单词和短语来微调语音识别。例如,如果您设计了一个带有语音命令的计算器,您的自定义语法将包括数字(“one”,“two” 等)和计算器操作(“plus”,“minus” 等)。使用自定义语法有助于引导语音识别引擎捕捉您的应用程序所需的单词。
SpeechSynthesis API 支持语音合成标记语言(SSML)。SSML 是一种定制语音合成的 XML 语言。您可以在男性和女性之间切换语音,或指定浏览器按字母读取内容。在撰写时,SSML 标记被解析和理解,但引擎不会朗读标记标签,但当前大多数浏览器会忽略大多数指令。
添加到文本字段的口述
问题
您希望识别口述文本并将其添加到文本字段的内容中,允许用户口述文本字段的内容。
解决方案
使用SpeechRecognition接口来监听语音。当语音被识别时,提取识别的文本并追加到文本字段中(参见示例 9-1)。
示例 9-1. 向文本字段添加基本口述
/**
* Starts listening for speech. When speech is recognized, it is appended
* to the given text field's value.
* Recognition continues until the returned recognition object is stopped.
*
* @param textField A text field to append to
* @returns The recognition object
*/
function startDictation(textField) {
// Only proceed if this browser supports speech recognition.
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
const SpeechRecognition = window.SpeechRecognition
|| window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.addEventListener('result', event => {
const result = event.results[event.resultIndex];
textField.value += result[0].transcript;
});
recognition.addEventListener('error', event => {
console.log('error', event);
});
recognition.start();
// Return the recognition object so recognition
// can be stopped later (like when the user clicks a toggle button).
return recognition;
}
}
讨论
目前,WebKit 浏览器支持它,SpeechRecognition构造函数前缀为webkitSpeechRecognition。在不支持的浏览器中,SpeechRecognition和webkitSpeechRecognition都未定义,所以在继续之前检查浏览器支持是很重要的。
为了使代码具有未来的兼容性,示例检查了前缀版本(webkitSpeechRecognition)以及标准的SpeechRecognition版本。这样,您就不必更改代码以适应将来实现 API 的浏览器。
接下来,startDictation函数创建一个SpeechRecognition对象,并将其continuous标志设置为true。默认情况下,一旦识别到结果,不会执行进一步的识别。设置continuous标志告诉语音识别引擎继续监听并提供额外的结果。
当识别引擎识别到一些语音时,会触发一个result事件。此事件有一个results属性,是一个类似数组的对象(实际上是一个SpeechRecognitionResultList对象),包含结果。
当以连续模式运行时,像这个示例一样,results列表包含识别引擎识别到的所有结果。用户第一次讲话并识别到一些语音时,这有一个单一的结果。当用户再次讲话并且浏览器识别到更多单词时,会有两个结果——原始结果和刚刚识别到的新结果。如果将continuous设置为false(默认),则引擎只识别一个短语,然后不再触发进一步的result事件。
有用的是,事件还有一个resultIndex属性,指向触发此事件的新结果列表中的索引。
结果对象是另一个类似数组的对象(SpeechRecognitionAlternative对象)。当创建SpeechRecognition对象时,可以给它设置maxAlternatives属性。浏览器会呈现一系列可能的匹配识别语音的选项,每个都带有置信度值。然而,默认的maxAlternatives值为 1,因此这段口述代码只有一个SpeechRecognitionAlternative对象在列表中。
最后,此对象有一个transcript属性,即引擎识别的实际短语。您可以获取此值并将其追加到文本字段的当前值中。
调用识别对象上的start方法开始侦听语音,当听到声音时触发事件。然后startDictation函数返回识别对象,以便您在用户完成口述后停止识别。
与任何 API 一样,处理任何可能发生的错误也很重要。在语音识别中,您可能面临的一些常见错误包括:
权限错误
如果用户未授权使用麦克风。事件的error属性为not-allowed。
网络错误
如果浏览器无法访问语音识别服务。此错误为network。
硬件错误
如果浏览器无法访问麦克风。此错误代码为audio-capture。
创建语音识别的 Promise 辅助工具
问题
您希望将语音识别封装成单个函数调用。
解决方案
在辅助函数内部的新Promise中包装语音识别调用。在辅助函数内部,创建一个新的SpeechRecognition对象并侦听语音。当浏览器识别到一些语音时,您可以解析Promise(参见示例 9-2)。
示例 9-2. 用于语音识别的Promise辅助工具
/**
* Listens for speech and performs speech recognition.
* Assumes that speech recognition is available in the current browser.
* @returns a Promise that is resolved with the recognized transcript when speech
* is recognized, and rejects on an error.
*/
function captureSpeech() {
const speechPromise = new Promise((resolve, reject) => {
const SpeechRecognition = window.SpeechRecognition ||
window.webkitSpeechRecognition;
// If this browser doesn't support speech recognition, reject the Promise.
if (!SpeechRecognition) {
reject('Speech recognition is not supported on this browser.')
}
const recognition = new SpeechRecognition();
// Resolve the promise on successful speech recognition.
recognition.addEventListener('result', event => {
const result = event.results[event.resultIndex];
resolve(result[0].transcript);
});
recognition.addEventListener('error', event => {
// Reject the promise if there was a recognition error.
reject(event);
});
// Start listening for speech.
recognition.start();
});
// Whether there was successful speech recognition or an error, make sure
// the recognition engine has stopped listening.
return speechPromise.finally(() => {
recognition.stop();
});
}
讨论
captureSpeech辅助工具不使用continuous模式。这意味着您只能使用它来监听单个语音识别事件。如果希望在返回的Promise解析后捕获额外的语音,请再次调用captureSpeech并等待新的Promise。
您可能注意到,示例 9-2 没有直接返回Promise。相反,它在该Promise上调用finally来停止语音识别引擎,无论结果如何。captureSpeech函数让您只需等待一个Promise就可以快速识别语音(参见示例 9-3)。
示例 9-3. 使用captureSpeech辅助工具
const spokenText = await captureSpeech();
获取可用语音
问题
您想确定当前浏览器中可用的语音合成语音。
解决方案
通过调用speechSynthesis.getVoices查询语音列表,然后根据需要侦听voiceschanged事件,如示例 9-4 所示。
示例 9-4. 获取可用语音合成语音列表
function showVoices() {
speechSynthesis.getVoices().forEach(voice => {
console.log('Voice:', voice.name);
});
}
// Some browsers load the voice list asynchronously. In these browsers,
// the voices are available when the voiceschanged event is triggered.
speechSynthesis.addEventListener('voiceschanged', () => showVoices());
// Show the voices immediately in those browsers that support it.
showVoices();
讨论
某些浏览器(如 Chrome)异步加载语音列表。如果在列表准备好之前调用getVoices,您将得到一个空数组。speech Synthesis对象在列表准备好时会触发voiceschanged事件。
其他浏览器,包括 Firefox,立即提供语音列表。在这些浏览器中,voiceschanged事件从未触发。示例 9-4 中的代码处理了这两种情况。
注意
每个语音都有一个lang属性,指定语音的语言。在朗读文本时,语音使用其语言的发音规则。确保使用与合成文本语言相匹配的语音。否则,发音将不正确。
合成语音
问题
您希望您的应用向用户朗读一些文本。
解决方案
创建一个SpeechSynthesisUtterance并将其传递给speechSynthesis.speak方法(参见示例 9-5)。
示例 9-5. 使用 Web Speech API 朗读一些文本
function speakText(text) {
const utterance = new SpeechSynthesisUtterance(text);
speechSynthesis.speak(utterance);
}
讨论
话语是您希望浏览器朗读的一组单词。它使用SpeechSynthesisUtterance对象创建。
注意
浏览器只会在用户与页面进行交互后才允许语音合成。这是为了防止页面加载时立即开始朗读。因此,speakText辅助函数在页面上有用户活动之前不会发出任何声音。
使用默认语音朗读文本。如果要使用不同的支持系统语音,可以使用“获取可用语音”中的技术获取可用语音数组。您可以将话语的voice属性设置为该数组中的一个语音对象,如示例 9-6 所示。
示例 9-6. 使用另一个语音
// Assuming the voices are available now
const aliceVoice = speechSynthesis
.getVoices()
.find(voice => voice.name === 'Alice');
function speakText(text) {
const utterance = new SpeechSynthesisUtterance(text);
// Make sure the "Alice" voice was found.
if (aliceVoice) {
utterance.voice = aliceVoice;
}
speechSynthesis.speak(utterance);
}
自定义语音合成参数
问题
您希望加快、减慢或调整朗读文本的音调。
解决方案
创建SpeechSynthesisUtterance时,请使用rate和pitch属性来自定义朗读语音(参见示例 9-7)。
示例 9-7. 自定义语音输出
const utteranceLow =
new SpeechSynthesisUtterance('This is spoken slowly in a low tone');
utterance.pitch = 0.1;
utterance.rate = 0.5;
speechSynthesis.speak(utterance);
const utteranceHigh =
new SpeechSynthesisUtterance('This is spoken quickly in a high tone');
utterance.pitch = 2;
utterance.rate = 2;
speechSynthesis.speak(utterance);
讨论
pitch选项是一个浮点数,其值可以在 0 到 2 之间。较低的值会导致较低的音调,较高的值会导致较高的音调。降低音调不会影响说话速率。根据使用的浏览器或语音,支持的音调值范围可能会受到限制。
要加快或减慢语音的速率,您可以调整rate属性。每个语音都有一个默认的朗读速率,用rate值表示为 1。rate的值具有乘法效应。如果将rate设置为 0.5,则为默认朗读速率的一半。类似地,如果将rate设置为 1.5,则比默认速率快 50%。规范定义了有效范围为 0.1 到 10,但浏览器和语音通常将其限制在较小的范围内。
自动暂停语音
问题
当您的应用正在朗读时,您希望在切换到另一个标签时暂停语音,以免干扰其他标签的使用。同时,当离开页面时也希望停止朗读。
解决方案
监听visibilitychange事件并检查document.visibilityState属性。当页面变为隐藏状态时,暂停语音合成。当再次变为可见状态时,恢复朗读(参见示例 9-8)。
示例 9-8. 当页面变为隐藏状态时暂停语音
document.addEventListener('visibilitychange', () => {
// speechSynthesis.speaking is true:
// (1) when speech is currently being spoken
// (2) when speech was being spoken, but is paused
if (speechSynthesis.speaking) {
if (document.visibilityState === 'hidden') {
speechSynthesis.pause();
} else if (document.visibilityState === 'visible') {
speechSynthesis.resume();
}
}
});
讨论
默认情况下,如果在 Web 语音 API 正在播放某些文本时切换到另一个标签页,它会继续播放。这可能是您预期的行为 — 毕竟,如果您正在播放音频或视频然后切换到另一个标签页,您会继续听到来自其他标签页的音频。
当您切换标签页时,会触发visibilitychange事件。事件本身并不提供任何关于可见状态的信息,但您可以通过检查document.visibilityState属性来获取。示例 9-8 在您切换到另一个标签页时暂停语音播放。当您切换回来时,它会继续之前的播放位置。
有些浏览器即使在您离开页面或执行完整页面刷新时也会继续播放语音。离开或刷新页面也会触发visibilitychange事件,因此在示例 9-8 中的代码也能正确停止这些情况下的语音播放。
第十章:文件处理
引言
读写文件是许多应用程序的一部分。过去,无法在浏览器内直接处理本地文件。要读取数据,您需要将文件上传到后端服务器,服务器处理后返回数据给浏览器。
要写入数据,服务器将发送可下载的文件。没有浏览器插件,无法直接处理文件。
如今,浏览器对于读写文件有了一流的支持。file 输入类型打开文件选择器并提供有关所选文件的数据。您还可以限制支持的文件类型为特定扩展名或 MIME 类型。从这里,File API 可以将文件内容读取到内存中。
更进一步,文件系统 API 允许 JavaScript 代码直接与本地文件系统交互,无需首先选择文件输入(尽管根据设置,用户可能需要授予权限!)。
您可以使用这些 API 创建文本编辑器、图像查看器、音频或视频播放器等工具。
从文件加载文本
问题
您想从用户的本地文件系统加载一些文本数据。
解决方案
使用 <input type="file"> 选择文件(见 Example 10-1)。
Example 10-1. 文件输入
<input type="file" id="select-file">
当您点击文件输入时,浏览器将显示一个对话框,您可以在其中浏览本地系统中的文件和文件夹。显示的确切对话框将取决于浏览器和操作系统版本。导航到并选择所需的文件。选择文件后,像 Example 10-2 中显示的那样使用 FileReader 读取文件的文本内容。
Example 10-2. 从文件加载纯文本
/**
* Reads the text content of a file.
* @param file The File object containing the data to be read
* @param onSuccess A function to call when the data is available
*/
function readFileContent(file, onSuccess) {
const reader = new FileReader();
// When the content is loaded, the reader will emit a
// 'load' event.
reader.addEventListener('load', event => {
onSuccess(event.target.result);
});
// Always handle errors!
reader.addEventListener('error', event => {
console.error('Error reading file:', event);
});
// Start the file read operation.
reader.readAsText(file);
}
const fileInput = document.querySelector('#select-file');
// The input fires a 'change' event when a file is selected.
fileInput.addEventListener('change', event => {
// This is an array, because a file input can be used to select
// multiple files. Here, there's only once file selected.
// This is using array destructuring syntax to get the first file.
const [file] = fileInput.files;
readFileContent(file, content => {
// The file's text content is now available.
// Imagine you have a textarea element you want to set the text in.
const textArea = document.querySelector('.file-content-textarea');
textArea.textContent = content;
});
});
讨论
FileReader 是一个异步读取文件内容的对象。它可以根据文件类型以不同的方式读取文件内容。Example 10-2 使用 readAsText 方法,以纯文本形式检索文件内容。
如果您有一个二进制文件,比如 ZIP 归档或图像文件,可以使用 readAsBinaryString。图像可以使用 readAsDataURL 读取为包含 Base64 编码图像数据的数据 URL,您将在 “加载图像作为数据 URL” 中看到。
此 API 基于事件,因此 readFileContent 函数接受一个回调函数,在内容准备好时调用该函数。
您还可以将其包装为 Promise 以创建基于 Promise 的 API,就像 Example 10-3 中显示的那样。
Example 10-3. 使用 Promise 包装的 readFileContent 函数
function readFileContent(file) {
const reader = new FileReader();
return new Promise((resolve, reject) => {
reader.addEventListener('load', event => {
resolve(event.target.result);
});
reader.addEventListener('error', reject);
reader.readAsText(file);
});
}
try {
const content = await readFileContent(inputFile);
const textArea = document.querySelector('.file-content-textarea');
textArea.textContent = content;
} catch (error) {
console.error('Error reading file content:', error);
}
一旦获取文本内容,您可以通过几种方式将其添加到页面中。您可以将其设置为 DOM 节点的 textContent,甚至可以将其加载到 textarea 中以进行内容编辑。
加载图像作为数据 URL
问题
您希望用户选择一个本地图像文件,然后在页面上显示该图像。
解决方案
使用FileReader的readAsDataURL方法获取 Base64 编码的数据 URL,然后将其设置为img标签的src属性(参见示例 10-4 和 10-5)。
示例 10-4. 文件输入和图像占位符
<input
type="file"
id="select-file"
accept="image/*" 
>
<img id="placeholder-image">
限制文件选择器仅允许选择图像。这里使用通配符模式,但您也可以指定确切的 MIME 类型,如image/png。
示例 10-5. 将图像加载到页面中
/**
* Loads and shows an image from a file.
* @param file The File object containing the image data
* @param imageElement A placeholder Image element that will
* show the image data
*/
function showImageFile(file, imageElement) {
const reader = new FileReader();
reader.addEventListener('load', event => {
// Set the data URL directly as the image's
// src attribute to load the image.
imageElement.src = event.target.result;
});
reader.addEventListener('error', event => {
console.log('error', event);
});
reader.readAsDataURL(file);
}
const fileInput = document.querySelector('#select-file');
fileInput.addEventListener('change', event => {
showImageFile(
fileInput.files[0],
document.querySelector('#placeholder-image')
);
});
讨论
数据 URL 具有data URL 方案。它指定数据的 MIME 类型,然后图像数据以 Base64 编码格式包含在内:
data:image/png;base64,UHJldGVuZCB0aGlzIGlzIGltYWdlIGRhdGE=
当FileReader返回以数据 URL 编码的图像时,将该数据 URL 设置为图像元素的src属性。这将在页面中呈现图像。
需要注意的是,所有这些操作都是在用户的浏览器本地进行的。没有任何内容被上传到远程服务器,因为 File API 在本地文件系统上工作。
在第四章的“使用 Fetch API 上传文件”显示了使用<input type="file">将文件数据上传到远程服务器的示例,尽管这里使用的是 FormData API 而不是 File API。
关于数据 URL 和 Base64 编码的更多详细信息,请参阅MDN 上的这篇文章。
加载视频作为对象 URL
问题
您希望用户选择一个视频文件,然后在浏览器中播放它。
解决方案
为File对象创建对象 URL,并将其设置为<video>元素的src属性。
首先,您需要一个<video>元素和一个<input type="file">来选择视频文件(请参见示例 10-6)。
示例 10-6. 视频播放器标记
<input
id="file-upload"
type="file"
accept="video/*" 
>
<video
id="video-player"
controls 
>
仅允许选择视频文件
告诉浏览器包括播放控件
接下来,监听文件输入的change事件并创建一个对象 URL,如示例 10-7 所示。
示例 10-7. 播放视频文件
const fileInput = document.querySelector('#file-upload');
const video = document.querySelector('#video-player');
fileInput.addEventListener('change', event => {
const [file] = fileInput.files;
// File extends from Blob, which can be passed to
// createObjectURL.
const objectUrl = URL.createObjectURL(file);
// The <video> element can take the object URL to load the video.
video.src = objectUrl;
});
讨论
对象 URL 是指向文件内容的特殊 URL。您可以不使用FileReader来实现这一点,因为文件本身具有createObjectURL方法。此 URL 可以传递给<video>元素。
拖放加载图像
问题
您希望能够将图像文件拖放到浏览器窗口中,并在放置时在页面上显示该图像。
解决方案
定义一个用作拖放区域的元素和一个占位符图像元素(参见示例 10-8)。
示例 10-8. 拖放目标和图像元素
<label id="drop-target">
<div>Drag and drop an image here</div>
<input type="file" id="file-input">
</label>
<img id="placeholder">
请注意,此示例仍然包括文件input。这是为了使使用辅助技术的用户也可以上传图像,而无需尝试拖放操作。因为拖放目标是一个包含文件输入的标签,您可以在拖放目标的任何位置点击以打开文件选择器。
首先,创建一个函数,接收图片文件并将其作为数据 URL 读取(见 示例 10-9)。
示例 10-9. 读取拖放的文件
function showDroppedFile(file) {
// Read the file data and insert the loaded image
// into the page.
const reader = new FileReader();
reader.addEventListener('load', event => {
const image = document.querySelector('#placeholder');
image.src = event.target.result;
});
reader.readAsDataURL(file);
}
接下来,为 dragover 和 drop 事件创建处理函数。这些事件附加到拖放目标元素(见 示例 10-10)。
示例 10-10. 添加拖放代码
const target = document.querySelector('#drop-target');
target.addEventListener('drop', event => {
// Cancel the drop event. Otherwise, the browser will leave the page
// and navigate to the file directly.
event.preventDefault();
// Get the selected file data. dataTransfer.items is a
// DataTransferItemList. Each item in the list, a DataTransferItem, has data
// about an item being dropped. As this example only deals with a single file, it
// gets the first item in the list.
const [item] = event.dataTransfer.items;
// Get the dropped data as a File object.
const file = item.getAsFile();
// Only proceed if an image file was dropped.
if (file.type.startsWith('image/')) {
showDroppedFile(file);
}
});
// You need to cancel the dragover event as well to prevent the
// file from replacing the full page content.
target.addEventListener('dragover', event => {
event.preventDefault();
});
最后,确保连接后备文件输入。你只需获取选择的文件,然后将其传递给 showDroppedFile 方法以获得相同的结果(见 示例 10-11)。
示例 10-11. 处理文件输入
const fileInput = document.querySelector('#file-input');
fileInput.addEventListener('change', () => {
const [file] = fileInput.files;
showDroppedFile(file);
});
讨论
默认情况下,当你将一个图片拖放到页面上时,浏览器会离开当前页面。URL 会变成文件路径,并且图片会显示在浏览器窗口中。在这个示例中,你希望将图片数据加载到一个 <img> 元素中,并保持在当前页面。
为了阻止默认行为,拖放处理程序在 drop 事件上调用 preventDefault。为了完全阻止这种行为,你还需要在 dragover 事件上调用 preventDefault,这就是为什么你需要第二个事件侦听器。这样可以确保元素实际上可以接收 drop 事件。
检查和请求权限
问题
你需要检查——并在必要时请求——访问本地文件系统上的文件权限。
解决方案
显示文件选择器,当选择文件时,调用 queryPermission 检查现有权限。如果权限检查返回 prompt,则调用 requestPermission 显示权限请求(见 示例 10-12)。
示例 10-12. 选择和检查文件权限
/**
* Selects a file, then checks permissions, showing a request if necessary,
* for a file.
* @return true if the file can be written to, false otherwise
*/
async function canAccessFile() {
if ('showOpenFilePicker' in window) {
// showOpenFilePicker can select multiple files, just
// get the first one (with array destructuring).
const [file] = window.showOpenFilePicker();
let result = await file.queryPermission({ mode: 'readwrite' });
if (result === 'prompt') {
result = await file.requestPermission({ mode: 'readwrite' });
}
return result === 'granted';
}
// If you get here, it means the API isn't supported.
return false;
}
注意
这个 API 可能还不被所有浏览器支持。查看 CanIUse 获取最新的兼容性数据。
讨论
queryPermission 函数返回 granted(已授予权限)、denied(访问被拒绝)或 prompt(需要请求权限)。
请求模式为 readwrite,这意味着如果你授予权限,浏览器可以写入你的本地文件系统。因此,从安全和隐私角度来看,权限检查非常重要。
queryPermission 只检查权限而不显示提示。如果返回 prompt,则可以调用 requestPermission 在浏览器中显示权限请求。如果任一调用返回 granted,则认为文件可写。
将 API 数据导出到文件
问题
你正在从一个 API 请求 JSON 数据,并且你希望给用户一个选项来下载原始的 JSON 数据。
解决方案
让用户选择一个输出文件,然后将 JSON 数据写入到本地文件系统。
注意
这个 API 可能还不被所有浏览器支持。查看 CanIUse 获取最新的兼容性数据。
首先,定义一个辅助函数,显示文件选择器并返回所选择的文件(见 示例 10-13)。
示例 10-13. 选择输出文件
/**
* Shows a save file picker and returns the selected file handle.
* @returns a file handle to the selected file, or null if the user clicked Cancel.
*/
async function selectOutputFile() {
// Check to make sure the API is supported in this browser.
if (!('showSaveFilePicker' in window)) {
return null;
}
try {
return window.showSaveFilePicker({
// The default name for the output file
suggestedName: 'users.json',
// Limit the available file extensions.
types: [
{ description: "JSON", accept: { "application/json": [".json"] } }
]
});
} catch (error) {
// If the user clicks Cancel, an exception is thrown. In this case,
// return null to indicate no file was selected.
return null;
}
}
接下来,定义一个使用这个帮助函数的函数,并执行实际的导出操作(参见 示例 10-14)。
示例 10-14. 将数据导出到本地文件
async function exportData(data) {
// Use the helper function defined previously.
const outputFile = await selectOutputFile();
// Only proceed if an output file was actually selected.
if (outputFile) {
try {
// Prepare a writable stream, which is used to save the file
// to disk.
const stream = await outputFile.createWritable();
// Write the JSON t the stream in a human-readable format.
await stream.write(JSON.stringify(userList, null, 2));
await stream.close();
// Show a success message.
document.querySelector('#export-success').classList.remove('d-none');
} catch (error) {
console.error(error);
}
}
}
讨论
这是一个允许用户从你的应用中备份或导出数据的好方法。一些法规,比如欧盟的《通用数据保护条例》(GDPR),要求你让用户下载他们的数据。
在这种情况下,文本数据被写入流中,流的类型是 FileSystem 的 WritableFileStream。这些流也支持写入 ArrayBuffer、TypedArray、DataView 和 Blob 对象。
为了创建写入文件的文本,exportData 调用 JSON.stringify 函数并传入一些额外的参数。第二个 null 参数是 replacer 函数,你可以在 第二章 中看到它。这个参数必须提供以便传入第三个参数,用来指定缩进空格的数量,从而创建一个更易读的输出格式。
在撰写本文时,这个 API 仍然被视为实验性质。在它有更好的浏览器支持之前,你应该避免在生产应用中使用它。
使用下载链接导出 API 数据
问题
你想提供导出功能,但又不想担心文件系统的权限问题,就像在 “将 API 数据导出到文件” 中描述的那样。
解决方案
将 API 数据放入 Blob 对象中,并创建一个对象 URL 以设置为链接的 href 属性。然后你可以通过普通的浏览器文件下载导出数据,而无需文件系统的权限。
首先,在页面上添加一个占位符链接,这个链接会成为导出链接(参见 示例 10-15)。
示例 10-15. 导出链接的占位符
<a id="export-link" download="users.json">Export User Data</a> 
download 属性提供了一个默认的文件名用于下载。
在从 API 获取数据并在 UI 中渲染后,创建 Blob 和对象 URL(参见 示例 10-16)。
示例 10-16. 准备导出链接
const exportLink = document.querySelector('#export-link');
async function getUserData() {
const response = await fetch('/api/users');
const users = await response.json();
// Render the user data in the UI, assuming that you
// have a renderUsers function somewhere that does this.
renderUsers(users);
// Clean up the previous export data, if it exists.
const currentUrl = exportLink.href;
if (currentUrl) {
URL.revokeObjectURL(currentUrl);
}
// Need a Blob for creating an object URL
const blob = new Blob([JSON.stringify(userList, null, 2)], {
type: 'application/json'
});
// The object URL links to the Blob contents—set this in the link.
const url = URL.createObjectURL(blob);
exportLink.href = url;
}
讨论
这种导出方法不需要特殊权限。当链接被点击并且对象 URL 已经设置时,它会将 Blob 的内容作为一个文件下载下来,文件名建议为 users.json。
一个 Blob 是一个特殊的对象,用来保存一些数据片段。通常这些数据是二进制的,比如文件或者图片,但你也可以用字符串内容创建一个 Blob,这就是这个示例要做的事情。
Blob 存储在内存中,并且创建的对象 URL 链接到它。一旦链接元素的对象 URL 设置好了,它就变成了一个导出下载链接。当链接被点击时,对象 URL 返回原始字符串数据。由于链接有一个 download 属性,它会被下载到本地文件。
为了防止内存泄漏,通过调用 URL.revokeObjectURL 并将对象 URL 作为其参数来清除旧的 URL。当你不再需要对象 URL 时(例如用户下载文件或离开页面之前),可以执行此操作。
使用拖放上传文件
问题
允许用户拖放文件(例如图片),然后将该文件上传到远程服务。
解决方案
将接收到的 File 对象传递给 drop 事件的处理函数中的 Fetch API(参见 示例 10-17)。
示例 10-17. 上载拖放的文件
const target = document.querySelector('.drop-target');
target.addEventListener('drop', event => {
// Cancel the drop event. Otherwise, the browser will leave the page
// and navigate to the file directly.
event.preventDefault();
// Get the selected file data.
const [item] = event.dataTransfer.items;
const file = item.getAsFile();
if (file.type.startsWith('image/')) {
fetch('/api/uploadFile', {
method: 'POST',
body: file
});
}
});
// You need to cancel the dragover event as well, to prevent the
// file from replacing the full page content.
target.addEventListener('dragover', event => {
event.preventDefault();
});
讨论
当在数据传输对象上调用 getAsFile 时,会得到一个 File 对象。File 是 Blob 的扩展,因此可以使用 Fetch API 将文件(Blob)内容发送到远程服务器。
此示例检查上传文件的 MIME 类型,仅当其为图片文件时才会上传。
第十一章:国际化
简介
现代浏览器包括强大的国际化 API。这是一组围绕语言或特定区域任务的 API 集合,例如:
-
格式化日期和时间
-
格式化数字
-
货币
-
复数规则
在这个 API 出现之前,您可能不得不使用 Moment.js(用于日期和时间)或 Numeral.js(用于数字)等第三方库。然而,现代浏览器支持许多相同的用例,您可能不再需要这些库来开发应用程序。
大多数这些 API 使用区域设置的概念,通常是语言和地区的组合。例如,美国英语的区域设置是en-US,加拿大英语的区域设置是en-CA。您可以使用默认区域设置,即浏览器正在使用的区域设置,或者您可以指定特定的区域设置以便根据您所需的区域适当地格式化数据。
注意
JavaScript 正在开发中的新的日期和时间 API 称为 Temporal。在撰写本书时,这仍然是一个 ECMAScript 提案。它可能会在不久的将来成为语言的一部分,但目前本书将介绍标准的 Date API。
格式化日期
问题
您希望以适合用户区域设置的格式显示Date对象。
解决方案
使用Intl.DateTimeFormat将Date对象格式化为字符串值。创建包含两个参数的格式对象:所需的区域设置和一个可以指定格式样式的选项对象。对于日期,支持的格式样式有(在en-US区域设置下显示示例):
-
short: 10/16/23 -
medium: Oct 16, 2023 -
long: 2023 年 10 月 16 日 -
full: 2023 年 10 月 16 日星期一
要获取用户当前的区域设置,您可以检查navigator.language属性(参见示例 11-1)。
示例 11-1. 格式化日期
const formatter = new Intl.DateTimeFormat(navigator.language, { dateStyle: 'long' });
const formattedDate = formatter.format(new Date());
讨论
您还可以通过在选项对象中指定timeStyle属性和dateStyle(参见示例 11-2)来包含Date对象的时间信息。
示例 11-2. 格式化日期和时间
const formatter = new Intl.DateTimeFormat(navigator.language, {
dateStyle: 'long', timeStyle: 'long' });
const formattedDateAndTime = formatter.format(new Date());
获取格式化日期的部分
问题
您希望将格式化的日期拆分为令牌。例如,如果您希望不同部分的格式化日期具有不同的样式,则此操作非常有用。
解决方案
使用Intl.DateTimeFormat的formatToParts方法格式化日期并返回一个令牌数组(参见示例 11-3)。
示例 11-3. 获取格式化日期的部分
const formatter = new Intl.DateTimeFormat(navigator.language,
{ dateStyle: 'short' });
const parts = formatter.formatToParts(new Date());
讨论
对于10/1/23的短日期,示例 11-3 中显示的parts对象如示例 11-4 所示。
示例 11-4. 格式化的日期部分
[
{ type: 'month', value: '10' },
{ type: 'literal': value: '/' },
{ type: 'day': value: '1' },
{ type: 'literal', value: '/' },
{ type: 'year', value: '23' }
]
格式化相对日期
问题
您希望以近似的人类可读格式格式化给定日期与今天之间的差异。例如,您希望得到类似“2 天前”或“3 个月后”的格式化字符串。
解决方案
使用Intl.RelativeTimeFormat。它具有format方法,您可以用值偏移量(例如-2 代表过去,3 代表将来)和单位(如“day”、“month”等)调用它。例如,在en-US区域设置中调用format(-2, *day*)将返回字符串“2 天前”。
实际上这是一个两步过程。Intl.RelativeTimeFormat不直接计算两个日期之间的时间差。相反,您需要首先确定偏移量和单位,然后将它们传递给format方法。其思想是找到在两个日期之间存在差异的最大单位。
首先,创建一个帮助函数,该函数返回一个包含偏移量和单位的对象,如示例 11-5 所示。
示例 11-5. 查找偏移量和单位
function getDateDifference(fromDate) {
const today = new Date();
if (fromDate.getFullYear() !== today.getFullYear()) {
return { offset: fromDate.getFullYear() - today.getFullYear(), unit: 'year' };
} else if (fromDate.getMonth() !== today.getMonth()) {
return { offset: fromDate.getMonth() - today.getMonth(), unit: 'month' };
} else {
// You could even go more granular: down to hours, minutes, or seconds!
return { offset: fromDate.getDate() - today.getDate(), unit: 'day' };
}
}
此函数返回一个包含两个属性offset和unit的对象,您可以将其传递给Intl.RelativeTimeFormat(参见示例 11-6)。
示例 11-6. 格式化相对日期
function getRelativeDate(fromDate) {
const { offset, unit } = getDateDifference(fromDate);
const format = new Intl.RelativeTimeFormat();
return format.format(offset, unit);
}
如果您在 2023 年 10 月 7 日调用此函数,则以下是预期输出(请记住,以这种方式创建Date对象时,月份从 0 开始,但日期从 1 开始):
-
2023 年 10 月 1 日:
getRelativeDate(new Date(2023, 9, 1)):“6 天前” -
2023 年 5 月 2 日:
getRelativeDate(new Date(2023, 4, 2)):“5 个月前” -
2025 年 6 月 2 日:
getRelativeDate(new Date(2025, 5, 2)):“在 2 年内”
讨论
getDateDifference通过比较给定日期的年份、月份和日期(按顺序),与今天的日期进行比较,直到找到不匹配的日期为止。然后返回差异和单位名称,这些将传递给Intl.RelativeTimeFormat。
getRelativeDate函数并不会精确地返回月份、天数、小时、分钟和秒数的相对时间。它只是给出时间差的量级估计。
考虑将 2023 年 5 月 2 日与 2023 年 10 月 7 日进行比较。它们相差 5 个月 5 天,但getRelativeDate仅显示“5 个月前”作为近似。
格式化数字
问题
您希望以区域特定的方式使用千位分隔符和小数位数格式化数字。
解决方案
将数字传递给Intl.NumberFormat的format方法。该方法返回一个包含格式化后数字的字符串。
默认情况下,Intl.NumberFormat使用默认区域设置(假设示例 11-7 中的默认区域设置是en-US)。
示例 11-7. 使用默认区域设置格式化数字
// outputs '5,200.55' for en-US
console.log(
new Intl.NumberFormat().format(5200.55)
);
您还可以为Intl.NumberFormat构造函数指定不同的区域设置(参见示例 11-8)。
示例 11-8. 使用de-DE区域设置格式化数字
// outputs '5.200,55'
console.log(
new Intl.NumberFormat('de-DE').format(5200.55)
);
讨论
Intl.NumberFormat应用区域特定的格式化规则来格式化单个数字。您还可以通过将两个值传递给formatRange来格式化一系列数字,如示例 11-9 所示。
示例 11-9. 格式化数字范围
// outputs '1,000-5,000' for en-US
console.log(
new Intl.NumberFormat().formatRange(1000, 5000)
);
舍入小数位数
问题
您想要取一个可以有许多小数位数的分数,并将其舍入到一定小数位数。
解决方案
使用maximumFractionDigits选项来指定小数点后的位数。 示例 11-10 展示了如何将数字四舍五入到最多两位小数。
示例 11-10. 四舍五入数字
function roundToTwoDecimalPlaces(number) {
const format = new Intl.NumberFormat(navigator.language, {
maximumFractionDigits: 2
});
return format.format(number);
}
// prints "5.49"
console.log(roundToTwoDecimalPlaces(5.49125));
// prints "5.5"
console.log(roundToTwoDecimalPlaces(5.49621));
格式化价格范围
问题
给定一个作为数字存储的价格数组,您希望创建一个反映数组中最低和最高价格的格式化价格范围。
解决方案
确定最低和最高价格,然后在创建Intl.NumberFormat时传递style: *currency*选项。使用此Intl.NumberFormat创建范围。还可以指定货币以获取输出中的正确符号。最后,在Intl.NumberFormat上调用formatRange,传入较低和较高的价格边界(参见示例 11-11)。
示例 11-11. 格式化价格范围
function formatPriceRange(prices) {
const format = new Intl.NumberFormat(navigator.language, {
style: 'currency'.
// The currency code is required when using style: 'currency'.
currency: 'USD'
});
return format.formatRange(
// Find the lowest price in the array.
Math.min(...prices),
// Find the highest price in the array.
Math.max(...prices)
);
}
// outputs '$1.75—$11.00'
console.log(
formatPriceRange([5.5, 3, 1.75, 11, 9.5])
);
讨论
Math.max和Math.min函数接受多个参数,并从整组参数中返回最大值或最小值。 示例 11-11 使用数组展开语法将prices数组中的所有元素传递给Math.max和Math.min。
格式化测量单位
问题
您想要格式化一个带有测量单位的数字。
解决方案
在创建Intl.NumberFormat对象时使用unit样式,并指定目标单位。 示例 11-12 展示了如何格式化千兆字节的数字。
示例 11-12. 格式化千兆字节
const format = new Intl.NumberFormat(navigator.language, {
style: 'unit',
unit: 'gigabyte'
});
// prints "1,000 GB"
console.log(format.format(1000));
讨论
您还可以通过为NumberFormat指定unitDisplay选项来自定义单位标签。可能的值有:
short
显示缩写单位,用空格分隔:1,000 GB
narrow
显示缩写单位,无空格:1,000GB
long
显示完整的单位名称:1,000 gigabytes
应用复数规则
问题
您希望在引用不同数量的项目时使用正确的术语。例如,考虑用户列表。在英语中,您会说“一个用户”(单数),但“三个用户”(复数)。其他语言有更复杂的规则,您希望确保涵盖这些规则。
解决方案
使用Intl.PluralRules来选择正确的复数形式字符串。
首先,使用所需的区域设置构建一个Intl.PluralRules对象,并调用其select方法来确定用户数量(参见示例 11-13)。
示例 11-13. 确定复数形式
// An array containing the users
const users = getUsers();
const rules = new Intl.PluralRules('en-US');
const form = rules.select(users.length);
select方法根据要使用的复数形式和指定的区域设置返回一个字符串。对于en-US区域设置,它返回“one”(当用户计数为一时)或“other”(当用户计数不为一时)。您可以使用这些值作为键来定义消息,如示例 11-14 所示。
示例 11-14. 完整的复数规则解决方案
function formatUserCount(users) {
// The variations of the message, depending
// on the count
const messages = {
one: 'There is 1 user.',
other: `There are ${users.length} users.`
};
// Use Intl.PluralRules to determine which message
// should be displayed.
const rules = new Intl.PluralRules('en-US');
return messages[rules.select(users.length)];
}
讨论
此解决方案需要预先了解不同形式,以便定义正确的消息。
Intl.PluralRules 还支持一种 ordinal 模式,其工作方式略有不同。你可以使用此模式来格式化 序数 值,如 “1st,” “2nd,” “3rd,” 等。格式化规则因语言而异,你可以将它们映射到附加到数字的后缀。
例如,在 en-US 区域设置中,序数 Intl.PluralRules 返回如下值:
-
对于以 1 结尾的数字使用
one—“1st,” “21st,” 等。 -
对于以 2 结尾的数字使用
two—“2nd, 42nd,” 等。 -
对于以 3 结尾的数字使用
few—“3rd, “33rd,” 等。 -
对于其他数字使用
other—“5th,” “47th,” 等。
计算字符、单词和句子的数量
问题
你想使用特定于区域设置的规则计算字符串的字符数、单词数和句子数。
解决方案
使用 Intl.Segmenter 来分割字符串并计算出现次数。
你可以创建一个以字形(单个字符)、单词或句子为粒度的分段器。粒度决定了段的边界。每个分段器只能有一种粒度,所以你需要三个分段器(参见 示例 11-15)。
示例 11-15. 获取字符串的字符数、单词数和句子数
function getCounts(text) {
const characters = new Intl.Segmenter(
navigator.language,
{ granularity: 'grapheme' }
);
const words = new Intl.Segmenter(
navigator.language,
{ granularity: 'word' }
);
const sentences = new Intl.Segmenter(
navigator.language,
{ granularity: 'sentence' }
);
// Convert each segment to an array, then get its length.
return {
characters: [...characters.segment(text)].length,
words: [...words.segment(text)].length,
sentences: [...sentences.segment(text)].length
};
}
注意
此 API 可能尚未得到所有浏览器的支持。请查看 CanIUse 获取最新的兼容性数据。
讨论
当你在一段文本上调用分段器的 segment 方法时,它会返回一个包含所有段的可迭代对象。有几种方法可以获取此可迭代对象中项目的长度,但是此示例使用了数组展开语法,它创建一个包含所有项的数组。然后你只需要获取每个数组的长度。
你可能过去通过使用字符串的 split 方法来解决这个问题。例如,你可以在空格上分割以获取单词数组并获取单词计数。这种方法在你的语言中可能有效,但是使用 Intl.Segmenter 的优势在于它采用给定区域设置的单词和句子分割规则。
格式化列表
问题
你有一个项目数组,想要以逗号分隔的列表形式显示。例如,一个用户数组显示为 “user1, user2, 和 user3。”
解决方案
使用 Intl.ListFormat 根据给定区域设置的规则将项目合并成列表。示例 11-16 使用用户数组,每个用户具有 username 属性。
示例 11-16. 格式化用户对象列表
function getUserListString(users, locale = 'en-US') {
// The locale of the ListFormat is configurable.
const listFormat = new Intl.ListFormat(locale);
return listFormat.format(users.map(user => user.username));
}
讨论
Intl.ListFormat 根据需要添加单词和标点。例如,在 en-US 区域设置中,你会得到以下结果:
-
1 位用户:“user1”
-
2 位用户:“user1 和 user2”
-
3 位用户:“user1, user2, 和 user3”
下面是另一个使用 de-DE 区域设置的示例:
-
1 位用户:“user1”
-
2 位用户:“user1 und user2”
-
3 位用户:“user1, user2 und user3”
注意在第三种情况下使用 “und” 而不是 “and”,并且还要注意在 en-US 中没有在 user2 后面使用逗号的情况。这是因为德语语法不使用这个逗号(称为“牛津逗号”)。
正如你所看到的,使用Intl.ListFormat比使用数组的join方法更加健壮,后者当然没有考虑区域设置规则。
排序一个名字数组
问题
你有一个名字数组,想要使用特定于区域的排序规则进行排序。
解决方案
创建一个Intl.Collator提供比较逻辑,然后使用它的compare函数传递给Array.prototype.sort(参见示例 11-17)。此函数比较两个字符串。如果第一个字符串在第二个之前,则返回负值,如果相等则返回零,如果第一个字符串在第二个之后则返回正值。
示例 11-17. 使用Intl.Collator排序一个名字数组
const names = [
'Elena',
'Mário',
'André',
'Renée',
'Léo',
'Olga',
'Héctor',
]
const collator = new Intl.Collator();
names.sort(collator.compare);
注意
一个Collator可以返回任何负值或正值,不一定只是–1 或 1。
讨论
这是一种对字符串数组进行排序的简洁方式。在Intl.Collator之前,你可能会做类似于示例 11-18 的事情。
示例 11-18. 直接排序一个字符串数组
names.sort((a, b) => a.localeCompare(b));
这种方法运行良好,但一个主要的区别在于,当比较字符串时,你无法指定要应用的区域设置的排序规则。另一个Intl.Collator的好处是它的灵活性。你可以微调它用来比较字符串的逻辑。
例如,考虑数组[1, 2, 20, 3]。使用默认排序器,这将是排序后的顺序,因为它使用字符串比较逻辑。你可以传递numeric: true选项给Intl.Collator,然后排序后的数组变成[1, 2, 3, 20]。
第十二章:Web 组件
介绍
Web 组件是一种构建具有自身行为的新 HTML 元素的方法。这种行为被封装在一个自定义元素中。
创建一个组件
你可以通过定义一个扩展了HTMLElement的类来创建一个 Web 组件,如示例 12-1 所示。
示例 12-1. 一个简单的 Web 组件
class MyComponent extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello from MyComponent';
}
}
当你将自定义元素添加到 DOM 中时,浏览器会调用connectedCallback方法。这通常是大多数组件逻辑的所在地。这是其中一个生命周期回调。其他一些生命周期回调包括:
disconnectedCallback
在从 DOM 中移除自定义元素时调用。这是一个执行清理工作(如移除事件监听器)的好地方。
attributeChangedCallback
当你改变元素的一个监视属性时调用。
注册一个自定义元素
创建完自定义元素类之后,必须在 HTML 文档中使用它之前向浏览器注册它。你可以通过在全局的customElements对象上调用define来注册你的自定义元素,如示例 12-2 所示。
示例 12-2. 在浏览器中注册自定义元素
customElements.define('my-component', MyComponent);
注意
如果尝试定义一个已经定义过的自定义元素,浏览器会抛出一个错误。如果这对你可能是一个可能性,你可以调用customElements.get('my-component')来检查它是否已经定义。如果返回undefined,则可以安全地调用customElements.define。
注册了元素后,你可以像使用任何其他 HTML 元素一样使用它,如示例 12-3 所示。
示例 12-3. 使用自定义元素
<my-component></my-component>
注意
自定义元素的名称必须始终使用连字符命名。这是规范要求的。它们也必须始终有一个闭合标记,即使没有子内容。
模板
有几种将 HTML 标记引入 Web 组件中的方法。例如,在connectedCallback中,你可以通过调用document.createElement手动创建元素并手动附加它们。
你还可以使用<template>元素指定组件的标记。它包含一些 HTML,在connectedCallback期间用来为你的组件提供内容。这些模板非常简单,它们不支持数据绑定、变量插值或任何类型的逻辑。它们只作为 HTML 内容的起点。在connectedCallback中,你可以选择元素,设置动态值,并根据需要添加事件监听器。
插槽
<slot> 是一个特殊的元素,你可以在模板中使用它。插槽是一种用于传递某些子内容的占位符。组件可以有一个默认插槽以及一个或多个命名插槽。你可以使用命名插槽在组件内放置多个内容片段。
示例 12-4 展示了一个具有命名和默认插槽的简单模板。
示例 12-4. 带有插槽的模板
<template>
<h2><slot name="name"></slot></h2>
<slot></slot>
</template>
假设这个模板用于 <author-bio> 组件中,如 示例 12-5 所示。
示例 12-5. 为插槽指定内容
<author-bio>
<span slot="name">John Doe</span>
<p>John is a great author who has written many books.</p>
</author-bio>
在组件的子内容中,你可以指定一个 slot 属性,对应组件模板中的一个具名插槽。包含文本“John Doe”的 span 元素将被放置在组件的 name 插槽中,即 h2 元素内。任何其他没有 slot 元素的子内容都会被放置在默认插槽中(没有名称的插槽)。
影子 DOM
影子 DOM 是一组与主 DOM 隔离的元素集合。Web 组件广泛使用影子 DOM。使用影子 DOM 的一个主要优势是可以进行作用域 CSS 样式。你在影子 DOM 中定义的任何样式 仅 应用于该影子 DOM 内部的元素。文档中的其他元素,即使通常会匹配 CSS 规则的选择器,也不会应用这些 CSS 样式。
这种样式作用范围是双向的。如果你在页面上有全局样式,它们将不会应用于影子 DOM 中的任何元素。
通过将 shadow root 附加到 Web 组件来创建影子 DOM,这个影子 DOM 可以是开放的或者关闭的。当影子 DOM 是开放的时候,你可以使用 JavaScript 访问和修改它的元素。当它是关闭的时候,Web 组件的 shadowRoot 属性为 null,因此无法访问其内容。
Light DOM
然而,使用影子 DOM 是完全可选的。Light DOM 指的是 Web 组件内部的常规、非封装的 DOM。由于 Light DOM 不会从页面其余部分封装起来,全局样式将应用于其子元素。
创建一个显示今天日期的组件
问题
你希望一个 Web 组件能够在浏览器的本地化环境中格式化并展示今天的日期。
解决方案
在 Web 组件内部使用 Intl.DateTimeFormat 来格式化当前日期(参见 示例 12-6)。
示例 12-6. 格式化当前日期的自定义元素
class TodaysDate extends HTMLElement {
connectedCallback() {
const formatter = new Intl.DateTimeFormat(
navigator.language,
{ dateStyle: 'full' }
);
this.textContent = formatter.format(new Date());
}
}
customElements.define('todays-date', TodaysDate);
现在你可以使用这个 Web 组件来展示今天的日期,不需要任何属性或子内容,如 示例 12-7 所示。
示例 12-7. 展示当前日期
<p>
Today's date is: <todays-date></todays-date>
</p>
讨论
当一个 <todays-date> 元素进入 DOM 时,浏览器调用 connectedCallback 方法。在 connectedCallback 中,TodaysDate 类使用 Intl.DateTimeFormat 对象格式化当前日期,你可能还记得这个对象来自 第十一章。connectedCallback 将这个格式化的日期字符串设置为元素的 textContent,这个属性是从 Node(HTMLElement 的祖先)继承而来。
创建一个格式化自定义日期的组件
问题
你希望一个 Web 组件能够格式化任意日期值。
解决方案
为 Web 组件添加一个 date 属性,并使用它来生成格式化的日期(参见 示例 12-8)。你可以监听这个属性的变化,并在日期属性变化时重新格式化日期。
示例 12-8. 自定义日期组件
class DateFormatter extends HTMLElement {
// The browser will only notify the component about changes, via the
// attributeChangedCallback, for attributes that are listed here.
static observedAttributes = ['date'];
constructor() {
super();
// Create the format here so you don't have to
// re-create it every time the date changes.
this.formatter = new Intl.DateTimeFormat(
navigator.language,
{ dateStyle: 'full' }
);
}
/**
* Formats the date represented by the current value of the 'date'
* attribute, if any.
*/
formatDate() {
if (this.hasAttribute('date')) {
this.textContent = this.formatter.format(
new Date(this.getAttribute('date'))
);
} else {
// If no date specified, show nothing.
this.textContent = '';
}
}
attributeChangedCallback() {
// Only watching one attribute, so this must be a change
// to the date attribute. Update the formatted date, if any.
this.formatDate();
}
connectedCallback() {
// The element was just added. Show the initial formatted date, if any.
this.formatDate();
}
}
customElements.define('date-formatter', DateFormatter);
现在您可以将日期传递给date属性,以便以用户的区域设置格式化它(参见示例 12-9)。
示例 12-9. 使用date-formatter元素
<date-formatter date="2023-10-16T03:52:49.955Z"></date-formatter>
讨论
此配方在“创建一个显示今天日期的组件”基础上增加了通过属性指定自定义日期的功能。
默认情况下,如果更改传递给自定义元素的属性值,什么也不会发生。connectedCallback中的逻辑仅在首次将组件添加到 DOM 时运行。要使组件对属性更改做出响应,可以实现attributeChangedCallback方法。在date-formatter组件中,此方法接收更新后的date属性并创建新的格式化日期。当属性发生更改时,浏览器会调用此方法,并传递属性名称、旧值和新值。
然而,仅此还不足以解决问题。如果仅实现attributeChangedCallback,则仍无法收到属性更改的通知。这是因为浏览器仅为观察到的属性调用attributeChangedCallback。这允许您定义属性的子集,以便浏览器仅为您感兴趣的那些属性调用attributeChangedCallback。要定义这些属性,请将静态的observedAttributes属性添加到您的组件类中。这应该是一个属性名称数组。
在date-formatter组件中,您仅监视一个属性(date属性)。因此,在attributeChangedCallback中,您无需检查name参数,因为您已经知道更改的是date属性。对于具有多个监视属性的组件,您可以检查name以查找已更改的属性。
如果您使用 JavaScript 更改date属性的值,则attributeChangedCallback将运行并更新格式化日期。
创建反馈组件
问题
您希望创建一个可重用的组件,用户可以在其中提供关于页面是否有帮助的反馈。
解决方案
创建一个网络组件来呈现反馈按钮,并在用户单击其中一个按钮时分发自定义事件。
首先,您需要创建一个模板元素,其中包含此组件使用的标记,如示例 12-10 所示。
示例 12-10. 创建模板
const template = document.createElement('template');
template.innerHTML = `
<style>
.feedback-prompt {
display: flex;
align-items: center;
gap: 0.5em;
}
button {
padding: 0.5em 1em;
}
</style>
<div class="feedback-prompt">
<p>Was this helpful?</p>
<button type="button" data-helpful="true">Yes</button>
<button type="button" data-helpful="false">No</button>
</div>
`;
此组件使用包含模板标记的影子 DOM(参见示例 12-11)。CSS 样式规则仅限于此组件。
示例 12-11. 组件实现
class FeedbackRating extends HTMLElement {
constructor() {
super();
// Create the shadow DOM and render the template into it.
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(template.content.cloneNode(true));
}
connectedCallback() {
this.shadowRoot.querySelector('.feedback-prompt').addEventListener('click',
event => {
const { helpful } = event.target.dataset;
if (typeof helpful !== 'undefined') {
// Once a feedback option is chosen, hide the buttons and show a
// confirmation.
this.shadowRoot.querySelector('.feedback-prompt').remove();
this.shadowRoot.textContent = 'Thanks for your feedback!';
// JavaScript doesn't have a 'parseBoolean' type function, so convert the
// string value to the corresponding boolean value.
this.helpful = helpful === 'true';
// Dispatch a custom event, so your app can be notified when a feedback
// button is clicked.
this.shadowRoot.dispatchEvent(new CustomEvent('feedback', {
composed: true, // This is needed to "escape" the shadow DOM boundary.
bubbles: true // This is needed to propagate up the DOM.
}));
}
});
}
}
customElements.define('feedback-rating', FeedbackRating);
现在您可以将此反馈组件添加到您的应用程序中(参见示例 12-12)。
示例 12-12. 使用反馈评分组件
<h2>Feedback</h2>
<feedback-rating></feedback-rating>
您可以监听自定义feedback事件,以便在用户选择反馈选项时收到通知(参见示例 12-13)。如何处理这些信息由您决定;也许您想要使用 Fetch API 将数据发送到分析端点。
示例 12-13. 监听反馈事件
document.querySelector('feedback-rating').addEventListener('feedback', event => {
// Get the value of the feedback component's "helpful" property and send it to an
// endpoint with a POST request.
fetch('/api/analytics/feedback', {
method: 'POST',
body: JSON.stringify({ helpful: event.target.helpful }),
headers: {
'Content-Type': 'application/json'
}
});
});
讨论
feedback-rating组件显示提示和两个按钮。用户根据他们认为网站内容是否有帮助来点击两个按钮中的一个。
click事件侦听器使用事件委托。它不是给每个按钮添加监听器,而是添加一个响应反馈提示任何位置的单个监听器。如果点击的元素没有data-helpful属性,则用户可能没有点击反馈按钮,因此不执行任何操作。否则,它将字符串值转换为布尔值,并将其设置为可以稍后检索的自定义元素属性。它还分发了一个事件,您可以在其他地方监听到。
要使此事件跨越影子 DOM 进入常规 DOM,必须设置composed: true选项。否则,添加到自定义元素的任何事件侦听器都不会被触发。
触发事件后,可以检查反馈元素本身(作为event.target属性可用)的helpful属性,以确定用户点击了哪个反馈按钮。
由于样式和标记包含在影子 DOM 中,因此 CSS 规则不会影响影子 DOM 外的任何元素。这一点很重要,否则像button这样的元素选择器会样式化页面上的每个按钮。由于样式是作用域的,它们仅应用于自定义元素内的按钮。
但是,传递到组件插槽的内容可以由全局 CSS 规则进行样式化。插槽内容不会移动到影子 DOM 中,而是保留在标准或轻 DOM 中。
创建个人资料卡组件
问题
您想要创建一个可重用的卡片组件来显示用户资料。
解决方案
在您的 Web 组件中使用插槽将内容传递到特定区域。
首先,按照示例 12-14 中显示的样式和标记定义模板。
示例 12-14. 个人资料卡模板
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: grid;
border: 1px solid #ccc;
border-radius: 5px;
padding: 8px;
grid-template-columns: auto 1fr;
column-gap: 16px;
align-items: center;
margin: 1rem;
}
.photo {
border-radius: 50%;
grid-row: 1 / span 3;
}
.name {
font-size: 2rem;
font-weight: bold;
}
.title {
font-weight: bold;
}
</style>
<div class="photo"><slot name="photo"></slot></div>
<div class="name"><slot name="name"></slot></div>
<div class="title"><slot name="title"></slot></div>
<div class="bio"><slot></slot></div>
`;
此模板具有三个命名插槽(photo、name 和 title)和一个用于传记的默认插槽。组件实现本身相当简单;它只是创建并附加了一个包含模板的影子根(参见示例 12-15)。
示例 12-15. 组件实现
class ProfileCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define('profile-card', ProfileCard);
要使用该组件,可以在子元素上指定slot属性,以指定内容应放入哪个插槽(参见示例 12-16)。没有slot属性的传记元素放置在默认插槽中。
示例 12-16. 使用个人资料卡
<profile-card>
<img slot="photo" src="/api/portraits/chavez.jpg" />
<div slot="name">Phillip Chavez</div>
<div slot="title">CEO</div>
<p>Philip is a great CEO.</p>
</profile-card>
<profile-card>
<img slot="photo" src="/api/portraits/lynch.jpg" />
<div slot="name">Jamie Lynch</div>
<div slot="title">Vice President</div>
<p>Jamie is a great vice president.</p>
</profile-card>
图 12-1 显示了个人资料卡组件的渲染结果。

图 12-1. 渲染的个人资料卡
讨论
在 CSS 样式中,您可能已经注意到:host选择器,它表示应用于自定义元素影子宿主的样式。这是影子 DOM 附加到的元素。
通过此示例,您可以看到 Web 组件如何让您创建可重复使用的内容和布局。插槽是一个强大的工具,使您能够在需要的地方精确插入内容。
创建一个懒加载图像组件
问题
您需要一个可重用的组件,其中包含一个图像,直到滚动到视口中才加载。
解决方案
使用IntersectionObserver等待元素滚动到视图中,然后在包含的图像上设置src元素。
本配方调整了“滚动到视图时懒加载图像”(见示例 12-17 和 12-18)在 Web 组件中的解决方案。
示例 12-17. LazyImage组件
class LazyImage extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
this.image = document.createElement('img');
shadowRoot.appendChild(this.image);
}
connectedCallback() {
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
console.log('Loading image');
this.image.src = this.getAttribute('src');
observer.disconnect();
}
});
observer.observe(this);
}
}
customElements.define('lazy-image', LazyImage);
示例 12-18. 使用LazyImage组件
<lazy-image src="https://placekitten.com/200/138"></lazy-image>
讨论
一旦元素滚动到视图中,IntersectionObserver回调获取src属性,并将其设置为图像的src属性,从而触发图像加载。
注意
此示例说明如何创建扩展内置元素的自定义元素,但对于懒加载图像,您可能不需要它。较新的浏览器支持img标签上的loading="lazy"属性,具有相同的效果——直到滚动到视图中,图像才会加载。
创建一个披露组件
问题
您希望通过单击按钮显示或隐藏一些内容。例如,您可能有一个默认折叠的表单的“高级”部分,但可以通过单击按钮展开。
解决方案
构建一个披露网络组件。该组件分为两部分:切换内容的按钮和内容本身。这两部分将各自有一个插槽。默认插槽用于内容,按钮则有一个命名插槽。此组件还可以通过改变其open属性的值来以编程方式展开或折叠。
首先,定义披露组件的模板,如示例 12-19 所示。
示例 12-19. 披露组件模板
const template = document.createElement('template');
template.innerHTML = `
<div>
<button type="button" class="toggle-button">
<slot name="title"></slot>
</button>
<div class="content">
<slot></slot>
</div>
</div>
`;
组件的实现在示例 12-20 中显示。
示例 12-20. 披露组件的实现
class Disclosure extends HTMLElement {
// Watch the 'open' attribute to react to changes.
static observedAttributes = ['open'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
this.content = this.shadowRoot.querySelector('.content');
}
connectedCallback() {
this.content.hidden = !this.hasAttribute('open');
this.shadowRoot.querySelector('.toggle-button')
.addEventListener('click', () => {
if (this.hasAttribute('open')) {
// Content is currently showing; remove the 'open'
// attribute and hide the content.
this.removeAttribute('open');
this.content.hidden = true;
} else {
// Content is currently hidden; add the 'open' attribute
// and show the content.
this.setAttribute('open', '');
this.content.hidden = false;
}
});
}
attributeChangedCallback(name, oldValue, newValue) {
// Update the content's hidden state based on the new attribute value.
if (newValue !== null) {
this.content.hidden = false;
} else {
this.content.hidden = true;
}
}
}
// The element name must be hyphenated.
customElements.define('x-disclosure', Disclosure);
最后一件事——您需要在页面上添加一小段 CSS。否则,子内容将在页面上闪烁片刻,然后消失。这是因为在自定义元素注册之前,它没有行为,浏览器不知道其插槽。这意味着任何子内容将在页面中呈现。
然后,一旦自定义元素被定义,子内容移动到插槽中并消失。
要解决这个问题,您可以使用 CSS 来隐藏元素的内容,直到通过使用:defined伪类注册它。
示例 12-21. 修复闪烁问题
x-disclosure:not(:defined) {
display: none;
}
这将最初隐藏内容。一旦自定义元素定义,元素将显示出来。您不会看到闪烁,因为内容已经移动到插槽中。
最后,您可以使用披露元素,如示例 12-22 所示。
示例 12-22. 使用披露元素
<x-disclosure>
<div slot="title">Details</div>
This is the detail child content that will be expanded or collapsed
when clicking the title button.
</x-disclosure>
切换按钮将显示文本“Details”,因为它放置在title插槽中。其余内容放置在默认插槽中。
讨论
披露组件使用其open属性来确定是否显示子内容。当点击切换按钮时,根据当前状态添加或删除属性,然后有条件地应用hidden属性到子内容。
您还可以通过添加或移除open属性来程序化地切换子内容的可见性。这是因为组件正在观察open属性。如果您使用 JavaScript 更改它,甚至在浏览器开发工具中更改它,浏览器会调用组件的attributeChangedCallback方法,并传递新的值。
open属性没有值。如果要默认打开内容,请简单地添加没有值的open属性,如示例 12-23 所示。
示例 12-23. 默认显示内容
<x-disclosure open>
<div slot="title">Details</div>
This is the detail child content that will be expanded or collapsed
when clicking the title button.
</x-disclosure>
如果移除该属性,则attributeChangedCallback中的newValue参数将为null。在这种情况下,它将通过应用hidden属性来隐藏子内容。如果添加了没有值的属性,如示例 12-23,则newValue参数将为空字符串。在这种情况下,它将移除hidden属性。
创建一个样式化按钮组件
问题
您希望创建一个具有不同样式选项的可重用按钮组件。
解决方案
按钮将有三个变体:
-
默认变体,带有灰色背景
-
“primary”变体,带有蓝色背景
-
“danger”变体,带有红色背景
首先,创建带有自定义按钮样式的模板,以及“primary”和“danger”变体的 CSS 类,如示例 12-24 所示。
示例 12-24. 按钮模板
const template = document.createElement('template');
template.innerHTML = `
<style>
button {
background: #333;
padding: 0.5em 1.25em;
font-size: 1rem;
border: none;
border-radius: 5px;
color: white;
}
button.primary {
background: #2563eb;
}
button.danger {
background: #dc2626;
}
</style>
<button>
<slot></slot>
</button>
`;
大部分模板都是 CSS。组件本身的实际标记非常简单:只是一个带有默认插槽的按钮元素。
组件本身将支持两个属性:
variant
按钮变体的名称(primary或danger)
type
传递到底层button元素的type属性。将其设置为button以防止提交表单(参见示例 12-25)。
示例 12-25. 按钮组件
class StyledButton extends HTMLElement {
static observedAttributes = ['variant', 'type'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
this.button = this.shadowRoot.querySelector('button');
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'variant') {
this.button.className = newValue;
} else if (name === 'type') {
this.button.type = newValue;
}
}
}
customElements.define('styled-button', StyledButton);
要添加点击侦听器,实际上您不必再做任何额外的工作。您可以向styled-button元素添加点击侦听器,当您单击底层按钮时将触发它,这要归功于事件委托。通过事件委托,您可以向父元素添加事件侦听器,其子元素的事件也会触发父元素的事件侦听器。
最后,这是如何使用styled-button组件(参见示例 12-26)。
示例 12-26. 使用styled-button组件
<styled-button id="default-button" type="button">Default</styled-button>
<styled-button id="primary-button" type="button" variant="primary">
Primary
</styled-button>
<styled-button id="danger-button" type="button" variant="danger">
Danger
</styled-button>
讨论
通过在按钮元素上设置与变体名称相等的类名来应用样式。这将导致相应的 CSS 规则应用所需的背景颜色。
您不需要在connectedCallback中添加任何代码来应用类,因为浏览器会调用attributeChangedCallback,包括初始值和后续更新的值。
您可以像处理普通按钮一样向styled-button添加点击事件监听器(参见示例 12-27)。
示例 12-27. 添加点击监听器
<script>
document.querySelector('#default-button').addEventListener('click', () => {
console.log('Clicked the default button');
});
</script>
<styled-button id="default-button" type="button">Default</styled-button>
第十三章:UI 元素
介绍
现代浏览器具有几个强大的内置 UI 元素,您可以在应用程序中使用这些 UI 组件。这些 UI 组件以前需要第三方库(或者您可以自行构建)。
对话框
弹出对话框是许多应用程序的重要部分,用于提供反馈和提示输入。有无数的对话框库可供选择,您也可以自己构建。现代浏览器已经为您准备好了<dialog>元素。这是一个带有覆盖整个页面背景的弹出对话框。您可以用少量的 CSS 为背景和对话框应用样式。默认情况下,对话框只是一个弹出的框,背景后面是模糊的背景。您可以自行添加标题、按钮和其他内容。
一些对话框包含多个按钮,您希望根据选择的选项运行不同的代码。例如,确认模态框可能有“确认”和“取消”按钮。您还需要自行处理此操作,向按钮添加点击事件侦听器。在每个事件侦听器中,您可以通过调用对话框上的close方法来关闭对话框。close方法是对话框上的内置方法,它接受一个可选参数,允许您指定一个“返回值”。稍后可以从对话框的returnValue属性中检查此返回值。这使您可以从对话框传递数据回到打开它的页面。
细节
<details> 元素是一个可折叠内容的组件。它有一些摘要内容显示在交互元素中。通过点击该元素,您可以显示或隐藏详细内容。与对话框类似,您可以使用 CSS 样式化组件,并使用 JavaScript 切换其可见性。
弹出框
弹出框类似于对话框。这是另一种弹出元素类型。弹出框与对话框之间有一些区别:
-
单击弹出框外部将关闭它。
-
在弹出框可见时,您仍然可以与页面的其余部分进行交互。
-
您可以将任何 HTML 元素转换为弹出框。
通知
智能手机广泛使用通知,而较新的操作系统也支持通知。现代浏览器提供了一个 API,用于通过 JavaScript 触发本地操作系统通知。用户必须在发送这些通知之前授予权限。这些通知是在应用程序运行时根据需要在您的 JavaScript 代码中创建的。
创建警报对话框
问题
您希望显示一个简单消息的对话框,并有一个“确定”按钮来关闭它。
解决方案
使用带有“确定”按钮的<dialog>元素。
注意
该 API 可能不受较旧浏览器支持。请查看CanIUse获取最新的兼容性数据。
首先,定义您对话框的 HTML,如示例 13-1 所示。
示例 13-1. 对话框标记
<dialog id="alert">
<h2>Alert</h2>
<p>This is an alert dialog.</p>
<button type="button" id="ok-button">OK</button>
</dialog>
<button type="button" id="show-dialog">Show Dialog</button>
你需要两段 JavaScript 代码。首先,你需要一个函数来触发显示对话框,然后你需要一个监听器来监听 OK 按钮以关闭对话框(参见示例 13-2)。
示例 13-2. 对话框的 JavaScript
// Select the dialog, its OK button, and the trigger button elements.
const dialog = document.querySelector('#alert');
const okButton = document.querySelector('#ok-button');
const trigger = document.querySelector('#show-dialog');
// Close the dialog when the OK button is clicked.
okButton.addEventListener('click', () => {
dialog.close();
});
// Show the dialog when the trigger button is clicked.
trigger.addEventListener('click', () => {
dialog.showModal();
});
这将导致显示在图 13-1 中的对话框。

图 13-1. 提示对话框
讨论
对话框的showModal方法显示一个模态对话框。模态对话框会阻塞页面的其余部分,直到它关闭。这意味着如果你打开了一个模态对话框,点击页面上的其他元素将不会产生任何效果。在模态对话框中,焦点被“困住”在对话框内部。使用 Tab 键只会在对话框内的可聚焦元素之间循环焦点。如果这不是你想要的效果,你也可以调用show方法。这将显示一个非模态对话框,允许你在对话框打开时与页面的其余部分交互。
点击 OK 按钮将关闭对话框,因为点击监听器调用了dialog.close,但你也可以通过按 Escape 键关闭模态。为了捕获这个操作,你可以监听对话框的cancel事件。使用 Escape 键取消对话框也将触发对话框的close事件。最后,手动调用close来关闭对话框也会触发close事件。
<dialog>元素还具有一些良好的键盘可访问性功能。当你点击“显示对话框”按钮并且对话框打开时,第一个可聚焦的按钮元素会自动获得焦点。在这种情况下,它是 OK 按钮。你可以通过为你希望在打开对话框时获得初始焦点的元素添加autofocus属性来更改此行为。
当你通过按 Escape 键或点击 OK 按钮关闭对话框时,键盘焦点将返回到“显示对话框”按钮。
你可以使用 CSS 样式化对话框本身及其半透明背景。对于对话框,你可以添加一个 CSS 规则来针对<dialog>元素本身。要样式化背景——例如,你可能希望它是更不透明的黑色——你可以使用::backdrop伪元素(参见示例 13-3)。
示例 13-3. 设置背景样式
#alert::backdrop {
background: rgba(0, 0, 0, 0.75);
}
创建确认对话框
问题
你希望提示用户确认一个操作。提示应该显示一个问题,并有确认和取消按钮。
解决方案
这是另一个很好的使用场景,用于<dialog>。首先,使用提示和按钮创建你的对话框内容,如示例 13-4 所示。
示例 13-4. 确认对话框的标记
<dialog id="confirm">
<h2>Confirm</h2>
<p>Are you sure you want to do that?</p>
<button type="button" class="confirm-button">Confirm</button>
<button type="button" class="cancel-button">Cancel</button>
</dialog>
注意
这个 API 可能还不被所有浏览器支持。请参考CanIUse获取最新的兼容性数据。
您希望两个按钮都关闭对话框,但执行不同的操作。为此,可以向dialog.close传递一个字符串参数。这将在对话框本身上设置returnValue属性,您可以在接收到close事件时检查它(参见示例 13-3)。
示例 13-5. 确认对话框的事件监听器
const dialog = document.querySelector('#confirm');
confirmButton.addEventListener('click', () => {
// Close the dialog with a return value of 'confirm'
dialog.close('confirm');
});
cancelButton.addEventListener('click', () => {
// Close the dialog with a return value of 'cancel'
dialog.close('cancel');
});
dialog.addEventListener('cancel', () => {
// Canceling with the Escape key doesn't set a return value.
// Set it to 'cancel' here so the close event handler will get
// the proper value.
dialog.returnValue = 'cancel';
});
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'confirm') {
// The user clicked the Confirm button.
// Perform the action, such as creating or deleting data.
} else {
// The user clicked the Cancel button or pressed the Escape key.
// Don't perform the action.
}
});
生成的确认对话框如图 13-2 所示。

图 13-2. 确认对话框
讨论
如果用户点击其中一个按钮,则将使用取决于所点击按钮的返回值关闭对话框。对话框关闭后,将触发close事件,您可以在其中检查returnValue属性。如果returnValue是confirm,则知道用户点击了确认按钮。否则,returnValue是cancel,您可以取消操作。
此示例还监听cancel事件。如果通过按下 Escape 键关闭对话框,则触发此事件。当以这种方式关闭对话框时,对话框的returnValue不会更新,并将保留先前的任何值。为了确保returnValue正确,cancel事件处理程序将其设置。这是因为close事件在cancel事件之后触发。因为按 Escape 键会触发此事件,所以您实际上不需要监听 Escape 键是否被按下。
为什么需要处理这种情况?如果关闭对话框,它并没有被销毁。它仍然存在于 DOM 中,只是隐藏了,并且仍然设置了相同的returnValue。假设您之前打开了对话框,并且点击了确认。现在返回值设置为confirm。如果您再次打开确认对话框并通过按 Escape 键取消,则在处理close事件时,返回值仍然是confirm。为了避免这种潜在的 bug,您可以使用cancel事件处理程序将returnValue显式设置为cancel。
创建确认对话框 Web 组件
问题
您希望创建一个可定制的确认对话框。显示对话框时,您希望得到一个Promise,以便解决返回值,而不是必须监听多个事件。
解决方案
将对话框包装在 Web 组件中,使用插槽来显示确认消息。该组件公开一个showConfirmation方法,使用一个Promise。
注意
目前可能不是所有浏览器都支持此 API。有关最新的兼容性数据,请参见CanIUse。
与大多数 Web 组件一样,首先定义模板,如示例 13-6 所示。
示例 13-6. 确认对话框组件的模板
const template = document.createElement('template');
template.innerHTML = `
<dialog id="confirm">
<h2>Confirm</h2>
<p><slot></slot></p>
<button type="button" class="confirm-button">Confirm</button>
<button type="button" class="cancel-button">Cancel</button>
</dialog>
`;
模板包含一个插槽,将接收组件的子内容。接下来,示例 13-7 展示了组件的实现。
示例 13-7. 确认组件实现
class ConfirmDialog extends HTMLElement {
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(template.content.cloneNode(true));
this.dialog = shadowRoot.querySelector('dialog');
this.dialog.addEventListener('cancel', () => {
this.dialog.returnValue = 'cancel';
});
shadowRoot.querySelector('.confirm-button')
.addEventListener('click', () => {
this.dialog.close('confirm');
});
shadowRoot.querySelector('.cancel-button')
.addEventListener('click', () => {
this.dialog.close('cancel');
});
}
showConfirmation() {
this.dialog.showModal();
return new Promise(resolve => {
// Listen for the next close event and resolve the Promise.
// Resolve the Promise with a boolean indicating whether or not the
// user confirmed.
this.dialog.addEventListener('close', () => {
resolve(this.dialog.returnValue === 'confirm');
}, {
// Only listen for the event once, then remove the listener.
once: true
});
});
}
}
customElements.define('confirm-dialog', ConfirmDialog);
假设你想使用此组件来确认删除操作。你可以将元素添加到你的页面,并将确认提示作为子内容(参见 示例 13-8)。
示例 13-8. 组件标记
<confirm-dialog id="confirm-delete">
Are you sure you want to delete this item?
</confirm-dialog>
要显示对话框,选择 DOM 元素并调用它的 showConfirmation 方法。等待返回的 Promise 以获取返回值(参见 示例 13-9)。
示例 13-9. 使用确认对话框组件
const confirmDialog = document.querySelector('#confirm-delete');
if (await confirmDialog.showConfirmation()) {
// perform the delete operation
}
就像 “创建披露组件” 来自 第十二章 一样,你需要添加一些 CSS 来隐藏子内容,直到它放置在插槽中,以防止对话内容的闪烁(参见 示例 13-10)。
示例 13-10. 修复闪烁问题
confirm-dialog:not(:defined) {
display: none;
}
讨论
这是利用 Web 组件封装自定义行为的一个很好的例子。在这种情况下,你还添加了一个可以从外部调用的自定义方法。这个方法显示对话框并抽象了监听多个事件的需求。你只需要显示对话框并等待结果。
使用披露元素
问题
当你有一些内容想要使用切换按钮显示或隐藏时。
解决方案
使用内置的 <details> 元素(参见 示例 13-10)。
示例 13-11. 使用 details 元素
<details>
<summary>More Info</summary>
Here are some extra details that you can toggle.
</details>
当详情折叠时,你只会看到更多信息触发按钮,如 图 13-3 所示。

图 13-3. 折叠的详情元素
当你点击摘要时,详情打开,箭头变化指示内容已展开,如 图 13-4 所示。

图 13-4. 展开的详情元素
讨论
默认情况下,内部内容是隐藏的,你只会看到一个包含 <summary> 元素内容的披露元素。在这种情况下,按钮将显示更多信息。当你点击更多信息按钮时,隐藏内容将显示出来。如果再次点击它,内容将再次隐藏。
你可以使用 open 属性改变这种默认行为。如果你添加了这个属性,内容将初始可见(参见 示例 13-12)。
示例 13-12. 使用 open 属性控制默认状态
<details open>
<summary>More Info</summary>
This content is visible by default.
</details>
最后,你还可以使用 JavaScript 切换内容。你可以直接改变元素的 open 属性的值,就像 示例 13-13 中展示的那样。
示例 13-13. 使用 JavaScript 切换可见性
// Show the content
document.querySelector('details').open = true;
大多数浏览器对该元素有良好的辅助功能支持,识别触发元素给屏幕阅读器,并指示其展开或折叠状态。
显示弹出窗口
问题
你想通过点击按钮显示弹出内容,但仍允许用户与页面的其余部分交互。
解决方案
给元素添加 popover 属性,并在触发按钮上添加 popovertarget 属性(参见 示例 13-14)。
示例 13-14. 自动连接弹出框
<button type="button" popovertarget="greeting">Open Popover</button>
<div popover id="greeting">Hello world!</div>
注意
此 API 可能尚未被所有浏览器支持。请参见 CanIUse 获取最新的兼容性数据。
讨论
弹出框与对话框在几个方面有所不同:
-
您可以在没有任何 JavaScript 的情况下打开它。
-
与对话框不同,弹出框没有背景。
-
与对话框不同,您在显示弹出框时不会被阻止与底层页面的交互。
-
单击弹出框外部将关闭它。
要将元素变成弹出框,请给它添加 popover 属性。弹出框元素还需要一个 id 属性。为了将触发按钮链接到弹出框,该按钮被赋予一个 popovertarget 属性。此属性的值应与弹出框的 id 对应。
弹出框 API 当前的一个缺点是没有机制可以将弹出框相对于其触发器定位。默认情况下,弹出框始终出现在屏幕中心。如果您想更改其位置,则需要手动使用 CSS 进行调整。
将来,您将能够使用 CSS 锚点定位来将弹出框相对于其触发器定位。与此同时,还有第三方库,如 Floating UI,您可以使用它来增强此解决方案以定位元素。
手动控制弹出框
问题
您希望使用 popover 属性,但希望使用 JavaScript 以编程方式控制何时显示和隐藏弹出框。
解决方案
将 popover 属性设置为 manual 并调用其 showPopover、hidePopover 或 togglePopover 方法(参见 示例 13-15)。
示例 13-15. 弹出框和触发器标记
<button type="button" id="trigger">Show Popover</button>
<div id="greeting" popover="manual">Hello World!</div>
注意
此 API 可能尚未被所有浏览器支持。请参见 CanIUse 获取最新的兼容性数据。
popover="manual" 属性告诉浏览器弹出框将由手动控制(参见 示例 13-16)。要显示弹出框,请选择弹出框元素并调用其 togglePopover 方法。这将在弹出框隐藏时显示它,并在显示时隐藏弹出框。
示例 13-16. 切换按钮代码
const trigger = document.querySelector('#trigger');
const popover = document.querySelector('#greeting');
trigger.addEventListener('click', () => {
popover.togglePopover();
});
讨论
如果您想手动控制弹出框的可见性,请确保将 popover 属性设置为 manual。当弹出框元素设置为手动控制时,单击弹出框外部将 不 关闭它。要关闭弹出框,您需要调用其 hidePopover 或 togglePopover 方法之一。
将弹出框相对于一个元素进行定位
问题
您想显示一个弹出框,但不希望它出现在屏幕中间。您希望将其相对于另一个元素定位,例如触发它的按钮。
解决方案
计算元素的边界矩形,然后相应调整弹出窗口的位置。此示例将覆盖将工具提示定位在元素正下方的情况。
注意
这个 API 可能尚未被所有浏览器支持。参见CanIUse获取最新的兼容性数据。
首先,您需要对弹出窗口元素应用一些样式,如示例 13-17 所示。
示例 13-17. 弹出窗口样式
.popover {
margin: 0;
margin-top: 1em;
position: absolute;
}
默认情况下,浏览器使用边距来将弹出窗口居中显示在视口内。要相对于另一个元素定位弹出窗口,您需要移除此边距。由于您将工具提示定位在其他元素的下方,因此可以设置margin-top以在元素和弹出窗口之间留出一小段空间。最后,为了使弹出窗口随元素滚动,您需要设置position: fixed。
接下来,您可以在触发器上使用popovertarget属性,以便在单击时自动显示弹出窗口(参见示例 13-18)。
示例 13-18. 弹出窗口和触发器标记
<button type="button" class="trigger" popovertarget="popover">Show Popover</button>
<div class="popover" popover>
This is popover content anchored to the trigger button.
</div>
最后一步是在弹出窗口显示时更新其位置。您可以侦听弹出窗口元素的toggle事件,该事件在弹出窗口显示或隐藏时触发。处理此事件时,可以计算触发器元素的位置,并用其来更新弹出窗口的位置(参见示例 13-19)。
示例 13-19. 设置弹出窗口的位置
const popover = document.querySelector('.popover');
const trigger = document.querySelector('.trigger');
popover.addEventListener('toggle', event => {
// Update the position if the popover is being opened.
if (event.newState === 'open') {
// Find the position of the trigger element.
const triggerRect = trigger.getBoundingClientRect();
// Since the popover is positioned relative to the viewport,
// you need to account for the scroll offset.
popover.style.top = `${triggerRect.bottom + window.scrollY}px`;
popover.style.left = `${triggerRect.left}px`;
}
});
讨论
如果您熟悉 CSS 定位,可能会对此处position: absolute的行为感到有些困惑。通常情况下,position: absolute会将元素相对于其最近的已定位祖先元素定位。但在这种情况下,弹出窗口始终相对于视口定位。
这是因为弹出窗口位于浏览器的顶层内。这是一个特殊的层,位于文档中所有其他层的顶部。无论您的弹出窗口元素在 DOM 中的位置如何,弹出窗口内容都放置在顶层中。由于它位于这个特殊的顶层中,position: absolute将使元素相对于视口定位。
弹出窗口的位置是通过调用触发器元素上的getBoundingClientRect计算的。随着页面滚动,此矩形的顶部和底部位置将发生变化。为了确保弹出窗口正确地位于触发器下方,还需在计算中包括window.scrollY。
有一些限制需要注意。首先,如果触发器元素位于文档底部,则可能没有足够的空间在元素下方显示弹出窗口。您可以检查这一点,并在空间不足时将弹出窗口定位在触发器上方。
另一件你可能想要处理的事情是,如果在弹出窗口可见时调整窗口大小,则位置可能不会正确更新。你可以使用 ResizeObserver 或窗口的 resize 事件来处理这种情况。
显示工具提示
问题
当你希望在悬停或焦点在一个元素上时显示工具提示。
解决方案
使用手动控制的弹出窗口,在相应的鼠标事件中显示和隐藏它。这将使用与 “相对于元素定位弹出窗口的位置” 相同的定位方法,因此首先需要为弹出窗口定义自定义样式(参见 示例 13-20)。
示例 13-20。工具提示样式
#tooltip {
margin: 0;
margin-top: 1em;
position: absolute;
}
注意
这个 API 可能还不被所有浏览器支持。查看 CanIUse 获取最新的兼容性数据。
将工具提示实现为一个带有 popover 属性设置为 manual 的弹出窗口,如 示例 13-21 所示。
示例 13-21。工具提示标记
<button type="button" id="trigger">Hover Me</button>
<div id="tooltip" popover="manual" role="tooltip">Here is some tooltip content</div>
当鼠标悬停在触发器上时,计算位置并在 mouseover 事件上显示弹出窗口元素。在 mouseout 事件上隐藏弹出窗口元素(参见 示例 13-22)。
示例 13-22。显示和隐藏工具提示
const button = document.querySelector('#trigger');
const tooltip = document.querySelector('#tooltip');
function showTooltip() {
// Find the position of the trigger element.
const triggerRect = trigger.getBoundingClientRect();
// Since the popover is positioned relative to the viewport,
// you need to account for the scroll offset.
tooltip.style.top = `${triggerRect.bottom + window.scrollY}px`;
tooltip.style.left = `${triggerRect.left}px`;
tooltip.showPopover();
}
// Show and hide the tooltip in response to mouse events.
button.addEventListener('mouseover', () => {
showTooltip();
});
button.addEventListener('mouseout', () => {
tooltip.hidePopover();
});
// For keyboard accessibility, also show and hide the tooltip
// in response to focus events.
button.addEventListener('focus', () => {
showTooltip();
});
button.addEventListener('blur', () => {
tooltip.hidePopover();
});
讨论
由于这使用与 “相对于元素定位弹出窗口的位置” 相同的定位技术,因此它具有相同的限制:
-
它没有考虑到如果下方没有足够的空间来显示工具提示的情况。
-
它没有考虑调整窗口大小的情况。
显示通知
问题
当应用程序中发生某些事件时,你希望通知用户。
解决方案
使用 Notification 对象来显示原生操作系统通知。
要显示通知,必须首先请求用户的权限。这可以通过 Notification.requestPermission 方法来实现。要检查用户是否已经给予了权限,可以检查 Notification.permission 属性。
示例 13-23 展示了一个检查权限的辅助函数,如果需要的话会请求用户的权限,并返回一个布尔值,指示是否可以显示通知。
示例 13-23。检查通知权限
async function getPermission() {
// If the user has already explicitly denied permission, don't ask again.
if (Notification.permission !== 'denied') {
// The result of this permission request will update the Notification.permission
// property.
// The permission request returns a Promise.
await Notification.requestPermission();
}
// Only show a notification if Notification.permission is 'granted'.
return Notification.permission === 'granted';
}
一旦检查了权限,你可以通过创建一个新的 Notification 实例来发送新通知。使用 getPermission 辅助函数来确定是否应该显示通知(参见 示例 13-24)。
示例 13-24。显示通知
if (await getPermission()) {
new Notification('Hello!', {
body: 'This is a test notification'
});
}
如果尝试在未获得权限的情况下显示通知,则 Notification 对象将触发一个 error 事件。
图 13-5 展示了桌面电脑上此通知可能的样子。

第 13-5 图。在 macOS 14 上渲染的通知
讨论
通知只能从运行在安全上下文中的应用程序中显示。通常,这意味着它必须通过 HTTPS 或者 localhost URL 提供。
Notification.permission 属性有三个可能的值之一:
granted
用户已明确授予显示通知的权限。
denied
用户明确拒绝在提示时显示通知。
default
用户未响应通知权限请求。浏览器将其视为与 denied 情况相同。
一个 Notification 也可以触发其他一些事件:
show
在通知显示时触发
close
在通知关闭时触发
click
在通知被点击时触发
第十四章:设备集成
介绍
现代网络浏览器平台包括与各种设备信息和功能进行交互的 API,包括:
-
电池状态
-
网络状态
-
地理位置
-
设备剪贴板
-
分享内容
-
触觉反馈
在撰写本文时,某些 API 还没有得到良好支持。有些仍被视为实验性质,因此您不应立即在生产应用中使用它们。
某些浏览器可能支持这些 API,如 Chrome,但如果设备缺少所需的功能,则仍无法使用。例如,振动 API 在 Chrome 上得到了很好的支持,但在没有振动支持的笔记本电脑或其他设备上无法工作。
读取电池状态
问题
您希望在应用程序中显示设备的电池充电状态。
解决方案
使用电池状态 API。
注意
一些浏览器可能尚未支持此 API。请查看 CanIUse 获取最新的兼容性数据。
您可以通过调用navigator.getBattery来查询电池状态 API。此方法返回一个Promise,解析为包含电池信息的对象。
首先,编写一些 HTML 占位元素来保存电池状态,如 示例 14-1 所示。
示例 14-1. 电池状态标记
<ul>
<li>Battery charge level:<span id="battery-level">--</span></li>
<li>Battery charge status:<span id="battery-charging">--</span></li>
</ul>
然后,您可以查询电池状态 API 来获取电池充电水平和充电状态,并将它们添加到相应的 DOM 元素中(参见 示例 14-2)。
示例 14-2. 查询电池状态 API
const batteryLevelItem = document.querySelector('#battery-level');
const batteryChargingItem = document.querySelector('#battery-charging');
navigator.getBattery().then(battery => {
// Battery level is a number between 0 and 1\. Multiply by 100 to convert it to
// a percentage.
batteryLevelItem.textContent = `${battery.level * 100}%`;
batteryChargingItem.textContent = battery.charging ? 'Charging' : 'Not charging';
});
如果您拔掉笔记本电脑的电源,显示的充电状态将不再准确。为处理这种情况,您可以监听一些事件:
levelchange
当电池充电水平发生变化时触发
chargingchange
当电池开始充电或停止充电时触发
当这些事件发生时,您可以更新 UI。确保您有对battery对象的引用,然后添加事件侦听器(参见示例 14-3)。
示例 14-3. 监听电池事件
battery.addEventListener('levelchange', () => {
batteryLevelItem.textContent = `${battery.level * 100}%`;
});
battery.addEventListener('chargingchange', () => {
batteryChargingItem.textContent = battery.charging ? 'Charging' : 'Not charging';
});
现在您的电池状态保持更新。如果您拔掉笔记本电脑,充电状态将从“正在充电”变为“未充电”。
讨论
在撰写本文时,一些浏览器根本不支持此 API。您可以使用 示例 14-4 中的代码来检查用户浏览器上是否支持电池状态 API。
示例 14-4. 检查电池状态 API 支持
if ('getBattery' in navigator) {
// request the battery status here
} else {
// it's not supported
}
battery对象还有一些其他可用的属性。这些包括:
chargingTime
如果电池正在充电,直到电池完全充满还剩余的秒数。如果电池未充电,则该值为Infinity。
dischargingTime
如果电池未充电,直到电池完全放电还剩余的秒数。如果电池未放电,则该值为Infinity。
这两个属性还有它们自己的change事件,您可以监听chargingtimechange和dischargingtimechange。
电池状态 API 提供的信息可以做很多事情。例如,如果电池电量低,您可以禁用后台任务或其他高耗电操作。或者,甚至可以简单地提醒用户应该保存他们的更改,因为设备的电池电量低。
您还可以使用它来显示简单的电池状态指示器。如果您有一系列代表不同电池状态的图标(充满电、未充电、充电中、低电量),您可以通过监听变更事件来保持显示的图标更新。
阅读网络状态
问题
您想知道用户的网络连接速度有多快。
解决方案
使用网络信息 API 获取有关用户网络连接的数据(参见 示例 14-5)。
示例 14-5. 检查网络能力
if (navigator.connection.effectiveType === '4g') {
// User can perform high-bandwidth activities.
}
注意
此 API 可能尚未由所有浏览器支持。请查看 CanIUse 获取最新的兼容性数据。
讨论
网络信息包含在 navigator.connection 对象中。要获取网络连接能力的近似值,可以检查 navigator.connection.effectiveType 属性。目前根据下载速度,navigator.connection.effectiveType 可能的取值包括:
-
slow-2g: 最高 50 Kbps -
2g: 最高 70 Kbps -
3g: 最高 700 Kbps -
4g: 700 Kbps 及以上
这些数值是基于真实用户数据的测量结果计算的。规范说明这些数值可能会在未来更新。您可以使用这些数值来大致确定设备的网络能力。例如,effectiveType 为 slow-2g 的设备可能无法处理像高清视频流等高带宽活动。
如果页面打开时网络连接发生变化,navigator.connection 对象可以触发 change 事件。您可以监听此事件,并根据接收到的新网络连接信息调整您的应用程序。
获取设备位置
问题
您想获取设备的位置。
解决方案
使用地理位置 API 获取纬度和经度。地理位置 API 提供了 navigator.geolocation 对象,该对象用于使用 getCurrentPosition 方法请求用户的位置。这是一个基于回调的 API。getCurrentPosition 接受两个参数。第一个参数是成功的回调函数,第二个是错误回调(参见 示例 14-6)。
示例 14-6. 请求设备位置
navigator.geolocation.getCurrentPosition(position => {
console.log('Latitude: ' + position.coords.latitude);
console.log('Longitude: ' + position.coords.longitude);
}, error => {
// Either the user denied permission, or the device location could not
// be determined.
console.log(error);
});
此 API 需要用户授权。第一次调用 getCurrentPosition 时,浏览器会询问用户是否允许共享其位置。如果用户未授权,则地理位置请求失败,并调用错误回调。
如果您希望事先检查权限,以避免捕获错误,则可以使用权限 API 检查其状态(参见示例 14-7)。
示例 14-7. 检查地理位置权限
const permission = await navigator.permissions.query({
name: 'geolocation'
});
返回的权限对象具有state属性,可以具有granted、denied或prompt之一的值。如果状态为denied,则表示用户已经被提示过并拒绝了,因此您不应再尝试获取其位置,因为这将失败。
讨论
浏览器可以尝试几种方式来检测用户的位置。它可以尝试使用设备的 GPS,或者可能使用关于用户 WiFi 连接或 IP 地址的信息。在某些情况下,例如用户使用 VPN 时,基于 IP 的地理位置可能无法返回用户设备的正确位置。
地理位置 API 在浏览器支持方面非常好,因此除非您针对旧版浏览器,否则不需要检查功能支持。
除了坐标之外,position对象还包含一些其他可能不适用于所有设备的有趣信息:
altitude
设备相对海平面的高度,以米为单位
heading
设备的罗盘方位,以度数表示
speed
如果设备正在移动,则其速度以每秒米数显示。
您还可以通过调用navigator.geolocation.watchCurrentPosition来监视设备位置的更改。当位置发生变化时,浏览器定期调用您传递给此方法的回调函数,提供更新后的坐标。
在地图上显示设备位置
问题
您想要显示设备位置的地图。
解决方案
使用像 Google Maps API 或 OpenStreetMaps 这样的服务生成地图,从地理位置 API 传递纬度和经度坐标。
注意
对于此示例,您需要注册一个 Google Maps API 密钥。您可以在Google 开发者网站上找到注册 API 密钥的说明。
此示例显示了如何使用 Google Maps 嵌入 API 嵌入地图。您可以通过嵌入一个带有特别设计 URL 的iframe元素来使用 Google Maps 嵌入 API。URL 必须包含:
-
地图类型(本示例中,您需要一个
place地图) -
您的 API 密钥
-
地理位置坐标
请求设备位置,并在成功回调中创建iframe并将其添加到文档中(参见示例 14-8)。
示例 14-8. 创建地图 iframe
// Assuming you have a placeholder element in the page with the ID 'map'
const map = document.querySelector('#map');
navigator.geolocation.getCurrentPosition(position => {
const { latitude, longitude } = position.coords;
// Adjust the iframe size as desired.
const iframe = document.createElement('iframe');
iframe.width = 450;
iframe.height = 250;
// The map type is part of the URL path.
const url = new URL('https://www.google.com/maps/embed/v1/place');
// The 'key' parameter contains your API key.
url.searchParams.append('key', 'YOUR_GOOGLE_MAPS_API_KEY');
// The 'q' parameter contains the latitude and longitude coordinates
// separated by a comma.
url.searchParams.append('q', `${latitude},${longitude}`);
iframe.src = url;
map.appendChild(iframe);
});
讨论
参见此 Google 文章以了解如何正确保护 Google Maps API 密钥。
一旦获取了设备的位置信息,您可以使用许多可能的地图集成之一。Google Maps 有其他类型的 API,还有像 Mapbox 或 OpenStreetMap 这样的其他服务。您还可以集成地理编码 API 以显示带有实际地址的地图标记。
复制和粘贴文本
问题
在文本区域内,您希望添加复制和粘贴功能。用户应能够突出显示某些文本并复制它,粘贴时应替换所选的任何文本。
解决方案
使用 Clipboard API 与文本区域中选择的文本交互。您可以在 UI 中添加复制和粘贴按钮,调用 Clipboard API 中相应的功能。
注意
此 API 可能尚未被所有浏览器完全支持。请查看CanIUse以获取最新的兼容性数据。
要复制文本,获取选择的起始和结束索引,然后从文本区域的值中取该子字符串。然后,将该文本写入系统剪贴板(参见示例 14-9)。
示例 14-9. 从选择中复制文本
async function copySelection(textarea) {
const { selectionStart, selectionEnd } = textarea;
const selectedText = textarea.value.slice(selectionStart, selectionEnd);
try {
await navigator.clipboard.writeText(selectedText);
} catch (error) {
console.error('Clipboard error:', error);
}
}
粘贴类似,但还有一个额外的步骤。如果文本区域内选择了文本,则需要删除所选的文本并从剪贴板中插入新的文本(参见示例 14-10)。Clipboard API 是异步的,因此您需要等待一个Promise来接收系统剪贴板中的值。
示例 14-10. 在选择中粘贴文本
async function pasteToSelection(textarea) {
const currentValue = textarea.value;
const { selectionStart, selectionEnd } = textarea;
try {
const clipboardValue = await navigator.clipboard.readText();
const newValue = currentValue.slice(0, selectionStart)
+ clipboardValue + currentValue.slice(selectionEnd);
textarea.value = newValue;
} catch (error) {
console.error('Clipboard error:', error);
}
}
这将用剪贴板的文本替换当前选定的文本。
讨论
请注意,即使您没有处理navigator.clipboard.writeText的返回值,仍然需要等待Promise。这是因为您需要处理Promise被拒绝的情况。
另外,在粘贴时,还有另外两种情况需要注意:
-
如果没有选择文本但文本区域有焦点,则文本将粘贴在光标位置。
-
如果文本区域失去焦点,则文本将粘贴到文本区域值的末尾。
正如您可能预期的那样,通过程序从系统剪贴板读取文本可能会引起隐私问题。因此,它需要用户的许可。当您首次尝试从剪贴板读取时,浏览器会询问用户是否允许。如果他们允许,剪贴板操作完成。如果他们拒绝权限,则 Clipboard API 返回的Promise将被拒绝并显示错误。
如果您想避免权限错误,可以使用权限 API 来检查用户是否已授予从系统剪贴板读取的权限(参见示例 14-11)。
示例 14-11. 检查剪贴板读取权限
const permission = await navigator.permissions.query({
name: 'clipboard-read'
});
if (permission.state !== 'denied') {
// Continue with the clipboard read operation.
}
permission.state的三个可能值是:
granted
用户已明确授予权限。
denied
用户已明确拒绝了权限。
prompt
用户尚未被请求授权。
如果permission.state的值为prompt,则浏览器将在您首次尝试执行剪贴板读取操作时自动提示用户。
使用 Web Share API 分享内容
问题
您希望为用户提供一种使用其设备的原生分享功能来轻松分享链接的方法。
解决方案
使用 Web Share API 来分享内容。
注意
此 API 可能尚未被所有浏览器支持。请参阅 CanIUse 获取最新的兼容性数据。
调用 navigator.share 并传递包含标题和 URL 的对象(参见 示例 14-12)。在支持的设备和浏览器上,这将弹出一个熟悉的共享界面,允许用户以各种方式共享链接。
示例 14-12. 共享链接
if ('share' in navigator) {
navigator.share({
title: 'Web API Cookbook',
text: 'Check out this awesome site!',
url: 'https://browserapis.dev'
});
}
从这里,用户可以创建包含内容链接的文本消息、电子邮件或其他通信。
讨论
设备和操作系统不同,共享界面看起来也不同。例如,图 14-1 是我在运行 macOS 14 的电脑上的共享界面的截图。

图 14-1. macOS 14 上的共享界面
使设备振动
问题
您想要在应用中添加一些触觉反馈,使用户设备振动。
解决方案
使用振动 API 来对设备进行编程振动。
注意
此 API 可能尚未被所有浏览器支持。请参阅 CanIUse 获取最新的兼容性数据。
要执行单次振动,可以调用 navigator.vibrate 并传递一个整数参数(振动的持续时间),如 示例 14-13 所示。
示例 14-13. 触发单次振动
// A single vibration for 500ms
navigator.vibrate(500);
您还可以通过将数组传递给 navigator.vibrate 来触发一系列的振动(见 示例 14-14)。数组的元素被解释为一系列振动和暂停。
示例 14-14. 连续振动三次
// Vibrate for 500ms three times, with a 250ms pause in between
navigator.vibrate([500, 250, 500, 250, 500]);
讨论
此 API 在某些不支持振动的设备上也可用,例如 MacBook Pro 上的 Chrome。对于这些设备,调用 navigator.vibrate 没有效果,但也不会报错。
如果正在运行一系列的振动,可以调用 navigator.vibrate(0) 来取消任何正在进行的振动。
与自动播放视频一样,在页面加载时不能自动触发振动。用户必须在页面上进行某种交互,然后才能进行振动。
获取设备方向
问题
您想要确定设备是处于竖直还是水平方向。
解决方案
使用 screen.orientation.type 属性来获取设备的方向,或者使用 screen.orientation.angle 属性来获取设备相对于其自然方向的角度。
讨论
screen.orientation.type 可能有四个值之一,具体取决于设备及其方向(见 图 14-2):
-
portrait-primary: 0 度(自然设备位置) -
portrait-secondary: 180 度 -
landscape-primary: 90 度 -
landscape-secondary: 270 度

图 14-2. 不同的方向值
前述数值适用于像手机这样自然方向为纵向的设备。对于其他自然方向为横向(如某些平板电脑)的设备,数值则相反:
-
landscape-primary: 0 度(设备的自然位置) -
landscape-secondary: 180 度 -
portrait-primary: 90 度 -
portrait-secondary: 270 度
screen.orientation 对象还具有 change 事件,您可以监听该事件以获取设备方向变化的通知。
第十五章:测量性能
介绍
JavaScript 应用程序中有许多第三方工具可用于性能测量,但浏览器还内置了一些方便的工具来捕获性能指标。
导航时间 API 用于捕获有关初始页面加载的性能数据。您可以检查页面加载所需的时间、DOM 变得可交互所需的时间等。它返回一组时间戳,指示页面加载期间每个事件发生的时间。
资源定时 API 允许您检查下载资源和进行网络请求所花费的时间。这涵盖页面资源,如 HTML 文件、CSS 文件、JavaScript 文件和图像,还包括使用 Fetch API 进行的异步请求。
用户定时 API 是一种计算任意操作经过的时间的方法。您可以创建性能 标记,这些是时间点,以及 度量,这是两个标记之间的计算持续时间。
所有这些 API 在页面上创建性能条目缓冲区。这是所有类型性能条目的单一集合。您可以随时检查此缓冲区,也可以使用 PerformanceObserver 异步监听新的性能条目添加。
性能条目使用高精度时间戳。时间以毫秒为单位测量,但在某些浏览器中还可以包含微秒精度的小数部分。在浏览器中,这些时间戳存储为 DOMHighResTimeStamp 对象。这些是数字,从页面加载时开始计时,表示自页面加载以来给定条目发生的时间。
本章的示例探讨了收集性能指标的解决方案。如何处理这些指标取决于您。您可以使用 Fetch 或 Beacon API 将性能指标发送到 API 进行收集和后续分析。
在开发过程中,您可以使用这些性能指标进行调试,或者保留它们以收集用户的实际性能指标。这些数据可以发送到分析服务进行聚合和分析。
测量页面加载性能
问题
您想要收集有关页面加载事件时间的信息。
解决方案
查找类型为 navigation 的单个性能条目,并从性能条目对象中检索导航时间戳(参见 示例 15-1)。然后可以计算这些时间戳之间的间隔,以了解各种页面加载事件所需的时间。
示例 15-1. 查找导航时间性能条目
// There is only one navigation performance entry.
const [navigation] = window.performance.getEntriesByType('navigation');
此对象具有许多属性。表 15-1 列出了一些有用的计算示例。
表 15-1. 导航时间计算
| 指标 | 开始时间 | 结束时间 |
|---|---|---|
| 首字节时间 | startTime |
responseStart |
| DOM 交互时间 | startTime |
domInteractive |
| 总加载时间 | startTime |
loadEventEnd |
讨论
导航性能条目的startTime属性始终为 0。
此条目不仅包含时间信息,还包括数据传输量、HTTP 响应代码和页面 URL 等信息。这些信息对于确定应用程序在首次加载时的响应速度非常有用。
测量资源性能
问题
想要获取加载在页面上的资源的信息。
解决方案
在性能缓冲区中查找资源性能条目(见示例 15-2)。
示例 15-2. 获取资源性能条目
const entries = window.performance.getEntriesByType('resource');
每个页面资源都会生成一个条目。资源包括 CSS 文件、JavaScript 文件、图像以及页面发出的任何其他请求。
您可以通过计算startTime和responseEnd属性之间的差来确定每个资源的加载时间。资源的 URL 可在name属性中找到。
讨论
使用 Fetch API 进行的任何网络请求都会显示为一个资源。这使得该 API 对于分析您的 REST API 端点的实际性能非常有用。
页面首次加载时,性能缓冲区包含初始页面加载期间请求的所有资源的条目。随后的请求在生成时会添加到性能缓冲区中。
找出加载最慢的资源
问题
想要获取加载时间最长的资源列表。
解决方案
对资源性能条目列表进行排序和过滤。由于这个列表只是一个数组,因此可以对其调用诸如sort和slice之类的方法。要找出资源加载所花费的时间,可以通过responseEnd和startTime时间戳之间的差来计算。
示例 15-3 展示了如何查找加载最慢的五个资源。
示例 15-3. 查找加载最慢的五个资源
const slowestResources = window.performance.getEntriesByType('resource')
.sort((a, b) =>
(b.responseEnd - b.startTime) - (a.responseEnd - a.startTime))
.slice(0, 5);
讨论
关键在于sort调用。此调用将每对加载时间进行比较,并按加载时间降序对整个列表进行排序。然后,slice调用只获取排序后数组的前五个元素。
如果要获取加载时间最快的五个资源列表,只需反转比较加载时间的顺序即可(见示例 15-4)。
示例 15-4. 查找加载最快的 5 个资源
const fastestResources = window.performance.getEntriesByType('resource')
.sort((a, b) =>
(a.responseEnd - a.startTime) - (b.responseEnd - b.startTime))
.slice(0, 5);
反转比较意味着数组按升序而非降序排序。现在,slice调用返回加载时间最快的五个资源。
找到特定资源的时间信息
问题
想要查找特定资源请求的时间信息。
解决方案
使用方法window.performance.getEntriesByName按特定 URL 查找资源(见示例 15-5)。
示例 15-5. 查找特定 URL 的所有资源时间
// Look up all requests to the /api/users API
const entries = window.performance.getEntriesByName('https://localhost/api/users',
'resource');
讨论
资源条目的名称是其 URL。getEntriesByName的第一个参数是 URL,第二个参数表示您对资源时间信息感兴趣。
如果对给定 URL 有多个请求,则返回的数组中将有多个资源条目。
分析渲染性能
问题
您希望记录页面上渲染某些数据所需的时间。
解决方案
在渲染开始之前创建一个性能标记。一旦渲染完成,再创建另一个标记。然后,您可以在两个标记之间创建一个度量来记录渲染所需的时间。
假设您有一个DataView组件,可以用来在页面上渲染一些数据(参见示例 15-6)。
示例 15-6. 测量渲染性能
// Create the initial performance mark just before rendering.
window.performance.mark('render-start');
// Create the component and render the data.
const dataView = new DataView();
dataView.render(data);
// When rendering is done, create the ending performance mark.
window.performance.mark('render-end');
// Create a measure between the two marks.
const measure = window.performance.measure('render', 'render-start', 'render-end');
measure对象包含度量的开始时间和计算出的持续时间。
讨论
每当创建性能标记和度量时,它们都会添加到页面的性能缓冲区以供以后查找。例如,如果您想稍后查找render度量,可以使用window.performance.getEntriesByName(参见示例 15-7)。
示例 15-7. 按名称查找度量
// There is only one 'render' measure, so you can use
// array destructuring to get the first (and only) entry.
const [renderMeasure] = window.performance.getEntriesByName('render');
标记和度量也可以通过传递detail选项与它们关联的数据。例如,在渲染示例 15-6 中的数据时,您可以在创建度量时将数据本身作为元数据传递。
使用这种方式创建度量时,需要在选项对象内包含起始和结束标记(参见示例 15-8)。
示例 15-8. 使用数据测量渲染性能
// Create the initial performance mark just before rendering.
window.performance.mark('render-start');
// Create the component and render the data.
const dataView = new DataView();
dataView.render(data);
// When rendering is done, create the ending performance mark.
window.performance.mark('render-end');
// Create a measure between the two marks, passing the
// data being rendered as the measure detail.
const measure = window.performance.measure('render', {
start: 'render-start',
end: 'render-end',
detail: data
});
稍后,当您查找此性能条目时,度量的detail元数据将可用。
分析多步骤任务
问题
您希望收集多步骤过程的性能数据。您希望获取整个序列的时间,但也需要各个步骤的时间。例如,您可能希望从 API 加载一些数据,然后对这些数据进行处理。在这种情况下,您希望知道 API 请求的时间,处理的时间以及总共花费的时间。
解决方案
创建多个标记和度量。您可以在多个度量计算中使用给定的标记。
在示例 15-9 中,有一个 API 返回一些用户交易。一旦接收到交易数据,您希望对交易数据进行分析。最后,分析数据将发送到另一个 API。
示例 15-9. 分析多步骤过程
window.performance.mark('transactions-start');
const transactions = await fetch('/api/users/123/transactions');
window.performance.mark('transactions-end');
window.performance.mark('process-start');
const analytics = processAnalytics(transactions);
window.performance.mark('process-end');
window.performance.mark('upload-start');
await fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(analytics),
headers: {
'Content-Type': 'application/json'
}
});
window.performance.mark('upload-end');
一旦进程完成并且标记已经获取,您可以使用这些标记生成多个度量,如示例 15-10 所示。
示例 15-10. 生成度量
console.log('Download transactions:',
window.performance.measure(
'transactions', 'transactions-start', 'transactions-end'
).duration
);
console.log('Process analytics:',
window.performance.measure(
'analytics', 'process-start', 'process-end'
).duration
);
console.log('Upload analytics:',
window.performance.measure(
'upload', 'upload-start', 'upload-end'
).duration
);
console.log('Total time:',
window.performance.measure(
'total', 'transactions-start', 'upload-end'
).duration
);
讨论
这个示例展示了如何创建多个标记和度量来收集一组任务的性能数据。给定的标记可以在多个度量中使用多次。示例 15-10 为每个步骤创建一个度量,然后生成整个任务持续时间的最终度量。这是通过获取下载任务的第一个标记和上传任务的最后一个标记,并计算它们之间的度量来完成的。
监听性能条目
问题
您希望在有新的性能条目时收到通知,以便将其报告给分析服务。例如,考虑您希望在每次发出 API 请求时通知性能统计数据的情况。
解决方案
使用PerformanceObserver监听所需类型的新性能条目。对于 API 请求,类型将是resource(参见 示例 15-11)。
示例 15-11. 使用PerformanceObserver
const analyticsEndpoint = 'https://example.com/api/analytics';
const observer = new PerformanceObserver(entries => {
for (let entry of entries.getEntries()) {
// Only interested in 'fetch' entries.
// Use the Beacon API to send a quick request containing the performance
// entry data.
if (entry.initiatorType === 'fetch') {
navigator.sendBeacon(analyticsEndpoint, entry);
}
}
});
observer.observe({ type: 'resource' });
讨论
PerformanceObserver会为每个网络请求触发,包括您发送到分析服务的请求。因此,示例 15-11 在发送请求之前会检查给定条目是否不是分析端点。如果没有这个检查,您会陷入无限的 POST 请求循环中。当发出网络请求时,观察者触发并发送 POST 请求。这会创建一个新的性能条目,进而再次调用观察者。每次向分析服务发送 POST 请求都会触发新的观察者回调。
为了防止在短时间内向分析服务发送大量请求,对于真实的应用程序,您可能希望将性能条目收集到缓冲区中。一旦缓冲区达到一定大小,您可以将缓冲区中的所有条目一次性发送(参见 示例 15-12)。
示例 15-12. 批量发送性能条目
const analyticsEndpoint = 'https://example.com/api/analytics';
// An array to hold buffered entries. Once the buffer reaches the desired size,
// all entries are sent in a single request.
const BUFFER_SIZE = 10;
let buffer = [];
const observer = new PerformanceObserver(entries => {
for (let entry of entries.getEntries()) {
if (entry.initiatorType === 'fetch' && entry.name !== analyticsEndpoint) {
buffer.push(entry);
}
// If the buffer has reached its target size, send the analytics request.
if (buffer.length === BUFFER_SIZE) {
fetch(analyticsEndpoint, {
method: 'POST',
body: JSON.stringify(buffer),
headers: {
'Content-Type': 'application/json'
}
});
// Reset the buffer now that the batched entries have been sent.
buffer = [];
}
}
});
observer.observe({ type: 'resource' });
第十六章:使用控制台
简介
尽管您的意图良好,但您的代码可能出现问题。有几种调试工具可供使用。今天的浏览器内置了强大的调试器,可让您逐步执行代码并检查变量和表达式的值。但有时,您可能希望保持简单并使用控制台。
最基本的形式是通过调用console.log与消息进行交互。此消息将打印到浏览器的 JavaScript 控制台。虽然比基于断点的调试更冗长,但有时仍然可以在运行时记录和检查值。
除了简单的console.log外,您还可以执行其他操作,如分组消息、使用计数器、显示表格,甚至使用 CSS 样式化输出。还有其他日志级别(错误、警告、调试)可供分类和筛选控制台消息使用。
样式化控制台输出
问题
您希望对控制台日志输出应用一些 CSS 样式。例如,也许您想要增加字体大小并改变颜色。
解决方案
在您的日志消息中使用%c 指令来指示您想要样式化的文本。对于每个%c的使用,都要在console.log中添加另一个参数,其中包含 CSS 样式(参见示例 16-1)。
示例 16-1. 样式化控制台输出
console.log('%cHello world!', 'font-size: 2rem; color: red;');
console.log('This console message uses %cstyled text. %cCool!',
'font-style: italic;',
'font-weight: bold;'
);
图 16-1 展示了控制台中样式文本的效果。

图 16-1. 样式化的控制台输出
讨论
console.log接受可变数量的参数。对于每个%c指令的使用,应有相应的额外参数,其中包含要应用于该文本部分的样式。
注意在图 16-1 中,每个%c部分之间的样式会被重置。第一部分的斜体字体不会延续到第二部分的粗体字体中。
使用日志级别
问题
您希望在控制台中区分信息、警告和错误消息。
解决方案
分别使用console.info,console.warn和console.error代替console.log(参见示例 16-2)。这些消息的样式不同,并且大多数浏览器允许您按其级别筛选日志消息。
示例 16-2. 使用不同的日志级别
console.info('This is an info message');
console.warn('This is a warning message');
console.error('This is an error message');
消息显示不同的样式和图标,如图 16-2 所示。

图 16-2. 不同的日志级别(在 Chrome 中显示)
讨论
警告和错误消息还会显示可以展开并在控制台中查看的堆栈跟踪。这使得跟踪错误发生的位置变得更加容易。
创建命名记录器
问题
您希望以给定颜色的模块名称为前缀记录来自应用程序不同模块的消息。
解决方案
在console.log函数上使用Function.prototype.bind,绑定模块名前缀和颜色样式(参见示例 16-3)。
示例 16-3. 创建一个命名的日志记录器
function createLogger(name, color) {
return console.log.bind(console, `%c${name}`, `color: ${color};`);
}
createLogger函数返回一个新的日志函数,你可以像调用console.log一样调用它,但消息有一个彩色前缀(参见示例 16-4)。
示例 16-4. 使用命名的日志记录器
const rendererLogger = createLogger('renderer', 'blue');
const dataLogger = createLogger('data', 'green');
// Outputs with a blue "renderer" prefix
rendererLogger('Rendering component');
// Outputs with a green "data" prefix
dataLogger('Fetching data');
这会按照彩色前缀渲染日志消息,如图 16-3 所示。

图 16-3. 彩色日志记录器(在 Chrome 中显示)
讨论
以这种方式调用bind会创建console.log函数的部分应用版本,它会自动添加前缀和颜色。你传递给它的任何其他参数都会在前缀和颜色样式之后添加。
在表格中显示对象数组
问题
你有一个对象数组,想要以易读的方式记录它们。
解决方案
将数组传递给console.table,它会显示一个表格。每个对象属性对应一列,数组中每个对象对应一行(参见示例 16-5)。
示例 16-5. 记录表格
const users = [
{ firstName: "John", lastName: "Smith", department: "Sales" },
{ firstName: "Emily", lastName: "Johnson", department: "Marketing" },
{ firstName: "Michael", lastName: "Davis", department: "Human Resources" },
{ firstName: "Sarah", lastName: "Thompson", department: "Finance" },
{ firstName: "David", lastName: "Wilson", department: "Engineering" }
];
console.table(users);
图 16-4 展示了数据以表格形式记录的方式。

图 16-4. 日志记录的表格(在 Chrome 中显示)
讨论
通过向console.table传递第二个参数,可以限制显示的对象属性。这个参数是一个属性名的数组。如果提供了这个参数,则只会显示这些属性在表格输出中。
console.table也可以与对象一起使用。在示例 16-6 中,index列包含属性名而不是数组索引。
示例 16-6. 向console.table传递一个对象
console.table({
name: 'sysadmin',
email: 'admin@example.com'
});
示例 16-6 生成了图 16-5 中的表格。

图 16-5. 记录的表格(在 Chrome 中显示)
示例 16-7 将用户记录在表格中,但只显示 firstName 和 lastName 列(参见图 16-6)。
示例 16-7. 限制表格列
const users = [
{ firstName: "John", lastName: "Smith", department: "Sales" },
{ firstName: "Emily", lastName: "Johnson", department: "Marketing" },
{ firstName: "Michael", lastName: "Davis", department: "Human Resources" },
{ firstName: "Sarah", lastName: "Thompson", department: "Finance" },
{ firstName: "David", lastName: "Wilson", department: "Engineering" }
];
console.table(users, ['firstName', 'lastName']);

图 16-6. 仅显示名字和姓氏列(在 Chrome 中显示)
渲染的表格也是可排序的。你可以点击列名以按该列排序表格(参见图 16-7)。

图 16-7. 按姓氏排序表格(在 Chrome 中显示)
使用控制台计时器
问题
你想要计算某段代码的执行时间,用于调试目的。
解决方案
使用console.time和console.timeEnd方法(参见示例 16-8)。
示例 16-8. 使用console.time和console.timeEnd
// Start the' loadTransactions' timer.
console.time('loadTransactions');
// Load some data.
const data = await fetch('/api/users/123/transactions');
// Stop the 'loadTransactions' timer.
// Prints: "loadTransactions: <elapsed time> ms"
console.timeEnd('loadTransactions');
当你使用console.time并指定一个计时器名称时,它会启动命名的计时器。进行要进行性能分析的工作,完成后,使用相同的计时器名称调用console.timeEnd。消耗的时间和计时器名称将被打印到控制台上。
如果使用一个不存在的计时器名称调用 console.timeEnd,不会抛出错误,但会在控制台上记录一个警告消息,指示计时器不存在。
讨论
这与在 第十五章 中描述的 window.performance.mark 和 window.performance.measure 不同。console.time 通常在调试期间用于临时计时。显著的区别在于,console.time 和 console.timeEnd 不会向性能时间线添加条目。一旦为给定计时器调用了 console.timeEnd,该计时器就会被销毁。如果需要在内存中持久保存计时数据,你可能会想使用性能 API。
使用控制台分组
问题
想要更好地组织日志消息的组。
解决方案
使用 console.group 创建可以展开和折叠的嵌套消息组(参见 示例 16-9)。
示例 16-9. 使用控制台分组
const users = [
{ id: 1, firstName: "John", lastName: "Smith", department: "Sales" },
{ id: 2, firstName: "Emily", lastName: "Johnson", department: "Marketing" },
{ id: 3, firstName: "Michael", lastName: "Davis", department: "Human Resources" },
{ id: 4, firstName: "Sarah", lastName: "Thompson", department: "Finance" },
{ id: 5, firstName: "David", lastName: "Wilson", department: "Engineering" }
];
console.log('Updating user data');
for (const user of users) {
console.group(`User: ${user.firstName} ${user.lastName}`);
console.log('Loading employee data from API');
const response = await fetch(`/api/users/${user.id}`);
const userData = await response.json();
console.log('Updating profile');
userData.lastUpdated = Date.now();
console.log('Saving user data');
await fetch(`/api/users/${user.id}`, {
method: 'POST',
body: JSON.stringify(userData),
headers: {
'Content-Type': 'application/json'
}
});
console.groupEnd();
}
这将在控制台中打印分组消息。你可以展开和折叠这些组,以便专注于你感兴趣的特定组,就像 图 16-8 中所示。

图 16-8. 分组的控制台消息(在 Chrome 中显示)
讨论
你也可以使用控制台分组来追踪复杂算法。分组可以嵌套多层,这样在复杂计算过程中跟踪日志消息会更加轻松。当需要默认折叠一个组时,可以使用 console.groupCollapsed 而不是 console.group。
使用计数器
问题
想要统计你的代码中某部分被调用的次数。
解决方案
使用一个在你的代码中唯一的计数器名称调用 console.count。每次执行 console.count 语句时,它都会打印并递增计数器的值。这样你可以跟踪 console.count 被调用的次数。
示例 16-10. 使用计数器
const users = [
{ id: 1, firstName: "John", lastName: "Smith", department: "Sales" },
{ id: 2, firstName: "Emily", lastName: "Johnson", department: "Marketing" },
{ id: 3, firstName: "Michael", lastName: "Davis", department: "Human Resources" },
{ id: 4, firstName: "Sarah", lastName: "Thompson", department: "Finance" },
{ id: 5, firstName: "David", lastName: "Wilson", department: "Engineering" }
];
users.forEach(user => {
console.count('user');
});
示例 16-10 输出的结果如 示例 16-11 所示。
示例 16-11. 计数器输出
user: 1
user: 2
user: 3
user: 4
user: 5
讨论
console.count 用于追踪循环迭代或递归函数调用。和其他控制台方法一样,它主要用于调试目的,而不是用于收集使用指标。
你还可以不带任何参数调用 console.count,这样它将使用一个名为 default 的计数器。
记录变量及其值
问题
想要记录一个变量名及其值,而不必重复输入变量名。
解决方案
使用对象简写符号来记录包含变量的对象(参见 示例 16-12)。
示例 16-12. 记录变量及其值
const username = 'sysadmin';
// logs { username: 'sysadmin' }
console.log({ username });
这将创建一个名为 username 的对象,其值为 username 变量的值,并将其记录到控制台中,如 图 16-9 所示。

图 16-9. 具有命名值的对象(在 Chrome 中显示)
讨论
在对象简写符号出现之前,您需要两次输入变量名(参见 示例 16-13)。
示例 16-13. 记录变量及其值(不使用对象简写)
const username = 'sysadmin';
console.log('username', username);
这不是很大的改变,但是它是一个快速省时的捷径。
记录堆栈跟踪
问题
您希望查看代码当前执行时的堆栈跟踪。
解决方案
使用 console.trace 记录当前调用堆栈的跟踪(参见 示例 16-14)。
示例 16-14. 使用 console.trace
function foo() {
function bar() {
console.trace();
}
bar();
}
foo();
这将输出显示在 图 16-10 中的堆栈跟踪。

图 16-10. 记录堆栈跟踪(在 Chrome 中显示)
讨论
堆栈跟踪是一个有用的调试工具。它显示调用堆栈的当前状态。堆栈跟踪中的第一个条目是 console.trace 调用本身。然后,下一个条目是调用包含 console.trace 调用的函数的函数,依此类推。在大多数浏览器中,您可以单击堆栈跟踪元素以跳转到代码的那一行。您可以使用此功能添加日志语句或设置断点。
验证预期值
问题
在调试过程中,您希望确保表达式具有预期的值。如果没有,您希望看到控制台错误。
解决方案
使用 console.assert 来在表达式不匹配预期值时打印错误(参见 示例 16-15)。
示例 16-15. 使用 console.assert
function updateUser(user) {
// Log an error if the user id is null.
console.assert(user.id !== null, 'user.id must not be null');
// Update the user.
return fetch(`/api/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify(user),
headers: {
'Content-Type': 'application/json'
}
});
}
如果 updateUser 被调用时使用没有 id 属性的用户对象,将记录错误。
讨论
断言通常不用于生产环境,因为它是像其他控制台方法一样的调试工具。需要注意的是,如果断言失败,它会打印一个错误消息,但不会抛出错误或者停止函数的执行。在 示例 16-15 中,如果用户 ID 的断言失败,它仍然尝试进行 PUT 请求来更新用户。这可能会导致 404 错误,因为 URL 中为 null。
检查对象的属性
问题
您想要检查对象的属性,包括深度嵌套的属性和原型链。
解决方案
使用 console.dir 来记录对象。
示例 16-16 展示了如何使用 console.dir 来检查 console 对象本身。
示例 16-16. 使用 console.dir
console.dir(console);
图 16-11 显示了可展开的树状结构,该结构被记录到控制台中。对象中的每个函数和属性都是可展开的。它还包括原型链,也可以展开并检查。

图 16-11. 在控制台对象上使用 console.dir(在 Chrome 中显示)
讨论
在某些浏览器版本中,console.log 也提供了一个交互式结构来检查对象。虽然这种行为依赖于浏览器,console.dir 总是检查对象,如图 16-11 所示。
欲了解更多信息,请查看官方控制台规范。
第十七章:CSS
介绍
在现代浏览器环境中,CSS 不仅允许您编写样式规则,还具有一组可以用来进一步增强应用程序的 API。
CSS 对象模型(CSSOM)允许您从 JavaScript 代码中以编程方式设置内联样式。不仅如此,您甚至可以在运行时更改 CSS 变量的值。
在 第八章 中,您看到了一个示例,使用 window.matchMedia 来程序化地检查媒体查询是否在当前页面上匹配。
本章包含一些使用这些 CSS 相关 API 的实用示例。在撰写时,一些这些 API 的浏览器支持并不理想。始终在使用之前检查浏览器兼容性。
文本范围的突出显示
问题
您想要在文档中的一段文本范围上应用突出显示效果。
解决方案
围绕所需文本创建一个 Range 对象,然后使用 CSS 自定义高亮 API 将高亮样式应用于该范围。
第一步是创建一个 Range 对象。此对象表示文档中的文本区域。示例 17-1 展示了一个通用的实用函数,用于根据文本节点和要突出显示的文本创建范围。
示例 17-1. 创建一个范围
/**
* Given a text node and a substring to highlight, creates a Range object covering
* the desired text.
*/
function getRange(textNode, textToHighlight) {
const startOffset = textNode.textContent.indexOf(textToHighlight);
const endOffset = startOffset + textToHighlight.length;
// Create a Range for the text to highlight.
const range = new Range();
range.setStart(textNode, startOffset);
range.setEnd(textNode, endOffset);
return range;
}
注意
这个 API 可能尚未被所有浏览器支持。请参阅 CanIUse 获取最新的兼容性数据。
假设您有在 示例 17-2 中显示的 HTML 元素。
示例 17-2. 一些 HTML 标记
<p id="text">
This is some text. We're using the CSS Custom Highlight API to highlight some of
the text.
</p>
如果您想要突出显示文本“highlight some of the text”,您可以使用 getRange 助手来创建围绕该文本的 Range(参见 示例 17-3)。
示例 17-3. 使用 getRange 助手
const node = document.querySelector('#text');
const range = getRange(node.firstChild, 'highlight some of the text');
现在您有了范围,需要向浏览器的高亮注册表注册一个新的高亮效果。通过使用该范围创建一个新的 Highlight 对象,然后将该 Highlight 对象传递给 CSS.highlights.set 函数(参见 示例 17-4)。
示例 17-4. 注册高亮
const highlight = new Highlight(range);
CSS.highlights.set('highlight-range', highlight);
这注册了高亮,但默认情况下没有视觉效果。接下来,您需要创建一些 CSS 样式,以便应用于高亮。通过使用 ::highlight 伪元素完成此操作。您可以将此伪元素与在 示例 17-4 中注册高亮的关键字组合使用(参见 示例 17-5)。
示例 17-5. 设置高亮样式
::highlight(highlight-range) {
background-color: #fef3c7;
}
应用此样式后,范围内的文本现在以浅琥珀色突出显示。
讨论
您还可以使用 <mark> 元素来突出显示内容。示例 17-6 展示了如何使用 <mark> 来突出显示某些文本。
示例 17-6. 使用 mark 元素进行突出显示
<p id="text">
This is some text. We're using the mark element to
<mark>highlight some of the text</mark>.
</p>
这与使用 CSS 自定义高亮 API 具有相同的视觉效果,但关键区别在于使用 <mark> 需要将新元素插入到 DOM 中。这可能取决于您添加新元素的位置。
例如,如果您想要高亮的文本跨越多个元素,可能无法仅使用 <mark> 元素来实现,同时保持有效的 HTML。考虑 示例 17-7 中的 HTML。
示例 17-7. 一些需要高亮的标记
<p>
This is a paragraph, which is being highlighted.
</p>
<p>
The highlight extends to this paragraph. This is not highlighted.
</p>
如果您想要高亮显示“正在进行高亮。高亮扩展到这一段落。”,您无法仅使用单个 <mark> 元素实现(参见 示例 17-8)。
示例 17-8. 无效的 HTML
<p>
This is a paragraph, <mark>which is being highlighted.
</p>
<p>
The highlight extends to this paragraph</mark>. This is not highlighted.
</p>
这不是有效的 HTML。解决方案是使用两个单独的 <mark> 元素,但这样就不是单个连续高亮区域了。
使用 CSS 自定义高亮 API 可以实现跨多个标签的高亮效果,通过创建跨多个标签的范围并应用高亮效果。
防止未样式化文本的闪烁
问题
您希望避免在使用 Web 字体时出现未样式化文本的闪烁。
解决方案
使用 CSS 字体加载 API 显式加载您希望在应用程序中使用的字体,并延迟渲染任何文本,直到字体加载完成。
要使用此 API 加载字体,首先创建一个包含有关要加载的字体面的 FontFace 对象。示例 17-9 使用了 Roboto 字体。
示例 17-9. 创建 Roboto 字体
const roboto = new FontFace(
'Roboto',
'url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2)', {
style: 'normal',
weight: 400
});
文档具有全局 fonts 属性,它是一个 FontFaceSet,包含文档中使用的所有字体面。要使用此字体面,您需要将其添加到 FontFaceSet 中(参见 示例 17-10)。
示例 17-10. 将 Roboto 添加到全局 FontFaceSet
document.fonts.add(roboto);
到目前为止,您只是定义了字体,但尚未加载任何内容。您可以通过在 FontFace 对象上调用 load 来启动加载过程(参见 示例 17-11)。这会返回一个 Promise,一旦字体加载完成就会解析。
示例 17-11. 等待字体加载完成
roboto.load()
.then(() => {
// Font has been loaded and is ready for use.
});
为了防止未样式化文本的闪烁,您需要隐藏使用该字体的文本,直到字体加载完成。例如,如果您的应用程序显示初始加载动画,则可以继续动画,直到必要的字体加载完成,然后删除加载程序并开始渲染应用程序。
如果您的应用程序使用多个字体,可以等待 document.fonts.ready 的 Promise。一旦所有字体加载并准备好,该 Promise 就会解析。
讨论
在使用 CSS 的 Web 字体时,字体是通过 @font-face 规则声明的,其中包含要下载的字体文件的 URL。如果在字体加载完成之前渲染文本,则会使用回退系统字体。一旦字体准备就绪,文本会重新以正确的字体进行渲染。这可能会导致不良效果,例如如果字体度量不同,则会出现布局转移。
使用 @font-face 的缺点是无法知道字体何时已加载并准备好使用。通过使用 CSS 字体加载 API,您可以更好地控制字体加载,并确切地知道何时可以安全地开始使用特定字体来渲染文本。
如果加载字体时出现错误——例如,可能是字体 URL 打错了——字体的 load 方法返回的 Promise 将会以错误拒绝。
动画化 DOM 过渡
问题
你希望在添加或移除 DOM 元素时展示一个动画过渡效果。
解决方案
使用视图过渡 API 提供两个状态之间的动画过渡效果。
注意
该 API 可能尚未被所有浏览器支持。请查看 CanIUse 获取最新的兼容性数据。
此 API 用于在两个 DOM 状态之间应用过渡效果。要启动视图过渡,请调用 document.startViewTransition 函数。此函数将回调函数作为其参数。您需要在此回调函数内执行您的 DOM 更改。
在 示例 17-12 中,假设您有一个单页面应用。该应用的每个视图都是具有唯一 ID 的顶级 HTML 元素。要在视图之间进行路由,可以移除当前视图并添加新视图。
示例 17-12. 一个简单的视图过渡
function showAboutPage() {
document.startViewTransition(() => {
document.querySelector('#home-page').style.display = 'none';
document.querySelector('#about-page').style.display = 'block';
});
}
这应用了一个基本的交叉淡入淡出过渡效果在两个视图之间。
如果要调整交叉淡入淡出过渡的速度,可以通过一些 CSS 来实现,如 示例 17-13 所示。
示例 17-13. 减缓过渡速度
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 2s;
}
讨论
视图过渡效果通过有效地捕获当前 DOM 状态的屏幕截图来工作。一旦回调内进行的 DOM 更改完成,另一个截图就会被捕获。浏览器在页面上创建一些伪元素,并在它们之间应用动画过渡。
创建的伪元素包括:
::view-transition
一个包含所有视图过渡的顶级覆盖层
::view-transition-group(<name>)
单个视图过渡
::view-transition-image-pair(<name>)
包含正在过渡的两个图像
::view-transition-old(<name>)
旧 DOM 状态的图像
::view-transition-new(<name>)
新 DOM 状态的图像
一些伪元素需要一个 name 参数。可以是以下之一:
*
匹配所有视图过渡组
root
匹配 root 过渡组,如果没有提供自定义名称,则使用默认名称。
自定义标识符
您可以通过在要过渡的元素上设置 view-transition-name 属性来指定自定义标识符。
你可以使用 CSS 选择器来定位这些伪元素并应用不同的动画效果。可以通过创建 @keyframes 规则来实现这一点,并将该动画应用于 ::view-transition-old 或 ::view-transition-new 伪元素。
在运行时修改样式表
问题
你希望动态向页面样式表添加 CSS 规则。
解决方案
使用 CSSStyleSheet 的 insertRule 方法添加所需的规则(见 示例 17-14)。
示例 17-14. 添加 CSS 规则
const [stylesheet] = document.styleSheets;
stylesheet.insertRule(`
.some-selector {
background-color: red;
}
`);
讨论
如果你有动态添加到页面的新 HTML 内容(如单页应用程序),可能会需要这样做。可以在添加新内容时动态添加样式规则。
条件性地设置 CSS 类
问题
你想要仅在满足某个条件时将 CSS 类应用于元素。
解决方案
使用元素的 classList 的 toggle 方法(参见 示例 17-15)。
示例 17-15. 条件性地切换类名
// Assume isExpanded is a variable with the current expanded
// state
element.classList.toggle('expanded', isExpanded);
讨论
如果在没有第二个参数的情况下调用 toggle,它会在当前未设置时添加类名,或者在已经设置时移除类名。
除了 toggle,你还可以使用 add 和 remove 来通过添加和移除给定的类名来操作类列表。如果在已经设置了类名的情况下调用 add,则没有任何效果。类似地,如果在未设置类名的情况下调用 remove,也没有任何效果。
匹配媒体查询
问题
你想要使用 JavaScript 检查特定的媒体查询是否满足。例如,你可以使用 prefers-color-scheme 媒体查询来确定用户操作系统是否设置为暗主题。
解决方案
使用 window.matchMedia 来评估媒体查询或监听其变化(参见 示例 17-16)。
示例 17-16. 检查暗色主题
const isDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
讨论
window.matchMedia 返回一个 MediaQueryList 对象,不仅具有 matches 属性,还可以监听 change 事件。如果媒体查询的结果发生变化,将触发此事件。
例如,如果用户的操作系统颜色主题设置在应用程序打开时发生更改,则 prefers-color-scheme 查询的 change 事件将触发。然后,可以检查新的匹配状态(参见 示例 17-17)。
示例 17-17. 监听媒体查询变化
const query = window.matchMedia('(prefers-color-scheme: dark)');
query.addEventListener('change', () => {
if (query.matches) {
// switch to dark mode
} else {
// switch to light mode
}
});
获取元素的计算样式
问题
你想要找到来自样式表(而不是内联样式)的特定 CSS 样式。
解决方案
使用 window.getComputedStyle 来计算元素的最终样式。
谨慎使用 getComputedStyle
调用 getComputedStyle 时,会强制浏览器重新计算样式和布局,这可能成为性能瓶颈。
考虑在 示例 17-18 中应用了一些样式的 HTML 元素。
示例 17-18. 带有样式的一些 HTML
<style>
#content {
background-color: blue;
}
.container {
background-color: red;
color: white;
}
</style>
<div id="content" class="container">What color am I?</div>
要确定应用于元素的样式,请将元素传递给 window.getComputedStyle(参见 示例 17-19)。
示例 17-19. 获取计算样式
const content = document.querySelector('#content');
const styles = window.getComputedStyle(content);
console.log(styles.backgroundColor);
因为 ID 选择器的特异性高于类选择器,所以它会赢得冲突,并且 styles.backgroundColor 是蓝色。在某些浏览器上,它可能不是字符串“blue”,而是诸如 rgb(0, 0, 255) 的颜色表达式。
讨论
元素的 style 属性仅适用于内联样式。参考 示例 17-20。
示例 17-20. 具有内联样式的元素
<style>
#content {
background-color: blue;
}
</style>
<div id="content" style="color: white;">Content</div>
此示例将 color 属性指定为内联样式,因此可以通过引用 style 属性访问。然而,背景颜色来自样式表,不能通过这种方式找到(参见 示例 17-21)。
示例 17-21. 检查内联样式
const content = document.querySelector('#content');
console.log(content.style.backgroundColor); // empty string
console.log(content.style.color); // 'white'
因为 getComputedStyle 计算元素的最终样式,它包含样式表样式和内联样式(参见 示例 17-22)。
示例 17-22. 检查计算样式
const content = document.querySelector('#content');
const styles = window.getComputedStyle(content);
console.log(styles.backgroundColor); // 'rgb(0, 0, 255)'
console.log(styles.color); // 'rgb(255, 255, 255)'
第十八章:媒体
介绍
现代浏览器提供了丰富的 API,用于处理视频和音频流。WebRTC API 支持从摄像头等设备创建这些流。
可以在 <video> 元素中实时播放视频流,并从中捕获视频帧以保存为图像或上传到 API。<video> 元素还可用于播放从流中录制的视频。
在这些 API 可用之前,您需要浏览器插件来访问用户的摄像头。如今,您可以使用媒体捕获和流 API 只需少量代码即可从摄像头和麦克风开始读取数据。
录制屏幕
问题
您想要捕获用户屏幕的视频。
解决方案
使用屏幕捕获 API 捕获屏幕视频,然后将其设置为 <video> 元素的源(参见 示例 18-1)。
示例 18-1. 捕获屏幕的视频
async function captureScreen() {
const stream = await navigator.mediaDevices.getDisplayMedia();
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm'
});
mediaRecorder.addEventListener('dataavailable', event => {
const blob = new Blob([event.data], {
type: 'video/webm',
});
const url = URL.createObjectURL(blob);
video.src = url;
});
mediaRecorder.start();
}
注意
屏幕内容并不会实时流到 <video> 元素中。相反,屏幕共享被捕获到内存中。一旦完成屏幕捕获,录制的视频将在 <video> 元素中播放。
这里有很多事情要做。首先,调用 navigator.mediaDevices.getDisplayMedia() 来启动屏幕捕获。根据浏览器和操作系统的不同,您会看到有关屏幕录制的提示(见 图 18-1)。

图 18-1. 来自 macOS Chrome 的屏幕录制提示
此函数返回一个 Promise,解析为用户屏幕的 MediaStream。一旦此 Promise 解析,屏幕即开始录制,但数据尚未传送到任何地方。
要停止录制,请单击由浏览器提供的停止共享按钮或调用 mediaRecorder.stop()。这将触发 dataavailable 事件。
接下来,事件处理程序创建一个包含捕获视频数据的 Blob 并创建一个对象 URL。然后,您可以将视频的 src 属性设置为此对象 URL。
完成此步骤后,屏幕录制将开始在浏览器中播放。
讨论
此示例使用具有良好浏览器支持的 video/webm MIME 类型。WebM 是一种开放的音频和视频文件格式,支持多种编解码器。
如果用户未授权屏幕录制,getDisplayMedia 返回的 Promise 将被拒绝并带有错误。
此示例展示了如何在 <video> 元素中播放屏幕录制内容,但是一旦获取了 Blob 和对象 URL,您还可以做其他事情。
例如,您可以使用 Fetch API 将 Blob 发送到服务器(参见 示例 18-2)。
示例 18-2. 上传捕获的屏幕录制
const form = new FormData();
// Here, "blob" is the Blob created in the captureScreen method.
formData.append('file', blob);
fetch('/api/video/upload', {
method: 'POST',
body: formData
});
您还可以触发浏览器下载捕获的视频(参见 示例 18-3)。
示例 18-3. 使用隐藏链接触发下载
const link = document.createElement('a');
// Here, "url" is the object URL created in the captureScreen method.
link.href = url;
link.textContent = 'Download';
link.download = 'screen-recording.webm';
link.click();
从用户摄像头捕获图像
问题
您希望激活用户的摄像头并拍照。
解决方案
使用 navigator.mediaDevices.getUserMedia 获取来自摄像头的视频。
首先,您需要创建几个元素,如示例 18-4 所示。
示例 18-4. 从摄像头捕获图像的标记
<style>
#canvas {
display: none;
}
#photo {
width: 640px;
height: 480px;
}
</style>
<canvas id="canvas"></canvas>
<img id="photo">
<video id="preview">
画布被隐藏,因为它是生成图像之前的中间步骤。
总体方法如下:
-
将视频流发送到
<video>元素,以显示来自摄像头的实时预览。 -
当您想要拍摄照片时,在画布上绘制当前视频帧。
-
从画布创建数据 URL 以生成 JPEG 图像,并在
<img>元素中设置它。
首先,打开视频流并将其附加到 <video> 元素上(见示例 18-5)。
示例 18-5. 获取视频流
const preview = document.querySelector('#preview');
async function startCamera() {
const stream = await navigator.mediaDevices.getUserMedia(
{
video: true,
audio: false
}
);
preview.srcObject = stream;
preview.play();
}
稍后,通过按钮点击或其他事件捕获图像(见示例 18-6)。
示例 18-6. 捕获图像
// This is the <video> element.
const preview = document.querySelector('#preview');
const photo = document.querySelector('#photo');
const canvas = document.querySelector('#canvas');
function captureImage() {
// Resize the canvas based on the device pixel density.
// This helps prevent a blurred or pixellated image.
canvas.width = canvas.width * window.devicePixelRatio;
canvas.height = canvas.height * window.devicePixelRatio;
// Get the 2D context from the canvas and draw the current video frame.
const context = canvas.getContext('2d');
context.drawImage(preview, 0, 0, canvas.width, canvas.height);
// Create a JPEG data URL and set it as the image source.
const dataUrl = canvas.toDataURL('image/jpeg');
photo.src = dataUrl;
}
讨论
正如您可能期望的那样,从摄像头读取会引发隐私问题。因此,第一次为用户打开相机将在浏览器中触发权限请求,用户必须接受以授予访问权限。如果此请求被拒绝,navigator.mediaDevices.getUserMedia 返回的 Promise 将被拒绝并伴有错误。
从用户摄像头捕获视频
问题
您希望从用户的摄像头录制视频并在浏览器中播放它。
解决方案
此解决方案包含以下几个步骤:
-
使用
getUserMedia打开来自摄像头的流。 -
使用
<video>元素显示视频预览。 -
使用
MediaRecorder录制视频。 -
在
<video>元素中播放已录制的视频。
对于此示例,您需要 <video> 元素和开始/停止录制的按钮(见示例 18-7)。
示例 18-7. 设置视频元素
<video id="preview" muted></video>
<button id="record-button">Record</button>
<button id="stop-record-button">Stop Recording</button>
接下来,打开视频流并设置 <video> 元素来预览它(见示例 18-8)。
示例 18-8. 打开音频和视频流
const preview = document.querySelector('#preview');
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
preview.srcObject = stream;
preview.play();
一旦流打开,下一步是设置 MediaRecorder(见示例 18-9)。
示例 18-9. 设置 MediaRecorder
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm'
});
mediaRecorder.addEventListener('dataavailable', event => {
const blob = new Blob([event.data], {
type: 'video/webm',
});
const url = URL.createObjectURL(blob);
// Clear the "muted" flag so that the playback will
// include audio.
preview.muted = false;
// Reset the source of the video element to the object
// URL just created.
preview.srcObject = null;
preview.src = url;
// Start playing the recording immediately.
preview.autoplay = true;
preview.loop = true;
preview.controls = true;
});
最后一步是连接按钮以启动和停止 MediaRecorder(见示例 18-10)。
示例 18-10. 添加按钮事件处理程序
document.querySelector('#record-button').addEventListener('click', () => {
mediaRecorder.start();
});
document.querySelector('#stop-record-button').addEventListener('click', () => {
mediaRecorder.stop();
});
讨论
您可能已经注意到,初始时 video 元素上设置了 muted 属性。您打开的媒体流包含视频和音频。您想要预览视频,但可能不希望预览音频 —— 这会导致录制的任何音频立即在扬声器上播放回来,可能会影响录制或甚至导致麦克风反馈。为防止这种情况发生,您可以在 <video> 元素上设置 muted 属性。
稍后,当播放录制内容时,您需要清除 muted 标志,以使录制的音频也能播放。
确定系统媒体能力
问题
你想知道特定的媒体类型是否被浏览器支持。
解决方案
使用媒体能力 API 来查询浏览器是否支持给定的媒体类型。结果将告诉你该媒体类型是否被支持(参见示例 18-11)。
示例 18-11. 检查媒体能力
navigator.mediaCapabilities.decodingInfo({
type: 'file',
audio: {
contentType: 'audio/mp3'
}
}).then(result => {
if (result.supported) {
// mp3 audio is supported!
}
});
navigator.mediaCapabilities.decodingInfo({
type: 'file',
audio: {
contentType: 'audio/webm;codecs=opus'
}
}).then(result => {
if (result.supported) {
// WebM audio is supported with the opus codec.
}
});
讨论
示例 18-11 展示了一些检查音频编解码器支持的示例。媒体能力 API 还允许你检查特定视频格式的支持。你不仅可以按编解码器查询,还可以按帧率、比特率、宽度和高度等其他属性查询(参见示例 18-12)。
示例 18-12. 检查支持的视频格式
navigator.mediaCapabilities.decodingInfo({
type: 'file',
video: {
contentType: 'video/webm;codecs=vp8',
bitrate: 4000000, // 4 MB
framerate: 30,
width: 1920,
height: 1080
}
}).then(result => {
if (result.supported) {
// This WebM configuration is supported.
}
});
应用视频滤镜
问题
你想给视频流应用一个滤镜效果。
解决方案
将视频流渲染到一个<canvas>中,并对 canvas 应用 CSS 滤镜。
你将视频流设置为<video>元素的源,如“从用户摄像头捕获图像”。然而,在这种情况下,你会隐藏<video>元素,因为它只是一个中间步骤。
然后,根据所需的帧率,将视频的每一帧渲染到一个<canvas>元素中。从那里,你可以应用 CSS 滤镜。
首先,标记(参见示例 18-13)。
示例 18-13. 视频滤镜示例的标记
<canvas id="canvas"></canvas>
<video id="preview" style="display: none;"></video>
然后,打开媒体流并将其设置在<video>元素中(参见示例 18-14)。
示例 18-14. 设置视频流
async function startCamera() {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false
});
// Hook up the video element to the stream.
preview.srcObject = stream;
preview.play();
// Resize the canvas based on the device pixel density.
// This helps prevent a blurred or pixelated image.
canvas.width = canvas.width * window.devicePixelRatio;
canvas.height = canvas.height * window.devicePixelRatio;
const context = canvas.getContext('2d');
// Target frame rate of 30 FPS—draw each frame to the canvas.
setInterval(() => {
context.drawImage(preview, 0, 0, canvas.width, canvas.height);
}, 30 / 1000);
}
现在,你可以向<canvas>元素应用 CSS 滤镜(参见示例 18-15)。
示例 18-15. 应用滤镜
#canvas {
filter: hue-rotate(90deg);
}
讨论
每 0.03 秒,视频的当前帧将被绘制到 canvas 上。这实际上是媒体流的预览,使用<video>元素作为中间件。这是因为目前还没有直接将媒体流“绘制”到<canvas>元素的方法。
除了使用 CSS 设置滤镜之外,你还可以使用 canvas 2D 上下文的filter属性来设置它们。
第十九章:总结思考
介绍
希望您发现本书中涵盖的示例和 API 对您有所帮助并且有趣。希望您能够将在本书中学到的内容应用于提升您的 JavaScript 应用程序。
为第三方库辩护
本书的主要主题之一是,在不需要第三方库的情况下,您可以做很多事情。这是事实,但不要觉得必须以不使用第三方库为代价。有时使用内置的浏览器 API 可以避免依赖性,但您可能需要编写额外的“粘合”代码以使其适应您要完成的任务。
有些浏览器 API 可能难以处理。以 IndexedDB API 为例,它是一个强大的数据持久化和访问层,但其 API 基于回调,可能让人感到棘手。有些库封装了 IndexedDB 并提供了更简单或更强大的 API。例如,Dexie.js 使用基于Promise的 API 封装了 IndexedDB。
最终,一切都是一种权衡。如果在 JavaScript 包中有多余的空间来提供更简单的开发者体验,那可能是值得的。
检测特性,而非浏览器版本
如果您需要检查用户是否在支持您想要使用的 API 的浏览器上运行,您可能会考虑查看用户代理字符串,并确定用户的浏览器版本。尽量避免这样做。这种方法极不可靠,而且轻而易举地伪装用户代理字符串以冒充其他浏览器。
反之,检测特定功能是否可用。例如,如果您想检查浏览器是否支持 IndexedDB,只需检查window对象中是否存在indexedDB属性(参见示例 19-1)。
示例 19-1. 检查 IndexedDB 支持
if ('indexedDB' in window) {
// IndexedDB is supported!
}
Polyfills
如果您需要支持旧版浏览器,可能仍然可以使用某些 API 的 polyfill。这是一个第三方 JavaScript 库,用于添加缺失的功能。这些 polyfill 可能不如内置 API 效率高,但它们允许您在其他情况下不支持的浏览器中使用新的 API。
当然,有些 API 无法进行 polyfill,因为它们依赖于与加速计或地理位置等本机设备能力的集成。如果浏览器无法与这些系统服务通信,任何第三方代码都无法弥补这一差距。
展望未来
未来可能会有更多令人兴奋的 API 出现,进一步扩展基于浏览器的应用程序的功能,而无需插件或第三方库。本节简要介绍了一些即将推出的实验性 API。
Web Bluetooth API
不久后,您将能够使用 Web 蓝牙 API 在浏览器中原生地与蓝牙设备交互。它提供了基于Promise的接口,用于发现和读取连接的蓝牙设备的信息。您可以读取诸如电池电量之类的数据,或者监听来自设备的通知。
这通过与设备的 GATT(通用属性)配置文件进行交互来实现,该配置文件定义了蓝牙设备支持的服务和特性。这使 API 通用化,允许它灵活地与支持 GATT 的任何设备一起工作。
Web NFC API
近场通信(NFC)允许设备在彼此靠近时交换信息。Web NFC API 将允许设备与 NFC 硬件交换消息和信息。
此 API 提供使用 NFC 数据交换格式(NDEF)交换消息的能力。这是 NFC 论坛发布的标准化格式。
EyeDropper API
EyeDropper API 将允许您通过取色器工具从屏幕上的像素中选择颜色。此工具将在浏览器窗口内外都能工作,使您能够从屏幕上的任何位置选择颜色。
您可以通过调用EyeDropper构造函数来创建一个取色器。EyeDropper提供一个open方法,在屏幕上显示一个取色器界面,并返回一个Promise。一旦您使用取色器选择了像素,Promise将以所选像素的颜色解析。
Barcode Detection API
此 API 将使您的应用程序能够读取条形码和 QR 码。它支持多种标准条形码类型。这将是一个多功能的 API,可以从许多不同的图像源读取条形码:图像和视频元素、Blob、画布元素等。
通过将图像数据传递给BarcodeDetector的detect方法来检测条形码。这将返回一个Promise,该Promise解析为关于检测到的任何条形码及其值的数据。
Cookie Store API
当前在浏览器中使用 cookie 的机制并不是很方便。document.cookie属性是一个包含当前站点的 cookie 名称和值映射的单个字符串。
即将推出的 Cookie Store API 提供了一个异步、更健壮的接口,用于访问 cookie 信息。您可以使用CookieStore.get方法查找单个 cookie 的详细信息,该方法返回一个Promise,解析为具有给定名称的 cookie 的信息。
它还允许您监听change事件,该事件在 cookie 数据更改时触发。
Payment APIs
支付请求 API 提供了一种在浏览器中启动支付的方式。然后,您可以使用 Payment Handler API 处理支付,而无需重定向到另一个网站。
这将使您在使用外部支付处理器时提供更一致的体验。
寻找下一个步骤
网络世界一直在变化。如果你想了解其他网页浏览器 API 的即将到来的情况,一些好的资源包括:
-
MDN Web 文档有一个Web API 页面,展示当前和即将推出或实验性 API 的概述。
-
W3C 标准和草案页面包含一个可搜索的标准和各个开发阶段草案规范的目录。


浙公网安备 33010602011771号