拼路径读文件总踩坑?我把 Node 的 path 和 fs 彻彻底底捋了一遍

6 阅读9分钟

起因:一个路径写错,打包出来的静态资源全 404

上周写一个简易的资源打包脚本,要把 dist 目录里的文件拷贝到指定位置,我随手用path.join拼了个路径,本地跑的好好的,一放到 CI 流水线里就疯狂报ENOENT,说找不到目标目录。

我盯着代码看了十分钟,没觉得哪写错了,不就是把几段路径拼起来吗?后来逐行打印调试,才发现是joinresolve搞混了 ——CI 的执行目录和本地不一样,相对路径直接歪到了姥姥家。

说实话之前我一直觉得这俩都是拼路径的,随便用用就行,真踩了坑才发现差的不是一点半点。索性趁着这个机会,把 path 模块常用的 API,还有 fs 读写文件的几种写法,全跑了一遍测清楚,省得以后再栽跟头。

先掰扯清楚:join 和 resolve 到底差在哪

最开始我对这俩的认知就是 “都能把几段字符串拼成一个路径”,直到我把测试用例挨个跑了一遍,才慢慢品出本质区别。

先看最基础的相对路径拼接:

import path from 'path';

console.log(path.join('a', 'b', 'c'));    // a/b/c
console.log(path.resolve('a', 'b', 'c')); // /Users/xxx/当前工作目录/a/b/c

第一眼看上去,好像就是 resolve 会返回绝对路径,join 只做纯拼接。但这只是表面现象,真正的坑藏在遇到绝对路径的时候。

我当时踩坑的写法大概是这样,本想基于当前目录拼子路径,结果完全不是预期:

const cwd = process.cwd();

// 我本以为俩结果差不多,跑完整个人傻了
console.log(path.join(cwd, '/hello', 'world')); 
// 输出:/当前工作目录/hello/world(join老老实实按顺序拼)

console.log(path.resolve(cwd, '/hello', 'world'));
// 输出:/hello/world(前面的cwd直接被扔掉了!)

我对着输出愣了好半天,才反应过来 resolve 的逻辑根本不是 “从左往右拼”,而是从右往左找,遇到第一个绝对路径就停,左边的所有路径全部丢弃,再以这个绝对路径为基准往后拼接。

而 join 就很单纯,就是把所有路径片段按顺序拼在一起,顺手帮你把多余的斜杠、./../这些整理干净,不会因为某个片段带了开头的/就重置基准。

一个好记的类比 你可以把 join 理解成 “穿珠子”,给你多少颗珠子就按顺序串起来,顶多帮你把歪的珠子摆正。 resolve 则是 “找终点”,从最后一段路往回走,只要遇到一条大马路(绝对路径),就直接以这里为起点重新算路,前面的小路全部不算数。

当然也有俩结果一致的时候,比如第一个参数本身就是绝对路径,后面全是相对路径:

// 这种场景下俩输出完全一样,也是我之前一直没分清的原因
console.log(path.resolve('/hello', 'world', '../a', 'b')); // /hello/a/b
console.log(path.join('/hello', 'world', '../a', 'b'));    // /hello/a/b

平时写代码如果全是绝对路径开头,俩用起来没区别,可一旦混了相对路径和带/的片段,很容易就踩坑。

path 里其他几个常用的,也藏着小细节

本来我以为把 join 和 resolve 搞懂就够了,结果顺着往下测,发现其他几个高频 API 也有容易记错的地方,索性一起整理了。

路径拆分三件套:dirname、basename、extname

这三个平时用得最多,拆目录、拿文件名、取后缀,基本都靠它们:

console.log(path.dirname('/a/b/c.js'));  // /a/b  拿目录部分
console.log(path.basename('a/b/c.js'));  // c.js 拿完整文件名
console.log(path.extname('a/b/c.js'));   // .js  拿扩展名

看起来没啥问题,直到我手贱试了 basename 的第二个参数 —— 可选的后缀去除。

我本来以为传扩展名进去就会自动去掉后缀,比如传.js就得到c,这个确实是对的。但我图省事漏写了点,直接传了js

console.log(path.basename('a/b/c.js', '.js')); // c  正常预期
console.log(path.basename('a/b/c.js', 'js'));  // c. ???
console.log(path.basename('a/b/c.js', 's'));   // c.j

当时给我整笑了,合着它就是纯纯匹配末尾的字符串,根本不管你是不是扩展名,末尾对上几个字符就切几个。所以用的时候一定记得带那个点,别偷工减料,不然出来的结果能给你整懵。

整理路径和全量拆解

还有两个偶尔能救命的方法:normalizeparse

normalize专门收拾乱七八糟的路径,比如多写了斜杠、加了多余的../,丢进去过一遍就干净了:

console.log(path.normalize('a/b//c/d/e/..')); // a/b/c/d

parse则是直接把路径拆成一个完整对象,啥信息都有,需要批量处理路径各个部分的时候,比自己用字符串切方便一万倍:

console.log(path.parse('/home/user/dir/file.txt'));
// {
//   root: '/',
//   dir: '/home/user/dir',
//   base: 'file.txt',
//   ext: '.txt',
//   name: 'file'
// }

说完路径,聊聊文件读写那点事

路径搞明白了,接下来自然就是读写文件。最开始接触 fs 模块的时候,我其实挺懵的,一会同步一会异步,还有回调、Promise、async/await 好几种写法,完全不知道该用哪个。

最开始图省事,我全用readFileSync,同步读取,写完下一行就能拿到结果,逻辑顺得一批:

import fs from 'fs';

// 注意第二个参数 utf-8,漏了就等着踩坑
const data = fs.readFileSync('./test.txt', 'utf-8');
console.log(data);

直到后来被提醒,同步 IO 会直接阻塞整个 Node 线程,要是在服务接口里用,一个请求读文件慢了,其他所有请求全跟着卡。这才开始老老实实写异步写法。

最原始的异步写法就是回调函数,Node 的惯例是回调第一个参数是错误对象,没报错就是 null:

fs.readFile('./test.txt', 'utf-8', (err, data) => {
  if (!err) {
    console.log(data);
  } else {
    console.log(err);
  }
})

读单个文件还好,要是有严格的顺序依赖,比如必须先读 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);
    })
  })
})

说实话就这三层嵌套,我现在回头看都眼晕,更别说真实业务里还要加判断、做数据处理,那代码直接往右歪到屏幕外面,根本没法维护。

后来 ES6 出了 Promise,Node 也提供了fs/promises,不用自己手动包 Promise 了。用 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 里面套来套去也挺乱。

直到 ES8 出了 async/await,我才觉得终于舒服了,异步代码写起来跟同步似的,执行流程一目了然:

// 用立即执行函数包裹,现在高版本Node也支持顶层await了
(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);
})();

当时我还天真的以为,async/await 是不是把异步变成同步了?后来才搞懂,它就是个语法糖,底层还是 Promise,本质还是异步,不会阻塞线程。只是让我们写起来、读起来更像同步代码,执行的时候还是会丢进事件循环走微任务。

别搞混了 await 只是让代码的执行顺序看起来同步,不会阻塞整个 Node 进程,其他代码该跑还是跑。它和readFileSync那种真・阻塞线程的同步方法,完全不是一回事。

我踩过的几个实实在在的坑

捋完一遍再回头看自己之前踩的坑,个个都挺典型的,列出来给大家提个醒。

坑 1:相对路径用 join,换个目录执行就炸

最开始写脚本,总喜欢用path.join('./dist', 'assets'),觉得反正都是在项目根目录执行,肯定没问题。结果有一次在父目录执行脚本,路径直接就错了,死活找不到文件。

后来就养成习惯了:只要是定位项目内的固定文件,一律用path.resolve(process.cwd(), 'dist', 'assets')生成绝对路径,稳得一批,在哪执行都不会错。

坑 2:readFile 不传编码,拿到 Buffer 以为读失败了

刚学 fs 的时候,写readFile忘了加第二个参数'utf-8',控制台打印出来一堆<Buffer 68 65 6c 6c 6f ...>,我当时还以为文件读坏了,折腾了半天才反应过来 —— 不传编码默认返回 Buffer 对象,要么加'utf-8',要么自己调用.toString()转一下。

坑 3:resolve 遇到绝对路径,前面的基准全白给

就是最开始那个打包脚本的坑,我写了path.resolve(basePath, '/public'),本以为会拼在 basePath 后面,结果/public是绝对路径,直接把 basePath 顶掉了,路径直接从系统根目录开始算,当然找不到文件。

后来记住了:用 resolve 的时候,后面的路径片段别随便加开头的/,除非你就是想重置基准路径。

最后说两句

其实这些东西都不算难,就是细节多,平时不注意就容易踩坑。这一趟捋下来,我自己最深的感受有三个:

第一,工程化脚本和配置里,拼绝对路径优先用 resolve,别图省事用 join 拼相对路径,执行目录不确定的时候,绝对路径才是最稳的。

第二,业务代码里读写文件优先用fs/promises加 async/await,可读性最高,也不容易写出回调地狱,同步方法只适合那种一次性的简单脚本。

第三,API 别凭印象用,拿不准的时候就写两行代码跑一下,比猜半天管用多了。

当然也不是说 join 就没用,比如你确定就是要拼相对路径,或者拼接的片段本来就是绝对路径开头,用 join 也完全没问题,关键是得知道俩的区别,别稀里糊涂混用。

搞懂了的话可以在评论区留个言,说说你平时踩过什么 fs 或者 path 的坑,我也想看看大家都是怎么栽跟头的。