Skip to content

前置知识

1.FileReader

FileReader是一个读取文件的API,可以异步的读取并格式化文件。可以对读取出来的文件进行进一步操作。将文件真真的数据读取出来,计算出hash值。

ts
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file); // 将文件转换成缓冲区数组
fileReader.onload=()=>{
    fileReader.result // 读取的结果
    const data = new Blob([fileReader.result]) // 转换成blob对象
}
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(file); // 将文件转换成缓冲区数组
fileReader.onload=()=>{
    fileReader.result // 读取的结果
    const data = new Blob([fileReader.result]) // 转换成blob对象
}

2.Blob、File

Blob和File并不是保存了文件的数据,而是仅仅保存了文件的基本信息。

Blob保存了文件的大小和类型

File继承与Blob,保存了文件的名字、修改时间等信息

这两个对象只是保存了文件的基本信息,若需要读取文件的内容还需要使用FileReader这个API。

3.断点续传

​ 断点续传就是客户端在还没有上传完所有分片时,取消了上传,想要恢复上一次的上传进度,通过与服务器交互得到当前已经上传的文件切片,客户端继续上传后续的切片,而不是上传所有切片。

​ 客户端想要得知此文件的上传进度时,就需要使用hash,hash值在服务端中用来唯一标识一个文件,通过此hash值就能查找到对应文件。

​ hash就是一种算法,可以将任意数据转换成固定长度的字符串,且是不可逆的。当文件中某一个位发生了变化,整个hash的结果也不一样了,所以hash可以唯一标识一个文件,可以用简单的字符串,来描述整个文件的数据。秒传功能也是通过hash来实现。常用的hash算法就是md5。

4.hash算法

​ 通过spark-md5可以使用hash算法来计算出文件的hash值。

计算hash的策略:

  1. 将文件的全部数据用来计算
  2. 第一个、最后一个切片全部参与计算,中间的切片只有前面两个字节、中间两个字节、最后两个字节用来计算hash。✅
  3. 增量计算,读取遍历所有切片来计算出整个文件的hash✅

5.文件切片

文件切片就是将大文件按照固定长度进行切片。

ts
/**
 * 获取切片
 * @param {File} file
 * @param {number} chunkSize
 * @returns
 */
function getChunk(file, chunkSize) {
  const result = [];
  for (let i = 0; i < file.size; i += chunkSize) {
    result.push(file.slice(i, i+chunkSize));
  }
  return result;
}
/**
 * 获取切片
 * @param {File} file
 * @param {number} chunkSize
 * @returns
 */
function getChunk(file, chunkSize) {
  const result = [];
  for (let i = 0; i < file.size; i += chunkSize) {
    result.push(file.slice(i, i+chunkSize));
  }
  return result;
}

6.计算整个文件的hash值

ts
/**
 * 计算出整个文件的hash值
 * @param {Blob[]} chunks
 */
async function getFileHush(chunks) {
  // 整个文件的hash值
  const result = new SparkMD5();
  for (let i = 0; i < chunks.length; i++) {
    await new Promise((r, j) => {
      const fileReader = new FileReader();
      fileReader.onload = function () {
        result.append(this.result);
        r();
      };
      fileReader.onerror = j;
      fileReader.readAsArrayBuffer(chunks[i]);
    });
  }
  return result.end();
}
/**
 * 计算出整个文件的hash值
 * @param {Blob[]} chunks
 */
async function getFileHush(chunks) {
  // 整个文件的hash值
  const result = new SparkMD5();
  for (let i = 0; i < chunks.length; i++) {
    await new Promise((r, j) => {
      const fileReader = new FileReader();
      fileReader.onload = function () {
        result.append(this.result);
        r();
      };
      fileReader.onerror = j;
      fileReader.readAsArrayBuffer(chunks[i]);
    });
  }
  return result.end();
}

hash计算过程监听

​ 通过切片大小,知晓需要计算的总数量,计算完一个切片的hash就计数+1,然后计算出总比例就成。

ts
/**
 * 计算出整个文件的hash值
 * @param {Blob[]} chunks
 */
async function getFileHash(chunks) {
  // 计算了多少个切片的hash了
  let count = 0;
  // 整个文件的hash值
  const result = new SparkMD5();
  for (let i = 0; i < chunks.length; i++) {
    await new Promise((r, j) => {
      const fileReader = new FileReader();
      fileReader.onload = function () {
        result.append(this.result);
        count++;
        console.log(`hash计算进度:${(count / chunks.length) * 100}%`);
        r();
      };
      fileReader.onerror = j;
      fileReader.readAsArrayBuffer(chunks[i]);
    });
  }
  return result.end();
}
/**
 * 计算出整个文件的hash值
 * @param {Blob[]} chunks
 */
async function getFileHash(chunks) {
  // 计算了多少个切片的hash了
  let count = 0;
  // 整个文件的hash值
  const result = new SparkMD5();
  for (let i = 0; i < chunks.length; i++) {
    await new Promise((r, j) => {
      const fileReader = new FileReader();
      fileReader.onload = function () {
        result.append(this.result);
        count++;
        console.log(`hash计算进度:${(count / chunks.length) * 100}%`);
        r();
      };
      fileReader.onerror = j;
      fileReader.readAsArrayBuffer(chunks[i]);
    });
  }
  return result.end();
}

通过WebWorker计算出hash值

​ 大文件需要让浏览器花费很多时间在JS线程上,导致渲染线程的卡顿,推荐使用WebWorker来计算出整个文件的hash值。

7.上传切片

​ 上传切片通常是携带文件hash、切片hash、切片hash可以通过文件hash-切片索引来构造出切片的hash。在上传切片时也要注意切片需要控制并发数量,不能同时将所有切片都上传了,可以考虑使用并发池的方式来上传切片。

1.并发上传所有分片❌

​ 不推荐一次性上传所有分片,浏览器一次性只能同时进行六个HTTP请求,多余的请求都会被挂起,浪费资源。

2.逐片上传切片✅

​ 每次上传一个切片,才可以上传下一个切片,同步的上传切片。效率低,但是简单。

ts
/**
 * 逐片上传切片
 * @param {Blob[]} chunks
 * @param {string} hash
 */
async function chunksUpload01(chunks, hash) {
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  for (let i = 0; i < chunksData.length; i++) {
    await new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", "http://127.0.0.1:3000/upload");
      xhr.send(chunksData[i]);
      xhr.onload = resolve;
      xhr.onerror = reject;
    });
  }
}
/**
 * 逐片上传切片
 * @param {Blob[]} chunks
 * @param {string} hash
 */
async function chunksUpload01(chunks, hash) {
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  for (let i = 0; i < chunksData.length; i++) {
    await new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", "http://127.0.0.1:3000/upload");
      xhr.send(chunksData[i]);
      xhr.onload = resolve;
      xhr.onerror = reject;
    });
  }
}

3.并发控制上传所有切片✅

ts
/**
 * 并发上传切片
 * @param {Blob[]} chunks
 * @param {string} hash
 */
async function chunksUpload02(chunks, hash) {
  // 构造每次请求的请求体
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  // 并发池
  const pool = [];
  // 最大并发数量
  const max = 6;
  for (let i = 0; i < chunksData.length; i++) {
    // 发送请求
    const request = new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", "http://127.0.0.1:3000/upload");
      xhr.send(chunksData[i]);
      xhr.onload = resolve;
      xhr.onerror = reject;
    });
	// 将请求保存并发池
    pool.push(request);
	
    // 请求成功,空闲出一个位置
    request.then(() => {
      const index = pool.findIndex((item) => item === request);
      pool.splice(index, 1);
    });
	
    if (pool.length >= max) {
      // 请求池满了
      console.log("等待");
      // 请求池达到最大,阻塞后续切片的上传直到有一个空闲位置
      await Promise.race(pool);
      console.log("等待结束");
    }
  }
  // 等待所有请求结束
  await Promise.all(pool);
  console.log("切片上传完成!");
}
/**
 * 并发上传切片
 * @param {Blob[]} chunks
 * @param {string} hash
 */
async function chunksUpload02(chunks, hash) {
  // 构造每次请求的请求体
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  // 并发池
  const pool = [];
  // 最大并发数量
  const max = 6;
  for (let i = 0; i < chunksData.length; i++) {
    // 发送请求
    const request = new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", "http://127.0.0.1:3000/upload");
      xhr.send(chunksData[i]);
      xhr.onload = resolve;
      xhr.onerror = reject;
    });
	// 将请求保存并发池
    pool.push(request);
	
    // 请求成功,空闲出一个位置
    request.then(() => {
      const index = pool.findIndex((item) => item === request);
      pool.splice(index, 1);
    });
	
    if (pool.length >= max) {
      // 请求池满了
      console.log("等待");
      // 请求池达到最大,阻塞后续切片的上传直到有一个空闲位置
      await Promise.race(pool);
      console.log("等待结束");
    }
  }
  // 等待所有请求结束
  await Promise.all(pool);
  console.log("切片上传完成!");
}

4.出错控制

​ 切片上传失败的策略:全部重传?选择重传?GBN?

切片只要有一个上传失败,终止后续上传。恢复时,前端查询切片上传进度,后端返回上传的切片索引,前端就只需要上传未上传的切片即可。(选择重传)

8.文件上传错误控制

  • 若服务器磁盘空间不足?(上传切片时,合并切片时需要考虑)

  • 客户端上传有个切片上传失败?

实现

1.切片上传

后端实现

后端实现的步骤:

  1. 规定前端需要传递文件hash、切片hash、切片数据三个数据,并在处理函数中解析出来
  2. 通过文件hash创建切片文件夹,将所有相同文件hash的切片存储在此文件夹中
  3. 存储完成响应结果
ts
// 切片上传接口(不包含秒传的逻辑)
app.post("/upload", upload.single("file"), function (req, res, next) {
  const file_hash = req.body["file-hash"]; // 约定前端传入file-hash代表文件的hash值
  const chunk_hash = req.body["chunk-hash"]; // 约定传入chunk-hash代表切片的hash值
  const file = req.file;
  const [_, chunk_index] = chunk_hash.split("-"); // 约定切片的hash值的格式为:文件hash-切片索引
  if (!chunk_hash || Number.isNaN(+chunk_index) || _ !== file_hash) {
    res.status(400);
    res.send("error");
  }
  console.log(chunk_hash, file_hash, chunk_index);
  // 以文件hash为文件名,将此文件的所有切片存在此文件夹
  const chunkDirPath = resolve(tempPath, `./${file_hash}`);
  // 切片的路径
  const chunkPath = resolve(chunkDirPath, `./${chunk_hash}`);
  if (!existsSync(chunkDirPath)) {
    // 不存在切片文件夹,说明首次上传,创建切片文件夹
    mkdirSync(chunkDirPath);
  }
  // 将切片写入到对应的文件夹中
  writeFile(chunkPath, file.buffer, (err) => {
    if (err) {
      res.send(err);
    } else {
      res.send("ok");
    }
  });
});
// 切片上传接口(不包含秒传的逻辑)
app.post("/upload", upload.single("file"), function (req, res, next) {
  const file_hash = req.body["file-hash"]; // 约定前端传入file-hash代表文件的hash值
  const chunk_hash = req.body["chunk-hash"]; // 约定传入chunk-hash代表切片的hash值
  const file = req.file;
  const [_, chunk_index] = chunk_hash.split("-"); // 约定切片的hash值的格式为:文件hash-切片索引
  if (!chunk_hash || Number.isNaN(+chunk_index) || _ !== file_hash) {
    res.status(400);
    res.send("error");
  }
  console.log(chunk_hash, file_hash, chunk_index);
  // 以文件hash为文件名,将此文件的所有切片存在此文件夹
  const chunkDirPath = resolve(tempPath, `./${file_hash}`);
  // 切片的路径
  const chunkPath = resolve(chunkDirPath, `./${chunk_hash}`);
  if (!existsSync(chunkDirPath)) {
    // 不存在切片文件夹,说明首次上传,创建切片文件夹
    mkdirSync(chunkDirPath);
  }
  // 将切片写入到对应的文件夹中
  writeFile(chunkPath, file.buffer, (err) => {
    if (err) {
      res.send(err);
    } else {
      res.send("ok");
    }
  });
});

前端实现

实现步骤:

  1. 将文件按照后端规定的大小分片成固定大小的切片数组
  2. 通过第三方库使用hash算法增量计算出整个文件的hash值(计算hash的过程可以使用webworker)
  3. 将分片并发上传到后端,注意并发控制
ts
/**
 * 并发上传切片(循环)
 * @param {Blob[]} chunks
 * @param {string} hash
 */
async function chunksUpload02(chunks, hash) {
  // 上传成功的数量
  let count = 0;
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  // 并发池
  const pool = [];
  // 最大并发数量
  const max = 6;

  for (let i = 0; i < chunksData.length; i++) {
    const request = new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", `${baseURL}/upload`);
      xhr.send(chunksData[i]);
      xhr.onload = resolve;
      xhr.onerror = reject;
    });
    // 将请求保存在并发池中
    pool.push(request);
    // 请求成功后,从并发池中移除该请求
    request.then(() => {
      // 计算上传进度
      textProxy.uploadProgress = (++count / chunks.length) * 100;
      const index = pool.findIndex((item) => item === request);
      pool.splice(index, 1);
    });

    if (pool.length >= max) {
      // 请求池满了
      console.log("等待");
      // 请求池达到最大,需要等待并发池空闲,才能上传后续分片
      await Promise.race(pool);
      console.log("等待结束");
    }
  }
  // 等待所有请求结束
  await Promise.all(pool);
  console.log("切片上传完成!");
}

/**
 * 并发上传切片(递归)
 * @param {Blob[]} chunks
 * @param {string} hash
 */
async function chunksUpload03(chunks, hash) {
  // 上传成功的数量
  let count = 0;
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  // 并发池
  const pool = [];
  // 最大并发数量
  const max = 6;
  return new Promise((r) => {
    upload(0);
    async function upload(index) {
      if (index >= chunksData.length) {
        // 等待所有请求结束
        await Promise.all(pool);
        console.log("切片上传完成!");
        return r();
      }
      const request = new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open("POST", `${baseURL}/upload`);
        xhr.send(chunksData[index]);
        xhr.onload = resolve;
        xhr.onerror = reject;
      });
      pool.push(request);
      request.then(() => {
        const requsetIndex = pool.findIndex((item) => item === request);
        pool.splice(requsetIndex, 1);
        textProxy.uploadProgress = (++count / chunksData.length) * 100;
      });
      if (pool.length >= max) {
        await Promise.race(pool);
        upload(index + 1);
      } else {
        upload(index + 1);
      }
    }
  });
}
/**
 * 并发上传切片(循环)
 * @param {Blob[]} chunks
 * @param {string} hash
 */
async function chunksUpload02(chunks, hash) {
  // 上传成功的数量
  let count = 0;
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  // 并发池
  const pool = [];
  // 最大并发数量
  const max = 6;

  for (let i = 0; i < chunksData.length; i++) {
    const request = new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", `${baseURL}/upload`);
      xhr.send(chunksData[i]);
      xhr.onload = resolve;
      xhr.onerror = reject;
    });
    // 将请求保存在并发池中
    pool.push(request);
    // 请求成功后,从并发池中移除该请求
    request.then(() => {
      // 计算上传进度
      textProxy.uploadProgress = (++count / chunks.length) * 100;
      const index = pool.findIndex((item) => item === request);
      pool.splice(index, 1);
    });

    if (pool.length >= max) {
      // 请求池满了
      console.log("等待");
      // 请求池达到最大,需要等待并发池空闲,才能上传后续分片
      await Promise.race(pool);
      console.log("等待结束");
    }
  }
  // 等待所有请求结束
  await Promise.all(pool);
  console.log("切片上传完成!");
}

/**
 * 并发上传切片(递归)
 * @param {Blob[]} chunks
 * @param {string} hash
 */
async function chunksUpload03(chunks, hash) {
  // 上传成功的数量
  let count = 0;
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  // 并发池
  const pool = [];
  // 最大并发数量
  const max = 6;
  return new Promise((r) => {
    upload(0);
    async function upload(index) {
      if (index >= chunksData.length) {
        // 等待所有请求结束
        await Promise.all(pool);
        console.log("切片上传完成!");
        return r();
      }
      const request = new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open("POST", `${baseURL}/upload`);
        xhr.send(chunksData[index]);
        xhr.onload = resolve;
        xhr.onerror = reject;
      });
      pool.push(request);
      request.then(() => {
        const requsetIndex = pool.findIndex((item) => item === request);
        pool.splice(requsetIndex, 1);
        textProxy.uploadProgress = (++count / chunksData.length) * 100;
      });
      if (pool.length >= max) {
        await Promise.race(pool);
        upload(index + 1);
      } else {
        upload(index + 1);
      }
    }
  });
}

2.切片合并

​ 切片合并包含了文件合并和文件删除,其中有些坑需要注意。createReadSteam读取文件是异步的,所以需要等待所有切片都读取完成后,才能删除切片。

后端实现

实现步骤:

  1. 规定前端需要传递文件hash、文件名称,用来上传合并文件的名称,文件名格式为hash-文件名。同时需要传递切片数量来验证切片是否上传完成
  2. 根据文件hash,找到对应文件的切片文件夹,读取所有的分片名称
  3. 遍历所有切片文件,按照索引和切片大小规则,将切片数据存储在合并文件对应偏移量中
  4. 合并完成,删除切片文件夹及其所有文件
ts
// 合并切片文件
app.post("/merge", async (req, res) => {
  const { file_name, file_hash } = req.body;
  // 非法校验
  if (!file_hash || !file_hash) return;
  // 文件切片的路径
  const chunkDirPath = resolve(tempPath, `./${file_hash}`);
  if (!existsSync(chunkDirPath)) {
    // 不存在此文件的切片
    res.status(400);
    res.send("error!");
    return;
  }
  // 1.切片文件合并
  // 读取切片文件夹中的所有文件
  const chunksName = readdirSync(chunkDirPath);
  // 合并文件的路径 文件名格式为:hash-原名称
  const filePath = resolve(uploadPath, `./${file_hash}-${file_name}`);
  await Promise.all(
    chunksName.map((chunkName) => {
      // 获取切片路径和切片索引
      const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
      const [_, index] = chunkName.split("-");
      if (Number.isNaN(index)) {
        // 合并失败
        res.status(400);
        res.send("error!");
      }
      // 创建合并文件
      const ws = createWriteStream(filePath, {
        start: +index * chunkSize,
      });
      // 将切片文件写入到合并文件中
      return new Promise((r, j) => {
        const rs = createReadStream(chunkPath);
        rs.pipe(ws);
        rs.on("end", r);
        rs.on("error",j);
      });
    })
  );
  // 2.将切片文件删除
  try {
    // 删除所有切片文件
    await Promise.all(
      chunksName.map((chunkName) => {
        return new Promise((r, j) => {
          const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
          unlink(chunkPath, (err) => {
            if (err) {
              j(err);
            } else {
              console.log(`删除文件${chunkName}成功!`);
              r();
            }
          });
        });
      })
    );
    // 删除切片文件夹
    rmdir(chunkDirPath, (err) => {
      if (err) {
        throw new Error(err);
      } else {
        res.send("ok!");
      }
    });
  } catch (error) {
    res.status(500);
    res.send("error!");
  }
});
// 合并切片文件
app.post("/merge", async (req, res) => {
  const { file_name, file_hash } = req.body;
  // 非法校验
  if (!file_hash || !file_hash) return;
  // 文件切片的路径
  const chunkDirPath = resolve(tempPath, `./${file_hash}`);
  if (!existsSync(chunkDirPath)) {
    // 不存在此文件的切片
    res.status(400);
    res.send("error!");
    return;
  }
  // 1.切片文件合并
  // 读取切片文件夹中的所有文件
  const chunksName = readdirSync(chunkDirPath);
  // 合并文件的路径 文件名格式为:hash-原名称
  const filePath = resolve(uploadPath, `./${file_hash}-${file_name}`);
  await Promise.all(
    chunksName.map((chunkName) => {
      // 获取切片路径和切片索引
      const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
      const [_, index] = chunkName.split("-");
      if (Number.isNaN(index)) {
        // 合并失败
        res.status(400);
        res.send("error!");
      }
      // 创建合并文件
      const ws = createWriteStream(filePath, {
        start: +index * chunkSize,
      });
      // 将切片文件写入到合并文件中
      return new Promise((r, j) => {
        const rs = createReadStream(chunkPath);
        rs.pipe(ws);
        rs.on("end", r);
        rs.on("error",j);
      });
    })
  );
  // 2.将切片文件删除
  try {
    // 删除所有切片文件
    await Promise.all(
      chunksName.map((chunkName) => {
        return new Promise((r, j) => {
          const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
          unlink(chunkPath, (err) => {
            if (err) {
              j(err);
            } else {
              console.log(`删除文件${chunkName}成功!`);
              r();
            }
          });
        });
      })
    );
    // 删除切片文件夹
    rmdir(chunkDirPath, (err) => {
      if (err) {
        throw new Error(err);
      } else {
        res.send("ok!");
      }
    });
  } catch (error) {
    res.status(500);
    res.send("error!");
  }
});

3.文件秒传

​ 通过检查合并文件夹中的文件hash是否存在,存在则响应文件存在,无需重复上传。

后端实现

实现步骤:

  1. 前端发送文件hash值
  2. 后端通过在合并文件夹中查询是否有此hash值的文件夹存在
  3. 返回是否存在的信息
ts
// 文件秒传(查询文件是否上传过了)
app.get("/check", (req, res) => {
  const { file_hash } = req.query;
  if (!file_hash) {
    res.status(400);
    return res.send("error");
  }
  const filesName = readdirSync(uploadPath);
  const flag = filesName.some((filename) => {
    return filename.startsWith(file_hash);
  });
  if (flag) {
    res.send({
      code: 200,
      msg: "ok",
      data: 1, // 存在
    });
  } else {
    res.send({
      code: 200,
      msg: "ok",
      data: 0, // 不存在
    });
  }
});
// 文件秒传(查询文件是否上传过了)
app.get("/check", (req, res) => {
  const { file_hash } = req.query;
  if (!file_hash) {
    res.status(400);
    return res.send("error");
  }
  const filesName = readdirSync(uploadPath);
  const flag = filesName.some((filename) => {
    return filename.startsWith(file_hash);
  });
  if (flag) {
    res.send({
      code: 200,
      msg: "ok",
      data: 1, // 存在
    });
  } else {
    res.send({
      code: 200,
      msg: "ok",
      data: 0, // 不存在
    });
  }
});

4.断点续传(最难)

前端实现

实现步骤:

  1. 点击暂停按钮,终止请求
  2. 点击恢复按钮,通过文件hash来查询切片上传进度
  3. 根据后端返回的切片索引,过滤出未上传的切片,重新上传切片
  4. 每次选择文件后,在查询秒传后,如果不存在文件,则需要查询此文件的上传进度,上传未上传的切片

前面三个步骤都是用于暂停和恢复,第四个是用于关闭网页后又恢复网页重新上传文件。

恢复重传
ts
const mitt = new EventEmitter();
// 暂停的数据
const recoverData = {
  hash: "",
  chunksData: null,
  file: null,
};
/**
 * 监听按钮的点击事件
 */
buttonDOM.addEventListener("click", () => {
  textProxy.isUploading = !textProxy.isUploading;
  if (textProxy.isUploading === false) {
    // 取消上传
    mitt.emit("cancel");
  } else {
    recoverUpload(recoverData.hash, recoverData.chunksData, recoverData.file);
  }
});


/**
 * 并发上传切片(循环)
 * @param {FormData[]} chunksData - 切片数据
 * @param {string} hash - hash值
 * @param {number} needCount - 需要上传的切片数量(总的)
 * @param {number} uploadCount - 已经上传的切片数量
 */
function chunksUpload02(
  chunksData,
  hash,
  needCount = chunksData.length,
  uploadCount = 0
) {
  return new Promise(async (r, j) => {
    // 上传成功的数量
    let count = uploadCount;
    // 并发池
    const pool = [];
    // 最大并发数量
    const max = 6;
    for (let i = 0; i < chunksData.length; i++) {
      const request = new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open("POST", `${baseURL}/upload`);
        xhr.send(chunksData[i]);
        xhr.onload = () => {
          resolve();
          // 请求成功移除监听
          mitt.removeListener("cancel", handleAbort);
        };
        xhr.onerror = () => {
          reject();
          // 请求失败移除监听
          mitt.removeListener("cancel", handleAbort);
        };
        function handleAbort() {
          // 取消请求
          xhr.abort();
          if (chunksData.length === needCount) {
            // 第一次暂停保存全部的文件切片和hash值
            recoverData.hash = hash;
            recoverData.chunksData = chunksData;
          }
        }
        // 监听暂停上传的事件
        mitt.on("cancel", handleAbort);
      });
      // 将请求保存在并发池中
      pool.push(request);
      // 请求成功后,从并发池中移除该请求
      request.then(
        () => {
          // 计算上传进度
          textProxy.uploadProgress = (++count / needCount) * 100;
          const index = pool.findIndex((item) => item === request);
          pool.splice(index, 1);
        },
        // 有一个切片上传失败了
        j
      );

      if (pool.length >= max) {
        // 请求池达到最大,需要等待并发池空闲,才能上传后续分片
        await Promise.race(pool);
      }
    }
    // 等待所有请求结束
    await Promise.all(pool);
    r();
  });
}

/**
 * 恢复上传
 * @param {string} hash
 * @param {FormData[]} chunksData
 * @param {File} file
 */
async function recoverUpload(hash, chunksData, file) {
  // 查询文件是否存在
  const uploadChunksData = await new Promise((r, j) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
    xhr.onload = (e) => {
      try {
        const data = JSON.parse(e.target.response);
        r(data.map((item) => +item).sort((a, b) => a - b));
      } catch (error) {
        r(false);
      }
    };
    xhr.onerror = j;
    xhr.send();
  });
  if (!uploadChunksData) {
    // 不存在切片文件夹
    return;
  }
  // 构造出未上传的切片数据
  const needUploadChunksData = [];
  for (let i = 0; i < chunksData.length; i++) {
    const chunkHash = chunksData[i].get("chunk-hash");
    const index = uploadChunksData.findIndex((item) => {
      return `${hash}-${item}` === chunkHash;
    });
    if (index === -1) {
      // 保存未上传的切片
      needUploadChunksData.push(chunksData[i]);
    }
  }
  await chunksUpload02(
    needUploadChunksData,
    hash,
    chunksData.length,
    uploadChunksData.length
  );
  // 合并切片
  mergeChunk(file, hash, chunksData.length);
}
const mitt = new EventEmitter();
// 暂停的数据
const recoverData = {
  hash: "",
  chunksData: null,
  file: null,
};
/**
 * 监听按钮的点击事件
 */
buttonDOM.addEventListener("click", () => {
  textProxy.isUploading = !textProxy.isUploading;
  if (textProxy.isUploading === false) {
    // 取消上传
    mitt.emit("cancel");
  } else {
    recoverUpload(recoverData.hash, recoverData.chunksData, recoverData.file);
  }
});


/**
 * 并发上传切片(循环)
 * @param {FormData[]} chunksData - 切片数据
 * @param {string} hash - hash值
 * @param {number} needCount - 需要上传的切片数量(总的)
 * @param {number} uploadCount - 已经上传的切片数量
 */
function chunksUpload02(
  chunksData,
  hash,
  needCount = chunksData.length,
  uploadCount = 0
) {
  return new Promise(async (r, j) => {
    // 上传成功的数量
    let count = uploadCount;
    // 并发池
    const pool = [];
    // 最大并发数量
    const max = 6;
    for (let i = 0; i < chunksData.length; i++) {
      const request = new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open("POST", `${baseURL}/upload`);
        xhr.send(chunksData[i]);
        xhr.onload = () => {
          resolve();
          // 请求成功移除监听
          mitt.removeListener("cancel", handleAbort);
        };
        xhr.onerror = () => {
          reject();
          // 请求失败移除监听
          mitt.removeListener("cancel", handleAbort);
        };
        function handleAbort() {
          // 取消请求
          xhr.abort();
          if (chunksData.length === needCount) {
            // 第一次暂停保存全部的文件切片和hash值
            recoverData.hash = hash;
            recoverData.chunksData = chunksData;
          }
        }
        // 监听暂停上传的事件
        mitt.on("cancel", handleAbort);
      });
      // 将请求保存在并发池中
      pool.push(request);
      // 请求成功后,从并发池中移除该请求
      request.then(
        () => {
          // 计算上传进度
          textProxy.uploadProgress = (++count / needCount) * 100;
          const index = pool.findIndex((item) => item === request);
          pool.splice(index, 1);
        },
        // 有一个切片上传失败了
        j
      );

      if (pool.length >= max) {
        // 请求池达到最大,需要等待并发池空闲,才能上传后续分片
        await Promise.race(pool);
      }
    }
    // 等待所有请求结束
    await Promise.all(pool);
    r();
  });
}

/**
 * 恢复上传
 * @param {string} hash
 * @param {FormData[]} chunksData
 * @param {File} file
 */
async function recoverUpload(hash, chunksData, file) {
  // 查询文件是否存在
  const uploadChunksData = await new Promise((r, j) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
    xhr.onload = (e) => {
      try {
        const data = JSON.parse(e.target.response);
        r(data.map((item) => +item).sort((a, b) => a - b));
      } catch (error) {
        r(false);
      }
    };
    xhr.onerror = j;
    xhr.send();
  });
  if (!uploadChunksData) {
    // 不存在切片文件夹
    return;
  }
  // 构造出未上传的切片数据
  const needUploadChunksData = [];
  for (let i = 0; i < chunksData.length; i++) {
    const chunkHash = chunksData[i].get("chunk-hash");
    const index = uploadChunksData.findIndex((item) => {
      return `${hash}-${item}` === chunkHash;
    });
    if (index === -1) {
      // 保存未上传的切片
      needUploadChunksData.push(chunksData[i]);
    }
  }
  await chunksUpload02(
    needUploadChunksData,
    hash,
    chunksData.length,
    uploadChunksData.length
  );
  // 合并切片
  mergeChunk(file, hash, chunksData.length);
}
重传
ts
/**
 * 大文件上传的整个逻辑
 * @param {File} file
 */
async function uploadBid(file) {
  textProxy.hashProgress = 0;
  textProxy.uploadProgress = 0;
  // 将文件切片
  const chunks = getChunk(file, chunkSize);
  // 通过切片增量计算出整个文件的hash
  const hash = await getFileHash(chunks);
  const flag = await checkExists(hash);
  // 监听暂停事件,保存文件file
  mitt.on("cancel", () => {
    recoverData.file = file;
  });
  if (flag) {
    textProxy.uploadProgress = 100;
    alert("秒传成功!");
  } else {
    // 检测是否上传过切片了
    const res = await checkRecoverUpload(hash, chunks);
    if (res) {
      // 上传未上传的切片
      await chunksUpload02(res, hash, chunks.length, (chunks.length-res.length));
    } else {
      // 上传所有切片
      await chunksUpload02(createChunksData(chunks, hash), hash);
    }
    // 合并切片
    await mergeChunk(file, hash, chunks.length);
    alert("上传成功!");
  }
}
/**
 * 检查恢复重传
 * @param {string} hash - hash值
 * @param {Blob[]} chunks - 整个文件的所有切片
 */
async function checkRecoverUpload(hash, chunks) {
  // 查询文件是否存在
  const uploadChunksData = await new Promise((r, j) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
    xhr.onload = (e) => {
      try {
        const data = JSON.parse(e.target.response);
        r(data.map((item) => +item).sort((a, b) => a - b));
      } catch (error) {
        r(e.target.response);
      }
    };
    xhr.onerror = j;
    xhr.send();
  });
  if (uploadChunksData instanceof Array) {
    // 有记录
    // 过滤出需要重传的切片数据
    const needUploadChunk = [];
    for (let i = 0; i < chunks.length; i++) {
      if (!uploadChunksData.includes(i)) {
        // 未上传的文件切片
        const chunkData = new FormData();
        chunkData.append("file", chunks[i]);
        chunkData.append("file-hash", hash);
        chunkData.append("chunk-hash", `${hash}-${i}`);
        needUploadChunk.push(chunkData);
      }
    }
    return needUploadChunk;
  } else {
    // 无记录
    return false;
  }
}
/**
 * 大文件上传的整个逻辑
 * @param {File} file
 */
async function uploadBid(file) {
  textProxy.hashProgress = 0;
  textProxy.uploadProgress = 0;
  // 将文件切片
  const chunks = getChunk(file, chunkSize);
  // 通过切片增量计算出整个文件的hash
  const hash = await getFileHash(chunks);
  const flag = await checkExists(hash);
  // 监听暂停事件,保存文件file
  mitt.on("cancel", () => {
    recoverData.file = file;
  });
  if (flag) {
    textProxy.uploadProgress = 100;
    alert("秒传成功!");
  } else {
    // 检测是否上传过切片了
    const res = await checkRecoverUpload(hash, chunks);
    if (res) {
      // 上传未上传的切片
      await chunksUpload02(res, hash, chunks.length, (chunks.length-res.length));
    } else {
      // 上传所有切片
      await chunksUpload02(createChunksData(chunks, hash), hash);
    }
    // 合并切片
    await mergeChunk(file, hash, chunks.length);
    alert("上传成功!");
  }
}
/**
 * 检查恢复重传
 * @param {string} hash - hash值
 * @param {Blob[]} chunks - 整个文件的所有切片
 */
async function checkRecoverUpload(hash, chunks) {
  // 查询文件是否存在
  const uploadChunksData = await new Promise((r, j) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
    xhr.onload = (e) => {
      try {
        const data = JSON.parse(e.target.response);
        r(data.map((item) => +item).sort((a, b) => a - b));
      } catch (error) {
        r(e.target.response);
      }
    };
    xhr.onerror = j;
    xhr.send();
  });
  if (uploadChunksData instanceof Array) {
    // 有记录
    // 过滤出需要重传的切片数据
    const needUploadChunk = [];
    for (let i = 0; i < chunks.length; i++) {
      if (!uploadChunksData.includes(i)) {
        // 未上传的文件切片
        const chunkData = new FormData();
        chunkData.append("file", chunks[i]);
        chunkData.append("file-hash", hash);
        chunkData.append("chunk-hash", `${hash}-${i}`);
        needUploadChunk.push(chunkData);
      }
    }
    return needUploadChunk;
  } else {
    // 无记录
    return false;
  }
}

当然也可以把这些步骤都当作一种情况就是重新上传的步骤(不过需要计算多次的hash):

  1. 计算文件hash
  2. 查询此文件是否存在
  3. 不存在,查询文件上传进度
  4. 无任何进度,上传所有切片;有进度,上传未上传的切片即可

后端实现

实现步骤:

  1. 接收前端发送的文件hash
  2. 查询文件是否已经存在了
  3. 若不存在,从切片文件夹中查询此文件的切片文件夹是否存在
  4. 若存在,则读取切片文件夹中的所有文件,返回已经存在的切片索引
ts
// 查询切片上传得进度
app.get("/check-chunk", (req, res) => {
  const { file_hash } = req.query;
  if (!file_hash) {
    res.status(400);
    return res.send("error");
  }
  // 查找此切片文件夹是否存在
  const chunksDirPath = resolve(tempPath, `./${file_hash}`);
  if (!existsSync(chunksDirPath)) {
    // 未找到此切片文件夹,可能没有上传过,可能已经合并完成了
    res.status(404);
    return res.send("not found");
  }
  // 读出此切片文件夹中已经上传的切片索引
  const chunksIndex = readdirSync(chunksDirPath).map((chunkname) => {
    const [_, index] = chunkname.split("-");
    return index;
  });
  res.send(chunksIndex);
});
// 查询切片上传得进度
app.get("/check-chunk", (req, res) => {
  const { file_hash } = req.query;
  if (!file_hash) {
    res.status(400);
    return res.send("error");
  }
  // 查找此切片文件夹是否存在
  const chunksDirPath = resolve(tempPath, `./${file_hash}`);
  if (!existsSync(chunksDirPath)) {
    // 未找到此切片文件夹,可能没有上传过,可能已经合并完成了
    res.status(404);
    return res.send("not found");
  }
  // 读出此切片文件夹中已经上传的切片索引
  const chunksIndex = readdirSync(chunksDirPath).map((chunkname) => {
    const [_, index] = chunkname.split("-");
    return index;
  });
  res.send(chunksIndex);
});

所有代码

主要难度在于切片上传和断点续传,还要考虑到各种健壮性的问题。此代码仅仅只包含了基本的文件分片上传、断点续传、秒传的功能。

前端

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="X-UA-Compatible"
      content="IE=edge" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      #hash-progress,
      #upload-progress {
        background-color: skyblue;
        height: 20px;
        width: 0%;
      }
    </style>
    <!-- <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.js"></script> -->
    <script src="/client/lib/spark-md5.js"></script>
    <script src="/client/lib/mitt.js"></script>
  </head>
  <body>
    <input type="file" />
    <button>暂停</button>
    <div>
      <div>
        <div>hash计算进度</div>
        <div>
          <div id="hash-progress"></div>
        </div>
      </div>
      <div>
        <div>上传进度</div>
        <div>
          <div id="upload-progress"></div>
        </div>
      </div>
    </div>
    <script src="/client/index.js"></script>
  </body>
</html>
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      http-equiv="X-UA-Compatible"
      content="IE=edge" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      #hash-progress,
      #upload-progress {
        background-color: skyblue;
        height: 20px;
        width: 0%;
      }
    </style>
    <!-- <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.js"></script> -->
    <script src="/client/lib/spark-md5.js"></script>
    <script src="/client/lib/mitt.js"></script>
  </head>
  <body>
    <input type="file" />
    <button>暂停</button>
    <div>
      <div>
        <div>hash计算进度</div>
        <div>
          <div id="hash-progress"></div>
        </div>
      </div>
      <div>
        <div>上传进度</div>
        <div>
          <div id="upload-progress"></div>
        </div>
      </div>
    </div>
    <script src="/client/index.js"></script>
  </body>
</html>
ts
const fileDOM = document.querySelector("input[type=file]");
const buttonDOM = document.querySelector("button");
const hashProgressDOM = document.querySelector("#hash-progress");
const uploadProgressDOM = document.querySelector("#upload-progress");
const chunkSize = 1 * 1024 * 1024;
const baseURL = "http://127.0.0.1:3000";
const text = {
  // 当前是在上传还是暂停状态
  isUploading: true,
  // hash计算进度
  hashProgress: 0,
  // 上传进度
  uploadProgress: 0,
};
const textProxy = new Proxy(text, {
  get(target, key) {
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    if (key === "isUploading") {
      buttonDOM.innerText = value ? "暂停" : "恢复";
    } else if (key === "hashProgress") {
      hashProgressDOM.style.width = value + "%";
    } else if (key === "uploadProgress") {
      uploadProgressDOM.style.width = value + "%";
    }
  },
});
const mitt = new EventEmitter();
// 暂停的数据
const recoverData = {
  hash: "",
  chunksData: null,
  file: null,
};

/**
 * 监听文件域的变化
 */
fileDOM.addEventListener("change", function (e) {
  e.preventDefault();
  const file = this.files[0];
  file && uploadBid(file);
});

/**
 * 监听按钮的点击事件
 */
buttonDOM.addEventListener("click", () => {
  textProxy.isUploading = !textProxy.isUploading;
  if (textProxy.isUploading === false) {
    // 取消上传
    mitt.emit("cancel");
  } else {
    recoverUpload(recoverData.hash, recoverData.chunksData, recoverData.file);
  }
});

/**
 * 大文件上传的整个逻辑
 * @param {File} file
 */
async function uploadBid(file) {
  textProxy.hashProgress = 0;
  textProxy.uploadProgress = 0;
  // 将文件切片
  const chunks = getChunk(file, chunkSize);
  // 通过切片增量计算出整个文件的hash
  const hash = await getFileHash(chunks);
  const flag = await checkExists(hash);
  // 监听暂停事件,保存文件file
  mitt.on("cancel", () => {
    recoverData.file = file;
  });
  if (flag) {
    textProxy.uploadProgress = 100;
    alert("秒传成功!");
  } else {
    // 检测是否上传过切片了
    const res = await checkRecoverUpload(hash, chunks);
    if (res) {
      // 上传未上传的切片
      await chunksUpload02(
        res,
        hash,
        chunks.length,
        chunks.length - res.length
      );
    } else {
      // 上传所有切片
      await chunksUpload02(createChunksData(chunks, hash), hash);
    }
    // 合并切片
    await mergeChunk(file, hash, chunks.length);
    alert("上传成功!");
  }
}

/**
 * 获取切片
 * @param {File} file
 * @param {number} chunkSize
 * @returns
 */
function getChunk(file, chunkSize) {
  const result = [];
  for (let i = 0; i < file.size; i += chunkSize) {
    result.push(file.slice(i, i + chunkSize));
  }
  return result;
}

/**
 * 计算出整个文件的hash值
 * @param {Blob[]} chunks
 */
async function getFileHash(chunks) {
  // 计算了多少个切片的hash了
  let count = 0;
  // 整个文件的hash值
  const result = new SparkMD5();
  for (let i = 0; i < chunks.length; i++) {
    await new Promise((r, j) => {
      const fileReader = new FileReader();
      fileReader.onload = function () {
        result.append(this.result);
        // 计算hash计算进度
        textProxy.hashProgress = (++count / chunks.length) * 100;
        r();
      };
      fileReader.onerror = j;
      fileReader.readAsArrayBuffer(chunks[i]);
    });
  }
  return result.end();
}

/**
 * 上传所有分片(每六个分片上传)
 * @param {Blob[]} chunks -文件的所有切片
 * @param {string} hash -文件的hash
 */
async function chunkUpload(chunks, hash) {
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  // 并发上传的数量
  const max = 6;
  for (let i = 0; i < chunksData.length; i += max) {
    await Promise.all(
      chunksData
        .slice(i, Math.floor(i + max, chunksData.length))
        .map((data) => {
          return new Promise((r, j) => {
            const xhr = new XMLHttpRequest();
            xhr.open("POST", `${baseURL}/upload`);
            xhr.send(data);
            xhr.onload = r;
            xhr.onerror = j;
          });
        })
    );
  }
}

/**
 * 上传所有分片(并发控制六个请求)
 * @param {*} chunks
 * @param {*} hash
 */
async function chunksUpload(chunks, hash) {
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });

  const uploadPool = [];
  const max = 6;
  for (let i = 0; i < chunksData.length; i++) {
    const request = new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", `${baseURL}/upload`);
      xhr.send(chunksData[i]);
      xhr.onload = resolve;
      xhr.onerror = reject;
    });

    request.then(() => {
      const index = uploadPool.findIndex((req) => req === request);
      uploadPool.splice(index, 1);
    });

    uploadPool.push(request);

    if (uploadPool.length >= max) {
      // 当前任务已经开始,等待空闲位置
      const readyPromise = new Promise((resolve) => {
        const readyInterval = setInterval(() => {
          if (uploadPool.length < max) {
            clearInterval(readyInterval);
            resolve();
          }
        }, 100);
      });
      await Promise.race([request, readyPromise]);
    }
  }

  await Promise.all(uploadPool);
}

/**
 * 逐片上传切片
 * @param {Blob[]} chunks
 * @param {string} hash
 */
async function chunksUpload01(chunks, hash) {
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  for (let i = 0; i < chunksData.length; i++) {
    await new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", `${baseURL}/upload`);
      xhr.send(chunksData[i]);
      xhr.onload = resolve;
      xhr.onerror = reject;
    });
  }
}

/**
 * 并发上传切片(循环)
 * @param {FormData[]} chunksData - 切片数据
 * @param {string} hash - hash值
 * @param {number} needCount - 需要上传的切片数量(总的)
 * @param {number} uploadCount - 已经上传的切片数量
 */
function chunksUpload02(
  chunksData,
  hash,
  needCount = chunksData.length,
  uploadCount = 0
) {
  return new Promise(async (r, j) => {
    // 上传成功的数量
    let count = uploadCount;
    // 并发池
    const pool = [];
    // 最大并发数量
    const max = 6;
    for (let i = 0; i < chunksData.length; i++) {
      const request = new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open("POST", `${baseURL}/upload`);
        xhr.send(chunksData[i]);
        xhr.onload = () => {
          resolve();
          // 请求成功移除监听
          mitt.removeListener("cancel", handleAbort);
        };
        xhr.onerror = () => {
          reject();
          // 请求失败移除监听
          mitt.removeListener("cancel", handleAbort);
        };
        function handleAbort() {
          // 取消请求
          xhr.abort();
          if (chunksData.length === needCount) {
            // 第一次暂停保存全部的文件切片和hash值
            recoverData.hash = hash;
            recoverData.chunksData = chunksData;
          }
        }
        // 监听暂停上传的事件
        mitt.on("cancel", handleAbort);
      });
      // 将请求保存在并发池中
      pool.push(request);
      // 请求成功后,从并发池中移除该请求
      request.then(
        () => {
          // 计算上传进度
          textProxy.uploadProgress = (++count / needCount) * 100;
          const index = pool.findIndex((item) => item === request);
          pool.splice(index, 1);
        },
        // 有一个切片上传失败了
        j
      );

      if (pool.length >= max) {
        // 请求池达到最大,需要等待并发池空闲,才能上传后续分片
        await Promise.race(pool);
      }
    }
    // 等待所有请求结束
    await Promise.all(pool);
    r();
  });
}

/**
 * 并发上传切片(递归)
 * @param {FormData[]} chunksData - 切片数据
 * @param {string} hash - hash值
 * @param {number} needCount - 需要上传的切片数量(总的)
 * @param {number} uploadCount - 已经上传的切片数量
 */
async function chunksUpload03(
  chunksData,
  hash,
  needCount = chunksData.length,
  uploadCount = 0
) {
  return new Promise((done, fail) => {
    // 上传成功的数量
    let count = uploadCount;
    // 并发池
    const pool = [];
    // 最大并发数量
    const max = 6;
    return new Promise((r) => {
      upload(0);
      async function upload(index) {
        if (index >= chunksData.length) {
          // 等待所有请求结束
          await Promise.all(pool);
          console.log("切片上传完成!");
          return r();
        }
        const request = new Promise((resolve, reject) => {
          const xhr = new XMLHttpRequest();
          xhr.open("POST", `${baseURL}/upload`);
          xhr.send(chunksData[index]);
          xhr.onload = () => {
            resolve();
            // 移除暂停上传事件监听
            mitt.removeListener("cancel", handleCancel);
          };
          xhr.onerror = () => {
            reject();
            // 移除暂停上传事件监听
            mitt.removeListener("cancel", handleCancel);
          };
          // 监听暂停上传事件
          mitt.on("cancel", handleCancel);
          function handleCancel() {
            // 取消请求
            xhr.abort();
            // 保存文件切片和hash值
            recoverData.hash = hash;
            recoverData.chunksData = chunksData;
            fail();
          }
        });
        pool.push(request);
        request.then(() => {
          const requsetIndex = pool.findIndex((item) => item === request);
          pool.splice(requsetIndex, 1);
          textProxy.uploadProgress = (++count / needCount) * 100;
          if (count === chunksData.length) {
            done();
          }
        });
        if (pool.length >= max) {
          await Promise.race(pool);
        }
        await upload(index + 1);
      }
    });
  });
}

/**
 * 合并切片
 * @param {File} file
 * @param {string} hash
 * @param {number} count
 */
async function mergeChunk(file, hash, count) {
  const xhr = new XMLHttpRequest();
  xhr.open("POST", `${baseURL}/merge`);
  xhr.setRequestHeader("content-type", "application/json");
  xhr.send(
    JSON.stringify({
      file_name: file.name,
      file_hash: hash,
      chunk_count: count,
    })
  );

  return new Promise((r, j) => {
    xhr.onload = r;
    xhr.onerror = j;
  });
}

/**
 * 文件秒传
 * @param {string} hash
 * @returns
 */
async function checkExists(hash) {
  const xhr = new XMLHttpRequest();
  xhr.open("GET", `${baseURL}/check?file_hash=${hash}`);
  xhr.send();
  return new Promise((r, j) => {
    xhr.onload = (e) => {
      const res = JSON.parse(e.target.response);
      r(!!res.data);
    };
    xhr.onerror = j;
  });
}

/**
 * 恢复上传
 * @param {string} hash
 * @param {FormData[]} chunksData
 * @param {File} file
 */
async function recoverUpload(hash, chunksData, file) {
  // 查询文件是否存在
  const uploadChunksData = await new Promise((r, j) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
    xhr.onload = (e) => {
      try {
        const data = JSON.parse(e.target.response);
        r(data.map((item) => +item).sort((a, b) => a - b));
      } catch (error) {
        r(false);
      }
    };
    xhr.onerror = j;
    xhr.send();
  });
  if (!uploadChunksData) {
    // 不存在切片文件夹
    return;
  }
  // 构造出未上传的切片数据
  const needUploadChunksData = [];
  for (let i = 0; i < chunksData.length; i++) {
    const chunkHash = chunksData[i].get("chunk-hash");
    const index = uploadChunksData.findIndex((item) => {
      return `${hash}-${item}` === chunkHash;
    });
    if (index === -1) {
      // 保存未上传的切片
      needUploadChunksData.push(chunksData[i]);
    }
  }
  await chunksUpload02(
    needUploadChunksData,
    hash,
    chunksData.length,
    uploadChunksData.length
  );
  // 合并切片
  mergeChunk(file, hash, chunksData.length);
}

/**
 * 检查恢复重传
 * @param {string} hash - hash值
 * @param {Blob[]} chunks - 整个文件的所有切片
 */
async function checkRecoverUpload(hash, chunks) {
  // 查询文件是否存在
  const uploadChunksData = await new Promise((r, j) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
    xhr.onload = (e) => {
      try {
        const data = JSON.parse(e.target.response);
        r(data.map((item) => +item).sort((a, b) => a - b));
      } catch (error) {
        r(e.target.response);
      }
    };
    xhr.onerror = j;
    xhr.send();
  });
  if (uploadChunksData instanceof Array) {
    // 有记录
    // 过滤出需要重传的切片数据
    const needUploadChunk = [];
    for (let i = 0; i < chunks.length; i++) {
      if (!uploadChunksData.includes(i)) {
        // 未上传的文件切片
        const chunkData = new FormData();
        chunkData.append("file", chunks[i]);
        chunkData.append("file-hash", hash);
        chunkData.append("chunk-hash", `${hash}-${i}`);
        needUploadChunk.push(chunkData);
      }
    }
    return needUploadChunk;
  } else {
    // 无记录
    return false;
  }
}

/**
 * 构造切片数据
 * @param {Blob[]} chunks
 * @param {string} hash
 */
function createChunksData(chunks, hash) {
  return chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
}
const fileDOM = document.querySelector("input[type=file]");
const buttonDOM = document.querySelector("button");
const hashProgressDOM = document.querySelector("#hash-progress");
const uploadProgressDOM = document.querySelector("#upload-progress");
const chunkSize = 1 * 1024 * 1024;
const baseURL = "http://127.0.0.1:3000";
const text = {
  // 当前是在上传还是暂停状态
  isUploading: true,
  // hash计算进度
  hashProgress: 0,
  // 上传进度
  uploadProgress: 0,
};
const textProxy = new Proxy(text, {
  get(target, key) {
    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    if (key === "isUploading") {
      buttonDOM.innerText = value ? "暂停" : "恢复";
    } else if (key === "hashProgress") {
      hashProgressDOM.style.width = value + "%";
    } else if (key === "uploadProgress") {
      uploadProgressDOM.style.width = value + "%";
    }
  },
});
const mitt = new EventEmitter();
// 暂停的数据
const recoverData = {
  hash: "",
  chunksData: null,
  file: null,
};

/**
 * 监听文件域的变化
 */
fileDOM.addEventListener("change", function (e) {
  e.preventDefault();
  const file = this.files[0];
  file && uploadBid(file);
});

/**
 * 监听按钮的点击事件
 */
buttonDOM.addEventListener("click", () => {
  textProxy.isUploading = !textProxy.isUploading;
  if (textProxy.isUploading === false) {
    // 取消上传
    mitt.emit("cancel");
  } else {
    recoverUpload(recoverData.hash, recoverData.chunksData, recoverData.file);
  }
});

/**
 * 大文件上传的整个逻辑
 * @param {File} file
 */
async function uploadBid(file) {
  textProxy.hashProgress = 0;
  textProxy.uploadProgress = 0;
  // 将文件切片
  const chunks = getChunk(file, chunkSize);
  // 通过切片增量计算出整个文件的hash
  const hash = await getFileHash(chunks);
  const flag = await checkExists(hash);
  // 监听暂停事件,保存文件file
  mitt.on("cancel", () => {
    recoverData.file = file;
  });
  if (flag) {
    textProxy.uploadProgress = 100;
    alert("秒传成功!");
  } else {
    // 检测是否上传过切片了
    const res = await checkRecoverUpload(hash, chunks);
    if (res) {
      // 上传未上传的切片
      await chunksUpload02(
        res,
        hash,
        chunks.length,
        chunks.length - res.length
      );
    } else {
      // 上传所有切片
      await chunksUpload02(createChunksData(chunks, hash), hash);
    }
    // 合并切片
    await mergeChunk(file, hash, chunks.length);
    alert("上传成功!");
  }
}

/**
 * 获取切片
 * @param {File} file
 * @param {number} chunkSize
 * @returns
 */
function getChunk(file, chunkSize) {
  const result = [];
  for (let i = 0; i < file.size; i += chunkSize) {
    result.push(file.slice(i, i + chunkSize));
  }
  return result;
}

/**
 * 计算出整个文件的hash值
 * @param {Blob[]} chunks
 */
async function getFileHash(chunks) {
  // 计算了多少个切片的hash了
  let count = 0;
  // 整个文件的hash值
  const result = new SparkMD5();
  for (let i = 0; i < chunks.length; i++) {
    await new Promise((r, j) => {
      const fileReader = new FileReader();
      fileReader.onload = function () {
        result.append(this.result);
        // 计算hash计算进度
        textProxy.hashProgress = (++count / chunks.length) * 100;
        r();
      };
      fileReader.onerror = j;
      fileReader.readAsArrayBuffer(chunks[i]);
    });
  }
  return result.end();
}

/**
 * 上传所有分片(每六个分片上传)
 * @param {Blob[]} chunks -文件的所有切片
 * @param {string} hash -文件的hash
 */
async function chunkUpload(chunks, hash) {
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  // 并发上传的数量
  const max = 6;
  for (let i = 0; i < chunksData.length; i += max) {
    await Promise.all(
      chunksData
        .slice(i, Math.floor(i + max, chunksData.length))
        .map((data) => {
          return new Promise((r, j) => {
            const xhr = new XMLHttpRequest();
            xhr.open("POST", `${baseURL}/upload`);
            xhr.send(data);
            xhr.onload = r;
            xhr.onerror = j;
          });
        })
    );
  }
}

/**
 * 上传所有分片(并发控制六个请求)
 * @param {*} chunks
 * @param {*} hash
 */
async function chunksUpload(chunks, hash) {
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });

  const uploadPool = [];
  const max = 6;
  for (let i = 0; i < chunksData.length; i++) {
    const request = new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", `${baseURL}/upload`);
      xhr.send(chunksData[i]);
      xhr.onload = resolve;
      xhr.onerror = reject;
    });

    request.then(() => {
      const index = uploadPool.findIndex((req) => req === request);
      uploadPool.splice(index, 1);
    });

    uploadPool.push(request);

    if (uploadPool.length >= max) {
      // 当前任务已经开始,等待空闲位置
      const readyPromise = new Promise((resolve) => {
        const readyInterval = setInterval(() => {
          if (uploadPool.length < max) {
            clearInterval(readyInterval);
            resolve();
          }
        }, 100);
      });
      await Promise.race([request, readyPromise]);
    }
  }

  await Promise.all(uploadPool);
}

/**
 * 逐片上传切片
 * @param {Blob[]} chunks
 * @param {string} hash
 */
async function chunksUpload01(chunks, hash) {
  const chunksData = chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
  for (let i = 0; i < chunksData.length; i++) {
    await new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("POST", `${baseURL}/upload`);
      xhr.send(chunksData[i]);
      xhr.onload = resolve;
      xhr.onerror = reject;
    });
  }
}

/**
 * 并发上传切片(循环)
 * @param {FormData[]} chunksData - 切片数据
 * @param {string} hash - hash值
 * @param {number} needCount - 需要上传的切片数量(总的)
 * @param {number} uploadCount - 已经上传的切片数量
 */
function chunksUpload02(
  chunksData,
  hash,
  needCount = chunksData.length,
  uploadCount = 0
) {
  return new Promise(async (r, j) => {
    // 上传成功的数量
    let count = uploadCount;
    // 并发池
    const pool = [];
    // 最大并发数量
    const max = 6;
    for (let i = 0; i < chunksData.length; i++) {
      const request = new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open("POST", `${baseURL}/upload`);
        xhr.send(chunksData[i]);
        xhr.onload = () => {
          resolve();
          // 请求成功移除监听
          mitt.removeListener("cancel", handleAbort);
        };
        xhr.onerror = () => {
          reject();
          // 请求失败移除监听
          mitt.removeListener("cancel", handleAbort);
        };
        function handleAbort() {
          // 取消请求
          xhr.abort();
          if (chunksData.length === needCount) {
            // 第一次暂停保存全部的文件切片和hash值
            recoverData.hash = hash;
            recoverData.chunksData = chunksData;
          }
        }
        // 监听暂停上传的事件
        mitt.on("cancel", handleAbort);
      });
      // 将请求保存在并发池中
      pool.push(request);
      // 请求成功后,从并发池中移除该请求
      request.then(
        () => {
          // 计算上传进度
          textProxy.uploadProgress = (++count / needCount) * 100;
          const index = pool.findIndex((item) => item === request);
          pool.splice(index, 1);
        },
        // 有一个切片上传失败了
        j
      );

      if (pool.length >= max) {
        // 请求池达到最大,需要等待并发池空闲,才能上传后续分片
        await Promise.race(pool);
      }
    }
    // 等待所有请求结束
    await Promise.all(pool);
    r();
  });
}

/**
 * 并发上传切片(递归)
 * @param {FormData[]} chunksData - 切片数据
 * @param {string} hash - hash值
 * @param {number} needCount - 需要上传的切片数量(总的)
 * @param {number} uploadCount - 已经上传的切片数量
 */
async function chunksUpload03(
  chunksData,
  hash,
  needCount = chunksData.length,
  uploadCount = 0
) {
  return new Promise((done, fail) => {
    // 上传成功的数量
    let count = uploadCount;
    // 并发池
    const pool = [];
    // 最大并发数量
    const max = 6;
    return new Promise((r) => {
      upload(0);
      async function upload(index) {
        if (index >= chunksData.length) {
          // 等待所有请求结束
          await Promise.all(pool);
          console.log("切片上传完成!");
          return r();
        }
        const request = new Promise((resolve, reject) => {
          const xhr = new XMLHttpRequest();
          xhr.open("POST", `${baseURL}/upload`);
          xhr.send(chunksData[index]);
          xhr.onload = () => {
            resolve();
            // 移除暂停上传事件监听
            mitt.removeListener("cancel", handleCancel);
          };
          xhr.onerror = () => {
            reject();
            // 移除暂停上传事件监听
            mitt.removeListener("cancel", handleCancel);
          };
          // 监听暂停上传事件
          mitt.on("cancel", handleCancel);
          function handleCancel() {
            // 取消请求
            xhr.abort();
            // 保存文件切片和hash值
            recoverData.hash = hash;
            recoverData.chunksData = chunksData;
            fail();
          }
        });
        pool.push(request);
        request.then(() => {
          const requsetIndex = pool.findIndex((item) => item === request);
          pool.splice(requsetIndex, 1);
          textProxy.uploadProgress = (++count / needCount) * 100;
          if (count === chunksData.length) {
            done();
          }
        });
        if (pool.length >= max) {
          await Promise.race(pool);
        }
        await upload(index + 1);
      }
    });
  });
}

/**
 * 合并切片
 * @param {File} file
 * @param {string} hash
 * @param {number} count
 */
async function mergeChunk(file, hash, count) {
  const xhr = new XMLHttpRequest();
  xhr.open("POST", `${baseURL}/merge`);
  xhr.setRequestHeader("content-type", "application/json");
  xhr.send(
    JSON.stringify({
      file_name: file.name,
      file_hash: hash,
      chunk_count: count,
    })
  );

  return new Promise((r, j) => {
    xhr.onload = r;
    xhr.onerror = j;
  });
}

/**
 * 文件秒传
 * @param {string} hash
 * @returns
 */
async function checkExists(hash) {
  const xhr = new XMLHttpRequest();
  xhr.open("GET", `${baseURL}/check?file_hash=${hash}`);
  xhr.send();
  return new Promise((r, j) => {
    xhr.onload = (e) => {
      const res = JSON.parse(e.target.response);
      r(!!res.data);
    };
    xhr.onerror = j;
  });
}

/**
 * 恢复上传
 * @param {string} hash
 * @param {FormData[]} chunksData
 * @param {File} file
 */
async function recoverUpload(hash, chunksData, file) {
  // 查询文件是否存在
  const uploadChunksData = await new Promise((r, j) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
    xhr.onload = (e) => {
      try {
        const data = JSON.parse(e.target.response);
        r(data.map((item) => +item).sort((a, b) => a - b));
      } catch (error) {
        r(false);
      }
    };
    xhr.onerror = j;
    xhr.send();
  });
  if (!uploadChunksData) {
    // 不存在切片文件夹
    return;
  }
  // 构造出未上传的切片数据
  const needUploadChunksData = [];
  for (let i = 0; i < chunksData.length; i++) {
    const chunkHash = chunksData[i].get("chunk-hash");
    const index = uploadChunksData.findIndex((item) => {
      return `${hash}-${item}` === chunkHash;
    });
    if (index === -1) {
      // 保存未上传的切片
      needUploadChunksData.push(chunksData[i]);
    }
  }
  await chunksUpload02(
    needUploadChunksData,
    hash,
    chunksData.length,
    uploadChunksData.length
  );
  // 合并切片
  mergeChunk(file, hash, chunksData.length);
}

/**
 * 检查恢复重传
 * @param {string} hash - hash值
 * @param {Blob[]} chunks - 整个文件的所有切片
 */
async function checkRecoverUpload(hash, chunks) {
  // 查询文件是否存在
  const uploadChunksData = await new Promise((r, j) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", `${baseURL}/check-chunk?file_hash=${hash}`);
    xhr.onload = (e) => {
      try {
        const data = JSON.parse(e.target.response);
        r(data.map((item) => +item).sort((a, b) => a - b));
      } catch (error) {
        r(e.target.response);
      }
    };
    xhr.onerror = j;
    xhr.send();
  });
  if (uploadChunksData instanceof Array) {
    // 有记录
    // 过滤出需要重传的切片数据
    const needUploadChunk = [];
    for (let i = 0; i < chunks.length; i++) {
      if (!uploadChunksData.includes(i)) {
        // 未上传的文件切片
        const chunkData = new FormData();
        chunkData.append("file", chunks[i]);
        chunkData.append("file-hash", hash);
        chunkData.append("chunk-hash", `${hash}-${i}`);
        needUploadChunk.push(chunkData);
      }
    }
    return needUploadChunk;
  } else {
    // 无记录
    return false;
  }
}

/**
 * 构造切片数据
 * @param {Blob[]} chunks
 * @param {string} hash
 */
function createChunksData(chunks, hash) {
  return chunks.map((item, index) => {
    const data = new FormData();
    data.append("file", item);
    data.append("file-hash", hash);
    data.append("chunk-hash", `${hash}-${index}`);
    return data;
  });
}

后端

ts
const express = require("express");
const multer = require("multer");
const {
  existsSync,
  createWriteStream,
  createReadStream,
  readdirSync,
  writeFile,
  mkdirSync,
  rmdir,
  unlink,
  rmdirSync,
} = require("fs");
const { resolve } = require("path");
const upload = multer({ storage: multer.memoryStorage() });

// 切片文件夹
const tempPath = resolve(__dirname, "./static/temp");
// 合并切片的文件夹
const uploadPath = resolve(__dirname, "./static/upload");
// 客户端文件夹
const clientPath = resolve(__dirname, "./client");
// 切片固定大小
const chunkSize = 1 * 1024 * 1024;
const app = express();

// 解析json格式数据
app.use((req, res, next) => {
  if (
    req.headers["content-type"] &&
    req.headers["content-type"].includes("application/json")
  ) {
    let json = "";
    req.on("data", (chunk) => {
      json += chunk;
    });
    req.on("error", () => {
      res.status(400);
      res.send("json error!");
    });
    req.on("end", () => {
      try {
        const data = JSON.parse(json);
        req.body = data;
        next();
      } catch (error) {
        res.status(400);
        res.send("json error!");
      }
    });
  } else {
    next();
  }
});

// 请求文档资源
app.use((req, res, next) => {
  if (req.path.startsWith("/client")) {
    const filePath = resolve(__dirname, `./${req.path}`);
    if (!existsSync(filePath)) {
      res.status(404);
      res.send("not found");
      return;
    }
    res.setHeader("content-type", "text/javascript");
    createReadStream(filePath).pipe(res);
  } else {
    next();
  }
});

// 请求文档
app.get("/", (_, res) => {
  createReadStream(resolve(clientPath, "./index.html")).pipe(res);
});

// 切片上传接口(不包含秒传的逻辑)
app.post("/upload", upload.single("file"), function (req, res, next) {
  const file_hash = req.body["file-hash"]; // 约定前端传入file-hash代表文件的hash值
  const chunk_hash = req.body["chunk-hash"]; // 约定传入chunk-hash代表切片的hash值
  const file = req.file;
  const [_, chunk_index] = chunk_hash.split("-"); // 约定切片的hash值的格式为:文件hash-切片索引
  if (!chunk_hash || Number.isNaN(+chunk_index) || _ !== file_hash) {
    res.status(400);
    res.send("error");
  }
  // 以文件hash为文件名,将此文件的所有切片存在此文件夹
  const chunkDirPath = resolve(tempPath, `./${file_hash}`);
  // 切片的路径
  const chunkPath = resolve(chunkDirPath, `./${chunk_hash}`);
  if (!existsSync(chunkDirPath)) {
    // 不存在切片文件夹,说明首次上传,创建切片文件夹
    mkdirSync(chunkDirPath);
  }
  // 将切片写入到对应的文件夹中
  writeFile(chunkPath, file.buffer, (err) => {
    if (err) {
      res.send(err);
    } else {
      res.send("ok");
    }
  });
});

// 合并切片文件
app.post("/merge", async (req, res) => {
  const { file_name, file_hash, chunk_count } = req.body;
  // 非法校验
  if (!file_hash || !file_hash || !chunk_count || Number.isNaN(+chunk_count))
    return;
  const chunkCount = +chunk_count;
  // 文件切片的路径
  const chunkDirPath = resolve(tempPath, `./${file_hash}`);
  if (!existsSync(chunkDirPath)) {
    // 不存在此文件的切片
    res.status(400);
    res.send("error!");
    return;
  }
  // 1.切片文件合并
  // 读取切片文件夹中的所有文件
  const chunksName = readdirSync(chunkDirPath);
  // 1.5 校验切片文件是否完整
  console.log(chunksName.length, chunkCount);
  const indexs = chunksName
    .map((item) => {
      const [_, index] = item.split("-");
      return +index;
    })
    .sort((a, b) => a - b);
  // 检测切片长度是否相同 || 检测切片文件名称是否有误
  if (
    chunksName.length !== chunkCount ||
    indexs.some((index) => Number.isNaN(index))
  ) {
    // 删除所有切片文件
    await Promise.all(
      chunksName.map((chunkName) => {
        return new Promise((r, j) => {
          const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
          unlink(chunkPath, (err) => {
            if (err) {
              j(err);
            } else {
              r();
            }
          });
        });
      })
    );
    // 删除切片文件夹
    rmdirSync(chunkDirPath);
    res.status(500);
    res.send("合并失败");
    return;
  }
  // 检测切片文件是否完整
  for (let i = 0; i < indexs.length; i++) {
    if (i !== indexs[i]) {
      // 删除所有切片文件
      // 删除所有切片文件
      await Promise.all(
        chunksName.map((chunkName) => {
          return new Promise((r, j) => {
            const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
            unlink(chunkPath, (err) => {
              if (err) {
                j(err);
              } else {
                r();
              }
            });
          });
        })
      );
      // 删除切片文件夹
      rmdirSync(chunkDirPath);
      res.status(500);
      res.send("合并失败");
      return;
    }
  }

  // 合并文件的路径 文件名格式为:hash-原名称
  const filePath = resolve(uploadPath, `./${file_hash}-${file_name}`);
  await Promise.all(
    chunksName.map((chunkName) => {
      // 获取切片路径和切片索引
      const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
      const [_, index] = chunkName.split("-");
      if (Number.isNaN(index)) {
        // 合并失败
        res.status(400);
        res.send("error!");
      }
      // 创建合并文件
      const ws = createWriteStream(filePath, {
        start: +index * chunkSize,
      });
      // 将切片文件写入到合并文件中
      return new Promise((r, j) => {
        const rs = createReadStream(chunkPath);
        rs.pipe(ws);
        rs.on("end", r);
      });
    })
  );
  // 2.将切片文件删除
  try {
    // 删除所有切片文件
    await Promise.all(
      chunksName.map((chunkName) => {
        return new Promise((r, j) => {
          const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
          unlink(chunkPath, (err) => {
            if (err) {
              j(err);
            } else {
              r();
            }
          });
        });
      })
    );
    // 删除切片文件夹
    rmdir(chunkDirPath, (err) => {
      if (err) {
        throw new Error(err);
      } else {
        res.send("ok!");
      }
    });
  } catch (error) {
    res.status(500);
    res.send("error!");
  }
});

// 文件秒传(查询文件是否上传过了)
app.get("/check", (req, res) => {
  const { file_hash } = req.query;
  if (!file_hash) {
    res.status(400);
    return res.send("error");
  }
  const filesName = readdirSync(uploadPath);
  const flag = filesName.some((filename) => {
    return filename.startsWith(file_hash);
  });
  if (flag) {
    res.send({
      code: 200,
      msg: "ok",
      data: 1, // 存在
    });
  } else {
    res.send({
      code: 200,
      msg: "ok",
      data: 0, // 不存在
    });
  }
});

// 查询切片上传得进度
app.get("/check-chunk", (req, res) => {
  const { file_hash } = req.query;
  if (!file_hash) {
    res.status(400);
    return res.send("error");
  }
  // 查找此切片文件夹是否存在
  const chunksDirPath = resolve(tempPath, `./${file_hash}`);
  if (!existsSync(chunksDirPath)) {
    // 未找到此切片文件夹,可能没有上传过,可能已经合并完成了
    res.status(404);
    return res.send("not found");
  }
  // 读出此切片文件夹中已经上传的切片索引
  const chunksIndex = readdirSync(chunksDirPath).map((chunkname) => {
    const [_, index] = chunkname.split("-");
    return index;
  });
  res.send(chunksIndex);
});

app.listen(3000);
const express = require("express");
const multer = require("multer");
const {
  existsSync,
  createWriteStream,
  createReadStream,
  readdirSync,
  writeFile,
  mkdirSync,
  rmdir,
  unlink,
  rmdirSync,
} = require("fs");
const { resolve } = require("path");
const upload = multer({ storage: multer.memoryStorage() });

// 切片文件夹
const tempPath = resolve(__dirname, "./static/temp");
// 合并切片的文件夹
const uploadPath = resolve(__dirname, "./static/upload");
// 客户端文件夹
const clientPath = resolve(__dirname, "./client");
// 切片固定大小
const chunkSize = 1 * 1024 * 1024;
const app = express();

// 解析json格式数据
app.use((req, res, next) => {
  if (
    req.headers["content-type"] &&
    req.headers["content-type"].includes("application/json")
  ) {
    let json = "";
    req.on("data", (chunk) => {
      json += chunk;
    });
    req.on("error", () => {
      res.status(400);
      res.send("json error!");
    });
    req.on("end", () => {
      try {
        const data = JSON.parse(json);
        req.body = data;
        next();
      } catch (error) {
        res.status(400);
        res.send("json error!");
      }
    });
  } else {
    next();
  }
});

// 请求文档资源
app.use((req, res, next) => {
  if (req.path.startsWith("/client")) {
    const filePath = resolve(__dirname, `./${req.path}`);
    if (!existsSync(filePath)) {
      res.status(404);
      res.send("not found");
      return;
    }
    res.setHeader("content-type", "text/javascript");
    createReadStream(filePath).pipe(res);
  } else {
    next();
  }
});

// 请求文档
app.get("/", (_, res) => {
  createReadStream(resolve(clientPath, "./index.html")).pipe(res);
});

// 切片上传接口(不包含秒传的逻辑)
app.post("/upload", upload.single("file"), function (req, res, next) {
  const file_hash = req.body["file-hash"]; // 约定前端传入file-hash代表文件的hash值
  const chunk_hash = req.body["chunk-hash"]; // 约定传入chunk-hash代表切片的hash值
  const file = req.file;
  const [_, chunk_index] = chunk_hash.split("-"); // 约定切片的hash值的格式为:文件hash-切片索引
  if (!chunk_hash || Number.isNaN(+chunk_index) || _ !== file_hash) {
    res.status(400);
    res.send("error");
  }
  // 以文件hash为文件名,将此文件的所有切片存在此文件夹
  const chunkDirPath = resolve(tempPath, `./${file_hash}`);
  // 切片的路径
  const chunkPath = resolve(chunkDirPath, `./${chunk_hash}`);
  if (!existsSync(chunkDirPath)) {
    // 不存在切片文件夹,说明首次上传,创建切片文件夹
    mkdirSync(chunkDirPath);
  }
  // 将切片写入到对应的文件夹中
  writeFile(chunkPath, file.buffer, (err) => {
    if (err) {
      res.send(err);
    } else {
      res.send("ok");
    }
  });
});

// 合并切片文件
app.post("/merge", async (req, res) => {
  const { file_name, file_hash, chunk_count } = req.body;
  // 非法校验
  if (!file_hash || !file_hash || !chunk_count || Number.isNaN(+chunk_count))
    return;
  const chunkCount = +chunk_count;
  // 文件切片的路径
  const chunkDirPath = resolve(tempPath, `./${file_hash}`);
  if (!existsSync(chunkDirPath)) {
    // 不存在此文件的切片
    res.status(400);
    res.send("error!");
    return;
  }
  // 1.切片文件合并
  // 读取切片文件夹中的所有文件
  const chunksName = readdirSync(chunkDirPath);
  // 1.5 校验切片文件是否完整
  console.log(chunksName.length, chunkCount);
  const indexs = chunksName
    .map((item) => {
      const [_, index] = item.split("-");
      return +index;
    })
    .sort((a, b) => a - b);
  // 检测切片长度是否相同 || 检测切片文件名称是否有误
  if (
    chunksName.length !== chunkCount ||
    indexs.some((index) => Number.isNaN(index))
  ) {
    // 删除所有切片文件
    await Promise.all(
      chunksName.map((chunkName) => {
        return new Promise((r, j) => {
          const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
          unlink(chunkPath, (err) => {
            if (err) {
              j(err);
            } else {
              r();
            }
          });
        });
      })
    );
    // 删除切片文件夹
    rmdirSync(chunkDirPath);
    res.status(500);
    res.send("合并失败");
    return;
  }
  // 检测切片文件是否完整
  for (let i = 0; i < indexs.length; i++) {
    if (i !== indexs[i]) {
      // 删除所有切片文件
      // 删除所有切片文件
      await Promise.all(
        chunksName.map((chunkName) => {
          return new Promise((r, j) => {
            const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
            unlink(chunkPath, (err) => {
              if (err) {
                j(err);
              } else {
                r();
              }
            });
          });
        })
      );
      // 删除切片文件夹
      rmdirSync(chunkDirPath);
      res.status(500);
      res.send("合并失败");
      return;
    }
  }

  // 合并文件的路径 文件名格式为:hash-原名称
  const filePath = resolve(uploadPath, `./${file_hash}-${file_name}`);
  await Promise.all(
    chunksName.map((chunkName) => {
      // 获取切片路径和切片索引
      const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
      const [_, index] = chunkName.split("-");
      if (Number.isNaN(index)) {
        // 合并失败
        res.status(400);
        res.send("error!");
      }
      // 创建合并文件
      const ws = createWriteStream(filePath, {
        start: +index * chunkSize,
      });
      // 将切片文件写入到合并文件中
      return new Promise((r, j) => {
        const rs = createReadStream(chunkPath);
        rs.pipe(ws);
        rs.on("end", r);
      });
    })
  );
  // 2.将切片文件删除
  try {
    // 删除所有切片文件
    await Promise.all(
      chunksName.map((chunkName) => {
        return new Promise((r, j) => {
          const chunkPath = resolve(chunkDirPath, `./${chunkName}`);
          unlink(chunkPath, (err) => {
            if (err) {
              j(err);
            } else {
              r();
            }
          });
        });
      })
    );
    // 删除切片文件夹
    rmdir(chunkDirPath, (err) => {
      if (err) {
        throw new Error(err);
      } else {
        res.send("ok!");
      }
    });
  } catch (error) {
    res.status(500);
    res.send("error!");
  }
});

// 文件秒传(查询文件是否上传过了)
app.get("/check", (req, res) => {
  const { file_hash } = req.query;
  if (!file_hash) {
    res.status(400);
    return res.send("error");
  }
  const filesName = readdirSync(uploadPath);
  const flag = filesName.some((filename) => {
    return filename.startsWith(file_hash);
  });
  if (flag) {
    res.send({
      code: 200,
      msg: "ok",
      data: 1, // 存在
    });
  } else {
    res.send({
      code: 200,
      msg: "ok",
      data: 0, // 不存在
    });
  }
});

// 查询切片上传得进度
app.get("/check-chunk", (req, res) => {
  const { file_hash } = req.query;
  if (!file_hash) {
    res.status(400);
    return res.send("error");
  }
  // 查找此切片文件夹是否存在
  const chunksDirPath = resolve(tempPath, `./${file_hash}`);
  if (!existsSync(chunksDirPath)) {
    // 未找到此切片文件夹,可能没有上传过,可能已经合并完成了
    res.status(404);
    return res.send("not found");
  }
  // 读出此切片文件夹中已经上传的切片索引
  const chunksIndex = readdirSync(chunksDirPath).map((chunkname) => {
    const [_, index] = chunkname.split("-");
    return index;
  });
  res.send(chunksIndex);
});

app.listen(3000);