前端直接下载到本地(实时显示下载进度)

介绍:以往的前端下载基本流程都是**(服务端 -> 客户端 -> 再触发客户端下载)** ,这样当文件过大 时候,会导致浏览器崩溃 (原因是下载的文件缓存在浏览器)。要想解决这个问题,第一种方案: 则只能通过 <a src="" download></a> 标签去下载(因为a标签是直接存到本地的),但是a标签会有很多限制,比如请求头之类的一系列配置,不同源无法触发下载等。下面我介绍一下第二种方案: 借助StreamSaver.js 实现管道下载,且实时获取下载进度.

文件目录:

一共两个文件 (downloadProgress.js 和 streamsaver.js)

downloadProgress.js

javascript 复制代码
import './streamsaver'
/**
 * 使用 StreamSaver.js 进行流式下载,并实时监控进度
 * @param {object} options - 配置对象
 * @param {string} options.url - 要下载的文件URL
 * @param {string} options.fileName - 保存时建议的文件名
 * @param {string} options.method - 请求方法
 * @param {object} options.headers - 请求头
 * @param {object} options.data - 请求体
 * @param {function} options.onProgress - 进度回调函数 (percent) => {}
 * @param {function} options.onSuccess - 成功回调函数 () => {}
 * @param {function} options.onError - 错误回调函数 (error) => {}
 */

// MIME 类型到文件后缀的映射表
const MIME_TYPE_MAP = {
    'video/mp4': 'mp4',
    'video/webm': 'webm',
    'video/ogg': 'ogv',
    'video/quicktime': 'mov',
    'image/jpeg': 'jpg',
    'image/png': 'png',
    'image/gif': 'gif',
    'application/pdf': 'pdf',
    'application/zip': 'zip',
    'text/plain': 'txt',
};

let extension = '';

const isValidFilename = (filename) => {
    const pattern = /^[^\\/:*?"<>|]+\.[a-zA-Z0-9]+$/;
    return pattern.test(filename);
}

const streamHttp = async ({ url, fileName = 'download-file', method = 'GET', headers = {}, data = {},  onProgress, onSuccess, onError }) => {
    // 1. 检查浏览器支持
    if (typeof streamSaver === 'undefined') {
        const error = new Error('您没有引入StreamSaver.js \n\n<script src="https://cdn.jsdelivr.net/npm/streamsaver@2.0.6/StreamSaver.min.js"></script>\n');
        return onError(error);
    }

    if (!streamSaver.supported) {
        const error = new Error('你的浏览器不支持流式下载,请升级浏览器。');
        return onError(error);
    }

    try {
        // 1. 启动 fetch 请求
        const config = {
            method,
            headers,
            body: JSON.stringify(data),
        }
        // 如果是get请求,则不需要body
        if (method?.toLocaleUpperCase() === 'GET') {
            delete config.body;
        }
        const response = await fetch(url, config);

        const contentType = '';

        if (contentType && MIME_TYPE_MAP[contentType]) {
            // 如果有精确匹配,优先使用
            extension = MIME_TYPE_MAP[contentType];
        }

        // 如果 contentType 不存在,并且 fileName不是一个正常带后缀的文件名,则给出下载不了的提示
        if (!contentType && !isValidFilename(fileName)) {
            return onError(new Error('无法确定文件类型,请提供正确的文件名。'));
        }

        // 如果用户传入的 fileName 没有后缀,则自动添加
        const finalFileName = fileName.includes('.') ? fileName : `${fileName}.${extension}`;

        // 2. 创建文件流,这会立即触发浏览器的下载对话框
        const fileStream = streamSaver.createWriteStream(finalFileName);

        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }

        // 3. 获取文件总大小,用于计算进度
        const contentLength = response.headers.get('Content-Length');
        let loaded = 0;

        // 4. 创建一个转换流,用于在数据流过时计算进度
        const progressStream = new TransformStream({
            transform(chunk, controller) {
                loaded += chunk.length;

                // 如果有总大小,计算并更新进度
                if (contentLength) {
                    const percent = (loaded / contentLength) * 100;
                    onProgress({ 
                        percent, 
                    });
                } else {
                    // 如果没有 Content-Length,只显示已下载的字节数
                    onProgress({ 
                        percent: null, // 表示无法计算百分比
                    });
                }
                
                // 将数据块传递给下一个流
                controller.enqueue(chunk);
            }
        });

        // 5. 连接管道:fetch响应 -> 进度监控流 -> 文件写入流
        // pipeTo 返回一个 Promise,它会在下载完成时 resolve
        await response.body.pipeThrough(progressStream).pipeTo(fileStream);

        // 6. 如果 pipeTo 成功,下载完成
        onSuccess({ status: 200, message: '下载完成' });

    } catch (error) {
        onError(error);
    }
};

export default streamHttp;

streamsaver.js

javascript 复制代码
/**
 * Minified by jsDelivr using Terser v5.37.0.
 * Original file: /npm/streamsaver@2.0.6/StreamSaver.js
 *
 * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
 */
/*! streamsaver. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
( (e, t) => {
    "undefined" != typeof module ? module.exports = t() : "function" == typeof define && "object" == typeof define.amd ? define(t) : window.streamSaver = t()
}
)(0, ( () => {
    "use strict";
    const e = window;
    e.HTMLElement || console.warn("streamsaver is meant to run on browsers main thread");
    let t = null
      , a = !1;
    const r = e.WebStreamsPolyfill || {}
      , n = e.isSecureContext;
    let o = /constructor/i.test(e.HTMLElement) || !!e.safari || !!e.WebKitPoint;
    const s = n || "MozAppearance"in document.documentElement.style ? "iframe" : "navigate"
      , i = {
        createWriteStream: function(r, m, c) {
            let d = {
                size: null,
                pathname: null,
                writableStrategy: void 0,
                readableStrategy: void 0
            }
              , p = 0
              , u = null
              , f = null
              , g = null;
            Number.isFinite(m) ? ([c,m] = [m, c],
            console.warn("[StreamSaver] Deprecated pass an object as 2nd argument when creating a write stream"),
            d.size = c,
            d.writableStrategy = m) : m && m.highWaterMark ? (console.warn("[StreamSaver] Deprecated pass an object as 2nd argument when creating a write stream"),
            d.size = c,
            d.writableStrategy = m) : d = m || {};
            if (!o) {
                t || (t = n ? l(i.mitm) : function(t) {
                    const a = "width=200,height=100"
                      , r = document.createDocumentFragment()
                      , n = {
                        frame: e.open(t, "popup", a),
                        loaded: !1,
                        isIframe: !1,
                        isPopup: !0,
                        remove() {
                            n.frame.close()
                        },
                        addEventListener(...e) {
                            r.addEventListener(...e)
                        },
                        dispatchEvent(...e) {
                            r.dispatchEvent(...e)
                        },
                        removeEventListener(...e) {
                            r.removeEventListener(...e)
                        },
                        postMessage(...e) {
                            n.frame.postMessage(...e)
                        }
                    }
                      , o = t => {
                        t.source === n.frame && (n.loaded = !0,
                        e.removeEventListener("message", o),
                        n.dispatchEvent(new Event("load")))
                    }
                    ;
                    return e.addEventListener("message", o),
                    n
                }(i.mitm)),
                f = new MessageChannel,
                r = encodeURIComponent(r.replace(/\//g, ":")).replace(/['()]/g, escape).replace(/\*/g, "%2A");
                const o = {
                    transferringReadable: a,
                    pathname: d.pathname || Math.random().toString().slice(-6) + "/" + r,
                    headers: {
                        "Content-Type": "application/octet-stream; charset=utf-8",
                        "Content-Disposition": "attachment; filename*=UTF-8''" + r
                    }
                };
                d.size && (o.headers["Content-Length"] = d.size);
                const m = [o, "*", [f.port2]];
                if (a) {
                    const e = "iframe" === s ? void 0 : {
                        transform(e, t) {
                            if (!(e instanceof Uint8Array))
                                throw new TypeError("Can only write Uint8Arrays");
                            p += e.length,
                            t.enqueue(e),
                            u && (location.href = u,
                            u = null)
                        },
                        flush() {
                            u && (location.href = u)
                        }
                    };
                    g = new i.TransformStream(e,d.writableStrategy,d.readableStrategy);
                    const t = g.readable;
                    f.port1.postMessage({
                        readableStream: t
                    }, [t])
                }
                f.port1.onmessage = e => {
                    e.data.download ? "navigate" === s ? (t.remove(),
                    t = null,
                    p ? location.href = e.data.download : u = e.data.download) : (t.isPopup && (t.remove(),
                    t = null,
                    "iframe" === s && l(i.mitm)),
                    l(e.data.download)) : e.data.abort && (h = [],
                    f.port1.postMessage("abort"),
                    f.port1.onmessage = null,
                    f.port1.close(),
                    f.port2.close(),
                    f = null)
                }
                ,
                t.loaded ? t.postMessage(...m) : t.addEventListener("load", ( () => {
                    t.postMessage(...m)
                }
                ), {
                    once: !0
                })
            }
            let h = [];
            return !o && g && g.writable || new i.WritableStream({
                write(e) {
                    if (!(e instanceof Uint8Array))
                        throw new TypeError("Can only write Uint8Arrays");
                    o ? h.push(e) : (f.port1.postMessage(e),
                    p += e.length,
                    u && (location.href = u,
                    u = null))
                },
                close() {
                    if (o) {
                        const e = new Blob(h,{
                            type: "application/octet-stream; charset=utf-8"
                        })
                          , t = document.createElement("a");
                        t.href = URL.createObjectURL(e),
                        t.download = r,
                        t.click()
                    } else
                        f.port1.postMessage("end")
                },
                abort() {
                    h = [],
                    f.port1.postMessage("abort"),
                    f.port1.onmessage = null,
                    f.port1.close(),
                    f.port2.close(),
                    f = null
                }
            },d.writableStrategy)
        },
        WritableStream: e.WritableStream || r.WritableStream,
        supported: !0,
        version: {
            full: "2.0.5",
            major: 2,
            minor: 0,
            dot: 5
        },
        mitm: "https://jimmywarting.github.io/StreamSaver.js/mitm.html?version=2.0.0"
    };
    function l(e) {
        if (!e)
            throw new Error("meh");
        const t = document.createElement("iframe");
        return t.hidden = !0,
        t.src = e,
        t.loaded = !1,
        t.name = "iframe",
        t.isIframe = !0,
        t.postMessage = (...e) => t.contentWindow.postMessage(...e),
        t.addEventListener("load", ( () => {
            t.loaded = !0
        }
        ), {
            once: !0
        }),
        document.body.appendChild(t),
        t
    }
    try {
        new Response(new ReadableStream),
        n && !("serviceWorker"in navigator) && (o = !0)
    } catch (e) {
        o = !0
    }
    return (e => {
        try {
            e()
        } catch (e) {}
    }
    )(( () => {
        const {readable: e} = new TransformStream
          , t = new MessageChannel;
        t.port1.postMessage(e, [e]),
        t.port1.close(),
        t.port2.close(),
        a = !0,
        Object.defineProperty(i, "TransformStream", {
            configurable: !1,
            writable: !1,
            value: TransformStream
        })
    }
    )),
    i
}
));
//# sourceMappingURL=/sm/a47e90c9e77f79e22405531b2deda4d299dc4ffb8262b6b5b3af3580ec770db0.map

使用方式:

javascript 复制代码
<template>
  <el-button @click="download">下载</el-button>
</template>

<script setup>
import http from './utils/downloadProgress'

const download = () => {
  http({
    url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4',
    method: 'get',
    header: {},
    data: {},
    onProgress: (progress) => {
      console.log(progress)
    },
    onSuccess: (res) => {
      console.log(res)
    },
    onError: (err) => {
      console.log(err)
    }
  })
}
</script>
相关推荐
三小河1 小时前
前端 Class 语法从 0 开始学起
前端
hjt_未来可期1 小时前
js实现复制、粘贴文字
前端·javascript·html
小明记账簿_微信小程序1 小时前
webpack实用配置dev--react(一)
前端
ohyeah1 小时前
AI First 时代:用大模型构建轻量级后台管理系统
前端·llm
Apeng_09191 小时前
vue实现页面不断插入内容并且自动滚动功能
前端·javascript·vue.js
孟祥_成都2 小时前
Prompt 还能哄女朋友!你真的知道如何问 ai 问题吗?
前端·人工智能
前端涂涂2 小时前
第3讲:BTC-数据结构
前端
白狐_7982 小时前
【项目实战】我用一个 HTML 文件写了一个“CET-6 单词斩”
前端·算法·html
夕水2 小时前
React Server Components 中的严重安全漏洞
前端