Flutter + Node.js:图片上传与文件管理接口设计
图片上传是几乎所有移动应用的标配功能:用户头像、聊天图片、商品图片、朋友圈动态……这些场景都离不开一个稳定可靠的文件上传接口。这篇文章记录了我在 Flutter + Node.js 项目中实现图片上传功能的完整过程,从后端的 Multer 中间件配置到 Flutter 端的图片选择与上传封装。
一、后端:Multer 文件上传中间件
Node.js 生态中处理 multipart/form-data 最成熟的方案是 multer。它支持内存存储和磁盘存储,可以灵活配置文件大小限制、文件类型过滤和存储路径规则。
首先安装依赖:
npm install multer sharp
其中 sharp 是一个高性能的图片处理库,用于在上传时自动压缩图片、生成缩略图。
配置 Multer 存储策略:
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
// 确保上传目录存在
const uploadDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 按日期分目录存储
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const dateDir = new Date()
.toISOString()
.split('T')[0]
.replace(/-/g, '/');
const dir = path.join(uploadDir, dateDir);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const name = crypto.randomBytes(16).toString('hex');
cb(null, `${name}${ext}`);
}
});
// 文件过滤器:只允许图片
const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('仅支持 JPG/PNG/GIF/WEBP 格式的图片'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024 // 单文件最大 10MB
}
});
module.exports = upload;
二、图片处理:自动压缩与缩略图
用户上传的图片往往体积很大,直接存储和传输会浪费大量带宽。使用 sharp 在上传时自动处理图片:
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const processImage = async (filePath) => {
const dir = path.dirname(filePath);
const ext = path.extname(filePath);
const baseName = path.basename(filePath, ext);
const optimizedPath = path.join(dir, `${baseName}_opt${ext}`);
const thumbPath = path.join(dir, `${baseName}_thumb${ext}`);
// 压缩原图(保持比例,最大宽度 1200px)
await sharp(filePath)
.resize(1200, null, { withoutEnlargement: true })
.jpeg({ quality: 80, progressive: true })
.toFile(optimizedPath);
// 生成缩略图(200x200)
await sharp(filePath)
.resize(200, 200, { fit: 'cover' })
.jpeg({ quality: 70 })
.toFile(thumbPath);
// 删除原始文件,保留处理后的版本
fs.unlinkSync(filePath);
return {
original: optimizedPath,
thumbnail: thumbPath
};
};
三、上传接口实现
定义上传路由,支持单图和多图上传:
const express = require('express');
const router = express.Router();
const upload = require('../middleware/upload');
const { protect } = require('../middleware/auth');
const { processImage } = require('../utils/imageProcessor');
const { successResponse, errorResponse } = require('../utils/response');
// 单图上传
router.post('/single', protect, upload.single('image'),
async (req, res) => {
try {
if (!req.file) {
return errorResponse(res, '请选择要上传的图片', 400);
}
const processed = await processImage(req.file.path);
// 返回可访问的 URL
const fileUrl = processed.original.replace(/\\/g, '/');
successResponse(res, {
url: `/uploads/${fileUrl}`,
thumbnail: `/uploads/${processed.thumbnail.replace(/\\/g, '/')}`,
size: fs.statSync(processed.original).size,
mimetype: req.file.mimetype
});
} catch (error) {
errorResponse(res, '图片处理失败: ' + error.message, 500);
}
}
);
// 多图上传(最多 9 张)
router.post('/multiple', protect,
upload.array('images', 9),
async (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return errorResponse(res, '请选择要上传的图片', 400);
}
const results = await Promise.all(
req.files.map(async (file) => {
const processed = await processImage(file.path);
return {
url: `/uploads/${processed.original.replace(/\\/g, '/')}`,
thumbnail: `/uploads/${processed.thumbnail.replace(/\\/g, '/')}`
};
})
);
successResponse(res, { images: results });
} catch (error) {
errorResponse(res, '图片处理失败: ' + error.message, 500);
}
}
);
module.exports = router;
不要忘记在 Express 入口中设置静态文件服务,让上传的图片可以通过 URL 直接访问:
const path = require('path');
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
四、Flutter 端:图片选择与上传
Flutter 端使用 image_picker 选择图片,用 dio 发送 multipart 请求:
import 'package:image_picker/image_picker.dart';
import 'package:dio/dio.dart';
class ImageUploadService {
final Dio _dio;
final ImagePicker _picker = ImagePicker();
ImageUploadService(this._dio);
// 选择并上传单张图片
Future<String?> pickAndUploadImage() async {
try {
// 选择图片
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 90,
);
if (image == null) return null;
// 构建 FormData
final formData = FormData.fromMap({
'image': await MultipartFile.fromFile(
image.path,
filename: image.name,
),
});
// 发送上传请求
final response = await _dio.post(
'/upload/single',
data: formData,
options: Options(contentType: 'multipart/form-data'),
);
if (response.data['success']) {
return response.data['data']['url'];
}
return null;
} catch (e) {
print('图片上传失败: $e');
return null;
}
}
// 选择并上传多张图片
Future<List<String>> pickAndUploadMultiple() async {
try {
final List<XFile> images = await _picker.pickMultiImage(
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 90,
);
if (images.isEmpty) return [];
final List<MultipartFile> files = images.map((img) =>
MultipartFile.fromFileSync(img.path, filename: img.name)
).toList();
// 最多上传9张
final uploadFiles = files.take(9).toList();
final formData = FormData.fromMap({
'images': uploadFiles,
});
final response = await _dio.post(
'/upload/multiple',
data: formData,
options: Options(contentType: 'multipart/form-data'),
);
if (response.data['success']) {
return (response.data['data']['images'] as List)
.map((img) => img['url'] as String)
.toList();
}
return [];
} catch (e) {
print('多图上传失败: $e');
return [];
}
}
}
五、上传进度监听
大文件上传时,进度反馈对用户体验至关重要。Dio 原生支持 onSendProgress 回调:
Future<void> uploadWithProgress(
String filePath,
Function(int sent, int total) onProgress,
) async {
final formData = FormData.fromMap({
'image': await MultipartFile.fromFile(filePath),
});
await _dio.post('/upload/single', data: formData,
onSendProgress: (sent, total) {
final percent = (sent / total * 100).toStringAsFixed(1);
onProgress(sent, total);
print('上传进度: $percent%');
},
);
}
总结
一个完整的图片上传方案需要考虑的远不止「接收文件」这么简单。从后端的文件类型校验、大小限制、自动压缩、缩略图生成,到 Flutter 端的图片选择、multipart 构建和上传进度监听,每一个环节都直接影响用户的实际体验。
这个方案的亮点在于:按日期自动分目录存储、文件名随机生成避免冲突、上传时自动生成优化版本和缩略图、以及 Flutter 端统一的上传服务封装。生产环境中还可以进一步接入云存储(如阿里云 OSS、腾讯云 COS)来替代本地存储,大幅提升可靠性和访问速度。