JavaScript 权威指南第七版(GPT 重译)(七)(2)https://developer.aliyun.com/article/1485473
16.10.4 fork()
child_process.fork()是一个专门用于在子 Node 进程中运行 JavaScript 代码模块的函数。fork()期望与spawn()相同的参数,但第一个参数应指定 JavaScript 代码文件的路径,而不是可执行二进制文件。
使用fork()创建的子进程可以通过其标准输入和标准输出流与父进程通信,就像在spawn()的前一节中描述的那样。但是,fork()还为父子进程之间提供了另一个更简单的通信渠道。
当你使用fork()创建一个子进程时,你可以使用返回的 ChildProcess 对象的send()方法向子进程发送一个对象的副本。你可以监听 ChildProcess 上的“message”事件来接收子进程发送的消息。在子进程中运行的代码可以使用process.send()向父进程发送消息,并且可以监听process上的“message”事件来接收父进程发送的消息。
这里,例如,是一些使用fork()创建子进程的代码,然后向该子进程发送消息并等待响应的代码:
const child_process = require("child_process"); // Start a new node process running the code in child.js in our directory let child = child_process.fork(`${__dirname}/child.js`); // Send a message to the child child.send({x: 4, y: 3}); // Print the child's response when it arrives. child.on("message", message => { console.log(message.hypotenuse); // This should print "5" // Since we only send one message we only expect one response. // After we receive it we call disconnect() to terminate the connection // between parent and child. This allows both processes to exit cleanly. child.disconnect(); });
这里是在子进程中运行的代码:
// Wait for messages from our parent process process.on("message", message => { // When we receive one, do a calculation and send the result // back to the parent. process.send({hypotenuse: Math.hypot(message.x, message.y)}); });
启动子进程是一个昂贵的操作,子进程必须进行数量级更多的计算才能使用fork()和这种方式的进程间通信才有意义。如果你正在编写一个需要对传入事件非常敏感并且还需要执行耗时计算的程序,那么你可能会考虑使用一个单独的子进程来执行计算,以便它们不会阻塞事件循环并降低父进程的响应性。(尽管在这种情况下,线程—参见§16.11—可能比子进程更好的选择。)
send()的第一个参数将使用JSON.stringify()进行序列化,并在子进程中使用JSON.parse()进行反序列化,因此你应该只包含 JSON 格式支持的值。然而,send()有一个特殊的第二个参数,允许你传输 Socket 和 Server 对象(来自“net”模块)到子进程。网络服务器往往是 IO 绑定的,而不是计算绑定的,但如果你编写了一个需要进行比单个 CPU 处理更多计算的服务器,并且在拥有多个 CPU 的机器上运行该服务器,那么你可以使用fork()创建多个子进程来处理请求。在父进程中,你可能会监听 Server 对象上的“connection”事件,然后从该“connection”事件中获取 Socket 对象,并使用特殊的第二个参数send()到一个子进程中处理。(请注意,这是一个不太常见的情况的不太可能的解决方案。与编写分叉子进程的服务器相比,保持服务器单线程并在生产环境中部署多个实例来处理负载可能更简单。)
16.11 Worker Threads
正如本章开头所解释的,Node 的并发模型是单线程和基于事件的。但在版本 10 及更高版本中,Node 确实允许真正的多线程编程,其 API 与由 Web 浏览器定义的 Web Workers API(§15.13)非常相似。多线程编程以难度大而著称。这几乎完全是因为需要仔细同步线程对共享内存的访问。但 JavaScript 线程(无论是在 Node 还是浏览器中)默认不共享内存,因此使用线程的危险和困难不适用于 JavaScript 中的这些“工作线程”。
JavaScript 的工作线程通过消息传递进行通信,而不是使用共享内存。主线程可以通过调用表示该线程的 Worker 对象的postMessage()方法向工作线程发送消息。工作线程可以通过监听“message”事件来接收来自其父级的消息。工作线程可以通过自己的postMessage()方法向主线程发送消息,父级可以通过自己的“message”事件处理程序接收消息。示例代码将清楚地说明这是如何工作的。
有三个原因可能会让你想在 Node 应用程序中使用工作线程:
- 如果您的应用程序实际上需要进行比一个 CPU 核心处理更多的计算,那么线程可以让您在多个核心之间分配工作,这在今天的计算机上已经很普遍。如果您在 Node 中进行科学计算、机器学习或图形处理,那么您可能希望使用线程来为问题提供更多的计算能力。
- 即使您的应用程序没有充分利用一个 CPU 的全部性能,您可能仍然希望使用线程来保持主线程的响应性。考虑一个处理大型但相对不频繁请求的服务器。假设它每秒只收到一个请求,但需要大约半秒钟的(阻塞 CPU 密集型)计算来处理每个请求。平均而言,它将有 50% 的空闲时间。但当两个请求在几毫秒内同时到达时,服务器甚至无法开始响应第二个请求,直到第一个响应的计算完成。相反,如果服务器使用工作线程执行计算,服务器可以立即开始响应两个请求,并为服务器的客户提供更好的体验。假设服务器有多个 CPU 核心,它还可以并行计算两个响应的主体,但即使只有一个核心,使用工作线程仍然可以提高响应性。
- 一般来说,工作线程允许我们将阻塞的同步操作转换为非阻塞的异步操作。如果您正在编写一个依赖不可避免同步的传统代码的程序,您可以使用工作线程来避免在需要调用该传统代码时阻塞。
工作线程并不像子进程那样沉重,但也不轻量级。通常情况下,除非有大量工作要做,否则创建工作线程是没有意义的。一般来说,如果您的程序既不受 CPU 限制,也没有响应问题,那么您可能不需要工作线程。
16.11.1 创建工作线程并传递消息
定义工作线程的 Node 模块被称为“worker_threads”。在本节中,我们将使用标识符threads来引用它:
const threads = require("worker_threads");
该模块定义了一个 Worker 类来表示一个工作线程,您可以使用threads.Worker()构造函数创建一个新线程。以下代码演示了如何使用此构造函数创建一个工作线程,并展示了如何从主线程向工作线程传递消息,以及从工作线程向主线程传递消息。它还演示了一个技巧,允许您将主线程代码和工作线程代码放在同一个文件中。²
const threads = require("worker_threads"); // The worker_threads module exports the boolean isMainThread property. // This property is true when Node is running the main thread and it is // false when Node is running a worker. We can use this fact to implement // the main and worker threads in the same file. if (threads.isMainThread) { // If we're running in the main thread, then all we do is export // a function. Instead of performing a computationally intensive // task on the main thread, this function passes the task to a worker // and returns a Promise that will resolve when the worker is done. module.exports = function reticulateSplines(splines) { return new Promise((resolve,reject) => { // Create a worker that loads and runs this same file of code. // Note the use of the special __filename variable. let reticulator = new threads.Worker(__filename); // Pass a copy of the splines array to the worker reticulator.postMessage(splines); // And then resolve or reject the Promise when we get // a message or error from the worker. reticulator.on("message", resolve); reticulator.on("error", reject); }); }; } else { // If we get here, it means we're in the worker, so we register a // handler to get messages from the main thread. This worker is designed // to only receive a single message, so we register the event handler // with once() instead of on(). This allows the worker to exit naturally // when its work is complete. threads.parentPort.once("message", splines => { // When we get the splines from the parent thread, loop // through them and reticulate all of them. for(let spline of splines) { // For the sake of example, assume that spline objects usually // have a reticulate() method that does a lot of computation. spline.reticulate ? spline.reticulate() : spline.reticulated = true; } // When all the splines have (finally!) been reticulated // pass a copy back to the main thread. threads.parentPort.postMessage(splines); }); }
Worker() 构造函数的第一个参数是要在线程中运行的 JavaScript 代码文件的路径。在上面的代码中,我们使用预定义的 __filename 标识符创建一个加载和运行与主线程相同文件的工作线程。不过,一般来说,你会传递一个文件路径。请注意,如果指定相对路径,则相对于 process.cwd(),而不是相对于当前运行的模块。如果你想要一个相对于当前模块的路径,可以使用类似 path.resolve(__dirname, 'workers/reticulator.js') 的方式。
Worker() 构造函数还可以接受一个对象作为其第二个参数,该对象的属性为工作线程提供可选配置。我们稍后会介绍其中一些选项,但现在请注意,如果将 {eval: true} 作为第二个参数传递,那么 Worker() 的第一个参数将被解释为要评估的 JavaScript 代码字符串,而不是文件名:
new threads.Worker(` const threads = require("worker_threads"); threads.parentPort.postMessage(threads.isMainThread); `, {eval: true}).on("message", console.log); // This will print "false"
Node 在传递给 postMessage() 的对象上创建一个副本,而不是直接与工作线程共享。这样可以防止工作线程和主线程共享内存。你可能会期望这种复制是通过 JSON.stringify() 和 JSON.parse()(§11.6)来完成的。但事实上,Node 借用了一种更强大的技术,即从 Web 浏览器中知名的结构化克隆算法。
结构化克隆算法可以序列化大多数 JavaScript 类型,包括 Map、Set、Date 和 RegExp 对象以及类型化数组,但通常无法复制由 Node 主机环境定义的类型,如套接字和流。然而,需要注意的是,Buffer 对象部分支持:如果你将一个 Buffer 传递给 postMessage(),它将被接收为 Uint8Array,并且可以使用 Buffer.from() 转换回 Buffer。在 “结构化克隆算法” 中了解更多关于结构化克隆算法的信息。
16.11.2 工作线程执行环境
在大多数情况下,Node 中的工作线程中的 JavaScript 代码运行方式与在 Node 的主线程中一样。有一些差异需要注意,其中一些差异涉及到 Worker() 构造函数的可选第二个参数的属性:
- 正如我们所见,
threads.isMainThread在主线程中为true,但在任何工作线程中始终为false。 - 在工作线程中,你可以使用
threads.parentPort.postMessage()向父线程发送消息,使用threads.parentPort.on注册来自父线程的消息的事件处理程序。在主线程中,threads.parentPort始终为null。 - 在工作线程中,
threads.workerData被设置为Worker()构造函数的第二个参数的workerData属性的副本。在主线程中,此属性始终为null。你可以使用这个workerData属性向工作线程传递一个初始消息,该消息将在工作线程启动后立即可用,这样工作线程就不必等待“message”事件才能开始工作。 - 默认情况下,在工作线程中,
process.env是父线程中process.env的副本。但父线程可以通过设置Worker()构造函数的第二个参数的env属性来指定一组自定义的环境变量。作为一个特殊(可能危险)的情况,父线程可以将env属性设置为threads.SHARE_ENV,这将导致两个线程共享一组环境变量,以便一个线程中的更改在另一个线程中可见。 - 默认情况下,在工作线程中,
process.stdin流永远没有可读数据。你可以通过在Worker()构造函数的第二个参数中传递stdin: true来更改此默认行为。如果这样做,那么 Worker 对象的stdin属性将是一个可写流。父进程写入worker.stdin的任何数据在工作线程中的process.stdin上变为可读。 - 默认情况下,工作线程中的
process.stdout和process.stderr流会简单地传输到父线程中对应的流。这意味着,例如,console.log()和console.error()在工作线程中的输出方式与主线程中完全相同。你可以通过在Worker()构造函数的第二个参数中传递stdout:true或stderr:true来覆盖此默认行为。如果这样做,那么工作线程写入这些流的任何输出都可以在父线程的worker.stdout和worker.stderr流中读取到。(这里存在一个潜在的令人困惑的流方向倒置,我们在本章前面的子进程中也看到了相同的情况:工作线程的输出流是父线程的输入流,工作线程的输入流是父线程的输出流。) - 如果工作线程调用
process.exit(),只有该线程退出,整个进程不会退出。 - 工作线程不允许更改它们所属进程的共享状态。当从工作线程调用
process.chdir()和process.setuid()等函数时,会抛出异常。 - 操作系统信号(如
SIGINT和SIGTERM)只会传递给主线程;它们无法在工作线程中接收或处理。
16.11.3 通信通道和 MessagePorts
创建新的工作线程时,会同时创建一个通信通道,允许工作线程和父线程之间传递消息。正如我们所见,工作线程使用threads.parentPort与父线程发送和接收消息,父线程使用 Worker 对象与工作线程发送和接收消息。
工作线程 API 还允许使用由 Web 浏览器定义并在 §15.13.5 中介绍的 MessageChannel API 创建自定义通信通道。如果你已经阅读了该部分,接下来的内容会让你感到很熟悉。
假设一个工作线程需要处理主线程中两个不同模块发送的两种不同消息。这两个不同模块可以共享默认通道,并使用worker.postMessage()发送消息,但如果每个模块都有自己的私有通道向工作线程发送消息会更清晰。或者考虑主线程创建两个独立工作线程的情况。自定义通信通道可以让这两个工作线程直接相互通信,而不必通过父线程发送所有消息。
使用MessageChannel()构造函数创建一个新的消息通道。一个 MessageChannel 对象有两个属性,名为port1和port2。这些属性指向一对 MessagePort 对象。在其中一个端口上调用postMessage()将导致另一个端口生成“message”事件,并携带 Message 对象的结构化克隆:
const threads = require("worker_threads"); let channel = new threads.MessageChannel(); channel.port2.on("message", console.log); // Log any messages we receive channel.port1.postMessage("hello"); // Will cause "hello" to be printed
你也可以在任一端口上调用close()来断开两个端口之间的连接,并表示不会再交换更多消息。当任一端口上调用close()时,将向两个端口传递“close”事件。
注意,上面的代码示例创建了一对 MessagePort 对象,然后使用这些对象在主线程内传输消息。为了在工作线程中使用自定义通信通道,我们必须将两个端口中的一个从创建它的线程传输到将要使用它的线程。下一节将解释如何做到这一点。
16.11.4 传输 MessagePorts 和 Typed Arrays
postMessage() 函数使用结构化克隆算法,正如我们所指出的,它不能复制像 SSockets 和 Streams 这样的对象。它可以处理 MessagePort 对象,但只能使用一种特殊技术作为特例。postMessage() 方法(Worker 对象的方法,threads.parentPort 的方法,或任何 MessagePort 对象的方法)接受一个可选的第二个参数。这个参数(称为 transferList)是一个要在线程之间传输而不是复制的对象数组。
MessagePort 对象不能被结构化克隆算法复制,但可以被传输。如果 postMessage() 的第一个参数包含了一个或多个 MessagePorts(在 Message 对象中任意深度嵌套),那么这些 MessagePort 对象也必须作为第二个参数传递的数组的成员出现。这样做告诉 Node 不需要复制 MessagePort,并且可以直接将现有对象交给另一个线程。然而,关于在线程之间传输值的关键是,一旦值被传输,它就不能再在调用 postMessage() 的线程中使用。
下面是如何创建一个新的 MessageChannel 并将其中一个 MessagePort 传输给工作线程的方法:
// Create a custom communication channel const threads = require("worker_threads"); let channel = new threads.MessageChannel(); // Use the worker's default channel to transfer one end of the new // channel to the worker. Assume that when the worker receives this // message it immediately begins to listen for messages on the new channel. worker.postMessage({ command: "changeChannel", data: channel.port1 }, [ channel.port1 ]); // Now send a message to the worker using our end of the custom channel channel.port2.postMessage("Can you hear me now?"); // And listen for responses from the worker as well channel.port2.on("message", handleMessagesFromWorker);
MessagePort 对象并不是唯一可以传输的对象。如果你使用一个类型化数组作为消息调用 postMessage()(或者消息中包含一个或多个任意深度嵌套的类型化数组),那么这个类型化数组(或这些类型化数组)将会被结构化克隆算法简单地复制。但是类型化数组可能很大;例如,如果你正在使用一个工作线程对数百万像素进行图像处理。因此,为了效率起见,postMessage() 还给了我们传输类型化数组而不是复制它们的选项。(线程默认共享内存。JavaScript 中的工作线程通常避免共享内存,但当我们允许这种受控传输时,可以非常高效地完成。)这种安全性的保证在于,当一个类型化数组被传输到另一个线程时,它在传输它的线程中将变得无法使用。在图像处理场景中,主线程可以将图像的像素传输给工作线程,然后工作线程在完成后可以将处理后的像素传回主线程。内存不需要被复制,但永远不会被两个线程同时访问。
要传输一个类型化数组而不是复制它,将支持数组的 ArrayBuffer 包含在 postMessage() 的第二个参数中:
let pixels = new Uint32Array(1024*1024); // 4 megabytes of memory // Assume we read some data into this typed array, and then transfer the // pixels to a worker without copying. Note that we don't put the array // itself in the transfer list, but the array's Buffer object instead. worker.postMessage(pixels, [ pixels.buffer ]);
与传输的 MessagePort 一样,一旦传输了一个类型化数组,它就变得无法使用。如果尝试使用已经传输的 MessagePort 或类型化数组,不会抛出异常;当与它们交互时,这些对象只是停止执行任何操作。
16.11.5 在线程之间共享类型化数组
除了在线程之间传输类型化数组,实际上还可以在线程之间共享类型化数组。只需创建一个所需大小的 SharedArrayBuffer,然后使用该缓冲区创建一个类型化数组。当通过 postMessage() 传递由 SharedArrayBuffer 支持的类型化数组时,底层内存将在线程之间共享。在这种情况下,不应该将共享缓冲区包含在 postMessage() 的第二个参数中。
然而,你真的不应该这样做,因为 JavaScript 从未考虑过线程安全,并且多线程编程非常难以正确实现。(这也是为什么 SharedArrayBuffer 没有在 §11.2 中涵盖的原因:它是一个难以正确实现的小众功能。)即使简单的 ++ 运算符也不是线程安全的,因为它需要读取一个值,递增它,然后写回。如果两个线程同时递增一个值,它通常只会被递增一次,如下面的代码所示:
const threads = require("worker_threads"); if (threads.isMainThread) { // In the main thread, we create a shared typed array with // one element. Both threads will be able to read and write // sharedArray[0] at the same time. let sharedBuffer = new SharedArrayBuffer(4); let sharedArray = new Int32Array(sharedBuffer); // Now create a worker thread, passing the shared array to it with // as its initial workerData value so we don't have to bother with // sending and receiving a message let worker = new threads.Worker(__filename, { workerData: sharedArray }); // Wait for the worker to start running and then increment the // shared integer 10 million times. worker.on("online", () => { for(let i = 0; i < 10_000_000; i++) sharedArray[0]++; // Once we're done with our increments, we start listening for // message events so we know when the worker is done. worker.on("message", () => { // Although the shared integer has been incremented // 20 million times, its value will generally be much less. // On my computer the final value is typically under 12 million. console.log(sharedArray[0]); }); }); } else { // In the worker thread, we get the shared array from workerData // and then increment it 10 million times. let sharedArray = threads.workerData; for(let i = 0; i < 10_000_000; i++) sharedArray[0]++; // When we're done incrementing, let the main thread know threads.parentPort.postMessage("done"); }
有一种情况下可能合理使用 SharedArrayBuffer,即当两个线程在共享内存的完全不同部分上操作时。你可以通过创建两个作为非重叠区域视图的类型化数组来强制执行这一点,然后让你的两个线程使用这两个单独的类型化数组。例如,可以这样执行并行归并排序:一个线程对数组的下半部分进行排序,另一个线程对数组的上半部分进行排序。或者某些类型的图像处理算法也适合这种方法:多个线程在图像的不同区域上工作。
如果你确实需要允许多个线程访问共享数组的同一区域,你可以通过使用 Atomics 对象定义的函数向线程安全迈出一步。当 SharedArrayBuffer 添加到 JavaScript 时,Atomics 也被添加以定义共享数组元素上的原子操作。例如,Atomics.add()函数读取共享数组的指定元素,将指定值添加到其中,并将总和写回数组。它以原子方式执行此操作,就好像它是一个单独的操作,并确保在操作进行时没有其他线程可以读取或写入该值。Atomics.add()允许我们重新编写我们刚刚查看的并获得正确结果的并行增量代码,即对共享数组元素进行 2000 万次增量:
const threads = require("worker_threads"); if (threads.isMainThread) { let sharedBuffer = new SharedArrayBuffer(4); let sharedArray = new Int32Array(sharedBuffer); let worker = new threads.Worker(__filename, { workerData: sharedArray }); worker.on("online", () => { for(let i = 0; i < 10_000_000; i++) { Atomics.add(sharedArray, 0, 1); // Threadsafe atomic increment } worker.on("message", (message) => { // When both threads are done, use a threadsafe function // to read the shared array and confirm that it has the // expected value of 20,000,000. console.log(Atomics.load(sharedArray, 0)); }); }); } else { let sharedArray = threads.workerData; for(let i = 0; i < 10_000_000; i++) { Atomics.add(sharedArray, 0, 1); // Threadsafe atomic increment } threads.parentPort.postMessage("done"); }
这个新版本的代码正确地打印出数字 20,000,000。但它比它替换的不正确代码慢大约九倍。在一个线程中执行所有 2000 万次增量会更简单、更快。还要注意,原子操作可能能够确保图像处理算法的线程安全,其中每个数组元素都是完全独立于所有其他值的值。但在大多数实际程序中,多个数组元素通常彼此相关,并且需要某种高级别的线程同步。低级别的Atomics.wait()和Atomics.notify()函数可以帮助解决这个问题,但本书不涉及它们的使用讨论。
16.12 总结
尽管 JavaScript 是为在 Web 浏览器中运行而创建的,但 Node 已经将 JavaScript 变成了一种通用编程语言。它特别受欢迎用于实现 Web 服务器,但它与操作系统的深层绑定意味着它也是 shell 脚本的一个很好的替代品。
这一长章节涵盖的最重要主题包括:
- Node 的默认异步 API 和其单线程、回调和基于事件的并发风格。
- Node 的基本数据类型、缓冲区和流。
- Node 的“fs”和“path”模块用于处理文件系统。
- Node 的“http”和“https”模块用于编写 HTTP 客户端和服务器。
- Node 的“net”模块用于编写非 HTTP 客户端和服务器。
- Node 的“child_process”模块用于创建和与子进程通信。
- Node 的“worker_threads”模块用于使用消息传递而不是共享内存进行真正的多线程编程。
¹ Node 定义了一个fs.copyFile()函数,实际上你会在实践中使用它。
² 将工作代码定义在一个单独的文件中通常更清晰、更简单。但当我第一次遇到 Unix 的fork()系统调用时,两个线程运行同一文件的不同部分的技巧让我大吃一惊。我认为值得演示这种技术,仅仅因为它的奇怪优雅。
第十七章:JavaScript 工具和扩展
恭喜您达到本书的最后一章。如果您已经阅读了前面的所有内容,现在您对 JavaScript 语言有了详细的了解,并知道如何在 Node 和 Web 浏览器中使用它。本章是一种毕业礼物:它介绍了许多 JavaScript 程序员发现有用的重要编程工具,并描述了核心 JavaScript 语言的两个广泛使用的扩展。无论您是否选择为自己的项目使用这些工具和扩展,您几乎肯定会在其他项目中看到它们的使用,因此至少了解它们是很重要的。
本章涵盖的工具和语言扩展包括:
- ESLint 用于在代码中查找潜在的错误和样式问题。
- 使用 Prettier 以标准化方式格式化您的 JavaScript 代码。
- Jest 作为编写 JavaScript 单元测试的一体化解决方案。
- npm 用于管理和安装程序依赖的软件库。
- 代码捆绑工具——如 webpack、Rollup 和 Parcel——将您的 JavaScript 代码模块转换为用于 Web 的单个捆绑包。
- Babel 用于将使用全新语言特性(或语言扩展)的 JavaScript 代码转换为可以在当前 Web 浏览器中运行的 JavaScript 代码。
- JSX 语言扩展(由 React 框架使用)允许您使用类似 HTML 标记的 JavaScript 表达式描述用户界面。
- Flow 语言扩展(或类似的 TypeScript 扩展)允许您使用类型注释注释您的 JavaScript 代码,并检查代码是否具有类型安全性。
本章不会以任何全面的方式记录这些工具和扩展。目标只是以足够深度解释它们,以便您了解它们为何有用以及何时可能需要使用它们。本章涵盖的所有内容在 JavaScript 编程世界中被广泛使用,如果您决定采用工具或扩展,您会在网上找到大量文档和教程。
17.1 使用 ESLint 进行 Linting
在编程中,术语lint指的是在技术上正确但不雅观、可能存在错误或以某种方式不够优化的代码。linter是一种用于检测代码中 lint 的工具,linting是在代码上运行 linter 的过程(然后修复代码以消除 lint,使 linter 不再抱怨)。
今天 JavaScript 最常用的 linter 是ESLint。如果您运行它,然后花时间实际修复它指出的问题,它将使您的代码更清洁,更不容易出现错误。考虑以下代码:
var x = 'unused'; export function factorial(x) { if (x == 1) { return 1; } else { return x * factorial(x-1) } }
如果您在此代码上运行 ESLint,可能会得到如下输出:
$ eslint code/ch17/linty.js code/ch17/linty.js 1:1 error Unexpected var, use let or const instead no-var 1:5 error 'x' is assigned a value but never used no-unused-vars 1:9 warning Strings must use doublequote quotes 4:11 error Expected '===' and instead saw '==' eqeqeq 5:1 error Expected indentation of 8 spaces but found 6 indent 7:28 error Missing semicolon semi ✖ 6 problems (5 errors, 1 warning) 3 errors and 1 warning potentially fixable with the `--fix` option.
有时 linter 可能看起来很挑剔。我们是使用双引号还是单引号真的很重要吗?另一方面,正确的缩进对于可读性很重要,使用===和let而不是==和var可以保护您免受微妙错误的影响。未使用的变量是代码中的累赘——没有理由保留它们。
ESLint 定义了许多 linting 规则,并具有添加许多其他规则的插件生态系统。但 ESLint 是完全可配置的,您可以定义一个配置文件来调整 ESLint 以强制执行您想要的规则,仅限于这些规则。
17.2 使用 Prettier 进行 JavaScript 格式化
一些项目使用 linter 的原因之一是强制执行一致的编码风格,以便当程序员团队共同工作在共享的代码库上时,他们使用兼容的代码约定。这包括代码缩进规则,但也可以包括诸如首选引号类型以及for关键字和其后的开括号之间是否应该有空格等内容。
强制代码格式规则的现代替代方法是采用类似 Prettier 的工具,自动解析和重新格式化所有代码。
假设你已经编写了以下函数,它可以工作,但格式不太规范:
function factorial(x) { if(x===1){return 1} else{return x*factorial(x-1)} }
运行 Prettier 对这段代码进行了缩进修复,添加了缺失的分号,围绕二进制运算符添加了空格,并在 { 之后和 } 之前插入了换行符,使代码看起来更加传统:
$ prettier factorial.js function factorial(x) { if (x === 1) { return 1; } else { return x * factorial(x - 1); } }
如果你使用 --write 选项调用 Prettier,它将简单地在原地重新格式化指定的文件,而不是打印重新格式化的版本。如果你使用 git 管理你的源代码,你可以在提交钩子中使用 --write 选项调用 Prettier,这样代码就会在检入之前自动格式化。
如果你配置你的代码编辑器在每次保存文件时自动运行 Prettier,Prettier 就会变得非常强大。我觉得写松散的代码然后看到它被自动修复很解放。
Prettier 是可配置的,但只有少数选项。你可以选择最大行长度、缩进量、是否应该使用分号、字符串是单引号还是双引号,以及其他一些内容。一般来说,Prettier 的默认选项是相当合理的。其思想是你只需为你的项目采用 Prettier,然后就再也不用考虑代码格式了。
就我个人而言,我非常喜欢在 JavaScript 项目中使用 Prettier。然而,在这本书中的代码中我没有使用它,因为在我的许多代码中,我依赖仔细的手动格式化来垂直对齐我的注释,而 Prettier 会搞乱它们。
17.3 使用 Jest 进行单元测试
写测试是任何非平凡编程项目的重要部分。像 JavaScript 这样的动态语言支持测试框架,大大减少了编写测试所需的工作量,几乎让测试编写变得有趣!JavaScript 有很多测试工具和库,许多都是以模块化的方式编写的,因此可以选择一个库作为测试运行器,另一个库用于断言,第三个库用于模拟。然而,在本节中,我们将描述 Jest ,这是一个流行的框架,包含了你在一个单一包中所需的一切。
假设你已经编写了以下函数:
const getJSON = require("./getJSON.js"); /** * getTemperature() takes the name of a city as its input, and returns * a Promise that will resolve to the current temperature of that city, * in degrees Fahrenheit. It relies on a (fake) web service that returns * world temperatures in degrees Celsius. */ module.exports = async function getTemperature(city) { // Get the temperature in Celsius from the web service let c = await getJSON( `https://globaltemps.example.com/api/city/${city.toLowerCase()}` ); // Convert to Fahrenheit and return that value. return (c * 5 / 9) + 32; // TODO: double-check this formula };
对于这个函数的一个很好的测试集可能会验证 getTemperature() 是否获取了正确的 URL,并且是否正确地转换了温度标度。我们可以使用类似下面的基于 Jest 的测试来做到这一点。这段代码定义了 getJSON() 的模拟实现,以便测试实际上不会发出网络请求。由于 getTemperature() 是一个异步函数,所以测试也是异步的——测试异步函数可能有些棘手,但 Jest 让它相对容易:
// Import the function we are going to test const getTemperature = require("./getTemperature.js"); // And mock the getJSON() module that getTemperature() depends on jest.mock("./getJSON"); const getJSON = require("./getJSON.js"); // Tell the mock getJSON() function to return an already resolved Promise // with fulfillment value 0. getJSON.mockResolvedValue(0); // Our set of tests for getTemperature() begins here describe("getTemperature()", () => { // This is the first test. We're ensuring that getTemperature() calls // getJSON() with the URL that we expect test("Invokes the correct API", async () => { let expectedURL = "https://globaltemps.example.com/api/city/vancouver"; let t = await(getTemperature("Vancouver")); // Jest mocks remember how they were called, and we can check that. expect(getJSON).toHaveBeenCalledWith(expectedURL); }); // This second test verifies that getTemperature() converts // Celsius to Fahrenheit correctly test("Converts C to F correctly", async () => { getJSON.mockResolvedValue(0); // If getJSON returns 0C expect(await getTemperature("x")).toBe(32); // We expect 32F // 100C should convert to 212F getJSON.mockResolvedValue(100); // If getJSON returns 100C expect(await getTemperature("x")).toBe(212); // We expect 212F }); });
写好测试后,我们可以使用 jest 命令来运行它,然后我们发现我们的一个测试失败了:
$ jest getTemperature FAIL ch17/getTemperature.test.js getTemperature() ✓ Invokes the correct API (4ms) ✕ Converts C to F correctly (3ms) ● getTemperature() › Converts C to F correctly expect(received).toBe(expected) // Object.is equality Expected: 212 Received: 87.55555555555556 29 | // 100C should convert to 212F 30 | getJSON.mockResolvedValue(100); // If getJSON returns 100C > 31 | expect(await getTemperature("x")).toBe(212); // Expect 212F | ^ 32 | }); 33 | }); 34 | at Object.<anonymous> (ch17/getTemperature.test.js:31:43) Test Suites: 1 failed, 1 total Tests: 1 failed, 1 passed, 2 total Snapshots: 0 total Time: 1.403s Ran all test suites matching /getTemperature/i.
我们的 getTemperature() 实现使用了错误的公式将摄氏度转换为华氏度。它将乘以 5 再除以 9,而不是乘以 9 再除以 5。如果我们修复代码并再次运行 Jest,我们可以看到测试通过。而且,作为一个奖励,如果我们在调用 jest 时添加 --coverage 参数,它将计算并显示我们测试的代码覆盖率:
$ jest --coverage getTemperature PASS ch17/getTemperature.test.js getTemperature() ✓ Invokes the correct API (3ms) ✓ Converts C to F correctly (1ms) ------------------|--------|---------|---------|---------|------------------| File | % Stmts| % Branch| % Funcs| % Lines| Uncovered Line #s| ------------------|--------|---------|---------|---------|------------------| All files | 71.43| 100| 33.33| 83.33| | getJSON.js | 33.33| 100| 0| 50| 2| getTemperature.js| 100| 100| 100| 100| | ------------------|--------|---------|---------|---------|------------------| Test Suites: 1 passed, 1 total Tests: 2 passed, 2 total Snapshots: 0 total Time: 1.508s Ran all test suites matching /getTemperature/i.
运行我们的测试为我们正在测试的模块提供了 100% 的代码覆盖率,这正是我们想要的。它只为 getJSON() 提供了部分覆盖,但我们对该模块进行了模拟,并且并不打算测试它,所以这是预期的。
17.4 使用 npm 进行包管理
在现代软件开发中,编写的任何非平凡程序都可能依赖于第三方软件库。例如,如果您在 Node 中编写 Web 服务器,可能会使用 Express 框架。如果您正在创建要在 Web 浏览器中显示的用户界面,则可能会使用像 React、LitElement 或 Angular 这样的前端框架。包管理器使查找和安装这些第三方包变得容易。同样重要的是,包管理器会跟踪代码所依赖的包,并将此信息保存到文件中,以便其他人想要尝试您的程序时,他们可以下载您的代码和依赖项列表,然后使用自己的包管理器安装代码所需的所有第三方包。
npm 是随 Node 捆绑的包管理器,并在§16.1.5 中介绍。它对于客户端 JavaScript 编程和 Node 服务器端编程同样有用。
如果您尝试其他人的 JavaScript 项目,那么在下载他们的代码后,您通常会首先执行npm install。这会读取package.json文件中列出的依赖项,并下载项目需要的第三方包并将其保存在*node_modules/*目录中。
您还可以输入npm install 来将特定包安装到项目的*node_modules/*目录中:
$ npm install express
除了安装命名的包外,npm 还会在项目的package.json文件中记录依赖关系。以这种方式记录依赖关系是让其他人通过输入npm install来安装这些依赖关系的原因。
另一种依赖关系是开发人员工具的依赖,这些工具是开发人员想要在项目上工作时需要的,但实际上不需要运行代码。例如,如果项目使用 Prettier 来确保所有代码格式一致,那么 Prettier 就是一个“dev dependency”,您可以使用--save-dev来安装和记录其中之一:
$ npm install --save-dev prettier
有时您可能希望全局安装开发工具,以便它们可以在任何地方访问,即使不是正式项目的代码也可以使用package.json文件和*node_modules/*目录。为此,您可以使用-g(全局)选项:
$ npm install -g eslint jest /usr/local/bin/eslint -> /usr/local/lib/node_modules/eslint/bin/eslint.js /usr/local/bin/jest -> /usr/local/lib/node_modules/jest/bin/jest.js + jest@24.9.0 + eslint@6.7.2 added 653 packages from 414 contributors in 25.596s $ which eslint /usr/local/bin/eslint $ which jest /usr/local/bin/jest
除了“install”命令,npm 还支持“uninstall”和“update”命令,其功能如其名称所示。npm 还有一个有趣的“audit”命令,您可以使用它来查找并修复依赖项中的安全漏洞:
$ npm audit --fix === npm audit security report === found 0 vulnerabilities in 876354 scanned packages
当您为项目本地安装类似 ESLint 这样的工具时,eslint 脚本会出现在*./node_modules/.bin/eslint*中,这使得运行命令变得笨拙。幸运的是,npm 捆绑了一个名为“npx”的命令,您可以使用它来运行本地安装的工具,如npx eslint或npx jest。(如果您使用 npx 调用尚未安装的工具,它将为您安装它。)
npm 背后的公司还维护着https://npmjs.com包仓库,其中包含数十万个开源包。但您不必使用 npm 包管理器来访问这个包仓库。替代方案包括yarn和pnpm。
17.5 代码捆绑
如果您正在编写一个大型的 JavaScript 程序以在网络浏览器中运行,那么您可能需要使用一个代码捆绑工具,特别是如果您使用作为模块交付的外部库。多年来,网络开发人员一直在使用 ES6 模块(§10.3),早在网络上支持import和export关键字之前。为了做到这一点,程序员使用一个代码捆绑工具,从程序的主入口点(或入口点)开始,并遵循import指令的树,以找到程序所依赖的所有模块。然后,它将所有这些单独的模块文件组合成一个 JavaScript 代码的单个捆绑包,并重写import和export指令,使代码在这种新形式下工作。结果是一个单个的代码文件,可以加载到不支持模块的网络浏览器中。
ES6 模块现在几乎被所有的网络浏览器支持,但网络开发人员在发布生产代码时仍倾向于使用代码捆绑工具。开发人员发现,当用户首次访问网站时,加载一个中等大小的代码捆绑包比加载许多小模块时用户体验更好。
注意
网络性能是一个众所周知的棘手话题,有很多要考虑的变量,包括浏览器供应商的持续改进,因此确保加载代码的最快方式的唯一方法是进行彻底的测试和仔细的测量。请记住,有一个完全在您控制之下的变量:代码大小。较少的 JavaScript 代码总是比较多的 JavaScript 代码加载和运行更快!
有许多优秀的 JavaScript 捆绑工具可供选择。常用的捆绑工具包括webpack、Rollup和Parcel。捆绑工具的基本功能大致相同,它们的区别在于可配置性或易用性。Webpack 已经存在很长时间,拥有庞大的插件生态系统,可高度配置,并且可以支持旧的非模块化库。但它也可能复杂且难以配置。另一端是 Parcel,它被设计为一个零配置的替代方案,只需简单地做正确的事情。
除了执行基本的捆绑外,捆绑工具还可以提供一些附加功能:
- 一些程序可能有多个入口点。例如,一个具有多个页面的网络应用程序可以为每个页面编写不同的入口点。打包工具通常允许您为每个入口点创建一个捆绑包,或者创建一个支持多个入口点的单个捆绑包。
- 程序可以使用
import()的功能形式(§10.3.6),而不是静态形式,在实际需要时动态加载模块,而不是在程序启动时静态加载它们。这通常是改善程序启动时间的好方法。支持import()的捆绑工具可能能够生成多个输出捆绑包:一个在启动时加载,一个或多个在需要时动态加载。如果动态加载的模块共享依赖关系,那么确定要生成多少个捆绑包就变得棘手了,您可能需要手动配置捆绑工具来解决这个问题。 - 捆绑工具通常可以输出一个源映射文件,定义了捆绑包中代码行与原始源文件中对应行之间的映射关系。这使得浏览器开发工具可以自动显示 JavaScript 错误的原始未捆绑位置。
- 有时当你将一个模块导入到你的程序中时,你可能只使用其中的一部分功能。一个好的打包工具可以分析代码以确定哪些部分是未使用的,可以从捆绑包中省略。这个功能被戏称为“tree-shaking”。
- 打包工具通常具有基于插件的架构,并支持插件,允许导入和捆绑实际上不是 JavaScript 代码文件的“模块”。假设你的程序包含一个大型的 JSON 兼容数据结构。代码打包工具可以配置允许你将该数据结构移动到一个单独的 JSON 文件中,然后通过类似
import widgets from "./big-widget-list.json"的声明将其导入到你的程序中。同样,将 CSS 嵌入到 JavaScript 程序中的 web 开发人员可以使用打包工具插件,允许他们使用import指令导入 CSS 文件。但是请注意,如果导入的不是 JavaScript 文件,你正在使用一个非标准的 JavaScript 扩展,并使你的代码依赖于打包工具。 - 在像 JavaScript 这样不需要编译的语言中,运行一个打包工具感觉像是一个编译步骤,每次在运行代码之前都必须运行一个打包工具,这让人感到沮丧。打包工具通常支持文件系统监视器,检测项目目录中任何文件的编辑,并自动重新生成必要的捆绑包。有了这个功能,你通常可以保存你的代码,然后立即重新加载你的 web 浏览器窗口以尝试它。
- 一些打包工具还支持开发人员的“热模块替换”模式,每次重新生成捆绑包时,它会自动加载到浏览器中。当这个功能起作用时,对开发人员来说是一种神奇的体验,但在幕后进行了一些技巧使其工作,它并不适用于所有项目。
JavaScript 权威指南第七版(GPT 重译)(七)(4)https://developer.aliyun.com/article/1485476