React函数中setState多次执行问题排查(已更新)
问题概述
我有一个包含meals的Context,meals在MealDetails组件中使用。该组件会渲染多个Food组件组成的列表:点击Food组件的X按钮会移除对应食物,否则跳转到FoodDetails页面。
点击按钮时,deleteFood函数会执行,通过setState重新设置meals状态来移除食物,但问题是这个函数里的setState不是执行一次,而是在当前组件中执行两次;在另外两个组件测试时,分别出现执行一次和四次的情况。
更新内容
我的addFood函数也有类似问题,这个函数在另一个组件中调用,用来向两个不同Context的状态中添加Food,但两个Context里添加的数值都翻倍了。我发现浏览器控制台里,setState内部的console.log会通过react_devtools_backend.js打印第二次,而且只有这两个有问题的函数会出现这种情况。
更新2
我在MealsDetails组件中渲染Food组件,而这个组件用到了MealsContext的meals,这会不会是问题根源?
更新3
补充说明:MealsContext里还有其他修改状态的函数,其中changeFoodWeight和addFood在同一个组件中调用,但没有异常,而且react_devtools_backend.js也没有相关日志。我附上了这个函数的代码。
MealsDetails.tsx
const MealDetails = () => { const navigate = useNavigate(); let { id } = useParams(); const { meals } = useMeals(); const style = { "--percentage": "75%" } as React.CSSProperties; return ( <div className="MealDetails"> <header className="BarcodeScannerHeader"> <ArrowBackIcon onClick={() => { navigate("/"); }} sx={{ fontSize: 35 }} ></ArrowBackIcon> <div className="HeaderText">{meals.meals[Number(id)].name}</div> </header> <div className="MealDetailsContent"> <span className="label">{meals.meals[Number(id)].calories} ate</span> <hr className="SolidMealDetails"></hr> {meals.meals[Number(id)] != null ? meals.meals[Number(id)].food.map((food: any, index: number) => <Food key={index} food={food}/>) : "Not loading"} </div> <Link className="Link" to={`/AddFood/${id!}`}> <div className="MealDetailsAddFoodButton"> <img className="SVG" id="SVG" src={SVG} alt="+" /> </div> </Link> </div> ); };
MealsContext.tsx
let initialState: MealsType = { calories: 0, carbs: 0, meals: [ { calories: 0, carbs: 0, food: [] } ], }; const MealsProvider = ({ children }: any) => { const [meals, setMeals] = useState<MealsType>(initialState); const addFood = async ( id: number, addedFood: FoodType, selectedLocation: string, date: Date ) => { //Removed the fetch for readability console.log("func") setMeals((prevMeals) => { console.log("state"); const updatedMeals = prevMeals; let foodExists = false; updatedMeals.calories += addedFood.kcal; updatedMeals.carbs += addedFood.carbs; updatedMeals.meals[id].calories += addedFood.kcal; updatedMeals.meals[id].carbs += addedFood.carbs; updatedMeals.meals[id].food.forEach((food : FoodType) => { if(food.code === addedFood.code){ food.kcal += addedFood.kcal; food.carbs += addedFood.carbs; foodExists = true; } }) if(!foodExists){ updatedMeals.meals[id].food.push(addedFood); } return {...prevMeals,updatedMeals}; }); setUser(prevUser => { const updatedStorage = prevUser.storage; updatedStorage.map((storage : any) => { if(storage.location === selectedLocation){ storage.storedFood.map((storedFood : any) => { if(storedFood.code === addedFood.code){ storedFood.weight -= addedFood.weight; } }) } }) return {...prevUser, updatedStorage}; }) }; const changeFoodWeight = async ( id: number, foodDiff: any, selectedLocation: string, date: Date, newWeight: number ) => { //Removed the fetch for readability console.log("func"); setMeals((prevMeals) => { console.log("state"); const updatedMeals = prevMeals; updatedMeals.calories += foodDiff.kcalDiff; updatedMeals.carbs += foodDiff.carbsDiff; updatedMeals.meals[id].calories += foodDiff.kcalDiff; updatedMeals.meals[id].carbs += foodDiff.carbsDiff; updatedMeals.meals[id].food.forEach((food) => { if (food.code === foodDiff.code) { food.kcal += foodDiff.kcalDiff; food.carbs += foodDiff.carbsDiff; } }); return {...prevMeals,updatedMeals}; }); setUser(prevUser => { const updatedStorage = prevUser.storage; updatedStorage.map((storage : any) => { if(storage.location === selectedLocation){ storage.storedFood.map((storedFood : any) => { if(storedFood.code === foodDiff.code){ storedFood.weight -= foodDiff.weightDiff; } }) } }) return {...prevUser, updatedStorage}; }) }; const deleteFood = (id: number, deletedFood: FoodType) => { setMeals((prevMeals) => { const updatedMeals = prevMeals; updatedMeals.calories -= deletedFood.kcal; updatedMeals.carbs -= deletedFood.carbs; updatedMeals.meals[id].calories -= deletedFood.kcal; updatedMeals.meals[id].carbs -= deletedFood.carbs; for(let i = 0; i < updatedMeals.meals[id].food.length; i++){ if(updatedMeals.meals[id].food[i].code === deletedFood.code){ updatedMeals.meals[id].food.splice(i, 1); } } return {...prevMeals,updatedMeals}; }); }; return ( <MealsContext.Provider value={{ meals, deleteFood }} > {children} </MealsContext.Provider> ); }; const useMeals = () => useContext(MealsContext); export { MealsProvider, useMeals }; };
Food.tsx
const Food = ({ food }: any) => { const location = useLocation(); const {deleteFood} = useMeals(); return ( <Link className="FoodContent Link" to={`/FoodDetails/`} state= {{ food: food , location: location.pathname}} > <div className="Food"> <div className="FoodDetail"> <div className="FoodNameFoodDelete"> <div className="FoodName">{food.name}</div> <img className="FoodDelete SVG" onClick={(e : any) => {e.preventDefault(); deleteFood(parseInt(location.pathname[location.pathname.length - 1]), food)}} src={SVG}></img> </div> <div className="FoodGram-FoodKcal"> <div className="FoodBrand-FoodGram"> <div className="FoodBrand">{food.band + ","} </div> <div className="FoodGram"> {food.weight ? food.weight + " g" : 100 + " g"}{" "} </div> </div> <div className="FoodKcal">{food.kcal} kcal</div> </div> </div> <div className="FoodNutritions"> <div className="FoodNutrition"> <div className="FoodNutritionGrams">{Math.round(food.carbs*10)/10} g</div> <div className="FoodNutritionText">Carbs</div> </div> <div className="FoodNutrition"> <div className="FoodNutritionGrams">{Math.round(food.protein*10)/10} g</div> <div className="FoodNutritionText">Protein</div> </div> <div className="FoodNutrition"> <div className="FoodNutritionGrams">{Math.round(food.fat*10)/10} g</div> <div className="FoodNutritionText">Fat</div> </div> </div> </div> </Link> ); };
核心问题1:直接修改React状态的引用值
你的addFood、deleteFood函数里,都直接引用并修改了prevMeals(React状态的前值):
const updatedMeals = prevMeals; // 直接引用原状态对象,而非创建新副本
React状态是不可变的,直接修改prevState会破坏状态更新的不可变性原则,导致React无法正确追踪状态变化。加上开发环境默认开启的StrictMode会重复执行setState的更新函数来检测副作用,就会出现状态修改翻倍、执行多次的情况。
changeFoodWeight同样存在这个问题,只是当前场景未触发明显bug,本质仍不安全。
核心问题2:StrictMode的开发环境检测
React StrictMode在开发环境下会重复执行状态更新函数、useEffect回调等,目的是提前发现副作用问题。如果状态更新函数存在直接修改原状态的操作,就会被放大,出现多次执行的现象。
修复步骤
1. 深拷贝状态对象,保证不可变性
修改所有状态更新函数,对prevState进行深拷贝,确保不修改原始状态:
修复后的addFood函数
const addFood = async ( id: number, addedFood: FoodType, selectedLocation: string, date: Date ) => { console.log("func") setMeals((prevMeals) => { console.log("state"); // 深拷贝整个状态对象 const updatedMeals = JSON.parse(JSON.stringify(prevMeals)); let foodExists = false; updatedMeals.calories += addedFood.kcal; updatedMeals.carbs += addedFood.carbs; updatedMeals.meals[id].calories += addedFood.kcal; updatedMeals.meals[id].carbs += addedFood.carbs; updatedMeals.meals[id].food.forEach((food : FoodType) => { if(food.code === addedFood.code){ food.kcal += addedFood.kcal; food.carbs += addedFood.carbs; foodExists = true; } }) if(!foodExists){ updatedMeals.meals[id].food.push(addedFood); } return updatedMeals; }); setUser(prevUser => { // 深拷贝storage对象 const updatedStorage = JSON.parse(JSON.stringify(prevUser.storage)); updatedStorage.map((storage : any) => { if(storage.location === selectedLocation){ storage.storedFood.map((storedFood : any) => { if(storedFood.code === addedFood.code){ storedFood.weight -= addedFood.weight; } }) } }) return {...prevUser, storage: updatedStorage}; }) };
修复后的deleteFood函数
const deleteFood = (id: number, deletedFood: FoodType) => { setMeals((prevMeals) => { // 深拷贝状态对象 const updatedMeals = JSON.parse(JSON.stringify(prevMeals)); updatedMeals.calories -= deletedFood.kcal; updatedMeals.carbs -= deletedFood.carbs; updatedMeals.meals[id].calories -= deletedFood.kcal; updatedMeals.meals[id].carbs -= deletedFood.carbs; // 用filter替代splice,更符合不可变原则 updatedMeals.meals[id].food = updatedMeals.meals[id].food.filter(food => food.code !== deletedFood.code); return updatedMeals; }); };
修复后的changeFoodWeight函数
const changeFoodWeight = async ( id: number, foodDiff: any, selectedLocation: string, date: Date, newWeight: number ) => { console.log("func"); setMeals((prevMeals) => { console.log("state"); const updatedMeals = JSON.parse(JSON.stringify(prevMeals)); updatedMeals.calories += foodDiff.kcalDiff; updatedMeals.carbs += foodDiff.carbsDiff; updatedMeals.meals[id].calories += foodDiff.kcalDiff; updatedMeals.meals[id].carbs += foodDiff.carbsDiff; updatedMeals.meals[id].food.forEach((food) => { if (food.code === foodDiff.code) { food.kcal += foodDiff.kcalDiff; food.carbs += foodDiff.carbsDiff; } }); return updatedMeals; }); setUser(prevUser => { const updatedStorage = JSON.parse(JSON.stringify(prevUser.storage)); updatedStorage.map((storage : any) => { if(storage.location === selectedLocation){ storage.storedFood.map((storedFood : any) => { if(storedFood.code === foodDiff.code){ storedFood.weight -= foodDiff.weightDiff; } }) } }) return {...prevUser, storage: updatedStorage}; }) };
2. 优化组件key值(推荐)
MealDetails中渲染Food组件时用index作为key,当数组元素删除时会导致React复用错误组件,建议改用食物的唯一标识:
meals.meals[Number(id)].food.map((food: any) => <Food key={food.code} food={food}/>)
3. 关于StrictMode(可选)
开发环境的StrictMode是用来检测潜在问题的,生产环境不会有重复执行的情况,不建议移除。如果想临时屏蔽日志,可暂时去掉根组件的<StrictMode>包裹。
内容的提问来源于stack exchange,提问作者Cinc-B

