React Strict Mode下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

