ファイルシステム操作において、ディレクトリの作成・削除・一覧取得は基本中の基本です。しかし、Node.jsには複数のAPIパターンがあり、再帰的な操作やシンボリックリンクの扱いなど、理解すべきポイントが多くあります。

本記事では、fs/promisesモジュールを使用したディレクトリ操作を体系的に解説します。mkdirによるディレクトリ作成、readdirによる一覧取得、stat/lstatによるファイル情報取得、そしてwatch/watchFileによるファイル監視まで、実践的なコード例を通じて習得できます。

実行環境

項目 バージョン
Node.js 20.x LTS以上
npm 10.x以上
OS Windows/macOS/Linux

前提条件

  • JavaScriptの基礎知識(関数、オブジェクト、async/await)
  • Node.jsの基本操作(スクリプト実行、npm利用)経験
  • fsモジュールの基本的な理解

fsモジュールの読み書き基礎についてはNode.js fsモジュール入門を参照してください。

ディレクトリ操作APIの全体像

Node.jsのディレクトリ操作に関連する主要なAPIを整理します。

主要API一覧

API 用途 備考
mkdir ディレクトリ作成 recursiveオプションで深い階層も一括作成
rmdir 空ディレクトリ削除 非推奨、rmを推奨
rm ファイル・ディレクトリ削除 recursive + forceでrm -rf相当
readdir ディレクトリ一覧取得 withFileTypesでDirent取得可能
opendir Dirオブジェクト取得 大量エントリに効率的
stat ファイル情報取得 シンボリックリンクを辿る
lstat ファイル情報取得 シンボリックリンク自体の情報
watch ファイル監視 OSのファイルシステムイベント利用
watchFile ファイル監視 ポーリングベース

API選択のフローチャート

flowchart TD
    A[ディレクトリ操作したい] --> B{操作の種類は?}
    B -->|作成| C[mkdir]
    B -->|削除| D{空のディレクトリ?}
    D -->|はい| E[rmdir]
    D -->|いいえ| F["rm + recursive: true"]
    B -->|一覧取得| G{エントリ数は?}
    G -->|少ない| H[readdir]
    G -->|多い| I[opendir]
    B -->|情報取得| J{シンボリックリンク?}
    J -->|辿る| K[stat]
    J -->|辿らない| L[lstat]
    B -->|監視| M{精度重視?}
    M -->|効率重視| N[watch]
    M -->|確実性重視| O[watchFile]

mkdirでディレクトリを作成する

mkdirは、新しいディレクトリを作成する関数です。recursiveオプションを使用すると、親ディレクトリも含めて一括で作成できます。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { mkdir } from 'node:fs/promises';

async function createDirectory() {
  try {
    // 単一のディレクトリを作成
    await mkdir('./output');
    console.log('ディレクトリを作成しました');
  } catch (error) {
    if (error.code === 'EEXIST') {
      console.log('ディレクトリは既に存在します');
    } else {
      throw error;
    }
  }
}

await createDirectory();

recursiveオプションで深い階層を作成

recursive: trueを指定すると、親ディレクトリが存在しない場合でも、必要なディレクトリをすべて作成します。これはUnixのmkdir -pコマンドに相当します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';

async function createNestedDirectories() {
  const projectPath = join(process.cwd(), 'project', 'src', 'components');
  
  try {
    // recursive: trueで親ディレクトリも作成
    const createdPath = await mkdir(projectPath, { recursive: true });
    
    if (createdPath) {
      console.log(`作成されたディレクトリ: ${createdPath}`);
    } else {
      console.log('ディレクトリは既に存在していました');
    }
  } catch (error) {
    console.error('ディレクトリ作成エラー:', error.message);
    throw error;
  }
}

await createNestedDirectories();

recursive: trueの場合、戻り値は最初に作成されたディレクトリのパスです。すべてのディレクトリが既に存在する場合はundefinedが返されます。

パーミッションの指定

Unix系OSでは、modeオプションでパーミッションを指定できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { mkdir } from 'node:fs/promises';

async function createDirectoryWithPermission() {
  // 所有者のみ読み書き実行可能なディレクトリを作成
  await mkdir('./secure-data', { 
    mode: 0o700,
    recursive: true 
  });
  
  console.log('制限付きディレクトリを作成しました');
}

await createDirectoryWithPermission();

Windowsではmodeオプションは無視されます。

mkdtempで一時ディレクトリを作成

ユニークな一時ディレクトリが必要な場合は、mkdtempを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, sep } from 'node:path';

async function createTempDirectory() {
  // OSの一時ディレクトリ内にユニークなディレクトリを作成
  const prefix = join(tmpdir(), 'myapp-');
  const tempDir = await mkdtemp(prefix);
  
  console.log(`一時ディレクトリ: ${tempDir}`);
  // 例: /tmp/myapp-abc123 または C:\Users\...\AppData\Local\Temp\myapp-abc123
  
  // 使用後はクリーンアップ
  await rm(tempDir, { recursive: true, force: true });
  console.log('一時ディレクトリを削除しました');
}

await createTempDirectory();

rmdir/rmでディレクトリを削除する

ディレクトリの削除には、rmdirrmの2つの関数があります。現在はrmの使用が推奨されています。

rmdirで空のディレクトリを削除

rmdirは空のディレクトリのみを削除できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { rmdir, mkdir } from 'node:fs/promises';

async function removeEmptyDirectory() {
  // テスト用ディレクトリを作成
  await mkdir('./temp-empty', { recursive: true });
  
  try {
    // 空のディレクトリを削除
    await rmdir('./temp-empty');
    console.log('ディレクトリを削除しました');
  } catch (error) {
    if (error.code === 'ENOTEMPTY') {
      console.error('ディレクトリが空ではありません');
    } else if (error.code === 'ENOENT') {
      console.error('ディレクトリが存在しません');
    } else {
      throw error;
    }
  }
}

await removeEmptyDirectory();

rmでディレクトリとその中身を削除

rmは、Unixのrmコマンドに相当する関数です。recursiveforceオプションを組み合わせることで、rm -rfと同じ動作を実現できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { rm, mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

async function removeDirectoryRecursively() {
  const testDir = './test-delete';
  
  // テスト用のディレクトリ構造を作成
  await mkdir(join(testDir, 'sub', 'deep'), { recursive: true });
  await writeFile(join(testDir, 'file.txt'), 'test content');
  await writeFile(join(testDir, 'sub', 'nested.txt'), 'nested content');
  
  console.log('テスト用ディレクトリを作成しました');
  
  // ディレクトリとその中身をすべて削除
  await rm(testDir, { 
    recursive: true,  // サブディレクトリも削除
    force: true       // 存在しなくてもエラーにしない
  });
  
  console.log('ディレクトリを再帰的に削除しました');
}

await removeDirectoryRecursively();

rmオプションの詳細

オプション デフォルト 説明
force false パスが存在しなくてもエラーにしない
recursive false 再帰的に削除する
maxRetries 0 失敗時のリトライ回数
retryDelay 100 リトライ間隔(ミリ秒)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { rm } from 'node:fs/promises';

async function removeWithRetry() {
  await rm('./busy-directory', {
    recursive: true,
    force: true,
    maxRetries: 3,      // 最大3回リトライ
    retryDelay: 200     // 200ミリ秒間隔
  });
}

await removeWithRetry();

ファイルがロックされている場合など、削除に失敗することがあります。maxRetriesretryDelayを指定することで、リトライ処理を自動化できます。

readdirでディレクトリ一覧を取得する

readdirは、ディレクトリ内のファイルやサブディレクトリの一覧を取得する関数です。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { readdir } from 'node:fs/promises';

async function listDirectory() {
  try {
    // ファイル名の配列を取得
    const files = await readdir('./');
    
    console.log('ディレクトリ内のファイル:');
    files.forEach(file => console.log(`  ${file}`));
    
    return files;
  } catch (error) {
    if (error.code === 'ENOENT') {
      console.error('ディレクトリが存在しません');
    } else if (error.code === 'ENOTDIR') {
      console.error('指定されたパスはディレクトリではありません');
    } else {
      throw error;
    }
  }
}

await listDirectory();

withFileTypesでファイル種別を取得

withFileTypes: trueを指定すると、ファイル名の代わりにDirentオブジェクトの配列が返されます。これにより、追加のstat呼び出しなしにファイル種別を判定できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { readdir } from 'node:fs/promises';

async function listDirectoryWithTypes() {
  // Direntオブジェクトの配列を取得
  const entries = await readdir('./', { withFileTypes: true });
  
  const directories = [];
  const files = [];
  const symlinks = [];
  
  for (const entry of entries) {
    if (entry.isDirectory()) {
      directories.push(entry.name);
    } else if (entry.isFile()) {
      files.push(entry.name);
    } else if (entry.isSymbolicLink()) {
      symlinks.push(entry.name);
    }
  }
  
  console.log('ディレクトリ:', directories);
  console.log('ファイル:', files);
  console.log('シンボリックリンク:', symlinks);
  
  return { directories, files, symlinks };
}

await listDirectoryWithTypes();

Direntオブジェクトのメソッド

メソッド 説明
isFile() 通常のファイルか
isDirectory() ディレクトリか
isSymbolicLink() シンボリックリンクか
isBlockDevice() ブロックデバイスか
isCharacterDevice() キャラクターデバイスか
isFIFO() FIFOパイプか
isSocket() ソケットか

recursiveオプションで再帰的に取得

Node.js 18.17.0以降では、recursive: trueオプションで再帰的にファイル一覧を取得できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { readdir } from 'node:fs/promises';

async function listAllFiles() {
  // 再帰的にすべてのファイル・ディレクトリを取得
  const allEntries = await readdir('./', { 
    recursive: true,
    withFileTypes: true 
  });
  
  console.log(`合計 ${allEntries.length} エントリ`);
  
  // ファイルのみをフィルタ
  const files = allEntries
    .filter(entry => entry.isFile())
    .map(entry => `${entry.parentPath}/${entry.name}`);
  
  console.log('すべてのファイル:');
  files.forEach(file => console.log(`  ${file}`));
  
  return files;
}

await listAllFiles();

opendirで大量エントリを効率的に処理

大量のファイルがあるディレクトリでは、opendirを使用してイテレータとして処理するほうがメモリ効率が良くなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { opendir } from 'node:fs/promises';

async function processLargeDirectory() {
  const dir = await opendir('./large-directory');
  
  let count = 0;
  
  // for-await-ofで順次処理(メモリ効率が良い)
  for await (const entry of dir) {
    count++;
    
    if (entry.isFile()) {
      // ファイルに対する処理
      console.log(`処理中: ${entry.name}`);
    }
    
    // 100件ごとに進捗表示
    if (count % 100 === 0) {
      console.log(`${count} エントリを処理しました`);
    }
  }
  
  console.log(`合計 ${count} エントリを処理しました`);
}

await processLargeDirectory();

stat/lstatでファイル情報を取得する

statlstatは、ファイルやディレクトリの詳細情報を取得する関数です。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { stat } from 'node:fs/promises';

async function getFileInfo(path) {
  try {
    const stats = await stat(path);
    
    console.log('ファイル情報:');
    console.log(`  タイプ: ${stats.isFile() ? 'ファイル' : 'ディレクトリ'}`);
    console.log(`  サイズ: ${stats.size} bytes`);
    console.log(`  作成日時: ${stats.birthtime}`);
    console.log(`  最終更新: ${stats.mtime}`);
    console.log(`  最終アクセス: ${stats.atime}`);
    
    return stats;
  } catch (error) {
    if (error.code === 'ENOENT') {
      console.error('ファイルが存在しません:', path);
    } else {
      throw error;
    }
  }
}

await getFileInfo('./package.json');

Statsオブジェクトのプロパティ

プロパティ 説明
size ファイルサイズ(バイト)
mtime 最終更新日時(Date)
atime 最終アクセス日時(Date)
ctime ステータス変更日時(Date)
birthtime 作成日時(Date)
mode パーミッションビット
uid 所有者のユーザーID
gid 所有者のグループID

statとlstatの違い

statはシンボリックリンクを辿ってリンク先の情報を取得しますが、lstatはシンボリックリンク自体の情報を取得します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { stat, lstat, symlink, unlink, writeFile } from 'node:fs/promises';

async function compareStatAndLstat() {
  // テスト用ファイルとシンボリックリンクを作成
  await writeFile('./original.txt', 'Hello, World!');
  
  try {
    await symlink('./original.txt', './link.txt');
  } catch (error) {
    if (error.code !== 'EEXIST') throw error;
  }
  
  // statはリンク先のファイル情報を取得
  const statResult = await stat('./link.txt');
  console.log('stat結果:');
  console.log(`  isFile: ${statResult.isFile()}`);           // true
  console.log(`  isSymbolicLink: ${statResult.isSymbolicLink()}`); // false
  console.log(`  size: ${statResult.size}`);                  // 13 (ファイル内容のサイズ)
  
  // lstatはシンボリックリンク自体の情報を取得
  const lstatResult = await lstat('./link.txt');
  console.log('lstat結果:');
  console.log(`  isFile: ${lstatResult.isFile()}`);           // false
  console.log(`  isSymbolicLink: ${lstatResult.isSymbolicLink()}`); // true
  console.log(`  size: ${lstatResult.size}`);                  // リンクのサイズ
  
  // クリーンアップ
  await unlink('./link.txt');
  await unlink('./original.txt');
}

await compareStatAndLstat();

ファイルの存在確認

statのエラーハンドリングでファイルの存在を確認できますが、公式には推奨されていません。ファイル操作を直接行い、エラーをハンドリングするほうが確実です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { stat, access, constants } from 'node:fs/promises';

// 非推奨: statでの存在確認
async function existsWithStat(path) {
  try {
    await stat(path);
    return true;
  } catch (error) {
    if (error.code === 'ENOENT') {
      return false;
    }
    throw error;
  }
}

// 推奨: accessでアクセス可能性を確認
async function isAccessible(path) {
  try {
    await access(path, constants.F_OK);
    return true;
  } catch {
    return false;
  }
}

// より推奨: 直接操作してエラーハンドリング
import { readFile } from 'node:fs/promises';

async function readIfExists(path) {
  try {
    const content = await readFile(path, 'utf8');
    return content;
  } catch (error) {
    if (error.code === 'ENOENT') {
      return null; // ファイルが存在しない
    }
    throw error; // その他のエラーは再スロー
  }
}

ディレクトリ統計情報の収集

再帰的にディレクトリを走査し、統計情報を収集する実践的な例を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';

async function getDirectoryStats(dirPath) {
  const stats = {
    totalFiles: 0,
    totalDirectories: 0,
    totalSize: 0,
    fileTypes: {}
  };
  
  async function processDirectory(currentPath) {
    const entries = await readdir(currentPath, { withFileTypes: true });
    
    for (const entry of entries) {
      const fullPath = join(currentPath, entry.name);
      
      if (entry.isDirectory()) {
        stats.totalDirectories++;
        await processDirectory(fullPath);
      } else if (entry.isFile()) {
        stats.totalFiles++;
        
        const fileStats = await stat(fullPath);
        stats.totalSize += fileStats.size;
        
        // 拡張子ごとにカウント
        const ext = entry.name.includes('.') 
          ? entry.name.split('.').pop().toLowerCase()
          : 'no-extension';
        stats.fileTypes[ext] = (stats.fileTypes[ext] || 0) + 1;
      }
    }
  }
  
  await processDirectory(dirPath);
  
  return stats;
}

// 使用例
const projectStats = await getDirectoryStats('./src');
console.log('ディレクトリ統計:');
console.log(`  ファイル数: ${projectStats.totalFiles}`);
console.log(`  ディレクトリ数: ${projectStats.totalDirectories}`);
console.log(`  合計サイズ: ${(projectStats.totalSize / 1024).toFixed(2)} KB`);
console.log('  ファイルタイプ:', projectStats.fileTypes);

watch/watchFileでファイルを監視する

Node.jsには、ファイルやディレクトリの変更を監視する2つの方法があります。

watchとwatchFileの比較

特徴 watch watchFile
検出方式 OSのファイルシステムイベント ポーリング
効率 高い 低い(定期的にstatを実行)
信頼性 プラットフォーム依存 安定している
リソース使用 少ない 多い(CPU使用率が高い)
推奨度 推奨 watchが使えない場合のみ

watchの基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { watch } from 'node:fs/promises';

async function watchDirectory() {
  const ac = new AbortController();
  const { signal } = ac;
  
  // 10秒後に監視を停止
  setTimeout(() => {
    console.log('監視を停止します');
    ac.abort();
  }, 10000);
  
  try {
    const watcher = watch('./watched-folder', { signal });
    
    console.log('ファイル監視を開始しました...');
    
    for await (const event of watcher) {
      console.log(`イベント: ${event.eventType}, ファイル: ${event.filename}`);
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('監視が正常に停止しました');
    } else {
      throw error;
    }
  }
}

await watchDirectory();

recursiveオプションでサブディレクトリも監視

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { watch } from 'node:fs/promises';

async function watchRecursively() {
  const ac = new AbortController();
  const { signal } = ac;
  
  const watcher = watch('./project', { 
    signal,
    recursive: true  // サブディレクトリも監視
  });
  
  console.log('再帰的なファイル監視を開始...');
  
  for await (const event of watcher) {
    const { eventType, filename } = event;
    
    // node_modulesは無視
    if (filename && filename.includes('node_modules')) {
      continue;
    }
    
    console.log(`[${eventType}] ${filename}`);
  }
}

await watchRecursively();

Callback APIでのwatch使用

fs/promisesのwatchはAsyncIteratorを返しますが、従来のCallback APIも使用できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { watch } from 'node:fs';

function watchWithCallback() {
  const watcher = watch('./watched-folder', (eventType, filename) => {
    console.log(`[${eventType}] ${filename}`);
  });
  
  // changeイベントでも監視可能
  watcher.on('change', (eventType, filename) => {
    console.log(`変更検出: ${eventType} - ${filename}`);
  });
  
  watcher.on('error', (error) => {
    console.error('監視エラー:', error);
  });
  
  watcher.on('close', () => {
    console.log('監視を終了しました');
  });
  
  // 10秒後に停止
  setTimeout(() => {
    watcher.close();
  }, 10000);
}

watchWithCallback();

watchFileでポーリング監視

watchFileは、定期的にstatを実行してファイルの変更を検出します。watchが動作しない環境(NFSなど)で使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { watchFile, unwatchFile } from 'node:fs';

function watchFileWithPolling() {
  const filename = './config.json';
  
  watchFile(filename, { interval: 1000 }, (curr, prev) => {
    // mtimeを比較して変更を検出
    if (curr.mtimeMs !== prev.mtimeMs) {
      console.log('ファイルが変更されました');
      console.log(`  前回更新: ${prev.mtime}`);
      console.log(`  今回更新: ${curr.mtime}`);
    }
  });
  
  console.log(`${filename} の監視を開始しました(ポーリング間隔: 1秒)`);
  
  // 監視を停止する場合
  setTimeout(() => {
    unwatchFile(filename);
    console.log('監視を停止しました');
  }, 30000);
}

watchFileWithPolling();

実践的なファイル監視ユーティリティ

開発時に使用できる、実践的なファイル監視ユーティリティを実装します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
import { watch } from 'node:fs/promises';
import { stat } from 'node:fs/promises';
import { join, extname } from 'node:path';

class FileWatcher {
  constructor(options = {}) {
    this.watchPath = options.path || './';
    this.extensions = options.extensions || ['.js', '.ts', '.json'];
    this.ignorePatterns = options.ignore || ['node_modules', '.git', 'dist'];
    this.debounceMs = options.debounce || 100;
    this.onChange = options.onChange || (() => {});
    
    this.controller = new AbortController();
    this.lastEvent = {};
  }
  
  shouldIgnore(filename) {
    if (!filename) return true;
    
    // 無視パターンをチェック
    for (const pattern of this.ignorePatterns) {
      if (filename.includes(pattern)) {
        return true;
      }
    }
    
    // 拡張子をチェック
    const ext = extname(filename).toLowerCase();
    if (this.extensions.length > 0 && !this.extensions.includes(ext)) {
      return true;
    }
    
    return false;
  }
  
  debounce(filename) {
    const now = Date.now();
    const lastTime = this.lastEvent[filename] || 0;
    
    if (now - lastTime < this.debounceMs) {
      return true; // デバウンス中
    }
    
    this.lastEvent[filename] = now;
    return false;
  }
  
  async start() {
    const { signal } = this.controller;
    
    try {
      const watcher = watch(this.watchPath, { 
        signal, 
        recursive: true 
      });
      
      console.log(`ファイル監視を開始: ${this.watchPath}`);
      console.log(`  対象拡張子: ${this.extensions.join(', ')}`);
      console.log(`  無視パターン: ${this.ignorePatterns.join(', ')}`);
      
      for await (const event of watcher) {
        const { eventType, filename } = event;
        
        if (this.shouldIgnore(filename)) continue;
        if (this.debounce(filename)) continue;
        
        console.log(`[${eventType}] ${filename}`);
        
        // コールバックを実行
        await this.onChange({
          type: eventType,
          filename,
          path: join(this.watchPath, filename)
        });
      }
    } catch (error) {
      if (error.name !== 'AbortError') {
        throw error;
      }
    }
  }
  
  stop() {
    this.controller.abort();
    console.log('ファイル監視を停止しました');
  }
}

// 使用例
const watcher = new FileWatcher({
  path: './src',
  extensions: ['.js', '.ts'],
  ignore: ['node_modules', '.git', '__tests__'],
  debounce: 200,
  onChange: async (event) => {
    console.log(`変更を検出: ${event.path}`);
    // ここでビルドやテストを実行
  }
});

await watcher.start();

watchのプラットフォーム依存性

fs.watchはOSのファイルシステムイベントを使用するため、プラットフォームによって動作が異なります。

プラットフォーム別の実装

プラットフォーム 使用技術 備考
Linux inotify 信頼性が高い
macOS kqueue(ファイル)、FSEvents(ディレクトリ) 信頼性が高い
Windows ReadDirectoryChangesW ディレクトリ移動で問題あり

注意点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { watch } from 'node:fs';

function demonstrateCaveats() {
  // 1. ファイル名がnullになることがある
  watch('./dir', (eventType, filename) => {
    if (filename === null) {
      console.log('ファイル名を取得できませんでした');
      return;
    }
    console.log(`${eventType}: ${filename}`);
  });
  
  // 2. 同じ変更で複数のイベントが発生することがある
  // → デバウンス処理が必要
  
  // 3. ネットワークファイルシステム(NFS、SMB)では動作しないことがある
  // → watchFileを使用するか、chokidarなどのライブラリを検討
}

より信頼性の高い監視が必要な場合

本番環境や複雑な監視要件には、chokidarなどのサードパーティライブラリの使用を検討してください。

1
npm install chokidar
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import chokidar from 'chokidar';

// chokidarはクロスプラットフォームで信頼性が高い
const watcher = chokidar.watch('./src', {
  ignored: /node_modules/,
  persistent: true
});

watcher
  .on('add', path => console.log(`追加: ${path}`))
  .on('change', path => console.log(`変更: ${path}`))
  .on('unlink', path => console.log(`削除: ${path}`));

実践的なディレクトリ操作ユーティリティ

これまで学んだAPIを組み合わせて、実践的なユーティリティを実装します。

ディレクトリのコピー

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { readdir, stat, mkdir, copyFile } from 'node:fs/promises';
import { join } from 'node:path';

async function copyDirectory(src, dest) {
  // 送信先ディレクトリを作成
  await mkdir(dest, { recursive: true });
  
  const entries = await readdir(src, { withFileTypes: true });
  
  for (const entry of entries) {
    const srcPath = join(src, entry.name);
    const destPath = join(dest, entry.name);
    
    if (entry.isDirectory()) {
      // 再帰的にコピー
      await copyDirectory(srcPath, destPath);
    } else if (entry.isFile()) {
      await copyFile(srcPath, destPath);
    }
    // シンボリックリンクは必要に応じて処理
  }
}

// 使用例
await copyDirectory('./src', './backup/src');
console.log('ディレクトリをコピーしました');

条件に一致するファイルを検索

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { readdir, stat } from 'node:fs/promises';
import { join, extname } from 'node:path';

async function findFiles(dir, predicate) {
  const results = [];
  
  async function search(currentDir) {
    const entries = await readdir(currentDir, { withFileTypes: true });
    
    for (const entry of entries) {
      const fullPath = join(currentDir, entry.name);
      
      if (entry.isDirectory()) {
        await search(fullPath);
      } else if (entry.isFile()) {
        const stats = await stat(fullPath);
        
        if (await predicate(fullPath, stats, entry)) {
          results.push(fullPath);
        }
      }
    }
  }
  
  await search(dir);
  return results;
}

// 使用例: 1MB以上のJavaScriptファイルを検索
const largeJsFiles = await findFiles('./src', async (path, stats) => {
  return extname(path) === '.js' && stats.size > 1024 * 1024;
});

console.log('1MB以上のJSファイル:', largeJsFiles);

// 使用例: 30日以上更新されていないファイルを検索
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
const oldFiles = await findFiles('./logs', async (path, stats) => {
  return stats.mtimeMs < thirtyDaysAgo;
});

console.log('30日以上更新されていないファイル:', oldFiles);

ディレクトリのクリーンアップ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import { readdir, stat, rm } from 'node:fs/promises';
import { join } from 'node:path';

async function cleanupOldFiles(dir, maxAgeDays) {
  const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
  const now = Date.now();
  let deletedCount = 0;
  let deletedSize = 0;
  
  async function cleanup(currentDir) {
    const entries = await readdir(currentDir, { withFileTypes: true });
    
    for (const entry of entries) {
      const fullPath = join(currentDir, entry.name);
      
      if (entry.isDirectory()) {
        await cleanup(fullPath);
        
        // 空のディレクトリを削除
        const remaining = await readdir(fullPath);
        if (remaining.length === 0) {
          await rm(fullPath, { recursive: true });
        }
      } else if (entry.isFile()) {
        const stats = await stat(fullPath);
        
        if (now - stats.mtimeMs > maxAgeMs) {
          await rm(fullPath);
          deletedCount++;
          deletedSize += stats.size;
        }
      }
    }
  }
  
  await cleanup(dir);
  
  return {
    deletedCount,
    deletedSize,
    deletedSizeFormatted: `${(deletedSize / 1024 / 1024).toFixed(2)} MB`
  };
}

// 使用例: 7日以上古いログファイルを削除
const result = await cleanupOldFiles('./logs', 7);
console.log(`削除されたファイル: ${result.deletedCount}`);
console.log(`削除されたサイズ: ${result.deletedSizeFormatted}`);

まとめ

Node.jsのディレクトリ操作について、以下のポイントを解説しました。

  • mkdirrecursiveオプションで深い階層のディレクトリを一括作成
  • rmrecursiveforceオプションでrm -rf相当の削除が可能
  • readdirwithFileTypesオプションでファイル種別を効率的に取得
  • statlstatの違いはシンボリックリンクの扱い
  • watchはOS依存のファイルシステムイベントを使用し効率的
  • watchFileはポーリングベースで確実だがリソース消費が多い

ディレクトリ操作は、ビルドツール、CLIツール、ログ管理など、多くの場面で必要となります。fs/promisesのAPIを使いこなすことで、堅牢なファイルシステム操作を実装できるようになります。

次のステップとして、Node.jsのpathモジュール完全ガイドでクロスプラットフォームなパス操作を学ぶことで、より堅牢なファイルシステム操作が可能になります。

参考リンク