MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Node.js 文件系统批量操作的最佳实践

2021-04-224.3k 阅读

1. 理解 Node.js 文件系统模块

在 Node.js 开发中,fs 模块是与文件系统进行交互的核心模块。它提供了一系列用于文件和目录操作的方法,包括读取、写入、创建、删除等。这些操作既可以是同步的,也可以是异步的。

1.1 同步与异步操作的区别

  • 同步操作:同步方法会阻塞 Node.js 事件循环,直到操作完成。这意味着在同步操作执行期间,其他代码无法执行。例如 fs.readFileSync 方法,它会读取文件内容并立即返回结果。如果文件较大,这种阻塞可能会导致应用程序在读取期间无响应。
  • 异步操作:异步方法不会阻塞事件循环,它们会在后台执行操作,并在操作完成时通过回调函数或 Promise 返回结果。例如 fs.readFile 方法,它接受一个回调函数,当文件读取完成后,回调函数会被调用并传入读取结果。这种方式使得 Node.js 可以在等待文件操作完成的同时继续执行其他任务,提高了应用程序的性能和响应性。

1.2 常用的文件系统操作方法

  • 读取文件
    • fs.readFile(path[, options], callback):异步读取文件。path 是文件路径,options 可以指定编码(如 {encoding: 'utf8'}),callback 会接收 (err, data) 两个参数,err 表示错误,data 是读取到的文件内容。
    • fs.readFileSync(path[, options]):同步读取文件,返回文件内容。如果读取失败,会抛出异常。
  • 写入文件
    • fs.writeFile(file, data[, options], callback):异步写入文件。file 是文件路径,data 是要写入的内容,可以是字符串或 buffer,options 可指定写入模式等,callback 用于处理写入完成后的回调。
    • fs.writeFileSync(file, data[, options]):同步写入文件,成功时无返回值,失败时抛出异常。
  • 创建目录
    • fs.mkdir(path[, options], callback):异步创建目录。path 是目录路径,options 可指定权限等,callback 处理创建完成后的回调。
    • fs.mkdirSync(path[, options]):同步创建目录,失败时抛出异常。
  • 删除文件或目录
    • fs.unlink(path, callback):异步删除文件。path 是文件路径,callback 处理删除完成后的回调。
    • fs.unlinkSync(path):同步删除文件,失败时抛出异常。
    • fs.rmdir(path, callback):异步删除目录。path 是目录路径,callback 处理删除完成后的回调。该目录必须为空才能删除。
    • fs.rmdirSync(path):同步删除目录,失败时抛出异常。

2. 批量操作的需求场景

在实际开发中,我们经常会遇到需要对多个文件或目录进行相同操作的情况,这就涉及到批量操作。以下是一些常见的场景:

2.1 项目资源管理

  • 静态资源预处理:在前端项目中,可能需要对大量的图片、CSS 和 JavaScript 文件进行压缩、合并等操作。例如,将多个 CSS 文件合并成一个,以减少 HTTP 请求次数,提高页面加载速度。这就需要批量读取这些文件,进行相应处理后再批量写入新的文件。
  • 资产清理:随着项目的迭代,可能会产生一些无用的文件或目录,如旧的编译产物、临时文件等。需要批量删除这些文件或目录,以清理项目空间,保持项目结构的整洁。

2.2 数据处理与迁移

  • 数据文件转换:假设我们有一个项目,其中数据存储在多个 JSON 文件中,现在需要将这些 JSON 文件转换为 XML 格式。这就需要批量读取 JSON 文件,进行格式转换后,再批量写入 XML 文件。
  • 数据备份与迁移:将数据库导出的多个数据文件从一个目录迁移到另一个目录,或者备份到远程存储,都涉及到批量操作文件的复制或移动。

2.3 自动化构建流程

  • 代码编译:在大型项目中,可能有多个源文件需要编译成目标代码。例如,将多个 TypeScript 文件编译成 JavaScript 文件。这就需要批量读取 TypeScript 文件,调用编译工具进行编译,然后批量写入编译后的 JavaScript 文件。
  • 文档生成:从多个 Markdown 文件生成项目文档。需要批量读取 Markdown 文件,进行解析和格式转换,最终生成 HTML 或 PDF 格式的文档。

3. 简单批量操作实现

3.1 批量读取文件

假设我们有一个目录,里面包含多个文本文件,我们要批量读取这些文件的内容。可以使用 fs.readFile 结合 fs.readdir 来实现。fs.readdir 用于读取目录中的所有文件和子目录。

const fs = require('fs');
const path = require('path');

const directory = './files';

fs.readdir(directory, (err, files) => {
    if (err) {
        console.error('Error reading directory:', err);
        return;
    }

    files.forEach(file => {
        const filePath = path.join(directory, file);
        fs.readFile(filePath, 'utf8', (err, data) => {
            if (err) {
                console.error('Error reading file:', err);
                return;
            }
            console.log(`Content of ${file}:\n${data}`);
        });
    });
});

在上述代码中,首先使用 fs.readdir 读取指定目录 ./files 下的所有文件和子目录。然后遍历这些文件,对于每个文件,使用 path.join 构建完整的文件路径,再使用 fs.readFile 异步读取文件内容并输出。

3.2 批量写入文件

假设我们有一个数组,每个元素是一段文本,我们要将这些文本分别写入到不同的文件中。

const fs = require('fs');
const path = require('path');

const texts = ['This is the first file content.', 'This is the second file content.'];
const directory = './output';

if (!fs.existsSync(directory)) {
    fs.mkdirSync(directory);
}

texts.forEach((text, index) => {
    const filePath = path.join(directory, `file${index + 1}.txt`);
    fs.writeFile(filePath, text, err => {
        if (err) {
            console.error('Error writing file:', err);
            return;
        }
        console.log(`File ${filePath} written successfully.`);
    });
});

在这段代码中,首先检查输出目录 ./output 是否存在,如果不存在则使用 fs.mkdirSync 创建。然后遍历 texts 数组,为每个文本生成一个文件名,使用 fs.writeFile 异步将文本写入文件。

3.3 批量删除文件

假设我们要删除一个目录下的所有文件。

const fs = require('fs');
const path = require('path');

const directory = './filesToDelete';

fs.readdir(directory, (err, files) => {
    if (err) {
        console.error('Error reading directory:', err);
        return;
    }

    files.forEach(file => {
        const filePath = path.join(directory, file);
        fs.unlink(filePath, err => {
            if (err) {
                console.error('Error deleting file:', err);
                return;
            }
            console.log(`File ${filePath} deleted successfully.`);
        });
    });
});

这里通过 fs.readdir 读取目录中的文件,然后对每个文件使用 fs.unlink 异步删除。

4. 优化批量操作

上述简单的批量操作实现存在一些问题,特别是在性能和错误处理方面。

4.1 性能优化 - 并发与队列控制

在前面的批量读取文件示例中,fs.readFile 操作是异步的,但它们几乎是同时发起的。如果文件数量过多,可能会导致系统资源耗尽,例如打开过多文件描述符。

  • 并发控制:可以使用 asyncawait 结合 Promise.all 来控制并发数量。假设我们设置最大并发数为 5。
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');

const readFile = promisify(fs.readFile);
const readdir = promisify(fs.readdir);

const directory = './files';
const maxConcurrent = 5;

async function readFilesInBatch() {
    const files = await readdir(directory);
    const filePromises = files.map(file => {
        const filePath = path.join(directory, file);
        return readFile(filePath, 'utf8');
    });

    const results = [];
    for (let i = 0; i < filePromises.length; i += maxConcurrent) {
        const batchPromises = filePromises.slice(i, i + maxConcurrent);
        const batchResults = await Promise.all(batchPromises);
        results.push(...batchResults);
    }
    return results;
}

readFilesInBatch().then(data => {
    console.log('All files read successfully:', data);
}).catch(err => {
    console.error('Error reading files:', err);
});

在这段代码中,首先使用 promisifyfs.readFilefs.readdir 转换为返回 Promise 的函数。然后通过 Promise.all 分批次处理文件读取,每批次最多 maxConcurrent 个操作,这样可以有效控制并发数量,避免资源耗尽。

  • 队列控制:另一种方式是使用队列来依次处理任务,确保同一时间只有一个任务在执行。可以使用 asyncawait 结合 async-queue 库来实现。
const fs = require('fs');
const path = require('path');
const queue = require('async-queue');

const readFile = (filePath) => {
    return new Promise((resolve, reject) => {
        fs.readFile(filePath, 'utf8', (err, data) => {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }
        });
    });
};

const directory = './files';
const q = queue(async (file, done) => {
    const filePath = path.join(directory, file);
    try {
        const data = await readFile(filePath);
        console.log(`Content of ${file}:\n${data}`);
        done();
    } catch (err) {
        console.error('Error reading file:', err);
        done(err);
    }
}, 1);

fs.readdir(directory, (err, files) => {
    if (err) {
        console.error('Error reading directory:', err);
        return;
    }
    files.forEach(file => {
        q.push(file);
    });
    q.drain(() => {
        console.log('All files processed.');
    });
});

这里使用 async-queue 创建一个队列,每次只处理一个文件读取任务。q.push 将任务添加到队列中,q.drain 事件在所有任务完成时触发。

4.2 错误处理优化

在前面的示例中,对于单个文件操作的错误处理比较简单,只是在回调中打印错误信息。在批量操作中,我们需要更全面的错误处理策略。

  • 全局错误捕获:在使用 Promise.all 进行并发操作时,如果有一个 Promise 被拒绝,Promise.all 会立即被拒绝。可以在外部捕获这个错误。
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');

const readFile = promisify(fs.readFile);
const readdir = promisify(fs.readdir);

const directory = './files';

async function readFiles() {
    const files = await readdir(directory);
    const filePromises = files.map(file => {
        const filePath = path.join(directory, file);
        return readFile(filePath, 'utf8');
    });
    try {
        const results = await Promise.all(filePromises);
        console.log('All files read successfully:', results);
    } catch (err) {
        console.error('Error reading files:', err);
    }
}

readFiles();
  • 错误隔离与继续执行:有时候我们希望在某个文件操作失败时,不影响其他文件的操作继续进行。可以使用 Promise.allSettled
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');

const readFile = promisify(fs.readFile);
const readdir = promisify(fs.readdir);

const directory = './files';

async function readFiles() {
    const files = await readdir(directory);
    const filePromises = files.map(file => {
        const filePath = path.join(directory, file);
        return readFile(filePath, 'utf8');
    });
    const results = await Promise.allSettled(filePromises);
    results.forEach((result, index) => {
        if (result.status === 'fulfilled') {
            console.log(`File ${files[index]} read successfully:`, result.value);
        } else {
            console.error(`Error reading file ${files[index]}:`, result.reason);
        }
    });
}

readFiles();

Promise.allSettled 会等待所有 Promise 都完成(无论是成功还是失败),然后返回一个包含每个 Promise 结果的数组。通过这种方式,我们可以对每个文件操作的结果进行单独处理,即使某个文件操作失败,其他文件操作仍能继续执行。

5. 递归批量操作

在处理文件系统时,经常会遇到需要对目录及其所有子目录中的文件进行操作的情况,这就需要递归操作。

5.1 递归读取目录下所有文件

const fs = require('fs');
const path = require('path');

const directory = './rootDirectory';

function readAllFiles(directory) {
    const files = [];
    function traverse(dir) {
        const items = fs.readdirSync(dir);
        items.forEach(item => {
            const itemPath = path.join(dir, item);
            const stats = fs.statSync(itemPath);
            if (stats.isDirectory()) {
                traverse(itemPath);
            } else {
                files.push(itemPath);
            }
        });
    }
    traverse(directory);
    return files;
}

const allFiles = readAllFiles(directory);
console.log('All files:', allFiles);

在这段代码中,readAllFiles 函数接受一个目录路径作为参数。traverse 函数是一个内部递归函数,它使用 fs.readdirSync 读取目录中的所有项,通过 fs.statSync 判断该项是文件还是目录。如果是目录,则递归调用 traverse;如果是文件,则将其路径添加到 files 数组中。

5.2 递归删除目录及其所有内容

const fs = require('fs');
const path = require('path');

const directory = './directoryToDelete';

function deleteDirectory(directory) {
    if (!fs.existsSync(directory)) {
        return;
    }
    const items = fs.readdirSync(directory);
    items.forEach(item => {
        const itemPath = path.join(directory, item);
        const stats = fs.statSync(itemPath);
        if (stats.isDirectory()) {
            deleteDirectory(itemPath);
        } else {
            fs.unlinkSync(itemPath);
        }
    });
    fs.rmdirSync(directory);
}

deleteDirectory(directory);
console.log('Directory and its contents deleted successfully.');

此代码中,deleteDirectory 函数首先检查目录是否存在。如果存在,则读取目录中的所有项。对于子目录,递归调用 deleteDirectory 进行删除;对于文件,使用 fs.unlinkSync 删除。最后,使用 fs.rmdirSync 删除空目录。

6. 结合流进行批量操作

在处理大量文件或大文件时,使用文件系统流可以显著提高性能,因为流可以逐块处理数据,而不是一次性将整个文件读入内存。

6.1 批量读取文件并通过流处理

假设我们要批量读取多个文本文件,并将它们的内容通过流输出到控制台。

const fs = require('fs');
const path = require('path');

const directory = './files';

fs.readdir(directory, (err, files) => {
    if (err) {
        console.error('Error reading directory:', err);
        return;
    }

    files.forEach(file => {
        const filePath = path.join(directory, file);
        const readStream = fs.createReadStream(filePath, { encoding: 'utf8' });
        readStream.on('data', chunk => {
            process.stdout.write(chunk);
        });
        readStream.on('end', () => {
            console.log(`Finished reading ${file}`);
        });
        readStream.on('error', err => {
            console.error('Error reading file:', err);
        });
    });
});

在这段代码中,为每个文件创建一个可读流 fs.createReadStream。通过监听 data 事件,逐块将文件内容输出到控制台;监听 end 事件,在文件读取完成时打印提示信息;监听 error 事件,处理读取过程中的错误。

6.2 批量写入文件并使用流

假设我们有多个字符串,要将它们分别写入不同的文件,并使用流进行写入。

const fs = require('fs');
const path = require('path');

const texts = ['Text for file1', 'Text for file2'];
const directory = './output';

if (!fs.existsSync(directory)) {
    fs.mkdirSync(directory);
}

texts.forEach((text, index) => {
    const filePath = path.join(directory, `file${index + 1}.txt`);
    const writeStream = fs.createWriteStream(filePath);
    writeStream.write(text);
    writeStream.end();
    writeStream.on('finish', () => {
        console.log(`File ${filePath} written successfully.`);
    });
    writeStream.on('error', err => {
        console.error('Error writing file:', err);
    });
});

这里为每个文本创建一个可写流 fs.createWriteStream,使用 write 方法写入文本内容,然后调用 end 方法结束写入。通过监听 finish 事件,在写入完成时打印提示信息;监听 error 事件,处理写入过程中的错误。

7. 与第三方库结合进行批量操作

除了原生的 fs 模块,还有一些第三方库可以简化文件系统的批量操作,并且提供更丰富的功能。

7.1 使用 fs-extra

fs-extra 是一个扩展 Node.js fs 模块的库,它提供了更多的方法,并且对错误处理更友好。

  • 批量复制目录:假设我们要将一个目录及其所有内容复制到另一个目录。
const fs = require('fs-extra');

const sourceDir = './source';
const targetDir = './target';

fs.copy(sourceDir, targetDir)
  .then(() => {
        console.log('Directory copied successfully.');
    })
  .catch(err => {
        console.error('Error copying directory:', err);
    });

fs-extracopy 方法可以递归复制目录及其所有内容,并且返回一个 Promise,使得错误处理更加方便。

  • 批量删除目录及其所有内容
const fs = require('fs-extra');

const directory = './directoryToDelete';

fs.remove(directory)
  .then(() => {
        console.log('Directory and its contents deleted successfully.');
    })
  .catch(err => {
        console.error('Error deleting directory:', err);
    });

fs.remove 方法可以递归删除目录及其所有内容,同样返回 Promise 进行错误处理。

7.2 使用 globby

globby 是一个用于匹配文件路径的库,它支持通配符模式,非常适合在批量操作中筛选文件。

假设我们要读取项目中所有的 JavaScript 文件(.js)并打印它们的内容。

const fs = require('fs');
const { promisify } = require('util');
const globby = require('globby');

const readFile = promisify(fs.readFile);

globby('**/*.js')
  .then(files => {
        return Promise.all(files.map(file => readFile(file, 'utf8')));
    })
  .then(data => {
        data.forEach((content, index) => {
            console.log(`Content of ${files[index]}:\n${content}`);
        });
    })
  .catch(err => {
        console.error('Error reading files:', err);
    });

在这段代码中,globby('**/*.js') 使用通配符模式匹配项目中所有的 JavaScript 文件路径。然后通过 Promise.allfs.readFile 批量读取这些文件的内容并打印。

通过结合这些第三方库,可以在 Node.js 文件系统批量操作中提高开发效率,并且获得更好的功能和错误处理能力。在实际项目中,应根据具体需求选择合适的库和方法来实现高效、稳定的文件系统批量操作。