深入解读@expo/json-file:Expo 工具链中读写与操纵 JSON 文件的基础库
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
@expo/json-file是 Expo 开源仓库中一个面向 JSON 文件的高效封装库,提供类型安全的读、写、查询、合并与删除等操作。本文将以其 README 为核心骨架,结合 核心实现、原子写入 与 错误处理 等源码,系统讲解其安装方式、完整 API、配置项语义与源码级原理,并展示其在 Expo CLI(如 UserSettings.ts)中的真实落地场景。读完后,你将能够熟练使用该库为 CLI 工具、构建脚本或配置系统实现可靠的 JSON 持久化。
一、为什么需要@expo/json-file
在 Expo 生态中,CLI 工具与构建系统需要频繁读取与改写各类 JSON 配置,例如用户状态文件、缓存清单、打包结果元数据等。直接使用fs.readFileSync与JSON.parse虽然可行,但会带来大量重复的样板代码,并普遍存在以下痛点:
- 类型安全缺失:手动断言解析结果的形状容易出错;
- 错误信息不友好:JSON 语法错误往往难以定位到具体行列;
- 文件不存在时抛异常:很多场景希望回退到默认值;
- 写入过程可能损坏文件:普通写入在进程中断时可能留下半截文件;
- JSON5 支持:一些配置文件允许注释与尾逗号。
@expo/json-file用一个JsonFile类统一解决这些问题。正如其在 package.json 中的描述:A module for reading, writing, and manipulating JSON files。每个方法都提供同步与异步两个版本,并且同时支持类上的实例调用与静态调用两种形态。
二、安装与快速上手
1. 安装
在任意 Node.js 项目中通过包管理器安装即可:
yarn add @expo/json-file也可以使用npm install @expo/json-file或pnpm add @expo/json-file。该包的运行时依赖只有json5与@babel/code-frame(用于美化错误输出),体积轻量。
2. 最小可用示例
仓库 README 给出的核心用法如下:
import JsonFile, { JSONObject } from '@expo/json-file'; // Create a file instance const jsonFile = new JsonFile<JSONObject>(filePath); // Interact with the file await jsonFile.readAsync(); await jsonFile.writeAsync({ some: 'data' });更完整的流程通常是:实例化 → 读取(容错)→ 修改 → 原子写回:
import JsonFile, { JSONObject } from '@expo/json-file'; type AppConfig = JSONObject & { version?: string; features?: { darkMode?: boolean }; }; const file = new JsonFile<AppConfig>('config.json', { ensureDir: true, // 目录不存在时自动创建 jsonParseErrorDefault: {},// 文件损坏时回退为空对象 cantReadFileDefault: {}, // 文件不存在时也回退为空对象 }); const config = await file.readAsync(); // 读取(带容错) await file.setAsync('version', '2.0.0'); // 更新单个键并原子写回 const version = file.get('version', '1.0.0'); // 读取键,带默认值三、完整 API:同步 / 异步与实例 / 静态双形态
在 JsonFile.ts 的类定义中,所有公共方法都被同时注册为实例方法与静态方法(static read = read等)。下表基于源码逐一对应:
| 方法 | 作用 | 返回值 |
|---|---|---|
read()/readAsync() | 读取并解析整个文件 | 解析后的对象 |
write(object)/writeAsync(object) | 序列化并写入整个对象 | 写入的对象 |
rewrite()/rewriteAsync() | 读取后原样重写(用于统一格式化) | 对象 |
parseJsonString(str) | 直接解析一段 JSON/JSON5 字符串 | 对象 |
get(key, default)/getAsync(key, default) | 读取顶层某个键的值 | 该键的值 |
set(key, value)/setAsync(key, value) | 更新或新增顶层键并落盘 | 更新后的对象 |
merge(sources)/mergeAsync(sources) | 合并一个或多个对象并落盘 | 合并后的对象 |
deleteKey(key)/deleteKeyAsync(key) | 删除单个顶层键 | 更新后的对象 |
deleteKeys(keys[])/deleteKeysAsync(keys[]) | 批量删除多个顶层键 | 更新后的对象 |
每个异步方法返回
Promise<TJSONObject>,同步方法直接返回对象。测试 JsonFile-test.ts 中对上述 8 组方法逐一断言了静态与实例形态的存在性。
1. 实例方法:绑定文件路径与默认选项
const file = new JsonFile<JSONObject>('package.json'); const obj = await file.readAsync(); await file.setAsync('name', 'my-app');构造函数签名如下(源码 JsonFile.ts#L69-L72):
constructor(file: string, options: Options<TJSONObject> = {})实例持有file与options,每次方法调用时,临时传入的选项会通过_getOptions与构造选项做浅合并({ ...this.options, ...options }),实现"实例默认 + 单次覆盖"。
2. 静态方法:无状态的一次性调用
不需要复用实例时可直接静态调用:
const obj = await JsonFile.readAsync<JSONObject>('app.json'); await JsonFile.writeAsync('app.json', { name: 'demo' }); const v = JsonFile.get('package.json', 'version', '0.0.0'); await JsonFile.setAsync('package.json', 'license', 'MIT'); await JsonFile.mergeAsync('app.json', [{ expo: { name: 'Demo' } }]);3. 常用操作的行为语义
get/getAsync:先读取整个文件,再检查key in object。键存在则返回值;键不存在且未提供defaultValue时会抛出JsonFileError(消息为No value at key path ...),提供了则返回默认值。源码见 JsonFile.ts#L233-L263。set/setAsync:等价于先read整个对象,再展开写入{ ...object, [key]: value },随后整体落盘。注意它仅操作顶层键。merge/mergeAsync:入参可以是单个对象或对象数组,内部通过Object.assign依次并入后整体写回。deleteKey(s)/deleteKey(s)Async:批量删除,只有确实发生了删除时才触发磁盘写入(源码通过didDelete标志判断),避免无谓 I/O。rewrite/rewriteAsync:先读取、再原样写回,典型用途是"统一重新格式化/规范化"现有文件(例如按新的缩进风格重排)。
四、Options 配置项详解
所有读写方法都接受可选的 Options 对象,其完整定义与默认值都在 JsonFile.ts#L16-L38:
| 选项 | 类型 | 默认值 | 语义 |
|---|---|---|---|
default | TJSONObject | undefined | 读取失败(不可读或解析失败)时的兜底默认对象 |
badJsonDefault | TJSONObject | undefined | 声明兼容用;实际兜底逻辑见下方两个选项 |
jsonParseErrorDefault | TJSONObject | undefined | 文件内容存在但解析失败(非法 JSON)时返回的对象 |
cantReadFileDefault | TJSONObject | undefined | 文件不存在/无权限等无法读取时返回的对象 |
ensureDir | boolean | false | 写入前自动创建父目录(递归mkdir) |
mode | fs.Mode | undefined | 写入后强制设置的文件权限位 |
json5 | boolean | false | 为true时以 JSON5 语法解析/序列化 |
space | number | 2 | 序列化缩进空格数 |
addNewLineAtEOF | boolean | true | 写出的文件末尾追加一个换行符 |
1. 三个默认值选项的优先级
源码中的jsonParseErrorDefault()与cantReadFileDefault()(JsonFile.ts#L444-L462)揭示了一个容易忽略的细节:当专门的默认值选项未设置时,会回退读取default。因此default是"读取失败的通用兜底",而两个专项选项可以分别控制"文件坏了"与"文件读不到"两种失败分支的返回值。当所有兜底都未配置且读取/解析失败时,才会抛出异常。
2.get的第三个参数默认值
注意get/getAsync的方法签名中,第二个参数defaultValue就是键缺失时的兜底值(键不存在时的兜底),而 Options 中的default系列处理的是整个文件读取失败的情况——两者场景不同,不要混淆。
五、JSON5 支持:宽松语法的配置文件
当json5: true时,读取走JSON5.parse、写入走JSON5.stringify(JsonFile.ts#L209-L213)。这意味着文件可以包含:
- 单行/多行注释;
- 尾逗号;
- 不带引号的键名;
- 单引号字符串。
仓库 fixtures 中的 test-json5.json 就演示了以上全部语法(例如itParsedProperly: 42、x: 'z'与块注释),而对应的标准 JSON 版本在 test.json 中则必须全部使用双引号键。测试 JsonFile-test.ts 验证了json5: true时能正确解析该 fixture 并读到score: 5与itParsedProperly: 42。
六、写入可靠性:原子写与文件权限
write并非直接覆盖目标文件,而是委托给 writeAtomic.ts 实现原子写入:
- 以文件内容计算 SHA-256,生成唯一临时文件名(
${filename}.${hash},base64url 编码); - 先写入临时文件;
- 再通过
rename原子替换目标文件。
由于rename在同一文件系统内是原子操作,中途崩溃也不会留下"半个文件",这保证了并发或异常场景下配置文件的完整性。细节上,源码特意注释说明rename会保留目标文件原有的权限模式,因此在指定了mode选项时会随后执行一次chmod来强制施加期望的权限位(writeAtomic.ts#L21-L24)。
write/writeAsync的流程为(JsonFile.ts#L265-L315):
- 若
ensureDir为真,先递归创建父目录; - 依据
json5、space序列化对象(序列化失败会抛出JsonFileError); - 依据
addNewLineAtEOF决定是否在末尾追加\n; - 以原子方式写入,并带上
mode。
测试 JsonFile-test.ts 中通过
mode: 0o600验证了写入后fs.stat(...).mode & 0o777恰为0o600;另一个用例验证了文件最后一个字符是\n(对应addNewLineAtEOF默认值true)。
七、错误处理:JsonFileError 与友好的诊断信息
错误体系定义在 JsonFileError.ts 中:
JsonFileError:所有失败的统一错误类型。其构造器会把文件路径与底层cause拼进多行消息(使用├─/└─字符绘制错误树),并暴露cause、code、fileName以及标记位isJsonFileError: true。注意源码注释特别提醒:该类的实例不会通过instanceof JsonFileError(因为它直接继承Error而非再继承一个中间类,构造器里也没有调用Object.setPrototypeOf),实际中通常靠isJsonFileError标志位来判别。EmptyJsonFileError:当文件内容为空字符串(trim()后为空)时抛出,错误码为EJSONEMPTY。
当内容解析失败时,parseJsonString 还会利用@babel/code-frame生成带行列高亮的代码帧并拼入错误消息:JSON5 的 SyntaxError 直接携带lineNumber/columnNumber;原生 JSON 的 SyntaxError 则通过消息里的at position N反推出行列(见locationFromSyntaxError)。这让开发者能在终端直接看到出错位置附近的原文。
测试 JsonFileError-test.ts 验证了isJsonFileError标志与cause的携带;JsonFile-test.ts 则断言了 JSON 与 JSON5 两种语法错误的Cause: SyntaxError: ...消息片段。
八、源码级工作流:一次readAsync发生了什么
以异步读取为例,调用链如下:
file.readAsync()→readAsync(file, options);fs.promises.readFile(file, 'utf8')读取原文(JsonFile.ts#L189);- 读取抛错时先经
assertEmptyJsonString排除空文件,再依据cantReadFileDefault/default决定返回默认值还是抛出带原因链的JsonFileError; - 读取成功则进入
parseJsonString,依据json5选项选择解析器; - 解析失败且无兜底时,附加 code frame 后抛出
JsonFileError(错误码EJSONPARSE)。
类型层面,JsonFile<TJSONObject>是泛型类,约束TJSONObject extends JSONObject;JsonFile.ts#L8-L12 定义了递归的JSONValue = boolean | number | string | null | JSONArray | JSONObject等类型,保证读取结果与写入入参都被类型检查覆盖。
九、仓库内的真实应用:从 CLI 工具看典型用法
@expo/json-file不是孤立的教学包,而是 Expo CLI 与多个工具模块的实际依赖。例如 packages/@expo/cli/src/api/user/UserSettings.ts 中,CLI 将用户会话状态持久化到~/.expo/state.json:
// state.json holds the auth session secret, so restrict it to the owner only. const SETTINGS_FILE_MODE = 0o600; export function getSettings(): JsonFile<UserSettingsData> { return new JsonFile<UserSettingsData>(getSettingsFilePath(), { ensureDir: true, mode: SETTINGS_FILE_MODE, jsonParseErrorDefault: {}, // This will ensure that an error isn't thrown if the file doesn't exist. cantReadFileDefault: {}, }); }这段真实代码几乎用到了前文讲到的全部容错特性:
ensureDir: true保证.expo目录不存在时也能自动创建;mode: 0o600将含认证机密的state.json限制为仅属主可读写;jsonParseErrorDefault/cantReadFileDefault配合空对象,使首次运行(文件不存在)或文件损坏时优雅回退而不是崩溃;- 随后通过
getSettings().get('auth', null)读取会话、setAsync('auth', sessionData, { default: {} })更新会话。
在同一个 CLI 中,bundledNativeModules.ts、ESlintPrerequisite.ts、getExpoSchema.ts 等十余个模块也都在使用@expo/json-file,足以说明该库承担了 Expo CLI 中几乎所有 JSON 配置的读写职责。如果你在开发需要持久化 JSON 的 Node 工具或 CI 脚本,完全可以套用同样的模式。
十、测试与质量保障
包内置完整的 Jest 测试,入口见 jest.config.js,测试用例集中在tests目录,并通过memfs在内存文件系统中模拟磁盘,覆盖了:
- 同步/异步读取、JSON5 解析、语法错误的错误消息;
- 写入、
rewrite、文件权限模式、EOF 换行符; set的增改、deleteKey/deleteKeys的删除;- 连续 50 轮高频写读下的无竞态验证(测试注释指出约 200 轮以上在高并发压力下可能失败,但真实场景几乎不会如此高频)。
通过cd packages/@expo/json-file && yarn test或pnpm test可在本地复跑这些用例,是理解各 API 行为的直接参考。
总结
@expo/json-file用极简的接口把 JSON 文件的"读、写、查、改、并、删、格式化重写"收敛到一个泛型类中,并同时提供同步/异步、实例/静态四种调用组合。其差异化价值集中在三点:统一的容错默认值体系(default/jsonParseErrorDefault/cantReadFileDefault)、基于临时文件 + rename 的原子写入,以及附代码帧的友好错误诊断;json5选项与mode/ensureDir则让它可以安全地处理宽松语法的配置文件与含敏感信息的系统状态文件。从 Expo CLI 中state.json等真实实践可以看出,这一模式已成为 Expo 工具链处理 JSON 持久化的标准答案,同样值得在自研 CLI 与脚本工程中借鉴。
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考