Vue 3 + TypeScript 实战:构建高可维护性 Chatbot 的避坑指南
背景与痛点
- 类型“裸奔”:从 Props 到 Event 全是 any,维护两周后连自己都看不懂。
- 状态“千层饼”:消息、输入、加载、错误混在一个大对象,改一行崩三处。
- 渲染“卡顿”:千条消息一次性塞进 DOM,滚动条直接罢工。
- 安全“裸奔”:用户输入的
<script>alert(1)</script>被原样渲染,XSS 现场教学。
这些问题在 Chatbot 场景里尤其明显:高频输入、实时推送、长列表渲染,任何一个小坑都会被无限放大。本指南用 Vue 3 + TypeScript 组合式 API 重新梳理链路,把“能跑”升级为“好维护”。
技术选型对比:Options API vs Composition API
| 维度 | Options API | Composition API |
|---|---|---|
| 类型推断 | 依赖this类型声明,经常“断链” | 纯函数,TypeScript 一路推导到底 |
| 逻辑复用 | Mixin 暗合并,命名冲突地狱 | 自定义 Hook,返回什么一目了然 |
| 响应式粒度 | 必须整个data一起 reactive | 用ref/reactive精确控制 |
| 代码量 | 百行小组件尚可,千行 Chatbot 直接失控 | 按功能拆 Hook,文件再大也不迷路 |
结论:Chatbot 这种“输入→状态→渲染”高频交织的场景,Composition API 是亲儿子。
核心实现
1. 使用 Composition API 组织聊天逻辑
把“发送、接收、异常、重试”拆成独立 Hook,组件只负责搭积木。
// composables/useChat.ts import { ref, computed } from 'vue' import type { ChatMessage } from '@/types' export function useChat() { const list = ref<ChatMessage[]>([]) const loading = ref(false) const error = ref('') const sortedList = computed(() => list.value.sort((a, b) => a.timestamp - b.timestamp) ) async function send(text: string) { loading.value = true error.value = '' const temp: ChatMessage = { id: crypto.randomUUID(), role: 'user', text, timestamp: Date.now() } list.value.push(temp) try { const reply = await api.chat(text) list.value.push({ id: crypto.randomUUID(), role: 'bot', text: reply, timestamp: Date.now() }) } catch (e) { error.value = (e as Error).message } finally { loading.value = false } } return { list: sortedList, loading, error, send } }组件层只剩 10 行模板,逻辑改动直接进 Hook,无需翻山越岭。
2. 类型安全的 Pinia 状态管理方案
Chatbot 需要跨组件共享“当前会话 ID、历史记录、草稿”,Pinia 的 TypeScript 支持几乎零成本。
// stores/chat.ts import { defineStore } from 'pinia' import type { ChatSession } from '@/types' export const useChatStore = defineStore('chat', { state: () => ({ sessions: [] as ChatSession[], activeId: '' }), getters: { current(state): ChatSession | undefined { return state.sessions.find(s => s.id === state.activeId) } }, actions: { addSession(title: string) { const session: ChatSession = { id: crypto.randomUUID(), title, messages: [], createdAt: Date.now() } this.sessions.push(session) this.activeId = session.id } } })在组件里用storeToRefs解构,依然保持响应式,且全程有类型提示。
3. 消息组件的高性能渲染策略
千条消息 = 千个组件?直接卡成 PPT。思路:
- 只渲染可视区域 + 上下缓冲 5 条
- 每条消息高度缓存,避免反复测量
- 用
item-key强制复用 DOM,减少创建开销
代码片段见下一节。
完整代码示例
VirtualList.vue
<template> <div ref="wrap" class="virtual-list" @scroll="onScroll"> <div :style="{ height: totalHeight + 'px' }" class="phantom"></div> <div :style="{ transform: `translateY(${offsetY}px)` }" class="viewport"> <MessageItem v-for="msg in visibleList" :key="msg.id" :msg="msg" /> </div> </div> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue' import type { ChatMessage } from '@/types' import MessageItem from './MessageItem.vue' const props = defineProps<{ list: ChatMessage[] }>() const wrap = ref<HTMLDivElement>() const itemHeight = 64 // 预设单条高度 const buffer = 5 // 缓冲条数 const offsetY = ref(0) const startIdx = ref(0) const visibleList = computed(() => { const start = Math.max(0, startIdx.value - buffer) const end = Math.min(props.list.length, startIdx.value + containerCount.value + buffer) return props.list.slice(start, end三面) }) const containerCount = computed(() => Math.ceil((wrap.value?.clientHeight ?? 0) / itemHeight)) const totalHeight = computed(() => props.list.length * itemHeight) function onScroll() { if (!wrap.value) return const scrollTop = wrap.value.scrollTop startIdx.value = Math.floor(scrollTop / itemHeight) offsetY.value = startIdx.value * itemHeight } onMounted(() => { onScroll() // 初始化 }) </script>MessageItem.vue(XSS 防护版)
<template> <div class="msg" :class="msg.role"> <!-- 用 v-html 前必须消毒 --> <div v-html="sanitized"></div> </div> </template> <script setup lang="ts"> import DOMPurify from 'dompurify' import type { ChatMessage } from '@/types' const props = defineProps<{ msg: ChatMessage }>() const sanitized = DOMPurify.sanitize(props.msg.text, { ALLOWED_TAGS: ['b', 'i', 'code', 'pre'] }) </script>性能与安全
- 虚拟滚动:把 DOM 节点量从O(n)降到O(1),实测 5k 条消息滚动 60 FPS。
- XSS:所有用户输入先过
DOMPurify,白名单标签,拒绝一切脚本。 - 额外小技巧:开启
v-once对纯展示型历史消息做静态化,内存再降 30%。
避坑指南
直接
reactive整个数组
问题:数组里新增属性不会触发视图
解决:数组元素用ref或定义成 class,再push把
computed当方法用
问题:每次渲染都重新计算,缓存失效
解决:依赖不变就缓存,复杂逻辑拆computed+watchEffect在
v-for里写index当key
问题:删除一条消息后面全重渲染
解决:用业务唯一id当key把 Pinia 的
state解构出来
问题:失去响应式
解决:用storeToRefs保持代理忽略
<script setup lang="ts">的defineProps泛型
问题:Props 推断成any
解决:defineProps<{ msg: ChatMessage }>()
延伸思考:插件化 Chatbot 架构
当业务需要“多角色、多技能、多通道”时,硬编码 if/else 会爆炸。可提前预留:
- 插件协议:
interface Plugin { name: string; onMessage: (ctx: Context) => Promise<void> } - 依赖注入:用 Vue 的
provide/inject或轻量 DI 容器,把核心能力(发送、渲染、状态)注入插件 - 动态加载:
import.meta.glob扫描本地插件目录,或远端拉 JS 文件,实现“装包即用” - 权限沙箱:通过
ShadowRealm或 Web Worker 隔离第三方代码,避免污染主上下文
这样,Chatbot 从“单点技能”升级为“平台”,后续让运营同学自己写 JSON 配置就能上新能力。
开放式问题
- 如果消息里要支持交互卡片(按钮、表单),你会如何设计组件协议,既保证类型安全又能让插件动态注册?
- 当语音、图片、文件混合传输时,虚拟滚动的高度预估失效,你会怎么动态测量并缓存真实尺寸?
- 在多人房间场景,WebSocket 推送频率高达 100msg/s,如何合并渲染帧,既不掉消息又不卡 UI?
欢迎在评论区贴出你的思路或仓库链接,一起把 Chatbot 玩成“乐高”。
想亲手把上述思路跑通?我最近在 从0打造个人豆包实时通话AI 这个动手实验里,用火山引擎的豆包语音大模型把 ASR→LLM→TTS 整条链路串成了可运行的 Web Demo。实验把聊天室直接升级成“能听会说”的实时通话,代码全开源,照着敲一遍,对语音交互的延迟、断句、音色切换会有更直观的体感。小白也能 30 分钟跑通,不妨边学边试,回来再聊聊你遇到的坑。