v0.1.0-alpha
官网Home
docs/guide/reactivity
核心概念 · Data flowCore concepts · Data flow

响应式系统Reactivity

用 Signal 保存事实、Memo 表达派生值、Effect 同步外部结果。Keep facts in Signals, derive with Memos, and sync the outside world with Effects.

预计阅读 10 分钟10 min read

三个原语Three primitives

01
SignalSignal

可写的事实来源。读取用 get(),更新用 set(v);读取会在当前计算中登记依赖。A writable source of truth. Read with get(), write with set(v); reading records a dependency in the current computation.

02
MemoMemo

由其他响应值惰性计算;输入不变时复用上次结果,值相等时不通知下游。Computed lazily from other reactive values; reuses its last result while inputs are unchanged and stays quiet when the value compares equal.

03
EffectEffect

依赖变化时执行副作用,例如更新节点、写日志或同步外部系统。Runs a side effect when its dependencies change — updating a node, logging, syncing an external system.

完整数据流End-to-end data flow

ui.textFmt 订阅传入的 Signal / Memo,依赖变化时只重排版这一个文本节点。Memo 的依赖通过一个上下文结构体显式传入。ui.textFmt subscribes to the Signals / Memos you pass and re-formats just that one text node when they change. A Memo receives its dependencies through an explicit context struct.

counter.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。One set travels the dependency edges — Signal → Memo → textFmt — and only the subscribed text node is marked render-dirty.

Signal,不是 diffSignals, not diffing

zenit 保留一棵真实的 UI 树用于布局、命中与绘制,但状态更新不走「重新执行组件 → 生成候选树 → 整树 diff」。读取 Signal 的 Memo / Effect 在订阅时建立依赖;set 只通知这些订阅者,最终把受影响的节点标记为 layout 或 render dirty。zenit keeps a real UI tree for layout, hit-testing and drawing, but updates don’t go “re-run components → build a candidate tree → diff the whole thing”. Memos and Effects that read a Signal register a dependency; set notifies exactly those subscribers, which mark the affected nodes layout- or render-dirty.

SIGNAL vs DIFF
树仍然存在;消失的是「每次状态变化都重建候选树再找差异」这一步。The tree is still there; what disappears is rebuilding a candidate tree and hunting for differences on every change.
真实 Reactive Counter.app:每次 +1 同时把 count 与 doubled 更新到 3 / 6,Reset 再把两个订阅结果归零。The real Reactive Counter.app: each +1 updates count and doubled together up to 3 / 6, then Reset zeroes both subscribers.

从事件更新 SignalUpdating from events

组件回调是 HandlerRef。用 cx.bindState 保存一个持有 Signal 指针的小结构体,再用 cx.on 把它的方法变成回调。Component callbacks are HandlerRefs. Keep a small struct holding the Signal pointer with cx.bindState, then turn one of its methods into a callback with cx.on.

handlers.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);

读取与订阅Reading and subscribing

APIAPI作用What it does
signal.get()读取并在当前 Memo / Effect 中登记依赖Read and register a dependency in the current Memo / Effect
signal.peek()读取但不订阅,适合事件处理器里取快照Read without subscribing — handy for snapshots inside handlers
signal.set(v)写入并通知订阅者Write and notify subscribers
signal.update(fn)基于旧值计算新值后写入Compute the next value from the previous one, then write
scope.createEffect(ctx, fn)依赖变化时运行副作用,随 Scope 释放Run a side effect on change; disposed with its Scope
effects.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);

选择规则Rules of thumb

  • ✓

    唯一事实用 Signal。不要同时在普通字段和 Signal 里保存同一份状态。One fact, one Signal. Don’t keep the same state in both a plain field and a Signal.

  • ✓

    可计算的值用 Memo。不要在每个事件处理器里手工同步派生字段。Derivable values are Memos. Don’t hand-sync derived fields in every handler.

  • ✓

    Effect 只做同步。不要在 Effect 中无条件写回自己的依赖,以免形成循环。Effects only sync. Never unconditionally write back to an Effect’s own dependency — that’s a loop.

  • ✓

    生命周期归 Scope。页面 Scope 销毁时,其中的 Signal、Memo 与 Effect 一起释放。Lifetimes belong to a Scope. When a page’s Scope is disposed, its Signals, Memos and Effects go with it.

zenit · 双授权Dual-licensed开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。Free for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.可联系作者:Contact the author: zongyi.xzy#gmail.com(# 换成 @) (replace # with @)zenit 5f9add5+wip 2026-09-30