最早在项目里落地“打字机效果组件”我的第一版实现很简单setInterval每隔几十毫秒截一次字符串往模板里塞。演示的时候确实唬人可等文案一长问题全暴露出来——用户切个后台标签页再回来整段文字“嗖”地一下跳到结尾几百字的长段落打出来一顿一顿的连鼠标滚轮都感觉被拖累。后来我抽了一晚上把组件推翻重写核心调度从定时器换成了requestAnimationFrame再加上字素拆分、变速曲线、段落暂停、指令式控制这些细节才做出真正称得上“丝滑”的Vue3 打字机效果组件。这篇属于进阶内容基础写法我不再展开重点讲清重写过程中的方案取舍、关键代码和实际踩坑。适合已经在用 Vue3、做过简单打字效果但想让动画节奏、性能和细节控制都更上一个台阶的开发朋友。1. 先想清楚进阶版比基础版到底“进”在哪做任何组件之前先别急着写代码。把旧方案为什么不好用想透了新方案才不会走回头路。1.1 setTimeout 实现打字机的三个硬伤第一版组件用定时器跑起来很顺是因为文案短、场景演示足够。但真正放到生产环境后三个问题会依次浮出来。问题一定时器在后台会被节流切回前台直接跳结尾。浏览器为了省电对后台页面的setTimeout会做节流处理节流间隔大时可以到 1 秒以上。用户切走标签页定时器回调全被积压再切回来的时候积压的回调会在极短时间内连续触发表现就是文字“啪”一下打完。我用一段 600 字的文案实测过切后台 5 秒再切回来动画基本是瞬移完成毫无悬念。问题二逐字符截取字符串复杂度是 O(n²)。旧方案里每一帧都重新执行text.slice(0, index)字符串越长一次性拷贝的代价越大。文案 300 字以内尚可一旦出现几千字的演讲稿、小说章节打字到中后段时每次 slice 都涉及大段内存复制肉眼可见掉帧。问题三控制能力和节奏表达几乎为零。基础版只有一个方向从头打到尾。想暂停、继续、重置都要另写逻辑想让一万字文案分段落展示、段落之间留停顿、模仿真人打字时快时慢的节奏基础版压根做不到。1.2 进阶版要解决的四个核心课题把上面三个痛点拆开进阶版的目标就很清晰了。稳定性切后台、突然卡顿都不能让整体进度失真。要么自动暂停要么恢复后从断点继续。性能文本再长也要保持 60 帧的流畅体验不能随着字符串变长而线性劣化。节奏感匀速打字像机器人在念稿真正“打字机”的感觉应该是有起速、有冲刺、有收尾的类似人在键盘上敲击。控制力组件要对外提供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 正常情况下每帧间隔不会超过 100msclamp 后既不影响正常速度又兜住了极端情况。再来说打字节奏。如果不做处理速度就是匀速的从头到尾每个字符间隔完全相同机械感很重。我引入了一个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 langts组织逻辑。3.1 组件的基础结构与 props 设计先看模板部分结构非常简单本质就三个节点容器、文本区、光标区。template div refrootRef classtw-root :class{ tw-root--active: isRunning } span reftextRef classtw-text/span span v-ifcursor classtw-caret aria-hiddentrue/span /div /templateprops 和 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 指令式 APIstart / 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置 04.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把节奏微调交给变速曲线这个组件基本就告别了之前那种土味打字机的观感。后续我还在考虑继续扩展给每个字加打字音效、配合语音朗读高亮当前字符、从任意暂停点继续播放这类周边能力方向也都是建立在现有调度框架之上扩展起来比较省事。如果你现在正被手头打字机组件的卡顿和跳段困扰可以把这些思路直接搬到自己的工程里相信会有立竿见影的效果。