跳到主要内容

04 · 渲染内核实现

本章讲什么: web_core 这个框架无关内核到底怎么把 JSON 变成响应式 UI。三块:signals 响应式基座、DataModel 的路径信号与通知、以及最妙的 GenericBinder——一个「读 schema 决定怎么绑」的零样板引擎。给要读源码的人。

1. 响应式基座:可替换的 signals

内核的响应式建在 Preact Signals 上,但做了一层可替换抽象(reactivity/signals.ts)。它把 signal/computed/effect/batchWrite/getValue/setValue/peekValue 都收口成模块级函数,默认实现是 Preact,但可用 setSignalImplementation 换掉(signals.ts:54-86)。

为什么要这层抽象:让 Angular/其他框架能换成自己的响应式库,而内核逻辑不改。isSignal 的判断也很务实——靠「有没有 valuepeek」鸭子类型,而非 instanceof(signals.ts:59-60),规避了双模块打包时 instanceof 失效的坑。

2. DataModel:按路径建信号,变更通知祖先与后代

核心结构

DataModel 持有一份原始 data 对象,外加一个 Map<path, Signal>——按需为被订阅的路径创建信号(data-model.ts:47-76,getSignal)。

set 的两个细节

// 真实源码 data-model.ts:104-132(精简):写路径时自动建中间容器
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
// 在数组上用非数字段 → 报错
if (Array.isArray(current) && !isNumeric(segment)) throw ...;
// 遇到原始值却想往下钻 → 报错
if (current[segment] 是原始值) throw ...;
// 缺失则按「下一段是不是数字」决定建数组还是对象
if (current[segment] == null)
current[segment] = isNumeric(nextSegment) ? [] : {};
current = current[segment];
}
  • 自动建路径:/a/b/0 时若 a/b 不存在,会按「下一段是数字吗」自动建 []{}(data-model.ts:127-130)。
  • undefined 语义: 给对象属性置 undefined = 删 key;给数组下标置 undefined = 保留长度的稀疏空位(data-model.ts:141-149)。这对应协议「value 省略则删 key」。

变更通知:祖先 + 自己 + 后代

这是 DataModel 最值得学的一段。改了一个路径,要通知三类信号(data-model.ts:240-260,notifySignals):

改 /user/name 时,唤醒:
self : /user/name
ancestors: /user , / (父链一路到根)
descendants: /user/name/... (所有以 /user/name/ 开头的已订阅路径)

为什么三类都要通知:订了 /user(整个对象)的组件,在 /user/name 变化时也得刷新;订了 /user/name/first 的子路径同理。全程包在 batchWrite 里合并成一次刷新(data-model.ts:243)。updateSignal 还对数组/对象做浅拷贝再 setValue(data-model.ts:266-273),保证引用变化触发下游 effect。

3. GenericBinder:读 schema 决定怎么绑(本仓库最妙的一处)

它解决的问题

每个组件的属性五花八门:有的是静态字符串、有的要绑数据、有的是 action、有的是子列表、有的是校验规则数组。如果为每种组件手写绑定逻辑,样板代码爆炸。GenericBinder 的思路:不写死,改成读组件的 Zod schema,自动推断每个属性该怎么处理

第一步:爬 schema 得到「行为树」

scrapeSchemaBehavior 遍历 Zod schema,给每个字段贴一个 BehaviorNode 标签(generic-binder.ts:37-117):

BehaviorNode含义怎么识别
DYNAMIC可绑数据的值union 里有含 {path} 但无 componentId 的对象
ACTION用户交互动作union 里有含 {event} 的对象
STRUCTURAL子组件列表/模板union 里有含 {componentId, path} 的对象
CHECKABLE校验规则数组属性名恰好是 checks
STATIC静态原始值兜底
OBJECT/ARRAY递归节点对象/数组继续往下爬
// 真实源码 generic-binder.ts:75-92(精简):靠结构而非 instanceof 识别原语
if (current._def.typeName === 'ZodUnion') {
const options = current._def.options;
if (options.some(o => o._def.shape().event)) return {type: 'ACTION'};
if (options.some(o => o._def.shape().path && !o._def.shape().componentId)) return {type: 'DYNAMIC'};
if (options.some(o => o._def.shape().componentId && o._def.shape().path)) return {type: 'STRUCTURAL'};
}

注意它刻意用 typeName 字符串比较而非 instanceof(注释明说为避免「双模块 instanceof」问题)——和 signals 那处一个思路。

第二步:按行为树绑定每个属性

resolveAndBind 按标签分别处理(generic-binder.ts:225-367):

  • DYNAMIC: subscribeDynamicValue 订阅,值变了 updateDeepValue + notify,把订阅塞进 dataListeners 以便 teardown(generic-binder.ts:229-241)。
  • ACTION: 返回一个 () => void 闭包;调用时同步深度解析 context 里的 {path}/{call}dispatchAction(generic-binder.ts:243-256)。
  • STRUCTURAL: 订阅列表路径,把数组映射成 [{id: 模板id, basePath: /list/i}],每项一个 nested 子作用域(generic-binder.ts:258-288)——这就是列表渲染。
  • CHECKABLE:checks 里每条规则订阅成布尔,聚合出 isValidvalidationErrors 注入父对象(generic-binder.ts:290-329)。这正是协议里「输入校验」「按钮按校验自动禁用」的实现。
  • OBJECT: 递归;并为每个 DYNAMIC 字段补一个 setXxx 双向 setter(generic-binder.ts:341-365,见 03 章 §3)。

第三步:对外只暴露一个订阅接口

GenericBinder 维护 currentProps(解析好的、随数据变化的 props),框架适配器通过 subscribe(listener) 拿更新;首个订阅者到来时 connect()、最后一个离开时 dispose(),自动管订阅生命周期(generic-binder.ts:404-418)。cloneAndUpdate 用不可变方式更新深层值,保证每次 props 引用变化(generic-binder.ts:373-387)——对 React 的 memo 友好。

4. 框架适配:以 React 为例

适配器的活很薄:把 GenericBinder 接到框架的响应式上。React 用 useSyncExternalStore(react/src/v0_9/adapter.tsx:44-102):

// 真实源码 adapter.tsx:63-95(精简)
bindingRef.current ??= new GenericBinder<Props>(context, api.schema);
// context 变了就重建 binder(类型变更 / basePath 调整)
const subscribe = useCallback(cb => { const s = binding.subscribe(cb); return () => s.unsubscribe(); }, [binding]);
const getSnapshot = useCallback(() => binding.snapshot, [binding]);
const props = useSyncExternalStore(subscribe, getSnapshot); // 把 binder 当外部 store
useEffect(() => () => binding.dispose(), [binding]); // 卸载时清订阅,防泄漏

要点:

  • useSyncExternalStore 把 binder 当外部 store——binder 的 subscribe/snapshot 正好是它要的两个参数。
  • context 变化才重建 binder:上层 DeferredChild 会 memo 住 context,所以引用变化严格对应 ComponentModel 更新或 basePath 调整(adapter.tsx:65-73)。
  • memo 比较:props 引用变 / 组件 id 变 / dataContext.path 变 才重渲(adapter.tsx:52-57)——配合 binder 的不可变更新,精准最小重渲。

开发者写组件时只管「props 进、视图出」,所有订阅、回写、列表展开、校验聚合都被 binder 抹平了。这就是 A2UI 渲染层「框架无关」的底气:内核(web_core)做完全部脏活,适配器(React/Angular/Lit)只是几十行胶水

5. 一次数据更新的端到端追踪

updateDataModel(/user/name="Jane")
│ message-processor.ts:335

DataModel.set("/user/name","Jane")
│ data-model.ts:151 notifySignals
▼ 唤醒 self + 祖先(/user,/) + 后代信号(batchWrite 合并)
DataContext 订阅的 effect 被触发
│ data-context.ts:145 subscribeDynamicValue

GenericBinder 的 onChange → updateDeepValue + notify
│ generic-binder.ts:229-241

适配器 useSyncExternalStore 拿到新 snapshot → React 重渲该组件
│ adapter.tsx:85

绑同一路径的所有组件一起刷新(响应式)

6. 代码地图

主题文件符号
可替换 signalsrenderers/web_core/src/v0_9/reactivity/signals.tssetSignalImplementationPREACT_SIGNAL_IMPLEMENTATION
响应式数据模型renderers/web_core/src/v0_9/state/data-model.tsDataModelsetnotifySignalsupdateSignal
schema 爬取renderers/web_core/src/v0_9/rendering/generic-binder.tsscrapeSchemaBehaviorBehaviorNode
绑定引擎同上GenericBinderresolveAndBindcloneAndUpdate
反应式求值renderers/web_core/src/v0_9/rendering/data-context.tsresolveSignalsubscribeDynamicValue
React 适配renderers/react/src/v0_9/adapter.tsxcreateComponentImplementation