You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React Strict Mode下Text Typer组件双定时器异常问题求助

React 18严格模式下Text Typer组件定时器重复运行问题

我开发了一个Text Typer组件,实现每隔100ms逐个字符输出文本的效果。生产环境运行正常,但在开发环境里,不管怎么调整,都会出现两个定时器同时运行的情况,最终输出文本变成ADFHJLNPRTVXZ。

我知道这是React 18+严格模式的检测机制导致的,但这影响了组件功能,我需要组件在所有环境表现一致。我试过在useEffect开头加clearInterval终止第一个定时器,但没用。


TextTyper组件代码

import React, { Dispatch, useEffect, useRef, useState } from 'react';

export type TextTyperProps = {
  text: string,
  /** 别忘了用`useCallback`包裹,避免触发useEffect依赖更新 */
  onTextTyped?: Dispatch<void>,
  intervalTime?: number
};

export const TextTyper = ({ text, onTextTyped, intervalTime = 100 }: TextTyperProps) => {
  const [textTyped, setTextTyped] = useState('');
  const indexRef = useRef(0); // 用ref存储索引
  const intervalId = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {

    // useEffect触发时重置所有状态
    setTextTyped("");
    indexRef.current = 0;
    // 清理当前定时器,防止文本还没输完就更新,或者开发模式下useEffect执行两次
    if (intervalId.current) {
      clearInterval(intervalId.current);
    }

    // 选择并输入下一个字符
    const typeNextChar = () => {
      if (indexRef.current < text.length) {
        setTextTyped((currentText) => {
          const nextChar = text.charAt(indexRef.current);
          console.log(`${indexRef.current}/${text.length} => ${currentText}[${nextChar}] `)
          indexRef.current++;
          return currentText + nextChar;
        });
      } else {
        // 文本输入完成后停止定时器
        if (intervalId.current) {
          clearInterval(intervalId.current);
        }
        // 触发回调
        onTextTyped?.();
      }
    };

    // 启动新定时器
    intervalId.current = setInterval(typeNextChar, intervalTime);

    // 卸载时清理定时器
    return () => {
      if (intervalId.current) {
        clearInterval(intervalId.current);
      }
    };
  }, [text, onTextTyped, intervalTime]);

  return <div>{textTyped}</div>;
};

测试组件代码

"use client"

import { useCallback, useState } from "react";
import { TextTyper } from "~/components/ui/TextTyper";

export default function TestTextTyper() {

  const [text, setText] = useState<string>("");
  const [isDone, setDone] = useState<boolean>(false);
  // 额外功能:如果请求相同文本,强制重新加载
  const [lastUpdate, setLastUpdate] = useState<number>(0);

  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
  const reversed = alphabet.split('').reverse().join('');
  const handleClick = (newText: string) => {
    setDone(false);
    setText(newText);
    setLastUpdate(Date.now())
  }
  const handleTextTyped = useCallback(() => {
    console.log("文本输入完成");
    setDone(true)
  }, []);

  return (
    <div>
      <button onClick={() => { handleClick(alphabet) }}>{alphabet}</button>
      <button onClick={() => { handleClick(reversed) }}>{reversed}</button>
      <TextTyper key={lastUpdate} text={text} onTextTyped={handleTextTyped} />
      {isDone && "完成"}
    </div>
  );
}

点击第一个按钮后的日志输出

TextTyper.tsx:30 0/26 => [A] 
TextTyper.tsx:30 1/26 => [B] 
TextTyper.tsx:30 2/26 => A[C] 
TextTyper.tsx:30 3/26 => A[D] 
TextTyper.tsx:30 4/26 => AD[E] 
TextTyper.tsx:30 5/26 => AD[F] 
TextTyper.tsx:30 6/26 => ADF[G] 
TextTyper.tsx:30 7/26 => ADF[H] 
TextTyper.tsx:30 8/26 => ADFH[I] 
TextTyper.tsx:30 9/26 => ADFH[J] 
TextTyper.tsx:30 10/26 => ADFHJ[K] 
TextTyper.tsx:30 11/26 => ADFHJ[L] 
TextTyper.tsx:30 12/26 => ADFHJL[M] 
TextTyper.tsx:30 13/26 => ADFHJL[N] 
TextTyper.tsx:30 14/26 => ADFHJLN[O] 
TextTyper.tsx:30 15/26 => ADFHJLN[P] 
TextTyper.tsx:30 16/26 => ADFHJLNP[Q] 
TextTyper.tsx:30 17/26 => ADFHJLNP[R] 
TextTyper.tsx:30 18/26 => ADFHJLNPR[S] 
TextTyper.tsx:30 19/26 => ADFHJLNPR[T] 
TextTyper.tsx:30 20/26 => ADFHJLNPRT[U] 
TextTyper.tsx:30 21/26 => ADFHJLNPRT[V] 
TextTyper.tsx:30 22/26 => ADFHJLNPRTV[W] 
TextTyper.tsx:30 23/26 => ADFHJLNPRTV[X] 
TextTyper.tsx:30 24/26 => ADFHJLNPRTVX[Y] 
TextTyper.tsx:30 25/26 => ADFHJLNPRTVX[Z] 
page.tsx:24 文本输入完成

解决方案

React 18严格模式会在开发环境中两次执行挂载逻辑(包括useEffect),然后立即执行清理函数,以此检测组件是否有潜在的内存泄漏或不一致问题。你的代码里,第一次挂载创建的定时器在清理时被clearInterval,但因为setInterval的回调可能已经进入事件队列,加上indexRef是共享的,导致两个定时器实例互相干扰。

方案一:标记过滤首次执行

用useRef标记组件是否已完成有效挂载,过滤严格模式下的首次无效执行:

import React, { Dispatch, useEffect, useRef, useState } from 'react';

export type TextTyperProps = {
  text: string,
  /** 别忘了用`useCallback`包裹,避免触发useEffect依赖更新 */
  onTextTyped?: Dispatch<void>,
  intervalTime?: number
};

export const TextTyper = ({ text, onTextTyped, intervalTime = 100 }: TextTyperProps) => {
  const [textTyped, setTextTyped] = useState('');
  const indexRef = useRef(0);
  const intervalId = useRef<NodeJS.Timeout | null>(null);
  const isMounted = useRef(false); // 标记是否已完成首次有效挂载

  useEffect(() => {
    // 严格模式下第一次执行直接跳过,只标记状态
    if (!isMounted.current) {
      isMounted.current = true;
      return;
    }

    // 重置状态与索引
    setTextTyped("");
    indexRef.current = 0;

    // 清理旧定时器
    if (intervalId.current) {
      clearInterval(intervalId.current);
      intervalId.current = null;
    }

    const typeNextChar = () => {
      if (indexRef.current < text.length) {
        const nextChar = text.charAt(indexRef.current);
        setTextTyped(prev => prev + nextChar);
        indexRef.current++;
      } else {
        if (intervalId.current) {
          clearInterval(intervalId.current);
          intervalId.current = null;
        }
        onTextTyped?.();
      }
    };

    intervalId.current = setInterval(typeNextChar, intervalTime);

    return () => {
      if (intervalId.current) {
        clearInterval(intervalId.current);
        intervalId.current = null;
      }
      // 清理时重置标记,确保下次重新挂载正常触发
      isMounted.current = false;
    };
  }, [text, onTextTyped, intervalTime]);

  return <div>{textTyped}</div>;
};

方案二:改用setTimeout替代setInterval

setInterval的回调会按固定间隔触发,即使前一个回调还没执行完成,改用setTimeout可以避免这个问题,同时更容易控制执行流程:

import React, { Dispatch, useEffect, useRef, useState } from 'react';

export type TextTyperProps = {
  text: string,
  /** 别忘了用`useCallback`包裹,避免触发useEffect依赖更新 */
  onTextTyped?: Dispatch<void>,
  intervalTime?: number
};

export const TextTyper = ({ text, onTextTyped, intervalTime = 100 }: TextTyperProps) => {
  const [textTyped, setTextTyped] = useState('');
  const indexRef = useRef(0);
  const timeoutId = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    setTextTyped("");
    indexRef.current = 0;

    if (timeoutId.current) {
      clearTimeout(timeoutId.current);
    }

    const typeNextChar = () => {
      if (indexRef.current < text.length) {
        const nextChar = text.charAt(indexRef.current);
        setTextTyped(prev => prev + nextChar);
        indexRef.current++;
        timeoutId.current = setTimeout(typeNextChar, intervalTime);
      } else {
        onTextTyped?.();
      }
    };

    timeoutId.current = setTimeout(typeNextChar, intervalTime);

    return () => {
      if (timeoutId.current) {
        clearTimeout(timeoutId.current);
      }
    };
  }, [text, onTextTyped, intervalTime]);

  return <div>{textTyped}</div>;
};

为什么之前的clearInterval没用?

在严格模式下,React会先执行第一次useEffect创建定时器,然后立即执行清理函数清除它,接着再执行第二次useEffect。但setInterval的回调可能已经被调度到事件队列,加上indexRef是同一个引用,第一次回调执行时修改了索引,导致第二次定时器的回调拿到的索引已经被修改,从而出现跳字符的情况。改用setTimeout或者用isMounted标记过滤第一次执行,就能避免这个问题。


内容的提问来源于stack exchange,提问作者Arthur

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 00:14:53