文件上传
1.文件上传模式
在实际开发中,文件上传通常有两种模式:分步提交(先传文件,再提交表单)和同步提交(文件与表单字段一起提交)。选择哪种模式,直接影响前后端架构、代码复杂度和后期维护成本。
✅ 现代项目推荐:分步提交
- 流程:
- 前端先将文件单独上传到服务端;
- 服务端返回一个临时或永久的文件 URL;
- 用户提交表单时,仅将该 URL 作为普通字符串字段,通过 JSON 发送给后端。
- 优势:
- 表单提交轻量、统一(纯 JSON),无需特殊处理;
- 文件可预览、裁剪、重传,用户体验更好;
- 便于对接 CDN、OSS 等云存储;
- 适用于跨平台场景(如微信小程序 + H5 + App 共用一套逻辑)
传统项目常见:同步提交
- 流程:将文件与其他表单字段(如标题、描述)打包进 FormData,一次性提交。
- 本质:无论使用原生 form标签 -> enctype="multipart/form-data" 还是 JavaScript 的 new FormData(),底层都是 multipart 请求。
- 缺点:
- 每次提交都要区分“哪些是文件、哪些是普通字段”;
- 不利于前后端分离(后端需解析 multipart 而非 JSON);
- 在小程序等受限环境中兼容性差(例如不支持 FormData)。
🔑 关键结论
- 无论哪种模式,第一步上传文件的本质都是 FormData(或等效的二进制流)。区别在于:
- 分步提交:只在上传文件时用一次 FormData,后续全是 JSON;
- 同步提交:每次提交都要构造 FormData,混合处理文件与字段。
- 从长期维护角度看,分步提交更清晰、更易扩展,尤其适合人员流动大、业务变化快的企业后台系统。
2.前端代码
Web 端:Vue 2 + Element UI
<template>
<el-upload
ref="upload"
class="upload-demo"
drag
action="http://127.0.0.1:8089/api/uploads"
:limit="1"
:on-success="handleFileUploadSuccess"
:on-remove="handleFileRemove"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">
将文件拖到此处,或<em>点击上传</em>
</div>
</el-upload>
</template>
<script>
export default {
data() {
return {
form: {
fileName: '',
filePath: ''
}
};
},
methods: {
// 文件上传成功
handleFileUploadSuccess(response) {
// 假设后端返回:{ data: [{ fileName: 'xxx', filePath: '/uploads/xxx' }] }
const file = response.data?.[0];
if (file) {
this.form.fileName = file.fileName;
this.form.filePath = file.filePath;
// 清除可能存在的表单验证错误(如有)
this.$refs.ruleForm?.clearValidate('filePath');
}
},
// 文件被移除
handleFileRemove() {
this.form.fileName = '';
this.form.filePath = '';
}
}
};
</script>
App端:uni-app
uni.chooseImage({
success: (chooseImageRes) => {
const tempFilePaths = chooseImageRes.tempFilePaths;
uni.uploadFile({
url: 'https://www.example.com/upload', //仅为示例,非真实的接口地址
filePath: tempFilePaths[0],
name: 'file',
formData: {
'user': 'test'
},
success: (uploadFileRes) => {
console.log(uploadFileRes.data);
}
});
}
});
📌 补充说明
无论是 Element UI 的 el-upload,还是 uni-app 的 uni.uploadFile(),它们在发起文件上传请求时,底层最终都会使用类似 FormData 的机制(或等效的 multipart/form-data 格式)将文件作为二进制数据提交到服务端。
- 在 浏览器环境(如 Element UI)中,el-upload 内部确实直接使用了原生的 FormData API 构造请求体。
- 在 小程序或 App 环境(如 uni-app)中,虽然 JavaScript 层没有暴露 FormData 对象(微信小程序等不支持),但 uni.uploadFile() 会在原生层自动构造一个符合 multipart/form-data 规范的 HTTP 请求,其效果与 FormData 等价
所有基于 HTTP 的文件上传,只要走的是标准表单上传方式,本质上都是 multipart/form-data 格式 —— 只是不同平台对开发者是否暴露 FormData 这个接口而已
这也解释了为什么后端(如 Express、Spring Boot、Go 等)可以用同一套逻辑处理来自 Web、H5、小程序的文件上传请求:它们收到的,都是标准的 multipart/form-data 请求
3.后端代码
接收文件,返回临时链接
const path = require('path');
const dayjs = require('dayjs');
// multer 是用于处理 multipart/form-data 请求的 Node.js 中间件,常用于文件上传
const multer = require('multer');
const fs = require('fs').promises;
// 获取项目根目录
const rootPath = getProjectRoot();
// 临时文件存储目录
const uploadDir = path.resolve(rootPath, 'public/tmp');
// 确保上传目录存在
ensureUploadsDirectory().catch(err => {
console.error('无法创建上传目录:', err);
});
// 配置文件存储引擎
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, uploadDir);
},
filename: function (req, file, cb) {
cb(null, dayjs().format('YYYYMMDDHHmmss') + '-' + file.originalname);
}
});
// 初始化 multer 中间件
const upload = multer({ storage: storage });
// 注册路由
exports.install = function (router) {
router.post(`/uploads`, upload.array('file', 9), async function (req, res) {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).send('没有接收到文件!');
}
// 构造返回的文件信息列表
const files = req.files.map((file) => {
const absoluteFilePath = file.path;
const relativeFilePath = path.relative(rootPath, absoluteFilePath);
return {
originalName: file.originalname,
fileName: file.filename,
filePath: relativeFilePath,
fileType: file.mimetype,
fileSize: file.size
};
});
// 返回成功响应
res.send({
code: 1,
data: files,
msg: "上传成功!"
});
} catch (error) {
res.send({
code: 0,
data: null,
msg: error.message
});
}
});
};
// 确保上传目录存在的工具方法
async function ensureUploadsDirectory() {
try {
await fs.access(uploadDir);
} catch (error) {
await fs.mkdir(uploadDir, { recursive: true });
}
}
// 获取项目根目录的工具方法
function getProjectRoot() {
let currentDir = __dirname;
while (true) {
if (fs.existsSync(path.join(currentDir, 'package.json'))) {
return currentDir;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
throw new Error("Could not find project root containing package.json");
}
currentDir = parentDir;
}
}
“转正”接口设计
// 获取项目根目录
const rootPath = getProjectRoot();
// 正式文件存储目录
const targetDir = path.resolve(rootPath, 'public/AppPackages');
try {
// 构造临时文件的绝对路径
const tempFilePath = path.join(rootPath, req.body.filePath);
// 使用原上传时生成的文件名
const newFileName = path.basename(req.body.filePath);
// 目标文件的绝对路径
const targetFilePath = path.join(targetDir, newFileName);
// 生成相对于项目根目录的路径(用于数据库存储或前端访问)
const relativeFilePath = path.relative(rootPath, targetFilePath);
// 将文件从临时目录移动到正式目录
await fs.rename(tempFilePath, targetFilePath);
// 组装业务数据
const row = {
appName: req.body.appName,
version: req.body.version,
fileName: req.body.fileName,
filePath: relativeFilePath,
content: req.body.content,
status: req.body.status === undefined ? 1 : req.body.status,
};
// 此处执行数据库插入操作(例如:await AppUpdateModel.create(row))
// 成功后返回新记录
res.send({
code: 1,
data: row, // 或实际插入后的完整记录
msg: "新增成功!"
});
} catch (error) {
res.send({
code: 0,
data: null,
msg: error.message
});
}
4.文件清理
方案一:手动梳理 + 脚本清理
- 步骤:
- 手动梳理数据表(识别文件引用字段)
- 编写清理脚本
为避免未来再花人力梳理,建议在数据库设计时遵循以下规范:所有文件字段以 _file 或 _path 结尾,如 contract_file, id_card_front_path
方案二:成本对比 —— 硬盘 vs 人工
- 硬盘成本(企业级 SATA HDD)
- 人力成本(开发/运维工程师)
现实中,绝大多数普通企业内部管理系统(如 OA、HR、CRM 等)遇到上传文件存储快满的情况时,会选择直接增加硬盘(或云存储空间),而不是投入人力去梳理和清理文件
这一做法在中小企业中尤为普遍,原因包括:
- 硬盘价格低廉(2025年,一块 8TB 企业级 SATA 硬盘市场价格约 ¥1200–¥1800);
- 人力成本高(一名中级开发工程师日薪通常在 ¥1000–¥2000,含社保和管理成本后更高);
- 清理逻辑复杂、易出错,而加硬盘操作简单、风险低;
- 内部系统用户量有限,文件增长缓慢,一块大容量硬盘往往可支撑数年

浙公网安备 33010602011771号