我大概从2000年、PHP 3.0发布并开始席卷Web世界后入行,用过各种生成HTML的方式;后来转向单页应用和JavaScript框架,就不再做前端了。两年前我接触Zig,并在用zio和Dusty搭建为API后端输出JSON或MessagePack的库时发现,配合arena分配器,连Web开发也可管理,因为不用记得释放大量字符串;Zig的comptime还能兼顾高级接口与性能,借助htmx或Datastar甚至能不碰JavaScript/TypeScript做出单页应用,而碰JavaScript/TypeScript正是我曾发誓绝不做的。
正文
我做过很多年 Web 开发。大约 2000 年 PHP 3.0 发布并开始席卷网络时,我就入行了。我用过各种生成 HTML 的方式:原生 PHP、Python 里拼字符串、PHP 的 Smarty、Perl 的 Template Toolkit,以及 Python 的 Jinja/Django 模板。后来世界转向 SPA 和 JavaScript 框架,我就不再做前端了。
两年前我开始用 Zig,从没想过自己会考虑用这种底层语言做 Web 开发。但在构建自己的 zio 和 Dusty 生态时,我发现这并不离谱。这两个库本来是给 API 后端用的,输出是 JSON 或 MessagePack;但我发现只要用好 arena allocator,Web 开发其实也能驾驭,因为不必记得去释放通常涉及的所有字符串。Zig 的 comptime 让你能构建非常高层接口又不损失性能。再加上 htmx 或 Datastar,你甚至可以不碰 JavaScript/TypeScript 就做出 SPA——我以前发过誓绝不碰那些东西。
话说回来,还缺一样东西:模板。字符串拼接不能接受,我不想退回 2000 年前的 CGI 时代。我对模板库有几个要求:
- 必须是类型安全的,所有输入都有类型并在编译期检查。我这辈子受够了动态语言,不想再遇到运行时模板错误。
- 必须快。不分配内存、不产生中间状态,直接把输出写到
std.Io.Writer。 - 要有安全的默认值。我不想再处理 XSS,所以所有输入默认都要转义。
- 写起来还得像在写 HTML。我不想要一个需要不断去对抗的、基于 Zig 的 DSL。
我找不到能同时满足这些要求的方案。后来我看到 Ziex,非常惊讶。我从没想过 JSX 那种做法能在 JavaScript 之外实现。Nurul Huda 在 Zig 里做出的开发体验,对我来说简直不可思议。它并不直接符合我的需求,因为我不想要一个 WASM 框架,但它的语法以及把 Zig 代码集成进模板的思路给了我一些启发。之后我进一步调研,发现了 Go 的 Templ,于是我很清楚自己要做什么了。
所以,我来介绍一下 zt(抱歉,我不擅长起名)。它是 Templ 到 Zig 的移植。模板会在 zig build 期间被解析并转译成 Zig 代码。这意味着你可以直接从应用代码里导入模板;也许更重要的是,模板也能导入你的应用代码。因此所有参数都能完全类型化。如果有什么东西无法渲染,Zig 编译器会告诉你。生成的代码相当简单,只是往 std.Io.Writer 写东西。你可以直接写进 socket,也可以把输出攒在 buffer 里,随你选。下面是一个模板的样子:
const User = @import("../models.zig").User;
pub templ Layout(title: []const u8) {
<!DOCTYPE html>
<html>
<head>
<title>{title}</title>
</head>
<body>
@children
</body>
</html>
}
pub templ HomePage(user: ?User) {
@Layout("Home") {
if (user) |u| {
<h1>Welcome back, {u.name}!</h1>
<a href="/users/{u.id}">Your profile</a>
} else {
<h1>Welcome, guest!</h1>
<a href="/login">Log in</a>
}
}
}
下面是怎么在 Zig 代码里使用它:
const std = @import("std");
const User = @import("models.zig").User;
const pages = @import("templates/pages.zig");
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var stdout = std.Io.File.stdout().writer(init.io, &buf);
const w = &stdout.interface;
const user: User = .{ .id = 1, .name = "Lukas" };
try pages.HomePage.render(.{user}, w);
try w.flush();
}
如果你想在 Dusty 里用它渲染 HTML 页面,只要这样写:
try res.render(.html, pages.HomePage, .{user});
顺带一提,上面那段 main 代码里有一个关于错误处理的非常不明显的 bug——如果你在某个需要关心 std.Io cancellation 的地方用了同样的写法的话。我可能会写一篇博客讲它,如果你感兴趣,可以看这个 Ziggit 帖子。Dusty 里包装了 std.Io.Writer 处理的 render 函数解决了这个问题。
所有动态值默认都会做 HTML 转义,文本和属性里都是如此;放在 href、src 这类 URL 属性里的值还会被检查是否含有危险 scheme。如果你确实需要输出原始 HTML,必须显式用 {!value},这样在模板里很容易看出来。解析器还要求所有元素都正确闭合,所以你不会不小心产生损坏的标记。它挡不住所有问题,但去掉了最常见的 XSS 来源。
我最喜欢的一点是:模板错误就是 Zig 编译错误。如果你在上面的模板里打错字,把 {u.name} 写成 {u.nmae},zig build 会告诉你:
src/templates/pages.zig:45:43: error: no field named 'nmae' in struct 'models.User'
try zt.writeEscaped(writer, u.nmae); // pages.zt:18:31
^~~~
src/models.zig:1:18: note: struct declared here
pub const User = struct {
^~~~~~
错误指向生成后的代码,但每一行都带有注释标明它在原模板里的位置。调用侧也一样:如果你试图在 HomePage 期望 ?User 的地方传一个字符串,编译就过不了。
除了这里展示的基础功能,你还能构建可复用组件,把它们当作值传来传去;用 @children 可以做出包裹其他模板的布局,这覆盖了其他语言里模板继承的用途。控制流就是 Zig 本身,所以 if、for、switch 都按你预期工作,包括 payload capture。所有这些最终仍会编译成一连串写操作,没有任何分配。
你可以看看 Dusty 里的 TODO 示例。它是一个基于 htmx 的简单 Web 应用,展示了这个库,以及构建动态 SPA 所需的 Dusty 其他部分。
语法里还有一处没定下来。块级控制流按 Templ 的例子写成普通 Zig,所以以 if、for 或 switch 开头的一行会被解析成代码。这意味着像 “if you forgot your password…” 这样单独成行的文本会引发解析错误,你得把它包进某个元素,或者写成字符串表达式。我在考虑改成 @if、@for、@switch,类似 Razor 或 Twirl,这样能消除歧义,代价是模板会稍微啰嗦一点。如果你对此有想法,请在 这个 GitHub issue 里留言。
虽然已经有多人在用了,但项目还不是很成熟,欢迎任何反馈。发现问题可以开 GitHub issue,或者在 Zig Discord 上找我。
评论
社区讨论气氛轻松且带有技术怀旧色彩,分歧主轴在于是否值得为 Web 开发引入新的类型安全模板引擎:认可者把它类比到 Rust 宏、jte.gg、Soy 或 Kotlin 的类型安全方案,质疑者则认为直接输出字符串、用 m4 宏或最朴素的 HTML 包含就已足够,不需要另发明语法;也有人借机批评 Zig 标准库仍不稳定。
认为类型安全模板是合理探索,并能在其他语言中找到先例
- 不少人对文章思路表示认可,认为在编译期保证类型安全、由语言原生表达式直接生成 HTML 是优点;这种思路并非孤例,Rust 的宏、Java 的 jte.gg、Kotlin 自建引擎(反向生成以值为参数的模板类,带来完整 IDE 支持和零运行时解析,不过作者自称目前只有自己使用、不建议上生产),以及类型安全的 TSX、Google Soy、Go 模板和 Helm 模板都早已提供类似能力,有的还内置 HTML/JS 转义。(较多人)
- 需要引用时: > Rust 的宏可以直接在代码里描述组件,不必引入单独的文件类型或另发明一套语法。(@slopinthebag)
质疑模板引擎必要性,主张直接输出字符串
- 有人坦言从未理解模板引擎的价值,平时只是在普通代码中穿插输出原始字符串字面量来拼 HTML,认为这样最直接,不需要额外的模板层或语法。(少数人) > 我一直不理解模板引擎的意义,直接在所用语言里输出原始字符串来生成 HTML 就够了。(@jaxxzi)
认为最朴素的 HTML 原生包含就能解决问题
- 有人以调侃口吻说,只要 W3C 让浏览器原生支持类似 includes src 指向 ./header.html 的包含语法,模板引擎这类东西就完全没必要了,这代表了一部分人“别把简单事情复杂化”的情绪。(少数人) > 为什么 W3C 不能直接让 HTML 用 includes src="./header.html" 把问题彻底解决?(@fergie)
认为 Zig 生态与标准库尚不成熟,拿它做 Web 开发有隐患
- 有人借楼主贴出的讨论帖指出,Zig 标准库看起来 bug 不少,备受好评的 IO 接口在取消(cancellation)处理上也显得吃力,核心维护者对此回应有限;这暗示在 Zig 上构建 Web 方案目前仍有风险。(少数人) > Zig 标准库似乎有不少 bug,被广泛称赞的 IO 接口在取消处理上也很挣扎。(@orf)
有价值的补充
- 早期 Web 开发本就大量使用 C、Perl、Tcl 甚至 ATL Server、ISAPI 等底层或系统级语言与组件,PHP 3 时代也常写 C 扩展;因此在脚本语言接管之前,底层语言做 Web 并非新鲜事,如今用 Zig 做 Web 更像是一种历史回摆。(@pjmlp)
- 古老的 m4 宏处理器可用 sinclude 静默包含文件,还能用 syscmd 调用 shell 命令(如生成日期)来拼页面,是比现代模板引擎更“原始”的文本生成方案。(@vogelke)