React应用更换图片源为AWS S3预签名URL后浏览器不刷新图片
问题描述
- React应用支持本地图片上传(后续将支持Facebook),本地图片通过Object URL展示
- 点击Save按钮时,向服务器请求预签名URL,将图片上传到S3后,截取预签名URL的基础地址(到.png/.jpg为止)替换原图片源
- 预期图片会自动从新源加载,但实际无反应,仅右键选择“加载图片”才能显示
- 已尝试缓存破折号、封装图片组件、onError fallback、设置唯一key等方法,均无效
代码结构说明
useEffect:根据图片数组是否有内容控制Save按钮显示inputFilePickerFunc:验证本地图片尺寸,通过后将图片添加到数组,用Object URL作为图片源setImgProfilePic:设置指定图片为头像deleteItem:删除图片savePics:计算需上传到S3的图片数量,获取预签名URL并上传,上传后替换图片源(问题出在此处)
完整代码
import Box from '@mui/material/Box' import * as React from 'react'; import { useState, useEffect } from "react"; import ReactDOM from "react-dom/client"; import '../../css/styles.css' import Typography from '@mui/material/Typography'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faRectangleXmark } from "@fortawesome/free-solid-svg-icons"; import { faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { faComputer } from "@fortawesome/free-solid-svg-icons"; import FacebookIcon from '@mui/icons-material/Facebook'; import Icon from '@mui/material/Icon'; import { useRef } from 'react'; import Button from '@mui/material/Button'; import { store } from '../appstate/store' import axios from 'axios'; import Tooltip from '@mui/material/Tooltip'; export default function PhotoPicker() { const inputRef = useRef(); const openFileDialog = (e) => { inputRef.current.value = null; inputRef.current.click() } const [presignedURL, setPresignedURL] = useState(''); const [imageArray, setImageArray] = useState([]); const [displaySaveCancelButton, setDisplaySaveCancelButton] = useState("none"); const [profilePic, setProfilePic] = useState(''); useEffect(() => { imageArray.length !== 0 ? setDisplaySaveCancelButton("") : setDisplaySaveCancelButton("none") }) const inputFilePickerFunc = (e) => { var file = e.target.files; var k = Object.keys(file); var objStore = []; k.forEach( (key) => { objStore.push(file[k[key]]); } ); // 验证是否为图片文件 let pattern = /image/ objStore.forEach((index) => { let text = index.type; let result = text.match(pattern); if (result == null) { console.log('invalid file'); objStore.splice(index, 1) } }) // 验证图片大小(不超过15MB) objStore.forEach((index) => { let imageSize = index.size; if (imageSize > 15728640) { console.log('file ' + index + ' image is bigger than 15 MB size limit.'); objStore.splice(index, 1) } }) // 验证图片尺寸(最小400x500) objStore.forEach( (item) => { var im = new Image(); im.src = URL.createObjectURL(item); im.onload = function () { if (im.width < 400 || im.height < 500) { objStore.splice(item, 1); console.log('dimensions should be minimum 400 x 500 of image ' + item.name) } else { setImageArray(prevState => { // 给每个图片项添加唯一ID,用于React key return [...prevState, { id: Date.now() + Math.random().toString(36).substr(2, 9), "imagesource": im.src, "profilepic": "no", "file": item, "inS3": "no" }] }) } } } ) } const setImgProfilePic = (e) => { setProfilePic(e.currentTarget.id); let imageArrayDump = [...imageArray]; for (let i = 0; i < imageArrayDump.length; i++) { if (imageArrayDump[i].profilepic == "yes") { imageArrayDump[i].profilepic = "no"; break; } } for (let k = 0; k < imageArrayDump.length; k++) { if (imageArrayDump[k].imagesource == e.currentTarget.id) { imageArrayDump[k].profilepic = "yes"; setImageArray([...imageArrayDump]); break; } } } function deleteItem(e, item) { e.stopPropagation(); let indexofItemtoBeDeleted; let dupArr = imageArray.slice(); for (let i = 0; i < dupArr.length; i++) { if (dupArr[i].imagesource == item) { indexofItemtoBeDeleted = i; // 释放Object URL URL.revokeObjectURL(dupArr[i].imagesource); break; } } dupArr.splice(indexofItemtoBeDeleted, 1); setImageArray([...dupArr]); } function savePics() { // 筛选需要上传到S3的图片 let presignedURLsRequired = []; for (let i = 0; i < imageArray.length; i++) { if (imageArray[i].inS3 == "no") { presignedURLsRequired.push(imageArray[i].file.type); } } axios.post('/presignedUploadURL', { "numberOfPresignedURLsRequired": presignedURLsRequired }).then( (res) => { let imageArrayDump = [...imageArray]; // 匹配预签名URL和对应图片 for (let i = 0; i < imageArrayDump.length; i++) { for (let k = 0; k < res.data.length; k++) { if (imageArrayDump[i].file.type == res.data[k].imageType && imageArrayDump[i].inS3 === "no") { imageArrayDump[i].presignedURL = res.data[k].presignedURL; res.data.splice(k, 1); break; } } } // 收集所有上传请求,等待全部完成 const uploadPromises = imageArrayDump .filter(item => item.inS3 === "no") .map(item => { return axios.put(item.presignedURL, item.file) .then(res => { if (res.status === 200) { item.inS3 = "yes"; // 释放Object URL URL.revokeObjectURL(item.imagesource); // 截取基础URL并添加缓存破击参数 let baseURL = item.presignedURL.split('?')[0]; item.imagesource = `${baseURL}?t=${Date.now()}`; delete item.presignedURL; } return item; }) .catch(err => { console.error('上传失败:', err); return item; }); }); // 等待所有上传完成后更新状态 Promise.all(uploadPromises).then(() => { setImageArray([...imageArrayDump]); }); } ).catch(err => console.error('获取预签名URL失败:', err)); } return ( <Box sx={{ border: 1, display: 'flex', flexWrap: 'wrap', width: '50%', margin: 'auto', minWidth: '570px' }}> <Box sx={{ display: 'flex', border: 1, borderColor: 'red', width: '100%', flexWrap: 'nowrap' }}> <Typography sx={{ border: 1, width: '100%', minHeight: '30px', textAlign: "center" }}> Upload pictures </Typography> <Icon onClick={() => alert("hi")} sx={{ border: 1, cursor: "pointer" }}> <FontAwesomeIcon icon={faRectangleXmark} /> </Icon> </Box> <Box sx={{ border: 1, width: '49.5%', minHeight: '50px', textAlign: "center", cursor: "pointer" }} onClick={openFileDialog}> <input ref={inputRef} type="file" multiple onChange={inputFilePickerFunc} style={{ display: 'none' }}></input> <Typography>Computer</Typography> <Icon><FontAwesomeIcon icon={faComputer} /></Icon> </Box> <Box sx={{ border: 1, width: '49.5%', minHeight: '50px', textAlign: "center" }}> <Typography>Facebook</Typography> <Icon sx={{ color: 'blue' }}><FacebookIcon /></Icon> </Box> { imageArray.length !== 0 && imageArray.map( (item) => ( <Tooltip key={`tooltip-${item.id}`} title={profilePic == item.imagesource ? "" : "Set as profile pic"}> <Box key={`box-${item.id}`} id={item.imagesource} style={{ border: profilePic == item.imagesource ? "2px solid red" : "0px" }} onClick={setImgProfilePic} sx={{ width: '49.5%', height: "150px", position: 'relative', cursor: "pointer" }} > <Icon key={`icon-${item.id}`} sx={{ position: 'absolute', right: '0px', cursor: "pointer", color: 'red' }} onClick={(e) => deleteItem(e, item.imagesource)} > <FontAwesomeIcon icon={faTrashCan} /> </Icon> <img src={item.imagesource} key={`img-${item.id}`} style={{ width: '100%', height: '100%', objectFit: 'contain' }} // 添加onError处理,强制刷新图片 onError={(e) => { e.target.src = `${item.imagesource.split('?')[0]}&t=${Math.random()}`; }} /> </Box> </Tooltip> ) ) } <Box sx={{ width: '100%', textAlign: "center", display: displaySaveCancelButton }}> <Button variant="contained" sx={{ m: 2 }} onClick={savePics}>Save</Button> </Box> </Box> ) }
解决方案说明
- 等待上传完成再更新图片源:原代码中直接循环发起上传请求后立即修改
imagesource,此时图片可能还未上传到S3,导致加载失败。改用Promise.all等待所有上传请求完成后再更新状态,确保S3上的图片已就绪。 - 添加唯一ID作为React Key:原代码使用
imagesource作为key,当imagesource从Object URL切换到S3 URL时,key会变化,虽然能触发重新渲染,但用固定的唯一ID更稳定,避免不必要的DOM重建。 - 释放Object URL:本地图片展示完成后,调用
URL.revokeObjectURL释放内存,避免内存泄漏。 - 缓存破击参数:在S3 URL后添加时间戳参数(
?t=${Date.now()}),强制浏览器重新请求图片,避免缓存旧的加载失败状态。 - onError fallback处理:给图片添加
onError事件,当加载失败时重新生成带随机参数的URL,触发重新加载。
内容的提问来源于stack exchange,提问作者Steve Maxwell
相关产品推荐
相关产品推荐

