文章目录
- [一、path 模块 ------ 路径处理](#一、path 模块 —— 路径处理)
-
- [1.1 path.join 与 path.resolve ------ 最容易混淆的两个 API](#1.1 path.join 与 path.resolve —— 最容易混淆的两个 API)
- [1.2 path.dirname / basename / extname](#1.2 path.dirname / basename / extname)
- [1.3 path.normalize / path.parse](#1.3 path.normalize / path.parse)
- [二、fs 模块 ------ 文件系统操作](#二、fs 模块 —— 文件系统操作)
-
- [2.1 同步 vs 异步](#2.1 同步 vs 异步)
- [2.2 回调地狱 → Promise → async/await](#2.2 回调地狱 → Promise → async/await)
- [2.3 异步演进全景](#2.3 异步演进全景)
- [2.4 综合实战 ------ path + fs 协同](#2.4 综合实战 —— path + fs 协同)
- 三、全文总结
- 四、核心知识点复盘
- [五、常见问题 / 避坑指南](#五、常见问题 / 避坑指南)
本文系统梳理 Node.js
path与fs模块的核心 API,深度对比join与resolve的差异,完整演绎异步流程控制从 回调 → Promise → async/await 的演进过程。
一、path 模块 ------ 路径处理
path 是 Node.js 内置模块,无需安装,专门用于处理和转换文件路径。
js
import path from 'path';
1.1 path.join 与 path.resolve ------ 最容易混淆的两个 API
这是日常踩坑最多的地方。两者都能拼接路径,但行为有本质区别。
path.join :纯路径拼接,使用平台分隔符(Windows \,Linux /),不会将相对路径转为绝对路径。
path.resolve :将路径序列解析为绝对路径。从右向左处理,直到构造出绝对路径:
| 场景 | resolve 行为 |
|---|---|
路径序列中含绝对路径(以 / 开头) |
以最右边的绝对路径为起点,左边全部忽略 |
| 路径序列全是相对路径 | 以当前工作目录 (process.cwd())为起点 |
| 第一个参数就是绝对路径 | join 和 resolve 结果相同 |
js
import path from 'path';
// ---- join:纯拼接,不转绝对路径 ----
console.log(path.join('a', 'b', 'c')); // a\b\c (仍是相对路径)
console.log(path.join('/a/', '/b/', '/c')); // \a\b\c
// ---- resolve:解析为绝对路径 ----
console.log(path.resolve('a', 'b', 'c')); // D:\...\当前目录\a\b\c
console.log(path.resolve('/hello', 'world')); // \hello\world
// process.cwd() 被 '/hello' 覆盖!
console.log(path.resolve(process.cwd(), '/hello', 'world')); // \hello\world
join vs resolve 关键对比:
js
// 对比一:第一个参数是绝对路径 → 结果相同
path.join('/a', 'b', 'c'); // \a\b\c
path.resolve('/a', 'b', 'c'); // \a\b\c
// 对比二:全是相对路径 → 差异明显
path.join('a', 'b', 'c'); // a\b\c (相对路径!)
path.resolve('a', 'b', 'c'); // D:\...\当前目录\a\b\c (绝对路径!)
// 对比三:.. 的处理
path.join('/hello', 'world', '../a', 'b'); // \hello\world\..\a\b (不解析 ..)
path.resolve('/hello', 'world', '../a', 'b'); // \hello\a\b (解析 ..,回退一级)
工程化最佳实践:
js
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ✅ 构建项目路径 → 用 resolve(确保绝对路径)
const PROJECT_ROOT = path.resolve(__dirname, '..');
const SRC_DIR = path.resolve(PROJECT_ROOT, 'src');
const ASSETS_DIR = path.resolve(SRC_DIR, 'assets'); // 静态资源目录
const LIBS_DIR = path.resolve(SRC_DIR, 'libs'); // 工具函数目录
// ✅ 在绝对路径后追加子路径 → 用 join(更简洁)
const configPath = path.join(SRC_DIR, 'config', 'app.config.js');
// ❌ 不要依赖相对路径,它随 cwd 变化,是线上事故温床
口诀: 需要绝对路径 →
resolve;已有绝对路径拼子路径 →join;不确定就无脑用resolve。
1.2 path.dirname / basename / extname
js
import path from 'path';
// dirname ------ 获取目录名
path.dirname('/a/b/c.js'); // /a/b
// basename ------ 获取文件名,可选去除后缀
path.basename('/a/b/c.js'); // c.js
path.basename('/a/b/c.js', '.js'); // c
path.basename('/a/b/c.js', 'js'); // c. ⚠️ 少写点号,后缀匹配失败!
// extname ------ 获取扩展名(含点号)
path.extname('/a/b/c.js'); // .js
path.extname('/a/b/c.min.js'); // .js (只取最后一个)
path.extname('/a/b/.gitignore'); // '' (点开头文件无扩展名)
⚠️
basename第二个参数是后缀字符串匹配,不是"去扩展名" 。写'js'而非'.js',点号会被保留,这是个高频踩坑点。
1.3 path.normalize / path.parse
js
// normalize ------ 规范化冗余分隔符、. 和 ..
path.normalize('/a/b//c/d/e/..'); // \a\b\c\d
path.normalize('/a/./b/../c/'); // \a\c
// parse ------ 将路径拆解为结构化对象
path.parse('/home/user/dir/file.txt');
// { root: '/', dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' }
| 属性 | 含义 | 关系 |
|---|---|---|
root |
根目录 | |
dir |
完整目录路径 | dir + base = 完整路径 |
base |
文件名(含扩展名) | name + ext = base |
ext |
扩展名(含点号) | |
name |
纯文件名 |
二、fs 模块 ------ 文件系统操作
fs 是 Node.js 操作文件的核心模块,底层由 C++(libuv 库) 实现,暴露给 JS 调用。Node.js 封装 V8 引擎解析 JS,借助操作系统异步能力实现非阻塞 I/O。
js
import fs from 'fs'; // 回调风格 API
import fs from 'fs/promises'; // Promise 风格 API(推荐)
2.1 同步 vs 异步
js
import fs from 'fs';
// 同步读取 ------ 阻塞线程,代码执行完之前后续代码全部等待
const syncData = fs.readFileSync('./test.txt', 'utf-8');
console.log(syncData);
// 异步读取(回调)------ 不阻塞,结果通过回调返回
// Node.js 铁律:Error-First Callback,第一个参数永远是 err
fs.readFile('./test.txt', 'utf-8', (err, data) => {
if (!err) console.log(data);
});
console.log('111'); // 这行先执行!
| 场景 | 推荐 | 原因 |
|---|---|---|
| 启动加载配置 | readFileSync |
一次性阻塞可接受 |
| HTTP 请求中读文件 | 异步 readFile |
绝不能阻塞事件循环 |
| 流式处理大文件 | createReadStream |
避免 OOM |
2.2 回调地狱 → Promise → async/await
当需要按顺序读取多个文件 时,回调代码会退化为"回调地狱"(Callback Hell)------代码向右层层嵌套呈金字塔形,每个层级重复 if-else 错误处理,业务逻辑被淹没。
js
import fs from 'fs';
// 回调地狱:读 file1 → file2 → file3
fs.readFile('./file1.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file1', data);
fs.readFile('./file2.txt', 'utf-8', (err, data) => {
if (!err) {
console.log('file2', data);
fs.readFile('./file3.txt', 'utf-8', (err, data) => {
if (!err) console.log('file3', data);
else console.log(err);
});
} else console.log(err);
});
} else console.log(err);
});
ES6 Promise ------ 从"横向嵌套"变"纵向链式":
js
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); })
.catch(err => console.error('读取失败:', err)); // 统一错误处理
ES8 async/await ------ 异步代码同步化写法:
js
import fs from 'fs/promises';
(async () => {
try {
const file1Data = await fs.readFile('./file1.txt', 'utf-8');
const file2Data = await fs.readFile('./file2.txt', 'utf-8');
const file3Data = await fs.readFile('./file3.txt', 'utf-8');
console.log(file1Data, file2Data, file3Data);
} catch (err) {
console.error('读取失败:', err);
}
})();
⚠️ 关键认知:
await是语法糖,不是同步! 它的本质仍是 Promise + 微任务(microtask),不会阻塞事件循环。readFileSync阻塞整个线程,await只暂停当前 async 函数------两者有天壤之别。await是"让路",Sync是"堵路"。
2.3 异步演进全景
同步(readFileSync)
│ 问题:JS 单线程,耗时 I/O 卡死进程
▼
异步回调(readFile + callback)
│ 问题:回调地狱,业务复杂时代码无法维护
▼
Promise + then 链式调用
│ 问题:"爬楼梯",代码仍不够直观
▼
async/await(ES8 语法糖)
│ 本质:Promise + 微任务封装
│ 特点:异步代码同步化写法,可读性巅峰
▼
核心结论:
async/await= Promise 语法糖,运行时走微任务队列;setTimeout是宏任务,优先级不同;Node.js 高并发的核心正是异步非阻塞 I/O + 事件循环。
2.4 综合实战 ------ path + fs 协同
js
import path from 'path';
import fs from 'fs/promises';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function readAllTextFiles() {
const assetsDir = path.resolve(__dirname, 'assets'); // 绝对路径
try {
const files = await fs.readdir(assetsDir);
const txtFiles = files.filter(f => path.extname(f) === '.txt');
const results = [];
for (const file of txtFiles) {
const filePath = path.join(assetsDir, file); // 拼接子路径
const content = await fs.readFile(filePath, 'utf-8');
results.push({
name: path.basename(file, '.txt'), // 去扩展名
path: path.normalize(filePath), // 规范化
content
});
}
console.log(results);
} catch (err) {
console.error('操作失败:', err.message);
}
}
readAllTextFiles();
三、全文总结
- path 模块 :
join纯拼接,resolve解析绝对路径(从右向左,遇绝对路径则左边作废);dirname/basename/extname/normalize/parse各司其职;工程中优先resolve构建绝对路径。 - fs 模块 :同步 API 简单但阻塞;异步 API 是非阻塞基石;异步流程控制经历 回调 → Promise → async/await 三次进化;
async/await是当前最佳实践。
四、核心知识点复盘
| 序号 | 知识点 | 关键内容 |
|---|---|---|
| 1 | path.join |
纯拼接,不转绝对路径,自动处理多余分隔符 |
| 2 | path.resolve |
解析为绝对路径,从右向左,遇绝对路径则左边被丢弃 |
| 3 | join vs resolve | 首参数为绝对路径时结果相同;全相对时 join 保留相对 |
| 4 | path.basename |
第二个参数是后缀字符串匹配 ,务必带点号 .js |
| 5 | path.extname |
返回最后一个 . 之后的内容(含点号) |
| 6 | path.normalize |
去除冗余分隔符,解析 . 和 .. |
| 7 | path.parse |
解析为 {root, dir, base, ext, name} 结构 |
| 8 | fs.readFileSync |
同步阻塞,适合启动加载配置 |
| 9 | fs/promises + async/await |
异步非阻塞,代码同步化写法,微任务机制 |
| 10 | await vs Sync | await 只暂停当前函数,Sync 阻塞整个线程 |
五、常见问题 / 避坑指南
Q1:join 和 resolve 什么时候用哪个?
- 需要绝对路径 →
resolve;已有绝对路径拼子路径 →join;不确定就resolve。
Q2:basename('/a/b/c.js', 'js') 输出为什么是 c.?
- 第二个参数是后缀字符串匹配,不是智能去扩展名。写
'js'只去掉末尾js,点号还在。正确写法带.:basename('/a/b/c.js', '.js')。
Q3:resolve 传了 process.cwd() 为什么不生效?
resolve从右向左处理,遇到/开头的绝对路径时左边全部丢弃。path.resolve(process.cwd(), '/hello', 'world')等价于path.resolve('/hello', 'world'),cwd 被覆盖。
Q4:async/await 和 readFileSync 不都是"等结果"吗?
readFileSync阻塞整个线程,事件循环停摆。await只暂停当前函数,事件循环继续运转------这是 Node.js 高并发的根本原因。
Q5:跨平台路径兼容怎么做?
- 永远不要手动拼接字符串(
dir + '/' + file),始终用path.join或path.resolve,它们自动适配当前平台分隔符。