25 KiB
ETAF 架构
本文是 ETAF 的规范性架构契约,定义概念、职责、公共语法和 lowering 路径。实现状态与后续工作写在 implementation-plan.zh.md,面向用户的使用方式写在 user-guide.zh.md。
1. 设计结果
ETAF 是面向文本应用的统一 View 与 Component 层。Elisp 仍然是完整的计算语言,Ebox 仍然是负责可测量布局和文本渲染的底层引擎。
应用
→ Runtime / 响应式状态 / Action / Data
→ Component(props, 局部 Scope)
→ View
→ Renderer
→ Ebox Node
→ measure → layout → paint → commit
→ Emacs buffer
设计有五条不变量:
- 所有可见结构都使用同一种
NAME + 属性 + 子节点形状。 - 每项能力只有一个 owner:View 负责结构,Component 负责复用,Runtime 负责生命周期,响应式状态负责失效,Ebox 负责几何和发布。
- 上层复用下层契约,不复制下层逻辑,也不引入平行概念。
- 计算在 Elisp 表达式位置中完成,不伪装成另一类视觉节点。
- 渲染候选失败时,绝不会替换上一次已提交的 buffer。
2. 用户需要学习的模型
| 概念 | 它是什么 | 它负责什么 |
|---|---|---|
| View | 规范化后的界面描述 | Host、Component 调用、文本和子结构 |
| Host | 固定的结构性 View 名称 | 核心文本或布局语义 |
| Component | 可复用的 View 生产者 | Props、可选局部 Scope、slot 和生命周期 |
| Runtime | 一次挂载的应用运行 | 渲染、事件、调度、提交、回滚和释放 |
| Ebox Node | 更底层的可渲染对象 | 几何、布局、surface、滚动和 buffer 发布 |
以下是机制,不是额外的视觉节点类型:
expr计算一个子节点位置的表达式。slot读写同一个 Component slot 集合。Behavior安装可复用的非视觉交互能力。Action命名业务状态变更入口。Effect管理订阅和外部同步。watch观察响应式状态。Context提供继承的依赖。Data管理应用数据状态和数据源请求。
3. 统一 View 语法
每个 Host 和 Component 调用都使用一种形状:
(NAME ATTRIBUTE* CHILD*)
ATTRIBUTE = :KEY VALUE
属性必须在第一个子节点之前全部结束。子节点可以是字符串、规范化 View、nil,或者由 expr 返回的序列。
(etaf-view
(column
:class "welcome"
(text :font-weight 'bold "Hello")
(text
:color "#687386"
(expr (if ready "Ready" "Waiting")))))
属性出现在子节点之后时,属性区和子节点区被交错,属于非法结构。
etaf-view 是唯一的公共结构构造入口。宏在结构位置读取 View form 并生成规范化 View 值;etaf-render 负责纯 View 的 lowering,etaf-mount 为 View 建立有状态 Runtime 并发布到 buffer。
3.1 quote 与求值
规则只有两条:
- 结构性 View 位置不需要 quote,包括
etaf-view、Host、Component 调用、子节点、slot 和静态 Component styles。 - Elisp 表达式位置遵循普通 Elisp 求值,包括属性值、
:key、:on-*、:use、expr、:setup、Context 值、Behavior 构造器和 Action。
(etaf-view
(text
:color (if dark "#F4F6FB" "#1F2328")
(expr label)))
(etaf-view
(column
(expr
(when open
(etaf-view
(text :font-weight 'bold "Details"))))))
'bold 是普通 Elisp 字面量 symbol。'(text "Details") 只是普通数据,不是 View;当 Elisp 表达式需要构造 View 时,使用 (etaf-view (text "Details"))。ETAF 不会对被 quote 的 View 数据再次 eval,也不增加单独的 literal/eval 节点。
属性值不需要 expr 包装。expr 只因为子节点区是结构语法,需要一个明确的桥接点来执行任意 Elisp。
3.2 expr 语义
expr 只接受一个属性且不能有子节点:
(expr ELISP-EXPRESSION)
它执行表达式,然后接受字符串、typed View、typed View proper sequence 或 nil。
它不创建 Ebox wrapper、identity、生命周期、watch 或 effect。if、when、cond、
let、mapcar、cl-loop 等仍是 form 中的普通 Elisp。
当前 core 没有面向文件的 .etaf pair loader。etaf-define-component 才是结构/样式/行为单元:View 定义结构,:styles 负责 presentation,:setup 负责 retained state、事件和生命周期行为。未来的 .etaf SFC 属于把结果 lowering 到同一套公共 View/Component 契约的 compiler layer,而不是第二套 Runtime 语法。
4. Component
公共定义宏有四个关键字。两个 frontend 必须二选一,另外两个 clause 可选:
(etaf-define-component NAME (&key PROPS)
DOCSTRING?
:setup OPAQUE-STATE-FORM
:view VIEW
:styles (styles RULE...))
(etaf-define-component NAME (&key PROPS)
DOCSTRING?
:setup OPAQUE-STATE-FORM
:render ORDINARY-ELISP
:styles (styles RULE...))
:view 和 :render 互斥且必须恰好出现一个;:setup 与 :styles 可选且各最多出现一次。Props 是唯一需要声明的业务输入;普通尾部子节点和命名 slot 会被规范化为 Component 的 slot 集合。
Component definition 是当前结构/样式/行为边界:动态状态、Action callback 和生命周期工作放进 :setup,静态 presentation 放进 :styles。未来 .etaf SFC compiler 可以生成这些 definition,但 Runtime 不会直接加载 .etaf 文件。
4.1 无状态与状态型形式
(etaf-define-component status-label (&key label)
"Render a status label."
:view
(text
:font-weight 'bold
(expr label)))
(etaf-define-component disclosure (&key title)
"Render a retained disclosure."
:setup
(etaf-ref nil)
:view
(column
(text
:role 'button
:on-press
(let ((open (etaf-state)))
(lambda ()
(setf (etaf-value open)
(not (etaf-value open)))))
(expr (if (etaf-value (etaf-state)) "Hide" "Show")))
(expr
(when (etaf-value (etaf-state))
(etaf-view (text (expr title)))))))
:setup 对一个 retained Component instance 只运行一次,返回一个 opaque 状态值。
:view 或 :render 中用 etaf-state 取得这个准确值。重新渲染读取当前 props 和
state,不重新运行 setup。setup 负责局部 ref、computed、watch、Effect 和 cleanup,
不返回 render 函数。
:key 是稳定的 identity metadata,不是业务 prop。放在 Component 调用上时,它选择同级作用域内要保留的 Component instance;放在 Host 上时,它会作为 Ebox node key 向下传递。候选渲染失败时,Runtime 恢复旧 instance、handlers、Behaviors 和 buffer。
在 View 语法中,Component 的规范名称可以省略 etaf- 前缀。如果短名称会与 Elisp 函数、special form 或 Host 冲突,注册表会分配语义明确的 -view alias。普通 Elisp API,例如 etaf-value、etaf-ref 和 etaf-mount,始终保留前缀。
5. children 与 slot
所有 Component 内容都是同一个 slot 集合:
slots.default = 普通尾部子节点
slots.NAME = 命名 slot 内容
children 只是匿名/默认 slot 的便捷写法,不是第二套内容模型,也不需要出现在业务 &key 声明中。
尾部子节点自动填充默认 slot:
(card
:title "Account"
(text "Card body"))
命名内容使用同样的结构形状:
(card
:title "Account"
(slot :name 'header (text :font-weight 'bold "Account settings"))
(text "Card body"))
在 Component View 内,默认 outlet 以及 fallback 写法是:
(slot)
(slot (text :color "#687386" "No content"))
完整的内部规范写法是:
(slot :name 'default (text :color "#687386" "No content"))
对用户来说,优先使用前两个简写;只有需要明确名字时才写 :name。Slot 名称必须是稳定的、非 keyword 的 symbol。字符串、数字、变量和运行时表达式都会被拒绝,因为 slot 名称属于 retained 结构。同名输入只能出现一次。显式空输入 (slot :name 'header) 会抑制 outlet fallback。Slot form 不创建 Ebox wrapper。
在 Component 内,slot 表示投影;在 Component 调用的子节点区,带 :name 的 slot 表示贡献内容。编译器对两种位置使用同一个规范化 slot 表示。
6. Core Host 与 Ebox
ETAF core 只提供最小且无样式的 Host:
text · fragment · container · row · column · stack · flex · grid · spacer
| Host | 作者看到的含义 | lowering 方向 |
|---|---|---|
text |
带可选 inline runs 的文本 surface | Ebox box/content |
fragment |
不增加视觉 wrapper 的子节点集合 | 展平的子节点序列 |
container |
中性的子节点容器 | Ebox container/column 路径 |
row |
水平排列子节点 | Ebox row layout |
column |
垂直排列子节点 | Ebox column layout |
stack |
结构性的组合容器 | Ebox container 路径 |
flex |
通过 flex 分配空间 | Ebox flex layout |
grid |
二维轨道、放置和跨度 | Ebox Grid formatting context |
spacer |
有意表达的空几何 | Ebox spacer |
字符串是最小的文本 View,会降低为 Ebox content。text 中兼容的嵌套 text 会成为带 text properties 的 inline run;非文本子节点则回到普通布局 lowering。因此 Text、View Host、Component 和 Ebox Node 是连续的表示层,而不是三棵相互竞争的树。
grid 复用 Ebox 的公共二维布局契约,支持轨道模板、auto/分数/minmax 轨道、间距、行列放置、跨度、自动流向和 item 对齐。轨道测量和放置由 Ebox 负责;ETAF 只把 grid Host 映射到这个节点。Ebox 的可选 native reflow 后端不参与正确性保证;如果该后端不支持 Grid,Grid 树会使用普通 Ebox 渲染器。
ETAF 的 Renderer 是唯一把 View 语义 lower 为 Ebox 节点、属性与 Host 查询的框架模块;etaf-render-port.el 是唯一探测 versioned Ebox framework SPI 并选择发布路径的模块。两者都只使用 Ebox 公共 API,Runtime 只读取已经选定的 immutable port,不再自行猜测 Ebox 版本。Ebox 不理解 Component、slot、Action、Context、Behavior 或 Data。
公共 View 语法不接受裸 Ebox Node。框架集成层通过 Ebox 的 typed integration port 构造规范的 TextNode 和 BoxNode;普通 ETAF 应用只使用 Host 与 Component。这样 measurement、identity 和 rollback 始终由同一条 lowering 路径负责。
7. Runtime 与响应式状态
Runtime 的挂载是事务性的:
创建 Runtime
→ 建立 retained Component scope
→ 渲染 View 候选
→ lowering 并发布 Ebox 候选
→ 提升 handlers、instances 和 Behaviors
→ 运行 mounted/updated 生命周期
更新使用同一条路径。响应式 ref 和 computed 让 render effect 失效;Runtime scheduler 在当前边界同步 flush。渲染阶段写入状态会触发 etaf-render-write-error;状态应在事件、Action、Effect 或 watch callback 中改变。
渲染、lowering 和 Ebox 发布构成回滚边界。如果候选步骤之一失败,上一棵已提交的树仍保持 active。生命周期和 cleanup callback 在 retained state 提升且发布完成后运行;它们的错误会保持可见,但不会假装回滚已经发布的 Ebox tree。
响应式 API 只有一套模型:
(let* ((count (etaf-ref 0))
(double (etaf-computed
(lambda () (* 2 (etaf-value count))))))
(etaf-watch count
(lambda (new old)
(message "%s → %s" old new)))
(etaf-watch-effect
(lambda ()
(message "double=%s" (etaf-value double)))))
etaf-effect-scope 负责 effects 和 cleanup。Component setup 自动运行在 Component Scope 内;Component 释放时,会停止子 scope、watcher 和 resource cleanup。
8. Behavior、事件、Action 与 Effect
on-xx = 一个局部事件属性
Action = 一个命名的业务变更入口
Effect = 一个订阅/外部同步 owner
Behavior = 安装多个非视觉能力的可复用 bundle
一次性的交互直接使用 callback:
(text
:role 'button
:on-press (lambda () (message "Opened"))
"Open")
当变更需要命名并被多个入口复用时,使用 etaf-action-define 和 etaf-dispatch。当多个 Host 需要同一套非视觉能力时,使用 etaf-define-behavior 或 etaf-behavior-create,并通过 :use 安装。Behavior 不是 View 节点,也不直接修改 buffer。
etaf-behavior-create 接受保留的 :install 属性,用于可选的零参数 installer。Installer 可以返回 cleanup;运行期间可通过 etaf-current-behavior-context 读取当前 Runtime、结构路径和 Host props。Behavior 被替换或 owner 卸载时,installer 状态会被释放。
交互组合是确定的:显式 Host :on-* callback 先运行,然后按声明顺序运行 Behavior
callback;任一错误会 short-circuit 后续 callback。非事件属性由显式 Host 值优先,
否则第一个声明该属性的 Behavior 获胜(first-wins)。同一 Host 的 Behavior name 必须在任何
installer 运行前保持唯一。稳定 installer identity 会复用,每个已安装 cleanup
exactly-once。事件只 dispatch 给准确 Host ref;ETAF 没有 capture 或 bubble 阶段。
Action name 应使用 application/feature-prefixed symbol。重复注册默认报错。
etaf-action-redefine-run 是显式 authoring/reload 边界;替换只影响未来按 name 的
dispatch,不会 flush 已挂载 Runtime。
Runtime 事件通过 etaf-dispatch-event 进入;命中测试和 focus 通过 Ebox Host 引用查询,并由 etaf-activate、etaf-focus、etaf-focus-next、etaf-host-ref-bounds 和 etaf-host-ref-position 提供公共入口。
9. Context、Theme、Data 与 Resource
9.1 Context 与 Theme
Context 是继承的 Component Scope 环境:
(etaf-define-component service-provider ()
"Provide a reactive service to descendants."
:setup
(let ((service (etaf-ref "demo-service")))
(etaf-provide 'service service)
service)
:view (slot))
(etaf-define-component service-consumer ()
"Read the inherited service."
:setup (etaf-inject 'service nil t)
:view (text (expr (etaf-value (etaf-state)))))
(etaf-view (service-provider (service-consumer)))
key 使用稳定的普通 symbol。最近的祖先优先,缺失的 required key 触发 etaf-context-error。Theme 是一个 Context value,内容是属性 plist:
(etaf-define-component themed-shell ()
"Provide default text colors to a subtree."
:setup
(etaf-theme-provide
'(:color "#F4F6FB" :bgcolor "#202634"))
:view (slot))
Palette 解析属于 Theme,而不是 UI 目录。core 提供
etaf-theme-resolve-palette 来解析显式的亮/暗语义 pair;可选的
etaf-theme-tp 文件是唯一可以读取 TP renderer-level palette registry 的桥接层。
etaf-ui 只消费 :ui-* 语义 token,不依赖 TP 名称,也不访问 Ebox/TP 私有状态。
静态 Component 规则如果需要读取一个 Theme 值,应使用 etaf-theme-token;
ETAF 会在样式边界解析这个延迟 token,使 retained Host 仍然走静态样式路径。
显式 Host props 覆盖 Component styles,Component styles 覆盖 Theme defaults。
样式所有权跟随创建每个 View node 的 Component。嵌套 Component 是样式边界:父规则不会穿透其内部。调用者提供的 slot 内容使用 caller scope,子组件自有的 fallback 内容保留在 child scope。
9.2 Data
Data 是 ETAF core 能力,不是需要用户额外学习的第二套框架。Data Source 是一个小的 capability plist:
(etaf-data-source
:load (lambda (query page page-size)
(ignore query page page-size)
(let ((rows '((:id 1 :name "Ada"))))
(list :items rows :total (length rows))))
:mutate (lambda (operation payload)
(ignore operation payload)
t)
:dispose (lambda () t))
:load 必选,接收 QUERY、PAGE 和 PAGE-SIZE,返回带 :items 的 plist,可选 :total、:page 和 :page-size。:mutate 和 :dispose 可选。核心边界刻意是同步的,因此不需要 Promise、Task 或 Executor 概念;外部 callback 型集成可以通过同一套响应式 ref 或 Resource 边界发布结果。
etaf-data-controller 负责 query、分页、items、total、status、error、selection、request generation 和释放。etaf-data-memory-source 是示例和测试使用的内存 source。存储包只是具体 source;SQLite 不是 core 前提,ORM 也保持在 ETAF 数据模型之外。
9.3 Resource 与 Error Boundary
etaf-resource 是 Scope 所有的同步 loader,拥有响应式的 loading、success 和 error 状态:
(let* ((filename "README.md")
(resource
(etaf-resource
(lambda ()
(with-temp-buffer
(insert-file-contents filename)
(buffer-string)))))
(stop
(etaf-watch-effect
(lambda ()
(message "resource=%s" (etaf-resource-status resource))))))
(unwind-protect
(etaf-resource-value resource)
(funcall stop)
(etaf-resource-dispose resource)))
etaf-resource-result 为替换和释放提供 cleanup。etaf-error-boundary-run 是明确的函数边界:它处理 body 抛出的错误,让边界外的错误保持可见。Resource 和 Data 状态通过普通 expr 分支投影,不增加专门的 Error 或 Loading 节点。
10. 官方 UI 与包边界
正式的可复用界面概念只有 Component:
ebox
└── 可选的 ebox-playground
etaf → ebox
├── View / Component / Runtime
├── reactive / Action / Effect / Data
└── Renderer
etaf-ui → etaf
└── 官方 Button、Checkbox、Label、Panel、DataGrid 等 Component
etaf-sqlite → etaf
└── 类型化 SQLite Data Source
etaf-playground → etaf
└── 展示官方 Component 时可选依赖 etaf-ui
etaf-ui 是官方现成 Component 目录。用户只需要理解 Component;文件只是维护者边界。Controls、Widgets 和 DataGrid 不是平行的 Runtime 类型,DataGrid 只是由同一套 View、props、slot、事件和 Data 契约构成的复合 Component。
etaf-sqlite 是具体的数据源包,负责 schema 声明、标识符校验、连接、分页和 mutation;etaf-data 仍然属于 ETAF core。PostgreSQL、REST、文件和 ORM 集成可以在独立包中实现同一个 source capability,不增加 etaf-adapters 概念。
ebox-playground、etaf-playground、etaf-ui 和 etaf-sqlite 都是可以独立加载的同级包。ebox-playground 只使用 Ebox 公共 API,不加载 ETAF;etaf-playground 使用 ETAF 公共 API,并且只在展示官方组件时可选加载 etaf-ui,不依赖 ebox-playground。核心包不会自动加载任何可选包。
可选存储集成应使用具体名称,例如 SQLite 或 PostgreSQL source。通用 adapter 包无法拥有稳定的行为,只会增加用户需要记忆的名称,因此不属于公共模型。
11. 保留式发布与 fixed-point 安全
mounted Component、expr、slot、fragment、raw、inline 和 Root owner 都经过同一 个外层 reactive dispatch。局部 owner 先生成 candidate generation 和 Ebox logical replacement;不相交 owner 会合并为一次 TP/Ebox publication。只有 Root owner 可以进入 complete-root adapter。
已提交 semantic generation 的唯一 mutable pointer 由
etaf-generation-authority 持有。handler 与 Host-prop 等公共查询直接读取该
generation;Runtime 中同名 hash table 只是由 generation 单向重建的兼容 mirror,
既不能独立授权查询,也不能反向改写 generation。迁移期的
legacy/project/shadow 路由只用于证明投影等价与安全回退,不增加第二份
committed truth。
每个 semantic candidate 同时捕获 expected generation、semantic token,以及 instance/resource/artifact/route store versions。render 路径在 Ebox framework stage 中暂存一次 CAS,失败时由同一个 inverse journal 恢复;没有可见 Ebox 变化的 semantic-only 路径由 ETAF 自己执行同一 CAS,不创建 Ebox commit 或 TP revision。CAS 后的 mirror 与旧 route 清理属于 postcommit,失败不能反向恢复已提交 token。
每个 mounted Runtime 还拥有独立的 Host authority:state / opaque token / version。initial v2 framework stage 只进入 provisional state,并向 TP 注册固定
slot final marker;只有 buffer final accept 成功时 Host 才成为 attached。公开
lookup、event 和 source route 同时校验 attached state 与 token。detach 先在 O(1)
边界让 token 失效,再清理 registry、routes、Component scopes 和 Behaviors;因此
cleanup 数量或错误不能让旧 Host 重新获得 authority。
lifecycle callback 与结构 cleanup 在该 commit boundary 之后进入
etaf-retirement-journal。每个 entry 都有稳定 identity、ordering key、attempt
count、policy 与 terminal state。mounted、updated、unmounted 等公开 callback
只运行一次;第一个公开 callback 失败后,后续公开 callback 标记为 abandoned,
但结构 cleanup 继续。可幂等的框架 cleanup 只做有上限的重试;contained cleanup
只写 diagnostics,不改变已提交 generation、Ebox revision 或 Host authority。
Runtime 以有界历史保存已完成 journal,且它们与 committed outcome 分离。
当显式操作需要向调用者暴露 postcommit callback 错误时,ETAF 会用原 condition
symbol 重新 signal,并逐项保留原 condition data 前缀;末尾只追加一个固定
:etaf-condition-trailer/v1 datum,其中包含 operation、outcome、generation、
Ebox revision 与 diagnostic-journal ID。etaf-condition-postcommit-info 会校验并
读取该 trailer,让调用者机械区分“已经提交、随后 callback 失败”和 rollback
failure。buffer-kill 路径会 drain 或 contain retirement 工作,但绝不会从 kill
hook 抛 retirement condition。
Reactive publication 由显式 etaf-scheduler-context 协调。context 拥有 source
与 Runtime FIFO、对应 dedupe set、effect claim、turn/projection epoch、
nesting/busy 状态、fault diagnostics 与成本计数器;它不拥有 Component、
resource 或 generation state。既有调用者继续使用
etaf-scheduler-default-context,mount 也可以传入 :scheduler-context 隔离
dispatch authority。Scope、Effect 与 opaque Runtime route 会继承并保留该
context。
一次 logical projection 会先按 live subscriber context 对所有 changed source 在一次 subscriber-table scan 中分组;所有已触及 context 的 source propagation 都稳定后,Runtime callback 才能 publish。在同一 context 中,每个 source 与 Effect 每个 scheduler turn 只 delivery 一次,多个 changed source 也只 enqueue 同一 Runtime 一次。已经 delivery 的 source 若重入写入,会延后到下一个 turn; per-context turn budget 会包含跨 context cycle,并留下可复用的 fault diagnostics。 context A 的 dedupe 不会压制 context B;registry/token/Host 校验会在 fan-out 前 过滤 stale Runtime route。
Runtime callback 先作为一个完整 turn detach,因此 lifecycle write 会进入下一个 turn。Data success/error 的 multi-ref publication 与 event/action callback 都复用 同一个 projection boundary,legacy façade 则继续走 default context。Data source failure 会更新 Controller error state;之后发生的 projection/render failure 保持 原 condition,不能被重新归类为 source failure。Runtime operation report 同时携带 本 context delta 与完整 cross-context projection summary,覆盖 source delivery、 subscriber visit、effect work、Runtime work、stale drop、turn 与 fault。
每次 Runtime flush 都记录 candidate-aware effect tuple:其中包含 generation id、 effect→source 边和 source version,以及 candidate input/context/output facts 的 immutable semantic-node stamp。重复 tuple 会报告有序的 effect/edge path;step bound 从 candidate nodes、dependency edges 和 source entries 推导,不再是任意 固定倍率阈值。rollback 会丢弃本次 flush 的 stamp,因此同一个失败状态可以作为 新的 transaction 重试。
Behavior installer 使用 target-specific identity:reactive value 和 function 用
eq,普通 scalar attribute 才使用 value equality。每个已安装 Behavior 都获得
稳定的 (mount-epoch resource-id) 地址;generation membership 与 Runtime resource
registry 决定当前 authority。失败 candidate 只移除 staged Behavior resource,并
执行受 containment 保护的 cleanup。
12. 拓展规则
增加新能力前,优先选择最小的既有 owner:
| 需求 | Owner |
|---|---|
| 可复用的视觉组合 | Component 或普通 View helper |
| 一个子节点计算 | expr |
| 局部派生值 | etaf-computed |
| 可复用交互 | Behavior |
| 命名变更 | Action |
| 跨层级依赖 | Context |
| 请求或变更状态 | Data / Resource |
| 几何或布局算法 | Ebox |
只有在现有 owner 无法表达、能够明确 identity/lifecycle/error/rollback 规则,并且可以用公共路径测试证明时,才增加新的公共概念。这样既保留完整的 Elisp 表达能力,又让用户模型保持干净。