指南
响应式
nsh 用 signal 更新界面:signal 是知道谁在读取自己的值。signal 变化时,nsh 只重新运行读取了它的那些计算(一段文本、一个样式、一个 effect),其他一概不动。组件不会重新渲染。这套模型来自 Solid,而响应式图本身是原生实现的:读取 signal 是一次对 Rust 的调用,调度、依赖跟踪和清理都在解释器之外完成。
import { batch, cleanup, computed, context, effect, mount, Scroll, signal, Text, untracked, View } from "nsh";
const Accent = context(signal("#0969da"));
/** A stopwatch: the effect starts a timer and its cleanup stops it. */
function Stopwatch() {
const running = signal(false);
const tenths = signal(0);
effect(() => {
if (!running()) return;
const id = setInterval(() => tenths.update((t) => t + 1), 100);
cleanup(() => clearInterval(id));
});
const accent = Accent();
return (
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<Text style={{ flex: 1, fontSize: 28, fontWeight: "600", fontVariant: ["tabular-nums"], color: "#1f2328" }}>
{(tenths() / 10).toFixed(1)} s
</Text>
<View onPress={() => running.set(!running())} activeOpacity={0.6} style={{ backgroundColor: accent(), borderRadius: 8, paddingHorizontal: 14, paddingVertical: 8 }}>
<Text style={{ color: "#ffffff", fontWeight: "600" }}>{running() ? "Stop" : "Start"}</Text>
</View>
<View onPress={() => tenths.set(0)} activeOpacity={0.6} style={{ backgroundColor: "#57606a", borderRadius: 8, paddingHorizontal: 14, paddingVertical: 8 }}>
<Text style={{ color: "#ffffff" }}>Reset</Text>
</View>
</View>
);
}
function Section(props: { title: string; children: any }) {
return (
<View style={{ backgroundColor: "#ffffff", borderRadius: 12, padding: 14, gap: 10 }}>
<Text style={{ fontSize: 13, fontWeight: "600", color: "#6e7781" }}>{props.title}</Text>
{props.children}
</View>
);
}
function App() {
const price = signal(12);
const quantity = signal(1);
const total = computed(() => price() * quantity());
const writes = signal(0);
const accent = Accent();
// Runs again whenever `total` changes; reads of `writes` are not tracked.
const log = signal<string[]>([]);
effect(() => {
const t = total();
const n = untracked(writes);
log.update((l) => [`total ${t} after ${n} writes`, ...l].slice(0, 3));
});
const step = (dp: number, dq: number) => {
batch(() => {
price.update((p) => Math.max(0, p + dp));
quantity.update((q) => Math.max(0, q + dq));
writes.update((w) => w + 2);
});
};
const colors = ["#0969da", "#8250df", "#1a7f37", "#bf3989"];
return (
<Scroll style={{ flex: 1, backgroundColor: "#f6f8fa" }}>
<View style={{ padding: 20, paddingTop: 24, gap: 16 }}>
<Section title="signal + computed">
<Text style={{ fontSize: 17, color: "#1f2328" }}>
{price()} × {quantity()} = <Text style={{ fontWeight: "700", color: accent() }}>{total()}</Text>
</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<View onPress={() => step(1, 0)} activeOpacity={0.6} style={{ backgroundColor: "#ddf4ff", borderRadius: 8, padding: 8 }}>
<Text>price +1</Text>
</View>
<View onPress={() => step(0, 1)} activeOpacity={0.6} style={{ backgroundColor: "#ddf4ff", borderRadius: 8, padding: 8 }}>
<Text>qty +1</Text>
</View>
<View onPress={() => step(1, 1)} activeOpacity={0.6} style={{ backgroundColor: "#fff8c5", borderRadius: 8, padding: 8 }}>
<Text>both (batch)</Text>
</View>
</View>
</Section>
<Section title="effect + untracked">
<Each of={log()}>{(line) => <Text style={{ fontSize: 13, color: "#57606a" }}>{line()}</Text>}</Each>
</Section>
<Section title="effect + cleanup">
<Stopwatch />
</Section>
<Section title="context (the value is a signal)">
<View style={{ flexDirection: "row", gap: 10 }}>
<Each of={colors}>
{(c) => (
<View
onPress={() => accent.set(c())}
style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: c(), borderWidth: 3, borderColor: accent() === c() ? "#1f2328" : "transparent" }}
/>
)}
</Each>
</View>
</Section>
</View>
</Scroll>
);
}
mount(() => (
<Accent.Provider value={signal("#0969da")}>
<App />
</Accent.Provider>
));本页的所有内容都从 nsh 导入:
import { batch, cleanup, computed, context, effect, signal, untracked } from "nsh";
#signal
signal(value) 创建一个 signal。调用它读取值;set 和 update 写入值:
const count = signal(0);
count(); // 0, and tracks the read
count.set(5);
count.update((n) => n + 1); // 6
在跟踪作用域(JSX、computed、effect)内读取 signal,会让该作用域订阅这个 signal。写入时如果值发生了变化(默认用 === 比较),就会通知订阅者。
function Counter() {
const count = signal(0);
return (
<View onPress={() => count.update((n) => n + 1)}>
<Text>Tapped {count()} times</Text>
</View>
);
}
点击只会更新读取 count() 的那一个文本节点。Counter 本身只运行一次。
不带初始值的 signal<T>() 从 undefined 开始,类型为 Signal<T | undefined>。第二个参数决定什么样的写入算作变化:
const position = signal({ x: 0, y: 0 }, { equals: (a, b) => a.x === b.x && a.y === b.y });
const tick = signal(0, { equals: false }); // every write notifies
#对象与数组
signal 用 === 比较,因此原地修改的值不算变化。请写入新的对象或数组:
const todos = signal<Todo[]>([]);
todos.update((list) => [...list, { id: 3, title: "New" }]); // notifies
todos.update((list) => list.map((t) => (t.id === id ? { ...t, done: true } : t)));
todos().push(item); // does not notify anyone
todos.set(todos()); // still the same array: no change
<Each> 按项为行设置 key,所以在新数组中替换某一项时,只会重建那一行(JSX 与组件)。
#computed
computed(fn) 从其他 signal 派生出一个值。它运行 fn 并记住结果,当它读取过的 signal 变化时再重新运行。读取它和读取 signal 一样:
const price = signal(12);
const quantity = signal(2);
const total = computed(() => price() * quantity());
<Text>Total: {total()}</Text>
当派生值在多处被读取,或计算开销较大时,使用 computed:无论有多少读取者,每次变化它只运行一次,而且只有结果真正变化时(===,或 equals 选项)才会通知读取者。对于只读一次、计算很便宜的值,普通函数就够了:
const label = () => `${quantity()} items`;
<Text>{label()}</Text>
fn 会收到上一次的值(第一次为 undefined),适合需要累积的值:
const max = computed((prev: number | undefined) => Math.max(prev ?? 0, speed()));
#effect
effect(fn) 在当前渲染完成后运行 fn,之后每当它读取过的 signal 变化时再次运行。effect 用于副作用:日志、定时器、网络请求、对 ref 的命令式调用。
function Search(props: { query: string }) {
const results = signal<string[]>([]);
effect(() => {
const q = props.query; // tracked: runs again when the prop changes
if (!q) return;
fetch(`https://api.example.com/search?q=${encodeURIComponent(q)}`)
.then((r) => r.json())
.then((data) => results.set(data.items));
});
return <Each of={results()}>{(item) => <Text>{item()}</Text>}</Each>;
}
effect 只跟踪它同步运行期间的读取。.then 回调中或 await 之后的读取不会被跟踪。
effect 在同一轮创建的元素都已存在之后才运行,因此 ref 已经被设置:
let input: YNode | undefined;
effect(() => {
if (editing()) input?.focus();
});
#cleanup
cleanup(fn) 注册 fn,在当前 effect 再次运行之前,或者在它所属的组件或 effect 被销毁时运行:
function Clock() {
const now = signal(Date.now());
const id = setInterval(() => now.set(Date.now()), 1000);
cleanup(() => clearInterval(id)); // when Clock is removed
return <Text>{new Date(now()).toLocaleTimeString()}</Text>;
}
effect(() => {
if (!running()) return;
const id = setInterval(tick, 100);
cleanup(() => clearInterval(id)); // before the next run, or on removal
});
组件不再被渲染时就会被销毁:它所在的 <If> 分支被隐藏、所在的 <Each> 行被移除,或者它的页面被弹出。它创建的一切(effect、computed 值、cleanup、嵌套组件)都会随之释放。
#batch
每次写入都会立即通知订阅者。要让多次写入算作一次变化,用 batch 包起来:依赖方会在最后一次写入之后只运行一次。
const save = () =>
batch(() => {
firstName.set(form.first);
lastName.set(form.last);
dirty.set(false);
});
事件处理函数不会自动批处理:在一个 onPress 中写入两次,读取这两个 signal 的 effect 就会运行两次。batch 返回其函数的返回值。
#untracked
untracked(fn) 运行 fn,但不订阅它读取的内容。在跟踪作用域中,用它读取一个值而不对它产生依赖:
effect(() => {
const q = query(); // runs again when query changes
const filters = untracked(activeFilters); // read, but not a reason to run again
search(q, filters);
});
组件函数体本身就不被跟踪:在那里读取 signal 只读一次。
#context
context(defaultValue) 创建一个 context:组件向它渲染的所有内容(无论多深)提供的值。调用 context 读取最近一层提供的值(或默认值),渲染 <Ctx.Provider value> 来提供一个值:
const Theme = context<"light" | "dark">("light");
function Label(props: { children: string }) {
const theme = Theme();
return <Text style={{ color: theme === "dark" ? "#ffffff" : "#1f2328" }}>{props.children}</Text>;
}
mount(() => (
<Theme.Provider value="dark">
<Label>Hello</Label>
</Theme.Provider>
));
provider 只在创建时读取一次 value。要提供会变化的内容,就提供一个 signal(或由 signal 组成的对象),并在 JSX 中读取它:
const Accent = context(signal("#0969da"));
function App() {
const accent = signal("#0969da");
return (
<Accent.Provider value={accent}>
<Swatch />
</Accent.Provider>
);
}
function Swatch() {
const accent = Accent();
return <View onPress={() => accent.set("#8250df")} style={{ width: 40, height: 40, backgroundColor: accent() }} />;
}
请在组件函数体中(或在组件创建期间运行的代码中)读取 context,而不是在事件处理函数或定时器中读取:那里没有可供查找的组件。
#什么会运行,何时运行
| 你写的 | 何时运行 | 何时再次运行 |
|---|---|---|
| 组件函数体 | 创建时运行一次 | 从不(热重载除外) |
调用函数或读取属性的 {expr} 或 prop={expr} | 元素创建时运行一次 | 它读取的 signal 变化时;只更新那一个 prop 或子节点 |
computed(fn) | 创建时 | 它读取的 signal 变化时 |
effect(fn) | 当前这一轮的元素都已存在之后 | 它读取的 signal 变化时 |
<If when> | 创建时 | when 的真假变化时 |
<Each of> 的行 | 它的 key 第一次出现时 | 从不;改为由 item() / index() 访问器变化 |
环境同样是响应式的:nsh/device 中的 windowSize()、colorScheme()、fontScale(),以及 nsh/ui/mobile 中的 safeArea() / keyboard() 都是访问器,读取它们的 JSX 会跟随屏幕旋转、深色模式或键盘变化。
#常见错误
解构 props。 const { title } = props 只会读取一次 title。请在使用的地方读取 props.title,或用 split 拆分 props。
在组件函数体中读取 signal 并期待它更新。
function Bad() {
const n = count(); // read once
return <Text>{n}</Text>; // never changes
}
function Good() {
return <Text>{count()}</Text>;
}
原地修改而不是重新设置。 list().push(x) 改变了数组,却不会通知任何人。请写入一个新数组。
用 effect 派生状态。 effect(() => total.set(price() * qty())) 能工作,但在写入和 effect 运行之间 total 是过期的,还要多付出一轮更新的代价。请使用 computed。
在组件之外创建 effect。 effect 和 computed 值属于创建它们的组件,并随组件一起释放。如果在事件处理函数或定时器回调中创建,它们没有所有者,会永远存在。请在组件函数体中创建,让它们去读取会变化的 signal。
期待 async 代码被跟踪。 只有 effect 或 computed 值运行期间同步发生的读取才会被跟踪。先读取 signal,再开始异步工作。