etaf/docs/architecture.zh.md
2026-09-01 03:56:37 +08:00

26 KiB
Raw Blame History

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 + 属性 + 子节点 形状。
  • 每项能力只有一个 ownerView 负责结构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 的 loweringetaf-mount 为 View 建立有状态 Runtime 并发布到 buffer。

3.1 quote 与求值

规则只有两条:

  1. 结构性 View 位置不需要 quote包括 etaf-view、Host、Component 调用、子节点、slot 和静态 Component styles。
  2. Elisp 表达式位置遵循普通 Elisp 求值,包括属性值、:key:on-*:useexpr: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。ifwhencondletmapcarcl-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-valueetaf-refetaf-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 调用的子节点区,带 :nameslot 表示贡献内容。编译器对两种位置使用同一个规范化 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 后端不参与正确性保证;如果该后端不支持 GridGrid 树会使用普通 Ebox 渲染器。

ETAF 的 Renderer 是唯一把 View 语义 lower 为 Ebox 节点、属性与 Host 查询的框架模块;etaf-render-port.el 是唯一探测 versioned Ebox framework SPI 并选择发布路径的模块。两者都只使用 Ebox 公共 APIRuntime 只读取已经选定的 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-defineetaf-dispatch。当多个 Host 需要同一套非视觉能力时,使用 etaf-define-behavioretaf-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 refETAF 没有 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-activateetaf-focusetaf-focus-nextetaf-host-ref-boundsetaf-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 stylesComponent 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 必选,接收 QUERYPAGEPAGE-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。存储包只是具体 sourceSQLite 不是 core 前提ORM 也保持在 ETAF 数据模型之外。

9.3 Resource 与 Error Boundary

etaf-resource 是 Scope 所有的同步 loader拥有响应式的 loadingsuccesserror 状态:

(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 声明、标识符校验、连接、分页和 mutationetaf-data 仍然属于 ETAF core。PostgreSQL、REST、文件和 ORM 集成可以在独立包中实现同一个 source capability不增加 etaf-adapters 概念。

ebox-playgroundetaf-playgroundetaf-uietaf-sqlite 都是可以独立加载的同级包。ebox-playground 只使用 Ebox 公共 API不加载 ETAFetaf-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 等公共查询直接读取该 generationRuntime 中同名 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这类失败会写入 retirement diagnostic journal并用与 lifecycle failure 相同的 committed trailer 重新 signal绝不会重新进入 semantic rollback。

每个 mounted Runtime 还拥有独立的 Host authoritystate / 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-contextmount 也可以传入 :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。 每个 source wave 都会 snapshot 当前 FIFO因此 wave 中发现的不同 source 必须在 下一个受预算约束的 turn 执行,不能垄断一次 drain。context A 的 dedupe 不会压制 context Bregistry/token/Host 校验会在 fan-out 前过滤 stale Runtime route。

Runtime callback 先作为一个完整 turn detach因此 lifecycle write 会进入下一个 turn某个 callback 失败时scheduler 会先执行该 detached turn 中剩余 callback 再把首个 condition 记录为 context fault。Data success/error 的 multi-ref publication 与 event/action callback 都复用同一个 projection boundarylegacy 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 pathstep bound 从 candidate nodes、dependency edges 和 source entries 推导,不再是任意 固定倍率阈值。rollback 会丢弃本次 flush 的 stamp因此同一个失败状态可以作为 新的 transaction 重试。

Behavior installer 使用 target-specific identityreactive 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 表达能力,又让用户模型保持干净。