React 自定义 Hook 实战:把 AI Chat 的会话流和滚动体验从组件中拆出来

0 阅读24分钟

本文基于 AI Mind 项目的真实实现整理。
GitHub:github.com/HWYD/ai-min…
对应代码 tag:blog-ai-chat-hooks-refactor-2026-06

AI Mind 是一个持续迭代中的 Next.js AI Chat 项目。从最基础的本地聊天开始,逐步加入流式协议、工具调用、MCP、Skill 和 Agent 能力。

如果你对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 Star⭐,这会是对我继续更新很大的鼓励。

这篇文章对应的是一次 React 自定义 Hook 视角下的前端结构优化:把 AI 对话页面里的会话流状态和流式滚动体验,分别拆到 useChatStreamuseChatAutoScroll里。

  • useChatStream:会话流 Hook,负责发送、中断、消息更新和错误收口
  • useChatAutoScroll:自动滚动 Hook,负责自动跟随、用户滚动锁定和底部留白

主图.png

我在做 AI Mind 的聊天前端时,最早以为难点会在消息列表和输入框。

后来发现,真正让页面组件变重的不是 UI 本身,而是流式对话带来的那一整套状态和异步流程。

一个 AI 对话页面刚开始可能很简单:用户输入一段话,页面追加一条用户消息,再展示助手回答。但只要把它往真实应用方向推进,复杂度很快会涌进来:服务端一段段返回内容,用户随时可能点击停止,工具调用和资源读取要插进消息流,深度思考要折叠展示,生成过程中还要自动滚到底部,但用户看历史消息时又不能强行把页面拉走。

这些逻辑都和页面体验有关,却不应该全部堆在页面组件里。

这也是我这次选择拆自定义 Hook 的背景。为了让后面的项目实践更容易读,先用很短的一节把自定义 Hook 的定位说清楚。

React 自定义 Hook 是什么

在 React 里,自定义 Hook 本质上不是一个特殊语法,而是一种把组件里的状态、副作用和事件逻辑组合起来的方式。

它通常以 use 开头,内部可以继续使用 useStateuseEffectuseRefuseCallback 等 React Hook,然后对组件暴露一组更稳定的状态和动作。

所以我对自定义 Hook 的理解是:

组件不直接维护一整段复杂流程,
而是把这段流程交给 Hook,
组件只消费 Hook 暴露出来的状态和动作。

如果用一个很简化的代码形态表示,它大概是这样:

function useSomeFeature() {
  const [state, setState] = useState('ready')
  const controllerRef = useRef<AbortController | null>(null)

  useEffect(() => {
    // 这里可以处理监听、定时器、请求清理等副作用
    return () => {
      controllerRef.current?.abort()
    }
  }, [])

  function start() {
    setState('running')
  }

  function stop() {
    controllerRef.current?.abort()
    setState('ready')
  }

  return {
    state,
    start,
    stop,
  }
}

function Page() {
  const { state, start, stop } = useSomeFeature()

  return <button onClick={state === 'running' ? stop : start}>切换状态</button>
}

从组件视角看,自定义 Hook 最终暴露出来的通常就是两类东西:一类是页面要渲染的状态,一类是页面能触发的动作。至于状态怎么维护、副作用怎么清理、异步流程怎么推进,都留在 Hook 内部。

这篇文章里的 useChatStreamuseChatAutoScroll,就是这个思路下拆出来的两个页面级 Hook。

自定义 Hook 到底解决什么问题

很多 React 组件变复杂,往往不是因为 JSX 太多,而是因为组件开始同时承担几种角色。

它既要渲染页面,又要维护请求状态;既要响应用户操作,又要处理流式数据;既要监听浏览器事件,又要清理定时器和 requestAnimationFrame。时间一长,组件就会从“页面描述”变成“业务流程容器”。

自定义 Hook 适合接住这类问题。

它可以把一组相关的状态、事件处理、副作用和清理逻辑,包装成一个以 use 开头的函数。组件不需要知道内部怎么请求、怎么监听、怎么缓冲、怎么清理,只需要拿到它返回的“状态”和“动作”。

我更喜欢用一个简单的类比:

组件像页面编排者。
Hook 像某个流程负责人。

页面只问它:
现在是什么状态?
我能触发什么动作?

流程内部怎么请求、怎么中断、怎么监听、怎么清理,
交给 Hook 自己维护。

所以这篇文章里的自定义 Hook,不是为了少写几行代码,而是为了给复杂前端流程划边界。

好的自定义 Hook 应该能回答四个问题:

  • 它负责哪段稳定流程
  • 它对组件暴露什么状态
  • 它对组件暴露什么动作
  • 它明确不负责什么

这四个问题,比“这个 Hook 里用了几个 useStateuseEffect”更重要。

回到本项目:这次拆出了哪些 Hook

图1.png

回到 AI Mind 的对话页面 (InstantMindPage),这次真正从页面组件里拆出来的是两个页面级 Hook:

  • useChatStream:负责会话流,包括发送、中断、消息更新、错误收口和重新生成。
  • useChatAutoScroll:负责流式输出体验,包括自动跟随、用户滚动锁定、底部留白和回到底部。

除此之外,useChatStream 内部还继续拆出了一个更小的辅助 Hook:

  • useStreamTextBuffer:负责合并高频 text / reasoning delta,尽量减少 token 级 React state 更新。

这里的关系可以简单理解为:页面直接消费 useChatStreamuseChatAutoScrolluseStreamTextBuffer 则是 useChatStream 内部为了流式文本体验继续拆出来的一层。

除了请求和消息状态,这篇也会聊一个 AI 聊天页面里很容易踩坑的体验问题:流式输出时如何自动跟随,又不打断正在查看历史消息的用户。

这篇文章想讨论的问题很具体:

AI 对话页面变重之后,
怎样把组件、Hook、协议解析和运行时边界拆清楚。

也就是让每一层各自留在合适的位置:

组件负责连接 UI,
Hook 负责前端状态和流程,
parser(解析器)负责读懂服务端返回的流式数据,
service(服务层)负责组织业务请求和接口处理,
runtime(运行时)负责更底层的模型、工具和执行链路。

自定义 Hook 在这里不是“把代码挪到另一个文件里”,而是把一段稳定的前端流程变成组件可以理解的状态和动作。

为什么不能只做一个更简单的封装

这里也有一个很自然的问题:为什么不只写一个更简单的 useChat,里面直接 fetch,然后把返回文本拼到最后一条消息上?

如果只是普通问答,这样确实可以。

但当前页面面对的不是单一文本返回,而是一条持续变化的会话流:请求可以被中断,服务端会返回结构化事件,高频文本增量需要合并,工具和资源卡片要按顺序插入,错误还要区分是局部卡片失败,还是整轮请求失败。

所以这次拆分不是为了把代码拆得更碎,而是为了避免一个 Hook 同时吞掉所有细节。

更合理的边界是:

useChatStream 负责会话流主链,
useStreamTextBuffer 负责高频文本刷新节奏,
消息树更新逻辑负责把结构化事件落到消息里,
useChatAutoScroll 负责浏览器滚动体验。

也就是说,简单封装可以解决“发请求并展示文本”,但很难稳定承接“可中断、可插卡片、可缓冲、可错误收口”的真实 AI 对话页面。

拆完之后,页面组件回到了什么位置

有了这层分工之后,再看 InstantMindPage(AI Mind 的即时对话页面组件,也就是当前聊天页的页面组装层)就会比较清楚:它没有消失,也不是变成一个空壳,而是回到了页面组装层。

在拆之前,页面组件很容易长成这样:

function InstantMindPage() {
  // 页面状态
  const [messages, setMessages] = useState([])
  const [status, setStatus] = useState('ready')

  // 请求控制
  const abortControllerRef = useRef(null)

  // 发送、中断、读取流、解析 chunk、更新消息
  async function handleSubmit(input) {
    // fetch / abort / reader / switch chunk / setMessages...
  }

  // 滚动监听、自动跟随、用户滚动锁定、rAF 清理
  useEffect(() => {
    // wheel / scroll / resize / requestAnimationFrame...
  }, [])

  return (
    <>
      <ChatMessageList />
      <ChatComposer />
    </>
  )
}

这种写法短期能跑,但页面组件会同时承担 UI 组装、请求流程、消息变换和滚动体验。后续每加一种消息部件或滚动策略,都要继续往页面里塞逻辑。

InstantMindPage 现在主要做三件事:

  • 保存模型、技能模式、深度思考开关这些页面级 UI 状态
  • 调用 useChatStreamuseChatAutoScroll
  • 把状态和动作传给 ChatMessageListChatComposer 和“回到底部”按钮

关键结构可以简化成这样:

export default function InstantMindPage() {
  const { messages, status, sendMessage, cancel } = useChatStream({
    /* model / skill / reasoning 等页面配置 */
  })

  const {
    inputContainerRef,
    bottomSpacing,
    showScrollToBottom,
    resetAutoScrollForNewTurn,
    restoreAutoFollowAndScrollToBottom,
  } = useChatAutoScroll({
    /* 当前是否正在输出 + 消息变化信号 */
  })

  async function handleSubmit(value) {
    resetAutoScrollForNewTurn()
    void sendMessage(value)
    return true
  }

  return (
    <>
      <div style={{ paddingBottom: bottomSpacing }}>
        <ChatMessageList messages={messages} status={status} />
      </div>

      {showScrollToBottom ? (
        <Button onClick={restoreAutoFollowAndScrollToBottom}>回到底部</Button>
      ) : null}

      <div ref={inputContainerRef}>
        <ChatComposer status={status} onSubmit={handleSubmit} onStop={cancel} />
      </div>
    </>
  )
}

这段代码不是完整源码,而是保留页面结构后的简化版本。它想表达的是:页面组件仍然负责组织 UI,但请求流和滚动体验已经交给两个自定义 Hook。

对应真实代码:

  • apps/webapp/components/instamind/instantmind-page.tsx(对话页面组装层)
  • apps/webapp/components/instamind/use-chat-stream.ts(前端会话流 Hook)
  • apps/webapp/components/instamind/use-chat-auto-scroll.ts(流式输出自动滚动 Hook)

拆分后的结构可以简化成这样:

InstantMindPage(即时对话页面组件)
  ├─ useChatStream        会话流:发送 / 中断 / 消息 / 错误 / 重新生成
  ├─ useChatAutoScroll    流式体验:自动跟随 / 用户滚动锁定 / 底部留白
  ├─ ChatMessageList      消息展示
  └─ ChatComposer         输入和提交

也可以把页面组装关系画成下面这样:

flowchart TD
    Page["InstantMindPage<br/>即时对话页面 / 页面组装层"]

    Composer["ChatComposer<br/>输入区"]
    MessageList["ChatMessageList<br/>消息展示区"]

    ChatStream["useChatStream<br/>聊天请求 + 流式消息状态"]
    TextBuffer["useStreamTextBuffer<br/>合并 text / reasoning delta"]
    MessageUpdate["消息更新逻辑<br/>把流式事件落到消息里"]
    AutoScroll["useChatAutoScroll<br/>自动滚动 + 回到底部"]

    API["/api/chat<br/>服务端聊天接口"]

    Page --> Composer
    Page --> MessageList
    Page --> ChatStream
    Page --> AutoScroll

    ChatStream --> API
    ChatStream --> TextBuffer
    ChatStream --> MessageUpdate
    TextBuffer --> MessageUpdate

    ChatStream -- "messages / status / error" --> Page
    Page -- "messages" --> MessageList
    Page -- "滚动状态" --> AutoScroll

    Composer -- "submit / stop / config" --> Page

上面的图保留页面组件视角,所以主角仍然是 InstantMindPageuseChatStreamuseChatAutoScrollChatMessageListChatComposeruseStreamTextBuffer 只是会话流内部为了文本增量体验继续拆出来的一层。

页面组件没有消失,它仍然负责把页面拼起来。但它不再直接维护 fetch 流、AbortController、chunk 分发、滚动监听、自动跟随锁定、定时器和 rAF 清理。这些流程都被放到了更合适的位置。

拆到这里,页面组件已经变轻了。

但复杂度并没有凭空消失,它只是从页面组件里转移到了更明确的边界里。对这个 AI Chat 页面来说,最核心的复杂度主要有两类:

  • 一类是会话流:用户提交之后,请求如何发起、如何中断、流式数据如何落到消息里。
  • 另一类是滚动体验:内容持续变长时,页面如何自动跟随,又如何尊重用户正在查看历史消息的行为。

所以接下来先看第一类:useChatStream 是怎么接住会话流的。

第一类复杂度:useChatStream 接住会话流

AI 对话里的“发送消息”不是一个简单的按钮事件。

在当前实现里,useChatStream(前端会话流 Hook)负责把一次用户输入推进成一条完整的前端会话流程。

它对页面暴露的是一组稳定接口:

return {
  messages,
  status,
  error,
  sendMessage,
  cancel,
  deleteUserTurn,
  regenerateLastTurn,
}

这个接口刻意避开了底层细节:页面只需要知道当前消息、当前状态、是否有错误,以及能触发哪些动作。

这就是 useChatStream 的边界:

组件触发动作
  -> useChatStream 维护会话流程
    -> stream-reader 读取协议分片
      -> handleChunk 区分文本 delta 和结构性 chunk
        -> useStreamTextBuffer / 消息更新逻辑
          -> 更新消息树

在继续看细节前,可以先把 useChatStream 理解成一条前端会话流的控制线。

它不是只负责 fetch,而是把一次用户输入,从“提交”推进到“消息稳定落到页面上”:

用户提交
  -> 解析输入和 Composer 结构化内容
  -> 拒绝并发中的新请求
  -> 清理上一轮临时消息和文本 buffer
  -> 追加用户消息
  -> 创建本轮 AbortController
  -> 进入 submitted
  -> 组装 ChatRequest
  -> 请求 /api/chat
  -> 进入 streaming
  -> consumeNdjsonStream 读取服务端 chunk
  -> handleChunk 区分文本 delta 和结构性 chunk
  -> useStreamTextBuffer / 消息更新逻辑分别处理
  -> message-operations 更新消息树
  -> finish / abort / error 收口
  -> finally 兜底 flush 并清理 controller

所以后面讲到的中断、NDJSON、文本缓冲和错误处理,本质上都是这条会话流里的不同环节。

用流程图看会更直观:

flowchart TD
    Submit["用户提交"] --> Resolve["解析输入 + Composer 结构化内容"]
    Resolve --> Guard{"当前是否已有请求?"}
    Guard -- "是" --> Reject["拒绝本次提交"]
    Guard -- "否" --> Clean["清理临时消息 + 文本 buffer"]
    Clean --> UserMsg["追加用户消息"]
    UserMsg --> Controller["创建 AbortController"]
    Controller --> Submitted["status = submitted"]
    Submitted --> Build["组装 ChatRequest<br/>messages / composer / model / skill / reasoning"]
    Build --> Fetch["请求 /api/chat"]
    Fetch --> Streaming["status = streaming"]
    Streaming --> Reader["consumeNdjsonStream<br/>读取服务端 chunk"]
    Reader --> Handler["handleChunk<br/>区分文本 delta / 结构性 chunk"]

    Handler --> Text["text / reasoning delta"]
    Text --> Buffer["useStreamTextBuffer<br/>合并文本增量"]
    Buffer --> TextUpdate["批量写入文本内容"]

    Handler --> Parts["start / finish / error<br/>tool / resource / prompt / agent / artifact"]
    Parts --> PartUpdate["更新对应消息部件"]

    PartUpdate --> Finish["finish"]
    PartUpdate --> Error["error"]
    Reader --> Abort["abort"]

    Finish --> Finalize["清理临时占位 + reset active stream"]
    Error --> ErrorClose["部件失败或全局错误收口"]
    Abort --> AbortClose["保留已收到内容<br/>清理空占位"]

    Finalize --> Finally["finally: flush buffer<br/>清理 controller"]
    ErrorClose --> Finally
    AbortClose --> Finally

如果把这条链路直接写在页面组件里,页面很快就会从“渲染页面”变成“维护一个小型运行时”。

一次发送背后,其实是一条异步主链

sendMessage 看起来只是发送一条消息,实际背后会转进 submitTurn

submitTurn 负责把页面传入的普通文本、Composer 结构化输入、当前模型、Skill 模式和深度思考开关,组装成一次 /api/chat 请求。请求开始前,它会先把状态切到 submitted;服务端响应可读后,再切到 streaming

关键片段可以简化成这样:

const controller = new AbortController()

const response = await fetch('/api/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(payload),
  signal: controller.signal,
})

setStatus('streaming')
await consumeNdjsonStream(response.body, handleChunk)

这段代码解决的不是“怎么调用接口”,而是把一次用户输入变成可取消、可流式更新、可错误收口的前端会话。

这里有几个真实边界:

  • AbortController 只属于当前请求
  • 请求前会清理上一轮临时消息
  • 请求成功后进入 streaming
  • 流式 chunk 统一交给 handleChunk,再分流到文本 buffer 或 stream reducer
  • 中断和错误会走不同收口逻辑

这些都是会话流的一部分,所以放在 useChatStream 里比放在页面组件里更合适。

中断不是简单删除回答

AI 对话里的“停止生成”也不只是调用一下 abort()

用户点击停止时,前端确实会中断当前请求:

function cancel() {
  if (!abortControllerRef.current) {
    return
  }

  abortControllerRef.current.abort()
}

但更关键的问题是:已经收到的半截回答怎么办?

当前实现选择保留已经收到的正文、推理和卡片,只清理没有稳定内容的临时占位。

function finalizeAbortedAssistantMessage() {
  textBuffer.clear()
  updateMessages(current => pruneTransientMessages(current))
  resetActiveStream()
}

这个设计很细,但很重要。

用户点停止时,屏幕上已经出现的内容仍然有价值。直接删掉整条 assistant 消息,会让用户感觉刚才看到的内容消失了。

所以这里把“请求中断”和“消息清理”分开处理:请求可以中断,已经稳定落到 UI 上的内容不轻易删除。

这就是会话体验的边界,也正是 Hook 应该承接的部分。

流式响应不是直接拼字符串

很多 AI 流式输出的第一版实现,都会把响应当成一段段字符串,然后直接拼到当前消息上。

AI Mind 当前实现没有这样做。

它更像是在接收一串“结构化事件流”:服务端不是只告诉前端“新增了哪些文字”,还会告诉前端“一个正文片段开始了”“工具调用开始了”“资源读取结束了”“某个 Agent 步骤完成了”。

服务端返回的是结构化 NDJSON 分片。前端先把流解析成 ChatStreamChunk,再根据 chunk 类型更新不同的消息部件。

读取逻辑放在 consumeNdjsonStream(NDJSON 流解析器)里:

function parseChatStreamLine(line: string): ChatStreamChunk {
  let parsedJson: unknown

  try {
    parsedJson = JSON.parse(line)
  } catch {
    throw new Error('服务端返回了无法解析的流式数据。')
  }

  const parsedChunk = chatStreamChunkSchema.safeParse(parsedJson)

  if (!parsedChunk.success) {
    throw new Error('服务端返回了无法解析的流式数据。')
  }

  return parsedChunk.data
}

export async function consumeNdjsonStream(
  stream: ReadableStream<Uint8Array>,
  onChunk: (chunk: ChatStreamChunk) => void
) {
  const reader = stream.getReader()
  const decoder = new TextDecoder()
  let buffer = ''

  while (true) {
    const { done, value } = await reader.read()

    if (done) {
      break
    }

    buffer += decoder.decode(value, { stream: true })
    const lines = buffer.split('\n')
    buffer = lines.pop() ?? ''

    for (const line of lines) {
      const trimmedLine = line.trim()

      if (!trimmedLine) {
        continue
      }

      onChunk(parseChatStreamLine(trimmedLine))
    }
  }

  const finalLine = buffer.trim()

  if (!finalLine) {
    return
  }

  onChunk(parseChatStreamLine(finalLine))
}

这段代码解决两个问题。

第一,流式响应不保证每次读取都是一个完整 JSON。buffer 要先接住半截内容,等下一次读取再拼成完整行;最后如果还有没有换行结尾的 finalLine,也要单独解析一次,避免丢掉尾部 chunk。

第二,前端不能无条件相信服务端分片。每一行都会先 JSON.parse,再用 chatStreamChunkSchema 校验,非法协议会被及时收口。

解析完成后,useChatStream 再用 handleChunk 做第一层分流。

最新实现里,handleChunk 不再把所有结构性事件都铺开处理。它只直接拦截高频的 text-deltareasoning-delta,其余结构性 chunk 统一交给消息更新逻辑。

switch (chunk.type) {
  case 'text-delta':
    appendTextDeltaBuffered(chunk)
    return
  case 'reasoning-delta':
    appendReasoningDeltaBuffered(chunk)
    return
  default:
    commitStreamReduction(current => reduceStreamChunk(current, chunk))
    return
}

这里的重点不是 switch 写法,而是职责拆开了:

text / reasoning delta
  -> useStreamTextBuffer
  -> 批量写入文本内容

start / tool / resource / prompt / agent / artifact / error / finish
  -> 更新对应消息部件

也就是说,useChatStream 保留的是会话流主链和调度边界;结构化 chunk 到消息树的转换,不再散落在页面组件里。

这样做之后,消息模型仍然不只是一段文本。

AI 回答里可能同时出现:

  • 正文文本:text-delta
  • 深度思考:reasoning-delta
  • 工具调用:tool-start / tool-end
  • 资源读取:resource-start / resource-end
  • Agent 执行过程:agent-step-start / agent-step-end
  • 文本产物:artifact-start / artifact-delta / artifact-end
  • 错误事件:error
  • 结束事件:finish

前端真正做的事,是把服务端事件流转换成可渲染的消息部件。只是最新实现里,这个转换不再全部写在页面组件里,而是收口到更靠近消息模型的更新逻辑里。

真实实现里还有一个顺序细节:非文本 delta 的结构性 chunk 到来前,会先把文本 buffer flush 掉。否则可能出现工具卡片、资源卡片或结束事件已经渲染出来了,但前面最后一段正文还停在 buffer 里,导致消息顺序看起来不稳定。

高频文本增量不能每次都 setState

流式输出还有一个容易被忽略的问题:token 很碎。

如果每来一个 text-delta 就更新一次 React state,Markdown 渲染、DOM 更新和滚动同步都可能被频繁触发。

这也是 useStreamTextBuffer 出现的原因。

它不是页面直接消费的 Hook,而是 useChatStream 内部的文本增量缓冲层。它只处理一件事:把高频 text-deltareasoning-delta 尽量合并成更少的消息树更新,再批量落到消息里。

核心逻辑可以简化成这样:

function enqueue(messageId: string, partId: string, partType: 'text' | 'reasoning', delta: string) {
  if (!delta) {
    return
  }

  const pendingKey = `${messageId}:${partType}:${partId}`
  const existing = pendingTextDeltasRef.current.get(pendingKey)

  if (existing) {
    existing.delta += delta
  } else {
    pendingTextDeltasRef.current.set(pendingKey, {
      messageId,
      partId,
      partType,
      delta,
    })
  }

  scheduleFlushByTimer()
}

这个设计不是为了让输出变慢,而是为了在保留流式输出体感的同时,降低 token 级更新带来的渲染压力。

这里不是简单 debounce。文本先经过一个很短的时间窗口合并,真正写入 state 会放到 requestAnimationFrame 里,尽量贴近浏览器下一次绘制。

另外,如果 delta 里包含换行或代码块标记,它会提前调度一次 flush,让 Markdown 结构不要明显滞后。

这一层可以单独画成一个很小的内部流程:

flowchart TD
    Delta["text-delta / reasoning-delta"] --> Enqueue["enqueue"]
    Enqueue --> Merge["pendingTextDeltas Map<br/>按 messageId + partType + partId 合并"]
    Merge --> Timer["scheduleFlushByTimer()<br/>总是启动短时间窗口"]
    Timer --> Check{"delta 包含换行<br/>或代码块标记?"}
    Check -- "否" --> Wait["等待 timer 到期"]
    Check -- "是" --> EarlyRAF["额外 scheduleFlushByAnimationFrame()<br/>提前到最近一帧 flush"]
    Wait --> RAF["timer 到期后<br/>requestAnimationFrame"]
    RAF --> Flush["flush"]
    EarlyRAF --> Flush
    Flush --> Clear["clearScheduledFlushes<br/>清理 timer / rAF"]
    Clear --> Pending["Map values 转数组<br/>并 clear Map"]
    Pending --> Reduce["reduceStreamTextDeltas"]
    Reduce --> Update["appendTextualPartDelta"]
    Update --> State["setMessages<br/>写入消息树"]

AI 聊天前端里的“流畅”,不一定等于每个 token 都立刻 setState。在当前场景里,更合适的做法是保留打字感,同时把多个增量合并到一次状态更新里。

到这里,useChatStream 解决的是第一类复杂度:请求、中断、协议分发、消息更新、错误收口和重新生成。

页面组件不再直接面对这条异步主链,它只接收 useChatStream 暴露出来的状态和动作。

第二类复杂度:流式输出体验

如果只看请求流,useChatStream 已经让页面轻了很多。

但 AI 对话页面还有另一类复杂度:滚动体验。

这里还有一个前提:AI Mind 当前聊天页保留的是浏览器整页滚动,而不是在消息列表里再做一个内部滚动容器。所以自动滚动的目标统一收口到页面本身,输入框固定在底部,消息区通过动态 bottomSpacing 避免最后一段内容贴住输入框。

很多聊天页面最开始会写一句:

scrollToBottom()

真实做起来会发现,这远远不够。

在 AI 流式输出里,自动滚动至少要处理这些问题:

  • 用户正在看历史消息时,不能强行拉回底部
  • 程序滚动不能被误判成用户滚动
  • Markdown 流式渲染会持续撑高页面
  • 输入框固定在底部,需要动态留白
  • 流结束后还要补一次底部对齐
  • 点击“回到底部”要恢复自动跟随
  • 组件卸载时要清理 timeoutrequestAnimationFrame

这些逻辑如果留在 InstantMindPage,页面组件会再次变成流程容器。

所以我把它抽成了 useChatAutoScroll(聊天自动滚动 Hook)。

它的入参很克制:

useChatAutoScroll({
  isStreamingOutput,
  contentSignal: messages,
})

返回值也只暴露页面真正需要的东西:

return {
  inputContainerRef,
  bottomSpacing,
  showScrollToBottom,
  resetAutoScrollForNewTurn,
  restoreAutoFollowAndScrollToBottom,
}

这个接口背后的含义是:

  • inputContainerRef:让 Hook 测量底部输入框高度
  • bottomSpacing:给消息区留出底部空间
  • showScrollToBottom:控制“回到底部”按钮
  • resetAutoScrollForNewTurn:新一轮请求开始前恢复自动跟随
  • restoreAutoFollowAndScrollToBottom:用户点击按钮后回到底部,并恢复跟随

页面不用知道 wheeltouchmovekeydownscrollResizeObserver 和 rAF 怎么配合。它只负责把这些返回值接到布局和按钮上。

useChatAutoScroll 的整体流程可以简化成这样:

flowchart TD
    Changes["messages / bottomSpacing / isStreamingOutput 变化"] --> Schedule["scheduleScrollSync"]
    Schedule --> RAF["requestAnimationFrame<br/>合并测量"]
    RAF --> Distance["计算 distanceFromBottom"]
    Distance --> ShowBtn["更新 showScrollToBottom"]
    Distance --> CanFollow{"能否自动跟随?"}

    CanFollow -- "否" --> Wait["等待下一次变化"]
    CanFollow -- "是" --> Threshold{"距离底部超过阈值?"}
    Threshold -- "否" --> Keep["保留底部缓冲"]
    Threshold -- "是" --> Throttle["按最小间隔节流"]
    Throttle --> Scroll["scrollPageToBottomFromCode"]

    UserInput["wheel / touchmove / keydown"] --> Intent["标记用户滚动意图"]
    Intent --> ScrollEvent["scroll 事件"]
    ScrollEvent --> IsProgrammatic{"是否程序滚动?"}
    IsProgrammatic -- "否" --> Lock["锁住本轮自动跟随"]
    IsProgrammatic -- "是" --> Ignore["不锁定"]

    NewTurn["新一轮提交"] --> Reset["resetAutoScrollForNewTurn"]
    Reset --> Unlock["清除上一轮锁定"]

    BackBtn["点击回到底部"] --> Restore["restoreAutoFollowAndScrollToBottom"]
    Restore --> Scroll

    End["流式结束"] --> FinalAlign["再对齐一次底部"]
    FinalAlign --> Scroll

自动滚动最重要的边界:不要打断用户

AI 聊天页面里,最影响体验的滚动问题不是“有没有自动滚到底”,而是“什么时候不应该自动滚到底”。

当用户在流式输出期间主动往上滚动,通常说明他正在看历史内容。这个时候如果新 token 一来,页面立刻被拉回底部,体验会很糟。

所以 useChatAutoScroll 里有一个关键状态:

const autoScrollLockedForCurrentTurnRef = useRef(false)

它表示“本轮回答期间,用户是否已经主动浏览历史内容”。一旦用户手动滚动,本轮自动跟随就会被锁住,直到下一轮请求开始前重置。

核心逻辑可以简化成这样:

const markUserScrollIntent = () => {
  if (!isStreamingOutputRef.current) {
    return
  }

  userScrollIntentRef.current = true
  scheduleUserScrollIntentReset()
}

const handleScroll = () => {
  const isProgrammaticScroll = programmaticScrollRef.current && !userScrollIntentRef.current

  if (!isProgrammaticScroll && userScrollIntentRef.current) {
    autoScrollLockedForCurrentTurnRef.current = true
    clearPendingAutoScroll()
  }

  userScrollIntentRef.current = false
  scheduleScrollSync(false)
}

这里有两个细节。

第一,不能只监听 scroll

程序调用 window.scrollTo 也会触发 scroll。如果只看 scroll 事件,就分不清是用户滚动还是代码滚动。所以 Hook 同时监听 wheeltouchmove 和键盘滚动键,再用一个短暂的“用户滚动意图窗口”判断后续 scroll 是否来自用户。

第二,用户本轮锁住自动跟随后,下一轮请求要重置。

这也是页面提交时,会先调用 resetAutoScrollForNewTurn,再进入 sendMessage 的原因。新一轮请求开始前,要先把上一轮用户手动浏览历史消息留下的自动跟随锁定清掉。

有些状态是流程状态,不是渲染状态

useChatAutoScroll 里有很多 ref:

const userScrollIntentRef = useRef(false)
const autoScrollLockedForCurrentTurnRef = useRef(false)
const programmaticScrollRef = useRef(false)
const pendingAutoScrollTimeoutRef = useRef<number | null>(null)
const scrollAnimationRafRef = useRef<number | null>(null)

这些变量变化很频繁,但变化本身不需要立刻触发 UI 更新。

比如:

  • 当前有没有用户滚动意图
  • 本轮是否锁定自动跟随
  • 当前滚动是不是程序触发的
  • 有没有待执行的滚动定时器
  • 有没有正在执行的 rAF 动画

这些都是流程状态,不是渲染状态。

真正需要渲染的只有两个:

const [bottomSpacing, setBottomSpacing] = useState(220 + EXTRA_BOTTOM_SCROLL_SPACING)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)

bottomSpacing 会影响页面底部留白,showScrollToBottom 会影响按钮显示,所以它们用 state。

这也是我觉得 useChatAutoScroll 值得单独抽出来的原因:它不只是把代码搬走,而是把渲染状态和流程状态分开了。

还有一个容易忽略的点:流式状态结束,并不代表 DOM 高度已经完全稳定。最后一批 Markdown 渲染可能还会继续撑高页面,所以流结束后还会再调度一次底部对齐,避免最后几行回答被固定输入框附近的空间影响。

到这里,useChatAutoScroll 解决的是第二类复杂度:自动跟随、用户滚动锁定、底部留白、回到底部和浏览器事件清理。

页面组件不需要理解这些滚动细节,只需要接收它暴露出来的状态和动作,再把它们接到布局和按钮上。

Hook 不是终点,底层复杂度还要继续分层

写到这里,很容易产生一个误解:既然 Hook 好用,那是不是所有逻辑都应该塞进 Hook?

不是。

在 AI Mind 当前实现里,Hook 只是 React 前端的边界层。它适合承接页面状态、浏览器事件、用户操作和前端会话流程;但协议解析、消息树转换、服务端执行链路,仍然应该留在各自更稳定的模块里。

真实分层可以简化成这样:

UI 组件
  -> useChatStream
    -> stream-reader
    -> useStreamTextBuffer
    -> 消息更新逻辑
  -> useChatAutoScroll

这条链路里,每层职责都不一样:

  • useChatStream:发送、中断、消息流更新、错误和重新生成。
  • useStreamTextBuffer:合并高频 text / reasoning delta,降低 token 级更新压力。
  • stream-reader:按 NDJSON 协议读取响应,并校验每个 chunk。
  • 消息更新逻辑:把结构化 chunk 映射成当前 UI 使用的消息变化。
  • useChatAutoScroll:处理自动跟随、用户滚动锁定、底部留白和回到底部。

如果把协议、模型执行、工具调用等底层细节全部塞进 Hook,前端代码只会换一种方式变难维护。

真正稳定的结构,是让每一层只负责它该负责的复杂度。

这次拆分带来了什么

这次拆分之后,最直接的变化是 InstantMindPage 更像页面组装层。

它不再维护大量滚动事件、rAF、timeout、AbortController、流式 chunk 分发,而是把职责交给更明确的模块:

页面组件:连接 UI
useChatStream:维护会话流
stream-reader:解析流式协议
useStreamTextBuffer:缓冲高频 text / reasoning delta
消息更新逻辑:把结构性 chunk 转成消息变化
useChatAutoScroll:维护流式输出体验

收益主要有三点。

第一,页面更容易读。

打开 InstantMindPage,可以很快看出页面由哪些部分组成,数据从哪里来,动作往哪里传。

第二,行为边界更清楚。

中断、错误收口、文本缓冲、消息树更新和自动滚动分别落在对应模块里。后续排查问题时,不需要在页面组件里同时翻请求逻辑、消息逻辑和滚动逻辑。

第三,后续扩展更自然。

如果新增一种消息部件,重点会落在协议和消息渲染链路上;如果调整自动滚动策略,主要改 useChatAutoScroll;如果调整发送流程,主要改 useChatStream

但这次也保留了边界。

useChatStream 仍然偏大,但它大的原因不是 JSX,而是它承接了前端会话主链和流式事件调度。后续如果继续拆,我更倾向于把更多 chunk 映射或消息更新策略继续下沉成普通模块,而不是为了拆 Hook 再造一堆小 Hook。

useChatAutoScroll 是一个强 DOM、强浏览器事件的体验型 Hook。它适合留在前端层,不应该下沉到 runtime。

流式协议、模型调用、Tool Calling、Agent 执行也不应该进入 Hook。这些仍然属于服务端 runtime 和共享协议层。

我对自定义 Hook 的一点理解

写完这次拆分,我对自定义 Hook 的理解更明确了一点。

自定义 Hook 的价值,不是把一个大文件拆成两个文件,也不是为了让代码看起来更“React”。

它真正有用的时候,是我们能说清楚:

这段流程属于谁?
它对组件暴露什么?
它内部维护什么?
它不负责什么?

在这次 AI 对话前端里,我最后得到的答案是:

useChatStream 管会话流。
useStreamTextBuffer 管高频文本增量缓冲。
useChatAutoScroll 管流式输出体验。
组件管 UI 组装。
parser / reducer / service 管更底层的协议、消息转换和执行。

这里的 useStreamTextBuffer 不是页面级 Hook,而是 useChatStream 内部继续拆出来的一层。

也就是说,这次拆分的重点不是“把逻辑放进 Hook”,而是让页面、Hook、协议解析和消息转换各自留在合适的位置。

因为复杂系统里,代码真正难维护的地方,往往不是某个函数太长,而是边界不清楚。

项目地址

👉 GitHub:github.com/HWYD/ai-min…

如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎顺手帮项目点个 Star⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。