☰
Vue3打字机效果组件进阶:从定时器到帧动画的性能优化
2026/10/1 17:21:23 网站建设 项目流程

最早在项目里落地“打字机效果组件”,我的第一版实现很简单:setInterval每隔几十毫秒截一次字符串,往模板里塞。演示的时候确实唬人,可等文案一长,问题全暴露出来——用户切个后台标签页再回来,整段文字“嗖”地一下跳到结尾;几百字的长段落打出来一顿一顿的,连鼠标滚轮都感觉被拖累。后来我抽了一晚上把组件推翻重写,核心调度从定时器换成了requestAnimationFrame,再加上字素拆分、变速曲线、段落暂停、指令式控制这些细节,才做出真正称得上“丝滑”的Vue3 打字机效果组件。

这篇属于进阶内容,基础写法我不再展开,重点讲清重写过程中的方案取舍、关键代码和实际踩坑。适合已经在用 Vue3、做过简单打字效果,但想让动画节奏、性能和细节控制都更上一个台阶的开发朋友。

1. 先想清楚:进阶版比基础版到底“进”在哪

做任何组件之前,先别急着写代码。把旧方案为什么不好用想透了,新方案才不会走回头路。

1.1 setTimeout 实现打字机的三个硬伤

第一版组件用定时器跑起来很顺,是因为文案短、场景演示足够。但真正放到生产环境后,三个问题会依次浮出来。

问题一:定时器在后台会被节流,切回前台直接跳结尾。浏览器为了省电,对后台页面的setTimeout会做节流处理,节流间隔大时可以到 1 秒以上。用户切走标签页,定时器回调全被积压;再切回来的时候,积压的回调会在极短时间内连续触发,表现就是文字“啪”一下打完。我用一段 600 字的文案实测过,切后台 5 秒再切回来,动画基本是瞬移完成,毫无悬念。

问题二:逐字符截取字符串,复杂度是 O(n²)。旧方案里每一帧都重新执行text.slice(0, index),字符串越长,一次性拷贝的代价越大。文案 300 字以内尚可,一旦出现几千字的演讲稿、小说章节,打字到中后段时每次 slice 都涉及大段内存复制,肉眼可见掉帧。

问题三:控制能力和节奏表达几乎为零。基础版只有一个方向:从头打到尾。想暂停、继续、重置都要另写逻辑;想让一万字文案分段落展示、段落之间留停顿、模仿真人打字时快时慢的节奏,基础版压根做不到。

1.2 进阶版要解决的四个核心课题

把上面三个痛点拆开,进阶版的目标就很清晰了。

  1. 稳定性:切后台、突然卡顿,都不能让整体进度失真。要么自动暂停,要么恢复后从断点继续。
  2. 性能:文本再长也要保持 60 帧的流畅体验,不能随着字符串变长而线性劣化。
  3. 节奏感:匀速打字像机器人在念稿,真正“打字机”的感觉应该是有起速、有冲刺、有收尾的,类似人在键盘上敲击。
  4. 控制力:组件要对外提供start、pause、resume、reset等指令式 API,多段文本之间要支持停顿和轮播。

1.3 为什么最终选择 requestAnimationFrame

关于动画调度,业界早有共识:凡是跟画面变化挂钩的,优先选requestAnimationFrame,而不是setInterval或setTimeout。

RAF 最大的优势,是它由浏览器在每一次重绘之前调用,回调参数自带高精度时间戳。屏幕是 60Hz,它就每秒触发约 60 次;屏幕是 120Hz,它会跟着提高频率;CPU 忙不过来导致掉帧时,浏览器会自动跳过中间帧,而不是像定时器那样在卡顿结束后把积压的回调一股脑补跑。

我用一个更生活化的类比来理解:

.setInterval像定闹钟,闹钟响的时候渲染线程不一定有空;.requestAnimationFrame像跟渲染引擎打同一套拍子,引擎每次迈腿,你都踩着点落脚。

因为 RAF 天然和屏幕刷新同步,打字动画就不会出现“两帧相隔时间忽长忽短”的抖动感。顺带还拿到一个福利:页面切到后台,RAF 自动停止;切回来继续跑,完全不需要自己写节流逻辑。

2. 核心细节解析:从文本到逐帧渲染

决定了用 RAF 之后,接下来要处理的是三个细节层面:文本怎么拆、时间怎么算、光标怎么做。这三点直接决定组件用起来“像不像那么回事”。

2.1 不能直接 slice 文本:字素拆分才是正解

字符串处理是最容易被忽略的坑。JavaScript 的字符串索引基于 UTF-16 码元,一个 emoji、一个生僻字、一个带声调的字母组合,都可能占用多个码元。如果直接按length或者slice(from, to)截取,就会把完整字符从中间劈开,轻则显示乱码,重则出现“半个 emoji”。

我试过用一个家庭 emoji👨‍👩‍👧‍👦做测试,结果差异非常明显:

const family = '👨‍👩‍👧‍👦'; console.log(family.length); // 11,纯码元数量 console.log(Array.from(family).length); // 7,按码点拆,ZWJ 序列被拆开 console.log([...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(family)].length); // 1,按用户感知的字素拆

Intl.Segmenter是原生按“用户感知字符”做分词的 API,支持绝大多数现代浏览器。它能把 ZWJ 序列、组合变音符号、旗帜这类复杂字符正确识别为一个整体,这正是打字机逐字显示需要的粒度。用它的结果作为渲染单元,每个“字”在视觉上都是完整且独立的。

当然,老浏览器不一定支持Intl.Segmenter,我保留了Array.from作为降级方案,兼容性从 IE 时代到最新版都能兜底:

function splitText(raw: string): string[] { const Ctor = (Intl as any).Segmenter; if (Ctor) { const segmenter = new Ctor(undefined, { granularity: 'grapheme' }); return Array.from(segmenter.segment(raw), (item: any) => item.segment); } return Array.from(raw); }

2.2 帧间隔怎么算:从 delta 到变速曲线

有了文本单元,下一步就是时间模型。这里我选择“每帧累计 delta”的方式,而不是“从开始到现在总时长”的方式。

核心思想很简单:记录上一帧时间戳,每一帧进来先算差值,再把差值累加到已运行时间。这样暂停处理起来非常干净——暂停就是停止累加,恢复后第一帧重新记录时间戳,进度从暂停点继续走,不用去修正什么“已过时间”。

let prevTimestamp = 0; let runMs = 0; function tick(now: number) { if (prevTimestamp === 0) { prevTimestamp = now; } const delta = Math.min(0.1, (now - prevTimestamp) / 1000); prevTimestamp = now; runMs += delta * 1000; // 后续处理... }

要注意Math.min(0.1, ...)这个 clamp 很关键。它防止用户从休眠状态唤醒电脑,或调试器卡了十几秒之后,浏览器突然补一个超大 delta,让动画一帧内跳过去一百个字。RAF 正常情况下每帧间隔不会超过 100ms,clamp 后既不影响正常速度,又兜住了极端情况。

再来说打字节奏。如果不做处理,速度就是匀速的,从头到尾每个字符间隔完全相同,机械感很重。我引入了一个easeInOutCubic曲线,把“时间进度”映射成“显示进度”:

function easeInOutCubic(t: number): number { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }

举一个具体的例子。假设某段文案 100 字,打字速度是 50 字/秒,总时长约 2 秒。在时间走到 0.5 秒时,时间进度是 0.25,经过曲线变换后显示进度大约只有 0.062,也就是只显示 6 个字;时间走到 1 秒时,显示进度 0.5,显示 50 个字;时间走到 1.5 秒时,显示进度约 0.94,已经显示 94 个字。

这样打出来的效果就是:开头几个字“慢热”出来,中间段匀速冲刺,接近结尾时逐步收住。人工打字的感觉一下子就有了。

2.3 光标跟随与闪烁:能交给 CSS 的绝不用 JS

光标的实现同样有讲究。我见过很多实现会在 JavaScript 里再开一个定时器去控制光标显示、隐藏,这既浪费资源,又容易造成闪烁频率不稳。

正确做法是纯 CSS 动画,只在显示节点后面放一个<span>,用steps做无过渡闪烁:

.tw-caret { display: inline-block; width: 2px; height: 1em; margin-left: 2px; background: currentColor; vertical-align: text-bottom; animation: tw-blink 0.8s steps(2, start) infinite; } @keyframes tw-blink { to { visibility: hidden; } }

解释一下steps(2, start):它让动画在“可见/隐藏”两个状态之间切换,中间不插入过渡帧。打字机的光标就是这种干脆利落的闪现,而不是渐隐渐现。使用visibility而不是opacity,是因为visibility: hidden时元素不参与绘制,性能上也更有优势。

3. 实操过程与核心代码实现

概念讲清楚了,直接上一版可用的完整实现。这个组件我封装成一个单文件 SFC,用<script setup lang="ts">组织逻辑。

3.1 组件的基础结构与 props 设计

先看模板部分,结构非常简单,本质就三个节点:容器、文本区、光标区。

<template> <div ref="rootRef" class="tw-root" :class="{ 'tw-root--active': isRunning }"> <span ref="textRef" class="tw-text"></span> <span v-if="cursor" class="tw-caret" aria-hidden="true"></span> </div> </template>

props 和 emits 的设计如下表:

参数类型默认值说明
textstring | string[]必填单段文案或多段文案
speednumber60打字速度,单位:字符/秒
easebooleantrue是否启用变速曲线
pausenumber700多段文案之间的停顿,单位:毫秒
cursorbooleantrue是否显示光标
startDelaynumber0调用 start 后的延迟启动时间,单位:毫秒

事件方面只暴露三个:start开始播放、update更新已显示字符数、end全部播放完成。

3.2 文本预解析与状态管理

组件的核心状态如下,每个变量都有明确分工:

let rafId = 0; let pauseTimer = 0; let prevTimestamp = 0; let runMs = 0; let segmentIndex = 0; let renderedInSegment = 0;
  • rafId:保存当前 RAF 回调 id,用于cancelAnimationFrame。
  • pauseTimer:保存段落停顿的setTimeoutid,用于清理。
  • prevTimestamp:上一帧时间戳,用于计算 delta。
  • runMs:当前段落已累计运行时间。
  • segmentIndex:当前正在播放第几段。
  • renderedInSegment:当前段内已经显示了几个字。

预处理阶段,把传入的text统一处理成string[][],外层是段落列表,内层是该段落的字素数组:

const segments = computed(() => { const list = Array.isArray(props.text) ? props.text : [props.text]; return list.filter((item) => item && item.length > 0).map(splitText); });

3.3 调度循环:RAF 怎么接管定时器

核心循环函数tick是组件的心脏。它负责计算本帧应新增的字数、追加文本、处理段落切换和结束逻辑。

function tick(now: number) { if (!isRunning.value) return; if (prevTimestamp === 0) { prevTimestamp = now; } const delta = Math.min(0.1, (now - prevTimestamp) / 1000); prevTimestamp = now; const currentSegment = segments.value[segmentIndex]; if (!currentSegment) { finish(); return; } runMs += delta * 1000; const segmentDuration = (currentSegment.length / props.speed) * 1000; const rawProgress = Math.min(1, runMs / segmentDuration); const progress = props.ease ? easeInOutCubic(rawProgress) : rawProgress; const targetCount = Math.min( currentSegment.length, Math.floor(progress * currentSegment.length) ); if (targetCount > renderedInSegment) { textRef.value!.textContent += currentSegment .slice(renderedInSegment, targetCount) .join(''); renderedInSegment = targetCount; const doneBefore = segments.value .slice(0, segmentIndex) .reduce((sum, item) => sum + item.length, 0); emit('update', doneBefore + renderedInSegment); } if (targetCount < currentSegment.length) { rafId = requestAnimationFrame(tick); return; } // 当前段打完了,切下一段或收尾 if (segmentIndex < segments.value.length - 1) { segmentIndex += 1; renderedInSegment = 0; runMs = 0; prevTimestamp = 0; pauseTimer = window.setTimeout(() => { if (isRunning.value) { prevTimestamp = 0; rafId = requestAnimationFrame(tick); } }, props.pause); } else { finish(); } }

几个细节值得展开:

为什么用textContent +=而不是textContent = slice()?前者是不断增加文本,已显示部分不会被重新拷贝;后者每次都要把整个字符串重新拼接、赋值,字符串越长越慢。实测 3000 字长文案,两种写法性能差距非常明显。

为什么段落停顿用setTimeout而不是 RAF 里等待?RAF 不应该空转,没有渲染任务时就不该占用回调队列。段落间停顿本质是“什么都不做”的等待,交给setTimeout最干净。但要记得在组件卸载时清掉这个定时器。

为什么重新开始新段之前要把prevTimestamp = 0?因为停顿期间 RAF 不执行,下一段开始时上一帧时间戳已经过期。清零后,新段第一帧会重新记录时间戳,避免把停顿时间也算进动画进度里。

3.4 指令式 API:start / pause / resume / reset

组件对外的四个方法语义要明确:

  • start:从头开始播放。
  • pause:暂停,停在当前位置。
  • resume:从暂停位置继续。
  • reset:清空文本和进度,回到初始状态。

它们通过defineExpose暴露给父组件:

function start() { if (isFinished.value || renderedInSegment > 0) { reset(); } isRunning.value = true; isFinished.value = false; emit('start'); if (props.startDelay > 0) { pauseTimer = window.setTimeout(() => { if (isRunning.value) { prevTimestamp = 0; rafId = requestAnimationFrame(tick); } }, props.startDelay); } else { prevTimestamp = 0; rafId = requestAnimationFrame(tick); } } function pause() { isRunning.value = false; cancelAnimationFrame(rafId); clearTimeout(pauseTimer); } function resume() { if (isRunning.value || isFinished.value) return; isRunning.value = true; prevTimestamp = 0; rafId = requestAnimationFrame(tick); } function reset() { pause(); segmentIndex = 0; renderedInSegment = 0; runMs = 0; prevTimestamp = 0; isFinished.value = false; if (textRef.value) { textRef.value.textContent = ''; } } defineExpose({ start, pause, resume, reset, isRunning, isFinished });

这里有一个平时文档里不常提的设计细节:resume里特意加了isFinished.value判断。动画已经结束后再调用resume,如果不加判断,会让组件回到一个“文本完整显示但状态是播放中”的中间态,光标会一直亮着,容易造成视觉困惑。这种边界状态我踩过一次,现在写进组件里当成默认行为。

3.5 组件销毁与文本变化时的兜底逻辑

watch监听text变化,变化后重置组件。组件卸载时清理 RAF 和定时器:

watch(() => props.text, () => { reset(); }); onBeforeUnmount(() => { cancelAnimationFrame(rafId); clearTimeout(pauseTimer); });

需要注意这里的清理顺序:先清除定时器,再取消 RAF,防止组件卸载后还有异步回调尝试操作已销毁的 DOM。

4. 常见问题与排查技巧实录

最后这部分,我把实际使用中遇到的坑和排查思路整理出来,做成一份可直接对照的问题速查表。

4.1 问题速查表

现象可能原因解决方案
切回页面后动画直接跳到结尾setTimeout被浏览器后台节流改用 RAF + delta 循环
末尾多出半个 emoji 或乱码直接用slice切字符串用Intl.Segmenter按字素拆分
动画停在最后一两个字不显示进度计算用floor导致最后一位差一帧当前段targetCount >= len时直接补齐完整文本
组件被keep-alive缓存后定时器残留卸载/失活时未清理在onDeactivated暂停、onBeforeUnmount清理
恢复暂停后第一帧冲得太快暂停前时间戳过期,delta 计算出异常值resume时把prevTimestamp置 0

4.2 keep-alive 与 SSR 场景下的兼容处理

如果你的组件会出现在被keep-alive包裹的动态页面里,建议补上生命周期处理:

onActivated(() => { if (!isFinished.value) { resume(); } }); onDeactivated(() => { pause(); });

这样组件从缓存重新激活时,动画从暂停位置继续走,而不是因为浏览器后台调度导致进度错乱。

SSR 场景的注意点则是:requestAnimationFrame、performance.now都是浏览器环境才有的 API,组件逻辑必须放在onMounted之后执行。如果项目需要服务端渲染,组件不要自动播放,由父组件挂载完成后再调用start();或者加一个autoPlayprop,在onMounted里调用start()。

4.3 大文本性能优化:追加模式是关键中的关键

再强调一次这个点,因为它太容易被人忽略。

错误的写法是把已显示文本全量塞回节点:

// 错误示例:O(n²) textEl.textContent = wholeText.slice(0, index);

正确的写法是只追加新增部分:

// 正确示例:O(新增量) textEl.textContent += segment.slice(from, to).join('');

前者越到后面越慢,后者无论文本多长,每一帧的代价只跟本帧要新增的字符数相关。我拿一段 5000 字的文本做过对比,全量重设方案在中后段已经掉到十几帧,追加方案全程稳定满帧。

还有一个优化点是触发时机:targetCount > renderedInSegment时才写textContent。曲线起始阶段进度很慢,可能连续好几帧的目标字符数都没变化,这时应该直接跳过 DOM 操作,只调requestAnimationFrame等下一帧。

4.4 换行、中英文混排与原生 xss 安全

样式上的三个细节直接影响了组件在不同场景的观感:

.tw-root { display: inline-flex; align-items: baseline; white-space: pre-wrap; } .tw-text { white-space: pre-wrap; word-break: break-word; }

pre-wrap会保留文本中的换行和连续空格,同时允许文本到达容器边缘时自然换行;word-break配合中文长文案可以在任意字符处断行,避免一长串英文字母把布局撑破。光标用inline-flex + align-items: baseline垂直对齐,能让光标稳定落在文本基线上,中英文混排时不会上下跳动。

另外特别提醒:打字机组件不要用v-html渲染文本。逐字显示时v-html会把<、>之类的字符当成标签解析,既容易产生意外换行,又存在 XSS 风险。用textContent展示是一劳永逸的解法——哪怕文案里带<script>标签,它也只是被当作普通文本打出来。

让我再补充一个光标颜色的细节:光标没有设置独立颜色,而是用background: currentColor自动继承父级文字颜色。这样在不同主题色下都不需要额外改样式,换肤时组件自己跟得上。


这套组件我在好几个真实项目里跑过:官网首屏 slogan 轮播、活动落地页的多段引导文案、后台的模拟数据演示页,用的都是同一个组件。给我留下最深印象的体会是,打字机效果看着简单,真正的难点不在“能不能打出来”,而在“每一步是否都稳得住”。把时间基准交还给浏览器,把展示文本交给textContent,把节奏微调交给变速曲线,这个组件基本就告别了之前那种土味打字机的观感。后续我还在考虑继续扩展:给每个字加打字音效、配合语音朗读高亮当前字符、从任意暂停点继续播放这类周边能力,方向也都是建立在现有调度框架之上,扩展起来比较省事。如果你现在正被手头打字机组件的卡顿和跳段困扰,可以把这些思路直接搬到自己的工程里,相信会有立竿见影的效果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询