指南

响应式

nsh 用 signal 更新界面:signal 是知道谁在读取自己的值。signal 变化时,nsh 只重新运行读取了它的那些计算(一段文本、一个样式、一个 effect),其他一概不动。组件不会重新渲染。这套模型来自 Solid,而响应式图本身是原生实现的:读取 signal 是一次对 Rust 的调用,调度、依赖跟踪和清理都在解释器之外完成。

guide-reactivity.tsx 在线编辑 在新标签页打开
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,再开始异步工作。

#相关内容

  • JSX 与组件:作为 getter 的 props、<If>、<Each>。
  • nsh 模块参考:每个函数的签名。
  • 动画:不经过 signal、在 UI 线程上运行动画的原生值。