文件切片上传(断点续传)

上传文件体积一大,要等很久。如果只是水管小,等一会也就算了。但是如果网络差,上传到 99% 突然断网了又重新开始,就比较难受了。所以又有了文件切片进行断点上传的方法。

上传的文件对象是一个 File 对象,然后它是 Blob 对象的一个子类。再然后它有一个 slice 方法,用这个方法就可以对文件进行切片,就和数组的这个方法一样。用法如示例。

话不多说直接上代码(里面代码是问了 chatgpt 然后改的,哈哈)。把下面两份代码分别保存为 index.htmlserver.js,安装好依赖,然后启动执行 node server.js,访问 http://localhost:3000

前端也大概美化了一下上传组件,支持点击选择文件或者拖拽文件上传。原生的 <input type="file" ></input> 组件的样式应该没人用吧,那么丑…。其中的 loading 效果出自网上大佬的《纯css实现117个Loading效果》,里面挺多效果不错的。

前端代码

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>upload</title>
<style>
.upload {
  padding: 10px;
  width: 200px;
  height: 200px;
  border-radius: 8px;
  border: 1px solid #ccc;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  color: gray;
  font-size: 24px;
}
#upload-target {
  position: absolute;
  width: 100%;
  height: 100%;
  cursor: pointer;
}
#alert {
  display: none;
  position: absolute;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
}
#loading-frame {
  width: 100%;
  height: 100%;
  background-color: #ccc;
  opacity: 0.7;
  text-align: center;
  display: flex;
  justify-content: center;
  align-items: center;
}
#text {
  position: absolute;
  text-align: center;
  bottom: 10px;
  width: 100%;
  color: black;
  font-size: 16px;
}
#progress {
  position: absolute;
  bottom: 10px;
  font-size: 22px;
}
.loading,
.loading > div {
  position: relative;
  box-sizing: border-box;
}

.loading {
  display: block;
  font-size: 0;
  color: #000;
}

.loading.la-dark {
  color: #333;
}

.loading > div {
  display: inline-block;
  float: none;
  background-color: currentColor;
  border: 0 solid currentColor;
}

.loading {
  width: 64px;
  height: 64px;
}

.loading > div {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 10px;
  height: 10px;
  margin-top: -4px;
  margin-left: -4px;
  border-radius: 100%;
  animation: ball-spin-clockwise 1s infinite ease-in-out;
}

.loading > div:nth-child(1) {
  top: 5%;
  left: 50%;
  animation-delay: -0.875s;
}

.loading > div:nth-child(2) {
  top: 18.1801948466%;
  left: 81.8198051534%;
  animation-delay: -0.75s;
}

.loading > div:nth-child(3) {
  top: 50%;
  left: 95%;
  animation-delay: -0.625s;
}

.loading > div:nth-child(4) {
  top: 81.8198051534%;
  left: 81.8198051534%;
  animation-delay: -0.5s;
}

.loading > div:nth-child(5) {
  top: 94.9999999966%;
  left: 50.0000000005%;
  animation-delay: -0.375s;
}

.loading > div:nth-child(6) {
  top: 81.8198046966%;
  left: 18.1801949248%;
  animation-delay: -0.25s;
}

.loading > div:nth-child(7) {
  top: 49.9999750815%;
  left: 5.0000051215%;
  animation-delay: -0.125s;
}

.loading > div:nth-child(8) {
  top: 18.179464974%;
  left: 18.1803700518%;
  animation-delay: 0s;
}

.loading.la-sm {
  width: 16px;
  height: 16px;
}

.loading.la-sm > div {
  width: 4px;
  height: 4px;
  margin-top: -2px;
  margin-left: -2px;
}

.loading.la-2x {
  width: 64px;
  height: 64px;
}

.loading.la-2x > div {
  width: 16px;
  height: 16px;
  margin-top: -8px;
  margin-left: -8px;
}

.loading.la-3x {
  width: 96px;
  height: 96px;
}

.loading.la-3x > div {
  width: 24px;
  height: 24px;
  margin-top: -12px;
  margin-left: -12px;
}

@keyframes ball-spin-clockwise {
  0%,
  100% {
    opacity: 1;
    transform: scale(1);
  }

  20% {
    opacity: 1;
  }

  80% {
    opacity: 0;
    transform: scale(0);
  }
}
</style>
</head>
<body>
  <div class="upload" id="upload">
    click or drop file here
    <div id="upload-target"></div>
    <div id="alert">
      <div id="loading-frame">
        <div class="loading">
          <div></div>
          <div></div>
          <div></div>
          <div></div>
          <div></div>
          <div></div>
          <div></div>
          <div></div>
        </div>
      </div>
      <p id="text"></p>
      <progress id="progress" max="100"></progress>
    </div>
  </div>
  <script>
    // 这个方法可以在页面没有上传元素场景下,自行触发上传事件,也就是文件选择框,然后返回值就是上传的文件流
    function fileUpload() {
      return new Promise((resolve, reject) => {
        const $file = document.createElement('input');
        $file.type = 'file';
        $file.addEventListener('change', (e) => {
          resolve(e.target.files[0]);
        });
        $file.click();
      });
    }
    // 将上传文件根据设定的大小切片
    function sliceFile(file, chunkSize) {
      const fileSize = file.size;
      let offset = 0;
      const chunks = [];
      while (offset < fileSize) {
        const chunk = file.slice(offset, offset + chunkSize);
        chunks.push(chunk);
        offset += chunkSize;
      }
      return chunks;
    }
    // 递归,把切片一个个按顺序上传
    function uploadFileChunks(file, chunkSize, uploadUrl) {
      const chunks = sliceFile(file, chunkSize);
      const totalChunks = chunks.length;
      let currentChunk = 0;

      function uploadNextChunk() {
        const chunk = chunks[currentChunk];
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('index', currentChunk);
        formData.append('total', totalChunks);
        formData.append('filename', file.name);

        fetch(uploadUrl, {
          method: 'POST',
          body: formData
        })
        .then(response => {
          if (response.ok) {
            currentChunk++;
            if (currentChunk < totalChunks) {
              uploadNextChunk();
            } else {
              console.log('File upload complete!');
            }
          } else {
            console.error('Error uploading file chunk:', response.statusText);
          }
        })
        .catch(error => {
          console.error('Error uploading file chunk:', error);
        });
      }
      uploadNextChunk();
    }

    const chunkSize = 1 * 1024 * 1024; // 以 1MB 为单位切片

    const $alert = document.getElementById('alert');
    const $progress = document.getElementById('progress');
    const $text = document.getElementById('text');
    const $upload = document.getElementById('upload-target');

    const handUpload = async (file) => {
      $alert.style.display = 'block';
      $text.innerText = `${0}%`;
      try {
        console.time('uploadTask');
        await uploadFileChunks(file, chunkSize, '/api/slice/upload', (currentChunk, totalChunks) => {
          const value = Math.round(currentChunk / totalChunks * 100);
          $progress.value = value;
          $text.innerText = `${value}%`;
        });
        console.timeEnd('uploadTask');
        // 这个延迟只是为了能有显示到 100% 的效果
        setTimeout(() => {
          $alert.style.display = 'none';
        }, 1000);
      } catch(_) {}
    };

    $upload.onclick = async function(e) {
      const file = await fileUpload();
      handUpload(file);
    };
    // 一定要加这个 ondragover 事件,然后 preventDefault,不然文件拖到浏览器就变成下载了
    $upload.ondragover = async function(e) {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'copy';
    }
    $upload.ondrop = async function(e) {
      e.preventDefault();
      const file = e.dataTransfer.items
        ? e.dataTransfer.items[0].getAsFile()
        : e.dataTransfer.files[0];
      handUpload(file);
    }
  </script>
</body>
</html>

后端代码

这里用了 node,我也只会node。用了 express 和 fs-extra,先安装一下。

npm i express fs-extra
const path = require('path');
const fse = require('fs-extra');
const express = require('express');
const multer  = require('multer');

const app = express();
const uploadFileDir = path.resolve(process.cwd(), 'uploadfile');
const uploadMulter = multer({ dest: uploadFileDir });

// 合并切片文件
function mergeChunks(chunksDir, destFilePath) {
  let resolve, reject;
  const chunkFiles = fse.readdirSync(chunksDir).sort((a, b) => a - b);
  const destStream = fse.createWriteStream(destFilePath);

  destStream.on('error', (error) => {
    reject && reject(error);
  });

  const writeNext = () => {
    if (!chunkFiles.length) {
      resolve && resolve();
      return destStream.end();
    }
    const chunkFile = chunkFiles.shift();
    const readStream = fse.createReadStream(path.join(chunksDir, chunkFile));
    readStream.on('end', () => {
      writeNext();
    });
    readStream.on('error', (error) => {
      reject && reject(error);
    });
    readStream.pipe(destStream, { end: false });
    // end是pipe方法的可选参数,用于指定在写入完所有数据后,是否自动关闭写入流。
    // 如果end为true,则在写入完所有数据后,写入流会自动关闭。
    // 如果end为false,则写入流不会自动关闭,需要手动调用end方法来关闭写入流。
    // 在上面的代码中,end被设置为false,这是因为我们要在所有切片文件都被写入到目标文件后,手动调用end方法来关闭写入流。
    // 如果end被设置为true,则在写入完第一个切片文件后,写入流就会自动关闭,导致后续的切片文件无法写入目标文件。
    // 因此,当我们需要手动控制写入流何时关闭时,需要将end设置为false。
  };
  writeNext();

  return new Promise((_resolve, _reject) => {
    resolve = _resolve;
    reject = _reject
  });
}

const uploadHash = {};
app.post('/api/slice/upload', uploadMulter.any(), (req, res) => {
  let { filename, total, index } = req.body;
  total = parseInt(total);
  index = parseInt(index);
  ext = path.parse(filename).ext;
  filename = path.parse(filename).name;

  if (uploadHash[filename] && uploadHash[filename].list.includes(index)) {
    return res.send('成功已上传');
  }

  const newName = path.join(uploadFileDir, filename, `${index}`);
  fse.move(req.files[0].path, newName, { overwrite: true }, (err) => {
    if(err) return res.send('上传失败');

    // 把已上传成功的文件记录起来
    if (!uploadHash[filename]) uploadHash[filename] = { total, list: [] };
    uploadHash[filename].list.push(index);

    // 接受的文件合集已完整,合并切片文件
    if (uploadHash[filename].list.length === total) {
      return mergeChunks(
        path.join(uploadFileDir, filename),
        path.join(uploadFileDir, filename + ext),
      )
        .then(() => {
          // 注释掉下面删除语句,页面刷新再次请求可看到直接返回成功
          delete uploadHash[filename];
          res.send('成功合并');
        })
        .catch((error) => {
          res.send('上传失败');
        });
    }
    return res.send('成功');
  });
});

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'index.html'));
});

const server = app.listen(3000, () => {

  const host = server.address().address;
  const port = server.address().port;
 
  console.info('应用实例,访问地址为 http://%s:%s', host, port);
});

其实感觉前端能做的也就是切片然后分多次上传。重点在于后端需要保存后,记录已上传的片段。这里只是简单地记录在内存,而且也只是简单地用序号做 hash 标识,生产使用的话应该是用文件内容生成 md5 来做唯一标识。

同时也有很多意外情况需要处理,过期文件,反复上传什么的都要考虑。

网上有个 resumable.js 是专门做这个工作的,稍微看了一下,示例还挺齐全,前后端用法都有。记录一下,后面真有用到再看看。