🌱 需求解剖:一条丝带由哪些细胞组成?
| 细胞类型 | 生物学意义 | 映射到 UI |
|---|---|---|
| 气泡 | 细胞质 | MessageBubble |
| 头像 | 细胞核 | Avatar |
| 时间戳 | 细胞壁 | TimeStamp |
| 加载条 | 伪足 | TypingIndicator |
| 滚动容器 | 细胞外基质 | ScrollRegion |
🧬 底层视角:浏览器如何搬动这些细胞?
- Reflow:DOM 节点插入 → 计算布局(O(n) 往上冒泡)
- Paint:光栅化气泡、头像、圆角
- 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!