【功能实现】基于Vue3+TS实现大文件分片上传

功能:大文件上传

文章目录

业务背景

  1. 媒体处理:视频编辑平台、音频处理软件、图像库等需要上传大量媒体文件。
  2. 数据备份与迁移:企业需要备份或迁移大量数据,包括数据库文件、系统镜像等。
  3. 内容分发网络:在CDN中上传大文件以便更快地在全球范围内分发。
  4. 科学与研究:上传大型数据集,例如基因组序列、气象模型数据等。
  5. 教育和在线学习:上传高质量的教学视频和教材。
  6. 法律和财务:共享大量的法律文档或财务报表。

面临挑战

  1. 性能问题:大文件上传可能导致客户端(浏览器)性能下降,特别是在资源有限的设备上。
  2. 网络不稳定:大文件更有可能在上传过程中遇到网络问题,如断线、超时等。
  3. 服务器负载:大文件上传会给服务器带来更大的负载,特别是在处理大量此类请求时。
  4. 用户体验:长时间的上传过程可能导致用户感到不耐烦,影响用户体验。
  5. 文件完整性和安全性:确保文件在传输过程中不被破坏或篡改,同时保证数据的隐私和安全。
  6. 断点续传:支持在网络中断后能够继续上传,而不是重新开始。
  7. 数据处理:大文件需要更复杂的处理流程,例如切片、压缩和解压缩。
  8. 兼容性和标准化:确保各种浏览器和设备都能顺利完成上传过程。

拖拽文件功能

拖拽API

事件

  1. dragenter :当拖动的元素或选中的文本进入有效拖放目标时触发。在这里,它用于初始化拖拽进入目标区域的行为。
  2. dragover :当元素或选中的文本在有效拖放目标上方移动时触发。通常用于阻止默认的处理方式,从而允许放置。
  3. drop :当拖动的元素或选中的文本在有效拖放目标上被放置时触发。这是处理文件放置逻辑的关键点。
  4. dragleave :当拖动的元素或选中的文本离开有效拖放目标时触发。可以用于处理元素拖离目标区域的行为。

API

  • event.preventDefault():阻止事件的默认行为。在拖放事件中,这通常用于阻止浏览器默认的文件打开行为。
  • event.stopPropagation():阻止事件冒泡到父元素。这可以防止嵌套元素的拖放事件影响到外层元素。
  • event.dataTransfer:一个包含拖放操作数据的对象。在 drop 事件中,它可以用来获取被拖放的文件。
  • filesevent.dataTransfer.files 是一个包含了所有拖放的文件的 FileList 对象。可以通过这个对象来访问和处理拖放的文件。

URL.createObjectURL

URL.createObjectURL 是一个非常实用的 Web API,它允许你创建一个指向特定文件对象或 Blob(Binary Large Object)的 URL。这个 URL 可以用于存储用户本地的文件数据,而无需实际上传文件到服务器。

代码实践

拖拽文件

首先搭建一个模板:

js 复制代码
<template>
  <div class="box">
    <div class="upload-container" ref="uploadRef">
      <el-icon><Upload /></el-icon>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";

const uploadRef = ref(null);
</script>

<style scoped lang="scss">
.box {
  display: flex;
  height: 100vh;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}
.upload-container {
  width: 10%;
  height: 100px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 4px dashed #d3cbcb;
  background-color: #c9e7ff;
  &:hover {
    border-color: #40a9ff;
  }
  & span {
    font-size: 60px;
  }
}
</style>

./useDrag中编写hooks:

js 复制代码
import { onMounted, onUnmounted, ref, watch } from "vue";

function useDrag(uploadContainerRef: any) {
    const prohibitEvent = (e: any) => {
        e.preventDefault(); // 阻止浏览器默认行为
        e.stopPropagation(); // 阻止事件冒泡
    }
    // 拖拽事件处理
    const handleDrag = (e: any) => {
        prohibitEvent(e)
    }
    // 拖拽文件释放
    const handleDrop = (e: any) => {
        prohibitEvent(e)
        const { files } = e.dataTransfer  // 一个包含拖放操作数据的对象,它可以用来获取被拖放的文件
        console.log("🚀 ~ handleDrop ~ files:", files)
    }

    onMounted(() => {
        const uploadContainer = uploadContainerRef.value;
        if (uploadContainer) {
            // 进入拖放目标
            uploadContainer.addEventListener("dragenter", handleDrag);
            // 拖放目标上方移动
            uploadContainer.addEventListener("dragover", handleDrag);
            // 拖放目标上被放置时
            uploadContainer.addEventListener("drop", handleDrop);
            // 离开有效拖放目标
            uploadContainer.addEventListener("dragleave", handleDrag);
        }
    })
    onUnmounted(() => {
        const uploadContainer = uploadContainerRef.value;
        if (uploadContainer) {
            uploadContainer.removeEventListener("dragenter", handleDrag);
            uploadContainer.removeEventListener("dragover", handleDrag);
            uploadContainer.removeEventListener("drop", handleDrop);
            uploadContainer.removeEventListener("dragleave", handleDrag);

        }
    })

}
export default useDrag;

此时我们尝试拖入文件,可以看到控制台打印出内容:

文件预览

添加存储选择文件变量和存储预览文件

js 复制代码
// 存储选择文件
const selectFile = ref<File | null>(null);
// 存储预览文件
const previewFiles = ref<Type_PreviewFile | null>(null);

添加检查文件方法

js 复制代码
// 检查文件方法
const checkFile = (files: Array<File>) => {
    const file = files[0];
    // 判断非空
    if (!file) {
        ElMessage.error('没有选择任何文件')
        return
    }
    // 限制大小
    if (file.size > MAX_FILE_SIZE) {
        ElMessage.error('文件过大,请选择小于2G的文件')
        return
    }
    // 判断类型
    if (!(file.type.startsWith("image/") || file.type.startsWith("video/"))) {
        ElMessage.error('文件类型必须是图片或者视频')
        return
    }
    selectFile.value = file
}

监听文件选择:

js 复制代码
// 监听文件选择
watch(selectFile, (newFile, oldFile) => {
    // 清理旧URL
    if (oldFile && previewFiles.value?.url) {
        URL.revokeObjectURL(previewFiles.value.url); // 撤销URL占用
    }
    if (!newFile) return;
    // 创建临时 URL
    const url = URL.createObjectURL(newFile)
    previewFiles.value = { url: url, type: newFile.type }
}, { immediate: false })

在拖拽文件释放方法handleDrop中使用:

js 复制代码
// 拖拽文件释放
const handleDrop = (e: any) => {
    prohibitEvent(e)
    const { files } = e.dataTransfer  // 一个包含拖放操作数据的对象,它可以用来获取被拖放的文件
    ⭕ checkFile(files)
}

最后返回selectFile, previewFiles

js 复制代码
return { selectFile, previewFiles }

新增代码:

js 复制代码
// 存储选择文件
const selectFile = ref<File | null>(null);
// 存储预览文件
const previewFiles = ref<Type_PreviewFile | null>(null);
// 检查文件方法
const checkFile = (files: Array<File>) => {
    const file = files[0];
    // 判断非空
    if (!file) {
        ElMessage.error('没有选择任何文件')
        return
    }
    // 限制大小
    if (file.size > MAX_FILE_SIZE) {
        ElMessage.error('文件过大,请选择小于2G的文件')
        return
    }
    // 判断类型
    if (!(file.type.startsWith("image/") || file.type.startsWith("video/"))) {
        ElMessage.error('文件类型必须是图片或者视频')
        return
    }
    selectFile.value = file
}
// 监听文件选择
watch(selectFile, (newFile, oldFile) => {
    // 清理旧URL
    if (oldFile && previewFiles.value?.url) {
        URL.revokeObjectURL(previewFiles.value.url); // 撤销URL占用
    }
    if (!newFile) return;
    // 创建临时 URL
    const url = URL.createObjectURL(newFile)
    previewFiles.value = { url: url, type: newFile.type }
}, { immediate: false })

完整代码:

js 复制代码
import { onMounted, onUnmounted, ref, watch } from "vue";
import { ElMessage } from 'element-plus'

export const MAX_FILE_SIZE = 1024 * 1024 * 1024 * 2;  // 2G
export type Type_PreviewFile = { url: string, type: any }

// 阻止事件方法
const prohibitEvent = (e: any) => {
    e.preventDefault(); // 阻止浏览器默认行为
    e.stopPropagation(); // 阻止事件冒泡
}
function useDrag(uploadContainerRef: any) {


    // 存储选择文件
    const selectFile = ref<File | null>(null);
    // 存储预览文件
    const previewFiles = ref<Type_PreviewFile | null>(null);
    // 检查文件方法
    const checkFile = (files: Array<File>) => {
        const file = files[0];
        // 判断非空
        if (!file) {
            ElMessage.error('没有选择任何文件')
            return
        }
        // 限制大小
        if (file.size > MAX_FILE_SIZE) {
            ElMessage.error('文件过大,请选择小于2G的文件')
            return
        }
        // 判断类型
        if (!(file.type.startsWith("image/") || file.type.startsWith("video/"))) {
            ElMessage.error('文件类型必须是图片或者视频')
            return
        }
        selectFile.value = file
    }
    // 监听文件选择
    watch(selectFile, (newFile, oldFile) => {
        // 清理旧URL
        if (oldFile && previewFiles.value?.url) {
            URL.revokeObjectURL(previewFiles.value.url); // 撤销URL占用
        }
        if (!newFile) return;
        // 创建临时 URL
        const url = URL.createObjectURL(newFile)
        previewFiles.value = { url: url, type: newFile.type }
    }, { immediate: false })
    // 拖拽事件处理
    const handleDrag = (e: any) => {
        prohibitEvent(e)
    }
    // 拖拽文件释放
    const handleDrop = (e: any) => {
        prohibitEvent(e)
        const { files } = e.dataTransfer  // 一个包含拖放操作数据的对象,它可以用来获取被拖放的文件
        checkFile(files)
    }
    onMounted(() => {
        const uploadContainer = uploadContainerRef.value;
        if (uploadContainer) {
            // 进入拖放目标
            uploadContainer.addEventListener("dragenter", handleDrag);
            // 拖放目标上方移动
            uploadContainer.addEventListener("dragover", handleDrag);
            // 拖放目标上被放置时
            uploadContainer.addEventListener("drop", handleDrop);
            // 离开有效拖放目标
            uploadContainer.addEventListener("dragleave", handleDrag);
        }
    })
    onUnmounted(() => {
        const uploadContainer = uploadContainerRef.value;
        if (!uploadContainer) return;
        uploadContainer.removeEventListener("dragenter", handleDrag);
        uploadContainer.removeEventListener("dragover", handleDrag);
        uploadContainer.removeEventListener("drop", handleDrop);
        uploadContainer.removeEventListener("dragleave", handleDrag);

    })
    return { selectFile, previewFiles }
}
export default useDrag;

模板 中使用:

js 复制代码
<template>
  <div class="box">
    <template v-if="previewFiles?.url">
     <div style="max-width: 600px">
      <img v-if="fileInfo?.isImage" :src="previewFiles.url" alt="预览图片" />
      <video
        v-else-if="fileInfo?.isVideo"
        :src="previewFiles.url"
        controls
        alt="预览视频" />
      <audio
        v-else-if="fileInfo?.isAudio"
        :src="previewFiles.url"
        controls
        alt="预览音频" />
      <iframe
        v-else-if="fileInfo?.isPDF"
        :src="previewFiles.url"
        class="pdf-viewer"
        alt="预览PDF" />
      <div v-else class="unsupported-file">
        暂不支持该文件类型: {{ previewFiles.type }}
      </div>
	</div>
    </template>
    <div v-else class="upload-container" ref="uploadRef">
      <el-icon><Upload /></el-icon>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import useDrag from "./useDrag";

const uploadRef = ref(null);
const { selectFile, previewFiles } = useDrag(uploadRef);

// 文件预览
const fileInfo = computed(() => {
  if (!previewFiles.value?.url) return null;

  const { type } = previewFiles.value;
  return {
    isImage: type?.startsWith("image/"),
    isVideo: type?.startsWith("video/"),
    isAudio: type?.startsWith("audio/"),
    isPDF: type === "application/pdf",
  };
});
</script>

分片上传

处理文件的上传,为了提升性能,在上传大文件的时候,可以把一个大文件切成多个小文件,然后并行上传。另外为了以后在实现类似妙传的功能,所有需要对文件进行唯一标识,所以我们需要根据文件的内容生成一个hash值来唯一的这一个文件,文件内容如果一样,就产生的文件名是一样的。

接下来我们直接开始🤗

新增一个上传文件按钮:

js 复制代码
<div class="box">
    <template v-if="previewFiles?.url"></template>
    <div v-else class="upload-container" ref="uploadRef"></div>
	
    <el-button
      style="margin-top: 20px"
      type="primary"
      size="default"
      @click="handleUpload">
      上传文件
    </el-button>
  </div>

实现上传文件方法handleUpload

js 复制代码
const handleUpload = async () => {
  if (!selectFile.value) {
    ElMessage.error("请选择文件");
    return;
  }
  const filename = await getFileName(selectFile.value);
  console.log("🚀 ~ handleUpload ~ filename:", filename);
};
/**
 * 获取文件名
 * @param file
 */
const getFileName = async (file: any) => {
  // 计算此文件的hash值
  const fileHash = await calculateFileHash(file);
  // 获取文件扩展名
  const ext = file.name.split(".").pop();
  return `${fileHash}.${ext}`;
};
/**
 * 计算文件hash字符串
 * @param file
 */
const calculateFileHash = async (file: any) => {
  const arrayBuffer = await file.arrayBuffer();
  const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
  return bufferToHex(hashBuffer);
};
/**
 * 把一个ArrayBuffer转成一个16进制字符串
 * @param buffer
 */
const bufferToHex = (buffer: any) => {
  return Array.from(new Uint8Array(buffer))
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
};

uploadFile上传文件:

js 复制代码
const handleUpload = async () => {
    //...
    const filename = await getFileName(selectFile.value);
    await uploadFile(selectFile.value, filename);
};
//...
/**
 * 上传文件
 * @param file
 * @param filename
 */
const uploadFile = async (file, filename) => {
  // 将大文件进行切片
  const chunks = createFileChunks(file, filename);
  console.log("🚀 ~ uploadFile ~ chunks:", chunks);
};
/**
 * 创建文件切片
 * @param file
 * @param filename
 */
const createFileChunks = (file, filename) => {
  // 创建切片
  let chunks = [];
  // 计算一共要切成多少片
  let count = Math.ceil(file.size / CHUNK_SIZE);
  for (let i = 0; i < count; i++) {
    let chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
    chunks.push({
      chunk,
      chunkFileName: `${filename}-${i}`,
    });
  }
  return chunks;
};

./useDrag中定义的常量:

js 复制代码
// 切片分块大小:10M
export const CHUNK_SIZE = 1024 * 1024 * 10;

接下来我们测试一下:

效果刚刚好🤗。

接下来处理并行上传:

js 复制代码
/**
 * 上传文件
 * @param file
 * @param filename
 */
const uploadFile = async (file, filename) => {
  // 将大文件进行切片
  const chunks = createFileChunks(file, filename);
  // 并行上传
  const requestArray = chunks.map(({ chunk, chunkFileName }) => {
    return createRequest(filename, chunkFileName, chunk);
  });
  try {
    // 并行上传每个分片
    await Promise.all(requestArray);
    // 等全部的分片上传完了,会向服务器发送一个合并请求
    await axios.get(`/merge/${filename}`);
    ElMessage.success("上传成功");
  } catch (error) {
    ElMessage.error("上传失败");
    console.log("上传失败", error);
  }
};
/**
 * 创建上传请求
 * @param filename
 * @param chunkFileName
 * @param chunk
 */
const createRequest = (filename, chunkFileName, chunk) => {
  return axios.post(`/upload/${filename}`, chunk, {
    headers: {
        // 设置请求头,告诉服务器上传的是二进制字节流数据
      "Content-Type": "application/octet-stream",
    },
    params: {
      chunkFileName,
    },
  });
};

在同级目录下创建一个axios实例,方便后续发送请求:

js 复制代码
import axios from 'axios';

// 创建 axios 实例
const api = axios.create({
    baseURL: 'http://localhost:8080'
});

// 添加响应拦截器
api.interceptors.response.use(
    (response) => {
        // response响应对象 data, headers
        // response.data.success为true表示成功,为false表示失败了
        if (response.data && response.data.success) {
            return response.data; // 返回响应体,这样的话可以在代码直接获取响应体
        } else {
            throw new Error(response.data.message || '服务器端错误');
        }
    },
    (error) => {
        console.error('错误', error);
        throw error;
    }
);

export default api;

搭建后端

初始化文件夹:

js 复制代码
npm init -y

安装依赖:

js 复制代码
npm install express morgan http-status-codes http-errors cors fs-extra

安装nodemon

js 复制代码
npm install --save-dev nodemon

改用nodemon启动:

js 复制代码
"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
        "start":"nodemon index.js"
},

编写index.js

js 复制代码
const express = require("express");
const logger = require("morgan");
const { StatusCodes } = require("http-status-codes");
const cors = require("cors");
const fs = require("fs-extra");
const path = require("path");

// 存放上传并合并好的文件
fs.ensureDirSync(path.resolve(__dirname, "public"));
// 存放分片文件的目录
fs.ensureDirSync(path.resolve(__dirname, "temp"));

const app = express();
app.use(logger("dev"));
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.resolve(__dirname, "public")));

// 接口
app.post("/upload/:filename", async (req, res) => {
  res.json({ success: true });
});
app.get("/merage/:filename", async (req, res, next) => {
  res.json({ success: true });
});

// 启动服务器
app.listen(8080, () => {
  console.log("服务器启动成功");
});

写入分片文件:

js 复制代码
const PUBLIC_DIR = path.resolve(__dirname, "public");
const TEMP_DIR = path.resolve(__dirname, "temp");

// 接口
app.post("/upload/:filename", async (req, res) => {
  // 通过查询参数获取文件名
  const { filename } = req.params;
  // 通过查询参数获取分片名
  const { chunkFileName } = req.query;
  // 创建用户保存此文件的分片的目录
  const chunkDir = path.resolve(TEMP_DIR, filename);
  // 分片的文件路径
  const chunkFilePath = path.resolve(chunkDir, chunkFileName);
  // 确保分片目录存在
  await fs.ensureDirSync(chunkDir);
  // 创建文件可写流
  const ws = fs.createWriteStream(chunkFilePath, { flags: "a" });
  // 后面会实现暂停操作,如果客户端点击了暂停按钮,会取消上传操作,取消后会在服务器触发请求
  req.on("aborted", () => {
    ws.close();
  });
  // 使用管道方式把请求中的请求体流数据写入到文件中
  try {
    await pipeStream(req, ws);
  } catch (error) {
    next(error);
  }
  res.json({ success: true });
});
// 创建管道
const pipeStream = (rs, ws) => {
  return new Promise((resolve, reject) => {
    // 把可读流中的数据写入可写流
    rs.pipe(ws).on("finish", resolve).on("error", reject);
  });
};

此时上传文件,temp目录下已经保存了分片文件:

合并分片文件:

js 复制代码
app.get("/merge/:filename", async (req, res, next) => {
  // 通过查询参数获取文件名
  const { filename } = req.params;
  try {
    await mergeChunks(filename);
  } catch (error) {
    next(error);
  }
  res.json({ success: true });
});

// 合并分片
const mergeChunks = async (filename) => {
  const mergedFilePath = path.resolve(PUBLIC_DIR, filename);
  // 获取分片文件的目录
  const chunkDir = path.resolve(TEMP_DIR, filename);
  // 获取分片文件的列表
  const chunkFiles = await fs.readdir(chunkDir);
  // 对分片文件进行排序
  chunkFiles.sort((a, b) => Number(a.split("-"[1]) - Number(b.split("-")[1])));
  try {
    // 为了提高性能,可以写一个并行写入
    const pipes = chunkFiles.map((chunkFile, index) => {
      return pipeStream(
        fs.createReadStream(path.resolve(chunkDir, chunkFile), {
          autoClose: true,
        }),
        fs.createWriteStream(mergedFilePath, { start: index * CHUNK_SIZE }),
      );
    });
    // 并发把每一个分片的数据写入到目标文件中
    await Promise.all(pipes);
    // 删除分片文件
    await fs.rmdir(chunkDir, { recursive: true });
  } catch (error) {
    next(error);
  }
};

useDrag.ts文件中新增重置文件状态方法并暴露出去:

js 复制代码
// 重置文件状态
    const resetFileStatus = () => {
        selectFile.value = null
        previewFiles.value = null
    }
    return { selectFile, previewFiles, resetFileStatus }

上传进度

首先定义一个变量:

js 复制代码
// 上传进度
const uploadProgress = ref({});

在创建上传请求方法中使用axios的onUploadProgress参数:

js 复制代码
/**
 * 创建上传请求
 * @param filename
 * @param chunkFileName
 * @param chunk
 */
const createRequest = (filename, chunkFileName, chunk) => {
  // 提取分片索引(假设 chunkFileName 结尾是 -index)
  const chunkIndex = parseInt(chunkFileName.split("-").pop());
  return api.post(`/upload/${filename}`, chunk, {
    headers: {
      // 设置请求头,告诉服务器上传的是二进制字节流数据
      "Content-Type": "application/octet-stream",
    },
    params: {
      chunkFileName,
    },
    // 计算上传进度
    onUploadProgress: (progressEvent) => {
      const progress = Math.round(
        (progressEvent.loaded * 100) / progressEvent.total,
      );
      uploadProgress.value[chunkIndex] = progress;
    },
  });
};

渲染到模板中:

js 复制代码
</el-button>
    <div>
      <div v-for="(progress, index) in uploadProgress" :key="index">
        {{ "分片 " + index + " : " + progress + "%" }}
      </div>
    </div>

重置状态:

js 复制代码
const { selectFile, previewFiles, resetFileStatus } = useDrag(uploadRef);

/**
 * 重置所有状态
 */
const resetAllStatus = () => {
  resetFileStatus();
  uploadProgress.value = {};
};

在上传文件uploadFile 方法中添加:

js 复制代码
/**
 * 上传文件
 * @param file
 * @param filename
 */
const uploadFile = async (file, filename) => {
  //...
  try {
    //...
    resetAllStatus();
  } catch (error) {
    //...
  }
};

总进度:

js 复制代码
<div>
      <div v-if="totalProgredd" style="font-weight: 600; color: red">
        {{ "总进度 " + totalProgredd + "%" }}
      </div>
      <div v-for="(progress, index) in uploadProgress" :key="index">
        {{ "分片 " + index + " : " + progress + "%" }}
      </div>
    </div>

// 计算总进度
const totalProgredd = computed(() => {
    // 获取所有进度
    const percents = Object.values(uploadProgress.value);
    // 计算总进度
    const total = percents.reduce((acc, cur) => acc + cur, 0) / percents.length;
    return Math.round(total);
});

秒传

了解决网络发生错误的情况,或者用户惦记了暂停又继续的情况,或者此文件服务器上已经存在了,这不需要重复上传。

要想实现妙传的功能,需要服务器提供一个接口,返回已经上传的分片和大小。

加一个verify验证接口:

js 复制代码
app.get("/verify/:filename", async (req, res, next) => {
  const { filename } = req.params;
  // 先获取文件在服务器的路径
  const filePath = path.resolve(PUBLIC_DIR, filename);
  // 判断文件是否在服务器中
  const isExist = await fs.pathExists(filePath);
  if (isExist) {
    res.json({ success: true, needUpload: false });
  }
  res.json({ success: true, needUpload: true });
});

uploadFile上传文件方法中添加请求接口校验:

js 复制代码
const uploadFile = async (file, filename) => {
    // 上传前校验
    const {needUpload} = await api.get(`/verify/${filename}`);
    if(!needUpload){
        ElMessage.warning("文件已存在");
        return
        //...
    }

暂停上传

核心功能axios.CancelToken 是 Axios 提供的一个机制,允许在请求发出后,根据需求取消该请求。

应用场景:特别适用于处理那些"不再需要"的请求,例如:

  • 用户导航离开了当前页面。
  • 应用程序的逻辑判断认为该请求的结果已无意义。

先来改造一下我们的模板代码:

js 复制代码
<div>
      <el-button
        v-if="fileUploadStatus === 'NOT_STARTED'"
        style="margin-top: 20px"
        type="primary"
        size="default"
        @click="handleUpload">
        上传文件
      </el-button>
      <el-button
        v-if="fileUploadStatus === 'UPLOADING'"
        style="margin-top: 20px"
        type="warning"
        size="default"
        @click="handlePause">
        暂停上传
      </el-button>
      <el-button
        v-if="fileUploadStatus === 'PAUSED'"
        style="margin-top: 20px"
        type="success"
        size="default"
        @click="handleUpload">
        恢复上传
      </el-button>
    </div>
    <div class="progressBox" v-if="fileUploadStatus === 'UPLOADING'">
      <div v-if="totalProgredd" style="font-weight: 600; color: red">
        {{ "总进度 " + totalProgredd + "%" }}
      </div>
      <div v-for="(progress, index) in uploadProgress" :key="index">
        {{ "分片 " + index + " : " + progress + "%" }}
      </div>
    </div>

.progressBox {
  height: 500px;
  overflow-y: scroll;
  width: auto;
  padding: 2dvh;
  border-radius: 10px;
  position: fixed;
  top: 20px;
  right: 20px;
  background-color: yellow;
}

定义上传文件状态:

js 复制代码
// 上传状态
const uploadStatus = {
  NOT_STARTED: "NOT_STARTED", // 未开始
  UPLOADING: "UPLOADING", // 上传中
  PAUSED: "PAUSED", // 暂停上传
};
// 文件上传状态
const fileUploadStatus = ref(uploadStatus.NOT_STARTED);

当点击上传的时候,fileUploadStatus上传状态设置为上传中,新增暂停上传方法handlePause

js 复制代码
const handleUpload = async () => {
  if (!selectFile.value) {
    ElMessage.error("请选择文件");
    return;
  }
  const filename = await getFileName(selectFile.value);
  fileUploadStatus.value = uploadStatus.UPLOADING;
  await uploadFile(selectFile.value, filename);
};

const handlePause = () => {
  fileUploadStatus.value = uploadStatus.PAUSED;
};

重置状态时顺带重置文件上传状态:

js 复制代码
/**
 * 重置所有状态
 */
const resetAllStatus = () => {
  resetFileStatus();
  uploadProgress.value = {};
  fileUploadStatus.value = uploadStatus.NOT_STARTED;
};

断点续传

断点续传:已经上传了一部分,我们可以是把已经上传的分片名,以及分片的大小给客户端,客户端可以只要对剩下部分上传即可

改造后台:

js 复制代码
app.get("/verify/:filename", async (req, res, next) => {
  const { filename } = req.params;
  // 先获取文件在服务器的路径
  const filePath = path.resolve(PUBLIC_DIR, filename);
  // 判断文件是否在服务器中
  const isExist = await fs.pathExists(filePath);
  if (isExist) {
    return res.json({ success: true, needUpload: false });
  }
  const chunksDir = path.resolve(TEMP_DIR, filename);
  const existDir = await fs.pathExists(chunksDir);
  let uploadChunkList = []; // 存放已经上传分片的对象数组
  if (existDir) {
    // 读取临时目录里面的所有分片对应的文件
    const chunkFileNames = await fs.readdir(chunksDir);
    // 读取每个分片文件的文件信息,主要是是它的文件大小,表示已经上传的文件大小
    uploadChunkList = await Promise.all(
      chunkFileNames.map(async (chunkFileName) => {
        const { size } = await fs.stat(path.resolve(chunksDir, chunkFileName));
        return { chunkFileName, size };
      }),
    );
  }
  res.json({ success: true, needUpload: true, uploadChunkList });
});

百分比进度条调整:

js 复制代码
// 计算上传进度
    onUploadProgress: (progressEvent) => {
      const progress = Math.round(
        (progressEvent.loaded + start * 100) / progressEvent.total,
      );
      uploadProgress.value[chunkIndex] = progress;
    },

        // 如果存在说明分片已经上传过一部分了,或者说已经完全上传完成
        if (existingChunk) {
            const uploadSize = existingChunk.size;
            const remainChunk = chunk.slice(uploadSize);
            if (remainChunk.size === 0) {
                ⭕ uploadProgress.value[chunkIndex] = 100;
                return Promise.resolve();
            }
            //...
        }

前端完整代码index.vue

js 复制代码
<template>
  <div class="box">
    <template v-if="previewFiles?.url">
      <div style="max-width: 600px">
        <img v-if="fileInfo?.isImage" :src="previewFiles.url" alt="预览图片" />
        <video
          v-else-if="fileInfo?.isVideo"
          :src="previewFiles.url"
          controls
          alt="预览视频" />
        <audio
          v-else-if="fileInfo?.isAudio"
          :src="previewFiles.url"
          controls
          alt="预览音频" />
        <iframe
          v-else-if="fileInfo?.isPDF"
          :src="previewFiles.url"
          class="pdf-viewer"
          alt="预览PDF" />
        <div v-else class="unsupported-file">
          暂不支持该文件类型: {{ previewFiles.type }}
        </div>
      </div>
    </template>
    <div v-else class="upload-container" ref="uploadRef">
      <el-icon><Upload /></el-icon>
    </div>
    <div>
      <el-button
        v-if="fileUploadStatus === 'NOT_STARTED'"
        style="margin-top: 20px"
        type="primary"
        size="default"
        @click="handleUpload">
        上传文件
      </el-button>
      <el-button
        v-if="fileUploadStatus === 'UPLOADING'"
        style="margin-top: 20px"
        type="warning"
        size="default"
        @click="handlePause">
        暂停上传
      </el-button>
      <el-button
        v-if="fileUploadStatus === 'PAUSED'"
        style="margin-top: 20px"
        type="success"
        size="default"
        @click="handleUpload">
        恢复上传
      </el-button>
    </div>
    <div class="progressBox" v-if="fileUploadStatus === 'UPLOADING'">
      <div v-if="totalProgredd" style="font-weight: 600; color: red">
        {{ "总进度 " + totalProgredd + "%" }}
      </div>
      <div v-for="(progress, index) in uploadProgress" :key="index">
        {{ "分片 " + index + " : " + progress + "%" }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import useDrag, { CHUNK_SIZE } from "./useDrag";
import type { Type_PreviewFile } from "./useDrag";
import { ElMessage } from "element-plus";
import api from "./axios";
import axios from "axios";

const uploadRef = ref(null);
const { selectFile, previewFiles, resetFileStatus } = useDrag(uploadRef);
// 上传进度
const uploadProgress = ref({});
// 上传状态
const uploadStatus = {
  NOT_STARTED: "NOT_STARTED", // 未开始
  UPLOADING: "UPLOADING", // 上传中
  PAUSED: "PAUSED", // 暂停上传
};
// 文件上传状态
const fileUploadStatus = ref(uploadStatus.NOT_STARTED);
// 存放所有上传请求的取消token
const cancelToken = ref();

// 文件预览
const fileInfo = computed(() => {
  if (!previewFiles.value?.url) return null;
  const { type } = previewFiles.value;
  return {
    isImage: type?.startsWith("image/"),
    isVideo: type?.startsWith("video/"),
    isAudio: type?.startsWith("audio/"),
    isPDF: type === "application/pdf",
  };
});

const handleUpload = async () => {
  if (!selectFile.value) {
    ElMessage.error("请选择文件");
    return;
  }
  const filename = await getFileName(selectFile.value);
  fileUploadStatus.value = uploadStatus.UPLOADING;
  await uploadFile(selectFile.value, filename);
};
const handlePause = () => {
  fileUploadStatus.value = uploadStatus.PAUSED;
};
/**
 * 获取文件名
 * @param file
 */
const getFileName = async (file: any) => {
  // 计算此文件的hash值
  const fileHash = await calculateFileHash(file);
  // 获取文件扩展名
  const ext = file.name.split(".").pop();
  return `${fileHash}.${ext}`;
};
/**
 * 计算文件hash字符串
 * @param file
 */
const calculateFileHash = async (file: any) => {
  const arrayBuffer = await file.arrayBuffer();
  const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
  return bufferToHex(hashBuffer);
};
/**
 * 把一个ArrayBuffer转成一个16进制字符串
 * @param buffer
 */
const bufferToHex = (buffer: any) => {
  return Array.from(new Uint8Array(buffer))
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
};
/**
 * 上传文件
 * @param file
 * @param filename
 */
const uploadFile = async (file, filename) => {
  // 上传前校验
  const { needUpload, uploadChunkList } = await api.get(`/verify/${filename}`);
  if (!needUpload) {
    ElMessage.warning("文件已存在");
    return;
  }
  // 将大文件进行切片
  const chunks = createFileChunks(file, filename);
  const newCancelTokens = [];
  // 并行上传
  const requestArray = chunks.map(({ chunk, chunkFileName }) => {
    const cancelToken = axios.CancelToken.source();
    newCancelTokens.push(cancelToken);
    // 以后往服务器发送的数据就可能是不再是完整的分片数据了
    // 判断当前的分片是不是已经上传服务器了
    const existingChunk = uploadChunkList.find((uploadChunk) => {
      return uploadChunk.chunkFileName == chunkFileName;
    });
    // 如果存在说明分片已经上传过一部分了,或者说已经完全上传完成
    if (existingChunk) {
      const uploadSize = existingChunk.size;
      const remainChunk = chunk.slice(uploadSize);
      if (remainChunk.size === 0) {
        uploadProgress.value[chunkIndex] = 100;
        return Promise.resolve();
      }
      // 设置为默认值
      uploadProgress.value[chunkIndex] = (uploadSize * 100) / chunk.size;
      return createRequest(
        filename,
        chunkFileName,
        remainChunk,
        cancelToken,
        uploadSize,
      );
    } else {
      return createRequest(filename, chunkFileName, chunk, cancelToken);
    }
  });
  cancelToken.value = newCancelTokens;
  try {
    // 并行上传每个分片
    await Promise.all(requestArray);
    // 等全部的分片上传完了,会向服务器发送一个合并请求
    await api.get(`/merge/${filename}`);
    ElMessage.success("上传成功");
    resetAllStatus();
  } catch (error) {
    // 用户主动暂停上传
    if (axios.isCancel(error)) {
      ElMessage.warning("上传暂停");
    } else {
      ElMessage.error("上传失败");
      console.log("上传失败", error);
    }
  }
};
/**
 * 创建文件切片
 * @param file
 * @param filename
 */
const createFileChunks = (file, filename) => {
  // 创建切片
  let chunks = [];
  // 计算一共要切成多少片
  let count = Math.ceil(file.size / CHUNK_SIZE);
  for (let i = 0; i < count; i++) {
    let chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
    chunks.push({
      chunk,
      chunkFileName: `${filename}-${i}`,
    });
  }
  return chunks;
};
/**
 * 创建上传请求
 * @param filename
 * @param chunkFileName
 * @param chunk
 */
const createRequest = (
  filename,
  chunkFileName,
  chunk,
  cancelToken,
  start = 0,
) => {
  // 提取分片索引(假设 chunkFileName 结尾是 -index)
  const chunkIndex = parseInt(chunkFileName.split("-").pop());
  return api.post(`/upload/${filename}`, chunk, {
    headers: {
      // 设置请求头,告诉服务器上传的是二进制字节流数据
      "Content-Type": "application/octet-stream",
    },
    params: {
      chunkFileName,
      start, // 告诉服务器从哪个位置开始上传
    },
    // 计算上传进度
    onUploadProgress: (progressEvent) => {
      const progress = Math.round(
        (progressEvent.loaded + start * 100) / progressEvent.total,
      );
      uploadProgress.value[chunkIndex] = progress;
    },
    cancelToken: cancelToken.token, // 添加取消token
  });
};
// 计算总进度
const totalProgredd = computed(() => {
  // 获取所有进度
  const percents = Object.values(uploadProgress.value);
  // 计算总进度
  const total = percents.reduce((acc, cur) => acc + cur, 0) / percents.length;
  return Math.round(total);
});
/**
 * 重置所有状态
 */
const resetAllStatus = () => {
  resetFileStatus();
  uploadProgress.value = {};
  fileUploadStatus.value = uploadStatus.NOT_STARTED;
};
</script>

<style scoped lang="scss">
.box {
  display: flex;
  width: 100%;
  height: 100vh;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}
.upload-container {
  width: 10%;
  height: 100px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 4px dashed #d3cbcb;
  background-color: #c9e7ff;
  &:hover {
    border-color: #40a9ff;
  }
  & span {
    font-size: 60px;
  }
}
.progressBox {
  height: 500px;
  overflow-y: scroll;
  width: auto;
  padding: 2dvh;
  border-radius: 10px;
  position: fixed;
  top: 20px;
  right: 20px;
  background-color: yellow;
}
</style>

后端完整代码:

js 复制代码
const express = require("express");
const logger = require("morgan");
const { StatusCodes } = require("http-status-codes");
const cors = require("cors");
const fs = require("fs-extra");
const path = require("path");
const PUBLIC_DIR = path.resolve(__dirname, "public");
const TEMP_DIR = path.resolve(__dirname, "temp");
const CHUNK_SIZE = 1024 * 1024 * 10;

// 存放上传并合并好的文件
fs.ensureDirSync(PUBLIC_DIR);
// 存放分片文件的目录
fs.ensureDirSync(TEMP_DIR);

const app = express();
app.use(logger("dev"));
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.resolve(__dirname, "public")));

//------接口-------------
app.post("/upload/:filename", async (req, res, next) => {
  // 通过查询参数获取文件名
  const { filename } = req.params;
  // 通过查询参数获取分片名
  const { chunkFileName, start } = req.query;
  // 写入文件起始位置
  const chunkStart = isNaN(start) ? 0 : parseInt(start, 10);
  // 创建用户保存此文件的分片的目录
  const chunkDir = path.resolve(TEMP_DIR, filename);
  // 分片的文件路径
  const chunkFilePath = path.resolve(chunkDir, chunkFileName);
  // 确保分片目录存在
  await fs.ensureDir(chunkDir);
  // 创建文件可写流
  const ws = fs.createWriteStream(chunkFilePath, { chunkStart, flags: "a" });
  // 后面会实现暂停操作,如果客户端点击了暂停按钮,会取消上传操作,取消后会在服务器触发请求
  req.on("aborted", () => {
    ws.close();
  });
  // 使用管道方式把请求中的请求体流数据写入到文件中
  try {
    await pipeStream(req, ws);
  } catch (error) {
    return next(error);
  }
  res.json({ success: true });
});
app.get("/merge/:filename", async (req, res, next) => {
  // 通过查询参数获取文件名
  const { filename } = req.params;
  try {
    // 等待合并操作完成
    await mergeChunks(filename);
    // 合并成功后发送响应
    res.json({ success: true });
  } catch (error) {
    next(error);
  }
});
app.get("/verify/:filename", async (req, res, next) => {
  const { filename } = req.params;
  // 先获取文件在服务器的路径
  const filePath = path.resolve(PUBLIC_DIR, filename);
  // 判断文件是否在服务器中
  const isExist = await fs.pathExists(filePath);
  if (isExist) {
    return res.json({ success: true, needUpload: false });
  }
  const chunksDir = path.resolve(TEMP_DIR, filename);
  const existDir = await fs.pathExists(chunksDir);
  let uploadChunkList = []; // 存放已经上传分片的对象数组
  if (existDir) {
    // 读取临时目录里面的所有分片对应的文件
    const chunkFileNames = await fs.readdir(chunksDir);
    // 读取每个分片文件的文件信息,主要是是它的文件大小,表示已经上传的文件大小
    uploadChunkList = await Promise.all(
      chunkFileNames.map(async (chunkFileName) => {
        const { size } = await fs.stat(path.resolve(chunksDir, chunkFileName));
        return { chunkFileName, size };
      }),
    );
  }
  res.json({ success: true, needUpload: true, uploadChunkList });
});

// 启动服务器
app.listen(8080, () => {
  console.log("服务器启动成功");
});

//------方法-------------
// 创建管道
const pipeStream = (rs, ws) => {
  return new Promise((resolve, reject) => {
    // 把可读流中的数据写入可写流
    rs.pipe(ws).on("finish", resolve).on("error", reject);
  });
};
// 合并分片
const mergeChunks = async (filename) => {
  const mergedFilePath = path.resolve(PUBLIC_DIR, filename);
  // 获取分片文件的目录
  const chunkDir = path.resolve(TEMP_DIR, filename);
  // 获取分片文件的列表
  const chunkFiles = await fs.readdir(chunkDir);
  // 对分片文件进行排序
  chunkFiles.sort((a, b) => Number(a.split("-")[1]) - Number(b.split("-")[1]));
  try {
    // 为了提高性能,可以写一个并行写入
    const pipes = chunkFiles.map((chunkFile, index) => {
      return pipeStream(
        fs.createReadStream(path.resolve(chunkDir, chunkFile), {
          autoClose: true,
        }),
        fs.createWriteStream(mergedFilePath, { start: index * CHUNK_SIZE }),
      );
    });
    // 并发把每一个分片的数据写入到目标文件中
    await Promise.all(pipes);
    // 删除分片文件
    await fs.rm(chunkDir, { recursive: true, force: true });
  } catch (error) {
    throw error;
  }
};

web workers

要优化大文件上传并利用web worker的优势,你可以讲耗时操作的逻辑一道web worker中,这样就可以防止耗时文件操作阻塞UI线程的响应式:

web workers提供了一种在web应用程序中执行脚本的操作方式,这些操作运行在与主执行线程(通常是UI线程)分离的后台线程中,这意味着web worker允许进行并行计算,而不会阻塞浏览器的用户界面

基础概念:

  • 多线程执行:web workers允许你再浏览器中创建一个独立的线程执行js代码,这有助于处理高计算量或耗时任务,而不会影响页面性能和响应式
  • 与主线程分离:worker线程与主线程是完全隔离的,他们有自己的全局上下文,不能直接访问DOM、window对象。所有的数据交换都能通过消息传递进行。

使用方法:

  1. 创建worker文件(注意要放到静态文件public/worker.js根目录下):其中包含讲worker线程中运行的代码:

    js 复制代码
    self.addEventListener("message", async (e) => {
        
    });
  2. 在主线程中使用worker,然后在主线程中创建worker实例,并与之通信:

    js 复制代码
    const fileWorker = ref(new Worker("./worker.ts"));
    const isCalculatingFileName = ref(false);
    
    const handleUpload = async () => {
      if (!selectFile.value) {
        ElMessage.error("请选择文件");
        return;
      }
      fileUploadStatus.value = uploadStatus.UPLOADING;
      // const filename = await getFileName(selectFile.value);
      // 改用web worker处理文件名👇
      // 向worker发送文件信息,让他帮助计算文件对应的文件名
      fileWorker.value.postMessage(selectFile.value);
      isCalculatingFileName.value = true;
      // 监听worker消息,接受计算好的文件名
      fileWorker.value.onmessage = async (e) => {
        if (isCalculatingFileName.value) {
          isCalculatingFileName.value = false;
          await uploadFile(selectFile.value, e.data);
        }
      };
    };
  3. 将原来index.vue中的计算文件名方法拷贝到worker.js中:

    js 复制代码
    self.addEventListener("message", async (e) => {
      // 获取主进程发送的文件
      const file = e.data;
      // 单独开一个进程来计算hash并得到新的文件名
      const fileName = await getFileName(file);
      // 发送文件名给主进程
      self.postMessage(fileName);
    });
    /**
     * 获取文件名
     * @param file
     */
    const getFileName = async (file) => {
      // 计算此文件的hash值
      const fileHash = await calculateFileHash(file);
      // 获取文件扩展名
      const ext = file.name.split(".").pop();
      return `${fileHash}.${ext}`;
    };
    /**
     * 计算文件hash字符串
     * @param file
     */
    const calculateFileHash = async (file) => {
      const arrayBuffer = await file.arrayBuffer();
      const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
      return bufferToHex(hashBuffer);
    };
    /**
     * 把一个ArrayBuffer转成一个16进制字符串
     * @param buffer
     */
    const bufferToHex = (buffer) => {
      return Array.from(new Uint8Array(buffer))
        .map((byte) => byte.toString(16).padStart(2, "0"))
        .join("");
    };
  4. 上传文件按钮添加一个loading效果:

    js 复制代码
    <span
          v-if="isCalculatingFileName"
          style="margin-top: 4px; color: darkgrey; font-size: 12px"
          >文件解析中...
        </span>

效果:

重试机制

如果上传失败了,可以自动重试三次:

useDrag.ts中设置重置次数常量:

js 复制代码
export const MAX_RETRYS = 3;

uploadFile中使用:

js 复制代码
/**
 * 上传文件
 * @param file
 * @param filename
 */
const uploadFile = async (file, filename, retry = 0) => {
  //...
  try {
    // 并行上传每个分片
    await Promise.all(requestArray);
    // 等全部的分片上传完了,会向服务器发送一个合并请求
    await api.get(`/merge/${filename}`);
    ElMessage.success("上传成功");
    resetAllStatus();
  } catch (error) {
    // 用户主动暂停上传
    if (axios.isCancel(error)) {
      ElMessage.warning("上传暂停");
    } else {
      if (retry < MAX_RETRYS) {
        ElMessage.error("上传失败,重试中...");
        uploadFile(file, filename, retry + 1);
      } else {
        ElMessage.error("上传失败");
        console.log("上传失败", error);
      }
    }
  }
};

实现点击上传

js 复制代码
<div v-else class="upload-container" ref="uploadRef" @click="clickUpload">
      <el-icon><Upload /></el-icon>
    </div>

实现点击效果useDrag.ts

js 复制代码
// 实现点击上传
    const clickUpload = () => {
        const uploadContainer = uploadContainerRef.value
        uploadContainer.addEventListener("click", () => {
            const fileInput = document.createElement("input")
            fileInput.type = "file"
            fileInput.style.display = "none"
            fileInput.addEventListener("change", (e: any) => {
                checkFile(e.target.files)
            })
            document.body.appendChild(fileInput)
            fileInput.click()
        })

    }
    return { selectFile, previewFiles, resetFileStatus, clickUpload }

文件校验

为了确保上传过程中文件的传输没有被篡改,可以增加一些校验机制。在本项目中,文件名就是通过hash算法得到的。然后在后端服务器中完文件之后,可以重新计算合并后的文件hash值,和文件中是的hash值进行对比,如果值是一样的,说明肯定内容是正确的。

文件加密

如果使用ssh或者hhtps协议上传,就不需要加密了。

如果使用的是http协议,可以在客户端对分片数据进行加密,在服务器加密,也被称为对称加密

在浏览器端进行加密。在public/aes.html

js 复制代码
<script>
    //在浏览器端如何加密
    async function encryptChunk(chunk, key){
        //iv是中密过程中一个随机值,用于确保就是使用相同的密钥对相同的数据进行加密,每次加密的结果也不一样
        //iv是一个12个字节的随机数组为初始化的向量
        const algorithm= {
            name: 'AES-GCM',
            iv:window.crypto.getRandomValues(new Uint8Array(12))
        }
        const encryptedChunk =  await window.subtle.encrypt(algorithm,key,chunk);
        return {encryptedChunk,iv:algorithm.iv}
    }
    async funciton generateKey(){
        const key = await window.crypto.subtle.generateKey({
            name:'ARS_GCM',
            length:256
        },true,['encrypt','decrypt'])
        return key;
    }
    const key = generteKey()
    const {encryptedChunk,iv} = await encryptChunk(chunk,key)
</script>

完整代码

前端部分

index.vue

js 复制代码
<template>
  <div class="box">
    <template v-if="previewFiles?.url">
      <div style="max-width: 600px">
        <img v-if="fileInfo?.isImage" :src="previewFiles.url" alt="预览图片" />
        <video
          v-else-if="fileInfo?.isVideo"
          :src="previewFiles.url"
          controls
          alt="预览视频" />
        <audio
          v-else-if="fileInfo?.isAudio"
          :src="previewFiles.url"
          controls
          alt="预览音频" />
        <iframe
          v-else-if="fileInfo?.isPDF"
          :src="previewFiles.url"
          class="pdf-viewer"
          alt="预览PDF" />
        <div v-else class="unsupported-file">
          暂不支持该文件类型: {{ previewFiles.type }}
        </div>
      </div>
    </template>
    <div v-else class="upload-container" ref="uploadRef" @click="clickUpload">
      <el-icon><Upload /></el-icon>
    </div>
    <div>
      <el-button
        v-if="fileUploadStatus === 'NOT_STARTED'"
        style="margin-top: 20px"
        type="primary"
        size="default"
        @click="handleUpload">
        上传文件
      </el-button>
      <el-button
        v-if="fileUploadStatus === 'UPLOADING'"
        style="margin-top: 20px"
        type="warning"
        size="default"
        @click="handlePause">
        暂停上传
      </el-button>
      <el-button
        v-if="fileUploadStatus === 'PAUSED'"
        style="margin-top: 20px"
        type="success"
        size="default"
        @click="handleUpload">
        恢复上传
      </el-button>
    </div>
    <span
      v-if="isCalculatingFileName"
      style="margin-top: 4px; color: darkgrey; font-size: 12px"
      >文件解析中...
    </span>
    <div class="progressBox" v-if="fileUploadStatus === 'UPLOADING'">
      <div v-if="totalProgredd" style="font-weight: 600; color: red">
        {{ "总进度 " + totalProgredd + "%" }}
      </div>
      <div v-for="(progress, index) in uploadProgress" :key="index">
        {{ "分片 " + index + " : " + progress + "%" }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import useDrag, { CHUNK_SIZE, MAX_RETRYS } from "./useDrag";
import type { Type_PreviewFile } from "./useDrag";
import { ElMessage } from "element-plus";
import api from "./axios";
import axios from "axios";

const uploadRef = ref(null);
const { selectFile, previewFiles, resetFileStatus, clickUpload } =
  useDrag(uploadRef);
// 上传进度
const uploadProgress = ref({});
// 上传状态
const uploadStatus = {
  NOT_STARTED: "NOT_STARTED", // 未开始
  UPLOADING: "UPLOADING", // 上传中
  PAUSED: "PAUSED", // 暂停上传
};
// 文件上传状态
const fileUploadStatus = ref(uploadStatus.NOT_STARTED);
// 存放所有上传请求的取消token
const cancelToken = ref();
const fileWorker = ref(new Worker("./worker.js"));
const isCalculatingFileName = ref(false);

// 文件预览
const fileInfo = computed(() => {
  if (!previewFiles.value?.url) return null;
  const { type } = previewFiles.value;
  return {
    isImage: type?.startsWith("image/"),
    isVideo: type?.startsWith("video/"),
    isAudio: type?.startsWith("audio/"),
    isPDF: type === "application/pdf",
  };
});

const handleUpload = async () => {
  if (!selectFile.value) {
    ElMessage.error("请选择文件");
    return;
  }
  fileUploadStatus.value = uploadStatus.UPLOADING;
  // const filename = await getFileName(selectFile.value);
  // 改用web worker处理文件名👇
  // 向worker发送文件信息,让他帮助计算文件对应的文件名
  fileWorker.value.postMessage(selectFile.value);
  isCalculatingFileName.value = true;
  // 监听worker消息,接受计算好的文件名
  fileWorker.value.onmessage = async (e) => {
    if (isCalculatingFileName.value) {
      isCalculatingFileName.value = false;
      await uploadFile(selectFile.value, e.data);
    }
  };
};
const handlePause = () => {
  fileUploadStatus.value = uploadStatus.PAUSED;
};

/**
 * 上传文件
 * @param file
 * @param filename
 */
const uploadFile = async (file, filename, retry = 0) => {
  // 上传前校验
  const { needUpload, uploadChunkList } = await api.get(`/verify/${filename}`);
  if (!needUpload) {
    ElMessage.warning("文件已存在");
    return;
  }
  // 将大文件进行切片
  const chunks = createFileChunks(file, filename);
  const newCancelTokens = [];
  // 并行上传
  const requestArray = chunks.map(({ chunk, chunkFileName }) => {
    const cancelToken = axios.CancelToken.source();
    newCancelTokens.push(cancelToken);
    // 以后往服务器发送的数据就可能是不再是完整的分片数据了
    // 判断当前的分片是不是已经上传服务器了
    const existingChunk = uploadChunkList.find((uploadChunk) => {
      return uploadChunk.chunkFileName == chunkFileName;
    });
    // 如果存在说明分片已经上传过一部分了,或者说已经完全上传完成
    if (existingChunk) {
      const uploadSize = existingChunk.size;
      const remainChunk = chunk.slice(uploadSize);
      if (remainChunk.size === 0) {
        uploadProgress.value[chunkIndex] = 100;
        return Promise.resolve();
      }
      // 设置为默认值
      uploadProgress.value[chunkIndex] = (uploadSize * 100) / chunk.size;
      return createRequest(
        filename,
        chunkFileName,
        remainChunk,
        cancelToken,
        uploadSize,
      );
    } else {
      return createRequest(filename, chunkFileName, chunk, cancelToken);
    }
  });
  cancelToken.value = newCancelTokens;
  try {
    // 并行上传每个分片
    await Promise.all(requestArray);
    // 等全部的分片上传完了,会向服务器发送一个合并请求
    await api.get(`/merge/${filename}`);
    ElMessage.success("上传成功");
    resetAllStatus();
  } catch (error) {
    // 用户主动暂停上传
    if (axios.isCancel(error)) {
      ElMessage.warning("上传暂停");
    } else {
      if (retry < MAX_RETRYS) {
        ElMessage.error("上传失败,重试中...");
        uploadFile(file, filename, retry + 1);
      } else {
        ElMessage.error("上传失败");
        console.log("上传失败", error);
      }
    }
  }
};
/**
 * 创建文件切片
 * @param file
 * @param filename
 */
const createFileChunks = (file, filename) => {
  // 创建切片
  let chunks = [];
  // 计算一共要切成多少片
  let count = Math.ceil(file.size / CHUNK_SIZE);
  for (let i = 0; i < count; i++) {
    let chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
    chunks.push({
      chunk,
      chunkFileName: `${filename}-${i}`,
    });
  }
  return chunks;
};
/**
 * 创建上传请求
 * @param filename
 * @param chunkFileName
 * @param chunk
 */
const createRequest = (
  filename,
  chunkFileName,
  chunk,
  cancelToken,
  start = 0,
) => {
  // 提取分片索引(假设 chunkFileName 结尾是 -index)
  const chunkIndex = parseInt(chunkFileName.split("-").pop());
  return api.post(`/upload/${filename}`, chunk, {
    headers: {
      // 设置请求头,告诉服务器上传的是二进制字节流数据
      "Content-Type": "application/octet-stream",
    },
    params: {
      chunkFileName,
      start, // 告诉服务器从哪个位置开始上传
    },
    // 计算上传进度
    onUploadProgress: (progressEvent) => {
      const progress = Math.round(
        (progressEvent.loaded + start * 100) / progressEvent.total,
      );
      uploadProgress.value[chunkIndex] = progress;
    },
    cancelToken: cancelToken.token, // 添加取消token
  });
};
// 计算总进度
const totalProgredd = computed(() => {
  // 获取所有进度
  const percents = Object.values(uploadProgress.value);
  // 计算总进度
  const total = percents.reduce((acc, cur) => acc + cur, 0) / percents.length;
  return Math.round(total);
});
/**
 * 重置所有状态
 */
const resetAllStatus = () => {
  resetFileStatus();
  uploadProgress.value = {};
  fileUploadStatus.value = uploadStatus.NOT_STARTED;
};
</script>

<style scoped lang="scss">
.box {
  display: flex;
  width: 100%;
  height: 100vh;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}
.upload-container {
  width: 10%;
  height: 100px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 4px dashed #d3cbcb;
  background-color: #c9e7ff;
  cursor: pointer;
  &:hover {
    border-color: #40a9ff;
  }
  & span {
    font-size: 60px;
  }
}
.progressBox {
  height: 500px;
  overflow-y: scroll;
  width: auto;
  padding: 2dvh;
  border-radius: 10px;
  position: fixed;
  top: 20px;
  right: 20px;
  background-color: yellow;
}
</style>

useDrag.ts

js 复制代码
import { onMounted, onUnmounted, ref, watch } from "vue";
import { ElMessage } from 'element-plus'

// 限制最大上传:2G
export const MAX_FILE_SIZE = 1024 * 1024 * 1024 * 2;
// 切片分块大小:10M
export const CHUNK_SIZE = 1024 * 1024 * 10;
export type Type_PreviewFile = { url: string, type: any }
export const MAX_RETRYS = 3;
// 阻止事件方法
const prohibitEvent = (e: any) => {
    e.preventDefault(); // 阻止浏览器默认行为
    e.stopPropagation(); // 阻止事件冒泡
}
function useDrag(uploadContainerRef: any) {
    // 存储选择文件
    const selectFile = ref<File | null>(null);
    // 存储预览文件
    const previewFiles = ref<Type_PreviewFile | null>(null);
    // 检查文件方法
    const checkFile = (files: Array<File>) => {
        const file = files[0];
        // 判断非空
        if (!file) {
            ElMessage.error('没有选择任何文件')
            return
        }
        // 限制大小
        if (file.size > MAX_FILE_SIZE) {
            ElMessage.error('文件过大,请选择小于2G的文件')
            return
        }
        // 判断类型
        if (!(file.type.startsWith("image/") || file.type.startsWith("video/"))) {
            ElMessage.error('文件类型必须是图片或者视频')
            return
        }
        selectFile.value = file
    }
    // 监听文件选择
    watch(selectFile, (newFile, oldFile) => {
        // 清理旧URL
        if (oldFile && previewFiles.value?.url) {
            URL.revokeObjectURL(previewFiles.value.url); // 撤销URL占用
        }
        if (!newFile) return;
        // 创建临时 URL
        const url = URL.createObjectURL(newFile)
        previewFiles.value = { url: url, type: newFile.type }
    }, { immediate: false })
    // 拖拽事件处理
    const handleDrag = (e: any) => {
        prohibitEvent(e)
    }
    // 拖拽文件释放
    const handleDrop = (e: any) => {
        prohibitEvent(e)
        const { files } = e.dataTransfer  // 一个包含拖放操作数据的对象,它可以用来获取被拖放的文件
        checkFile(files)
    }
    onMounted(() => {
        const uploadContainer = uploadContainerRef.value;
        if (uploadContainer) {
            // 进入拖放目标
            uploadContainer.addEventListener("dragenter", handleDrag);
            // 拖放目标上方移动
            uploadContainer.addEventListener("dragover", handleDrag);
            // 拖放目标上被放置时
            uploadContainer.addEventListener("drop", handleDrop);
            // 离开有效拖放目标
            uploadContainer.addEventListener("dragleave", handleDrag);
        }
    })
    onUnmounted(() => {
        const uploadContainer = uploadContainerRef.value;
        if (!uploadContainer) return;
        uploadContainer.removeEventListener("dragenter", handleDrag);
        uploadContainer.removeEventListener("dragover", handleDrag);
        uploadContainer.removeEventListener("drop", handleDrop);
        uploadContainer.removeEventListener("dragleave", handleDrag);

    })
    // 重置文件状态
    const resetFileStatus = () => {
        selectFile.value = null
        previewFiles.value = null
    }
    // 实现点击上传
    const clickUpload = () => {
        const uploadContainer = uploadContainerRef.value
        uploadContainer.addEventListener("click", () => {
            const fileInput = document.createElement("input")
            fileInput.type = "file"
            fileInput.style.display = "none"
            fileInput.addEventListener("change", (e: any) => {
                checkFile(e.target.files)
            })
            document.body.appendChild(fileInput)
            fileInput.click()
        })

    }
    return { selectFile, previewFiles, resetFileStatus, clickUpload }
}
export default useDrag;

axios.ts

js 复制代码
import axios from 'axios';

// 创建 axios 实例
const api = axios.create({
    baseURL: 'http://localhost:8080'
});

// 添加响应拦截器
api.interceptors.response.use(
    (response) => {
        // response响应对象 data, headers
        // response.data.success为true表示成功,为false表示失败了
        if (response.data && response.data.success) {
            return response.data; // 返回响应体,这样的话可以在代码直接获取响应体
        } else {
            throw new Error(response.data.message || '服务器端错误');
        }
    },
    (error) => {
        console.error('错误', error);
        throw error;
    }
);

export default api;

worker.js

js 复制代码
self.addEventListener("message", async (e) => {
  // 获取主进程发送的文件
  const file = e.data;
  // 单独开一个进程来计算hash并得到新的文件名
  const fileName = await getFileName(file);
  // 发送文件名给主进程
  self.postMessage(fileName);
});
/**
 * 获取文件名
 * @param file
 */
const getFileName = async (file) => {
  // 计算此文件的hash值
  const fileHash = await calculateFileHash(file);
  // 获取文件扩展名
  const ext = file.name.split(".").pop();
  return `${fileHash}.${ext}`;
};
/**
 * 计算文件hash字符串
 * @param file
 */
const calculateFileHash = async (file) => {
  const arrayBuffer = await file.arrayBuffer();
  const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
  return bufferToHex(hashBuffer);
};
/**
 * 把一个ArrayBuffer转成一个16进制字符串
 * @param buffer
 */
const bufferToHex = (buffer) => {
  return Array.from(new Uint8Array(buffer))
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
};

后端部分

js 复制代码
const express = require("express");
const logger = require("morgan");
const { StatusCodes } = require("http-status-codes");
const cors = require("cors");
const fs = require("fs-extra");
const path = require("path");
const PUBLIC_DIR = path.resolve(__dirname, "public");
const TEMP_DIR = path.resolve(__dirname, "temp");
const CHUNK_SIZE = 1024 * 1024 * 10;

// 存放上传并合并好的文件
fs.ensureDirSync(PUBLIC_DIR);
// 存放分片文件的目录
fs.ensureDirSync(TEMP_DIR);

const app = express();
app.use(logger("dev"));
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.resolve(__dirname, "public")));

//------接口-------------
app.post("/upload/:filename", async (req, res, next) => {
  // 通过查询参数获取文件名
  const { filename } = req.params;
  // 通过查询参数获取分片名
  const { chunkFileName, start } = req.query;
  // 写入文件起始位置
  const chunkStart = isNaN(start) ? 0 : parseInt(start, 10);
  // 创建用户保存此文件的分片的目录
  const chunkDir = path.resolve(TEMP_DIR, filename);
  // 分片的文件路径
  const chunkFilePath = path.resolve(chunkDir, chunkFileName);
  // 确保分片目录存在
  await fs.ensureDir(chunkDir);
  // 创建文件可写流
  const ws = fs.createWriteStream(chunkFilePath, { chunkStart, flags: "a" });
  // 后面会实现暂停操作,如果客户端点击了暂停按钮,会取消上传操作,取消后会在服务器触发请求
  req.on("aborted", () => {
    ws.close();
  });
  // 使用管道方式把请求中的请求体流数据写入到文件中
  try {
    await pipeStream(req, ws);
  } catch (error) {
    return next(error);
  }
  res.json({ success: true });
});
app.get("/merge/:filename", async (req, res, next) => {
  // 通过查询参数获取文件名
  const { filename } = req.params;
  try {
    // 等待合并操作完成
    await mergeChunks(filename);
    // 合并成功后发送响应
    res.json({ success: true });
  } catch (error) {
    next(error);
  }
});
app.get("/verify/:filename", async (req, res, next) => {
  const { filename } = req.params;
  // 先获取文件在服务器的路径
  const filePath = path.resolve(PUBLIC_DIR, filename);
  // 判断文件是否在服务器中
  const isExist = await fs.pathExists(filePath);
  if (isExist) {
    return res.json({ success: true, needUpload: false });
  }
  const chunksDir = path.resolve(TEMP_DIR, filename);
  const existDir = await fs.pathExists(chunksDir);
  let uploadChunkList = []; // 存放已经上传分片的对象数组
  if (existDir) {
    // 读取临时目录里面的所有分片对应的文件
    const chunkFileNames = await fs.readdir(chunksDir);
    // 读取每个分片文件的文件信息,主要是是它的文件大小,表示已经上传的文件大小
    uploadChunkList = await Promise.all(
      chunkFileNames.map(async (chunkFileName) => {
        const { size } = await fs.stat(path.resolve(chunksDir, chunkFileName));
        return { chunkFileName, size };
      }),
    );
  }
  res.json({ success: true, needUpload: true, uploadChunkList });
});

// 启动服务器
app.listen(8080, () => {
  console.log("服务器启动成功");
});

//------方法-------------
// 创建管道
const pipeStream = (rs, ws) => {
  return new Promise((resolve, reject) => {
    // 把可读流中的数据写入可写流
    rs.pipe(ws).on("finish", resolve).on("error", reject);
  });
};
// 合并分片
const mergeChunks = async (filename) => {
  const mergedFilePath = path.resolve(PUBLIC_DIR, filename);
  // 获取分片文件的目录
  const chunkDir = path.resolve(TEMP_DIR, filename);
  // 获取分片文件的列表
  const chunkFiles = await fs.readdir(chunkDir);
  // 对分片文件进行排序
  chunkFiles.sort((a, b) => Number(a.split("-")[1]) - Number(b.split("-")[1]));
  try {
    // 为了提高性能,可以写一个并行写入
    const pipes = chunkFiles.map((chunkFile, index) => {
      return pipeStream(
        fs.createReadStream(path.resolve(chunkDir, chunkFile), {
          autoClose: true,
        }),
        fs.createWriteStream(mergedFilePath, { start: index * CHUNK_SIZE }),
      );
    });
    // 并发把每一个分片的数据写入到目标文件中
    await Promise.all(pipes);
    // 删除分片文件
    await fs.rm(chunkDir, { recursive: true, force: true });
  } catch (error) {
    throw error;
  }
};
相关推荐
程序员小寒2 小时前
JavaScript设计模式(九):工厂模式实现与应用
开发语言·前端·javascript·设计模式
派大星酷2 小时前
线程池-----Executors
java·开发语言
小碗羊肉2 小时前
【从零开始学Java | 第二十八篇】可变参数
java·开发语言
zhensherlock2 小时前
Protocol Launcher 系列:Agenda 优雅组织你的想法与日程
javascript·macos·ios·typescript·node.js·mac·ipad
清汤饺子2 小时前
Cursor + Claude Code 组合使用心得:我为什么不只用一个 AI 编程工具
前端·javascript·后端
weitingfu2 小时前
Excel VBA 入门到精通(二):变量、数据类型与运算符
java·大数据·开发语言·学习·microsoft·excel·vba
foundbug9992 小时前
无人机离散系统模型预测控制(MPC)MATLAB实现
开发语言·matlab·无人机
爱写代码的小朋友2 小时前
使用 Nuitka 打包 Python 应用:从入门到进阶
开发语言·python
yuan199972 小时前
C# 断点续传下载文件工具设计与实现
开发语言·c#