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

React数组修改后无法渲染问题求助

打字动画React组件渲染异常问题排查与修复

问题描述

开发了一个React组件,接收字符串数组,将字符包裹在span标签中并切换opacity实现打字动画。发现修改displayedText数组后无法正常渲染,交换displayedText[0]和[1]后,渲染情况反转。

原问题代码

"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useInterval } from "src/hooks/setInterval";

type Props = { text: string[]; aniLength: number };

export default function TypeIn({ text, aniLength }: Props) {
  const fullText = text.join("");
  const [numPrintedLetter, setNumPrintedLetter] = useState<number>(0);
  const [numPrintedStrings, setNumPrintedStrings] = useState<number>(0);
  const [delay, setDelay] = useState<number | null>(
    aniLength / fullText.length
  );

  const [displayedText, setDisplayedText] = useState<JSX.Element[][]>(() =>
    text.map((stringArr) =>
      stringArr.split("").map((letter, i) => (
        <span key={i} style={{ opacity: "0" }}>
          {letter}
        </span>
      ))
    )
  );

  useEffect(() => {
    if (text.length <= 0) throw Error("CAN NOT TYPE IN NOTHING");
  }, [text]);

  useInterval(() => {
    if (numPrintedLetter < text[numPrintedStrings].length) {
      // Here is the place where code per character is executed
      setDisplayedText((prevText) => {
        let curNewElement = (
          <span
            key={prevText[numPrintedStrings][numPrintedLetter].key}
            style={{ opacity: "1.0" }}
          >
            {prevText[numPrintedStrings][numPrintedLetter].props.children}
          </span>
        );
        prevText[numPrintedStrings][numPrintedLetter] = curNewElement;
        return prevText;
      });

      setNumPrintedLetter(numPrintedLetter + 1);
    } else {
      // subtract one to compensate for 0 index
      if (numPrintedStrings >= text.length - 1) {
        setDelay(null);
      } else {
        setNumPrintedLetter(0);
        setNumPrintedStrings(numPrintedStrings + 1);
      }
    }
  }, delay);

  useEffect(() => {
    console.log("displayedText", displayedText);
  }, [displayedText]);

  return (
    <>
      <h1 className={`text-stroke-2 pt-[max(76px,32vh)]`}>
        {displayedText[0]}

        <Link
          href="/contact"
          className="text-transparent dark:text-stroke-white text-stroke-black text-stroke-2
dark:hover:text-white hover:text-black
transition-all ease-in-out duration-300"
        >
          {displayedText[1]}
        </Link>
      </h1>
    </>
  );
}

问题根源

  1. 直接修改原状态数组:在setDisplayedText回调中直接修改prevText数组元素,React无法检测到引用变化,不会触发重新渲染。
  2. 将JSX元素存入状态:把UI元素存在state里是React反模式,状态应仅存储数据,UI渲染逻辑需放在组件render阶段。
  3. 闭包陷阱:useInterval中依赖的numPrintedLetter和numPrintedStrings可能因闭包无法获取最新值,导致逻辑异常。

修复方案

1. 重构状态,仅存储数据

将状态改为记录每个字符串已打印的字符数量,而非JSX元素:

const [printedCounts, setPrintedCounts] = useState<number[]>(
  () => new Array(text.length).fill(0)
);

2. 动态生成UI元素

渲染时根据printedCounts动态生成带opacity的span:

const renderTextSegment = (index: number) => {
  const str = text[index];
  const printedCount = printedCounts[index];
  return str.split("").map((letter, i) => (
    <span key={i} style={{ opacity: i < printedCount ? 1 : 0 }}>
      {letter}
    </span>
  ));
};

3. 修复状态更新逻辑

使用函数式更新确保获取最新状态,避免闭包问题:

useInterval(() => {
  setPrintedCounts((prevCounts) => {
    const newCounts = [...prevCounts];
    if (newCounts[numPrintedStrings] < text[numPrintedStrings].length) {
      newCounts[numPrintedStrings] += 1;
      setNumPrintedLetter((prev) => prev + 1);
    } else {
      if (numPrintedStrings >= text.length - 1) {
        setDelay(null);
      } else {
        setNumPrintedLetter(0);
        setNumPrintedStrings((prev) => prev + 1);
      }
    }
    return newCounts;
  });
}, delay);

修复后的完整代码

"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useInterval } from "src/hooks/setInterval";

type Props = { text: string[]; aniLength: number };

export default function TypeIn({ text, aniLength }: Props) {
  const fullText = text.join("");
  const [numPrintedLetter, setNumPrintedLetter] = useState<number>(0);
  const [numPrintedStrings, setNumPrintedStrings] = useState<number>(0);
  const [delay, setDelay] = useState<number | null>(
    aniLength / fullText.length
  );

  // 仅存储每个字符串已打印的字符数量
  const [printedCounts, setPrintedCounts] = useState<number[]>(
    () => new Array(text.length).fill(0)
  );

  useEffect(() => {
    if (text.length <= 0) throw Error("CAN NOT TYPE IN NOTHING");
    // 当text或aniLength变化时重置状态
    setPrintedCounts(new Array(text.length).fill(0));
    setNumPrintedLetter(0);
    setNumPrintedStrings(0);
    setDelay(aniLength / text.join("").length);
  }, [text, aniLength]);

  useInterval(() => {
    setPrintedCounts((prevCounts) => {
      const newCounts = [...prevCounts];
      // 当前字符串还有未打印字符
      if (newCounts[numPrintedStrings] < text[numPrintedStrings].length) {
        newCounts[numPrintedStrings] += 1;
        setNumPrintedLetter((prev) => prev + 1);
      } else {
        // 所有字符串打印完成
        if (numPrintedStrings >= text.length - 1) {
          setDelay(null);
        } else {
          // 切换到下一个字符串
          setNumPrintedLetter(0);
          setNumPrintedStrings((prev) => prev + 1);
        }
      }
      return newCounts;
    });
  }, delay);

  // 根据打印进度动态生成UI
  const renderTextSegment = (index: number) => {
    const str = text[index];
    const printedCount = printedCounts[index];
    return str.split("").map((letter, i) => (
      <span key={i} style={{ opacity: i < printedCount ? 1 : 0 }}>
        {letter}
      </span>
    ));
  };

  return (
    <>
      <h1 className={`text-stroke-2 pt-[max(76px,32vh)]`}>
        {renderTextSegment(0)}
        <Link
          href="/contact"
          className="text-transparent dark:text-stroke-white text-stroke-black text-stroke-2
dark:hover:text-white hover:text-black
transition-all ease-in-out duration-300"
        >
          {renderTextSegment(1)}
        </Link>
      </h1>
    </>
  );
}

关键优化点

  • 状态与UI分离:状态仅存数据,UI由数据驱动,符合React设计理念。
  • 不可变更新:每次状态更新都创建新数组,确保React能检测到变化并触发渲染。
  • 避免闭包陷阱:使用函数式更新获取最新状态值,保证逻辑正确性。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.23 02:37:54