React中父组件网络请求导致Input的onChange事件不触发
我开发了ImagePicker.js组件,用于选择图片并发起网络请求,单独测试时功能正常。但将其嵌入Profile.js后,input组件onChange绑定的onFileChange方法完全不触发。
Profile.js中包含多个基于useQuery的网络请求,排查发现只要保留网络请求代码就会出现该问题。怀疑打开文件选择器时页面重渲染触发网络请求,导致文件选择器与组件的关联断开,但通过useEffect确认组件并未卸载。
相关代码
ImagePicker.js:
import React, { useEffect, useRef } from 'react' import ConfirmModal from './ConfirmModal' import { useState } from 'react'; import { useMutation } from 'react-query'; import { Modal, Spinner, Button } from 'react-bootstrap'; import { useApi } from '../contexts/ApiProvider'; export default function ImagePicker({ showing, setShowing, }) { const [title, setTitle] = useState("Choose a file") const hiddenFileInput = useRef(null); const api = useApi(); const [imgFile, setImgFile] = useState(null); useEffect(() => { if (imgFile == null) { setTitle("No file chosen") } else { console.log("changing title") setTitle(`File ${imgFile.name} chosen`) } }, [imgFile]) useEffect(() => { console.log("ImagePicker mounted"); return () => { console.log("ImagePicker unmounting"); }; }, []); const onFileChange = (e) => { console.log("file changed2") e.preventDefault(); console.log("file changed") setImgFile(e.target.files[0]) } const sendImageMutation = useMutation({ mutationFn: (value) => { const formData = new FormData(); formData.append("file", value) return api.post("/user/pfp/", formData) }, }) const openFileChooser = (e) => { e.preventDefault() console.log("opening file chooser") hiddenFileInput.current.click(); } const ModalBody = () => sendImageMutation.isLoading ? <Spinner /> : <div className='flex flex-col'> <button onClick={openFileChooser}> {title} </button> <input ref={hiddenFileInput} className='hidden' type='file' onChange={onFileChange} accept='image/*' id='fileInput' /> <p>{sendImageMutation.isError ? "Error submitting file" : ""}</p> <p>{sendImageMutation.isSuccess ? "Successfully sent request" : ""}</p> </div> const handleClose = () => { setShowing(false); } const handleConfirm = () => { sendImageMutation.mutate(imgFile); } useEffect(() => { if (sendImageMutation.isSuccess) { setShowing(false); } }, [sendImageMutation]) return ( <> <Modal show={showing} onHide={handleClose}> <Modal.Header> <Modal.Title>Select an image from your files.</Modal.Title> </Modal.Header> <Modal.Body><ModalBody /></Modal.Body> <Modal.Footer> <Button variant="secondary" onClick={handleClose}> Cancel </Button> <Button variant="outline-danger" onClick={handleConfirm}> Continue </Button> </Modal.Footer> </Modal> </> ) }
Profile.js:
import Stat from "../components/statGraphs/Stat"; import Graph from "../components/statGraphs/Graph"; import Histories from "../components/happinessHistory/Histories"; import DayPreview from "../components/statGraphs/DayPreview"; import { useState, useEffect } from "react"; import { useQuery } from "react-query"; import { useUser } from "../contexts/UserProvider"; import { useParams } from "react-router-dom"; import { Spinner, Button } from "react-bootstrap"; import { PrevWeekData, GetCountHappiness, } from "../components/happinessHistory/GetHappinessData"; import { useApi } from "../contexts/ApiProvider"; import { Link } from "react-router-dom"; import ImagePicker from "../components/ImagePicker"; export default function Profile() { const me = useUser(); const userID = useParams().userID; const today = new Date(); const todayString = today.toISOString().substring(0, 10); const api = useApi(); const [imgPickerShowing, setImgPickerShowing] = useState(false) const { isLoading: isLoadingU, data: user, error: errorU, refetch: refetchU, } = useQuery( "get user from user id", () => api.get("/user/" + userID).then((res) => res.data), { fetchPolicy: "cache-and-network" } ); const [pageLoading, setPageLoading] = useState(true); const { isLoading: isLoadingUG, data: dataUG, error: errorUG, refetch: refetchUG, } = useQuery("get user groups", () => api.get("/user/groups").then((res) => res.data) ); const [isLoadingH, dataH, errorH, refetchH] = PrevWeekData(true, userID); const [isLoadingC, dataC, errorC, refetchC] = GetCountHappiness(4, userID); useEffect(() => { const refetchAll = async () => { setPageLoading(true); console.log("refetching!!"); await refetchU(); await refetchH(); await refetchC(); await refetchUG(); setPageLoading(false); }; refetchAll(); }, [userID]); let userCreated = ""; if (user !== undefined && user.created !== null) { let forDa = new Date(user.created) .toLocaleDateString("sv") .substring(0, 10) .split("-"); userCreated = "Member since " + forDa[1] + "/" + forDa[2] + "/" + forDa[0].substring(2); } const [dShow, setDShow] = useState(false); return ( <> <div className="flex flex-wrap justify-center"> {isLoadingUG || isLoadingC || isLoadingH || isLoadingU || pageLoading ? ( <Spinner animation="border" /> ) : ( <> {errorUG || errorU || errorC || errorH ? ( <p className="text-md font-medium text-raisin-600 m-3"> Error: Could not load information. </p> ) : ( <> <ImagePicker showing={imgPickerShowing} setShowing={setImgPickerShowing} /> <div className="lg:w-3/4 sm:w-full"> <div> <p className="text-center text-4xl font-medium m-2 text-raisin-600"> Profile </p> </div> <div className="flex flex-wrap justify-center items-center"> <div className="flex flex-wrap justify-center min-w-[330px] max-w-[600px] min-h-[200px] mx-4 my-2 bg-cultured-50 rounded-xl shadow-lg space-y-2"> <div className="relative flex flex-wrap justify-center w-full bg-buff-300 rounded-t-xl"> <div className="absolute -sm:absolute sm:relative left-4 top-4 sm:flex items-center md:px-4 md:w-1/3 sm:mx-4"> <img className="mb-4 justify-center max-w-[65px] max-h-[65px] md:min-h-[125px] md:max-h-[125px] md:min-w-[125px] md:max-w-[125px] block mx-auto rounded-full sm:mx-0 sm:shrink-0" src={user.profile_picture} alt="profile" onClick={() => { setImgPickerShowing(true) }} /> </div> <div className="w-full justify-end sm:w-1/2 py-2 px-4"> <p className="text-center text-2xl font-medium m-2 text-raisin-600"> {user.username} </p> <p className="text-center text-raisin-600"> {userCreated} </p> <div className="flex flex-wrap justify-center items-center @container"> <div className="justify-center"> {parseInt(userID) === me.user.user.id ? ( <> <Link to="/groups" className="no-underline"> <p className="text-center text-raisin-600 text-md font-medium m-2 sm:w-full"> Groups: {dataUG.length} </p> </Link> </> ) : ( <></> )} </div> </div> </div> </div> <div className="flex m-4"> {dataC.length === 0 ? ( <p className="text-md font-medium text-raisin-600 m-3"> No happiness data available. </p> ) : ( <> <div className="space-y-2 px-2 md:px-4"> {todayString.substring(0, 10) === dataC[0].timestamp ? ( <p className="text-md text-raisin-600 font-semibold text-center"> Today's Happiness </p> ) : ( <p className="text-md text-raisin-600 font-semibold text-center"> Recent Happiness </p> )} <p className="text-2xl text-rhythm-500 font-medium text-center"> {dataC[0].value.toFixed(1)} </p> </div> {dataC[0].comment ? ( <> <DayPreview open={dShow} setOpen={setDShow} data={[dataC[0]]} users={[user]} /> <div className="space-y-2 px-2 md:px-4 md:mx-4" onClick={() => setDShow(true)} > <p className="text-md text-raisin-600 font-semibold text-center"> Comment </p> <p className="line-clamp-3 -md:line-clamp-3 text-md text-rhythm-500 font-medium text-center"> {dataC[0].comment} </p> </div> </> ) : ( <></> )} </> )} </div> </div> <div className="flex flex-wrap justify-center items-center"> {dataH.length === 0 ? ( <p className="text-xl font-medium text-raisin-600 m-3"> Data not available for selected period. </p> ) : ( <> <p className="mt-4 sm:w-full text-center font-medium text-2xl text-raisin-600"> Weekly Statistics </p> <div className="flex flex-wrap justify-center items-center mx-2 md:ml-4 max-w-[400px] max-h-[400px]"> <Graph data={dataH} users={[user]} time="Weekly" /> </div> <div className="flex flex-wrap justify-center items-center md:max-w-[205px] sm:max-w-[400px] mr-2"> <Stat data={dataH.map((f) => f.value)} key={0} val={0} /> <Stat data={dataH.map((f) => f.value)} key={1} val={1} /> </div> </> )} </div> </div> </div> <div className="justify-center lg:w-1/4 w-full max-w-[550px]"> <div> <p className="text-center text-4xl font-medium m-2 text-raisin-600"> History </p> </div> {dataC.length === 0 ? ( <p className="text-md font-medium text-raisin-600 m-3"> Data not available for selected period. </p> ) : ( <> <Histories dataList={dataC} userList={[user]} /> <div className="m-3 flex justify-center"> <Link to={"/history/" + userID}> <Button variant="outline-secondary">show all</Button> </Link> </div> </> )} </div> </> )} </> )} </div> </> ); }
已尝试的排查
- 注释
Profile.js中的网络请求代码后,ImagePicker功能恢复正常; - 移除
ImagePicker中input的hidden样式,问题仍存在; - 通过
useEffect日志确认ImagePicker组件并未卸载。
解决方案
问题核心是**ModalBody作为内部函数组件,每次父组件重渲染时都会被重新创建,导致文件选择器DOM节点被替换,丢失事件绑定**。Profile中useQuery的频繁重渲染放大了这个问题。
修复步骤:
取消
ModalBody的独立函数定义,直接内联到Modal.Body中
修改ImagePicker.js的返回部分,把原ModalBody的内容直接写在<Modal.Body>里,避免每次重渲染都生成新组件:// 移除原ModalBody函数,替换成以下内容 return ( <> <Modal show={showing} onHide={handleClose}> <Modal.Header> <Modal.Title>Select an image from your files.</Modal.Title> </Modal.Header> <Modal.Body> {sendImageMutation.isLoading ? ( <Spinner /> ) : ( <div className='flex flex-col'> <button onClick={openFileChooser}> {title} </button> <input ref={hiddenFileInput} className='hidden' type='file' onChange={onFileChange} accept='image/*' id='fileInput' /> <p>{sendImageMutation.isError ? "Error submitting file" : ""}</p> <p>{sendImageMutation.isSuccess ? "Successfully sent request" : ""}</p> </div> )} </Modal.Body> <Modal.Footer> <Button variant="secondary" onClick={handleClose}> Cancel </Button> <Button variant="outline-danger" onClick={handleConfirm}> Continue </Button> </Modal.Footer> </Modal> </> )用
useCallback稳定事件处理函数
给onFileChange和openFileChooser加上useCallback,避免每次重渲染生成新函数实例:import { useCallback } from 'react'; // 记得导入useCallback const onFileChange = useCallback((e) => { console.log("file changed2") e.preventDefault(); console.log("file changed") setImgFile(e.target.files[0]) }, []); const openFileChooser = useCallback((e) => { e.preventDefault() console.log("opening file chooser") hiddenFileInput.current.click(); }, []);优化Profile中的useQuery配置
把get user from user id的fetchPolicy从cache-and-network改为cache-first,减少不必要的网络请求和重渲染:const { isLoading: isLoadingU, data: user, error: errorU, refetch: refetchU, } = useQuery( "get user from user id", () => api.get("/user/" + userID).then((res) => res.data), { fetchPolicy: "cache-first" } // 修改此处 );
原理说明
当ModalBody作为内部函数存在时,每次ImagePicker重渲染(比如Profile的useQuery请求完成触发父组件重渲染),ModalBody都会被定义为全新的组件。React会

