Vue实现ChatGPT打字机效果:从原理到实战避坑指南

8次阅读
没有评论

共计 3732 个字符,预计需要花费 10 分钟才能阅读完成。

image.webp

背景痛点

在实现类 ChatGPT 的打字机效果时,很多开发者首先想到的是使用setInterval。但在 Vue 中,这种方案会面临两个主要问题:

Vue 实现 ChatGPT 打字机效果:从原理到实战避坑指南

  1. 性能缺陷 setInterval 无法与浏览器刷新率同步,可能导致动画卡顿或丢帧。在长文本场景下,频繁触发 Vue 的响应式更新会引发不必要的重渲染。

  2. 状态同步问题:当用户快速切换对话时,旧的定时器可能仍在执行,导致新旧内容混杂。需要手动管理定时器生命周期,代码复杂度高。

技术选型

实现打字机效果主要有三种技术路径:

  • CSS 动画 :通过@keyframessteps()函数实现。优点是性能好,但难以动态控制速度,且无法中途暂停 / 恢复。

  • Web Animation API:原生动画 API,适合复杂动画场景。但打字机效果本质上属于文本逐字更新,用 WAAPI 显得有些重。

  • JS 时序控制 :灵活度高,可精确控制每个字符的显示时机。配合requestAnimationFrame 能获得最佳性能。

最终选择 :基于requestAnimationFrame 的 JS 方案,理由如下:

  1. 完美匹配浏览器刷新率(通常 60fps)
  2. 天然支持动画暂停 / 继续
  3. 便于与 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>

动画内存管理

实现暂停 / 恢复时需注意:

  1. 离开页面时(如路由切换)自动停止所有动画
  2. 页面可见性 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 内容的渐进式渲染,可以:

  1. 将 Markdown 按语法块拆分(段落、代码块等)
  2. 对每个块依次应用打字机效果
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 响应式系统的结合,我们实现了一个高性能的打字机效果。关键点包括:

  1. 避免直接操作 DOM,利用 Vue 的响应式更新
  2. 动画帧与浏览器刷新率同步
  3. 完善的资源清理机制
  4. 针对特殊环境的兼容处理

这种模式不仅适用于聊天场景,也可以扩展到代码演示、交互式教程等需要渐进展示内容的场景。

正文完
 0
评论(没有评论)