跳到主要内容

薄适配层: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 等回调接到框架 setteronMessagesChange: (m) => setMessages(m)
③ 框架→classoptions 变了就 updateOptions 逐槽推回去多个 useEffect(...updateOptions...)

2.3 引擎写一次、各框架复用

七个状态从 class 广播出来,五个框架用各自的原语接住。回调名和语义完全一致,只是"怎么把新值塞进响应式系统"不同:

class 广播的回调ReactVueSolidSveltePreact
onMessagesChangesetMessagesmessages.value=setMessagesmessages=($state)setMessages
onLoadingChangesetIsLoadingisLoading.value=setIsLoadingisLoading=setIsLoading
onStatusChangesetStatusstatus.value=setStatusstatus=setStatus
onErrorChangesetErrorerror.value=setErrorerror=setError
onSubscriptionChangesetIsSubscribedisSubscribed.value=setIsSubscribedisSubscribed=setIsSubscribed
onConnectionStatusChangesetConnectionStatusconnectionStatus.value=setConnectionStatusconnectionStatus=setConnectionStatus
onSessionGeneratingChangesetSessionGeneratingsessionGenerating.value=setSessionGeneratingsessionGenerating=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 里其它字段(bodyconnection、回调)在 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),可用户传的 onFinishonChunk 每次渲染都是新的闭包。若把第一次渲染时的 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 的注释点破了差异:updateOptionsundefined 是"跳过、保留旧值",无法用它"清空"一个回调;而 ref+?. 方案里,把回调设 undefined 就真的静默了(ai-vue/src/use-chat.ts:66-72 注释)。

3.4 逐槽同步:每个 wire 字段一个 useEffect

要解决的小问题: 实例不重建(3.1),那 options.bodyoptions.toolsoptions.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): 注意 forwardedPropstools 前面有 !== undefined 守卫,bodycontext 却没有。原因在 class 侧 updateOptions 的语义(ai-client/src/chat-client.ts:1471-1483):body/forwardedPropsundefined 表示"这一槽不动";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 串起来:

  1. activeStructuredPart(:296-314):倒序找到 lastUserIndex,再从末尾扫到它之后的 assistant,取其 structured-output part。
  2. partial(:316-320):有 part 就返回 part.partial ?? part.data,否则空对象。
  3. 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)
响应式原语useStateshallowRefcreateSignal$state 符文useState
派生值useMemocomputedcreateMemo$derived.by无(不含结构化输出)
单例化useMemo([clientId])直接 new(每 setup 一次)createMemo直接 newuseMemo([clientId])
activeClientRef 守卫
options 同步4 个独立 effect单个 watch(合并 body/fwd/ctx)单个 createEffect手动 updateBody/… 方法单个 effect(合并)
卸载清理mount effect 的 cleanuponScopeDisposeonCleanup无自动清理,需手动 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/forwardedPropsundefined 表示"不动"、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(useGenerationuseGenerateImageuseGenerateAudiouseTranscriptionuseSummarizeuseGenerateVideouseAudioRecorder,见 ai-react/src/index.ts:2, 17-61)。它们各有自己的 client,不在本 area 范围。
  • UI 组件不在这里。 useChat 只给状态和方法,渲染是你自己的事;无样式的 render-prop 组合件是 第 5 章 的内容。
  • class 内部机制不展开。 状态机与流式生命周期见 01;工具、审批、续跑、持久化见 06
  • 框架特性差异是真实约束。 如 Svelte 无法自动卸载清理($effect 限制)、Vue 不需要 StrictMode 守卫——这些不是疏漏,是各框架响应式模型使然。

8. 代码地图(导航索引)

主题文件路径符号名
React 主线 hookpackages/ai-react/src/use-chat.tsuseChat
单例化 classpackages/ai-react/src/use-chat.ts:68-162useMemo / client
class→响应式回调packages/ai-react/src/use-chat.ts:131-158onMessagesChange
optionsRef 转发packages/ai-react/src/use-chat.ts:64-65, 105-127optionsRef
activeClientRef 守卫packages/ai-react/src/use-chat.ts:55, 106, 203-217activeClientRef / cleanupInvalidationRef
逐槽同步 optionspackages/ai-react/src/use-chat.ts:175-193updateOptions
live 订阅packages/ai-react/src/use-chat.ts:195-201subscribe / unsubscribe
卸载清理packages/ai-react/src/use-chat.ts:222-228stop / dispose
结构化输出派生packages/ai-react/src/use-chat.ts:296-327activeStructuredPart / partial / final
class 回调接口/缺省packages/ai-client/src/chat-client.ts:155-161, 203-220callbacksRef
updateOptions 逐槽语义packages/ai-client/src/chat-client.ts:1440-1518updateOptions
subscribe/unsubscribe/disposepackages/ai-client/src/chat-client.ts:1093-1120, 1520-1524subscribe / unsubscribe / dispose
ChatClientState 取值packages/ai-client/src/types.ts:107ChatClientState
StructuredOutputPart 形状packages/ai/src/types.ts:404-421StructuredOutputPart
Vue 适配packages/ai-vue/src/use-chat.tsuseChat
Solid 适配packages/ai-solid/src/use-chat.tsuseChat
Svelte 适配packages/ai-svelte/src/create-chat.svelte.tscreateChat
Preact 适配packages/ai-preact/src/use-chat.tsuseChat
React 包导出面packages/ai-react/src/index.tsuseChat / useRealtimeChat / generation hooks