免费资源下载
在移动应用开发中,文件上传功能是许多应用的核心需求。然而在UniApp跨端开发中,不同平台(H5、小程序、App)的文件系统差异给开发者带来了巨大挑战。本文将分享一套经过生产环境验证的完整文件上传解决方案,涵盖从客户端文件选择到云存储集成的全流程实现。
一、跨平台文件处理的挑战分析
在开发一个支持多端的内容创作应用时,我们面临以下技术难题:
- 平台API差异:H5使用input[type=file],小程序使用wx.chooseMedia,App需要原生插件
- 文件格式限制:各平台支持的文件类型、大小限制不一致
- 大文件处理:如何实现大文件分片上传和断点续传
- 上传进度管理:跨平台的上传进度监控方案
- 图片视频压缩:移动端上传前的媒体文件优化处理
二、统一文件选择器设计与实现
2.1 平台适配层封装
// utils/file-chooser.js
class UnifiedFileChooser {
constructor(options = {}) {
this.maxSize = options.maxSize || 50 * 1024 * 1024 // 默认50MB
this.accept = options.accept || 'image/*,video/*'
this.count = options.count || 9
}
async chooseFiles() {
const platform = uni.getSystemInfoSync().platform
let files = []
switch (platform) {
case 'h5':
files = await this.chooseH5Files()
break
case 'mp-weixin':
files = await this.chooseMpFiles()
break
case 'app':
files = await this.chooseAppFiles()
break
default:
throw new Error('Unsupported platform')
}
return await this.processFiles(files)
}
// H5平台实现
chooseH5Files() {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = this.accept
input.multiple = this.count > 1
input.onchange = (e) => {
const files = Array.from(e.target.files)
resolve(files)
}
input.click()
})
}
// 微信小程序实现
chooseMpFiles() {
return new Promise((resolve, reject) => {
uni.chooseMedia({
count: this.count,
mediaType: ['image', 'video'],
sourceType: ['album', 'camera'],
maxDuration: 60,
success: (res) => {
resolve(res.tempFiles)
},
fail: reject
})
})
}
// App平台实现(使用uni.chooseFile)
chooseAppFiles() {
return new Promise((resolve, reject) => {
uni.chooseFile({
count: this.count,
type: 'all',
success: (res) => {
resolve(res.tempFiles)
},
fail: reject
})
})
}
// 文件预处理
async processFiles(files) {
const processedFiles = []
for (const file of files) {
// 检查文件大小
if (file.size > this.maxSize) {
throw new Error(`文件 ${file.name} 超过大小限制`)
}
// 获取文件类型
const fileType = this.getFileType(file)
// 如果是图片,进行压缩处理
if (fileType === 'image') {
const compressed = await this.compressImage(file)
processedFiles.push(compressed)
}
// 如果是视频,获取缩略图
else if (fileType === 'video') {
const videoInfo = await this.getVideoInfo(file)
processedFiles.push(videoInfo)
}
else {
processedFiles.push(file)
}
}
return processedFiles
}
}
三、智能分片上传引擎实现
3.1 分片上传核心类
// utils/chunk-uploader.js
export class ChunkUploader {
constructor(options) {
this.chunkSize = options.chunkSize || 2 * 1024 * 1024 // 2MB
this.concurrent = options.concurrent || 3
this.retryTimes = options.retryTimes || 3
this.file = null
this.chunks = []
this.uploadedChunks = new Set()
this.onProgress = options.onProgress
this.onComplete = options.onComplete
this.onError = options.onError
}
// 初始化文件分片
initFile(file) {
this.file = file
this.totalChunks = Math.ceil(file.size / this.chunkSize)
this.chunks = this.createChunks()
// 尝试恢复已上传的分片
this.restoreProgress()
}
// 创建分片信息
createChunks() {
const chunks = []
for (let i = 0; i b.toString(16).padStart(2, '0')).join('')
chunk.hash = hashHex
return hashHex
}
// 上传单个分片
async uploadChunk(chunk) {
const formData = new FormData()
formData.append('file', chunk.blob)
formData.append('chunkIndex', chunk.index)
formData.append('totalChunks', this.totalChunks)
formData.append('fileHash', this.fileHash)
formData.append('chunkHash', chunk.hash)
formData.append('fileName', this.file.name)
try {
const response = await uni.uploadFile({
url: `${this.baseUrl}/upload/chunk`,
filePath: chunk.blob,
name: 'file',
formData: {
chunkIndex: chunk.index,
totalChunks: this.totalChunks,
fileHash: this.fileHash,
chunkHash: chunk.hash,
fileName: this.file.name
},
header: {
'Authorization': `Bearer ${this.token}`
}
})
if (response.statusCode === 200) {
this.uploadedChunks.add(chunk.index)
this.saveProgress()
this.updateProgress()
return true
}
throw new Error('上传失败')
} catch (error) {
throw error
}
}
// 并发上传控制
async startUpload() {
// 计算文件整体哈希
this.fileHash = await this.calculateFileHash()
// 检查服务器是否已有该文件
const checkResult = await this.checkFileExists()
if (checkResult.exists) {
this.onComplete?.(checkResult.url)
return
}
// 获取已上传的分片列表
const uploaded = await this.getUploadedChunks()
this.uploadedChunks = new Set(uploaded)
// 创建上传队列
const pendingChunks = this.chunks
.filter(chunk => !this.uploadedChunks.has(chunk.index))
.map(chunk => ({ chunk, retry: 0 }))
// 并发上传
const queue = []
for (let i = 0; i 0) {
const task = queue.shift()
try {
await this.uploadChunk(task.chunk)
} catch (error) {
if (task.retry sum + this.chunks[index].size, 0)
const progress = (uploadedSize / this.file.size) * 100
this.onProgress?.({
loaded: uploadedSize,
total: this.file.size,
progress: Math.round(progress),
chunks: {
uploaded: this.uploadedChunks.size,
total: this.totalChunks
}
})
}
}
四、服务端接收与处理
4.1 Node.js服务端实现
// server/upload-controller.js
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
class UploadController {
constructor() {
this.tempDir = path.join(__dirname, '../temp')
this.uploadDir = path.join(__dirname, '../uploads')
this.ensureDirectories()
}
ensureDirectories() {
[this.tempDir, this.uploadDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
})
}
// 接收分片
async receiveChunk(req, res) {
try {
const { chunkIndex, totalChunks, fileHash, chunkHash, fileName } = req.body
const file = req.file
// 验证分片哈希
const calculatedHash = this.calculateHash(file.buffer)
if (calculatedHash !== chunkHash) {
return res.status(400).json({ error: '分片校验失败' })
}
// 保存分片到临时目录
const chunkDir = path.join(this.tempDir, fileHash)
if (!fs.existsSync(chunkDir)) {
fs.mkdirSync(chunkDir, { recursive: true })
}
const chunkPath = path.join(chunkDir, `chunk-${chunkIndex}`)
fs.writeFileSync(chunkPath, file.buffer)
// 记录上传进度
await this.recordProgress(fileHash, chunkIndex)
res.json({
success: true,
chunkIndex,
message: '分片上传成功'
})
} catch (error) {
res.status(500).json({ error: error.message })
}
}
// 合并分片
async mergeChunks(req, res) {
try {
const { fileHash, fileName, totalChunks } = req.body
const chunkDir = path.join(this.tempDir, fileHash)
const filePath = path.join(this.uploadDir, `${fileHash}-${fileName}`)
// 检查是否所有分片都已上传
const uploadedChunks = await this.getUploadedChunks(fileHash)
if (uploadedChunks.length !== totalChunks) {
return res.status(400).json({ error: '分片不完整' })
}
// 按顺序合并分片
const writeStream = fs.createWriteStream(filePath)
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(chunkDir, `chunk-${i}`)
const chunkBuffer = fs.readFileSync(chunkPath)
writeStream.write(chunkBuffer)
// 删除临时分片文件
fs.unlinkSync(chunkPath)
}
writeStream.end()
// 删除临时目录
fs.rmdirSync(chunkDir)
// 清理进度记录
await this.clearProgress(fileHash)
// 生成访问URL
const fileUrl = `/uploads/${path.basename(filePath)}`
res.json({
success: true,
url: fileUrl,
size: fs.statSync(filePath).size,
hash: fileHash
})
} catch (error) {
res.status(500).json({ error: error.message })
}
}
// 检查文件是否存在(秒传功能)
async checkFile(req, res) {
const { fileHash } = req.query
// 在数据库中查找文件
const fileRecord = await this.findFileByHash(fileHash)
if (fileRecord) {
return res.json({
exists: true,
url: fileRecord.url,
size: fileRecord.size
})
}
// 检查已上传的分片
const uploadedChunks = await this.getUploadedChunks(fileHash)
res.json({
exists: false,
uploadedChunks
})
}
calculateHash(buffer) {
return crypto.createHash('sha256').update(buffer).digest('hex')
}
}
五、云存储集成方案
5.1 多云存储适配器
// utils/cloud-storage.js
class CloudStorageAdapter {
constructor(config) {
this.config = config
this.provider = this.initProvider()
}
initProvider() {
switch (this.config.provider) {
case 'qiniu':
return new QiniuProvider(this.config)
case 'aliyun':
return new AliyunOSSProvider(this.config)
case 'tencent':
return new TencentCOSProvider(this.config)
case 'aws':
return new AWSProvider(this.config)
default:
throw new Error('不支持的云存储提供商')
}
}
async uploadFile(file, options = {}) {
// 生成唯一文件名
const fileName = this.generateFileName(file, options)
// 如果是大文件,使用分片上传
if (file.size > this.config.chunkThreshold) {
return await this.provider.multipartUpload(file, fileName, options)
}
// 小文件直接上传
return await this.provider.putObject(file, fileName, options)
}
generateFileName(file, options) {
const timestamp = Date.now()
const random = Math.random().toString(36).substr(2, 8)
const ext = path.extname(file.name)
const prefix = options.prefix || 'uploads'
return `${prefix}/${timestamp}-${random}${ext}`
}
async getUploadToken(fileName) {
// 生成临时上传凭证
const policy = {
scope: `${this.config.bucket}:${fileName}`,
deadline: Math.floor(Date.now() / 1000) + 3600 // 1小时有效
}
const encodedPolicy = Buffer.from(JSON.stringify(policy)).toString('base64')
const sign = this.signPolicy(encodedPolicy)
return {
token: `${this.config.accessKey}:${sign}:${encodedPolicy}`,
uploadUrl: this.config.uploadUrl,
expires: policy.deadline
}
}
}
// 七牛云实现示例
class QiniuProvider {
constructor(config) {
this.config = config
}
async multipartUpload(file, fileName, options) {
// 初始化分片上传
const initResponse = await this.initiateMultipartUpload(fileName)
const uploadId = initResponse.uploadId
// 分片上传
const chunkSize = 4 * 1024 * 1024 // 4MB
const totalChunks = Math.ceil(file.size / chunkSize)
const uploadedParts = []
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
const partResponse = await this.uploadPart(
fileName,
uploadId,
i + 1,
chunk
)
uploadedParts.push({
partNumber: i + 1,
etag: partResponse.etag
})
// 更新进度
options.onProgress?.({
loaded: end,
total: file.size,
progress: Math.round((end / file.size) * 100)
})
}
// 完成分片上传
return await this.completeMultipartUpload(
fileName,
uploadId,
uploadedParts
)
}
}
六、客户端完整集成示例
6.1 上传组件实现
<template>
<view class="upload-container">
<!-- 上传区域 -->
<view class="upload-area" @click="chooseFiles">
<uni-icons type="plus" size="40" color="#ccc"></uni-icons>
<text class="upload-text">点击选择文件</text>
<text class="upload-hint">支持图片、视频、文档,最大50MB</text>
</view>
<!-- 文件列表 -->
<view class="file-list" v-if="files.length > 0">
<view class="file-item" v-for="(file, index) in files" :key="file.id">
<!-- 文件预览 -->
<view class="file-preview">
<image v-if="file.type === 'image'"
:src="file.previewUrl"
mode="aspectFill"></image>
<video v-else-if="file.type === 'video'"
:src="file.previewUrl"
controls></video>
<uni-icons v-else type="file" size="40"></uni-icons>
</view>
<!-- 文件信息 -->
<view class="file-info">
<text class="file-name">{{ file.name }}</text>
<text class="file-size">{{ formatSize(file.size) }}</text>
<!-- 上传进度 -->
<view class="progress-container" v-if="file.status !== 'success'">
<progress :percent="file.progress"
stroke-width="4"
:active-color="getProgressColor(file.status)">
</progress>
<text class="progress-text">
{{ getStatusText(file) }}
</text>
</view>
<!-- 操作按钮 -->
<view class="file-actions">
<button v-if="file.status === 'error'"
@click="retryUpload(index)"
size="mini">
重试
</button>
<button @click="removeFile(index)"
size="mini">
删除
</button>
</view>
</view>
</view>
</view>
<!-- 上传按钮 -->
<button class="upload-button"
@click="startUploadAll"
:disabled="uploading || files.length === 0">
{{ uploading ? '上传中...' : `开始上传 (${files.length})` }}
</button>
</view>
</template>
<script>
import { UnifiedFileChooser } from '@/utils/file-chooser'
import { ChunkUploader } from '@/utils/chunk-uploader'
export default {
data() {
return {
files: [],
uploading: false,
uploader: null
}
},
methods: {
async chooseFiles() {
try {
const chooser = new UnifiedFileChooser({
maxSize: 50 * 1024 * 1024,
count: 9,
accept: 'image/*,video/*,.pdf,.doc,.docx'
})
const selectedFiles = await chooser.chooseFiles()
// 添加到文件列表
selectedFiles.forEach(file => {
this.files.push({
id: this.generateId(),
name: file.name,
size: file.size,
type: this.getFileType(file),
previewUrl: file.previewUrl || '',
rawFile: file,
status: 'pending', // pending, uploading, success, error
progress: 0,
error: null
})
})
} catch (error) {
uni.showToast({
title: error.message,
icon: 'none'
})
}
},
async startUploadAll() {
this.uploading = true
for (let i = 0; i {
this.files[index].progress = progress.progress
this.$forceUpdate()
},
onComplete: (url) => {
this.files[index].status = 'success'
this.files[index].progress = 100
this.files[index].url = url
this.$forceUpdate()
},
onError: (error) => {
this.files[index].status = 'error'
this.files[index].error = error.message
this.$forceUpdate()
}
})
uploader.initFile(file.rawFile)
await uploader.startUpload()
},
retryUpload(index) {
this.files[index].status = 'pending'
this.files[index].progress = 0
this.files[index].error = null
this.uploadFile(this.files[index], index)
},
removeFile(index) {
this.files.splice(index, 1)
},
getFileType(file) {
if (file.type.startsWith('image/')) return 'image'
if (file.type.startsWith('video/')) return 'video'
return 'document'
},
generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
},
formatSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
},
getProgressColor(status) {
const colors = {
uploading: '#007aff',
success: '#34c759',
error: '#ff3b30',
pending: '#c7c7cc'
}
return colors[status] || '#007aff'
},
getStatusText(file) {
const texts = {
uploading: `上传中 ${file.progress}%`,
success: '上传成功',
error: `上传失败: ${file.error}`,
pending: '等待上传'
}
return texts[file.status] || ''
}
}
}
</script>
七、性能优化与最佳实践
7.1 上传性能优化策略
- 并发控制:根据网络类型动态调整并发数(WiFi: 5, 4G: 3, 3G: 2)
- 智能分片:根据文件大小和网络状况动态调整分片大小
- 本地缓存:已上传文件信息本地缓存,避免重复上传
- 网络检测:上传前检测网络状况,弱网环境下降低质量
7.2 内存管理优化
// 内存优化示例
class MemoryOptimizedUploader {
constructor() {
this.maxMemoryUsage = 100 * 1024 * 1024 // 100MB
this.currentMemory = 0
this.fileCache = new Map()
}
async processLargeFile(file) {
// 使用流式处理,避免一次性加载大文件到内存
const stream = file.stream()
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
// 处理数据块
await this.processChunk(value)
// 释放内存
if (this.currentMemory > this.maxMemoryUsage) {
await this.cleanupMemory()
}
}
}
cleanupMemory() {
// 清理旧的缓存
const keys = Array.from(this.fileCache.keys())
if (keys.length > 10) {
const toRemove = keys.slice(0, keys.length - 10)
toRemove.forEach(key => {
this.fileCache.delete(key)
this.currentMemory -= this.calculateSize(key)
})
}
}
}
八、错误处理与监控
8.1 完整的错误处理机制
// utils/upload-error-handler.js
class UploadErrorHandler {
static errors = {
NETWORK_ERROR: {
code: 1001,
message: '网络连接失败',
action: '检查网络后重试'
},
FILE_TOO_LARGE: {
code: 1002,
message: '文件大小超过限制',
action: '请选择小于50MB的文件'
},
UPLOAD_TIMEOUT: {
code: 1003,
message: '上传超时',
action: '请重试或切换网络'
},
SERVER_ERROR: {
code: 1004,
message: '服务器错误',
action: '请稍后重试'
}
}
static handleError(error, context) {
// 记录错误日志
this.logError(error, context)
// 用户友好的错误提示
const userMessage = this.getUserMessage(error)
this.showToast(userMessage)
// 根据错误类型采取不同策略
switch (error.code) {
case 1001: // 网络错误
return this.retryWithBackoff(context)
case 1002: // 文件太大
return this.suggestCompression(context)
case 1003: // 超时
return this.retryWithLargerTimeout(context)
default:
return this.generalRecovery(context)
}
}
static logError(error, context) {
const logData = {
timestamp: new Date().toISOString(),
error: {
code: error.code,
message: error.message,
stack: error.stack
},
context: {
fileSize: context.file?.size,
fileType: context.file?.type,
networkType: context.networkType,
platform: uni.getSystemInfoSync().platform
},
userInfo: this.getAnonymousUserInfo()
}
// 发送到监控平台
this.reportToMonitoring(logData)
// 本地存储用于调试
this.storeLocally(logData)
}
}
九、总结与展望
本文详细介绍了UniApp中实现跨平台文件上传的完整解决方案,涵盖以下核心内容:
- 统一文件选择器:封装各平台差异,提供一致的API
- 智能分片上传:支持大文件上传和断点续传
- 服务端处理:完整的Node.js接收和合并实现
- 云存储集成:多云存储提供商适配方案
- 性能优化:内存管理、并发控制等优化策略
- 错误处理:完善的错误监控和恢复机制
该方案已在多个生产环境中验证,能够稳定处理各种文件上传场景。未来可以进一步优化:
- 集成WebRTC实现P2P文件传输
- 添加AI驱动的智能压缩和格式转换
- 实现离线上传队列和后台传输
- 集成区块链技术实现文件存证
通过本文的完整实现,开发者可以快速构建稳定、高效的文件上传功能,为用户提供流畅的文件传输体验。

