系统级语言跑去写 Web 页面,听起来总像是一种技术苦修。资深开发者 Lukáš Lalinský 最近公布了为 Zig 语言打造的类型安全 HTML 模板引擎 zt,官方支持 Zig 0.16 和 0.17 版本。受到 Go 社区 templ 库的启发,zt 放弃了传统的运行时字符串拼接,试图让低级语言也能端出高安全性的 HTML 页面。

很多人对 Zig 的第一反应,是其能力近乎万能的编译期求值机制 comptime。但 zt 最值得玩味的地方在于:它明确绕开纯 comptime,转而在 zig build 阶段引入独立转译步骤,把 .zt 模板文件重写成原生 .zig 代码。这不是退步,而是撞上语言底层边界后的务实妥协。

zt 构建期转译与渲染管线 .zt 模板 HTML + Zig 语法 zig build 阶段 源码转译器 .zig 源码 静态类型结构体 流式直写 std.Io.Writer

撞上 comptime 的表达力天花板

常识通常认为,Zig 凭借强大的 comptime,足以在内联宏和类型反射层面搞定一切 DSL。但在任意表达式模板这个场景下,这是一种认知偏差。

构建期预处理绕过纯编译期限制,将模板转为严密契合的底层结构
构建期预处理绕过纯编译期限制,将模板转为严密契合的底层结构

Zig 的 comptime 擅长对常量进行类型反射、展开循环或静态检验,但它有一个根本约束:无法动态求值任意字符串并生成新符号调用。它没有类似 Rust 的过程宏系统,无法在单一文件中把夹杂在 HTML 里的动态表达式无缝注入当前作用域。

如果坚持纯 comptime,作者就只能退化为受限的 AST 解释器,或者发明一套别扭的内部 DSL。zt 放弃了这条死胡同,直接采用构建步代码生成:

  • 在构建期间将模板转译为独立的 `.zig` 代码。
  • 允许模板像原生代码一样 @import 数据模型。
  • 将符号解析与类型推导完整丢给 Zig 编译器。

这样一来,类型错误直接变成了 Zig 编译错误。当开发者误写了结构体字段名,编译器会像对待原生代码一样拒绝通过。zt 为每个模板生成包含 render(args, writer) !void、bind(args) 及 Args 元组类型的结构体,完全由编译器保证类型安全。

零分配写入与架构分歧

在渲染层面,zt 宣称其渲染过程无状态、零内存分配(no allocations),且没有任何模板解释带来的运行时开销。所有的动态渲染,最终都退化为对 std.Io.Writer 的直接写入,无论底层是一个网络套接字还是预分配的内存块。

无状态直通套接字避免了中间内存树,实现零内存分配写入
无状态直通套接字避免了中间内存树,实现零内存分配写入

这种极致克制的取向,与生态内的其他探索形成了鲜明对照:

Zig Web 渲染方案对比 zt(转译流式模型) • 渲染机制:直写 std.Io.Writer • 内存占用:零额外分配,无中间树 • 编译管线:独立 .zt 转译步骤 • 权衡代价:语法存在边界歧义 Ziex(JSX 组件树模型) • 渲染机制:内存构建 DOM 树结构 • 内存占用:依赖 arena 分配器管理 • 编译管线:偏向运行时与 WASM • 权衡代价:存在中间状态内存开销

同类项目 Ziex 借鉴了 JSX 语法并构建完整的组件树,即便借助 arena 分配器可以成批释放,中间状态依然不可避免;Rust 可以借助过程宏优雅地把模板和业务逻辑揉进一个 .rs 文件。zt 既没有过程宏的便利,也不想要组件树的内存开销,它选择了一条极其单向度的流水线:只管把字节流推出去。

不过需要注意,官方 README 目前尚未提供量化的基准测试数据,这套零分配承诺在真实的长列表和复杂嵌套组件中能榨出多少性能优势,仍有待生产环境检验。

在安全性上,zt 默认对所有动态输出进行 HTML 转义,并限制常见危险属性。开发者如果需要渲染原始 HTML,必须显式调用 formatHtml 或使用 {!expression} 语法绕过。这种严格的白名单机制杜绝了大多数粗心的反射型 XSS,但对内联 JavaScript 等特定上下文依然无能为力。


务实工程背后的妥协与暗流

这种为了维持极低开销和类型安全而拼装出来的架构,必然要付出人体工程学上的代价。

粗粝的轻量骨架直连后端界面,替代沉重复杂的前端基础设施(示意图)
粗粝的轻量骨架直连后端界面,替代沉重复杂的前端基础设施(示意图)

最直观的痛点是诊断断层。当模板发生类型不匹配时,报错发生在生成的 .zig 临时文件中,开发者只能依靠自动生成的行号注释人工对齐回原始 .zt 文件。这并不是真正的一等公民调试体验。

缺乏过程宏的系统级语言,总要有人在开发体验与运行时性能之间充当垫脚石。

语法层面的边界冲突也摆在眼前。由于 zt 沿用了 Templ 风格的代码块判定,只要文本行以 if、for 或 switch 开头,解析器就会当成 Zig 代码,导致纯文本诸如 if you forgot password 直接触发语法报错。项目正在 Issue #27 中讨论引入 @if、@for、@switch 这种破坏性变更来消除歧义。

  • 提醒.zt 仍处于极早期阶段,跨文件排错体验与控制流语法的潜在变更是现阶段上车的主要成本。

然而,这套工具的出现并非孤立的技术炫技。它背后是整个全栈领域正在发生的一股暗流:伴随 htmx 和 Datastar 等后端驱动 UI 架构的扩散,前后端分离带来的厚重 JavaScript 基础设施正在受到审视。系统程序员开始重新审视原本被脚本语言垄断的呈现层。

工欲善其事,必先利其器。zt 并不优雅,它甚至带着浓重的缝合感,但它展现了一种清晰的工程现实主义:不用为不存在的宏系统叹息,在现有工具链的约束下,把代码转译和类型检查串联起来,同样能拼出一副能用的全栈骨架。