上一篇我们实现了最基础的 FormData 上传,体会了前后端的基础联调。但如果在面试中被问到:“如果用户上传了一个 10GB 的文件怎么办?”或者“用户同时选了 100 张图片,浏览器卡死怎么办?”这就需要设置安全防线和并发控制。

1 核心概念

安全防线 - 文件校验
前端需要做文件筛查,后端实现安全防线和数据兜底:

  • 第一层:前端 JS 校验, 通过设置文件类型和大小的最大限制, 在文件上传之前对文件做出校验, 不符合要求拦截请求, 不让发出
// 安全校验
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/jpg', 'application/pdf']
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_CONCURRENT = 3

if (!ALLOWED_TYPES.includes(file.type)) {
     message.value = '文件类型不支持'
     return
   }
if (file.size > MAX_FILE_SIZE) {
     message.value = '文件大小超过限制'
     return
}
  • 第二层:后端请求头校验 使用 Node.js 的 multer 中间件,通过配置服务端 limits 文件限制和 fileFilter 文件过滤, 能拦截超大文件,防止服务器内存溢出。
const upload = multer({
storage: storage,
limits: { fileSize: 1024 * 1024 * 10, files: 1 },
fileFilter: (req, file, cb) => {
  const allowedTypes = ["image/jpeg", "image/png", "application/pdf"];
  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new Error("文件类型不支持"), false);
  }
},
});
  • 第三层:二进制文件校验, 读取文件底层二进制数据的前 8 个字节,与标准文件类型的十六进制码进行比对。通过 fs.read()buffer 比对。无论用户怎么改后缀、改请求头,文件底层的二进制编码是无法伪造的。
// 底层二进制防线
const isJPG = (buffer) =>
buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
const isPNG = (buffer) =>
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47;
const isPDF = (buffer) =>
buffer[0] === 0x25 &&
buffer[1] === 0x50 &&
buffer[2] === 0x44 &&
buffer[3] === 0x46;

app.post("/upload", upload.single("file"), (req, res) => {
if (!req.file) {
  return res.status(400).json({ msg: "文件不存在" });
}
const filePath = req.file.path;
// 读取文件的前 8 个字节
const buffer = Buffer.alloc(8);
const fd = fs.openSync(filePath, "r");
fs.readSync(fd, buffer, 0, 8, 0);
fs.closeSync(fd);

// 进行深度校验
if (isJPG(buffer) || isPNG(buffer) || isPDF(buffer)) {
  // 校验通过,保留文件
  res.json({
    msg: "安全校验通过,上传成功",
    filename: req.file.filename,
  });
} else {
  // 校验失败:检测到恶意伪装!立刻从硬盘删除临时文件
  fs.unlinkSync(filePath);
  console.warn(`[安全拦截] 恶意文件尝试上传: ${req.file.originalname}`);
  res.status(403).json({ msg: "安全警告:检测到伪造文件,已强制删除!" });
}
});

并发控制 - 多文件上传
如果不做控制,直接通过 Promise.all 一次性发送 100 个文件,会导致两个严重问题:

  • 浏览器 TCP 连接限制, Chrome 浏览器规定,对同一个域名最多只能同时维持 6 个网络连接。多出的 94
    个请求会被强制挂起(Pending)。排队时间过长会导致后面的请求直接超时(Timeout)断开。
  • 前端内存占用过高100 个大文件的 FormData 实例会瞬间占满内存,触发浏览器垃圾回收(GC),直接导致页面卡死。

这种情况下我们应该如何解决呢?

  1. 使用函数回调和 activeCount 来实现文件上传的接力, 通过设置任务池, 活动计数器和最大并发限制来实现任务的执行
// 状态管理
const pendingFiles = ref<File[]>([])
const activeCount = ref(0)
const message = ref('')

// 第一道安全防线
const uploadFileChange = (e: Event) => {
 const target = e.target as HTMLInputElement
 const files = target.files
 if (!files) return
 message.value = ''
 let addedCount = 0
 for (let i = 0; i < files.length; i++) {
   const file = files[i] as File
   if (!ALLOWED_TYPES.includes(file.type)) {
     message.value = '文件类型不支持'
     return
   }
   if (file.size > MAX_FILE_SIZE) {
     message.value = '文件大小超过限制'
     return
   }
   pendingFiles.value.push(file)
   addedCount++
 }
 message.value = `已选择 ${addedCount} 个文件`
 target.value = ''
}

const startUpload = () => {
 if (pendingFiles.value.length === 0) {
   message.value = '请先选择文件'
   return
 }
 processQueue()
}

const processQueue = () => {
 if (activeCount.value >= MAX_CONCURRENT || pendingFiles.value.length === 0) {
   return
 }
 const nextFile = pendingFiles.value.shift()
 if (!nextFile) {
   return
 }
 activeCount.value++
 uploadSingleFile(nextFile)
 processQueue()
}
  1. 利用node.js异步非阻塞的优点, 并限制最大请求文件
limits: { fileSize: 1024 * 1024 * 10, files: 1 },

2 前端实现:

我们需要使用 Axios/XHR 而不是 fetch, 因为 fetch 具有局限性。fetch API 设计上不支持监听上传进度(只能通过 response.body.getReader() 监听下载进度)。要实现上传进度条,底层必须依赖 XMLHttpRequest 的 upload.onprogress 事件,这也是我们选用 Axios 的原因。
在前端,我们给 Axios 配置 onUploadProgress 来获取进度。同时,利用现代 JS 的 AbortController 来实现“取消上传”功能

<template>
  <div class="upload-container">
    <input type="file" multiple accept=".jpg,.png,.jpeg,.pdf" @change="uploadFileChange" />
    <button @click="startUpload" :disabled="pendingFiles.length === 0 && activeCount === 0">
      开始并发上传
    </button>
    <div class="status-panel">
      <p v-if="message" class="message">{{ message }}</p>
      <p>待上传文件: {{ pendingFiles.length }}</p>
      <p>正在上传: {{ activeCount }}</p>
    </div>
  </div>
</template>
<script setup lang="ts">
import axios from 'axios'
import { ref } from 'vue'

// 配置文件状态
// 安全校验
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/jpg', 'application/pdf']
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_CONCURRENT = 3

// 状态管理
const pendingFiles = ref<File[]>([])
const activeCount = ref(0)
const message = ref('')

// 第一道安全防线
const uploadFileChange = (e: Event) => {
  const target = e.target as HTMLInputElement
  const files = target.files
  if (!files) return
  message.value = ''
  let addedCount = 0
  for (let i = 0; i < files.length; i++) {
    const file = files[i] as File
    if (!ALLOWED_TYPES.includes(file.type)) {
      message.value = '文件类型不支持'
      return
    }
    if (file.size > MAX_FILE_SIZE) {
      message.value = '文件大小超过限制'
      return
    }
    pendingFiles.value.push(file)
    addedCount++
  }
  message.value = `已选择 ${addedCount} 个文件`
  target.value = ''
}

const startUpload = () => {
  if (pendingFiles.value.length === 0) {
    message.value = '请先选择文件'
    return
  }
  processQueue()
}

const processQueue = () => {
  if (activeCount.value >= MAX_CONCURRENT || pendingFiles.value.length === 0) {
    return
  }
  const nextFile = pendingFiles.value.shift()
  if (!nextFile) {
    return
  }
  activeCount.value++
  uploadSingleFile(nextFile)
  processQueue()
}

const uploadSingleFile = async (file: File) => {
  const formData = new FormData()
  formData.append('file', file)
  try {
    const res = await axios.post('http://localhost:3000/upload', formData)
    console.log(`上传成功: ${res.data.filename}`)
  } catch (error) {
    console.error(`上传失败: ${file.name}`, error)
  } finally {
    // 【关键接力点】:无论成功失败,释放通道,并呼叫下一个排队文件
    activeCount.value--
    processQueue()
  }
}
</script>

<style scoped>
.upload-container {
  max-width: 400px;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}
input,
button {
  margin-bottom: 15px;
  cursor: pointer;
}
button {
  padding: 8px 16px;
  background-color: #4caf50;
  color: white;
  border: none;
  border-radius: 4px;
}
button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

/* 状态面板样式 */
.status-panel {
  background: #f9f9f9;
  padding: 10px;
  border-radius: 4px;
}
.message {
  color: #d32f2f;
  font-size: 14px;
}
.badge {
  font-weight: bold;
}
.pending {
  color: #f57c00;
}
.active {
  color: #1976d2;
}
</style>


在 Vue 3 中,不要把 File 对象直接放到 reactive 里,这会导致性能问题。使用 ref 并通过 .value 操作即可。

3 后端实现:多文件与拦截器(实现并发和安全防线)

后端我们继续使用 Multer,但这次升级为 upload.array() 以支持多文件,并加上了 limits 和 fileFilter。这一步体现了后端对内存和存储的保护。

const express = require("express");
const app = express();
const cors = require("cors");
// 引入multer
const multer = require("multer");
const fs = require("fs");
const path = require("path");

// 修复cors中间件错误
app.use(cors());

// 确保存储的目录存在
const uploadDir = path.join(__dirname, "uploads");
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir);
}

// 配置 Multer 存储引擎
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, uploadDir);
  },
  filename: function (req, file, cb) {
    // 修复文件名乱码问题
    // Multer 处理 header 时默认用 latin1,需转回 utf8
    const originalName = Buffer.from(file.originalname, "latin1").toString(
      "utf8",
    );
    // 加上时间戳防止同名文件覆盖
    cb(null, Date.now() + "-" + originalName);
  },
});

// 配置安全防线
const upload = multer({
  storage: storage,
  limits: { fileSize: 1024 * 1024 * 10, files: 1 },
  fileFilter: (req, file, cb) => {
    const allowedTypes = ["image/jpeg", "image/png", "application/pdf"];
    if (allowedTypes.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("文件类型不支持"), false);
    }
  },
});

// 底层二进制防线
const isJPG = (buffer) =>
  buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
const isPNG = (buffer) =>
  buffer[0] === 0x89 &&
  buffer[1] === 0x50 &&
  buffer[2] === 0x4e &&
  buffer[3] === 0x47;
const isPDF = (buffer) =>
  buffer[0] === 0x25 &&
  buffer[1] === 0x50 &&
  buffer[2] === 0x44 &&
  buffer[3] === 0x46;

app.post("/upload", upload.single("file"), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ msg: "文件不存在" });
  }
  const filePath = req.file.path;
  // 读取文件的前 8 个字节
  const buffer = Buffer.alloc(8);
  const fd = fs.openSync(filePath, "r");
  fs.readSync(fd, buffer, 0, 8, 0);
  fs.closeSync(fd);

  // 进行深度校验
  if (isJPG(buffer) || isPNG(buffer) || isPDF(buffer)) {
    // 校验通过,保留文件
    res.json({
      msg: "安全校验通过,上传成功",
      filename: req.file.filename,
    });
  } else {
    // 校验失败:检测到恶意伪装!立刻从硬盘删除临时文件
    fs.unlinkSync(filePath);
    console.warn(`[安全拦截] 恶意文件尝试上传: ${req.file.originalname}`);
    res.status(403).json({ msg: "安全警告:检测到伪造文件,已强制删除!" });
  }
});

// 专门捕获 Multer 抛出的异常 (如超出大小)
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    if (err.code === "LIMIT_FILE_SIZE") {
      return res.status(400).json({ msg: "文件过大,最大限制为 5MB" });
    }
  }
  // 捕获 fileFilter 自定义的错误
  res.status(400).json({ msg: err.message || "上传失败" });
});

const server = app.listen(3000, () => {
  console.log("Server started on port 3000");
});

// 设置超时时间
server.timeout = 600000;

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐