前置:第 02、07 课 | 预计时长:60 分钟 | 动手环节:必须完成 | 可与第 11 课互换、可跳过
你将学会
- 浏览器侧为什么是「与宿主同构但独立的另一个 Cordis 世界」;
- Slots 如何成为唯一的 UI 组合 API,以及「声明即授权」;
- 模块图与 Cordis DI 的分工(字节供给 vs 服务依赖);
- 业务组件为什么是「可整体重写的纯 props 消耗品」。
问题引入
第 07 课结尾提到 host/client 双编译面。现在看浏览器那一面:Web UI 的每个面板、每个工具卡片,是怎么被组合出来的?为什么说在浏览器里写 UI 组件和写宿主插件是两套语法?
正文
12.1 同构但独立:浏览器里的 Cordis
浏览器侧运行一套自己的 Cordis:UI 组合只走 Slots 插槽、字节供给走模块图(在 Cordis DI 之下)、代码分三层单向依赖。总体气质:业务组件是可整体重写的纯 props 消耗品。
12.2 Slots:唯一的 UI 组合 API
对应 packages/client/ui-slots/ 与 ui-renderer/:
// 注册是 effect(ui-renderer/src/client/registry.ts:606-613)——随 fiber 可逆,同第 02 课
register(options, component) {
return ctx.effect(() => this._register(options, component), 'slots.register()')
}
// 加载期强制(ui-slots/src/index.ts:787-789, 824-829)
if (!rec?.spec) throw `slot "${name}" is not declared` // 注册进未声明槽位 → 抛
if (childRec?.spec) throw `slot "${key}" is already declared` // 声明他人已声明的键 → 抛
children = 声明 + 授权:父条目必须先声明槽位,子组件才注册得进去;渲染期还有越权兜底(SlotOwnershipError)。- 命名约定
<domain>.<entry>.<hole>(如tool.call.toolview)。shell 构造期播种唯一先验槽位root,应用树 =ctx.slots.renderSlot('root', {})。
12.3 四份 props 与实时数据三通道
组件 props 由四份份额类型交汇(ui-slots/src/index.ts:440-448),全部推导、防漂移:
| 份额 | 内容 |
|---|---|
PropsRuntime | owner params + keyProps + inject face + 按 scope 合并的标准钩子 |
PropsRenderSlots | 由 children 键集推导(renderSlot 静态收窄到声明键) |
PropsStore | { useStore: 选择器钩子, actions: 烘焙后的写操作 }(store/src/contract.ts:135) |
| inject face | hooks 隔间映射为 use<Name>,其余成员透传 |
实时数据只有三通道(背下来,这是防状态混乱的关键约定):
- 父级已知 → owner props;
- 仅组件自知 → local state;
- 跨条目/跨重挂载 → register 声明的 store。
派生数据只能是对框架钩子的纯函数。store 引擎是 React-free 的(zustand vanilla + immer + rAF 批处理),读只经 props.useStore,写只经 props.actions.*;业务数据住在数据对象层、永不进 store(store 只装共享的查看/交互状态:选中、草稿、面板宽度)。还有一条硬规矩:禁止模块级 handle——模块缓存身份是跨插件重载的伪装单例。
12.4 模块图:Cordis DI 之下的字节供给
// packages/client/web/src/platform.ts:8-13 —— 基线模块表
PLATFORM_MODULES = ['react', 'react/jsx-runtime', 'react-dom', '@deepseek-ai/cordis',
'dsh-client-store', 'dsh-client-ui-slots', 'dsh-client-ui-primitives']
// 接缝(boot.ts:113-116):cordis 经 EntryTree.import 取插件代码
await ctx.plugin(Loader); ctx.loader.internal = this.modules
Cordis inject | 模块图 external | |
|---|---|---|
| 单位 | 服务名 | 模块标识符 |
| 时机 | 运行期等待 | 物化期同步 |
| 不满足 | 保持 PENDING,无超时 | 当场抛错 |
| 可满足者 | 任何提供该服务的插件,可替换 | 唯一模块身份,不可替换 |
| 环 | 允许 | 拒绝 |
模块 require 同步且不可替换,所以必须先于 cordis 排序激活可满足;dsh.client.inject 只是信息性边。构建侧纯度门拒绝一切未声明的 @deepseek-ai/* 值导入——跨插件协作走 cordis 服务或 slots,不走模块导入。
12.5 Conversation Node 与三层单向
- Conversation:每个业务特性注册一个
ConversationNodeDefinition;match(event)只读当前事件提取稳定业务 id(无 Context/历史访问),update折叠一个 Match 进 State、按逻辑日志序可重放——你会认出这就是第 06 课「日志化状态」模式在 UI 的翻版。追加热路径每次只处理一条新事件(按 seq 去重 + 二分插入),禁止全窗口扫描。 - 三层单向:① 数据对象层(React-free:connection、session-controller、store 引擎,产品是裸可观察源)→ ② 渲染机制(ui-renderer,唯一的 ctx→React 集成)→ ③ 展示组件(纯 props,不见 ctx、不读 React context)。
- rpcId 双向纪律:发起方铸造、应答方回显、不匹配即抛(
connection/src/client/rpc.ts:36-58)。 - notifier 发布纪律:
markDirty(微任务批量,结构更新)/markFrameDirty(每帧至多一次,流式块)/notifyNow(仅受控输入回显,否则 React 回滚 DOM)。
12.6 构建与引导
dsh.client 清单:platform 必须为 'web'、./client 导出必须存在(缺失即扫描失败)、immediately: true 仅供一阶段预取。Node 半边扫描 Loader 条目的 dsh.client 声明,按模块图序组合入口图注入 window.__DSH_BOOT__;浏览器端 main.ts 只做 new AppWebEntry(el).run()。
apps/web 不是独立应用:裸 Vite 无法注入 window.__DSH_BOOT__,serve 模式被拒(postmortem 0003 的教训——模型曾把裸 Vite 的 HTTP 200 当成功)。
动手环节
- 找槽名:在
packages/client/里找三个形如<domain>.<entry>.<hole>的槽注册,从root槽出发画出到它的挂载链。 - 对照表:把 12.4 的表格抄一遍,然后各找一处真实代码印证「inject 等待 vs require 抛错」。
- 推演题:一个组件想显示「当前选中会话的标题」。它该走三通道中的哪一条?如果改成把标题塞进 store,会破坏什么约定?
自检清单
- 为什么说 Slots 是「唯一」UI 组合 API?绕过它直接 import 别家组件会发生什么?(提示:纯度门)
- 「声明即授权」挡住了哪两类错误?分别在加载期还是渲染期?
- 模块图和 Cordis DI 各自解决什么?为什么模块 require 必须同步?
- 展示组件为什么不许见 ctx?数据对象层为什么 React-free?
常见误解
- 「
apps/web可以用 Vite 单独起」——不行,serve 模式被拒;必须经dsh web引导。 - 「store 是前端数据库」——store 只装共享查看/交互状态;业务数据住数据对象层。
- 「组件间可以互相 import」——跨插件协作走 cordis 服务或 slots;构建纯度门会拒绝未声明的
@deepseek-ai/*值导入。
延伸阅读
- 维基:《web-client》、《external-interfaces》(Client 与网关之间的 Typert RPC)
- 仓库:
docs/subsystems/web-client.md、docs/subsystems/slots.md、docs/subsystems/conversation.md、docs/module-graph.md - 下一课:第 13 课 · 工程文化与门禁