造一条会说话的丝带:从零实现 `ChatMessageList`

106 阅读2分钟

🌱 需求解剖:一条丝带由哪些细胞组成?

细胞类型生物学意义映射到 UI
气泡细胞质MessageBubble
头像细胞核Avatar
时间戳细胞壁TimeStamp
加载条伪足TypingIndicator
滚动容器细胞外基质ScrollRegion

🧬 底层视角:浏览器如何搬动这些细胞?

  1. Reflow:DOM 节点插入 → 计算布局(O(n) 往上冒泡)
  2. Paint:光栅化气泡、头像、圆角
  3. Composite:把图层贴到屏幕上(GPU)

频繁插入消息 → 长任务 → 掉帧
解决:把新增节点放进 requestAnimationFrame,再配合 ResizeObserver 做滚动锚定。


🏗️ 架构速写:三层洋葱模型

┌──────────────┬────────────────────────────┐
│   Shell      │ Next.js / React            │
├──────────────┼────────────────────────────┤
│   Kernel     │ Messages State Machine     │
├──────────────┼────────────────────────────┤
│   Driver     │ Scroll, Resize, Intersection│
└──────────────┴────────────────────────────┘

🧑‍💻 代码实现:让丝带开口说话

1. 数据合同(TypeScript 写成 JS 注释版)

/**
 * @typedef {{
 *   id: string;
 *   sender: 'me' | 'ai';
 *   text: string;
 *   createdAt: Date;
 *   status: 'sent' | 'delivered' | 'read';
 * }} ChatMessage
 */

2. 状态核心:极简 Zustand

// stores/chat.js
import { create } from 'zustand'

export const useChatStore = create((set) => ({
  messages: [],
  isTyping: false,
  addMessage: (msg) =>
    set((state) => ({ messages: [...state.messages, msg] })),
  setTyping: (flag) => set({ isTyping: flag }),
}))

3. 丝带本体:ChatMessageList.jsx

import { useEffect, useRef } from 'react'
import { useChatStore } from '../stores/chat'
import MessageBubble from './MessageBubble'
import TypingIndicator from './TypingIndicator'

export default function ChatMessageList() {
  const { messages, isTyping } = useChatStore()
  const scrollRef = useRef(null)

  // 自动滚动到底:IntersectionObserver 的轻量替身
  useEffect(() => {
    const node = scrollRef.current
    if (!node) return
    // 把滚动放进下一帧,避免阻塞渲染
    requestAnimationFrame(() => {
      node.scrollTop = node.scrollHeight
    })
  }, [messages, isTyping])

  return (
    <div
      ref={scrollRef}
      className="flex-1 overflow-y-auto p-4 space-y-3"
      style={{ scrollBehavior: 'smooth' }}
    >
      {messages.map((m) => (
        <MessageBubble key={m.id} message={m} />
      ))}
      {isTyping && <TypingIndicator />}
    </div>
  )
}

4. 气泡细胞:MessageBubble.jsx

import { formatDistanceToNow } from 'date-fns'

export default function MessageBubble({ message }) {
  const isMe = message.sender === 'me'
  return (
    <div className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
      <div
        className={`
          max-w-xs md:max-w-md lg:max-w-lg xl:max-w-xl
          px-4 py-2 rounded-2xl shadow-sm
          ${isMe ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-900'}
        `}
      >
        <p className="whitespace-pre-wrap">{message.text}</p>
        <p className="text-xs opacity-70 mt-1">
          {formatDistanceToNow(message.createdAt, { addSuffix: true })}
        </p>
      </div>
    </div>
  )
}

5. 伪足:TypingIndicator.jsx

export default function TypingIndicator() {
  return (
    <div className="flex items-center space-x-2 text-sm text-gray-500">
      <span>AI 正在思考</span>
      <div className="flex space-x-1">
        <span className="w-2 h-2 bg-gray-400 rounded-full animate-pulse [animation-delay:-0.3s]" />
        <span className="w-2 h-2 bg-gray-400 rounded-full animate-pulse [animation-delay:-0.15s]" />
        <span className="w-2 h-2 bg-gray-400 rounded-full animate-pulse" />
      </div>
    </div>
  )
}

🎛️ 性能锦囊:别让丝带打结

问题场景现象对策
大量历史消息首屏卡顿虚拟滚动(react-window
图片 / 代码块内容高度突变ResizeObserver 重新计算滚动
频繁渲染React DevTools 一片红React.memo 包住 MessageBubble

📦 虚拟滚动:给丝带装上滚筒洗衣机

import { FixedSizeList } from 'react-window'
import { useChatStore } from '../stores/chat'

function Row({ index, style }) {
  const messages = useChatStore((s) => s.messages)
  const msg = messages[index]
  return (
    <div style={style}>
      <MessageBubble message={msg} />
    </div>
  )
}

export default function VirtualMessageList() {
  const messages = useChatStore((s) => s.messages)
  return (
    <FixedSizeList
      height={600}
      itemCount={messages.length}
      itemSize={80}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  )
}

🖼️ ASCII 插图:丝带的三视图

侧视图(时间轴)
┌────────────────────────────→ t
│  me: 你好
│               ai: 你好,世界
│  me: 讲个笑话
│                          ai: 程序员的笑话…

俯视图(布局)
┌────────────┐
│  头像 气泡 │
│    头像    │
└────────────┘

透视图(DOM Tree)
<div class="ChatMessageList">
  <div class="MessageBubble me">…</div>
  <div class="MessageBubble ai">…</div>
  …
</div>

🧪 彩蛋:把 AI 的“思考”做成流式打字机

// utils/typewriter.js
export async function* typewriter(stream) {
  let buffer = ''
  for await (const chunk of stream) {
    buffer += chunk
    yield buffer
  }
}

// 在组件里
useEffect(() => {
  let cancelled = false
  ;(async () => {
    for await (const partial of typewriter(aiStream)) {
      if (cancelled) break
      setCurrentText(partial)
    }
  })()
  return () => (cancelled = true)
}, [])

🏁 结语:丝带会老,但故事不会

你已拥有一条会说话的丝带:

  • 它的细胞是 React 节点
  • 它的血液是状态流
  • 它的灵魂是用户体验

下次当你在凌晨三点调试滚动条时,请记得:

“代码不是行尸走肉,而是让时间开口的魔法。”

Happy weaving!