Things I Learned (Node.js)

require.main🔗

Node.js

在 Python 中,可以通过如下的代码来判断,当前的文件是否是入口文件:

if __name__ == "__main__":
  print("entry file")

Node.js 中也可以写类似的判断,上面的 Python 代码等价于:

if (require.main === module) {
  console.log('entry file');
}

这样,当程序是作为入口文件被运行的时候,可以在 if 语句内直接运行业务代码;而如果这个文件是作为 API 被别的文件加载的,那么就只会暴露 API 接口,运行的部分交给使用者自行完成。

几点说明:

  1. 这里,require.main 是一个 Module(也就是 module 的类型,等价于 module.constructor)。而 module 则是 Node.js 在加载 JavaScript 文件的时候提供的,参考 Module.prototype._compile 函数调用的 wrapSafe 函数(见这里);
  2. 对于 Node.js 来说,这里的 require.main 就是命令行加载的文件,比如运行 node xxx.js 命令,那么 xxx.js 生成的 Module 就是这里的 require.main;而对于 Electron 来说,每一个 render 进程加载的 HTML 文件就是对应的 require.main 模块;
  3. 从 Node.js 代码可知,这里的 require.main 等价于 process.mainModule(见这里)。

Require CSS in Electron🔗

Node.js

在实际开发过程中,可能会遇到在 Electron 项目中需要引用组件库的情况。因为组件库往往除了 JavaScript 文件之外,还连带有 CSS 文件,因此一般即便有 lib 文件的输出(也就是 JavaScript 经过了预编译,在 Node.js 环境下可以直接运行),也没法直接使用。这里 CSS 文件一般通过 import 'styles.css'; 这样的语法引入,编译后会变成 require('styles.css')。由于 Node.js 不支持直接 require CSS 文件,因此 lib 在 Electron 下是没法直接运行的。

针对这种情况,常规的做法是通过 Webpack 将组件库打包到最终的产物中,通过 css-loader 和 mini-css-extract-plugin 消化 CSS 产物,最终在 Electron / Web 环境下运行。

然而,这样的做法可能面临几个问题:

  1. 如果 Electron 的不同 Webview 需要组件库的不同部分(不完全重叠),那么实践上最方便的做法只能是分别打包。相当于同一份代码被复制了多份,存在于各自的打包产物中,造成了包体积的浪费;
  2. 上面的做法其实是 Web 的,Electron 环境的优势(包含 Node.js)并没有体现出来。

因为 Node.js 在进行文件载入(require)的时候,提供了扩展能力。因此,只需要做如下的代码增强,在 Electron 项目中也可以直接 require CSS 文件了:

const fs = require('fs');
// 当 Node.js 需要 require 任意后缀是 .css 的文件时,就会执行这个自定义的回调
require.extensions['.css'] = function (module, filename) {
  const css = fs.readFileSync(filename, 'utf8');
  /**
   * 这里让 Electron 支持 require CSS 的思路非常简单,类似于 Webpack 中的 style-loader:
   * 首先创建一个 <style> 标签;
   * 然后将 CSS 文件的内容读取出来;
   * 将 CSS 的内容插入到 <style> 中并最终插入到 <head> 里面;
   * 剩下的渲染工作就交给浏览器了。
   */
  const js = [
    `const css = ${JSON.stringify(css)};`,
    /**
     * 将引用文件的路径作为 id
     * 用于确保同一个 CSS 文件不会因为多次 require 而被重复插入
     */
    `const id = ${JSON.stringify(filename)};`,
    'if (document.head.getElementById(id)) return;',
    'const style = document.createElement("style")',
    'style.id = id;',
    'style.textContent = css;',
    'document.head.appendChild(style);',
  ].join('');
  return module._compile(js, filename);
}

在上述代码中,参考了 style-loader 的思路,实现了一个简单的从 CSS 转化到 JavaScript 代码的操作。剩下的编译工作交给原来 Node.js 的流程去做就可以了(module._compile 部分的代码)。


Require Resolve🔗

Node.js

Node.js 中的 require API 在加载 commonjs 模块的时候,会做两个事情:

  1. 根据 Node.js 的算法,查找到对应的模块文件;
  2. 加载查找到的模块文件并运行。

这里,如果只是想执行第一步,但并不真的运行这个被找到的模块,可以使用 Node.js 提供的 require.resolve API。具体的调用示例如下:

const modulePath = require('some-package');

console.log(modulePath);

上面代码中,modulePath 是一个完整的文件路径,指向的位置就是 some-package 这个模块的 entry 文件(定义在 package.json 的 main 中)。默认情况下,这个 require.resolve 的查找路径是和 require API 一致的:在查找的时候,会从当前文件的目录开始,逐级往上查找 node_modules 目录下是否有需要的库。

举例来说,假设有下面这样一个目录结构:

A.js
node_modules
|- some-package
   |- index.js
|- other-package
   |- index.js
utils
|- B.js
   node_modules
   |- some-package
      |- index.js

且,假设 A.js 的代码为:

require('./utils/B');

console.log('A: ', require.resolve('some-package'));
console.log('A: ', require.resolve('other-package'));

B.js 的代码为:

console.log('B: ', require.resolve('some-package'));
console.log('B: ', require.resolve('other-package'));

那么,输出的结果为:

B: utils/node_modules/some-package/index.js
B: node_modules/other-package/index.js
A: node_modules/some-package/index.js
A: node_modules/other-package/index.js

但有的时候,只是希望可以使用 Node.js 的查找算法,但是查找的目录位置,并不一定是从当前文件所在的目录开始的。这种时候,就需要用到 require.resolve 的第二个参数了:

require.resolve('some-package', {
  paths: [
    'where-to-start-searching',
    'other-possible-search-location',
  ]
});

这里,paths 是一个数组,表示所有的搜索起始位置。Node.js 会依次以这些路径为起始点,查找各个层级往上的 node_modules 目录。一旦找到需要的库,就停止查找,否则就会一直往上直到根目录。到达根目录后,当前的查找就以失败告终。如果还有其他的查找路径,就会继续上面的操作,否则程序会抛出异常。

第二个参数的一个实际应用场景如下:假设有一个 CLI 可以用于代理执行 Webpack 命令,这时候就需要首先通过 require.resolve 命令找出当前执行 CLI 命令的目录内,Webpack 库在什么位置。然后才可以用 require 命令去加载真正在项目中使用到的 Webpack 版本,而不是 CLI 内部可能依赖的一个 Webpack 版本。

更多关于 require.resolve 命令的说明,可以参考官方的文档


Require Performance in Node.js🔗

Node.js

在 Node.js 中,可以通过使用 Performance API 来对 require 模块的性能进行检测。这里的 Performance 模块,是 Node.js 根据 W3C Performance Timeline 规范,实现的一套和 Web 相同的 API 接口集合。一般的时间测量,可以通过 Performance.markPerformance.measure 的组合来进行,使用的方法大体上和 Web 中一致(但是需要使用 PerformanceObserver 来获取测量的结果,这一点和 Web 不太相同,具体可以参考官方的文档)。

和 Web 不同的是,在 Node.js 的 Performance 模块中,还提供了一个 timerify 的接口,可以简便的对一个函数进行封装,从而测量出这个函数的实际调用时间。

有了这个接口,就可以很容易的测量 Node.js 中加载模块的耗时了。示例代码如下:

const {
  performance,
  PerformanceObserver
} = require('perf_hooks');
const fs = require('fs');
const mod = require('module');

mod.Module.prototype.require =
  performance.timerify(mod.Module.prototype.require);

const obs = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  fs.writeFileSync('./profile.json', JSON.stringify(entries), 'utf8');
  obs.disconnect();
});
obs.observe({
  entryTypes: ['function'],
  name: 'Module.require',
  buffered: true,
});

require('some-path');

这里有几点可以说明一下:

  1. mod.Module.prototype.requiretimerify 之后,所有模块在 require 的时候,都会使用被 timerify 过的版本;
  2. PerformanceObserver 的作用是获取 entries 的结果;
  3. obs.disconnect 用于解除连接,不再进行后续的接听;
  4. obs.observer 设置 entryTypes: ['function'],确保这里 timerify 的结果都可以被获取到;
  5. obs.observer 中设置 buffered: true,确保 observer 的回调函数不会被立刻执行,而是用 setImmediate 延迟调用。这样的好处是,一次 require 后,该模块的调用时间和该模块内部调用子模块的耗时都会一次性通过回调函数返回。(注:默认这里的值是 false,见文档);
  6. 官方给出的示例,还 timerifyrequire 函数(见这里),这样做会导致当前模块中 require 的调用,生成两份 Performance 数据(一份来自 require,一份来自 Module.require)。出于精简的考虑,上面的示例代码中去掉了对 require 函数的 timerify

在上面的示例代码中,最终得到的结果,存放在了一个 JSON 文件内,大体的格式如下:

[
  {
    "0": "required-module-name",
    "name": "Module.require",
    "entryType": "function",
    "startTime": 7397.399892,
    "duration": 112.681678
  }
]

这里,0 表示第一个参数的值,对于 require 来说就是具体引用的模块的名称/地址;name 表示是哪个函数的调用,在示例中就是被 timerify 过的 Module.require 函数;entryType 是固定的 function,因为这个值是通过 timerify 拿到的;startTimeduration 分别表示调用开始的时间以及实际调用的耗时。


Eval Script via Nodejs🔗

Node.js

在编写 Bash 脚本的过程中,难免会遇到一些单纯用 Linux 命令很难实现的功能,比如,希望将一个文件的内容作为 JSON 的一个字段,并通过 curl 将这个 JSON 数据发送给服务器。如果单纯使用 Linux 的命令来拼接这个 JSON 字符串,在转意上会遇到很大的问题。但其实同样的需求,在 JavaScript 中可以通过一行命令完成:

JSON.stringify({ text: fs.readFileSync(filepath, 'utf8') })

对于这样的情况,如果正好环境中有 Node.js(比如基于 Node 的 Docker 环境),就可以很方便的通过直接调用 Node 来处理这部分的需求。

需要用到 node.js 内建的参数 -e-p

对于 -e 这个参数(或 --eval),Node 会执行参数后面的字符串。类似于 node file.js 可以让 Node.js 执行 file.js 这个文件,node -e "script" 可以让 Node.js 执行 script 这串语句。需要注意的几点:

  1. Windows 的 CMD 不能使用单引号,只能使用双引号;Powershell 的的话,两者都是支持的;
  2. -e 指令并不会将执行的结果输出到终端,因而如果需要 Bash 中能够用变量保存运行结果的话,需要额外使用 console.log 将结果输出到 stdout,然后再由 Bash 传递给需要赋值的变量。

综上所述,上面这个 JSON 序列化的需求,就可以写成下面的这种形式:

node -e "console.log(JSON.stringify({ text: fs.readFileSync(file, 'utf8') }))"

显然,对于需要赋值的情况,每次都加上 console.log 去输出结果比较的繁琐。Node.js 为此提供了另外一个可用的参数 -p。这个参数的表现形式和 -e 几乎是一样的,唯一的区别是,-p 会将结果默认输出到 stdout 中,不需要额外套一层 console.log 来完成这个操作。-p 也可以写作 --print

下面用一个例子来说明两者的区别:

echo $(node -e "true")

上面这个命令运行的结果将输出空字符串;而下面这个命令,则会在终端输出 true

echo $(node -p "true")

如此一来,上面的例子可以进一步转化为:

node -p "JSON.stringify({ text: fs.readFileSync(file, 'utf8') })"

配合 curl 最终的命令大体上如下:

curl -X POST \
  -H "Content-Type: application/json" \
  $URL \
  --data-binary \
    "$(node -p 'JSON.stringify({text:fs.readFileSync("filepath","utf8")})')"

Follow Redirection🔗

Node.js

在进行网络请求的时候,有可能会遇上服务器指定链接跳转的情况。此时,无论是 301(永久转移)还是 302(暂时转移),在请求页面的时候都需要根据服务器的指示,去访问下一个链接。当然,这里下一个链接依然有可能是跳转链接,需要继续执行跳转操作。

一段可用的 Node.js 代码如下:

const http = require('http');
const https = require('https');

function fetch(link) {
  return new Promise((resolve) => {
    const { get } = link.startsWith('https') ? https : http;
    get(link, response => {
      if (response.statusCode >= 300 && response.statusCode < 400) {
        const { location } = response.headers;
        return fetch(location).then(resolve);
      }
      resolve(response.headers['set-cookie']);
    });
  });
}

这里,Node.js 处理 HTTP 和 HTTPs 请求使用的模块是不相同的,因而需要根据链接地址的 protocol 进行按需索取。同时,如果是 3xx 的 HTTP 结果,则需要进行链接跳转。可以直接读取 headers 中的 location 数据,找到下一步需要跳转到的位置。


CPU Usage via Nodejs🔗

Node.js

在 Node.js 中,可以通过 os.cpus() 这个函数,来了解当前状态下,计算机 CPU 的一些状态。以 MacBookPro 2019 款为例,以下是一份输出的结果:

[
  {
    model: 'Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz',
    speed: 2200,
    times: {
      user: 239572260,
      nice: 0,
      sys: 133555650,
      idle: 644681690,
      irq: 0,
    },
  },
  // 省略类似的其他十一个...
]

在这个返回的数组中,每一个元素代表一个 CPU 的线程。这台 MacBookPro 是六核(core)十二线程(processor),所以返回的数组长度是十二。具体来看每一个元素:

  • model,表示了当前 CPU 的型号
  • speed,表示 CPU 的运算速度,单位是 MHz
  • times 表示 CPU 的使用情况,分别记录了一些时间(单位是毫秒):

    • user 表示进程在用户模式(user mode)下使用的时间;
    • nice 表示被调整优先级(nice)的进程在用户模式下使用的时间。这里,nice 用于表示执行的优先级,从 -20(最高优先级)到 19(最低优先级)。默认的程序,优先级是 0;(注:这是一个 Unix 特有的功能,对于 Windows 用户来说,这个值永远是 0)
    • sys 表示进程在内核模式下使用的时间;
    • idle 表示空闲的时间;
    • irq 表示硬中断请求花费的时间。硬中断也叫外部中断,由外部硬件产生,如鼠标、键盘等。

有了以上的数据,就可以在 Node / Electron 程序中查看 CPU 的使用,从而对当前程序的执行情况有一个客观的了解。


scripts-prepend-node-path🔗

Node.js

在使用服务器编译上线包的过程中,可能会使用 nvm 来管理编译使用的 Node.js 版本。此时,运行时 Node.js 的二进制文件和实际服务器本身的 Node.js 文件将会是不同的。

这样做的好处是可以隔离,保证编译的环境是稳定的。

但是,当 npm 试图运行 Node.js 的时候,就要格外小心了。默认情况下,npm 会使用 path 中定义的 Node.js 版本。而对于打包服务器来说,这个 path 上的 Node.js 很可能并不是 nvm 管理的那个版本。

举个例子,如果 package.json 中定义了 postinstall 的脚本,如下;

{
  "scripts": {
    "postinstall": "some-cli"
  }
}

那么,在 yarn install 之后,执行这个 postinstall 的过程中,some-cli 需要 Node.js 来执行,而 npm 使用的 Node.js 是 path 上的默认版本,和 nvm 管理的并不是同一个。命令行会有如下的报错:

npm WARN lifecycle The node binary used for scripts is xxx but npm is using xxx itself. Use the --scripts-prepend-node-path option to include the path for the node binary npm was executed with.

正如报错指出的,为了修正这个问题,需要用到 scripts-prepend-node-path。这样,当 npm 需要 Node.js 来执行代码的时候,会选择和自身匹配的 Node.js 版本,而不是默认 path 上的版本。(文档

除了每次运行时加上命令行参数之外,一个比较简单的方法是使用 npm config 设置相对应的参数:

npm config set scripts-prepend-node-path=true

或是在 .npmrc 中加上

scripts-prepend-node-path true

设置完后,就不会再有报错了。


debuglog in node.js🔗

Node.js

在 Node.js 中,utils 提供了 debuglog 模块可以用于调试信息的输出。默认情况下,debuglog 的内容是不会输出的,只有当 NODE_DEBUG 这个环境变量设置了合适的值,输出才会显示。这样,调试信息不会影响正常的使用,同时也依然保留了调试的需求。

具体的使用如下:

const debuglog = require('util').debuglog('name');

debuglog('hello world: [%d]', 1);

上面的例子中,如果直接运行,是不会包含 hello world: 1 的输出的。如果设置 NODE_DEBUGname(也就是 debuglog 函数调用时设置的值),那么再次运行,hello world: 1 就会输出了。

同时,可以用逗号分隔的方式一次性为 NODE_DEBUG 设置多个值,比如:NODE_DEBUG=foo,bar

在 Mac 下,一次性设置 NODE_DEBUG 可以输入:

NODE_DEBUG=name node ./index.js

在 Windows 下(Powershell),设置 NODE_DEBUG 可以输入:

$env:NODE_DEBUG="name"; node ./index.js

设置完成之后,NODE_DEBUG 会一直保留着,直到 powershell 被关闭。需要删掉原先设置的 NODE_DEBUG,可以输入:

Remove-Item env:\NODE_DEBUG