Vercel Native SDK 深度解剖:没有 WebView、没有 JS 运行时,用 Zig 把 TypeScript 编译成原生桌面应用
做了十几年桌面客户端,我一直觉得这个领域有个绕不开的死结:表达力和性能,你只能选一个。想要 Web 那套灵活的 UI 表达能力和快速迭代节奏,就得背上 Electron 那个几百兆的 Chromium 运行时;想要真正原生的性能和小巧的包体积,就得回去写 Qt、写 SwiftUI、写 Win32,然后跟"一次编写到处运行"说再见。
Vercel Labs 最近开源的这个项目(早期代号 zero-native,现在正式名字叫 Native SDK)想干的事情很激进:它不是又一个 Electron 套壳,也不是 Tauri 那种"用系统 WebView 省内存"的路子——它干脆把 WebView 和 JS 运行时整个从二进制里删掉了。视图用声明式的 .native 标记语言写,逻辑用 TypeScript 写但在构建期编译成原生代码,然后由一个用 Zig 写的渲染引擎亲手把每个像素画进真实的操作系统窗口里。
这篇文章我想从工程师视角,把这套架构彻底拆开讲清楚:它到底怎么做到"没有浏览器却能用 Web 的方式写 UI",TypeScript 编译成原生代码这件事的真相和边界在哪,它的 Elm 式状态循环为什么值得学,以及它对 Tauri/Electron/Flutter 意味着什么。全程配代码,尽量不讲空话。
一、先说清楚问题:桌面 GUI 的三条老路都不太行
要理解 Native SDK 的价值,得先把现有方案的坑摊开看。桌面跨平台 GUI 这些年基本就三条路线:
1. Electron 路线:把整个浏览器打包进去
Electron 的思路简单粗暴——每个 app 内置一份 Chromium + Node.js。好处是 Web 开发者零成本上手,生态无敌;坏处是每个"记事本级别"的应用都要背 Chromium 全家桶。一个最小 Electron app 动辄 80–150MB,空窗口内存占用轻松上百 MB,几十个 Electron 应用同时开着,你的内存条会哭。
更本质的问题是:你在用一个为"跑任意不受信任网页"设计的重型沙箱,去跑一个你完全掌控的可信应用。这里有巨大的资源错配。
2. Tauri 路线:复用系统 WebView
Tauri 学聪明了:不打包 Chromium,改用操作系统自带的 WebView(macOS 的 WKWebView、Windows 的 WebView2、Linux 的 WebKitGTK),后端用 Rust。包体积能压到几 MB,内存也小很多。
但这条路有个绕不过去的痛点:渲染一致性。三个平台三套 WebView 内核,CSS 行为、字体渲染、滚动手感、API 支持度全都有微妙差异。你在 macOS 上调好的界面,到 Linux 的 WebKitGTK 上可能就错位了。为了追一致性,很多团队又退回去打包 CEF(Chromium Embedded Framework),于是包体积和内存又涨回来了——绕了一圈。
3. Flutter / 原生渲染路线:自己画每个像素
Flutter 走的是"自绘引擎"路线:不用系统控件,Skia 自己画。一致性问题解决了,性能也不错。代价是你得学 Dart、学一整套 Widget 体系,而且"自己画一切"意味着系统级的滚动物理、输入法(IME)、右键菜单、无障碍这些东西都要引擎重新实现,做不好就有"不像原生"的塑料感。
Native SDK 的定位,某种意义上是**"Flutter 的自绘架构 + Web 的声明式表达力 + Elm 的状态模型 + Zig 的系统级性能"**的一次缝合,而且它在"哪些交给引擎、哪些还给操作系统"这个分界线上做了非常克制的取舍。官方 README 里那句话说得很直白:
Native SDK exists because expressive UI and native performance should not be competing goals.
(表达力和原生性能不该是互相竞争的目标。)
二、核心架构:三个文件就是一个应用
Native SDK 最反直觉、也最值得讲的一点是它的编译模型。我们先看它长什么样,再讲底下发生了什么。
装 CLI,起一个项目:
npm install -g @native-sdk/cli
native init my_app
cd my_app
native dev
一个原生窗口弹出来,里面是一个能用的计数器。整个 app 官方称之为 "three files of truth"——视图、逻辑、清单三个文件,没有构建配置。
2.1 视图层:.native 声明式标记
视图写在 src/app.native 里,是一种类 HTML 的标记语言,支持 flex 布局、{绑定} 和表达式。计数器的核心那一行:
<row gap="8" main="center" cross="center" grow="1">
<button variant="secondary" on-press="decrement">-</button>
<text>{count}</text>
<button variant="primary" on-press="increment">+</button>
</row>
注意几个设计细节:
row/text/button是内置元素,不是 HTML 标签——它们最终映射到引擎的原生绘制原语,不是 DOM 节点。{count}是数据绑定,读取的是状态模型里的字段。on-press="increment"派发的是一个消息(message),而不是直接改状态。这一点是整个架构的灵魂,后面细讲。main/cross/grow是 flex 布局语义,Web 开发者一眼就懂。
关键在于:这些标记不是运行时解析的。它们在 native build 时被编译进可执行文件。所以 release 二进制里既没有 HTML 解析器,也没有模板引擎——标记语言只是"作者友好的输入格式",运行期它已经变成了机器码里的绘制指令。
2.2 逻辑层:TypeScript,但编译成原生代码
所有逻辑住在 src/core.ts:一个 Model 接口、一个 Msg 联合类型、以及一个纯函数 update——这是状态唯一能改变的地方:
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) {
case "increment":
return { ...model, count: model.count + 1 };
case "decrement":
return { ...model, count: model.count - 1 };
case "reset":
return { ...model, count: 0 };
}
}
看到这里熟悉 Elm 或 Redux 的人应该会心一笑:这就是经典的 Model-View-Update(MVU)单向数据流。事件产生消息,消息经过 update 生成新状态,新状态驱动视图重绘。标记可以绑定和派发,但永远不能直接改状态。
而这里最颠覆认知的一句话是官方原文:
logic is plain TypeScript compiled to native code at build time
(逻辑是普通 TypeScript,在构建期被编译成原生代码。)
也就是说,你写的 TypeScript 不是打包进一个 JS 引擎里解释执行,而是在构建阶段被翻译成原生机器码,塞进那个 Zig 引擎的二进制里。release build 里没有 V8、没有 QuickJS、没有任何 JS 运行时。这也是它包体积能压到"几 MB"的根本原因。
2.3 关于"TypeScript 编译成原生代码"的真相与边界
这里我必须诚实地给个工程判断,因为这是全文最容易被神话、也最容易被误读的地方。
你不可能把任意 TypeScript 都 AOT 编译成高效原生代码——TypeScript 的类型系统是可擦除的、运行时是动态的、有 eval、有原型链、有 any。真要通用 AOT,等于重新实现一个 JS 引擎,那就自相矛盾了。
Native SDK 的做法(从文档结构能推断出来,官方把这块叫 "the app-core subset")是:只支持 TypeScript 的一个受约束子集,专门用来写这个 Model / Msg / update 纯逻辑核心。这个子集大概率:
- 要求
update是纯函数,输入输出都是可静态确定形状的数据; - 副作用(写文件、网络、定时器)不能在
update里直接做,而要走一个专门的 effects channel / subscriptions 机制(记事本例子里的持久化就是走这条通道); - 禁用或限制动态特性,让编译器能把它映射到 Zig/原生的静态数据结构上。
换句话说,它给你的是"TypeScript 的语法和类型检查体验"+"原生代码的执行性能",代价是你只能在一个规矩的子集里写核心逻辑。这是个非常聪明的工程权衡:UI 应用的状态逻辑本来就应该是纯的、可预测的,限制反而成了护栏。
它还留了个后门给系统程序员——如果你嫌 TS 子集不够,可以直接用 Zig 写核心:
native init my_app --template zig-core
同样的 MVU 循环,src/main.zig 换掉 src/core.ts,"first-class by choice"(Zig 是一等公民,不是降级选项)。
三、为什么是 Zig:与 C 无缝互操作 + 增量编译
引擎和编译目标选 Zig 不是赶时髦。对这种"要画每个像素、要跟三个操作系统的原生 API 打交道"的项目,Zig 有几个别人给不了的优势:
1. 与 C 库零成本互操作。 Zig 可以直接 @cImport C 头文件,不需要 FFI 绑定层。这对桌面引擎是刚需——Metal、Win32、WebKitGTK、CEF 这些系统能力全是 C/ObjC/C++ 接口。Zig 能直接调,没有 Rust 那种 unsafe extern + bindgen 的中间层摩擦。
2. 编译期计算(comptime)。 Zig 的 comptime 让"把 .native 标记和 TS 逻辑在构建期编译成静态结构"这件事有了天然的宿主。很多本该运行时做的事(布局树构建、组件注册、令牌解析)可以提前到编译期。
3. 快速增量编译。 README 强调 "Instant rebuilds"。桌面 UI 开发最痛的就是改一行等半天,Zig 的编译速度和增量能力让 native dev 的热重载能做到"改 .native 文件窗口原地更新且保留状态"。
4. 小、可控、无隐藏成本。 Zig 没有 GC、没有隐藏的内存分配、没有庞大 runtime。这直接翻译成"几 MB 的单文件二进制"和"极低内存占用"。
一句话:Native SDK 用 Zig,是因为它要同时扮演编译器宿主和系统级渲染引擎两个角色,而 Zig 恰好是这两件事都能干、还能干得很轻的少数语言之一。
四、代码实战:从计数器到十万行虚拟列表
光看计数器不够,官方 examples/ 里几个例子恰好展示了这套架构在真实复杂度下的样子。我挑三个最有代表性的讲。
4.1 Notes:副作用怎么处理(effects channel)
记事本要持久化——写磁盘。但 update 是纯函数,不能直接写文件。Native SDK 的解法是把副作用建模成数据:update 除了返回新状态,还可以返回一批"要执行的效果",由运行时(引擎侧)去真正执行,执行完再把结果作为新消息喂回 update。
伪代码大概是这个形状(基于 MVU 通用模式重建,用于说明机制):
type Effect =
| { kind: "save"; path: string; content: string }
| { kind: "load"; path: string };
// update 返回 [新状态, 要执行的副作用列表]
export function update(model: Model, msg: Msg): [Model, Effect[]] {
switch (msg.kind) {
case "editNote": {
const next = { ...model, draft: msg.text };
// 防抖写盘:交给运行时去做,update 本身依旧是纯的
return [next, [{ kind: "save", path: model.currentPath, content: msg.text }]];
}
case "saved":
return [{ ...model, dirty: false }, []];
case "booted":
return [model, [{ kind: "load", path: model.currentPath }]];
default:
return [model, []];
}
}
这套设计的妙处:update 永远可测试(给定输入,输出固定),所有"脏活"都被隔离在引擎的效果执行器里。README 里说 notes 例子演示的正是 "debounced writes, restore on boot, dialogs, search"——防抖写入、开机恢复、对话框、搜索,全都通过这条 effects channel 走。
4.2 Feed:十万行列表,运行时拥有滚动
feed 例子是一个 100,000 行的虚拟化列表,滚动由运行时(引擎)拥有。这一点在 Web 方案里是老大难:DOM 虚拟列表要自己算可视区、自己回收节点、还要跟浏览器的滚动惯性打架,稍不注意就掉帧。
Native SDK 因为是自绘引擎 + 原生滚动物理,虚拟化可以做在引擎层:只为可视窗口内的行生成绘制指令,滚动交给操作系统的滚动物理("runtime-owned scrolling")。开发者侧的心智负担几乎为零——你声明"这是一个有十万条数据的列表",引擎负责只画你看得见的那几十行。这是自绘架构相对 WebView 的结构性优势。
4.3 Soundboard vs Deck:设计令牌驱动的换肤
这两个例子是同一个音乐播放器,只差一套 design tokens 和一层 chrome。Native SDK 的样式系统是端到端的设计令牌:颜色、圆角、字体全部按名字解析(color.primary、radius.md 这种),主题切换时实时重新解析,还能整套替换。
<!-- 组件只引用语义化令牌,不写死具体值 -->
<button
background="{color.accent}"
radius="{radius.md}"
text-color="{color.on-accent}">
播放
</button>
// tokens.json —— 换肤只换这里
{
"color": { "accent": "#7C3AED", "on-accent": "#FFFFFF" },
"radius": { "md": "10" }
}
结果就是:soundboard(现代播放器)和 deck(做成硬件调音台外观的同一个播放器)共享全部组件和逻辑,视觉完全不同却零重复代码。这种"同一份逻辑 + 可替换视觉皮肤"的能力,对做 white-label 产品或多品牌 app 的团队价值极大。
五、确定性渲染:record / replay 与可测试的 UI
这是我个人最欣赏的一块设计,也是很多桌面框架做不到的。
因为整个状态循环是确定性的(事件→消息→状态→渲染,无隐藏可变状态),Native SDK 可以做到:
native automate record # 录制一次真实操作会话
native automate replay # 无头复现,逐帧对着 state fingerprint 校验
它录制的不是像素视频,而是消息序列 + 状态指纹(state fingerprint)。replay 时在无头环境重新跑一遍 update,逐帧比对状态哈希。这意味着:
- UI 测试可以在任何机器上无头跑(CI 友好),不依赖真实显示器;
- 回归测试极其稳定——不是"截图像素比对"这种脆弱方案,而是"状态语义比对";
- 出 bug 时,一段录制就是完美的复现脚本。
README 里那句 "verified frame by frame against state fingerprints"(逐帧对状态指纹校验)就是这个意思。做过 Selenium/Playwright 那种 flaky UI 测试的人应该懂这有多香。
配套的 native check 更狠:它在毫秒级校验每个 .native 视图是否和你真实的 Model / Msg 对得上——绑定字段存不存在、可迭代对象对不对、消息标签合不合法,全部静态检查,报错带 file:line:column。相当于把"运行时才会炸的 UI 错误"提前到了编译期。
六、AI 原生:每个 app 自带自动化服务器
Native SDK 有个很"2026 年"的设计取向——它把 AI agent 当成一等公民的使用者。
每个 app 都内嵌一个 automation server,任何 agent(Claude、Copilot、你自己写的脚本)都可以:
- 读取无障碍快照(accessibility snapshot),理解界面结构;
- 驱动控件(点按钮、填输入框);
- 对实时状态做断言;
- 对运行中的窗口拍确定性截图。
而且无障碍信息会在 native check 里被机器校验,CLI 还直接带了教 agent 怎么用这套东西的 skills(native skills list)。
这背后的逻辑很清楚:如果你的 app 状态是确定性的、可快照的、可断言的,那么"人和 AI 一起构建软件"就从口号变成了工程现实。agent 不用去猜屏幕上像素的含义,它能直接读到结构化的 accessibility tree 和 state。这跟传统桌面 app "对自动化极不友好"形成鲜明对比。
七、跨平台现状:别被"跨平台"三个字骗了
跨平台框架最容易夸大其词,这里给个冷静的现状盘点(基于官方 platform-support 描述):
| 平台 | 成熟度 | 关键能力 |
|---|---|---|
| macOS | 主力平台,最深 | Metal 呈现、系统滚动物理、原生右键菜单、应用菜单、托盘、对话框 |
| Linux | 完整跑通 | 确定性软件渲染器画真实窗口,指针/键盘/滚动/原生右键菜单/IME 输入法/HiDPI |
| Windows | CI 覆盖 | Win32 宿主,原生右键菜单、IME,CI 里有真实输入注入测试 |
| iOS | 实验性 | 通过 embed 库在模拟器验证 |
| Android | 实验性 | 交叉编译带完整 embed ABI,但 API 和工具链还在演进 |
划重点:桌面(尤其 macOS)是成熟面,移动端是实验面。macOS 用 Metal 硬件呈现,Linux 目前是确定性软件渲染器(保证一致性但吃 CPU),Windows 走 Win32。而且在每个桌面平台上,WebView surface 是可以和原生绘制共存的——也就是说你可以在一个原生窗口里嵌一块 WebView 去渲染富文本或第三方 Web 内容,两种渲染路径混用。这个"渐进逃生舱"设计很务实。
一个重要提醒:项目现在是 pre-1.0,API 还在动。README 自己说了 "APIs still move, and the toolkit is evolving quickly"。想上生产的话,桌面端可以做原型和内部工具,移动端和长期 API 稳定性得再等等。
八、横向对比:它到底站在哪
把它和主流方案摆一起,差异一目了然:
| 维度 | Electron | Tauri | Flutter | Native SDK |
|---|---|---|---|---|
| 渲染方式 | 打包 Chromium | 系统 WebView | Skia 自绘 | 自绘引擎(Zig) |
| 二进制里有无 JS runtime | 有(V8+Node) | 有(系统 JS 引擎) | 无(Dart AOT) | 无 |
| UI 表达方式 | HTML/CSS/JS | HTML/CSS/JS | Dart Widget | .native 标记 + TS/Zig |
| 逻辑语言 | JS/TS(解释) | JS/TS + Rust | Dart | TS 子集编译成原生 / Zig |
| 包体积 | 很大(80MB+) | 小(几 MB) | 中(20MB+) | 很小(几 MB) |
| 渲染一致性 | 高(自带 Chromium) | 低(三套 WebView) | 高 | 高(自绘) |
| 状态模型 | 自选 | 自选 | 自选(多方案) | 强制 MVU 单向流 |
| 学习曲线 | 低(Web 直接来) | 中 | 中高(学 Dart) | 中(学标记 + TS 子集) |
| AI/自动化友好度 | 一般 | 一般 | 一般 | 原生内建 |
它最独特的三个点:
- 既没有 WebView 也没有 JS 运行时,却保留了 Web 式的声明表达力——这是它区别于 Electron/Tauri 的根本;
- 强制的 MVU 架构 + 确定性渲染,让 UI 变得可测试、可录制回放、可被 AI 驱动——这是它区别于 Flutter 的地方;
- TypeScript 子集直接编译成原生代码——这是它区别于所有人的地方,也是最激进、最需要观望的地方。
九、局限与冷思考
吹了这么多,得泼点冷水。作为一个可能要押上项目的工程师,你需要清楚它的边界:
1. TS 子集是把双刃剑。 "普通 TypeScript 编译成原生"听起来很美,但你写的是受限子集,不是完整 TS。你熟悉的很多库、动态特性、npm 生态大概率用不了在核心逻辑里。这意味着你不能把现有 React/TS 代码直接搬过来——心智模型要重建。它更像"用 TS 语法写 Elm",而不是"把你的 Web app 编译成原生"。
2. 生态几乎从零。 Electron/Tauri 背后是整个 npm 和 Web 生态,Flutter 有 pub.dev。Native SDK 现在只有官方内置组件目录,第三方组件、主题、工具链基本是空白。你遇到问题时能搜到的答案极少。
3. pre-1.0 的稳定性风险。 API 会变,升级可能破坏你的代码。现在适合做原型、内部工具、技术预研,不适合押注长周期商业产品。
4. 移动端还是 PPT 级别。 iOS 只在模拟器验证过,Android 只是能交叉编译。要做移动端生产应用,现在别碰。
5. 自绘的原生感永远要打问号。 虽然它很克制地把滚动物理、菜单、IME、对话框还给了操作系统(这比 Flutter 早期强很多),但自绘引擎和真正的系统控件之间,总会有些说不清的细微差异。像素级的"原生手感"是个需要长期打磨的深坑。
我的判断是:Native SDK 押注的方向是对的——桌面 GUI 确实需要一个"轻、快、可测试、AI 友好"的新范式,而"删掉运行时、编译期确定一切"是通往这个目标的合理路径。但它现在是一个有远见的 pre-1.0 实验,而不是一个可以立刻上生产的成熟框架。值得学、值得试、值得跟,但先别 all in。
十、总结:这可能是桌面 GUI 的一次范式预演
把这套东西拉远了看,Native SDK 真正有意思的不是"又一个跨平台框架",而是它同时押了三个趋势:
- 编译期确定一切:把解析、模板、运行时能删的全删掉,release 里只剩机器码。这是对"运行时越来越重"的一次反叛。
- 强约束换可预测性:强制 MVU、纯
update、副作用建模成数据、确定性渲染——用架构约束换来了可测试性和可回放性。 - 为人机协作而设计:内建 automation server、机器可校验的无障碍、随 CLI 分发的 agent skills,把"AI 参与构建软件"当成默认场景而非事后补丁。
对我们这些天天在 Electron 的内存和 Tauri 的一致性之间反复横跳的人来说,它至少证明了一件事:表达力和原生性能,真的可以不是对立的。Zig 做引擎宿主、TS 子集编译成原生、Elm 式单向流、自绘但把系统能力还给系统——这套组合拳打得很有想法。
技术选型上,我给的建议很明确:现在就去 npm install -g @native-sdk/cli 跑一遍那个计数器和 feed 例子,感受一下"几 MB 的原生窗口 + 十万行丝滑滚动"是什么体验;用它做个内部小工具练手,把 MVU 和 effects channel 的心智模型吃透。但生产项目,等它到 1.0、等生态长出来、等移动端不再是实验品——再说。
技术的价值不在于它今天能不能替代谁,而在于它有没有指出一个更好的方向。Native SDK 指出的这个方向——轻量、确定、可测试、AI 原生的桌面 GUI——我赌它是对的。剩下的,交给时间和社区。