v0.1.0-alpha
官网Home
docs/guide/project-structure
开始 · 组织代码Get started · Organizing code

项目结构Project structure

把构建入口、业务界面、样式和资源放到可持续扩展的位置,并弄清每块内存归谁所有。Put the build entry, feature UI, styles and assets where they can grow — and know who owns each piece of memory.

预计阅读 6 分钟6 min read

推荐结构Suggested layout

zenit 不强制应用目录。推荐让构建文件保持稳定,把每个功能的树结构、状态与样式放在同一个目录里,改一个功能时不必在仓库里来回跳。zenit doesn’t impose a directory layout. Keep the build files stable and put each feature’s tree, state and styles side by side, so changing one feature doesn’t mean hopping around the repo.

myapp/
myapp/
├── build.zig
├── build.zig.zon
└── src/
    ├── main.zig
    ├── app_state.zig
    ├── assets.zig
    └── features/
        └── dashboard/
            ├── view.zig
            ├── state.zig
            └── styles.zig

文件职责File responsibilities

文件File负责Owns避免Avoid
main.zigAllocator、App 初始化、根挂载Allocator, App init, root mount堆积具体页面 UIPiling up page-level UI
view.zig节点树与事件连接The node tree and event wiring裸颜色、复杂数据变换Raw colors, heavy data transforms
state.zig业务状态与方法Business state and its methods长期持有可能被移除或重建的 Node 指针Long-lived pointers to nodes that may be removed or rebuilt
styles.zigToken 驱动的具名样式函数 fn (*const ui.ThemeTokens) ui.BoxStyleToken-driven named style functions, fn (*const ui.ThemeTokens) ui.BoxStyle发起 IO 或改变状态Doing IO or mutating state

样式函数是纯函数,由 ui.boxStyled / ui.vstackStyled 等构造器在挂载时求值;切换主题时框架会用新 Token 重放它们,内联字面量样式则不会。Style functions are pure; builders like ui.boxStyled / ui.vstackStyled evaluate them at mount, and replay them with the new tokens when the theme changes — inline literal styles don’t get that.

styles.zig + view.zig
// features/dashboard/styles.zig
const ui = @import("ui");

pub fn card(t: *const ui.ThemeTokens) ui.BoxStyle {
    return .{
        .padding = ui.Padding.all(t.space._6),
        .gap = t.space._4,
        .background = t.color.bg_secondary,
        .corner_radius = t.radius.xl,
    };
}

// features/dashboard/view.zig
const S = @import("styles.zig");
const panel = try ui.vstackStyled(cx, S.card, .{});

导入边界Import boundary

公开表面分两层:常用类型与构造器位于 ui.X,进阶能力按关注点位于 ui.<group>.X(ui.widgets、ui.fx、ui.events、ui.theme 等)。两层之外的一切都是内部实现。The public surface has two tiers: common types and builders live at ui.X, advanced features are grouped under ui.<group>.X (ui.widgets, ui.fx, ui.events, ui.theme, …). Everything outside those two tiers is internal.

zig
const ui = @import("ui");

// Tier 1: what most UI code needs
const Node = ui.Node;
const Signal = ui.Signal;

// Tier 2: grouped by concern
const Router = ui.fx.Router;
const Button = ui.widgets.Button;
const Event = ui.events.Event;

这条边界由构建系统保证:zenit.attach 只给应用加上 ui 与 zenit_app 两个模块,render、gpu 等内部模块在应用里根本导入不到(架构图见手册首页)。The build system enforces this: zenit.attach gives the app only the ui and zenit_app modules, so internal modules such as render and gpu can’t be imported at all (see the diagram on the Overview).

所有权与生命周期Ownership and lifetimes

两个所有者,两种寿命。Cx 是窗口级的:它持有节点树和 cx.bindState 分配的结构体,直到 Cx.deinit。Scope 持有响应式资源:Signal、Memo、Effect、onCleanup 回调和登记的资源,在 dispose() 时释放;子 Scope 随父 Scope 一起释放。Two owners, two lifetimes. Cx is window-level: it owns the node tree and every struct allocated by cx.bindState until Cx.deinit. Scope owns reactive resources — Signals, Memos, Effects, onCleanup callbacks and registered resources — and frees them on dispose(); child scopes go with their parent.

OWNERSHIP
销毁页面 Scope 不会释放你在该页面里 bindState 的结构体——它们活到窗口关闭。Cx.deinit 的顺序是:dispose 根 Scope → 释放节点 → 释放 state。Disposing a page Scope does not free the structs you bindState-ed on that page — they live until the window closes. Cx.deinit runs: dispose the root Scope → free nodes → free state.

绑定状态:声明 pub deinit 即可Bound state: declare a pub deinit

如果结构体声明了 pub deinit(*T),框架会在 Cx.deinit 释放它之前自动调用。需要 allocator 的清理,把 allocator 存成字段。If the struct declares a pub deinit(*T), the framework calls it automatically before freeing the struct at Cx.deinit. If cleanup needs an allocator, store one as a field.

state.zig
const Item = struct { title: []const u8 };

const Model = struct {
    allocator: std.mem.Allocator,
    items: std.ArrayList(Item) = .empty,

    // Must be pub: Cx.deinit detects it and calls it before freeing the struct.
    pub fn deinit(self: *Model) void {
        self.items.deinit(self.allocator);
    }
};

const model = try cx.bindState(Model, .{ .allocator = cx.allocator });
// Do NOT also call scope.onCleanup(Model, model, Model.deinit):
// that would run deinit twice.

页面级资源:用 onCleanupPage-lifetime resources: onCleanup

只有两种情况需要 scope.onCleanup:清理函数不是 pub,或者资源必须跟随某个 Scope(例如一个页面)而不是整个窗口释放。这类对象自己分配,不经过 bindState。Reach for scope.onCleanup in just two cases: the cleanup isn’t pub, or the resource must follow a Scope’s lifetime (say, a page) rather than the window’s. Allocate such objects yourself instead of through bindState.

features/dashboard/view.zig
const PageCache = struct {
    allocator: std.mem.Allocator,
    rows: std.ArrayList(Item) = .empty,

    fn release(self: *PageCache) void {
        self.rows.deinit(self.allocator);
        self.allocator.destroy(self);
    }
};

fn mountDashboard(cx: *ui.Cx, parent: *ui.Scope) !*ui.Node {
    const scope = try parent.childScope();

    // Page-lifetime resource: freed when this scope is disposed,
    // not when the window closes.
    const cache = try cx.allocator.create(PageCache);
    cache.* = .{ .allocator = cx.allocator };
    scope.onCleanup(PageCache, cache, PageCache.release) catch |err| {
        cx.allocator.destroy(cache);
        return err;
    };

    return ui.vstack(cx, .{ .gap = cx.tokens.space._4 }, .{});
}

检查清单Checklist

  • ✓

    main.zig 保持短小。只做 allocator、App.init 与 runWith。Keep main.zig short. Allocator, App.init and runWith — nothing else.

  • ✓

    只导入 ui 与 zenit_app。需要的能力不在两层公开表面上时,先提 issue,而不是绕进内部目录。Import only ui and zenit_app. If something you need isn’t on the two public tiers, file an issue instead of reaching into internals.

  • ✓

    窗口寿命用 bindState,页面寿命用 Scope。前者声明 pub deinit,后者用 onCleanup,二者不要叠加。Window lifetime → bindState, page lifetime → Scope. Give the former a pub deinit, the latter an onCleanup — never both.

  • ✓

    样式写成具名函数。放进 styles.zig,由 *Styled 构造器消费,主题切换自动生效。Write styles as named functions. Put them in styles.zig and consume them with the *Styled builders so theme switches just work.

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