🐢

Node.js の進化に伴い不要となったかもしれないパッケージたち

2024/05/06に公開

tl;dr

https://twitter.com/bholmesdev/status/1784972421776199697

はじめに

2024 年の 4 月 24 日に Node.js 22 がリリースされました。ESM を 条件付きで require する機能や、--run フラグによる npm スクリプトのパフォーマンス改善などが v22 で追加され、2009 年に Ryan Dahl が Node.js をリリースしてから 15 年が経つ今も、Node.js は進化を続けています[1]

こうして Node.js 自身が強化されていくにつれ、以前はサードパーティーのパッケージを使用して実現することが一般的であった機能が Node.js のみで実現可能となり、当該パッケージが不要となるような場合があります。冒頭に引用した Ben Holmes の動画では、そのように不要となったパッケージとして

  • dotenv
  • node-fetch
  • chalk
  • mocha

が挙げられていますが、この記事では「これらのパッケージ + α が本当に不要になったのか」、あるいは「不要とまでは言えなくとも、特定条件下で乗り換えるべきか」について考察していきます[2]

ところで、サードパーティーのパッケージにより提供されていた機能が Node.js に組み込まれることのメリットは何でしょうか。依存関係が減ることはそれ自体が良いことであると素朴に感じられますが、改めて考えてみると、以下のようなメリットが期待できそうです:

  • 保守性
    • パッケージの管理コストが減る
    • 互換性問題が減る (たとえば特定のパッケージが最新の Node.js に対応していない場合、そのパッケージを使用しているプロジェクトは Node.js のバージョンアップができないといった問題が発生しうる)
  • パフォーマンス
    • サードパーティーのパッケージと比べ開発リソースが豊富な Node.js が提供するバージョンの方が高速である、あるいは将来的に高速化されると期待できる
    • (やや些末だが) パッケージインストールの時間が短縮され、その結果 CI/CD の実行時間も短縮される
    • (やや些末だが) パッケージのロード時間が短縮される
    • (やや些末だが) バンドルサイズが減る
  • セキュリティ
    • 脆弱性が埋め込まれたコードを実行するリスクが減る
  • 一貫性
    • (サードパーティーのパッケージをパッチワークすることに比較し) Node.js という主体が統一的な API を提供することが期待される

ただし、これらの多くはあくまで「期待」であり、実際には真逆の結果となるかもしれないことに注意が必要です。たとえばパフォーマンスの項目などはわかりやすいですが、実際にはサードパーティー製のパッケージのほうが高速であるということは容易に起こり得るため、速度が重要であるような状況であれば、外部のパッケージをあえて使い続けるという判断も必要になってきます。API の一貫性といった側面も、人によって結論は異なってくるでしょう。よって、実際にパッケージを置き換えるかどうかを判断する際には、上のような様々な観点から検討することが重要です。

それでは以下で、実際に各パッケージについて検討していきます。

node-fetch

node-fetch は、ブラウザの Fetch API を Node.js で使用するためのパッケージです。現在でこそ Node.js には Stable な機能として fetch が組み込まれていますが、この API がフラグ付きで使用可能となったのは v17.5.0v16.15.0 からであり、それ以前にブラウザの Fetch API を Node.js で使用するためには node-fetch を使用する必要がありました。

node-fetch の目的が「ブラウザと互換性のある Fetch API を Node.js の世界にもたらすこと」であるとすると、v22 で同じ目的の fetch が Stable として組み込まれた時点で、node-fetch を積極的に使用する理由はほとんどなくなったと言えるでしょう。Node.js の fetch が内部的に使用している undici の README には仕様との差分に関する言及がありますが[3]、こうした差分は当然ながら node-fetch にも存在しており、使用するコンテキストによっては具体的な差分の内容が重要となりそうですが、差分自体の有無という点では両者とも同様です。個人的には、web-platform-tests などのテストスイートの結果が確認可能となっている点や、単純に使用者すなわち監視の目が多いことから、仕様へのコンプライアンスという観点からは Node.js の fetch の方が信頼できるのではないかと考えています。

なお、パフォーマンスの観点から node-fetch を使用する理由があるかどうかについては、Matteo Collina による以下の記事が参考になります:

https://adventures.nodeland.dev/archive/which-http-client-for-nodejs-should-nodejs-stop/

記事によると、fetchnode-fetch のパフォーマンスをすでに超えているということなので、速度的な観点でも node-fetch を使用する根拠はなさそうです。

Dotenv

v20.6.0--env-file オプションが追加されるまで、Node.js において .env ファイルから環境変数を読み込むためには Dotenv を使用することが一般的でした。Dotenv を使用すると、require('dotenv').config() または import 'dotenv/config' というコードを記述すれば、プロジェクトのルートに置かれた .env ファイルの内容が process.env に読み込まれ、環境変数として利用可能となります。

ファイルから環境変数を読み込むためのこうした機能を提供するためのオプションが、上述した --env-file です。node --env-file=.env index.js のようにプログラムを実行すれば、Dotenv を使用した場合と同様の効果が得られます。Dotenv では、.env ファイルにコメントを記述したり、複数行に渡る値の設定が可能ですが、--env-file でも同じことができます。シンプルに .env ファイルの内容を環境変数として読み込むだけであれば、--env-file があれば十分であると言えそうです。

また、Node.js 自体を設定するための環境変数を記載した .env--env-file により読み込むと、その設定が適用された状態で Node.js が実行されます。仕組み上、Node.js プロセスの実行後に環境変数を読み込む必要がある Dotenv ではこうした挙動を実現することは難しいため、この点は --env-file のユニークな点と言えるでしょう。

ただし、Dotenv では dotenv-expand により変数の展開が可能ですが、--env-file ではそのような機能は 2024 年現在提供されていません。また、これは --env-file オプションの責務からは逸脱する機能ですが、dotenv-vault が提供する .env を共有するための仕組みも Node.js には現状存在しません。こうした機能が必要な場合は、引き続き Dotenv およびそのエコシステムを使用することが必要です。

さらに、API の Stability は現在 1.1 Active development となっており、今後何らかの小さな変更が生じる可能性はあるため、完全に安定した状態の機能を使いたい場合も Dotenv の使用を検討すべきでしょう。

なお、Node.js の .env ファイルサポートに関する開発状況は以下の issue から確認可能です:

https://github.com/nodejs/node/issues/49148

Chalk

CLI 向けのコマンドを作成する際、ターミナルに表示する文字列のスタイルを変更するためには ANSI エスケープシーケンス を使用するという選択肢がありますが、エスケープシーケンスを文字列に正確に埋め込みながらコードを書くのは煩雑です。こうした難点を解消し、ターミナル上に表示する文字列を簡単にスタイリングするためのパッケージが Chalk です。Chalk により、chalk.red('Hello, world!') のような直感的にわかりやすいコードを使用して、文字列に色を付けたり、文字の太さを変えたりすることができます。

v21.7.0v20.12.0 において、Chalk のようにターミナル上の文字列をスタイリングするための util.styleText という API が Node.js に追加されました。使い方は formattext を引数として与えるだけで、たとえば以下のようにして簡単に文字列をスタイリングできます:

import { styleText } from 'node:util'
console.log(styleText('red', 'Hello, world!'))

util.styleText でサポートされている format は以下の通りです:

  • 修飾子
    • reset
    • bold
    • italic
    • underline
    • strikethrough (エイリアス: strikeThrough、crossedoutcrossedOut)
    • hidden (エイリアス: conceal)
    • dim (エイリアス: faint)
    • overlined
    • blink
    • inverse (エイリアス: swapcolorsswapColors)
    • doubleunderline (エイリアス: doubleUnderline)
    • framed
  • 前景色
    • black
    • red
    • green
    • yellow
    • blue
    • magenta
    • cyan
    • white
    • gray (エイリアス: greyblackBright)
    • redBright
    • greenBright
    • yellowBright
    • blueBright
    • magentaBright
    • cyanBright
    • whiteBright
  • 背景色
    • bgBlack
    • bgRed
    • bgGreen
    • bgYellow
    • bgBlue
    • bgMagenta
    • bgCyan
    • bgWhite
    • bgGray (エイリアス: bgGreybgBlackBright)
    • bgRedBright
    • bgGreenBright
    • bgYellowBright
    • bgBlueBright
    • bgMagentaBright
    • bgCyanBright
    • bgWhiteBright

多くの項目は Chalk とオーバーラップしているため、基本的なスタイリングが目的であれば util.styleText で十分そうです。Wes Bos動画 を参考に、すべての前景色と背景色を組み合わせた画像を作成してみましたので、こちらも参考にしてください:

styleText でサポートされている前景色と背景色の組み合わせ

ただし、Chalk のようなスタイルをチェーンする書き方が好みである場合や、Chalk でしか提供されていない API が必要な場合などは、そのまま Chalk を使い続ければよいでしょう。

ところで、パフォーマンスについても一応調べてみたところ、以下のような簡単なベンチマークでは Chalk に軍配が上がったことも付記しておきます。Vitest には Experimental な機能として bench 関数があり、これは内部で Tinybench を使用しているのですが、テストコードを書くように気軽にベンチマークが実行できる点が最近気に入っているため、今回はこれを用いて以下のコードを実行しました:

import chalk from 'chalk'
import { styleText } from 'node:util'
import { bench, describe } from 'vitest'

describe('Combine styled and normal strings', () => {
  bench('chalk', () => {
    chalk.blue('Hello') + ' World' + chalk.red('!')
  })
  bench('styleText', () => {
    styleText('blue', 'Hello') + ' World' + styleText('red', '!')
  })
})

describe('Compose multiple styles', () => {
  bench('chalk', () => {
    chalk.blue.bgRed.bold('Hello World!')
  })
  bench('styleText', () => {
    styleText(['blue', 'bgRed', 'bold'], 'Hello World!')
  })
})

describe('Nest styles', () => {
  bench('chalk', () => {
    chalk.blue('Hello', chalk.underline(' World') + '!')
  })
  bench('styleText', () => {
    styleText('blue', 'Hello' + styleText('underline', ' World') + '!')
  })
})

Node.js 22.1.0 on Ryzen 9 5900X + Ubuntu 22.04 という環境での実行結果は以下の通りでした[4]:

 ✓ tests/color.bench.js (6) 6951ms
   ✓ Combine styled and normal strings (2) 2199ms
     name                 hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · chalk      2,975,898.70  0.0003  0.1753  0.0003  0.0003  0.0006  0.0006  0.0008  ±0.10%  1487950   fastest
   · styleText  2,078,918.75  0.0004  0.2335  0.0005  0.0005  0.0009  0.0010  0.0011  ±0.12%  1039460  
   ✓ Compose multiple styles (2) 2698ms
     name                 hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · chalk      5,099,670.12  0.0002  0.0241  0.0002  0.0002  0.0003  0.0004  0.0004  ±0.04%  2549836   fastest
   · styleText  2,842,975.26  0.0003  0.3307  0.0004  0.0003  0.0007  0.0007  0.0009  ±0.46%  1421488  
   ✓ Nest styles (2) 2052ms
     name                 hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · chalk      2,180,543.15  0.0004  0.0154  0.0005  0.0005  0.0007  0.0008  0.0010  ±0.04%  1090272   fastest
   · styleText  2,116,827.76  0.0004  0.5064  0.0005  0.0005  0.0008  0.0009  0.0010  ±0.26%  1058414  


 BENCH  Summary

  chalk - tests/color.bench.js > Combine styled and normal strings
    1.43x faster than styleText

  chalk - tests/color.bench.js > Compose multiple styles
    1.79x faster than styleText

  chalk - tests/color.bench.js > Nest styles
    1.03x faster than styleText

各項目の細かい説明は省略しますが、Summary からわかるように、すべてのベンチマークにおいて Chalk のパフォーマンスが優れていました。結果を一度だけ表示するような CLI コマンドを作成する際には文字列の表示速度をそれほど気にする必要はないかもしれませんが、パフォーマンスが重要なコンテキストにおいては、現状では Chalk を使用したほうが良いかもしれません。

Mocha

Mocha は Node.js 向けの軽量なテストフレームワークです。同様のテストフレームワークとしては JestVitest なども有名ですが、Mocha は Chai などのアサーションライブラリと組み合わせて使用するように設計されており、軽量なテストランナーといった趣きがあるのが特徴です。

Node.js の v18.0.0v16.17.0 においてテストランナーの機能が組み込まれたことにより、Mocha の役割をある程度代替することが可能となりました。特に v20.0.0 からは Stable な機能となっており、--test-reporter によるフィードバック形式の指定、--watch によるウォッチモードでのテスト実行、assert による様々な形式のアサーション、カバレッジ の取得、等々がサポートされており、本格的なテストを記述するための準備が出来上がってきていると言えそうです。

他と異なり検討事項が多く、また筆者が Mocha についてあまり詳しくないため、Mocha を代替可能かどうかについて断定することは避けますが、基本的なテストを記述することは十分に可能なように見えるため、小規模なプロジェクトなどで本 API を試し始めていくのが良さそうです。

なお、大きなプロジェクトで Mocha から Node.js のテストランナーに乗り換えた事例としては、Astro があります:

https://astro.build/blog/node-test-migration/

これを読むと、Node.js のテストランナーがテストファイルごとにプロセスを生成しており速度的な課題があったことや、Chai と比較してアサーションの表現力が劣る点があること、OSS プロジェクトとしてどのようにマイグレーションを進めていったか、など興味深い内容を知ることができます。Astro のような著名なプロジェクトが、各局面でどういった判断を下していったかを報告している貴重な資料であるため、興味がある方はぜひ一読することをおすすめします。

Nodemon

Nodemon は、ディレクトリ内のファイルの変更を検知し、アプリケーションを自動的に再起動するためのツールです。nodemon index.js のように実行したいアプリケーションを nodemon コマンドに続けて指定するだけで、ファイルの変更を検知してアプリケーションを再起動してくれます。

Nodemon と同様にファイルを監視してアプリケーションを再起動するための --watch オプションが、v18.11.0v16.19.0 で追加されました。node --watch index.js のようにエントリーポイントを指定するだけで、このファイルとその依存関係を監視し、更新があればアプリケーションを再起動してくれます。このオプションは v22 において Stable となりました。

Nodemon と異なり、--watch はエントリーポイントと関係のないパスを監視してくれません。そうした目的のためには --watch-path というオプションが用意されており、node --watch-path=./src index.js のように監視対象のパスを指定できます。ただしドキュメントによると、公式にサポートされている OS は macOS と Windows だけとされており、Linux では動作しない可能性がある点に注意してください[5]

このように、「ファイルの変更を検知してアプリケーションを再起動する」という目的においては、--watch--watch-path オプションで単純なケースであれば対応できそうです。ただし、たとえば glob パターンにより監視対象を指定したり、特定の監視イベントに対して何らかの処理を実行するなど、Nodemon にできてこれらのオプションだけではできないことは多岐にわたります。よって、乗り換えの前にきちんとユースケースを検討することが重要になりそうです。

glob

v22.0.0globglobSync という関数が実験的な機能として追加されました。Glob とは一般に、ワイルドカードを使用してファイルパスを指定するためのパターンマッチングのことを指しますが、Node.js において Glob を使用するには従来 globfast-glob などのサードパーティーパッケージを使用する必要がありました。

v22 によって glob が Node.js 本体に組み込まれたため、たとえば

import { globSync } from 'node:fs'
console.log(globSync('**/*.js'))

のようにしてファイルパスを簡単に取得できるようになりました。シンプルなパターンであれば、これで一旦は対応できそうです。

なお、パフォーマンスに関して、https://github.com/isaacs/node-glob?tab=readme-ov-file#benchmark-results にあるパターンを globSync のみ測定した限りでは、globfast-glob に分がありそうです。長くなるため、気になる方は以下のアコーディオンをクリックして詳細を表示してください:

`globSync` のパフォーマンス比較

実行したコードは以下の通りです:

import * as fg from 'fast-glob'
import * as glob from 'glob'
import * as fs from 'node:fs'
import { bench, describe } from 'vitest'

describe('**', () => {
  bench('glob', () => {
    glob.globSync('**')
  })
  bench('fast-glob', () => {
    fg.globSync('**')
  })
  bench('fs', () => {
    fs.globSync('**')
  })
})

describe('**/..', () => {
  bench('glob', () => {
    glob.globSync('**/..')
  })
  bench('fast-glob', () => {
    fg.globSync('**/..')
  })
  bench('fs', () => {
    fs.globSync('**/..')
  })
})

describe('./**/0/**/0/**/0/**/0/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./**/0/**/0/**/0/**/0/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./**/0/**/0/**/0/**/0/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./**/0/**/0/**/0/**/0/**/*.txt')
  })
})

describe('./**/[01]/**/[12]/**/[23]/**/[45]/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./**/[01]/**/[12]/**/[23]/**/[45]/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./**/[01]/**/[12]/**/[23]/**/[45]/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./**/[01]/**/[12]/**/[23]/**/[45]/**/*.txt')
  })
})

describe('./**/0/**/0/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./**/0/**/0/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./**/0/**/0/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./**/0/**/0/**/*.txt')
  })
})

describe('**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('**/*.txt')
  })
})

describe('{**/*.txt,**/?/**/*.txt,**/?/**/?/**/*.txt,**/?/**/?/**/?/**/*.txt,**/?/**/?/**/?/**/?/**/*.txt}', () => {
  bench('glob', () => {
    glob.globSync('{**/*.txt,**/?/**/*.txt,**/?/**/?/**/*.txt,**/?/**/?/**/?/**/*.txt,**/?/**/?/**/?/**/?/**/*.txt}')
  })
  bench('fast-glob', () => {
    fg.globSync('{**/*.txt,**/?/**/*.txt,**/?/**/?/**/*.txt,**/?/**/?/**/?/**/*.txt,**/?/**/?/**/?/**/?/**/*.txt}')
  })
  bench('fs', () => {
    fs.globSync('{**/*.txt,**/?/**/*.txt,**/?/**/?/**/*.txt,**/?/**/?/**/?/**/*.txt,**/?/**/?/**/?/**/?/**/*.txt}')
  })
})

describe('**/5555/0000/*.txt', () => {
  bench('glob', () => {
    glob.globSync('**/5555/0000/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('**/5555/0000/*.txt')
  })
  bench('fs', () => {
    fs.globSync('**/5555/0000/*.txt')
  })
})

describe('./**/0/**/../[01]/**/0/../**/0/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./**/0/**/../[01]/**/0/../**/0/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./**/0/**/../[01]/**/0/../**/0/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./**/0/**/../[01]/**/0/../**/0/*.txt')
  })
})

describe('**/????/????/????/????/*.txt', () => {
  bench('glob', () => {
    glob.globSync('**/????/????/????/????/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('**/????/????/????/????/*.txt')
  })
  bench('fs', () => {
    fs.globSync('**/????/????/????/????/*.txt')
  })
})

describe('./{**/?{/**/?{/**/?{/**/?,,,,},,,,},,,,},,,}/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./{**/?{/**/?{/**/?{/**/?,,,,},,,,},,,,},,,}/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./{**/?{/**/?{/**/?{/**/?,,,,},,,,},,,,},,,}/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./{**/?{/**/?{/**/?{/**/?,,,,},,,,},,,,},,,}/**/*.txt')
  })
})

describe('**/!(0|9).txt', () => {
  bench('glob', () => {
    glob.globSync('**/!(0|9).txt')
  })
  bench('fast-glob', () => {
    fg.globSync('**/!(0|9).txt')
  })
  bench('fs', () => {
    fs.globSync('**/!(0|9).txt')
  })
})

describe('./{*/**/../{*/**/../{*/**/../{*/**/../{*/**,,,,},,,,},,,,},,,,},,,,}/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./{*/**/../{*/**/../{*/**/../{*/**/../{*/**,,,,},,,,},,,,},,,,},,,,}/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./{*/**/../{*/**/../{*/**/../{*/**/../{*/**,,,,},,,,},,,,},,,,},,,,}/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./{*/**/../{*/**/../{*/**/../{*/**/../{*/**,,,,},,,,},,,,},,,,},,,,}/*.txt')
  })
})

describe('./*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/*.txt')
  })
})

describe('./*/**/../*/**/../*/**/../*/**/../*/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./*/**/../*/**/../*/**/../*/**/../*/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./*/**/../*/**/../*/**/../*/**/../*/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./*/**/../*/**/../*/**/../*/**/../*/**/*.txt')
  })
})

describe('./0/**/../1/**/../2/**/../3/**/../4/**/../5/**/../6/**/../7/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./0/**/../1/**/../2/**/../3/**/../4/**/../5/**/../6/**/../7/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./0/**/../1/**/../2/**/../3/**/../4/**/../5/**/../6/**/../7/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./0/**/../1/**/../2/**/../3/**/../4/**/../5/**/../6/**/../7/**/*.txt')
  })
})

describe('./**/?/**/?/**/?/**/?/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./**/?/**/?/**/?/**/?/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./**/?/**/?/**/?/**/?/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./**/?/**/?/**/?/**/?/**/*.txt')
  })
})

describe('**/*/**/*/**/*/**/*/**', () => {
  bench('glob', () => {
    glob.globSync('**/*/**/*/**/*/**/*/**')
  })
  bench('fast-glob', () => {
    fg.globSync('**/*/**/*/**/*/**/*/**')
  })
  bench('fs', () => {
    fs.globSync('**/*/**/*/**/*/**/*/**')
  })
})

describe('./**/*/**/*/**/*/**/*/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./**/*/**/*/**/*/**/*/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./**/*/**/*/**/*/**/*/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./**/*/**/*/**/*/**/*/**/*.txt')
  })
})

describe('./**/**/**/**/**/**/**/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('./**/**/**/**/**/**/**/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('./**/**/**/**/**/**/**/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('./**/**/**/**/**/**/**/**/*.txt')
  })
})

describe('**/*/*.txt', () => {
  bench('glob', () => {
    glob.globSync('**/*/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('**/*/*.txt')
  })
  bench('fs', () => {
    fs.globSync('**/*/*.txt')
  })
})

describe('**/*/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('**/*/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('**/*/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('**/*/**/*.txt')
  })
})

describe('**/[0-9]/**/*.txt', () => {
  bench('glob', () => {
    glob.globSync('**/[0-9]/**/*.txt')
  })
  bench('fast-glob', () => {
    fg.globSync('**/[0-9]/**/*.txt')
  })
  bench('fs', () => {
    fs.globSync('**/[0-9]/**/*.txt')
  })
})

実行結果は以下の通りとなりました:

 ✓ tests/glob.bench.js (69) 42929ms
   ✓ ** (3) 1881ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       113.32  4.8546  98.5830  8.8248  6.7211  98.5830  98.5830  98.5830  ±41.02%       57   slowest
   · fast-glob  216.99  4.0431  11.2906  4.6085  4.8396   5.8854  11.2906  11.2906   ±3.20%      109   fastest
   · fs         141.77  6.2543   8.8528  7.0537  7.3294   8.8528   8.8528   8.8528   ±1.93%       71
   ✓ **/.. (3) 1877ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       145.40  4.3822  87.9975  6.8775  6.0419  87.9975  87.9975  87.9975  ±33.46%       74   slowest
   · fast-glob  245.53  3.8536   5.4069  4.0728  4.0830   5.0975   5.4069   5.4069   ±1.19%      123   fastest
   · fs         148.76  6.2633   7.8895  6.7220  7.0513   7.8895   7.8895   7.8895   ±1.31%       75
   ✓ ./**/0/**/0/**/0/**/0/**/*.txt (3) 1851ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       205.16  4.1240   7.7028  4.8742  5.3673   7.4834   7.7028   7.7028   ±3.09%      103   fastest
   · fast-glob  193.93  3.8691  61.9729  5.1566  4.6539  61.9729  61.9729  61.9729  ±23.58%       97
   · fs         143.61  6.4944   8.5380  6.9635  7.1978   8.5380   8.5380   8.5380   ±1.50%       72   slowest
   ✓ ./**/[01]/**/[12]/**/[23]/**/[45]/**/*.txt (3) 1906ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       197.98  4.1941   8.5955  5.0510  5.3996   8.2710   8.5955   8.5955   ±3.11%      100   fastest
   · fast-glob  170.73  3.9933  72.4826  5.8572  4.8392  72.4826  72.4826  72.4826  ±29.21%       93
   · fs         139.35  6.6269   8.7886  7.1760  7.3960   8.7886   8.7886   8.7886   ±1.75%       70   slowest
   ✓ ./**/0/**/0/**/*.txt (3) 1848ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       216.00  4.0717   6.3122  4.6296  5.0088   6.3009   6.3122   6.3122   ±2.44%      108
   · fast-glob  244.51  3.8660   4.9823  4.0898  4.2290   4.9574   4.9823   4.9823   ±1.13%      123   fastest
   · fs         113.87  6.3623  53.8540  8.7819  7.4448  53.8540  53.8540  53.8540  ±26.15%       57   slowest
   ✓ **/*.txt (3) 1856ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       214.25  4.1049   6.7463  4.6674  5.0210   6.4188   6.7463   6.7463   ±2.42%      108
   · fast-glob  239.53  3.9331   5.4114  4.1748  4.2344   5.3673   5.4114   5.4114   ±1.31%      120   fastest
   · fs         129.60  6.1572  29.9861  7.7162  7.4608  29.9861  29.9861  29.9861  ±10.54%       65   slowest
   ✓ {**/*.txt,**/?/**/*.txt,**/?/**/?/**/*.txt,**/?/**/?/**/?/**/*.txt,**/?/**/?/**/?/**/?/**/*.txt} (3) 1901ms
     name            hz      min      max     mean      p75      p99     p995     p999     rme  samples
   · glob        198.82   4.4492   8.0885   5.0296   5.4659   6.5957   8.0885   8.0885  ±2.43%      100
   · fast-glob   208.94   4.5496   5.8909   4.7861   4.8349   5.7184   5.8909   5.8909  ±1.13%      105   fastest
   · fs         50.7169  18.1543  24.3457  19.7173  19.9703  24.3457  24.3457  24.3457  ±3.09%       26   slowest
   ✓ **/5555/0000/*.txt (3) 1864ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       181.75  4.0950  37.9464  5.5020  5.5252  37.9464  37.9464  37.9464  ±13.91%       91
   · fast-glob  246.60  3.8361   5.3238  4.0552  4.0637   5.1471   5.3238   5.3238   ±1.16%      124   fastest
   · fs         145.12  6.1828   8.3297  6.8907  7.2526   8.3297   8.3297   8.3297   ±1.99%       73   slowest
   ✓ ./**/0/**/../[01]/**/0/../**/0/*.txt (3) 1867ms
     name            hz     min      max     mean      p75      p99     p995     p999      rme  samples
   · glob        171.70  4.2964  31.4052   5.8241   5.7767  31.4052  31.4052  31.4052  ±11.82%       86
   · fast-glob   251.42  3.8561   4.6463   3.9774   3.9898   4.4931   4.6463   4.6463   ±0.70%      126   fastest
   · fs         63.0568  9.8307   112.10  15.8587  12.4407   112.10   112.10   112.10  ±41.90%       32   slowest
   ✓ **/????/????/????/????/*.txt (3) 1856ms
     name           hz     min      max    mean     p75      p99     p995     p999     rme  samples
   · glob       209.66  4.1012   8.0333  4.7697  5.2483   7.1307   8.0333   8.0333  ±3.02%      105
   · fast-glob  232.56  3.8346   7.7895  4.3000  4.4621   6.6667   7.7895   7.7895  ±2.60%      117   fastest
   · fs         141.31  6.3033  10.0012  7.0765  7.5553  10.0012  10.0012  10.0012  ±2.53%       71   slowest
   ✓ ./{**/?{/**/?{/**/?{/**/?,,,,},,,,},,,,},,,}/**/*.txt (3) 1891ms
     name            hz      min      max     mean      p75      p99     p995     p999      rme  samples
   · glob        186.49   4.5379   8.9847   5.3622   5.6831   8.9847   8.9847   8.9847   ±3.23%       94   fastest
   · fast-glob   130.61   4.6183  32.9973   7.6563   7.9482  32.9973  32.9973  32.9973  ±15.24%       66
   · fs         49.0208  19.5948  21.5136  20.3995  20.7745  21.5136  21.5136  21.5136   ±1.05%       25   slowest
   ✓ **/!(0|9).txt (3) 1871ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       205.68  4.1866   7.8510  4.8619  5.2790   6.8577   7.8510   7.8510   ±2.86%      103
   · fast-glob  243.38  3.9246   5.0857  4.1089  4.1474   5.0750   5.0857   5.0857   ±1.12%      122   fastest
   · fs         128.29  6.3918  38.0924  7.7951  7.3733  38.0924  38.0924  38.0924  ±14.18%       65   slowest
   ✓ ./{*/**/../{*/**/../{*/**/../{*/**/../{*/**,,,,},,,,},,,,},,,,},,,,}/*.txt (3) 1886ms
     name            hz      min      max     mean      p75      p99     p995     p999      rme  samples
   · glob        158.19   5.5495   9.0565   6.3215   6.6645   9.0565   9.0565   9.0565   ±2.13%       80
   · fast-glob   238.81   3.8613   5.5281   4.1874   4.4124   5.2933   5.5281   5.5281   ±1.47%      120   fastest
   · fs         66.2732  11.5230  86.9126  15.0891  13.0505  86.9126  86.9126  86.9126  ±29.59%       34   slowest
   ✓ ./*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/*.txt (3) 1861ms
     name           hz     min      max    mean     p75      p99     p995     p999     rme  samples
   · glob       175.31  5.0608   8.4763  5.7041  6.1189   8.4763   8.4763   8.4763  ±2.11%       88
   · fast-glob  242.83  3.8849   4.9059  4.1182  4.2812   4.8403   4.9059   4.9059  ±1.03%      122   fastest
   · fs         107.77  8.2525  15.2572  9.2791  9.3240  15.2572  15.2572  15.2572  ±4.13%       54   slowest
   ✓ ./*/**/../*/**/../*/**/../*/**/../*/**/*.txt (3) 1853ms
     name           hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · glob       193.41  4.5927  7.9756  5.1703  5.7163  7.9756  7.9756  7.9756  ±2.23%       97
   · fast-glob  238.14  3.8687  5.3547  4.1992  4.3932  4.9689  5.3547  5.3547  ±1.29%      120   fastest
   · fs         129.55  7.2153  9.0693  7.7193  7.9746  9.0693  9.0693  9.0693  ±1.32%       65   slowest
   ✓ ./0/**/../1/**/../2/**/../3/**/../4/**/../5/**/../6/**/../7/**/*.txt (3) 1824ms
     name              hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · glob          735.29  1.1960  4.5898  1.3600  1.3839  3.2482  4.4349  4.5898  ±2.71%      368
   · fast-glob  25,505.49  0.0300  2.5715  0.0392  0.0343  0.0773  0.1450  1.6269  ±3.89%    12753   fastest
   · fs            432.48  2.1939  2.8817  2.3122  2.3782  2.7649  2.7965  2.8817  ±0.72%      217   slowest
   ✓ ./**/?/**/?/**/?/**/?/**/*.txt (3) 1870ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       191.12  4.0779  39.1255  5.2322  5.3968  39.1255  39.1255  39.1255  ±13.78%       99
   · fast-glob  240.04  3.8902  11.7147  4.1660  4.1399   6.6125  11.7147  11.7147   ±3.32%      121   fastest
   · fs         142.55  6.4803   7.9945  7.0151  7.3281   7.9945   7.9945   7.9945   ±1.34%       72   slowest
   ✓ **/*/**/*/**/*/**/*/** (3) 1869ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       188.25  4.6475   6.7591  5.3121  5.8537   6.7591   6.7591   6.7591   ±2.23%       95   fastest
   · fast-glob  170.66  4.6015  24.0452  5.8596  5.5817  24.0452  24.0452  24.0452  ±10.09%       86
   · fs         118.72  7.7093  10.0317  8.4230  8.9300  10.0317  10.0317  10.0317   ±1.85%       60   slowest
   ✓ ./**/*/**/*/**/*/**/*/**/*.txt (3) 1867ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       191.44  4.4333   8.9459  5.2235  5.6585   8.9459   8.9459   8.9459   ±3.41%       96   fastest
   · fast-glob  159.54  5.1703  44.8609  6.2678  6.1527  44.8609  44.8609  44.8609  ±15.79%       80
   · fs         121.01  7.7415   9.2315  8.2635  8.6832   9.2315   9.2315   9.2315   ±1.48%       61   slowest
   ✓ ./**/**/**/**/**/**/**/**/*.txt (3) 1852ms
     name           hz     min      max    mean     p75      p99     p995     p999     rme  samples
   · glob       209.38  4.1413   8.1267  4.7760  5.2608   8.0191   8.1267   8.1267  ±3.06%      105   fastest
   · fast-glob  207.98  3.9507  17.9885  4.8082  4.8776  13.4335  17.9885  17.9885  ±7.11%      104
   · fs         126.43  6.3830  16.2043  7.9096  8.0078  16.2043  16.2043  16.2043  ±6.47%       64   slowest
   ✓ **/*/*.txt (3) 1854ms
     name           hz     min      max    mean     p75      p99     p995     p999      rme  samples
   · glob       185.44  4.2857  14.3469  5.3927  5.5844  14.3469  14.3469  14.3469   ±5.83%       93
   · fast-glob  234.24  4.0245   5.2329  4.2692  4.3831   5.2123   5.2329   5.2329   ±1.31%      118   fastest
   · fs         121.88  6.6029  28.5266  8.2050  7.6272  28.5266  28.5266  28.5266  ±11.19%       61   slowest
   ✓ **/*/**/*.txt (3) 1863ms
     name           hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · glob       199.63  4.2537  8.2889  5.0091  5.2935  7.8715  8.2889  8.2889  ±3.31%      101
   · fast-glob  217.02  4.3337  5.6199  4.6079  4.7862  5.5467  5.6199  5.6199  ±1.30%      109   fastest
   · fs         138.06  6.6851  8.6301  7.2430  7.5631  8.6301  8.6301  8.6301  ±1.77%       70   slowest
   ✓ **/[0-9]/**/*.txt (3) 1858ms
     name           hz     min      max    mean     p75      p99     p995     p999     rme  samples
   · glob       189.91  4.2157  11.7635  5.2657  5.6619  11.7635  11.7635  11.7635  ±4.79%       95
   · fast-glob  240.39  3.8569   5.4217  4.1599  4.3755   5.4138   5.4217   5.4217  ±1.50%      121   fastest
   · fs         146.27  6.2664   7.9613  6.8366  7.0962   7.9613   7.9613   7.9613  ±1.46%       74   slowest


 BENCH  Summary

  fast-glob - tests/glob.bench.js > **
    1.53x faster than fs
    1.91x faster than glob

  fast-glob - tests/glob.bench.js > **/..
    1.65x faster than fs
    1.69x faster than glob

  glob - tests/glob.bench.js > ./**/0/**/0/**/0/**/0/**/*.txt
    1.06x faster than fast-glob
    1.43x faster than fs

  glob - tests/glob.bench.js > ./**/[01]/**/[12]/**/[23]/**/[45]/**/*.txt
    1.16x faster than fast-glob
    1.42x faster than fs

  fast-glob - tests/glob.bench.js > ./**/0/**/0/**/*.txt
    1.13x faster than glob
    2.15x faster than fs

  fast-glob - tests/glob.bench.js > **/*.txt
    1.12x faster than glob
    1.85x faster than fs

  fast-glob - tests/glob.bench.js > {**/*.txt,**/?/**/*.txt,**/?/**/?/**/*.txt,**/?/**/?/**/?/**/*.txt,**/?/**/?/**/?/**/?/**/*.txt}
    1.05x faster than glob
    4.12x faster than fs

  fast-glob - tests/glob.bench.js > **/5555/0000/*.txt
    1.36x faster than glob
    1.70x faster than fs

  fast-glob - tests/glob.bench.js > ./**/0/**/../[01]/**/0/../**/0/*.txt
    1.46x faster than glob
    3.99x faster than fs

  fast-glob - tests/glob.bench.js > **/????/????/????/????/*.txt
    1.11x faster than glob
    1.65x faster than fs

  glob - tests/glob.bench.js > ./{**/?{/**/?{/**/?{/**/?,,,,},,,,},,,,},,,}/**/*.txt
    1.43x faster than fast-glob
    3.80x faster than fs

  fast-glob - tests/glob.bench.js > **/!(0|9).txt
    1.18x faster than glob
    1.90x faster than fs

  fast-glob - tests/glob.bench.js > ./{*/**/../{*/**/../{*/**/../{*/**/../{*/**,,,,},,,,},,,,},,,,},,,,}/*.txt
    1.51x faster than glob
    3.60x faster than fs

  fast-glob - tests/glob.bench.js > ./*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/../*/**/*.txt
    1.39x faster than glob
    2.25x faster than fs

  fast-glob - tests/glob.bench.js > ./*/**/../*/**/../*/**/../*/**/../*/**/*.txt
    1.23x faster than glob
    1.84x faster than fs

  fast-glob - tests/glob.bench.js > ./0/**/../1/**/../2/**/../3/**/../4/**/../5/**/../6/**/../7/**/*.txt
    34.69x faster than glob
    58.97x faster than fs

  fast-glob - tests/glob.bench.js > ./**/?/**/?/**/?/**/?/**/*.txt
    1.26x faster than glob
    1.68x faster than fs

  glob - tests/glob.bench.js > **/*/**/*/**/*/**/*/**
    1.10x faster than fast-glob
    1.59x faster than fs

  glob - tests/glob.bench.js > ./**/*/**/*/**/*/**/*/**/*.txt
    1.20x faster than fast-glob
    1.58x faster than fs

  glob - tests/glob.bench.js > ./**/**/**/**/**/**/**/**/*.txt
    1.01x faster than fast-glob
    1.66x faster than fs

  fast-glob - tests/glob.bench.js > **/*/*.txt
    1.26x faster than glob
    1.92x faster than fs

  fast-glob - tests/glob.bench.js > **/*/**/*.txt
    1.09x faster than glob
    1.57x faster than fs

  fast-glob - tests/glob.bench.js > **/[0-9]/**/*.txt
    1.27x faster than glob
    1.64x faster than fs

このように、ほぼすべてのパターンにおいて fs.globSync が最も遅い結果となりました。

現在指定可能なオプションは cwdexclude のみとまだ限られており、Stability のステータスも上述のように Experimental の扱いであるため、既存の glob パッケージ等をすぐに置き換え可能であるとは言い難いでしょう。

Commander.js、Yargs

Commander.jsYargsminimist などのパッケージはいずれも、CLI コマンドの実行時にコマンドライン引数をパースし、ヘルプの表示やバリデーション、サブコマンドの設定など、CLI コマンドに必要な機能を提供する役割を担います。Node.js では process.argv からコマンド引数を取得でき、これを直接利用して CLI コマンドを作成することもできますが、上に述べたような本格的な機能を実装するためにはそれなりの手間が掛かります。そのため、きちんとしたパッケージとして配布するといった目的で CLI コマンドを作成するような場合には、こうしたパッケージを利用してコマンドライン引数をパースすることが一般的となっています。

Node.js v18.3.0v16.17.0 で、上記パッケージのようにコマンドライン引数をパースするための機能である util.parseArgs が追加されました。この API は v20.0.0 で Stable となりました。

たとえば名前と言語を受け取り、受け取った言語に対応する挨拶を返すようなコマンドは、util.parseArgs を使うと以下のように実装できます:

greet.js
import { parseArgs } from 'node:util'

const options = {
  name: {
    type: 'string',
    short: 'n',
  },
  japanese: {
    type: 'boolean',
    short: 'j',
    default: false,
  },
}

const { values: { name, japanese } } = parseArgs({ options })

console.log(`${japanese ? 'こんにちは' : 'Hello'} ${name}!`)

options に引数やその型、短縮形、デフォルト値などを指定し、parseArgs に渡すと、与えられたオプションに基づいて process.argv をパースしてくれます。これを node greet.js -n 太郎 --japanese のように実行すると、こんにちは 太郎! と表示されます。

以上のように、コマンド引数をパースして利用するための最低限の機能が util.parseArgs には組み込まれているため、簡単なコマンドであればこれを使って実装しても良さそうです。ただし、たとえば与えたオプションなどから自動的にヘルプを生成する機能などは個人的に入っていてほしいですが、他のパッケージにあるようなそうした便利な機能はまだまだ足りないように思われるため、要件が単純でない場合は Commander.js や Yargs などのパッケージも同時に比較検討し、適切な選択をする必要がありそうです。

おわりに

自分は普段から基本的にプロジェクトの依存を少なくしたいと考えており、それに関する情報を目にするたびに適宜試しているのですが、そうした情報がどこかにまとまっていれば便利だと思いこの記事を書きました。時間が経つに連れて Stability のステータスや API が変化したり、他のパッケージが不要になっていくことが予想されますが、なるべく記事の情報を最新に保っていく予定です。

なお、言うまでもないことですが、各パッケージの背後には無数のコントリビューターがおり、彼らのおかげで便利な機能が提供され、またそのことが Node.js 本体の改善を促し、エコシステム全体が発展してきました。不要となって削除することとなっても、開発に関わった人たちへのリスペクトの気持ちは忘れずにいたいと思います[6]

脚注
  1. Node.js: The Documentary はいいぞ。 ↩︎

  2. たとえば Promise がネイティブに組み込まれたことにより Bluebird が不要になったりなど、過去に遡ればこういった例は数多くあるでしょうが、この記事ではすべてを網羅することは目指しません。あくまで上のツイートを起点として、2024 年というタイミングで重要そうないくつかのパッケージについて触れるにとどめます。 ↩︎

  3. 少し前にも、ガベージコレクションの挙動の差異を原因とするメモリリークについて触れた Malte Ubl のツイートが話題となっていました。 ↩︎

  4. Node.js 22.1.0 on Apple M1 + macOS Sonoma 14.4.1 でも試しましたが同様の結果でした。 ↩︎

  5. 筆者が Ubuntu 22.04 で試した限りにおいては動いているようでした。 ↩︎

  6. 気を整え、拝み、祈り、構えて、npm uninstall 🙏 ↩︎

Discussion