前端打字机效果如何实现
一句话先定性
我不会把打字机效果讲成“
setInterval每次截一个字符”,而会讲成“把文本来源、输出节奏和展示层拆开”。
30 秒版本
如果只是静态文案动画,纯 CSS 也能做,比如
steps()配合宽度裁剪和光标闪烁。
但如果是业务里真正常见的 AI 输出、聊天消息或者 Markdown 渐进渲染,我一般会用 JS 去做。核心思路是把完整文本、当前已显示文本、输出指针和调度器分开管理,然后按字符、按词或者按 chunk 逐步吐出来。这样后面要做暂停、跳过、加速、自动滚动和流式输出都比较好接。
1 分钟版本
我会先分两类场景。第一类是纯展示型打字机,比如首页 slogan,这种可以直接用 CSS 动画解决;第二类是动态内容型打字机,比如 AI 回复、聊天消息、代码解释,这种我会用 JavaScript 做一个小调度器。
具体实现上,我一般会维护几份状态:
fullText表示完整文本,visibleText表示当前展示文本,cursor表示已经输出到哪里,status表示是 typing、paused 还是 done。然后通过setTimeout、requestAnimationFrame或者定时批量 flush,让文本按节奏逐步进入界面。如果再工程化一点,我会把它拆成三层:数据源层负责拿完整文本或流式 chunk;调度层负责决定什么时候吐多少;展示层只负责渲染。这样打字机效果就不是一个小技巧,而是一套可扩展的展示机制。
2 到 3 分钟版本
打字机效果这个题,我一般不会只回答“用
setInterval每次slice一下字符串”,因为那样太像 demo 方案。真实业务里,打字机效果通常不只是为了好看,而是为了把内容的生成过程、阅读节奏和反馈感做出来。尤其在 AI 聊天、流式输出这种场景里,它本质上更像一种增量渲染策略。所以我会先分场景。静态文案型场景,比如 banner 文案、标题动画,用 CSS 就够了。典型做法是固定容器宽度,配合
overflow: hidden、white-space: nowrap、border-right这种光标,再用steps(n)模拟逐字出现。这种方案简单、性能也好。但如果是动态内容,尤其是 AI 回复这种边生成边显示的内容,我会优先用 JavaScript。这里我更关心三层。第一层是数据源层,负责接完整文本或者流式 chunk;第二层是调度层,控制输出节奏,比如一次吐一个字符、一个词,还是一个 chunk;第三层是展示层,只负责渲染
visibleText。具体状态上,我通常会维护
fullText、visibleText、cursor、queue和status。如果是一次性文本,就让fullText固定,cursor逐步推进;如果是流式输出,就把服务端返回的 chunk 先放进queue,然后由前端调度器按节奏消费。这样数据接收和视觉打字机就是两条链,不会混在一起。这里一个很重要的点是,打字机效果最好不要和真实数据源强耦合。比如在 Team AI 这种场景里,我会把 SSE 或流式响应当成真实数据通道,而把打字机当成展示策略。也就是说,后端什么时候来数据是一回事,前端怎么让用户“读起来更顺”是另一回事。这样后面要切换成秒出全文、跳过动画或者加速播放,都不用改数据层。
性能上我也不会真的每来一个字符就触发一次重渲染,尤其是长文本和 Markdown 场景。更稳的做法通常是按词、按 token 或按 chunk 批量输出,必要时结合
requestAnimationFrame或节流,让更新频率保持在一个合理范围。否则文本一长,React 每个字符都 setState,页面会明显抖。如果内容里还有 Markdown、代码块或者高亮,我一般会把“打字机中的轻渲染”和“完成后的正式渲染”分开。也就是说,打字过程中可以先渲染纯文本或轻量 Markdown,等结束后再做完整解析和高亮。这样既能保留过程感,也不会把渲染成本压得太高。
所以如果让我总结,我会说前端打字机效果真正要讲的是三件事:场景判断、调度分层、性能控制。demo 可以很简单,但如果放到真实业务里,尤其是 AI 流式输出里,就要把它当成一个小型的增量渲染系统来看。
如果面试官追问“为什么不用纯 CSS”
我会说纯 CSS 更适合静态文案,因为它不知道真实数据什么时候到,也不方便处理暂停、跳过、加速、流式追加这些行为。一旦内容是动态的,尤其是接口边返回边显示,还是 JS 更可控。
如果面试官追问“AI 流式输出场景怎么做”
我会把它拆成两层。底层先接 SSE、fetch stream 或 WebSocket,把 chunk 收下来;上层再做打字机调度。也就是说,流式返回解决的是“数据怎么进来”,打字机解决的是“内容怎么展示出来”,两层不要混在一起。
如果面试官追问“性能怎么优化”
我不会每个字符都触发一次渲染,而是按词、按 token 或按 chunk 批量输出,必要时结合
requestAnimationFrame。另外长文本场景里,我会把打字中的轻渲染和结束后的完整渲染拆开,避免 Markdown 解析和代码高亮在每一帧都跑。
如果面试官追问“用户点击跳过怎么办”
这个比较简单,调度层直接把
cursor推到末尾,或者把队列一次性 flush 掉就行。所以我前面才会强调一定要把数据源、调度和展示拆开。只要拆开了,跳过、暂停、继续、加速这些都只是调度问题,不需要重写整个组件。
代码实现版
如果面试官想听更落地一点的实现,我一般会先给一个最小可用方案,再补工程化点。
最小可用方案就是维护两份数据:完整文本和当前展示文本。然后用一个指针控制输出到哪里,每次推进一点。
function createTypewriter(
text: string,
onUpdate: (value: string) => void,
speed = 40,
) {
let cursor = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = () => {
cursor += 1;
onUpdate(text.slice(0, cursor));
if (cursor < text.length) {
timer = setTimeout(tick, speed);
}
};
return {
start() {
if (!timer) tick();
},
stop() {
if (timer) clearTimeout(timer);
timer = null;
},
skip() {
if (timer) clearTimeout(timer);
cursor = text.length;
onUpdate(text);
timer = null;
},
};
}如果再往真实业务走,我会补四件事。
- 不按字符每次都更新,而是按词、按 token 或按 chunk 批量输出
- 调度层和数据层拆开,尤其是流式响应场景
- 长文本时用
requestAnimationFrame或节流,避免频繁重渲染 - Markdown / 代码块场景里区分轻渲染和最终渲染
如果是流式输出,我一般会再加一个队列,把服务端回来的 chunk 先缓存住,再由前端慢慢消费。
class TypewriterQueue {
private queue: string[] = [];
private visibleText = "";
constructor(private onUpdate: (value: string) => void) {}
push(chunk: string) {
this.queue.push(chunk);
}
flushOne() {
const chunk = this.queue.shift();
if (!chunk) return;
this.visibleText += chunk;
this.onUpdate(this.visibleText);
}
}所以代码层面我会总结成一句:最小实现就是“完整文本 + 指针 + 调度器”,工程化实现就是再加上队列、批量更新和渲染分层。
React 里怎么封装成 hook / 组件
如果面试官问我在 React 里怎么落地,我一般会说我会先封成一个 useTypewriter hook,再根据业务决定要不要包一层组件。这样逻辑和 UI 分开,复用会更舒服。
最自然的 hook 设计,大概会长这样:
type UseTypewriterOptions = {
text: string;
speed?: number;
autoStart?: boolean;
};
type UseTypewriterResult = {
visibleText: string;
isTyping: boolean;
start: () => void;
stop: () => void;
skip: () => void;
reset: () => void;
};一个简化实现可以这样写:
import { useEffect, useRef, useState } from "react";
export function useTypewriter({
text,
speed = 40,
autoStart = true,
}: UseTypewriterOptions): UseTypewriterResult {
const [visibleText, setVisibleText] = useState("");
const [isTyping, setIsTyping] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cursorRef = useRef(0);
const clear = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
};
const step = () => {
cursorRef.current += 1;
setVisibleText(text.slice(0, cursorRef.current));
if (cursorRef.current >= text.length) {
setIsTyping(false);
timerRef.current = null;
return;
}
timerRef.current = setTimeout(step, speed);
};
const start = () => {
if (isTyping || cursorRef.current >= text.length) return;
setIsTyping(true);
timerRef.current = setTimeout(step, speed);
};
const stop = () => {
clear();
setIsTyping(false);
};
const skip = () => {
clear();
cursorRef.current = text.length;
setVisibleText(text);
setIsTyping(false);
};
const reset = () => {
clear();
cursorRef.current = 0;
setVisibleText("");
setIsTyping(false);
};
useEffect(() => {
reset();
if (autoStart) start();
return clear;
}, [text]);
return { visibleText, isTyping, start, stop, skip, reset };
}如果业务里只是一个简单展示组件,我会再包一层:
type TypewriterTextProps = {
text: string;
speed?: number;
cursor?: boolean;
};
export function TypewriterText({
text,
speed,
cursor = true,
}: TypewriterTextProps) {
const { visibleText, isTyping } = useTypewriter({ text, speed });
return (
<span>
{visibleText}
{cursor && isTyping ? "|" : null}
</span>
);
}如果用口语化一点的方式回答,我会这样说:
在 React 里我不会把打字机逻辑直接写死在组件里,而会先抽成
useTypewriter,因为它本质上是一个节奏控制问题,不是一个视图问题。hook 负责管理visibleText、isTyping、start/stop/skip/reset这些状态和动作,组件层只负责渲染文本、光标和样式。这样后面你不管是普通文案、聊天消息,还是 AI 流式输出,都可以复用同一套逻辑。
最后一句收尾
所以前端打字机效果如果只是写 demo,用定时器就够了;但如果放到真实业务里,尤其是 AI 流式内容里,我会把它当成一个“增量渲染 + 调度控制”的问题来设计。