JavaScript-高性能实用指南-全-

JavaScript 高性能实用指南(全)

原文:zh.annas-archive.org/md5/C818A725F2703F2B569E2EC2BCD4F774

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

今天的网络环境发生了巨大变化-不仅在创建 Web 应用程序方面,而且在创建服务器端应用程序方面也是如此。以 jQuery 和 Bootstrap 等 CSS 框架为主导的前端生态系统已经被具有响应性的完整应用程序所取代,这些应用程序可能被误认为是在桌面上运行的应用程序。

我们编写这些应用程序的语言也发生了戏剧性的变化。曾经是一团混乱的var和作用域问题已经变成了一种快速且易于编程的语言。JavaScript 不仅改变了我们编写前端的方式,还改变了后端编程的体验。

我们现在能够用我们前端使用的语言来编写服务器端应用程序。JavaScript 也现代化了,甚至可能通过 Node.js 推广了事件驱动系统。我们现在可以用 JavaScript 编写前端和后端的代码,甚至可能在两者之间共享我们生成的 JavaScript 文件。

然而,尽管应用程序的格局已经发生了变化,许多人已经转向了现代框架,比如 React 和 Vue.js 用于前端,Express 和 Sails 用于后端,但许多这些开发人员并不了解内部运作。虽然这展示了进入生态系统是多么简单,但也展示了我们如何不理解如何优化我们的代码库是多么容易。

本书专注于教授高性能 JavaScript。这不仅意味着快速执行速度,还意味着更低的内存占用。这意味着任何前端系统都能更快地到达用户手中,我们也能更快地启动我们的应用程序。除此之外,我们还有许多新技术推动了网络的发展,比如 Web Workers。

本书对象

本书适合那些对现代网络的最新功能感兴趣的人。除此之外,它也适合那些对减少内存成本并提高速度感兴趣的人。对计算机工作原理甚至 JavaScript 编译器工作原理感兴趣的人也会对本书内容感兴趣。最后,对于那些对 WebAssembly 感兴趣但不知道从何开始的人来说,这是学习基本知识的好起点。

本书内容

第一章,“网络高性能工具”,将介绍我们的应用程序可以运行的各种浏览器。我们还将介绍各种工具,帮助我们调试、分析,甚至运行临时代码来测试我们的 JavaScript 功能。

第二章,“不可变性与可变性-安全与速度之间的平衡”,将探讨可变/不可变状态的概念。我们将介绍何时何地使用每种状态。除此之外,我们还将介绍如何在拥有可变数据结构的同时创建不可变性的幻觉。

第三章,“原生之地-看看现代网络”,将介绍 JavaScript 的发展历程以及截至 ECMAScript 2020 的所有新功能。除此之外,我们还将探讨各种高级功能,比如柯里化和以函数式方式编写。

第四章,“实际例子-看看 Svelte 和原生”,将介绍一个相当新的框架叫做 Svelte。它将介绍这个编译成原生 JavaScript 的框架,并探讨它如何通过直观的框架实现闪电般快速的结果。

第五章,“切换上下文-无 DOM,不同的原生”,将介绍低级别的 Node.js 工作。这意味着我们将看看各种可用的模块。我们还将看看如何在没有额外库的情况下实现惊人的结果。

第六章,消息传递-了解不同类型,将介绍不同进程之间交流的不同方式。我们将涵盖未命名管道,命名管道,套接字,以及通过 TCP/UDP 进行传输。我们还将简要介绍 HTTP/2 和 HTTP/3。

第七章,流-理解流和非阻塞 I/O,将介绍流 API 以及如何利用它。我们将介绍每种类型的流以及每种流的用例。除此之外,我们还将实现一些实用的流,经过一些修改后,可以在其他项目中使用。

第八章,数据格式-查看除 JSON 之外的不同数据类型,将研究模式和无模式数据类型。我们将研究实施数据格式,然后看看流行的数据格式是如何运作的。

第九章,实际示例-构建静态服务器,将使用前面四章的概念构建一个静态站点生成器。虽然它可能没有 GatsbyJS 那么强大,但它将具有我们从静态站点生成器中期望的大多数功能。

第十章,Workers-了解专用和共享工作者,将回到前端,看看两种 Web Worker 类型。我们将利用这些来处理来自主线程的数据。除此之外,我们还将看看如何在工作者和主进程之间交流。

第十一章,Service Workers-缓存和加速,将介绍服务工作者和服务工作者的生命周期。除此之外,我们还将看看如何在渐进式 Web 应用程序中利用服务工作者的实际示例。

第十二章,构建和部署完整的 Web 应用程序,将使用 CircleCI 工具进行持续集成/持续部署CI/CD)。我们将看到如何使用它来部署我们在第九章构建的 Web 应用程序,实际示例-构建静态服务器,到服务器上。我们甚至将在部署之前检查应用程序的一些安全性。

第十三章,WebAssembly-简要了解 Web 上的本机代码,将介绍这项相对较新的技术。我们将看到如何编写低级 WebAssembly 以及它在 Web 上的运行方式。然后,我们将把注意力转向为浏览器编写 C++。最后,我们将看看一个移植的应用程序以及背后的 WebAssembly。

为了充分利用本书

总的来说,运行大多数代码的要求是最低的。需要一台能够运行 Chrome、Node.js 和 C 编译器的计算机。我们将在本书的最后使用的 C 编译器将是 CMake。这些系统应该在所有现代操作系统上都能运行。

对于 Chrome 来说,拥有最新版本将是有帮助的,因为我们将利用一些提案阶段或 ECMAScript 2020 中的功能。我们正在使用最新的 LTS 版本的 Node.js(v12.16.1),并且避免使用 Node.js 13,因为它不会被提升为 LTS。除此之外,Windows 的命令行工具并不是很好,因此建议下载 Cmder,从cmder.net/,以在 Windows 上拥有类似 Bash 的 shell。

最后,需要一个现代的集成开发环境或编辑器。我们将在整本书中使用 Visual Studio Code,但也可以使用许多其他替代方案,比如 Visual Studio、IntelliJ、Sublime Text 3 等。

书中涉及的软件/硬件 操作系统要求
Svelte.js v3 Windows 10/OSX/Linux
ECMAScript 2020 Windows 10/OSX/Linux
Node.js v12.16.1 LTS Windows 10/OSX/Linux
WebAssembly Windows 10/OSX/Linux

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的软件解压或提取文件夹:

  • Windows 系统使用 WinRAR/7-Zip

  • Mac 系统使用 Zipeg/iZip/UnRarX

  • Linux 系统使用 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-High-Performance-Web-Development-with-JavaScript。如果代码有更新,将在现有的 GitHub 存储库中更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/ 上找到。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:"这与console.timetimeEnd非常相似,但它应该展示生成器可用的内容。"

代码块设置如下:

for(let i = 0; i < 100000; i++) {
    const j = Library.outerFun(true);
}

任何命令行输入或输出都是这样写的:

> npm install what-the-pack

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"如果我们在 Windows 中按下F12打开 DevTools,我们可能会看到Shader Editor选项卡已经打开。"

警告或重要提示会显示为这样。提示和技巧会显示为这样。

第一章:网络高性能工具

JavaScript 已经成为网络的主要语言。它不需要额外的运行时,也不需要编译过程来运行 JavaScript 应用程序。任何用户都可以打开一个网络浏览器并开始在控制台中输入来学习这种语言。除此之外,语言和文档对象模型DOM)也有许多进步。所有这些都为开发人员提供了一个丰富的环境来创造。

除此之外,我们可以将网络视为一次构建,随处部署的环境。在一个操作系统上运行的代码也将在另一个操作系统上运行。如果我们想要针对所有浏览器,可能需要进行一些调整,但它可以被视为一次开发,随处部署的平台。然而,所有这些都导致了应用程序变得臃肿,使用了昂贵的框架和不必要的 polyfill。大多数工作职位都需要这些框架,但有时我们不需要它们来创建丰富的应用程序。

本章重点介绍我们将用来帮助构建和分析高性能网络应用程序的工具。我们将研究不同的现代浏览器及其独特的贡献。然后我们将深入研究 Chrome 开发者工具。总的来说,我们将学到以下内容:

  • 每个浏览器中嵌入的不同开发工具

  • 深入了解以下 Chrome 工具:

  • 性能选项卡

  • 内存选项卡

  • 渲染器选项卡

  • jsPerf 和代码基准测试

技术要求

本章的先决条件如下:

不同环境的开发工具

有四种被认为是现代浏览器的浏览器。它们是 Edge、Chrome、Firefox 和 Safari。这些浏览器遵守最新的标准,并且正在积极开发。我们将看看它们各自的发展情况以及一些独特的功能。

Internet Explorer 接近于终止其生命周期。浏览器只会进行关键的安全修复。新应用程序应该尽量淘汰这个浏览器,但如果仍有客户群在使用它,我们可能需要为其开发。在本书中,我们不会专注于为其提供 polyfill。

Edge

微软的 Edge 浏览器是他们对现代网络的看法。借助 EdgeHTML 渲染器和 Chakra JavaScript 引擎,在许多基准测试中表现良好。虽然 Chakra 引擎与 Chrome 或 Firefox 有不同的优化,但从纯 JavaScript 的角度来看,这是一个有趣的浏览器。

在撰写本书时,微软正在将 Edge 的渲染引擎更改为 Chromium 系统。这对 Web 开发人员有许多影响。首先,这意味着更多的浏览器将运行 Chromium 系统。这意味着在跨浏览器开发方面要担心的事情会减少。虽然需要支持当前形式的 Edge,但它可能会在未来一年内消失。

在功能方面,Edge 相对于其他浏览器来说比较轻。如果我们需要对其进行任何类型的性能测试,最好的选择是使用 jsPerf 或其他工具来分析代码,而不是使用内置工具。此外,Chakra 引擎利用不同的优化技术,因此在 Chrome 或 Safari 上有效的代码可能对 Edge 来说不够优化。在 Windows 上打开开发者工具,我们可以按下F12。这将弹出通常的控制台对话框,如下所示:

我们不会介绍 Edge 的任何有趣功能,因为他们的开发工具中的大多数,如果不是全部,功能与其他浏览器中的功能相同。

基于 Chromium 的最新 Edge 浏览器将支持 OS X 用户,这意味着与 Windows 或 Linux 相比,OS X 用户进行跨浏览器开发将变得更加容易。

Safari

苹果的 Safari 浏览器基于 WebKit 渲染引擎和 JavaScriptCore 引擎。WebKit 引擎是 Chrome 的 Blink 引擎的基础,JavaScriptCore 引擎在 OS X 操作系统的一些地方使用。关于 Safari 的一个有趣的点是,如果我们运行 Windows 或 Linux,我们将无法直接访问它。

要访问 Safari,我们需要利用在线服务。BrowserStack 或 LambdaTest 以及其他一些服务都可以为我们完成这项工作。有了这些服务中的任何一个,我们现在可以访问我们可能没有的浏览器。感谢 LambdaTest,我们将利用他们的免费服务简要查看 Safari。

再次,我们会注意到 Safari 浏览器开发工具并不是太多。所有这些工具在其他浏览器中也都可用,并且通常在这些其他浏览器中更加强大。熟悉每个界面可以帮助在特定浏览器中进行调试,但不需要花费太多时间查看那些没有任何特定功能的浏览器。

Firefox

Mozilla 的 Firefox 使用了 SpiderMonkey JavaScript 引擎和增强的 Gecko 引擎。当他们将他们的项目 Servo 代码的部分添加到 Gecko 引擎中时,Gecko 引擎得到了一些很好的改进,从而提供了一个不错的多线程渲染器。Mozilla 一直处于最新 Web 技术的前沿。他们是最早实现 WebGL 的之一,他们也是最早实现 WebAssembly 和WebAssembly System InterfaceWASI)标准的之一。

接下来是关于着色器和着色器语言OpenGL Shading LanguageGLSL)的一些技术讨论。建议您继续阅读以了解更多信息,但对于那些迷失方向的人来说,访问文档以了解更多关于这项技术的信息可能会有所帮助,网址为developer.mozilla.org/en-US/docs/Games/Techniques/3D_on_the_web/GLSL_Shaders

如果我们在 Windows 中打开 DevTools,按F12,我们可能会看到Shader Editor选项卡。如果没有,可以转到右侧的三个点菜单,打开设置。在左侧,应该有一个带有默认开发者工具标题的复选框列表。继续选择Shader Editor选项。现在,如果我们进入此选项卡,应该会看到以下内容:

该选项卡正在请求画布上下文。基本上,该工具正在寻找一些项目:

  • 一个画布元素

  • 一个启用了 3D 的上下文

  • 顶点和片段着色器

我们仓库中的一个名为shader_editor.html的文件包含了设置画布和着色器的必要代码,这样我们就可以利用着色器编辑器。这些着色器是在 Web 上以编程方式使用 GPU 的方法。它们利用了一个名为 OpenGL ES 3.0 的 OpenGL 规范的版本。这使我们能够使用该规范中的几乎所有内容,特别是顶点和片段着色器。

要使用这些着色器进行编程,我们使用一种称为GL Shading LanguageGLSL)的语言。这是一种类似于 C 的语言,具有许多特定于它的功能,例如 swizzling。Swizzling 是利用矢量组件(最多四个)并以我们选择的任何形状或形式组合它们的能力。这看起来像下面这样的例子:

vec2 item = vec2(1.0, 1.0);
vec4 other_item = item.xyxx;

这创建了一个四元素向量,并将xyzw分量分别设置为两元素向量中的xyxx项。命名可能需要一段时间才能习惯,但它确实使某些事情变得更容易。如上所示的一个例子,我们需要从两元素向量中创建一个四元素向量。在基本的 JavaScript 中,我们需要执行以下操作:

const item = [1.0, 1.0];
const other_item = [item[0], item[1], item[0], item[0]];

我们可以利用 swizzling 的简写语法,而不是编写前面的内容。GLSL 系统中还有其他功能,我们将在后面的章节中进行介绍,但这应该让我们对这些语言有所了解。

现在,如果我们打开shader_editor.html文件并重新加载页面,我们应该会看到一个白色的页面。如果我们查看着色器编辑器,我们可以看到右侧我们正在将一个名为gl_FragColor的变量设置为一个四元素向量,其中所有元素都设置为1.0。如果我们将它设置为vec4(0.0, 0.0, 0.0, 1.0)会发生什么?我们现在应该在左上角看到一个黑色的框。这展示了向量的四个分量是颜色的红色、绿色、蓝色和 alpha 分量,范围从0.01.0,就像 CSS 的rgba系统一样。

除了单一的纯色之外,还有其他颜色组合吗?每个着色器都带有一些预先定义的全局变量。其中之一,在片段着色器中,称为gl_FragCoord。这是窗口空间中左下角的坐标,范围从0.01.0(这里应该有一个主题,说明在 GLSL 中哪些值被认为是好的)。如果我们将四元素向量的x元素设置为gl_FragCoordx元素,将y元素设置为gl_FragCoordy元素,我们应该会得到一个简单的白色框,但左侧和底部各有一个单像素的边框。

除了 swizzling 和全局变量,我们还可以在这些着色器中使用其他数学函数。让我们将这些xy元素包装在sin函数中。如果我们这样做,我们应该在屏幕上得到一个漂亮的格子图案。这应该给出片段着色器实际在做什么的提示。它试图根据各种输入在 3D 空间中绘制该位置,其中一个输入是来自顶点着色器的位置。

然后它试图绘制构成我们用顶点着色器声明的网格内部的每个像素。此外,这些片段是同时计算的(或者尽可能多地由显卡来计算),因此这是一个高度并行化的操作。

这应该给我们一个很好的窥视 GLSL 编程世界的机会,以及 GLSL 语言除了 3D 工作之外可以为我们提供的可能性。现在,我们可以更多地尝试这些概念,并转向最后一个浏览器 Chrome。

Chrome

谷歌的 Chrome 浏览器使用 Blink 引擎,并使用著名的 V8 JavaScript 运行时。这是 Node.js 内部使用的相同运行时,因此熟悉开发工具将在很多方面帮助我们。

Chrome 一直处于网络技术的前沿,就像 Firefox 一样。他们是第一个实现各种想法的人,比如 QUIC 协议,HTTP/3 标准就是基于它。他们创建了原生插件接口(NaCL),帮助创建了 WebAssembly 的标准。他们甚至是使 Web 应用程序开始变得更像本地应用程序的先驱,通过提供蓝牙、游戏手柄和通知等 API。

我们将特别关注 Chrome 附带的 Lighthouse 功能。Lighthouse 功能可以从 Chrome 浏览器的审计选项卡中访问。一旦我们在这里,我们可以使用各种设置来设置我们的审计:

  • 首先,我们可以根据页面是在移动设备上运行还是在桌面上运行来审计我们的页面。然后我们可以审计我们网站的各种功能。

  • 如果我们正在开发渐进式 Web 应用程序,我们可能会决定不需要 SEO。另一方面,如果我们正在开发营销网站,我们可能会决定不需要渐进式 Web 应用程序检查。我们可以模拟受限连接。

  • 最后,我们可以从干净的存储开始。如果我们的应用程序利用了内置浏览器缓存系统,比如会话存储或本地存储,这将特别有帮助。

举例来说,让我们看看外部网站,并查看它在审核应用程序中的表现如何。我们将查看的网站是亚马逊,位于www.amazon.com。这个网站应该是我们要遵循的一个很好的例子。我们将把它作为桌面应用程序来查看,不进行任何限制。如果我们运行审核,我们应该得到类似以下的结果:

正如我们所看到的,主页在性能和最佳实践方面表现良好,但 Chrome 警告我们有关可访问性和 SEO 性能。在可访问性方面,似乎图片没有alt属性,这意味着屏幕阅读器将无法正常工作。此外,似乎开发人员的tabindexes高于 0,可能导致选项卡顺序不遵循正常页面流程。

如果我们想要设置自己的系统进行测试,我们需要在本地托管我们的页面。有许多出色的静态站点托管解决方案(我们将在本书后面构建一个),但如果我们需要托管内容,最简单的方法之一是下载 Node.js 并安装static-server模块。我们将在后面深入介绍如何启动和运行 Node.js,并如何创建我们自己的服务器,但目前这是最好的选择。

我们已经看过了主要的现代 Web 浏览器,我们应该瞄准它们。它们每个都有自己的能力和限制,这意味着我们应该在所有这些浏览器上测试我们的应用程序。然而,本书的重点将是 Chrome 浏览器及其附带的开发工具。由于 Node.js 是使用 V8 引擎构建的,并且许多其他新浏览器都是基于 Chromium 引擎构建的,比如 Brave,因此利用这一点是有意义的。我们将详细介绍 Chrome 开发工具给我们的三个特定功能。

Chrome - 深入了解性能选项卡

除了 Firefox 内部的一些工具外,Chrome 已经成为用户和开发人员首选的广泛使用的浏览器。对于开发人员来说,这在很大程度上要归功于其出色的开发工具。接下来的部分将着眼于设计 Web 应用程序时对任何开发人员都重要的三个关键工具。我们将从性能工具开始。

这个工具允许我们在应用程序运行时运行性能测试。如果我们想要查看我们的应用程序在执行某些操作时的行为,这将非常有用。例如,我们可以分析我们的应用程序的启动状态,并查看可能的瓶颈位置。或者,当用户交互发生时,比如在表单上提交时,我们可以看到我们经过的调用层次结构来发布信息并将其返回给用户。除此之外,它甚至可以帮助我们分析在使用 Web Worker 时代码的性能以及我们的应用程序上下文之间的数据传输方式。

以下是撰写时最新版本 Chrome 的性能选项卡的屏幕截图:

有几个部分是我们感兴趣的。首先,我们开发工具标签下面的工具栏是我们的主要工具栏。左边的两个按钮可能是最重要的,记录和重新加载记录工具。这些将允许我们对我们的应用程序进行分析,并查看在我们的代码的关键时刻发生了什么。在这些之后是选择工具,用于获取之前可能运行过的配置文件。

接下来是两个选项,我通常都会随时打开:

  • 首先,当应用程序发生关键事件时,屏幕截图功能会为我们抓取屏幕截图,比如内存增长或添加新文档。

  • 下一个选项是内存分析器。它将告诉我们当前消耗了多少内存。

最后,还有删除操作。正如许多人所推测的那样,这将删除您当前正在使用的配置文件。

让我们在一个简单的测试应用程序上进行测试运行。从存储库中获取chrome_performance.html文件。这个文件展示了一个标准的待办事项应用程序,但它是用一个非常基本的模板系统和没有库来编写的。在本书中,不使用库将成为标准。

如果我们运行这个应用程序并从重新加载运行性能测试,我们应该得到以下结果:

页面加载几乎是瞬间完成的,但我们仍然可以在这里得到一些有用的信息。从上到下,我们得到以下信息:

  • 一系列图片的时间轴,以及 FPS、CPU 使用率、网络使用率和堆使用率的图表。

  • 不同统计数据的折线图,比如 JavaScript 堆使用、文档数量、文档节点数量、监听器数量以及我们正在使用的 GPU 内存。

  • 最后,我们得到一个分栏部分,其中包含有关时间和时间分配的所有信息。

确保让分析器自行运行。在页面上的所有操作完成后,它应该会自动关闭。这应该确保您尽可能接近正确的应用程序运行信息。分析器可能需要运行几次才能得到准确的图片。内部垃圾收集器正在努力保留一些对象,以便稍后重用它们,因此获得准确的图片意味着看到低点是最有可能的应用程序基线,随后是垃圾收集GC)。一个很好的指标是看到主要的 GC 和/或 DOM GC。这意味着我们又重新开始了。

在这个基本示例中,我们可以看到大部分时间都花在了 HTML 上。如果我们打开它,我们会看到评估我们的脚本占用了大部分时间。由于大部分时间都花在了评估脚本和将我们的模板化待办事项应用程序插入 DOM 中,让我们看看如果没有这种行为,统计数据会是什么样子。

注释掉除了我们的基本标签之外的所有内容,比如htmlheadbody标签。这次运行有一些有趣的元素。首先,文档的数量应该保持不变或减少。这将在后面提到。其次,节点的数量急剧减少,可能降到了大约 12 个。我们的 JavaScript 堆略微减少,监听器的数量显著减少。

让我们再加入一个div标签。文档、堆空间和监听器的数量保持不变,但节点的数量再次增加。让我们再添加另一个div元素,看看它对节点数量的影响。它应该增加四个。最后一次,让我们再添加另一个div元素。同样,我们应该注意到增加了四个 DOM 节点。这给了我们一些线索,了解 DOM 的运行方式以及如何确保我们的分析是正确的。

首先,节点的数量并不直接等于屏幕上的 DOM 元素数量。DOM 节点由几个基本节点组成。例如,如果我们添加一个input元素,我们可能会注意到节点的数量增加了超过四个。其次,可用的文档数量几乎总是高于单个文档。

虽然一些行为可以归因于性能分析器中的错误,但它也展示了幕后发生的事情,这些事情对开发人员是不可见的。当我们触及内存选项卡并查看调用层次时,我们会看到内部系统正在创建和销毁开发人员无法完全控制的节点,以及开发人员看不到但是浏览器优化的文档。

让我们再次添加我们的代码块,回到原始文档。如果需要的话,继续回滚 Git 分支(如果这是从存储库中拉取的),然后再次运行性能分析器。我们特别想查看调用树选项卡和解析 HTML 下拉菜单。应该有一个类似以下的层次结构:解析 HTML > 评估脚本 > (匿名) > runTemplate > runTemplate

让我们改变代码,将我们的内部for循环转换为一个数组map函数,就像这样:

const tempFun = runTemplate.bind(null, loopTemp);
loopEls = data.items.map(tempFun);

注释掉loopEls数组初始化和for循环。再次运行性能分析器,让我们看看这个调用堆栈是什么样子。我们会注意到,即使我们将其绑定到一个名为tempFun的新函数,它仍然会将runTemplate函数本身作为自己进行性能分析。这是我们在查看调用层次时必须牢记的另一个要点。我们可能会绑定、调用或应用函数,但开发工具仍会尝试维护函数的原始定义。

最后,让我们向我们的数据列表添加很多项目,看看这对我们的分析有什么影响。将以下代码放在数据部分下面:

for(let i = 0; i < 10000; i++) {
    data.items.push({text : `Another item ${i}`});
}

现在我们应该得到一个与之前不同的画面:

  • 首先,我们的时间几乎平均分配在 GPU 的布局和脚本的评估之间,现在看起来我们大部分时间都在运行布局引擎。这是有道理的,因为我们在脚本的末尾添加每个项目时,我们强制 DOM 来计算布局。

  • 其次,评估脚本部分现在应该包含比之前简单的调用层次更多的部分。

  • 我们还将开始看到函数的不同部分在性能分析器中注册。这表明,如果某些东西低于某个阈值(这实际上取决于机器甚至 Chrome 的版本),它将不会显示函数被认为足够重要以进行性能分析。

垃圾回收是环境清理我们不再使用的未使用项目的过程。由于 JavaScript 是一个内存管理环境,这意味着开发人员不像在 C++和 Rust 等语言中那样自己分配/释放内存,我们有一个程序来为我们做这些。特别是 V8 有两个 GC,一个叫做Scavenger的次要 GC,一个叫做Mark-Compact的主要 GC。

清道夫会检查新分配的对象,看看是否有任何准备清理的对象。大多数时候,我们的代码将被编写为在短时间内使用大量临时变量。这意味着它们在初始化变量的几个语句之后将不再需要。看下面的代码片段:

const markedEls = [];
for(let i = 0; i < 10000; i++) {
    const obj = els[i];
    if( obj.marked ) {
        markedEls.push(Object.assign({}, obj));
    }
}

在这个假设的例子中,我们想获取对象并在它们标记为某个过程时对它们进行克隆。我们收集我们想要的对象,其余的现在没有用了。清道夫会注意到几件事情。首先,它似乎我们不再使用旧列表,所以它会自动收集这些内存。其次,它会注意到我们有一堆未使用的对象指针(除了 JavaScript 中的原始类型,其他都是按引用传递的),它可以清理这些。

这是一个快速的过程,它要么交织在我们的运行时中,称为停止-继续垃圾回收,要么会在与我们的代码并行运行,这意味着它将在另一个执行线程中的确切时间运行。

标记-压缩垃圾收集运行时间更长,但收集的内存更多。它将遍历当前仍在堆中的物品列表,并查看这些物品是否有零引用。如果没有更多的引用,它将从堆中删除这些对象。然后它将尝试压缩堆中的所有空隙,这样我们就不会有高度碎片化的内存。这对于诸如数组之类的东西特别有用。

数组在内存中是连续的,所以如果 V8 引擎能找到足够大的空间来放置数组,它就会放在那里。否则,它可能需要扩展堆并为我们的运行时分配更多内存。这就是标记-压缩 GC 试图防止发生的事情。

虽然不需要完全了解垃圾收集器的工作方式才能编写高性能的 JavaScript,但对它有一个良好的理解将有助于编写不仅易于阅读而且在你使用的环境中表现良好的代码。

如果你想了解更多关于 V8 垃圾收集器的信息,我建议你去这个网站v8.dev/blog。看到 V8 引擎是如何工作的,以及新的优化如何导致某些编码风格比过去更高效,比如数组的 map 函数,总是很有趣。

我们没有详细介绍性能选项卡,但这应该给出了如何在测试代码时利用它的一个很好的概述。它还应该展示了 Chrome 的一些内部工作和垃圾收集器。

在下一节关于内存的讨论中将会有更多内容,但强烈建议对当前的代码库运行一些测试,并注意在运行这些应用程序时性能如何。

Chrome-深入了解内存选项卡

当我们从性能部分转移到内存部分时,我们将重新审视性能工具中的许多概念。V8 引擎为开发既在 CPU 使用效率方面又在内存使用效率方面高效的应用程序提供了大量支持。测试内存使用情况以及内存分配位置的一个很好的方法是内存分析工具。

在撰写本文时的最新版本的 Chrome 中,内存分析器显示如下:

我们主要将关注被选中的第一个选项,即堆快照工具。时间轴上的分配仪表盘是可视化和回放堆是如何被分配的以及哪些对象导致分配发生的一个很好的方法。最后,分配抽样工具会定期进行快照,而不是提供连续的查看,使其更轻便,并能够在进行繁重操作时执行内存测试。

堆快照工具将允许我们看到堆上内存的分配位置。从我们之前的例子中,让我们运行堆快照工具(如果你还没有注释掉分配了 10,000 个 DOM 节点的for循环,现在注释掉它)。快照运行后,你应该会得到一个左侧有树形视图的表格。让我们去寻找在控制台中能够访问到的global物品之一。

我们目前按它们是什么或者它们属于谁来分组物品。如果我们打开(闭包)列表,我们可以找到runTemplate()函数被保存在那里。如果我们进入(字符串)列表,我们可以找到用来创建我们列表的字符串。一个可能提出的问题是为什么一些这些物品仍然被保存在堆上,即使我们不再需要它们。嗯,这涉及到垃圾收集器的工作方式以及谁当前正在引用这些物品。

查看当前存储在内存中的列表项。如果您点击每个列表项,它会显示它们被loopEls引用。如果我们回到我们的代码,可以注意到我们使用的唯一一行代码loopEls在以下位置:

const tempFun = runTemplate.bind(null, loopTemp);
loopEls = data.items.map(tempFun);

将其移除并将基本的for循环放回。运行堆快照并返回(strings)部分。这些字符串不再存在!让我们再次更改代码,使用map函数,但这次不使用 bind 函数创建新函数。代码应该如下所示:

const loopEls = data.items.map((item) => {
    return runTemplate(loopTemp, item);
});

再次更改代码后运行堆快照,我们会注意到这些字符串不再存在。敏锐的读者会注意到第一次运行中代码存在错误;loopEls变量没有添加任何变量类型前缀。这导致loopEls变量进入全局范围,这意味着垃圾收集器无法收集它,因为垃圾收集器认为该变量仍在使用中。

现在,如果我们把注意力转向列表中的第一项,我们应该观察到整个模板字符串仍然被保留。如果我们点击该元素,我们会注意到它被template变量所持有。然而,我们可以说,由于该变量是一个常量,它应该自动被收集。再次说明,V8 编译器不知道这一点,已经将它放在全局范围内。

我们可以通过两种方式解决这个问题。首先,我们可以使用老式技术,并将其包装在立即调用的函数表达式IIFE)中,如下所示:

(function() { })();

或者,如果我们愿意并且正在为支持它的浏览器编写我们的应用程序,我们可以将脚本类型更改为module类型。这两种解决方案都确保我们的代码现在不再是全局范围的。让我们将整个代码库放在 IIFE 中,因为这在所有浏览器中都受支持。如果我们运行堆转储,我们会看到那个字符串不再存在。

最后,应该触及的最后一个领域是堆空间的工作集和实际分配的数量。在 HTML 文件的顶部添加以下行:

<script type="text/javascript" src="./fake_library.js"></script>

这是一个简单的文件,它将自身添加到窗口以充当库。然后,我们将测试两种情况。首先,运行以下代码:

for(let i = 0; i < 100000; i++) {
    const j = Library.outerFun(true);
    const k = Library.outerFun(true);
    const l = Library.outerFun(true);
    const m = Library.outerFun(true);
    const n = Library.outerFun(true);
}

现在,转到性能部分,查看显示的两个数字。如果需要,可以点击垃圾桶。这会导致主要的垃圾收集器运行。应该注意左边的数字是当前使用的,右边的数字是已分配的。这意味着 V8 引擎为堆分配了大约 6-6.5 MB 的空间。

现在,以类似的方式运行代码,但让我们将每个运行分解成它们自己的循环,如下所示:

for(let i = 0; i < 100000; i++) {
    const j = Library.outerFun(true);
}

再次检查性能选项卡。内存应该在 7 MB 左右。点击垃圾桶,它应该降到 5.8 MB 左右,或者接近基线堆应该在的位置。这给我们展示了什么?由于它必须为第一个for循环中的每个变量分配项目,它必须增加其堆空间。即使它只运行了一次,次要垃圾收集器应该已经收集了它,但由于垃圾收集器内置的启发式,它将保留该堆空间。由于我们决定这样做,垃圾收集器将保留更多的堆内存,因为我们很可能会在短期内重复这种行为。

现在,对于第二组代码,我们决定使用一堆for循环,每次只分配一个变量。虽然这可能会慢一些,V8 看到我们只分配了小块空间,因此可以减少主堆的大小,因为我们很可能会在不久的将来保持相同的行为。V8 系统内置了许多启发式规则,并且它会尝试根据我们过去的行为来猜测我们将要做什么。堆分配器可以帮助我们了解 V8 编译器将要做什么,以及我们的编码模式在内存使用方面最像什么。

继续玩内存标签,并添加代码。看看流行的库(尝试保持它们小,以便跟踪内存分配),注意它们决定如何编写代码以及它如何导致堆分配器在内存中保留对象,甚至保持更大的堆大小。

通常情况下,编写小函数有很多好处,但编写做一件事情非常出色的小函数对于垃圾收集器也非常有益。它将根据编码人员编写这些小函数的事实来制定启发式规则,并减少总体堆空间。这反过来会导致应用程序的内存占用也减少。请记住,我们的内存使用情况不是工作集大小(左侧数字),而是总堆空间(右侧数字)。

Chrome-深入了解渲染标签

我们将在开发者工具中查看的最后一个部分将是渲染部分。这通常不是一个默认可用的标签。在工具栏中,您会注意到关闭按钮旁边有一个三点按钮。点击它,转到更多工具,然后点击渲染选项。

现在应该有一个标签项,靠近控制台标签,看起来像下面这样:

这个标签可以展示一些我们在开发应用程序时感兴趣的项目:

  • 首先,在开发一个将有大量数据或大量事件的应用程序时,建议打开 FPS 计量器。这不仅可以让我们知道我们的 GPU 是否被利用,还可以告诉我们是否由于不断重绘而丢失帧数。

  • 其次,如果我们正在开发一个有大量滚动的应用程序(考虑无限滚动的应用程序),那么我们将希望打开滚动性能问题部分。这可以通知我们,如果我们的应用程序中有一个或多个项目可能会导致滚动体验不流畅。

  • 最后,绘制闪烁选项非常适合在我们的应用程序中有大量动态内容时使用。当发生绘制事件时,它会闪烁,并突出显示必须重新绘制的部分。

我们将通过一个应用程序,这个应用程序将会对大多数这些设置造成问题,并看看我们如何提高性能以改善用户体验。打开以下文件:chrome_rendering.html

我们应该看到左上角有一个方框在变换颜色。如果我们打开绘制闪烁选项,现在每当方框颜色改变时,我们应该看到一个绿色方框出现。

这是有道理的。每次重新着色时,这意味着渲染器必须重新绘制该位置。现在取消以下行的注释:

let appendCount = 0;
const append = function() {
    if( appendCount >= 100 ) {
        return clearInterval(append);
    }
    const temp = document.createElement('p');
    temp.textContent = `We are element ${appendCount}`;
    appendEl.appendChild(temp);
    appendCount += 1;
};
setInterval(append, 1000);

我们应该看到大约每隔 1 秒添加一个元素。有几件事情很有趣。首先,我们仍然看到每秒或更长时间自行着色的框被重新绘制。但是,除此之外,我们会注意到滚动条也在重新绘制自己。这意味着滚动条是渲染表面的一部分(有些人可能知道这一点,因为你可以用 CSS 来定位滚动条)。但同样有趣的是,当每个元素被添加时,它不必重新绘制整个父元素;它只在添加子元素的地方进行绘制。

那么,一个很好的问题是:如果我们在文档中添加一个元素会发生什么?注释掉正在改变 DOM 的代码行,并取消注释以下代码行以查看其效果:

setTimeout(() => {
    const prependElement = document.createElement('p');
    prependElement.textContent = 'we are being prepended to the entire  
     DOM';
    document.body.prepend(prependElement);
}, 5000);

我们可以看到,在文档的生命周期中大约五秒钟后,我们添加的元素和那个红色框都被重新绘制了。这是有道理的。Chrome 必须重新绘制任何发生变化的东西。就我们的窗口外观而言,这意味着它必须改变框的位置,并添加我们在顶部添加的文本,导致两个项目都被重新绘制。

现在,我们可以看到一个有趣的事情,那就是如果我们用 CSS 将元素绝对定位会发生什么。这意味着,就我们所看到的而言,只有矩形的顶部部分和我们的文本元素需要重新绘制。但是,如果我们通过将位置设置为绝对来做到这一点,我们仍然会看到 Chrome 不得不重新绘制两个元素。

即使我们将document.body.prepend改为document.body.append,它仍然会同时绘制两个对象。Chrome 必须这样做,因为框是一个 DOM 对象。它无法只重绘对象的部分;它必须重绘整个对象。

一个要记住的好事是,当改变文档中的某些内容时,它会导致重新布局或重绘吗?添加一个列表项是否也会导致其他元素移动、改变颜色等?如果是,我们可能需要重新考虑我们的内容层次结构,以确保我们在文档中引起最少的重绘。

关于绘画的最后一点。我们应该看看画布元素是如何工作的。画布元素允许我们通过 2D 渲染上下文或 WebGL 上下文创建 2D 和 3D 图像。我们将专门关注 2D 渲染上下文,但应该注意这些规则也适用于 WebGL 上下文。

继续注释掉我们迄今为止添加的所有代码,并取消注释以下代码行:

const context = canvasEl.getContext('2d');
context.fillStyle = 'green';
context.fillRect(10, 10, 10, 10);
context.fillStyle = 'red';
context.fillRect(20, 20, 10, 10);
setTimeout(() => {
    context.fillStyle = 'green';
    context.fillRect(30, 30, 10, 10);
}, 2000);

大约两秒后,我们应该看到一个绿色框被添加到我们小的对角线方块组中。这种绘画方式有趣的地方在于它只显示了对那个小绿色方块的重新绘制。让我们注释掉那段代码,并添加以下代码:

const fillStyles = ['green', 'red'];
const numOfRunsX = 15;
const numOfRunsY = 10;
const totalRuns = numOfRunsX * numOfRunsY;
let currX = 0;
let currY = 0;
let count = 0;
const paint = function() {
    context.fillStyle = fillStyles[count % 2];
    context.fillRect(currX, currY, 10, 10);
    if(!currX ) {
        currY += 10;
    }
    if( count === totalRuns ) {
        clearInterval(paint);
    }
}
setInterval(paint, 1000);

大约每隔 1 秒,我们会看到它真正只在我们指定的位置进行重新绘制。这对于需要不断改变页面上的信息的应用程序可能会产生重大影响。如果我们发现需要不断更新某些内容,实际上在画布中完成可能比在 DOM 中更好。虽然画布 API 可能不适合成为一个丰富的环境,但有一些库可以帮助解决这个问题。

并不是每个应用都需要画布的重绘能力,大多数应用都不需要。然而,我们在本书中讨论的每一种技术都不会解决应用程序中发现的 100%的问题。其中一个问题是重绘问题,这可以通过基于画布的解决方案来解决。画布特别适用于绘图和基于网格的应用程序。

现在,我们将看一下滚动选项。当我们有一个很长的项目列表时,这可以帮助我们。这可能是在树视图中,在无限滚动应用程序中,甚至在基于网格的应用程序中。在某些时候,由于尝试一次渲染数千个元素,我们将遇到严重的减速问题。

首先,让我们使用以下代码将 1,000,000 个段落元素渲染到我们的应用程序中:

for(let i = 0; i < 1000000; i++) {
    const temp = document.createElement('p');
    temp.textContent = `We are element ${i}`;
    appendEl.appendChild(temp);
}

虽然这可能看起来不像一个真实的场景,但它展示了如果我们必须立即将所有内容添加到 DOM 中,无限加载的应用程序将会变得不可行。那么我们该如何处理这种情况呢?我们将使用一种称为延迟渲染的东西。基本上,我们将把所有对象保存在内存中(在这种情况下;对于其他用例,我们将不断地为更多数据进行 REST 请求),并且我们将按照它们应该出现在屏幕上的顺序添加它们。我们需要一些代码来实现这一点。

以下示例绝不是实现延迟渲染的一种可靠方式。与本书中的大多数代码一样,它采用了一个简单的视图来展示一个观点。它可以很容易地进行扩展,以创建一个延迟渲染的真实系统,但这不是应该被复制和粘贴的东西。

开始延迟渲染的一个好方法是知道我们将拥有多少元素,或者至少想要在我们的列表中展示多少元素。为此,我们将使用 460 像素的高度。除此之外,我们将设置我们的列表元素具有 5 像素的填充,并且高度为 12 像素,底部有 1 像素的边框。这意味着每个元素的总高度为 23 像素。这也意味着一次可以看到 20 个元素(460 / 23)。

接下来,我们通过将我们拥有的项目数量乘以每个项目的高度来设置列表的高度。这可以在以下代码中看到:

list.style.height = `${itemHeight * items.length}px`;

现在,我们需要保存我们当前所在的索引(屏幕上当前的 20 个项目),并在发生滚动事件时进行测量。如果我们注意到我们在阈值以上,我们就会移动到一个新的索引,并重置我们的列表以保存那组 20 个元素。最后,我们将无序列表的顶部填充设置为列表的总高度减去我们已经滚动的部分。

所有这些都可以在以下代码中看到:

const checkForNewIndex = function(loc) {
    let tIndex = Math.floor(Math.abs(loc) / ( itemHeight * numItemsOnScreen 
     ));
    if( tIndex !== currIndex ) {
        currIndex = tIndex;
        const fragment = document.createDocumentFragment();
        fragment.append(...items.slice(currIndex * numItemsOnScreen, 
         (currIndex + 2) * numItemsOnScreen));
        list.style.paddingTop = `${currIndex * containerHeight}px`;
        list.style.height = `${(itemHeight * items.length) - (currIndex * 
         containerHeight)}px`;
        list.innerHTML = '';
        list.appendChild(fragment);
    }
}

现在我们拥有了所有这些,我们把这个函数放在什么地方呢?嗯,既然我们在滚动,逻辑上来说,把它放在列表的滚动处理程序中是有意义的。让我们用以下代码来做到这一点:

list.onwheel = function(ev) {
    checkForNewIndex(list.getBoundingClientRect().y);
}

现在,让我们打开滚动性能问题选项。如果我们重新加载页面,我们会注意到它正在突出显示我们的列表,并声明mousewheel事件可能成为潜在的瓶颈。这是有道理的。Chrome 注意到我们在每次滚动事件上附加了一个非平凡的代码片段,因此它向我们显示我们可能会有问题。

现在,如果我们在常规桌面上,很可能不会有任何问题,但是如果我们添加以下代码,我们可以很容易地看到 Chrome 试图告诉我们的内容:

const start = Date.now();
while( Date.now() < start + 1000 ) {; }

有了这段代码,我们可以看到滚动时出现了卡顿。既然我们现在能看到卡顿,并且它可能成为滚动的潜在瓶颈,下一个最佳选择是什么?将其放入setInterval中,使用requestAnimationFrame,甚至使用requestIdleCallback,最后一个是最不理想的解决方案。

渲染选项卡可以帮助解决应用程序中可能出现的许多问题,并且应该成为开发人员经常使用的工具,以找出是什么导致了应用程序的卡顿或性能问题。

这三个选项卡可以帮助诊断大多数问题,并且在开发应用程序时应该经常使用。

jsPerf 和基准测试

我们已经来到了关于网络高性能的最后一节,以及我们如何轻松评估我们的应用程序是否以最佳效率运行。然而,有时我们会想要真正进行基准测试,即使这可能不会给出最好的结果。jsPerf 就是这样的工具之一。

创建 jsPerf 测试时必须非常小心。首先,我们可能会遇到浏览器进行的优化,这可能会使结果偏向于某种实现而不是另一种。接下来,我们必须确保在多个浏览器中运行这些测试。如前一节所述,每个浏览器都运行不同的 JavaScript 引擎,这意味着创建者们对它们进行了不同的实现。最后,我们需要确保在我们的测试中没有任何多余的代码,否则结果可能会被扭曲。

让我们看一些脚本,并根据在 jsPerf 中运行它们的结果来看看它们的效果。所以,让我们开始:

  1. 转到jsperf.com。如果我们想创建自己的测试,我们将需要使用 GitHub 账户登录,所以现在就去做吧。

  2. 接下来,让我们创建我们的第一个性能测试。系统是不言自明的,但我们将讨论一些方面:

  • 首先,如果我们需要添加一些 HTML 代码,以便进行 DOM 操作,我们会将其放在准备代码 HTML部分。

  • 接下来,我们将输入我们所有测试中需要的任何变量。

  • 最后,我们可以整合我们的测试用例。让我们运行一个测试。

  1. 我们将首先查看的测试是利用循环与利用filter函数。对于这个测试,我们不需要任何 HTML,所以我们可以将这一部分留空。

  2. 接下来,我们将输入所有测试用例都需要的以下代码:

const arr = new Array(10000);
for(let i = 0; i < arr.length; i++) {
    arr[i] = i % 2 ? i : -1;
}
  1. 然后,我们将添加两个不同的测试用例,for循环和filter函数。它们应该如下所示:

对于循环的情况:

const nArr = [];
for(let i = 0; i < arr.length; i++) {
    if( Math.abs(arr[i]) === arr[i]) {
        nArr.push(arr[i]);
    }
}

对于 filter 的情况:

const nArr = arr.filter(item => Math.abs(item) === item);
  1. 现在,我们可以保存测试用例并运行性能测试器。点击运行按钮,观察测试运行器多次检查每段代码。我们应该看到如下内容:

嗯,正如预期的那样,for循环的性能优于filter函数。右侧的这三个数字的分解如下:

  • 每秒操作次数,或者系统在一秒内可以运行多少基本指令。

  • 每个特定测试用例的每次测试运行的差异。对于for循环,加减 2%。

  • 最后,它会告诉我们它是否是最快的,或者比最快的慢了多少。对于 filter,它慢了 86%。

哇,这明显慢了很多!在这种情况下,我们可能会想出一种让 filter 运行更加高效的方法。一种方法是我们可以提前创建函数,而不是创建一个匿名函数。在我们的结果底部附近,我们将看到一个链接,可以让我们添加更多的测试。让我们回到测试用例中,为我们的新测试添加一个测试。

在底部附近应该有一个添加代码片段按钮。让我们点击这个按钮并填写细节。我们将称这个新的代码片段为filterFunctionDefined,它应该看起来像下面这样:

const reducer = function(item) {
    return Math.abs(item) === item;
}
const nArr = arr.filter(reducer);

我们可以保存这个测试用例并重新运行结果。结果似乎几乎与常规的filter函数完全相同。其中一些原因是我们的浏览器为我们优化了我们的代码。我们可以在所有浏览器中测试这些结果,以便更好地了解我们的代码在每个浏览器中的运行情况。但是,即使我们在其他地方运行这个测试,我们也会看到结果是一样的;filter函数比普通的for循环慢。

这对于几乎每个基于数组的函数都是正确的。辅助函数很棒,但它们也比常规循环慢。我们将在下一章中详细介绍,但请提前意识到,浏览器提供给我们的大多数便利都会比直接以更简单的方式编写函数要慢。

让我们设置另一个测试,只是为了确保我们理解 jsPerf。

首先,创建一个新的测试。让我们对对象执行一个测试,并查看使用for-in循环与使用Object.keys()方法的差异。同样,我们不需要使用 DOM,因此在 HTML 部分不需要填写任何内容。

对于我们的测试设置,让我们创建一个空对象,然后使用以下代码填充它,其中包含一堆无用的数据:

const obj = {};
for(let i = 0; i < 10000; i++) {
    obj[`item${i}`] = i;
}

接下来,让我们创建两个测试用例,第一个是调用for in,应该如下所示:

const results = [];
for(let key in obj) {
    results.push([key, obj[key]]);
}

第二个测试用例是Object.keys()版本,如下所示:

const results = [];
const keys = Object.keys(obj);
for(let i = 0; i < keys.length; i++) {
    results.push([keys[i], obj[keys[i]]);
}

现在,如果我们运行我们的测试,我们会注意到keys版本能够每秒执行大约 600 次操作,而fo..in版本能够每秒执行大约 550 次。这两者相差不大,因此浏览器的差异实际上可能会起作用。当我们开始出现轻微差异时,最好选择后来实现的或最有可能进行优化的选项。

大多数情况下,如果某些东西只是被实现,并且浏览器供应商同意添加某些东西,那么它可能处于早期开发阶段。如果性能结果在允许的公差范围内(通常在 5-10%的差异左右),那么最好选择更新的选项。它更有可能在未来进行优化。

所有这些测试都很棒,如果我们找到了真正想与人们分享的东西,这是一个很好的解决方案。但是,如果我们想自己运行这些测试而不必担心外部网站怎么办呢?嗯,我们可以利用 jsPerf 正在使用的基础库。它被称为 Benchmark.js,当我们需要为调试代码设置自己的系统时,它是一个很好的工具。我们可以在benchmarkjs.com/找到它。

让我们获取源代码,并将其设置为 HTML 文件中的外部脚本。我们还需要将Lodash添加为依赖项。接下来,让我们编写与之前相同的测试,但是我们将在内部脚本中编写它们,并在屏幕上显示结果。我们还将只显示我们脚本的标题以及这些结果。

我们显然可以使这个更加花哨,但重点将是让库为我们正确地进行基准测试。

我们将有一些设置代码,其中将有一个对象数组。这些对象只有两个属性,测试的名称和我们想要运行的函数。在我们的for循环与filter测试的情况下,它看起来会像这样:

const forTest = Object.assign({}, testBaseObj);
forTest.title = 'for loop';
forTest.fun = function() {
    const arr = [];
    for(let i = 0; i < startup.length; i++) {
        if( Math.abs(startup[i]) === startup[i] ) {
            arr.push(startup[i]);
        }
    }
}
const filterTest = Object.assign({}, testBaseObj);
filterTest.title = 'filter';
filterTest.fun = function() {
    const arr = startup.filter((item) => Math.abs(item) === item);
}
const tests = [forTest, filterTest];

从这里开始,我们设置了一个基准套件,并循环执行我们的测试,将它们添加到套件中。然后我们添加了两个监听器,一个用于完成循环,以便我们可以在列表中显示它,另一个用于完成,以便我们可以突出显示运行最快的条目。它应该如下所示:

const suite = new Benchmark.Suite;
for(let i = 0; i < tests.length; i++) {
    suite.add(tests[i].title, tests[i].fun);
}
suite.on('cycle', function(event) {
    const el = document.createElement('li');
    el.textContent = event.target;
    el.id = event.target.name;
    appendEl.appendChild(el);
})
.on('complete', function() {
    const fastest = this.filter('fastest').map('name');
    document.getElementById(fastest[0]).style.backgroundColor = 'green';
})
.run({ 'async' : true });

如果我们设置了所有这些,或者运行了benchmark.html,我们将看到输出。我们可以从基准库中获得许多其他有趣的统计数据。其中之一是每个测试的标准偏差。在 Edge 中运行的for循环测试的情况下,大约为 0.004。另一个有趣的注释是我们可以查看每次运行所花费的时间。同样,以for循环为例,Edge 浏览器正在慢慢优化我们的代码,并且很可能将其放入缓存,因为时间不断减少。

总结

本章介绍了许多用于分析和调试代码的概念。它考虑了各种现代浏览器,甚至考虑了它们可能具有或不具有的特殊功能。我们特别关注了 Chrome 浏览器,因为许多开发人员将其用作主要的开发浏览器。除此之外,V8 引擎用于 Node.js,这意味着我们所有的 Node.js 代码将使用 V8 调试器。最后,我们看了一下如何利用 jsPerf 来找出某段代码的最佳实现方式。我们甚至研究了在我们自己的系统中运行它的可能性以及如何实现这一点。

展望未来,本书的剩余部分将不再具体讨论这些主题,但在本书的其余部分开发代码时应该使用这些工具。除此之外,我们将几乎在 Chrome 浏览器中运行所有的代码,除了当我们编写 GLSL 时,因为 Firefox 拥有最好的组件来实际测试这些代码。在下一章中,我们将探讨不可变性以及在开发中何时应该利用它。

第二章:不可变性与可变性-安全与速度之间的平衡

近年来,开发实践已经转向更加功能化的编程风格。这意味着更少关注可变编程(在修改某些东西时改变变量而不是创建新变量)。当我们将变量从一种东西改变为另一种东西时,就会发生可变性。这可能是更新数字,改变消息内容,甚至将项目从字符串更改为数字。可变状态会导致编程陷阱的许多领域,例如不确定状态,在多线程环境中死锁,甚至在我们不希望的情况下更改数据类型(也称为副作用)。现在,我们有许多库和语言可以帮助我们遏制这种行为。

所有这些都导致了对使用不可变数据结构和基于输入创建新对象的函数的推动。虽然这会减少可变状态的错误,但它也带来了一系列其他问题,主要是更高的内存使用和更低的速度。大多数 JavaScript 运行时都没有优化,允许这种编程风格。当我们关注内存和速度时,我们需要尽可能多地获得优势,这就是可变编程给我们带来的优势。

在本章中,我们将重点关注以下主题:

  • 当前网络上的不可变性趋势

  • 编写安全的可变代码

  • 网络上的类似功能的编程

技术要求

本章的先决条件如下:

当前对不可变性的迷恋

当前网络趋势显示了对利用不可变性的迷恋。诸如 React 之类的库可以在没有其不可变状态的情况下使用,但它们通常与 Redux 或 Facebook 的 Flow 库一起使用。这些库中的任何一个都将展示不可变性如何可以导致更安全的代码和更少的错误。

对于那些不了解的人,不可变性意味着一旦设置了数据,就无法更改变量。这意味着一旦我们给变量分配了某些内容,我们就不能再更改该变量。这有助于防止不必要的更改发生,并且还可以导致一个称为纯函数的概念。我们不会深入讨论纯函数是什么,但要知道这是许多函数式程序员一直在引入 JavaScript 的概念。

但是,这是否意味着我们需要它,它是否会导致更快的系统?在 JavaScript 的情况下,这可能取决于情况。一个管理良好的项目,有文档和测试,可以很容易地展示出我们可能不需要这些库。除此之外,我们可能需要实际改变对象的状态。我们可能在一个位置写入对象,但有许多其他部分从该对象中读取。

有许多开发模式可以给我们带来与不可变性相似的好处,而不需要创建大量临时对象或者甚至进入完全纯粹的功能化编程风格。我们可以利用诸如资源获取即初始化RAII)的系统。我们可能会发现自己想要使用一些不可变性,在这种情况下,我们可以利用内置的浏览器工具,如Object.freeze()Object.seal()

然而,我们在走得太快了。让我们来看看其中提到的一些库,看看它们如何处理不可变状态,以及在编码时可能会导致问题。

深入 Redux

Redux是一个很好的状态管理系统。当我们开发诸如 Google Docs 或者一个报告系统这样的复杂系统时,它可以管理我们应用程序的状态。然而,它可能会导致一些过于复杂的系统,这些系统可能并不需要它所代表的状态管理。

Redux 的理念是没有一个对象应该能够改变应用程序的状态。所有的状态都需要托管在一个单一的位置,并且应该有处理状态变化的函数。这意味着写入的单一位置,以及多个位置能够读取数据。这与我们以后想要利用的一些概念类似。

然而,它会进一步进行许多文章都希望我们传回全新的对象。这是有原因的。许多对象,特别是那些具有多层的对象,不容易复制。简单的复制操作,比如使用Object.assign({}, obj)或者利用数组的扩展运算符,只会复制它们内部持有的引用。在我们编写基于 Redux 的应用程序之前,让我们看一个例子。

如果我们从我们的存储库中打开not_deep_copy.html,我们将看到控制台打印相同的内容。如果我们看一下代码,我们将看到一个非常常见的复制对象和数组的情况:

const newObj = Object.assign({}, obj);
const newArr = [...arr];

如果我们只将其复制一层深,我们将看到它实际上执行了一次复制。以下代码将展示这一点:

const obj2 = {item : 'thing', another : 'what'};
const arr2 = ['yes', 'no', 'nope'];

const newObj2 = Object.assign({}, obj2);
const newArr2 = [...arr2]

我们将更详细地讨论这个案例,以及如何真正执行深层复制,但我们可以开始看到 Redux 可能隐藏了仍然存在于我们系统中的问题。让我们构建一个简单的 Todo 应用程序,至少展示 Redux 及其能力。所以,让我们开始:

  1. 首先,我们需要拉取 Redux。我们可以通过Node Package Managernpm)来做到这一点,并在我们的系统中安装它。只需简单地npm install redux

  2. 我们现在将进入新创建的文件夹,获取redux.min.js文件并将其放入我们的工作目录中。

  3. 现在我们将创建一个名为todo_redux.html的文件。这将包含我们的所有主要逻辑。

  4. 在顶部,我们将将 Redux 库作为依赖项添加进来。

  5. 然后,我们将添加我们要在存储库上执行的操作。

  6. 接下来,我们将设置我们想要在应用程序中使用的 reducers。

  7. 然后,我们将设置存储并准备好进行数据更改。

  8. 然后我们将订阅这些数据变化并更新 UI。

我们正在处理的示例是 Redux 示例中 Todo 应用程序的略微修改版本。其中一个好处是我们将利用原始 DOM,而不是使用其他库,比如 React,所以我们可以看到 Redux 如何适用于任何应用程序,如果需要的话。

  1. 所以,我们的操作将是添加一个todo元素,切换一个todo元素以完成或未完成,并设置我们想要看到的todo元素。这段代码如下所示:
const addTodo = function(test) {
    return { type : ACTIONS.ADD_TODO, text };
}
const toggleTodo = function(index) {
    return { type : ACTIONS.TOGGLE_TODO, index };
}
const setVisibilityFilter = function(filter) {
    return { type : ACTIONS.SET_VISIBILITY_FILTER, filter };
}
  1. 接下来,reducers 将被分开,一个用于我们的可见性过滤器,另一个用于实际的todo元素。

可见性 reducer 非常简单。它检查操作的类型,如果是SET_VISIBILITY_FILTER类型,我们将处理它,否则,我们只是传递状态对象。对于我们的todo reducer,如果我们看到一个ADD_TODO操作,我们将返回一个新的项目列表,其中我们的项目位于底部。如果我们切换其中一个项目,我们将返回一个将该项目设置为与其原来设置相反的新列表。否则,我们只是传递状态对象。所有这些看起来像下面这样:

const visibilityFilter = function(state = 'SHOW_ALL', action) {
    switch(action.type) {
        case 'SET_VISIBILITY_FILTER': {
            return action.filter;
        }
        default: {
            return state;
        }
    }
}

const todo = function(state = [], action) {
    switch(action.type) {
        case 'ADD_TODO': {
            return [
                ...state,
                {
                    text : action.text,
                    completed : false
                }
        }
        case 'TOGGLE_TODO': {
            return state.map((todo, index) => {
                if( index === action.index ) {
                    return Object.assign({}, todo, {
                        completed : !todo.completed
                    });
                }
                return todo;
            }
        }
        default: {
            return state;
        }
    }
}
  1. 完成后,我们将两个 reducer 放入一个单一的 reducer 中,并设置state对象。

我们逻辑的核心在于 UI 实现。请注意,我们设置了这个工作基于数据。这意味着数据可以传递到我们的函数中,UI 会相应地更新。我们也可以反过来,但是让 UI 由数据驱动是一个很好的范例。我们首先有一个先前的状态存储。我们可以进一步利用它,只更新实际更新的内容,但我们只在第一次检查时使用它。我们获取当前状态并检查两者之间的差异。如果我们看到长度已经改变,我们知道应该添加一个todo项目。如果我们看到可见性过滤器已更改,我们将相应地更新 UI。最后,如果这两者都不是真的,我们将检查哪个项目被选中或取消选中。代码如下所示:

store.subscribe(() => 
    const state = store.getState();
    // first type of actions ADD_TODO
    if( prevState.todo.length !== state.todo.length ) {
     container.appendChild(createTodo(state.todo[state.todo.length
     - 1].text));
    // second type of action SET_VISIBILITY_FILTER
    } else if( prevState.visibilityFilter !== 
      state.visibilityFilter ) {
        setVisibility(container.children, state);
    // final type of action TOGGLE_TODO
    } else {
        const todos = container.children;
        for(let i = 0; i < todos.length; i++) {
            if( state.todo[i].completed ) {
                todos[i].classList.add('completed');
            } else {
                todos[i].classList.remove('completed');
            }
        }
    }
    prevState = state;
});

如果我们运行这个,我们应该得到一个简单的 UI,我们可以以以下方式进行交互:

  • 添加todo项目。

  • 将现有的todo项目标记为已完成。

我们还可以通过点击底部的三个按钮之一来查看不同的视图,如下面的截图所示。如果我们只想看到我们所有已完成的任务,我们可以点击“更新”按钮。

现在,我们可以保存状态以进行离线存储,或者我们可以将状态发送回服务器进行常规更新。这就是使 Redux 非常好的地方。但是,在使用 Redux 时也有一些注意事项,与我们之前所述的相关:

  1. 首先,我们需要在我们的 Todo 应用程序中添加一些内容,以便能够处理我们状态中的嵌套对象。这个 Todo 应用程序中遗漏的一部分信息是设置一个截止日期。因此,让我们添加一些字段供我们填写以设置完成日期。我们将添加三个新的数字输入,如下所示:
<input id="year" type="number" placeholder="Year" />
<input id="month" type="number" placeholder="Month" />
<input id="day" type="number" placeholder="Day" />
  1. 然后,我们将添加另一种Overdue的过滤器类型:
<button id="SHOW_OVERDUE">Overdue</button>
  1. 确保将其添加到visibilityFilters对象中。现在,我们需要更新我们的addTodo操作。我们还将传递一个Date对象。这也意味着我们需要更新我们的ADD_TODO情况,以将action.date添加到我们的新todo对象中。然后,我们将更新我们的 Add 按钮的onclick处理程序,并调整为以下内容:
const year = document.getElementById('year');
const month = document.getElementById('month');
const day = document.getElementById('day');
store.dispatch(addTodo(input.value), {year : year.value, month : month.value, day : day.value}));
year.value = "";
month.value = "";
day.value = "";
  1. 我们可以将日期保存为Date对象(这样更有意义),但为了展示可能出现的问题,我们将只保存一个带有yearmonthday字段的新对象。然后,我们将通过添加另一个span元素并用这些字段的值填充它来在 Todo 应用程序上展示这个日期。最后,我们需要更新我们的setVisibility方法,以便显示我们过期的项目。它应该如下所示:
case visibilityFilters.SHOW_OVERDUE: {
    const currTodo = state.todo[i];
    const tempTime = currTodo.date;
    const tempDate = new Date(`${tempTime.year}/${tempTime.month}/${tempTime.day}`);
    if( tempDate < currDay && !currTodo.completed ) {
        todos[i].classList.remove('hide');
    } else {
        todos[i].classList.add('hide');
    }
}

有了所有这些,我们现在应该有一个可工作的 Todo 应用程序,同时展示我们的过期项目。现在,这就是在处理 Redux 等状态管理系统时可能变得混乱的地方。当我们想要对已创建的项目进行修改,而它不是一个简单的扁平对象时会发生什么?好吧,我们可以只获取该项目并在状态系统中对其进行更新。让我们添加这段代码:

  1. 首先,我们将创建一个新的按钮和输入,用于更改最后一个条目的年份。我们将为“更新”按钮添加一个点击处理程序:
document.getElementById('UPDATE_LAST_YEAR').onclick = function(e) {
    store.dispatch({ type : ACTIONS.UPDATE_LAST_YEAR, year :  
     document.getElementById('updateYear').value });
}
  1. 然后,我们将为todo系统添加这个新的操作处理程序:
case 'UPDATE_LAST_YEAR': {
    const prevState = state;
    const tempObj = Object.assign({}, state[state.length - 
     1].date);
    tempObj.year = action.year;
    state[state.length - 1].date = tempObj;
    return state;
}

现在,如果我们运行我们的系统,我们会注意到一些情况。我们的代码在订阅中的检查对象条件中没有通过:

if( prevState === state ) {
    return;
}

我们直接更新了状态,因此 Redux 从未创建新对象,因为它没有检测到更改(我们直接更新了一个对象的值,而我们没有一个 reducer)。现在,我们可以创建另一个专门用于日期的 reducer,但我们也可以重新创建数组并将其传递:

case 'UPDATE_LAST_YEAR': {
    const prevState = state;
    const tempObj = Object.assign({}, state[state.length - 1].date);
    tempObj.year = action.year;
    state[state.length - 1].date = tempObj;
    return [...state];
}

现在,我们的系统检测到有变化,我们能够通过我们的方法来更新代码。

更好的实现方式是将我们的todo reducer 拆分为两个单独的 reducer。但是,由于我们正在进行示例,所以尽可能简单。

通过所有这些,我们可以看到我们需要遵守 Redux 为我们制定的规则。虽然这个工具在大规模应用中可能会带来巨大的好处,但对于较小的状态系统甚至组件化系统,我们可能会发现直接使用真正的可变状态更好。只要我们控制对可变状态的访问,我们就能充分利用可变状态的优势。

这并不是要贬低 Redux。它是一个很棒的库,即使在更重的负载下也能表现良好。但是,有时我们想直接使用数据集并直接进行变异。Redux 可以做到这一点,并为我们提供其事件系统,但是我们可以在不使用 Redux 提供的所有其他部分的情况下自己构建这个。记住,我们希望尽可能地精简代码库,并使其尽可能高效。当我们处理成千上万的数据项时,额外的方法和额外的调用会累积起来。

通过这个对 Redux 和状态管理系统的介绍,我们还应该看一下一个使不可变系统成为必需的库:Immutable.js。

Immutable.js

再次利用不可变性,我们可以以更易于理解的方式编写代码。然而,这通常意味着我们无法满足真正高性能应用所需的规模。

首先,Immutable.js 在 JavaScript 中提供了一种很好的函数式数据结构和方法,这通常会导致更清晰的代码和更清晰的架构。但是,我们在这些优势方面得到的东西会导致速度的降低和/或内存的增加。

记住,当我们使用 JavaScript 时,我们处于一个单线程环境。这意味着我们实际上没有死锁、竞争条件或读/写访问问题。

在使用诸如SharedArrayBuffers之类的东西在工作线程或不同的标签之间可能会遇到这些问题,但这是以后章节的讨论。现在,我们正在一个单线程环境中工作,多核系统的问题并不会真正出现。

让我们举一个现实生活中可能出现的用例的例子。我们想将一个列表的列表转换为对象列表(想象一下 CSV)。在普通的 JavaScript 中构建这种数据结构的代码可能如下所示:

const fArr = new Array(fillArr.length - 1);
const rowSize = fillArr[0].length;
const keys = new Array(rowSize);
for(let i = 0; i < rowSize; i++) {
    keys[i] = fillArr[0][i];
}
for(let i = 1; i < fillArr.length; i++) {
    const obj = {};
    for(let j = 0; j < rowSize; j++) {
        obj[keys[j]] = fillArr[i][j];
    }
    fArr[i - 1] = obj;
}

我们构建一个新的数组,大小为输入列表的大小减一(第一行是键)。然后,我们存储行大小,而不是每次在内部循环中计算。然后,我们创建另一个数组来保存键,并从输入数组的第一个索引中获取它们。接下来,我们循环遍历输入中的其余条目并创建对象。然后,我们循环遍历每个内部数组,并将键设置为值和位置j,并将值设置为输入的ij值。

通过嵌套数组和循环读取数据可能会令人困惑,但可以获得快速的读取时间。在一个双核处理器和 8GB RAM 的计算机上,这段代码花了 83 毫秒。

现在,让我们在 Immutable.js 中构建类似的东西。它应该看起来像下面这样:

const l = Immutable.List(fillArr);
const _k = Immutable.List(fillArr[0]);
const tFinal = l.map((val, index) => {
    if(!index ) return;
    return Immutable.Map(_k.zip(val));
});
const final = tfinal.shift();

如果我们理解函数式概念,这将更容易解释。首先,我们想要根据我们的输入创建一个列表。然后我们创建另一个临时列表用于存储键称为_k.。对于我们的临时最终列表,我们利用map函数。如果我们在0索引处,我们就从函数中return(因为这是键)。否则,我们返回一个通过将键列表与当前值进行 zip 的新映射。最后,我们移除最终列表的前部,因为它将是未定义的。

这段代码在可读性方面很棒,但它的性能特征如何?在当前的机器上,它运行大约需要 1 秒。这在速度方面有很大的差异。让我们看看它们在内存使用方面的比较。

已解决的内存(运行代码后内存返回的状态)似乎是相同的,回到了大约 1.2 MB。然而,不可变版本的峰值内存约为 110 MB,而 Vanilla JavaScript 版本只达到了 48 MB,所以内存使用量略低于一半。让我们看另一个例子并看看发生的结果。

我们将创建一个值数组,除了我们希望其中一个值是不正确的。因此,我们将使用以下代码将第 50,000 个索引设置为wrong

const tempArr = new Array(100000);
for(let i = 0; i < tempArr.length; i++) {
    if( i === 50000 ) { tempArr[i] = 'wrong'; }
    else { tempArr[i] = i; }
}

然后,我们将使用简单的for循环遍历一个新数组,如下所示:

const mutArr = Array.apply([], tempArr);
const errs = [];
for(let i = 0; i < mutArr.length; i++) {
    if( mutArr[i] !== i ) {
        errs.push(`Error at loc ${i}. Value : ${mutArr[i]}`);
        mutArr[i] = i;
    }
}

我们还将测试内置的map函数:

const mut2Arr = Array.apply([], tempArr);
const errs2 = [];
const fArr = mut2Arr.map((val, index) => {
    if( val !== index ) {
        errs2.push(`Error at loc: ${index}. Value : ${val}`);
        return index;
    }
    return val;
});

最后,这是不可变版本:

const immArr = Immutable.List(tempArr);
const ierrs = [];
const corrArr = immArr.map((item, index) => {
    if( item !== index ) {
        ierrs.push(`Error at loc ${index}. Value : ${item}`);
        return index;
    }
    return item;
});

如果我们运行这些实例,我们会发现最快的将在基本的for循环和内置的map函数之间切换。不可变版本仍然比其他版本慢 8 倍。当我们增加不正确值的数量时会发生什么?让我们添加一个随机数生成器来构建我们的临时数组,以便产生随机数量的错误,并看看它们的表现。代码应该如下所示:

for(let i = 0; i < tempArr.length; i++) {
    if( Math.random() < 0.4 ) {
        tempArr[i] = 'wrong';
    } else {
        tempArr[i] = i;
    }
}

运行相同的测试,我们发现不可变版本大约会慢十倍。现在,这并不是说不可变版本在某些情况下不会运行得更快,因为我们只涉及了它的 map 和 list 功能,但这确实提出了一个观点,即在将其应用于 JavaScript 库时,不可变性在内存和速度方面是有代价的。

我们将在下一节中看到为什么可变性可能会导致一些问题,但也会看到我们如何通过利用类似 Redux 处理数据的想法来处理它。

不同的库总是有其适用的时间和场合,并不是说 Immutable.js 或类似的库是不好的。如果我们发现我们的数据集很小或其他考虑因素起作用,Immutable.js 可能适合我们。但是,当我们在高性能应用程序上工作时,这通常意味着两件事。一是我们将一次性获得大量数据,二是我们将获得大量导致数据积累的事件。我们需要尽可能使用最有效的方法,而这些通常内置在我们正在使用的运行时中。

编写安全的可变代码

在我们继续编写安全的可变代码之前,我们需要讨论引用和值。值可以被认为是任何原始类型。在 JavaScript 中,原始类型是指不被视为对象的任何内容。简单来说,数字、字符串、布尔值、null 和 undefined 都是值。这意味着如果你创建一个新变量并将其分配给原始变量,它实际上会给它一个新值。那么这对我们的代码意味着什么呢?嗯,我们之前在 Redux 中看到,它无法看到我们更新了状态系统中的属性,因此我们的先前状态和当前状态显示它们是相同的。这是由于浅相等测试。这个基本测试测试传入的两个变量是否指向同一个对象。一个简单的例子是在以下代码中看到的:

let x = {};
let y = x;
console.log( x === y );
y = Object.assign({}, x);
console.log( x === y );

我们会发现第一个版本说这两个项目是相等的。但是,当我们创建对象的副本时,它会声明它们不相等。y现在有一个全新的对象,这意味着它指向内存中的一个新位置。虽然对按值传递按引用传递的更深入理解可能有好处,但这应该足以继续使用可变代码。

在编写安全的可变代码时,我们希望给人一种错觉,即我们正在编写不可变的代码。换句话说,接口应该看起来像我们在使用不可变的系统,但实际上我们在内部使用的是可变的系统。因此,接口与实现之间存在分离。

我们可以通过以可变的方式编写代码来使实现变得非常快速,但提供一个看起来不可变的接口。一个例子如下:

Array.prototype._map = function(fun) {
    if( typeof fun !== 'function' ) {
        return null;
    }
    const arr = new Array(this.length);
    for(let i = 0; i < this.length; i++) {
        arr[i] = fun(this[i]);
    }
    return arr;
}

我们在数组原型上编写了一个_map函数,以便每个数组都可以使用它,并且我们编写了一个简单的map函数。如果我们现在测试运行这段代码,我们会发现一些浏览器使用这种方式更快,而其他浏览器使用内置选项更快。如前所述,内置选项最终会变得更快,但往往一个简单的循环会更快。现在让我们看另一个可变实现的例子,但具有不可变的接口:

Array.prototype._reduce = function(fun, initial=null) {
    if( typeof fun !== 'function' ) {
        return null;
    }
    let val = initial ? initial : this[0];
    const startIndex = initial ? 0 : 1;
    for(let i = startIndex; i < this.length; i++) {
        val = fun(val, this[i], i, this);
    }
    return val;
}

我们编写了一个reduce函数,在每个浏览器中都有更好的性能。现在,它没有相同数量的类型检查,这可能会导致更好的性能,但它展示了我们如何编写可以提供更好性能但给用户提供相同类型接口的函数。

到目前为止,我们讨论的是,如果我们为别人编写一个库来使他们的生活更轻松。如果我们正在编写一些我们自己或内部团队将要使用的东西,这是大多数应用程序开发人员的情况,会发生什么呢?

在这种情况下,我们有两个选择。首先,我们可能会发现我们正在处理一个传统系统,并且我们将不得不尝试以与已有代码类似的风格进行编程,或者我们正在开发一些全新的东西,我们可以从头开始。

编写传统代码是一项艰巨的工作,大多数人通常会做错。虽然我们应该致力于改进代码库,但我们也在努力匹配风格。对于开发人员来说,尤其困难的是,他们需要浏览代码并看到使用了 10 种不同代码选择,因为在项目的整个生命周期中有 10 个不同的开发人员参与其中。如果我们正在处理其他人编写的东西,通常最好匹配代码风格,而不是提出完全不同的东西。

有了一个新系统,我们可以按照自己的意愿编写代码,并且在适当的文档支持下,我们可以编写出非常快速的代码,同时也容易让其他人理解。在这种情况下,我们可以编写可变的代码,函数中可能会产生副作用,但我们可以记录这些情况。

副作用是指当一个函数不仅返回一个新变量或者变量的引用时发生的情况。当我们更新另一个变量,而我们对其没有当前范围时,这构成了一个副作用。一个例子如下:

var glob = 'a single point system';
const implement = function(x) {
    glob = glob.concat(' more');
    return x += 2;
}

我们有一个名为glob的全局变量,我们在函数内部对其进行更改。从技术上讲,这个函数对glob有范围,但我们应该尝试将实现的范围定义为仅限于传入的内容以及实现内部定义的临时变量。由于我们正在改变glob,我们在代码库中引入了一个副作用。

现在,在某些情况下,副作用是必需的。我们可能需要更新一个单一点,或者我们可能需要将某些东西存储在一个单一位置,但我们应该尝试实现一个接口来为我们完成这些操作,而不是直接影响全局项目(这听起来很像 Redux)。通过编写一个或两个函数来影响超出范围的项目,我们现在可以诊断问题可能出现的地方,因为我们有这些单一的入口点。

那么这会是什么样子呢?我们可以创建一个状态对象,就像一个普通的对象一样。然后,我们可以在全局范围内编写一个名为updateState的函数,如下所示:

const updateState = function(update) {
    const x = Object.keys(update);
    for(let i = 0; i < x.length; i++) {
        state[x[i]] = update[x[i]];
    }
}

现在,虽然这可能是好的,但我们仍然容易受到通过实际全局属性更新我们的状态对象的影响。幸运的是,通过将我们的状态对象和函数设为const,我们可以确保错误的代码无法触及这些实际的名称。让我们更新我们的代码,以确保我们的状态受到直接更新的保护。我们可以通过两种方式来实现这一点。第一种方法是使用模块编码,然后我们的状态对象将被限定在该模块中。我们将在本书中进一步讨论模块和导入语法。在这种情况下,我们将使用第二种方法,即立即调用函数表达式IIFE)的方式进行编码。以下展示了这种实现方式:

const state = {};
(function(scope) {
    const _state = {};
    scope.update = function(obj) {
        const x = Object.keys(obj);
        for(let i = 0; i < x.length; i++) {
            _state[x[i]] = obj[x[i]];
        }
    }
    scope.set = function(key, val) {
        _state[key] = val;
    }
    scope.get = function(key) {
        return _state[key];
    }
    scope.getAll = function() {
        return Object.assign({}, _state);
    }
})(state);
Object.freeze(state);

首先,我们创建一个常量状态。然后我们使用 IIFE 并传入状态对象,在其上设置一堆函数。它在一个内部scoped _state变量上工作。我们还拥有所有我们期望的内部状态系统的基本函数。我们还冻结了外部状态对象,因此它不再能被操纵。可能会出现的一个问题是,为什么我们要返回一个新对象而不是一个引用。如果我们试图确保没有人能够触及内部状态,那么我们不能传递一个引用出去;我们必须传递一个新对象。

我们仍然有一个问题。如果我们想要更新多层深度,会发生什么?我们将再次遇到引用问题。这意味着我们需要更新我们的更新函数以执行深度更新。我们可以用多种方式来做到这一点,但一种方法是将值作为字符串传递,然后在小数点上分割。

这并不是处理这个问题的最佳方式,因为我们在技术上可以让对象的属性以小数点命名,但这将允许我们快速编写一些东西。在编写高性能代码库时,平衡编写功能性代码和被认为是完整解决方案的东西之间的平衡是两回事,必须在写作时加以平衡。

因此,我们现在将有一个如下所示的方法:

const getNestedProperty = function(key) {
    const tempArr = key.split('.');
    let temp = _state;
    while( tempArr.length > 1 ) {
        temp = temp[tempArr.shift()];
        if( temp === undefined ) {
            throw new Error('Unable to find key!');
        }
    }
    return {obj : temp, finalKey : tempArr[0] };
}
scope.set = function(key, val) {
    const {obj, finalKey} = getNestedProperty(key);
    obj[finalKey] = val;
}
scope.get = function(key) {
    const {obj, finalKey} = getNestedProperty(key);
    return obj[finalKey];
}

我们正在通过小数点来分解键。我们还获取了对内部状态对象的引用。当列表中仍有项目时,我们会在对象中向下移动一级。如果我们发现它是未定义的,那么我们将抛出一个错误。否则,一旦我们在我们想要的位置的上一级,我们将返回一个具有该引用和最终键的对象。然后我们将在 getter 和 setter 中使用这个对象来替换这些值。

现在,我们仍然有一个问题。如果我们想要使引用类型成为内部状态系统的属性值,会怎么样呢?嗯,我们将遇到之前看到的相同问题。我们将在单个状态对象之外有引用。这意味着我们将不得不克隆每一步,以确保外部引用不指向内部副本中的任何内容。我们可以通过添加一堆检查并确保当我们到达引用类型时,以一种高效的方式进行克隆来创建这个系统。代码如下所示:

const _state = {},
checkPrimitives = function(item) {
    return item === null || typeof item === 'boolean' || typeof item === 
     'string' || typeof item === 'number' || typeof item === 'undefined';
},
cloneFunction = function(fun, scope=null) {
    return fun.bind(scope);
},
cloneObject = function(obj) {
    const newObj = {};
    const keys = Object.keys(obj);
    for(let i = 0; i < keys.length; i++) {
        const key = keys[i];
        const item = obj[key];
        newObj[key] = runUpdate(item);
    }
    return newObj;
},
cloneArray = function(arr) {
    const newArr = new Array(arr.length);
    for(let i = 0; i < arr.length; i++) {
        newArr[i] = runUpdate(arr[i]);
    }
    return newArr;
},
runUpdate = function(item) {
    return checkPrimitives(item) ?
        item : 
        typeof item === 'function' ?
            cloneFunction(item) :
        Array.isArray(item) ?
            cloneArray(item) :
            cloneObject(item);
};

scope.update = function(obj) {
    const x = Object.keys(obj);
    for(let i = 0; i < x.length; i++) {
        _state[x[i]] = runUpdate(obj[x[i]]);
    }
}

我们所做的是编写一个简单的克隆系统。我们的update函数将遍历键并运行更新。然后我们将检查各种条件,比如我们是否是原始类型。如果是,我们只需复制值,否则,我们需要弄清楚我们是什么复杂类型。我们首先搜索是否是一个函数;如果是,我们只需绑定值。如果是一个数组,我们将遍历所有的值,并确保它们都不是复杂类型。最后,如果是一个对象,我们将遍历所有的键,并尝试运行相同的检查来更新这些键。

然而,我们刚刚做了我们一直在避免的事情;我们已经创建了一个不可变的状态系统。我们可以为这个集中的状态系统添加更多的功能,比如事件,或者我们可以实现一个已经存在很长时间的编码标准,称为Resource Allocation Is InitializationRAII)。

有一个名为proxies的内置 Web API 非常好。这些基本上是系统,我们能够在对象发生某些事情时执行某些操作。在撰写本文时,这些仍然相当慢,除非是在我们不担心时间敏感的对象上,否则不应该真正使用它们。我们不打算对它们进行详细讨论,但对于那些想要了解它们的读者来说,它们是可用的。

资源分配即初始化(RAII)

RAII 的概念来自 C++,在那里我们没有内存管理器。我们封装逻辑,可能希望共享需要在使用后释放的资源。这确保我们没有内存泄漏,并且正在使用该项的对象是以安全的方式进行的。这个概念的另一个名称是scope-bound resource managementSBRM),也在另一种最近的语言 Rust 中使用。

我们可以在 JavaScript 代码中应用与 C++和 Rust 相同类型的 RAII 思想。我们可以处理这个问题的几种方法,我们将对它们进行讨论。第一种方法是,当我们将一个对象传递给一个函数时,我们可以从调用函数中将该对象null掉。

现在,我们将不得不在大多数情况下使用let而不是const,但这是一种有用的范式,可以确保我们只保留我们需要的对象。

这个概念可以在以下代码中看到:

const getData = function() {
    return document.getElementById('container').value;
};
const encodeData = function(data) {
    let te = new TextEncoder();
    return te.encode(data);
};
const hashData = function(algorithm) {
    let str = getData();
    let finData = encodeData(str);
    str = null;
    return crypto.subtle.digest(algorithm, finData);
};
{
    let but = document.getElementById('submit');
    but.onclick = function(ev) {
        let algos = ['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'];
        let out = document.getElementById('output');
        for(let i = 0; i < algos.length; i++) {
            const newEl = document.createElement('li');
            hashData(algos[i]).then((res) => {
                let te = new TextDecoder();
                newEl.textContent = te.decode(res);
                out.append(newEl);
            });
        }
        out = null;
    }
    but = null;
}

如果我们运行以下代码,我们会注意到我们正在尝试追加到一个null。这就是这种设计可能会让我们陷入麻烦的地方。我们有一个异步方法,我们正在尝试使用一个我们已经使无效的值,尽管我们仍然需要它。处理这种情况的最佳方法是什么?一种方法是在使用完毕后将其null掉。因此,我们可以将代码更改为以下内容:

for(let i = 0; i < algos.length; i++) {
    let temp = out;
    const newEl = document.createElement('li');
    hashData(algos[i]).then((res) => {
        let te = new TextDecoder();
        newEl.textContent = te.decode(res);
        temp.append(newEl);
        temp = null
    });
}

我们仍然有一个问题。在Promise的下一部分(then方法)运行之前,我们仍然可以修改值。一个最后的好主意是将此输入输出包装在一个新函数中。这将给我们所寻找的安全性,同时也确保我们遵循 RAII 背后的原则。以下代码是由此产生的:

const showHashData = function(parent, algorithm) {
    const newEl = document.createElement('li');
    hashData(algorithm).then((res) => {
        let te = new TextDecoder();
        newEl.textContent = te.decode(res);
        parent.append(newEl);
    });
}

我们还可以摆脱一些之前的 null,因为函数将处理这些临时变量。虽然这个例子相当琐碎,但它展示了在 JavaScript 中处理 RAII 的一种方式。

除此范式之外,我们还可以向传递的项目添加属性,以表明它是只读版本。这将确保我们不会修改该项目,但如果我们仍然想从中读取,我们也不需要在调用函数上将元素null掉。这使我们能够确保我们的对象可以被利用和维护,而不必担心它们会被修改。

我们将删除以前的代码示例,并更新它以利用这个只读属性。我们首先定义一个函数,将其添加到任何传入的对象中,如下所示:

const addReadableProperty = function(item) {
    Object.defineProperty(item, 'readonly', {
        value : true,
        writable :false
    });
    return item;
}

接下来,在我们的onclick方法中,我们将输出传递给此方法。现在,它已经附加了readonly属性。最后,在我们的showHashData函数中,当我们尝试访问它时,我们已经在readonly属性上设置了保护。如果我们注意到对象具有它,我们将不会尝试追加到它,就像这样:

if(!parent.readonly ) {
    parent.append(newEl);
}

我们还将此属性设置为不可写,因此如果一个恶意的行为者决定操纵我们对象的readonly属性,他们仍然会注意到我们不再向 DOM 追加内容。defineProperty方法非常适用于编写无法轻易操纵的 API 和库。另一种处理方法是冻结对象。使用freeze方法,我们可以确保对象的浅拷贝是只读的。请记住,这仅适用于浅实例,而不适用于持有引用类型的任何其他属性。

最后,我们可以利用计数器来查看是否可以设置数据。我们基本上正在创建一个读取端锁。这意味着在读取数据时,我们不希望设置数据。这意味着我们必须采取许多预防措施,以确保我们在读取所需内容后正确释放数据。这可能看起来像下面这样:

const ReaderWriter = function() {
    let data = {};
    let readers = 0;
    let readyForSet = new CustomEvent('readydata');
    this.getData = function() {
        readers += 1;
        return data;
    }
    this.releaseData = function() {
        if( readers ) {
            readers -= 1;
            if(!readers ) {
                document.dispatchEvent(readyForSet);
            }
        }
        return readers;
    }
    this.setData = function(d) {
        return new Promise((resolve, reject) => {
            if(!readers ) {
                data = d;
                resolve(true);
            } else {
                document.addEventListener('readydata', function(e) {
                    data = d;
                    resolve(true);
                }, { once : true });
            }
        });
    }
}

我们所做的是设置一个构造函数。我们将数据、读者数量和自定义事件作为私有变量保存。然后创建三种方法。首先,getData将获取数据,并为使用它的人添加一个计数器。接下来是release方法。这将递减计数器,如果计数器为 0,我们将触发一个事件,告诉setData事件可以最终写入可变状态。最后是setData函数。返回值将是一个 promise。如果没有人持有数据,我们将立即设置并解析它。否则,我们将为我们的自定义事件设置一个事件监听器。一旦触发,我们将设置数据并解析 promise。

现在,这种锁定可变数据的最终方法不应该在大多数情况下使用。可能只有少数情况下你会想要使用它,比如热缓存,我们需要确保在读者从中读取时不要覆盖某些东西(这在 Node.js 方面尤其可能发生)。

所有这些方法都有助于创建一个安全的可变状态。通过这些方法,我们能够直接改变对象并共享内存空间。大多数情况下,良好的文档和对数据的谨慎控制将使我们不需要采取我们在这里所做的极端措施,但是当我们发现某些问题出现并且我们正在改变不应该改变的东西时,拥有这些 RAII 方法是很好的。

大多数情况下,不可变和高度函数式的代码最终会更易读,如果某些东西不需要高度优化,建议以易读性为重。但是,在高度优化的情况下,例如编码和解码或装饰表中的列,我们需要尽可能地提高性能。这将在本书的后面部分看到,我们将利用各种编程技术的混合。

尽管可变编程可能很快,但有时我们希望以函数方式实现事物。接下来的部分将探讨以这种函数方式实现程序的方法。

函数式编程风格

即使我们谈论了关于函数概念在原始速度方面不是最佳的,但在 JavaScript 中利用它们仍然可能非常有帮助。有许多语言不是纯函数式的,所有这些语言都给了我们利用许多范式的最佳思想的能力。例如 F#和 Scala 等语言。在这种编程风格方面有一些很棒的想法,我们可以利用 JavaScript 中的内置概念。

惰性评估

在 JavaScript 中,我们可以进行所谓的惰性评估。惰性评估意味着程序不运行不需要的部分。一个思考这个问题的方式是,当有人得到一个问题的答案列表,并被告知把正确的答案放在问题的答案列表中。如果他们发现答案是他们查看的第二个项目,他们就不会继续查看他们得到的其他答案;他们会在第二个项目处停下来。我们在 JavaScript 中使用惰性评估的方式是使用生成器。

生成器是一种函数,它会暂停执行,直到在它们上调用next方法。一个简单的例子如下所示:

const simpleGenerator = function*() {
    let it = 0;
    for(;;) {
        yield it;
        it++;
    }
}

const sg = simpleGenerator();
for(let i = 0; i < 10; i++) {
    console.log(sg.next().value);
}
sg.return();
console.log(sg.next().value);

首先,我们注意到function旁边有一个星号。这表明这是一个生成器函数。接下来,我们设置一个简单的变量来保存我们的值,然后我们有一个无限循环。有些人可能会认为这将持续运行,但惰性评估表明我们只会运行到yield。这个yield意味着我们将在这里暂停执行,并且我们可以获取我们发送回来的值。

所以,我们启动函数。我们没有什么要传递给它,所以我们只是简单地启动它。接下来,我们在生成器上调用next并获取值。这给了我们一个单独的迭代,并返回yield语句上的任何内容。最后,我们调用return来表示我们已经完成了这个生成器。如果我们愿意,我们可以在这里获取最终值。

现在,我们会注意到当我们调用next并尝试获取值时,它返回了 undefined。我们可以看一下生成器,并注意到它有一个叫做done的属性。这可以让我们看到有限生成器是否已经完成。那么当我们想要做一些事情时,这怎么会有帮助呢?一个相当琐碎的例子是一个计时函数。我们将在想要计时的东西之前启动计时器,然后我们将再次调用它来计算某个东西运行所花费的时间(与console.timetimeEnd非常相似,但它应该展示了生成器的可用性)。

这个生成器可能看起来像下面这样:

const timing = function*(time) {
    yeild Date.now() - time;
}
const time = timing(Date.now());
let sum = 0;
for(let i = 0; i < 1000000; i++) {
    sum = sum + i;
}
console.log(time.next().value);

我们现在正在计时一个简单的求和函数。所有这个函数做的就是用当前时间初始化计时生成器。一旦调用下一个函数,它就会运行到yield语句并返回yield中保存的值。这将给我们一个新的时间与我们传入的时间进行比较。现在我们有了一个用于计时的简单函数。这对于我们可能无法访问控制台并且需要在其他地方记录这些信息的环境特别有用。

就像前面的代码块所示,我们也可以使用许多不同类型的惰性加载。其中利用这个接口的最好类型之一是流。流在 Node.js 中已经有一段时间了,但是浏览器的流接口有一个基本的标准化,某些部分仍在讨论中。这种类型的惰性加载或惰性读取的一个简单例子可以在下面的代码中看到:

const nums = function*(fn=null) {
    let i = 0;
    for(;;) {
        yield i;
        if( fn ) {
            i += fn(i);
        } else {
            i += 1;
        }
    }
}
const data = {};
const gen = nums();
for(let i of gen) {
    console.log(i);
    if( i > 100 ) {
        break;
    }
    data.push(i);
}

const fakestream = function*(data) {
    const chunkSize = 10;
    const dataLength = data.length;
    let i = 0;
    while( i < dataLength) {
        const outData = [];
        for(let j = 0; j < chunkSize; j++) {
            outData.push(data[i]);
            i+=1;
        }
        yield outData;
    }
}

for(let i of fakestream(data)) {
    console.log(i);
}

这个例子展示了惰性评估的概念,以及我们将在后面章节中看到的流的一些概念。首先,我们创建一个生成器,它可以接受一个函数,并可以利用它来创建我们的逻辑函数中的数字。在我们的例子中,我们只会使用默认情况,并且让它一次生成一个数字。接下来,我们将通过for/of循环运行这个生成器,以生成 101 个数字。

接下来,我们创建一个fakestream生成器,它将为我们分块数据。这类似于流,允许我们一次处理一块数据。我们可以对这些数据进行转换(称为TransformStream),或者只是让它通过(称为PassThrough的一种特殊类型的TransformStream)。我们在10处创建一个假的块大小。然后我们再次对之前的数据运行另一个for/of循环,并简单地记录它。但是,如果我们愿意,我们也可以对这些数据做些什么。

这不是流使用的确切接口,但它展示了我们如何在生成器中实现惰性求值,并且这也内置在某些概念中,比如流。生成器和惰性求值技术还有许多其他潜在的用途,这里不会涉及,但对于寻求更功能式风格的列表和映射理解的开发人员来说,它们是可用的。

尾递归优化

这是许多功能性语言具有的另一个概念,但大多数 JavaScript 引擎没有(WebKit 是个例外)。尾递归优化允许以一定方式构建的递归函数运行得就像一个简单的循环一样。在纯函数语言中,没有循环这样的东西,所以处理集合的唯一方法是通过递归进行。我们可以看到,如果我们将一个函数构建为尾递归函数,它将破坏我们的堆栈。以下代码说明了这一点:

const _d = new Array(100000);
for(let i = 0; i < _d.length; i++) {
    _d[i] = i;
}
const recurseSummer = function(data, sum=0) {
    if(!data.length ) {
        return sum;
    }
    return recurseSummer(data.slice(1), sum + data[0]);
}
console.log(recurseSummer(_d));

我们创建了一个包含 100,000 个项目的数组,并为它们分配了它们索引处的值。然后我们尝试使用递归函数来对数组中的所有数据进行求和。由于函数的最后一次调用是函数本身,一些编译器能够在这里进行优化。如果它们注意到最后一次调用是对同一个函数的调用,它们知道当前的堆栈可以被销毁(函数没有剩余工作要做)。然而,非优化的编译器(大多数 JavaScript 引擎)不会进行这种优化,因此我们不断向我们的调用系统添加堆栈。这导致调用堆栈大小超出限制,并使我们无法利用这个纯粹的功能概念。

然而,JavaScript 还是有希望的。一个叫做 trampolining 的概念可以通过修改函数和我们调用它的方式来实现尾递归。以下是修改后的代码,以利用 trampolining 并得到我们想要的结果:

const trampoline = (fun) => {
    return (...arguments) => {
        let result = fun(...arguments);
        while( typeof result === 'function' ) {
            result = result();
        }
        return result;
    }
}

const _d = new Array(100000);
for(let i = 0; i < _d.length; i++) {
    _d[i] = i;
}
const recurseSummer = function(data, sum=0) {
    if(!data.length ) {
        return sum;
    }
    return () => recurseSummer(data.slice(1), sum + data[0]);
}
const final = trampoline(recurseSummer);
console.log(final(_d));

我们所做的是将我们的递归函数包装在一个我们通过简单循环运行的函数中。trampoline函数的工作方式如下:

  • 它接受一个函数,并返回一个新构造的函数,该函数将运行我们的递归函数,但通过循环进行检查返回类型。

  • 在这个内部函数中,它通过执行函数的第一次运行来启动循环。

  • 当我们仍然将一个函数作为我们的返回类型时,它将继续循环。

  • 一旦我们最终得到了一个函数,我们将返回结果。

现在我们能够利用尾递归来做一些在纯粹的功能世界中会做的事情。之前看到的一个例子(可以看作是一个简单的 reduce 函数)是一个例子。

const recurseFilter = function(data, con, filtered=[]) {
    if(!data.length ) {
        return filtered;
    }
    return () => recurseFilter(data.slice(1), con, con(data[0]) ? 
     filtered.length ? new Array(...filtered), data[0]) : [data[0]] : filtered);

const finalFilter = trampoline(recurseFilter);
console.log(finalFilter(_d, item => item % 2 === 0));

通过这个函数,我们模拟了纯函数语言中基于过滤的操作可能是什么样子。同样,如果没有长度,我们就到达了数组的末尾,并返回我们过滤后的数组。否则,我们返回一个新函数,该函数用一个新列表、我们要进行过滤的函数以及过滤后的列表递归调用自身。这里有一些奇怪的语法。如果我们有一个空列表,我们必须返回一个带有新项的单个数组,否则,它将给我们一个包含我们传入的项目数量的空数组。

我们可以看到,这两个函数都通过了尾递归的检查,并且也是纯函数语言中可以编写的函数。但是,我们也会看到,这些函数运行起来比简单的for循环甚至这些类型函数的内置数组方法要慢得多。归根结底,如果我们想要使用尾递归来编写纯粹的函数式编程,我们可以,但在 JavaScript 中这样做是不明智的。

柯里化

我们将要看的最后一个概念是柯里化。柯里化是一个接受多个参数的函数实际上是一系列接受单个参数并返回另一个函数或最终值的函数。让我们看一个简单的例子来看看这个概念是如何运作的:

const add = function(a) {
    return function(b) {
        return a + b;
    }
}

我们正在接受一个接受多个参数的函数,比如add函数。然后我们返回一个接受单个参数的函数,这里是b。这个函数然后将数字ab相加。这使我们能够像通常一样使用函数(除了我们运行返回给我们的函数并传入第二个参数),或者我们得到运行它的返回值,并使用该函数来添加接下来的任何值。这些概念中的每一个都可以在以下代码中看到:

console.log(add(2)(5), 'this will be 7');
const add5 = add(5);
console.log(add5(5), 'this will be 10');

柯里化有一些用途,它们也展示了一个可以经常使用的概念。首先,它展示了部分应用的概念。这样做是为我们设置一些参数并返回一个函数。然后我们可以将这个函数传递到语句链中,并最终用它来填充剩下的函数。

只需记住,所有柯里化函数都是部分应用函数,但并非所有部分应用函数都是柯里化函数。

部分应用的示例可以在以下代码中看到:

const fullFun = function(a, b, c) {
    console.log('a', a);
    console.log('b', b);
    console.log('c', c);
}
const tempFun = fullFun.bind(null, 2);
setTimeout(() => {
    const temp2Fun = tempFun.bind(null, 3);
    setTimeout(() => {
        const temp3Fun = temp2Fun.bind(null, 5);
        setTimeout() => {
            console.log('temp3Fun');
            temp3Fun();
        }, 1000);
    }, 1000);
    console.log('temp2Fun');
    temp2Fun(5);
}, 1000);
console.log('tempFun');
tempFun(3, 5);

首先,我们创建一个接受三个参数的函数。然后我们创建一个新的临时函数,将2绑定到该函数的第一个参数。Bind是一个有趣的函数。它将我们想要的作用域作为第一个参数(this指向的内容),然后接受任意长度的参数来填充我们正在处理的函数的参数。在我们的例子中,我们只将第一个变量绑定到数字2。然后我们创建一个第二个临时函数,其中我们将第一个临时函数的第一个变量绑定到3。最后,我们创建一个第三个临时函数,其中我们将第二个函数的第一个参数绑定到数字5

我们可以在每次运行时看到,我们能够运行这些函数中的每一个,并且它们根据我们使用的函数版本不同而接受不同数量的参数。bind是一个非常强大的工具,它允许我们传递函数,这些函数可能会在最终使用函数之前从其他函数中获取参数填充。

柯里化是我们将使用部分应用,但我们将用多个嵌套函数组成多参数函数的概念。那么柯里化给我们带来了什么,而其他概念无法做到呢?如果我们处于纯函数世界,实际上我们可以得到很多。例如,数组上的map函数。它希望一个单个项目的函数定义(我们将忽略通常不使用的其他参数),并希望函数返回一个单个项目。当我们有一个像下面这样的函数,并且它可以在map函数中使用,但它有多个参数时会发生什么?以下代码展示了我们可以用柯里化和这种用例做些什么:

const calculateArtbitraryValueWithPrecision = function(prec=0, val) {
    return function(val) {
        return parseFloat((val / 1000).toFixed(prec));
    }
}
const arr = new Array(50000);
for(let i = 0; i < arr.length; i++) {
    arr[i] = i + 1000;
}
console.log(arr.map(calculatorArbitraryValueWithPrecision(2)));

我们正在接受一个通用函数(甚至是任意的),并通过使其更具体(在本例中是保留两位小数)在map函数中使用它。这使我们能够编写非常通用的函数,可以处理任意数据并从中制作特定的函数。

我们将在我们的代码中使用部分应用,并且可能会使用柯里化。然而,总的来说,我们不会像在纯函数式语言中那样使用柯里化,因为这可能会导致减速和更高的内存消耗。最重要的是要理解部分应用和外部作用域变量如何在内部作用域位置中使用的概念。

这三个概念对于纯函数式编程的理念非常关键,但我们将不会利用其中大部分。在高性能代码中,我们需要尽可能地提高速度和内存利用率,而其中大部分构造占用的资源超出了我们的承受范围。某些概念可以在高性能代码中大量使用。以下内容将在后续章节中使用:部分应用、流式/惰性求值,可能还有一些递归。熟悉函数式代码将有助于在使用利用这些概念的库时更加得心应手,但正如我们长时间讨论过的那样,它们并不像我们的迭代方法那样高性能。

总结

在本章中,我们已经了解了可变性和不可变性的概念。我们已经看到不可变性可能会导致减速和更高的内存消耗,并且在编写高性能代码时可能会成为一个问题。我们已经看了可变性以及如何确保我们编写的代码利用了它,但也使其安全。除此之外,我们还进行了可变和不可变代码的性能比较,并看到了不可变类型的速度和内存消耗增加的情况。最后,我们看了 JavaScript 中的函数式编程以及我们如何利用这些概念。函数式编程可以帮助解决许多问题,比如无锁并发,但我们也知道 JavaScript 运行时是单线程的,因此这并没有给我们带来优势。总的来说,我们可以从不同的编程范式中借鉴许多概念,拥有这些概念可以使我们成为更好的程序员,并帮助我们编写干净、安全和高性能的代码。

在下一章中,我们将看一下 JavaScript 作为一种语言是如何发展的。我们还将看一下浏览器是如何改变以满足开发人员的需求的,新的 API 涵盖了从访问 DOM 到长期存储的所有内容。

第三章:Vanilla Land - 看现代 Web

自 ECMAScript 2015 标准发布以来,JavaScript 语言的格局发生了很大变化。现在有许多新功能使 JavaScript 成为各种开发的一流语言。使用该语言变得更容易,我们现在甚至可以看到一些语法糖。

从 ECMAScript 2015 标准及以后,我们已经获得了类、模块、更多声明变量的方式、作用域的变化等。所有这些特性等等将在本章的其余部分进行解释。如果您对该语言还不熟悉,或者只是想了解一下可能不熟悉的特性,这是一章值得阅读的好章节。我们还将看一下一些旧的 Web 部分,如 DOM 查询,以及我们如何利用它们来替换我们可能当前正在使用的多余库,如 jQuery。

在本章中,将涵盖以下主题:

  • 深入现代 JavaScript

  • 理解类和模块

  • 与 DOM 一起工作

  • 理解 Fetch API

技术要求

本章的先决条件如下:

深入现代 JavaScript

如介绍中所述,语言在许多方面都有所改进。我们现在有了适当的作用域,更好地处理async操作,更多的集合类型,甚至元编程特性,如反射和代理。所有这些特性都导致了更复杂的语言,但也导致了更有效的问题解决。我们将看一下新标准中出现的一些最佳项,以及它们在我们的代码中可以用来做什么。

另一个需要注意的是,未来显示的任何 JavaScript 代码都可以通过以下方式运行:

  1. 通过按下键盘上的F12将其添加到开发者控制台

  2. 利用开发者控制台中可以在“Sources”选项卡中看到的片段,在左侧面板中应该有一个名为“Snippets”的选项

  3. 编写一个基本的index.html,其中添加了一个脚本元素

Let/const 和块作用域

在 ECMAScript 2015 之前,我们只能使用var关键字来定义变量。var关键字的生命周期从函数声明到函数结束。这可能会导致很多问题。以下代码展示了我们可能在var关键字中遇到的问题之一:

var fun = function() {
    for(var i = 0; i < 10; i++) {
        state['this'] += 'what';
    }
    console.log('i', i);
}
fun();

控制台会打印出什么?在大多数语言中,我们可能会猜想这是一个错误,或者会打印null。然而,JavaScript 的var关键字是函数作用域的,所以变量i将是10。这导致了许多错误的出现,因为意外地忘记声明变量,甚至可怕的switch语句错误(这些错误仍然会发生在letconst中)。switch语句错误的一个例子如下:

var x = 'a';
switch(x) {
    case 'a':
        y = 'z';
        break;
    case 'b':
        y = 'y';
        break;
    default:
        y = 'b';
}
console.log(y);

从前面的switch语句中,我们期望ynull,但因为var关键字不是块作用域的,它将是字母z。我们总是必须掌握变量并确保我们没有使用在我们范围之外声明的东西并改变它,或者我们确保我们重新声明变量以阻止泄漏发生。

使用letconst,我们得到了块作用域。这意味着花括号告诉我们变量应该存在多久。这里有一个例子:

let x = 10;
let fun2 = function() {
    {
        let x = 20;
        console.log('inner scope', x);
    }
    console.log('outer scope', x);
    x += 10;
}
fun2();
console.log('this should be 20', x);

当我们查看变量x的打印输出时,我们可以看到我们首先在函数外部将其声明为10。在函数内部,我们使用大括号创建了一个新的作用域,并将x重新声明为20。在块内部,代码将打印出inner scope 20。但是,在fun2内部的块之外,我们打印出x,它是10let关键字遵循此块作用域。如果我们将变量声明为var,则第二次打印时它将保持为20。最后,我们将10添加到外部的x,我们应该看到x20

除了获得块作用域之外,const关键字还赋予了我们一些不可变性。如果我们正在使用的类型是值类型,我们将无法改变该值。如果我们有一个引用类型,引用内部的值可以被改变,但是我们不能改变引用本身。这带来了一些很好的功能。

一个很好的编码风格是尽可能多地使用const,只有在需要在基本级别上改变某些东西时才使用let,比如循环。由于对象、数组或函数的值可以被改变,我们可以将它们设置为const。唯一的缺点是它们不能被置空,但它仍然在可能的性能增益之上增加了相当多的安全性,编译器可以利用知道一个值是不可变的。

箭头函数

语言的另一个显著变化是添加了箭头函数。有了这个,我们现在可以在不使用语言上的各种技巧的情况下改变this。可以看到以下示例:

const create = function() {
    this.x = 10;
    console.log('this', this);
    const innerFun = function() {
        console.log('inner this', this);
    }
    const innerArrowFun = () => {
        console.log('inner arrow this', this);
    }
    innerFun();
    innerArrowFun();
}
const item = new create();

我们正在为一个新对象创建一个构造函数。我们有两个内部函数,一个是基本函数调用,另一个是箭头函数。当我们打印这个时,我们注意到基本函数打印出了窗口的作用域。当我们打印内部箭头函数的作用域时,我们得到了父级的作用域。

我们可以通过几种方式来解决基本内部函数的问题。首先,我们可以在父级中声明一个变量,并在内部函数中使用它。此外,当我们运行函数时,我们可以使用 call 或apply来实际运行函数。

然而,这两种方法都不是一个好主意,特别是当我们现在有箭头函数时。要记住的一个关键点是箭头函数获取父级的作用域,所以无论this指向父级的什么,我们现在都将在箭头函数内部执行相同的操作。现在,我们可以通过在箭头函数上使用apply来始终更改它,但最好只使用apply等来进行部分应用,而不是通过更改其this关键字来调用函数。

集合类型

数组和对象一直是 JavaScript 开发人员使用的两种主要类型。但是,现在我们有了另外两种集合类型,可以帮助我们做一些我们过去使用这些其他类型的事情。这些是 set 和 map。set 是一个无序的唯一项集合。这意味着如果我们试图将已经存在的东西放入 set 中,我们会注意到我们只有一个单一项。我们可以很容易地用数组模拟一个 set,如下所示:

const set = function(...items) {
   this._arr = [...items];
   this.add = function(item) {
       if( this._arr.includes(item) ) return false;
       this._arr.push(item);
       return true;
   }
   this.has = function(item) {
       return this._arr.includes(item);
   }
   this.values = function() {
       return this._arr;
   }
   this.clear = function() {
       this._arr = [];
   }
}

由于我们现在有了 set 系统,我们可以直接使用该 API。我们还可以访问for of循环,因为 set 是一个可迭代项(如果我们获取附加到 set 的迭代器,我们也可以使用下一个语法)。与数组相比,当我们处理大型数据集时,set 在读取访问速度上也具有优势。以下示例说明了这一点:

const data = new Array(10000000);
for(let i = 0; i < data.length; i++) {
    data[i] = i;
}
const setData = new Set();
for(let i = 0; i < data.length; i++) {
    setData.add(i);
}
data.includes(5000000);
setData.has(5000000);

尽管创建 set 需要一些时间,但是当查找项目或甚至获取它们时,set 的性能几乎比数组快 100 倍。这主要是由于数组查找项目的方式。由于数组是纯线性的,它必须遍历每个元素进行检查,而 set 是一个简单的常量时间检查。

集合可以根据引擎的不同方式实现。V8 引擎中的集合是利用哈希字典进行查找构建的。我们不会详细介绍这些内部情况,但基本上,查找时间被认为是常数,或者对于计算机科学家来说是O(1),而数组查找时间是线性的,或者O(n)

除了集合,我们还有地图。我们可以将它们视为普通对象,但它们有一些很好的属性:

  • 首先,我们可以使用任何值作为键,甚至是对象。这对于添加我们不想直接绑定到对象的其他数据非常有用(私有值浮现在脑海中)。

  • 除此之外,地图也是可迭代的,因此我们可以像集合一样利用for of循环。

  • 最后,地图可以在大型数据集和键和值类型相同的情况下为我们带来性能优势。

以下示例突出了地图通常比普通对象更好的许多领域,以及曾经使用对象的领域:

const map = new Map();
for(let i = 0; i < 10000; i++) {
    map.set(`${i}item`, i);
}
map.forEach((val, key) => console.log(val));
map.size();
map.has('0item');
map.clear();

除了这两个项目,我们还有它们的弱版本。弱版本有一个主要限制:值必须是对象。一旦我们了解了WeakSetWeakMap的作用,这就说得通了。它们弱地存储对项目的引用。这意味着当它们存储的项目存在时,我们可以执行这些接口给我们的方法。一旦垃圾收集器决定收集它们,引用将从弱版本中删除。我们可能会想,为什么要使用这些?

对于WeakMap,有一些用例:

  • 首先,如果我们没有私有变量,我们可以利用WeakMap在对象上存储值,而实际上不将属性附加到它们上。现在,当对象最终被垃圾收集时,这个私有引用也会被回收。

  • 我们还可以利用弱映射将属性或数据附加到 DOM,而实际上不必向 DOM 添加属性。我们可以获得数据属性的所有好处,而不会使 DOM 混乱。

  • 最后,如果我们想要将引用数据存储到一边,但在数据消失时使其消失,这是另一个用例。

总的来说,当我们想要将某种数据与对象绑定而不需要紧密耦合时,我们会使用WeakMap。我们将能够看到这一点,如下所示:

const items = new WeakMap();
const container = document.getElementById('content');
for(let i = 0; i < 50000; i++) {
    const el = document.createElement('li');
    el.textContent = `we are element ${i}`;
    el.onclick = function(ev) {
        console.log(items.get(el));
    }
    items.set(el, i);
    container.appendChild(el);
}
const removeHalf = function() {
    const amount = Math.floor(container.children.length / 2);
    for(let i = 0; i < amount; i++) {
        container.removeChild(container.firstChild); 
    }
}

首先,我们创建一个WeakMap来存储我们想要针对创建的 DOM 元素的数据。接下来,我们获取我们的无序列表,并在每次迭代中添加一个列表元素。然后,我们通过WeakMap将我们所在的数字与 DOM 元素联系起来。这样,onclick处理程序就可以获取该项并取回我们存储在其中的数据。

有了这个,我们可以点击任何元素并取回数据。这很酷,因为我们过去直接在 DOM 中向 HTML 元素添加数据属性。现在我们可以使用WeakMap。但是,我们还有一个更多的好处,这已经被讨论过。如果我们在命令行中运行removeHalf函数并进行垃圾收集,我们可以看一下WeakMap中有多少项。如果我们这样做,并检查WeakMap中有多少元素,我们会注意到它存储的元素数量可以从 25,000 到我们开始的完整 50,000 个元素。这是由于上面所述的原因;一旦引用被垃圾收集,WeakMap将不再存储它。它具有弱引用。

垃圾收集器要收集的数量将取决于我们正在运行的系统。在某些系统上,垃圾收集器可能决定不从列表中收集任何内容。这完全取决于 Chrome 或 Node.js 中的 V8 垃圾收集是如何设置的。

如果我们用普通的WeakMap替换它,我们很容易看到这一点。让我们继续进行这个小改变。通过这个改变,观察同样的步骤。我们会注意到地图仍然有 50,000 个项目。这就是我们所说的,当我们说某物有强引用或弱引用时的意思。弱引用将允许垃圾收集器清理项目,而强引用则不会。WeakMaps非常适合这种数据与另一个数据源的链接。如果我们希望在主对象被清理时清理项目装饰或链接,WeakMap是一个不错的选择。

WeakSet有一个更有限的用例。一个很好的用例是检查对象属性或图中的无限循环。如果我们将所有访问过的节点存储在WeakSet中,我们就能够检查我们是否有这些项目,但我们也不必在检查完成后清除集合。这意味着一旦数据被收集,存储在WeakSet中的所有引用也将被收集。总的来说,当我们需要标记一个对象或引用时,应该使用WeakSet。这意味着如果我们需要查看我们是否拥有它或它是否被访问过,WeakSet很可能是这项工作的合适选择。

我们可以利用上一章的深拷贝示例。通过它,我们还遇到了一个我们没有考虑到的用例。如果一个项目指向对象中的另一个项目,并且同一个项目决定再次指向原始项目,会发生什么?这可以在以下代码中看到:

const a = {item1 : b};
const b = {item1 : a};

如果每个项目都指向彼此,我们将遇到循环引用的问题。解决这个问题的方法是使用WeakSet。我们可以保存所有访问过的节点,如果我们遇到一个已经访问过的节点,我们就从函数中返回。这可以在代码的修改版本中看到:

const state = {};
(function(scope) {
    const _state = {},
          _held = new WeakSet(),
          checkPrimitives = function(item) {
              return item === null || typeof item === 'string' || typeof 
               item === 'number' || typeof item === 'boolean' ||
               typeof item === 'undefined';
          },
          cloneFunction = function(fun, scope=null) {
              return fun.bind(scope);
          },
          cloneObject = function(obj) {
              const newObj = {},
              const keys = Object.keys(obj);
              for(let i = 0; i < keys.length; i++) {
                  const key = keys[i];
                  const item = obj[key];
                  newObj[key] = runUpdate(item);
              }
              return newObj;
          },
          cloneArray = function(arr) {
              const newArr = new Array(arr.length);
              for(let i = 0; i < arr.length; i++) {
                  newArr[i] = runUpdate(arr[i]);
              }
              return newArr;
          },
          runUpdate = function(item) {
              if( checkPrimitives(item) ) {
                  return item;
              }
              if( typeof item === 'function' ) {
                  return cloneFunction(item);
              }
              if(!_held.has(item) ) {
                  _held.add(item);
                  if( item instanceof Array ) {
                      return cloneArray(item);
                  } else {
                      return cloneObject(item);
                  }
              }
          };
    scope.update = function(obj) {
        const x = Object.keys(obj);
        for(let i = 0; i < x.length; i++) {
            _state[x[i]] = runUpdate(obj[x[i]]);
        }
        _held = new WeakSet();
    }
})(state);
Object.freeze(state);

正如我们所看到的,我们已经添加了一个新的_held变量,它将保存我们所有的引用。然后,runUpdate函数已经被修改,以确保当一个项目不是原始类型或函数时,我们检查我们的held列表中是否已经有它。如果有,我们就跳过这个项目,否则我们将继续进行。最后,我们用一个新的WeakSet替换了_held变量,因为在WeakSetsclear方法不再可用。

这并不会保留循环引用,这可能是一个问题,但它解决了因对象相互引用而导致系统陷入无限循环的问题。除了这种用例,也许还有一些更高级的想法,WeakSet并没有太多其他的需求。主要的是,如果我们需要跟踪某物的存在。如果我们需要这样做,WeakSet就是我们的完美用例。

大多数开发人员不会发现需要WeakSetsWeakMaps。这些可能会被库作者使用。然而,之前提到的约定在某些情况下可能会出现,因此了解这些项目的原因和存在的意义是很好的。如果我们没有使用某物的理由,那么我们很可能不应该使用它,这在这两个项目中绝对是这样,因为它们有非常具体的用例,而WeakMaps的主要用例之一是在 ECMAScript 标准中提供给我们的(私有变量)。

反射和代理

我们要讨论的 ECMAScript 标准的最后一个重要部分是两个元编程对象。元编程是指生成代码的技术。这可能是用于编译器或解析器等工具。它也可以用于自我改变的代码。甚至可以用于运行时评估另一种语言(解释)并对其进行操作。虽然这可能是反射和代理给我们的主要功能,但它也使我们能够监听对象上的事件。

在上一章中,我们谈到了监听事件,并创建了一个CustomEvent来监听对象上的事件。好吧,我们可以改变那段代码,并利用代理来实现该行为。以下是处理对象上基本事件的一些基本代码:

const item = new Proxy({}, {
    get: function(obj, prop) {
        console.log('getting the following property', prop);
        return Reflect.has(obj, prop) ? obj[prop] : null;
    },
    set: function(obj, prop, value) {
        console.log('trying to set the following prop with the following 
         value', prop, value);
        if( typeof value === 'string' ) {
            obj[prop] = value;
        } else {
            throw new Error('Value type is not a string!');
        }
    }
});
item.one = 'what';
item.two = 'is';
console.log(item.one);
console.log(item.three);
item.three = 12;

我们所做的是为这个对象的getset方法添加了一些基本的日志记录。我们通过使set方法只接受字符串值,扩展了这个对象的功能。有了这个,我们创建了一个可以被监听的对象,并且我们可以对这些事件做出响应。

代理目前比向系统添加CustomEvent要慢。正如前面所述,尽管代理在 ECMAScript 2015 标准中,但它们的采用速度很慢,因此浏览器需要更多时间来优化它们。另外,应该注意的是,我们不希望直接在这里运行日志记录。相反,我们选择让系统排队消息,并利用称为requestIdleCallback的东西,在浏览器注意到我们应用程序的空闲时间时运行我们的日志记录代码。这仍然是一项实验性技术,但应该很快添加到所有浏览器中。

代理的另一个有趣特性是可撤销方法。这是一个代理,我们最终可以说是被撤销的,当我们尝试在此方法调用后使用它时,会抛出TypeError。这对于任何试图使用对象实现 RAII 模式的人来说非常有用。我们可以撤销代理,而不再能够利用它,而不是试图将引用null掉。

这种 RAII 模式与空引用略有不同。一旦我们撤销了代理,所有引用将不再能够使用它。这可能会成为一个问题,但它也会给我们带来失败快速的额外好处,这在代码开发中总是一个很好的特性。这意味着当我们在开发中时,它会抛出TypeError,而不仅仅是传递一个空值。在这种情况下,只有 try-catch 块才能让这段代码继续运行,而不仅仅是简单的空检查。失败快速是保护我们自己在开发中并更早捕获错误的好方法。

这里展示了一个示例,修改了前面代码的版本:

const isPrimitive = function(item) {
    return typeof item === 'string' || typeof item === 'number' || typeof 
     item === 'boolean';
}
const item2 = Proxy.revocable({}, {
    get: function(obj, prop) {
        return Reflect.has(obj, prop) ? obj[prop] : null
    },
    set: function(obj, prop, value) {
        if( isPrimitive(value) ) {
            obj[prop] = value;
        } else {
            throw new Error('Value type is not a primitive!');
        }
    }
});
const item2Proxy = item2.proxy;
item2Proxy.one = 'this';
item2Proxy.two = 12;
item2Proxy.three = true;
item2.revoke();
(function(obj) {
    console.log(obj.one);
})(item2Proxy);

现在,我们不仅在设置时抛出TypeErrors,一旦我们撤销代理,我们也会抛出TypeError。当我们决定编写能够保护自己的代码时,这对我们非常有用。当我们使用对象时,我们也不再需要在代码中编写一堆守卫子句。如果我们使用代理和可撤销代替,我们可以保护我们的设置。

我们没有深入讨论代理系统的术语。从技术上讲,我们在代理处理程序中添加的方法称为陷阱,类似于操作系统陷阱,但我们实际上可以将它们简单地视为简单的事件。有时,术语可能会给事情增加一些混乱,通常是不需要的。

除了代理,反射 API 是一堆静态方法,它们反映了代理处理程序。我们可以在某些熟悉的系统的位置使用它们,比如Function.prototype.apply方法。我们可以使用Reflect.apply方法,这在编写我们的代码时可能会更清晰一些。如下所示:

Math.max.apply(null, [1, 2, 3]);
Reflect.apply(Math.max, null, [1, 2, 3]);
item3 = {};
if( Reflect.set(item3, 'yep', 12) {
    console.log('value was set correctly!');
} else {
    console.log('value was not set!');
}
Reflect.defineProperty(item3, 'readonly', {value : 42});
if( Reflect.set(item3, 'readonly', 'nope') ) {
    console.log('we set the value');
} else {
    console.log('value should not be set!');
}

正如我们所看到的,我们第一次在对象上设置了一个值,并且成功了。但是,第二个属性首先被定义,并且被设置为不可写(当我们使用defineProperty时的默认值),因此我们无法在其上设置一个值。

通过这两个 API,我们可以为访问对象编写一些不错的功能,甚至使变异尽可能安全。我们可以很容易地利用这两个 API 来使用 RAII 模式,甚至可以进行一些很酷的元编程。

其他值得注意的变化

随着 ECMAScript 标准的进步,出现了许多变化,我们可以专门讨论所有这些变化,但我们将在这里列出一些在本书中编写的代码中以及其他地方可能看到的变化。

展开运算符

展开运算符允许我们拆开数组,可迭代集合(如集合或映射),甚至是最新标准中的对象。这为我们提供了更美观的语法,用于执行一些常见操作,例如以下操作:

// working with a variable amount of arguments
const oldVarArgs = function() {
    console.log('old variable amount of arguments', arguments);
}
const varArgs = function(...args) {
    console.log('variable amount of arguments', args);
}
// transform HTML list into a basic array so we have access to array
// operations
const domArr = [...document.getElementsByTagName('li')];
// clone array
const oldArr = [1, 2, 3, 4, 5];
const clonedArr = [...oldArr];
// clone object
const oldObj = {item1 : 'that', item2 : 'this'};
const cloneObj = {...oldObj};

以前的 for 循环和其他迭代版本现在变成了简单的一行代码。此外,第一个项目很好,因为它向代码的读者显示我们正在将函数用作变量参数函数。我们可以通过代码看到这一点,而不需要文档来说明这一点。

处理参数时,如果我们在函数中要对其进行任何改变,先创建一个副本,然后再进行改变。如果我们决定直接改变参数,会发生某些非优化。

解构

解构是将数组或对象的项目以更简单的方式传递给我们分配给的变量的过程。可以在以下代码中看到:

//object
const desObj = {item1 : 'what', item2 : 'is', item3 : 'this'};
const {item1, item2} = desObj;
console.log(item1, item2);

//array
const arr = [1, 2, 3, 4, 5];
const [a, ,b, ...c] = arr;
console.log(a, b, c);

这两个示例展示了一些很酷的特性。首先,我们可以从对象中挑选我们想要的项目。我们还可以在左侧重新分配值为其他值。除此之外,我们甚至可以进行嵌套对象和解构。

对于数组,我们可以选择所有项目、部分项目,甚至通过将数组的其余部分放入变量来使用 rest 语法。在前面的示例中,a 将保存 1b 将保存 3c 将是一个包含 45 的数组。我们通过使该空间为空来跳过了 2。在其他语言中,我们会使用 _ 来展示这一点,但在这里我们可以直接跳过它。同样,所有这些只是语法糖,使得能够编写更紧凑和更清晰的代码。

幂运算符

这里没有什么可说的,除了我们不再需要使用 Math.pow() 函数;我们现在有了幂运算符或 **,从而使代码更清晰,数学方程更美观。

参数默认值

这些允许我们在调用函数时为某个位置放入默认值。可以如下所示:

const defParams = function(arg1, arg2=null, arg3=10) {
    if(!arg2 ) {
        console.log('nothing was passed in or we passed in a falsy value');
    }
    const pow = arg3;
    if( typeof arg1 === 'number' ) {
        return arg1 ** pow;
    } else {
        throw new TypeError('argument 1 was not a number!');
    }
}

需要注意的一点是,一旦我们开始在参数链中使用默认值,就不能停止使用默认值。在前面的示例中,如果我们给参数 2 设置了默认值,那么我们必须给参数 3 设置默认值,即使我们只是将 undefined 或 null 传递给它。同样,这有助于代码的清晰度,并确保我们不再需要在查看数组的参数时创建默认情况。

很多代码仍然利用函数的参数部分。甚至还有函数的其他属性可以获取,比如调用者。如果我们处于严格模式,很多这种行为都会被破坏。严格模式是一种不允许访问 JavaScript 引擎中某些行为的方式。关于这一点的良好描述可以在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode找到。除此之外,由于新标准提供了许多有用的替代方案,我们不应再使用函数的参数部分。

字符串模板

字符串模板允许我们传入将求值为字符串或具有 toString 函数的对象的任意代码。这再次使我们能够编写更清晰的代码,而不必创建大量连接的字符串。它还允许我们编写多行字符串,而无需创建转义序列。可以如下所示:

const literal = `This is a string literal. It can hold multiple lines and
variables by denoting them with curly braces and prepended with the dollar 
sign like so \$\{\}.
here is a value from before ${a}. We can also pass an arbitrary expression 
that evaluates ${a === 1 ? b : c}.
`
console.log(literal);

只需记住,即使我们可以做某事,做某事可能不是最好的主意。具体来说,我们可能能够传递将求值为某个值的任意表达式,但我们应该尽量保持它们简洁清晰,以使代码更易读。

类型化数组

我们将在未来的章节中对此进行详细讨论,但类型化数组是表示系统中任意字节的一种方式。这使我们能够使用更低级的功能,例如编码器和解码器,甚至直接处理fetch调用的字节流,而无需将 blob 转换为数字或字符串。

这些通常以ArrayBuffer开始,然后我们在其上创建一个视图。这可能看起来像下面这样:

const arrBuf = new ArrayBuffer(16);
const uint8 = new Uint8Array(arrBuf);
uint8[0] = 255;

正如我们所看到的,我们首先创建一个数组缓冲区。可以将其视为低级实例。它只保存原始字节。然后我们必须在其上创建一个视图。大部分时间,我们将使用Uint8Array,因为我们需要处理任意字节,但我们可以一直使用视图到BigInt。这些通常用于低级系统,例如 3D 画布代码、WebAssembly 或来自服务器的原始流。

BigInt

BigInt是一个任意长的整数。JavaScript 中的数字以 64 位浮点数双精度存储。这意味着即使我们只有一个普通整数,我们仍然只能获得 53 位的精度。我们只能在变量中存储数字,最大到 9000 万亿。比这更大的数字通常会导致系统进入未定义的行为。为了弥补这一点,我们现在可以在 JavaScript 中利用BigInt特性。这看起来像下面这样:

const bigInt = 100n;
console.log('adding two big ints', 100n + 250n);
console.log('add a big int and a regular number', 100n + BigInt(250));

我们会注意到BigInts后面会附加一个n。如果我们想要在常规操作中使用它们,我们还需要将常规数字强制转换为BigInts。现在我们有了大整数,我们可以处理非常大的数字,这在 3D、金融和科学应用中非常有用。

不要试图将BigInts强制转换回常规数字。这里存在一些未定义的行为,如果我们尝试这样做,可能会失去精度。最好的方法是,如果我们需要使用BigInts,就保持在BigInts中。

国际化

最后,我们来到国际化。以前,我们需要国际化诸如日期、数字格式甚至货币等内容。我们会使用特殊的查找或转换器来为我们执行这些操作。通过 ECMAScript 的更新版本,我们已经获得了使用内置Intl对象获取这些新格式的支持。一些用例可以如下所示:

const amount = 1478.99;
console.log(new Intl.NumberFormat('en-UK', {style : 'currency', currency : 'EUR'}).format(amount));
console.log(new Intl.NumberFormat('de-DE', {style : 'currency', currency : 'EUR'}).format(amount));
const date = new Date(0);
console.log(new Intl.DateTimeFormat('en-UK').format(date));
console.log(new Intl.DateTimeFormat('de-DE').format(date));

有了这个,我们现在可以根据某人的所在地或他们在我们应用程序开始时选择的语言来国际化我们的系统。

这只会将数字转换为该国家代码的样式;它不会尝试转换实际值,因为货币等选项在一天之内会发生变化。如果我们需要执行这样的转换,我们将需要使用 API。除此之外,如果我们想要翻译某些内容,我们仍然需要有单独的查找,以确定我们需要在文本中放置什么,因为不同语言之间没有直接的翻译。

有了 ECMAScript 标准中添加的这些令人惊叹的功能,现在让我们转向一种封装函数和数据的方式。为此,我们将使用类和模块。

理解类和模块

随着新的 ECMAScript 标准,我们得到了新的类语法,用于实现面向对象编程OOP),后来,我们还得到了模块,一种导入和导出用户定义的函数和对象集合的方式。这两种系统使我们能够消除系统中内置的某些黑客技巧,并且也能够移除一些几乎是必不可少的库,用于模块化我们的代码库。

首先,我们需要了解 JavaScript 是什么类型的语言。JavaScript 是一种多范式语言。这意味着我们可以利用许多不同编程风格的思想,并将它们合并到我们的代码库中。我们在之前的章节中提到的一种编程风格是函数式编程。

在纯函数式编程中,我们有纯函数,或者执行操作并且没有副作用(在函数应该执行的外部执行其他操作)的函数。当我们以这种方式编写时,我们可以创建通用函数,并将它们组合在一起,以创建可以处理复杂思想的一系列简单思想。我们还将函数视为语言中的一等公民。这意味着函数可以分配给变量并传递给其他函数。我们还可以组合这些函数,正如我们在之前的章节中所看到的。这是解决问题的一种方式。

另一种流行的编程风格是面向对象编程。这种风格表明程序可以用类和对象的层次结构来描述,并且可以构建和一起使用这些类和对象来创建这个复杂的思想。这个想法可以在大多数流行的语言中看到。我们构建具有一些通用功能或某些特定版本需要合并的定义的基类。我们从这个基类继承并添加我们自己的特定功能,然后我们创建这些对象。一旦我们把所有这些对象放在一起,我们就可以处理我们需要的复杂思想。

使用 JavaScript,我们可以得到这两种思想,但是 JavaScript 中的面向对象设计有点不同。我们拥有所谓的原型继承。这意味着在 JavaScript 中并没有所谓的抽象概念。在 JavaScript 中,我们只有对象。我们继承一个对象的原型,该原型具有方法和数据,所有具有相同原型的对象共享,但它们都是实例化的实例。

当我们在 JavaScript 中谈论类语法时,我们指的是构造函数和我们添加到它们的原型的方法/数据的语法糖。另一种思考这种类型的继承的方式是注意到在 JavaScript 中没有抽象概念,只有具体对象。如果这看起来有点神秘或令人困惑,下面的代码应该澄清这些陈述的含义:

const Item = funciton() {
    this.a = 1;
    this.b = 'this';
    this.c = function() {
        console.log('this is going to be a new function each time');
    }
}
Item.prototype.d = function() {
    console.log('this is on the prototype so it will only be here 
     once');
}
const item1 = new Item();
const item2 = new Item();

item1.c === item2.c; //false
item1.d === item2.d; //true

const item3 = new (Object.getPrototypeOf(item1)).constructor();
item3.d === item2.d ;//true
Object.getPrototypeOf(item1).constructor === Item; //true

通过这个例子,我们展示了一些东西。首先,这是创建构造函数的旧方法。构造函数是设置作用域和在实例化时直接可用于对象的所有函数的函数。在这种情况下,我们已经将abc作为Item构造函数的实例变量。其次,我们已经向项目的原型添加了一些内容。当我们在构造函数的原型上声明某些内容时,我们使所有该构造函数的实例都可以使用它。

从这里,我们声明了两个基于Item构造函数的项目。这意味着它们将分别获得abc变量的单独实例,但它们将共享函数d。我们可以在接下来的两个语句中看到这一点。这展示了如果我们直接添加一些内容到构造函数的this作用域中,它将创建该项目的全新实例,但如果我们将一些内容放在原型上,所有项目都将共享它。

最后,我们可以看到item3是一个新的Item,但我们通过迂回的方式到达了构造函数。一些浏览器支持项目上的__proto__属性,但这个函数应该在所有浏览器中都可用。我们获取原型并注意到有一个构造函数。这正是我们在顶部声明的完全相同的函数,因此我们能够利用它来创建一个新的项目。我们可以看到它也在与其他项目相同的原型上,并且原型上的构造函数与我们声明的item变量完全相同。

所有这些都应该展示的是 JavaScript 纯粹由对象构成。没有其他语言中真正的类等抽象类型。如果我们利用新的语法,最好理解我们所做的只是利用语法糖来做我们以前可以用原型做的事情。也就是说,下一个例子将展示完全相同的行为,但一个是老式的基于原型的,另一个是利用新的类语法:

class newItem {
    constructor() {
        this.c = function() {
            console.log('this is going to be a new function each time!);
        }
    }
    a = '1';
    b = 'this';
    d() {
        console.log('this is on the prototype so it will only be here 
         once');
    }
}
const newItem1 = new newItem();
const newItem2 = new newItem();

newItem1.c === newItem2.c //false
newItem1.d === newItem2.d //true

newItem === Object.getPrototypeOf(newItem1).constructor; //true

通过这个例子,我们可以看到在创建与原型版本之前相同的对象时,我们获得了一些更清晰的语法。构造函数与我们声明Item为函数时是一样的。我们可以传入任何参数并在这里进行设置。类中的一个有趣之处是我们能够在类内部创建实例变量,就像我们在原型示例中在this上声明它们一样。我们还可以看到d的声明放在了原型上。我们将在下面探索类语法的更多方面,但花些时间并玩弄这两段代码。当我们试图编写高性能代码时,理解 JavaScript 是基于原型的将会极大地帮助。

类中的公共变量是相当新的(Chrome 72)。如果我们无法访问更新的浏览器,我们将不得不使用 Babel 将我们的代码转译回浏览器能理解的版本。我们还将看看另一个只在 Chrome 中且是实验性的功能,但它应该在一年内传递到所有浏览器。

其他值得注意的功能

JavaScript 类为我们提供了许多很好的功能,使我们编写的代码清晰简洁,同时性能几乎与直接编写原型相同。一个很好的功能是包括静态成员变量和静态成员函数。

虽然没有太大的区别,但它确实允许我们编写无法被成员函数访问的函数(它们仍然可以被访问,但要困难得多),并且它可以为将实用函数分组到特定类中提供一个很好的工具。这里展示了静态函数和变量的一个例子:

class newItem {
    static e() {
        console.log(this);
    }
    static f = 10;
}

newItem1.e() //TypeError
newItem.e() //give us the class
newItem.f //10

两个静态定义被添加到newItem类中,然后我们展示了可用的内容。通过函数e和静态变量f,我们可以看到它们不包括在我们从newItem创建的对象中,但当我们直接访问newItem时,我们可以访问它们。除此之外,我们可以看到静态函数内部的this指向类。静态成员和变量非常适合创建实用函数,甚至用于在 JavaScript 中创建单例模式。

如果我们想要以旧式风格创建相同的体验,它看起来会像下面这样:

Item.e = function() {
    console.log(this);
}
Item.f = 10;

正如我们所看到的,我们必须在Item的第一个定义之后放置这些定义。这意味着我们必须相对小心地尝试将我们的类定义的所有代码分组在旧式风格中,而类语法允许我们将其全部放在一个组中。

除了静态变量和函数之外,我们还有一种为类中的变量编写 getter 和 setter 的简写方式。可以如下所示:

get g() {
    return this._g;
}
set g(val) {
    if( typeof val !== 'string' ) {
        return;
    }
    this._g = val;
}

有了这个 getter 和 setter,当有人或某物尝试访问这个变量时,我们能够在这些函数内部做各种事情。就像我们设置了一个代理来监听变化一样,我们也可以在 getter 和 setter 中做类似的事情。我们还可以在这里设置日志记录。当我们想要访问某些东西时,这种语法非常好,只需使用属性名,而不是像getGsetG这样写。

最后,还有 Chrome 76 中出现的新私有变量。虽然这仍处于候选推荐阶段,但仍然会被讨论,因为它很可能会发挥作用。很多时候,我们希望尽可能多地公开信息。然而,有时我们希望利用内部变量来保存状态,或者一般情况下不被访问。在这方面,JavaScript 社区提出了_解决方案。任何带有_的东西都被视为私有变量。但是,用户仍然可以访问这些变量并对其进行操作。更糟糕的是,恶意用户可能会发现这些私有变量中的漏洞,并能够操纵系统。在旧系统中创建私有变量的一种技术是以下形式:

const Public = (function() {
    let priv = 0;
    const Private = function() {}
    Private.prototype.add1 = function() {
        priv += 1;
    }
    Private.prototype.getVal = function() {
        return priv;
    }
    return Private;
})();

有了这个,除了实现者之外,没有人可以访问priv变量。这为我们提供了一个面向公众的系统,而不会访问私有变量。然而,这个系统仍然有一个问题:如果我们创建另一个Public对象,我们仍然会影响相同的priv变量。还有其他方法可以确保我们在创建新对象时获得新变量,但这些都是我们试图制定的系统的变通方法。相反,我们现在可以利用以下语法:

class Public {
    #h = 10;
    get h() {
        return this.#h;
    }
}

井号的作用是表示这是一个私有变量。如果我们尝试从任何一个实例中访问它,它将返回未定义。这与 getter 和 setter 接口非常配合,因为我们将能够控制对变量的访问,甚至在需要时修改它们。

最后,再来看一下类的extendsuper关键字。通过extend,我们可以对类进行扩展。让我们以newItem类为例,扩展其功能。这可能看起来像这样:

class extendedNewItem extends newItem {
    constructor() {
        super();
        console.log(this.c());
    }
    get super_h() {
        return super.h;
    }
    static e() {
        super.e();
        console.log('this came from our extended class');
    }
}
const extended = new extendedNewItem();

在这个例子中发生了一些有趣的行为。首先,如果我们在扩展对象上运行Object.getPrototypeOf,我们会看到原型是我们所期望的extendedNewItem。现在,如果我们获取它的原型,我们会看到它是newItem。我们创建了一个原型链,就像许多内置对象一样。

其次,我们可以使用super从类内部访问父类的方法。这本质上是对我们父类的原型的引用。如果我们想要继续遍历所有的原型,我们不能链式调用它们。我们必须利用诸如Object.getPrototypeOf之类的东西。我们还可以通过检查我们的扩展对象来看到,我们得到了我们父类系统中保存的所有成员变量。

这使我们能够组合我们的类并创建基类或抽象类,这些类给我们一些定义好的行为,然后我们可以创建扩展类,给我们想要的特定行为。我们将在后面看到更多使用类和我们在这里讨论过的许多概念的代码,但请记住,类只是原型系统的语法糖,对此的良好理解将有助于理解 JavaScript 作为一种语言的工作原理。

关于 JavaScript 生态系统中的类接口有很多好东西,而且似乎还有一些其他很棒的想法可能会在未来出现,比如装饰器。随时关注Mozilla 开发者网络MDN)页面,了解新的内容和可能在未来出现的内容总是一个好主意。现在我们将看一下模块以及它们在我们编写清晰快速代码的系统中是如何工作的。

一个很好的经验法则是不要扩展任何类超过一到两个级别。如果我们再继续下去,我们可能会开始创建一个维护的噩梦,除了潜在的对象变得过于沉重,包含了它们不需要的信息。提前考虑将始终是我们创建系统时的最佳选择,尽量减少我们类的影响是减少内存使用的一种方式。

模块

在 ECMAScript 2015 之前,我们没有加载代码的概念,除了使用脚本标签。我们提出了许多模块概念和库,比如RequireJSAMD,但没有一个是内置到语言中的。随着模块的出现,我们现在有了一种创建高度模块化代码的方式,可以轻松地打包并导入到我们代码的其他部分。我们还在我们以前必须使用 IIFE 来获得这种行为的系统中获得了作用域锁。

首先,在我们开始使用模块之前,我们需要一个静态服务器来托管我们所有的内容。即使我们让 Chrome 允许访问本地文件系统,模块系统也会因为无法将它们作为文本/JavaScript 提供而感到不安。为了解决这个问题,我们可以安装 node 包node-static。我们将把这个包添加到一个静态目录中。我们可以运行以下命令:npm install node-static。一旦这个包下载完成到static目录中,我们可以从我们的存储库中的Chapter03文件夹中获取app.js文件并运行node app.js。这将启动静态服务器,并从static目录中的files目录中提供服务。然后我们可以把任何想要提供的文件放在那里,并且能够从我们的代码中获取到它们。

现在,我们可以编写一个基本的模块,如下所示,并将其保存为lib.js

export default function() {
    console.log('this is going to be our simple lib');
}

然后,我们可以从 HTML 文件中导入这个模块,如下所示:

<script type="module'>
    import lib from './lib.js';
</script>

即使是这个基本的例子,我们也可以了解模块在浏览器中是如何工作的。首先,脚本的类型需要是一个模块。这告诉浏览器我们要加载模块,并且我们要把这段代码作为模块来处理。这给了我们几个好处。首先,当我们使用模块时,我们会自动进入严格模式。其次,我们在模块中自动获得了作用域。这意味着我们刚刚导入的lib不会作为全局变量可用。如果我们将内容加载为文本/JavaScript 并将变量放在全局路径上,那么我们将自动拥有它们;这就是为什么我们通常必须使用 IIFE。最后,我们得到了一个很好的语法来加载我们的 JavaScript 文件。我们仍然可以使用旧的方式加载一堆脚本,但我们也可以只导入基于模块的脚本。

接下来,我们可以看到模块本身使用了exportdefault关键字。export表示我们希望这个项在这个作用域或文件之外可用。现在我们可以在当前文件之外访问到这个项。default表示如果我们加载模块而没有定义我们想要的内容,我们将自动获得这个项。这可以在以下示例中看到:

const exports = {
    this : 'that',
    that : 'this'
}

export { exports as Item };

首先,我们定义了一个名为exports的对象。这是我们要添加为导出项的对象。其次,我们将此项添加到一个export声明中,并且还重命名了它。这是模块的一个好处。在导出或导入的一侧,我们都可以重命名我们想要导出的项。现在,在我们的 HTML 文件中,我们会有如下声明:

import { Item } from './lib.js';

如果我们在声明周围没有括号,我们将尝试引入默认导出。由于我们有花括号,它将在lib.js中查找名为Item的项目。如果找到,它将引入与之关联的代码。

现在,就像我们从导出列表中重命名导出一样,我们可以重命名导入。让我们继续将其更改为以下内容:

import { Item as _item } from './lib.js';

现在我们可以像往常一样利用该项,但是作为变量_item而不是Item。这对于名称冲突非常有用。我们只能想出那么多变量名,所以,我们可以在加载它们时改变它们,而不是在单独的库中更改变量。

良好的样式约定是在顶部声明所有导入。然而,有一些用例可能需要动态加载模块,因为某种类型的用户交互或其他事件。如果发生这种情况,我们可以利用动态导入来实现这一点。这些看起来如下:

document.querySelector('#loader').addEventListener('click', (ev) => {
    if(!('./lib2.js' in imported)) {
        import('./lib2.js')
        .then((module) => {
            imported['./lib2.js'] = module;
            module.default();
        });
    } else {
        imported['./lib2.js'].default();
    }
});

我们添加了一个按钮,当点击时,我们尝试将模块加载到我们的系统中。这不是在我们的系统中缓存模块的最佳方式,大多数浏览器也会为我们做一些缓存,但这种方式相当简单,展示了动态导入系统。导入函数基于承诺,因此我们尝试抓取它,如果成功,我们将其添加到导入的对象中。然后调用默认方法。我们可以访问模块为我们导出的任何项目,但这是最容易访问的项目之一。

看到 JavaScript 的发展是令人惊讶的。所有这些新功能给了我们以前必须依赖第三方的能力。关于 DOM 的变化也是如此。我们现在将看看这些变化。

使用 DOM

文档对象模型(DOM)并不总是最容易使用的技术。我们有古老的过时 API,大多数时候,它们在不同浏览器之间无法对齐。但是,在过去的几年里,我们已经得到了一些很好的 API 来做以下事情:轻松获取元素,构建内存层次结构以进行快速附加,并使用 DOM 阴影进行模板。所有这些都导致了一个丰富的环境,可以对底层节点进行更改,并创建许多丰富的前端,而无需使用 jQuery 等库。在接下来的几节中,我们将看到如何使用这些新 API 有所帮助。

查询选择器

在拥有这个 API 之前(或者我们试图尽可能跨浏览器),我们依赖于诸如getElementByIdgetElementsByClassName之类的系统。每个都提供了一种我们可以获取 DOM 元素的方式,如下例所示:

<p>This is a paragraph element</p>
<ul id="main">
    <li class="hidden">1</li>
    <li class="hidden">2</li>
    <li>3</li>
    <li class="hidden">4</li>
    <li>5</li>
</ul>
<script type="module">
    const main = document.getElementById('main');
    const hidden = document.getElementsByClassName('hidden');
</script>

这个旧 API 和新的querySelectorquerySelectorAll之间的一个区别是,旧 API 将 DOM 节点集合实现为HTMLCollection,而新 API 将它们实现为NodeList。虽然这可能看起来不是一个重大的区别,但NodeListAPI 确实给了我们一个已经内置到系统中的forEach。否则,我们将不得不将这两个集合都更改为常规的 DOM 节点数组。在新 API 中实现的前面的示例如下:

const main = document.querySelector('#main');
const hidden = document.querySelectorAll('.hidden');

当我们想要开始向我们的选择过程添加其他功能时,这变得更加美好。

假设我们现在有一些输入,并且我们想获取所有文本类型的输入。在旧 API 中会是什么样子?如果需要,我们可以给它们都附加一个类,但这会污染我们对类的使用,可能不是处理这些信息的最佳方式。

我们可以通过利用旧 API 方法之一来获取这些数据,然后检查这些元素是否将输入属性设置为text。这可能看起来像下面这样:

const allTextInput = Array.from(document.getElementsByTagName('input'))
    .filter(item => item.getAttribute('type') === "text");

但是现在我们有了一定程度的冗长,这是不需要的。相反,我们可以通过使用 CSS 选择器来获取它们,使用选择器 API 如下:

const alsoTextInput = doucment.querySelectorAll('input[type="text"]');

这意味着我们应该能够利用 CSS 语法访问任何 DOM 节点,就像 jQuery 一样。我们甚至可以从另一个元素开始,这样我们就不必解析整个 DOM,就像这样:

const hidden = document.querySelector('#main').querySelectorAll('.hidden');

选择器 API 的另一个好处是,如果我们不使用正确的 CSS 选择器,它将抛出错误。这为我们提供了系统为我们运行检查的额外好处。虽然新的选择器 API 已经存在,但由于需要包括 Internet Explorer 在支持的 Web 浏览器中,它并没有被广泛使用。强烈建议开始使用新的选择器 API,因为它不那么冗长,我们能够做的事情比旧系统多得多。

jQuery 是一个库,它为我们提供了比基本系统更好的 API。jQuery 支持的大多数更改现在已经过时,许多我们已经谈论过的新的 web API 正在接管。对于大多数新应用程序,它们将不再需要使用 jQuery。

文档片段

我们在之前的章节中已经看到了这些,但是触及它们是很好的。文档片段是可重用的容器,我们可以在其中创建 DOM 层次结构,并一次性附加所有这些节点。这导致更快的绘制时间和更少的重绘。

以下示例展示了两种使用直接 DOM 添加和片段添加的方式附加一系列列表元素:

const num = 10000;
const container = document.querySelector('#add');
for(let i = 0; i < num; i++) {
    const temp = document.createElement('li');
    temp.textContent = `item ${i}`;
    container.appendChild(temp);
}
while(container.firstChild) {
    container.removeChild(container.firstChild);
}
const fragment = document.createDocumentFragment();
for(let i = 0; i < num; i++) {
    const temp = document.createElement('li');
    temp.textContent = `item ${i}`;
    fragment.appendChild(temp);
}
container.appendChild(fragment);

虽然这两者之间的时间很短,但发生的重绘次数并非如此。在我们的第一个示例中,每次直接向文档添加元素时,文档都会重绘,而我们的第二个示例只会重绘一次 DOM。这就是文档片段的好处;它使向 DOM 添加变得简单,同时只使用最少的重绘。

Shadow DOM

阴影 DOM 通常与模板和 Web 组件配对使用,但也可以单独使用。阴影 DOM 允许我们封装我们应用程序的特定部分的标记和样式。如果我们想要页面的某个部分具有特定的样式,但不希望其传播到页面的其他部分,这是很好的。

我们可以通过利用其 API 轻松地使用阴影 DOM,如下所示:

const shadow = document.querySelector('#shadowHolder').attachShadow({mode : 'open'});
const style = document.createElement('style');
style.textContent = `<left out to shorten code snippet>`;
const frag = document.createDocumentFragment();
const header = document.createElement('h1');
const par = document.createElement('p');
header.textContent = 'this is a header';
par.textContent = 'Here is some text inside of a paragraph element. It is going to get the styles we outlined above';

frag.appendChild(header);
frag.appendChild(par);
shadow.appendChild(style);
shadow.appendChild(frag);

首先,我们将阴影 DOM 附加到一个元素上,这里是我们的shadowHolder元素。有一个模式选项,它允许我们说是否可以在阴影上下文之外通过 JavaScript 访问内容,但已经发现我们可以轻松地规避这一点,因此建议保持它开放。接下来,我们创建一些元素,其中一个是一些样式属性。然后,我们将这些附加到一个文档片段,最后附加到阴影根。

搞定所有这些之后,我们可以看到并注意到我们的阴影 DOM 受到了放在其中的样式属性的影响,而不是放在我们主文档顶部的样式属性。如果我们在文档顶部放置一个我们的阴影样式没有的样式会发生什么?它仍然不会受到影响。有了这个,我们现在能够创建可以单独样式化的组件,而无需使用类。这将我们带到 DOM 的最后一个主题之一。

Web 组件

Web 组件 API 允许我们创建具有定义行为的自定义元素,仅利用浏览器 API。这与诸如 Bootstrap 甚至 Vue 之类的框架不同,因为我们能够利用浏览器中存在的所有技术。

Chrome 和 Firefox 都支持所有这些 API。Safari 支持其中大部分,如果这是我们想要支持的浏览器,我们只能利用其中的一些 API。Edge 不支持 Web 组件 API,但随着它转向 Chromium 基础,我们将看到另一个能够利用这项技术的浏览器。

让我们创建一个基本的tooltip元素。首先,我们需要在我们的类中扩展基本的HTMLElement。然后,我们需要附加一些属性,以允许我们放置元素并给我们需要使用的文本。最后,我们需要注册这个组件到我们的系统中,以确保它识别我们的自定义元素。以下代码创建了这个自定义元素(修改自developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements):

class Tooltip extends HTMLElement {
    constructor() {
        super();
        this.text = this.getAttribute('text');
        this.type = this.getAttribute('type');
        this.typeMap = new Map(Object.entries({
            'success' : "&#x2714",
            'error' : "&#x2716",
            'info' : "&#x2755",
            'default' : "&#x2709"
        }));

        this.shadow = this.attachShadow({mode : 'open'});
        const container = document.createElement('span');
        container.classList.add('wrapper');
        container.classList.add('hidden');
        const type = document.createElement('span');
        type.id = 'icon';
        const el = document.createElement('span');
        el.id = 'main';
        const style = document.createElement('style');
        el.textContent = this.text;
        type.innerHTML = this.getType(this.type);

        style.innerText = `<left out>`
        this.shadow.append(style);
        this.shadow.append(container);
        container.append(type);
        contianer.append(el);
    }
    update() {
        const x = this.getAttribute('x');
        const y = this.getAttribute('y');
        const type = this.getAttribute('type');
        const text = this.getAttribute('text');
        const show = this.getAttribute('show');
        const wrapper = this.shadow.querySelector('.wrapper');
        if( show === "true" ) {
            wrapper.classList.remove('hidden');
        } else {
            wrapper.classList.add('hidden');
        }
        this.shadow.querySelector('#icon').innerHTML = this.getType(type);
        this.shadow.querySelector('#main').innerText = text;
        wrapper.style.left = `${x}px`;
        wrapper.style.top = `${y}px`;
    }
    getType(type) {
        return type ?
            this.typeMap.has(type) ?
                this.typeMap.get(type) :
                this.typeMap.get('default') :
            this.typeMap.get('default');
    }
    connectCallback() {
        this.update(this);
    }
    attributeChangedCallback(name, oldValue, newValue) {
        this.update(this);
    }
    static get observedAttributes() {
        return ['x', 'y', 'type', 'text', 'show'];
    }
}

customElements.define('our-tooltip', Tooltip);

首先,我们有一个属性列表,我们将使用它们来样式化和定位我们的tooltip。它们分别称为xytypetextshow。接下来,我们创建了一个基于表情符号的文本映射,这样我们就可以利用图标而不需要引入一个完整的库。然后我们在一个阴影容器内设置了可重用的对象。我们还将阴影根放在对象上,这样我们就可以轻松访问它。update方法将在我们的元素第一次创建时触发,并在属性的任何后续更改时触发。我们可以在最后三个函数中看到这一点。connectedCallback将在我们被附加到 DOM 时触发。attributeChangedCallback将提醒我们发生了任何属性更改。这与代理 API 非常相似。最后一部分让我们的对象知道我们特别关心哪些属性,这种情况下是xytypetextshow。最后,我们使用customElements.define方法注册我们的自定义组件,给它一个名称和我们想要在创建这些对象时运行的类。

现在,如果我们创建我们的tooltip,我们可以利用这些不同的属性来制作一个可重用的tooltip系统,甚至是警报。以下代码演示了这一点:

<our-tooltip show="true" x="100" y="100" icon="success" text="here is our tooltip"></our-tooltip>

我们应该看到一个浮动框,上面有一个复选标记和文本“这是我们的提示”。通过利用 Web 组件 API 中的模板系统,我们可以使这个tooltip更容易阅读。

模板

现在,我们有一个不错的可重用的tooltip元素,但是我们的样式标签中也有相当多的代码,它完全由模板化的字符串组成。最好的办法是,如果我们可以把这个语义标记放在别的地方,并把执行逻辑放在我们的 Web 组件中,就像现在一样。这就是模板发挥作用的地方。<template>元素不会显示在页面上,但我们仍然可以通过给它一个 ID 来很容易地获取它。因此,重构我们当前的代码的一种方式是这样的:

<template id="tooltip">
    <style>
        /* left out */
    </style>
    <span class="wrapper hidden" x="0" y="0" type="default" show="false">
        <span id="icon">&#2709</span>
        <span id="main">This is some default text</span>
    </span>
</template>

我们的 JavaScript 类构造函数现在应该是这样的:

constructor() {
    super();
    this.type = this.getAttribute('type');
    this.typeMap = // same as before
    const template = document.querySelector('#tooltip').content;
    this.shadow = this.attachShadow({mode : 'open'});
    this.shadow.appendChild(template.cloneNode(true));
}

这样更容易阅读,更容易理解。现在我们获取我们的模板并获取它的内容。我们创建一个shadow对象并附加我们的模板。我们需要确保克隆我们的模板节点,否则我们将在我们决定创建的所有元素之间共享相同的引用!你会注意到的一件事是,我们现在无法通过属性来控制文本。虽然看到这种行为很有趣,但我们真的希望把这些信息留给我们的tooltip的创建者。我们可以通过<slot>元素来实现这一点。

插槽给了我们一个区域,我们可以在那个位置放置 HTML。我们可以利用这一点,让tooltip的用户放入他们想要的标记。我们可以给他们一个看起来像下面这样的模板:

<span class="wrapper hidden" x="0" y="0" type="default" show="false">
    <span id="icon">&#2709</span>
    <span id="main"><slot name="main_text">This is default text</slot></span>
</span>

我们的实现可能如下所示:

<our-tooltip show="true" x="100" y="100" type="success">
    <span slot="main_text">That was a successful operation!</span>
</our-tooltip>

正如我们所看到的,阴影 DOM 的使用,以及浏览器中的 Web 组件和模板系统,使我们能够创建丰富的元素,而无需外部库,如 Bootstrap 或 Foundation。

我们可能仍然需要这些库来提供一些基本的样式,但我们不应该像过去那样需要它们。最理想的情况是,我们可以编写所有自己的组件和样式,而不需要利用外部库。但是,由于这些系统相对较新,如果我们无法控制用户的使用,我们可能会陷入填充的困境。

理解 Fetch API

在 Fetch API 之前,我们必须利用XMLHttpRequest系统。要创建一个服务器数据请求,我们必须编写类似以下的内容:

const oldReq = new XMLHttpRequest();
oldReq.addEventListener('load', function(ev) {
    document.querySelector('#content').innerHTML = 
     JSON.stringify(ev.target.response);
});
oldReq.open('GET', 'http://localhost:8081/sample');
oldReq.setRequestHeader('Accept', 'application/json');
oldReq.responseType = 'json';
oldReq.send();

首先,您会注意到对象类型被称为XMLHttpRequest。原因是由于谁发明了它以及背后的原因。微软最初开发了这种技术,用于 Outlook Web Access 产品。最初,他们来回传输 XML 文档,因此他们为其构建的对象命名。一旦其他浏览器供应商,主要是 Mozilla,采用了它,他们决定保留名称,即使其目的已经从仅发送 XML 文档转变为从服务器发送到客户端的任何类型的响应。

其次,我们向对象添加了一个事件监听器。由于这是一个普通对象而不是基于 promise 的,我们以addEventListener方法的老式方式添加监听器。这意味着一旦它被使用,我们也会清理事件监听器。接下来,我们打开请求,传入我们想要发送的方法和发送的位置。然后我们可以设置一堆请求头(在这里特别指定我们想要的应用程序/JSON 数据,并将responseType设置为json,以便浏览器正确转换)。最后,我们发送请求。

一旦我们获得响应,我们的事件将触发,我们可以从事件的目标中检索响应。一旦我们开始发布数据,情况可能会变得更加繁琐。这就是 jQuery 的$.ajax和类似方法的原因。它使得与XMLHttpRequest对象一起工作变得更加容易。那么从 Fetch API 的角度来看,这种响应是什么样子的呢?这个完全相同的请求可以如下所示:

fetch('http://localhost:8081/sample')
.then((res) => res.json())
.then((res) => {
    document.querySelector('#content').innerHTML = JSON.stringify(res);
});

我们可以看到这样阅读和理解起来要容易得多。首先,我们设置我们要访问的 URL。如果我们在fetch调用中不传递操作,它将自动假定我们正在创建一个GET请求。接下来,我们获取响应,并确保以json格式获取它。响应将始终作为promise返回(稍后会详细介绍),因此我们希望将其转换为我们想要的格式,即json。从这里,我们得到了最终的对象,我们可以将其设置为我们内容的innerHTML。从这两个基本对象的示例中,我们可以看到 Fetch API 几乎具有与XMLHttpRequest相同的功能,但它的格式更容易理解,我们可以轻松地使用 API。

Promises

正如我们在之前的fetch示例中看到的,我们利用了一个叫做 promise 的东西。简单地说,promise 就是一个我们将来会需要的值,而返回给我们的是一个合同,声明了“我会在以后把它交给你”。Promise 是基于回调的概念。如果我们看一个可能包装在XMLHttpRequest周围的回调的例子,我们可以看到它是如何作为一个 promise 运行的:

const makeRequest = function(loc, success, failure) {
    const oldReq = new XMLHttpRequest();
    oldReq.addEventListener('load', function(ev) {
        if( ev.target.status === 200 ) {
            success(ev.target.response);
        } else {
            failure(ev.target.response);
        }
    }, { once : true });
    oldReq.open('GET', loc);
    oldReq.setRequestHeader('Accept', 'application/json');
    oldReq.responseType = 'json';
    oldReq.send();
}

通过这样,我们几乎可以得到与 promise 相同的功能,但是利用回调或我们想要在发生某事时运行的函数。回调系统的问题是被称为回调地狱。这是高度异步代码总是有回调的想法,这意味着如果我们想要利用它,我们将会有一个美妙的回调树视图。这看起来像下面这样:

const fakeFetchRequest(url, (res) => {
    res.json((final) => {
        document.querySelector('#content').innerHTML = 
         JSON.stringify(final);
    });
});

这个虚构的fetch版本是如果fetch的 API 不是基于 promise 的。首先,我们会传入我们的 URL。我们还需要为响应返回时提供一个回调。然后,我们需要将该响应传递给json方法,该方法还需要一个回调来将响应数据转换为json。最后,我们会得到结果并将其放入我们的 DOM。

正如我们所看到的,回调可能会导致很多问题。相反,我们有了 promise。promise 在创建时需要一个参数,即一个具有两个参数(resolve 和 reject)的函数。有了这些,我们可以通过resolve函数向调用者返回成功,或者通过reject函数报错。这将允许我们通过then调用和catch调用将这些 promise 链接在一起,就像我们在fetch示例中看到的那样。

然而,这也可能导致另一个问题。我们可能会得到一长串 promise,看起来比回调好一些,但并不明显。然后我们有了async/await系统。我们可以使用await来利用响应,而不是不断地用then链接 promise。然后我们可以将我们的fetch调用转换成以下形式:

(async function() {
    const res = await fetch('http://localhost:8081/sample');
    const final = await res.json();
    document.querySelector('#content').innerHTML = JSON.stringify(final);
})();

函数前的async描述符告诉我们这是一个async函数。如果没有这个描述符,我们就无法使用await。接下来,我们可以直接使用await函数,而不是将then函数链接在一起。结果就是原本会被包装在我们的resolve函数中的内容。现在,我们有了一个非常易读的东西。

我们需要小心async/await系统。它确实会等待,所以如果我们将其放在主线程上,或者没有将其包装在其他东西中,它可能会阻塞主线程,导致我们无法继续执行。此外,如果我们有一堆任务需要同时运行,我们可以利用Promise.all(),而不是一个接一个地等待它们(使我们的代码变成顺序执行)。这允许我们将一堆 promise 放在一起,并允许它们异步运行。一旦它们都返回,我们就可以继续执行。

async/await系统的一个好处是它实际上可能比使用通用 promise 更快。许多浏览器已经围绕这些特定的关键字添加了优化,因此我们应该尽可能地利用它们。

之前已经提到过,但浏览器供应商不断改进他们对 ECMAScript 标准的实现。这意味着新技术一开始会比较慢,但一旦被广泛使用或得到所有供应商的认可,它们就会开始优化,并通常比其对应的技术更快。在可能的情况下,利用浏览器供应商提供给我们的新技术!

回到 fetch

现在我们已经看到了fetch请求的样子,我们应该看一下如何获取底层的可读流。fetch系统已经添加了很多功能,其中两个是管道和流。这可以在许多最近的 Web API 中看到,可以观察到浏览器供应商已经注意到了 Node.js 如何利用流。

如前一章所述,流是一种一次处理数据块的方式。它还确保我们不必一次性获取整个有效负载,而是可以逐步构建有效负载。这意味着如果我们需要转换数据,我们可以在数据块到达时即时进行转换。这也意味着我们可以处理不常见的数据类型,比如 JSON 和纯文本。

我们将编写一个基本示例,演示TransformStream如何对输入进行简单的 ROT13 编码(ROT13 是一种非常基本的编码器,它将我们得到的第 13 个字母替换原来的字母)。稍后我们将更详细地介绍流(这些将是 Node.js 版本,但概念相对类似)。示例大致如下:

class Rot13Transform {
    constructor() {
    }
    async transform(chunk, controller) {
        const _chunk = await chunk;
        const _newChunk = _chunk.map((item) => ((item - 65 + 13) % 26) + 
         65);
        controller.enqueue(_newChunk);
        return;
    }
}

fetch('http://localhost:8081/rot')
.then(response => response.body)
.then(res => res.pipeThrough(new TransformStream(new Rot13Transform())))
.then(res => new Response(res))
.then(response => response.text())
.then(final => document.querySelector('#content').innerHTML = final)
.catch(err => console.error(err));

让我们将这个例子分解成实际的TransformStream,然后是利用它的代码。首先,我们创建一个类,用于容纳我们的旋转代码。然后,我们需要一个叫做transform的方法,它接受两个参数,块和控制器。块是我们将要获取的数据。

请记住,这不会一次性获取所有数据,因此如果我们需要构建对象或类似的东西,我们需要为前面的数据创建一个可能的临时存储位置,如果当前块没有给我们想要的所有内容。在我们的情况下,我们只是在底层字节上运行一个旋转方案,因此我们不需要有一个临时持有者。

接下来,控制器是流控制和声明数据是否准备从中读取(一个可读或转换流)或写入(一个可写流)的基础系统。接下来,我们等待一些数据并将其放入一个临时变量中。然后,我们对每个字节运行一个简单的映射表达式,将它们向右旋转 13 次,然后对 26 取模。

ASCII 约定将所有大写字符从 65 开始。这就是这里涉及一些数学的原因,因为我们试图首先得到 0 到 26 之间的数字,进行操作,然后将其移回正常的 ASCII 范围内。

一旦我们旋转了输入,我们就会将其排队在控制器上。这意味着数据已准备好从另一个流中读取。接下来,我们可以看一系列发生的承诺。首先,我们获取我们的数据。然后,我们通过获取其主体从fetch请求中获取底层的ReadableStream。然后,我们利用一个叫做pipeThrough的方法。管道机制会自动为我们处理流量控制,因此在处理流时会让我们的生活变得更加轻松。

流控制对于使流工作至关重要。它基本上告诉其他流,如果我们被堵住了,就不要再发送数据,或者我们可以继续接收数据。如果没有这种机制,我们将不断地不得不控制我们的流,当我们只想专注于我们想要合并的逻辑时,这可能会是一个真正的痛苦。

我们将数据传输到一个新的TransformStream中,该流采用我们的旋转逻辑。现在,这将把响应中的所有数据传输到我们的转换代码中,并确保它经过转换后输出。然后,我们将我们的ReadableStream包装在一个新的Response中,这样我们就可以像处理fetch请求的任何其他Response对象一样处理它。然后,我们像处理普通文本一样获取数据并将其放入我们的 DOM 中。

正如我们所看到的,这个例子展示了我们可以通过流系统做很多很酷的事情。虽然 DOM API 仍在变化中,但这些概念与 Node.js 中的流接口类似。它还展示了我们如何可能为更复杂的二进制类型编写解码器,这些类型可能通过网络传输,比如 smile 格式。

停止 fetch 请求

在进行请求时,我们可能想要执行的一个操作是停止它们。这可能是出于多种原因,比如:

  • 首先,如果我们在后台进行请求,并且让用户更新POST请求的参数,我们可能希望停止当前请求,并让他们发出新的请求。

  • 其次,一个请求可能花费太长时间,我们希望确保停止请求,而不是挂起应用程序或使其进入未知状态。

  • 最后,我们可能有一个设置好的缓存机制,一旦我们完成缓存大量数据,我们希望使用它。如果发生这种情况,我们希望停止任何待处理的请求,并将其切换到该来源。

任何这些原因都是停止请求的好理由,现在我们有一个可以做到这一点的 API。AbortController系统允许我们停止这些请求。发生的情况是AbortController有一个signal属性。我们将这个signal附加到fetch请求上,当我们调用abort方法时,它告诉fetch请求我们不希望它继续进行请求。这非常简单和直观。以下是一个例子:

(async function() {
    const controller = new AbortController();
    const signal = controller.signal;
    document.querySelector('#stop').addEventListener('click', (ev) => {
        controller.abort();
    });
    try {
        const res = await fetch('http://localhost:8081/longload', 
         {signal});
        const final = await res.text();
        document.querySelector('#content').innerHTML = final;
    } catch(e) {
        console.error('failed to download', e);
    }
})();

正如我们所看到的,我们已经建立了一个AbortController系统并获取了它的signal属性。然后我们设置了一个按钮,当点击时,将运行abort方法。接下来,我们看到了典型的fetch请求,但在选项中,我们传递了signal。现在,当我们点击按钮时,我们会看到请求因 DOM 错误而停止。我们还看到了一些关于async/await的错误处理。async/await可以利用基本的try-catch语句来捕获错误,这只是async/awaitAPI 使代码比回调和基于 promise 的版本更可读的另一种方式。

这是另一个实验性的 API,将来很可能会有变化。但是,我们在XMLHttpRequest中也有了相同类型的想法,因此 Fetch API 也会得到它是有道理的。请注意,MDN 网站是获取有关浏览器支持和任何我们已经讨论过并将在未来章节讨论的实验性 API 的最新信息的最佳地方。

fetch和 promise 系统是从服务器获取数据并展示处理异步流量的新方式。虽然我们过去必须利用回调和一些看起来很糟糕的对象,但现在我们有了一个非常容易使用的简洁的 API。尽管 API 的部分正在变化,但请注意,这些系统很可能会以某种方式存在。

总结

在本章中,我们看到了过去 5 年来浏览器环境发生了多少变化。通过新的 API 增强了我们编写代码的方式,通过 DOM API 使我们能够编写具有内置控件的丰富 UI,我们现在能够尽可能地使用原生应用。这包括获取外部数据的使用,以及新的异步 API,如 promises 和async/await系统。

在下一章中,我们将看到一个专注于输出原生 JavaScript 并为我们提供无运行时应用环境的库。当我们讨论节点和工作线程时,我们还将把大部分现代 API 整合到本书的其余部分中。玩弄这些系统,并熟悉它们,因为我们才刚刚开始。

第四章:实际示例-看看 Svelte 和 Vanilla

由于过去几章讨论了现代网络和我们可用的 API,现在我们将实际示例中使用这些 API。在创建与之相关的一种运行时的 Web 框架方面已经有了相当多的发展。这个运行时几乎可以归因于虚拟 DOMVDOM)和状态系统。当这两个东西相互关联时,我们能够创建丰富和反应灵敏的前端。这些框架的例子包括 React、Vue 和 Angular。

但是,如果我们摆脱 VDOM 和运行时概念,并以某种方式将所有这些代码编译为纯 JavaScript 和 Web API 调用,会发生什么?这就是 Svelte 框架的创建者所考虑的:利用浏览器中已有的内容,而不是创建我们自己的浏览器版本(这显然是一个过度简化,但并不太离谱)。在本章中,我们将看看 Svelte 以及它如何实现一些魔术,以及使用这个框架编写的一些应用程序示例。这应该让我们对 Svelte 和存在的无运行时框架有一个很好的理解,以及它们如何潜在地加快我们的应用程序运行速度。

本章涉及的主题如下:

  • 纯速度的框架

  • 构建基础-待办事项应用程序

  • 变得更花哨-基本天气应用程序

技术要求

本章需要以下内容:

纯速度的框架

Svelte 框架决定将焦点从基于运行时的系统转移到基于编译器的系统。这可以在他们的网站上看到,位于svelte.dev。在他们的首页上,甚至明确指出了以下内容:

Svelte 将您的代码编译为微小的、无框架的 vanilla JS-您的应用程序启动快速并保持快速。

通过将步骤从运行时移至初始编译,我们能够创建下载和运行速度快的应用程序。但是,在我们开始研究这个编译器之前,我们需要将其安装到我们的机器上。以下步骤应该使我们能够开始为 Svelte 编写代码(直接从svelte.dev/blog/the-easiest-way-to-get-started获取):

> npx degit sveltejs/template todo
> cd todo
> npm install
> npm run dev

有了这些命令,我们现在有一个位于localhost:5000的运行中的 Svelte 应用程序。让我们看看让我们如此快速启动的package.json中有什么。首先,我们会注意到我们有一堆基于 Rollup 的依赖项。Rollup 是 JavaScript 的模块捆绑器,还有一套丰富的工具来执行许多其他任务。它类似于 webpack 或 Parcel,但这是 Svelte 决定依赖的工具。我们将在第十二章中更深入地了解 Rollup,构建和部署完整的 Web 应用程序。只需知道它正在为我们编译和捆绑我们的代码。

似乎我们有一个名为sirv的东西(可以在package.json文件中看到)。如果我们在npm中查找sirv,我们会发现它是一个静态资产服务器,但是,它不是直接在文件系统上查找文件(这是一个非常昂贵的操作),而是将请求头和响应缓存在内存中一段时间。这使得它能够快速提供可能已经被提供的资产,因为它只需要查看自己的内存,而不是进行 I/O 操作来查找资产。命令行界面CLI)使我们能够快速设置服务器。

最后,我们以开发模式启动我们的应用程序。如果我们查看package.json文件的scripts部分,我们会看到它运行以下命令:run-p start:dev autobuildrun-p命令表示并行运行所有后续命令。start:dev命令表示在开发模式下启动我们的sirv服务器,autobuild命令告诉 Rollup 编译和监视我们的代码。这意味着每当我们对文件进行更改时,它都会自动为我们构建。让我们快速看看它的运行情况。让我们进入src文件夹并对App.svelte文件进行更改。添加以下内容:

//inside of the script tag
export let name;
export let counter;

function clicker() {
   counter += 1;
}

//add to the template
<span>We have been clicked {counter} times</span>
<button on:click={clicker}>Click me!</button>

我们会注意到我们的网页已经自动更新,现在我们有一个基于事件的响应式网页!这在开发模式下非常好,因为我们不必不断触发编译器。

这些示例中的首选编辑器是 VS Code。如果我们转到 VS Code 的扩展部分,那里有一个很好的 Svelte 插件。我们可以利用这个插件进行语法高亮和一些警报,当我们做错事时。如果首选编辑器没有 Svelte 插件,请尝试至少启用编辑器的 HTML 高亮显示。

好的:这个简单的例子已经给了我们很多东西可以看。首先,App.svelte文件给我们提供了类似 Vue 文件的语法。我们有一个 JavaScript 部分,一个样式部分,和一个增强的 HTML 部分。我们导出了两个变量,名为namecounter。我们还有一个函数,我们在按钮的点击处理程序中使用。我们还为我们的h1元素启用了样式。

看起来花括号添加了我们从这些响应式框架中期望的单向数据绑定。它看起来也像是我们通过简单的on:<event>绑定来附加事件,而不是利用内置的on<event>系统。

如果我们现在进入main.js文件,我们会看到我们正在导入刚刚查看的 Svelte 文件。然后我们创建一个新的app(它应该看起来很熟悉,类似其他响应式框架),并且将我们的应用程序定位到文档的主体。除此之外,我们还设置了一些属性,即我们之前导出的namecounter变量。然后我们将其作为此文件的默认导出进行导出。

所有这些都应该与前一章非常相似,当我们查看内置于浏览器中的类和模块系统时。Svelte 只是借用了这些类似的概念来编写他们的编译器。现在,我们应该看一下编译过程的输出。我们会注意到我们有一个bundle.css和一个bundle.js文件。如果我们首先查看生成的bundle.css文件,我们会看到类似以下的内容:

h1.svelte-i7qo5m{color:purple}

基本上,Svelte 通过将它们放在一个唯一的命名空间下来模仿Web 组件,这种情况下是svelte-i7qo5m。这非常简单,那些使用过其他系统的人会注意到这是许多框架创建作用域样式表的方式。

现在,如果我们进入bundle.js文件,我们会看到一个完全不同的情况。首先,我们有一个立即调用的函数表达式IIFE),这是实时重新加载代码。接下来,我们有另一个 IIFE,它将我们的应用程序分配给一个名为app的全局变量。然后,代码内部有一堆样板代码,如nooprunblank_object。我们还可以看到 Svelte 包装了许多内置方法,例如 DOM 的appendChildcreateElementAPI。以下代码可以看到:

function append(target, node) {
    target.appendChild(node);
}
function insert(target, node, anchor) {
    target.insertBefore(node, anchor || null);
}
function detach(node) {
    node.parentNode.removeChild(node);
}
function element(name) {
    return document.createElement(name);
}
function text(data) {
    return document.createTextNode(data);
}
function space() {
    return text(' ');
}

他们甚至将addEventListener系统包装在自己的形式中,以便他们可以控制回调和生命周期事件。以下代码可以看到:

function listen(node, event, handler, options) {
    node.addEventListener(event, handler, options);
    return () => node.removeEventListener(event, handler, options);
}

他们随后有一堆数组,它们被用作各种事件的队列。他们循环遍历这些数组,弹出并运行事件。这可以在他们设计的 flush 方法中看到。有一个有趣的地方是他们设置了seen_callbacks。这是为了通过计算可能导致无限循环的方法/事件来阻止无限循环。例如,组件A得到一个更新,随后发送一个更新给组件B,然后组件B再发送一个更新给组件A。在这里,WeakSet可能是一个更好的选择,但他们选择使用常规的Set,因为一旦 flush 方法完成,它将被清除。

一个很好查看的最终函数是create_fragment方法。我们会注意到它返回一个对象,其中有一个名为c的 create 函数。正如我们所看到的,这将创建我们在 Svelte 文件中拥有的 HTML 元素。然后我们会看到一个m属性,这是将我们的 DOM 元素添加到实际文档中的 mount 函数。p属性更新了我们绑定到这个 Svelte 组件的属性(在这种情况下是namecounter属性)。最后,我们有d属性,它与destroy方法相关,它会删除所有 DOM 元素和 DOM 事件。

通过查看这段代码,我们可以看到 Svelte 正在利用我们如果从头开始构建 UI 并自己利用 DOM API 时会使用的许多概念,但他们只是将它包装成一堆方便的包装器和巧妙的代码行。

了解一个库的一个很好的方法是阅读源代码或查看它的输出。通过这样做,我们可以找到魔力通常存在的地方。虽然这可能不会立即有益,但它可以帮助我们为框架编写代码,甚至利用我们在他们的代码中看到的一些技巧来编写我们自己的代码库。学习的一种方式是模仿他人。

在所有这些中,我们可以看到 Svelte 声称没有运行时。他们利用了 DOM 提供的基本元素,以一些方便的包装器的形式。他们还为我们提供了一个很好的文件格式来编写我们的代码。尽管这可能看起来像一些基本的代码,但我们能够以这种风格编写复杂的应用程序。

我们将编写的第一个应用程序是一个简单的待办事项应用程序。我们将为其添加一些自己的想法,但它起初将是一个传统的待办事项应用程序。

构建基础-一个待办事项应用程序

为了开始我们的待办事项应用程序,让我们继续使用我们已经有的模板。现在,在大多数待办事项应用程序中,我们希望能够做以下事情:

  • 添加

  • 删除/标记为完成

  • 更新

所以我们拥有一个基本的 CRUD 应用程序,没有任何服务器操作。让我们继续编写我们期望在这个应用程序中看到的 Svelte HTML:

<script>
    import { createEventDispatcher } from 'svelte';
    export let completed;
    export let num;
    export let description;

    const dispatch = createEventDispatcher();
</script>
<style>
    .completed {
        text-decoration: line-through;
    }
</style>
<li class:completed>
    Task {num}: {description}
    <input type="checkbox" bind:checked={completed} />
    <button on:click="{() => dispatch('remove', null)}">Remove</button>
</li>

我们将我们的待办事项应用程序分成了一个待办事项组件和一个通用应用程序。待办事项元素将包含我们的所有逻辑,用于完成和删除元素。正如我们从前面的例子中看到的,我们正在做以下事情:

  • 我们公开这项任务的编号和描述。

  • 我们有一个隐藏在主应用程序中的已完成属性。

  • 我们有一个用于样式化已完成项目的类。

  • 列表元素与完成变量绑定到完成类。

  • numdescription属性与信息相关联。

  • 当我们完成一个项目时,会添加一个复选框。

  • 还有一个按钮,它会告诉我们的应用程序我们想要删除什么。

这是相当多的内容需要消化,但当我们把它们放在一起时,我们会发现这包含了大部分单个待办事项的逻辑。现在,我们需要添加我们应用程序的所有逻辑。它应该看起来像下面这样:

<script>
    import Todo from './Todo.svelte';

    let newTodoText = '';
    const Todos = new Set();

    function addTodo() {
        const todo = new Todo({
            target: document.querySelector('#main'),
            props: {
                num : Todos.size,
                description : newTodoText
            }
        });
        newTodoText = '';
        todo.$on('remove', () => {
            Todos.delete(todo);
            todo.$destroy();
        });
        Todos.add(todo);
    }
</script>
<style></style>
<h1>Todo Application!</h1>
<ul id="main">
</ul>
<button on:click={addTodo}>Add Todo</button>
<input type="text" bind:value={newTodoText} />

首先导入我们之前创建的“待办事项”。然后,我们将newTodoText作为与我们的输入文本绑定的属性。然后,我们创建一个集合来存储我们所有的“待办事项”。接下来,我们创建一个addTodo方法,该方法将绑定到我们的“添加待办事项”按钮的click事件上。这将创建一个新的“待办事项”,将元素绑定到我们的无序列表,并将属性设置为我们的集合大小和输入文本。我们重置“待办事项”文本,并添加一个移除监听器来销毁“待办事项”,并从我们的集合中删除它。最后,我们将其添加到我们的集合中。

我们现在有了一个基本的待办事项应用程序!所有这些逻辑应该都很简单。让我们添加一些额外的功能,就像在上一章中一样。我们将向我们的待办事项应用程序添加以下内容,使其更加健壮和有用:

  • 每个“待办事项”都有关联的截止日期

  • 保持所有“待办事项”的计数

  • 创建过期、已完成和全部过滤器

  • 基于过滤器和每个“待办事项”的添加进行过渡

首先,让我们向我们的待办事项应用程序添加一个截止日期。我们将在我们的Todo.svelte文件中添加一个名为dueDate的新导出字段,并将其添加到我们的模板中,如下所示:

//inside of script tag
export let dueDate;

//part of the template
<li class:completed>
    Task {num}: {description} - Due on {dueDate}
    <input type="checkbox" bind:checked={completed} />
    <button on:click="{() => dispatch('remove', null)}">Remove</button>
</li>

然后,在我们的App.svelte文件中,我们将添加一个日期控件,并确保当我们将我们的“待办事项”添加到列表时,我们还要确保将此字段放回去。这应该看起来像以下内容:

//inside of the script tag
let newTodoDate = null;
function addTodo() {
    const todo = new Todo({
        target: document.querySelector('#main'),
        props: {
            num : Todos.size + 1,
            dueDate : newTodoDate,
            description : newTodoText
        }
    });
    newTodoText = '';
    newTodoDate = null;
    todo.$on('remove', () => {
        Todos.delete(todo);
        todo.$destroy();
    });
    Todos.add(todo);
}

//part of the template
<input type="date" bind:value={newTodoDate} />

我们现在有一个完全功能的截止日期系统。接下来,我们将在我们的应用程序中添加当前“待办事项”的数量。这只需要将一些文本绑定到我们集合的大小的 span 中,如下所示:

//inside of script tag
let currSize = 0;
function addTodo() {
    const todo = new Todo({
        // code removed for readability
    });
    todo.$on('remove', () => {
        Todos.delete(todo);
        currSize = Todos.size;
        todo.$destroy();
    });
    Todos.add(todo);
    currSize = Todos.size;
}

//part of the template
<h1>Todo Application! <span> Current number of Todos: {currSize}</span></h1>

好了,现在我们想要对所有日期和已完成状态做一些处理。让我们添加一些过滤器,这样我们就可以删除不符合我们条件的“待办事项”。我们将添加已完成和过期过滤器。我们将把它们做成复选框,因为一项任务可以同时过期和已完成:

//inside of script tag
let completed = false;
let overdue = false;

//part of the template
<label><input type="checkbox" bind:checked={completed}
    on:change={handleFilter}/>Completed</label>
<label><input type="checkbox" bind:checked={overdue}
    on:change={handleFilter}/>Overdue</label>

我们的处理过滤逻辑应该看起来像以下内容:

function handleHide(item) {
    const currDate = Date.now();
    if( completed && overdue ) {
        item.hidden = !item.completed || new Date(item.dueDate).getTime() < currDate;
        return;
    }
    if( completed ) {
        item.hidden = !item.completed;
        return;
    }
    if( overdue ) {
        item.hidden = new Date(item.dueDate).getTime() < currDate;
        return;
    }
    item.hidden = false;
}

function handleFilter() {
    for(const item of Todos) {
        handleHide(item);
    }
}

我们还需要确保对任何新的“待办事项”项目都有相同的隐藏逻辑:

const todo = new Todo({
    target: document.querySelector('#main'),
    props: {
        num : Todos.size + 1,
        dueDate : newTodoDate,
        description : newTodoText
    }
});
handleHide(todo);

最后,我们的Todo.svelte组件应该看起来像以下内容:

<svelte:options accessors={true} />
<script>
    import { createEventDispatcher } from 'svelte';

    export let num;
    export let description;
    export let dueDate;
    export let hidden = false;
    export let completed = false;

    const dispatch = createEventDispatcher();
</script>
<style>
    .completed {
        text-decoration: line-through;
    }
    .hidden {
        display : none;
    }
</style>
<li class:completed class:hidden>
    Task {num}: {description} - Due on {dueDate}
    <input type="checkbox" bind:checked={completed} />
    <button on:click="{() => dispatch('remove', null)}">Remove</button>
</li>

这些大部分应该看起来很熟悉,除了顶部部分。我们可以在 Svelte 文件中添加特殊标签,以便访问某些属性,例如以下内容:

  • <svelte:window> 给了我们访问窗口事件的权限。

  • <svelte:body> 给了我们访问 body 事件的权限。

  • <svelte:head> 给了我们访问文档头部的权限。

  • <svelte:component> 给了我们访问自己作为 DOM 元素的权限。

  • <svelete:self> 允许我们包含自己(用于递归结构,如树)。

  • <svelte:options> 允许我们向组件添加编译器选项。

在这种情况下,我们希望我们的父组件能够通过 getter/setter 访问我们的属性,因此我们将accessors选项设置为true。这就是我们能够在App.svelte文件中更改我们的隐藏属性,并允许我们获取每个“待办事项”的属性的方式。

最后,让我们添加一些淡入淡出的过渡效果。Svelte 在添加/删除元素时带有一些不错的动画。我们要使用的是fade动画。因此,我们的Todo.svelte文件现在将添加以下内容:

//inside of script tag
import { fade } form 'svelte/transition';

//part of template
{#if !hidden}
    <li in:fade out:fade class:completed>
        Task {num}: {description} - Due on {dueDate}
        <input type="checkbox" bind:checked={completed} />
        <button on:click="{() => dispatch('remove', null)}">Remove</button>
    </li>
{/if}

这种特殊的语法是用于条件性 DOM 添加/删除。就像我们可以用 DOM API 添加/删除子元素一样,Svelte 也在做同样的事情。接下来,我们可以看到我们在列表元素上添加了in:fadeout:fade指令。现在,当元素从 DOM 中添加或移除时,它将淡入和淡出。

我们现在有一个相当功能齐全的待办事项应用程序。我们有过滤逻辑,与截止日期相关的“待办事项”,甚至还有一点动画。下一步是稍微整理一下代码。我们可以通过 Svelte 内置的存储来实现这一点。

存储是一种在不必使用一些我们在应用程序中必须使用的技巧的情况下共享状态的方法(当我们可能不应该打开访问者系统时)。我们的Todos和我们的主应用程序之间的共享状态是过期和已完成的过滤器。每个Todo很可能应该控制这个属性,但我们目前正在利用访问者选项,并且所有的过滤都是在我们的主应用程序中完成的。有了可写存储,我们就不再需要这样做了。

首先,我们编写一个stores.js文件,如下所示:

import { writable } from 'svelte/store';

export const overdue = writable(false);
export const completed = writable(false);

接下来,我们更新我们的App.svelte文件,不再针对Todos中的hidden属性,并将我们的复选框输入的checked属性绑定到存储,如下所示:

//inside of script tag
import { completed, overdue } from './stores.js';

//part of the template
<label><input type="checkbox" bind:checked={$completed} />Completed</label>
<label><input type="checkbox" bind:checked={$overdue} />Overdue</label>

我们脚本中的存储前面的美元符号表示这些是存储而不是变量。它允许我们在销毁时更新和订阅存储,而无需取消订阅。最后,我们可以更新我们的Todo.svelte文件,使其如下所示:

<script>
    import { overdue, completed } from './stores.js';
    import { createEventDispatcher, onDestroy } from 'svelte';
    import { fade } from 'svelte/transition';

    export let num;
    export let description;
    export let dueDate;
    let _completed = false;

    const dispatch = createEventDispatcher();
</script>
<style>
    .completed {
        text-decoration: line-through;
    }
</style>
{#if
    !(
         ($completed && !_completed) ||
         ($overdue && new Date(dueDate).getTime() >= Date.now())
     )
}
    <li in:fade out:fade class:_completed>
        Task {num}: {description} - Due on {dueDate}
        <input type="checkbox" bind:checked={_completed} />
        <button on:click="{() => dispatch('remove', null)}">Remove</button>
    </li>
{/if}

我们已经将过期和已完成的存储添加到我们的系统中。您可能已经注意到,我们已经摆脱了文件顶部的编译器选项。然后我们将我们的#if条件链接到这些存储。我们现在已经将隐藏Todos的责任放在了Todos自身上,同时也删除了相当多的代码。很明显,我们可以以多种方式构建 Svelte 应用程序,并对应用程序保持相当多的控制。

在进入下一个应用程序之前,继续查看捆绑的 JavaScript 和 CSS,并向应用程序添加新功能。接下来,我们将看看如何构建一个天气应用程序并从服务器获取这些信息。

变得更加花哨-一个基本的天气应用程序

很明显,Svelte 已经建立起了与大多数现代 ECMAScript 标准兼容的编译器。他们没有提供任何获取数据的包装器的领域是在这里。添加这个并看到效果的一个好方法是构建一个基本的天气应用程序。

天气应用程序在其核心需要能够输入邮政编码或城市,并输出该地区的当前天气信息。我们还可以根据这个位置得到天气的预测。最后,我们还可以将这些选择保存在浏览器中,这样我们在回到应用程序时就可以使用它们。

对于我们的天气数据,我们将从openweathermap.org/api获取。在这里,免费服务将允许我们获取当前天气。除此之外,我们还需要一个输入系统,可以接受以下内容:

  • 城市/国家

  • 邮政编码(如果没有给出国家,我们将假设是美国,因为这是 API 的默认值)

当我们输入正确的值时,我们将把它存储在LocalStorage中。在本章的后面,我们将更深入地研究LocalStorageAPI,但请注意它是浏览器中的键值存储机制。当我们输入输入值时,我们将得到所有先前搜索的下拉列表。我们还将添加删除列表中任何一个结果的功能。

首先,我们需要获取一个 API 密钥。要做到这一点,请按照以下步骤进行:

  1. 前往openweathermap.org/api并按照说明获取 API 密钥。

  2. 一旦我们创建了一个帐户并验证它,我们就能够添加 API 密钥。

  3. 登录后,应该有一个标签,上面写着API keys。如果我们去那里,应该会看到一个no api keys的消息。

  4. 我们可以创建一个密钥并为其添加一个名称(我们可以称之为default)。

  5. 有了这个密钥,我们现在可以开始调用他们的服务器。

让我们继续设置一个测试调用。以下代码应该可以工作:

let api_key = "<your_api_key>";
fetch(`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${api_key}`)
    .then((res) => res.json())
    .then((final) => console.log(final));

如果我们将这些放入代码片段中,我们应该会得到一个包含大量数据的 JSON 对象。现在我们可以继续使用 Svelte 来利用这个 API 创建一个漂亮的天气应用程序。

让我们以与设置 Todo 应用程序相同的方式设置我们的应用程序。运行以下命令:

> cd ..
> npx degit sveltejs/template weather
> cd weather
> npm install
> npm run dev

现在我们已经启动了环境,让我们创建一个带有一些基本样式的样板应用程序。在global.css文件中,将以下行添加到 body 中:

display: flex;
flex-direction : column;
align-items : center;

这将确保我们的元素都是基于列的,并且它们将从中心开始并向外扩展。这将为我们的应用程序提供一个漂亮的外观。接下来,我们将创建两个 Svelte 组件,一个WeatherInput和一个WeatherOutput组件。接下来,我们将专注于输入。

我们需要以下项目,以便从用户那里获得正确的输入:

  • 输入邮政编码或城市

  • 输入国家代码

  • 一个提交按钮

我们还将向我们的应用程序添加一些条件逻辑。我们将根据输入框左侧的复选框有条件地呈现文本或数字输入,而不是尝试解析输入。有了这些想法,我们的WeatherInput.svelte文件应该如下所示:

<script>
    import { zipcode } from './stores.js';
    const api_key = '<your_api_key>'

    let city = null;
    let zip = null;
    let country_code = null;

    const submitData = function() {
        fetch(`https://api.openweathermap.org/data/2.5/weather?q=${zipcode 
         ? zip : city},${country_code}&appid=${api_key}`)
            .then(res => res.json())
            .then(final => console.log(final));
    }
</script>
<style>
    input:valid {
        border: 1px solid #333;
    }
    input:invalid {
        border: 1px solid #c71e19;
    }
</style>
<div>
    <input type="checkbox" bind:checked={$zipcode} />
    {#if zipcode}
        <input type="number" bind:value={zip} minLength="6" maxLength="10" 
         require />
    {:else}
        <input type="text" bind:value={city} required />
    {/if}
    <input type="text" bind:value={country_code} minLength="2" 
     maxLength="2" required />
    <button on:click={submitData}>Check</button>
</div>

有了这个,我们就有了我们输入的基本模板。首先,我们创建一个zipcode存储,以有条件地显示数字或文本输入。然后,我们创建了一些本地变量,将它们绑定到我们的输入值上。submitData函数将在我们准备好获得某种响应时提交所有内容。目前,我们只是将输出记录到开发者控制台中。

对于样式,我们只是为有效和无效的输入添加了一些基本样式。我们的模板给了我们一个复选框,用于打开zipcode功能或关闭它。然后我们有条件地显示zipcode或城市文本框。每个文本框都添加了内置验证。接下来,我们添加了另一个文本字段,以从用户那里获取国家代码。最后,我们添加了一个按钮,将会去检查数据。

在 Svelte 中,括号被大量使用。输入验证的一个特性是基于正则表达式的。该字段称为模式。如果我们在这里尝试使用括号,将会导致 Svelte 编译器失败。请注意这一点。

在进行输出之前,让我们先给我们的输入添加一些标签,以使用户更容易使用。以下内容应该可以做到:

//in the style tag
input {
    margin-left: 10px;
}
label {
    display: inline-block;
}
#cc input {
    width: 3em;
}

对于每个input元素,我们已经将它们包装在label中,如下所示:

<label id="cc">Country Code<input type="text" bind:value={country_code} minLength="2" maxLength="2" required /></label>

有了这个,我们有了input元素的基本用户界面。现在,我们需要让fetch调用实际输出到可以在我们创建后可用于WeatherOutput元素的东西。让我们创建一个自定义存储来实现gather方法,而不是只是将这些数据作为 props 传递出去。在stores.js中,我们应该有以下内容:

function createWeather() {
    const { subscribe, update } = writable({});
    const api_key = '<your_api_key>';
    return {
        subscribe,
        gather: (cc, _z, zip=null, city=null) => {
            fetch(`https://api.openweathermap.org/data/2.5/weather?=${_z ? 
             zip : city},${cc}&appid=${api_key})
                .then(res => res.json())
                .then(final => update(() => { return {...final} }));
        }
    }
}

我们现在已经将获取数据的逻辑移到了存储中,现在我们可以订阅这个存储来更新自己。这意味着我们可以让WeatherOutput组件订阅这个存储以获得一些基本输出。以下代码应该放入WeatherOtuput.svelte中:

<script>
    import { weather } from './stores.js';
</script>
<style>
</style>
<p>{JSON.stringify($weather)}</p>

现在我们所做的就是将我们的天气输出放入一个段落元素中,并对其进行字符串化,以便我们可以在不查看控制台的情况下阅读输出。我们还需要更新我们的App.svelte文件,并像这样导入WeatherOutput组件:

//inside the script tag
import WeatherOutput from './WeatherOutput.svelte'

//part of the template
<WeatherOutput></WeatherOutput>

如果我们现在测试我们的应用程序,我们应该会得到一些难看的 JSON,但是我们现在通过存储将我们的两个组件联系起来了!现在,我们只需要美化输出,我们就有了一个完全功能的天气应用程序!更改WeatherOutput.svelte中的样式和模板如下:

<div>
    {#if $weather.error}
        <p>There was an error getting your data!</p>
    {:else if $weather.data}
        <dl>
            <dt>Conditions</dt>
            <dd>{$weather.weather}</dd>
            <dt>Temperature</dt>
            <dd>{$weather.temperature.current}</dd>
            <dd>{$weather.temperature.min}</dd>
            <dd>{$weather.temperature.max}</dd>
            <dt>Humidity</dt>
            <dd>{$weather.humidity}</dd>
            <dt>Sunrise</dt>
            <dd>{$weather.sunrise}</dd>
            <dt>Sunset</dt>
            <dd>{$weather.sunset}</dd>
            <dt>Windspeed</dt>
            <dd>{$weather.windspeed}</dd>
            <dt>Direction</dt>
            <dd>{$weather.direction}</dd>
        </dl>
    {:else}
        <p>No city or zipcode has been submitted!</p>
    {/if}
</div>

最后,我们应该添加一个新的控件,让我们的用户可以选择输出的公制或英制单位。将以下内容添加到WeatherInput.svelte中:

<label>Metric?<input type="checkbox" bind:checked={$metric}</label>

我们还将在stores.js文件中使用一个新的metric存储,默认值为false。有了这一切,我们现在应该有一个功能齐全的天气应用程序了!我们唯一剩下的部分是添加LocalStorage功能。

有两种类型的存储可以做类似的事情。它们是LocalStorageSessionStorage。主要区别在于它们的缓存时间有多长。LocalStorage会一直保留,直到用户删除缓存或应用程序开发人员决定删除它。SessionStorage在页面的生命周期内保留在缓存中。一旦用户决定离开页面,SessionStorage就会清除。离开页面意味着关闭标签页或导航离开;它不意味着重新加载页面或 Chrome 崩溃并且用户恢复页面。由设计者决定使用哪种方式。

利用LocalStorage非常容易。在我们的情况下,该对象保存在窗口上(如果我们在工作程序中,它将保存在全局对象上)。需要记住的一件事是,当我们使用LocalStorage时,它会将所有值转换为字符串,因此如果我们想要存储复杂对象,我们需要进行转换。

要更改我们的应用程序,让我们专门为我们的下拉列表创建一个新组件。让我们称之为Dropdown。首先,创建一个Dropdown.svelte文件。接下来,将以下代码添加到文件中:

<script>
    import { weather } from './stores.js';
    import { onDestroy, onMount } from 'svelte';

    export let type = "text";
    export let name = "DEFAULT";
    export let value = null;
    export let required = true;
    export let minLength = 0;
    export let maxLength = 100000;
    let active = false;
    let inputs = [];
    let el;

    const unsubscribe = weather.subscribe(() => {
        if(!inputs.includes(value) ) {
            inputs = [...inputs, value];
            localStorage.setItem(name, inputs);
        }
        value = '';
    });
    const active = function() {
        active = true;
    }
    const deactivate = function(ev) {
        if(!ev.path.includes(el) ) 
            active = false;
    }
    const add = function(ev) {
        value = ev.target.innerText;
        active = false;
    }
    const remove = function(ev) {
        const text = ev.target.parentNode.querySelector('span').innerText;
        const data = localStorage.getItem(name).split(',');
        data.splice(data.indexOf(text));
        inputs = [...data];
        localStorage.setItem(name, inputs);
    }
    onMount(() => {
        const data = localStorage.getItem(name);
        if( data === "" ) { inputs = []; }
        else { inputs = [...data.split(',')]; }
    });
    onDestroy(() => {
        unsubscribe();
    });
</script>
<style>
    input:valid {
        border 1px solid #333;
    }
    input:invalid {
        border 1px solid #c71e19;
    }
    div {
        position : relative;
    }
    ul {
        position : absolute;
        top : 100%;
        list-style-type : none;
        background : white;
        display : none;
    }
    li {
        cursor : hand;
        border-bottom : 1px solid black;
    }
    ul.active {
        display : inline-block;
    }
</style>
<svelte:window on:mousedown={deactivate} />
<div>
    {#if type === "text"}
        <input on:focus={activate} type="text" bind:value={value} 
         {minLength} {maxLength} {required} />
    {:else}
        <input on:focus={activate} type="number" bind:value={value} 
         {minLength} {maxLength} {required} />
    {/if}
    <ul class:active bind:this={el}>
        {#each inputs as input }
            <li><span on:click={add}>{input}</span> <button 
             on:click={remove}>&times;</button></li>
        {/each}
    </ul>
</div>

这是相当多的代码,让我们分解一下我们刚刚做的事情。首先,我们将我们的输入更改为dropdown组件。我们还将许多状态内部化到这个组件中。我们打开各种字段,以便用户能够自定义字段本身。我们需要确保设置的主要字段是name。这是我们用于存储搜索的LocalStorage键。

接下来,我们订阅weather存储。我们不使用实际数据,但我们确实获得事件,因此如果选择是唯一的(可以使用集合而不是数组),我们可以将其添加到存储中。如果我们想要激活下拉列表,我们还添加了一些基本逻辑,如果我们聚焦或者点击了下拉列表之外。我们还为列表元素的点击事件添加了一些逻辑(实际上是将其添加到列表元素的子元素),以将文本放入下拉列表或从我们的LocalStorage中删除。最后,我们为组件的onMountonDestroy添加了行为。onMount将从localStorage中获取并将其添加到我们的输入列表中。onDestroy只是取消了我们的订阅,以防止内存泄漏。

其余的样式和模板应该看起来很熟悉,除了无序列表系统中的bind:this。这允许我们将变量绑定到元素本身。这使我们能够在事件路径中的元素不在列表中时取消激活我们的下拉列表。

有了这些,对WeatherInput.svelte进行以下更新:

//inside the script tag
import Dropdown from './Dropdown.svelte';

//part of the template
{#if $zipcode}
    <label>Zip<Dropdown name="zip" type="number" bind:value={zip} 
     minLength="6" maxLength="10"></Dropdown></label>
{:else}
    <label>City<Dropdown name="city" bind:value={city}></Dropdown></label>
{/if}
<label>Country Code<Dropdown name="cc" bind:value={country_code} 
 minLength="2" maxLength="2"></Dropdown></label>

我们现在已经创建了一个半可重用的dropdown组件(我们依赖于天气存储,因此它实际上只适用于我们的应用程序),并且已经创建了一个看起来像单个组件的东西。

总结

Svelte 是一个有趣的框架,我们将代码编译成原生 JavaScript。它利用现代思想,如模块、模板和作用域样式。我们还能够以简单的方式创建可重用的组件。虽然我们可以对我们构建的应用程序进行更多的优化,但我们可以看到它们确实有多快。虽然 Svelte 可能不会成为应用程序开发的主流选择,但它是一个很好的框架,可以看到我们在之前章节中探讨的许多概念。

接下来,我们将暂时离开浏览器,看看如何利用 Node.js 在服务器上使用 JavaScript。我们在这里看到的许多想法将被应用在那里。我们还将看到编写应用程序的新方法,以及如何在整个网络生态系统中使用一种语言。

第五章:切换上下文-没有 DOM,不同的 Vanilla

当我们把注意力从浏览器转向其他地方时,我们将进入大多数后端程序员熟悉的环境。Node.js 为我们提供了一个熟悉的语言,即 JavaScript,可以在系统环境中使用。虽然 Node.js 以用于编写服务器的语言而闻名,但它可以用于大多数其他语言所用的大多数功能。例如,如果我们想创建一个命令行界面CLI)工具,我们就有能力做到。

Node.js 还为我们提供了类似于浏览器中看到的编程环境。我们得到了一个允许我们进行异步输入和输出I/O)的事件循环。这是通过 libuv 库实现的。在本章的后面,我们将解释这个库以及它如何帮助我们提供我们习惯的常见事件循环。首先,我们将看看如何启动和运行 Node.js,以及编写一些简单的程序。

在本章中,我们将涵盖以下主题:

  • 获取 Node.js

  • 理解无 DOM 的世界

  • 调试和检查代码

让我们开始吧。

技术要求

本章需要以下技术要求:

获取 Node.js

之前的章节要求有一个 Node.js 运行时。在本章中,我们将看看如何在我们的系统上安装它。如果我们前往Node.js.org/en/,我们将能够下载长期支持LTS)版本或当前版本。对于本书来说,建议获取当前版本,因为模块支持更好。

对于 Windows,我们只需要下载并运行可执行文件。对于 OS X 和 Linux,这也应该很简单。特别是对于 Linux 用户,可能在特定发行版的存储库中有一个版本,但这个版本可能很旧,或者与 LTS 版本一致。记住:我们希望运行最新版本的 Node.js。

一旦我们安装好了,我们应该能够从任何命令行调用node命令(Linux 用户可能需要调用Node.js命令,因为一些存储库已经在其存储库中包含了一个 node 包)。一旦调用,我们应该会看到一个读取评估打印循环REPL)工具。这使我们能够在实际将代码写入文件之前测试一些代码。运行以下代码片段:

1 + 2 //3
typeof("this") //'string'
const x = 'what' //undefined
x //'what'
x = 'this' //TypeError: Assignment to a constant variable
const fun = function() { console.log(x); } //undefined
fun() //'what' then undefined
fun = 'something' //TypeError: Assignment to a constant variable

从这些例子中,很明显我们正在一个类似于我们在浏览器中使用的环境中工作。我们可以访问大多数我们在浏览器中拥有的数据操作和功能概念。

我们无法访问许多特定于浏览器的库/ API,比如 DOM API。我们也无法访问任何浏览器外部资源访问库,比如FetchXMLHttpRequest。我们将稍后讨论它们的较低级版本,但应该注意的是,在某些方面,它并不像调用 fetch API 那样简单。

继续玩一下 REPL 系统。如果我们想退出,只需要在 Windows 上按两次Ctrl + C(Linux 应该是一样的;对于 OS X,我们需要使用command + C)。现在,要运行一个脚本,我们只需要把一些代码放在一个 JavaScript 文件中,然后调用node <filename>。这应该在立即模式下运行我们的脚本。这可以在以下的example.js文件中看到:

const x = 'what';
console.log('this is the value of x', x); //'this is the value of x what'
const obj = {
    what : 'that',
    huh : 'this',
    eh : 'yeah'
}
console.log(obj); // { what : 'that', huh : 'this', eh : 'yeah' }

为了访问 Node.js 给我们的各种内置库,我们可以利用两种不同的方法。首先,我们可以使用旧的require系统。以下脚本展示了这种能力:

const os = require('os');

console.log(os.arch()); //prints out x64 on 64-bit OS
console.log(os.cpus()); //prints out information on our CPU

这是当前引入内置/用户构建模块的方式。这是 Node 团队决定的风格,因为没有常见的引入模块的方式。我们有 RequireJS 或 CommonJS 等系统,Node.js 决定采用 CommonJS 风格引入模块。然而,正如我们所了解的,也有一种标准化的方式将模块引入浏览器。对于 Node.js 平台也是如此。

模块系统目前处于实验阶段,但如果需要,可以使用诸如 RollupJS 之类的系统将代码更改为通用识别的系统版本,例如通用模块依赖UDM)系统。

这个系统看起来应该很熟悉。以下脚本显示了先前的示例,但是在模块导入系统中:

import os from 'os';

console.log(os.arch());
console.log(os.cpus());

我们还需要一个package.json文件,在其清单中有"type" : "module"

package.json 文件概述

package.json文件包含了我们正在构建的包的所有信息。它甚至让我们能够将其与我们的版本控制系统联系起来,甚至可以将其与我们的构建系统联系起来。让我们现在来看一下。

首先,package.json文件应该填写以下字段:

  • name:这是包的名称。

  • version:这是我们软件包的当前版本。

  • type:这应该是modulecommonjs。这将允许我们区分传统系统和新的 ECMAScript 模块系统。

  • license:这是我们想要许可我们的模块的方式。大多数情况下,只需放置 MIT 许可证。然而,如果我们想要更严格地限制它,我们可以随时使用 GPL 或 LGPL 许可证。

  • author:这是一个带有nameemailurl字段的对象。这为软件提供了归属,并帮助人们知道是谁构建了它。

  • main:这是模块的主入口点。这将允许其他人使用我们的模块并要求/导入它。它还将让系统知道在哪里寻找我们模块的起始点。

还有许多其他可以使用的字段,如下:

  • man:这允许man命令找到我们希望为我们的文档提供的文件。

  • description:这允许我们提供关于我们的模块及其功能的更多信息。如果描述超过两到三句,建议附带一个README文件。

  • repository:这允许其他人找到存储库并为其做出贡献或提交错误报告/功能请求。

  • config:这是一个对象,可以被我们在package.json文件的脚本部分定义的脚本使用。脚本将很快会详细讨论。

  • dependencies:这是我们的模块依赖的模块列表。这可以是来自公共npm注册表、私有存储库、Git 存储库、tarballs,甚至是本地文件路径用于本地开发。

  • devDependencies:这是需要用于此软件包开发的依赖列表。

  • peerDependencies:这是我们的包可能需要的依赖列表,如果有人使用系统的一部分。这允许我们的用户下载核心系统,如果他们想要使用其他部分,他们可以下载这些其他子系统需要的对等依赖。

  • OS:这是我们运行的操作系统列表。这也可以是其否定版本,比如!darwin,意味着这个系统将在除 OS X 之外的所有操作系统上运行。

  • engines:我们运行的 Node.js 版本。当我们使用最近版本引入的功能(例如 ECMAScript 模块)时,我们将要使用这个。如果我们使用已被弃用的模块并希望将 Node.js 版本锁定到旧版本,我们也可能想要使用这个功能。

package.json文件中还有一些其他字段,但这些是最常见的。

我们想要查看的package.json文件的一个特定部分是脚本部分。如果我们去查看npm的网站关于脚本部分的信息,它陈述了以下内容:

scripts属性是一个包含在包的生命周期中的各个时间点运行的脚本命令的字典。键是生命周期事件,值是在该点运行的命令。

如果我们进入更多细节部分,我们将看到我们可以使用生命周期钩子,以便我们可以通过捆绑和分发过程运行各种脚本。

值得注意的是,这些信息特定于Node Package Managernpm)。在学习 Node.js 的过程中,我们会经常遇到npm,因此学习 Node.js 也意味着学习npm

我们感兴趣的一些具体点是打包生命周期的prepareinstall部分。让我们看看这些部分涵盖了什么:

  • Prepare将在将包打包成 tarball 并发布到远程存储库之前运行脚本。这是运行编译器和捆绑器以准备部署我们的包的好方法。

  • Install将在安装完包后运行脚本。当我们拉取一个包并想要运行诸如node-gyp之类的东西,或者我们的包可能需要的特定于操作系统的东西时,这非常有用。

scripts部分的另一个好处是,我们可以在这里放任意字符串并运行npm run <script>。无论我们决定使用什么值,都将在运行命令时进行评估。让我们将以下内容添加到我们的package.json文件中:

"config" : {
    "port" : 8080,
    "name" : "example",
    "testdata" : {
        "thing" : 5,
        "other" : 10
    }
},
"scripts" : {
    "example-script" : "node main.js"
}

这将使我们能够获取配置数据。除此之外,我们还添加了一个可以通过npm run example-script命令运行的脚本。如果我们创建一个main.js文件并向其中添加以下字段,我们应该会得到以下输出:

console.log(process.env.npm_package_config_port); //8080
console.log(process.env.npm_package_config_name); //'example'
console.log(process.env.npm_package_config_testdata); //undefined

这表明我们可以在配置中放入原始值,但我们不能尝试访问复杂对象。我们可以这样做来获取testdata对象的属性:

console.log(process.env.npm_package_config_testdata_thing) //5
console.log(process.env.npm_package_config_testdata_other) //10

现在我们对 Node.js 和npm生态系统有了一些了解,让我们来看看 Node.js 是如何组合在一起的,以及我们将在接下来的章节中使用的一些关键模块。

理解无 DOM 世界

正如我们在介绍中所述,Node.js 的出现是基于这样一个想法:如果我们在浏览器中编写代码,那么我们应该能够在服务器上运行它。在这里,我们有一个语言适用于两种情境,无论我们在哪个部分工作,都不必切换上下文。

Node.js 可以通过两个库的混合方式运行。这些库是 V8,我们应该已经熟悉了,以及 libuv,我们目前还不熟悉。libuv 库为我们提供了异步 I/O。每个操作系统都有不同的处理方式,所以 libuv 为我们提供了一个很好的 C 包装器来处理所有这些实例。

libuv 库将 I/O 请求排队到请求堆栈上。然后,它将它们分配给一定数量的线程(Node.js 默认使用四个)。一旦这些线程的响应返回,libuv 将它们放在响应堆栈上,并通知 V8 响应已准备好被消耗。一旦 V8 注意到这个通知,它将从中取出值并将其用于对我们发出的请求的响应。这就是 Node.js 运行时如何能够具有异步 I/O 并仍然保持单线程执行的方式(至少对用户来说是这样看的)。

有了这些基本理解,我们应该能够开始编写一些处理各种 I/O 操作并利用使 Node.js 特殊的想法之一的基本脚本:流系统。

流的第一印象

正如我们在 DOM 中看到的那样,流给了我们控制数据流的能力,并且能够以创建非阻塞系统的方式处理数据。通过创建一个简单的流,我们可以看到这一点。让我们继续利用 Node.js 提供的内置流之一,readFileStream。让我们编写以下脚本:

import fs from 'fs';
import { PassThrough } from 'stream'

const str = fs.createReadStream('./example.txt');
const pt = new PassThrough();
str.pipe(pt);
pt.on('data', (chunk) => {
    console.log(chunk);
});

在这里,我们导入了fs库和stream库中的PassThrough流。然后,我们为example.txt文件创建了一个读取流,以及一个PassThrough流。

PassThrough流允许我们处理数据,而无需显式创建流。我们读取数据并将其传输到我们的PassThrough流。

从这里,我们能够获得数据事件的处理,这给了我们一块数据。除此之外,我们还确保在pipe方法之后放置了我们的数据事件监听器。通过这样做,我们确保在附加监听器之前没有data事件运行。

让我们创建以下example.txt文件:

This is some data
it should be processed by our system
it should be coming in chunks that our system can handle
most likely this will all come in one chunk

现在,如果我们运行node --experimental-modules read_file_stream.js命令,我们将看到它打印出一个Buffer。除非我们明确将其设置为对象模式,否则所有数据处理都是以二进制块包装在Buffer对象中的。如果我们将控制台日志命令更改为打印以下内容,我们应该得到纯文本输出:

console.log(chunk.toString('utf8'));

让我们创建一个程序,统计文本中单词the的使用次数。我们可以使用我们的PassThrough流来做到这一点,如下所示:

import fs from 'fs';
import { PassThrough } from 'stream';

let numberOfThe = 0;
const chars = Buffer.from('the');
let currPos = 0;
const str = fs.createReadStream('./example.txt');
const pt = new PassThrough();
str.pipe(pt);
pt.on('data', (chunk) => {
    for(let i = 0; i < chunk.byteLength; i++) {
        const char = chunk[i];
        if( char === chars[currPos] ) {
            if( currPos === chars.byteLength - 1 ) // we are at the end so 
             reset
                numberOfThe += 1;
                currPos = 0;
            } else {
                currPos += 1;
            }
        } else {
            currPos += 1;
        }
    }
});
pt.on('end', () => {
    console.log('the number of THE in the text is: ', numberOfThe);
});

我们需要记录单词the出现的次数。我们还将创建一个the字符串的字节缓冲区。我们还需要跟踪我们当前的位置。通过这样做,每当我们获得数据时,我们可以运行并测试每个字节。如果字节与我们持有的当前位置匹配,那么我们需要进行另一个检查。如果它等于单词the的字符字节计数,那么我们更新the的数量并重置我们的当前位置。否则,我们将当前位置设置为下一个索引。如果我们没有找到匹配,我们需要重置当前位置;否则,我们将得到字符the的任意组合。

这是一个有趣的例子,展示了如何利用PassThrough流,但让我们继续创建我们自己的写Transform流。我们将应用与之前相同的操作,但我们将构建一个自定义流。正如文档中所述,我们必须编写_transform函数,并且可以选择实现_flush函数。我们将实现_transform_flush函数。我们还将利用新的类语法,而不是利用旧的基于原型的系统。在构建我们自己的自定义流时要记住的一件事是,在流中做任何其他事情之前运行super(options)方法。这将允许用户传递各种流选项,而无需我们做任何事情。

考虑到所有这些,我们应该得到类似以下的东西:

import { Transform } from 'stream';

class GetThe extends Transform {
    #currPos = 0;
    #numberOfThe = 0;

    static chars = Buffer.from('the');
    constructor(options) {
        super(options);
    }
    _transform(chunk, encoding, callback) {
        for(let i = 0; i < chunk.byteLength; i++) {
            const char = chunk[i];
            if( char === GetThe.chars[this.#currPos]) {
                if( this.#currPos === GetThe.chars.byteLength - 1 ) {
                    this.#numberOfThe += 1;
                    this.#currPos = 0;
                } else {
                    this.#currPos += 1;
                }
            } else {
                this.#currPos = 0;
            }
        }
        callback();
    }
    _flush(callback) {
        callback(null, this.#numberOfThe.toString());
    }
}

export default GetThe;

首先,我们从stream基础库中导入Transform流。我们扩展它并创建一些私有变量,即the缓冲区中的当前位置和流中the的当前计数。我们还为我们要进行比较的缓冲区创建了一个静态变量。然后,我们有我们的构造函数。这是我们将选项传递给Transform流构造函数的地方。

接下来,我们以与我们在PassThrough流的data事件上实现的方式实现_transform方法。唯一的新部分应该是在最后调用回调函数。这让我们的流知道我们已经准备好处理更多数据。如果我们需要出错,我们可以将其作为第一个参数传递。我们还可以传递第二个参数,就像在_flush函数中所示的那样。这允许我们将处理过的数据传递给可能正在监听的人。在我们的情况下,我们只想传递我们在文本中找到的the的数量。我们还可以只传递BufferStringUint8Array,所以我们决定传递我们数字的字符串版本(我们本可以使用Buffer,这可能是更好的选择)。最后,我们从我们的模块中导出这个。

在我们的read_file_stream文件中,我们将使用以下命令导入此模块:

import GetThe from './custom_transform.js';

然后,我们可以使用以下代码:

const gt = new GetThe();
gt.on('data', (data) => {
    console.log('the number of THE produced by the custom stream is: ', 
     data.toString('utf8'));
});
const str2 = fs.createReadStream('./example.txt');
str2.pipe(gt);

通过这样做,我们将所有这些逻辑封装到一个单独的模块和可重用的流中,而不仅仅是在PassThroughdata事件中这样做。我们还可以将我们的流实现链接到另一个流(在这种情况下,除非我们要将其传递给套接字,否则可能没有意义)。

这是流接口的简短介绍,并概述了我们将在后面章节中详细讨论的内容。接下来,我们将看一下 Node.js 附带的一些模块以及它们如何帮助我们编写服务器应用程序。

模块的高级视图

有三个 I/O 模块允许我们的应用程序与文件系统和外部世界进行交互。它们分别是:

  • fs

  • net

  • http

这三个模块很可能是用户在开发 Node.js 应用程序时将使用的主要模块。让我们分别来看看它们。

fs 模块

首先,让我们创建一个访问文件系统、打开文件、添加一些文本、关闭文件,然后再追加一些文本的基本示例。这看起来类似于以下内容:

import { promises } from 'fs';

(async() => {
    await promises.writeFile('example2.txt', "Here is some text\n");
    const fd = await promises.open('example2.txt', 'a');
    await fd.appendFile("Here is some more text\n");
    await fd.close();
    console.log(await promises.readFile('example2.txt', 'utf8'));
})();

首先,我们正在获取基于 Promise 的库版本。大多数内置模块都有基于 Promise 的版本,这可以导致看起来很好的代码,特别是与回调系统相比。接下来,我们写入一个文件并给它一些文本。writeFile方法允许我们写入文件并在文件不存在时创建文件。之后,我们打开我们文件的FileHandle

Node.js 采用了 POSIX 风格的 I/O。这意味着一切都像文件一样对待。在这种情况下,一切都被分配了一个文件描述符fd)。这对我们来说看起来像是 C++等语言中的一个数字。之后,我们可以将这个数字传递给我们可用的各种文件函数。在 Promises API 中,Node.js 决定切换到FileHandle对象,这是我们得到的而不是这个文件描述符。这导致了更清晰的代码,并且有时需要在系统上提供一层抽象。

我们可以看到作为第二个参数的a表示我们将如何使用文件。在这种情况下,我们将追加到文件中。如果我们用r打开它,这意味着我们要从中读取,而如果我们用w打开它,这意味着我们要覆盖已经存在的内容。

了解 Unix 系统可以帮助我们更好地理解 Node.js 的工作原理,以及所有这些与我们试图编写的程序之间的对应关系。

然后,我们向文件追加一些文本并关闭它。最后,我们在控制台记录文件中的内容,并声明我们要以 UTF-8 文本而不是二进制形式读取它。

与文件系统相关的 API 还有很多,建议查看 Promise 文档以了解我们有哪些能力,但它们都归结为我们可以访问文件系统,并能够读取/写入/追加到各种文件和目录。现在,让我们继续讨论net模块。

网络模块

net模块为我们提供了对底层套接字系统甚至本地进程间通信IPC)方案的访问权限。IPC 方案是允许我们在进程之间进行通信的通信策略。进程不共享内存,这意味着我们必须通过其他方式进行通信。在 Node.js 中,这通常意味着三种不同的策略,它们都取决于我们希望系统有多快速和紧密耦合。这三种策略如下:

  • 无名管道

  • 命名管道/本地域套接字

  • TCP/UDP 套接字

首先,我们有无名管道。这些是单向通信系统,不会出现在文件系统中,并且在parentchild进程之间共享。这意味着parent进程会生成一个child进程,并且parent会将管道一端的位置传递给child。通过这样做,它们可以通过这个通道进行通信。一个例子如下:

import { fork } from 'child_process';

const child = fork('child.js');
child.on('message', (msg) => {
    switch(msg) {
        case 'CONNECT': {
            console.log('our child is connected to us. Tell it to dispose 
             of itself');
            child.send('DISCONNECT');
            break;
        }
        case 'DISCONNECT': { 
            console.log('our child is disposing of itself. Time for us to 
             do the same');
            process.exit();
            break;
        }
    }
});

我们的child文件将如下所示:

process.on('message', (msg) => {
    switch(msg) {
        case 'DISCONNECT': {
            process.exit();
            break;
        }
    }
});
process.send('CONNECT');

我们从child_process模块中获取 fork 方法(这允许我们生成新的进程)。然后,我们从child JavaScript 文件中 fork 一个新的child,并获得对该child进程的处理程序。作为 fork 过程的一部分,Node.js 会自动为我们创建一个无名管道,以便我们可以在两个进程之间进行通信。然后,我们监听child进程上的事件,并根据接收到的消息执行各种操作。

child端,我们可以自动监听来自生成我们的进程的事件,并且可以通过我们的进程接口发送消息(这在每个启动的 Node.js 文件中都是全局的)。如下面的代码所示,我们能够在两个独立的进程之间进行通信。如果我们想要真正看到这一点,我们需要在parent进程中添加一个超时,以便在15秒内不发送DISCONNECT消息,就像这样:

setTimeout(() => {
    child.send('DISCONNECT');
}, 15000);

现在,如果我们打开任务管理器,我们会看到启动了两个 Node.js 进程。其中一个是parent,另一个是child。我们正在通过一个无名管道进行通信,因此它们被认为是紧密耦合的,因为它们是唯一共享它的进程。这对于我们希望有parent/child关系的系统非常有用,并且不希望以不同的方式生成它们。

与在两个进程之间创建紧密链接不同,我们可以使用称为命名管道的东西(在 OS X 和 Linux 上称为 Unix 域套接字)。它的工作方式类似于无名管道,但我们能够连接两个不相关的进程。为了实现这种类型的连接,我们可以利用net模块。它提供了一个低级 API,可以用来创建、连接和监听这些连接。我们还会得到一个低级套接字连接,因此它的行为类似于http(s)模块。

要建立连接,我们可以这样做:

import net from 'net';
import path from 'path';
import os from 'os';

const pipeName = (os.platform() === 'win32' ?
    path.join('\\\\?\\pipe', process.cwd(), 'temp') :
    path.join(process.cwd(), 'temp');
const server = net.createServer().listen(pipeName);
server.on('connection', (socket) => {
    console.log('a socket has joined the party!');
    socket.write('DISCONNECT');
    socket.on('close', () => {
        console.log('socket has been closed!');
    });
});

在这里,我们导入了netpathos模块。path模块帮助我们创建和解析文件系统路径,而无需为特定的操作系统编写路径表达式。正如我们之前看到的,os模块可以为我们提供有关当前所在的操作系统的信息。在创建管道名称时,Windows 需要在\\?\pipe\<something>。在其他操作系统上,它可以只是一个常规路径。还有一点需要注意的是,除了 Windows 之外的任何其他操作系统在我们使用完管道后都不会清理它。这意味着我们需要确保在退出程序之前删除文件。

在我们的情况下,我们根据平台创建一个管道名称。无论如何,我们确保它在我们当前的工作目录(process.cwd())中,并且它被称为temp。从这里,我们可以创建一个服务器,并在这个文件上监听连接。当有人连接时,我们收到一个Socket对象。这是一个完整的双工流,这意味着我们可以从中读取和写入。我们还能够将信息传送到其中。在我们的情况下,我们想要记录到控制台socket加入,然后发送一个DISCONNECT消息。一旦我们收到关闭事件,我们就会记录socket关闭。

对于我们的客户端代码,我们应该有类似以下的东西:

import net from 'net';
import path from 'path';
import os from 'os';

const pipeName = (os.platform() === 'win32') ? 
    path.join('\\\\?\\pipe', process.cwd(), 'temp') :
    path.join(process.cwd(), 'temp');
const socket = new net.Socket().connect(pipeName);
socket.on('connect', () => {
    console.log('we have connected');
});
socket.on('data', (data) => {
    if( data.toString('utf8') === 'DISCONNECT' ) {
        socket.destroy();
    }
});

这段代码与之前的代码非常相似,只是我们直接创建了一个Socket对象并尝试连接到相同的管道名称。一旦连接成功,我们就会记录下来。当我们收到数据时,我们会检查它是否等于我们的DISCONNECT消息,如果是,我们就会摆脱这个套接字。

IPC 机制的好处在于我们可以在不同语言编写的不同程序之间传递消息。它们唯一需要共同拥有的是某种形式的共同语言。有许多系统可以做到这一点。尽管这不是本书的重点,但请注意,如果我们需要连接到另一个程序,我们可以使用net模块相当容易地实现这一点。

http 模块

我们要高层次地看一下的最后一个模块是http模块。这个模块允许我们轻松创建http服务器。以下是一个简单的http服务器示例:

import http from 'http';

const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type' : 'application/json'});
    res.end(JSON.stringify({here : 'is', some : 'data'}));
});
server.listen(8000, '127.0.0.1');

如果我们在浏览器中输入localhost:8000,我们应该能在浏览器中看到 JSON 对象。如果我们想变得更加花哨,我们可以发送一些基本的 HTML,比如下面这样:

const server = https.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type' : 'text/html' });
    res.end(`
        <html>
            <head></head>
            <body>
                <h1>Hello!</h1>
                <p>This is from our server!</p>
            </body>
        </html>
    `);
});

我们将内容类型设置为text/html,而不是application/json,以便浏览器知道如何解释这个请求。然后,我们用我们的基本 HTML 结束响应。如果我们的 HTML 请求 CSS 文件,我们将如何响应服务器?

我们需要解释请求并能够发送一些 CSS。我们可以使用以下方式来做到这一点:

const server = http.createServer((req, res) => {
    if( req.method === 'GET' &&
        req.url === '/main.css' ) {
        res.writeHead(200, { 'Content-Type' : 'text/css' });
        res.end(`
            h1 {
                color : green;
            }
            p {
                color : purple;
            }
        `);
    } else {
        res.writeHead(200, { 'Content-Type' : 'text/html' });
        // same as above
    }
});

我们能够从接收到的请求中提取各种信息。在这种情况下,我们只关心这是否是一个GET请求,并且它是否在请求main.css资源。如果是,我们返回 CSS;否则,我们只返回我们的 HTML。值得注意的是,这段代码应该看起来与诸如 Express 之类的 Web 服务器框架相似。Express 添加了许多辅助方法和保护服务器的方法,但值得注意的是,我们可以通过利用 Node.js 内部的模块编写简单的服务器,减少依赖。

我们还可以使用http模块从各种资源中获取数据。如果我们使用内置在http模块中的get方法,甚至更通用的请求方法,我们可以从各种其他服务器获取资源。以下代码说明了这一点:

import https from 'https';

https.get('https://en.wikipedia.org/wiki/Surprise_(emotion)', (res) => {
    if( res.statusCode === 200 ) {
        res.on('data', (data) => {
            console.log(data.toString('utf8'));
        });
        res.on('end', () => {
            console.log('no more information');
        });
    } else {
        console.error('bad error code!', res.statusCode);
    }
});

首先,我们可以看到我们必须利用https模块。由于这个网页位于一个使用安全套接字层SSL)证书的服务器上,我们必须使用安全连接方法。然后,我们只需调用get方法,传入我们想要的 URL,并从响应中读取数据。如果出现某种原因,我们没有得到一个 200 响应(一个正常的消息),我们就会出错。

这三个模块应该展示了我们在 Node.js 生态系统中有相当大的能力,并且应该引发一些好奇心,让我们想知道如何在没有任何依赖的情况下使用 Node.js 来制作有用的系统。在下一节中,我们将看看如何在命令行调试器中调试我们的 Node.js 代码,以及我们习惯于在 Chrome 中使用的代码检查系统。

调试和检查代码

新的 Node.js 开发人员常常在调试代码方面遇到困难。与检查员不同,我们有一个系统,第一次崩溃会将一些信息转储到屏幕上,然后立即将我们踢到命令行。以下代码可以看到这一点:

const thing = 'this';
console.log(thing);
thing = 10;

在这里,我们可以看到我们正在尝试重新分配一个常量,所以 Node.js 将抛出类似以下的错误:

TypeError: Assignment to constant variable. 
 at Object.<anonymous> (C:\Code\Ch5\bad_code.js:3:7) 
 at Module._compile (internal/modules/cjs/loader.js:774:30) 
 at Object.Module._extensions..js (internal/modules/cjs/loader.js:785:10) 
 at Module.load (internal/modules/cjs/loader.js:641:32) 
 at Function.Module._load (internal/modules/cjs/loader.js:556:12) 
 at Function.Module.runMain (internal/modules/cjs/loader.js:837:10) 
 at internal/main/run_main_module.js:17:11

虽然这可能让人感到害怕,但它也向我们展示了错误的位置。这个堆栈跟踪中的第一行告诉我们它在第 3 行第 7 个字符

堆栈跟踪是系统向开发人员提供有关调用了什么函数的信息的一种方式。在我们的情况下,Object.<anonymous>Module.__compile调用,依此类推。当堆栈的大部分是我们自己的时候,错误实际上发生在更远的地方时,这可能有所帮助。

有了这些信息,我们知道如何纠正问题,但如果我们想在特定语句或特定行上中断怎么办?这就是检查员系统发挥作用的地方。在这里,我们可以利用类似于我们在代码的 Web 版本中看到的语句。如果我们在代码的中间插入一个调试语句,我们的命令行将在那一点停止。

让我们创建一些基本的代码来展示这一点。以下代码应该足够展示检查员的使用:

const fun = function() {
    const item = 10;
    for(let i = 0; i < item; i++) {
        const tempObj = {};
        tempObj[i] = "what " + i;
    }
    return function() {
        console.log('we will have access to other things');
        const alternative = 'what';
        debugger;
        return item;
    }
}

console.log('this is some code');
const x = 'what';
debugger;
fun()();

这段代码将允许我们玩弄检查员的各个部分。如果我们运行npm inspect bad_code.js命令,我们应该会在对fun的调用上中断。我们会看到一个终端界面,指出我们处于调试模式。现在我们在这里停止执行,我们可以设置一个监视器。这允许我们捕获各种变量和表达式,并查看它们在下一个中断时的结果。在这里,我们通过在调试器中执行watch('x')来设置一个监视器,监视x变量。从这里,如果我们输入next,我们将移动到下一行。如果我们这样做几次,我们会注意到一旦我们通过变量的赋值,监视器将把x变量从未定义变为 10。

当我们需要调试一个在相当多的对象之间共享状态的有状态系统时,这可能特别有帮助。当我们试图查看我们可以访问的内容时,这也可能有所帮助。让我们设置几个更多的监视器,以便在下一个调试语句被触发时看到它们的值。在以下变量上设置监视器:itemtempObjalternative。现在,输入cont。这将把我们移动到下一个调试器语句。让我们看看我们的监视器打印出了什么。当我们移动到下一个点时,我们会看到tempObjx未定义,但我们可以访问itemalternative

这是我们所期望的,因为我们被限定在外部fun函数内部。我们可以用这个版本的检查员做更多事情,但我们也可以连接到我们习惯的检查员。

现在,如果我们使用以下命令运行我们的代码,我们将能够将调试工具附加到我们的脚本上:

 > node --inspect bad_code.js 

有了这个,我们将得到一个我们可以连接的地址。让我们这样做。我们还需要有一些长时间运行的代码;否则,脚本将退出,我们将没有任何东西可以监听。让我们回到named_pipe.js示例。运行node --inspect -–experimental-modules named_pipe.js

我们应该得到类似以下的东西:

Debugger listening on ws://127.0.0.1:9229/6abd394d-d5e0-4bba-8b28-69069d2cb800

如果我们在 Chrome 浏览器中输入以下地址,我们应该会看到一个熟悉的界面:

chrome-devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=<url>

现在,我们可以在 Node.js 代码中使用 Chrome 的检查器的全部功能。在这里,我们可以看到,如果我们用named_pipe_child.js文件连接到我们的命名管道服务器,我们将在调试器中看到控制台日志。现在,如果我们添加调试器语句,我们应该能够在检查器中得到断点。如果我们在套接字连接到我们时添加一个调试语句,当我们用子套接字连接时,我们将能够以与在浏览器中一样的方式运行我们的代码!这是调试和逐步执行我们的代码的好方法。

我们还可以进行内存分析。如果我们转到内存选项卡并创建堆快照,我们将得到一个漂亮的内存转储。它应该看起来非常熟悉,就像我们已经看到的那样。

有了这些知识,我们可以进入围绕 Node.js 的更复杂的主题。

总结

随着 Node.js 的出现,我们能够使用一种编程语言,可以在客户端和服务器上都使用。虽然 Node.js 给我们的 API 可能看起来不太熟悉,但我们可以用它们创建强大的服务器应用程序。

在本章中,我们介绍了流的基础知识以及一些允许我们创建强大服务器应用程序的 API。我们还看了一些工具,可以让我们在有 GUI 和没有 GUI 的情况下进行调试。

有了这些知识,下一章中,我们将更深入地探讨我们可以使用的机制,以在线程和进程之间传递数据。

第六章:消息传递 - 了解不同类型

在上一章中,我们看了 Node.js 和我们需要创建服务器端应用程序的基本环境。现在,我们将看看如何利用我们之前学习的通信技术来编写可扩展的系统。消息传递是应用程序解耦但仍然能够共同工作的一种很好的方式。这意味着我们可以创建相互独立工作的模块,无论是通过进程还是线程,仍然可以实现共同的目标。

在本章中,我们将涵盖以下主题:

  • 使用 net 模块进行本地通信

  • 利用网络

  • 快速浏览 HTTP/3

我们还将在查看正在开发的 HTTP/3 标准时,了解客户端/服务器通信的未来。然后,我们将查看 QUIC 协议的实现,这是由 Google 开发的协议,HTTP/3 从中汲取了一些想法。

让我们开始吧!

技术要求

对于本章,您将需要以下技术要求:

使用 net 模块进行本地通信

虽然许多应用程序可以在单个线程上运行并利用事件循环来运行,但当我们编写服务器应用程序时,我们将希望尽量利用我们可用的所有核心。我们可以通过使用进程线程来实现这一点。在大多数情况下,我们将希望使用线程,因为它们更轻量级且启动速度更快。

我们可以根据我们是否需要在主系统死机后仍然运行子系统来确定我们是否需要进程还是线程。如果我们不在乎,我们应该利用线程,但如果我们需要在主进程死机后仍然运行该子系统,我们应该利用一个解耦的进程。这只是考虑何时使用进程或线程的一种方式,但它是一个很好的指标。

在浏览器和 Node.js 中,我们有 Web Workers 来代替传统系统中的线程。虽然它们与其他语言的线程有许多相同的概念,但它们无法共享状态(在我们的情况下,这是首选)。

有一种方法可以在 worker 之间共享状态。这可以通过SharedArrayBuffer来实现。虽然我们可以利用这一点来共享状态,但我们要强调事件系统和 IPC 几乎总是足够快,可以在不同的部分之间移动状态和协调。此外,我们不必处理锁等概念。

要启动一个 worker,我们需要调用new Worker(<script here>)。让我们来看看这个概念:

  1. 创建一个名为Main_Worker.js的文件,并将以下代码添加到其中:
import { Worker } from 'worker_threads';

const data = {what: 'this', huh: 'yeah'};
const worker = new Worker('./worker.js');
worker.postMessage(data);
worker.on('message', (data) => {
    worker.terminate();
});
worker.on('exit', (code) => {
    console.log('our worker stopped with the following code: ', 
     code);
});
  1. 创建一个名为worker.js的文件,并将以下代码添加到其中:
import { parentPort } from 'worker_threads'

parentPort.on('message', (msg) => {
    console.log('we received a message from our parent: ', msg);
    parentPort.postMessage({RECEIVED: true});
});

正如我们所看到的,这个系统与浏览器中的系统类似。首先,我们从worker_threads模块中导入 worker。然后,我们启动它。线程将启动,这意味着我们可以向其发送消息并监听事件,就像我们在上一章中能够与进程一样。

worker.js文件中,我们从worker_threads模块中导入parentPort消息通道。我们监听并传递消息的方式与父级相同。一旦我们收到消息,我们就会声明我们收到了消息。然后父级终止我们,我们打印出我们已经被终止。

现在,如果我们想要紧密耦合所有子系统,这种形式的消息传递是完全可以的。但是,如果我们希望不同的线程有不同的工作,该怎么办?我们可以有一个只为我们缓存数据的线程。另一个可能为我们发出请求。最后,我们的主线程(起始进程)可以移动所有这些数据并从命令行中接收数据。

要做到所有这些,我们可以简单地使用内置系统。或者,我们可以利用我们在上一章中看到的机制。这不仅使我们拥有高度可扩展的系统,还允许我们将这些各个子系统从线程转换为进程,如果需要的话。这也允许我们在需要时用另一种语言编写这些单独的子系统。现在让我们来看一下:

  1. 让我们继续制作这个系统。我们将创建四个文件:main.jscache.jssend.jspackage.json。我们的package.json文件应该看起来像这样:
{
    "name" : "Chapter6_Local",
    "version" : "0.0.1",
    "type" : "module",
    "main" : "main.js"
}
  1. 接下来,将以下代码添加到cache.js文件中:
import net from 'net';
import pipeName from './helper.js';

let count = 0;
let cacheTable = new Map();
// these correspond to !!!BEGIN!!!, !!!END!!!, !!!GET!!!, and 
// !!!DELETE!!! respectively
const begin, end, get, del; //shortened for readability they will use the Buffer.from() methods
let currData = [];

const socket = new net.Socket().connect(pipeName());
socket.on('data', (data) => {
    if( data.toString('utf8') === 'WHOIS' ) {
        return socket.write('cache');
    }
    if( data.includes(get) ) {
        const loc = parseInt(data.slice(get.byteLength).toString('utf8'));
        const d = cacheTable.get(loc);
        if( typeof d !== 'undefined' ) {
            socket.write(begin.toString('utf8') + d + 
             end.toString('utf8'));
        }
    }
    if( data.includes(del) ) {
        if( data.byteLength === del.byteLength ) {
            cacheTable.clear();
        } else {
            const loc = parseInt(data.slice(del.byteLength).toString('utf8'));
            cacheTable.delete(loc);
        }
    }
    if( data.includes(begin) ) {
        currData.push(data.slice(begin.byteLength).toString('utf8'));
    }
    if( currData.length ) {
        currData.push(data.toString('utf8'));
    }
    if( data.includes(end) ) {
        currData.push(data.slice(0, data.byteLength - 
         end.byteLength).toString('utf8'));
        cacheTable.set(count, currData.join(''));
        currData = [];
    }
});

这绝对不是处理流数据的万无一失的机制。!!!BEGIN!!!和其他命令消息可能会被分块,我们永远看不到它们。虽然我们保持简单,但要记住,生产级别的流处理需要处理所有这些类型的问题。

cache子模块检查消息上的不同标头。根据每种类型,我们将执行该类型的操作。这可以被视为一种简单的远程过程调用。以下列表描述了我们根据每个事件所做的操作:

  • !!!BEGIN!!!:我们需要开始监听线路上的更多数据,因为这意味着我们将存储数据。

  • !!!END!!!:一旦我们看到这条消息,我们就可以将所有这些数据放在一起并根据缓存中的计数存储它。

  • !!!GET!!!:我们将尝试获取由服务器提供给我们的编号位置存储的文件。

  • !!!DELETE!!!:如果消息的长度与此字符串一样长,这意味着我们想要从缓存中删除所有内容。否则,我们将尝试删除稍后在消息中指定的位置的数据。

  1. 将以下代码添加到send.js文件中:
import net from 'net'
import https from 'https'
import pipeName from './helpers.js'

const socket = new net.Socket().connect(pipeName());
socket.on('data', (data) => {
    if( data.toString('utf8') === 'WHOIS' ) {
        return socket.write('send');
    }
    const all = [];
    https.get(data.toString('utf8'), (res) => {
        res.on('data', (data) => {
            all.push(data.toString('utf8'));
        });
        res.on('end', () => {
            socket.write('!!!BEGIN!!!');
            socket.write(all.join(''));
            socket.write('!!!END!!!');
        });
    }).on('error', (err) => {
        socket.write('!!!FALSE!!!');
    });
    console.log('we received data from the main application',  
     data.toString('utf8'));
});

对于我们拥有的每个子模块,我们处理可能通过网络传输的特定命令。正如send子模块所示,我们处理除了WHOIS命令之外的任何网络传输,该命令告诉主应用程序谁连接到它。我们尝试从指定的地址获取文件并将其写回主应用程序,以便将其存储在缓存中。

我们还添加了我们自己的协议来发送数据。虽然这个系统并非万无一失,我们应该添加某种类型的锁定(比如一个布尔值,这样我们在完全发送当前数据之前不会尝试接收更多数据),但它展示了我们如何在系统中发送数据。在第七章中,流-理解流和非阻塞 I/O,我们将看到一个类似的概念,但我们将利用流,这样我们就不会在每个线程中使用太多内存。

正如我们所看到的,我们只导入了https模块。这意味着我们只能向通过 HTTPS 提供的地址发出请求。如果我们想要支持 HTTP,我们将不得不导入http模块,然后检查用户输入的地址。在我们的情况下,我们尽可能地简化了它。

当我们想要发送数据时,我们发送!!!BEGIN!!!消息,以让接收方知道我们将发送无法适应单个帧的数据。然后,我们用!!!END!!!消息结束我们的消息。

如果我们无法读取我们尝试抓取的端点或者我们的连接超时(这两种情况都会进入错误条件),我们将发送!!!FALSE!!!消息,以让接收方知道我们无法完全传输数据。

在几乎所有数据传输系统中,都使用了将我们的数据包装在中的概念。没有帧,我们将不得不发送一个标头,说明数据传输的大小。然而,这意味着我们需要在发送之前知道内容的大小。帧给了我们选择不发送消息的长度的选项,因此我们可以处理无限大的消息。

在任何地方都会对数据进行包装或装箱。例如,如果我们看一下如何创建数据包,这个概念仍然适用。理解这个概念是理解通信堆栈的较低层次的关键。另一个需要了解的概念是,并非所有数据都一次性发送。它是分批发送的。一次可以发送的数量通常在操作系统级别设置。我们可以设置的唯一属性之一是流的highWaterMark属性。该属性允许我们说出在停止读取/写入之前我们将在内存中保存多少数据。

缓存应用程序类似于发送子模块,只是它响应更多的命令。如果我们收到一个get命令,我们可以尝试从缓存中获取该项并将其发送回主模块;否则,我们只是发送回null。如果我们收到一个delete命令,如果没有其他参数,我们将删除整个缓存;否则,我们将删除特定位置的项目。最后,如果我们收到开始或结束包装,我们将处理数据并将其缓存。

目前,我们的缓存是无限增加的。我们可以很容易地添加一个允许数据在缓存中停留的一定时间阈值(生存时间TTL),或者只保留一定数量的记录,通常通过利用最近最少使用LRU)销毁系统。我们将看看如何在第九章中实现缓存策略,实际示例 - 构建静态服务器。只需注意,这些概念在缓存和缓存策略中是非常普遍的。

回到代码中,创建main.js并添加以下代码:

  1. 为我们的状态变量创建占位符。这些对应于我们的消息可能处于的各种状态以及通过套接字传递的数据:
// import required modules and methods
const table = new Map();
let currData = [];
// These three items correspond to the buffers for: !!!FALSE!!!, 
// !!!BEGIN!!!, and !!!END!!! respectively
const failure, begin, end;
const methodTable = new WeakMap();
  1. 创建处理通过我们的缓存传入的数据的方法:

const cacheHandler = function(data) {
    if( data.includes(begin) || currData.length ) {
        currData.push(data.toString('utf8'));
    }
    if( data.includes(end) ) {
        currData.push(data.toString('utf8'));
        const final = currData.join('');
        console.log(final.substring(begin.byteLength, 
         final.byteLength - end.byteLength));
        currData = [];
    }
}
  1. 接下来,添加一个方法来处理我们的send工作进程发送的消息:

const sendHandler = function(data) {
    if( data.equals(failure) ) { //failure }
    if( data.includes(begin) ) { 
     currData.push(data.toString('utf8')); }
    if( currData.length ) { currData.push(data.toString('utf8')); }
    if( data.includes(end) ) { 
        table.get('cache').write(currData.join(''));
        currData = [];
    }
}
  1. 创建两个最终的辅助方法。这些方法将测试我们拥有的工作进程数量,以便知道何时准备就绪,另一个将向每个工作进程套接字添加方法处理程序:

const testConnections = function() {
    return table.size === 2;
}
const setupHandler = function() {
    table.forEach((value, key) => {
        value.on('data', methodTable.get(value.bind(value));
    });
}
  1. 最终的大型方法将处理我们通过命令行接收到的所有消息:
const startCLIMode = function() {
    process.stdin.on('data', function(data) {
        const d = data.toString('utf8');
        const instruction = d.trim().split(/\s/ig);
        switch(instruction[0]) {
            case 'delete': {
                table.get('cache').write(`!!!DELETE!!!${instruction[1] || ''}`);
                break; }
            case 'get': {
                if( typeof instruction[1] === 'undefined' ) {
                    return console.log('get needs a number 
                     associated with it!');
                }
                table.get('cache').write(`!!!GET!!!${instruction[1]}`);
                break; }
            case 'grab': {
                table.get('send').write(instruction[1]);
                break; }
            case 'stop': {
                table.forEach((value, key) => value.end());
                process.exit();
                break; }
    }});
}
  1. 最后,创建服务器并启动工作进程:
const server = net.createServer().listen(pipeName());
server.on('connection', (socket) => {
    socket.once('data', (data) => {
        const type = data.toString('utf8');
        table.set(type, socket);
        if( testConnections() ) {
            setupHandlers();
            startCLIMode();
        }
    });
    socket.once('close', () => {
        table.delete(type);
    });
    socket.write('WHOIS');
});

const cache = new Worker('./cache.js');
const send = new Worker('./send.js');

为了缩短本书中的代码量,主文件的某些部分已被删除。完整的示例可以在本书的 GitHub 存储库中找到。

在这里,我们有一堆辅助程序,将处理来自缓存和发送子系统的消息。我们还将套接字映射到我们的处理程序。使用WeakMap的好处是,如果这些子系统崩溃或以某种方式被移除,我们就不需要清理。我们还将子系统的名称映射到套接字,以便我们可以轻松地向正确的子系统发送消息。最后,我们创建一个服务器并处理传入的连接。在我们的情况下,我们只想检查两个子系统。一旦我们看到两个,我们就启动我们的程序。

我们包装消息的方式存在一些缺陷,测试连接数量以查看我们是否准备就绪也不是处理程序的最佳方式。然而,这确实使我们能够创建一个相当复杂的应用程序,以便我们可以快速测试这里所见的想法。有了这个应用程序,我们现在能够从远程资源缓存各种文件,并在需要时获取它们。这是一种类似于某些静态服务器工作方式的系统。

通过查看前面的应用程序,很容易看出我们可以利用本地连接来创建一个只使用核心 Node.js 系统的消息传递系统。同样有趣的是,我们可以将listen方法的参数从管道名称替换为端口号,这样我们就可以将这个应用程序从使用命名管道/Unix 域套接字转换为使用 TCP 套接字。

在 Node.js 中有这些工作线程之前,我们必须用进程将所有东西分开。起初,我们只有 fork 系统。当我们开始创建更多的进程时,这使得一些系统变得非常复杂。为了帮助我们理解这个概念,创建了cluster模块。使用cluster模块,更容易管理主/从架构中的进程。

了解 cluster 模块

虽然cluster模块可能不像过去那样经常使用,因为我们在 Node.js 中有工作线程,但仍有一个概念使其强大。我们能够在应用程序中的各个工作线程之间共享服务器连接。我们的主进程将使用一种策略,以便我们只向其中一个从进程发送请求。这使我们能够处理许多同时运行在完全相同的地址和端口上的连接。

有了这个概念,让我们利用cluster模块来实现前面的程序。现在,我们将确保发送和缓存子系统与主进程绑定。我们的子进程将负责处理通过我们的服务器传入的请求。要记住的一件事是,如果父进程死亡,我们的子进程也会死亡。如果我们不希望出现这种行为,在我们的主进程内调用 fork 时,我们可以传递detached : true选项。这将允许工作线程继续运行。这通常不是我们在使用cluster模块时想要的行为,但知道它是可用的是很好的。

我们已将以下程序分成更易管理的块。要查看完整的程序,请转到本章的代码存储库。

现在,我们应该能够编写一个类似于我们的 IPC 程序的程序。让我们来看一下:

  1. 首先,我们将导入在cluster模式下实现我们之前示例所需的所有 Node 模块:
import cluster from 'cluster';
import https from 'https';
import http from 'http';
import { URL } from 'url';
  1. 接下来,我们设置可以在各个进程中使用的常量:
const numWorkers = 2;
const CACHE = 0;
const SEND = 1;
const server = '127.0.0.1';
const port = 3000;
  1. 然后,我们添加一个if/else检查,以查看我们是主进程还是从进程。同一文件用于两种类型的进程,因此我们需要一种区分两者的方法:
if( cluster.isMaster ) {
    // do master work
} else {
    // handle incoming connections
}
  1. 现在,编写主代码。这将进入if/else语句的第一个块中。我们的主节点需要启动从节点,并初始化我们的缓存:
let count = 1; //where our current record is at. We start at 1
const cache = new Map();
for(let i = 0; i < numWorkers; i++ ) {
    const worker = cluster.fork();
    worker.on('message', (msg) => {
        // handle incoming cache request
    });
}
  1. 添加一些代码来处理每个请求,就像我们在之前的例子中所做的那样。记住,如果我们停止主进程,它将销毁所有从进程。如果我们收到STOP命令,我们将只杀死主进程:
// inside of the worker message handler
switch(msg.cmd) {
    case 'STOP': {
        process.exit();
        break;
    }
    case 'DELETE': {
        if( msg.opt != 'all' ) {
            cache.delete(parseInt(msg.opt);
        } else {
            cache.clear();
        }
        worker.send({cmd : 'GOOD' });
        break;
    }
    case 'GET': {
        worker.send(cache.get(parseInt(msg.opt));
        break;
    }
    case 'GRAB': {
        // grab the information
        break;
    }
}
  1. 编写GRAB case 语句。为此,利用https模块请求资源:
// inside the GRAB case statement
const buf = [];
https.get(msg.opt, (res) => {
    res.on('data', (data) => {
        buf.push(data.toString('utf8'));
    });
    res.on('end', () => {
        const final = buf.join('');
        cache.set(count, final);
        count += 1;
        worker.send({cmd : 'GOOD' });
    });
});

现在,我们将编写从节点代码。所有这些将保存在else块中。记住我们可以在从节点之间共享相同的服务器位置和端口。我们还将通过传递给我们的 URL 的搜索参数来处理所有传入的请求。这就是为什么我们从url模块导入了URL类。让我们开始吧:

  1. 通过启动一个HTTP服务器来启动从节点代码。记住它们将共享相同的位置和端口:
// inside of the else block
http.Server((req, res) => {
    const search = new URL(`${location}${req.url}`).searchParams;
    const command = search.get('command');
    const params = search.get('options');
    // handle the command
    handleCommand(res, command, params);
}).listen(port);
  1. 现在,我们可以处理传递给我们的命令。这将类似于我们之前的例子,只是我们将通过进程间通信IPC)与主进程交谈,并通过 HTTP/2 服务器处理请求。这里只显示了get命令;其余内容可以在本章的 GitHub 存储库中找到:
const handleCommand = function(res, command, params=null) {
    switch(command) {
        case 'get': {
            process.send({cmd: 'GET', opt : params});
            process.once('message', (msg) => {
                res.writeHead(200, { 'Content-Type' : 'text/plain' });
                res.end(msg);
            });
            break;
        }
    }
}

在这里,我们可以看到两个工作进程都创建了一个HTTP服务器。虽然它们都创建了独立的对象,但它们共享底层端口。这对我们来说完全是隐藏的,但这是通过cluster模块完成的。如果我们尝试使用自己的版本来做类似的事情,同时使用child_process的 fork 方法,我们将会收到一个错误,指出EADDRINUSE

如果我们请求以 HTML 格式存储的数据,我们将看到它以纯文本形式返回。这涉及到writeHead方法。我们告诉浏览器我们正在写text/plain。浏览器接收这些信息并利用它来查看它需要如何解析数据。由于它被告知数据是纯文本,它将只是在屏幕上显示它。如果我们在获取 HTML 数据时将其更改为text/html,它将解析并尝试呈现它。

通过这两种方法,我们能够编写能够充分利用系统上所有核心的程序,同时仍然能够协同工作。第一种架构为我们提供了一个良好的解耦系统,是大多数应用程序应该编写的方式,但cluster模块为我们提供了一个处理服务器的好方法。通过结合这两种方法,我们可以创建一个高吞吐量的服务器。在 Node.js 中构建这些客户端/服务器应用程序可能很容易,但也有一些需要注意的事项。

新开发人员常见的陷阱

在使用 Unix 域套接字/Windows 命名管道时,这两个系统之间存在一些差异。Node.js 试图隐藏这些细节,以便我们可以专注于我们想要编写的应用程序,但它们仍然会显现出来。新开发人员可能会遇到的两个最常见的问题是:

  • Windows 命名管道在应用程序退出时会自动销毁。Unix 域套接字则不会。这意味着当我们退出应用程序时,我们应该尝试使用fs模块,并通过unlinkunlinkSync方法取消链接文件。我们还应该在启动时检查它是否存在,以防我们不能正常退出。

  • Windows 的数据帧可能比 Unix 域套接字大。这意味着一个应用程序在 Windows 上可能正常工作,但在 Unix 系统上会失败。这就是我们创建我们所做的数据帧系统的原因。特别是当我们可能想要使用外部库来处理构建 IPC 系统的部分时,要牢记这一点是很重要的,因为一些系统并没有考虑到这一点,因此可能会很容易出现错误。

Node.js 的目标是完全跨操作系统兼容,但这些系统在实际跨系统操作时总是有一些小问题。如果我们想要确保它能够正常工作,就像我们必须在不能保证我们的最终用户将使用什么浏览器一样,那么我们需要在所有系统上进行测试。

虽然开发跨越单台计算机的服务器应用程序很常见,但我们仍然需要连接所有这些应用程序。当我们不能再使用单台计算机时,我们需要通过网络进行通信。接下来我们将看看这些协议。

利用网络

构建能够在同一台机器上相互通信的应用程序可能很酷,但最终我们需要与外部系统进行通信。在我们的情况下,大多数这些系统将是浏览器,但它们也可能是其他服务器。由于我们无法通过这些通道使用命名管道/Unix 域套接字,我们需要使用各种网络协议。

从技术上讲,我们仍然可以通过使用共享驱动器/文件系统共享来跨服务器使用前面两个概念,但这不是一个好主意。我们已经表明我们可以将listen方法从指向文件更改为指向端口。在最坏的情况下,我们可以使用共享文件系统,但这远非最佳选择,应该转换为使用我们将在这里介绍的协议之一。

我们将重点关注两种低级协议,即传输控制协议(TCP)和用户数据报协议(UDP)。我们还将研究网络的高级协议:超文本传输协议版本 2(HTTP/2)。通过这些协议,我们将能够创建高度可用的应用程序,可以通过网络访问。

TCP/UDP

TCP 和 UDP 是 Node.js 中我们可以访问的两种低级网络协议。这两种协议都允许我们发送和接收消息,但它们在一些关键领域有所不同。首先,TCP 需要连接的接收方和发送方。因此,我们不能只在一个通道上广播,而不关心是否有人在听。

其次,除了 TCP 需要握手过程外,它还为我们提供了可靠的传输。这意味着我们知道当我们发送数据时,它应该到达另一端(显然,这也有失败的可能,但我们不打算讨论这个)。最后,TCP 保证了传递的顺序。如果我们在一个通道上向接收方发送数据,它将按照我们发送的顺序接收数据。因为这些原因,当我们需要保证传递和顺序时,我们使用 TCP。

实际上,TCP 并不一定需要按顺序发送数据。所有数据都是以数据包的形式发送的。它们实际上可以发送到不同的服务器,路由逻辑可能意味着后续数据包会比后来的更早到达接收方。然而,我们接收方的网络卡会为我们重新排序它们,使得看起来我们是按顺序接收它们的。TCP 还有许多其他很酷的方面,包括数据的传输,这些都超出了本书的范围,但任何人都可以查阅网络并了解更多这些概念以及它们是如何实现的。

话虽如此,TCP 似乎是我们总是想要使用的东西。为什么我们不使用能够保证传递的东西呢?此外,如果我们可以遍历所有当前的连接并将数据发送给每个人,我们就不需要广播。然而,由于所有这些保证,这使得 TCP 更加沉重和缓慢。这对于我们需要尽快发送数据的系统来说并不好。对于这种类型的数据传输,我们可以利用 UDP。UDP 给我们提供了一种称为无状态传输的东西。无状态传输意味着我们可以在一个通道上发送数据,它会将数据发送出去然后忘记。我们不需要连接到一个地址;相反,我们可以直接发送数据(只要没有其他人绑定到该地址和端口)。我们甚至可以建立一个多播系统,任何人都可以收听该地址,它可能会接收到数据。

这种类型的传输希望/需要的一些领域如下:

  • 发送股票交易的买卖订单。由于数据传输速度很快,我们只关心最新的信息。因此,如果我们没有收到一些买卖订单,也并不重要。

  • 视频游戏中的玩家位置数据。我们只能以有限的速度更新游戏。如果我们已经知道玩家移动的方向和速度,我们还可以插值或推断玩家在屏幕上的位置。因此,我们可以以任何速率接收玩家位置,并计算出他们应该在哪里(这有时被称为服务器的 tick 率)。

  • 电信数据并不一定在乎我们发送的所有数据,只要我们发送了大部分数据即可。我们不需要保证完整视频/音频信号的传递,因为我们仍然可以用大部分数据获得很好的画面。

这只是 UDP 发挥作用的一些领域。通过对这两种系统的理解,我们将通过构建一个高度简化和不切实际的股票应用程序来研究它们。行为将如下所示:

  1. 服务器将发布新的股票代码和可用股票数量。然后,它将在已知端口上通过 UDP 向所有人广播信息。

  2. 服务器将存储与客户持仓相关的所有信息。这样,客户端就无法操纵他们可能拥有的股票数量。

  3. 客户端将向服务器发送买入或卖出订单。服务器将确定它是否能处理该请求。所有这些流量都将通过 TCP 进行,因为我们需要确保知道服务器收到了我们的消息。

  4. 服务器将以错误或成功的消息作出回应,告诉客户端他们的订单已更新。

  5. 服务器将通过 UDP 通道广播股票的买入或卖出发生了。

这个应用程序看起来如下:

import dgram from 'dgram';
import { Socket } from 'net';
const multicastAddress = '239.192.0.0';
const sendMessageBadOutput = 'message needs to be formatted as follows: BUY|SELL <SYMBOL> <NUMBER>';
const recvClient = dgram.createSocket({type : 'udp4', reuseAddr: true }); //1.
const sendClient = new Socket().connect(3000, "127.0.0.1");
// receiving client code seen below
process.stdin.setEncoding('utf8');
process.stdin.on('data', (msg) => {
    const input = msg.split(' ');
    if( input.length !== 3 ) {
        console.log(sendMessageBadOutput);
        return;
    }
    const num = parseInt(input[2]);
    if( num.toString() === 'NaN' ) {
        console.log(sendMessageBadOutput);
        return;
    }
    sendClient.write(msg);
});
sendClient.on('data', (data) => {
    console.log(data.toString('utf8'));
});

前面的大部分程序应该是熟悉的,除了我们正在使用的新模块:dgram模块。这个模块允许我们在使用 UDP 时发送数据。

在这里,我们创建了一个使用 UDP4(IPv4 上的 UDP,或者我们通常知道的 IP 地址)的套接字。我们还声明我们正在重用地址和端口。我们这样做是为了在本地测试。在其他情况下我们不希望这样做:

recvClient.on('connect', () => {
    console.log('client is connected to the server');
});
recvClient.on('message', (msg) => {
    console.log('client received message', msg.toString('utf8'));
});
recvClient.bind(3000, () => {
    recvClient.addMembership(multicastAddress);
});

我们绑定到端口3000,因为服务器将在那里发送数据。然后,我们声明我们要将自己添加到多播地址。为了使多播工作,服务器需要通过多播地址发送数据。这些地址通常是操作系统设置的特定地址。每个操作系统都可以决定使用哪些地址,但我们选择的地址应该在任何操作系统上都可以使用。

一旦我们收到消息,我们就打印出来。再次,这应该看起来很熟悉。Node.js 是基于事件和流的,它们通常以相同的名称命名以保持一致性。

这个程序的其他部分处理用户输入,然后通过我们创建新套接字时打开的 TCP 通道发送数据(这应该类似于我们之前的 IPC 程序,只是我们传递了一个端口和一个 IP 地址)。

这个应用程序的服务器涉及的内容更多,因为它包含了股票应用程序的所有逻辑。我们将把这个过程分解为几个步骤:

  1. 创建一个名为main.js的文件,并将dgramnet模块导入其中:
import dgram from 'dgram';
import net from 'net';
  1. 为我们的多播地址、错误消息和股票代码和客户端的Maps添加一些常量:
const multicastAddress = '239.192.0.0';
const badListingNumMessage = 'to list a new ticker the following format needs to be followed <SYMBOL>
<NUMBER>';
const symbolTable = new Map();
const clientTable = new Map();
  1. 接下来,我们创建两个服务器。第一个用于监听 UDP 消息,而第二个用于接收 TCP 消息。我们将利用 TCP 服务器来处理客户端请求。TCP 是可靠的,而 UDP 不是:
const server = dgram.createSocket({type : 'udp4', reuseAddr : true}).bind(3000);
const recvServer = net.createServer().listen(3000, '127.0.0.1');
  1. 然后,我们需要在 TCP 服务器上设置一个监听器以接受任何连接。一旦有客户端连接,我们将为他们设置一个临时表,以便我们可以存储他们的投资组合:
recvServer.on('connection', (socket) => {
    const temp = new Map();
    clientTable.set(socket, temp);
});
  1. 现在,为客户端设置一个数据监听器。当我们收到数据时,我们将根据以下格式解析消息,SELL/BUY <Ticker> <Number>
// inside of the connection callback for recvServer
socket.on('data', (msg) => {
    const input = msg.toString('utf8').split(' ');
    const buyOrSell = input[0];
    const tickerSymbol = input[1];
    const num = parseInt(input[2]);
});
  1. 根据这个解析,我们检查客户端是否能执行这个操作。如果可以,我们将更改他们的投资组合,并发送一条消息告诉他们更改成功了:
// inside the socket 'data' handler
const numHeld = symbolTable.get(input[1]);
if( buyOrSell === "BUY" && (num <= 0 || numHeld - num <= 0) ) {
    socket.write("ERROR!");
    return;
} 
const clientBook = clientTable.get(socket);
const clientAmount = clientBook.get(tickerSymbol);
if( buyOrSell === "SELL" && clientAmount - num < 0 ) {
    socket.write("ERROR!");
    return;
}
if( buyOrSell === "BUY" ) {
    clientBook.set(tickerSymbol, clientAmount + num);
    symbolTable.set(tickerSymbol, numHeld - num);
} else if( buyOrSell === "SELL" ) {
    clientBook.set(tickerSymbol, clientAmount - num);
    symbolTable.set(tickerSymbol, numHeld + num);
}
socket.write(`successfully processed request. You now hold ${clientBook.get(tickerSymbol)}` of ${tickerSymbol}`);
  1. 一旦我们告诉客户端我们已处理他们的请求,我们可以通过 UDP 服务器向所有客户端写入:
// after the socket.write from above
const msg = Buffer.from(`${tickerSymbol} ${symbolTable.get(tickerSymbol)}`);
server.send(msg, 0, msg.byteLength, 3000, multicastAddress);
  1. 最后,我们需要通过标准输入处理来自服务器的新股票代码。一旦我们处理了请求,我们就通过 UDP 服务器发送数据,以便每个客户端都知道新股票的情况。
process.stdin.setEncoding('utf8');
process.stdin.on('data', (data) => {
    const input = data.split(' ');
    const num = parseInt(input[1]);
    symbolTable.set(input[0], num);
    for(const client of clientTable) {
        client[1].set(input[0], 0);
    }

    server.send(Buffer.from(data), 0, data.length, 3000, multicastAddress);
});

为了清晰起见,几乎所有的错误逻辑都已被移除,但你可以在本书的 GitHub 存储库中找到它们。正如前面的例子所示,利用所有接口向其他点发送数据非常简单,无论是我们应用程序的其他部分还是监听数据的远程客户端。它们几乎都使用相同的接口,只在细微的实现细节上有所不同。只需记住,如果需要保证交付,应使用 TCP;否则,UDP 也是一个不错的选择。

接下来,我们将看一下 HTTP/2 标准以及与netdgramhttp/https模块相比,Node.js 中的服务器系统有些不同。

HTTP/2

虽然它是在 2015 年引入的,但技术的采用速度很慢。HTTP/2 建立在 HTTP/1.1 协议的基础上,允许各种功能,这些功能在以前的系统中引起了问题。这使我们能够使用单个 TCP 连接接收不同的请求。这在 HTTP/1.1 中是不可能的,它引起了一个叫做头部阻塞的问题。这意味着我们实际上只能处理那么多的 TCP 连接,如果我们有一个长时间运行的 TCP 连接,它可能会阻塞之后的所有请求。

HTTP/2 还赋予了我们推送服务器端资源的能力。这意味着如果服务器知道浏览器将需要一个资源,比如一个 CSS 文件,它可以在需要之前将其推送到服务器。最后,HTTP/2 赋予了我们内置的流式传输能力。这意味着我们能够使用连接并将数据作为流发送,而不需要一次性发送所有数据。

HTTP/2 还给我们带来了其他好处,但这些是主要的好处。虽然httphttps模块可能还会在未来一段时间内使用,但 Node.js 中的http2模块应该用于任何新的应用程序。

Node.js 中的http2模块与httphttps模块有一些不同之处。虽然它不遵循许多其他 IPC/网络模块给我们的标准,但它确实为我们提供了一些很好的方法来通过 HTTP/2 发送数据。其中一个允许我们直接从文件系统流式传输文件,而不需要为文件创建管道并将其发送给发送方。以下代码中可以看到其中一些差异:

import http2 from 'http2';
import fs from 'fs';
const server = http2.createSecureServer({
    key : fs.readFileSync('server.key.pem'),
    cert : fs.readFileSync('server.crt.pem')
});
server.on('error', (err) => console.error(err));
server.on('stream', (stream, headers) => {
    stream.respond({
        'content-type': 'text/plain',
        ':status' : 200
    });
    stream.end('Hello from Http2 server');
});
server.listen(8081, '127.0.0.1');

首先,注意服务器需要一个私钥和一个公共证书。这些用于确保建立的连接是安全的,这意味着没有人可以看到正在发送的内容。为了能够做到这一点,我们需要一个工具,比如openssl来创建这些密钥和证书。在 Windows 10 和其他 Unix 操作系统中,我们可以免费获得这个工具。否则,我们需要下载 Cygwin(www.cygwin.com/)。使用openssl,我们可以运行以下命令:

> openssl req -x509 -newkey rsa:4096 -keyout server.key.pem -out server.crt.pem -days 365

这个命令生成了服务器和客户端进行安全通信所需的私钥和公共证书。我们不会在这里详细介绍它是如何实现的,但关于如何使用 SSL/TLS 实现这一点的信息可以在这里找到:www.cloudflare.com/learning/ssl/transport-layer-security-tls/

生成了我们的证书和密钥后,我们可以读取它们,以便我们的服务器可以开始运行。我们还会注意到,与响应消息事件或请求事件不同,我们响应流事件。HTTP/2 使用流而不是尝试一次性发送所有数据。虽然 Node.js 为我们封装了流的请求和响应,但这并不是操作系统层面可能处理的方式。HTTP/2 立即使用流。这就是为什么事件被称为流的原因。

接下来,我们不是调用writeHead方法,而是响应流。当我们想要发送信息时,我们利用respond方法并以这种方式发送头部。我们还会注意到一些头部是以冒号为前缀的。这是http2模块特有的,如果在发送特定头部时发现问题,加上冒号可能会解决问题。

除了我们在这里讨论的内容之外,这应该看起来与我们在 Node.js 中编写的普通 HTTP(s)服务器非常相似。然而,http2模块还有一些其他好处,其中之一是响应文件而不是必须读取文件并以这种方式发送。这可以在以下代码中看到:

import http2 from 'http2';
import fs from 'fs';
import path from 'path';

const basePath = process.env.npm_package_config_static; //1.
const supportedTypes = new Set(['.ico', '.html', '.css', '.js']);
const server = http2.createSecureServer({
    key : fs.readFileSync(process.env.npm_package_config_key),
    cert : fs.readFileSync(process.env.npm_package_config_cert),
    allowHTTP1 : true //2.
});
server.on('error', (err) => console.error(err));
server.on('stream', (stream, header) => {
    const fileLoc = header[':path'];
    const extension = path.extname(fileLoc); //3.
    if(!supportedTypes.has(extension)) {
        stream.respond({
            ':status' : 400,
            'content-type' : 'application/json'
        });
        stream.end(JSON.stringify({
            error : 'unsupported data type!',
            extension
        }));
        return;
    }
    stream.respondWithFile( //4.
        path.join(process.cwd(), basePath, fileLoc),
        {
            ':status' : 200,
            'content-type' :
                extension === ".html" ?
                'text/html' :
                extension === ".css" ?
                'text/css' :
                'text/javascript'
        },
        {
            onError : (err) => { //5.
                if( err.code === 'ENOENT') {
                    stream.respond({ ':status' : 404 });
                } else {
                    stream.respond({ ':status' : 500 });
                }
                stream.end();
            }
        }
    )
});
server.listen(80, '127.0.0.1');

程序编号是关键的兴趣点,它们的工作方式如下:

  1. 我们正在从package.json文件中读取信息,就像我们在上一章中所做的那样。我们还通过npm run <script>命令运行这个。查看上一章,了解如何做到这一点,以及我们如何在程序中使用package.json文件中的配置数据。

  2. 我们为服务器设置了特定的配置选项。如果连接到我们的客户端无法使用 HTTP/2,那么我们将自动将一切转换回协商的协议,例如 HTTP/1.1。

  3. 我们从 URL 中获取扩展名。这样,我们可以看到我们是否支持该文件类型,并发送适当的文件;否则,我们将返回一个 400 错误消息,并声明这是一个错误的请求。

  4. 这种方法允许我们传入一个路径。然后,核心系统将帮助我们发送文件。我们所需要做的就是确保正确设置内容类型,以便浏览器可以解释数据。

  5. 如果在任何时候出现错误,比如文件不存在,我们将以正确的状态做出响应,比如 404 或 500 错误。

虽然我们在这里呈现的只是http2模块的一小部分,但这展示了http2模块的不同之处,以及我们如何可以快速设置一个。如果需要,可以参考Node.js.org/dist/latest-v12.x/docs/api/http2.html来了解http2模块与http的不同之处以及它带来的所有功能。现在,我们将看一下网络的未来状态,并了解 Node.js 中的 HTTP/3。

快速浏览 HTTP/3

虽然我们所讨论的是进程、线程和其他计算机之间通信的现状,但有一种新的信息传递方式。新标准称为 HTTP/3,与前两个版本有很大不同。

QUIC 协议

Quick UDP Internet Connections (QUIC)是由 Google 于 2012 年推出的。它是一种类似于 TCP、传输层安全TLS)和 HTTP/2 协议的协议,但它全部通过 UDP 传输。这意味着 TCP 中内置的许多开销已经被移除,并用一种新的发送数据的方法替代。除此之外,由于 TLS 内置到协议中,这意味着在已定义的协议中添加安全性的开销已经被移除。

QUIC 目前被 Google 用于诸如 YouTube 之类的事物。虽然 QUIC 从未获得大规模的吸引力,但它帮助产生了将创建 HTTP/3 标准委员会的团体,并帮助指导委员会利用 UDP 作为协议的基础层。它还展示了安全性可以内置到协议中,并已经使 HTTP/3 具备了这一特性。

其他公司已经开始实施 QUIC 协议,而 HTTP/3 正在开发中。这个名单中一个显著的包括 Cloudflare。他们关于实施 QUIC 的博客文章可以在这里找到:blog.cloudflare.com/the-road-to-quic/

虽然 HTTP/3 尚未添加到 Node.js 中,但有一些包实现了 QUIC 协议。

对 node-quic 的一瞥

虽然 QUIC 目前不是最容易使用的,而且唯一的官方实现是在 Chromium 源代码中编写的,但已经有其他实现允许我们玩弄这个协议。node-quic模块已经被弃用,而 QUIC 实现正在尝试直接构建到 Node.js 中,但我们仍然可以使用它来看看我们将来如何利用 QUIC 甚至 HTTP/3。

首先,我们需要通过运行npm install node-quic命令来安装模块。有了这个,我们就能够编写一个简单的客户端-服务器应用程序。客户端应该看起来像下面这样:

import quic from 'node-quic'

const port = 3000;
const address = '127.0.0.1';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (data) => {
    quic.send(port, address, data.trim())
        .onData((data) => {
            console.log('we received the following back: ', data);
        });
});

我们会注意到,发送数据类似于我们在 UDP 系统中所做的方式;也就是说,我们可以发送数据而不需要绑定到端口和地址。除此之外,该系统运行方式类似于使用httphttp2模块编写的其他应用程序。这里值得注意的一件事是,当我们从quic流中接收数据时,数据会自动转换为字符串。

上一个客户端的服务器将如下所示:

import quic from 'node-quic'

const port = 3000;
const address = '127.0.0.1';
quic.listen(port, address)
    .then(() => {})
    .onError((err) => console.error(err))
    .onData((data, stream, buffer) => {
        console.log('we received data:', data);
        if( data === 'quit' ) {
            console.log('we are going to stop listening for data');
            quic.stopListening();
        } else {
            stream.write("Thank you for the data!");
        }
    });

再次,这应该看起来与我们编写的其他应用程序类似。这里的一个主要区别是,这个模块是针对 promise 编写的。除此之外,我们接收的数据是一个字符串,所以如果我们接收到quit,我们通过运行stopListening方法关闭自己。否则,我们将要发送的数据写入流中,类似于我们在 HTTP/2 协议中所做的。

为了了解 HTTP/3 的实现状态,建议您查看以下链接并定期检查:quicwg.org/

正如我们所看到的,使用这个模块来利用 QUIC 协议是相当简单的。这对内部应用程序也可能很有用。只要注意,QUC 协议和 HTTP/3 标准都还没有完全完成,可能还需要几年的时间。这并不意味着你不应该利用它们,只是意味着在标准不稳定的时候事情可能会发生很快。

摘要

在不同系统之间发送数据,无论是线程、进程,甚至其他计算机,这是我们作为开发人员所做的。我们可以使用许多工具来做到这一点,我们已经看过大部分。只要记住,虽然一个选项可能使应用程序变得简单,但这并不总是意味着它是最好的选择。当我们需要拆分系统时,通常希望将特定的工作分配给一个单元,并使用某种形式的 IPC,比如命名管道,进行通信。如果我们需要将该任务移动到另一台计算机,我们总是可以切换到 TCP。

有了这些 IPC 和 Web 协议的基础,我们将能够轻松解决 Node.js 中的大多数问题,并在涉及 Web 应用程序时编写客户端和服务器端代码。然而,Node.js 并不仅仅是为 Web 应用程序而构建的。我们几乎可以做任何其他语言可以做的事情,甚至拥有大多数其他语言拥有的工具。本章应该有助于澄清这一点,并帮助巩固 Node.js 如何构建到已经开发的应用程序生态系统中。

考虑到所有这些,我们将研究流和如何在 Node.js 中实现我们自己的流。

第七章:流-理解流和非阻塞 I/O

我们已经涉及了几乎所有帮助我们使用 JavaScript 为服务器编写高性能代码的主题。应该讨论的最后两个主题是流和数据格式。虽然这两个主题可以并驾齐驱(因为大多数数据格式是通过读/写流实现的),但我们将在本章中重点关注流。

流使我们能够编写可以处理数据而不占用大量工作内存并且不阻塞事件队列的系统。对于那些一直按顺序阅读本书的人来说,这可能听起来很熟悉,这是正确的。我们将重点关注 Node.js 提供的四种不同类型的流,以及我们可以轻松扩展的流。从那里,我们将看看如何结合流和生成器来处理具有内置生成器概念的数据。

本章涵盖以下主题:

  • 流基础知识

  • 可读流

  • 可写流

  • 双工流

  • 转换流

  • 附注-生成器和流

技术要求

本章的先决条件如下:

开始使用流

流是处理无限数据集的行为。这并不意味着它是无限的,但是意味着我们有可能拥有无限的数据源。如果我们从传统的数据处理上下文来思考,通常会经历三个主要步骤:

  1. 打开/获取对数据源的访问。

  2. 一旦数据源完全加载,就处理数据源。

  3. 将计算出的数据输出到另一个位置。

我们可以将其视为输入和输出I/O)的基础。我们的大多数 I/O 概念涉及批处理或处理所有或几乎所有数据。这意味着我们提前知道数据的限制。我们可以确保我们有足够的内存、存储空间、计算能力等来处理这个过程。一旦我们完成了这个过程,我们就会终止程序或排队下一批数据。

一个简单的例子如下所示,我们计算文件的行数:

import { readFileSync } from 'fs'
const count = readFileSync('./input.txt', {encoding : 'utf8'})
 .split(/\n|\r\n/g).length;
console.log('number of lines in our file is: ', count);

我们从fs模块中引入readFileSync方法,然后读取input.txt文件。从这里开始,我们在\n\r\n上拆分,这给我们一个文件所有行的数组。从那里,我们得到长度并将其放在我们的标准输出通道上。这似乎非常简单,而且似乎运行得很好。对于小到中等长度的文件,这很好用,但是当文件变得异常大时会发生什么呢?让我们继续看下去。前往loremipsum.io并输入 100 段落。将其复制并粘贴几次到input.txt文件中。现在,当我们运行这个程序时,我们可以在任务管理器中看到内存使用量的飙升。

我们将一个大约 3MB 的文件加载到内存中,计算换行符的数量,然后打印出来。这应该仍然非常快,但我们现在开始利用大量内存。让我们用这个文件做一些更复杂的事情。我们将计算文本中单词lorem出现的次数。我们可以使用以下代码来实现:

import { readFileSync } from 'fs'
const file = readFileSync('./input.txt', {encoding : 'utf8'});
const re = /\slorem\s/gi;
const matches = file.match(re);

console.log('the number of matches is: ', matches.length);

同样,这应该处理得很快,但在处理方式上可能会有一些滞后。虽然在这里使用正则表达式可能会给我们一些错误的结果,但它确实展示了我们在这个文件上进行批处理。在许多情况下,当我们在高速环境中工作时,我们处理的文件可能接近或超过 1GB。当我们处理这些类型的文件时,我们不希望将它们全部加载到内存中。这就是流的作用所在。

许多被认为是大数据的系统正在处理几 TB 的数据。虽然有一些内存应用程序会将大量数据存储在内存中,但这种类型的数据处理大部分使用文件流和使用内存数据源来处理数据的混合。

让我们拿第一个例子来说。我们正在从文件中读取,并尝试计算文件中的行数。嗯,与其考虑整个行数,我们可以寻找表示换行的字符。我们在正则表达式中寻找的字符是换行符(\n)或回车加换行(\r\n)字符。有了这个想法,我们应该能够构建一个流应用程序,它可以读取文件并计算行数,而不需要完全将文件加载到内存中。

这个例子介绍了利用流的 API。我们将讨论每个流 API 给我们的东西,以及我们如何利用它来实现我们的目的。现在,拿出代码示例并运行它们,看看这些类型的应用是如何工作的。

这可以在以下代码片段中看到:

import { createReadStream } from 'fs';

const newLine = 0x0A;
const readStream = createReadStream('./input.txt');
let counter = 1;
readStream.on('data', (chunk) => {
    for(const byte of chunk) {
        if( newLine === byte ) counter += 1;
    }
}).on('end', () => {
    console.log('number of line in our file is: ', counter);
});

我们从fs模块中获取一个Readable流并创建一个。我们还为 HEX 格式中表示的换行符创建一个常量。然后,我们监听数据事件,以便在数据到达时处理数据。然后,我们处理每个字节,看它是否与换行符相同。如果是,那么我们有一个换行符,否则我们继续搜索。我们不需要明确寻找回车符,因为我们知道它应该后跟一个换行符。

虽然这比将整个文件加载到内存中要慢,但在处理数据时它确实节省了我们相当多的内存。这种方法的另一个好处是这些都是事件。在我们的完整处理示例中,我们占用整个事件循环,直到处理完成。而使用流,我们有事件来处理数据进来。这意味着我们可以在同一个线程上同时运行多个流,而不必太担心阻塞(只要我们在数据块的处理上不花费太多时间)。

通过前面的例子,我们可以看到如何以流的形式编写反例。为了更好地说明问题,让我们继续做到这一点。它应该看起来像下面这样:

const stream = createReadStream('./input.txt');
const buf = Buffer.from('lorem');
let found = 0;
let count = 0;
stream.on('data', (chunk) => {
    for(const byte of chunk) {
        if( byte === buf[found] ) {
            found += 1;
        } else {
            found = 0;
        }
        if( found === buf.byteLength ) {
            count += 1;
            found = 0;
        }
    }
}).on('end', () => {
    console.log('the number of matches is: ', count)
});

首先,我们创建一个读取stream,就像以前一样。接下来,我们创建一个关键字的Buffer形式,我们正在寻找的关键字(在原始字节上工作可能比尝试将流转换为文本更快,即使 API 允许我们这样做)。接下来,我们维护一个found计数和一个actual计数。found计数将告诉我们是否找到了这个单词;另一个计数跟踪我们找到了多少个lorem实例。接下来,当数据事件上的一个块到来时,我们处理每个字节。如果我们发现下一个字节不是我们要找的字符,我们会自动将found计数返回为0(我们没有找到这个特定的文本字符串)。在这个检查之后,我们将看到我们是否找到了完整的字节长度。如果是,我们可以增加计数并将found移回0。我们将found计数器保留在数据事件之外,因为我们以块接收数据。由于它是分块的,lorem的一部分可能出现在一个块的末尾,而lorem的另一部分可能出现在下一个块的开头。一旦流结束,我们就输出计数。

现在,如果我们运行两个版本,我们会发现第一个实际上捕获了更多的lorem。我们为正则表达式添加了不区分大小写的标志。如果我们通过删除末尾的i来关闭它,并且我们删除字符序列周围的\s,我们将看到我们得到相同的结果。这个例子展示了写流可能比批处理版本更复杂一些,但通常会导致更低的内存使用和更快的代码。

虽然利用内置流(如zlibfs模块中的流)可以让我们走得更远,但我们将看到如何成为我们自己自定义流的生产者。我们将每个流都写成一个扩展流类型,以处理我们在上一章中所做的数据框架。

对于那些忘记或跳到本章的人,我们正在通过套接字对所有消息进行框架处理,使用!!!BEGIN!!!!!!END!!!标记来告诉我们何时将完整数据流式传输给我们。

构建自定义可读流

Readable流确切地做了它所声明的事情,它从流源中读取。它根据某些标准输出数据。我们的例子是对 Node.js 文档中显示的简单示例的一种理解。

我们将以计算文本文件中lorem的数量为例,但我们将输出在文件中找到lorem的位置:

  1. 从各自的模块中导入Readable类和createReadStream方法:
import { Readable } from 'stream'
import { createReadStream } from 'fs'
  1. 创建一个扩展Readable类的类,并设置一些私有变量来跟踪内部状态:
class LoremFinder extends Readable {
    #lorem = Buffer.from('lorem');
    #found = 0;
    #totalCount = 0;
    #startByteLoc = -1;
    #file = null;
}
  1. 添加一个构造函数,将我们的#file变量初始化为Readable流:
// inside our LoremFinder class
constructor(opts) {
    super(opts); 
    if(!opts.stream ) { 
        throw new Error("This stream needs a stream to be 
         provided!");
    }
    this.#file = opts.stream;
    this.#file.on('data', this.#data.bind(this)); // will add #data 
     method next
    this.#file.on('end', () => this.push(null)); 
}
  1. 根据构造函数,我们将利用一个#data私有变量,它将是一个函数。我们将利用它来从我们的#file流中读取,并检查lorem的位置:
// inside of the LoremFinder class
#data = function(chunk) {
    for(let i = 0; i < chunk.byteLength; i++) {
        const byte = chunk[i];
        if( byte === this.#lorem[this.#found] ) {
            if(!this.#found ) {
                this.#startByteLoc = this.#totalCount + i; 
            }
            this.#found += 1;
        } else {
            this.#found = 0;
        }
        if( this.#found === this.#lorem.byteLength ) {
            const buf = Buffer.alloc(4);
            buf.writeUInt32BE(this.#startByteLoc);
            this.push(buf);
            this.#found = 0;
        }
    }
    this.#totalCount += chunk.byteLength;
}

我们遍历每个字节,并检查我们当前是否拥有我们在lorem单词中寻找的字节。如果我们找到了,并且它是单词的l,那么我们设置我们的位置#startByteLoc变量。如果我们找到整个单词,我们输出#startByteLoc,否则,我们重置我们的查找变量并继续循环。一旦我们完成循环,我们将我们读取的字节数添加到我们的#totalCount中,并等待我们的#data函数再次被调用。为了结束我们的流并让其他人知道我们已完全消耗了资源,我们输出一个null值。

  1. 我们添加的最后一部分是_read方法。

这将通过Readable.read方法或通过挂接数据事件来调用。这是我们如何确保原始流(如FileStream)被消耗:

// inside of the LoremFinder class
_read(size) {
    this.#file.resume();
}
  1. 现在我们可以添加一些测试代码来确保这个流正常工作:
const locs = new Set();
const loremFinder = new LoremFinder({
    stream : createReadStream('./input.txt')
});
loremFinder.on('data', (chunk) => {
    const num = chunk.readUInt32BE();
    locs.add(num);
});
loremFinder.on('end', () => {
    console.log('here are all of the locations:');
    for(const val of locs) {
        console.log('location: ', val);
    }
    console.log('number of lorems found is', locs.size);
});

通过所有这些概念,我们可以看到我们如何能够消耗原始流并能够用超集流包装它们。现在我们有了这个流,我们可以随时使用管道接口并将其管道到Writable流中。让我们将索引写入文件。为此,我们可以做一些简单的事情,比如loremFinder.pipe(writeable)

如果我们打开文件,我们会发现它只是一堆随机数据。原因是我们将所有索引编码到 32 位缓冲区中。如果我们想看到它们,我们可以稍微修改我们的流实现。修改可能如下所示:this.push(this.#startByteLoc.toString() + "\r\n");

通过这种修改,我们现在可以查看output.txt文件并查看所有索引。如果我们只是不断地将它们通过各种阶段进行管道传输,代码变得多么可读。

理解可读流接口

Readable流有一些可用的属性。它们都在 Node.js 文档中有解释,但我们感兴趣的主要是highWaterMarkobjectMode

highWaterMark允许我们声明内部缓冲区在流声明无法再接收任何数据之前应该容纳多少数据。我们实现的一个问题是我们没有处理暂停。如果达到了这个highWaterMark,流就会暂停。虽然大多数情况下我们可能不担心这个问题,但它可能会引起问题,通常是流实现者会遇到问题的地方。通过设置更高的highWaterMark,我们可以防止这些问题。另一种处理方法是检查运行this.push的结果。如果返回true,那么我们可以向流写入更多数据,否则,我们应该暂停流,然后在从另一个流得到信号时恢复。流的默认highWaterMark大约为 16 KB。

objectMode 允许我们构建不基于Buffer的流。当我们想要遍历对象列表时,这非常有用。我们可以设置一个管道系统,通过流传递对象并对其执行某种操作,而不是使用for循环或map函数。我们不仅限于普通的对象,而是几乎可以使用除Buffer之外的任何数据类型。关于objectMode的一点需要注意的是它改变了highWaterMark的计数方式。它不再计算存储在内部缓冲区中的数据量,而是计算直到暂停流之前将存储的对象数量。默认值为16,但如果需要,我们可以随时更改它。

有了这两个属性的解释,我们应该讨论一下可用的各种内部方法。对于每种流类型,都有一个我们需要实现的方法和一些我们可以实现的方法。

对于Readable流,我们只需要实现_read方法。这个方法给我们一个size参数,表示从底层数据源中读取的字节数。我们不总是需要遵循这个数字,但如果需要,它是可用的。

除了_read方法,我们需要使用push方法。这是将数据推送到内部缓冲区并帮助发出数据事件的方法,正如我们之前所见。正如我们之前所述,push方法返回一个布尔值。如果这个值为true,我们可以继续使用push,否则,我们应该停止推送数据,直到我们的_read实现再次被调用。

正如之前所述,当首次实现Readable流时,返回值可以被忽略。但是,如果我们注意到数据没有流动或数据丢失,通常的罪魁祸首是push方法返回了false,而我们继续尝试向流中推送数据。一旦发生这种情况,我们应该通过停止使用push方法直到再次调用_read来实现暂停。

可读接口的另外两个部分是_destroy方法以及如何使我们的流在无法处理的情况下出错。如果有任何低级资源需要释放,应该实现_destroy方法。

这可以是使用fs.open命令打开的文件句柄,也可以是使用net模块创建的套接字。如果发生错误,我们也应该使用它来发出错误事件。

为了处理流可能出现的错误,我们应该通过this.emit系统发出错误。如果我们抛出错误,根据文档,可能会导致意外的结果。通过发出错误,我们让流的用户处理错误并根据他们的意愿处理它。

实现可读流

根据我们在这里学到的知识,让我们实现我们之前讨论过的帧系统。从我们之前的示例中,我们应该清楚地知道我们如何处理这个问题。我们将持有底层资源,即套接字。然后,我们将找到!!!BEGIN!!!缓冲区并让其通过。然后我们将开始存储所持有的数据。一旦我们到达!!!END!!!缓冲区,我们将推出数据块。

在这种情况下,我们持有相当多的数据,但它展示了我们如何处理帧。双工流将展示我们如何处理一个简单的协议。示例如下:

  1. 导入Readable流并创建一个名为ReadMessagePassStream的类:
import { Readable } from 'stream';

class ReadMessagePassStream extends Readable {
}
  1. 添加一些私有变量来保存流的内部状态:
// inside of the ReadMessagePassStream class
#socket = null;
#bufBegin = Buffer.from("!!!START!!!");
#bufEnd = Buffer.from("!!!END!!!");
#internalBuffer = [];
#size = 0;
  1. 创建一个像之前那样的#data方法。我们现在将寻找之前设置的开始和结束帧缓冲区#bufBegin#bufEnd
#data = function(chunk) {
    let i = -1 
    if((i = chunk.indexOf(this.#bufBegin)) !== -1) {
        const tempBuf = chunk.slice(i + this.#bufBegin.byteLength);
        this.#size += tempBuf.byteLength;            
        this.#internalBuffer.push(tempBuf);
    }
    else if((i = chunk.indexOf(this.#bufEnd)) !== -1) {
        const tempBuf = chunk.slice(0, i);
        this.#size += tempBuf.byteLength;
        this.#internalBuffer.push(tempBuf);
        const final = Buffer.concat(this.#internalBuffer);            
        this.#internalBuffer = [];
        if(!this.push(final)) { 
            this.#socket.pause();
        }
    } else {
        this.#size += chunk.byteLength;
        this.#internalBuffer.push(chunk);
    }
}
  1. 创建类的构造函数以初始化我们的私有变量:
constructor(options) {
    if( options.objectMode ) {
        options.objectMode = false //we don't want it on
    }
    super(options);
    if(!options.socket ) {
        throw "Need a socket to attach to!"
    }
    this.#socket = options.socket;
    this.#socket.on('data', this.#data.bind(this));
    this.#socket.on('end', () => this.push(null));
}

一个新的信息是objectMode属性,它可以传递到我们的流中。这允许我们的流读取对象而不是原始缓冲区。在我们的情况下,我们不希望发生这种情况;我们希望使用原始数据。

  1. 为确保我们的流将启动,请添加_read方法:
// inside the ReadMessagePassStream
_read(size) {
    this.#socket.resume();
}

有了这段代码,我们现在有了一种处理套接字的方法,而不必在主代码中监听数据事件;它现在包装在这个Readable流中。除此之外,我们现在有了将此流传输到另一个流的能力。以下是测试工具代码:

import { createWriteStream } from 'fs';

const socket = createConnection(3333);
const write = createWriteStream('./output.txt');
const messageStream = new ReadMessagePassStream({ socket });
messageStream.pipe(write);

我们在本地主机的端口3333上托管了一个服务器。我们创建一个write流,并将任何数据从我们的ReadMessagePassStream传输到该文件。如果我们将其连接到测试工具中的服务器,我们会注意到创建了一个输出文件,其中只包含我们发送的数据,而不包含帧代码。

我们正在使用的帧技术并不总是有效。就像在lorem示例中展示的那样,我们的数据可能在任何时候被分块,我们的!!!START!!!!!!END!!!可能会出现在其中一个块的边界上。如果发生这种情况,我们的流将失败。我们需要额外的代码来处理这些情况,但这些示例应该提供了实现流代码所需的所有必要思路。

接下来,我们将看一下Writable流接口。

构建可写流

Writable流是我们写入数据的流,它可以连接到ReadableDuplexTransform流。我们可以使用这些流以分块的方式写入数据,以便消费流可以以分块而不是一次性处理数据。可写流的 API 与Readable流非常相似,除了可用的方法。

理解可写流接口

可写流为我们提供了几乎与Readable流相同的选项,因此我们不会深入讨论。相反,我们将看一下可用于我们的四种方法——一种我们必须实现的方法和其余我们可以实现的方法:

  • _write方法允许我们执行任何类型的转换或数据操作,并为我们提供使用回调的能力。这个回调是信号,表明写流能够接收更多数据。

虽然不是固有的真实情况,但它会从内部缓冲区中弹出数据。然而,对于我们的目的,最好将回调视为处理更多数据的一种方式。

我们可以利用这一点来包装一个更原始的流,并在主数据块之前或之后添加我们自己的数据。我们将在我们的Readable流的实际对应物中看到这一点。

  • _final方法允许我们在可写流关闭之前执行任何必要的操作。这可能是清理资源或发送我们可能一直保留的任何数据。除非我们保留了诸如文件描述符之类的东西,我们通常不会实现这个方法。

  • _destroy方法与Readable流相同,应该类似于_final方法,只是我们可能会在这个方法上出现错误。

  • _writev方法使我们能够同时处理多个块。如果我们对块有某种排序系统,或者我们不在乎块的顺序,我们可以实现这一点。虽然现在可能不明显,但我们将在实现双工流时实现这个方法。用例可能有些有限,但仍然可能有益。

实现可写流

以下Writable流实现展示了我们的帧方法以及我们如何使用它在我们的数据上放置!!!START!!!!!!END!!!帧。虽然简单,但它展示了帧的强大和如何在原始流周围构建更复杂的流:

  1. 从流模块导入Writable类,并为WriteMessagePassStream创建外壳。将其设置为此文件的默认导出:
import { Writable } from 'stream';

export default class WriteMessagePassStream extends Writable {
}
  1. 添加私有状态变量和构造函数。确保不允许objectMode通过,因为我们要处理原始数据:
// inside the WriteMessagePassStream
#socket = null;
#writing = false;
constructor(options) {
  if( options.objectMode ) { 
        options.objectMode = false;
    }
    if(!options.socket ) {
        throw new Error("A socket is required to construct this 
         stream!");
    }
    super(options);
    this.#socket = options.socket;
}
  1. 向我们的类添加_write方法。将如下解释:
_write(chunk, encoding, callback) { 
    if(!this.#writing ) {
        this.#writing = true;
        this.#socket.write("!!!START!!!");
    }
    let i = -1;
    let prevI = 0;
    let numCount = 0;
    while((i = chunk.indexOf([0x00], i)) !== -1) {
        const buf = chunk.slice(prevI, i);
        this.#socket.write(buf);
        this.#socket.write("!!!END!!!");
        if( i !== chunk.byteLength - 1 ) {
            this.#socket.write("!!!START!!!");
        } else {
            return callback();
        }
        numCount += 1;
    }
    if(!numCount ) {
        this.#socket.write(chunk);
    }
    return callback();
}

有了这段代码,我们可以看到一些与我们处理可读端类似的地方。一些值得注意的例外包括以下项目:

  • 我们实现_write方法。再次忽略这个函数的编码参数,但我们应该检查这一点,以防我们得到一个意料之外的编码。chunk 是正在写入的数据,回调是在我们完成对这个块的写入处理时调用的。

  • 由于我们正在包装一个套接字,并且我们不希望在发送数据完成后关闭它,我们需要向我们的流发送某种停止信号。在我们的情况下,我们使用简单的0x00字节。在更健壮的实现中,我们会利用其他东西,但现在这应该可以工作。

  • 无论如何,我们要么使用帧,要么直接写入底层套接字。

  • 我们在处理完成后调用回调。在我们的情况下,如果我们设置了writing标志,这意味着我们仍然处于一个帧中,我们希望提前返回,否则,我们希望将我们的流置于写入模式,并写出!!!START!!!,然后是块。同样,如果我们从不使用回调,我们的流将被无限暂停。回调告诉内部机制从内部缓冲区中拉取更多数据供我们消耗。

有了这段代码,我们现在可以看一下测试工具和我们如何利用它来创建一个服务器并处理实现我们帧上下文的传入Readable流:

import { createServer } from 'net'
import WrappedWritableStream from '../writable/main.js'
const server = createServer((con) => {
 console.log('client connected. sending test data');
 const wrapped = new WrappedWritableStream({ socket : con });
 for(let i = 0; i < 100000; i++) {
 wrapped.write(`data${i}\r\n`);
 }
 wrapped.write(Buffer.from([0x00]));
 wrapped.end();
 console.log('finished sending test data');
});
server.listen(3333);

我们创建一个服务器,并在本地端口3333上监听。每当我们接收到一个连接时,我们用我们的Writable流包装它。然后我们发送一堆测试数据,一旦完成,我们写出0x00信号告诉我们的流这个帧已经完成,然后我们调用end方法告诉我们已经完成了这个套接字。如果我们在第一次之后添加了另一个测试运行,我们可以看到我们的帧系统是如何工作的。让我们继续做这件事。在wrapped.write(Buffer.from([0x00]))之后添加以下代码:

for(let i = 0; i < 100000; i++) {
    wrapped.write(`more_data${i}\r\n`);
}
wrapped.write(Buffer.from([0x00]));

如果我们达到流的highWaterMark,写入流将暂停,直到读取流开始从中消耗。

如果我们现在使用之前的Readable流运行测试工具,我们将看到我们正在处理所有这些数据并将其写入文件,而没有任何传输。有了这两种流实现,我们现在可以通过套接字传输数据,而不需要传输任何帧。我们现在可以使用这个系统来实现前一章中的数据传递系统。然而,我们将实现一个Duplex流,它将改进这个系统,并允许我们处理多个可写块,这将在下一节中看到。

实现双工流

双工流就是这样,可以双向工作。它将ReadableWritable流合并为一个单一的接口。有了这种类型的流,我们现在可以直接从套接字中导入到我们的自定义流中,而不是像以前那样包装流(尽管我们仍然将其实现为包装流)。

关于Duplex流没有更多可以谈论的了,除了一个让新手对流类型感到困惑的事实。有两个单独的缓冲区:一个用于Readable,一个用于Writable。我们需要确保将它们视为单独的实例。这意味着我们在_read方法中使用的变量,在_write_writev方法的实现中不应该使用,否则我们可能会遇到严重的错误。

如前所述,以下代码实现了一个Duplex流,以及一个计数机制,这样我们就可以利用_writev方法。正如在理解可写流接口部分所述,_writev方法允许我们一次处理多个数据块:

  1. stream模块导入Duplex类,并为我们的MessageTranslator类添加外壳。导出这个类:
import { Duplex } from 'stream';

export default class MessageTranslator extends Duplex {
}
  1. 添加所有内部状态变量。每个变量将在接下来的部分中解释:
// inside the MessageTranslator class
#socket = null;
#internalWriteBuf = new Map();
#internalReadHoldBuf = [];
#internalPacketNum = 0;
#readSize = 0;
#writeCounter = 0;
  1. 为我们的类添加构造函数。我们将在这个构造函数中处理我们的#socket的数据事件,而不是像以前那样创建另一个方法:
// inside the MessageTranslator class
constructor(opts) {
    if(!opts.socket ) {
        throw new Error("MessageTranslator stream needs a 
         socket!");
    }
    super(opts);
    this.#socket = opts.socket;
    // we are assuming a single message for each chunk
    this.#socket.on('data', (chunk) => {
        if(!this.#readSize ) {
            this.#internalPacketNum = chunk.readInt32BE();
            this.#readSize = chunk.readInt32BE(4);
            this.#internalReadHoldBuf.push(chunk.slice(8));
            this.#readSize -= chunk.byteLength - 8
        } else {
            this.#internalReadHoldBuf.push(chunk);
            this.#readSize -= chunk.byteLength;
        }
        // reached end of message
        if(!this.#readSize ) {
            this.push(Buffer.concat(this.#internalReadHoldBuf));
            this.#internalReadHoldBuf = [];
        }
    });
}

我们将自动假设每个块中有一条消息。这样处理会更容易。当我们获取数据时,我们将读取数据包编号,这应该是数据的前四个字节。然后我们读取消息的大小,这是接下来的4个字节数据。最后,我们将剩余的数据推入我们的内部缓冲区。一旦我们完成读取整个消息,我们将把所有内部块放在一起并推送它们出去。最后,我们将重置我们的内部缓冲区。

  1. 向我们的类添加_writev_write方法。记住,_writev方法用于多个数据块,所以我们需要循环遍历它们并将每个写出去:
// inside the MessageTranslator class
_writev(chunks, cb) { 
    for(const chunk of chunks) {
        this.#processChunkHelper(chunk); //shown next
    }
    this.#writeHelper(cb); //shown next
}
_write(chunk, encoding, cb) {
    this.#processChunkHelper(chunk); //shown next
    this.#writeHelper(cb); //shown next
}
  1. 添加处理块和实际写出的辅助方法。我们将使用数字-1作为4字节消息,表示我们已经完成了这条消息。
// inside the MessageTranslator class
#processChunkHelper = function(chunk) {
    if(chunk.readInt32BE() === -1) { 
        this.#internalWriteBuf.get(this.#writeCounter).done = true;
        this.#writeCounter += 1;
        this.#internalWriteBuf.set(this.#writeCounter, {buf : [], 
         done : false});
    } else {
        if(!this.#internalWriteBuf.has(this.#writeCounter)) {
            this.#internalWriteBuf.set(this.#writeCounter, {buf : 
             [], done : false}); }
            this.#internalWriteBuf.get(this.#writeCounter)
             .buf.push(chunk);
        }
    }
}
#writeHelper = function(cb) {
    const writeOut = [];
    for(const [key, val] of this.#internalWriteBuf) { 
        if( val.done ) {
            const cBuf = Buffer.allocUnsafe(4);
            const valBuf = Buffer.concat(val.buf);
            const sizeBuf = Buffer.allocUnsafe(4);
            cBuf.writeInt32BE(valBuf.readInt32BE());
            sizeBuf.writeInt32BE(valBuf.byteLength - 4);
            writeOut.push(Buffer.concat([cBuf, sizeBuf, 
             valBuf.slice(4)]));
            val.buf = [];
        }
    }
    if( writeOut.length ) {
        this.#socket.write(Buffer.concat(writeOut));
    }
    cb();
}

我们的#processChunkHelper方法检查我们是否达到了神奇的-1 4字节消息,表示我们已经完成了消息的写入。如果没有,我们将继续向我们的内部缓冲区(数组)添加。一旦我们到达末尾,我们将把所有数据放在一起,然后转移到下一个数据包。

我们的#writeHelper方法将循环遍历所有这些数据包,并检查它们是否有任何一个已经完成。如果有,它将获取数据包编号、缓冲区的大小、数据本身,并将它们全部连接在一起。一旦完成这些操作,它将重置内部缓冲区,以确保我们不会泄漏内存。我们将把所有这些数据写入套接字,然后调用回调函数表示我们已经完成写入。

  1. 通过实现我们之前的_read方法来完成Duplex流。_final方法应该只是调用回调函数,因为没有剩余的处理:
// inside the MessageTranslator class
_read() {
    this.#socket.resume();
}
_final(cb) {
    cb(); // nothing to do since it all should be consumed at this 
          // point
}

当顺序不重要且我们只是处理数据并可能将其转换为其他形式时,应该真正使用_writev。这可能是一个哈希算法或类似的东西。在几乎所有情况下,应该使用_write方法。

虽然这个实现有一些缺陷(其中一个是如果我们达到-1数字时没有寻找可能的其他数据包),但它展示了我们如何构建一个Duplex流,以及处理消息的另一种方式。不建议自己设计在套接字之间传输数据的方案(正如我们将在下一章中看到的),但如果有一个新的规范出来,我们总是可以利用Duplex套接字来编写它。

如果我们用我们的测试工具测试这个实现,我们应该得到一个名为output.txt的文件,其中包含了双工加上数字消息被写入了 10 万次,以及一个尾随的换行符。再次强调,Duplex流只是一个单独的ReadableWritable流组合在一起,应该在实现数据传输协议时使用。

我们将要看的最后一个流是Transform流。

实现 Transform 流

在这四个流中,这可能是最有用的,也可能是最常用的流之一。Transform流连接了流的可读和可写部分,并允许我们操纵流中传输的数据。这听起来可能类似于Duplex。嗯,Transform流是Duplex流的一种特殊类型!

Transform流的内置实现包括zlib模块中实现的任何流。基本思想是我们不仅仅是试图将信息从一端传递到另一端;我们试图操纵这些数据并将其转换为其他形式。这就是zlib流给我们的。它们压缩和解压数据。Transform流将数据转换为另一种形式。这也意味着我们可以使一个转换流成为单向转换;从转换流输出的任何东西都无法被撤销。我们将在这里创建一个这样的Transform流,具体地创建一个字符串的哈希。

首先,让我们来看一下Transform流的接口。

理解 Transform 流接口

我们可以访问两种方法,几乎无论如何我们都想要实现。其中一个让我们可以访问底层数据块,并允许我们对其进行转换。我们使用_transform方法来实现这一点。它接受三个参数:我们正在处理的数据块,编码和一个回调,让底层系统知道我们已经准备好处理更多信息。

Writable流的_write回调不同的是,回调函数的一个特殊之处是我们可以向其传递数据,以在Transform流的可读端发出数据,或者我们可以不传递任何数据,以表示我们想要处理更多数据。这使我们只在需要时发送数据事件,而不是几乎总是需要传递它们。

另一种方法是_flush方法。这允许我们完成可能仍在持有的任何数据的处理。或者,它将允许我们在流中发送的所有数据都输出一次。这就是我们将用字符串哈希函数实现的功能。

实现 Transform 流

我们的Transform流将接收字符串数据并继续运行哈希算法。一旦完成,它将输出计算出的最终哈希值。哈希函数是一种我们将某种形式的输入转换为唯一数据的函数。这个唯一的数据(在我们的例子中是一个数字)不应该容易发生碰撞。碰撞是两个不同值可能得到相同哈希值的概念。在我们的情况下,我们将字符串转换为 JavaScript 中的 32 位整数,因此我们很少发生碰撞,但并非不可能。

以下是示例:

// implemented in stream form from 
// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
export default class StreamHashCreator extends Transform {
    #currHash = 0; 
    constructor(options={}) {
        if( options.objectMode ) {
            throw new Error("This stream does not support object mode!");
        }
        options.decodeStrings = true;
        super(options);
    }
    _transform(chunk, encoding, callback) {
        if( Buffer.isBuffer(chunk) ) { 
            const str = chunk.toString('utf8');
            for(let i = 0; i < str.length; i++) {
                const char = str.charCodeAt(i);
                this.#currHash = ((this.#currHash << 5) - this.#currHash ) 
                 + char;
                this.#currHash |= 0;
            }
        }
        callback(); 
    }
    _flush(callback) {
        const buf = Buffer.alloc(4);
        buf.writeInt32BE(this.#currHash);
        this.push(buf); 
        callback(null);
    }
}

前一个流的每个函数都在下面解释:

  1. 我们需要持久化的唯一一件事是直到流被销毁的当前哈希码。这将允许哈希函数跟踪我们已经传递给它的内容,并在每次写入后处理数据。

  2. 我们在这里进行检查,看我们收到的块是否是一个Buffer。由于我们确保打开了decodeStrings选项,这意味着我们应该总是得到缓冲区,但检查仍然有帮助。

  3. 虽然哈希函数的内容可以在提供的 URL 中看到,但我们需要担心的唯一重要事情是,我们要调用我们的回调,就像我们在实现Writable流时所做的那样。

  4. 一旦我们准备生成数据,我们就使用push方法,就像我们在Readable流中所做的那样。记住,Transform流只是允许我们操纵输入数据并将其转换为输出的特殊Duplex流。我们还可以将代码的最后两行更改为callback(null, buf);这只是我们之前看到的简写。

现在,如果我们对前面的代码运行一些测试用例,我们会发现每个唯一字符串输入都会得到一个唯一的哈希码,但当我们输入完全相同的内容时,我们会得到相同的哈希码。这意味着我们的哈希函数很好,我们可以将其连接到流应用程序中。

使用流生成器

到目前为止,我们所看到的一切都展示了我们如何利用 Node.js 中的所有内置系统来创建流应用程序。然而,对于那些一直在按顺序阅读本书的人来说,我们已经讨论了生成器。那些一直在思考它们的人会注意到流和生成器之间有很强的相关性。事实上就是这样!我们可以利用生成器来连接到流 API。

有了这个概念,我们可以构建既可以在浏览器中工作又可以在 Node.js 中工作的生成器,而不需要太多的开销。我们甚至在第六章中看到了如何使用 Fetch API 获取底层流。现在,我们可以编写一个可以与这两个子系统一起工作的生成器。

现在,让我们只看一个async生成器的示例,以及我们如何将它们连接到 Node.js 流系统中。示例将是看看我们如何将生成器作为Readable流的输入:

  1. 我们将建立一个Readable流来读取英语字母表的 26 个小写字符。我们可以通过编写以下生成器来轻松实现这一点:
function* handleData() {
    let _char = 97;
    while(_char < 123 ) { //char code of 'z'
        yield String.fromCharCode(_char++);
    }
}
  1. 当字符代码低于123时,我们继续发送数据。然后我们可以将其包装在Readable流中,如下所示:
const readable = Readable.from(handleData());
readable.on('data', (chunk) => {
    console.log(chunk);
});

如果我们现在运行这段代码,我们会看到控制台中出现字符azReadable流知道它已经结束,因为生成器生成了一个具有两个键的对象。value字段给出了yield表达式的值,done告诉我们生成器是否已经完成运行。

这让可读接口知道何时发送data事件(通过我们产生一个值)以及何时关闭流(通过将done键设置为true)。我们还可以将可读系统的输出管道到可写系统的输出,以链接整个过程。这可以很容易地通过以下代码看到:

(async() => {
    const readable2 = Readable.from(grabData());
    const tempFile = createWriteStream('./temp.txt');
    readable2.pipe(tempFile);
    await once(tempFile, 'finish');
    console.log('all done');
})();

通过生成器和async/await实现流可能看起来是一个好主意,但只有在我们试图将一个已经是async/await的代码片段与流结合时,我们才应该利用它。始终要追求可读性;利用生成器或async/await方法很可能会导致代码难以阅读。

通过前面的例子,我们已经将生成器的可读性与利用管道机制发送到文件相结合。随着async/await和生成器成为 JavaScript 语言中的构造,流很快就会成为一个一流的概念。

总结

流是编写高性能 Node.js 代码的支柱之一。它允许我们不阻塞主线程,同时仍然能够处理数据。流 API 允许我们为我们的目的编写不同类型的流。虽然这些流大多数将是转换流的形式,但看到我们如何实现其他三种流也是很好的。

我们将在下一章中看到的最后一个主题是数据格式。处理除了 JSON 之外的不同数据格式将使我们能够与许多大数据提供商进行接口,并能够处理他们喜欢使用的数据格式。我们将看到他们如何利用流来实现所有的格式规范。

第八章:数据格式 - 查看除 JSON 之外的不同数据类型

我们几乎已经完成了关于服务器端 JavaScript 的讨论。一个话题似乎鲜为人知,但在与其他系统进行接口或使事情更快时经常出现的话题是以不同格式传输数据。其中最常见的,如果不是最常见的格式就是 JSON。JSON 是非常容易与之进行接口的数据格式之一,特别是在 JavaScript 中。

在 JavaScript 中,我们不必担心不匹配类的 JSON 对象。如果我们使用的是像 Java(或者正在使用 TypeScript 的人)这样的强类型语言,我们将不得不担心以下事项:

  • 创建一个模仿 JSON 对象格式的类。

  • 创建一个基于嵌套对象数量的嵌套映射结构。

  • 根据我们收到的 JSON 创建即时类。

这些都不一定难,但当我们与使用这些语言编写的系统进行接口时,它可能会增加速度和复杂性。使用其他数据格式时,我们可能会获得一些主要的速度优势;不仅可能会获得更小的数据传输量,而且其他语言也能更容易地解析对象。当我们转向基于模式的数据格式时,甚至会获得更多的好处,比如版本控制,这可以使向后兼容更容易。

考虑到所有这些,让我们继续看一下 JSON,并了解一些利弊,以及我们在使用它时得到的损失。除此之外,我们将看一下一个新的自定义格式,我们将为我们的服务创建一个更小的数据传输格式。之后,我们将看一下无模式数据格式,比如 JSON,最后,我们将看一下基于模式的格式。

这一章可能比其他章节都要轻一些,但在开发企业应用程序或与其进行接口时,这是一个非常有用的章节。

本章涵盖的主题如下:

  • 使用 JSON

  • JSON 编码

  • JSON 解码

  • 查看数据格式

在 TypeScript 中,如果我们愿意,我们可以只使用any类型,但这在某种程度上会削弱 TypeScript 的目的。虽然本书不会涉及 TypeScript,但知道它存在,并且很容易看出开发人员在开发后端应用程序时可能会遇到它。

技术要求

完成本章需要以下工具:

使用 JSON

如前所述,JSON 提供了一个易于使用和操作的接口,用于在服务之间发送和接收消息。对于不了解的人来说,JSON 代表JavaScript 对象表示法,这也是它与 JavaScript 接口得很好的原因之一。它模仿了 JavaScript 对象的许多行为,除了一些基本类型(例如函数)。这也使得它非常容易解析。我们可以使用内置的JSON.parse函数将 JSON 的字符串化版本转换为对象,或者使用JSON.stringify将我们的对象转换为其在网络上传输的格式。

那么在使用 JSON 时有哪些缺点呢?首先,当通过网络发送数据时,格式可能会变得非常冗长。考虑一个具有以下格式的对象数组:

{
    "name" : "Bob",
    "birth" : "01/02/1993",
    "address" : {
        "zipcode" : 11111,
        "street" : "avenue of av.",
        "streetnumber" : 123,
        "state" : "CA",
        "country" : "US"
    },
    "contact" : {
        "primary" : "111-222-3333",
        "secondary" : "444-555-6666",
        "email" : "bob@example.com"
    }
}

对于那些曾经处理过联系表单或客户信息的人来说,这可能是一个常见的情景。现在,虽然我们应该为网站涉及某种分页,但我们仍然可能一次获取100甚至500个这样的数据。这可能很容易导致巨大的传输成本。我们可以使用以下代码来模拟这种情况:

const send = new Array(100);
send.fill(json);
console.log('size of over the wire buffer is: ',
 Buffer.from(JSON.stringify(send)).byteLength);

通过使用这种方法,我们可以得到对于我们发送的100条数据进行字符串化后的缓冲区的字节长度。我们将看到它大约为 22 KB 的数据。如果我们将这个数字增加到500,我们可以推断出它将大约为 110 KB 的数据。虽然这可能看起来不像是很多数据,但我们可能会看到这种类型的数据被发送到智能手机上,我们希望限制我们传输的数据量,以免耗尽电池。

我们尚未深入讨论手机和我们的应用程序,尤其是在前端,但这是我们需要越来越意识到的事情,因为我们正在变得越来越像一个远程商业世界。许多用户,即使没有应用程序的移动版本,仍然会尝试使用它。一个个人的轶事是利用为桌面应用程序设计的电子邮件服务,因为移动版本的应用程序中缺少一些功能。我们始终需要意识到我们正在传输的数据量,但移动设备已经使这个想法成为主要目标。

解决这个问题的一种方法是利用某种压缩/解压缩格式。一个相当知名的格式是gzip。这种格式非常快速,没有数据质量损失(一些压缩格式有这个问题,比如 JPEG),并且在网页中非常普遍。

让我们继续使用 Node.js 中的zlib模块来gzip这些数据。以下代码展示了zlib中一个易于使用的gzip方法,并展示了原始版本和 gzip 版本之间的大小差异:

gzipSync(Buffer.from(JSON.stringify(send))).byteLength

现在我们将看到经过 gzip 压缩的版本只有 301 字节,对于 500 长度的数组,我们看到大约 645 字节的 gzip 版本。这是相当节省的!然而,这里有几点需要记住。首先,我们在数组中的每个项目中使用完全相同的对象。压缩算法是基于模式的,因此一遍又一遍地看到完全相同的对象给了我们对原始形式到压缩形式的错误感觉。这并不意味着这不是未压缩与压缩数据之间大小差异的指示,但在测试各种格式时需要牢记这一点。根据各种网站,我们将看到原始数据的 4-10 倍的压缩比(这意味着如果原始数据为 1 MB,我们将看到压缩大小从 250 KB 到 100 KB 不等)。

我们可以创建一个自己的格式,以更紧凑的方式表示数据,而不是使用 JSON。首先,我们将只支持三种项目类型:整数、浮点数和字符串。其次,我们将在消息头中存储所有的键。

模式最好可以描述为传入数据的定义。这意味着我们将知道如何解释传入的数据,而不必寻找特殊的编码符号来告诉我们负载的结束(尽管我们的格式将使用一个结束信号)。

我们的模式将看起来像以下内容:

  1. 我们将为消息的头部和主体使用包装字节。头部将用0x10字节表示,主体将用0x11字节表示。

  2. 我们将支持以下类型,它们的转换看起来类似于以下内容:

  • 整数:0x01后跟一个 32 位整数

  • 浮点数:0x02后跟一个 32 位整数

  • 字符串:0x03后跟字符串的长度,后跟数据

这应该足够让我们理解数据格式以及它们可能与仅对 JSON 进行编码和解码有所不同的工作方式。在接下来的两个部分中,我们将看到如何使用流来实现编码器和解码器。

实现编码器

我们将使用转换流来实现编码器和解码器。这将为我们提供最大的灵活性,因为我们实际上正在实现流,并且它已经具有我们需要的许多行为,因为我们在技术上正在转换数据。首先,我们需要一些通用的辅助方法,用于编码和解码我们特定数据类型的方法,并将所有这些方法放在一个helpers.js辅助文件中。编码函数将如下所示:

export const encodeString = function(str) {
    const buf = Buffer.from(str);
    const len = Buffer.alloc(4);
    len.writeUInt32BE(buf.byteLength);
    return Buffer.concat([Buffer.from([0x03]), len, buf]);
}
export const encodeNumber = function(num) {
    const type = Math.round(num) === num ? 0x01 : 0x02;
    const buf = Buffer.alloc(4);
    buf.writeInt32BE(num);
    return Buffer.concat([Buffer.from([type]), buf]); 
}

编码字符串接受字符串并输出将保存解码器工作信息的缓冲区。首先,我们将字符串更改为Buffer格式。接下来,我们创建一个缓冲区来保存字符串的长度。然后,我们利用writeUInt32BE方法存储缓冲区的长度。

对于那些不了解字节/位转换的人来说,8 位信息(位要么是 1 要么是 0-我们可以提供的最低形式的数据)组成 1 个字节。我们要写入的 32 位整数由 4 个字节组成(32/8)。该方法的 U 部分表示它是无符号的。无符号表示我们只想要正数(在我们的情况下长度只能是 0 或正数)。有了这些信息,我们就可以看到为什么我们为这个操作分配了 4 个字节,以及为什么我们要使用这个特定的方法。有关缓冲区的写入/读取部分的更多信息,请访问nodejs.org/api/buffer.html,因为它深入解释了我们可以访问的缓冲区操作。我们只会解释我们将要使用的操作。

一旦我们将字符串转换为缓冲区格式并获得字符串的长度,我们将写出一个缓冲区,其中type作为第一个字节,在我们的情况下是0x03字节;字符串的长度,这样我们就知道传入缓冲区的字符串有多长;最后,字符串本身。这个方法应该是两个辅助方法中最复杂的一个,但从解码的角度来看,它应该是有意义的。当我们读取缓冲区时,我们不知道字符串的长度。因此,我们需要在此类型的前缀中有一些信息,以知道实际读取多少。在我们的情况下,0x03告诉我们类型是字符串,根据我们之前建立的数据类型协议,我们知道接下来的 4 个字节将是字符串的长度。最后,我们可以使用这些信息来在缓冲区中向前读取,以获取字符串并将其解码回字符串。

encodeNumber方法更容易理解。首先,我们检查数字的四舍五入是否等于自身。如果是,那么我们知道我们正在处理一个整数,否则,我们将其视为浮点数。对于不了解的人来说,在大多数情况下,在 JavaScript 中知道这些信息并不太重要(尽管 V8 引擎在知道它正在处理整数时会使用某些优化),但如果我们想要将这种数据格式与其他语言一起使用,那么差异就很重要了。

接下来,我们分配了 4 个字节,因为我们只打算写出 32 位有符号整数。有符号意味着它们将支持正数和负数(再次,我们不会深入探讨两者之间的巨大差异,但对于那些好奇的人来说,如果我们使用有符号整数,我们实际上限制了我们可以在其中存储的最大值,因为我们必须利用其中一个位告诉我们这个数字是正数还是负数)。然后,我们写出最终的缓冲区,其中包括我们的类型,然后是缓冲区格式中的数字。

现在,使用helper.js文件中的辅助方法和以下常量进行如下操作:

export const CONSTANTS = {
    object : 0x04,
    number : 0x01,
    floating : 0x02,
    string : 0x03,
    header : 0x10,
    body : 0x11
}

我们可以创建我们的encoder.js文件:

  1. 导入必要的依赖项,并创建我们的SimpleSchemaWriter类的框架:
import { Transform } from 'stream';
import { encodeString, encodeNumber } from './helper.js';

export default class SimpleSchemaWriter extends Transform {
}
  1. 创建构造函数,并确保始终打开objectMode
// inside our SimpleSchemaWriter class
constructor(opts={}) {
    opts.writableObjectMode = true;
    super(opts);
}
  1. 添加一个私有的#encode辅助函数,它将为我们进行底层数据检查和转换:
// inside of our SimpleSchemaWriter class
#encode = function(data) {
    return typeof data === 'string' ?
            encodeString(data) :
            typeof data === 'number' ?
            encodeNumber(data) :
            null;
}
  1. 编写我们Transform流的主要_transform函数。该流的详细信息将在下文中解释:
_transform(chunk, encoding, callback) {
    const buf = [];
    buf.push(Buffer.from([0x10]));
    for(const key of Object.keys(chunk)) { 
        const item = this.#encode(key);
        if(item === null) {
            return callback(new Error("Unable to parse!"))
        }
        buf.push(item);
    }
    buf.push(Buffer.from([0x10])); 
    buf.push(Buffer.from([0x11]));
    for(const val of Object.values(chunk)) { 
        const item = this.#encode(val);
        if(item === null) {
            return callback(new Error("Unable to parse!"))
        }
        buf.push(item);
    }
    buf.push(Buffer.from([0x11]));
    this.push(Buffer.concat(buf)); 
    callback();
}

总的来说,transform函数应该与我们之前实现的_transform方法很相似,但有一些例外:

  1. 我们编码的第一部分是包装我们的标头(对象的键)。这意味着我们需要写出我们的标头分隔符,即0x10字节。

  2. 我们将遍历对象的所有键。然后,我们将利用private方法encode。这个方法将检查键的数据类型,并利用我们之前讨论过的辅助方法之一返回编码。如果它得到一个它不理解的类型,它将返回null。然后我们将返回一个Error,因为我们的数据协议不理解这种类型。

  3. 一旦我们遍历完所有的键,我们将再次写出0x10字节,表示我们已经完成了标头,并写出0x11字节告诉解码器我们要开始消息的主体部分。(我们可以在这里使用helpers.js文件中的常量,而且我们可能应该这样做,但这应该有助于理解底层协议。解码器将利用这些常量来展示更好的编程实践。)

  4. 现在我们将遍历对象的值,并将它们通过与标头相同的编码系统运行,并在不理解数据类型时返回一个Error

  5. 一旦我们完成了主体部分,我们将再次推送0x11字节,表示我们已经完成了主体部分。这将是解码器停止转换此对象并发送出它一直在转换的信号。然后我们将所有这些数据推送到我们Transform流的Readable部分,并使用回调来表示我们已准备好处理更多数据。

我们的编码方案的整体结构存在一些问题(我们不应该使用单个字节作为包装器,因为它们很容易被我们的编码器和解码器误解),我们应该支持更多的数据类型,但这应该对如何为更常用的数据格式构建编码器有一个很好的理解。

现在,我们无法测试这一点,除了它能正确输出编码外,但一旦我们的解码器运行起来,我们就能测试是否两边得到相同的对象。现在让我们来看看这个系统的解码器。

实现解码器

解码器的状态比编码器要复杂得多,这通常是数据格式的特点。当处理原始字节时,尝试从中解析信息通常比以原始格式写出数据更困难。

让我们来看看我们将用来解码支持的数据类型的辅助方法:

import { CONSTANTS } from './helper.js';

export const decodeString = function(buf) {
    if(buf[0] !== CONSTANTS.string) {
        return false;
    }
    const len = buf.readUInt32BE(1);
    return buf.slice(5, 5 + len).toString('utf8');
}
export const decodeNumber = function(buf) {
    return buf.readInt32BE(1);
}

decodeString方法展示了我们如何处理格式不正确的数据的错误,而decodeNumber方法则没有展示这一点。对于decodeString方法,我们需要从缓冲区中获取字符串的长度,我们知道这是传入的缓冲区的第二个字节。基于此,我们知道可以通过从缓冲区的第五个字节开始(第一个字节告诉我们这是一个字符串;接下来的四个字节是字符串的长度)获取字符串,并且获取直到达到字符串的长度。然后我们通过toString方法运行这个缓冲区。

decodeNumber非常简单,因为我们只需要读取告诉我们它是一个数字的第一个字节后面的 4 个字节(再次,我们应该在这里进行检查,但我们保持简单)。这展示了我们需要解码支持的数据类型的两个主要辅助方法。接下来,我们将看一下实际的解码器。它将看起来像下面这样。

如前所述,解码过程有点复杂。这是由于许多原因,如下所述:

  • 我们直接处理字节,所以我们需要做相当多的处理。

  • 我们正在处理头部和主体部分。如果我们创建了一个非基于模式的系统,我们可能可以编写一个解码器,其状态不像这个解码器中那么多。

  • 同样,由于我们直接处理缓冲区,所有数据可能不会一次全部到达,因此我们需要处理这种情况。编码器不必担心这一点,因为我们正在以对象模式操作可写流。

考虑到这一点,让我们来看一下解码流程:

  1. 我们将使用与以前的Transform流相同类型的设置来设置我们的解码流。我们将设置一些私有变量来跟踪我们在解码器中的状态:
import { Transform } from 'stream'
import { decodeString, decodeNumber, CONSTANTS } from './helper.js'

export default class SimpleSchemaReader extends Transform {
    #obj = {}
    #inHeaders = false
    #inBody = false
    #keys = []
    #currKey = 0
}
  1. 接下来,我们将在解码过程中使用一个索引。我们不能简单地一次读取一个字节,因为解码过程以不同的速度运行(当我们读取一个数字时,我们要读取 5 个字节;当我们读取一个字符串时,至少要读取 6 个字节)。因此,使用while循环会更好:
#decode = function(chunk, index, type='headers') { 
        const item = chunk[index] === CONSTANTS.string ?
            decodeString(chunk.slice(index)) :
            decodeNumber(chunk.slice(index, index + 5));

        if( type === 'headers' ) {
            this.#obj[item] = null;
        } else {
            this.#obj[this.#keys[this.#currKey]] = item;
        }
        return chunk[index] === CONSTANTS.string ?
            index + item.length + 5 :
            index + 5;
    }
    constructor(opts={}) {
        opts.readableObjectMode = true;
        super(opts);
    }
    _transform(chunk, encoding, callback) {
        let index = 0; //1
        while(index <= chunk.byteLength ) {
        }
    }
  1. 现在,我们要检查当前字节,看它是头部还是主体的分隔标记。这将让我们知道我们是在处理对象键还是对象值。如果我们检测到headers标志,我们将设置#inHeaders布尔值,表示我们在头部。如果我们在主体中,我们还有更多工作要做:
// in the while loop
const byte = chunk[index];
if( byte === CONSTANTS.header ) { 
    this.#inHeaders = !this.#inHeaders
    index += 1;
    continue;
} else if( byte === CONSTANTS.body ) { 
    this.#inBody = !this.#inBody
    if(!this.#inBody ) { 
        this.push(this.#obj);
        this.#obj = {};
        this.#keys = [];
        this.#currKey = 0;
        return callback();
    } else {
        this.#keys = Object.keys(this.#obj); 
    }
    index += 1;
    continue;
}
if( this.#inHeaders ) { 
    index = this.#decode(chunk, index);
} else if( this.#inBody ) {
    index = this.#decode(chunk, index, 'body');
    this.#currKey += 1;
} else {
    callback(new Error("Unknown state!"));
}
  1. 接下来,接下来的段落将解释获取每个 JSON 对象的头部和值的过程。

首先,我们将把我们的主体布尔值更改为当前状态的相反值。接下来,如果我们从主体内部到主体外部,这意味着我们已经完成了这个对象。因此,我们可以推出我们当前正在处理的对象,并重置所有内部状态变量(临时对象#obj,我们从头部获取的临时#keys集合,以及#currKey,用于在主体中工作时知道我们正在处理哪个键)。一旦我们完成这些操作,我们就可以运行回调(我们在这里返回,所以我们不会运行更多的主体)。如果我们不这样做,我们将继续循环,并处于一个糟糕的状态。

否则,我们已经浏览了有效负载的头部,并已经到达了每个对象的值。我们将把我们的私有#keys变量设置为对象的键(因为在这一点上,头部应该已经从头部获取了所有的键)。我们现在可以开始看到解码过程。

如果我们在头部,我们将运行我们的私有#decode方法,并且不使用第三个参数,因为默认情况下是以头部运行该方法。否则,我们将像在主体中一样运行它,并传递第三个参数以说明我们在主体中。此外,如果我们在主体中,我们将增加我们的#currKey变量。

最后,我们可以看一下解码过程的核心,#decode方法。我们根据缓冲区中的第一个字节获取项目,这将告诉我们应该运行哪个解码辅助方法。然后,如果我们在头部模式下运行此方法,我们将为我们的临时对象设置一个新键,并将其值设置为 null,因为一旦我们到达主体,它将被填充。如果我们在主体模式下,我们将设置与我们正在循环的#keys数组中的#currKey索引对应的键的值,一旦我们进入主体,我们就会开始循环。

有了这个代码解释,正在发生的基本过程可以总结为几个基本步骤:

  1. 我们需要浏览头部并将对象的键设置为这些值。我们暂时将这些键的值设置为 null,因为它们将在以后填充。

  2. 一旦我们离开头部部分并进入主体部分,我们可以从临时对象中获取所有键,并且我们在那时进行的解码运行应该对应于数组中当前键索引处的键。

  3. 一旦我们离开主体部分,我们将重置所有临时变量的状态,并发送相应的对象,因为我们已经完成了解码过程。

这可能看起来令人困惑,但我们所做的就是将头部与相同索引处的主体元素对齐。如果我们想要将键和值的数组放在一起,这将类似于以下代码:

const keys = ['item1', 'item2', 'item3'];
const values = [1, 'what', 2.2];
const tempObj = {};
for(let i = 0; i < keys.length; i++) {
    tempObj[keys[i]] = null;
}
for(let i = 0; i < values.length; i++) {
    tempObj[keys[i]] = values[i];
}

这段代码几乎与之前的缓冲区完全相同,只是我们必须使用原始字节而不是更高级的项目,如字符串、数组和对象。

解码器和编码器都完成后,我们现在可以通过我们的编码器和解码器运行一个对象,看看我们是否得到相同的值。让我们运行以下测试代码:

import encoder from './encoder.js'
import decoder from './decoder.js'
import json from './test.json'

const enc = new encoder();
const dec = new decoder();
enc.pipe(dec);
dec.on('data', (obj) => {
    console.log(obj);
});
enc.write(json);

我们将使用以下测试对象:

{
    "item1" : "item",
    "item2" : 12,
    "item3" : 3.3
}

我们将看到,当我们将数据通过编码器传输到解码器时,我们将得到相同的对象。现在,我们已经创建了自己的编码和解码方案,但它与 JSON 相比在传输大小上如何?使用这个负载,我们实际上增加了大小!如果我们考虑一下,这是有道理的。我们必须添加所有特殊的编码项(除了数据之外的所有信息,如0x100x11字节),但现在我们开始向我们的列表中添加更多的大型数字项。我们将看到,我们开始击败基本的JSON.stringifyJSON.parse

{
    "item1" : "item",
    "item2" : 120000000,
    "item3" : 3.3,
    "item4" : 120000000,
    "item5" : 120000000,
    "item6" : 120000000
}

这是因为字符串化的数字被转换成了字符串版本的数字,所以当我们得到大于 5 个字节的数字时,我们开始节省字节(1 个字节用于数据类型,4 个字节用于 32 位数字编码)。对于字符串,我们永远不会节省,因为我们总是添加额外的 5 个字节的信息(1 个字节用于数据类型,4 个字节用于字符串的长度)。

在大多数编码和解码方案中,情况都是如此。它们处理数据的方式取决于传递的数据类型。在我们的情况下,如果我们通过网络发送大量的高度数值化的数据,我们的方案可能效果更好,但如果我们传输字符串,我们将无法从这种编码和解码方案中获益。在我们看一些在野外广泛使用的数据格式时,请记住这一点。

记住,这种编码和解码方案并不是用于实际环境的,因为它充满了问题。然而,它展示了构建数据格式的基本主题。虽然大多数人永远不需要构建数据格式,但了解构建数据格式时发生的情况以及数据格式可能需要根据其主要处理的数据类型专门化其编码和解码方案是很好的。

数据格式的一瞥

现在我们已经看过了我们自己的数据格式,让我们继续看看一些目前流行的数据格式。这不是对这些数据格式的详尽了解,而是对数据格式和我们可能在野外发现的内容的介绍。

我们将要查看的第一种数据格式是无模式格式。如前所述,基于模式的格式要么提前发送数据的模式,要么将模式与数据本身一起发送。这通常允许数据以更紧凑的形式传入,同时确保双方同意数据接收方式。另一种形式是无模式,我们通过规范发送数据的新形式,但解码所有信息都是通过规范完成的。

JSON 就是其中一种格式。当我们发送 JSON 时,我们必须对其进行编码,然后在另一端对其进行解码。另一种无模式数据格式是 XML。这两种格式对于 Web 开发人员来说应该非常熟悉,因为我们广泛使用 JSON,并且在组装前端(HTML)时使用一种 XML 形式。

另一种流行的格式是MessagePackmsgpack.org/index.html)。MessagePack是一种以比 JSON 更小的有效载荷而闻名的格式。MessagePack的另一个优点是有许多语言为其编写了原生库。我们将看一下 Node.js 版本,但请注意,这可以在前端(浏览器)和服务器上都可以使用。所以让我们开始吧:

  1. 我们将使用以下命令通过npm install安装what-the-pack扩展。
> npm install what-the-pack
  1. 完成后,我们可以开始使用这个库。通过以下代码,我们可以看到在网络上传输这种数据格式是多么容易。
import MessagePack from 'what-the-pack';
import json from '../schema/test.json';

const { encode, decode } = MessagePack.initialize(2**22);
const encoded = encode(json);
const decoded = decode(encoded);
console.log(encoded.byteLength, Buffer.from(JSON.stringify(decoded)).byteLength);
console.log(encoded, decoded);

我们在这里看到的是对what-the-pack页面上示例的略微修改版本(www.npmjs.com/package/what-the-pack)。我们导入了该包,然后初始化了该库。该库的一个不同之处在于,我们需要为编码和解码过程初始化一个缓冲区。这就是initialize方法中的2**22所做的。我们正在初始化一个大小为 2 的 22 次方字节的缓冲区。这样,它可以轻松地切割缓冲区并复制它,而不需要昂贵的基于数组的操作。敏锐的观察者还会注意到的另一件事是,该库不是基于流的。他们很可能这样做是为了在浏览器和 Node.js 之间保持兼容。除了这些小问题,整个库的工作方式与我们想象的一样。

第一个控制台日志向我们展示了编码后的缓冲区比 JSON 版本少了 5 个字节。虽然这确实表明该库给我们提供了更紧凑的形式,但应该注意到,有些情况下MessagePack可能不比相应的 JSON 更小。它也可能比内置的JSON.stringifyJSON.parse方法运行得更慢。记住,一切都是一种权衡。

有很多无模式数据格式,每种格式都有自己的技巧,试图使编码/解码时间更快,使过程中的数据更小。然而,当我们处理企业系统时,我们很可能会看到使用基于模式的数据格式。

有几种定义模式的方法,但在我们的情况下,我们将使用 proto 文件格式。

  1. 让我们继续创建一个proto文件,以模拟我们之前的test.json文件。模式可能看起来像以下内容:
package exampleProtobuf;
syntax = "proto3";

message TestData {
    string item1 = 1;
    int32  item2 = 2;
    float  item3 = 3;
}

我们在这里声明的是,这条名为TestData的消息将存储在名为exampleProtobuf的包中。该包主要用于将类似的项目分组(这在诸如 Java 和 C#等语言中被广泛利用)。语法告诉我们的编码器和解码器,我们将使用的协议是proto3。协议还有其他版本,而这个版本是最新的稳定版本。

然后,我们声明一个名为TestData的新消息,其中包含三个条目。一个将被称为item1,类型为string,一个将是称为item2的整数,最后一个将是称为item3的浮点数。我们还为它们分配了 ID,因为这样可以更容易进行索引和自引用类型(也因为这对于protobuf来说是强制性的)。我们不会详细介绍这样做的具体作用,但请注意它可以帮助编码和解码过程。

  1. 接下来,我们可以编写一些代码,可以使用它在我们的代码中创建一个TestData对象,可以专门处理这些消息。这将看起来像下面这样:
protobuf.load('test.proto', function(err, root) {
    if( err ) throw err;
    const TestTypeProto = 
     root.lookupType("exampleProtobuf.TestData");
    if( TestTypeProto.verify(json) ) {
        throw Error("Invalid type!");
    }
    const message2 = TestTypeProto.create(json);
    const buf2 = TestTypeProto.encode(message2).finish();
    const final2 = TestTypeProto.decode(buf2);
    console.log(buf2.byteLength, 
     Buffer.from(JSON.stringify(final2)).byteLength);
    console.log(buf2, final2);
});

请注意,这与我们之前看到的大多数代码类似,除了一些验证和创建过程。首先,库需要读取我们拥有的原型文件,并确保它确实是正确的。接下来,我们根据我们给它的命名空间和名称创建对象。现在,我们验证我们的有效负载并从中创建消息。然后,我们通过特定于此数据类型的编码器运行它。最后,我们解码消息并测试以确保我们得到了与输入相同的数据。

从这个例子中应该注意到两件事。首先,数据大小非常小!这是基于模式/protobuf 的优势之一,超过了无模式数据格式。由于我们提前知道类型应该是什么,我们不需要将该信息编码到消息本身中。其次,我们将看到浮点数并没有返回为 3.3。这是由于精度错误,这是我们应该警惕的事情。

  1. 现在,如果我们不想像这样读取原型文件,我们可以在代码中构建消息,就像下面这样:
const TestType = new protobuf.Type("TestType");
TestType.add(new protobuf.Field("item1", 1, "string"));
TestType.add(new protobuf.Field("item2", 2, "int32"));
TestType.add(new protobuf.Field("item3", 3, "float"));

这应该类似于我们在原型文件中创建的消息,但我们将逐行查看以显示它与protobuf对象相同。在这种情况下,我们首先创建一个名为TestType的新类型(而不是TestData)。接下来,我们添加三个字段,每个字段都有自己的标签、索引号和存储在其中的数据类型。如果我们通过相同类型的验证、创建、编码、解码过程运行它,我们将得到与之前相同的结果。

虽然这并不是对不同数据格式的全面概述,但它应该有助于识别何时使用无模式(当我们不知道数据可能是什么样子时)以及何时使用模式(当在未知系统之间通信或我们需要减少有效负载大小时)。

总结

虽然我们大多数起始应用程序将使用 JSON 在不同服务器之间传递数据,甚至在我们应用程序的不同部分之间传递数据,但应该注意到我们可能不想使用它的地方。通过利用其他数据格式,我们可以确保尽可能地提高应用程序的速度。

我们已经看到了构建自己的数据格式可能涉及的内容,然后我们看了一下当前流行的其他格式。这应该是我们构建高性能 Node.js 服务器应用程序所需的最后一部分信息。虽然我们将使用一些数据格式的库,但我们也应该注意到,我们实际上只使用了 Node.js 自带的原始库。

接下来,我们将看一个实际的静态服务器示例,该服务器缓存信息。从这里开始,我们将利用之前的所有概念来创建一个高可用和高速的静态服务器。

第九章:实际示例 - 构建静态服务器

在过去的几章中,我们已经了解了 Node.js 及其提供的功能。虽然我们没有涵盖每个模块或 Node.js 提供的所有内容,但我们已经有了所有的要素来构建一个静态内容/生成器站点。这意味着我们将设置一个服务器来监听请求,并根据该请求构建页面。

为了实现这个服务器,我们需要了解站点生成的工作原理,以及如何将其作为即时操作实现。除此之外,我们还将研究缓存,以便我们不必在每次请求页面时重新编译。总的来说,在本章中,我们将查看并实现以下内容:

  • 理解静态内容

  • 设置我们的服务器

  • 添加缓存和集群

技术要求

理解静态内容

静态内容就是不变的内容。这可以是 HTML 页面、JavaScript、图像等。任何不需要通过数据库或某些外部系统进行处理的内容都可以被视为静态内容。

虽然我们不会直接实现静态内容服务器,但我们将实现一个即时静态内容生成器。对于不了解的人来说,静态内容生成器是一个构建静态内容然后提供该内容的系统。内容通常由某种模板系统构建。

一些常见的模板系统包括 Mustache、Handlebars.js 和 Jade。这些模板引擎寻找某种标记,并根据一些变量替换内容。虽然我们不会直接查看这些模板引擎,但要知道它们存在,并且它们对于诸如代码文档生成或甚至根据某些 API 规范创建 JavaScript 文件等方面非常有用。

我们将实现自己的模板系统版本,而不是使用其中一个常见格式,以了解模板的工作原理。我们将尽量保持简单,因为我们希望为我们的服务器使用最少的依赖项。我们将使用一个名为Remarkable的 Markdown 到 HTML 转换器作为依赖项:github.com/jonschlinkert/remarkable。它依赖于两个库,每个库又依赖于一个库,因此我们将导入总共五个库。

虽然即时创建所有页面将使我们能够轻松进行更改,但除非我们处于开发环境中,否则我们不希望一直这样做。为了确保我们不一遍又一遍地构建 HTML 文件,我们将实现一个内存缓存来存储被请求最多的文件。

有了这些,让我们继续开始构建我们的应用程序,通过设置我们的服务器并发送响应。

启动我们的应用程序

首先,让我们通过在我们选择的文件夹中创建我们的package.json文件来设置我们的项目。我们可以从以下基本的package.json文件开始:

{
    "version" : "0.0.1",
    "name"    : "microserver",
    "type"    : "module"
}

现在应该相当简单了。主要的是将类型设置为module,这样我们就可以在 Node.js 中使用模块。接下来,让我们继续通过在放置package.json文件的文件夹中运行npm install remarkable来添加Remarkable依赖项。有了这个,我们现在应该在我们的package.json文件中列出remarkable作为一个依赖项。接下来,让我们继续设置我们的服务器。为此,创建一个main.js文件并执行以下操作:

  1. 导入http2fs模块,因为我们将使用它们来启动我们的服务器和读取我们的私钥和证书文件,如下所示:
import http2 from 'http2'
import fs from 'fs'
  1. 创建我们的服务器并读取我们的密钥和证书文件。我们将在设置主文件后生成这些文件,就像这样:
const server = http2.createSecureServer({
    key: fs.readFileSync('selfsignedkey.pem'),
    cert: fs.readFileSync('selfsignedcertificate.pem')
});
  1. 通过崩溃我们的服务器来响应错误事件(我们可能应该更好地处理这个问题,但现在这样做就可以了)。我们还将通过简单的消息和状态码200(表示一切正常)来处理传入的请求,就像这样:
server.on('error', (err) => {
    console.error(err);
    process.exit();
});
server.on('stream', (stream, headers) => {
    stream.respond({
       'content-type': 'text/html',
        ':status': 200
    });
    stream.end("A okay!");
});
  1. 最后,我们将开始监听端口50000(这里可以使用一个随机端口号)。

现在,如果我们尝试运行这个,我们应该会被类似以下的一个令人讨厌的错误消息所打招呼:

Error: ENOENT: no such file or directory, open 'selfsignedkey.pem'

我们还没有生成自签名的私钥和证书。请记住从第六章中了解到,我们不能在不安全的通道(HTTP)上提供任何内容;相反,我们必须使用 HTTPS。为此,我们需要从证书颁发机构获取证书,或者我们需要自己生成一个。从第六章中了解到,我们应该在我们的计算机上安装openssl应用程序。

  1. 让我们继续通过运行以下命令来生成它,并只需通过命令提示符按Enter
> openssl req -newkey rsa:2048 -nodes -keyout selfsignedkey.pem -x509 -days 365 -out selfsignedcertificate.pem

现在我们应该在当前目录中有这两个文件,现在,如果我们尝试运行我们的应用程序,我们应该有一个在端口50000上监听的服务器。我们可以通过访问以下地址来检查:127.0.0.1:50000。如果一切正常,我们应该看到消息 A okay!

虽然像端口、私钥和证书这样的变量在开发过程中硬编码是可以的,但我们仍然应该将它们移到我们的package.json文件中,这样另一个用户可以在一个地方进行更改,而不是必须进入代码并进行更改。让我们继续进行这些更改。在我们的package.json文件中,让我们添加以下字段:

"config" : {
    "port" : 50000,
    "key"  : "selfsignedkey.pem",
    "certificate" : "selfsignedcertificate.pem",
    "template" : "template",
    "body_files" : "publish"
},
"scripts" : {
   "start": "node --experimental-modules main.js"   
}

config部分将允许我们传递各种变量,让包的用户使用package.jsonconfig部分设置,或者在运行我们的文件时使用npm config set tinyserve:<variable>命令设置。正如我们从第五章中看到的,scripts部分允许我们访问这些变量,并允许我们的包的用户现在只需使用npm start,而不是使用node --experimental-modules main.js。有了这个,我们可以通过在我们的main.js文件中声明所有这些变量来改变我们的main.js文件,就像这样:

const ENV_VARS = process.env;
const port = ENV_VARS.npm_package_config_port || 80;
const key  = ENV_VARS.npm_package_config_key || 'key.pem';
const cert = ENV_VARS.npm_package_config_certificate || 'cert.pem';
const templateDirectory = ENV_VARS.npm_package_config_template || 'template';
const publishedDirectory = ENV_VARS.npm_package_config_bodyFiles || 'body';

所有配置变量都可以在我们的process.env变量中找到,因此我们在文件顶部声明了一个快捷方式。 然后,我们可以访问各种变量,就像我们在第五章中看到的那样,切换上下文-没有 DOM,不同的 Vanilla。 我们还设置了默认值,以防用户没有使用我们声明的npm start脚本运行我们的文件。 用户还会注意到我们声明了一些额外的变量。 这些是我们稍后会讨论的变量,但它们涉及到我们要超链接到的位置以及我们是否要启用缓存(开发变量)。 接下来,我们将看一下我们将如何访问我们想要设置的模板系统。

设置我们的模板系统

我们将使用 Markdown 来托管我们想要托管的各种内容,但我们将希望在所有文章中使用某些部分。 这些将是我们页面的页眉、页脚和侧边栏等内容。 我们可以将这些内容模板化,而不必将它们插入到我们为文章创建的所有 Markdown 文件中。

我们将把这些部分放在一个在运行时将被知道的文件夹中,通过我们声明的templateDirectory变量。 这也将允许我们包的用户更改我们的静态站点服务器的外观和感觉,而无需做任何太疯狂的事情。 让我们继续创建模板部分的目录结构。 这应该看起来像下面这样:

  • 模板:我们应该在所有页面中查找静态内容

  • HTML:我们所有静态 HTML 代码将放在这里

  • CSS:我们的样式表将存放在这里

有了这个目录结构,我们现在可以创建一些基本的页眉、页脚和侧边栏 HTML 文件,以及一些基本的层叠样式表CSS),以获得一个对每个人都应该熟悉的页面结构。 所以,让我们开始,如下所示:

  1. 我们将编写header HTML,如下所示:
<header>
    <h1>Our Website</h1>
    <nav>
        <a href="/all">All Articles</a>
        <a href="/contact">Contact Us</a>
        <a href="/about">About Us</a>
    </nav>
</header>

有了这个基本结构,我们有了网站的名称,然后是大多数博客网站都会有的一些链接。

  1. 接下来,让我们创建footer部分,就像这样:
<footer>
    <p>Created by: Me</p>
    <p>Contact: <a href="mailto:me@example.com">Me</a></p>
</footer>
  1. 再次,相当容易理解。 最后,我们将创建侧边栏,如下所示:
<nav>
    <% loop 5
    <a href="article/${location}">${name}</a>
    %>
</nav>

这就是我们的模板引擎发挥作用的地方。 首先,我们将使用<% %>字符模式来表示我们要用一些静态内容替换它。 接下来,loop <number>将让我们的模板引擎知道我们计划在停止引擎之前循环一定次数的下一个内容。 最后,<a href="article/${location}">${name}</a>模式将告诉我们的模板引擎这是我们要放入的内容,但我们将要用我们在代码中传递的对象中的变量替换${}标签。

接下来,让我们继续创建我们页面的基本 CSS,如下所示:

*, html {
    margin : 0;
    padding : 0;
}
:root {
   --main-color : "#003A21"; 
   --text-color : "#efefef";
}
/* header styles */
header {
    background : var(--main-color);
    color      : var(--text-color);
}
/* Footer styles */
footer {
    background : var(--main-color);
    color  : var(--text-color);
}

由于大部分是样板代码,CSS 文件已经被剪切了。 值得一提的是自定义变量。 使用 CSS,我们可以通过使用模式--<name> : <content>声明自定义变量,然后我们可以在 CSS 文件中使用var()声明来使用它。 这使我们能够重用变量,如颜色和高度,而无需使用预处理器,如SASS

CSS 变量是有作用域的。 这意味着如果您为header部分定义变量,它将仅在header部分中可用。 这就是为什么我们决定将我们的颜色放在:root伪元素级别,因为它将在整个页面中可用。 只需记住,CSS 变量的作用域类似于我们在 JavaScript 中声明的letconst变量。

有了我们的 CSS 布局,我们现在可以在我们的template文件中编写我们的主 HTML 文件。我们将把这个文件移到 HTML 文件夹之外,因为这是我们想要的主文件,以便把所有东西放在一起。这也会让我们的包的用户知道这是我们将用来组合所有部分的主文件,如果他们想要改变它,他们应该在这里做。现在,让我们创建一个看起来像下面这样的main.html文件:

<!DOCTYPE html>
<html>
    <head>
        <link rel="stylesheet"  type="text/css" href="css/main.css" />
    </head>
    <body>
        <% from html header %>
        <% from html sidebar %>
        <% from html footer %>
    </body>
</html>

顶部部分应该看起来很熟悉,但是现在我们有了一个新的模板类型。from指令让我们知道我们将从其他地方获取这个文件。下一个语句表示它是一个HTML文件,所以我们将在HTML文件夹中查找。最后,我们看到文件的名称,所以我们知道我们要引入header.html文件。

有了所有这些,我们现在可以编写我们将用来构建页面的模板系统。我们将利用Transform流来实现我们的模板系统。虽然我们可以利用类似Writable流的东西,但是利用Transform流更有意义,因为我们根据一些输入条件改变输出。

要实现Transform流,我们需要跟踪一些东西,这样我们才能正确处理我们的键。首先,让我们读取并发送适当的块进行处理。我们可以通过实现transform方法并输出我们要替换的块来实现这一点。为此,我们将执行以下操作:

  1. 我们将扩展一个Transform流并设置基本结构,就像我们在第七章中所做的那样,流-理解流和非阻塞 I/O。我们还将创建一个自定义类来保存缓冲区的开始和结束位置。这将允许我们知道我们是否在同一个循环中得到了模式匹配的开始。我们以后会需要这个。我们还将为我们的类设置一些私有变量,比如beginend模板缓冲区,以及#pattern变量等状态变量,如下所示:
import { Transform } from 'stream'
class Pair {
    start = -1
    end = -1
}
export default class TemplateBuilder extends Transform {
    #pattern = []
    #pair = new Pair()
    #beforePattern = Buffer.from("<%")
    #afterPattern = Buffer.from("%>")
    constructor(opts={}) {
        super(opts);
    }
    _transform(chunk, encoding, cb) {
        // process data
    }
}
  1. 接下来,我们将不得不检查我们的#pattern状态变量中是否保存了数据。如果没有,那么我们知道要寻找模板的开始。一旦我们检查到模板语句的开始,我们可以检查它是否实际上在这个数据块中。如果是,我们将#pairstart属性设置为这个位置,这样我们的循环就可以继续进行;否则,我们在这个块中没有模板,我们可以开始处理下一个块,如下所示:
// inside the _transform function
if(!this.#pattern.length && !this.#pair.start) {
    location = chunk.indexOf(this.#beforePattern, location);
    if( location !== -1 ) {
        this.#pair.start = location;
        location += 2;
    } else {
        return cb();
   }
}
  1. 要处理另一个条件(我们正在寻找模板的结尾),我们需要处理更多的状态。首先,如果我们的#pair变量的start不是-1(我们设置它),我们知道我们仍在处理当前的块。这意味着我们需要检查我们是否可以在当前块中找到end模板缓冲区。如果我们找到了,那么我们可以处理模式并重置我们的#pair变量。否则,我们只是将当前块从#pairstart成员位置推送到我们的#pattern持有者的块末端,如下所示:
if( this.#pair.start !== -1 ) {
    location = chunk.indexOf(this.#afterPattern, location);
    if( location !== -1 ) {
        this.#pair.end = location;
        this.push(processPattern(chunk.slice(this.#pair.start,this.#pair.end)));
        this.#pair = new Pair();
    } else {
        this.#pattern.push(chunk.slice(this.#pair.start));
    }
}
  1. 最后,如果#pairstart成员被设置,我们检查end模板模式。如果我们找不到它,我们只是将整个块推送到#pattern数组。如果我们找到它,我们就从它的开头切割块,直到我们找到我们的end模板字符串。然后我们将所有这些连接在一起并进行处理。然后我们还将我们的#pattern变量重置为什么都不持有,就像这样:
location = chunk.indexOf(this.#afterPattern, location);
if( location !== -1 ) {
    this.#pattern.push(chunk.slice(0, location));
    this.push(processPattern(Buffer.concat(this.#pattern)));
    this.#pattern = [];
} else {
    this.#pattern.push(chunk);
}
  1. 所有这些都将包装在一个do/while循环中,因为我们至少要运行这段代码一次,当我们的location变量是-1时,我们就知道我们已经完成了(这是从indexOf检查返回的,当它找不到我们想要的时)。在do/while循环之后,我们运行回调,告诉我们的流我们已经准备好处理更多数据,如下所示:
do {
  // transformation code
} while( location !== -1 );
cb();

将所有这些放在一起,我们现在有一个transform循环,应该处理几乎所有情况来获取我们的模板系统。我们可以通过将我们的main.html文件传递进去并将以下代码放入我们的processPattern方法中来测试这一点,就像这样:

console.log(pattern.toString('utf8'));
  1. 我们可以创建一个测试脚本来运行我们的main.html文件。继续创建一个test.js文件,并将以下代码放入其中:
import TemplateStream from './template.js';
const file = fs.createReadStream('./template/main.html');
const tStream = new TemplateStream();
file.pipe(tStream);

有了这个,我们应该得到一个漂亮的输出,其中包含我们正在寻找的模板语法,比如from html header. 如果我们通过sidebar.html文件运行它,它应该看起来像下面这样:

loop 5
    <a href="article"/${location}">${name}</a>

现在我们知道我们的Transform流的模板查找代码是有效的,我们只需要编写我们的处理块系统来处理我们之前的情况。

现在要处理这些块,我们需要知道在哪里查找文件。还记得之前我们在package.json文件中声明各种变量吗?现在,我们将利用templateDirectory。让我们将其作为流的参数传递进去,就像这样:

#template = null
constructor(opts={}) {
    if( opts.templateDirectory ) {
        this.#template = opts.templateDirectory;
    }
    super(opts);
}

现在,当我们调用processPattern时,我们可以将块和template目录作为参数传递。从这里,我们现在可以实现processPattern方法。我们将处理两种情况:当我们找到一个for循环和当我们找到一个find语句。

要处理for循环和find语句,我们将按以下步骤进行:

  1. 我们将构建一个缓冲区数组,除了for循环之外,它将是模板保存的内容。我们可以使用以下代码来实现这一点:
const _process = pattern.toString('utf8').trim();
const LOOP = "loop";
const FIND = "from";
const breakdown = _process.split(' ');
switch(breakdown[0]) {
    case LOOP:
        const num = parseInt(breakdown[1]);
        const bufs = new Array(num);
        for(let i = 0; i < num; i++) {             
           bufs[i] = Buffer.from(breakdown.slice(2).join(''));
        }
        break;
   case FIND:
        console.log('we have a find loop', breakdown);
        break;
   default:
        return new Error("No keyword found for processing! " + 
         breakdown[0]);
}
  1. 我们将查找循环指令,然后获取第二个参数,它应该是一个数字。如果我们打印出来,我们会看到我们有一堆填满相同数据的缓冲区。

  2. 接下来,我们需要确保填写所有的模板字符串位置。这些看起来像${<name>}的模式。为此,我们将在这个循环中添加另一个参数,用于指定我们想要使用的变量的名称。让我们将其添加到sidebar.html文件中,如下所示:

<% loop 5 articles
    <a href="article/${location}">${name}</a>
%>
  1. 有了这个,我们现在应该传入一个我们想要在模板系统中使用的变量列表——在这种情况下,一个名为articles的数组,其中包含具有locationname键的对象。这可能看起来像下面这样:
const tStream = new TemplateStream({
    templateDirectory,
    templateVariables : {
        sidebar : [
            {
                location : temp1,
                name     : 'article 1'
            }
        ]
    }
}

满足我们for循环条件的条件足够多,现在我们可以回到Transform流,并将其作为我们在构造函数中要处理的项目之一,并将其发送到我们的processPattern方法。一旦我们在这里添加了这些项目,我们将在for循环内更新我们的循环情况,使用以下代码:

const num = parseInt(breakdown[1]);
const bufs = new Array(num);
const varName = breakdown[2].trim();
for(let i = 0; i < num; i++) {
    let temp = breakdown.slice(3).join(' ');
    const replace = /\${([0-9a-zA-Z]+)}/
    let results = replace.exec(temp);           
    while( results ) {
        if( vars[varName][i][results[1]] ) {
            temp = temp.replace(results[0], vars[varName][i][results[1]]);
        }
       results = replace.exec(temp);                
    }
    bufs[i] = Buffer.from(temp);
}
return Buffer.concat(bufs);

我们的临时字符串包含我们认为是模板的所有数据,而varName变量告诉我们在我们传递给processPattern的对象中查找的位置以执行我们的替换策略。接下来,我们将使用正则表达式提取变量的名称。这个特定的正则表达式表示查找${<name>}模式,同时也表示捕获<name>部分的内容。这样我们就可以轻松地获取变量的名称。我们还将继续循环遍历模板,看看是否有更多的正则表达式符合这些条件。最后,我们将用我们存储的变量替换模板代码。

完成所有这些后,我们将所有这些缓冲区连接在一起并返回它们。这对于那段代码来说是很多的;幸运的是,我们的模板的from部分要容易处理得多。我们的模板代码的from部分只需从我们的templateDirectory变量中查找具有该名称的文件,并将其返回为缓冲形式。

它应该看起来像下面这样:

case FIND: {
    const type = breakdown[1];
    const HTML = 'html';
    const CSS  = 'css';
    if(!(type === HTML || type === CSS)) return new Error("This is not a
     valid template type! " + breakdown[1]);
    return fs.readFileSync(path.join(templateDirectory, type, `${breakdown[2]}.${type}`));
}

首先,我们从第二个参数中获取文件类型。如果不是HTMLCSS文件,我们将拒绝它。否则,我们将尝试读取文件并将其发送到我们的流中。

你们中的一些人可能会想知道我们将如何处理其他文件中的模板。现在,如果我们在main.html文件上运行我们的系统,我们将得到所有单独的块,但我们的sidebar.html文件没有填充。这是我们模板系统的一个弱点。解决这个问题的一种方法是创建另一个函数,它将调用我们的Transform流一定次数。这将确保我们为这些单独的部分完成模板。让我们现在就创建这个函数。

这不是处理这个问题的唯一方法。相反,我们可以利用另一个系统:当我们在文件中看到模板指令时,我们将该缓冲区添加到需要处理的项目列表中。这将允许我们的流处理指令,而不是一遍又一遍地循环缓冲区。这会导致它自己的问题,因为有人可能会编写一个无限递归的模板,这将导致我们的流中断。一切都是一种权衡,现在,我们选择编码的简易性而不是使用的简易性。

首先,我们需要从events模块中导入once函数和从stream模块中导入PassThrough流。让我们现在更新这些依赖关系,就像这样:

import { Transform, PassThrough } from 'stream'
import { once } from 'events'

接下来,我们将创建一个新的Transform流,它将带入与以前相同的信息,但现在,我们还将添加一个循环计数器。我们还将响应transform事件,并将其推送到一个私有变量,直到我们读取完整的起始模板为止,如下所示:

export class LoopingStream extends Transform {
    #numberOfRolls = 1
    #data = []
    #dir = null
    #vars = null
    constructor(opts={}) {
        super(opts);
        if( 'loopAmount' in opts ) {
            this.#numberOfRolls = opts.loopAmount
        }
        if( opts.vars ) {
            this.#vars = opts.vars;
        }
        if( opts.dir) {
            this.#dir = opts.dir;
        }
    }
    _transform(chunk, encoding, cb) {
        this.#data.push(chunk);
        cb();
    }
    _flush(cb) {
    }
}

接下来,我们将使我们的flush事件async,因为我们将利用一个异步for循环,就像这样:

async _flush(cb) {
    let tData = Buffer.concat(this.#data);
    let tempBuf = [];
    for(let i = 0; i < this.#numberOfRolls; i++) {
        const passThrough = new PassThrough();
        const templateBuilder = new TemplateBuilder({ templateDirectory :
        this.#dir, templateVariables : this.#vars });
        passThrough.pipe(templateBuilder);
        templateBuilder.on('data', (data) => {
            tempBuf.push(data);
        });
        passThrough.end(tData);
        await once(templateBuilder, 'end');
        tData = Buffer.concat(tempBuf);
        tempBuf = [];
    }
    this.push(tData);
    cb();
}

基本上,我们将把所有的初始模板数据放在一起。然后,我们将通过我们的TemplateBuilder运行这些数据,构建一个新的模板来运行。我们利用await once(templateBuilder, ‘end')系统让我们以同步的方式处理这段代码。一旦我们完成了计数,我们将输出数据。

我们可以使用旧的测试工具来测试这一点。让我们继续设置它来利用我们的新的Transform流,并将数据输出到文件,如下所示:

const file = fs.createReadStream('./template/main.html');
const testOut = fs.createWriteStream('test.html');
const tStream = new LoopingStream({
    dir : templateDirectory,
    vars : { //removed for simplicity sake },
    loopAmount : 2
});
file.pipe(tStream).pipe(testOut);

如果我们现在运行这个,我们会注意到test.html文件包含了我们完全构建的template文件!我们现在有一个可以使用的模板系统。让我们把它连接到我们的服务器上。

设置我们的服务器

有了我们的模板系统工作,让我们继续把所有这些连接到我们的服务器上。现在不再简单地回复“一切正常!”,而是用我们的模板回复。我们可以通过运行以下代码轻松实现这一点:

stream.respond({
        'content-type': 'text/html',
        ':status': 200
    });
    const file = fs.createReadStream('./template/main.html');
    const tStream = new LoopingStream({
        dir: templateDirectory,
        vars : { //removed for readability }
},
        loopAmount : 2
    })
    file.pipe(tStream).pipe(stream);
});

这应该几乎和我们的测试工具一模一样。如果我们现在转到https://localhost:50000,我们应该会看到一个非常基本的 HTML 页面,但我们已经创建了我们的模板文件!如果我们现在进入开发工具并查看源代码,我们会看到一些奇怪的东西。CSS 表明我们加载了我们的main.css文件,但文件的内容看起来和我们的 HTML 文件完全一样!

我们的服务器对每个请求都以我们的 HTML 文件进行响应!我们需要做的是一些额外的工作,让我们的服务器能够正确地响应请求。我们将通过将请求的 URL 映射到我们拥有的文件来实现这一点。为了简单起见,我们只会响应 HTML 和 CSS 请求(我们不会发送任何 JavaScript),但是这个系统可以很容易地添加返回类型的图片,甚至文件。我们将通过以下方式添加所有这些:

  1. 我们将为我们的文件结尾设置一个查找表,就像这样:
const FILE_TYPES = new Map([
    ['.css', path.join('.', templateDirectory, 'css')],
    ['.html', path.join('.', templateDirectory, 'html')]
]);
  1. 接下来,我们将使用这个映射根据请求的headers来拉取文件,就像这样:
const p = headers[':path'];
for(const [fileType, loc] of FILE_TYPES) {
    if( p.endsWith(fileType) ) {
        stream.respondWithFile(
            path.join(loc, path.posix.basename(p)),
            {
                'content-type': `text/${fileType.slice(1)}`,
                ':status': 200
            }
        );
        return;
    }     
}

基本思想是循环遍历我们支持的文件类型,看看我们是否有这些文件。如果有,我们将用文件进行响应,并通过content-type头告诉浏览器它是 HTML 文件还是 CSS 文件。

  1. 现在,我们需要一种方法来判断请求是否良好。目前,我们可以转到任何 URL,我们将一遍又一遍地得到相同的响应。我们将利用publishedDirectory环境变量来实现这一点。根据其中的文件名,这些将是我们的端点。对于每个子 URL 模式,我们将寻找遵循相同模式的子目录。如下所示:
https:localhost:50000/articles/1 maps to <publishedDirectory>/articles/1.md

.md扩展名表示它是一个 Markdown 文件。这就是我们将编写页面的方式。

  1. 现在,让我们让这个映射工作。为此,我们将在我们的for循环下面放入以下代码:
try {
    const f = fs.statSync(path.join('.', publishedDirectory, p));
    stream.respond({
        'content-type': 'text/html',
        ':status': 200
    });
    const file = fs.createReadStream('./template/main.html');
    const tStream = new LoopingStream({
        dir: templateDirectory,
        vars : { },
        loopAmount : 2
    })
    file.pipe(tStream).pipe(stream);
} catch(e) {
    stream.respond({
        'content-type': 'text/html',
        ':status' : 404
    });
    stream.end('File Not Found! Turn Back!');
    console.warn('following file requested and not found! ', p);
}

我们将用try/catch块包装我们查找文件的方法(fs.statSync)。如果出现错误,这通常意味着我们没有找到文件,我们将向用户发送一个404消息。否则,我们将发送我们一直发送的内容:我们的示例template。如果我们现在运行服务器,我们将收到以下消息:文件未找到!回头吧!我们在那个目录中什么都没有!

让我们继续创建目录,并添加一个名为first.md的文件。如果我们添加这个目录和文件并重新运行服务器,如果我们转到https://localhost:50000/first,我们仍然会收到错误消息!我们之所以会收到这个消息,是因为在检查文件时我们没有添加 Markdown 文件扩展名!让我们继续将其添加到fs.statSync检查中,如下所示:

const f = fs.statSync(path.join('.', publishedDirectory, `${p}.md`));

现在,当我们重新运行服务器时,我们将看到以前的正常模板。如果我们向first.md文件添加内容,我们将得不到该文件。现在我们需要将此添加到我们的模板系统中。

还记得在本章开头我们添加了npmremarkable吗?现在我们将添加 Markdown 渲染器remarkable,以及我们的模板语言将寻找的新关键字,以渲染 Markdown,如下所示:

  1. 让我们将Remarkable作为一个导入添加到我们的template.js文件中,就像这样:
import Remarkable from 'remarkable'
  1. 我们将寻找以下指令来将 Markdown 文件包含到<% file <filename> %>模板中,就像这样:
const processPattern = function(pattern, templateDir, publishDir, vars=null) {
    const process = pattern.toString('utf8').trim();
    const LOOP = "loop";
    const FIND = "from";
    const FILE = "file";
    const breakdown = process.split(' ');
    switch(breakdown[0]) {
      // previous case statements removed for readability
        case FILE: {
            const file = breakdown[1];
            return fs.readFileSync(path.join(publishDir, file));
        }
        default:
            return new Error("Process directory not found! " +  
             breakdown[0]);
    }
}
  1. 现在,我们需要在构造函数中的Transform流的可能选项中添加publishDir变量,如下所示:
export default class TemplateBuilder extends Transform {
    #pattern = []
    #publish = null
    constructor(opts={}) {
        super(opts);
        if( opts.publishDirectory ) {
            this.#publish = opts.publishDirectory;
        }
    }
    _transform(chunk, encoding, cb) {
        let location = 0;
        do {
            if(!this.#pattern.length && this.#pair.start === -1 ) {
                // code from before
            } else {
                if( this.#pair.start !== -1 ) {
                        this.push(processPattern(chunk.slice(this.#pair.start,
this.#pair.end), this.#template, this.#publish, this.#vars)); //add publish to our processPattern function
                } 
            } 
        } while( location !== -1 );
    }
}

记住:为了使其更易于阅读,这些示例中删除了大量代码。要获取完整的示例,请转到本书的代码存储库。

  1. 创建一个LoopingStream类,它将循环并运行TemplateBuilder
export class LoopingStream extends Transform {
    #publish = null
    constructor(opts={}) {
        super(opts);
        if( opts.publish ) {
            this.#publish = opts.publish;
        }
    }
    async _flush(cb) {
        for(let i = 0; i < this.#numberOfRolls; i++) {
            const passThrough = new PassThrough();
            const templateBuilder = new TemplateBuilder({
                templateDirectory : this.#dir,
                templateVariables : this.#vars,
                publishDirectory  :this.#publish
            });
        }
        cb();
    }
}
  1. 我们需要使用以下模板化行更新我们的模板:
<!DOCTYPE html>
<html>
    <head>
        <link rel="stylesheet"  type="text/css" href="css/main.css" />
    </head>
    <body>
        <% from html header %>
        <% from html sidebar %>
        <% file first.md %>
        <% from html footer %>
    </body>
</html>
  1. 最后,我们需要将publish目录传递给服务器的流。我们可以通过以下代码进行此操作:
const tStream = new LoopingStream({
        dir: templateDirectory,
        publish: publishedDirectory,
        vars : {
}});

有了所有这些,我们应该从服务器那里得到一些不仅仅是我们的基本模板。如果我们向文件中添加了一些 Markdown,我们应该只看到带有我们模板的 Markdown。现在我们需要确保这个 Markdown 被处理。让我们回到我们的转换方法,并调用Remarkable方法,以便它处理 Markdown 并以 HTML 的形式返回给我们,如下面的代码块所示:

const MarkdownRenderer = new Remarkable.Remarkable();
const processPattern = function(…) {
      switch(breakdown[0]) {
            case FILE: {
                  const file = breakdown[1];
                  const html =
MarkdownRenderer.render(fs.readfileSync(path.join(publishDir, file)
).toString('utf8'));
            return Buffer.from(html);
            }
      }
}

通过这个改变,我们现在有了一个通用的 Markdown 解析器,它使我们能够获取我们的模板文件,并将它们与我们的main.html文件一起发送。为了使模板系统和静态服务器正常运行,我们需要做的最后一个改变是,确保main.html文件不再具有精确的模板,而是具有我们想要的指令状态,以便在那里放置一个文件,并且我们的模板系统将放置在我们流构造函数中声明的文件。我们可以通过以下更改轻松实现这一点:

  1. 在我们的template.js文件中,我们将利用一个名为fileToProcess的独特变量。我们以与我们通过传递的vars获取sidebar.html文件要处理的变量相同的方式获取它。如果我们没有来自fileToProcess变量的文件,我们将利用我们在template指令的第二部分中拥有的文件,如下面的代码块所示:
case FILE: {
    const file = breakdown[1];
    const html =
    MarkdownRenderer.render(fs.readFileSync(path.join(publishDir,  
    vars.fileToProcess || file)).toString('utf8'));
    return Buffer.from(html);
}
  1. 我们需要将这个变量从我们的服务器传递到流中,就像这样:
const p = headers[':path'];
const tStream = new LoopingStream({
    dir: templateDirectory,
    publish: publishedDirectory,
    vars : {
        articles : [ ],
        fileToProcess : `${p}.md`
    },
    loopAmount : 2
});
  1. 我们将进行的最后一个改变是改变html文件,为我们没有的页面创建一个新的基本 Markdown 文件。这可以让我们为根 URL 创建一个基本页面。我们不会实现这一点,但这是我们可以这样做的一种方式:
<body>
    <% from html header %>
    <% from html sidebar %>
    <% file base.md %>
    <% from html footer %>
</body>

有了这个改变,如果我们现在运行我们的服务器,我们就有了一个完全功能的模板系统,支持 Markdown!这是一个了不起的成就!然而,我们需要向我们的服务器添加两个功能,以便它能够处理更多的请求并快速处理相同的请求。这些功能是缓存和集群。

添加缓存和集群

首先,我们将通过向我们的服务器添加缓存来开始。我们不希望不断重新编译我们以前已经编译过的页面。为此,我们将实现一个围绕地图的类。这个类将同时跟踪 10 个文件。我们还将实现文件上次使用的时间戳。当我们达到第十一个文件时,我们将看到它不在缓存中,并且我们已经达到了我们可以在缓存中保存的文件的最大数量。我们将用时间戳最早的文件替换编译后的页面。

这被称为最近最少使用LRU)缓存。还有许多其他类型的缓存策略,比如生存时间TTL)缓存。这种缓存类型将消除在缓存中时间过长的文件。这是一种很好的缓存类型,当我们一遍又一遍地使用相同的文件,但当服务器有一段时间没有被访问时,我们最终希望释放空间。LRU 缓存将始终保留这些文件,即使服务器已经有好几个小时没有被访问。我们可以实现两种缓存策略,但现在我们只实现 LRU 缓存。

首先,我们将创建一个名为cache.js的新文件。在这里,我们将执行以下操作:

  1. 创建一个新的类。我们不需要扩展任何其他类,因为我们只是在 JavaScript 内置的Map数据结构周围编写一个包装器,如下面的代码块所示:
export default class LRUCache {
    #cache = new Map()
}
  1. 然后我们将有一个构造函数,它将接受我们想要在缓存中存储的文件数量,然后使用我们的策略来替换其中一个文件,就像这样:
#numEntries = 10
constructor(num=10) {
    this.#numEntries = num
}
  1. 接下来,我们将向我们的缓存添加add操作。它将接受我们页面的缓冲形式和我们用来获取它的 URL。键将是 URL,值将是我们页面的缓冲形式,如下面的代码块所示:
add(file, url) {
    const val = {
        page : file,
        time : Date.now()
    }
    if( this.#cache.size === this.#numEntries ) {
        // do something
        return;
    }
    this.#cache.set(url, val);
}
  1. 然后,我们将实现get操作,通过它我们尝试根据 URL 获取文件。如果我们没有它,我们将返回null。如果我们检索到一个文件,我们将更新时间,因为这将被视为最新的页面抓取。如下所示:
get(url) {
    const val = this.#cache.get(url);
    if( val ) {
        val.time = Date.now();
        this.#cache.set(url, val);
        return val.page;
    }
    return null;
}
  1. 现在,我们可以更新我们的add方法的if语句。如果我们达到了限制,我们将遍历我们的地图,看看最短的时间是什么。我们将删除那个文件,并用新创建的文件替换它,就像这样:
if( this.#cache.size === this.#numEntries ) {
    let top = Number.MAX_VALUE;
    let earliest = null;
    for(const [key, val] of this.#cache) {
        if( val.time < top ) {
            top = val.time;
            earliest = key;
        }
    }
    this.#cache.delete(earliest);
}

现在我们已经为我们的文件建立了一个基本的 LRU 缓存。要将其附加到我们的服务器上,我们需要将其放在我们的管道中间:

  1. 让我们回到主文件并导入这个文件:
import cache from './cache.js'
const serverCache = new cache();
  1. 现在我们将稍微改变我们的流处理程序中的逻辑。如果我们注意到 URL 是我们在缓存中有的东西,我们将只是获取数据并将其传送到我们的响应中。否则,我们将编译模板,将其设置在我们的缓存中,并将编译后的版本传送下来,就像这样:
const cacheHit = serverCache.get(p);
if( cacheHit ) {
    stream.end(cacheHit);
} else {
    const file = fs.createReadStream('./template/main.html');
    const tStream = new LoopingStream({
        dir: templateDirectory,
        publish: publishedDirectory,
        vars : { /* shortened for readability */ },
        loopAmount : 2
    });
    file.pipe(tStream);
    tStream.once('data', (data) => {
        serverCache.add(data, p);
        stream.end(data);
    });
}

如果我们尝试运行上述代码,我们现在将看到如果我们两次访问相同的页面,我们将从缓存中获取文件;如果我们第一次访问它,它将通过我们的模板流进行编译,然后将其设置在缓存中。

  1. 为了确保我们的替换策略有效,让我们将缓存的大小设置为只有1,看看如果我们访问一个新的 URL,我们是否不断替换文件,如下所示:
const serverCache = new cache(1);

如果我们现在在每个方法被调用时记录我们的缓存,我们将看到当我们访问新页面时,我们正在替换文件,但如果我们停留在同一个页面,我们只是发送缓存的文件回去。

现在我们已经添加了缓存,让我们在服务器上再添加一个部分,这样我们就可以处理大量的连接。我们将添加cluster模块,就像我们在第六章中所做的那样,消息传递-了解不同类型。我们将按照以下步骤进行:

  1. 让我们在main.js文件中导入cluster模块:
import cluster from 'cluster'
  1. 现在我们将在主进程中初始化服务器。对于其他进程,我们将处理请求。

  2. 现在,让我们改变策略,处理子进程内部的传入请求,就像这样:

if( cluster.isMaster ) {
    const numCpus = os.cpus().length;
    for(let i = 0; i < numCpus; i++) {
        cluster.fork();
    }
    cluster.on('exit', (worker, code, signal) => {
        console.log(`worker ${worker.process.pid} died`);
    });
} else {
    const serverCache = new cache();
    // all previous server logic
}

通过这个单一的改变,我们现在可以在四个不同的进程之间处理请求。就像我们在第六章中学到的那样,消息传递-了解不同类型,我们可以为cluster模块共享一个端口。

总结

虽然还有一个部分需要添加(将我们的侧边栏连接到实际文件),但这应该是一个非常通用的模板服务器。需要做的就是修改我们的FILE模板,并将其连接到我们模板系统的侧边栏。通过我们对 Node.js 的学习,我们应该能够处理几乎任何类型的服务器端应用程序。我们还应该能够理解像 Express 这样的 Web 服务器是如何从这些基本构建块中创建的。

从这里,我们将回到浏览器,并将书中这部分学到的一些概念应用到接下来的几章中。我们将首先看一下浏览器中的工作线程,即专用工作线程。然后我们将看一下共享工作线程,以及我们如何从这些工作线程中获益,但仍然能够从中获取数据。最后,我们将看一下服务工作者,并看看它们如何帮助我们进行各种优化,比如在浏览器中进行缓存。

第十章:工作者-学习专用和共享工作者

在过去的几章中,我们专注于 Node.js 以及如何利用与前端相同的语言编写后端应用程序。我们已经看到了创建服务器、卸载任务和流式传输的各种方法。在这一部分,我们将专注于浏览器的任务卸载方面。

最终,正如我们在 Node.js 中所看到的,我们需要将一些计算密集型任务从主线程转移到单独的线程或进程,以确保我们的应用程序保持响应。服务器不响应的影响可能相当令人震惊,而用户界面不工作的影响对大多数用户来说是非常令人反感的。因此,我们有了 Worker API。

在本章中,我们将专门研究两种工作方式,即专用和共享。总的来说,我们将做以下工作:

  • 学会通过 Worker API 将繁重的处理任务转移到工作线程。

  • 学习如何通过postMessageBroadcastChannel API 与工作线程进行通信。

  • 讨论ArrayBufferTransferrable属性,以便我们可以快速在工作者和主线程之间移动数据。

  • 查看SharedWorker和 Atomics API,看看我们如何在应用程序的多个选项卡之间共享数据。

  • 查看利用前几节知识的共享缓存的部分实现。

技术要求

完成本章需要以下项目:

将工作转移到专用工作者

工作者使我们能够将长时间运行的计算密集型任务转移到后台。我们不必再担心我们的事件循环是否被某种繁重的任务填满,我们可以将该任务转移到后台线程。

在其他语言/环境中,这可能看起来像以下内容(这只是伪代码,实际上与任何语言都没有真正联系):

Thread::runAsync((data) -> {
   for(d : data) { //do some computation }
});

虽然这在这些环境中运行良好,但我们必须开始考虑诸如死锁、僵尸线程、写后读等主题。所有这些都可能非常难以理解,通常是可以遇到的最困难的错误之一。JavaScript 没有给我们提供利用类似前述的能力,而是给了我们工作者,这给了我们另一个上下文来工作,我们在那里不会遇到相同的问题。

对于那些感兴趣的人,操作系统或 Unix 编程的书籍可以帮助解决上述问题。这些主题超出了本书的范围,但它们非常有趣,甚至有一些语言正在尝试通过将解决方案构建到语言中来解决这些问题。其中一些例子是 Go(golang.org/),它使用消息传递技术,以及 Rust(www.rust-lang.org/),它利用借用检查等概念来最小化这些问题。

首先,让我们以在后台进行工作的示例开始,我们将生成一个Worker并让它计算 100 万个数字的总和。为此:

  1. 我们在 HTML 文件中添加以下script部分:
<script type="text/javascript">
    const worker = new Worker('worker.js');
    console.log('this is on the main thread');
</script>
  1. 我们为我们的Worker创建一个 JavaScript 文件,并添加以下内容:
let num = 0;
for(let i = 0; i < 1000000; i++) {
    num += i;
}

如果我们启动 Chrome,我们应该看到打印出两条消息-一条说它在主线程上运行,另一条显示值为 499999500000。我们还应该看到其中一条是由 HTML 文件记录的,另一条是由工作者记录的。我们刚刚生成了一个工作者,并让它为我们做了一些工作!

请记住,如果我们想从我们的文件系统运行 JavaScript 文件而不是服务器,我们需要关闭所有 Chrome 的实例,然后从命令行重新启动它,使用chrome.exe –-allow-file-access-from-files。这将使我们能够从文件系统启动我们的外部 JavaScript 文件,而不需要服务器。

让我们继续做一些用户可能想做的更复杂的事情。一个有趣的数学问题是得到一个数字的质因数分解。这意味着,当给定一个数字时,我们将尝试找到组成该数字的所有质数(只能被 1 和它自己整除的数字)。一个例子是 12 的质因数分解,即 2、2 和 3。

这个问题导致了密码学的有趣领域以及公钥/私钥的工作原理。基本的理解是,给定两个相对较大的质数,将它们相乘很容易,但根据时间限制,从它们的乘积中找到这两个数字是不可行的。

回到手头的任务,我们将在用户将数字输入到输入框后生成一个worker。我们将计算该数字并将其记录到控制台。所以让我们开始:

  1. 我们在 HTML 文件中添加一个输入,并更改代码以在输入框的更改事件上生成一个worker
<input id="in" type="number" />
<script type="text/javascript">
document.querySelector("#in").addEventListener('change', (ev) => {
    const worker = new Worker('worker.js', {name : 
     ev.target.value});
});
</script>
  1. 接下来,我们将在worker中获取我们的名字,并将其用作输入。从那里,我们将运行在www.geeksforgeeks.org/print-all-prime-factors-of-a-given-number/找到的质因数分解算法,但转换为 JavaScript。完成后,我们将关闭worker
let numForPrimes = parseInt(self.name);
const primes = [];
console.log('we are looking for the prime factorization of: ', numForPrimes);
while( numForPrimes % 2 === 0 ) {
    primes.push(2);
    numForPrimes /= 2;
}
for(let i = 3; i <= Math.sqrt(numForPrimes); i+=2) {
    while( numForPrimes % i === 0 ) {
        primes.push(i);
        numForPrimes /= i;
    }
}
if( numForPrimes > 2 ) {
    primes.push(numForPrimes);
}
console.log('prime factorization is: ', primes.join(" "));
self.close();

如果我们现在在浏览器中运行这个应用程序,我们会看到在每次输入后,我们会在控制台中得到控制台日志消息。请注意,数字 1 没有因子。这是一个数学原因,但请注意数字 1 没有质因数分解。

我们可以对一堆输入运行这个,但如果我们输入一个相对较大的数字,比如123,456,789,它仍然会在后台计算,因为我们在主线程上做事情。现在,我们目前通过 worker 的名称向 worker 传递数据。必须有一种方法在 worker 和主线程之间传递数据。这就是postMessageBroadcastChannelAPI 发挥作用的地方!

在我们的应用程序中移动数据

正如我们在 Node.js 的worker_thread模块中看到的,有一种方法可以与我们的 worker 通信。这是通过postMessage系统。如果我们看一下方法签名,我们会发现它需要一个消息,可以是任何 JavaScript 对象,甚至带有循环引用的对象。我们还看到另一个名为 transfer 的参数。我们稍后会深入讨论这一点,但正如其名称所示,它允许我们实际传输数据,而不是将数据复制到 worker。这是一个更快的数据传输机制,但在利用它时有一些注意事项,我们稍后会讨论。

让我们以我们一直在构建的例子为例,并回应从前端发送的消息:

  1. 我们将在每次更改事件发生时创建一个新的worker并立即创建一个。然后,在更改事件上,我们将通过postMessage将数据发送到worker:*
const dedicated_worker = new Worker('worker.js', {name : 'heavy lifter'});
document.querySelector("#in").addEventListener('change', (ev) => {
    dedicated_worker.postMessage(parseInt(ev.target.value));
});
  1. 如果我们现在尝试这个例子,我们将不会从主线程收到任何东西。我们必须响应 worker 的全局描述符self上的onmessage事件。让我们继续添加我们的处理程序,并删除self.close()方法,因为我们想保留它:
function calculatePrimes(val) {
    let numForPrimes = val;
    const primes = [];
    while( numForPrimes % 2 === 0 ) {
        primes.push(2);
        numForPrimes /= 2;
    }
    for(let i = 3; i <= Math.sqrt(numForPrimes); i+=2) {
        while( numForPrimes % i === 0 ) {
            primes.push(i);
            numForPrimes /= i;
        }
    }
    if( numForPrimes > 2 ) {
        primes.push(numForPrimes);
    }
    return primes;
}
self.onmessage = function(ev) {
    console.log('our primes are: ', calculatePrimes(ev.data).join(' '));
}

从这个例子中可以看出,我们已经将素数的计算移到了一个单独的函数中,当我们收到消息时,我们获取数据并将其传递给calculatePrimes方法。现在,我们正在使用消息系统。让我们继续为我们的示例添加另一个功能。不要打印到控制台,让用户根据他们的输入得到一些反馈:

  1. 我们将在输入框下面添加一个段落标签来保存我们的答案:
<p>The primes for the number is: <span id="answer"></span></p>
<script type="text/javascript">
    const answer = document.querySelector('#answer');
    // previous code here
</script>
  1. 现在,我们将在workeronmessage处理程序中添加一些内容,就像我们在worker内部所做的那样,以监听来自worker的事件。当我们收到一些数据时,我们将用返回的值填充答案:
dedicated_worker.onmessage = function(ev) {
    answer.innerText = ev.data;
}
  1. 最后,我们将更改我们的worker代码,利用postMessage方法将数据发送回主线程:
self.onmessage = function(ev) {
    postMessage(calculatePrimes(ev.data).join(' '));
}

这也展示了我们不需要添加self来调用全局范围的方法。就像窗口是主线程的全局范围一样,self是工作线程的全局范围。

通过这个例子,我们已经探讨了postMessage方法,并看到了如何在工作线程和生成它的线程之间发送数据,但如果我们有多个选项卡想要进行通信怎么办?如果我们有多个工作线程想要发送消息怎么办?

处理这个问题的一种方法是跟踪所有的工作线程,并循环遍历它们,像下面这样发送数据:

const workers = [];
for(let i = 0; i < 5; i++) {
    const worker = new Worker('test.js', {name : `worker${i}`});
    workers.push(worker);
}
document.querySelector("#in").addEventListener('change', (ev) => {
    for(let i = 0; i < workers.length; i++) {
        workers[i].postMessage(ev.target.value);
    }
});

test.js文件中,我们只是控制台记录消息,并说明我们正在引用的工作线程的名称。这可能很快失控,因为我们需要跟踪哪些工作线程仍然存活,哪些已经被移除。处理这个问题的另一种方法是在一个通道上广播数据。幸运的是,我们有一个名为BroadcastChannel的 API 可以做到这一点。

正如 MDN 网站上的文档所述(developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API),我们只需要通过将单个参数传递给它的构造函数来创建一个BroadcastChannel对象,即通道的名称。谁先调用它就创建了通道,然后任何人都可以监听它。发送和接收数据就像我们的postMessageonmessage示例一样简单。以下是我们先前用于测试界面的代码,而不需要跟踪所有工作线程,只需广播数据出去:

const channel = new BroadcastChannel('workers');
document.querySelector("#in").addEventListener('change', (ev) => {
    channel.postMessage(ev.target.value);
});

然后,在我们的workers中,我们只需要监听BroadcastChannel,而不是监听我们自己的消息处理程序:

const channel = new BroadcastChannel('workers');
channel.onmessage = function(ev) {
    console.log(ev.data, 'was received by', name);
}

现在,我们已经简化了在多个工作线程和甚至多个具有相同主机的选项卡之间发送和接收消息的过程。这个系统的优点在于,我们可以根据一些标准让一些工作线程监听一个通道,而让其他工作线程监听另一个通道。然后,我们可以有一个全局通道发送命令,任何工作线程都可以响应。让我们继续对我们的素数程序进行简单的调整。我们将不再将数据发送到单独的工作线程,而是将有四个工作线程;其中两个将处理偶数,另外两个将处理奇数:

  1. 我们更新我们的主要代码以启动四个工作线程。我们将根据数字是偶数还是奇数来命名它们:
for(let i = 0; i < 4; i++) {
    const worker = new Worker('worker.js', 
        {name : `worker ${i % 2 === 0 ? 'even' : 'odd'}`}
    );
}
  1. 我们更改了输入后发生的事情,将偶数发送到偶数通道,将奇数发送到奇数通道:
document.querySelector("#in").addEventListener('change', (ev) => {
    const value = parseInt(ev.target.value);
    if( value % 2 === 0 ) {
        even_channel.postMessage(value);
    } else {
        odd_channel.postMessage(value);
    }
});
  1. 我们创建三个通道:一个用于偶数,一个用于奇数,一个用于全局发送给所有工作线程:
const even_channel = new BroadcastChannel('even');
const odd_channel = new BroadcastChannel('odd');
const global = new BroadcastChannel('global');
  1. 我们添加一个新按钮来终止所有工作线程,并将其连接到全局通道上广播:
<button id="quit">Stop Workers</button>
<script type="text/javascript">
document.querySelector('#quit').addEventListener('click', (ev) => {
     global.postMessage('quit');
});
</script>
  1. 我们更改我们的工作线程以根据其名称处理消息:
const mainChannelName = name.includes("odd") ? "odd" : "even";
const mainChannel = new BroadcastChannel(mainChannelName);
  1. 当我们在这些通道中的一个上收到消息时,我们会像以前一样做出响应:
mainChannel.onmessage = function(ev) {
    if( typeof ev.data === 'number' )
        this.postMessage(calculatePrimes(ev.data));
}
  1. 如果我们在全局通道上收到消息,我们检查它是否是quit消息。如果是,就终止工作线程:
const globalChannel = new BroadcastChannel('global');
globalChannel.onmessage = function(ev) {
    if( ev.data === 'quit' ) {
        close();
    }
}
  1. 现在,回到主线程,我们将监听奇数和偶数通道上的数据。当有数据时,我们几乎与以前处理它的方式完全相同:
even_channel.onmessage = function(ev) {
    if( typeof ev.data === 'object' ) {
        answer.innerText = ev.data.join(' ');
    }
}
odd_channel.onmessage= function(ev) {
    if( typeof ev.data === 'object' ) {
        answer.innerText = ev.data.join(' ');
    }
}

需要注意的一点是我们的工作线程和主线程如何处理奇数和偶数通道上的数据。由于我们是广播,我们需要确保它是我们想要的数据。在工作线程的情况下,我们只想要数字,在主线程的情况下,我们只想要看到数组。

BroadcastChannel API 只能与相同的源一起使用。这意味着我们不能在两个不同的站点之间通信,只能在同一域下的页面之间通信。

虽然这是BroadcastChannel机制的一个过于复杂的例子,但它应该展示了我们如何可以轻松地将工作线程与其父级解耦,并使它们易于发送数据而无需循环遍历它们。现在,我们将回到postMessage方法,并查看transferrable属性以及它对发送和接收数据的意义。

在浏览器中发送二进制数据

虽然消息传递是发送数据的一种很好的方式,但在通过通道发送非常大的对象时会出现一些问题。例如,假设我们有一个专用的工作线程代表我们发出请求,并且还从缓存中向工作线程添加一些数据。它可能会有数千条记录。虽然工作线程已经占用了相当多的内存,但一旦我们使用postMessage,我们会看到两件事:

  • 移动对象所需的时间会很长。

  • 我们的内存将大幅增加

这是因为浏览器使用结构化克隆算法来发送数据。基本上,它不仅仅是将数据移动到通道上,而是将对象进行序列化和反序列化,从根本上创建多个副本。除此之外,我们不知道垃圾回收器何时运行,因为我们知道它是不确定的。

我们实际上可以在浏览器中看到复制过程。如果我们创建一个名为largeObject.js的工作线程并移动一个巨大的有效负载,我们可以通过利用Date.now()方法来测量所需的时间。除此之外,我们还可以利用开发者工具中的记录系统,就像我们在第一章中学到的那样,网络高性能工具,来分析我们使用的内存量。让我们设置这个测试案例:

  1. 创建一个新的工作线程并分配一个大对象。在这种情况下,我们将使用一个存储对象的 100,000 元素数组:
const dataToSend = new Array(100000);
const baseObj = {prop1 : 1, prop2 : 'one'};
for(let i = 0; i < dataToSend.length; i++) {
    dataToSend[i] = Object.assign({}, baseObj);
    dataToSend[i].prop1 = i;
    dataToSend[i].prop2 = `Data for ${i}`;
}
console.log('send at', Date.now());
postMessage(dataToSend);
  1. 现在我们在 HTML 文件中添加一些代码来启动这个工作线程并监听消息。我们将标记消息到达的时间,然后对代码进行分析以查看内存增加情况:
const largeWorker = new Worker('largeObject.js');
largeWorker.onmessage = function(ev) {
    console.log('the time is', Date.now());
    const obj = ev.data;
}

如果我们现在将其加载到浏览器中并对代码进行分析,我们应该会看到类似以下的结果。消息的时间在 800 毫秒到 1.7 秒之间,堆大小在 80MB 到 100MB 之间。虽然这种情况绝对超出了大多数人的范围,但它展示了这种消息传递方式的一些问题。

解决这个问题的方法是使用postMessage方法的可传递部分。这允许我们发送一个二进制数据类型通过通道,而不是复制它,通道实际上只是转移对象。这意味着发送方不再能够访问它,但接收方可以。可以这样理解,发送方将数据放在一个保持位置,并告诉接收方它在哪里。此时,发送方不再能够访问它。接收方接收所有数据,并注意到它有一个位置来查找数据。它去到这个位置并获取数据,从而实现数据传输机制。

让我们继续编写一个简单的例子。让我们使用大量数据填充我们的重型工作线程,比如从 1 到 1,000,000 的数字列表:

  1. 我们创建一个包含 1,000,000 个元素的Int32Array。然后我们在其中添加从 1 到 1,000,000 的所有数字:
const viewOfData = new Int32Array(1000000);
for(let i = 1; i <= viewOfData.length; i++) {
    viewOfData[i-1] = i;
}
  1. 然后,我们将利用postMessage的可传递部分发送这些数据。请注意,我们必须获取基础的ArrayBuffer。我们很快会讨论这一点:
postMessage(viewOfData, [viewOfData.buffer]);
  1. 我们将在主线程上接收数据并输出该数据的长度:
const obj = ev.data;
console.log('data length', obj.byteLength);

我们会注意到传输这一大块数据所花费的时间几乎是不可察觉的。这是因为前面的理论,它只是将数据打包并将其放到接收端。

对于类型化数组和ArrayBuffers需要额外说明。ArrayBuffers可以被视为 Node.js 中的缓冲区。它们是存储数据的最低形式,并直接保存一些数据的字节。但是,为了真正利用它们,我们需要在ArrayBuffer上放置一个视图。这意味着我们需要赋予ArrayBuffer意义。在我们的例子中,我们说它存储有符号的 32 位整数。我们可以在ArrayBuffer上放置各种视图,就像我们可以以不同的方式解释 Node.js 中的缓冲区一样。最好的思考方式是,ArrayBuffer是我们真正不想使用的低级系统,而视图是赋予底层数据意义的系统。

考虑到这一点,如果我们在工作线程端检查Int32Array的字节长度,我们会发现它是零。我们不再可以访问那些数据,正如我们所说的。在继续讨论SharedWorkersSharedArrayBuffers之前,我们将修改我们的因式分解程序,利用这个可传递属性发送因子:

  1. 我们将几乎使用完全相同的逻辑,只是不再发送我们拥有的数组,而是发送Int32Array
if( typeof ev.data === 'number' ) {
    const result = calculatePrimes(ev.data);
    const send = new Int32Array(result);
    this.postMessage(result, [result.buffer]);
}
  1. 现在我们将更新接收端代码,以处理发送的ArrayBuffers而不仅仅是一个数组:
if( typeof ev.data === 'object' ) {
    const data = new Int32Array(ev.data);
    answer.innerText = data.join(' ');                  
}

如果我们测试这段代码,我们会发现它的工作方式是一样的,但我们不再复制数据,而是将其交给主线程,从而使消息传递更快,利用的内存更少。

主要思想是,如果我们只是发送结果或需要尽快完成,我们应该尝试利用可传递系统发送数据。如果我们在发送数据后需要在工作线程中使用数据,或者没有简单的方法发送数据(我们没有序列化技术),我们可以利用正常的postMessage系统。

仅仅因为我们可以使用可传递系统来减少内存占用,这可能会导致基于需要应用的数据转换量而增加时间。如果我们已经有二进制数据,这很好,但如果我们有需要移动的 JSON 数据,可能最好的方法是以该形式传输它,而不是经过许多中间转换。

有了所有这些想法,让我们来看看SharedWorker系统和SharedArrayBuffer系统。这两个系统,特别是SharedArrayBuffer,在过去引起了一些问题(我们将在下一节讨论),但如果我们小心使用它们,我们将能够利用它们作为良好的消息传递和数据共享机制的能力。

共享数据和工作线程

虽然大多数时候我们希望保持工作线程和应用程序选项卡之间的边界,但有时我们希望只是共享数据,甚至是工作线程。在这种情况下,我们可以利用两个系统,SharedWorkerSharedArrayBuffer

SharedWorker就像它的名字一样,当一个启动时,就像BroadcastChannel一样,当其他人调用创建SharedWorker时,它将连接到已经创建的实例。让我们继续做这件事:

  1. 我们将为SharedWorker JavaScript 代码创建一个新文件。在这里面,放一些通用的计算函数,比如加法和减法:
const add = function(a, b) {
    return a + b;
}
const mult = function(a, b) {
    return a * b;
}
const divide = function(a, b) {
    return a / b;
}
const remainder = function(a, b) {
    return a % b;
}
  1. 在我们当前某个工作线程的代码中,启动SharedWorker
const shared = new SharedWorker('shared.js');
shared.port.onmessage = function(ev) {
    console.log('message', ev);
}

我们已经看到了一个问题。我们的系统显示找不到SharedWorker。要使用SharedWorker,我们必须在一个窗口中启动它。所以现在,我们将不得不将启动代码移动到我们的主页面。

  1. 将启动代码移动到主页面,然后将端口传递给其中一个工作线程:
const shared = new SharedWorker('shared.js');
shared.port.start();
for(let i = 0; i < 4; i++) {
    const worker = new Worker('worker.js', 
        {name : `worker ${i % 2 === 0 ? 'even' : 'odd'}`}
    );
    worker.postMessage(shared.port, [shared.port]);
}

我们现在遇到另一个问题。由于我们想要将端口传递给工作线程,并且不希望在主窗口中访问它,所以我们利用了可传递的系统。然而,由于那时我们只有一个引用,一旦我们将它发送给一个工作线程,就无法再次发送。相反,让我们启动一个工作线程,并关闭我们的BroadcastChannel系统。

  1. 注释掉我们的BroadcastChannels和所有的循环代码。让我们只在这个窗口中启动一个工作线程:
const shared = new SharedWorker('shared.js');
shared.port.start();
const worker = new Worker('worker.js');
document.querySelector("#in").addEventListener('change', (ev) => {
    const value = parseInt(ev.target.value);
    worker.postMessage(value);
});
document.querySelector('#quit').addEventListener('click', (ev) => {
    worker.postMesasge('quit');
});
  1. 有了这些改变,我们将不得不简化我们的专用工作线程。我们将只是像以前一样响应我们消息通道上的事件:
let sharedPort = null;
onmessage = function(ev) {
    const data = ev.data;
    if( typeof data === 'string' ) {
        return close();
    }
    if( typeof data === 'number' ) {
        const result = calculatePrimes(data);
        const send = new Int32Array(result);
        return postMessage(send, [send.buffer]);
    }
    // handle the port
    sharedPort = data;
}
  1. 现在我们在一个单一的工作线程中有了SharedWorker端口,但是这对我们解决了什么问题呢?现在,我们可以同时打开多个选项卡,并将数据发送到每一个选项卡。为了看到这一点,让我们将一个处理程序连接到sharedPort
sharedPort.onmessage = function(ev) {
    console.log('data', ev.data);
}
  1. 最后,我们可以更新我们的SharedWorker,一旦连接发生,就做出响应,如下所示:
onconnect = function(e) {
    let port = e.ports[0];
    console.log('port', port);
    port.onmessage = function(e) {
        port.postMessage('you sent data');
    }
    port.postMessage('you connected');
}

有了这个,我们将看到一个消息回到我们的工作线程。我们现在的SharedWorker已经运行起来,并且直接与我们的DedicatedWorker进行通信!然而,仍然有一个问题:为什么我们没有看到来自我们的SharedWorker的日志?嗯,我们的SharedWorker存在于与我们的DedicatedWorker和主线程不同的上下文中。要访问我们的SharedWorker,我们可以转到 URLchrome://inspect/#workers,然后定位它。现在,我们没有给它起名字,所以它应该叫做untitled,但是当我们点击它下面的inspect选项时,我们现在有了一个工作线程的调试上下文。

我们已经将我们的SharedWorker连接到 DOM 上下文,并且已经将每个DedicatedWorker连接到该SharedWorker,但是我们需要能够向每个DedicatedWorker发送消息。让我们继续添加这段代码:

  1. 首先,我们需要跟踪所有通过SharedWorker连接到我们的工作线程。将以下代码添加到我们onconnect监听器的底部:
ports.push(port);
  1. 现在,我们将在我们的文档中添加一些 HTML,这样我们就可以发送addmultiplydividesubtract请求,以及两个新的数字输入:
<input id="in1" type="number" />
<input id="in2" type="number" />
<button id="add">Add</button>
<button id="subtract">Subtract</button>
<button id="multiply">Multiply</button>
<button id="divide">Divide</button>
  1. 接下来,我们将通过DedicatedWorker将这些信息传递给SharedWorker
if( typeof data === 'string' ) {
    if( data === 'quit' ) {
        close();
    } else {
        sharedPort.postMessage(data);
    }
}
  1. 最后,我们的SharedWorker将运行相应的操作,并将其传递回DedicatedWorker,后者将数据记录到控制台:
port.onmessage = function(e) {
    const _d = e.data.split(' ');
    const in1 = parseInt(_d[1]);
    const in2 = parseInt(_d[2]);
    switch(_d[0]) {
        case 'add': {
            port.postMessage(add(in1, in2));
            break;
        }
        // other operations removed since they are the same thing
    }
}

有了这一切,我们现在可以打开多个应用程序选项卡,它们都共享相同的前置数学系统!对于这种类型的应用程序来说,这有点过度,但是当我们需要在我们的应用程序中执行跨多个窗口或选项卡的复杂操作时,这可能是有用的。这可能是利用 GPU 的东西,我们只想做一次。让我们通过概述SharedArrayBuffer来结束本节。然而,要记住的一件事是,SharedWorker是所有选项卡持有的单个线程,而DedicatedWorker是每个选项卡/窗口的一个线程。虽然共享一个工作线程对于前面解释的一些任务可能是有益的,但如果多个选项卡同时使用它,也可能会减慢其他任务的速度。

SharedArrayBuffer允许我们的所有实例共享相同的内存块。就像可传递的对象可以根据将内存传递给另一个工作线程而有不同的所有者一样,SharedArrayBuffer允许不同的上下文共享相同的部分。这允许更新在我们的所有实例中传播,并且对于某些类型的数据几乎立即更新,但它也有许多与之相关的缺点。

这是我们在其他语言中最有可能接近SharedMemory的方式。要正确使用SharedArrayBuffer,我们需要使用 Atomics API。再次强调,不直接深入 Atomics API 背后的细节,它确保操作按正确顺序进行,并且保证在更新时能够更新需要更新的内容,而不会被其他人在更新过程中覆盖。

我们开始进入细节,这些细节可能很难完全理解发生了什么。一个好的理解 Atomics API 的方式是将其想象成一个许多人共享一张纸的系统。他们轮流在上面写字和阅读其他人写下的内容。

然而,其中一个缺点是他们一次只能写一个字符。因此,当他们仍在尝试完成写入单词时,其他人可能会在他们的位置上写入内容,或者有人可能会读取他们的不完整短语。我们需要一个机制,让人们能够在开始写入之前写入他们想要的整个单词,或者在开始写入之前读取整个部分。这就是 Atomics API 的工作。

SharedArrayBuffer确实存在一些问题,与浏览器不支持它有关(目前,只有 Chrome 支持它而无需标志),以及我们可能希望使用 Atomics API(由于安全问题,SharedWorker无法将其发送到主线程或专用 worker)。

为了设置SharedArrayBuffer的基本示例,我们将在主线程和 worker 之间共享一个缓冲区。当我们向 worker 发送请求时,我们将更新 worker 中的数字。更新这个数字应该对主线程可见,因为它们共享缓冲区。

  1. 创建一个简单的 worker,并使用onmessage处理程序检查是否收到了一个数字。如果是,我们将增加SharedArrayBuffer中的数据。否则,数据是来自主线程的SharedArrayBuffer
let sharedPort = null;
let buf = null;
onmessage = function(ev) {
    const data = ev.data;
    if( typeof data === 'number' ) {
        Atomics.add(buf, 0, 1);
    } else {
        buf = new Int32Array(ev.data);
    }
}
  1. 接下来,在我们的主线程上,我们将添加一个新的按钮,上面写着“增加”。当点击它时,它将向专用 worker 发送一条消息,以增加当前数字。
// HTML
<button id="increment">Increment</button>
<p id="num"></p>

// JavaScript
document.querySelector('#increment').addEventListener('click', () => {
    worker.postMessage(1);
});
  1. 现在,当 worker 在其端更新缓冲区时,我们将不断检查SharedArrayBuffer是否有更新。我们将始终将数字放在前面代码片段中显示的数字段落元素中。
setInterval(() => {
    document.querySelector('#num').innerText = shared;
}, 100);
  1. 最后,为了开始所有这些,我们将在主线程上创建一个SharedArrayBuffer,并在启动后将其发送给 worker:
let shared = new SharedArrayBuffer(4);
const worker = new Worker('worker_to_shared.js');
worker.postMessage(shared);
shared = new Int32Array(shared);

通过这样,我们可以看到我们的值现在正在增加,即使我们没有从 worker 发送任何数据到主线程!这就是共享内存的力量。现在,正如之前所述,由于我们无法在主线程上使用waitnotify系统,也无法在SharedWorker中使用SharedArrayBuffer,因此我们在 Atomics API 方面受到相当大的限制,但它对于只读取数据的系统可能是有用的。

在这些情况下,我们可能会更新SharedArrayBuffer,然后向主线程发送一条消息,告诉它我们已经更新了它,或者它可能已经是一个接受SharedArrayBuffers的 Web API,比如 WebGL 渲染上下文。虽然前面的例子并不是很有用,但它展示了如果再次可以在SharedWorker中生成和使用SharedArrayBuffer的能力,我们可能如何在未来使用共享系统。接下来,我们将专注于构建一个所有 worker 都可以共享的单一缓存。

构建一个简单的共享缓存

通过我们学到的一切,我们将专注于一个在报告系统和大多数类型的操作 GUI 中非常普遍的用例——需要添加其他数据的大块数据(有些人称之为装饰数据,其他人称之为属性)。一个例子是我们有一组客户的买入和卖出订单。

这些数据可能以以下方式返回:

{
    customerId : "<guid>",
    buy : 1000000,
    sell : 1000000
}

有了这些数据,我们可能想要添加一些与客户 ID 相关联的上下文。我们可以通过两种方式来做到这一点:

  • 首先,我们可以在数据库中执行联接操作,为用户添加所需的信息。

  • 其次,我们将在此处进行说明的是,在我们获得基本查询时在前端添加这些数据。这意味着当我们的应用程序启动时,我们将获取所有这些归因数据并将其存储在某个后台缓存中。接下来,当我们发出请求时,我们还将向缓存请求相应的数据。

为了实现第二个选项,我们将实现我们之前学到的两种技术,SharedWorkerpostMessage接口:

  1. 我们创建一个基本级别的 HTML 文件,其中包含每一行数据的模板。我们不会深入创建 Web 组件,就像我们在第三章中所做的那样,但我们将使用它来根据需要创建我们的表行:
<body>
    <template id="row">
        <tr>
            <td class="name"></td>
            <td class="zip"></td>
            <td class="phone"></td>
            <td class="email"></td>
            <td class="buy"></td>
            <td class="sell"></td>
        </tr>
    </template>
   <table id="buysellorders">
   <thead>
       <tr>
           <th>Customer Name</th>
           <th>Zipcode</th>
           <th>Phone Number</th>
           <th>Email</th>
           <th>Buy Order Amount</th>
           <th>Sell Order Amount</th>
       </tr>
   </thead>
   <tbody>
   </tbody>
   </table>
</body>
  1. 我们设置了一些指向我们模板和表的指针,以便我们可以快速插入。除此之外,我们可以为即将创建的SharedWorker创建一个占位符:
const tableBody = document.querySelector('#buysellorders > tbody');
const rowTemplate = document.querySelector('#row');
const worker = new SharedWorker('<fill in>', {name : 'cache'});
  1. 有了这个基本设置,我们可以创建我们的SharedWorker并为其提供一些基本数据。为此,我们将使用网站www.mockaroo.com/。这将允许我们创建大量随机数据,而无需自己考虑。我们可以将数据更改为我们想要的任何内容,但在我们的情况下,我们将选择以下选项:
  • id:行号

  • full_name:全名

  • email:电子邮件地址

  • phone:电话

  • zipcode:数字序列:######

  1. 填写了这些选项后,我们可以将格式更改为 JSON,并通过单击“下载数据”进行保存。完成后,我们可以构建我们的SharedWorker。与我们的其他SharedWorker类似,我们将使用onconnect处理程序,并为传入的端口添加一个onmessage处理程序:
onconnect = function(e) {
    let port = e.ports[0];
    port.onmessage = function(e) {
        // do something
    }
}
  1. 接下来,在我们的 HTML 文件中启动我们的SharedWorker
const worker = new SharedWorker('cache_shared.js', 'cache');
  1. 现在,当我们启动我们的SharedWorker时,我们将使用importScripts加载文件。这允许我们加载外部 JavaScript 文件,就像我们在 HTML 中使用script标签一样。为此,我们需要修改 JSON 文件,将对象指向一个变量并将其重命名为 JavaScript 文件:
let cache = [{"id":1,"full_name":"Binky Bibey","email":"bbibey0@furl.net","phone":"370-576-9587","zipcode":"640069"}, //rest of the data];

// SharedWorker.js
importScripts('./mock_customer_data.js');
  1. 现在我们已经将数据缓存进来,我们将回应从端口发送来的消息。我们只期望数字数组。这些将对应于与用户关联的 ID。现在,我们将循环遍历字典中的所有项目,看看我们是否有它们。如果有,我们将将它们添加到一个数组中,然后进行响应:
const handleReq = function(arr) {
    const res = new Array(arr.length)
    for(let i = 0; i < arr.length; i++) {
        const num = arr[i];
        for(let j = 0; j < cache.length; j++) {
            if( num === cache[j].id ) {
                res[i] = cache[j];
               break;
            }
        }
    }
    return res;
}
onconnect = function(e) {
    let port = e.ports[0];
    port.onmessage = function(e) {
        const request = e.data;
        if( Array.isArray(request) ) {
            const response = handleReq(request);
            port.postMessage(response);
        }
    }
}
  1. 因此,我们需要在我们的 HTML 文件中添加相应的代码。我们将添加一个按钮,该按钮将向我们的SharedWorker发送 100 个随机 ID。这将模拟当我们发出请求并获得与数据关联的 ID 时的情况。模拟函数如下:
// developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/
// Global_Objects/Math/random

const getRandomIntInclusive = function(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}
const simulateRequest = function() {
    const MAX_BUY_SELL = 1000000;
    const MIN_BUY_SELL = -1000000;
    const ids = [];
    const createdIds = [];
    for(let i = 0; i < 100; i++) {
        const id = getRandomIntInclusive(1, 1000);
        if(!createdIds.includes(id)) {
            const obj = {
                id,
                buy : getRandomIntInclusive(MIN_BUY_SELL,  
                 MAX_BUY_SELL),
                sell : getRandomIntInclusive(MIN_BUY_SELL, 
                 MAX_BUY_SELL)
            };
            ids.push(obj);
        }
    }
    return ids;
}
  1. 通过上述模拟,我们现在可以添加我们的请求输入,然后将其发送到我们的SharedWorker
requestButton.addEventListener('click', (ev) => {
    const res = simulateRequest();
    worker.port.postMessage(res);
});
  1. 现在,我们目前正在向我们的SharedWorker发布错误的数据。我们只想发布 ID,但是我们如何将我们的请求与我们的SharedWorker的响应联系起来呢?我们需要稍微修改我们的requestresponse方法的结构。我们现在将 ID 绑定到我们的消息,这样我们就可以让SharedWorker将其发送回给我们。这样,我们就可以在前端拥有请求和与之关联的 ID 的映射。进行以下更改:
// HTML file
const requestMap = new Map();
let reqCounter = 0;
requestButton.addEventListener('click', (ev) => {
    const res = simulateRequest();
    const reqId = reqCounter;
    reqCounter += 1;
    worker.port.postMessage({
        id : reqId,
        data : res
    });
});

// Shared worker
port.onmessage = function(e) {
    const request = e.data;
    if( request.id &&
        Array.isArray(request.data) ) {
        const response = handleReq(request.data);
        port.postMessage({
            id : request.id,
            data : response
        });
    }
}
  1. 通过这些更改,我们仍然需要确保我们只将 ID 传递给SharedWorker。在发送请求之前,我们可以从请求中取出这些 ID:
requestButton.addEventListener('click', (ev) => {
    const res = simulateRequest();
    const reqId = reqCounter;
    reqCounter += 1;
    requestMap.set(reqId, res);
    const attribute = [];
    for(let i = 0; i < res.length; i++) {
        attribute.push(res[i].id);
    }
    worker.port.postMessage({
        id : reqId,
        data : attribute
    });
});
  1. 现在我们需要处理返回到我们的 HTML 文件中的数据。首先,我们将一个onmessage处理程序附加到端口上:
worker.port.onmessage = function(ev) {
    console.log('data', ev.data);
}
  1. 最后,我们从地图中获取相关的买卖订单,并用返回的缓存数据填充它。完成这些后,我们只需克隆我们的行模板并填写相应的字段:
worker.port.onmessage = function(ev) {
    const data = ev.data;
    const baseData = requestMap.get(data.id);
    requestMap.delete(data.id);
    const attribution = data.data;
    tableBody.innerHTML = '';
    for(let i = 0; i < baseData.length; i++) {
        const _d = baseData[i];
        for(let j = 0; j < attribution.length; j++) {
            if( _d.id === attribution[j].id ) {
                const final = {..._d, ...attribution[j]};
                const newRow = rowTemplate.content.cloneNode(true);
                newRow.querySelector('.name').innerText =  
                 final.full_name;
                newRow.querySelector('.zip').innerText = 
                 final.zipcode;
                newRow.querySelector('.phone').innerText = 
                 final.phone;
                newRow.querySelector('.email').innerText = 
                 final.email;
                newRow.querySelector('.buy').innerText = 
                 final.buy;
                newRow.querySelector('.sell').innerText = 
                 final.sell;
                tableBody.appendChild(newRow);
            }
        }
    }
}

通过上面的例子,我们创建了一个任何具有相同域的页面都可以使用的共享缓存。虽然有一些优化(我们可以将数据存储为地图,并将 ID 作为键),但我们仍然会比潜在地等待数据库连接要快一些(特别是当我们在带宽有限的地方时)。

总结

整个章节都集中在将任务从主线程转移到其他工作线程上。我们看了只有单个页面才有的专用工作线程。然后我们看了如何在多个工作线程之间广播消息,而不必循环遍历各自的端口。

然后我们看到了如何在同一域上利用SharedWorker共享工作线程,还看了如何利用SharedArrayBuffer共享数据源。最后,我们实际看了一下如何创建一个任何人都可以访问的共享缓存。

在下一章中,我们将通过利用ServiceWorker将缓存和处理请求的概念推进一步。

第十一章:服务工作者-缓存和加速

到目前为止,我们已经看过了专用和共享工作线程,它们帮助将计算密集型任务放入后台。我们甚至创建了一个使用SharedWorker的共享缓存。现在,我们将看一下服务工作者,并学习它们如何用于为我们缓存资源(如 HTML、CSS、JavaScript 等)和数据,以便我们不必进行昂贵的往返到服务器。

在本章中,我们将涵盖以下主题:

  • 了解 ServiceWorker

  • 为离线使用缓存页面和模板

  • 保存请求以备后用

到本章结束时,我们将能够为我们的 Web 应用程序创建离线体验。

技术要求

对于本章,您将需要以下内容:

了解 ServiceWorker

ServiceWorker是一个位于我们的 Web 应用程序和服务器之间的代理。它捕获所做的请求并检查是否有与之匹配的模式。如果有模式匹配,则它将运行与该模式匹配的代码。为ServiceWorker编写代码与我们之前查看的SharedWorkerDedicatedWorker有些不同。最初,我们在一些代码中设置它并下载自身。我们有各种事件告诉我们工作线程所处的阶段。这些按以下顺序运行:

  1. 下载ServiceWorker正在为其托管的域或子域下载自身。

  2. 安装ServiceWorker正在附加到其托管的域或子域。

  3. 激活ServiceWorker已完全附加并加载以拦截请求。

安装事件尤其重要。这是我们可以监听更新的ServiceWorker的地方。假设我们想要为我们的ServiceWorker推送新代码。如果用户仍在我们决定将该代码推送到服务器时的页面上,他们仍将使用旧的工作线程。有办法终止旧的工作线程并强制它们更新(我们稍后会看到),但它仍将使用旧缓存。

此外,如果我们正在使用缓存来存储被请求的资源,它们将存储在旧缓存中。如果我们要更新这些资源,那么我们要确保清除先前的缓存并开始使用新的缓存。稍后我们将看一个例子,但最好提前了解这一点。

最后,服务工作者将每隔 24 小时更新一次自身,因此如果我们不强制用户更新ServiceWorker,他们将在 24 小时时获得这个新副本。这些都是我们在本章示例中要牢记的想法。我们在写出它们时会提醒您。

让我们从一个非常基本的例子开始。按照以下步骤进行:

  1. 首先,我们需要一个静态服务器,以便我们可以使用服务工作者。为此,请运行npm install serve并将以下代码添加到app.js文件:
const handler = require('serve-handler');
const http = require('http');
const server = http.createServer((req, res) => {
    return handler(req, res, {
        public : 'source'
    });
});
server.listen(3000, () => {
    console.log('listening at 3000');
});
  1. 现在,我们可以从source目录中提供所有内容。创建一个基本的 HTML 页面,并让它加载一个名为BaseServiceWorker.jsServiceWorker
<!DOCTYPE html>
<html>
    <head>
        <!-- get some resources -->
    </head>
    <body>
        <script type="text/javascript">
              navigator.serviceWorker.register('./BaseServiceWorker.js', 
             { scope : '/'})
            .then((reg) => {
                console.log('successfully registered worker');
            }).catch((err) => {
                console.error('there seems to be an issue!');
            })
        </script>
    </body>
</html>
  1. 创建一个基本的ServiceWorker,每当发出请求时都会记录到我们的控制台:
self.addEventListener('install', (event) => {
    console.log('we are installed!');
});
self.addEventListener('fetch', (event) => {
    console.log('a request was made!');
    fetch(event.request);
});

我们应该在控制台中看到两条消息。一条应该是静态的,说明我们已经正确安装了所有内容,而另一条将说明我们已成功注册了一个工作线程!现在,让我们向我们的 HTML 添加一个 CSS 文件并对其进行服务。

  1. 将我们的新 CSS 文件命名为main.css并添加以下 CSS:
*, :root {
    margin : 0;
    padding : 0;
    font-size : 12px;
}
  1. 将此 CSS 文件添加到我们的 HTML 页面的顶部。

有了这个,重新加载页面并查看控制台中显示的内容。注意它没有说明我们已成功发出请求。如果我们不断点击重新加载按钮,可能会在页面重新加载之前看到消息出现。如果我们想看到这条消息,我们可以在 Chrome 中转到以下链接并检查那里的ServiceWorkerchrome://serviceworker-internals

我们可能会看到其他服务工作者被加载。很多网站都这样做,这是一种缓存网页的技术。我们将很快更详细地研究这个问题。这就是为什么对于一些应用程序来说,第一次加载可能会很痛苦,而之后它们似乎加载得更快的原因。

页面顶部应该显示一个选项,用于在启动ServiceWorker时启动开发工具。请检查此选项。然后,停止/启动工作线程。现在,将打开一个控制台,允许我们调试我们的ServiceWorker

虽然这对调试很有用,但如果我们看一下启动此行为的页面,我们会看到一个小窗口,其中显示类似以下内容的信息:

Console: {"lineNumber":2,"message":"we are installed!","message_level":1,"sourceIdentifier":3,"sourceURL":"http://localhost:3000/BaseServiceWorker.js"}

每次重新加载页面时都会获取 CSS 文件!如果我们再重新加载几次,应该会有更多这样的消息。这很有趣,但我们肯定可以做得更好。让我们继续缓存我们的main.css文件。将以下内容添加到我们的BaseServiceWorker.js文件中:

self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open('v1').then((cache) => {
            return cache.addAll([
                './main.css'
            ]);
        }).then(() => {
            console.log('we are ready!');
        })
    );
});
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then((response) => {
            return response || fetch(event.request);
        })
    )
});

有了这个,我们引入了一个缓存。这个缓存将为我们获取各种资源。除了这个缓存,我们还引入了事件的waitUntil方法。这允许我们延迟ServiceWorker的初始化,直到我们从服务器获取了所有想要的数据。在我们的 fetch 处理程序中,我们现在正在检查我们的缓存中是否有资源。如果有,我们将提供该文件;否则,我们将代表页面发出 fetch 请求。

现在,如果我们加载页面,我们会注意到我们只有we are ready消息。尽管我们有新的代码,但页面被 Chrome 缓存了,所以它没有放弃我们的旧服务工作者。为了强制添加新的服务工作者,我们可以进入开发者控制台,转到应用程序选项卡。然后,我们可以转到左侧面板,转到ServiceWorker部分。应该有一个时间轴,说明有一个ServiceWorker正在等待被激活。如果我们点击旁边的文字,说 skipWaiting,我们可以激活新代码。

请点击此选项。看起来好像没有发生任何事情,但是如果我们返回到chrome://serviceworker-internals页面,我们会看到有一条消息。如果我们继续重新加载页面,我们会看到我们只有一条消息。这意味着我们已经加载了我们的新代码!

另一种检查我们是否成功缓存了main.css文件的方法是限制应用程序的下载速度(特别是因为我们是在本地托管)。返回开发人员工具,点击网络选项卡。在禁用缓存选项附近应该有一个网络速度的下拉菜单。目前,它应该显示我们在线。请将其切换到离线状态:

好吧,我们刚刚丢失了我们的页面!在BaseServiceWorker.js中,我们应该添加以下内容:

caches.open('v1').then((cache) => {
    return cache.addAll([
        './main.css',
        '/'
    ]);
})

现在,我们可以再次将我们的应用程序上线,并让这个新的ServiceWorker添加到页面中。添加完成后,将我们的应用程序切换到离线状态。现在,页面可以离线工作!我们将稍后更详细地探讨这个想法,但这给了我们一个很好的预览。

通过这简单的ServiceWorker和缓存机制的观察,让我们把注意力转向缓存页面并在ServiceWorker中添加一些模板功能。

为离线使用缓存页面和模板

正如我们在本章开头所述,Service Worker 的主要用途之一是缓存页面资源以供将来使用。我们在第一个简单的ServiceWorker中看到了这一点,但我们应该设置一个更复杂的页面,其中包含更多资源。按照以下步骤进行:

  1. 创建一个名为CacheServiceWorker.js的全新ServiceWorker,并将以下模板代码添加到其中。这是大多数ServiceWorker实例将使用的代码:
self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open('v1').then((cache) => {
            return cache.addAll([
                // add resources here
            ]);
        }).then(() => {
            console.log('we are ready!');
        })
    );
});
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then((response) => {
            return response || fetch(event.request);
        })
    )
});
  1. 更新我们的index.html文件,以利用这个新的ServiceWorker
navigator.serviceWorker.register('./CacheServiceWorker.js', { scope : '/'})
    .then((reg) => {
        console.log('successfully registered worker');
    }).catch((err) => {
        console.error('there seems to be an issue!', err);
    })
  1. 现在,让我们在我们的页面上添加一些按钮和表格。我们很快将利用这些:
<button id="addRow">Add</button>
<button id="remove">Remove</button>
<table>
    <thead>
        <tr>
            <th>Id</th>
            <th>Name</th>
            <th>Description</th>
            <th>Points</th>
        </tr>
    </thead>
    <tbody id="tablebody">
    </tbody>
</table>
  1. 添加一个 JavaScript 文件,用于处理我们与interactions.js页面的所有交互:
const add = document.querySelector('#addRow');
const remove = document.querySelector('#remove');
const tableBody = document.querySelector('#tablebody');
add.addEventListener('click', (ev) => {
    fetch('/add').then((res) => res.json()).then((fin) =>
     tableBody.appendChild(fin));
});
remove.addEventListener('click', (ev) => {
    while(tableBody.firstChild) {
        tableBody.removeChild(tableBody.firstChild);
    }
});
  1. 将 JavaScript 文件添加到我们的ServiceWorker作为预加载:
caches.open('v1').then((cache) => {
    return cache.addAll([
        '/',
        './interactions.js',
        './main.css'
    ]);
}).then(() => {
    console.log('we are ready!');
})
  1. 将 JavaScript 文件添加到我们的index.html文件的底部:
<script src="interactions.js" type="text/javascript"></script>

现在,如果我们加载我们的页面,我们应该看到一个简单的表格坐在那里,有一个标题行和一些按钮。让我们继续向我们的页面添加一些基本样式,以使它更容易看到。将以下内容添加到我们在处理BaseServiceWorker时添加的main.css文件中:

table {
    margin: 15px;
    border : 1px solid black;
}
th {
    border : 1px solid black;
    padding : 2px;
}
button {
    border : 1px solid black;
    padding :5px;
    background : #2e2e2e;
    color : #cfcfcf;
    cursor : pointer;
    margin-left : 15px;
    margin-top : 15px;
}

这个 CSS 为我们提供了一些基本的样式。现在,如果我们点击“添加”按钮,我们应该看到以下消息:

The FetchEvent for "http://localhost:3000/add" resulted in a network error response: the promise was rejected.

由于我们还没有添加任何代码来处理这个问题,让我们继续在我们的ServiceWorker中拦截这条消息。按照以下步骤进行:

  1. 将以下虚拟代码添加到我们的ServiceWorkerfetch事件处理程序中:
event.respondWith(
    caches.match(event.request).then((response) => {
        if( response ) {
            return response
        } else {
            if( event.request.url.includes("/add") ) {
                return new Response(new Blob(["Here is some data"], 
                    { type : 'text/plain'}),
                    { status : 200 });
            }
            fetch(event.request);
        }
    })
)
  1. 点击“添加”按钮。我们应该看到一个新的错误,说明它无法解析 JSON 消息。将Blob数据更改为一些 JSON:
return new Response(new Blob([JSON.stringify({test : 'example', stuff : 'other'})], { type : 'application/json'}), { status : 200 });
  1. 再次点击“添加”按钮。我们应该得到一个声明,说明我们刚刚传递给处理程序的内容不是Node类型。解析我们在“添加”按钮的点击处理程序中得到的数据:
fetch('/add').then((res) => res.json()).then((fin) =>  {
    const tr = document.createElement('tr');
    tr.innerHTML = `<td>${fin.test}</td>
                    <td>${fin.stuff}</td>
                    <td>other</td>`;
    tableBody.appendChild(tr);
});

现在,如果我们尝试运行我们的代码,我们会看到一些有趣的东西:我们的 JavaScript 文件仍然是旧代码。ServiceWorker正在使用我们以前的旧缓存。在这里我们可以做两件事。首先,我们可以禁用ServiceWorker。或者,我们可以删除旧缓存并用新缓存替换它。我们将执行第二个选项。为此,我们需要在安装监听器中添加以下代码到我们的ServiceWorker中:

event.waitUntil(
    caches.delete('v1').then(() => {
        caches.open('v1').then((cache) => {
            return cache.addAll([
                '/',
                './interactions.js',
                './main.css'
            ]);
        }).then(() => {
            console.log('we are ready!');
        });
    })
);

现在,我们可以在前端代码中加载模板,但我们将在这里模拟一个服务器端渲染系统。这有一些应用场景,但我想到的主要应用场景是我们在开发中尝试的模板系统。

大多数模板系统需要在我们使用它们之前编译成最终的 HTML 形式。我们可以设置一个watch类型的系统,在这个系统中,每当我们更新模板时,这些模板都会被重新加载,但这可能会变得繁琐,特别是当我们只想专注于前端时。另一种方法是将这些模板加载到我们的ServiceWorker中,并让它渲染它们。这样,当我们想要进行更新时,我们只需通过caches.delete方法删除我们的缓存,然后重新加载它。

让我们设置一个简单的示例,就像前面的示例一样,但模板不是在我们的前端代码中创建的,而是在我们的ServiceWorker中。按照以下步骤进行:

  1. 创建一个名为row.template的模板文件,并用以下代码填充它:
<td>${id}</td>
<td>${name}</td>
<td>${description}</td>
<td>${points}</td>
  1. 删除我们的interactions.js中的模板代码,并用以下代码替换它:
fetch('/add').then((res) => res.text()).then((fin) =>  {
    const row = document.createElement('tr');
    row.innerHTML = fin;
    tableBody.appendChild(row);
});
  1. 让我们设置一些基本的模板代码。我们不会做任何接近第九章中所做的实际示例-构建静态服务器。相反,我们将循环遍历我们传递的对象,并填写我们的模板的部分,其中我们的键在对象中对应:
const renderTemplate = function(template, obj) {
    const regex = /\${([a-zA-Z0-9]+)\}/;
    const keys = Object.keys(obj);
    let match = null;
    while(match = regex.exec(template)) {
        const key = match[1];
        if( keys.includes(key) ) {
            template = template.replace(match[0], obj[key]);
        } else {
            match = null;
        }
    }
    return template;
}
  1. 将响应更改为/add端点,使用以下代码:
if( event.request.url.includes('/add') ) {
    return fetch('./row.template')
        .then((res) => res.text())
        .then((template) => {
            return new Response(new Blob([renderTemplate(template, 
             add)],{type : 'text/html'}), {status : 200});   
        })
} else if( response ) {
    return response
} else {
    return fetch(event.request);
}

现在,我们将从服务器中获取我们想要的模板(在我们的情况下是row.template文件),并用我们拥有的任何数据填充它(同样,在我们的情况下,我们将使用存根数据)。现在,我们在ServiceWorker中有了模板,并且可以轻松地设置端点以通过这个模板系统。

当我们想要个性化网站的错误页面时,这也可能是有益的。如果我们想要在我们的 404 页面中出现一个随机图像并将其合并到页面中,我们可以在ServiceWorker中完成,而不是访问服务器。我们甚至可以在离线状态下这样做。我们只需要实现与此处相同类型的模板化。

有了这些概念,很容易看到我们在拦截请求时的能力以及我们如何使我们的 Web 应用程序在离线时工作。我们将学习的最后一个技术是在离线时存储我们的请求,并在重新联机时运行它们。这种类型的技术可以用于从浏览器中保存或加载文件。让我们来看看。

保存请求以便以后使用

到目前为止,我们已经学会了如何拦截请求并从我们的本地系统返回或甚至增强响应。现在,我们将学习如何在离线模式下保存请求,然后在联机时将调用发送到服务器。

让我们继续为此设置一个新的文件夹。按照以下步骤进行:

  1. 创建一个名为offline_storage的文件夹,并向其中添加以下文件:
  • index.html

  • main.css

  • interactions.js

  • OfflineServiceWorker.js

  1. 将以下样板代码添加到index.html中:
<!DOCTYPE html>
<html>
    <head><!-- add css file --></head>
    <body>
        <h1>Offline Storage</h1>
        <button id="makeRequest">Request</button>
        <table>
            <tbody id="body"></tbody>
        </table>
        <p>Are we online?: <span id="online">No</span>
        <script src="interactions.js"></script>
        <script>
            let online = false;
            const onlineNotification =  
             document.querySelector('#online');
            window.addEventListener('load', function() {
                const changeOnlineNotification = function(status) {
                    onlineNotification.textContent = status ? "Yes" 
                     : "No";
                    online = status;
                }
                changeOnlineNotification(navigator.onLine);
                 navigator.serviceWorker.register('.
                 /OfflineCacheWorker.js', {scope : '/'})
                window.addEventListener('online', () => {
                 changeOnlineNotification(navigator.onLine) });
                window.addEventListener('offline', () => {
                 changeOnlineNotification(navigator.onLine) });
            });
        </script>
    </body>
</html>
  1. 将以下样板代码添加到OfflineServiceWorker.js中:
self.addEventListener('install', (event) => {
    event.waitUntil(   
     // normal cache opening
    );
});
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then((response) => {
            // normal response handling
        })
    )
});
  1. 最后,将以下样板代码添加到interactions.js中:
const requestMaker = document.querySelector('#makeRequest');
const tableBody = document.querySelector('#body');
requestMaker.addEventListener('click', (ev) => {
    fetch('/request').then((res) => res.json()).then((fin) => {
        const row = document.createElement('tr');
        row.innerHTML = `
        <td>${fin.id}</td>
        <td>${fin.name}</td>
        <td>${fin.phone}</td>
        <td><button id=${fin.id}>Delete</button></td>
        `
        row.querySelector('button').addEventListener('click', (ev) 
         => {
            fetch(`/delete/${ev.target.id}`).then(() => {
                tableBody.removeChild(row);
            });
        });
        tableBody.appendChild(row);
    })
})

将所有这些代码放在一起后,让我们继续更改我们的 Node.js 服务器,使其指向这个新的文件夹位置。我们将通过停止旧服务器并更改app.js文件,使其指向我们的offline_storage文件夹来实现这一点:

const server = http.createServer((req, res) => {
    return handler(req, res, {
        public : 'offline_storage'
    });
});

有了这个,我们可以通过运行node app.js重新运行我们的服务器。我们可能会看到我们的旧页面出现。如果是这种情况,我们可以转到开发者工具中的“应用程序”选项卡,并在“服务工作者”部分下点击“注销”选项。重新加载页面后,我们应该看到新的index.html页面出现。我们的处理程序目前不起作用,所以让我们在ServiceWorker中添加一些存根代码,以处理我们在interactions.js中添加的两种 fetch 情况。按照以下步骤进行:

  1. 在 fetch 事件处理程序中添加以下支持:
caches.match(event.request).then((response) => {
    if( event.request.url.includes('/request') ) {
        return handleRequest();
    }
})
// below in the global scope of the ServiceWorker
let counter = 0;
let name = 65;
const handleRequest = function() {
    const data = {
        id : counter,
        name : String.fromCharCode(name),
        phone : Math.round(Math.random() * 10000)
    }
    counter += 1;
    name += 1;
    return new Response(new Blob([JSON.stringify(data)], {type : 
     'application/json'}), {status : 200});
}
  1. 通过确保它正确处理响应,确保它向我们的表中添加一行。重新加载页面并确保在单击请求按钮时添加了新行:

  1. 现在我们已经确保该处理程序正在工作,让我们继续为我们的删除请求添加另一个处理程序。我们将在我们的ServiceWorker中模拟服务器上的数据库删除:
caches.match(event.request).then((response) => {
    if( event.request.url.includes('/delete') ) {
        return handleDelete(event.request.url);
    }
})
// place in the global scope of the Service Worker
const handleDelete = function(url) {
    const id = url.split("/")[2];
    return new Response(new Blob([id], {type : 'text/plain'}), 
     {status : 200});
}
  1. 有了这个,让我们继续测试一下,确保我们点击删除按钮时行被删除。如果所有这些都有效,我们将拥有一个可以在线或离线工作的功能应用程序。

现在,我们所需要做的就是为即将发出但由于我们目前处于离线状态而无法发出的请求添加支持。为此,我们将在一个数组中存储请求,并一旦在我们的ServiceWorker中检测到我们重新联机,我们将发送所有请求。我们还将添加一些支持,让我们的前端知道我们正在等待这么多请求,如果需要,我们可以取消它们。现在让我们添加这个:

在 Chrome 中,从离线切换到在线会触发我们的在线处理程序,但从在线切换到离线似乎不会触发事件。我们可以测试离线到在线系统的功能,但测试另一种情况可能会更加困难。请注意,这种限制可能存在于许多开发系统中,试图解决这个问题可能会非常困难。

  1. 首先,将我们大部分的caches.match代码移动到一个独立的函数中,如下所示:
caches.match(event.request).then((response) => {
    if( response ) {
        return response
    }
    return actualRequestHandler(event);
})
  1. 编写独立的函数,如下所示:
const actualRequestHandler = function(req) {
    if( req.request.url.includes('/request') ) {
        return handleRequest();
    }
    if( req.request.url.includes('/delete') ) {
        return handleDelete(req.request.url);
    }
    return fetch(req.request);
}
  1. 我们将通过轮询处理请求,以查看我们是否重新联机。设置一个每 30 秒工作一次的轮询计时器,并将我们的caches.match处理程序更改如下:
const pollTime = 30000;
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then((response) => {
            if( response ) {
                return response
            }
            if(!navigator.onLine ) {
                return new Promise((resolve, reject) => {
                    const interval = setInterval(() => {
                        if( navigator.onLine ) {
                            clearInterval(interval);
                            resolve(actualRequestHandler(event));
                        }
                    }, pollTime)
                })
            } else {
                return actualRequestHandler(event);
            }
        })
    )
});

我们刚刚做的是为一个 promise 设置了一个返回。如果我们看不到系统在线,我们将每 30 秒轮询一次,以查看我们是否重新联机。一旦我们重新联机,我们的 promise 将清除间隔,并在 resolve 处理程序中实际处理请求。我们可以设置一个在取消请求之前尝试多少次的系统。我们只需要在间隔之后添加一个拒绝处理程序。

最后,我们将添加一种方法来停止当前所有未处理的请求。为此,我们需要一种方法来跟踪我们是否有未处理的请求,并且一种在ServiceWorker中中止它们的方法。这将非常简单,因为我们可以很容易地在前端跟踪仍在等待的内容。我们可以通过以下方式添加这个功能:

  1. 首先,我们将添加一个显示,显示前端有多少未处理的请求。我们将把这个显示放在我们的在线状态系统之后:
// inside of our index.html
<p>Oustanding requests: <span id="outstanding">0</span></p>

//inside our interactions.js
const requestAmount = document.querySelector('#outstanding');
let numRequests = 0;
requestMaker.addEventListener('click', (ev) => {
    numRequests += 1;
    requestAmount.textContent = numRequests;
    fetch('/request').then((res) => res.json()).then((fin) => {
        // our previous fetch handler
        numRequests -= 1;
        requestAmount.textContent = numRequests;
    });
    // can be setup for delete requests also
});
  1. 在我们的index.html文件中添加一个按钮,用于取消所有未处理的请求。同时,在我们的interactions.js文件中添加相应的 JavaScript 代码:
//index.html
<button id="stop">Stop all Pending</button>

//interactions.js
const stopRequests = document.querySelector('#stop');
stopRequests.addEventListener('click', (ev) => {   
    fetch('/stop').then((res) => {
        numRequests = 0;
        requestAmount.textContent = numRequests;
    });
});
  1. 为停止请求添加相应的处理程序到我们的ServiceWorker
caches.match(event.request).then((response) => {
    if( response ) {
        return response
    }
    if( event.request.url.includes('/stop') ) {
        controller.abort();
        return new Response(new Blob(["all done"], {type :
        'text/plain'}), {status : 200});
    }
    // our previous handler code
})

现在,我们将利用一个叫做AbortController的东西。这个系统允许我们向诸如 fetch 请求之类的东西发送信号,以便我们可以说我们想要停止等待的请求。虽然这个系统主要用于停止 fetch 请求,但实际上我们可以利用这个信号来停止任何异步请求。我们通过创建一个AbortController并从中获取信号来实现这一点。然后,在我们的 promise 中,我们监听信号上的中止事件并拒绝 promise。

  1. 添加AbortController,如下所示:
const controller = new AbortController();
const signal = controller.signal;
const pollTime = 30000;
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then((response) => {
            if( response ) {
                return response
            }
            if( event.request.url.includes('/stop') ) {
                controller.abort();
                return new Response(new Blob(["all done"], {type :
                'text/plain'}), {status : 200});
            }
            if(!navigator.onLine ) {
                return new Promise((resolve, reject) => {
                    const interval = setInterval(() => {
                        if( navigator.onLine ) {
                            clearInterval(interval);
                            resolve(actualRequestHandler(event));
                        }
                    }, pollTime)
                    signal.addEventListener('abort', () => {
                        reject('aborted');
                    })
                });
            } else {
                return actualRequestHandler(event);
            }
        })
    )
});

现在,如果我们进入我们的系统,在离线模式下准备一些请求,然后点击取消按钮,我们会看到所有的请求都被取消了!我们本可以把AbortController放在我们前端的interactions.js文件中的 fetch 请求上,但一旦我们恢复在线,所有的 promise 仍然会运行,所以我们想确保没有任何东西在运行。这就是为什么我们把它放在ServiceWorker中的原因。

通过这样做,我们不仅看到了我们可以通过缓存数据来处理请求,还看到了当我们处于不稳定的位置时,我们可以存储这些请求。除此之外,我们还看到了我们可以利用AbortController来停止等待的 promise 以及如何利用它们除了停止 fetch 请求之外的其他用途。

总结

在本章中,我们了解了服务工作者如何将我们的应用程序从始终在线转变为我们可以创建真正始终工作的应用程序的系统。通过保存状态、本地处理请求、本地丰富请求,甚至保存离线使用的请求,我们能够处理我们应用程序的完整状态。

现在我们已经从客户端和服务器端使用 JavaScript 创建了丰富的 Web 应用程序,我们将开始研究一些高级技术,这些技术可以帮助我们创建高性能的应用程序,这些应用程序以前只能通过本机应用程序代码实现。我们可以通过使用 C、C++或 Rust 来实现这一点。

然而,在我们讨论这个之前,一个经常被应用开发者忽视的应用开发的部分是部署过程。在下一章中,我们将介绍一种通过一个流行系统叫做 CircleCI 来建立持续集成和持续开发(CI/CD)的方法。

第十二章:构建和部署完整的 Web 应用程序

现在我们已经看到了 JavaScript 的服务器端和客户端代码,我们需要专注于另一个完全不同的问题;也就是说,构建我们的代码以进行部署,并将该代码部署到服务器上。

虽然我们在本地运行了我们的服务器,但我们从未在云环境中运行过,比如亚马逊的 AWS 或微软的 Azure。今天的部署不再像 5 年前那样。以前,我们可以通过文件传输协议FTP)将我们的应用程序移动到服务器上。现在,即使对于小型应用程序,我们也使用持续部署系统。

在本章中,我们将探讨以下主题:

  • 了解 Rollup

  • 集成到 CircleCI

这些主题将使我们能够在典型的开发环境中开发几乎任何应用程序并将其部署。到本章结束时,我们将能够为 Web 应用程序实现典型的构建和部署环境。

让我们开始吧。

技术要求

对于本章,您将需要以下内容:

了解 Rollup

RollupJS 是一个构建工具,它允许我们根据环境的不同方式准备我们的应用程序。在它之前有许多工具(Grunt、Gulp),许多工具正在与它竞争(Webpack、Parcel),并且将来还会有许多工具。我们将专注于 RollupJS 用于我们的特定用例(在第九章中构建我们的静态服务器应用程序的实际示例),但请注意,大多数构建工具在其架构方面是相似的。

RollupJS 给我们的是一种在构建生命周期的不同部分具有钩子的方式。大多数应用程序在构建过程中具有以下状态:

  • 构建开始

  • 依赖注入

  • 编译

  • 编译后

  • 构建结束

这些状态在不同的构建系统中可能有不同的名称,并且有些甚至可能不止这些(正如我们将看到的,RollupJS 有),但这是典型的构建系统。

在大多数情况下,我们需要为我们的 JavaScript 应用程序做以下事情:

  • 引入我们的 Node/browser 端需要的任何依赖项

  • 将我们的 JavaScript 编译为单个文件(如果针对 HTTP/1)或将其编译为较早版本(如果我们针对更广泛的浏览器支持)

  • 将 CSS 编译为单个文件,移动图片等

对于我们的应用程序来说,这将非常容易。在这里,我们将学习如何做以下事情:

  • 将我们的 Node.js 代码构建成一个单一的可分发文件

  • 准备我们的静态资产,如 CSS/图片

  • 将 Rollup 添加到我们的 npm 构建流程中

将我们的静态服务器构建成一个单一的可分发文件

首先,我们需要创建一个我们准备好使用的文件夹。为此,可以在我们在第九章中工作的文件夹中工作,实际示例-构建静态服务器,或者从本书的 GitHub 存储库中拉取代码。然后运行npm install -g rollup命令。这将把 rollup 系统放入我们的全局路径,以便我们可以通过运行rollup命令来使用命令行。接下来,我们将创建一个配置文件。为此,我们将在我们的目录的基础(与我们的package.json文件的确切位置相同)中添加一个rollup.config.js文件,并将以下代码添加到其中:

module.exports = {
    input: "./main.js",
    output: {
        file: "./dist/build.js",
        format: "esm"
    }
}

我们已经告诉 Rollup 我们应用程序的起点在main.js文件中。Rollup 将遵循这个起点并运行它以查看它依赖于什么。它依赖于什么,它将尝试将其放入一个单一的文件中,并在此过程中删除任何不需要的依赖项(这称为 tree-shaking)。完成后,它将文件放在dist/build.js中。

如果我们尝试运行这个,我们会遇到一个问题。在这里,我们正在为类使用私有变量,而 Rollup 不支持这一点,以及我们正在使用的 ESNext 的其他特性。我们还需要更改任何在函数外设置成员变量的地方。这意味着我们需要将cache.js更改为以下内容:

export default class LRUCache {
    constructor(num=10) {
        this.numEntries = num;
        this.cache = new Map();
    }
}

我们还需要替换template.js中的所有构造函数,就像我们在LRUCache中所做的那样。

在进行了上述更改后,我们应该看到rollup对我们感到满意,并且现在正在编译。如果我们进入dist/build.js文件,我们将看到它将所有文件放在一起。让我们继续在我们的配置文件中添加另一个选项。按照以下步骤进行:

  1. 运行以下命令将最小化器和代码混淆器插件添加到 Rollup 作为开发依赖项:
> npm install -D rollup-plugin-terser
  1. 安装了这个之后,将以下行添加到我们的config.js文件中:
import { terser } from 'rollup-plugin-terser';
module.exports = {
    input: "./main.js",
    output: {
        file: "./dist/build.js",
        format: "esm",
        plugins: [terser()]
    }
}

现在,如果我们查看我们的dist/build.js文件,我们将看到一个几乎不可见的文件。这就是我们的应用程序的 Rollup 配置所需的全部内容,但还有许多其他配置选项和插件可以帮助编译过程。接下来,我们将看一些可以帮助我们将 CSS 文件放入更小格式的选项,并查看如果我们使用 Sass 会发生什么以及如何将其与 Rollup 编译。

将其他文件类型添加到我们的分发

目前,我们只打包我们的 JavaScript 文件,但大多数应用程序开发人员知道任何前端工作也需要打包。例如,以 Sass (sass-lang.com/)为例。它允许我们以一种最大程度地实现可重用性的方式编写 CSS。

让我们继续将我们为这个项目准备的 CSS 转换为 Sass 文件。按照以下步骤进行:

  1. 创建一个名为stylesheets的新文件夹,并将main.scss添加到其中。

  2. 将以下代码添加到我们的 Sass 文件中:

$main-color: "#003A21";
$text-color: "#efefef";
/* header styles */
header {
    // removed for brevity
    background : $main-color;
    color      : $text-color;
    h1 {
        float : left;
    }
    nav {
        float : right;
    }
}
/* Footer styles */
footer {
    // removed for brevity
    h2 {
        float : left;
    }
    a {
        float : right;
    }
}

前面的代码展示了 Sass 的两个特性,使其更容易使用:

  • 它允许我们嵌套样式。我们不再需要单独的footerh2部分,我们可以将它们嵌套在一起。

  • 它允许使用变量(是的,在 CSS 中我们有它们)。

随着 HTTP/2 的出现,一些文件捆绑的标准已经被淘汰。诸如雪碧图之类的项目不再建议使用,因为 HTTP/2 标准增加了 TCP 多路复用的概念。下载多个较小的文件可能比下载一个大文件更快。对于那些感兴趣的人,以下链接更详细地解释了这些概念:css-tricks.com/musings-on-http2-and-bundling/

Sass 还有很多内容,不仅仅是在他们的网站上可以找到的,比如 mixin,但在这里,我们想专注于将这些文件转换为我们知道可以在前端使用的 CSS。

现在,我们需要将其转换为 CSS 并将其放入我们的原始文件夹中。为此,我们将在我们的配置中添加rollup-plugin-sass。我们可以通过运行npm install -D rollup-plugin-sass来实现。添加了这个之后,我们将添加一个名为rollup.sass.config.js的新 rollup 配置,并将以下代码添加到其中:

import sass from 'rollup-plugin-sass';
module.exports = {
    input: "./main-sass.js",
    output: {
        file: "./template/css/main.css",
        format: "cjs"
    },
    plugins: [
        sass()
    ]
}

一旦我们制作了我们的 rollup 文件,我们将需要创建我们目前拥有的main-sass.js文件。让我们继续做到这一点。将以下代码添加到该文件中:

import main_sass from './template/stylesheets/main.scss'
export default main_sass;

现在,让我们运行以下命令:

> rollup --config rollup.sass.config.js 

通过这样做,我们将看到模板文件夹内的css目录已经被填充。通过这样做,我们可以看到我们如何捆绑一切,不仅仅是我们的 JavaScript 文件。现在我们已经将 Rollup 的构建系统集成到了我们的开发流程中,我们将看看如何将 Rollup 集成到 NPM 的构建流程中。

将 rollup 引入 Node.js 命令

现在,我们可以只是让一切保持原样,并通过命令行运行我们的 rollup 命令,但是当我们将持续集成引入我们的流程时(接下来),这可能会使事情变得更加困难。此外,我们可能有其他开发人员在同一系统上工作,而不是让他们运行多个命令,他们可以运行一个npm命令。相反,我们希望将 rollup 集成到各种 Node.js 脚本中。

我们在第九章中看到了这一点,实际示例-构建静态服务器,使用了microserve包和start命令。但现在,我们想要集成两个新命令,称为buildwatch

首先,我们希望build命令运行我们的 rollup 配置。按照以下步骤来实现这一点:

  1. 让我们清理一下我们的主目录,并将我们的 rollup 配置移动到一个构建目录中。

  2. 这两个都移动后,我们将在package.json文件中添加以下行:

"scripts": {
        "start": "node --experimental-modules main.js",
        "build": "rollup --config ./build/rollup.config.js && rollup --config ./build/rollup.sass.config.js",
}
  1. 通过这一举措,我们可以运行npm run build,并在一个命令中看到所有内容都已构建完成。

其次,我们想要添加一个 watch 命令。这将允许 rollup 监视更改,并立即为我们运行该脚本。我们可以通过将以下行添加到我们的scripts部分中,轻松地将其添加到我们的package.json中:

"watch": "rollup --config ./build/rollup.config.js --watch"

现在,如果我们输入npm run watch,它将以监视模式启动 rollup。通过这样做,当我们对 JavaScript 文件进行更改时,我们可以看到 rollup 自动重新构建我们的分发文件。

在我们进入持续集成之前,我们需要做的最后一个改变是将我们的主入口点指向我们的分发文件。为此,我们将更改package.json文件中的 start 部分,使其指向dist/build.js

"start": "node --experimental-modules dist/build.js"

有了这个,让我们继续检查一下,确保一切仍然正常运行,通过运行npm run start。我们会发现一些文件没有指向正确的位置。让我们通过对package.json文件进行一些更改来修复这个问题:

"config": {
    "port": 50000,
    "key": "../selfsignedkey.pem",
    "certificate": "../selfsignedcertificate.pem",
    "template": "../template",
    "bodyfiles": "../publish",
    "development": true
}

有了这个,我们应该准备好了!Rollup 有很多选项,当我们想要集成到 Node 脚本系统时,甚至还有更多选项,但这应该让我们为本章的下一部分做好准备,即集成到 CI/CD 流水线中。我们选择的系统是 CircleCI。

集成到 CircleCI

正如我们之前提到的,过去几十年里,现实世界中的开发发生了巨大的变化。从在本地构建所有内容并从我们的开发机器部署到复杂的编排和依赖部署树,我们已经看到了一系列工具的崛起,这些工具帮助我们快速开发和部署。

我们可以利用的一个例子是 CI/CD 工具,比如 Jenkins、Travis、Bamboo 和 CircleCI。这些工具会触发各种钩子,比如将代码推送到远程存储库并立即运行构建。我们将利用 CircleCI 作为我们的选择工具。它易于设置,是一个易于使用的开发工具,为开发人员提供了一个不错的免费层。

在我们的情况下,这个构建将做以下三件事:

  1. 拉取所有项目依赖项

  2. 运行我们的 Node.js 构建脚本

  3. 将这些资源部署到我们的服务器上,我们将在那里运行应用程序

设置所有这些可能是一个相当令人沮丧的经验,但一旦我们的应用程序连接起来,它就是值得的。我们将利用以下技术来帮助我们进行这个过程:

  • CircleCI

  • GitHub

考虑到这一点,我们的第一步将是转到 GitHub 并创建一个个人资料,如果我们还没有这样做。只需转到github.com/,然后在右上角查找注册选项。一旦我们这样做了,我们就可以开始创建/分叉存储库。

由于这本书的所有代码都在 GitHub 上,大多数人应该已经有 GitHub 账户并了解如何使用 Git 的基础知识。

对于那些在 Git 上挣扎或尚未使用版本控制系统的人,以下资源可能会有所帮助:try.github.io/

现在,我们需要将所有代码都在的存储库分叉到我们自己的存储库中。要做到这一点,请按照以下步骤进行操作:

  1. 转到本书的 GitHub 存储库github.com/PacktPublishing/Hands-On-High-Performance-Web-Development-with-JavaScript,并单击右上角的选项,将整个存储库分叉。

如果我们不想这样做,我们可以将存储库克隆到本地计算机。(这可能是更好的选择,因为我们只想要Chapter12目录的内容。)

  1. 无论我们选择哪种选项,都可以将Chapter12目录移动到本地计算机的另一个位置,并将文件夹名称更改为microserve

  2. 回到 GitHub,创建一个新的存储库。将其设置为私有存储库。

  3. 最后,回到我们的本地机器,并使用以下命令删除已经存在的.git文件:

> rf -rf .git

对于使用 Windows 的人,如果你有 Windows 10 Linux 子系统,可以运行这些命令。或者,你可以下载 Cmder 工具:cmder.net/

  1. 运行以下命令,将本地系统连接到远程 GitHub 存储库:
> git init
> git add .
> git commit -m "first commit"
> git remote add origin 
  https://github.com/<your_username>/<the_repository>.git
> git push -u origin master
  1. 命令行将要求输入一些凭据。使用我们设置个人资料时的凭据。

我们的本地文件应该已经连接到 GitHub。现在我们需要做的就是用 CircleCI 设置这个系统。为此,我们需要在 CircleCI 的网站上创建一个账户。

  1. 转到circleci.com/,点击“注册”,然后使用 GitHub 注册。

一旦我们的账户连接上了,我们就可以登录。我们应该会看到以下屏幕:

  1. 点击“设置项目”以设置我们刚刚设置的存储库。

它应该会检测到我们的存储库中已经有一个 CircleCI 文件,但如果我们愿意,我们也可以从头开始。接下来的指示将是为了从头开始设置 CircleCI。为此,我们可以利用他们提供的 Node.js 模板。然而,我们主要需要做的是在.circleci目录中创建config.yml文件。我们应该有一个基本的东西,看起来像这样:

version: 2
jobs:
  build:
    docker:
      - image: circleci/node:12.13
    working_directory: ~/repo
    steps:
      - checkout
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "package.json" }}
            - v1-dependencies-
      - run: npm install
      - save_cache:
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}

CircleCI 配置文件的执行方式如下:

  1. 我们声明要使用 Docker 中的circleci/node:12.13镜像

我们不会在这里讨论 Docker,但这是许多公司用来部署和托管应用程序的另一种技术。有关这项技术的更多信息可以在这里找到:docs.docker.com/

  1. 我们希望在~/repo中运行所有命令。这将是我们几乎所有基本项目的情况。

  2. 接下来,我们将该存储库检入到~/repo中。

  3. 现在,如果我们还没有为这个存储库设置缓存,我们需要设置一个。这样可以确保我们只在需要时才拉取存储库。

  4. 我们需要运行npm install命令来拉取所有的依赖项。

  5. 最后,我们保存缓存。

这个过程被称为持续集成,因为当我们推送代码时,它会不断地为我们运行构建。如果我们想要,我们可以在 CircleCI 配置文件中添加不同的设置,但这超出了本书的范围。当构建完成时,我们还会通过电子邮件收到通知。如果需要,我们可以在以下位置进行调整:circleci.com/gh/organizations/<your_user>/settings

有了这个,我们已经创建了一个基本的 CircleCI 文件!现在,如果我们转到我们的仪表板,一旦我们推送这个 CircleCI 配置,它应该运行一个构建。它还应该显示我们之前列出的所有步骤。太棒了!现在,让我们连接我们的构建过程,这样我们就可以真正地使用我们的 CI 系统。

添加我们的构建步骤

通过我们的 CircleCI 配置,我们可以在流程中添加许多步骤,甚至添加称为 orbs 的东西。Orbs 本质上是预定义的包和命令,可以增强我们的构建过程。在本节中,我们将添加由 Snyk 发布的一个 orb:snyk.io/。这将扫描并查找当前在 npm 生态系统中存在的不良包。我们将在设置构建后添加这个。

为了让我们的构建运行并打包成我们可以部署的东西,我们将在我们的 CircleCI 配置中添加以下内容:

- run: npm install
- run: npm run build

有了这个,我们的系统将会像在本地运行一样构建。让我们继续尝试一下。按照以下步骤进行:

  1. 将我们的配置文件添加到我们的git提交中:
> git add .circleci/config.yml
  1. 将此提交到我们的本地存储库:
> git commit -m "changed configuration"
  1. 将此推送到我们的 GitHub 存储库:
> git push

一旦我们这样做,CircleCI 将启动一个构建。如果我们在 CircleCI 中的项目目录中,我们将看到它正在构建。如果我们点击作业,我们将看到它运行我们所有的步骤-我们甚至会看到它运行我们在文件中列出的步骤。在这里,我们将看到我们的构建失败!

这是因为当我们安装 Rollup 时,我们将其安装为全局项目。在这种情况下,我们需要将其添加为package.json文件中的开发依赖项。如果我们将其添加到我们的package.json文件中,我们应该有一个看起来像这样的devDependency部分:

"devDependencies": {
    "rollup-plugin-sass": "¹.2.2",
    "rollup-plugin-terser": "⁵.1.2",
    "rollup-plugin-uglify": "⁶.0.3",
    "rollup": "¹.27.5"
}

现在,如果我们将这些文件提交并推送到我们的 GitHub 存储库,我们将看到我们的构建通过了!

通过一个通过的构建,我们应该将 Snyk orb 添加到我们的配置中。如果我们前往circleci.com/orbs/registry/orb/snyk/snyk,我们将看到我们需要设置的所有命令和配置。让我们继续修改我们的config.yml文件,以引入 Snyk orb。我们将在构建后检查我们的存储库。这应该看起来像这样:

version: 2.1
orbs:
  snyk: snyk/snyk@0.0.8
jobs:  build:
    docker:
      - image: circleci/node:12.13
    working_directory: ~/repo
    steps:
      - checkout
      - run: npm install   
      - snyk/scan     
      - run: npm run build

有了上述配置,我们可以继续提交/推送到我们的 GitHub 存储库,并查看我们的构建的新运行。它应该失败,因为除非我们明确声明要运行它们,否则它不允许我们运行第三方 orbs。我们可以通过前往设置并转到安全部分来做到这一点。一旦在那里,继续声明我们要使用第三方 orbs。勾选后,我们可以进行另一个构建,我们将看到我们再次失败!

我们需要注册 Snyk 才能使用他们的 orb。前往 snyk.io 并使用 GitHub 帐户注册。然后,转到“帐户设置”部分。从那里,获取 API 令牌并转到“设置和上下文”部分。

创建一个新的上下文并添加以下环境变量:

SNYK_TOKEN : <Your_API_Key>

为了利用 contexts,我们需要稍微修改我们的config.yml文件。我们需要添加一个工作流部分,并告诉它使用该上下文运行我们的构建作业。文件应该看起来像下面这样:

version : 2.1
orbs:
    snyk: snyk/snyk@0.0.8
jobs:
  build:
    docker:
      - image: circleci/node:12.13
    working_directory: ~/repo
    steps:
      - checkout
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "package.json" }}
            - v1-dependencies-
      - run: npm install
      - snyk/scan     
      - run: npm run build
      - save_cache:
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}
workflows:
  version: 2
  build_and_deploy:
    jobs:
      - build:
          context: build

有了这个变化,我们可以继续将其推送到远程存储库。我们将看到构建通过,并且 Snyk 将安全扫描我们的包!

上下文的概念是为了隐藏配置文件中的 API 密钥和密码。我们不希望将它们放在配置文件中,因为任何人都可以看到它们。相反,我们将它们放在诸如上下文之类的地方,项目的管理员将能够看到它们。每个 CI/CD 系统都应该有这样的概念,并且在有这样的项目时应该使用它。

随着我们的项目构建和扫描完成,我们所需要做的就是将我们的应用程序部署到一台机器上!

部署我们的构建

要部署我们的应用程序,我们需要部署到我们自己的计算机上。有许多服务可以做到这一点,比如 AWS、Azure、Netlify 等等,它们都有自己的部署方式。在我们的情况下,我们将部署到 Heroku。

按照以下步骤操作:

  1. 如果我们还没有 Heroku 帐户,我们需要去注册一个。前往id.heroku.com/login,然后在表单底部选择“注册”。

  2. 登录新帐户,然后点击右上角的“新建”按钮。

  3. 在下拉菜单中,点击“创建新应用程序”。

  4. 我们可以随意给应用程序取任何名字。输入一个应用程序名称。

  5. 返回到我们的 CircleCI 仪表板,然后进入设置。创建一个名为“deploy”的新上下文。

  6. 添加一个名为HEROKU_APP_NAME的新变量。这是我们在步骤 3中设置的应用程序名称。

  7. 返回 Heroku,点击右上角的用户配置文件图标。从下拉菜单中,点击“帐户设置”。

  8. 您应该会看到一个名为“API 密钥”的部分。点击“显示”按钮,然后复制显示的密钥。

  9. 返回到我们的 CircleCI 仪表板,并创建一个名为HEROKU_API_KEY的新变量。值应该是我们在步骤 8中得到的密钥。

  10. 在我们的config.yml文件中添加一个新的作业。我们的作业应该看起来像下面这样:

version : 2.1
orbs:
  heroku: circleci/heroku@0.0.10
jobs:
  deploy:
    executor: heroku/default
    steps:
      - checkout
      - heroku/install
      - heroku/deploy-via-git:
          only-branch: master
workflows:
 version: 2
 build_and_deploy:
 jobs:
   - build:
       context: build
   - deploy
       context: deploy
       requires:
         - build

我们在这里做的是向我们的工作流程中添加了一个新的作业,即deploy作业。在这里,第一步是向我们的工作流程中添加官方的 Heroku orb。接下来,我们创建了一个名为deploy的作业,并按照 Heroku orb 中的步骤进行操作。这些步骤可以在circleci.com/orbs/registry/orb/circleci/heroku找到。

  1. 我们需要将我们的构建部署回 GitHub,以便 Heroku 获取更改。为此,我们需要创建一个部署密钥。在命令提示符中运行ssh-keygen -m PEM -t rsa -C "<your_email>"命令。确保不要输入密码。

  2. 复制刚生成的密钥,然后进入 GitHub 存储库的设置。

  3. 在左侧导航栏中点击“部署密钥”。

  4. 点击“添加部署密钥”。

  5. 添加一个标题,然后粘贴我们在步骤 12中复制的密钥。

  6. 勾选“允许写入访问”复选框。

  7. 返回 CircleCI,点击左侧导航栏中的项目设置。

  8. 点击“SSH 权限”,然后点击“添加 SSH 密钥”。

  9. 步骤 11中添加我们创建的私钥。确保在主机名部分添加github.com

  10. 添加以下行到我们构建作业的config.yml文件中:

steps:
  - add_ssh_keys:
  fingerprints:
 - "<fingerprint in SSH settings>"
  1. 在构建结束时,添加以下步骤:
- run: git push

我们将遇到的一个问题是,我们的应用程序希望通过 HTTPS 工作,但 Heroku 需要专业许可证才能实现这一点。要么选择这个(这是一个付费服务),要么更改我们的应用程序,使其只能使用 HTTP。

通过这样做,我们成功地建立了一个几乎可以在任何地方使用的 CI/CD 流水线。我们还增加了一个额外的安全检查,以确保我们部署的代码是安全的。有了这些,我们就能够构建和部署用 JavaScript 编写的 Web 应用程序了!

总结

在本章中,我们学习了如何在利用构建环境(如 RollupJS)的同时构建应用程序。除此之外,我们还学习了如何通过 CircleCI 添加 CI 和 CD。

下一章,也是本书的最后一章,将介绍一个名为 WebAssembly 的高级概念。虽然代码不会是 JavaScript,但它将帮助我们了解如何将我们的 Web 应用程序提升到一个新的水平。

第十三章:WebAssembly - 简要了解 Web 上的本地代码

过去的几章都是关于如何在现代网络环境中利用 JavaScript。我们已经看过前端开发、后端开发,甚至通过持续集成和持续部署(CI/CD)构建和部署应用程序。现在,我们要退一步,看看两个可以帮助我们加快本地速度代码开发的主题。

WebAssembly 是 Web 上汇编的规范。汇编是计算机理解的语言的一对一映射。另一方面,WebAssembly 是一个虚拟计算机的一对一映射,可以运行这些指令。在本章中,我们将探讨 WebAssembly 以及如何将本地应用程序移植到浏览器中。

总体上,我们将探讨以下主题:

  • 理解 WebAssembly

  • 设置我们的环境来编写 WebAssembly

  • 编写 WebAssembly 模块

  • 移植 C 应用程序

  • 查看一个主要应用程序

通过本章结束时,我们应该能够不仅在 WebAssembly 文本格式中开发,还能够在 Web 上使用 C。我们还将能够将二进制 WebAssembly 转换为其文本格式,以便诊断我们移植应用程序可能出现的问题。

技术要求

我们需要以下工具来完成本章内容:

理解 WebAssembly

WebAssembly 是一种可以在计算机上运行的指令集规范。在我们的情况下,这台计算机是虚拟的。为了理解这是如何转化为本地速度应用程序以及为什么指令被编写成这样,我们需要基本了解程序在计算机内部是如何运行的。为了理解 WebAssembly,我们将研究以下主题:

  • 理解一个基本程序的流程

  • 设置我们的环境来编写 WebAssembly 代码

理解程序

让我们来看一个非常基本的 C 程序:

#include <stdio.h>
int main() {
   printf("Hello, World!");
   return 0;
}

这个程序有一个入口点。在我们的情况下,它是main函数。从这里,我们利用了在stdio头文件中声明的一个函数(头文件给出了函数声明,这样我们就不必完全导入所有代码到这个文件中)。我们利用printf函数将Hello, World!打印到控制台,然后我们用return0来表示我们有一个成功的程序。

由于我们不会深入讨论我们将要编写的 C/C++代码,对于那些感兴趣的人,一个很好的资源是www.learn-c.org/

虽然这是一个我们作为程序员通常理解的程序格式,但它需要被转换成计算机实际理解的格式。这意味着它需要被编译。这个编译过程涉及引入任何外部文件(在这种情况下是stdio)并将它们链接在一起。这也意味着我们需要将我们的每个指令转换成一个或多个计算机指令。

一旦链接和编译过程发生,我们通常会得到一个可以被计算机读取的二进制文件。如果我们用一个字节阅读器打开这个文件,我们会看到一堆十六进制数字。这些数字中的每一个对应于我们在文件中放置的指令、数据点等等。

现在,这只是对程序如何转换为计算机理解的东西以及我们创建的二进制代码如何被计算机理解的基本理解。在大多数机器上,程序作为一堆指令和数据运行。这意味着它一次从堆栈顶部拉出一条指令。这些指令可以是任何东西,从将这个数字加载到一个位置,或者将这两个数字加在一起。当它剥离这些指令时,它会丢弃它们。

我们可以将各种数据存储在这个堆栈的本地,或者我们可以在全局级别存储数据。那些存储在堆栈上的数据就是确切地那个——堆栈。一旦那个堆栈被耗尽,我们就再也无法访问那些变量了。

全球的数据被放置在一个叫做的位置。堆允许我们从系统中的任何地方获取数据。一旦我们的程序的堆栈被耗尽,如果我们的程序仍在运行,那些堆对象可以留在那里。

最后,我们为我们编写的每个函数得到一个堆栈。因为这个原因,我们可以把每个函数当作一个小程序来对待。这意味着它将执行一个任务或一些任务,然后它将被耗尽,我们将回到调用我们的函数的堆栈。当我们耗尽这个堆栈时,我们可以做两件事。我们可以做的第一件事是回到调用我们的函数的堆栈,没有数据。或者,我们可以返回一些数据(这是我们在大多数语言中看到的return语句)。

在这里,我们可以通过这两种机制之一共享数据,要么通过从我们的子函数之一返回值,要么通过将我们的结果放到堆上,以便其他人可以访问它们。把它放在堆上意味着它将持续存在于我们的程序的持续时间,但它也需要我们来管理,而如果我们通过堆栈返回值,它将在调用我们的函数耗尽时立即清理。大多数时候,我们将使用简单的数据,并通过堆栈返回它。对于复杂的数据,我们将把它放在堆上。

在我们看 WebAssembly 之前,有一件最后需要知道的事情:如果我们把数据放在堆上,我们需要告诉程序的其他部分在哪里找到这些数据。为了做到这一点,我们传递一个指向该位置的指针。这个指针只是一个地址,告诉我们在哪里找到这些数据。我们将在我们的 WebAssembly 代码中利用这一点。

计算机和程序工作的主题是非常有趣的。对于那些感兴趣的人,最好在社区大学参加正式课程。对于那些喜欢自学的人,以下资源非常有帮助:www.nand2tetris.org/

现在,让我们设置我们的环境,以便我们可以在 WebAssembly 中编程。

设置我们的环境

要在 WebAssembly 中编程,我们需要在我们的机器上获取wat2wasm程序。这样做的最佳方法是下载 WebAssembly 程序套件的存储库,并将它们编译为我们的计算机。按照以下步骤来做到这一点:

  1. 我们需要在我们的系统上获取一个叫做 CMake 的程序。对于 Linux/OS X,这意味着只需转到cmake.org/download/并运行安装程序。对于那些在 Windows 上的人来说,这会有点冗长。前往visualstudio.microsoft.com/vs/并获取 Visual Studio。确保为其获取 C/C++模块。有了 CMake 和 Visual Studio 在我们的机器上,我们现在可以继续并编译 WebAssembly 工具套件。

  2. 前往github.com/WebAssembly/wabt并克隆到一个易于访问的位置。

  3. 打开 CMake GUI 工具。它应该看起来类似于以下内容:

  1. 对于源代码,转到我们从 GitHub 下载的wabt文件夹。

  2. 二进制文件的位置应该在我们在wabt文件夹中创建的build目录中。

  3. 有了这个,我们可以点击“配置”按钮。这应该会填充屏幕中间的面板。

  4. 现在,只需点击“生成”按钮。这应该会生成我们构建应用程序所需的文件。

  5. 最后,我们将进入 Visual Studio 并构建项目。

  6. 打开 Visual Studio,并从左上角的文件下拉菜单中打开项目。

  7. 项目加载后,我们可以点击“构建”。这应该会构建我们在 WebAssembly 中使用的所有二进制文件。屏幕应该看起来像这样:

如果遇到问题,github.com/WebAssembly/wabt存储库中包含了一些关于如何完成构建的优秀文档。前面的说明试图简化构建过程,但是要让这些项目正常运行可能会有一些困难。

现在我们已经构建了我们的二进制文件,让我们确保将它们放在路径上,以便我们可以轻松访问它们。在 Windows 上,我们可以这样做:

  1. 转到搜索栏,输入“路径变量”。第一个选项应该允许我们设置环境变量:

  1. 点击右下方的“环境变量...”选项:

  1. 对于底部的框,找到路径变量并点击编辑...:

  1. 点击“新建”,找到存放所有二进制文件的目录:

完成后,我们应该能够在命令行中输入wat2wasm并获取工具的帮助文档。现在,我们可以将 WebAssembly 的文本格式编译成浏览器期望的格式!

现在我们已经将 WebAssembly 二进制工具包添加到系统中,并且可以编译/反编译 WebAssembly 程序,让我们开始编写我们的第一个 WebAssembly 程序!

编写 WebAssembly 模块

WebAssembly 模块类似于 JavaScript 模块。我们需要明确地从其他 WebAssembly/JavaScript 模块中导入我们需要的任何内容。我们在 WebAssembly 模块中编写的任何内容,除非我们明确导出它,否则其他 WebAssembly 模块无法找到。我们可以将其视为 JavaScript 模块 - 它是一个沙盒环境。

让我们从 WebAssembly 模块的最基本和无用版本开始:

(module)

有了这个,我们可以转到命令行并运行以下命令:

> wat2wasm useless.wat

这段代码将生成一个带有wasm扩展名的文件。这是我们需要传递到 Web 浏览器以运行 WebAssembly 的文件。所有这些都告诉我们,WebAssembly 和 JavaScript 的 ESNext 一样,希望在模块中声明所有内容。更容易将其视为在 JavaScript 中加载时发生的情况:

<script type="module"></script>

这意味着在 WebAssembly 上下文中加载的所有代码都不能溢出到我们设置的其他 WebAssembly 模块中。现在,要将此wasm文件加载到我们的浏览器中,我们需要利用第九章中使用的静态服务器,实际示例 - 构建静态服务器

加载完毕后,按照以下步骤进行:

  1. 创建一个基本的index.html文件,如下所示:
<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <script type="text/javascript">
        </script>
 </body>
</html>
  1. 在我们的script元素中,我们将添加以下代码来加载模块:
WebAssembly.instantiateStreaming(fetch('useless.wasm')).then(obj => {
    // nothing here
});

我们已经将我们的第一个 WebAssembly 模块加载到浏览器中。API 非常基于 Promise,因此我们需要利用 Fetch 接口。一旦我们获取了对象,它就加载到浏览器的 WebAssembly 上下文中,这意味着我们可以使用这个对象。这就是我们刚刚加载的 WebAssembly 模块!

让我们继续制作一个更有用的 WebAssembly 模块。让我们输入两个数字并将它们相加。按照以下步骤进行:

  1. 创建一个名为math.wat的文件。

  2. 将以下代码放入文件中:

(module
    (func $add (param $p1 i32) (param $p2 i32) (result i32)
        local.get $p1
        local.get $p2
        i32.add
    )
    (export "add" (func $add))
)
  1. 通过运行wat2wasm math.wat来编译。

  2. 将新的wasm文件加载到浏览器中,并在then体中添加以下内容:

console.log(obj.instance.exports.add(100,200));
  1. 确保静态服务器正在运行,方法是进入文件夹并运行static-server命令。

对于那些直接跳到这一章的人,你可以通过运行npm install -g static-server来安装一个静态服务器。这将在全局安装这个静态服务器。然后,我们只需要在要部署文件的文件夹中运行static-server。现在我们已经做到了这一点,我们可以通过访问localhost:9080来打开我们的index.html文件。

如果我们打开浏览器,转到localhost:9080,并打开控制台,我们会看到数字 300 已经被打印出来。我们刚刚编写了我们的第一个可访问的 WebAssembly 模块!

让我们回顾一下我们在前面的代码中涵盖的一些概念。首先,我们定义了一个函数。我们声明这个函数的名称是$add(在 WebAssembly 中,所有变量都以美元符号开头)。然后,我们声明它将接受两个我们称为$p1$p2的参数。最后,我们将输出一个结果;也就是一个 32 位整数。

现在,我们将两个参数存储在我们的堆栈上。最后,我们将它们相加并将其作为结果。还记得本章开头我们谈到程序是堆栈的吗?这展示了完全相同的概念。我们将两个变量加载到堆栈上。我们弹出它们,以便我们可以在add函数中使用它们,这将在堆栈上放置一个新值。最后,我们从堆栈中弹出该值,并将其返回给主函数体;在我们的情况下,是模块。

接下来,我们导出了这个函数,以便我们的 JavaScript 代码可以访问它。这确保了我们的 WebAssembly 代码被保存在我们的沙盒中,就像我们想要的那样。现在,正如我们之前提到的,返回的对象是 WebAssembly 上下文。我们获取实例并查看可用的导出项。在我们的情况下,这是add函数,现在我们可以在我们的 JavaScript 代码中使用它。

现在我们已经学会了如何将 WebAssembly 模块导出到 JavaScript 上下文中,你可能会想知道我们是否可以将 JavaScript 函数加载到 WebAssembly 上下文中。我们可以!让我们继续向我们的index.html文件中添加以下代码:

const add = function(p1, p2) {
    return p1 + p2;
}
const importObject = { math : { add : add }};
WebAssembly.instantiateStreaming(fetch('math.wasm'), importObject).then(obj => {
    console.log(obj.instance.exports.add(100, 200));
    console.log(obj.instance.exports.add2(100, 200));
});

在这里,我们加载了从 JavaScript 上下文中获取的add函数,并创建了一个具有与 JavaScript 中add函数相同函数签名的关联函数。现在,我们创建了一个新的add函数称为$add2,具有类似的签名。我们将两个参数放入堆栈中并使用新的call指令。这个指令允许我们调用在我们上下文中声明的其他函数:

(func  $add2 (param  $p1  i32) (param  $p2  i32) (result  i32)
  local.get $p1
  local.get $p2
  call  $externalAdd )

最后,我们导出这个函数,就像我们之前对add函数做的那样。现在,如果我们编译我们的代码,回到浏览器,重新加载页面,我们会看到数字 300 被打印出两次。

现在,我们知道如何在 JavaScript 中使用 WebAssembly 函数,以及如何将 JavaScript 函数加载到 WebAssembly 中。我们即将能够编写一个在 JavaScript 编程面试中经常被问到的程序。不过,在这之前,我们需要看一下堆空间和在 JavaScript 和 WebAssembly 之间利用内存。

在 WebAssembly 和 JavaScript 之间共享内存

到目前为止,我们一直在处理 WebAssembly 中的一种变量类型。这些被称为本地变量,或者堆栈变量。还有另一种类型,它将允许我们不仅在 WebAssembly 上下文中使用它们,还可以在 JavaScript 和 WebAssembly 之间共享它们。但首先,我们需要讨论堆栈和堆之间的区别。

全局/局部变量和堆/栈之间存在差异。现在,我们将保持简单,将全局变量视为在堆上,将局部变量视为在栈上。稍后,我们的应用程序将具有不在堆上的全局状态,但最好尝试将局部等同于栈,全局等同于堆的概念。

当我们谈论程序在典型计算机上运行时,我们谈到了堆栈。最好的想法是将其视为一堆木头。我们总是从顶部取出,总是从顶部添加。这在编程中也是一样的。我们在堆栈顶部添加,然后从顶部取出这些项目。例如,让我们看看我们创建的add函数。

我们抓取了两个参数并将它们添加到堆栈中。首先是参数一,然后是参数二。当我们调用$externalAdd,甚至是调用内置在 WebAssembly 中的add函数时,它会从堆栈中取出这两个项目,并用一个项目替换它们,即结果。当我们从函数返回时,我们会从本地函数堆栈中取出该项目,并将其弹出到调用我们的上下文的堆栈顶部。

堆就像其名字所暗示的那样。我们有一堆可以从任何地方抓取、更改和替换的东西。任何人都可以访问堆,向其中放入项目并从中读取。就像一堆衣服一样 - 我们可以搜索并找到我们需要的项目,或者我们可以在一天结束时向其中添加项目。

两者之间的主要区别在于栈会被清理。一旦函数返回,我们在其中创建的任何变量都会被清理。另一方面,堆会一直存在。由于任何人都可以访问它,我们必须明确地摆脱它;否则,它将永久存在。在垃圾收集环境中,最好的想法是,我们的环境不知道谁还在上面放了东西,所以它不知道什么需要清理,什么不需要清理。

在 WebAssembly 中,我们没有垃圾收集环境,因此我们必须在 JavaScript 或 WebAssembly 上下文中完成后重新收集堆。在我们的示例中,我们不会这样做,因此请注意这是我们在生产环境中想要做的事情。为此,我们可以将 JavaScript 中的memory对象设置为null。这将让垃圾收集器知道没有人再使用它了。

让我们学习如何在 JavaScript 和 WebAssembly 之间共享内存,以及这如何等同于堆。按照以下步骤进行:

  1. 创建一个名为sharing_resources.wat的文件。

  2. 将以下代码放入文件中:

(module
   (import "js" "mem" (memory 1))
    (func $storeNumber
        (i32.store (i32.const 0) (i32.const 100))
    )
    (func $readNumber (result i32)
        (i32.load (i32.const 0))
    )
    (export "readNumber" (func $readNumber))
    (export "storeNumber" (func $storeNumber))
)

我们的第一个函数将数字100存储在内存位置0。如果我们要存储任意数量的数据,我们必须让调用我们的人知道我们存储了多少。但是,在这种情况下,我们总是知道只有一个数字。

我们的read函数只是从内存中读取该值并将其作为一个值返回。

  1. 我们在index.html文件中的脚本部分应该如下所示:
const memory = new WebAssembly.Memory({initial : 1});
const importObject = { js: {mem: memory}};
WebAssembly.instantiateStreaming(fetch('sharing_resources.wasm'), importObject).then(obj => {
    obj.instance.exports.storeNumber();
    console.log(obj.instance.exports.readNumber());
});

顶部部分应该看起来不同。首先,我们正在创建一个 JavaScript 和 WebAssembly 都可以共享的内存块。我们将只创建和加载一个内存块。在 WebAssembly 的上下文中,这是 64KB 的数据。

一旦我们的 WebAssembly 加载完成,我们存储该数字,然后读取它。现在,我们可以看到我们在 WebAssembly 中有一个全局状态,但是我们如何与 JavaScript 共享呢?好吧,代码的起始部分告诉了我们如何做。我们可以访问内存对象,所以我们应该能够获取它。让我们继续改变我们的脚本,以便我们可以直接在 JavaScript 中读取内存,而不是调用一个为我们执行此操作的函数。

以下代码应该可以做到这一点:

function readNumber() {
    const bytes = new Uint32Array(memory.buffer, 0, 1);
    console.log('The number that was put here is:', bytes[0]);
}

现在,我们可以在 WebAssembly 加载完成后将其添加到 body 中:

obj.instance.exports.storeNumber();
readNumber();

如果我们查看控制台,我们应该看到完全相同的输出!最后的测试是从 JavaScript 中存储一些东西并在 WebAssembly 中抓取它。我们可以通过将脚本更改为以下内容来实现这一点:

const memory = new WebAssembly.Memory({initial : 1});
const storeByte = new Int32Array(memory.buffer, 0, 1);    
storeByte[0] = 200;
const importObject = {js: {mem: memory}};
WebAssembly.instantiateStreaming(fetch('sharing_resources.wasm'), importObject).then(obj => {
    console.log(obj.instance.exports.readNumber());
});

如果我们保存这个并返回到控制台,我们应该看到数字 200 被打印出来!

现在,我们知道如何在两个实例之间共享内存,以及如何利用这一点来做一些很酷的事情。让我们继续测试我们所有的技能,并创建每个程序员最喜欢的程序:FizzBuzz。

在 WebAssembly 中编写 FizzBuzz

FizzBuzz 是一个编程挑战,要求用户输入一个正数循环从 1 到一个选择的数字,并根据以下标准打印结果:

  • 如果数字可以被 3 整除,则打印Fizz

  • 如果数字可以被 5 整除,则打印Buzz

  • 如果数字可以被 15 整除,则打印FizzBuzz

让我们继续准备我们的 JavaScript 环境。以下代码应该看起来很熟悉,除了我们的新日志函数:

const memory = new WebAssembly.Memory({initial : 1});
const storeByte = new Int32Array(memory.buffer, 0, 1);   
function consoleLogString(offset, length) {
    const bytes = new Uint8Array(memory.buffer, offset, length);
    const string = new TextDecoder('utf8').decode(bytes);
    console.log(string);
}
const importObject = { console: {log: consoleLogString}, js: {mem: memory}};
WebAssembly.instantiateStreaming(fetch('fizzbuzz.wasm'), importObject).then(obj => {
    //obj.instance.exports.fizzbuzz(10);
});

此函数接受内存偏移和数据长度,并将其打印出来。正如我们之前提到的,我们需要知道数据在哪里以及其长度,才能从堆中读取它。现在,我们可以进入程序的核心。按照以下步骤操作:

  1. 创建一个名为fizzbuzz.wat的新文件。

  2. 我们知道我们需要导入我们的内存和console函数,就像我们一直在导入其他函数一样。我们还知道我们将创建一个名为fizzbuzz的函数,并将其导出,以便我们的 JavaScript 上下文可以利用它:

(module
    (import "console" "log" (func $log (param i32 i32)))
    (import "js" "mem" (memory 1))
    (global $g (mut i32) (i32.const 0))
    (func $fizzbuzz (param $p i32)
        ;; content of the function
    )

    (export "fizzbuzz" (func $fizzbuzz))
)

前面代码的唯一有趣的部分是global部分。这是一个全局变量,可以被视为我们上下文的堆栈。它不在堆上,因此 JavaScript 上下文无法访问它。我们还可以看到声明前面的mut关键字。这告诉我们我们将从 WebAsembly 代码的各个部分更改全局变量。我们将利用这一点,使其保存我们的打印输出的长度。

  1. 我们需要检查 FizzBuzz 的两种情况:
(func $checkFizz (param $p1 i32))
(func $checkBuzz (param $p1 i32))

我们的两个函数都将接受一个数字。对于checkFizz函数,我们将测试它是否可以被 3 整除。如果可以,我们将在内存堆中存储单词Fizz,然后更新该全局变量为Fizz之后的位置。对于Buzz,我们将做完全相同的事情,只是我们将测试数字是否可以被 5 整除。如果是true,我们将在全局指针位置放置Buzz并更新它。

以下是checkFizz函数:

local.get $p1
i32.const 3
i32.rem_s
(if (i32.eq (i32.const 0))
    (then
        (i32.store8 (global.get $g) (i32.const 70))
        (i32.store8 (i32.add (global.get $g) (i32.const 1)) 
         (i32.const 105))
        (i32.store8 (i32.add (global.get $g) (i32.const 2)) 
         (i32.const 122))
        (i32.store8 (i32.add (global.get $g) (i32.const 3)) 
         (i32.const 122))
        (global.set $g (i32.add (global.get $g) (i32.const 4)))
    )
)

在这里,我们获取传入的数字。然后,我们将3放在堆栈上并运行余数函数。如果结果等于0,那么我们将单词Fizz放入内存中。现在,放入内存的内容可能看起来不像单词Fizz,但是如果我们查看每个字母的 UTF8 十进制数,我们将看到这就是我们放入内存的内容。

如果我们回到 JavaScript 代码,我们会看到我们正在使用TextDecoder。这允许我们读取这些字节值并将它们转换为它们的字符串等价物。由于 WebAssembly 只理解整数和浮点数的概念,这是我们现在必须处理它的方式。

接下来是checkBuzz函数。它应该与前面的代码类似,除了可被5整除:

(func $checkBuzz (param $p1 i32)
    local.get $p1
    i32.const 5
    i32.rem_s
    (if (i32.eq (i32.const 0))
        (then
            (i32.store8 (global.get $g) (i32.const 66))
            (i32.store8 (i32.add (global.get $g) (i32.const 1)) 
             (i32.const 117))
            (i32.store8 (i32.add (global.get $g) (i32.const 2)) 
             (i32.const 122))
            (i32.store8 (i32.add (global.get $g) (i32.const 3)) 
             (i32.const 122))
            (global.set $g (i32.add (global.get $g) (i32.const 4)))
        )
    )
)
  1. 现在,我们可以编写fizzbuzz。我们将接受整数,然后从1到该值运行我们的checkFizzcheckBuzz函数:
(func $fizzbuzz (param $p i32)
    (local $start i32)
    (local.set $start (i32.const 1))
    (block
        (loop
            (call $checkFizz (local.get $start))
            (call $checkBuzz (local.get $start))
            (br_if 1 (i32.eq (local.get $start) (local.get $p)))
            (local.set $start (i32.add (local.get $start) 
            (i32.const 1)))
            (br 0)
        )
    )
    i32.const 0
    global.get $g
    call $log
)

循环非常简单。br_if测试我们的start变量是否等于我们输入的值。如果是,它将等于1,并且将退出循环。否则,它将递增start变量一次。(br 0)是保持循环进行的部分。

完成循环后,我们将得到我们的全局变量,无论它最终在哪里,然后调用log函数。让我们编译并运行以下测试:

obj.instance.exports.fizzbuzz(10);

通过这样做,我们应该得到以下输出:

FizzBuzzFizzFizzBuzz

我们刚刚在纯 WebAssembly 中编写了一个非平凡的程序!到目前为止,您应该已经意识到为什么大多数人不会纯粹地编写 WebAssembly,因为本应该是一个简单的程序却花了我们相当多的代码。

在下一节中,我们将学习如何使用更高级的语言 C 来为 Web 编写程序。

为 Web 编写 C/C++

到目前为止,我们已经研究了编写 WebAssembly 的低级指令语言。虽然这可能是一个有趣的练习,但我们大多数项目的规模会更大,我们希望利用高级语言来实现我们的目标。虽然有一些类似于 JavaScript 的语言会编译成 WebAssembly(github.com/AssemblyScript/assemblyscript),但大部分模块将使用 C、C++或 Rust 等系统语言编写。在本节中,我们将研究为浏览器编写 C/C++代码。

Rust 语言(www.rust-lang.org/)为我们提供了一个比 C/C++更安全的选择。虽然长远来看使用它可能更好,但我们将坚持使用 C/C++,因为在可预见的未来,我们将广泛地将其编译为 WebAssembly,因为大多数程序目前都是用它编写的。

为了开始我们的 C/C++写作之旅,我们需要获取 Emscripten SDK 来编译为 WebAssembly。这可以在emscripten.org/index.html找到。我们将主要遵循 Emscripten 提供的入门指南。按照以下步骤:

  1. 首先,我们将克隆 Emscripten SDK,运行以下命令:
> git clone https://github.com/emscripten-core/emsdk.git
  1. 使用以下命令进入目录:
> cd emsdk
  1. 拉取最新更改和以下命令:
> git pull
> emsdk latest install
> emsdk activate latest
> emsdk_env.bat

现在我们有了前面的命令来帮助我们,我们准备开始为 Web 编写 C 和 C++!让我们开始一个简单的模块:

#include <stdio.h>
int main() {
   printf("Hello, World!\n");
   return 0;
}

这个基本的 C 程序是大家最喜欢的 Hello World 程序。要编译这个程序,请运行以下命令:

> emcc hello_world.c

如果一切安装正确,我们应该得到以下两个文件:

  • a.out.wasm

  • a.out.js

有了这两个文件,我们可以利用一个index.html文件并加载它们,就像这样:

<!DOCTYPE html>
<html>
    <head>    
    </head>
    <body>
        <script type="text/javascript" src="a.out.js"></script>
    </body>
</html>

我们应该在控制台上得到一个Hello World!的输出!让我们继续编写另一个 C 程序,就像我们之前写的 WebAssembly 程序一样 - FizzBuzz:

#include <stdio.h>
void fizzbuzz(int num) {
    for(int i = 1; i <= num; i++) {
        if(i%3 == 0) {
            printf("Fizz");
        }
        if(i%5 == 0) {
            printf("Buzz");
        }
    }
    printf("\n");
}

如果我们编译并尝试运行它,我们会发现什么也找不到。文档说明应该在全局Module变量上,但如果我们检查那里,我们会发现没有fizzbuzz程序。幸运的是,Emscripten 为我们进行了死代码分析,并注意到我们的 C 程序没有main函数,也没有调用fizzbuzz函数,因此将其消除了。

为了处理这个问题,我们可以在emcc调用中添加一个参数:

> emcc -s "EXPORTED_FUNCTIONS=['_fizzbuzz']" fizzbuzz.c

我们所有的函数都会在它们之前加上下划线。这有助于我们和系统区分在 JavaScript 系统中可能创建的内容和在 C/C++上下文中创建的内容。

有了这个,我们可以进入浏览器和我们的开发者控制台,输入以下内容:

Module._fizzbuzz(10);

我们应该看到一个输出!我们刚刚编译了我们的第一个可以在 JavaScript 代码中使用的来自 C 的库函数。现在,如果我们想尝试一些更困难的东西怎么办?如果我们想在我们的 C/C++代码中运行一个 JavaScript 函数怎么办?

为了做到这一点,我们将不得不执行以下操作:

  1. 我们需要在文件顶部放置一个extern声明(Emscripten 首先会在 JS 位置查找,但我们也可以传递一个命令行标志来告诉它在其他地方查找):
#include <stdio.h>
extern int add(int, int);
int main() {
    printf("%d\n", add(100, 200));
    return 1;
}
  1. 接下来,我们将创建一个名为external.js的文件,用来存放我们的新函数:
mergeInto(LibraryManager.library, {
    add: function(x, y) {
        return x + y;
    }
});
  1. 现在,我们可以用以下代码行来编译我们的程序:
> emcc -s extern.c --js-library external.js

之后,我们可以回到浏览器,看到它打印出了 300!现在,我们知道如何在我们的 C/C++程序中使用外部 JavaScript,并且可以从浏览器中获取我们的 C/C++代码。

一直以来,我们一直在覆盖我们的文件,但是有没有其他方法来处理这个问题呢?当然有 - 我们可以使用emcc -o <file_name.js>命令来调用emcc系统。因此,我们可以通过运行以下命令来编译我们的extern.c文件并将其命名为extern.js

> emcc --help

或者,我们可以去他们的网站:emscripten.org/

现在我们能够为我们的浏览器编写和编译 C 代码,我们将把注意力转向利用这一功能。让我们实现一个海明码生成器,我们可以在 JavaScript 中使用,它是用 C 编写的,并且可以编译成 WebAssembly。

编写海明码生成器

现在,我们将要编写一段复杂的软件。海明码生成器创建了一段数据,当它在两个媒介之间传输时应该能够被恢复。这些媒介可以是任何东西,从计算机到另一个计算机,甚至是一个进程到另一个进程(尽管我们希望进程之间的数据传输不会被损坏)。我们将要添加的数据被称为海明码。

为了编写这个软件,我们需要了解海明码是如何生成的,以及我们如何使用验证器来确保从一个媒介传输到另一个媒介的数据是正确的。具体来说,我们将研究海明数据的创建和验证过程。我们不会研究数据的恢复,因为这与创建数据的过程几乎相反。

为了理解海明数据是如何创建的,我们需要查看位级别的数据。这意味着如果我们想要传输数字 100,我们需要知道它在位上是什么样子的。位是计算机的最低数据单元。一个位只能是 0 或 1。当我们将更多的位加在一起时,它们代表 2 的幂。以下的表格应该有助于展示这一点:

位 3 位 2 位 1 位 0
8 4 2 1
2⁰

正如我们所看到的,每个位位置代表下一个 2 的幂。如果我们混合和匹配这些位,我们会发现我们可以表示所有正实数。还有一些方法可以表示负数甚至浮点数,但我们在这里不会涉及到这些。

对于那些好奇的人,关于浮点表示的文章可以在这里找到:www.cprogramming.com/tutorial/floating_point/understanding_floating_point_representation.html.

因此,如果我们想要以二进制形式看到这些数字,我们可以一个一个地查看它们。下表显示了左边的十进制表示和右边的二进制表示(十进制是我们习惯的):

0 0000
1 0001
2 0010
3 0011
4 0100
5 0101
6 0110
7 0111
8 1000

希望这样能澄清位和二进制表示是如何工作的。现在,我们将继续讲解海明码的实际工作原理。海明码通过在数据传输过程中的特定位置添加所谓的奇偶校验位来工作。这些奇偶校验位将根据我们选择的奇偶校验类型,要么是 1,要么是 0。

我们可以选择的两种奇偶校验类型是偶校验和奇校验。偶校验意味着当我们为奇偶校验位的所有位位置相加时,它们需要是偶数。如果我们选择奇校验,我们需要为奇校验位置的所有位进行相加,并检查它们是否是奇数。现在,我们需要决定哪些位对应于每个奇偶校验位位置,甚至奇偶校验位的位置。

首先,我们将看看奇偶校验位放在哪里。奇偶校验位将位于每个 2 的幂的位置。就像我们在前面的表格中看到的那样,我们将在以下位位置放置我们的奇偶校验位:1、2、4、8 和 16。如果我们看前面的表格,我们会注意到这些对应于只有一个位设置的位。

现在,我们需要决定哪些数据位位置对应于我们的奇偶校验位位置。嗯,我们可以根据奇偶校验位的位置猜测这些位置。对于每个数据位,我们将查看它们是否在相应的奇偶校验位设置了。这可以在以下表格中看到:

数字(十进制格式) 它是奇偶校验位吗? 使用此数据的奇偶校验位
1 N/A
2 N/A
3 1, 2
4 N/A
5 1, 4
6 2, 4
7 1, 2, 4
8 N/A

我们需要知道的最后一件事是如何将我们的数据与奇偶校验数据相结合。最好的方法是通过一个例子来看。让我们以数字 100 为例,并将其转换为二进制表示。我们可以手工完成这项工作,或者我们可以打开程序员计算器,大多数操作系统都有。

如果我们打开计算器并输入 100,我们应该得到它的以下二进制表示:1100100。现在,为了添加我们的奇偶校验位,我们需要根据我们是否在那里放置奇偶校验位来移动我们的数据位。让我们一步一步地来:

  1. 第一位是否用作奇偶校验位?是的,所以我们将在那里放置一个 0,并将我们的数据向左移动一次。现在我们有 11001000。

  2. 第二位是否用作奇偶校验位?是的,所以我们将在那里放置一个 0,并将我们的数据向左移动一次。现在我们有 110010000。

  3. 第三位是否用作奇偶校验位?否,所以我们可以把我们原来的第一个数据位放在那里,即 0。我们的数据看起来和以前一样:110010000。

  4. 第四位是否用作奇偶校验位?是的,所以我们将在那里放置一个 0,并将我们的数据向左移动一次。现在我们有 1100100000。

  5. 第五位是否用作奇偶校验位?否,所以我们将把我们原来的第二个数据位放在那里,即 0。我们的数据看起来和以前一样:1100100000。

  6. 第六位是否用作奇偶校验位?否,所以我们将把我们原来的第三个数据位放在那里,即 1。我们的数据看起来和以前一样:1100100000。

  7. 第七位是否用作奇偶校验位?否,所以我们将把我们原来的第四个数据位放在那里,即 0。我们的数据看起来如下:1100100000。

  8. 第八位是否用作奇偶校验位?是的,所以我们将把我们的数据向左移动一位,并在那里放置一个零。我们的数据如下:11000100000。

对于其余的数字,它们保持不变,因为我们没有更多的奇偶校验位要放置。现在我们有了我们的数据,我们必须设置我们的奇偶校验位。我们将在我们的示例和代码中使用偶校验。以下表格展示了最终数字以及我们必须将奇偶校验位设置为 1 或零的原因:

位位置 该位置的二进制 我们是否设置它? 奇偶校验的计数
1 00001 1
1 00010 3
0 00011 N/A
1 00100 1
0 00101 N/A
1 00110 N/A
0 00111 N/A
0 01000 2
0 01001 N/A
1 01010 N/A
1 01011 N/A

如前表所示,我们需要为 1、2 和 4 位置设置奇偶校验位。让我们看看第二位并经历这个过程。我们将寻找任何二进制表示中第二位设置的位位置。如果位在该位置被设置,我们将对其进行计数。在将所有这些数字相加后,如果它们相加得到奇数,我们需要设置奇偶校验位位置。对于第二位,我们可以看到 6、10 和 11 位置的数字有第二位设置,并且它们有一个 1。这就是为什么我们有三个计数,这意味着我们需要设置奇偶校验位以确保我们有偶校验。

这是很多信息要消化,重新阅读前面的部分可能有助于理解我们是如何得到最终的奇偶校验数的。如果想了解更多,请访问www.geeksforgeeks.org/hamming-code-in-computer-network/

现在,理论都讲完了,让我们开始编写 C 程序,能够创建奇偶校验数据并进行验证。

首先,让我们创建一个名为hamming.c的文件。我们将把它创建为一个纯库文件,所以我们不会有main函数。现在,让我们先梳理一下我们的函数,以便了解我们想要做什么。按照以下步骤进行:

  1. 为了创建我们的数据,我们需要读取数据并将数据位移动到正确的位置,就像我们之前做的那样。让我们称这个函数为placeBits
void placeBits(int data, int* parity) {
}
// creation of true data point with parity bits attached
int createData(int data) {
    int num = 0;
    placeBits(data, &num);
    return num;
}

我们可以看到placeBits函数的方法签名有一些有趣。它接受一个int*。对于 JavaScript 开发人员来说,这将是一个新概念。我们传递的是数据的位置,而不是数据本身。这被称为按引用传递。现在,这个想法与 JavaScript 中的情况类似;也就是说,如果我们传递一个对象,我们传递的是对它的引用。这意味着当我们对数据进行更改时,我们将在原始函数中看到这些更改。这与前面的概念相同,但我们对此有更多的控制。如果我们不按引用传递,它将按值传递,这意味着我们会得到前面数据的副本,并且我们不会看到在createData函数中反映出的更改。

  1. 现在,我们需要一个函数来确定我们是否为该位置设置了奇偶校验位。我们将称之为createParity。它的方法签名应该是这样的:
void createParity(int* data) 

再次,我们传递的是数据的引用,而不是数据本身。

  1. 对于我们的数据检查算法,我们将逐个检查每个奇偶校验位,并检查各自的数据位置。我们将称这个函数为checkAndVerifyData,它将具有以下方法签名:
int checkAndVerifyData(int data)

现在,我们将传回一个int,而不是一个布尔值,其中-1表示数据有问题,1表示数据正常。在基本的 C 中,我们没有布尔值的概念,所以我们使用数字来表示真或假的概念(在stdbool头文件中有一个布尔值,但如果我们看一下它,它利用0表示false1表示true的概念,所以它仍然利用了底层的数字)。我们还可以通过使每个负数表示特定的错误代码来使系统更健壮。在我们的情况下,我们只会使用-1,但这可以改进。

  1. 现在,我们可以开始填写我们的函数。首先,我们将把我们的数据放在正确的位置,并确保我们有奇偶校验位的空间。这将如下所示:
const int INT_SIZE = sizeof(int) * 8;
void placeBits(int data, int* parity) {
    int currentDataLoc = 1;
    int dataIterator = 0;
    for(int i = 1, j = 0; i < INT_SIZE; i++, j++) {
        if(ceil(log2(i)) == floor(log2(i))) continue; //we are at a 
         parity bit section
        *parity |= ((data & (currentDataLoc << dataIterator)) << (j 
         - dataIterator));
        dataIterator++;
    }
}

首先,我们创建了一个名为INT_SIZE的常量。这允许我们处理不同类型的环境(尽管 WebAssembly 应该是一个标准化的工作环境,但这使我们可以在其他地方使用这个 C 程序)。我们还使用了三个特殊函数:ceilfloorlog2。所有这些都可以在标准 C 库附带的数学库中找到。

我们通过在文件顶部导入头文件来实现这一点:

#include <math.h>

迭代过程如下:

  1. 它检查是否处于奇偶校验位部分。如果是,我们将跳过它并继续下一部分。

  2. 如果我们不处于奇偶校验位部分,我们将获取我们在dataIterator处的数据位。这个计数器记录了我们在传入的数据中的位置。所有前面的操作都是位操作。|告诉我们我们正在进行按位或操作,这意味着如果左边(奇偶变量)、右边(我们的等式)或两者都是1,那么该位将被设置为1;否则,它将是0

  3. 我们对我们的数据进行按位与运算,与我们的dataIterator处设置的位进行比较。这将让我们知道我们是否在那里设置了一个位。最后,我们需要确保将该位移动已设置的奇偶校验位的数量(这是j - dataIterator)。

  4. 如果我们到达这个for循环的底部,那么我们将检查一个数据位,所以我们需要增加我们的dataIterator

如果位操作对您来说是新的,最好阅读一下developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_Operators

现在,我们可以使用以下代码填充我们的createParity方法:

void createParity(int* data) {
    int parityChecks[4] = {1, 2, 4, 8};
    int bitSet[4] = {1, 2, 8, 128};
    for(int i = 0; i < 4; i++) {
        int count = 0;
        for(int j = 0; j < INT_SIZE; j++) {
            if((parityChecks[i] & (j+1)) != 0) {
                count += ((*data & (1 << j)) != 0) ? 1 : 0;
            }
        }
        if( count % 2 != 0 ) {
            *data |= bitSet[i];
        }
    }
}

这一部分可能会更加复杂,但它正在做我们之前手工完成的工作:

  1. 首先,我们只处理数据的一定数量的位,所以我们只使用四个奇偶校验位。这些奇偶校验位对应于0124位位置,即十进制数 1,2,4 和 8。

  2. 接下来,这些位位于1248位位置,分别表示为十进制的128128。如果需要设置位于那里的奇偶校验位,这将使得更容易。

  3. 现在,我们将循环遍历我们的每个奇偶校验检查,并查看我们新移动的数据是否在那里设置了一个位:

if((parityChecks[i] & (j+1)) != 0) {
    count += ((*data & (1 << j)) != 0) ? 1 : 0;
}

我们正在检查当前查看的位是否是我们担心的奇偶校验位的数据位。如果是,如果数据位设置了,我们将增加计数器。我们将通过使用数据进行按位与来实现这一点。如果我们得到一个非零值,这意味着该位被设置,所以我们将增加计数器。

  1. 在这个for循环结束时,如果我们没有偶校验,我们需要设置该位置的奇偶校验位以获得偶校验。

现在,让我们使用以下命令行操作编译我们的程序:

> emcc -s "EXPORTED_FUNCTIONS=['_createData']" hamming.c

接下来,我们需要在浏览器的index.html页面中运行以下命令:

Module._createData(100);

通过这样做,我们应该得到以下输出:1579。如果我们将这个十进制数放入我们的程序员计算器中,我们将得到以下二进制表示:11000101011。如果我们回头检查我们手工完成的工作,我们会发现我们得到了完全相同的结果!这意味着我们的海明数据生成器正在工作。现在,让我们制作验证工具。按照以下步骤进行操作:

  1. 在我们的checkAndVerifyData方法中,我们将添加以下代码:
int checkAndVerifyData(int data) {
    int verify = 0;
    int parityChecks[4] = {1, 2, 8, 128};
    for(int i = 0; i < 4; i++) {
        verify = checkRow(&data, parityChecks[i]);
        if(verify != 0) { // we do not have even parity
            return -1;
        }
    }
    return 1;
}

在这里,我们有一个verify变量,它将告诉我们数据是否正确。如果不正确,它将输出我们的错误状态,即-1。否则,我们将通过数据并看到它是正确的,所以我们将返回1。接下来,我们将利用奇偶校验位,正如我们已经知道的那样,它们保存在十进制数字128128中。我们将循环遍历这些数字,并利用checkRow方法检查我们的海明数据。

  1. checkRow方法将利用与我们创建过程类似的概念。它如下所示:
int checkRow(int* data, int loc) {
    int count = 0;
    int verifier = 1;
    for(int i = 1; i < INT_SIZE; i++) {
        if((loc & i) != 0 ){
            count += (*data & (verifier << (i - 1))) != 0 ? 1 : 0;
        }
    }
    return count % 2;
}

这应该与我们的createParity方法非常相似。我们将运行这个数字,并检查这是否是一个奇偶校验位数字。如果是,我们将在该位置使用按位 AND 操作一个已知具有该位设置的数字。如果不等于0,则该位已设置,并更新我们的计数器。我们将返回我们的计数器模2,因为这将告诉我们是否有偶校验。

这应该始终返回一个偶数(在我们的例子中是0)。如果不是,我们立即报错。让我们使用以下命令编译这个:

> emcc -s "EXPORTED_FUNCTIONS=['_createData', '_checkAndVerifyData']" hamming.c

现在,我们可以进入浏览器,并使用从createData方法得到的数字。进入开发者控制台,并运行以下命令:

Module._checkAndVerifyData(1579);

它应该打印出1,这意味着我们有良好的海明数据!现在,让我们尝试一个我们尚未手动解决的示例:数字1000。运行以下命令;我们应该得到相同的结果:

Module._createData(1000); // produces 16065
Module._checkAndVerifyData(16065); //produces 1

现在,我们有一个在浏览器中运行的 C 语言编写的工作海明数据创建方法和验证工具!这应该帮助您了解如何将现有应用程序移植到浏览器中,以及如何利用这种强大的技术,使您能够以接近本机速度运行计算密集型应用程序。

本章的最后一部分将介绍当今正在使用的一个端口,并甚至查看一些涉及其中的代码。这个库被许多应用程序开发人员使用,被称为 SQLite。

在浏览器中查看 SQLite

SQLite 是一个嵌入式数据库,被成千上万的应用程序使用。与大多数数据库一样,SQLite 不需要服务器和连接系统,允许我们像使用其他库一样利用它。但是,阻止我们在浏览器中开发这种强大功能的是一种无需本机绑定即可导入的方法。要在 Node.js 中使用它,我们需要使用类似 node-gyp 的东西,然后创建底层 C 代码的 JavaScript 绑定。

我们有一种在浏览器中利用这个数据库而不需要这些本机绑定的方法,这要感谢 WebAssembly。要获取已经为我们编译的版本,请访问github.com/kripken/sql.js/,并将存储库拉入我们的本地系统。让我们继续设置我们的静态服务器,为我们带来所有文件。按照以下步骤进行:

  1. 创建一个名为sqlitetest的新目录。

  2. 在这个目录中,继续运行以下命令,从 GitHub 克隆存储库:

> git clone https://github.com/kripken/sql.js.git
  1. 有了这个,我们可以创建一个基本的index.html文件,并将以下代码添加到其中:
<!DOCTYPE html>
<html>
    <head>
        <script src='sqljs/dist/sql-wasm.js'></script>
    </head>
    <body>
        <script type="module">
            initSqlJs({locateFile: () => `sqljs/dist/sql-wasm.wasm`
              }).then(function(SQL){
                console.log("SQL", SQL);
            });
        </script>
    </body>
</html>

如果我们查看开发者工具,我们会发现 SQLite 库已经在我们的浏览器中运行起来了!让我们继续创建一些表,并用一些数据填充它们:

  1. 我们将创建一个简单的两表数据库。这两个表将如下所示:
id first_name last_name username
<auto_increment>
id customer_id op timestamp
<auto_increment> <foreign_key>

基本上,我们将模拟一个远程过程调用服务器,在这个服务器上,当客户进行调用时,我们将记录他们执行的操作和执行操作的时间戳。

要在我们的 SQLite 数据库中创建这些表,我们将运行以下代码:

initSqlJs({locateFile: () => `sqljs/dist/sql-wasm.wasm` }).then(function(SQL){
    const db = new SQL.Database();
    db.run(`CREATE TABLE customer
        (id INTEGER PRIMARY KEY ASC,
        first_name TEXT,
        last_name TEXT,
        username TEXT UNIQUE)
    `);
    db.run(`CREATE TABLE rpc_operations
        (id INTEGER PRIMARY KEY ASC,
        customer_id INTEGER,
        op TEXT,
        timestamp INTEGER,
        FOREIGN KEY(customer_id) REFERENCES customer(id))`);
});

现在,我们有一个简单的两表数据库,里面包含我们需要的一切。

  1. 让我们继续为每个表填充一些数据。我们可以使用以下命令来做到这一点:
const insertCustomerData = `INSERT INTO customer VALUES (NULL, ?, ?, ?)`;
const insertRpcData = `INSERT INTO rpc_operations VALUES (NULL, ?, ?, time('now'))`;
const customers = [
    ['Morissa', 'Catford', 'mcatford0'],
    ['Aguistin', 'Blaxlande', 'ablaxlande1'] ];
const ops = [
    ['1', 'add'],
    ['2', 'subtract'] ]
for(let i = 0; i < customers.length; i++) {
    db.run(insertCustomerData, customers[i]);
}
for(let i = 0; i < ops.length; i++) {
    db.run(insertRpcData, ops[i]);
}

有了这段代码,我们已经输入了我们的测试数据。现在,让我们运行以下命令:

const statement = db.prepare("SELECT * FROM customer c JOIN rpc_operations ro ON c.id = ro.customer_id WHERE c.username = $username");
statement.bind({$username : 'mcatford0'});
while(statement.step()) {
    const row = statement.getAsObject();
    console.log(JSON.stringify(row));
}

我们已成功在浏览器中运行了一个 SQL 数据库!

有关如何利用此功能的更多信息,请访问github.com/kripken/sql.js/。要获取 SQLite 参考文档,请访问www.sqlite.org/lang.html

现在,在浏览器中运行 SQL 引擎是很棒的,但让我们看看一些基础 C 代码是如何转换成我们的浏览器能理解的东西的。如果我们前往www.sqlite.org/download.html并下载最新版本,我们可以打开sqlite3.c代码库。现在我们有了代码库,让我们寻找一些可能在 WebAssembly 输出中看到的东西。按照以下步骤进行:

我们将利用我们在安装 wasm 二进制工具时收到的wasm2wat工具。进入sqljs文件夹的dist文件夹并运行以下命令:

> wasm2wat sql-wasm-debug.wasm --output=sql-wasm.wat

现在,我们可以打开该文件以以人类可读的方式查看生成的 WebAssembly。正如我们所看到的,它并不那么易读,但在顶部附近,我们可以看到许多来自 Emscripten 的导入项。我们应该意识到所有这些都是 Emscripten 从他们的 JavaScript API 提供的函数,并且它们被用于将所有内容编译为 WebAssembly 并可用。

接下来,让我们转到文件底部。我们会注意到有许多命名的导出项。每个导出项应该对应于c文件中找到的一个函数。让我们继续看一个相对简单的函数:sqlite3_data_count。它应该如下所示:

else
    i32.const 0
end
else
    i32.const 0
end)

如果指针为空,我们将在 C 代码中看到这种返回类型。如果结果为空,我们将返回 0。这是我们在将 C 程序移植到 Web 时进行调试的方法。虽然这并不容易,但在需要进行这种调试时,它可以帮助我们。

本章仅涵盖了已经移植的库的一小部分。每天都有更多的库被移植,以及可以编译为 WebAssembly 的语言。

关于 WebAssembly 的最后说明:虽然我们仍处于这项技术的起步阶段,但我们已经看到了许多进展。从能够利用多个线程到新支持的多返回值,我们开始看到这项技术真正起飞。

总结

在本章中,我们学习了如何阅读和编写 WebAssembly。我们还了解了程序如何被典型计算机理解。除此之外,我们编写了一个能够利用接近本机速度的程序。最后,我们看了一个现有的 WebAssembly 程序以及它与生成它的代码之间的关系。

到目前为止,我们已经对 Web 开发领域有了相当多的了解。我们已经研究了在浏览器中编码以及如何利用所有新功能来创建功能丰富的 Web 应用程序。除此之外,我们已经看到了 JavaScript 如何作为我们的服务器端代码利用 Node.js。最后,我们看了如何构建和部署我们的应用程序。到目前为止,我们应该能够轻松构建可扩展的应用程序,并利用许多现代功能来创建快速的应用程序。

感谢阅读,希望这些信息有助于创建下一代 Web 应用程序!跟上现代 Web 的发展,构建下一个令人惊叹的应用程序!

进一步阅读

对于那些感兴趣的人,以下链接展示了 Mozilla 在 WebAssembly 上的工作以及他们如何推动这项技术的发展:hacks.mozilla.org/

其他使用 WebAssembly 创建的令人惊奇的项目可以在以下链接找到:

posted @ 2024-05-22 12:07  绝不原创的飞龙  阅读(23)  评论(0编辑  收藏  举报