💎 雨云 RainYun - 企业级云计算服务提供商

介绍 z.compile()

Colin McDonnell·

在底层,z.compile() 会遍历整个 schema 一次,并生成一段经过超级优化的扁平、无循环 JavaScript 代码,其验证输入的速度远快于标准运行时验证器。这段代码可以通过 new Function()(本质上是功能更强大的 eval)执行,从而作为快速验证路径。Schema 会利用这一点来“快速检查”有效性,在验证失败时回退到常规运行时逻辑,以提供详细的错误信息。

这可以带来显著的速度提升,尤其是对于对象、数组、联合类型和元组等容器类型。

import * as z from "zod";
 
const Player = z.object({
  username: z.string(),
  bio: z.string(),
  xp: z.number(),
  // ...20 more properties...
});
 
const CompiledPlayer = z.compile(Player);

CompiledPlayer 这样的已编译 schema 和其他任何 Zod schema 一样。已编译 schema 没有任何特殊规则。

  • 相同的方法:.parse().safeParse().extend().optional()
  • 相同的推断输入和输出类型
  • 相同的问题和错误信息

像使用 Player 一样使用它:

Player.parse({ ... });
CompiledPlayer.parse({ ... }); // ~9x faster

Zod 的整个测试套件会运行两次——一次正常运行,另一次则全局启用自动编译——以确保完全一致。

工作原理

请看这个简单的 Point schema:

const Point = z.object({
  x: z.number(),
  y: z.number()
});

这是为它生成的代码片段:

const isPoint = new Function("input", `
  if (typeof input !== "object" || input === null) return false;
  if (typeof input.x !== "number") return false;
  if (typeof input.y !== "number") return false;
  return true;
`);
 
isPoint({ x: 1, y: 2 }); // true
isPoint({ x: "1" });     // false

对于绝大多数输入,生成的函数会使用 JavaScript 能够表达的最快逻辑来验证数据:直接执行 typeof 检查和属性读取,中间没有解释器介入。当它无法处理某个输入时,Zod 会回退到标准解析器。

这是 Zod 为上面的 Player schema 生成的函数:

if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID;
const v0 = input["username"];
if (typeof v0 !== "string") return INVALID;
const v1 = input["bio"];
if (typeof v1 !== "string") return INVALID;
const v2 = input["xp"];
if (typeof v2 !== "number" || !Number.isFinite(v2)) return INVALID;
const v3 = { "username": v0, "bio": v1, "xp": v2 };
return v3;

借助 new Function() 的能力,这一切会在运行时于进程内完成。无需集成到你的构建系统中。

编译后的 schema 只是对现有 schema 的纯增量增强。它附加了用于检查有效输入的预编译快速路径。检测到无效数据时,它会返回 INVALID 符号,以表示解析应回退到未编译的解析器。这在结构上避免了编译版本与未编译版本之间错误报告的细微偏差。

对于无效输入,回退逻辑会运行未编译的 schema,因此错误来自未编译的 schema。

编译所有内容!

如果你在应用程序的入口点导入 "zod/compile",Zod 就会启用默认编译——你声明的所有 schema 都会在第一次使用时自行编译。只需一行代码,就能让整个应用程序获得编译带来的性能提升。

// in your entrypoint (or before any schemas are defined)
import "zod/compile";

然后像平常一样使用 Zod:

import * as z from "zod";
 
z.string().min(1).max(10).optional().parse("hello"); // compiled

编译是惰性的,因此只有实际用于解析的 schema 才会被编译。

它也支持 Node.js CLI 标志,这可以确保它在任何模块定义 schema 之前运行:

node --import zod/compile app.js   # ESM
node --require zod/compile app.cjs # CommonJS

或者在 bunfig.tomlnub.jsonc 中设置 preload

nub.jsonc
{
  "preload": ["zod/compile"]
}

如果内容安全策略阻止了 new Function()(例如在 Cloudflare Workers 环境中),默认启用的编译模式会优雅地停用并变为空操作。

速度提升

对象和元组等容器受益最大,因为编译会将运行时逐个键遍历的过程展开为扁平、无循环的验证逻辑,从而能够被 JS 引擎优化

在共享的纳秒轴上显示每次解析所需的时间,标准解析器为灰色条,编译后的时间为其中的蓝色条:包含 10 个对象的数组从 377 纳秒降至 68 纳秒(5.5 倍),包含 20 个键的对象从 301 纳秒降至 38 纳秒(7.8 倍),包含 10 个字符串的数组从 241 纳秒降至 33 纳秒(7.3 倍),包含 3 个对象的联合类型从 190 纳秒降至 36 纳秒(5.3 倍),包含 3 个元素的元组从 119 纳秒降至 33 纳秒(3.6 倍),包含 5 个键的严格对象从 117 纳秒降至 32 纳秒(3.7 倍),可区分联合类型从 92 纳秒降至 27 纳秒(3.4 倍),包含 5 个键的对象从 76 纳秒降至 28 纳秒(2.8 倍);编译后最高可提升 7.8 倍
每次解析所需的时间,标准解析器与编译后对比——越低越好(基准测试

性能收益会随着 schema 复杂度提升。这里的每个 schema 都是在紧密循环中单独测量的——这是标准解析器的最佳情况——因此其比率低于上方混合工作负载图表中的比率(基准测试)。

objectspeedup
5 个键1.8x
10 个键2.2x
20 个键5.0x
50 个键10.2x
tuplespeedup
1 个项2.2x
3 个项2.5x
5 个项3.0x
10 个项3.7x

权衡

编译器以包体积换取性能。编译器包含大量代码,通过 z.compile()"zod/compile" 调用它意味着这些代码会被包含在你的包中。它会增加约 7 KB 的 gzip 压缩体积(28 KB 的压缩后体积):包含一个四键对象 schema 的 Zod 包,其 gzip 压缩体积会从 24.1 KB 增加到 31.1 KB,而 Zod Mini 包则会从 4.6 KB 增加到 13.2 KB。完全不调用 z.compile() 或导入 zod/compile 的包无需承担任何成本;在打包过程中,它会被完全 tree-shaking 掉。

试试看

npm install zod@^4.5.0

然后对热路径中的任意 schema 使用 z.compile(),或者在入口点顶部使用 import "zod/compile"