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

移动端从Firebase Storage下载大文件失败问题求助

问题描述

基于Firebase开发的React应用,支持从Firebase Storage下载文件,遇到以下问题:

  • 桌面端功能完全正常,移动端下载200MB以下文件无异常
  • 下载400MB以上文件时必然失败(未找到精确临界值)
  • 调试发现下载进度走到99%,执行anchor.click()后,iPhone Safari弹出报错:「Safari无法打开页面,操作无法完成 (WebkitBlobResource error: 1)」,地址栏显示blob:https://myapp.com/xxx格式链接,最终无文件下载
  • 同iOS版本下,3GB RAM的iPhone XR可正常下载,2GB RAM的iPhone SE(2)下载失败
  • 尝试过Axios、fetch、getBlob()等方式,获取Blob环节无问题,问题集中在anchor.click()触发时
  • Stack Overflow上的旧解决方案均无效

附相关代码:

const downloadFile = async (storagePath: string): Promise<void> => {
    return new Promise(async (resolve, reject) => {
        try {
            const pathReference = ref(
                storage,
                `gs://bucket-url/${storagePath}`
            );
            const url = await getDownloadURL(pathReference);

            const xhr = new XMLHttpRequest();
            xhr.open("GET", url, true);
            xhr.responseType = "blob";

            xhr.addEventListener("progress", (event) => {
                if (event.lengthComputable) {
                    const percentComplete =
                        (event.loaded / event.total) * 100;
                    setDownloadProgress(Math.floor(percentComplete));
                }
            });

            xhr.onload = () => {
                console.log("loaded!");
                const blob = xhr.response;
                console.log("creating blob object!");
                const blobUrl = URL.createObjectURL(blob);
                console.log("Blob object created!!");

                const anchor = document.createElement("a");
                anchor.href = blobUrl;
                anchor.download = episodePath.split("/")[1];
                anchor.style.display = "none";
                document.body.appendChild(anchor);

                anchor.addEventListener("download", () => {
                    if (timeoutId.current) {
                        clearTimeout(timeoutId.current);
                        timeoutId.current = null;
                    }
                    if (blobUrl) {
                        URL.revokeObjectURL(blobUrl);
                    }
                });

                console.log("I'm gonna click!");
                anchor.click();

                // This is a fallback for when the download event doesn't fire
                timeoutId.current = setTimeout(() => {
                    if (blobUrl) {
                        console.log(
                            `Revoking blob URL in timeout: ${blobUrl}`
                        );
                        URL.revokeObjectURL(blobUrl);
                    }
                }, 1000);

                document.body.removeChild(anchor);
                currentDownloadTask.current = null;
                setDownloadProgress(0);
                setToastMessage("Download completed successfully!");
                setToastStatus("success");
                setToastVariant("success");
                setShowToast(true);
                setDownloading([]);
                resolve();
            };

            xhr.onerror = (error: any) => {
                console.error("Download failed:", error);
                setToastMessage("Download Failed.");
                setToastStatus("error");
                setToastVariant("danger");
                setShowToast(true);
                setDownloading([]);
                currentDownloadTask.current = null;
                setDownloadProgress(0);
                reject(error);
            };

            xhr.onabort = () => {
                console.log("Download aborted.");
                setToastMessage("Download cancelled.");
                setToastStatus("aborted");
                setToastVariant("danger");
                setShowToast(true);
                setDownloading([]);
                currentDownloadTask.current = null;
                setDownloadProgress(0);
                reject(new Error("Download Aborted"));
            };

            currentDownloadTask.current = xhr;
            xhr.send();
        } catch (error: any) {
            console.error("Error during download:", error);
            setToastMessage("Download Failed.");
            setToastStatus("error");
            setToastVariant("danger");
            setShowToast(true);
            setDownloading([]);
            currentDownloadTask.current = null;
            setDownloadProgress(0);
            reject(error);
        }
    });
};
解决建议

1. 跳过Blob直接使用Firebase下载链接

Safari对大Blob的内存处理存在硬性限制,直接用Firebase生成的下载URL作为<a>标签的href,避免将整个文件加载到内存:

const downloadFile = async (storagePath: string): Promise<void> => {
    try {
        const pathReference = ref(storage, `gs://bucket-url/${storagePath}`);
        const url = await getDownloadURL(pathReference);
        
        const anchor = document.createElement("a");
        anchor.href = url;
        anchor.download = storagePath.split("/")[1]; // 替换原episodePath为正确文件名变量
        anchor.style.display = "none";
        document.body.appendChild(anchor);
        
        anchor.click();
        document.body.removeChild(anchor);
        
        setToastMessage("Download started successfully!");
        // 注意:此方式无法监听下载进度,若需进度需额外实现分块下载逻辑
    } catch (error) {
        console.error("Download failed:", error);
        setToastMessage("Download Failed.");
        setToastStatus("error");
        setToastVariant("danger");
        setShowToast(true);
        setDownloading([]);
        currentDownloadTask.current = null;
        setDownloadProgress(0);
    }
};

说明:让浏览器直接处理下载请求,绕开Blob中转,彻底解决内存占用问题,但无法直接获取下载进度。如果必须保留进度监听,可结合Firebase的getBytes()分块下载并自行统计进度。

2. 调整Blob URL回收时机

当前代码在anchor.click()后1秒就回收Blob URL,Safari可能还未完成文件读取就失去资源,导致报错。修改回收逻辑:

  • 移除1秒超时回收,仅在download事件触发时回收
  • 或延长超时时间到5-10秒,给Safari足够的处理窗口

修改后的xhr.onload核心片段:

xhr.onload = () => {
    // ... 其他代码
    anchor.addEventListener("download", () => {
        if (timeoutId.current) {
            clearTimeout(timeoutId.current);
            timeoutId.current = null;
        }
        // 延迟3秒回收,给Safari足够处理时间
        setTimeout(() => {
            URL.revokeObjectURL(blobUrl);
        }, 3000);
    });

    anchor.click();

    // 兜底超时延长至10秒
    timeoutId.current = setTimeout(() => {
        URL.revokeObjectURL(blobUrl);
    }, 10000);
};

3. 分块处理大文件(保留进度监听场景)

如果必须保留进度监听,可通过分块下载控制内存占用:

  • 使用Firebase Storage的getBytes()方法,每次下载固定大小的分块(比如10MB)
  • 逐步将分块数据合并为Blob,或直接通过File System Access API写入本地文件(仅支持现代浏览器)
  • 此方式能精准控制内存使用,但实现复杂度较高,且File System Access API在iOS Safari中支持有限

4. 针对低内存设备做适配

iOS Safari在低内存设备上的文件处理限制更严格,可:

  • 在下载前通过navigator.deviceMemory检测设备内存,对2GB及以下内存的设备提示文件过大
  • 提供文件压缩版本或分卷下载选项,降低单文件大小

5. 尝试使用navigator.download()(实验性)

部分现代浏览器支持navigator.download()API,可直接传入Blob或URL触发下载,可能规避<a>标签的Blob问题:

// 先做兼容性判断
if ('download' in navigator) {
    await navigator.download(blob, fileName);
} else {
    // 回退到原<a>标签逻辑
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.14 04:35:57