指南
性能
性能是 nsh 的第一优先级,而其中大部分由框架而非你的应用决定:运行时是原生的,每次更新只跨一次线程,动画和列表虚拟化 都无需 JS 参与。本页介绍 nsh 遵循的规则、它们对你所写代码的意义,以及如何测量。
#规则
#1. JS 做得越少越好
QuickJS 是一个解释器:体积小、启动快,但做重活时比原生代码慢得多。因此在 nsh 中,只有你的应用代码是 JS。响应式图
(signal、computed 值、effect)、编译后的 JSX 所调用的渲染器、布局引擎、nsh/ui/mobile 的组件、列表虚拟化、动画以及
你导入的每个模块都是 Rust。解析、diff、布局和字符串处理都在原生侧完成。
效果是可以测量的。同一个计数器应用分别跑在 nsh 早先的 JS 运行时和原生运行时上(release 构建):
| 早先的 JS 运行时 | 原生运行时 | |
|---|---|---|
| bundle 大小 | 14 KB JS / 19.6 KB 字节码 | 886 B JS / 1.7 KB 字节码 |
| 执行 bundle | ~390 µs | ~95 µs |
| 一次点击的 JS 耗时(中位数) | ~14 µs | ~5 µs |
| 一次点击的 commit | ~22 µs | ~15 µs |
| 滚动 1000 行列表,每个重新绑定帧的 JS 耗时 | ~150 µs | ~77 µs |
组件也是如此:nsh/ui/mobile 中的 Button(Rust)与一个等价的 TSX 按钮对比,各 200 个:
Rust Button:JS / core | TSX 按钮:JS / core | |
|---|---|---|
| 首帧(ops) | 1.32 ms / 0.77 ms (1015) | 2.32 ms / 0.70 ms (2415) |
| 更新全部 200 个标题(ops) | 0.42 ms / 0.42 ms (200) | 0.77 ms / 0.36 ms (600) |
(在 Apple silicon 上测得;第二张表基于早先的运行时。两者均出自 nsh 的性能基线。)
对你的代码而言: 让事件处理函数和 computed 值保持轻量。繁重的计算(解析大型数据、每次按键都对上千条数据排序)在
QuickJS 中的代价,比你在带 JIT 的 V8 或 JavaScriptCore 中习惯的要高。只做一次,用 computed 缓存,或者把它移出交互路径。
#2. 批量跨线程
应用的 JS 运行在引擎线程上,从不在主线程上运行。一个 JS 轮次,例如一个事件处理函数及其引起的全部改动,会成为 一次 commit:运行时渲染组件、布局、diff,并生成一批原生视图操作,以一次跳转送达主线程。不存在每个视图一条的 bridge 消息,也没有每个 prop 一次的序列化。
对你的代码而言: 无需为了性能合并写入;一个处理函数里调用十次 set 仍然只产生一次 commit。如果你希望 effect 和
computed 值一次性看到所有写入,而不是每次写入后都运行一遍,请使用 batch。
#3. 细粒度更新
组件只运行一次。之后,当某个 signal 变化时,只有读取它的绑定会重新运行:一段文本、一个样式属性、<If> 的一个分支、
<Each> 的各行。不会为了找出变化而重新渲染整棵子树。
import type { Accessor } from "nsh";
import { Text } from "nsh";
type Quote = { price: number; change: number };
function Price(props: { quote: Accessor<Quote> }) {
// Runs once. When quote() changes, only the text and the color update.
return (
<Text style={{ color: props.quote().change < 0 ? "#cf222e" : "#1a7f37" }}>
{props.quote().price.toFixed(2)}
</Text>
);
}
对你的代码而言:
- 在使用 signal 的地方读取它(JSX 表达式、
computed、effect内部),而不是在组件顶部读取,这样更新的是范围最小的绑定。 - 集合使用
<Each>(按 key 保留行)或List;JSX 中普通的.map()会在每次变化时重新创建所有行。 - 当多个绑定用到同一个派生值时,用
computed计算它,这样每次变化只计算一次。
向一个三项列表追加第四项,nsh trace 显示的结果如下:
{items().map((n) => <Text>{`row ${n}`}</Text>)} // 22 mount items: 3 views removed, 4 created
<Each of={items()}>{(n) => <Text>{`row ${n()}`}</Text>}</Each> // 4 mount items: 1 view created
参见响应式。
#4. 不发送不需要的内容
core 只发送变化的部分:新视图不带默认样式,也不带空的事件掩码;未变化的样式和 frame 不会发送;文本颜色的变化不会让文本
重新布局。一次只改变一个数字的点击只发送一个文本条目。你可以在 nsh trace 的输出中看到这一点(见下文)。
#5. 原生执行动画
由 JS 驱动的动画(在 requestAnimationFrame 中写入 signal)每帧都要付出一次 JS 调用和一次 commit 的代价,而且引擎线程
繁忙时会卡顿。nsh/animated 则以原生方式运行动画:
import { View } from "nsh";
import { createNativeValue } from "nsh/animated";
import { List } from "nsh/list";
const scrollY = createNativeValue(0);
<View
style={{
opacity: scrollY.interpolate({ inputRange: [0, 120], outputRange: [1, 0.35] }),
transform: [{ translateY: scrollY.interpolate({ inputRange: [0, 120], outputRange: [0, -6] }) }],
}}
/>;
<List each={photos()} scrollValue={scrollY} estimatedItemSize={64} style={{ flex: 1 }}>
{(photo) => <Row photo={photo()} />}
</List>;
timing、spring和decay在平台的 UI 线程上逐帧运行,每帧不涉及 JS。scrollValue在同一帧内把滚动偏移写入原生值;pan={{ x, y }}让拖动直接移动视图。JS 只在结束时得到通知(promise resolve,onPanEnd拿到速度)。Animated.sequence/parallel/stagger/loop作为一棵原生树运行:每一步在上一步结束的瞬间开始,中间没有 等待 JS 的间隙。LayoutAnimation.configureNext以原生方式为下一次 commit 的 frame 变化、插入和删除添加动画。
#6. 虚拟化长列表
List 只保留视口附近的行,复用它们的单元格,并在原生侧计算位置、多列布局、吸顶标题和可见性。没有露出新行
的滚动帧不会调用 JS,也不会产生 batch;在 gallery 的照片列表中,绑定一个新行的帧只有 4 个操作和 4 个 mount item。
对你的代码而言:
estimatedItemSize要接近真实的行高。- 把每个条目的状态放在数据里(按 id 存放),而不是放在行内创建的 signal 中:单元格会被复用给其他条目,复用时
item()/index()会变化。 - 让行保持轻量:行在每次复用时绑定一次,所以它的 JS 是在用户滚动时运行的。
- 对跟随滚动位置的效果,使用
scrollValue而不是onScroll。onScroll每个引擎轮次最多投递一个事件,但每个事件 仍然是一次对 JS 的调用。
#测量
#无界面运行:nsh trace
nsh trace App.tsx # first frame + 2 taps
nsh trace App.tsx --scroll 3000 --taps 0 # scrolling, 10 pt per frame
info nsh::perf commit: 56 ops, 395µs (list js 6µs), 49 mount items, 21 nodes
info nsh::perf first frame: eval 271µs, total 1235µs
info nsh::perf event js: 88µs
info nsh::perf commit: 10 ops, 151µs (list js 2µs), 8 mount items, 27 nodes
需要关注的指标:
- ops(你的 JS 排入队列的操作)和 mount items(原生视图操作):这两者是确定性的。如果一次本应只改变一个标签的 点击产生了 40 个条目,说明有地方在重新创建本应更新的视图。
- event js:一个处理函数及其触发内容的 JS 开销。
- commit:core 渲染组件、布局和 diff 的开销。
- list js:该次 commit 中绑定列表行所花费的 JS 时间。
完整指南:用 nsh trace 测试。
#在设备上:--trace
nsh run App.tsx --trace
nsh run App.tsx --target android --trace --release
会在真实宿主上输出同样的 commit 日志行,使用的是平台自己的文本引擎。在嵌入式应用中,NshAppView(trace: true) 和
new NshView(context, true) 的作用相同。
#参考数据
来自 nsh 自己的基线(除特别注明外均为 Apple silicon):
| 场景 | 数据 |
|---|---|
| 计数器应用,无界面(debug) | 首帧 ~0.5–0.65 ms(58 ops / 49 mount items);一次点击 ~0.15 ms |
| 计数器应用,Mac Catalyst(release) | 首帧 ~1.05–1.15 ms(其中约 0.7 ms 用于首次用 CoreText 布局 10 段文本);一次点击 ~0.12 ms |
| Gallery 照片列表,无界面(debug) | 首帧 653 ops / 224 nodes,~2.7 ms |
| 滚动照片列表,Catalyst(release) | 慢速滚动时重新绑定一行 ~0.4 ms;快速甩动时一整屏约 32 行 0.9–1.6 ms |
| 滚动照片列表,Android 模拟器(arm64,release) | 重新绑定一行 ~0.3–0.4 ms(JS ~60 µs) |
| 引擎启动(release) | ~75–155 µs;Web API(fetch、URL 等)在首次使用前没有任何开销,首次使用后 ~90 µs |
| 热重载 | 从保存文件到新版本运行 ~120 ms;应用更新 0.2–0.5 ms |
#检查清单
- 交互:一次点击只改变少量 mount item,而不是几十个。
- 列表:长列表一律用
List,并设置estimatedItemSize;每个条目的状态放在数据里。 - 动画:使用原生值,而不是每帧写入的 signal。
- 滚动效果:使用
scrollValue,而不是onScroll。 - 处理函数:不在每次按键或每个滚动事件中做繁重工作;用
computed缓存。 - 组件:合适时优先使用
nsh/ui/mobile的 Rust 组件;它们比用 TSX 写的等价组件花费更少的 JS。