在 Vue 3 里做打字机效果,思路其实很直接:别直接操作 DOM,而是让一个响应式状态记录当前已经显示到第几个字符。定时器每次触发时把索引加一,模板再根据这个索引从原始文案里切出子串渲染出来。这样做的好处是 Vue 的更新机制会自然处理界面同步,手动操作 DOM 可能带来的卸载清理、复用问题也都消失了。

先做最小实现:用 ref 和 setInterval 驱动逐字输出
第一步需要准备三个东西:原始文案、当前已显示字数、以及一个定时器句柄。组合式 API 中可以写成 const fullText = '...'、const visibleText = ref('')、let timer = null。其中 fullText 不需要响应式,因为它不会变;visibleText 必须是 ref,模板里要绑定它;timer 用来在组件卸载时清理,避免内存泄漏。
定时器逻辑可以放在 onMounted 中。每隔一段时间执行一次回调:先判断当前索引是否已经等于原始文案长度,如果相等就清除定时器;否则索引加一,再用 slice(0, index) 更新 visibleText.value。这里用 setInterval 会比递归 setTimeout 简单,但间隔固定时两种差别不大。代码块里是完整的 script setup 写法。
import { ref, onMounted, onBeforeUnmount } from 'vue';
export default {
setup() {
const fullText = '这段文字会在页面上逐字显示,模拟打字机效果。';
const visibleText = ref('');
let timer = null;
let index = 0;
function startTyping() {
timer = setInterval(function () {
if (index === fullText.length) {
clearInterval(timer);
timer = null;
return;
}
index = index + 1;
visibleText.value = fullText.slice(0, index);
}, 120);
}
onMounted(startTyping);
onBeforeUnmount(function () {
if (timer) {
clearInterval(timer);
}
});
return { visibleText };
}
};
对应模板很简单:<p>{{ visibleText }}</p>。这里不需要引入第三方库,也不需要计算属性。因为 visibleText 已经是字符串,直接展示就行。如果你希望保留换行或空格,可以在外面套一个 <pre> 或给元素加 white-space: pre-wrap,避免文本被压缩。
封装成 useTypewriter 自定义 Hook 提高复用性
最小实现适合演示,但在真实项目里一个组件里写满定时器和索引会显得乱。Vue 3 的函数式组合方式很适合把这种逻辑抽成一个 useTypewriter 函数。这个函数接收原始文案和速度参数,返回 visibleText、isDone、reset、pause 等状态和方法。这样任何组件都能按需调用。
封装时需要注意两点。第一,定时器必须独立保存在函数作用域的内部变量里,不能暴露给组件,组件只需要关心返回的控制方法。第二,暂停和继续要能正确清除并重建定时器。下面这个实现用 setInterval 和一个内部索引,同时提供 pause 和 resume。完成时会触发传入的 onDone 回调,并把 isDone 置为 true。
import { ref, readonly } from 'vue';
export function useTypewriter(options) {
const text = options.text;
const speed = options.speed || 100;
const onDone = options.onDone || function () {};
const visibleText = ref('');
const isDone = ref(false);
let timer = null;
let index = 0;
function clearTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function start() {
clearTimer();
timer = setInterval(function () {
if (index === text.length) {
clearTimer();
isDone.value = true;
onDone();
return;
}
index = index + 1;
visibleText.value = text.slice(0, index);
}, speed);
}
function pause() {
clearTimer();
}
function resume() {
if (!isDone.value && !timer) {
start();
}
}
function reset() {
clearTimer();
index = 0;
visibleText.value = '';
isDone.value = false;
}
return {
visibleText: readonly(visibleText),
isDone: readonly(isDone),
start,
pause,
resume,
reset
};
}
这里把 visibleText 和 isDone 用 readonly 包了一层再返回,是防止使用方在组件里直接改这些状态。调用时只需要 const { visibleText, start, reset } = useTypewriter({ text: '...' }),再在 onMounted 里执行 start 即可。由于 readonly 不影响模板绑定,使用上不会有任何障碍。
这个 Hook 还可以进一步扩展,比如支持动态修改文案后自动重置、支持中英文混合时按字符切分而不是按字节切分。对中文场景来说,JavaScript 的字符串索引和 slice 都按 UTF-16 码元处理,常见汉字没有代理对问题,但如果文案包含 emoji 或特殊符号,建议先转成数组再切片,否则可能出现半个字符。
光标闪烁:用 CSS 动画而不是 JavaScript 定时器
光标闪烁通常只是在文字末尾显示一条竖线,并让它的透明度周期性变化。用 JavaScript 定时器去改样式也能做到,但 CSS 动画更省资源,也更自然。只需要在模板里给文案后面加一个 <span class="cursor"></span>,再用 CSS 定义宽度、颜色和 @keyframes 即可。光标元素可以设置成 inline-block,高度继承文字行高,宽度 2px。
.typewriter-cursor {
display: inline-block;
width: 2px;
height: 1.2em;
margin-left: 2px;
background-color: currentColor;
vertical-align: text-bottom;
animation: blink 0.8s step-end infinite;
}
@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
CSS 里的 step-end 能让光标在 0 和 1 之间跳变,而不是渐变,看起来更像终端光标。如果不加 step-end,闪烁过程会带半透明过渡,有些人会觉得不够干脆。颜色使用 currentColor 可以跟随文字颜色,避免额外维护一套色彩变量。
模板中只要在 {{ visibleText }} 后面跟一个 <span class="typewriter-cursor"></span>,就能看到光标跟随文字的末尾移动。如果文本很长导致换行,光标也会跟着折行,这是行内元素自然行为。只有需把光标固定在容器右下角时才考虑绝对定位,但这种情况较少见。
处理动画暂停、完成回调和 reduceMotion 降级
打字机效果如果用在首屏或介绍区域,往往会和用户滚动、点击等操作产生重叠。比如用户点击了跳转链接,但动画还在播放,就可能出现文案停留在半截的情况。比较好的做法是在组件卸载前清理定时器,这已经在前面的示例中覆盖。更进一步,当用户触发某个操作时调用 pause,等操作完成再 resume。如果认为动画不重要,也可以直接 reset 后一次性显示全文。
完成的判断不要依赖字符串长度比较可能出现的问题。前面实现里使用索引与总长度相等作为结束条件,这是稳定可靠的。但要注意,如果原始文案为空字符串,应当直接标记完成,不要启动定时器。另外 onDone 回调里如果包含修改其他响应式状态的操作,可能会触发额外的渲染,但不会导致死循环,因为回调只在定时器清理前执行一次。
无障碍方面,可以结合 window.matchMedia('(prefers-reduced-motion: reduce)') 判断用户是否希望减少动画。如果匹配为真,就跳过逐字动画,直接显示完整文案,同时光标闪烁也可以关闭。这个检测可以在 onMounted 中进行,也能写进 useTypewriter 内部,把动画时长设为 0 或直接调用完成逻辑。这样对启用系统减少动效的用户更友好。
还可以给文案容器加 aria-live="polite",让屏幕阅读器在文字变化时播报。对于逐字更新,播报频率可能过于频繁,建议动画结束后再更新一个完整文本的隐藏区域,或在完成时设置 aria-label。这些细节能显著提升可访问性,也是生产级组件容易忽略的地方。