薄适配层:useChat 如何把 class 状态机桥进框架
30 秒导读: 聊天的全部逻辑(消息、流式、工具、续跑)都活在一个纯 TypeScript 的 class——
ChatClient里,和任何 UI 框架无关。每个框架包(ai-react/ai-vue/ai-solid/ai-svelte/ai-preact)只写一个几百行的薄壳useChat,干三件事:① 把 class 单例化(别每次渲染都重建),② 把 class 的一组onXxxChange回调接到框架的响应式原语(React 的setState、Vue 的ref、Solid 的signal),③ 把外面变化的options逐槽同步回 class。本章以ai-react/src/use-chat.ts为主线,讲透这层"桥"的每个巧妙处。
1. 这是什么(零基础也能懂)
一句话定义: useChat 是一个框架 hook,它把 headless 的 ChatClient 状态机"翻译"成你在组件里能直接用的响应式数据和方法。
它解决什么问题。 假设你已经写好了一套完整的聊天引擎(拼消息、处理 SSE 流、跑工具循环)——但它是个 class,new 出来后靠回调通知你"消息变了""在加载了"。可 React 组件不认识 class,它只认 useState;Vue 只认 ref;Solid 只认 signal。谁来把 class 的"变了"翻译成框架的"重渲染"? 这就是 useChat 的活。
它给谁用。 应用开发者。你写:
// 示意,非源码:一个最小 React 聊天组件
function Chat() {
const { messages, sendMessage, isLoading } = useChat({
connection: fetchServerSentEvents('/api/chat'),
})
return (
<>
{messages.map((m) => <div key={m.id}>{m.role}: ...</div>)}
<button disabled={isLoading} onClick={() => sendMessage('Hi')}>
发送
</button>
</>
)
}
你完全看不到 ChatClient、看不到回调、看不到订阅——useChat 把它们全藏进了那层薄壳。
一句话直觉。 把 ChatClient 想成一台只会广播的收音机(状态一变就喊一嗓子),useChat 就是接线员:把每一路广播接到框架对应的"重画屏幕"按钮上;同时把你转的旋钮(options)反向拧回收音机。收音机本身(引擎)在所有框架里一模一样,只有接线方式各不同。
本节不出现底层代码。目标:知道 useChat 是"class 与框架之间的翻译层"。
2. 顶层全景(这层桥怎么搭)
2.1 一张图:桥的两个方向
这层适配的本质是双向绑定,但两个方向用完全不同的机制。怎么读这张图:上半是"class → 框架"(class 主动推),下半是"框架 → class"(hook 主动同步)。
┌────────────── ─────────────────┐
options 变化 ─┐ │ ChatClient (class) │
(body/tools/…) │ │ 状态机 · 流式 · 工具 · 续跑 │
│ └───────────────────────────────┘
│ ▲ │
② 逐槽同步 │ │ updateOptions │ ① 一组 onXxxChange 回调
updateOptions └──────────┘ (框架→class) │ (class→框架,主动推)
▼
┌─────────────────────────────────────────────────────────┐
│ useChat 薄壳:把每路回调接到框架响应式 setter │
│ React: setState · Vue: ref.value= · Solid: setSignal │
└─────────────────────────── ──────────────────────────────┘
│
▼
组件重渲染 / 视图更新
2.2 三件核心工作
不管哪个框架,这层薄壳都只做三件事:
| 工作 | 怎么做 | React 里对应 |
|---|---|---|
| ① 单例化 class | 只在必要时 new ChatClient,不随渲染重建 | useMemo(..., [clientId]) |
| ② class→响应式 | 把 onMessagesChange 等回调接到框架 setter | onMessagesChange: (m) => setMessages(m) |
| ③ 框架→class | options 变了就 updateOptions 逐槽推回去 | 多个 useEffect(...updateOptions...) |
2.3 引擎写一次、各框架复用
七个状态从 class 广播出来,五个框架用各自的原语接住。回调名和语义完全一致,只是"怎么把新 值塞进响应式系统"不同:
| class 广播的回调 | React | Vue | Solid | Svelte | Preact |
|---|---|---|---|---|---|
onMessagesChange | setMessages | messages.value= | setMessages | messages=($state) | setMessages |
onLoadingChange | setIsLoading | isLoading.value= | setIsLoading | isLoading= | setIsLoading |
onStatusChange | setStatus | status.value= | setStatus | status= | setStatus |
onErrorChange | setError | error.value= | setError | error= | setError |
onSubscriptionChange | setIsSubscribed | isSubscribed.value= | setIsSubscribed | isSubscribed= | setIsSubscribed |
onConnectionStatusChange | setConnectionStatus | connectionStatus.value= | setConnectionStatus | connectionStatus= | setConnectionStatus |
onSessionGeneratingChange | setSessionGenerating | sessionGenerating.value= | setSessionGenerating | sessionGenerating= | setSessionGenerating |
class 侧这七个回调的接口声明在 ai-client/src/chat-client.ts:155-161;class 内部用 callbacksRef 存它们,并在缺省时替换成空 函数,保证 class 逻辑不必判空:chat-client.ts:203-220(onMessagesChange: options.onMessagesChange || (() => {}))。
这就是"引擎写一次、复用"的全貌: 上面这张表是唯一真正因框架而异的地方,其余(状态机、流式、工具)全在 class 里,零重复。
3. 核心原理(以 React 为主线,逐个拆)
主线文件:ai-react/src/use-chat.ts。下面每小节挑一个巧妙处讲透。
3.1 useMemo 单例化:依赖只有 clientId
要解决的小问题: React 组件每次渲染都会重跑函数体。若每次都 new ChatClient(),那就等于每次渲染都换一台新收音机——流式中途换机、消息丢失、订阅泄漏。
思路: 用 useMemo 把 class 实例缓存住,依赖数组只放 clientId。只要 id 不变,渲染一百次也复用同一个实例;只有换 id(换会话)才重建。
真实实现。 use-chat.ts:68-162,const client = useMemo(() => { ... return instance }, [clientId])。注意依赖数组只有 [clientId](第 162 行),clientId 来自 options.id || hookId(:33-34,hookId = useId())。
关键细节: 既然依赖只有 clientId,那 options 里其它字段(body、connection、回调)在 new 之后就不会因为它们变化而重建实例——这正是设计意图,但也逼出了后面 3.3、3.4 两个补偿机制(否则实例会拿着过时的 options)。React 的 useChat 顶部注释直言:"Connection and body changes will recreate the ChatClient instance"仅指通过 id 维度;其余靠同步。
3.2 class→响应式:把回调接到 setState
思路: new ChatClient 时,把每个 onXxxChange 写成一个"调用框架 setter"的小闭包。class 一广播,setter 一调,React 就重渲染。
真实实现。 use-chat.ts:131-158:
// 真源码节选,use-chat.ts:131-137
onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {
if (activeClientRef.current !== instance) return
setMessages(newMessages)
},
onLoadingChange: (newIsLoading: boolean) => {
if (activeClientRef.current !== instance) return
setIsLoading(newIsLoading)
},
这段就是"class→响应式"的桥:ChatClient 内部改了消息,调 onMessagesChange,这里转手 setMessages,React 重画。每一路状态都这么接一遍。
注意那行 if (activeClientRef.current !== instance) return——这是本章最微妙的守卫,单独放 3.5 讲。
3.3 optionsRef:让回调永远读到最新 options
要解决的小问题: new ChatClient 只发生一次(3.1),可用户传的 onFinish、onChunk 每次渲染都是新的闭包。若把第一次渲染时的 onFinish 直接塞进 class,它就被冻结在旧闭包上,后续再也拿不到新的。
思路: 存一个 optionsRef,每次渲染同步刷新成最新 options;class 里的回调不直接调用户函数,而是通过 ref 转发——调用那一刻才去读最新值。
真实实现。 use-chat.ts:64-65 定义并每渲染刷新:
// 真源码,use-chat.ts:64-65
const optionsRef = useRef<UseChatOptions<...>>(options)
optionsRef.current = options
然后所有用户回调都走 ref:use-chat.ts:105-127,如 onChunk: (chunk) => { ...; optionsRef.current.onChunk?.(chunk) }。先读 ref 再调,所以永远是最新闭包;用户把回调设成 undefined 时 ?. 自动 no-op。
为何不用 updateOptions 更新回调? 也能,但 Vue/Solid 的注释点破了差异:updateOptions 对 undefined 是"跳过、保留旧值",无法用它"清空"一个回调;而 ref+?. 方案里,把回调设 undefined 就真的静默了(ai-vue/src/use-chat.ts:66-72 注释)。
3.4 逐槽同步:每个 wire 字段一个 useEffect
要解决的小问题: 实例不重建(3.1),那 options.body、options.tools、options.context 变了,怎么让 class 知道?
思路: 用 updateOptions 把新值推回 class。React 版把它拆成多个独立 useEffect,一个字段一个,这样"改了 body"不会连带触发"重推 tools"。
真实实现。 use-chat.ts:175-193:
// 真源码节选,use-chat.ts:175-193(四个独立 effect)
useEffect(() => {
client.updateOptions({ body: options.body })
}, [client, options.body])
useEffect(() => {
if (options.forwardedProps !== undefined) {
client.updateOptions({ forwardedProps: options.forwardedProps })
}
}, [client, options.forwardedProps])
useEffect(() => {
if (options.tools !== undefined) {
client.updateOptions({ tools: options.tools })
}
}, [client, options.tools])
useEffect(() => {
client.updateOptions({ context: options.context })
}, [client, options.context])
关键坑(EOPT): 注意 forwardedProps 和 tools 前面有 !== undefined 守卫,body 和 context 却没有。原因在 class 侧 updateOptions 的语义(ai-client/src/chat-client.ts:1471-1483):body/forwardedProps 传 undefined 表示"这一槽不动";context 则是"key 在场且值为 undefined 就清空"。所以能安全传 undefined 的槽(body/context)直接透传,不能的(forwardedProps/tools)就守卫。这也是为什么源码注释反复提 exactOptionalPropertyTypes(EOPT)——TS 严格模式下不允许把 undefined 赋给 field?: T 这种严格可选字段。
3.5 activeClientRef 守卫:StrictMode / 并发下防旧实例写新状态
这是本章最"不显然"的一处,值得慢讲。
要解决的小问题: React 18 的 StrictMode 会故意 mount→unmount→再 mount 组件来暴露副作用 bug;并发渲染也可能让新旧两个 ChatClient 实例短暂共存。旧实例的异步回调(比如一个还没结束的流)如果这时 setMessages,就会用旧实例的数据覆盖新实例的正确状态。
思路: 维护一个 activeClientRef 指向"当前当家的实例"。每个回调进来第一件事:核对自己是不是当家的,不是就直接 return,一个字都不写。
真实实现。 定义在 use-chat.ts:55;每个回调开头守卫(如 :106、:132);实例创建后立刻认领 activeClientRef.current = instance(:160)。再看回调里那行(3.2 已见):
onMessagesChange: (newMessages) => {
if (activeClientRef.current !== instance) return // ← 我不是当家的,闭嘴
setMessages(newMessages)
},
失活为什么要延迟一拍(setTimeout(0))? 卸载/换实例时,不能立刻把 activeClientRef 设 null——StrictMode 的"假卸载"后马上又 mount,若立刻清空会误伤。所以 cleanup 里用 setTimeout(0) 把"失活"推到下一个 tick,若期间新的 effect 又认领了就取消掉。真源码 use-chat.ts:203-229:
// 真源码节选,use-chat.ts:203-217
useEffect(() => {
if (cleanupInvalidationRef.current) {
clearTimeout(cleanupInvalidationRef.current) // 新 effect 抢回来,取消上一次失活
cleanupInvalidationRef.current = null
}
activeClientRef.current = client // 重新认领当家
client.mountDevtools()
return () => {
cleanupInvalidationRef.current = setTimeout(() => {
if (activeClientRef.current === client) {
activeClientRef.current = null // 延迟一拍再失活
}
cleanupInvalidationRef.current = null
}, 0)
// …(卸载清理见 3.6)
}
}, [client])
cleanupInvalidationRef(定义于 :56-58)就是这个"延迟失活句柄",能被下一次 mount 抢回来(clearTimeout),从而扛住 StrictMode 的抖动。
3.6 卸载:按 live 决定 unsubscribe vs stop,再 dispose
思路: 组件卸载(或换实例)时要收尾,但收尾方式取决于是不是"长连接"模式(options.live):
- live 模式:连接是长期订阅,卸载时只
unsubscribe()(断订阅),不stop。 - 非 live:只有一次性请求,卸载时
stop()掐掉在途请求即可。 - 两种情况最后都
dispose()释放 devtools 等资源。
真实实现。 use-chat.ts:222-227:
// 真源码,use-chat.ts:222-228
if (optionsRef.current.live) {
client.unsubscribe()
} else {
client.stop()
}
client.dispose()
关键细节: 这里读的是 optionsRef.current.live 而不是闭包里的 options.live。因为这个 cleanup 只应在"卸载或换实例"时跑,而订阅/退订本身由 3.7 那个专门的 effect 负责。若这里依赖 options.live,live 每次切换都会触发 dispose——注释 :218-221 明确点破这一点。class 侧:unsubscribe 会中止在途流并断连(chat-client.ts:1112-1120),dispose 会先 unsubscribe 再拆 devtools(:1520-1524)。
3.7 live → subscribe / unsubscribe
思路: options.live 是"是否 mount 时就建立长连接订阅"的开关。用一个专门的 effect 监听它:开就 subscribe(),关就 unsubscribe()。
真实实现。 use-chat.ts:195-201:
// 真源码,use-chat.ts:195-201
useEffect(() => {
if (options.live) {
client.subscribe()
} else {
client.unsubscribe()
}
}, [client, options.live])
订阅的生命周期(连接 loop、重连)全在 class 的 subscribe/unsubscribe 里(chat-client.ts:1093-1120);hook 只负责"根据开关拨一下"。这与 3.6 分工清晰:日常拨动归这个 effect,卸载清 理归 mount effect 的 cleanup。
4. 结构化输出:activeStructuredPart / partial / final 的派生
这是 useChat 在 chat 之外多提供的一层"语法糖":当你传了 outputSchema,hook 会额外吐出 partial(流式中的渐进解析)和 final(完成后的强类型对象)。
4.1 它从哪来——不是新状态,是从 messages 派生
关键设计: partial/final 不是独立状态,而是从 messages 里算出来的。具体是:找到"最后一条 user 消息之后的那条 assistant 消息"上的 structured-output part。这样每条历史 assistant 回合都各自带着自己的结构化 part,历史天然连贯,不需要额外的 reset 信号。
StructuredOutputPart 的形状见 ai/src/types.ts:404-421:status: 'streaming' | 'complete' | 'error',partial(流式渐进解析),data(仅 complete 时的最终对象)。
4.2 派生三步走
怎么读这段逻辑:从消息数组倒着找。
messages: [ … user(问) assistant(答:含 structured part) ]
▲lastUserIndex ▲从这里往回找第一个 assistant 的 structured part
真实实现 use-chat.ts:296-327,三个 useMemo 串起来:
activeStructuredPart(:296-314):倒序找到lastUserIndex,再从末尾扫到它之后的 assistant,取其structured-outputpart。partial(:316-320):有 part 就返回part.partial ?? part.data,否则空对象。final(:322-327):只有part.status === 'complete'才返回part.data,否则null。
4.3 "无 user 消息则返回 null"——防泄漏
要解决的坑: 如果 initialMessages 里只有一条上一轮的、陈旧的 assistant 消息(或只有 system prompt),而当前还没有任何 user 消息,直接扫历史就会把上一场会话的 final 泄漏到本次首渲染。
做法: 找不到 user 消息(lastUserIndex === -1)时,直接 return null,绝不回退去扫历史 assistant。真源码 use-chat.ts:304:if (lastUserIndex === -1) return null。注释 :284-293 把这个理由写得很清楚。
补充:
partial/final在运行时无条件都被计算和返回;只是公开返回类型UseChatReturn<TTools, TSchema>在你没传outputSchema时把它们隐藏掉了。TS 无法跨这个条件类型做结构化收窄,所以结尾用as unknown as UseChatReturn作缝(use-chat.ts:333-351,连带一条eslint-disable说明)。
5. 五个框架的差异面(不逐行,只讲差在哪)
引擎相同,薄壳因框架的响应式模型而异。下面是差异点速查,不是逐行复述。
| 维度 | React (use-chat.ts) | Vue (use-chat.ts) | Solid (use-chat.ts) | Svelte (create-chat.svelte.ts) | Preact (use-chat.ts) |
|---|---|---|---|---|---|
| 响应式原语 | useState | shallowRef | createSignal | $state 符文 | useState |
| 派生值 | useMemo | computed | createMemo | $derived.by | 无(不含结构化输出) |
| 单例化 | useMemo([clientId]) | 直接 new(每 setup 一次) | createMemo | 直接 new | useMemo([clientId]) |
| activeClientRef 守卫 | 有 | 无 | 无 | 无 | 有 |
| options 同步 | 4 个独立 effect | 单个 watch(合并 body/fwd/ctx) | 单个 createEffect | 手动 updateBody/… 方法 | 单个 effect(合并) |
| 卸载清理 | mount effect 的 cleanup | onScopeDispose | onCleanup | 无自动清理,需手动 stop() | 同 React |
| 结构化输出 | 有(partial/final) | 有 | 有 | 有 | 无 |
几个值得点名的差异:
React vs Preact——最像的一对。 二者几乎逐行同构(都有 activeClientRef + setTimeout(0) 失活 + useMemo 单例)。两处差别:① React 把 body/forwardedProps/tools/context 拆成四个独立 effect(3.4),Preact 合并进一个 effect(ai-preact/src/use-chat.ts:169-179);② Preact 版不做结构化输出——泛型里没有 TSchema,返回值无 partial/final(ai-preact/src/use-chat.ts:25-28, 278-294)。
Vue——不需要 activeClientRef 守卫。 Vue 的 useChat 直接 new ChatClient(不 memo),回调直接读 options.xxx(ai-vue/src/use-chat.ts:82-141);因为没有 StrictMode 式双挂载,省掉了整套守卫;options 同步用一个 watch 把 [body, forwardedProps, context] 合并处理(:150-161),清理走 onScopeDispose(:181-188)。
Svelte——最"手动"。 $effect 只能在组件初始化期用,所以 createChat 不做自动卸载清理,而是额外导出 dispose(),并让用户自己在组件清理时调 stop()(ai-svelte/src/create-chat.svelte.ts:166-169, 188-190)。options 同步也不自动:改 wire 字段要显式调 updateBody / updateForwardedProps / updateContext(:221-231)。返回值用 getter 让 Svelte 追踪响应性(:274-301)。
Solid——一处易被误读的写法 (inferred)。 Solid 版把实例包在 createMemo(() => new ChatClient(...), [clientId])(ai-solid/src/use-chat.ts:71-141),注释写"Only recreate when clientId changes"。但 Solid 的 createMemo(fn, value?) 第二个参数是初始值、不是 React 那样的依赖数组;clientId 又在 memo 外部算好(:44-45),memo 内部并未 track 任何会变的 signal。因此该 memo 实际上首跑后基本不再重建实例——效果上等价于"建一次",与注释描述的机制不完全对应。这一点标记为 (inferred),建议以运行行为为准。
6. 巧妙之处(可借鉴)
- 回调过 ref 转 发,而非直接注入。 单例 class + 每渲染刷新的
optionsRef,让"实例只建一次"与"回调始终最新"两个看似矛盾的目标共存(use-chat.ts:64-65, 105-127)。这是"稳定实例 + 易变闭包"通用解法。 - activeClientRef + setTimeout(0) 双保险扛 StrictMode。 用"当家实例"守卫过滤旧实例的异步写,再用"延迟一拍失活 + 可被抢回"扛住假卸载抖动(
use-chat.ts:55-58, 106, 203-217)。凡是"class 状态机 × React 并发"都会撞上这个坑。 - updateOptions 逐槽语义 + 逐 effect 同步。 class 侧
body/forwardedProps的undefined表示"不动"、context的在场undefined表示"清空"(chat-client.ts:1471-1483),hook 侧据此决定哪些槽要守卫哪些直传(use-chat.ts:175-193)。语义精确到"改一个不碰另一个"。 - 结构化输出从 messages 派生 + 防泄漏。 不新增状态、纯派生,历史天然连贯;无 user 消息时果断返回
null,杜绝上一场final泄漏(use-chat.ts:294-314)。
7. 边界与局限
- 本章只讲 chat。
ai-react还导出useRealtimeChat(实时语音/双工)与一整套 generation hook(useGeneration、useGenerateImage、useGenerateAudio、useTranscription、useSummarize、useGenerateVideo、useAudioRecorder,见ai-react/src/index.ts:2, 17-61)。它们各有自己的 client,不在本 area 范围。 - UI 组件不在这里。
useChat只给状态和方法,渲染是你自己的事;无样式的 render-prop 组合件是 第 5 章 的内容。 - class 内部机制不展开。 状态机与流式生命周期见 01;工具、审批、续跑、持久化见 06。
- 框架特性差异是真实约束。 如 Svelte 无法自动卸载清理(
$effect限制)、Vue 不需要 StrictMode 守卫——这些不是疏漏,是各框架响应式模型使然。
8. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| React 主线 hook | packages/ai-react/src/use-chat.ts | useChat |
| 单例化 class | packages/ai-react/src/use-chat.ts:68-162 | useMemo / client |
| class→响应式回调 | packages/ai-react/src/use-chat.ts:131-158 | onMessagesChange … |
| optionsRef 转发 | packages/ai-react/src/use-chat.ts:64-65, 105-127 | optionsRef |
| activeClientRef 守卫 | packages/ai-react/src/use-chat.ts:55, 106, 203-217 | activeClientRef / cleanupInvalidationRef |
| 逐槽同步 options | packages/ai-react/src/use-chat.ts:175-193 | updateOptions |
| live 订阅 | packages/ai-react/src/use-chat.ts:195-201 | subscribe / unsubscribe |
| 卸载清理 | packages/ai-react/src/use-chat.ts:222-228 | stop / dispose |
| 结构化输出派生 | packages/ai-react/src/use-chat.ts:296-327 | activeStructuredPart / partial / final |
| class 回调接口/缺省 | packages/ai-client/src/chat-client.ts:155-161, 203-220 | callbacksRef |
| updateOptions 逐槽语义 | packages/ai-client/src/chat-client.ts:1440-1518 | updateOptions |
| subscribe/unsubscribe/dispose | packages/ai-client/src/chat-client.ts:1093-1120, 1520-1524 | subscribe / unsubscribe / dispose |
| ChatClientState 取值 | packages/ai-client/src/types.ts:107 | ChatClientState |
| StructuredOutputPart 形状 | packages/ai/src/types.ts:404-421 | StructuredOutputPart |
| Vue 适配 | packages/ai-vue/src/use-chat.ts | useChat |
| Solid 适配 | packages/ai-solid/src/use-chat.ts | useChat |
| Svelte 适配 | packages/ai-svelte/src/create-chat.svelte.ts | createChat |
| Preact 适配 | packages/ai-preact/src/use-chat.ts | useChat |
| React 包导出面 | packages/ai-react/src/index.ts | useChat / useRealtimeChat / generation hooks |