前端打字机效果如何实现

一句话先定性

我不会把打字机效果讲成“setInterval 每次截一个字符”,而会讲成“把文本来源、输出节奏和展示层拆开”。


30 秒版本

如果只是静态文案动画,纯 CSS 也能做,比如 steps() 配合宽度裁剪和光标闪烁。
但如果是业务里真正常见的 AI 输出、聊天消息或者 Markdown 渐进渲染,我一般会用 JS 去做。核心思路是把完整文本、当前已显示文本、输出指针和调度器分开管理,然后按字符、按词或者按 chunk 逐步吐出来。这样后面要做暂停、跳过、加速、自动滚动和流式输出都比较好接。

1 分钟版本

我会先分两类场景。第一类是纯展示型打字机,比如首页 slogan,这种可以直接用 CSS 动画解决;第二类是动态内容型打字机,比如 AI 回复、聊天消息、代码解释,这种我会用 JavaScript 做一个小调度器。

具体实现上,我一般会维护几份状态:fullText 表示完整文本,visibleText 表示当前展示文本,cursor 表示已经输出到哪里,status 表示是 typing、paused 还是 done。然后通过 setTimeoutrequestAnimationFrame 或者定时批量 flush,让文本按节奏逐步进入界面。

如果再工程化一点,我会把它拆成三层:数据源层负责拿完整文本或流式 chunk;调度层负责决定什么时候吐多少;展示层只负责渲染。这样打字机效果就不是一个小技巧,而是一套可扩展的展示机制。

2 到 3 分钟版本

打字机效果这个题,我一般不会只回答“用 setInterval 每次 slice 一下字符串”,因为那样太像 demo 方案。真实业务里,打字机效果通常不只是为了好看,而是为了把内容的生成过程、阅读节奏和反馈感做出来。尤其在 AI 聊天、流式输出这种场景里,它本质上更像一种增量渲染策略。

所以我会先分场景。静态文案型场景,比如 banner 文案、标题动画,用 CSS 就够了。典型做法是固定容器宽度,配合 overflow: hiddenwhite-space: nowrapborder-right 这种光标,再用 steps(n) 模拟逐字出现。这种方案简单、性能也好。

但如果是动态内容,尤其是 AI 回复这种边生成边显示的内容,我会优先用 JavaScript。这里我更关心三层。第一层是数据源层,负责接完整文本或者流式 chunk;第二层是调度层,控制输出节奏,比如一次吐一个字符、一个词,还是一个 chunk;第三层是展示层,只负责渲染 visibleText

具体状态上,我通常会维护 fullTextvisibleTextcursorqueuestatus。如果是一次性文本,就让 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 负责管理 visibleTextisTypingstart/stop/skip/reset 这些状态和动作,组件层只负责渲染文本、光标和样式。这样后面你不管是普通文案、聊天消息,还是 AI 流式输出,都可以复用同一套逻辑。

最后一句收尾

所以前端打字机效果如果只是写 demo,用定时器就够了;但如果放到真实业务里,尤其是 AI 流式内容里,我会把它当成一个“增量渲染 + 调度控制”的问题来设计。