从零到一掌握 Node.js 文件系统操作,小白也能看懂的实战教程
📌 核心原则
在开始之前,请务必记住这四条黄金法则:
1. 异步优先:优先使用 fs.promises(不会阻塞程序运行)
2. 路径安全:使用 path.* 组合路径,避免字符串拼接
3. 原子写入:关键数据采用 "临时文件 + rename" 模式(下文会解释)
4. 安全校验:所有用户输入的路径必须做安全检查
💡 术语小课堂
在正式开始前,先了解几个常见术语:
🔹 什么是 API 签名(函数签名)?
就是函数的"使用说明书",告诉你:
例如:readFile(path, options) → Promise<string>
意思是:调用 readFile 函数,传入路径和选项,会返回一个 Promise,最终得到字符串内容
🔹 什么是原子写入?
简单理解:要么全部成功,要么全部失败,不会出现"写了一半"的情况。
举例:你正在保存一个重要配置文件,突然断电了
- ❌ 普通写入:文件可能只写了一半,变成乱码
- ✅ 原子写入:要么保存成功,要么还是原来的文件,绝不会损坏
实现方法:先写到临时文件,成功后再一次性替换原文件(rename 操作是原子性的)
🔹 什么是目录穿越攻击?
黑客通过特殊路径(如 ../../etc/passwd)来访问不该访问的文件。
举例:你的网站允许用户下载 /uploads 目录下的文件
- 用户正常请求:
/uploads/avatar.png ✅
- 黑客恶意请求:
/uploads/../../etc/passwd ❌ (试图访问系统密码文件)
防御方法:检查最终路径是否真的在允许的目录内
🔹 异步 vs 同步?
-
异步:不等待任务完成,程序继续往下执行(推荐)
-
同步:必须等待任务完成才能继续(会阻塞程序,不推荐)
举例:读取一个 100MB 的文件
- 异步:发起读取请求后,程序可以继续处理其他事情(如响应用户点击)
- 同步:程序卡住,等文件读完才能动(用户会觉得卡顿)
一、fs 模块 - 文件系统操作
1.1 API 基础用法
在动手实践前,先快速浏览一下 fs 模块提供了哪些工具:
📖 文件读写类
readFile(path, options) → 返回文件内容
// 读取文本文件
const content = await fs.readFile('config.json', 'utf8');
- 参数:文件路径 + 编码方式(如
'utf8')
- 返回:文件内容(字符串或二进制数据)
-
适用:小文件(< 10MB)
writeFile(path, data, options) → 写入文件
// 写入文本文件
await fs.writeFile('log.txt', '日志内容', 'utf8');
- 参数:文件路径 + 要写入的内容 + 编码方式
- 说明:文件不存在会自动创建,存在则覆盖
-
适用:小文件
appendFile(path, data, options) → 追加内容到文件末尾
// 在文件末尾追加内容(不覆盖原内容)
await fs.appendFile('log.txt', '新的一行日志\n', 'utf8');
📁 目录操作类
mkdir(path, options) → 创建目录
// 创建多级目录(父目录不存在也会自动创建)
await fs.mkdir('data/cache/temp', { recursive: true });
- 重要参数:
recursive: true 可创建多级目录
- 说明:目录已存在不会报错
readdir(path, options) → 读取目录内容
// 获取目录下的所有文件和子目录
const files = await fs.readdir('data'); // 返回文件名数组
// 获取详细信息(包括文件类型)
const entries = await fs.readdir('data', { withFileTypes: true });
for (const entry of entries) {
console.log(entry.name, entry.isFile() ? '文件' : '目录');
}
🔍 信息查询类
stat(path) → 获取文件详细信息
const info = await fs.stat('file.txt');
console.log(info.size); // 文件大小(字节)
console.log(info.isFile()); // 是否为文件
console.log(info.mtime); // 最后修改时间
access(path, mode) → 检查文件是否存在/可读写
import { constants } from 'node:fs';
// 检查文件是否可读写
await fs.access('file.txt', constants.R_OK | constants.W_OK);
🛠️ 文件操作类
copyFile(src, dest) → 拷贝文件
await fs.copyFile('source.txt', 'backup.txt');
rename(oldPath, newPath) → 重命名或移动文件
await fs.rename('old.txt', 'new.txt'); // 重命名
await fs.rename('temp/file.txt', 'data/file.txt'); // 移动
rm(path, options) → 删除文件或目录
// 删除文件(不存在也不报错)
await fs.rm('file.txt', { force: true });
// 删除整个目录(小心使用!)
await fs.rm('temp', { recursive: true, force: true });
-
⚠️ 危险操作:
recursive: true 会删除整个目录树
unlink(path) → 删除文件
await fs.unlink('file.txt'); // 只能删除文件,不能删除目录
🌊 流式操作类(处理大文件)
createReadStream(path) → 创建读取流
const stream = fs.createReadStream('video.mp4');
// 逐块读取,不会一次性加载到内存
createWriteStream(path) → 创建写入流
const stream = fs.createWriteStream('output.txt');
stream.write('内容');
pipeline(...streams) → 连接多个流(最佳实践)
import { pipeline } from 'node:stream/promises';
// 复制大文件
await pipeline(
fs.createReadStream('large.dat'),
fs.createWriteStream('large.dat.backup')
);
1.2 异步 vs 同步方法
Node.js 为每个文件操作都提供了两种版本:
🟢 异步方法(推荐)
使用 fs.promises 或回调函数,不会阻塞程序:
import { promises as fs } from 'node:fs';
// ✅ 推荐:使用 Promise 风格(现代写法)
const content = await fs.readFile('file.txt', 'utf8');
console.log('读取完成');
console.log('这行代码会等上面读取完成后执行');
// 在等待读取期间,程序可以处理其他任务(如响应用户操作)
优点:
- 不阻塞程序,性能好
- 适合服务器环境(可同时处理多个请求)
🔴 同步方法(谨慎使用)
方法名以 Sync 结尾,会阻塞程序:
import fs from 'node:fs';
// ❌ 不推荐:同步方法(会卡住程序)
const content = fs.readFileSync('file.txt', 'utf8');
console.log('读取完成');
// 在读取文件期间,程序完全卡住,无法做其他事情
缺点:
- 会阻塞事件循环,程序卡住
- 在服务器环境中会严重影响性能
何时可以用?
- 程序启动时读取配置文件(只执行一次)
- 命令行工具的简单脚本(单线程,无并发)
示例:程序启动时读取配置
import fs from 'node:fs';
// 程序启动时,可以使用同步读取
const config = JSON.parse(
fs.readFileSync('./config.json', 'utf8')
);
// 但在请求处理函数中,必须用异步
app.get('/data', async (req, res) => {
const data = await fs.promises.readFile('data.json', 'utf8'); // ✅
res.send(data);
});
1.3 实战场景详解
掌握了基础 API 后,我们来看看在实际项目中如何应用。
场景 1:读取与写入配置文件
💡 场景描述
- 程序启动时读取
config.json 配置
- 用户修改设置后保存到配置文件
- 写日志到
app.log 文件
import { promises as fs } from 'node:fs';
// 读取配置文件
async function loadConfig() {
const text = await fs.readFile('config.json', 'utf8');
return JSON.parse(text);
}
// 保存配置
async function saveConfig(config) {
const text = JSON.stringify(config, null, 2); // 格式化为易读的 JSON
await fs.writeFile('config.json', text, 'utf8');
}
// 记录日志(追加方式)
async function log(message) {
const timestamp = new Date().toISOString();
await fs.appendFile('app.log', `[${timestamp}] ${message}\n`, 'utf8');
}
// 使用示例
const config = await loadConfig();
config.theme = 'dark';
await saveConfig(config);
await log('配置已更新');
⚠️ 注意事项
- 小文件(< 10MB)用
readFile/writeFile
- 大文件会导致内存溢出(OOM),必须用流式处理(见后文)
- 指定
'utf8' 编码会返回字符串,否则返回二进制 Buffer
场景 2:遍历目录并分类文件
💡 场景描述
- 扫描
uploads 目录下的所有文件
- 根据类型(图片、文档、其他)分别统计
import { promises as fs } from 'node:fs';
import path from 'node:path';
async function classifyFiles(dirPath) {
const stats = {
images: [],
documents: [],
others: []
};
// withFileTypes: true 返回详细信息,性能更好
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
continue; // 跳过子目录
}
const ext = path.extname(entry.name).toLowerCase();
const fullPath = path.join(dirPath, entry.name);
if (['.jpg', '.png', '.gif'].includes(ext)) {
stats.images.push(fullPath);
} else if (['.pdf', '.docx', '.txt'].includes(ext)) {
stats.documents.push(fullPath);
} else {
stats.others.push(fullPath);
}
}
return stats;
}
// 使用示例
const result = await classifyFiles('uploads');
console.log(`找到 ${result.images.length} 张图片`);
console.log(`找到 ${result.documents.length} 个文档`);
💡 小技巧
-
withFileTypes: true 比先 readdir 再 stat 性能高很多
-
path.extname() 可以获取文件扩展名
场景 3:递归创建目录
💡 场景描述
- 脚手架工具初始化项目时创建目录结构
- 确保日志目录存在后再写入日志
import { promises as fs } from 'node:fs';
// 创建多级目录
async function ensureDir(dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
// 使用示例
await ensureDir('project/src/components/Button');
await ensureDir('logs/2024/01');
// 现在可以放心写入文件了
await fs.writeFile('logs/2024/01/app.log', '日志内容');
⚠️ 重点
-
recursive: true 会自动创建父目录
- 目录已存在不会报错
场景 4:备份与日志轮转
💡 场景描述
- 每天零点将今天的日志文件重命名为
app-2024-01-05.log
- 拷贝重要文件做备份
import { promises as fs } from 'node:fs';
// 日志轮转:重命名今天的日志
async function rotateLog() {
const today = new Date().toISOString().split('T')[0]; // '2024-01-05'
await fs.rename('app.log', `app-${today}.log`);
// 创建新的空日志文件
await fs.writeFile('app.log', '');
}
// 备份文件
async function backupFile(source) {
const backup = `${source}.backup`;
await fs.copyFile(source, backup);
console.log(`已备份到: ${backup}`);
}
// 使用示例
await rotateLog();
await backupFile('config.json');
场景 5:安全的原子写入(防止文件损坏)
💡 场景描述
- 保存重要配置文件时,防止写到一半程序崩溃导致文件损坏
🔹 为什么需要原子写入?
普通写入的问题:
// ❌ 危险:如果写到一半程序崩溃,文件会损坏
await fs.writeFile('config.json', largeContent);
// 假设写到这里突然断电 → config.json 只写了一半,变成乱码!
原子写入的解决方案:
import { promises as fs } from 'node:fs';
import path from 'node:path';
/**
* 原子写入:先写临时文件,成功后再替换
*/
async function atomicWrite(filePath, content) {
// 1. 确保目录存在
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
// 2. 先写到临时文件(加时间戳避免冲突)
const tmpFile = `${filePath}.tmp-${Date.now()}`;
await fs.writeFile(tmpFile, content);
// 3. 原子替换(rename 是原子操作)
await fs.rename(tmpFile, filePath);
// 这一步要么成功,要么失败,不会出现"替换了一半"的情况
}
// 使用示例
const config = { version: '2.0', theme: 'dark' };
await atomicWrite('config.json', JSON.stringify(config, null, 2));
✅ 原子写入的好处
- 写入过程中即使断电/崩溃,原文件不会损坏
- 要么看到旧版本,要么看到新版本,绝不会是"半截"内容
⚠️ 何时必须用
场景 6:流式处理大文件
💡 场景描述
- 复制 1GB 的视频文件
- 逐行读取 500MB 的日志文件并分析
🔹 为什么需要流式处理?
普通方式的问题:
// ❌ 危险:读取 1GB 文件会占用 1GB 内存,导致程序崩溃
const content = await fs.readFile('video.mp4'); // OOM!
await fs.writeFile('video.mp4.backup', content);
流式处理的解决方案:
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
// ✅ 正确:逐块读写,内存占用极低(只有几 MB)
async function copyLargeFile(source, dest) {
await pipeline(
createReadStream(source),
createWriteStream(dest)
);
}
// 带进度显示
async function copyWithProgress(source, dest) {
let processedBytes = 0;
const readStream = createReadStream(source);
readStream.on('data', (chunk) => {
processedBytes += chunk.length;
const mb = (processedBytes / 1024 / 1024).toFixed(2);
console.log(`已处理: ${mb} MB`);
});
await pipeline(readStream, createWriteStream(dest));
console.log('✅ 复制完成');
}
// 使用示例
await copyLargeFile('video.mp4', 'backup.mp4');
⚠️ 关键规则
- 文件 > 10MB 必须使用流
- 必须使用
pipeline(自动处理错误和背压)
- 禁止手写
readStream.on('data') + writeStream.write()
场景 7:批量处理文件(并发控制)
💡 场景描述
🔹 为什么需要并发控制?
// ❌ 危险:同时打开 1000 个文件会导致资源耗尽
const files = ['img1.jpg', 'img2.jpg', /* ...1000 个 */];
await Promise.all(files.map(file => compressImage(file))); // 崩溃!
限制并发数的解决方案:
/**
* 并发池:限制同时运行的任务数
*/
async function concurrentPool(items, limit, worker) {
const queue = [...items];
let running = 0;
let index = 0;
return new Promise((resolve) => {
function runNext() {
if (index >= items.length && running === 0) {
resolve();
return;
}
while (running < limit && index < items.length) {
const item = items[index++];
running++;
worker(item).finally(() => {
running--;
runNext();
});
}
}
runNext();
});
}
// 使用示例:限制并发为 10
const images = ['img1.jpg', 'img2.jpg', /* ...1000 个 */];
await concurrentPool(images, 10, async (file) => {
await compressImage(file);
console.log(`已处理: ${file}`);
});
💡 推荐并发数
- CPU 密集任务(图片压缩):CPU 核心数(如 4 或 8)
- IO 密集任务(文件复制):10-50
场景 8:错误处理最佳实践
💡 场景描述
import { promises as fs } from 'node:fs';
async function safeReadFile(filePath) {
try {
return await fs.readFile(filePath, 'utf8');
} catch (err) {
// 根据错误码提供友好提示
switch (err.code) {
case 'ENOENT':
console.error(`❌ 文件不存在: ${filePath}`);
console.error('请检查文件路径是否正确');
break;
case 'EACCES':
case 'EPERM':
console.error(`❌ 权限不足: ${filePath}`);
console.error('请检查文件权限或以管理员身份运行');
break;
case 'EISDIR':
console.error(`❌ 这是一个目录,不是文件: ${filePath}`);
break;
default:
console.error(`❌ 未知错误: ${err.message}`);
console.error(err.stack); // 开发环境保留堆栈
}
throw err;
}
}
// 使用示例
try {
const content = await safeReadFile('config.json');
console.log(content);
} catch {
console.log('读取失败,使用默认配置');
}
常见错误码
-
ENOENT:文件/目录不存在
-
EACCES / EPERM:权限不足
-
EEXIST:文件已存在
-
EISDIR:路径是目录,不是文件
-
ENOTDIR:路径是文件,不是目录
二、path 模块 - 路径处理
2.1 为什么需要 path 模块?
❌ 错误做法:字符串拼接
const filePath = root + '/' + folder + '/' + filename; // 在 Windows 上会出错!
✅ 正确做法:使用 path 模块
import path from 'node:path';
const filePath = path.join(root, folder, filename); // 跨平台兼容
原因:
- Windows 用
\(反斜杠):C:\Users\name\file.txt
- macOS/Linux 用
/(正斜杠):/home/name/file.txt
-
path 模块会自动处理这些差异
2.2 核心 API 详解
path.join() - 拼接路径
作用:将多个路径片段拼接成一个路径
import path from 'node:path';
const projectRoot = '/Users/me/project';
const logDir = path.join(projectRoot, 'logs');
// 结果: '/Users/me/project/logs'
const logFile = path.join(logDir, '2024', 'app.log');
// 结果: '/Users/me/project/logs/2024/app.log'
// 自动处理多余的斜杠
path.join('a', 'b'); // 'a/b'
path.join('a/', '/b'); // 'a/b'(自动清理)
path.join('a', '.', 'b'); // 'a/b'(处理 .)
path.resolve() - 解析为绝对路径
作用:将相对路径转为绝对路径
import path from 'node:path';
// 假设当前目录是 /Users/me/project
// 相对路径 → 绝对路径
const abs = path.resolve('logs', 'app.log');
// 结果: '/Users/me/project/logs/app.log'
// 如果遇到绝对路径,会从那里重新开始
const abs2 = path.resolve('a', '/b', 'c');
// 结果: '/b/c'(从 /b 重新开始)
💡 推荐用法
// 项目中定位文件的最佳实践
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM 模块中获取当前文件所在目录
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// 定位项目根目录的配置文件
const configPath = path.resolve(__dirname, '../config/app.json');
path.dirname() / path.basename() / path.extname() - 拆分路径
作用:提取路径的各个部分
import path from 'node:path';
const filePath = '/Users/me/project/src/app.js';
path.dirname(filePath); // '/Users/me/project/src'(目录部分)
path.basename(filePath); // 'app.js'(文件名部分)
path.extname(filePath); // '.js'(扩展名部分)
// basename 可以去掉扩展名
path.basename(filePath, '.js'); // 'app'
💡 实用案例:根据文件类型分类
import path from 'node:path';
function getFileType(filename) {
const ext = path.extname(filename).toLowerCase();
if (['.jpg', '.png', '.gif'].includes(ext)) return '图片';
if (['.mp4', '.avi'].includes(ext)) return '视频';
if (['.pdf', '.docx'].includes(ext)) return '文档';
return '其他';
}
console.log(getFileType('avatar.png')); // '图片'
console.log(getFileType('video.mp4')); // '视频'
path.parse() / path.format() - 结构化处理
作用:将路径拆解为对象,或从对象组装路径
import path from 'node:path';
// 拆解路径
const parsed = path.parse('/Users/me/project/app.js');
console.log(parsed);
// {
// root: '/',
// dir: '/Users/me/project',
// base: 'app.js',
// name: 'app',
// ext: '.js'
// }
// 修改后重新组装
const newPath = path.format({
...parsed,
name: 'server', // 改文件名
ext: '.ts' // 改扩展名
});
console.log(newPath); // '/Users/me/project/server.ts'
💡 实用案例:批量重命名
import path from 'node:path';
import { promises as fs } from 'node:fs';
// 将所有 .jpg 改为 .png
async function renameExtension(dir, oldExt, newExt) {
const files = await fs.readdir(dir);
for (const file of files) {
if (path.extname(file) === oldExt) {
const parsed = path.parse(file);
const newName = path.format({ ...parsed, ext: newExt });
await fs.rename(
path.join(dir, file),
path.join(dir, newName)
);
console.log(`${file} → ${newName}`);
}
}
}
await renameExtension('images', '.jpg', '.png');
path.normalize() - 清理路径
作用:清理路径中的多余部分
import path from 'node:path';
path.normalize('/a/b/c/../d'); // '/a/b/d'(处理 ..)
path.normalize('/a//b///c'); // '/a/b/c'(去掉多余斜杠)
path.normalize('./src/../dist'); // 'dist'
⚠️ 注意:normalize 只是字符串处理,不做安全检查!
path.relative() - 计算相对路径
作用:计算从一个路径到另一个路径的相对路径
import path from 'node:path';
const from = '/Users/me/project/src';
const to = '/Users/me/project/dist/index.js';
const rel = path.relative(from, to);
// 结果: '../dist/index.js'
💡 实用案例:生成 import 语句
import path from 'node:path';
function generateImport(fromFile, toFile) {
const fromDir = path.dirname(fromFile);
const rel = path.relative(fromDir, toFile);
return `import something from './${rel}';`;
}
const statement = generateImport(
'/project/src/app.js',
'/project/src/utils/helper.js'
);
console.log(statement);
// import something from './utils/helper.js';
2.3 安全拼接 - 防止目录穿越攻击
🔹 什么是目录穿越攻击?(复习)
黑客通过 ../../ 来访问不该访问的文件:
// 你的网站允许下载 /uploads 目录下的文件
const uploadDir = '/var/www/uploads';
const userInput = '../../etc/passwd'; // 黑客输入
// ❌ 危险:直接拼接会被攻击
const filePath = path.join(uploadDir, userInput);
// 结果: '/var/etc/passwd'(逃出了 uploads 目录!)
await fs.readFile(filePath); // 黑客成功读取系统密码文件!
✅ 安全解决方案:检查最终路径
import path from 'node:path';
/**
* 安全拼接:确保最终路径不会逃出根目录
*/
function safeJoin(rootDir, userInput) {
// 1. 拼接路径
const targetPath = path.resolve(rootDir, userInput);
// 2. 规范化根目录(加上斜杠)
const normalizedRoot = path.resolve(rootDir) + path.sep;
// 3. 检查目标路径是否以根目录开头
if (!targetPath.startsWith(normalizedRoot)) {
throw new Error('⚠️ 检测到目录穿越攻击,拒绝访问!');
}
return targetPath;
}
// 使用示例
const uploadDir = '/var/www/uploads';
try {
// ✅ 正常访问
const safe1 = safeJoin(uploadDir, 'avatar.png');
console.log(safe1); // '/var/www/uploads/avatar.png'
// ❌ 攻击被拦截
const safe2 = safeJoin(uploadDir, '../../etc/passwd');
} catch (err) {
console.error(err.message); // '⚠️ 检测到目录穿越攻击'
}
⚠️ 重要规则
- 所有用户输入的路径必须使用
safeJoin 检查
- 包括:文件上传、下载、静态文件服务等
- 这是防止路径穿越的最核心方法
2.4 URL 与路径互转(ESM 模块必备)
💡 问题背景
在 type: module 的 ESM 项目中,__dirname 和 __filename 不可用:
// ❌ ESM 模块中会报错
console.log(__dirname); // ReferenceError: __dirname is not defined
✅ 解决方案
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// 将当前模块的 URL 转为文件路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
console.log(__dirname); // '/Users/me/project/src'
// 现在可以定位项目文件了
const configPath = path.resolve(__dirname, '../config/app.json');
三、速查清单
fs 模块常用 API
| 功能 |
推荐 API |
说明 |
| 读小文件 |
readFile(path, 'utf8') |
< 10MB |
| 写小文件 |
writeFile(path, data) |
覆盖写 |
| 追加内容 |
appendFile(path, data) |
日志场景 |
| 读大文件 |
createReadStream(path) + pipeline
|
> 10MB |
| 写大文件 |
createWriteStream(path) + pipeline
|
> 10MB |
| 创建目录 |
mkdir(path, { recursive: true }) |
多级目录 |
| 读取目录 |
readdir(path, { withFileTypes: true }) |
获取类型 |
| 复制文件 |
copyFile(src, dest) |
|
| 移动/重命名 |
rename(old, new) |
|
| 删除文件 |
rm(path, { force: true }) |
|
| 删除目录 |
rm(path, { recursive: true }) |
危险操作 |
| 获取信息 |
stat(path) |
大小、时间等 |
| 检查权限 |
access(path, constants.R_OK) |
|
path 模块常用 API
| 功能 |
推荐 API |
示例 |
| 拼接路径 |
path.join(a, b, c) |
'a/b/c' |
| 绝对路径 |
path.resolve(a, b) |
/abs/path/a/b |
| 提取目录 |
path.dirname(p) |
'/a/b' |
| 提取文件名 |
path.basename(p) |
'file.txt' |
| 提取扩展名 |
path.extname(p) |
'.txt' |
| 计算相对路径 |
path.relative(from, to) |
'../other' |
| 拆解路径 |
path.parse(p) |
返回对象 |
| 组装路径 |
path.format(obj) |
返回字符串 |
四、最佳实践总结
✅ 推荐做法
-
异步优先:使用
fs.promises,避免同步方法(除了程序启动时)
-
路径安全:用
path.join/resolve 拼接,禁止字符串拼接
-
原子写入:重要文件用"临时文件 + rename"模式
-
流式处理:大文件(> 10MB)必须用 Stream +
pipeline
-
并发控制:批量操作限制并发数(10-50)
-
安全检查:用户输入路径必须用
safeJoin 验证
-
错误处理:根据
err.code 提供友好提示
❌ 禁止做法
- ❌ 在服务器代码中使用同步方法(
readFileSync 等)
- ❌ 字符串拼接路径:
root + '/' + file
- ❌ 用
readFile 读取大文件(内存溢出)
- ❌ 手写流处理逻辑(用
pipeline)
- ❌ 批量操作不限制并发(资源耗尽)
- ❌ 直接使用用户输入的路径(安全漏洞)
- ❌ 忽略错误码(用户体验差)
五、学习路线建议
第一步:基础练习(1-2 天)
- 读写配置文件(JSON)
- 遍历目录并统计文件数量
- 练习
path.join 和 path.resolve
第二步:进阶实战(3-5 天)
- 实现原子写入函数
- 用流复制大文件
- 实现批量图片处理(带并发控制)
第三步:安全加固(2-3 天)
- 实现
safeJoin 函数
- 完善错误处理
- 学习文件权限控制
第四步:项目实践
- 在项目中创建
utils/fs.js 工具模块
- 封装常用函数:
safeJoin、atomicWrite、concurrentPool
- 统一项目的文件操作规范
六、常见问题 FAQ
Q1:什么时候可以用同步方法?
A:只有以下两种情况:
- 程序启动时读取配置(只执行一次)
- 简单的命令行工具脚本
其他情况(特别是服务器环境)必须用异步方法。
Q2:如何判断应该用流还是 readFile?
A:看文件大小:
- < 10MB:用
readFile/writeFile
- > 10MB:用
createReadStream/createWriteStream
- 不确定大小:用流更安全
Q3:删除目录时 rm 和 unlink 有什么区别?
A:
-
unlink:只能删除文件
-
rm:可以删除文件和目录,支持 recursive 选项
推荐统一使用 rm(加 force: true 避免报错)。
Q4:path.join 和 path.resolve 有什么区别?
A:
-
path.join:简单拼接,不保证返回绝对路径
-
path.resolve:解析为绝对路径,基于当前工作目录
项目内定位文件推荐用 resolve。
Q5:如何在 ESM 模块中获取 __dirname?
A:
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
🎉 恭喜!你已经掌握了 Node.js 文件系统操作的核心知识。
💡 建议:将本文中的 safeJoin、atomicWrite、concurrentPool 等函数保存到你的代码片段库,方便随时使用!