Node.js 的 fs 是内置文件系统模块,负责文件读写和目录操作。由于 JavaScript 是单线程语言,文件 IO 的写法会直接影响主线程阻塞情况与代码维护成本。原文围绕同一个读取需求,给出了同步阻塞、回调异步、Promise 链式调用和 async/await 四种方案,并梳理了从回调地狱到线性书写的演进路线。下面按代码实现、参数含义、错误处理和适用场景重新整理。
一、模块导入方式
基础 fs 模块同时提供同步 API 和回调风格的异步 API:
fs/promises 专门提供 Promise 风格 API,更适合配合 async/await 使用,也是原文推荐的生产环境写法:
- import fs from 'fs/promises';
复制代码
两种导入方式对应不同的调用风格。基础 fs 适合沿用错误优先回调,fs/promises 则让 readFile 直接返回 Promise。
二、方案 1:readFileSync 同步读取
readFileSync 的代码顺序执行,读取期间会阻塞整个线程。它的优点是写法极简,缺点是面对接口请求或循环中的大量 IO 时会严重降低服务性能。原文明确建议,只把它用于项目启动时一次性读取配置文件。
- import fs from 'fs';
- const syncData = fs.readFileSync('./test.txt', 'utf-8');
- console.log(syncData);
复制代码
这里的第二个参数 'utf-8' 指定编码,返回内容直接是字符串。由于是同步调用,不需要回调,也不需要 await,但代价是主线程被占用。
三、方案 2:readFile 回调异步
readFile 采用 Node 统一的错误优先回调规范,回调形式为 (err, data) => {}。读取成功时 err 为 null,data 是文件内容;读取失败时 err 为错误对象,可能对应文件不存在、权限不足等情况。
- import fs from 'fs';
- fs.readFile('./file1.txt', 'utf-8', (err, data) => {
- if (!err) {
- console.log('file1', data);
- } else {
- console.log(err);
- }
- fs.readFile('./file2.txt', 'utf-8', (err, data) => {
- if (!err) {
- console.log('file2', data);
- } else {
- console.log(err);
- }
- fs.readFile('./file3.txt', 'utf-8', (err, data) => {
- if (!err) {
- console.log('file3', data);
- } else {
- console.log(err);
- }
- })
- })
- })
复制代码
回调方式不会阻塞线程,也不需要额外导入 Promise 风格模块。但串行读取多个文件时会形成多层嵌套,也就是常说的回调地狱,后续维护和错误处理成本都很高。
四、方案 3:Promise then 链式调用
改用 fs/promises 后,readFile 返回 Promise,可以通过 .then() 链式串行执行,从而消除多层回调嵌套。原文示例在每一步返回下一个 readFile 调用,让后续 then 接收新的文件内容。
- import fs from 'fs/promises';
- fs.readFile('./file1.txt', 'utf-8')
- .then(data => {
- console.log('file1', data);
- return fs.readFile('./file2.txt', 'utf-8');
- })
- .then(data => {
- console.log('file2', data);
- return fs.readFile('./file3.txt', 'utf-8');
- })
- .then(data => {
- console.log('file3', data);
- })
复制代码
这种写法已经解决了嵌套问题,但在多文件场景下仍要重复书写 then,代码相对冗余。
五、方案 4:async/await 生产首选
async/await 是 Promise 的语法糖,底层依旧异步,不会阻塞主线程。它的优势是代码可以像同步逻辑一样自上而下书写,可读性最强。需要注意的是,await 只能写在 async 函数内部,顶层直接使用 await 会报错,因此原文用立即执行函数包裹:
- import fs from 'fs/promises';
- (async () => {
- const file1Data = await fs.readFile('./file1.txt', 'utf-8');
- console.log('file1', file1Data);
- const file2Data = await fs.readFile('./file2.txt', 'utf-8');
- console.log('file2', file2Data);
- const file3Data = await fs.readFile('./file3.txt', 'utf-8');
- console.log('file3', file3Data);
- })();
复制代码
实际工程中还应增加 try/catch 捕获读取错误。原文给出的完整健壮写法如下:
- import fs from 'fs/promises';
- (async () => {
- try {
- const data = await fs.readFile('./test.txt', 'utf-8');
- console.log(data);
- } catch (err) {
- console.error('文件读取失败:', err);
- }
- })();
复制代码
原文还补充了一个异步执行顺序细节:Promise、await 的后续执行属于微任务,setTimeout 属于宏任务。理解这一点,有助于排查文件读取与其他定时逻辑混用时的输出顺序问题。
六、四种方式对比
readFileSync:阻塞线程,写法极简,但大量 IO 会严重影响服务性能。
readFile 回调:非阻塞,无需额外导入,但串行读取多文件会产生回调地狱。
Promise then:非阻塞,无嵌套结构,但多文件链式调用时代码重复繁琐。
async/await:非阻塞,线性阅读、代码整洁,属于 ES8 语法,需要 async 函数包裹。
从演进路线看,就是同步阻塞、回调函数、Promise then 链式、async/await 逐步过渡的过程,目标是在不阻塞主线程的前提下降低嵌套和维护成本。
七、工程开发最佳实践
1. 业务文件读写统一使用 fs/promises 配合 async/await,避免回调嵌套。
2. 所有文件读取都必须搭配 try/catch 捕获异常,不能只处理成功分支。
3. readFileSync 仅用于项目初始化加载配置,接口业务中禁止使用,防止阻塞主线程。
4. 路径搭配 path.resolve 生成绝对路径,防止运行目录变更导致文件找不到。
整体来看,fs 模块的四种读取方案并不是简单的 API 替换,而是围绕单线程模型做出的异步编程选择。理解每种写法是否阻塞、错误如何传递、串行代码如何组织,才能在实际项目中选对方案并减少调试成本。 |