聊天界面看似只是“消息列表加输入框”,真正实现时却会遇到自动滚动、角色布局、流式更新、长内容渲染和无障碍语义等问题。shadcn/ui 新增面向对话场景的组件,其中包括 MessageScroller 和 Message,把这些重复出现的界面能力拆成可以组合、替换和继续定制的原语。
组件不是黑盒,而是可修改的工程起点
这次更新延续了 shadcn/ui 的核心思路:开发者获得的是能够纳入项目并继续修改的组件代码,而不是只能通过有限属性控制的封闭组件。
在聊天场景里,这一点尤其重要。不同产品对消息的呈现差异很大:
- 客服系统需要区分客户、坐席和系统事件;
- AI 助手需要展示 Markdown、代码、工具调用与流式生成状态;
- 团队协作应用可能还要加入引用、附件、表情回应和线程;
- 审核或运维工具则更看重时间戳、错误状态和可追踪的消息 ID。
Message 可以承担单条消息的结构与角色样式,MessageScroller 则负责消息区域的滚动行为。业务层仍然保存消息状态、调用接口和处理流式数据。这样修改气泡外观时,不必碰请求逻辑;更换模型或后端协议时,也不必重写整个消息列表。
Headless 支持让行为和外观分开演进
摘要提到这批对话能力支持 headless 组件。对实际项目而言,headless 的价值不是“没有样式”,而是让行为、状态和 DOM 表达不被某一套视觉方案锁死。
团队可以在相同的交互逻辑上构建不同界面:桌面端使用紧凑的信息密度,移动端增加触控间距,嵌入式助手改成侧边栏布局。设计系统升级时,消息数据模型和发送流程仍可保持稳定。
这种组合方式也有边界。组件不会替业务层决定:
- 哪些新消息应该触发自动滚动;
- 用户向上阅读历史记录时是否保持当前位置;
- Markdown 和 HTML 如何清洗;
- 流式响应中断后怎样重试;
- 对话记录是否包含敏感数据,以及如何保存。
这些仍然需要产品和工程团队明确约束。
可以这样实践:组装一个最小聊天面板
下面是一个可改造的 React/TypeScript 示例。假设项目已经配置 shadcn/ui,并将新组件放在 @/components/ui/message 与 @/components/ui/message-scroller。组件的实际导出名称和属性可能随项目版本不同,请按本地生成的代码调整 import 和 props。
"use client";
import { FormEvent, useState } from "react";
import { Message } from "@/components/ui/message";
import { MessageScroller } from "@/components/ui/message-scroller";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type ChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
};
const initialMessages: ChatMessage[] = [
{
id: "welcome",
role: "assistant",
content: "你好,我可以帮你检查这次发布的变更。",
},
];
export default function ChatPanel() {
const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState("");
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const content = draft.trim();
if (!content) return;
setMessages((current) => [
...current,
{ id: crypto.randomUUID(), role: "user", content },
{
id: crypto.randomUUID(),
role: "assistant",
content: `已收到:${content}`,
},
]);
setDraft("");
}
return (
<section className="mx-auto grid h-[560px] max-w-2xl grid-rows-[1fr_auto] border">
<MessageScroller className="min-h-0 p-4" aria-live="polite">
<div className="space-y-4">
{messages.map((message) => (
<Message key={message.id} role={message.role}>
<p className="whitespace-pre-wrap break-words">{message.content}</p>
</Message>
))}
</div>
</MessageScroller>
<form onSubmit={submit} className="flex gap-2 border-t p-3">
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
aria-label="消息内容"
placeholder="输入消息"
autoComplete="off"
/>
<Button type="submit" disabled={!draft.trim()}>
发送
</Button>
</form>
</section>
);
}
这个示例故意把业务逻辑保持在组件外部。接入真实服务时,可以把模拟回复替换成 API 调用,但消息数组仍然作为唯一渲染来源:
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
});
if (!response.ok) {
throw new Error(`Chat request failed: ${response.status}`);
}
const reply = (await response.json()) as { content: string };
setMessages((current) => [
...current,
{
id: crypto.randomUUID(),
role: "assistant",
content: reply.content,
},
]);
生产环境还应增加请求中的禁用状态、取消请求、错误消息以及重复提交保护。若后端采用 SSE 或其他流式协议,不要为每个文本片段创建一条新消息;应更新同一条助手消息的 content,避免列表和辅助技术被大量节点淹没。
自动滚动需要尊重用户当前的位置
聊天组件最容易被低估的是滚动策略。收到新消息就无条件滚到底部,会打断正在阅读历史内容的用户。更稳妥的规则是:只有当用户原本接近底部,或者新消息由用户本人发送时,才自动跟随最新内容。
同时要测试三种情况:短回复、一次性插入很长的代码块,以及持续数十秒的流式回复。消息高度变化时,滚动容器应保持稳定;“跳到最新消息”按钮也不应遮挡输入框。
采用前的检查清单
把新组件接入现有项目时,可以按以下顺序评估:
- 先确认
Message的角色模型能否覆盖用户、助手、系统和工具消息。 - 检查
MessageScroller在历史记录加载、流式更新和移动端键盘弹出时的行为。 - 对 Markdown、链接和 HTML 做明确的转义或清洗,不能直接信任模型输出。
- 为发送中、失败、取消和重试设计可观察状态。
- 使用键盘和屏幕阅读器验证输入焦点、消息播报与错误提示。
- 将业务协议留在数据层,避免把 API 请求和供应商字段写进视觉组件。
shadcn/ui 的这次扩展并不是替开发者交付完整聊天产品,而是提供更合适的构建单位。它减少了消息结构和滚动容器的重复工作,同时保留了修改代码、替换样式以及接入自有状态管理的空间。对于已经使用 shadcn/ui 的团队,适合先在一个边界清晰的聊天面板中试用,再根据流式响应、历史加载和无障碍测试结果决定是否扩大采用范围。