原生JS + Node 实现大文件分片上传、断点续传、秒传

2,392 阅读6分钟

背景

前端上传文件时候,如果文件很大,上传时会出现各种问题,比如连接超时,网断了,都会导致上传失败。为了避免上传大文件时超时,就需要用到切片断点上传。

整体思路

  • 客户端

    1. 使用 Blob.prototype.slice 方法对文件切片
    2. 使用 spark-md5 库计算文件 hash,并创建 web worker 开启新的线程执行
    3. 将各个分片文件上传到服务器,并携带 hash
    4. 上传进度计算
    5. 当所有文件上传完成后,发起合并请求,携带文件 hash、后缀名、分片大小
  • 服务端

    1. 根据接收到的 hash 创建文件夹,将分片文件存储到文件夹中
    2. 收到合并请求后,读取各个分片文件。根据 hash 和后缀名,合并生成完整文件
    3. 删除存储分片文件的文件夹及其内容

架构图

  • 客户端

客户端.jpeg

  • 服务端

服务器.jpeg

前置知识点

大文件切片原理

File 是特殊的 Blob 对象,大文件上传切片的原理就是利用Blob.prototype.slice方法,文件的slice方法可以返回原文件的某个切片。和数组的 slice 方法相似。

ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。ArrayBuffer 的内容不能直接操作,只能通过 DataView 对象或 TypedArray 对象来访问。这些对象用于读取和写入缓冲区内容。

FileReader

FileReader 是一个异步 API,用于读取文件并提取其内容以供进一步使用。它可以将 Blob 读取为不同的格式。

  1. 基本使用

可以使用 FileReader 构造函数来创建一个 FileReader 对象:

const reader = new FileReader();

这个对象常用属性如下:

  • error:表示在读取文件时发生的错误;
  • result:文件内容。该属性仅在读取操作完成后才有效,数据的格式取决于使用哪种方法来启动读取文件
  • readyState:表示FileReader状态的数字。取值如下:
常量名描述
EMPTY0还没有加载任何数据
LOADING1数据正在被加载
DONE2已完成全部的读取要求

FileReader 对象提供了读取 ArrayBuffer数据对象的方法:

FileReader.readAsArrayBuffer():该方法会按字节读取文件内容,转换为 ArrayBuffer 对象。readAsArrayBuffer() 方法读取文件后,会在内存中创建一个 ArrayBuffer 对象(二进制缓冲区),会将二进制数据存放在其中。语法结构:

FileReader.readAsArrayBuffer(Blob|File); 参数为 Blob 或 File 对象。

代码示例:

<input type="file" id="file" />
<script>
    window.onload = function () {
        const inputEl = document.getElementById("file");
        inputEl.onchange = function () {
            const file = inputEl.files[0];
            if (file) {
                let reader = new FileReader();
                reader.readAsArrayBuffer(file);
                reader.onload = function () {
                    console.log(this.result);
                    // ArrayBuffer(50122101)
                    console.log(new Blob([this.result]));
                    // Blob {size: 50122101, type: ''}
                }
            }
        }
    }
</script>
  1. 事件代理

FileReader 对象常用的事件如下:

  • FileReader.onabort:该事件在读取操作被中断时触发
  • FileReader.onerror:该事件在读取操作发生错误时触发
  • FileReader.onload:该事件在读取操作完成时触发
  • FileReader.onprogress:该事件在读取 Blob 时触发

FormData

FormData 对象用以将数据编译成键值对,以便用XMLHttpRequest来发送数据,其主要用于发送表单数据,但亦可用于发送带键数据(keyed data),而独立于表单使用。

创建FormData对象,调用它的append()方法来添加字段:

var formData = new FormData();
formData.append("username","张三");

使用 spark-md5 生成 md5 文件

简单demo:

<input type="file" id="file" />
<input type="button" id="submitBtn" onclick="submitFile()" value="提交" />
<script>
    const btnEl = document.getElementById("submitBtn");
    const fileEl = document.getElementById("file");

    function submitFile() {
        const file = fileEl.files[0];
        if (file) {
            var fileSize = file.size;
            const chunkSize = 5 * 1024 * 1024;
            var chunks = Math.floor(fileSize / chunkSize);
            var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;;
            var spark = new SparkMD5.ArrayBuffer();
            var reader = new FileReader();
            var currentChunk = 0;

            reader.onload = function (e) {
                const result = e.target.result;
                spark.append(result);
                currentChunk++;
                if (currentChunk < chunks) {
                    loadNext();
                    console.log(`第${currentChunk}分片解析完成,开始解析${currentChunk + 1}分片`);
                } else {
                    const md5 = spark.end();
                    console.log("解析完成");
                    console.log(md5);
                }
            };

            function loadNext() {
                var start = currentChunk * chunkSize;
                var end = start + chunkSize > file.size ? file.size : (start + chunkSize);
                reader.readAsArrayBuffer(blobSlice.call(file, start, end));
            };
            loadNext();
        }
    }
</script>

技术选型

客户端:html + 原生 JavaScript

服务端:Nodejs + multiparty

具体实现

客户端

  1. 上传页面

文件上传控件选用了最原始的input上传,具体代码实现:

<input id="uploadFile" type="file" />
<button type="button" id="uploadBtn" onClick="startUpload()">开始上传</button>
<div class="progress">
    上传进度:<span id="progressValue">0</span>
</div>
  1. 请求封装

考虑到通用性,使用原生 XMLHttpRequest做一层简单的封装来请求

/**
 * 封装请求
 * @param {any} {
 *  url,
 *  method="post",
 *  data,
 *  headers={}
 * }
 * @returns 
 */
function singleRequest({
    url,
    method = "post",
    data,
    headers = {}
}) {
    return new Promise(resolve => {
        const xhr = new XMLHttpRequest();
        xhr.open(method, url);
        Object.keys(headers).forEach(key => {
            xhr.setRequestHeader(key, headers[key]);
        });
        xhr.send(data);
        xhr.onload = e => {
            resolve({
                data: e.target.response
            });
        };
    });
}
  1. 文件切片

点击上传按钮时,先调用handleFileChunk方法将文件切片。具体实现过程是:设置默认单个切片文件的大小,通过while循环和slice方法将文件进行切割,并将生成的一个个 chunk 切片数据放到fileChunkList数组中:

/**
 * 文件分片
 * @param {any} file 文件
 * @param {any} chunkSize 分片大小
 */
const handleFileChunk = function (file, chunkSize) {
    const fileChunkList = [];
    // 索引值
    let curIndex = 0;
    while (curIndex < file.size) {
        const endIndex = curIndex + chunkSize < file.size ? curIndex + chunkSize : file.size;
        const curFileChunkFile = file.slice(curIndex, endIndex);
        curIndex += chunkSize;
        fileChunkList.push({ file: curFileChunkFile });
    }
    return fileChunkList;
}
  1. 生成 hash

无论是客户端还是服务端,都要用到文件和切片的 hash,生成 hash 最简单的方法是 文件名 + 切片下标,但是如果文件名一旦修改,生成的 hash 就会失效。事实上只要文件内容不变, hash 就不应该变化,所以我们根据文件内容生成 hash

这里我们选用 spark-md5库,它可以根据文件内容计算出文件的hash值。

如果上传的文件过大时,读取文件内容计算hash非常耗时,并且会引起 UI 阻塞,导致页面假死,所以我们使用web-worker在worker 线程计算 hash,这样仍可以在主界面正常做交互。具体做法如下:

self.importScripts("./spark-md5.min.js");

//接受主进程发送过来的数据
self.onmessage = function (e) {
    const { fileChunkList } = e.data;
    getFileHash(fileChunkList).then((hash) => {
        self.postMessage({
            hash: hash
        });
    }).catch(() => {
        self.postMessage({
            error: "crate hash error"
        });
    })
};

/**
 * 
 * 获取全部文件内容hash
 * @param {any} fileList 
 */
async function getFileHash(fileList) {
    const spark = new SparkMD5.ArrayBuffer();
    const result = fileList.map((item, key) => {
        return getFileContent(item.file)
    });

    try {
        const contentList = await Promise.all(result);
        for (let i = 0; i < contentList.length; i++) {
            spark.append(contentList[i]);
        }
        return spark.end();
    } catch (e) {
        console.log(e);
    }
}

/**
 * 
 * 获取全部文件内容
 * @param {any} file 
 * @returns 
 */
function getFileContent(file) {
    return new Promise((resolve, reject) => {
        const fileReader = new FileReader();
        //读取文件内容
        fileReader.readAsArrayBuffer(file);
        fileReader.onload = (e) => {
            resolve(e.target.result);
        };
        fileReader.onerror = (e) => {
            reject(fileReader.error);
            fileReader.abort();
        };
    });
}
  1. 收集切片信息

生成文件切片后,将计算出来的hash、切片索引、文件个数、当前切片的hash、切片内容等信息都收集起来,发送给服务端。上传索引的目的是为了让后端可以知道当前切片是第几个切片,方便服务端合并切片:

const start = async function (bigFile) {
    // 生成多个切片
    const fileList = handleFileChunk(bigFile, DefaultChunkSize);
    // 获取整个大文件的内容hash,方便实现秒传
    const containerHash = await calculateHash(fileList);
    // 给每个切片添加辅助内容信息
    const chunksInfo = fileList.map(({ file }, index) => ({
        // 整个文件hash
        fileHash: containerHash,
        // 当前是第几个切片
        index,
        // 文件个数
        fileCount: fileList.length,
        // 当前切片的hash
        hash: containerHash + "-" + index,
        // 切片内容
        chunk: file,
        // 文件总体大小
        totalSize: bigFile.zise,
        // 单个文件大小
        size: file.size
    }));

    // 上传所有文件
    uploadChunks(chunksInfo, bigFile.name);
}
  1. 上传切片

将切片相关的信息都放入formData中,对每一个切片调用singleRequest请求,生成Promise,最后调用Promise.all并发上传所有的切片:

/**
 * 上传所有切片
 * @param {any} chunks 
 * @param {any} fileName 
 */
async function uploadChunks(chunks, fileName) {
    const requestList = chunks
        .map(({ chunk, hash, fileHash, index, fileCount, size, totalSize }) => {
            //生成每个切片上传的信息
            const formData = new FormData();
            formData.append("hash", hash);
            formData.append("index", index);
            formData.append("fileCount", fileCount);
            formData.append("size", size);
            formData.append("splitSize", DefaultChunkSize);
            formData.append("fileName", fileName);
            formData.append("fileHash", fileHash);
            formData.append("chunk", chunk);
            formData.append("totalSize", totalSize);
            return { formData, index };
        })
        .map(async ({ formData, index }) =>
            singleRequest({
                url: "http://127.0.0.1:3000/uploadBigFile",
                data: formData,
            })
        );
    //全部上传
    await Promise.all(requestList);
}
  1. 暂停上传

原理是使用 XMLHttpRequest 的 abort 方法,取消一个xhr请求的发送。因此需要把上传每个切片的 xhr 对象保存起来:

/**
 * 单个文件上传请求
 * @param {any} {
    url,
    method = "post",
    data,
    headers = {},
    xhrList
} 
 * @returns 
 */
function singleRequest({
    url,
    method = "post",
    data,
    headers = {},
    xhrList
}) {
    return new Promise(resolve => {
        const xhr = new XMLHttpRequest();
        xhr.open(method, url);
        Object.keys(headers).forEach(key => {
            xhr.setRequestHeader(key, headers[key]);
        });
        xhr.send(data);
        xhr.onload = e => {
            // 将请求成功的xhr从列表删除
            if (xhrList) {
                const xhrIndex = xhrList.findIndex(item => item === xhr);
                xhrList.splice(xhrIndex, 1);
            }
            resolve({
                data: e.target.response
            })
        };
        // 暴露xhr给外部
        xhrList?.push(xhr);
    })
}
  1. 恢复上传

切片文件上传后,服务端会创建一个文件夹存储所有上传的切片。每次前端上传前可以先验证下服务端是否已经保存了该切片,并把已经上传的切片列表返回,前端上传就跳过这些已经上传的切片

服务端

  1. express 搭建 node 服务

使用 express 搭建一个简单的 node 服务:

import http from "http";
import express from "express";
import createError from "http-errors";

const port = 3000;

const app = express();

const server = http.createServer(app);

// 设置跨域访问
app.use(function (req, res, next) {
    // 设置允许跨域的域名,* 表示允许任意域名跨域
    res.header("Access-Control-Allow-Origin", req.headers.origin);
    // 允许携带cookie
    res.header("Access-Control-Allow-Credentials", "true");
    // 允许的header类型
    res.header("Access-Control-Allow-Headers", [
        "content-type",
        "Origin"
    ]);
    // 跨域允许的请求方式
    res.header("Access-Controll-Allow-Methods", "DELETE,PUT,POST,GET,OPTIONS");

    res.header("Access-Controll-Allow-Max-Age", `${20}`);
    if (req.method.toLowerCase() === "options") res.send(200);
    // 让options尝试请求快速结束
    else next();
});

server.listen(port, () => {
  console.log("监听端口:", port);
});
  1. 接收客户端传来的切片

服务端使用multiparty处理客户端传来的formData

在 multipary.parse 的回调中,files 参数保存了 formData 中文件,fields 参数保存了 formData 中非文件的字段:

import http from "http";
import express from "express";
import path = require("path");
import createError from "http-errors";
const multiparty = require("multiparty");
const fse = require("fs-extra");

const port = 3000;

const app = express();

// 上传成功后返回URL地址
const resourceUrl = `http://127.0.0.1:${port}`;

// 存储文件目录
const uploadDir = path.join(__dirname, "/upload");

// 设置静态访问目录
app.use(express.static(uploadDir));
const server = http.createServer(app);

// 设置跨域访问
app.use(function (req, res, next) {
  // 设置允许跨域的域名,* 表示允许任意域名跨域
  res.header("Access-Control-Allow-Origin", req.headers.origin);
  // 允许携带cookie
  res.header("Access-Control-Allow-Credentials", "true");
  // 允许的header类型
  res.header("Access-Control-Allow-Headers", ["content-type", "Origin"]);
  // 跨域允许的请求方式
  res.header("Access-Controll-Allow-Methods", "DELETE,PUT,POST,GET,OPTIONS");

  res.header("Access-Controll-Allow-Max-Age", `${20}`);
  if (req.method.toLowerCase() === "options") res.send(200);
  // 让options尝试请求快速结束
  else next();
});

/**
 * 提取文件后缀名
 * @param filename
 */
const extractExt = (filename) => {
  filename.slice(filename.lastIndexOf("."), filename.length);
};

app.post("/uploadBigFile", function (req, res, _next) {
  const multipart = new multiparty.Form();
  multipart.parse(req, async (err, fields, files) => {
    if (err) {
      console.error(err);
      return res.json({
        code: 5000,
        data: null,
        msg: "上传文件失败",
      });
    }
    // 取出文件内容
    const [chunk] = files.chunk;
    // 当前chunk 文件 hash
    const [hash] = fields.hash;
    // 大文件的名称
    const [fileName] = fields.fileName;
    // 大文件的 hash
    const [fileHash] = fields.fileHash;
    // 切片索引
    const [index] = fields.index;
    // 总共切片个数
    const [fileCount] = fields.fileCount;
    // 当前 chunk 的大小
    const [splitSize] = fields.splitSize;
    // 整个文件大小
    const [totalSize] = fields.totalSize;

    // 切片目录不存在,创建切片目录
    if (!fse.existsSync(chunkDir)) {
      await fse.mkdirs(chunkDir);
    }

    const chunkFile = path.resolve(chunkDir, hash);
    if (!fse.existsSync(chunkFile)) {
      await fse.move(chunk.path, path.resolve(chunkDir, hash));
    }
  });
});

server.listen(port, () => {
  console.log("监听端口:", port);
});
  1. 合并切片

接收到客户端发送的合并请求后,服务端将文件夹下的所有切片进行合并:

const fse = require("fs-extra");
const path = require("path");

/**
 * 读流,写流
 * @param path
 * @param writeStream
 * @returns
 */
const pipeStream = (path, writeStream) =>
    new Promise((resolve) => {
        const readStream = fse.createReadStream(path);
        readStream.on("end", () => {
            // fse.unlinkSync(path);
            resolve(null);
        });
        readStream.pipe(writeStream);
    });

/**
 * 
 * 合并所有切片
 * @export
 * @param {any} {
 *     filePath:文件路径包含后缀名
 *     fileHash:文件hash
 *     chunkDir:切片存放的临时目录
 *     splitSize:每个切片的大小
 *     fileCount:文件总个数
 *     totalSize:文件总大小
 * } 
 * @returns 
 */
export async function chunkMerge({
    filePath,
    fileHash,
    chunkDir,
    splitSize,
    fileCount,
    totalSize,
}) {
    const chunkPaths = await fse.readdir(chunkDir);
    //帅选合适的切片
    const filterPath = chunkPaths.filter((item) => {
        return item.includes(fileHash);
    });
    //数量不对,抛出错误
    if (filterPath.length !== fileCount) {
        console.log("合并错误");
        return;
    }
    // 根据切片下标进行排序,方便合并
    filterPath.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
    await Promise.all(
        chunkPaths.map((chunkPath, index) => {
            //并发写入,需要知道开始和结束位置
            let end = (index + 1) * splitSize;
            if (index === fileCount - 1) {
                end = totalSize + 1;
            }
            return pipeStream(
                path.resolve(chunkDir, chunkPath),
                // 指定位置创建可写流
                fse.createWriteStream(filePath, {
                    start: index * splitSize,
                    end: end,
                })
            );
        })
    );
    //删除所有切片
    // fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录
    return filePath;
}
  1. 文件秒传

文件秒传,就是服务端已经存在了上传的资源,当用户再次上传时会直接提示上传成功。

文件秒传依赖上面生成的 hash,由于 hash 的唯一性,一旦服务端找到 hash 相同的文件,直接返回上传成功的消息

/**
 * 提取文件后缀名
 * @param filename
 * @returns
 */
const extractExt = (filename) =>
	filename.slice(filename.lastIndexOf("."), filename.length); // 提取后缀名

const saveFileName = `${fileHash}${extractExt(fileName)}`;
//获取整个文件存储路径
const filePath = path.resolve(uploadDIr, saveFileName);

// 大文件存在直接返回,根据内容hash存储,可以实现后续秒传
if (fse.existsSync(filePath)) {
	return res.json({
		code: 1000,
		data: { url: `${resourceUrl}${saveFileName}` },
		msg: "上传文件已存在",
	});
}

参考文章