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

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>
    )
}

解决方案说明

  1. 等待上传完成再更新图片源:原代码中直接循环发起上传请求后立即修改imagesource,此时图片可能还未上传到S3,导致加载失败。改用Promise.all等待所有上传请求完成后再更新状态,确保S3上的图片已就绪。
  2. 添加唯一ID作为React Key:原代码使用imagesource作为key,当imagesource从Object URL切换到S3 URL时,key会变化,虽然能触发重新渲染,但用固定的唯一ID更稳定,避免不必要的DOM重建。
  3. 释放Object URL:本地图片展示完成后,调用URL.revokeObjectURL释放内存,避免内存泄漏。
  4. 缓存破击参数:在S3 URL后添加时间戳参数(?t=${Date.now()}),强制浏览器重新请求图片,避免缓存旧的加载失败状态。
  5. onError fallback处理:给图片添加onError事件,当加载失败时重新生成带随机参数的URL,触发重新加载。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.22 01:25:55