# 客户端组件规则
[English](client-components.md) | 中文
关于客户端组件、其状态与 effect 的规则。详细纪律见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md);Slot 设计正家是 [slots.zh.md](../subsystems/slots.zh.md),样式规则正家是 [web-styling.zh.md](../web-styling.zh.md)。以下每一节给出要求、评审判定方式、边界与例外,以及一正一反两个例子。
## 状态经 Injection 到达
Slot 的组件经其渲染点绑定的 Injection 面获得它渲染所需的一切——owner props、hook 席位与 action——绝不先在顶层读一棵大全局状态再逐层手工传下去。把每个活值都放在这个面上,props 才能在没变时保持引用不变,组件也才能只订阅自己读取的那部分子状态。缺了这条,任何一个无关字段变化都会重渲整棵子树——长会话在流式事件下的掉帧、滚动与输入卡顿就是这么来的。
- 判定方式:每个 `slots.render` 点上的作者都必须能回答两件事——被渲染组件订阅了哪些子状态,以及它收到的哪些 props 在多次渲染之间保持引用稳定。要盯住每个 prop 的可变性:每次渲染都重建的值(新对象、新数组、内联回调)即使内容没变也会向下传播重渲。
- 不在范围内:组件自己的局部状态(折叠、悬停、草稿用的 `useState`)不属于注入内容;不订阅 React 之外任何东西的组件也不需要新席位。
- 例外:新增注入面或 hook 席位会改动 Slot 契约,因此该改动要在 review 中说明影响面——哪些组件订阅什么;使用既有注入面不需要逐组件批准。
- 修法:把注入收窄到该视图真正渲染的内容,每个读取项经一个 hook 席位暴露一个字段,用窄 action 集合取代 store 或 service 句柄。[组件输入](../subsystems/slots.zh.md#component-inputs) 正家负责 inject 工厂、标准 hook 席位,以及它们各自如何成为 prop。
渲染点只注入一个 hook 席位与一组窄 action,组件只读它渲染的那个字段:
```tsx
// Render site: plain owner data, one hook seat, a narrow action set.
slots.render('sidebar.right', {
props: { id },
useHooks: SidebarHooks,
action: { rename, archive },
})
function SidebarRight({ id, useInfo, action }: Props) {
const { title } = useInfo() // subscribes to title only
return
}
```
顶层组件读了整个 view store 并把它作为一个 prop 传下去,于是渲染点再也回答不了上面两个问题:
```tsx
function Sidebar() {
const view = useViewStore() // one top-level global read
return // the whole store travels down
}
function SidebarRight({ view }: { view: ViewStore }) {
return {view.activePanel.title}
}
```
该 store 里任何字段变化都会重渲整个侧栏子树,而且绑定它的渲染点完全看不到这个组件订阅了什么。
## props 承载数据与回调,不承载 React 节点
prop 是数据或回调;React 节点经声明的 slot 传递。把元素作为 prop 值或注入成员传下去,会藏起一条 Slot 契约没有描述的第二条组合路径,把渲染控制权从注册面挪到调用点,并让一个包去拼装另一个包的标记。新增的节点型 prop 或注入成员一律禁止;需要宿主提供标记的位置,改为声明一个 child slot。
- 判定方式:组件的 props 类型、inject 工厂的返回值、或渲染点的 owner props 对象里出现 `ReactNode`、`ReactElement` 或 `() => JSX.Element`。
- 不在范围内:已经发布的节点型字段继续可用。规则在改动波及它时触发,该改动把它改掉,而不是继续扩展它。
- 例外:同一改动内无法完成改造时,由对应领域专家确认延期,且改动中记录该确认;作者不得自行确认。
- 修法:在父级注册的 `children` 中声明该位置,用 `renderSlot` 渲染它,数据经子组件的 props 传入。[扩展规则](../subsystems/slots.zh.md#extension-rules) 正家负责哪个包可以声明和渲染哪个 slot。
头部位置是一个声明的 child slot,因此宿主只传数据,标记由注册组件自己决定:
```tsx
// The card declares the header location; the host passes data only.
ctx.slots.register(
{
name: 'tool.call.toolview',
children: { header: { kind: 'single', scope: 'session' } },
},
ToolCard,
)
function ToolCard({ title, renderSlot }: Props) {
return {renderSlot('header', { title })}
}
```
React 节点作为 prop 值越过边界,于是它所走的组合路径没有任何地方声明过:
```tsx
type ToolCardProps = {
header?: React.ReactNode
footer?: React.ReactNode
}
// The call site assembles another package's markup.
} />
```
卡片的 Slot 契约不再描述它渲染什么,调用点继续握着渲染控制权,而徽章所在的包变成了传节点方的运行时引用。
## slot 注册物是稳定的 function component
注册进 slot 的值是在模块作用域声明的稳定 function component;席位渲染固定文本时也可以是纯字符串。渲染期创建的组件函数每一轮都是新的组件类型,而 React 把变化的类型当作另一个组件:它卸载这棵子树并挂载一棵新的,展开的行、输入焦点与滚动位置随之重置。注册期参数经 props 到达组件,而不是靠闭包捕获渲染期变量。
- 判定方式:注册物在哪里创建。注册调用里的内联箭头、渲染函数体内定义的组件、以及用 `useCallback` 包住内联组件的写法都不合格;模块作用域的 `function` 声明或字符串合格。
- 不在范围内:本条约束的是注册物的身份,不是它的复杂度。稳定组件照样可以读取注入的 props、hooks 与回调,固定文本席位也可以保持为字符串。
- 例外:只有调用方确实想要这次重挂载时,渲染期创建组件才是对的,普通改动无需单独批准;Slot 契约形态的变更按「状态经 Injection 到达」一节描述的注入面 review 走。
- 修法:把组件移到模块作用域,用注册面的各个 share 给它的 props 定型,让变化的值经 inject 面传入而不是走闭包。
注册物持有对模块作用域组件的稳定引用,变化的值经注入到达:
```tsx
function PanelTitle({ title, count }: Props) {
return {title} ({count})
}
// The registration holds one stable reference to the module-scope component.
ctx.slots.registerFactory(
{
name: 'panel',
slots: { title: PanelTitle },
inject: () => ({ title: 'Sessions', count: 3 }),
},
Panel,
)
```
注册调用里的内联箭头会在注册每次运行时产生新的组件类型:
```tsx
// A new component type each time this registration runs.
ctx.slots.registerFactory(
{ name: 'panel', slots: { title: (p) => {p.title} } },
Panel,
)
```
React 看到的类型与上一次注册不同,于是重挂载该席位,并在一次无关更新中重置它的局部状态——展开态、焦点或滚动位置。
## 组件绝不接收 Cordis context
`ctx` 属于 apply 世界:插件主体与闭包持有它的 `inject` 工厂。组件不接收 Cordis context、不导入 service 类去戳它、也不读 React context,因为业务组件看不到任何 context——渲染器的 binding context 是它内部的。裸 observable 只有在 Injection 把它包成 hook 之后才到达组件;直接自行订阅的组件持有一份 Injection 面上无人释放的生命周期。
- 判定方式:组件的参数或 props 类型里出现 `ctx` 或 `Context`;组件体内读 `observable.value`、调用 `subscribe`、或接 `useSyncExternalStore`;feature 的 `.tsx` 模块里导入 service 类。
- 不在范围内:组件内部、不订阅 React 之外任何东西的行为 hook(计时器、媒体查询)不是注入内容,留在组件里。
- 例外:无。`TabSlot` 里现存的直接订阅是该席位 owner 的整改工作,不是新代码的先例。
- 修法:把该事实移进注册的 `inject`——纯数据与回调,裸 observable 放进保留的 `hooks` 隔间——或移进声明的 store,再经派生的席位读取。
Injection 绑定 observable,组件读一个派生的 hook,订阅生命周期由渲染器持有:
```tsx
// The inject factory is the only place that closes over ctx.
const inject = () => ({
hooks: { tab: tabStore.observable },
action: { openTab },
})
function TabView({ useTab, action }: Props) {
const { loaded } = useTab() // the renderer owns the subscription
return