技術記事に戻る

Node.jsで数千ファイル規模のディレクトリツリーを高速に解析する再帰アルゴリズムの実装

Node.jsJavaScript

Node.jsで数千ファイル規模のディレクトリツリーを高速に解析する再帰アルゴリズムの実装

ソースコード: https://github.com/hayate-hu6/local-media-viewer.git

Node.jsとExpressで構築するローカルメディアビューアー:サーバーサイド実装と認証機能の解説

はじめに

PCや外部ストレージに保存した大量の画像や動画を、スマホやタブレットから手軽に閲覧できるWebアプリを構築で大事なのは、複雑なフォルダ構成から画像や動画のリストを効率よく取得することです。 今回は、その実装方法を解説します。

再帰探索のアルゴリズム

ディレクトリ構造は「木構造」になっているため、これをすべて走査するには「再帰(Recursion)」を使うのが定石です。

// API (Protected)
function scanDir(dir, fileList = [], relPath = '') {
    if (!fs.existsSync(dir)) return fileList;
    
    // 同期処理でディレクトリ内のエントリ一覧を取得
    const files = fs.readdirSync(dir);
    
    files.forEach(file => {
        const fullPath = path.join(dir, file);
        const relativePath = path.join(relPath, file).replace(/\\/g, '/'); // Windowsパスの正規化
        const stat = fs.statSync(fullPath); // ファイル情報の取得
        
        if (stat.isDirectory()) {
            // ディレクトリなら、自分自身(scanDir)を呼び出す(再帰)
            scanDir(fullPath, fileList, relativePath);
        } else {
            // ファイルなら、リストに追加する処理へ
            // ...
        }
    });
    return fileList;
}

この関数のシグネチャ scanDir(dir, fileList = [], relPath = '') に注目してください。

  • dir: 現在スキャンしている絶対パス
  • fileList: 結果を蓄積していく配列(参照渡しされるため、再帰呼び出し先でも同じ配列に追加されます)
  • relPath: クライアントに返すための相対パス(ルートディレクトリからのパス)

fs.isDirectory() が真であれば、そのフォルダの中に入るために scanDir を呼び出します。これがフォルダが無くなるまで繰り返されるため、どんなに深い階層にあっても全てのファイルを見つけることができます。

Windows環境への配慮

const relativePath = path.join(relPath, file).replace(/\\/g, '/');

Windowsではパス区切り文字が \ (バックスラッシュ) ですが、Webブラウザ(URL)では / (スラッシュ) が標準です。ここで replace を使ってパスを正規化している点は、クロスプラットフォーム対応における重要なTipsです。

ファイルのフィルタリングとメタデータ取得

単にファイルをリストアップするだけでなく、画像や動画として扱えるものだけを選別する必要があります。

const SUPPORTED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.mov', '.webm'];

// ...ループ内(elseブロック)...
    // 空ファイル(0バイト)は無視する
    // Rangeリクエストなどでエラーになるのを防ぐため
    if (stat.size === 0) return;

    const ext = path.extname(file).toLowerCase();
    
    if (SUPPORTED_EXTENSIONS.includes(ext)) {
        fileList.push({
            name: file,
            path: relativePath,
            size: stat.size,
            mtime: stat.mtime, // 更新日時 (これを使ってソート可能)
            type: ['.mp4', '.mov', '.webm'].includes(ext) ? 'video' : 'image'
        });
    }

ポイント1: 拡張子フィルタ

path.extname(file) で拡張子を取得し、小文字化してからホワイトリスト形式でチェックしています。これにより、Thumbs.dbや隠しファイルなどが混入するのを防ぎます。

ポイント2: 0バイト対策

if (stat.size === 0) return;

ここが地味ながら重要です。サイズが0の動画ファイルをブラウザで再生しようとすると、HTTPのRangeリクエストが不正になり、サーバーエラー(RangeNotSatisfiableError)を引き起こすことがあります。これを防ぐため、中身のないファイルはリストの段階で除外しています。

ポイント3: type判定

拡張子に基づいて videoimage かのフラグを立てています。これをフロントエンドに渡すことで、<img> タグを作るか <video> タグを作るかを簡単に判断できるようになります。

パフォーマンスについての考察

今回は fs.readdirSync という同期メソッドを使っています。 同期メソッドは処理が終わるまでNode.jsのイベントループをブロックしてしまうため、通常、Webサーバーでは非推奨とされます。

しかし、今回のような「ローカル」かつ「個人利用」のツールでは、以下の理由から同期メソッドが合理的な選択肢となります。

  1. 同時アクセスがない: 使うのは自分一人(または家族数人)なので、ブロッキングの影響がほぼない。
  2. 実装が単純: コールバック地獄やPromiseチェーンがなくなり、コードが読みやすくなる。
  3. 整合性: スキャン中にファイルが移動されるリスクを考えなくて済む(厳密にはOSレベルでは起こりうるが、非同期より制御しやすい)。

もちろん、ファイル数が数十万件になるとレスポンスが遅くなりますが、数千〜数万件程度であればSSD環境なら一瞬で完了します。

まとめ

scanDir 関数は、再帰呼び出しを活用して深い階層までファイルを探索し、必要なメタデータを抽出してJSON化する、このアプリの心臓部です。 データベースを使わずとも、Node.jsの強力なファイルシステムAPIを使えば、これほど短いコードで実用的なファイル管理機能が実装できることがわかります。

次回はいよいよフロントエンド編です。Vanilla JSを使って、取得したデータをどのように効率よく画面に描画するかを解説します。

次回: React不要!標準JavaScriptで実装する軽量SPAの状態管理と高速DOM操作テクニック


同シリーズ記事