使用编译 Schema 提升 Zod 校验性能
了解Zod编译模式如何加快验证、何时收益明显,以及如何应对编译成本、不支持的模式、CSP限制和Cloudflare Workers环境。
从 Zod 4.5.0 开始,z.compile() 可以把一个 schema 转换为专门生成的 JavaScript 校验器。它校验有效输入的速度更快,返回的结果和错误与未编译的 schema 完全一致。
如果你的处理函数每分钟要解析成千上万次同样庞大的请求体,而校验层每次调用都要遍历一遍 schema 树,这部分开销就会出现在 CPU profile 中。Zod 4.5 版本为此提供了解决方案。本文是 OpenReplay 指南《在 TypeScript 中使用 Zod 校验数据》的续篇,内容包括:如何开启编译、编译的代价、哪些场景下的提速足够明显,以及在严格的内容安全策略(CSP)下或在 Cloudflare Workers 等运行时中会发生什么。
核心要点
z.compile(schema)会生成一个不含循环、也不做分支遍历的校验器,并通过new Function()执行。有效输入由一串直接的typeof检查完成校验,无需逐层遍历 schema 树。- 无效输入几乎得不到任何提升,因为解析失败时会先运行快速路径,再运行完整的标准解析器来生成错误信息。
- 在 Zod 官方的紧密循环基准测试中,编译后的 5、10、20、50 键对象分别提速 1.8 倍、2.2 倍、5.0 倍和 10.2 倍,因此小型 schema 收益有限。
- 任何调用了
z.compile()或导入了zod/compile的 bundle,都会因编译器增加约 7 KB(gzip 后)的体积。 - 使用
{ strict: true }时,无法编译的 schema 会抛出ZodCompileAsyncError或ZodCompileUnsupportedError,而不是静默回退。
编译后的 Zod Schema 是如何工作的?
编译后的 Zod schema 使用生成的代码来校验输入,而不是遍历 schema 树。Zod 会一次性读取整个 schema,生成一小段不含循环的 JavaScript 代码,再通过 new Function() 将其转换为函数。此后,对有效输入的校验就是逐行读取每个属性并检查其 typeof。如果快速路径拒绝了某个输入,Zod 会对其运行标准解析器。正因如此,编译后的 schema 报告的问题和错误信息与原始 schema 完全相同。你依然使用 .parse()、.safeParse(),类型推断也保持不变。
开启编译
有两种启用方式:使用 z.compile() 编译特定的 schema,或者通过全局导入 zod/compile 编译应用中的所有 schema。按 schema 编译可以精确控制热点路径;全局模式只需一行代码。
使用 z.compile() 按 Schema 编译
z.compile() 会返回一个新的、已编译的 schema,传入的原始 schema 保持不变。任何基于已编译 schema 构建新 schema 的方法,例如 .refine()、.extend() 或 .optional(),返回的都是未编译的 schema。因此应先构建完整的 schema,最后再编译:
import * as z from "zod";
const Base = z.object({
id: z.string(),
type: z.string(),
createdAt: z.number(),
});
const notInFuture = (e: { createdAt: number }) => e.createdAt <= Date.now();
// ❌ .refine() returns a new schema, and it is not compiled
const Wrong = z.compile(Base).refine(notInFuture);
// ✅ finish the schema, then compile it
const WebhookEvent = z.compile(Base.refine(notInFuture));
const result = WebhookEvent.safeParse(payload);
运行时不会有任何提示告诉你 Wrong 是未编译的。它的校验结果完全正确,只是速度更慢。下文介绍的 strict 选项同样无法发现这个问题,因为它只在 schema 根本无法编译时才会抛出错误。稳妥的做法是:始终把 z.compile() 作为链式调用的最后一步。
通过 zod/compile 全局编译
启用默认编译后,Zod 会编译导入之后创建的每一个 schema。编译是惰性进行的,发生在该 schema 首次解析时:
import "zod/compile"; // must run before any module that defines schemas
import * as z from "zod";
const User = z.object({ name: z.string() });
User.parse({ name: "ok" }); // compiled on first parse
在 ESM 中,模块加载顺序很容易出错,因此建议让运行时优先加载该模块:
node --import zod/compile app.js # ESM
node --require zod/compile app.cjs # CommonJS
Bun 用户可以改为在 bunfig.toml 的 preload 中添加 zod/compile。全局模式面向的是应用程序,库不应该替它的使用者开启该模式。
Zod 编译的代价是什么?
Zod schema 编译有三项开销,而且对无效输入帮助不大。一次被拒绝的解析会先运行快速路径,失败后再运行完整的标准解析器,后者几乎占据了全部耗时。三项开销如下:
- 一次性的编译开销:每个 schema 都需要编译一次,发生在调用编译时;在全局模式下则发生在首次解析时。
- Bundle 体积:编译器会增加约 7 KB(gzip 后)/ 28 KB(minify 后)。根据 Zod 官方的数据表,一个包含四个键的对象 schema,带上编译器后从 24.1 KB 增加到 31.1 KB(gzip 后)。Zod Mini 的增幅更大,从 4.6 KB 增加到 13.2 KB。从未调用
z.compile()或导入zod/compile的 bundle 会在 tree-shaking 过程中完全移除编译器。 - 失败时重复执行:输入有效时,refinement 或 transform 只运行一次;输入无效时则可能运行两次。如果某个 refinement 会记录日志、计数或写入数据,那么对于一个被拒绝的 webhook,这些操作会执行两遍。
在流量持续的请求边界上,一次性的编译开销很快就能收回。但在一次性脚本或只解析一个配置文件的 CLI 中,编译开销和额外的体积可能永远无法收回。如果流量以错误输入为主,例如机器人探测或伪造的 webhook 签名,收益同样很小。
Zod 的性能提升体现在哪里?
编译带来的 Zod 性能提升随 schema 规模增大而增加。宽对象和元组受益最大,因为生成的代码会依次检查每个键,省去了标准解析器针对每个键的循环。Zod 官方基准测试对每个 schema 单独计时,在紧密循环中反复执行。标准解析器在这种场景下表现最好,因此这里的提升幅度小于同一页面顶部主图表中展示的数据。
| Schema | 提速倍数 |
|---|---|
| 对象,5 个键 | 1.8x |
| 对象,10 个键 | 2.2x |
| 对象,20 个键 | 5.0x |
| 对象,50 个键 | 10.2x |
| 元组,1 个元素 | 2.2x |
| 元组,3 个元素 | 2.5x |
| 元组,5 个元素 | 3.0x |
| 元组,10 个元素 | 3.7x |
一个只有三个字段的登录表单感受不到差别,而每次请求都要解析的 50 键事件 payload 则会有明显提升。更高的数字,例如 zod-compiler 宣称的”最高 44 倍”(无效输入最高 46 倍),来自在构建时生成校验器的独立第三方工具,并不代表 Zod 内置的运行时编译器的表现。
哪些 Zod Schema 无法编译?
Zod schema 的某些特性无法编译。z.compile() 遇到这些特性时不会抛出错误,而是静默地原样返回传入的 schema,不做任何编译。根据不支持特性列表,影响范围可能是整个 schema,也可能只是其中某个子 schema:
| 特性 | 影响 |
|---|---|
| schema 树中任意位置存在异步 refinement、transform 或 check | 整个 schema 回退 |
带回调函数的 .catch()(.catch(value) 可以编译) | 整个 schema 回退 |
| 包含不支持成员的 union | 整个 schema 回退 |
z.xor()、递归 schema、z.coerce.*、带自定义 when 的 check | 不编译 |
| object、array、tuple、record 或 intersection 中的不支持子 schema | 仅该子 schema 使用标准解析器 |
编译永远不会作用于 z.encode() 或异步解析,二者始终走标准解析器。如果你的处理函数调用的是 safeParseAsync,编译 schema 对它们没有任何帮助。
为了防止后续修改在不知不觉中让热点路径上的编译失效,可以在 CI 中使用 { strict: true } 进行编译检查:
import { test } from "node:test";
import assert from "node:assert/strict";
import * as z from "zod";
import { WebhookEvent, OrderBody } from "../src/schemas.js";
test("hot-path schemas compile", () => {
for (const schema of [WebhookEvent, OrderBody]) {
assert.doesNotThrow(() => z.compile(schema, { strict: true }));
}
});
test("async refinements are rejected under strict", () => {
const Handle = z.string().refine(async (v) => v.length > 2);
assert.throws(() => z.compile(Handle, { strict: true })); // ZodCompileAsyncError
});
设置 strict 后,异步 schema 会抛出 ZodCompileAsyncError,其他 Zod 无法编译的 schema 会抛出 ZodCompileUnsupportedError。未设置 strict 时,这两种错误都不会抛出。
z.compile() 能否在 CSP 限制下和 Cloudflare Workers 中使用?
在禁止动态代码生成的环境中,z.compile() 会安全地失败,但不会带来任何收益。如果页面的内容安全策略没有在 script-src 中包含 'unsafe-eval'(或在没有 script-src 时,default-src 中未包含),new Function() 就会被阻止。Zod 还明确指出 Cloudflare Workers 是禁止 new Function() 的环境之一。设置 jitless 后,全局模式会自动关闭:
// config.ts: import this module before any module that defines schemas
import * as z from "zod";
z.config({ jitless: true });
但手动调用 z.compile() 则不同。Zod 会将其视为明确的编译请求,因此即使开启了 jitless,仍会尝试生成代码。如果环境阻止了 new Function,你只会拿回未编译的 schema,校验功能照常工作。不过,你已经引入了约 7 KB(gzip 后)永远无法运行的编译器代码,因此在面向这类环境的构建中,应避免使用 zod/compile 和 z.compile()。
总结
编译可以在不改变结果和错误的前提下加快有效输入的校验速度,而且 schema 越宽,收益越大。建议先对请求边界进行性能分析,然后将最宽、调用最频繁的 schema 放在构建链的最后一步进行编译,并添加 strict 测试确保它们始终可编译。对于会阻止 new Function 的运行时和 CSP 策略,不要在其构建中引入编译器。另外,建议安装当前最新的 Zod 4 版本,而不要锁定在 4.5.0,以便获得编译器的后续修复。
常见问题
在 Zod 中,有没有比 safeParse 更快的拒绝无效输入的方式?
有。Zod 4.6 新增了 .validate(),它只告诉你输入是否有效,不会构建 ZodError,因此拒绝错误输入的开销非常小。对于编译后的 schema,在处理无效输入时,它最高可比 .safeParse().success 快 35 倍。它还支持类型收窄:返回 true 时,TypeScript 会将该值视为 schema 的输入类型。如果只需要“是”或“否”的结果,就使用它;如果需要详细的错误信息,则继续使用 .safeParse()。
在禁止 new Function 的环境中,能否使用预编译的 Zod 解析器?
可以,从 Zod 4.6 开始支持。z.compile() 做两件事:生成解析器,并把它挂载到 schema 上。z.withParser() 只做第二件事:你传入一个在别处生成的解析器(例如由构建步骤或原生编译器生成),它会按照与 z.compile() 相同的规则进行挂载。由于生成的代码以普通 JavaScript 的形式发布,运行时完全不需要 new Function。
z.compile() 和 zod-compiler 有什么区别?
z.compile() 内置于 Zod,在运行时于你的进程内通过 new Function() 生成校验器。zod-compiler(gajus/zod-compiler)是独立的第三方工具,通过 Vite、webpack、esbuild、Rollup 等打包工具插件或 CLI,在构建时生成校验器。它在构建时的产物是不含 eval 的普通代码,因此不受 CSP 规则限制。不过,它可选的运行时模式仍然会使用 new Function。当前版本的 zod-compiler 要求 Zod 4.5 及以上,其 1.x 版本线支持 Zod 4.0 至 4.4。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k