数据截至 (上游 commit f5baf760d23a)
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-95)。
为什么要这层抽象:让 Angular/其他框架能换成自己的响应式库,而内核逻辑不改。isSignal 的判断也很务实——靠「有没有 value 和 peek」鸭子类型,而非 instanceof(signals.ts:59-60),规避了双模块打包时 instanceof 失效的坑。
2. DataModel:按路径建信号,变更通知祖先与后代
核心结构
DataModel 持有一份原始 data 对象,外加一个 Map<path, Signal>——按需为被订阅的路径创建信号(data-model.ts:54-86,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:146-168)。 undefined语义: 给对象属性置undefined= 删 key;给数组下标置undefined= 保留长度的稀疏空位(data-model.ts:184-192)。这对应协议「value 省略则删 key」。
变更通知:祖先 + 自己 + 后代
这是 DataModel 最值得学的一段。改了一个路径,要通知三类信号(data-model.ts:312-332,notifySignals):
改 /user/name 时,唤醒:
self : /user/name
ancestors: /user , / (父链一路到根)
descendants: /user/name/... (所有以 /user/name/ 开头的已订阅路径)
为什么三类都要通知:订了 /user(整个对象)的组件,在 /user/name 变化时也得刷新;订了 /user/name/first 的子路径同理。全程包在 batchWrite 里合并成一次刷新(data-model.ts:315)。updateSignal 还对数组/对象做浅拷贝再 setValue(data-model.ts:338-345),保证引用变化触发下游 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:229-378):
- DYNAMIC:
subscribeDynamicValue订阅,值变了updateDeepValue+notify,把订阅塞进dataListeners以便 teardown(generic-binder.ts:233-245)。 - ACTION: 返回一个
() => void闭包;调用时同步深度解析 context 里的{path}/{call}再dispatchAction(generic-binder.ts:247-267)。 - STRUCTURAL: 订阅列表路径,把数组映射成
[{id: 模板id, basePath: /list/i}],每项一个nested子作用 域(generic-binder.ts:269-299)——这就是列表渲染。 - CHECKABLE: 把
checks里每条规则订阅成布尔,聚合出isValid和validationErrors注入父对象(generic-binder.ts:301-340)。这正是协议里「输入校验」「按钮按校验自动禁用」的实现。 - OBJECT: 递归;并为每个 DYNAMIC 字段补一个
setXxx双向 setter(generic-binder.ts:352-376,见03章 §3)。
第三步:对外只暴露一个订阅接口
GenericBinder 维护 currentProps(解析好的、随数据变化的 props),框架适配器通过 subscribe(listener) 拿更新;首个订阅者到来时 connect()、最后一个离开时 dispose(),自动管订阅生命周期(generic-binder.ts:415-429)。cloneAndUpdate 用不可变方式更新深层值,保证每次 props 引用变化(generic-binder.ts:384-398)——对 React 的 memo 友好。
4. 框架适配:以 React 为例
适配器的活很薄:把 GenericBinder 接到框架的响应式上。React 用 useSyncExternalStore(react/src/v0_9/adapter.tsx:51-121):
// 真实源码 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:72-80)。memo比较:props 引用变 / 组件 id 变 / dataContext.path 变 才重渲(adapter.tsx:59-64)——配合 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. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 可替换 signals | renderers/web_core/src/v0_9/reactivity/signals.ts | setSignalImplementation、PREACT_SIGNAL_IMPLEMENTATION |
| 响应式数据模型 | renderers/web_core/src/v0_9/state/data-model.ts | DataModel、set、notifySignals、updateSignal |
| schema 爬取 | renderers/web_core/src/v0_9/rendering/generic-binder.ts | scrapeSchemaBehavior、BehaviorNode |
| 绑定引擎 | 同上 | GenericBinder、resolveAndBind、cloneAndUpdate |
| 反应式求值 | renderers/web_core/src/v0_9/rendering/data-context.ts | resolveSignal、subscribeDynamicValue |
| React 适配 | renderers/react/src/v0_9/adapter.tsx | createComponentImplementation |