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

React中.map函数重复渲染三次问题排查求助

问题描述

尝试渲染对象数组中的训练项,但内容重复渲染三次。理想状态是通过搜索栏过滤workouts数组获取指定项,但现在搜索栏和三个训练项都重复渲染了三次。怀疑是Home组件通过map传递props给WorkoutDetails组件,而后者又用map做搜索过滤导致的问题,已经尝试在filteredItems.map里指定索引,但还是重复三次,推测问题出在props传递环节。

相关代码

Home.js

import { useEffect } from "react";
import { useWorkoutsContext } from "../hooks/useWorkoutsContext";

//components
import WorkoutDetails from "../components/WorkoutDetails";
import WorkoutForm from "../components/WorkoutForm";

const Home = () => {
  const { workouts, dispatch } = useWorkoutsContext();

  useEffect(() => {
    const fetchWorkouts = async () => {
      const response = await fetch("/api/workouts");
      const json = await response.json();

      if (response.ok) {
        dispatch({ type: "SET_WORKOUTS", payload: json });
      }
    };
    fetchWorkouts();
  }, [dispatch]);
  console.log(workouts, "HOMEPAGE");

  return (
    <div className="home">
      <div className="workouts">
        {workouts &&
          workouts.map((workout) => (
            <WorkoutDetails key={workout.title} workout={workout} />
          ))}
      </div>

      <WorkoutForm />
    </div>
  );
};

export default Home;

WorkoutDetails.js

import { useWorkoutsContext } from "../hooks/useWorkoutsContext";
import { useState } from "react";
//date fns
import formatDistanceToNow from "date-fns/formatDistanceToNow";

const WorkoutDetails = ({ workout }) => {
  const { workouts, dispatch } = useWorkoutsContext();
  const [title, setTitle] = useState("");
  const [load, setLoad] = useState("");
  const [reps, setReps] = useState("");
  const [set, setSet] = useState("");
  const [error, setError] = useState(null);
  const [open, setOpen] = useState(false);

  const [query, setQuery] = useState("");

  const handleClick = async () => {
    const response = await fetch("/api/workouts/" + workout._id, {
      method: "DELETE",
    });
    const json = await response.json();

    if (response.ok) {
      dispatch({ type: "DELETE_WORKOUT", payload: json });
    }
  };

  const handleUpdate = async (e) => {
    e.preventDefault();

    const workouts = { title, load, reps, set };

    const response = await fetch("/api/workouts/" + workout._id, {
      method: "PATCH",
      body: JSON.stringify(workouts),
      headers: {
        "Content-Type": "application/json",
      },
    });

    const json = await response.json();

    if (!response.ok) {
      setError(json.error);
    }
    if (response.ok) {
      setError(null);
      dispatch({ type: "UPDATE_WORKOUT", payload: json });
    }
    window.location.reload();
  };

  const getFilteredItems = (query, workouts) => {
    if (!query) {
      return workouts;
    }
    return workouts.filter((workout) => workout.title.includes(query));
  };
  

  const filteredItems = getFilteredItems(query, workouts);
  console.log(filteredItems, "FILTERED ITEMS");

  return (
    <div>
      <div>
        <label>Search</label>
        <input type="text" onChange={(e) => setQuery(e.target.value)} />
      </div>
      <div className="workout-details">
        <div>
          {filteredItems.map((workout) => (
            <div key={workout._id}>
              <h4>{workout.title}</h4>
              <p>
                <strong>Load (kg): </strong>
                {workout.load}
              </p>
              <p>
                <strong>Number of reps: </strong>
                {workout.reps}
              </p>
              <p>
                <strong>Number of sets: </strong>
                {workout.set}
              </p>
            </div>
          ))}
          <p>
            {formatDistanceToNow(new Date(workout.createdAt), {
              addSuffix: true,
            })}
          </p>
        </div>
        <button className="material-symbols-outlined" onClick={handleClick}>
          delete
        </button>
        <div>
          {!open ? (
            <div>
              <button
                className="material-symbols-outlined"
                onClick={() => setOpen(!false)}
              >
                update
              </button>
            </div>
          ) : (
            <div>
              <button
                className="material-symbols-outlined"
                onClick={() => setOpen(!true)}
              >
                update
              </button>
              <form className="create" onSubmit={handleUpdate}>
                <h3>UPDATE - {workout.title}</h3>

                <label>Excersize Title:</label>
                <input
                  type="text"
                  onChange={(e) => setTitle(e.target.value)}
                  value={title}
                />

                <label>Load (in kg):</label>
                <input
                  type="number"
                  onChange={(e) => setLoad(e.target.value)}
                  value={load}
                />

                <label>Number of Reps:</label>
                <input
                  type="number"
                  onChange={(e) => setReps(e.target.value)}
                  value={reps}
                />

                <label>Number of Sets:</label>
                <input
                  type="number"
                  onChange={(e) => setSet(e.target.value)}
                  value={set}
                />

                <button>update</button>
                {error && <div className="error">{error}</div>}
              </form>
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

export default WorkoutDetails;
问题原因与解决方法

核心问题

你现在的代码存在职责混淆:

  1. Home组件通过workouts.map为每一条训练项渲染一个WorkoutDetails组件,训练数据有多少条,就会生成多少个WorkoutDetails实例。
  2. 每个WorkoutDetails组件内部又通过useWorkoutsContext拿到完整的workouts数组,再次执行filteredItems.map渲染所有训练项。

如果workouts数组有3条数据,就会生成3个WorkoutDetails组件,每个组件又渲染3条训练项,最终出现3×3=9条重复内容(你看到的三次重复是每个组件渲染的3条叠在一起),同时搜索栏也会被渲染3次。

修正方案

1. 拆分组件职责

  • 搜索功能:把搜索栏和过滤逻辑移到Home组件中,由Home负责全局搜索过滤,再将过滤后的结果传递给子组件渲染。
  • WorkoutDetails:只负责渲染单条训练项的详情、删除和更新功能,不再处理整个数组的渲染。

2. 修改后代码

Home.js 修改版

import { useEffect, useState } from "react";
import { useWorkoutsContext } from "../hooks/useWorkoutsContext";

//components
import WorkoutDetails from "../components/WorkoutDetails";
import WorkoutForm from "../components/WorkoutForm";

const Home = () => {
  const { workouts, dispatch } = useWorkoutsContext();
  const [query, setQuery] = useState("");

  useEffect(() => {
    const fetchWorkouts = async () => {
      const response = await fetch("/api/workouts");
      const json = await response.json();

      if (response.ok) {
        dispatch({ type: "SET_WORKOUTS", payload: json });
      }
    };
    fetchWorkouts();
  }, [dispatch]);

  // 全局过滤逻辑
  const getFilteredItems = (query, workouts) => {
    if (!query || !workouts) return workouts;
    return workouts.filter((workout) => workout.title.includes(query));
  };

  const filteredItems = getFilteredItems(query, workouts);

  return (
    <div className="home">
      {/* 全局搜索栏 */}
      <div className="search-bar">
        <label>Search</label>
        <input type="text" onChange={(e) => setQuery(e.target.value)} value={query} />
      </div>
      <div className="workouts">
        {filteredItems &&
          filteredItems.map((workout) => (
            <WorkoutDetails key={workout._id} workout={workout} />
          ))}
      </div>

      <WorkoutForm />
    </div>
  );
};

export default Home;

WorkoutDetails 修改版

import { useWorkoutsContext } from "../hooks/useWorkoutsContext";
import { useState } from "react";
//date fns
import formatDistanceToNow from "date-fns/formatDistanceToNow";

const WorkoutDetails = ({ workout }) => {
  const { dispatch } = useWorkoutsContext();
  const [title, setTitle] = useState(workout.title);
  const [load, setLoad] = useState(workout.load.toString());
  const [reps, setReps] = useState(workout.reps.toString());
  const [set, setSet] = useState(workout.set.toString());
  const [error, setError] = useState(null);
  const [open, setOpen] = useState(false);

  const handleClick = async () => {
    const response = await fetch("/api/workouts/" + workout._id, {
      method: "DELETE",
    });
    const json = await response.json();

    if (response.ok) {
      dispatch({ type: "DELETE_WORKOUT", payload: json });
    }
  };

  const handleUpdate = async (e) => {
    e.preventDefault();

    const updatedWorkout = { 
      title, 
      load: parseInt(load), 
      reps: parseInt(reps), 
      set: parseInt(set) 
    };

    const response = await fetch("/api/workouts/" + workout._id, {
      method: "PATCH",
      body: JSON.stringify(updatedWorkout),
      headers: {
        "Content-Type": "application/json",
      },
    });

    const json = await response.json();

    if (!response.ok) {
      setError(json.error);
    }
    if (response.ok) {
      setError(null);
      dispatch({ type: "UPDATE_WORKOUT", payload: json });
      setOpen(false); // 更新后关闭表单
    }
    // 移除window.location.reload,改用上下文更新触发重新渲染
  };

  return (
    <div className="workout-details">
      <div>
        <h4>{workout.title}</h4>
        <p>
          <strong>Load (kg): </strong>
          {workout.load}
        </p>
        <p>
          <strong>Number of reps: </strong>
          {workout.reps}
        </p>
        <p>
          <strong>Number of sets: </strong>
          {workout.set}
        </p>
        <p>
          {formatDistanceToNow(new Date(workout.createdAt), {
            addSuffix: true,
          })}
        </p>
      </div>
      <button className="material-symbols-outlined" onClick={handleClick}>
        delete
      </button>
      <div>
        {!open ? (
          <div>
            <button
              className="material-symbols-outlined"
              onClick={() => setOpen(true)}
            >
              update
            </button>
          </div>
        ) : (
          <div>
            <button
              className="material-symbols-outlined"
              onClick={() => setOpen(false)}
            >
              update
            </button>
            <form className="create" onSubmit={handleUpdate}>
              <h3>UPDATE - {workout.title}</h3>

              <label>Exercise Title:</label>
              <input
                type="text"
                onChange={(e) => setTitle(e.target.value)}
                value={title}
              />

              <label>Load (in kg):</label>
              <input
                type="number"
                onChange={(e) => setLoad(e.target.value)}
                value={load}
              />

              <label>Number of Reps:</label>
              <input
                type="number"
                onChange={(e) => setReps(e.target.value)}
                value={reps}
              />

              <label>Number of Sets:</label>
              <input
                type="number"
                onChange={(e) => setSet(e.target.value)}
                value={set}
              />

              <button>update</button>
              {error && <div className="error">{error}</div>}
            </form>
          </div>
        )}
      </div>
    </div>
  );
};

export default WorkoutDetails;

额外优化点

  • 把WorkoutDetails的初始状态设置为当前训练项的数值,避免更新时输入框为空。
  • 移除window.location.reload(),改用上下文的dispatch更新数据,让React自动重新渲染,提升用户体验。
  • Home组件中key改用workout._id,比title更唯一,避免重复标题导致的key冲突。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.29 12:27:03