---
title: "响应式系统 — zenit Zig UI 文档"
description: "用 Signal 保存事实、Memo 表达派生值、Effect 同步外部结果。"
url: https://zenit.z.express/zh/docs/guide/reactivity
language: zh-CN
alternate_en: https://zenit.z.express/docs/guide/reactivity.md
alternate_es: https://zenit.z.express/es/docs/guide/reactivity.md
alternate_ja: https://zenit.z.express/ja/docs/guide/reactivity.md
alternate_ko: https://zenit.z.express/ko/docs/guide/reactivity.md
alternate_fr: https://zenit.z.express/fr/docs/guide/reactivity.md
alternate_de: https://zenit.z.express/de/docs/guide/reactivity.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# 响应式系统

用 Signal 保存事实、Memo 表达派生值、Effect 同步外部结果。

## 三个原语

**Signal**

可写的事实来源。读取用 `get()`，更新用 `set(v)`；读取会在当前计算中登记依赖。

**Memo**

由其他响应值惰性计算；输入不变时复用上次结果，值相等时不通知下游。

**Effect**

依赖变化时执行副作用，例如更新节点、写日志或同步外部系统。

## 完整数据流

`ui.textFmt` 订阅传入的 Signal / Memo，依赖变化时只重排版这一个文本节点。Memo 的依赖通过一个上下文结构体显式传入。

`counter.zig`

```zig
const count = try scope.createSignal(u32, 0);

const doubled = try scope.createMemo(u32, .{ .count = count }, struct {
    fn compute(ctx: anytype) u32 {
        return ctx.count.get() * 2;
    }
}.compute);

try root.appendChild(cx.allocator, try ui.textFmt(
    cx,
    scope,
    "count = {d}, doubled = {d}",
    .{ count, doubled },
    .{ .color = cx.tokens.color.fg_primary },
));
```

DATA FLOW

一次 set 沿依赖边传播：Signal → Memo → textFmt，最终只有订阅它的文本节点被标记为 render dirty。

## Signal，不是 diff

zenit 保留一棵真实的 UI 树用于布局、命中与绘制，但状态更新不走「重新执行组件 → 生成候选树 → 整树 diff」。读取 Signal 的 Memo / Effect 在订阅时建立依赖；`set` 只通知这些订阅者，最终把受影响的节点标记为 layout 或 render dirty。

SIGNAL vs DIFF

树仍然存在；消失的是「每次状态变化都重建候选树再找差异」这一步。

[Video](https://zenit.z.express/media/reactive-counter.mp4?v=635ee40b41)

真实 Reactive Counter.app：每次 +1 同时把 count 与 doubled 更新到 3 / 6，Reset 再把两个订阅结果归零。

## 从事件更新 Signal

组件回调是 `HandlerRef`。用 `cx.bindState` 保存一个持有 Signal 指针的小结构体，再用 `cx.on` 把它的方法变成回调。

`handlers.zig`

```zig
const Bindings = struct {
    count: *ui.Signal(u32),

    fn increment(self: *Bindings) void {
        self.count.set(self.count.get() + 1);
    }
};

const bindings = try cx.bindState(Bindings, .{ .count = count });
const on_click = cx.on(Bindings, bindings, Bindings.increment);

const plus = try ui.widgets.Button(.{
    .label = "+1",
    .on_click = on_click,
}).mount(scope, cx);
```

## 读取与订阅

| API | 作用 |
| --- | --- |
| `signal.get()` | 读取并在当前 Memo / Effect 中登记依赖 |
| `signal.peek()` | 读取但不订阅，适合事件处理器里取快照 |
| `signal.set(v)` | 写入并通知订阅者 |
| `signal.update(fn)` | 基于旧值计算新值后写入 |
| `scope.createEffect(ctx, fn)` | 依赖变化时运行副作用，随 Scope 释放 |

`effects.zig`

```zig
// Effect: sync a value to something outside the reactive graph.
try scope.createEffect(.{ .count = count }, struct {
    fn run(ctx: anytype) void {
        std.log.info("count is now {d}", .{ctx.count.get()});
    }
}.run);

// peek(): read without subscribing (no dependency is recorded).
const snapshot = count.peek();

// update(): read-modify-write in one call.
count.update(struct {
    fn inc(v: u32) u32 {
        return v + 1;
    }
}.inc);
```

## 选择规则

-   **唯一事实用 Signal。**不要同时在普通字段和 Signal 里保存同一份状态。
    
-   **可计算的值用 Memo。**不要在每个事件处理器里手工同步派生字段。
    
-   **Effect 只做同步。**不要在 Effect 中无条件写回自己的依赖，以免形成循环。
    
-   **生命周期归 Scope。**页面 Scope 销毁时，其中的 Signal、Memo 与 Effect 一起释放。
    

> TIP
> 
> **静态界面无需响应式。** 不会变化的文本和结构直接挂载即可；响应式原语解决的是数据变化，不是每个节点都必须包一层。
