不要过度神秘化 Node.js 脚本

有些人可能会误解 Node.js 脚本,认为它是用 Node.js 编写的。他们可能会觉得如果不懂 Node.js 的语法就无法编写 Node.js 脚本,感觉会写 Node.js 脚本就很神秘。实际上,Node.js 脚本只是在 Node.js 环境中运行的 JavaScript 脚本而已。

Node.js是一个基于Chrome V8引擎的JavaScript运行时环境,使得您可以在服务器端运行JavaScript代码。

在Node.js环境中,您可以编写JavaScript脚本来执行各种任务,比如文件操作、网络通信、数据处理等。这些脚本可以被称为Node.js脚本,因为它们是在Node.js环境中运行的JavaScript代码,仅此而已,没有什么神秘的。

在前端工程中,Node.js 脚本最常用于文件操作,比如读取、写入、删除、新建文件等等操作。

如何运行Node.js脚本

非常简单,只要你的电脑中有装 Node.js,随便找个地方创建一个 index.js 文件,然后在文件中写入以下代码:

console.log('我是一个Node.js脚本');

接着,打开命令行工具,并进入该文件所在的目录。最后,在命令行中输入 node ./index.js 并按下回车键。你将会在命令行工具中看到输出 我是一个Node.js脚本。

如果你想将这个 Node.js 脚本作为 npm 脚本运行,可以将其添加到 package.json 文件中的 scripts 部分。假设你的脚本文件相对于 package.json 的路径是 ./scripts/index.js,你可以添加以下内容到 package.json 的 scripts 部分:

"scripts": {
  "my-script": "node ./scripts/index.js"
}

这样,你就创建了一个名为 my-script 的自定义脚本,可以通过在命令行中输入 npm run my-script 来运行你的 Node.js 脚本。

如何引入第三方Node.js包

要对系统中的文件进行操作,虽然可以直接使用 Node.js 内置的 fs 模块来实现,但是为了避免处理文件操作时出现的常见错误和边界情况,同时确保跨平台兼容性。还是选择 fs-extra 这个第三方 Node.js 包来进行文件操作,那我们该如何引入呢?

首先要看 Node.js 的版本,在 12 版本之前,只支持 require() 函数来引入。在 12 版本之后,就可以使用 ES6 的 import 语法来引入。但需要在 package.json 文件中设置 "type": "module"。如果这样设置不方便,还可以将 Node.js 脚本的后缀改为 .mjs。

我的 Node.js 版本是 16.14,所以采用 ES6 的 import 语法来引入。

首先,在组件工程的根目录下创建一个名为scripts的文件夹,并在其中创建一个名为autoExport.mjs的文件。

接着,在工程的package.json文件中的scripts部分添加以下内容:

"scripts": {
  "export": "node ./scripts/autoExport.mjs"
}

在 autoExport.mjs 文件中添加如下代码,引入 fs-extra 这个第三方 Node.js 包。

import fs from 'fs-extra';

Demo

这里以一个完整的小Demo来举个例子吧。

  1. 在项目路径下新建index.mjs文件,并新建assets文件夹,在assets文件夹中创建metaData.txt文件,并填入一定文本;
  2. 在终端中执行npm init命令,并根据需要完善配置,其中test command配置为node index.mjs即可;
  3. index.mjs文件中粘贴完整代码,并在终端中执行npm run test命令测试结果,应该能看到文件的读写均已成功。

完整代码

index.mjs

import fs from 'fs-extra';

function test1() {
  // 读取目录中的文件列表
  fs.readdir('./assets/', (err, files) => {
    if (err) {
      console.error('Error reading directory:', err);
      return;
    }
    let exportStr = '';

    // 遍历文件
    files.forEach((file, index) => {
      // console.log("🚀 ~ files.forEach ~ file:", file); 
      exportStr += `${index > 0 ? '\n' : ''}第 ${index} 个文件: ${file}`;
    });

    // 写入文件
    fs.writeFile('./assets/outputData.txt', exportStr);
  })
}

function test2() {
  // 读取文件
  fs.readFile('./assets/metaData.txt', 'utf8', (err, data) => {
    if (err) {
      console.error('Error reading file:', err);
      return;
    }

    let exportStr = '';

    console.log('>>> data: ', data, typeof data);
    exportStr = data.toString();

    // 写入文件
    fs.writeFile('./assets/outputData.txt', exportStr);
  })
}

function test3() {
  // 读取文件
  fs.readFile('./assets/metaData.txt', 'utf8', (err, data) => {
    if (err) {
      console.error('Error reading file:', err);
      return;
    }

    let exportStr = '';

    // 反转每行单词顺序
    data.split('\n').forEach((line, index) => {
      let ansLine = '';
      line.split(' ').forEach((word, index) => {
        ansLine = word + ' ' + ansLine;
      });
      exportStr += `${index > 0 ? '\n' : ''}${ansLine}`;
    });

    // 写入文件
    fs.writeFile('./assets/outputData.txt', exportStr);
  })
}

// 运行测试
// test1();
test2();
// test3();

Node.js 的事件循环

Node.js 的事件循环是其非阻塞 I/O 操作的核心,使得 Node.js 能高效处理并发操作,特别是在处理大量的网络请求或文件操作时。了解事件循环是掌握 Node.js 异步编程的关键。

Node.js 的架构

Node.js 基于 Chrome 的 V8 JavaScript 引擎,它执行 JavaScript 代码。然而,对于 I/O 操作(如读取文件、网络通信等),Node.js 使用了非阻塞的方式,这依赖于其内置的 libuv 库。

libuv 库负责 Node.js 的异步 I/O 操作,包括维护事件循环和其他底层操作(如线程池的管理、异步 TCP 和 UDP 套接字、文件系统操作等)。

事件循环的阶段

Node.js 的事件循环可以分为几个主要阶段,每个阶段都有自己的特定任务:

  1. timers:这一阶段执行已经设置的 setTimeout()setInterval() 回调。
  2. I/O callbacks:处理几乎所有的回调,除了关闭回调、定时器的回调以及 setImmediate() 的回调。
  3. idle, prepare:仅系统内部使用。
  4. poll:检索新的 I/O 事件; 执行与 I/O 相关的回调(几乎全部除了关闭的回调、定时器的回调以及 setImmediate() 的回调),适当的条件下节点将在这里阻塞。
  5. checksetImmediate() 回调在这里执行。
  6. close callbacks:例如 socket.on('close', ...)

事件循环的详细运作

Poll 阶段

事件循环的主要阶段是 poll 阶段,它负责等待待处理的事件,并执行事件的回调。如果事件队列是空的(没有即时待处理的事件),Node.js 会根据是否有设置 setImmediate() 检查 poll 阶段应该退出还是继续等待。

Check 阶段

poll 阶段完成后,如果存在由 setImmediate() 设置的回调,Node.js 会转到 check 阶段并执行这些回调。

Close Callbacks 阶段

如果一个 socket 或 handle 被突然关闭(例如 socket.destroy()),close 事件的回调将在这个阶段执行。

与其他技术的交互

除了上述核心阶段外,Node.js 还会处理操作系统的任务(如网络 I/O、文件 I/O),并且在适当的时候将这些任务的完成情况加入事件队列中。Node.js 还维护一个用于处理某些特定异步 I/O 操作的线程池。

Microtasks

在每个阶段之后,Node.js 会处理 microtasks 队列。Microtasks 主要来源于 promises,例如当 Promise 解决或拒绝时,对应的 .then().catch() 注册的回调会被放入 microtasks 队列中并在当前阶段之后立即执行。

事件循环的实际应用

了解事件循环的工作方式对于编写高效的非阻塞代码至关重要。例如,过度使用 setImmediate() 或密集的 CPU 计算可以阻塞 I/O 操作,因为它们可能会延迟 poll 阶段的处理。

总之,Node.js 的事件循环是其能够执行非阻塞 I/O 操作的基础,是理解和有效使用 Node.js 的关键。

代码示例

import fs from 'fs';

console.log('开始'); // 循环1之前的同步代码

// 循环2 阶段 1: timers
setTimeout(() => {
  console.log('setTimeout 执行'); // 添加到下一事件循环的 timers 阶段
}, 0);

// 循环1 阶段 5: check
setImmediate(() => {
  console.log('setImmediate 执行'); // 添加到当前事件循环的 check 阶段
});

// 循环1 首先 微任务: 处理在下一次事件循环前需要完成的事务
Promise.resolve().then(() => {
  console.log('Promise 1 执行');
}).then(() => {
  console.log('Promise 2 执行');
});

// 循环2 阶段 2: I/O 回调
fs.readFile('./assets/file.txt', () => {
  console.log('文件读取完成'); // 添加到下一事件循环的 I/O 回调阶段

  // 循环2 阶段 5: check
  setImmediate(() => {
    console.log('文件读取后的 setImmediate 执行'); // 添加到当前事件循环(循环2)的 check 阶段
  });

  // 循环3 阶段 1: timers
  setTimeout(() => {
    console.log('文件读取后的 setTimeout 执行'); // 添加到下一事件循环(循环3)的 timers 阶段
  }, 0);
});

console.log('计划任务已设置'); // 循环1之前的同步代码

最终输出结果为:

开始
计划任务已设置
Promise 1 执行
Promise 2 执行
setImmediate 执行
setTimeout 执行
文件读取完成
文件读取后的 setImmediate 执行
文件读取后的 setTimeout 执行

process.nextTick

在 Node.js 中,process.nextTick() 是一个非常重要的函数,它允许你安排一个回调函数在当前操作完成后、任何 I/O 事件(包括定时器)触发之前执行。process.nextTick() 让你可以有效地管理异步操作的执行顺序。

执行时机

process.nextTick() 函数安排的回调被放入一个叫做 "next tick queue" 的队列中。这个队列的任务会在当前 JavaScript 执行栈清空后、事件循环继续之前被执行。这意味着,无论事件循环的当前阶段如何,process.nextTick() 安排的任务总是在 Node.js 进入下一个事件循环阶段之前得到处理。

执行顺序

  • Microtasks: process.nextTick() 创建的任务属于 microtasks,执行时机在 promise 的 .then().catch() 之后。
  • 在任何 I/O 或定时器触发前执行: 无论事件循环当前处于哪个阶段,所有 nextTick 队列中的任务总是在 Node.js 进入下一个事件循环阶段之前完成。

代码示例

下面的示例代码演示了 process.nextTick() 的执行时机:

console.log('开始');

setTimeout(() => {
  console.log('setTimeout 执行');
}, 0);

setImmediate(() => {
  console.log('setImmediate 执行');
});

process.nextTick(() => {
  console.log('nextTick 1 执行');
});

Promise.resolve().then(() => {
  console.log('Promise 执行');
});

process.nextTick(() => {
  console.log('nextTick 2 执行');
});

console.log('计划任务已设置');

预期输出及解释

开始
计划任务已设置
Promise 执行
nextTick 1 执行
nextTick 2 执行
setImmediate 执行
setTimeout 执行

通过这个例子,可以看到 process.nextTick() 的任务如何在所有异步任务之前执行,从而提供一种强有力的方式来处理需要优先于 I/O 操作和定时器执行的紧急任务。这是编写高效和响应快速的 Node.js 应用程序时非常重要的一个工具。

简要总结

process.nextTick()的任务在Promise微任务之后,在setImmediate、其他定时器和I/O回调之前执行。且它也属于微任务。(process.nextTick()setImmediate更“immediate”,它在“下一”事件循环开始之前就执行,不排队)

最后

最后,除了fs-extra给大家分享一些常用的第三方 Node.js 包,大家可以去实践一下,写各种各样的用于前端工程中自动化操作的 Node.js 脚本。

  • yargs: 用于解析命令行参数。它提供了简单易用的 API,可以帮助您定义和解析命令行参数,支持选项、标志和参数的定义和解析。
  • chalk:用于给命令行输出添加颜色,可以让输出更具有可读性和吸引力。
  • cli-table:用于在命令行中创建漂亮的表格,方便展示数据。
  • ora:用于在命令行中显示加载动画,可以提示用户正在进行某些操作。
  • inquirer:用于在命令行中创建交互式命令行界面,方便用户输入信息或进行选择。
  • boxen:用于在命令行中创建带边框的框,可以突出显示某些信息。
  • progress:用于在命令行中显示进度条,方便展示任务的进度。
  • figlet:用于在命令行中生成艺术字体,可以让输出更具有视觉效果。
  • execa:一个更强大的子进程管理工具,它提供了更多的选项和功能,例如并发执行、流控制、更好的错误处理等。它是一个跨平台的替代方案,可以替代 Node.js 的 child_process 模块。
  • shelljs:一个 Unix shell 命令的包装器,它允许你在 Node.js 中以类似于 Shell 脚本的方式执行命令。它提供了简单的接口来执行命令、获取输出和处理错误。

相关阅读


A Student on the way to full stack of Web3.