12k
All articles

使用编译 Schema 提升 Zod 校验性能

了解Zod编译模式如何加快验证、何时收益明显,以及如何应对编译成本、不支持的模式、CSP限制和Cloudflare Workers环境。

OpenReplay Team
OpenReplay Team
使用编译 Schema 提升 Zod 校验性能

从 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 编译有三项开销,而且对无效输入帮助不大。一次被拒绝的解析会先运行快速路径,失败后再运行完整的标准解析器,后者几乎占据了全部耗时。三项开销如下:

  1. 一次性的编译开销:每个 schema 都需要编译一次,发生在调用编译时;在全局模式下则发生在首次解析时。
  2. 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 过程中完全移除编译器。
  3. 失败时重复执行:输入有效时,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。

DevTools for the frontend

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

We use cookies to improve your experience. By using our site, you accept cookies.