Vue3+Node.js实现文件上传并发控制与安全防线 进阶篇
上一篇我们实现了最基础的 FormData 上传,体会了前后端的基础联调。但如果在面试中被问到:“如果用户上传了一个 10GB 的文件怎么办?”或者“用户同时选了 100 张图片,浏览器卡死怎么办?”这就需要用到进度监听、双端校验与并发控制。
上一篇我们实现了最基础的 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),直接导致页面卡死。
这种情况下我们应该如何解决呢?
- 使用函数回调和 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()
}
- 利用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;
更多推荐



所有评论(0)