~/robin $ cat posts/nsh-cross-platform-ui.md

再探跨平台 UI:NSH

2026.11.09·阅读约 5 分钟·1,698 字·#OpenSource·#programming

做跨端应用,动工第一天就得做一道选择题:界面到底怎么画出来。

date
Nov 9, 2026
Lang
zh
slug
nsh-cross-platform-ui
status
Published
tags
OpenSource
programming
summary
做跨端应用,动工第一天就得做一道选择题:界面到底怎么画出来。
type
Post
做跨端应用,动工第一天就得做一道选择题:界面到底怎么画出来。
一份代码连到 macOS、Windows、Linux、iOS、Android 和 Web,各自用系统原生的界面
一份代码连到 macOS、Windows、Linux、iOS、Android 和 Web,各自用系统原生的界面
我一直都坚信,任何技术方案的出现,都是为了以更好的方式解决问题。过去跨平台 UI 基本就三条路,每条都有得咽下去的妥协。
Electron、Tauri,本质是在桌面和手机里跑网页。我前端出身,这条路最熟,写起来也省心。但总觉得隔了一层,内存和手感都带着浏览器的痕迹。
Flutter、Slint、GPUI,干脆不用系统控件,自己把按钮、文字、输入框一个个画出来。好处是每个平台长得一模一样。代价是系统看不懂这些像素,文本编辑、输入法候选框、屏幕阅读器,都得框架自己从头再接一遍。
React Native 倒是用了原生视图,但整套体系以 JavaScript 为核心,主场在手机。想顺手覆盖桌面和 Web,得靠别的项目去拼。
Native Shell(NSH) 想走的是另一条路。
它是一个 Apache-2.0、以 Rust 为核心的原生 UI 框架,口号很直接:“Build once, native everywhere.” 同一份代码,调每个系统原本的视图和输入框,覆盖手机、桌面和真正的 HTML 网页。只写 Rust 的话,连 JavaScript 引擎都不带。

行为和长相分开

一听“用系统原生控件”,我第一反应是:会不会像老系统的设置面板一样呆板?
NSH 的做法是把“控件怎么工作”和“控件长什么样”拆开,这个分层思路受了 GPUI Kit 的影响。最底层 nsh-core 管元素、样式、布局和响应式;中间 nsh-base 是无样式组件,只管行为、焦点、键盘导航和无障碍。同一套 Menu,有鼠标的电脑上是弹出层,到了手机上自动变成底部面板。最上层的有样式组件归应用自己,颜色、字体、间距用 Tailwind 或类型化样式来定。
写前端的对这套应该不陌生,就是 headless 组件那个意思。区别在底下干活的是系统自己:Apple 上是 AppKit / UIKit 和 CoreText,Android 是原生视图和 EditText,Windows 是 Win32 + DirectWrite,Linux 是 GTK 4,Web 直接输出 HTML 和 CSS,不在 canvas 里模拟。终端里能用 ratatui 跑,也能作为视图嵌进现有的 GPUI 应用。具体的系统版本要求,官网有一张表。
不用自己画字,不用自己造输入法。系统最顺手的东西,直接拿来用就完了。

只有应用代码是 JS

Rust 和 TSX 可以混着写,背后是同一套引擎。
写 TSX 也不用装 Node.js,nsh init、nsh run、nsh check、nsh test、nsh package 一个命令全包。类型检查用的是 TypeScript 7 的原生 tsc,JSX 由 NSH 自己的编译器直接编成对原生渲染器的调用。
文档里有个细节值得单独说:bundle 里只有你写的应用代码。
平时写前端,打包产物一大半是框架自己的运行时。NSH 里 import 进来的 nsh、nsh/base 不进包,它们本来就长在 Rust 运行时里。按官网测试,一个 hello world 的应用代码不到 1 KB,从保存文件到新版本跑起来约 120 毫秒;Web 端算上 Wasm 运行时,下载约 1.1 MB。

主线程不等 JS

跨平台框架最怕的是:业务代码一算点东西,列表就开始掉帧。我去翻了它的架构文档。
第一,没有虚拟 DOM。写 React 的都知道,状态一变,组件函数重跑,再生成虚拟树去比对。NSH 走的是 Solid 那条路,组件函数只跑一次,会变的表达式编译成绑定。依赖追踪和调度在 Rust 里做,遵循 Solid 1.9 的算法。某个 signal 变了,只有读它的那处绑定重新执行。
第二,引擎线程和 UI 线程拆开。组件、事件、定时器、effect 都在引擎线程里跑。一轮 JS 跑完,引擎线程把改动合成一次提交:文字排版、Flexbox / Grid 布局(用 Taffy)都在主线程之外算好,打包成一个 MountBatch。主线程拿到直接套到系统视图上,动画走系统自己的机制。长列表的复用也在原生侧做,滑动时没有新行露出来,就不碰 JS。
说白了就守一条:主线程永不等待 JavaScript。JS 忙的时候,数据最多晚一两帧,滑动和动画不会冻住。
官方测量的数字:目标 120 FPS,引擎启动约 75–155 µs,计数器应用首帧约 1 ms,一次点击到提交约 0.12 ms。小应用的内存只比系统裸窗口多几 MB,比如 macOS 上裸窗口约 17 MB,Rust 应用约 19 MB,TSX 应用约 20 MB。

上架之后还能换界面

这是我觉得最有意思的一块。
App 上架后,想下发一个活动页,或者让 AI 当场生成一个能交互的界面,以前的办法都不太痛快:WebView 不像原生,服务端驱动 UI 写不了本地逻辑,小程序容器太重,热更新又把下发的代码当成完全受信任的代码直接跑。
NSH 可以在运行时加载 .nshpkg 模块。模块要用 Ed25519 签名,发布密钥由离线的根证书认证,设备加载前校验签名、证书和吊销列表。每个视图跑在自己的 QuickJS 沙箱里,内存、单次调用时长、节点数量都有上限,只能用宿主明确给的能力。
有了签名和沙箱,AI 生成界面就顺理成章了:模型写一段 TSX,服务端类型检查、测试、签名,设备校验通过,就是一个真正的原生界面。
方案对比里说,在它对比的这些框架中,NSH 是唯一把原生界面、手机桌面 Web 一套代码、Rust 不带 JS 引擎、上架后安全下发界面同时做到的。

什么时候别用

我挺喜欢这份对比文档的一点,它自己写清楚了哪些场景别的工具更合适:
  • 持续高密度刷新、逐帧自定义可视化,比如高频行情终端:用长桥维护的 GPUI 分支 GPUI Fast。
  • 要求每个系统像素级一致:Flutter 或 Slint。
  • 离不开庞大的现成生态:React Native、Flutter 或 Qt。
  • 界面本身就是文档或网页:WebView。
  • 只有几个固定页面,平时改改文案和顺序:服务端驱动 UI 就够了。
跨端这些年,总让人在“写着省心”和“用着原生”之间二选一。NSH 的答案是把重活留在 Rust 和后台线程里,界面还给系统自己。