ReScript Schema - 整个 JavaScript 生态系统中最快的解析器之一,专注于小型化打包体积和一流的开发体验(DX)。
你当时怎么没听说,现在又来了解了?我从三年前就开始搞这个库了,现在已经达到了别人还没达到的水平。我在文章里会证明这一点,不过,在开始之前,我想先回答你可能有的几个问题。
什么是解析器呢?
ReScript Schema 的一个最基本的应用是解析——接受未知的 JavaScript 数据,验证其有效性,并返回你期望的数据类型。有许多这样的库可供选择,例如最受欢迎的有 Zod,Valibot,Runtypes,Arktype,Typia,Superstruct,Effect Schema 等等。尽管它们的重点略有不同,像 Ajv,Yup 等验证库也非常相似。
ReScript Schema 比其他语言或工具都快吗?
是的。它比Zod快约100倍,并且与Typia或Arktype(基准测试)相当。但是通常,除了验证之外,你可能需要转换进入系统的数据,在这方面,ReScript Schema 表现更佳,超过了现有 JavaScript 生态系统中的任何解决方案。
难道ReScript是什么呢?难道不是JavaScript/TypeScript的库吗?
ReScript 是一种编译成高效且易读的 JavaScript 的强类型语言。ReScript Schema 使用 ReScript 编写,但也有一个非常棒的 JavaScript API 和 TS 类型。你无需安装或运行编译器,只需执行 npm i rescript-schema
即可。
它支持三种语言——JavaScript、TypeScript 和 ReScript。当你在一个代码库中同时使用 TypeScript 和 ReScript 时,这尤其方便 👌
有得失吗?
是的。为了最大化开发体验(DX)、性能和包大小,同时保持库在运行时的完整性,我决定在底层使用eval。不用担心代码的安全性问题,不过,在一些特定环境下,比如Cloudflare Workers,可能会出现问题。在绝大多数情况下,你根本不需要担心这个问题。我认为提前告知你这1%的情况是我的责任。
打算怎么办?
接下来的计划是什么?
我将概述基本的ReScript Schema API及其心智模型。然后,我们将讨论是什么让它从数百万个类似库中脱颖而出(这不仅仅是因为性能)。我还将看看一些更高级的用例,并讨论生态系统、性能以及它在其他库中的位置。
希望你会喜欢它哦😊
解析及校验关注我 X,了解更多我正在准备的编程内容。
让我们从 ReScript Schema 最基本的用例开始。另外,如果你不知道解析(有时也称为解码)和验证之间的区别,这里有一篇Zod 文档中的不错文章。如果你对在应用程序中何时以及为何需要解析数据感到好奇,请可以在评论区留言告诉我。我可以写一篇文章来解释一下,不过现在我假定你已经对这个概念有所了解。
让我们最后来看看代码。我先举一个 TypeScript 的例子,这样大多数读者会感觉更熟悉。一切从定义你期望的数据结构开始:
import * as S from "rescript-schema";
const 电影模式图 = S.schema({
id: S.number,
title: S.string,
tags: S.array(S.string),
rating: S.union(["G", "PG", "PG13", "R"]) // 等级:G, PG, PG13, R (等级: děngjí)
})
切换到全屏,退出全屏
这里的模式更像是在运行时存在的类型定义。当你把鼠标悬停在 filmSchema
上时,你可以看到这样的类型。
S.Schema<{
id: number;
title: 字符串;
tags: 字符串[];
rating: "G" | "PG" | "PG13" | "R";
}>
全屏 退出全屏
这是一个Schema
类型,它推断出了影片对象的定义。我建议将其值提取到其自己的类型中,这样模式就可以作为事实的来源,并且,Film
类型始终与模式保持一致。
type Film = S.Output<typeof filmSchema>// 定义Film类型为S模块中filmSchema的输出
全屏模式。退出全屏
在我们定义了 Film
类型之后,我们可以解析进入我们应用程序的未知数据,确保它符合我们的预期标准。
S.parseOrThrow(
{
id: 1,
title: "我的第一部分电影",
tags: ["Loved"],
rating: "S",
},
filmSchema,
);
//? 抛出 RescriptSchemaError,消息为 `解析失败在 ["rating"]。原因:期望 'G' | 'PG' | 'PG13' | 'R',实际收到 'S'`
S.parseOrThrow(有效但类型未知的数据, filmSchema)
//? 返回 Film 类型的结果
// 如果你不想抛出异常,可以将其包裹在 S.safe 函数中,返回 S.Result 类型的结果
S.safe(() => S.parseOrThrow(未知类型的数据, filmSchema))
进入全屏模式,退出全屏
搞定啦!这里我们有有效数据 🙌
一些有经验的用户可能已经注意到,这个 API 与 Valibot 类似,但有着独特的风格。
你可以使用 S.schema
处理对象、元组和字面量。对于任何类型的联合,都可以使用 S.union
;即使是最复杂的区分联合类型,解析器也能以最优化的方式运行。到目前为止,我只在 ArkType 中见过这种开发体验。
此外,也没有烦人的括号;解析功能明确表示它可以抛异常,并且由于采用了模块化设计,该库的树摇效果很好。
包装尺寸
既然提到了 tree-shaking,这里简单说一下包的大小。包的大小是网页应用中一个非常重要的指标,我想分享一下 ReScript Schema 在这方面和其他库的比较。
rescript-schema@9.2.2 | Zod@3.24.1 | Valibot@1.0.0-beta.14 | ArkType@2.0.4 | |
---|---|---|---|---|
总大小(最小化 + 压缩后) | 12.7 KB | 15.2 KB | 12.3 KB | 40.8 KB |
示例大小(最小化 + 压缩后) | 5.14 KB | 14.5 KB | 1.39 KB | 40.7 KB |
在线示例 | 链接 | 链接 | 链接 | 链接 |
虽然ReScript Schema没有Valibot那么出色,但它的表现仍然不错。如果我们比较ReScript Schema和其他性能相似的库,它们都采用了代码生成的方法(除了ArkType)。这意味着它的初始体积较小,但每增加一个新类型,打包文件中的代码量就会逐渐增加,从而快速增大应用的体积。
用 ReScript 解析
尽管我希望让ReScript Schema在TS开发者中流行起来,ReScript仍然是该库的主要用户群体,因此我也会包含ReScript的例子。
与 TypeScript 相比,ReScript 的类型系统要简单得多;你根本无法在其中进行复杂的类型操作。结合 命名类型,要从模式中提取 film
类型变得几乎不可能(即使它能够推断出来)。不过,ReScript 内置了一种防止冗余代码生成的方法。你可以使用 ReScript Schema PPX 自动生成你类型的模式。只需用 @schema
属性进行标注即可。
@schema
type 评级 =
| @as("G") 适合全年龄段
| @as("PG") 家长指引
| @as("PG13") 强烈建议家长陪同
| @as("R") 仅限成人
@schema
type 电影 = {
id: float,
名称: string,
类别: array<string>,
评级: 评级,
}
点击全屏按钮可以进入全屏模式,再次点击可以退出全屏模式
看到 rating
类型你是不是觉得有点害怕?不用担心,这是一个 ReScript 变体,它是一种描述任何类型联合类型的很好方式。另外,你还可以使用 @as
为评分起一个更好的名字,同时还可以在运行时保留原始的简短值。
尽管 PPX 很不错,你也可以不用它来编写代码
type rating =
| @as("G") 适合所有年龄
| @as("PG") 建议家长指导
| @as("PG13") 13岁以上观众
| @as("R") 限制级
type film = {
id: float,
title: string,
tags: array<string>,
rating: rating,
}
let filmSchema = S.schema(s => {
id: s.matches(S.number),
title: s.matches(S.string),
tags: s.matches(S.array(S.string)),
rating: s.matches(S.union([
瑞典适合所有年龄,
建议家长指导,
强烈建议家长监护,
限制级
]))
})
注:代码中的S.schema
和 S.number
保持原样未翻译。
切换到全屏模式,退出全屏模式
TS API 在这里确实占了上风,因为我们不需要调用 s.matches
来让类型系统满意,但在解析方面,ReScript 又凭借其 管道运算符 和 异常的模式匹配 取回了优势。
{
"id": 1,
"title": "我的第一部长片",
"tags": ["喜欢"],
"rating": "S",
}->S.parseOrThrow(filmSchema)
//? 解析失败,原因在于 ['rating'] 字段,期望值为 'G'、'PG'、'PG13' 或 'R',但实际收到的是 'S'
validDataWithUnknownType->S.parseOrThrow(filmSchema)
//? 返回 film 类型的值
// 由于 ReScript 语言的优势,无需像 TypeScript 那样提供 S.safe 这样的 API。
switch data->S.parseOrThrow(filmSchema) {
| film => Ok(film)
| exception S.Raised(error) => Error(error)
}
进入全屏 退出全屏
独特之处让我们来看看是什么让ReScript Schema独特🔥
更改形状名和字段名
让我们想象一下,与一个奇怪的 REST API 一起工作,其中字段名称采用 PascalCase 且命名混乱,数据随机嵌套在对象或数组中。但我们不能更改后端,因此我们至少希望将数据转换为对我们应用程序更方便的格式。在 ReScript Schema 中,可以以声明性的方式实现这一点,这将是最高效的操作之一。
const filmSchema = S.object((s) => ({
id: s.field("Id", S.number),
title: s.nested("Meta").field("Title", S.string),
tags: s.field("Tags_v2", S.array(S.string)),
rating: s.field("Rating", S.schema([S.union(["G", "PG", "PG13", "R"])]))[0],
}));
S.parseOrThrow(
{
Id: 1,
Meta: {
Title: "我拍的第一部电影",
},
Tags_v2: ["喜爱"],
Rating: ["G级"],
},
filmSchema
);
//? { id: 1, title: "我拍的第一部电影", tags: ["喜爱"], rating: "G级" }
全屏 退出全屏
看起来吓人吗?让我们深入了解一下。首先,每个模式(schema)都有 Input
和 Output
。很多时候,它们是相等的,在解析时,库仅验证 Input
是否正确,然后立即返回。虽然也有办法如我们在前面的例子中所做的那样更改预期的 Output
类型。为了对比,我们来看看通常如何用其他模式库来实现同样的效果:
const filmSchema = S.transform(
S.schema({
Id: S.number, // Id 为数字类型
Meta: { // Meta 包含电影的元数据
Title: S.string, // Title 为字符串类型
},
Tags_v2: S.array(S.string), // Tags_v2 是字符串数组,v2 可能表示这是标签的第二个版本
Rating: S.schema([S.union(["G", "PG", "PG13", "R"])]), // Rating 是一个评级,取值为 ["G", "PG", "PG13", "R"]
}),
(input) => ({ // 转换函数将输入数据转换为所需的格式
id: input.Id, // id 为输入数据中的 Id 字段
title: input.Meta.Title, // title 为输入数据中的 Meta.Title 字段
tags: input.Tags_v2, // tags 为输入数据中的 Tags_v2 字段
rating: input.Rating[0], // rating 为输入数据中的 Rating 字段的第一个元素
})
);
开启全屏 关闭全屏
这仍然是 ReScript Schema,但我们这次手动使用 S.transform
来转换 Input
类型。类似这种 API 在许多其他 schema 库中也很常见。这个例子的好处在于我们能够清晰地看到,我们用 schema 声明性地描述我们系统接收的数据样子,然后将其转换成对我们来说更方便处理的形式。某种程度上,这里的 schema 就像客户端和服务器之间的一种合同,规定服务器如何返回数据。
在之前的高级 S.object
示例中,我们将 Input
类型定义的声明性描述与转换到 Output
类型定义结合起来。这除了使代码更短和提升性能之外,还带来了其他好处。
反序列化(即反序列化)
解码过程存在于许多其他语言的库中,但在 JS 生态系统中却不是很常见。这无疑是一大损失,因为能够执行逆向操作的能力是我认为最强大的特性之一。
如果你还不清楚我说的是什么意思,在其他流行的 JavaScript 模式库中,你只能将 Input
解析为 Output
类型。而在 ReScript 模式中,你可以轻松地将 Output
解析为 Input
,使用相同的模式。或者只做转换逻辑,因为 Output
类型通常不需要验证。
你还记得我们用 S.object
重命名字段的那个 filmSchema
吗?假设我们要发送一个包含电影实体的 POST 请求,而服务器还期望我们用它最初发给我们的奇怪的命名格式来发送数据结构。我们可以通过这种方式来处理它:
// 上面提到的相同模式
const filmSchema = S.object((s) => ({
id: s.field("Id", S.number),
title: s.nested("Meta").field("Title", S.string),
tags: s.field("Tags_v2", S.array(S.string)),
rating: s.field("Rating", S.schema([S.union(["G", "PG", "PG13", "R"])]))[0],
}));
S.reverseConvertOrThrow({ id: 1, title: "My first film", tags: ["喜欢"], rating: "G" }, filmSchema)
//? { Id: 1, Meta: { Title: "我的第一部电影" }, Tags_v2: ["喜欢"], Rating: ["G"] }
全屏/退出全屏
真棒!不是吗?虽然我之后还想再多聊聊性能,但忍不住想先跟大家分享一下这段代码:
(i) => {
// 这个函数接收一个对象i,返回一个包含id、标题、标签和评分的新对象
let v0 = i["tags"];
return {
Id: i["id"],
Meta: { Title: i["title"] },
Tags_v2: v0,
Rating: [i["rating"]],
};
};
点击全屏按钮可以进入全屏模式,再次点击则退出全屏
我觉得大多数人手动写代码会写得慢得多,哈哈 😅
反转
S.reverseConvertOrThrow
是我每天工作中常用的一个反向操作,实际上,这只是 S.convertOrThrow
和 S.reverse
这两个可以单独使用的简便写法之一。
S.reverse
- 这就是让你能够把 Schema<Input, Output>
变成 Schema<Output, Input>
的功能。
虽然这听起来可能有点枯燥,但与常用的解析器/序列化器或编码器/解码器这对组合相比,这里你可以得到一个真正的“模式”定义,可以像使用原始模式定义一样使用它,没有任何限制。
如果你想,你可以解析输出,带或不带数据验证,生成 JSON Schema,执行优化比较和哈希操作,还可以在运行时利用数据表示来执行任何自定义逻辑。
得益于在运行时能够识别 Input
和 Output
数据类型的能力,ReScript Schema 拥有非常强大的强制转换 API 功能,
const schema = S.coerce(S.string, S.bigint)
// 将字符串转换为大整数
S.parseOrThrow("123", schema) //? 123n
// 将大整数转换回字符串
S.reverseConvertOrThrow(123n, schema) //? "123"
全屏模式,退出全屏
将你希望进行转换的模式传递给 S.coerce
,ReScript Schema 会处理剩下的部分。
不过目前还没有实现这个功能,但是通过API,也可以实现比原生的JSON.stringify()
快一倍的效果。就像fast-json-stringify一样,甚至更快, 😎
100 个操作
如果你想要最佳性能,或者内置的操作无法满足你的特定需求,你可以使用 S.compile
来创建符合你需求的操作函数。
const operation = S.compile(S.string, "Any", "Assert", "Async");
//? (input: 任意类型) => Promise<void>
await operation('你好,世界!');
点击这里切换到或退出全屏模式
在上面的例子中,我们创建了一种异步断言操作(assert操作的一种),这种异步断言操作默认情况下不可用。
通过这个 API,你可以获得多达 100 种不同的操作组合,每一种都可能适用于你的具体场景。这就像 Valibot 中的 parser 功能,但放大了 💯 倍。
性能对比
正如我在开头提到的,ReScript Schema 确实很快。我现在来解释原因 🔥
此外,也可以通过大型社区提供的基准测试来自行验证。如果你发现Typia的表现超过了ReScript Schema,我也有自己的见解 😁
首先,ReScript Schema 最大的优势在于其非常聪明的核心库,能够利用 eval
构建最优化的操作代码。之前我已经展示了用于反向转换的操作代码;如下所示是 filmSchema
解析操作的代码:
(i) => {
if (typeof i !== "object" || !i) {
e[7](https://dev.to/I);
}
let v0 = i["Id"],
v1 = i["Meta"],
v3 = i["Tags_v2"],
v7 = i["Rating"];
if (typeof v0 !== "number" || Number.isNaN(v0)) {
e[0](https://dev.to/v0);
}
if (typeof v1 !== "object" || !v1) {
e[1](https://dev.to/v1);
}
let v2 = v1["Title"];
if (typeof v2 !== "string") {
e[2](https://dev.to/v2);
}
if (!Array.isArray(v3)) {
e[3](https://dev.to/v3);
}
for (let v4 = 0; v4 < v3.length; ++v4) {
let v6 = v3[v4];
try {
if (typeof v6 !== "string") {
e[4](https://dev.to/v6);
}
} catch (v5) {
if (v5 && v5.s === s) {
v5.path = '["Tags_v2"]' + '["' + v4 + '"]' + v5.path;
}
throw v5;
}
}
if (!Array.isArray(v7) || v7.length !== 1) {
e[5](https://dev.to/v7);
}
let v8 = v7["0"];
if (v8 !== "G") {
if (v8 !== "PG") {
if (v8 !== "PG13") {
if (v8 !== "R") {
e[6](https://dev.to/v8);
}
}
}
}
return { id: v0, title: v2, tags: v3, rating: v8 };
};
进入全屏退出全屏
感谢 eval
,我们可以省去函数调用并将所有类型验证直接用 if
语句实现。此外,通过运行时了解 Output
类型,使我们能够执行零分配转换,从而优化 JavaScript 引擎的性能。
有趣的是,你可能认为调用 eval
本身很慢,我自己也这么认为。然而,实际上并没有我想象中那么慢。例如,在创建一个简单的嵌套对象模式时,使用 ReScript Schema 调用 eval
比 Zod 还要快 1.8 倍。我确实花了很多心思让它尽可能快,非常感谢 ReScript 语言 及其背后的团队,让我能够写出既高效又安全的代码。
谈到AarkType,他们使用与eval
相同的方法,并且与ReScript Schema有类似的潜力,但他们的评估的代码目前还没达到应有的水平。目前,他们在操作上稍微慢一些,且创建模式定义的速度显著较慢一点。但我认为它们未来能迎头赶上。
其他库永远无法赶上的一个方面是声明性地重塑模式的能力。这也是我认为ReScript Schema比Typia更快的原因。此外,Typia生成的代码在处理可选字段时并不总是最优化的。它也没有提供许多专门为特定用例优化的内置操作。不过,Typia仍然是一个出色的库,具有快速JSON序列化和Protocol Buffer编码功能,这些都是我还没有完成实现的功能。
生态系统
在选择项目中的模式库时,如果性能不是您的主要关注点,那么生态系统是最重要的考虑因素。有了模式,您可以在运行时了解表示类型,从而可以完成成千上万种操作。例如,生成 JSON 架构、描述数据库架构、优化比较和哈希操作、编码到 proto buff,例如构建表单、模拟数据生成、与 AI 交互等等。
Zod在这里绝对是赢家。我在写这篇文章时统计到有78个库集成了Zod。甚至有些库可以直接使用你提供的Zod模式来渲染一个Vue页面,显示一个表单来获取所需的数据。这真是太方便了,不用它来做原型设计简直是不可能的。
但如果你不需要非常具体的需求,ReScript Schema 自身也有一个不错的生态系统,可以与 Valibot 和 ArkType 竞争。实际上,由于 Shape 可调节且能自动反向生成模式,它的潜力甚至更大。一个很好的例子是 ReScript Rest,它结合了 tRPC 的开发体验感,同时保持了 ts-rest 一样的无偏见风格。我还围绕 ReScript Schema 建设了众多强大的工具,不过我还没为 TypeScript 添加支持。如果你发现了有趣的用法,请告诉我,我会尽快处理
此外,ReScript Schema 支持 Standard Schema,这是一个为 TypeScript 验证库设计的通用接口。它最近由 Zod、Valibot 和 ArkType 的开发者设计,并已集成到许多流行的库中,包括。这意味着您可以使用 ReScript Schema 与 tRPC、TanStack Form、TanStack Router、Hono 等和其他流行的库(本文撰写时)20 多个库。
最后说一句
正如题,我完全相信ReScript Schema将成为schema库的未来。它提供了出色的DX、性能和打包大小,并且有许多创新功能。我尽量从整体上涵盖了所有这些方面,希望能让你对它有点兴趣 👌
我虽然不会推荐你下一个项目使用 ReScript Schema,但还是会倾向于推荐 Zod。不过,如果你能给 star 和 X 订阅,我会非常感谢你 🙏
让我们看看模式库以后会变成啥样。也许我会给 ReScript Schema 取个更酷的名字,说不定还能比 Zod 更火呢?那就祝好运吧 😁