JavaScript 实现大文件分片上传与断点续传

作者:异步漫步时间:2026-10-25 08:22:34浏览:568

本文介绍基于 JavaScript 的大文件断点续传方案。通过将大文件分割为多个切片,利用 File API 和 Blob.slice 进行分片处理,结合 MD5 生成文件 Hash 值以标识文件。前端通过接口获取已上传切片列表,仅上传缺失部分,最后调用合并接口完成文件组装,有效解决网络波动导致的重复上传问题。

近期业务需求涉及用户上传体积超过 1GB 的大文件。若在网络不稳定或用户操作失误导致上传中断,传统方式需重新从头上传,效率极低。经前后端协作调研成熟方案后,决定采用分片断点续传机制来优化上传流程。

完整代码-github

什么是断点续传

断点续传是指在文件上传过程中因刷新页面或网络中断等原因导致传输终止后,再次上传同一文件时,能够从上次中断的位置继续传输,从而节省时间并提升用户体验。

实现思路

核心策略是将大文件分割为多个切片,而非一次性传输整个文件。前端将切片逐个发送给服务器,服务器将接收到的切片暂存。待所有切片上传完毕后,服务器端执行合并操作,将切片重组为完整文件。

具体逻辑为:若上传过程中断,下次上传同一文件时,前端先向服务器发送请求,查询已上传的切片列表。前端将服务器返回的列表与本地计算的总切片列表进行比对,筛选出未上传的切片并继续上传。

后端处理切片命名与合并的细节如下:大文件切片时,后端需为每个切片指定特定文件名格式${原文件HASH值}_${切片序号}.${文件后缀},例如'f07ec272dbb0b883eed4b2f415625a90_2.mp4'。服务器将切片暂存于以文件 Hash 值命名的临时文件夹中。所有切片上传完成后,调用合并接口,后端将临时文件夹内的切片合并为完整文件。

需要后端提供的api

以下 API 需后端配合开发:

获取已经上传的切片

  url:/upload_already
  method:GET
  params:
    HASH:文件的HASH名字
  return:application/json
    code:0成功 1失败,
    codeText:状态描述,
    fileList:[...]

该接口用于获取已上传文件的所有切片名称,返回示例如下:

{
fileList:['f07ec272dbb0b883eed4b2f415625a90_1.mp4','f07ec272dbb0b883eed4b2f415625a90_2.mp4','f07ec272dbb0b883eed4b2f415625a90_3.mp4']
}

上述返回表示 Hash 值为'f07ec272dbb0b883eed4b2f415625a9'的文件已上传了三个切片,前端只需从第四个切片开始继续上传。若返回空数组,则表明该文件为首次上传。

上传切片

  url:/upload_chunk
  method:POST
  params:multipart/form-data
    file:切片数据
    filename:切片名字「文件生成的HASH_切片编号.后缀」
  return:application/json
    code:0成功 1失败,
    codeText:状态描述,
    originalFilename:文件原始名称,
    servicePath:文件服务器地址

前端上传file对象格式的切片文件,并将切片名称按${原文件HASH值}_${切片序号}.${文件后缀}格式传递给后端。后端利用 Hash 值将切片暂存至临时文件夹,待所有切片上传完成后,执行合并操作并删除临时文件夹。

合并切片

  url:/upload_merge
  method:POST
  params:application/x-www-form-urlencoded
    HASH:文件的HASH名字
    count:切片数量
  return:application/json
    code:0成功 1失败,
    codeText:状态描述,
    originalFilename:文件原始名称,
    servicePath:文件服务器地址

当所有切片上传完毕(由前端判断)后,调用合并接口。后端负责将切片合并,并删除存储切片的临时文件夹。

前端代码细节实现

HASH值的获取方法

  • 利用FileReader对象将用户选择的文件对象转换为buffer格式。
  • 基于文件的buffer数据,使用 MD5 库生成文件的 Hash 值。

封装成函数:

/**
 * 传入文件对象,返回文件生成的HASH值,后缀,buffer,以HASH值为名的新文件名
 * @param file
 * @returns {Promise<unknown>}
 */
const changeBuffer = file => {
    return new Promise(resolve => {
        let fileReader = new FileReader();
        fileReader.readAsArrayBuffer(file);
        fileReader.onload = ev => {
            let buffer = ev.target.result,
                spark = new SparkMD5.ArrayBuffer(),
                HASH,
                suffix;
            spark.append(buffer);
            HASH = spark.end();
            suffix = /.([a-zA-Z0-9]+)$/.exec(file.name)[1];
            resolve({
                buffer,
                HASH,
                suffix,
                filename: `${HASH}.${suffix}`
            });
        };
    });
};

切片处理

使用Blob对象提供的slice方法对文件进行切片处理,支持自定义切片大小和数量。

Blob.slice文档参考

例如file.slice(0,1024)表示提取文件从 0 到 1024 字节的数据进行切片,并返回一个新的 Blob 对象。

总体html结构

<div class="container">
    <div class="item">
        <h3>大文件上传</h3>
        <section class="upload_box" id="upload7">
            <input type="file" class="upload_inp">
            <div class="upload_button_box">
                <button class="upload_button select">上传文件</button>
            </div>
            <div class="upload_progress">
                <div class="value"></div>
            </div>
        </section>
    </div>
</div>

什么是断点续传

使用axios发送请求

/*把axios发送请求的公共信息进行提取*/
//创建一个单独的实例,不去项目全局的或者其他的axios冲突
let instance = axios.create();
instance.defaults.baseURL = 'http://127.0.0.1:8888';
//默认是multipart/form-data格式
instance.defaults.headers['Content-Type'] = 'multipart/form-data';
instance.defaults.transformRequest = (data, headers) => {
    //兼容x-www-form-urlencoded格式的请求发送
    const contentType = headers['Content-Type'];
    if (contentType === "application/x-www-form-urlencoded") return Qs.stringify(data);
    return data;
};
//统一结果的处理
instance.interceptors.response.use(response => {
    return response.data;
},reason=>{
    //统一失败的处理
    return Promise.reject(reason)
});

整体逻辑和代码

详细逻辑已包含在代码注释中,实现较为详尽。

完整代码-github

(function () {
    let upload = document.querySelector('#upload7'),
        upload_inp = upload.querySelector('.upload_inp'),
        upload_button_select = upload.querySelector('.upload_button.select'),
        upload_progress = upload.querySelector('.upload_progress'),
        upload_progress_value = upload_progress.querySelector('.value');

    const checkIsDisable = element => {
        let classList = element.classList;
        return classList.contains('disable') || classList.contains('loading');
    };

    /**
     * 传入文件对象,返回文件生成的HASH值,后缀,buffer,以HASH值为名的新文件名
     * @param file
     * @returns {Promise<unknown>}
     */
    const changeBuffer = file => {
        return new Promise(resolve => {
            let fileReader = new FileReader();
            fileReader.readAsArrayBuffer(file);
            fileReader.onload = ev => {
                let buffer = ev.target.result,
                    spark = new SparkMD5.ArrayBuffer(),
                    HASH,
                    suffix;
                spark.append(buffer);
                HASH = spark.end();
                suffix = /.([a-zA-Z0-9]+)$/.exec(file.name)[1];
                resolve({
                    buffer,
                    HASH,
                    suffix,
                    filename: `${HASH}.${suffix}`
                });
            };
        });
    };

    upload_inp.addEventListener('change', async function () {
        //get native file object
        let file = upload_inp.files[0];
        if (!file) return;
        //button add loading
        upload_button_select.classList.add('loading');
        //show progress
        upload_progress.style.display = 'block';

        // 获取文件的HASH
        let already = [],//已经上传过的切片的切片名
            data = null,
            {
                HASH,
                suffix
            } = await changeBuffer(file);//得到原始文件的hash和后缀

        // 获取已经上传的切片信息
        try {
            data = await instance.get('/upload_already', {
                params: {
                    HASH
                }
            });
            if (+data.code === 0) {
                already = data.fileList;
            }
        } catch (err) {}

        // 实现文件切片处理 「固定数量 & 固定大小」
        let max = 1024 * 100,//切片大小先设置100KB
            count = Math.ceil(file.size / max),//得到应该上传的切片
            index = 0,//存放切片数组的时候遍历使用
            chunks = [];//用以存放切片值
        if (count > 100) {//如果切片数量超过100,那么就只切成100个,因为切片太多的话也会影响调用接口的速度
            max = file.size / 100;
            count = 100;
        }
        while (index < count) {//循环生成切片
            //index 0 =>  0~max
            //index 1 =>  max~max*2
            //index*max ~(index+1)*max
            chunks.push({
                file: file.slice(index * max, (index + 1) * max),
                filename: `${HASH}_${index+1}.${suffix}`
            });
            index++;
        }

        index = 0;
        const clear = () => {//上传完成后,将状态回归
            upload_button_select.classList.remove('loading');
            upload_progress.style.display = 'none';
            upload_progress_value.style.width = '0%';
        };

        //每一次上传一个切片成功的处理[进度管控&切片合并]
        const complate = async () => {
            // 管控进度条:每上传完一个切片,就将进度条长度增加一点
            index++;
            upload_progress_value.style.width = `${index/count*100}%`;

            if (index < count) return;
            // 当所有切片都上传成功,就合并切片
            upload_progress_value.style.width = `100%`;
            try {
                //调用合并切片方法
                data = await instance.post('/upload_merge', {
                    HASH,
                    count
                }, {
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded'
                    }
                });
                if (+data.code === 0) {
                    alert(`恭喜您,文件上传成功,您可以基于 ${data.servicePath} 访问该文件~~`);
                    clear();
                    return;
                }
                throw data.codeText;
            } catch (err) {
                alert('切片合并失败,请您稍后再试~~');
                clear();
            }
        };

        // 循环上传每一个切片
        chunks.forEach(chunk => {
            // 已经上传的无需在上传
            //后台返回的already格式为['HASH_1.png','HASH_2.png'],既已经上传的文件的切片名
            if (already.length > 0 && already.includes(chunk.filename)) {
                //已经上传过了的切片就无需再调用接口上传了
                complate();//动进度条或合并所有切片
                return;
            }
            let fm = new FormData;
            fm.append('file', chunk.file);
            fm.append('filename', chunk.filename);
            instance.post('/upload_chunk', fm).then(data => {//使用form data格式上传切片
                if (+data.code === 0) {
                    complate();////动进度条或合并所有切片
                    return;
                }
                return Promise.reject(data.codeText);
            }).catch(() => {
                alert('当前切片上传失败,请您稍后再试~~');
                clear();
            });
        });
    });
    //触发原生的上传文件框
    upload_button_select.addEventListener('click', function () {
        if (checkIsDisable(this)) return;
        upload_inp.click();
    });
})();

实现效果

首次上传时,分别调用/upload_already和/upload_chunk接口获取已上传切片(此时为空数组),随后进行文件切片并逐个上传。

什么是断点续传

什么是断点续传

若在上传过程中刷新页面或中断切片上传行为,可查看后端临时数据状态。

什么是断点续传

后端可见一个以 Hash 值命名的临时文件夹,其中已包含 24 个已上传的切片。

若再次选择相同文件上传,进度条会直接跳转至上次已上传的位置。接口返回已上传的 24 个切片名称数组。

剩余切片上传完成后,调用接口完成合并,实现文件完整上传。

什么是断点续传

此时后端临时文件夹被删除,文件合并为完整大文件,上传流程结束。

什么是断点续传

到此这篇关于JavaScript利用切片实现大文件断点续传的文章就介绍到这了,更多相关JavaScript文件断点续传内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!

作者最新文章