共计 3732 个字符,预计需要花费 10 分钟才能阅读完成。
背景痛点
在实现类 ChatGPT 的打字机效果时,很多开发者首先想到的是使用setInterval。但在 Vue 中,这种方案会面临两个主要问题:

-
性能缺陷 :
setInterval无法与浏览器刷新率同步,可能导致动画卡顿或丢帧。在长文本场景下,频繁触发 Vue 的响应式更新会引发不必要的重渲染。 -
状态同步问题:当用户快速切换对话时,旧的定时器可能仍在执行,导致新旧内容混杂。需要手动管理定时器生命周期,代码复杂度高。
技术选型
实现打字机效果主要有三种技术路径:
-
CSS 动画 :通过
@keyframes和steps()函数实现。优点是性能好,但难以动态控制速度,且无法中途暂停 / 恢复。 -
Web Animation API:原生动画 API,适合复杂动画场景。但打字机效果本质上属于文本逐字更新,用 WAAPI 显得有些重。
-
JS 时序控制 :灵活度高,可精确控制每个字符的显示时机。配合
requestAnimationFrame能获得最佳性能。
最终选择 :基于requestAnimationFrame 的 JS 方案,理由如下:
- 完美匹配浏览器刷新率(通常 60fps)
- 天然支持动画暂停 / 继续
- 便于与 Vue 响应式系统集成
核心实现
1. 封装 Composition API Hook
// useTypewriter.ts
type TypewriterOptions = {
speed?: number // 字符 / 秒
onComplete?: () => void}
export function useTypewriter(options: TypewriterOptions = {}) {const { speed = 10, onComplete} = options
const text = ref('')
const isTyping = ref(false)
let rafId = 0
let startTime = 0
let sourceText = ''
// 核心动画逻辑
const animate = (timestamp: number) => {if (!startTime) startTime = timestamp
const elapsed = timestamp - startTime
const charsToShow = Math.floor(elapsed * speed / 1000)
if (charsToShow <= sourceText.length) {text.value = sourceText.slice(0, charsToShow)
rafId = requestAnimationFrame(animate)
} else {
isTyping.value = false
onComplete?.()}
}
// 开始输出
const start = (content: string) => {cancelAnimationFrame(rafId)
sourceText = content
text.value = ''
isTyping.value = true
startTime = 0
rafId = requestAnimationFrame(animate)
}
// 停止并清空
const stop = () => {cancelAnimationFrame(rafId)
isTyping.value = false
}
// 组件卸载时自动清理
onUnmounted(stop)
return {text, isTyping, start, stop}
}
2. 组件中使用示例
<script setup lang="ts">
import {useTypewriter} from './useTypewriter'
const {text, isTyping, start} = useTypewriter({
speed: 15,
onComplete: () => console.log('打印完成')
})
// 模拟 API 获取数据
const fetchMessage = async () => {const res = await fetch('/api/chat')
start(await res.text())
}
</script>
<template>
<div class="bubble">
<!-- 使用 teleport 避免父组件重渲染 -->
<span>{{text}}</span>
<span v-if="isTyping" class="cursor">|</span>
</div>
</template>
3. 响应式缓冲区设计
对于需要支持用户输入的场景(如聊天框),可以结合v-model:
<script setup lang="ts">
const props = defineProps<{modelValue: string}>()
const emit = defineEmits(['update:modelValue'])
const {text, start} = useTypewriter()
watch(() => props.modelValue,
(newVal) => start(newVal),
{immediate: true}
)
// 输入时立即更新(不带动画)const handleInput = (e: Event) => {emit('update:modelValue', (e.target as HTMLInputElement).value)
}
</script>
性能优化
虚拟滚动集成
当聊天记录很长时,建议使用虚拟滚动库(如vue-virtual-scroller):
<script setup>
import {RecycleScroller} from 'vue-virtual-scroller'
const messages = ref([]) // 聊天消息数组
</script>
<template>
<RecycleScroller
:items="messages"
:item-size="60"
key-field="id"
>
<template #default="{item}">
<!-- 每个消息项使用打字机效果 -->
<TypewriterBubble :content="item.text" />
</template>
</RecycleScroller>
</template>
动画内存管理
实现暂停 / 恢复时需注意:
- 离开页面时(如路由切换)自动停止所有动画
- 页面可见性 API 控制:
// 在 useTypewriter 中添加
const handleVisibilityChange = () => {if (document.hidden) {cancelAnimationFrame(rafId)
} else if (isTyping.value) {
// 恢复时重新计算已显示字符数
const displayedChars = text.value.length
startTime = performance.now() - (displayedChars / speed) * 1000
rafId = requestAnimationFrame(animate)
}
}
onMounted(() => {document.addEventListener('visibilitychange', handleVisibilityChange)
})
onUnmounted(() => {document.removeEventListener('visibilitychange', handleVisibilityChange)
})
避坑指南
1. SSR 兼容方案
服务端渲染时需特殊处理:
// 修改 useTypewriter 的 start 方法
const start = (content: string) => {if (import.meta.env.SSR) {
text.value = content // SSR 环境下直接显示完整文本
return
}
// ... 原有动画逻辑
}
2. 移动端 Safari 修复
iOS Safari 有时会暂停未可见的requestAnimationFrame,需要添加以下修复:
/* 强制开启 GPU 加速 */
.bubble {transform: translateZ(0);
backface-visibility: hidden;
}
扩展思考:Markdown 渐进渲染
要实现 Markdown 内容的渐进式渲染,可以:
- 将 Markdown 按语法块拆分(段落、代码块等)
- 对每个块依次应用打字机效果
const renderMarkdown = async (markdown: string) => {const blocks = markdown.split(/(?=^#{1,6}|^-|^\d\.|^```)/gm)
for (const block of blocks) {await new Promise<void>((resolve) => {const { start, isTyping} = useTypewriter({onComplete: resolve})
start(block)
})
}
}
总结
通过 requestAnimationFrame 与 Vue 响应式系统的结合,我们实现了一个高性能的打字机效果。关键点包括:
- 避免直接操作 DOM,利用 Vue 的响应式更新
- 动画帧与浏览器刷新率同步
- 完善的资源清理机制
- 针对特殊环境的兼容处理
这种模式不仅适用于聊天场景,也可以扩展到代码演示、交互式教程等需要渐进展示内容的场景。
