刚开始接触 Express.js 开发时,最头疼的问题往往不是路由怎么写,而是“用户数据到底从哪里来”。接数据库 Mongo、MySQL 对新手太重;写死在内存数组里,服务一重启数据就失效。对小工具、Demo 或者教学项目来说,一个很实用的过渡方案是:把用户信息保存到本地 JSON 文件,用 Node.js 自带的 fs.readFile 去读取,再通过 Express 接口返回给前端。本文通过一个完整示例,把 readFile 的基础语法、Express 集成方式、异常处理和工程化建议一次讲清楚。
很适合这几类同学阅读:正在学 Node.js / Express.js 的初学者;想用文件做临时数据的个人开发者;以及准备给老项目加一个轻量数据层的后端开发。学习完你不仅能写出可运行的用户列表接口,还能理解为什么自己写的路径会报错、为什么读出来的内容不能直接用,以及遇到报错该从哪里排查。
1. 为什么在 Express 应用中用 readFile 读取用户信息
1.1 这个需求从哪里来
很多前端转后端或者刚接触 Node.js 的同学都有一个惯性思维:把数据放在 JS 文件里,运行时用变量保存。
比如说:
const users = [ { id: 1, name: '张三' }, { id: 2, name: '李四' } ];这种方式写接口很快,但问题也很明显:
- 数据是临时内存数据,服务重启后全部丢失。
- 数据改动要在代码里进行,没有独立的“数据层”。
- 无法模拟真实项目中“异步读取数据”的场景。
后来我参与几个小型项目,才慢慢意识到一个折中方案:在项目目录里放一个users.json,用 Node.js 的fs.readFile把数据异步读取出来,再由 Express 路由返回。这样做的好处是:
- 数据从代码中分离出来,修改用户信息不需要重启接口服务。
- 只要文件存在,数据就是持久的。
- 不引入数据库依赖,项目结构依然很轻。
- 能顺便练习 Node.js 的回调、异步和异常处理。
这种模式常见于个人博客的留言数据、内网小工具的用户配置、课程设计的学校管理系统,以及临时给前端提供 Mock 数据。
1.2 readFile 到底做了什么
readFile是 Node.js 内置 fs 模块提供的一个异步方法,作用是把指定路径的文件整个读到内存中。
可以把它理解成“打开一个文件,读出全部文本内容”。它接收三个参数:文件路径、编码格式、回调函数。当文件读取完成时,Node.js 会把结果交给回调函数。
再看一个表达式:
fs.readFile(path, 'utf8', callback);其中第三参的回调如果写成(err, data) => {},那么:
- 当文件读取失败时,
err有值,data为undefined。 - 当文件读取成功时,
err为null,data是文件内容。
初学者最容易犯的错是:拿到data之后直接用,忘记它是一个字符串而不是对象。尤其当文件内容是 JSON 时,必须先JSON.parse(data)才能转成数组或者对象,否则 Express 的res.json()接收到字符串后,行为会和你预期的不一致。
2. 环境准备与项目初始化
为了下面的示例能跑起来,需要先准备好 Node.js 环境。这里以 Node.js LTS 环境为例,代码统一使用 CommonJS 模块规范。如果你的项目开启了"type": "module",稍作修改也能运行,但下面的内容默认不用 ESM。
版本方面不用刻意追求最新。只要能正常执行npm install express,并且内置的require('fs')可用即可。示例里依赖的核心包只有 Express,如果你只想测试 fs.readFile,甚至可以不安装任何第三方依赖。
2.1 初始化项目
在命令行中执行:
mkdir express-readfile-demo cd express-readfile-demo npm init -y执行之后,会生成一个package.json文件,用来管理项目依赖和脚本命令。
然后安装 Express:
npm install express安装完成后,package.json的dependencies中能看到express字段。如果你网络环境比较特殊,安装变慢,可以考虑切换 npm 镜像源后再试。
2.2 准备项目目录结构
推荐的文件组织方式如下:
express-readfile-demo/ ├── data/ │ └── users.json ├── app.js ├── package.json └── node_modules/data/users.json用来存放用户数据,app.js是 Express 服务入口。路由逻辑、文件读取逻辑会在app.js中完成。正式项目里可以把路由和数据访问层继续拆分,但这个 Demo 保持单文件有助于理解数据流。
创建data目录:
mkdir data3. 手写代码之前,先看懂 fs.readFile
3.1 完整语法与参数说明
fs.readFile的标准形式是:
fs.readFile(path[, options], callback)第二参options可以是一个字符串编码,也可以是一个对象。例如:
'utf8':表示按 UTF-8 编码读出字符串。{ encoding: 'utf8', flag: 'r' }:对象形式。- 如果不传编码,默认返回 Buffer 对象。
回调函数是必须的:
fs.readFile('./data/users.json', 'utf8', (err, data) => { if (err) { console.error(err); return; } console.log(data); });这样写的时候,console.log输出的data是一个字符串。如果文件内容是下面的 JSON:
[ { "id": 1, "name": "张三" } ]那么控制台输出的是包含方括号和花括号的完整文本。
3.2 为什么要传 utf8 编码
很多新手去掉'utf8'后发现打印出来的是一堆看不懂的字节,原因是 readFile 默认输出的 Buffer 对象,本质上是二进制数据在内存中的表示。打印时虽然能看到十六进制内容,但没法直接用于业务逻辑。
比如:
fs.readFile('./data/users.json', (err, data) => { console.log(data); // 默认输出 Buffer });输出类似:
<Buffer 5b 0a 20 20 7b 20 22 69 64 22 3a 20 31 20 7d 0a 5d>想让 readFile 直接输出文本,就必须指定编码。最简单的写法是传入'utf8',Node.js 读取完成后会自动做一次字符解码。
但在处理图片、音频、压缩包等二进制文件时,就不要指定 UTF-8 了。本需求是读取用户信息文本文件,所以必须设置。
3.3 相对路径为什么会踩坑
初学者最容易踩的坑,是把路径写成了相对当前命令执行目录,而不是相对当前代码文件。
比如你现在项目的根目录执行:
node app.js如果app.js和data目录都在项目根目录下,执行node app.js时也许一切正常。但如果你在src目录或者系统计划任务中换了一种启动方式,那么相对路径可能变为src/data/users.json,导致文件不存在错误。
更稳妥的做法是借助path模块,将路径基于__dirname拼接。__dirname永远是当前 JS 文件所在的目录。
const path = require('path'); const filePath = path.join(__dirname, 'data', 'users.json');这种方式不会因为启动目录的变化而找不到文件,后面所有示例都会使用这一写法。
4. 完整实战:用 Express + readFile 实现用户信息读取接口
4.1 准备用户数据文件
在data/users.json中写入以下内容:
[ { "id": 1, "name": "张三", "email": "zhangsan@example.com", "city": "北京" }, { "id": 2, "name": "李四", "email": "lisi@example.com", "city": "上海" }, { "id": 3, "name": "王五", "email": "wangwu@example.com", "city": "广州" } ]这里把用户设计成 JSON 数组。每个用户对象包含id、name、email、city。字段可以根据业务随意扩展,但要注意 JSON 不能写注释,逗号也必须规范。如果手写容易漏逗号,可以用 VS Code 的 JSON 格式化能力自动整理。
4.2 创建基础的 Express 服务
在项目根目录创建app.js:
const express = require('express'); const path = require('path'); const app = express(); const PORT = 3000; app.get('/', (req, res) => { res.send('用户信息服务已启动'); }); app.listen(PORT, () => { console.log(`Server running at http://localhost:${PORT}`); });这里的res.send()是 Express 提供的最简单响应方式。当我们在浏览器访问http://localhost:3000时,会看到页面输出字符。
先运行一遍:
node app.js如果控制台打印Server running at http://localhost:3000,说明 Express 环境没问题。接下来逐步加入 readFile 相关逻辑。
4.3 编写 /users 用户列表接口
在刚才的app.js基础上加入 fs 模块和 readFile 逻辑。
const express = require('express'); const fs = require('fs'); const path = require('path'); const app = express(); const PORT = 3000; const usersFilePath = path.join(__dirname, 'data', 'users.json'); app.get('/users', (req, res) => { fs.readFile(usersFilePath, 'utf8', (err, data) => { if (err) { console.error('读取用户文件失败:', err); return res.status(500).json({ message: '服务器内部错误' }); } try { const users = JSON.parse(data); res.json(users); } catch (parseError) { console.error('用户文件解析失败:', parseError); res.status(500).json({ message: '用户数据格式错误' }); } }); }); app.listen(PORT, () => { console.log(`Server running at http://localhost:${PORT}`); });代码说明:
usersFilePath保存绝对路径,放在路由外部,避免每次请求都重新拼接。fs.readFile是异步读取,读取完成前不会阻塞其他请求。err分支必须处理,因为文件一旦被意外删除,readFile 回调里会收到错误对象。JSON.parse放在 try-catch 中,是因为即使文件存在,内容也可能因为人为改动而不再是合法 JSON。res.status(500).json()表示返回 HTTP 状态码 500 和一个 JSON 对象。
这里的关键在于:readFile 的“操作结果”不是通过return返回的,而是通过回调参数传给我们的。阅读代码时,要把回调函数内部当成“文件读取完成后才执行的代码块”,不要误以为return res.status(...)能让外层函数立刻结束。
4.4 增加 /users/:id 单用户查询路由
多数项目里,光有列表还不够,还需要前端传入用户 ID 查询某个用户信息。我们可以修改app.js,增加一个带动态参数的路由:
const express = require('express'); const fs = require('fs'); const path = require('path'); const app = express(); const PORT = 3000; const usersFilePath = path.join(__dirname, 'data', 'users.json'); // 读取并解析用户文件 function readUsersFile(callback) { fs.readFile(usersFilePath, 'utf8', (err, data) => { if (err) { callback(err); return; } try { const users = JSON.parse(data); callback(null, users); } catch (parseError) { callback(parseError); } }); } // 用户列表 app.get('/users', (req, res) => { readUsersFile((err, users) => { if (err) { console.error('读取用户数据失败:', err); return res.status(500).json({ message: '服务器内部错误' }); } res.json(users); }); }); // 根据 id 查单个用户 app.get('/users/:id', (req, res) => { const userId = Number(req.params.id); readUsersFile((err, users) => { if (err) { console.error('读取用户数据失败:', err); return res.status(500).json({ message: '服务器内部错误' }); } const user = users.find((item) => item.id === userId); if (!user) { return res.status(404).json({ message: '用户不存在' }); } res.json(user); }); }); app.listen(PORT, () => { console.log(`Server running at http://localhost:${PORT}`); });这里的req.params.id是字符串类型。如果直接和 JSON 里的数字 ID 比较,可能会因为类型不一致导致永远匹配不到,所以要先用Number()做类型转换。
把读取文件的逻辑抽取成readUsersFile后,两个路由可以复用同一套文件读取和 JSON 解析逻辑。后续要换数据源,也只需要改这一个函数。
4.5 启动服务并用浏览器/curl 验证
重启服务:
node app.js访问用户列表接口:
curl http://localhost:3000/users预期输出:
[ { "id": 1, "name": "张三", "email": "zhangsan@example.com", "city": "北京" }, { "id": 2, "name": "李四", "email": "lisi@example.com", "city": "上海" }, { "id": 3, "name": "王五", "email": "wangwu@example.com", "city": "广州" } ]访问单个用户:
curl http://localhost:3000/users/2预期输出:
{ "id": 2, "name": "李四", "email": "lisi@example.com", "city": "上海" }访问不存在的 ID:
curl http://localhost:3000/users/999预期输出:
{ "message": "用户不存在" }5. 不只回调:readFile 的异步演进与现代写法
5.1 为什么越来越多的项目改用 fs.promises
Node.js 的 fs 模块自带回调风格,但回调风格在链路复杂之后容易产生嵌套。上面只读一个文件还好,如果查询某个用户后还要读取他的订单文件,再读取订单里的商品文件,就会形成“回调地狱”。
Node.js 提供了 Promise 版本 API,也就是:
const fs = require('fs').promises;也可以写为:
const fsPromises = require('fs/promises');两者本质一样,都提供了readFile方法,但不再使用回调,而是返回 Promise 对象。这样就能结合async/await写出更接近同步逻辑的代码。
5.2 async/await 读取用户文件的改写版
可以基于上面的示例,把readUsersFile改造成 Promise 版本:
const express = require('express'); const fs = require('fs').promises; const path = require('path'); const app = express(); const PORT = 3000; const usersFilePath = path.join(__dirname, 'data', 'users.json'); async function readUsers() { const data = await fs.readFile(usersFilePath, 'utf8'); return JSON.parse(data); } app.get('/users', async (req, res) => { try { const users = await readUsers(); res.json(users); } catch (error) { console.error('读取用户数据失败:', error); res.status(500).json({ message: '服务器内部错误' }); } }); app.get('/users/:id', async (req, res) => { const userId = Number(req.params.id); try { const users = await readUsers(); const user = users.find((item) => item.id === userId); if (!user) { return res.status(404).json({ message: '用户不存在' }); } res.json(user); } catch (error) { console.error('读取用户数据失败:', error); res.status(500).json({ message: '服务器内部错误' }); } }); app.listen(PORT, () => { console.log(`Server running at http://localhost:${PORT}`); });这里有个非常重要的变化:async函数中await fs.readFile()之后的代码,会等待文件读取完成再执行。如果文件读取失败,Promise 会进入 rejected 状态,并被路由函数里最近的 try-catch 捕获。
阅读这段代码时要注意:readUsers()函数中await后面的异常会向外抛出,因此调用它时必须用 try-catch 包裹,否则 Express 路由回调可能处理不了 rejected 状态,造成进程层面的 UnhandledPromiseRejection。
5.3 Express 5 / 异步路由的错误转发
如果你用的是 Express 5,异步路由抛出的错误会自动传给错误处理中间件。但为了兼容性和行为可控,我依然建议在每个路由内部显式处理 try-catch。尤其在团队协作中,这种“自己能处理的错误自己收口”的方式,比依赖框架行为更容易排查。
同样,生产项目里不建议把整段 try-catch 重复写在多个路由中,可以定义一个统一的错误处理中间件,把读取数据错误集中处理,但这部分内容超出本案例核心,可以留到后续讲。
6. 常见报错与排查清单
6.1 高频错误现象对照表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 浏览器访问接口返回 500 | 文件路径不对,readFile 找不到文件 | 打印 usersFilePath,检查文件是否真实存在 |
| 读取出来是 Buffer 乱码 | 没有给 readFile 传 utf8 编码 | 给第二参传入'utf8' |
JSON.parse报错 | JSON 文件被手写坏,缺少逗号或多余逗号 | 用 JSON 格式化工具检查内容 |
| 接口返回 undefined | 没有先 JSON.parse 就使用 data | 确认读取结果是字符串,先解析再使用 |
/users/:id查不到数据 | req.params.id 是字符串,而数据 id 是数字 | 用 Number() 做类型转换 |
| 服务启停后加载不出来 | 相对路径随启动位置变化 | 使用path.join(__dirname, ...)构建路径 |
| 回调函数里的 res.json 没有执行 | 误把 return 写在 readFile 外层 | 检查 readFile 回调内部是否有 return |
| 大量请求时接口变慢 | 每次请求都重复读盘 | 引入缓存或读取一次后放入内存 |
6.2 文件找不到排查步骤
当接口返回 500,同时控制台出现类似ENOENT: no such file or directory, open ...的错误时,说明 readFile 去打开不存在的文件。
执行顺序如下:
- 在
app.js中打印usersFilePath。 - 到打印出来的路径下确认
users.json是否真的存在。 - 如果路径最后是
.../express-readfile-demo/data/users.json,但目录data下文件名为user.json,则命名不一致。 - Windows 下注意路径分隔符,
path.join会自动处理,不要手动拼接'\\'或'/'。 - 确认你编辑的 JSON 文件保存时没有额外扩展名,比如
users.json.txt。
6.3 JSON.parse 报错排查
JSON 解析失败的原因最常见的是手写 JSON 不严谨。比如说:
{ id: 1, name: '张三', }在 JS 对象里能运行,但 JSON 文件里不能写单引号,也不能没有属性名的双引号。合法的 JSON 对象要保持"key": value的形式,并且最后一个属性后面不能有多余逗号。
如果你修改过users.json后接口突然 500,第一时间可以用 Node 命令行快速检查:
node -e "JSON.parse(require('fs').readFileSync('./data/users.json', 'utf8')); console.log('OK')"能打印OK说明数据文件格式正确。
7. 工程建议与小项目落地要点
7.1 永远使用绝对路径
不管项目有多小,读文件的路径都建议用path.join(__dirname, 'data', 'users.json')。初学者最容易忽略的就是把路径写成'./data/users.json',然后在不同终端环境下启动导致路径错乱。虽然一个同学在本地 ESM 项目里也用过import.meta.url拼接,但对 CommonJS 的 Express 项目,__dirname是最稳定的答案。
7.2 不要在路由回调里堆太多逻辑
上面示例把readUsers()抽成独立函数,是一种适合继续扩展的习惯。路由只负责“接收请求、调用数据函数、返回响应”,数据函数只负责“读文件并解析”。后续如果要换成从 MySQL 读取,路由层基本不用动。
如果项目继续膨胀,可以继续拆分controllers、services、models层。但即使不拆那么细,至少做到“一个函数只做一件事”,代码的可读性会高很多。
7.3 利用 JSON 解析后的缓存优化
每次请求都执行fs.readFile,在小 Demo 里没有任何问题,但如果这个接口被频繁调用,重复读同一个文件会造成不必要的磁盘 IO。可以考虑做一层简单的缓存:
let cachedUsers = null; let cacheTime = 0; const CACHE_DURATION = 60 * 1000; async function readUsersWithCache() { const now = Date.now(); if (cachedUsers && now - cacheTime < CACHE_DURATION) { return cachedUsers; } const data = await fs.readFile(usersFilePath, 'utf8'); cachedUsers = JSON.parse(data); cacheTime = now; return cachedUsers; }这样做的好处是,60 秒内多次请求不会重复读取文件。缺点是需要人工维护缓存失效时间。真实项目中如果对数据实时性要求较高,这种手动缓存需要谨慎使用。
7.4 文件更新与并发写入
本文只讲了读取用户信息,没有讲写入。但需要提醒的是,如果未来使用fs.writeFile修改同一个文件,必须考虑多个请求同时写文件的并发问题。Node.js 是单线程事件循环,但异步读写之间仍可能产生“后写覆盖先写”的情况。小项目可通过异步队列或一次只允许一个写操作来控制,生产环境则建议尽快切换到数据库、对象存储或 Redis。
7.5 权限与敏感信息
users.json如果包含手机号、密码等敏感字段,把它放在能被外部直接访问的静态资源目录中会非常危险。Express 的express.static中间件默认不会托管项目根目录,但如果你配了静态目录,一定要注意不要把data目录暴露出去。更稳妥的做法是:
- 将数据文件放在项目根目录外,或者非 public 目录。
- 用
app.get('/users')接口控制访问。 - 接口层不要随意把密码返回给前端。
生产环境中的“用户信息”类数据,应按最小返回原则设计接口字段,只返回前端实际需要的字段。
7.6 日志与观察
readFile 读取文件属于异步 IO,出错时一定要输出日志。不要只写res.status(500).json(...),却不打印错误细节。否则线上出现故障时,前端只能看到“服务器内部错误”,后端却无从排查。比较简单的做法是使用console.error输出上下文:
console.error(`读取用户文件失败,路径:${usersFilePath}`, error);后续接入日志框架时,可以把这一条换成结构化日志,方便检索。
7.7 结合前后端联调
在实际前后端联调过程中,前端同学通常不希望接口返回 500 后只有一句话。推荐的约定格式是:
{ "message": "用户不存在", "code": 40401 }把业务状态码与 HTTP 状态码分离,前端判断起来更灵活。不过,这类设计可以等业务变复杂之后再引入,不必强行套在所有小项目上。
7.8 还能继续扩展什么
本案例展示了用 readFile 读取用户数据。反向的需求也很常见:如何利用fs.writeFile新增用户、修改用户信息,让接口从只读模式变成支持增删改的模式。在扩展过程中,你会接触到JSON.stringify、文件锁、数据校验等概念。如果继续做下去,你会发现文件型数据层很快会遇到瓶颈,这时再引入 MySQL 或 MongoDB 也就顺理成章了。
下一期可以继续写“用 writeFile 保存用户注册信息”,把 Express + 文件操作的闭环补完整。先动手把上面的/users接口跑起来,再改一改代码,体会一下 readFile 从文件系统拉取数据再交给 HTTP 层的过程,比单纯看文章要快得多。