深入解读 `@expo/json-file`:Expo 工具链中读写与操纵 JSON 文件的基础库

深入解读 @expo/json-file:Expo 工具链中读写与操纵 JSON 文件的基础库

【免费下载链接】expo An open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web. 【免费下载链接】expo 项目地址: 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.readFileSyncJSON.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-filepnpm 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> = {})

实例持有 fileoptions,每次方法调用时,临时传入的选项会通过 _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

选项类型默认值语义
defaultTJSONObjectundefined读取失败(不可读或解析失败)时的兜底默认对象
badJsonDefaultTJSONObjectundefined声明兼容用;实际兜底逻辑见下方两个选项
jsonParseErrorDefaultTJSONObjectundefined文件内容存在但解析失败(非法 JSON)时返回的对象
cantReadFileDefaultTJSONObjectundefined文件不存在/无权限等无法读取时返回的对象
ensureDirbooleanfalse写入前自动创建父目录(递归 mkdir
modefs.Modeundefined写入后强制设置的文件权限位
json5booleanfalsetrue 时以 JSON5 语法解析/序列化
spacenumber2序列化缩进空格数
addNewLineAtEOFbooleantrue写出的文件末尾追加一个换行符

1. 三个默认值选项的优先级

源码中的 jsonParseErrorDefault()cantReadFileDefault()JsonFile.ts#L444-L462)揭示了一个容易忽略的细节:当专门的默认值选项未设置时,会回退读取 default。因此 default 是"读取失败的通用兜底",而两个专项选项可以分别控制"文件坏了"与"文件读不到"两种失败分支的返回值。当所有兜底都未配置且读取/解析失败时,才会抛出异常。

2. get 的第三个参数默认值

注意 get/getAsync 的方法签名中,第二个参数 defaultValue 就是键缺失时的兜底值(键不存在时的兜底),而 Options 中的 default 系列处理的是整个文件读取失败的情况——两者场景不同,不要混淆。

五、JSON5 支持:宽松语法的配置文件

json5: true 时,读取走 JSON5.parse、写入走 JSON5.stringifyJsonFile.ts#L209-L213)。这意味着文件可以包含:

  • 单行/多行注释;
  • 尾逗号;
  • 不带引号的键名;
  • 单引号字符串。

仓库 fixtures 中的 test-json5.json 就演示了以上全部语法(例如 itParsedProperly: 42x: 'z' 与块注释),而对应的标准 JSON 版本在 test.json 中则必须全部使用双引号键。测试 JsonFile-test.ts 验证了 json5: true 时能正确解析该 fixture 并读到 score: 5itParsedProperly: 42

六、写入可靠性:原子写与文件权限

write 并非直接覆盖目标文件,而是委托给 writeAtomic.ts 实现原子写入

  1. 以文件内容计算 SHA-256,生成唯一临时文件名(${filename}.${hash},base64url 编码);
  2. 先写入临时文件;
  3. 再通过 rename 原子替换目标文件。

由于 rename 在同一文件系统内是原子操作,中途崩溃也不会留下"半个文件",这保证了并发或异常场景下配置文件的完整性。细节上,源码特意注释说明 rename 会保留目标文件原有的权限模式,因此在指定了 mode 选项时会随后执行一次 chmod 来强制施加期望的权限位(writeAtomic.ts#L21-L24)。

write/writeAsync 的流程为(JsonFile.ts#L265-L315):

  • ensureDir 为真,先递归创建父目录;
  • 依据 json5space 序列化对象(序列化失败会抛出 JsonFileError);
  • 依据 addNewLineAtEOF 决定是否在末尾追加 \n
  • 以原子方式写入,并带上 mode

测试 JsonFile-test.ts 中通过 mode: 0o600 验证了写入后 fs.stat(...).mode & 0o777 恰为 0o600;另一个用例验证了文件最后一个字符是 \n(对应 addNewLineAtEOF 默认值 true)。

七、错误处理:JsonFileError 与友好的诊断信息

错误体系定义在 JsonFileError.ts 中:

  • JsonFileError:所有失败的统一错误类型。其构造器会把文件路径与底层 cause 拼进多行消息(使用 ├─/└─ 字符绘制错误树),并暴露 causecodefileName 以及标记位 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 发生了什么

以异步读取为例,调用链如下:

  1. file.readAsync()readAsync(file, options)
  2. fs.promises.readFile(file, 'utf8') 读取原文(JsonFile.ts#L189);
  3. 读取抛错时先经 assertEmptyJsonString 排除空文件,再依据 cantReadFileDefault/default 决定返回默认值还是抛出带原因链的 JsonFileError
  4. 读取成功则进入 parseJsonString,依据 json5 选项选择解析器;
  5. 解析失败且无兜底时,附加 code frame 后抛出 JsonFileError(错误码 EJSONPARSE)。

类型层面,JsonFile<TJSONObject> 是泛型类,约束 TJSONObject extends JSONObjectJsonFile.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.tsESlintPrerequisite.tsgetExpoSchema.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 testpnpm test 可在本地复跑这些用例,是理解各 API 行为的直接参考。

总结

@expo/json-file 用极简的接口把 JSON 文件的"读、写、查、改、并、删、格式化重写"收敛到一个泛型类中,并同时提供同步/异步、实例/静态四种调用组合。其差异化价值集中在三点:统一的容错默认值体系default / jsonParseErrorDefault / cantReadFileDefault)、基于临时文件 + rename 的原子写入,以及附代码帧的友好错误诊断json5 选项与 mode/ensureDir 则让它可以安全地处理宽松语法的配置文件与含敏感信息的系统状态文件。从 Expo CLI 中 state.json 等真实实践可以看出,这一模式已成为 Expo 工具链处理 JSON 持久化的标准答案,同样值得在自研 CLI 与脚本工程中借鉴。

【免费下载链接】expo An open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web. 【免费下载链接】expo 项目地址: https://gitcode.com/GitHub_Trending/ex/expo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值