把协议族跑起来:一个协议驱动的 TODO 应用(含可运行 demo)

系列实现篇。用 React + Vite + TS 把前面设计的协议族真正实现成一个可运行的 TODO 应用——整个 app 的视觉、数据、业务逻辑、边界态全部是一份声明式协议数据,运行时解释它渲染而成,换掉这份数据就换掉整个应用。接口用 mock 拦截真实 fetch 模拟真实行为(状态码/延迟/持久化/错误注入)。因为逻辑与 DOM 分离,同一个运行时能被无头单测完整覆盖。附可在线体验的 demo 与全部源码结构。

前五篇一路从出码难题推到协议族设计,全是纸上架构。这一篇把它跑起来:用 React + Vite + TS 严格按那套协议实现一个真实的 TODO 应用,并把它部署成一个你现在就能点的 demo。目的只有一个——验证「逻辑即数据、运行时解释」不是 PPT,而是真能落地、还能被测试。

👉 在线体验/interactive/todo-protocol/

一句话主线

整个 TODO 应用的视觉、数据、业务逻辑、边界态,全部是一份声明式协议数据;一个通用运行时解释这份数据渲染出应用。换掉协议就换掉整个 app,而通用运行时一行不改。也正因为业务逻辑与 DOM 彻底分离,同一个运行时可以被无头单测完整覆盖。

这一篇不再讲「应该怎样」,只讲「我真的这样做了,结果如何」。


先说结论:它证明了什么,没证明什么

负责任地先划边界,免得被 demo 冲昏头:

  • 证明了:系列第四篇那套协议族,确实能端到端表达一个含真实业务逻辑与边界态的可运行应用。协议是「目标表示」,这个表示是成立的。
  • 证明了:把「业务逻辑」做成声明式数据(trigger→guard→action)、把「边界态」做成声明而非硬编码,是可行且干净的。
  • 没有证明:AI harness 那半——从设计稿识别、从 PRD 抽逻辑自动生成这份协议——本篇没做。这个 demo 里的协议是我人手写的(所以每个 Envelope 的 provenance 都是 human)。它验证的是「把什么填进去能跑」,不是「怎么自动填」。
  • ⚠️ 有捷径:运行时里用了 new Function 求值协议里的表达式,生产环境应换成安全的表达式沙箱或预编译。demo 里这样做是为了短。

看清这条边界,下面的东西才不会被误读。


架构:四层,各司其职

整个项目就四层,数据从上往下流,控制反过来:

protocols/   ← 应用的「全部真相」:纯数据,零逻辑(P1/P3/P8/P9-P14 + Envelope)
   ↓ 被解释
engine/      ← 通用运行时:解释协议,管状态、算派生、跑交互(无 DOM,可单测)
   ↓ 驱动
materials/   ← 物料层:一堆「哑」React 组件,只认 props + emit,不懂业务
   ↓ 请求
mock/ + api/ ← 拦截真实 fetch 的 mock 后端:状态码/延迟/持久化/错误注入

一句话记住每层的职责:protocols 说「是什么」,engine 说「怎么解释」,materials 说「长什么样」,mock 说「数据从哪来」。 关键是——业务逻辑只存在于 protocols(数据)里,其余三层都是通用的、与这个 TODO 无关的。


第一层 · 协议:整个 app 就是这份数据

先看最能说明问题的一段——业务逻辑(P12 交互规格)长什么样。这是「添加待办」这条规则,纯数据:

{
  id: "r_add",
  trigger: { node: "n_input", event: "onEnter" },   // 谁触发
  guard: "$derived.canAdd",                          // 守卫条件
  actions: [
    { type: "call", endpoint: "ep_add", payload: { title: "$state.draft.trim()" } },
    { type: "onSuccess", do: [                        // 成功分支
      { type: "setState", atom: "draft", value: "" },
      { type: "refetch", source: "src_todos" },
    ]},
    { type: "onError", do: [{ type: "toast", msg: "添加失败,请重试" }] }, // 失败分支
  ],
  provenance: "prd#add", confidence: 1.0,             // 溯源 + 可信度
}

注意:这里没有一行 JavaScript 在「执行」,它全是描述。 onError 分支的存在尤其关键——它就是前面几篇反复强调的「设计稿从不画、却占一半工作量的 unhappy path」。在这套架构里,它是协议里一个必须被填的字段,而不是一段容易被忘掉的代码。

边界态(P14)同样是声明的:

{ target: "n_list", source: "src_todos",
  loading: { text: "加载中…" },
  empty:   { when: "$derived.visibleTodos.length === 0", text: "这里空空如也…" },
  error:   { byCode: { default: { text: "加载失败", retry: true } } } }

loading / empty / error 三态,是数据。渲染层照着这份声明去显示,谁也别想漏掉一个态。

派生状态(P11)也是表达式数据,运行时按依赖自动重算:

derived: [
  { id: "visibleTodos", expr: "filter === 'all' ? src_todos.list : src_todos.list.filter(...)" },
  { id: "activeCount",  expr: "src_todos.list.filter(t => !t.done).length" },
  { id: "canAdd",       expr: "draft.trim().length > 0" },
]

而页面骨架(P8 组合树)里,repeat 直接把「列表渲染」编码进结构,解决了系列开篇 pix2code 的「长列表就崩」老问题:

{ node: "n_list", material: "mat_list",
  repeat: { over: "$derived.visibleTodos", as: "item", key: "item.id" },
  children: [{ node: "n_item", material: "mat_todo_item",
              props: { id: "$item.id", title: "$item.title", done: "$item.done" } }] }

这一整个 app,就是上面这些数据拼起来的。 换掉 protocols/ 这个目录,运行时不动,你就得到另一个应用。


第二层 · 运行时:通用解释器,且无 DOM

engine 是唯一「聪明」的一层,但它对 TODO 一无所知。它只做四件事:

  1. 持有响应式 store:atoms(draft/filter)+ 数据源(src_todos 的 loading/ok/error)。
  2. 算派生态:把 derived 表达式在当前 scope 下求值。
  3. 解析绑定:把 "$state.draft""$item.title" 这类表达式解析成实际值。
  4. 跑交互dispatch(node, event, payload) → 找到匹配规则 → 验守卫 → 顺序执行 actions(call 抛错就走 onError)。

最关键的设计决定:运行时不碰 DOM、不 import React。 它是一个纯函数式的对象工厂 createRuntime(protocols, api, onChange)。React 只是薄薄一层,在渲染时调 getScope() 求 props、在事件里调 dispatch()onChange 一响就重渲染。

这个「纯」不是洁癖,它换来一个巨大的红利,见下一节。


验证:因为逻辑离开了 DOM,它能被无头测试

系列里反复讲 Design2Code 那一课——逻辑要靠执行来验证,不能靠观看。这个 demo 把它做实了:因为运行时是纯的,我可以用同一个运行时、同一份协议、同一个 mock,在 Node 里无头地把整个业务流程跑一遍并断言:

rt.boot();                                   // 启动 → loading
await sleep(30);
assert.equal(rt.store.sources.src_todos.status, "ok");   // → ok,空列表

await rt.dispatch("n_input", "onInput", { value: "买牛奶" });
assert.equal(rt.getScope().derived.canAdd, true);        // 派生态正确翻转

await rt.dispatch("n_input", "onEnter");     // 回车添加
await sleep(30);
assert.equal(todos(rt)[0].title, "买牛奶");   // 真的加进去了
assert.equal(rt.store.atoms.draft, "");       // draft 被清空

// …toggle / filter / clear / delete / 错误边界 + 重试 …

mock.failNext(true);
await rt.refetch("src_todos");
assert.equal(rt.store.sources.src_todos.status, "error"); // 错误边界真能到达

npm test,输出 ✓ all engine behaviors passed注意这里没有浏览器、没有 DOM、没有 mock UI 事件——因为业务逻辑根本不在 UI 里。 这正是「把逻辑做成数据 + 纯运行时」最实在的回报:可测试性是架构送的,不是额外补的。


接口 mock:模拟真实行为,让错误边界不是摆设

按要求,接口要 mock 且「模拟真实行为」。我没有用假数据糊弄,而是拦截真实的 fetch:应用里发的是货真价实的 fetch("/api/todos"),mock 在 globalThis.fetch 层把它接住,返回带正确状态码的 Response,并模拟:

  • 网络延迟(默认 420ms)→ 让 loading 态真的会出现;
  • HTTP 状态码(201 创建、400 校验失败、500 服务器错误、404);
  • 持久化:浏览器用 localStorage(刷新不丢),Node 用内存数组(供单测);
  • 错误注入:一个 failNext(true) 开关,让下次 GET 返回 500——demo 页面上有个「模拟网络异常」勾选框,勾上再点刷新,你能亲眼看到错误边界 + 重试

因为 mock 拦的是全局 fetch同一套 mock 既跑在浏览器里、也跑在单测里。而且——把这个 mock 换成真实后端,protocols 和 engine 一行都不用改。这就是 P9 数据契约把「数据来源」与「页面逻辑」解耦的意义。


第三层 · 物料:一堆「哑」组件

materials 是 P3 物料契约对应的 React 组件。它们的共同特征是:只接收解析好的 props 和一个 emit(event, payload),对应用数据和业务逻辑一无所知。比如 TodoItem:

const TodoItem = ({ props, emit }: MaterialProps) => (
  <div className={`tp-item ${props.done ? "tp-item--done" : ""}`}>
    <input type="checkbox" checked={!!props.done} onChange={() => emit("onToggle", { id: props.id })} />
    <span>{props.title}</span>
    <button onClick={() => emit("onDelete", { id: props.id })}>✕</button>
  </div>
);

它不知道「toggle 之后要调哪个接口、要不要刷新」——那是协议里 r_toggle 规则的事。物料只管「长这样、点了往外抛事件」。这种彻底的哑,正是物料能被跨页面复用的前提(呼应系列第二篇「物料是视觉/交互单元,逻辑在别处」)。

而那个通用的 <RenderNode> 组件,是唯一知道「组合树」存在的 React 代码:它递归走树、解析 props、接线 dispatch、展开 repeat、对边界目标节点套用 loading/empty/error。它同样对 TODO 一无所知——指给它另一份协议,它渲染另一个 app。


为什么是「运行时 schema」而不是「出码」

一个刻意的架构选择:这个 demo 走的是运行时解释协议(P22 / 系列上一篇的策略 B),而不是「把协议编译成 React 代码」。

原因正是上一篇讲 round-trip 的结论只要存在「代码产物」这个第二表示,就有 schema↔代码双向同步的噩梦。 运行时路线只有一个源头(协议数据),根本没有代码产物会漂移——round-trip 问题直接消失。代价是表达力有上限、逃生舱受限,但对 TODO 这种规整场景绰绰有余。这不是偷懒,是按那篇的分析主动选的路。


源码结构

全部源码在仓库 demos/todo-protocol/,构建产物输出到 public/interactive/todo-protocol/ 由 GitHub Pages 托管:

demos/todo-protocol/src/
  protocols/    # 应用的全部真相(types.ts + index.ts)
  engine/       # evaluator.ts / runtime.ts(纯)/ render.tsx / engine.test.ts
  materials/    # registry.tsx —— 哑组件
  mock/         # server.ts —— 拦截 fetch 的 mock 后端
  api/          # client.ts —— endpoint id → HTTP
  App.tsx       # 装 mock、建 runtime、boot、渲染组合树

一个值得玩味的数字:engine/ + render 这套通用运行时约 200 行,materials 约 60 行,而承载「这个 app 是什么」的 protocols/ 也就一百多行数据。通用的代码写一次到处用,每个新页面的增量,理论上只是一份新的协议数据。 这就是这套架构复利的地方。


三个可迁移的结论

  1. 把业务逻辑做成声明式数据,可测试性是白送的。 逻辑一旦离开 UI,就能被无头地、确定性地执行和断言——不需要驱动 DOM 去验业务对不对。
  2. 让每一层对业务「无知」,通用性才成立。 engine、materials、RenderNode 都不认识 TODO,所以它们能服务任意页面。凡是往通用层里塞业务的,都是在毁掉复用。
  3. 架构选择等于问题选择。 选运行时 schema,就把 round-trip 问题从存在变成不存在。很多难题不是靠更强的技术解决的,是靠换一个不产生该难题的架构绕过的。

写在最后:六篇到这里,从「设计稿出码为什么难」一路走到「一个真能点的 TODO」。这个 demo 很小,但它诚实地闭合了整条论证——协议族确实能表达一个带真实逻辑和边界态的应用,而且因为逻辑成了数据,它能被测试、被复用、被换掉。 剩下没做的那一半——用 AI harness 从设计稿和 PRD 自动生成这份协议——才是真正难的、也是这个系列真正指向的地方。目标表示已经立住了;怎么把它自动填对,是下一段路。

👉 再放一次 demo:/interactive/todo-protocol/(勾上「模拟网络异常」再刷新,看错误边界)