Skip to content
My Blog
Go back

第 12 课 · Web 客户端:Slots、模块图与三层分层

前置:第 02、07 课 | 预计时长:60 分钟 | 动手环节:必须完成 | 可与第 11 课互换、可跳过

你将学会

问题引入

第 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` // 声明他人已声明的键 → 抛

12.3 四份 props 与实时数据三通道

组件 props 由四份份额类型交汇(ui-slots/src/index.ts:440-448),全部推导、防漂移:

份额内容
PropsRuntimeowner params + keyProps + inject face + 按 scope 合并的标准钩子
PropsRenderSlots由 children 键集推导(renderSlot 静态收窄到声明键)
PropsStore{ useStore: 选择器钩子, actions: 烘焙后的写操作 }store/src/contract.ts:135
inject facehooks 隔间映射为 use<Name>,其余成员透传

实时数据只有三通道(背下来,这是防状态混乱的关键约定):

  1. 父级已知 → owner props
  2. 仅组件自知 → local state
  3. 跨条目/跨重挂载 → 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 与三层单向

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 当成功)。

动手环节

  1. 找槽名:在 packages/client/ 里找三个形如 <domain>.<entry>.<hole> 的槽注册,从 root 槽出发画出到它的挂载链。
  2. 对照表:把 12.4 的表格抄一遍,然后各找一处真实代码印证「inject 等待 vs require 抛错」。
  3. 推演题:一个组件想显示「当前选中会话的标题」。它该走三通道中的哪一条?如果改成把标题塞进 store,会破坏什么约定?

自检清单

常见误解

延伸阅读


Share this post:

Previous Post
第 11 课 · 自扩展:skill 与 extensions
Next Post
第 13 课 · 工程文化与门禁:把正确变成机器可执行