摘要:在 B 端复杂业务系统中,文件上传是高频且痛点颇多的场景。本文将分享如何通过策略模式、组合式 API (Composables) 和 分层架构,设计并落地一套高可用、可扩展的前端统一上传中心。支持自动分片、秒传、断点续传等高级特性,彻底告别“组件满天飞、逻辑到处写”的混沌状态。
🧐 背景与痛点
你是否在项目中遇到过以下情况?
- 重复造轮子:A 同学封装了
UploadImage,B 同学封装了FileUpload,C 同学直接在页面里手写axios调用。 - 体验不一致:有的地方有进度条,有的地方只能干等;有的支持拖拽,有的只能点击。
- 大文件噩梦:上传几百兆的 PDF 或视频时,经常超时失败,用户体验极差。
- 维护困难:后端接口变动(如增加签名校验),需要修改几十个文件。
为了解决这些问题,我们决定将“上传”这一能力服务化,沉淀出一套统一上传服务中心。
🏗️ 架构设计:分层解耦
为了兼顾灵活性(Utils 调用)和便捷性(组件调用),我们采用了分层架构设计:
flowchart BT
subgraph Layer4_Server [后端服务层]
API_Direct[直传接口]
API_Chunk[分片接口: Init/Upload/Merge]
end
subgraph Layer3_API [统一 API 层]
Service[api/_shared/upload-service.ts]
Service --> API_Direct
Service --> API_Chunk
end
subgraph Layer2_Core [核心 SDK 层]
Utils[utils/upload/core.ts]
Strategy[策略模式: 直传 vs 分片]
Hash[Hash 计算 (Web Worker)]
Queue[并发队列管理]
Utils --> Service
Utils --> Strategy
Strategy --> Hash
Strategy --> Queue
end
subgraph Layer1_State [状态管理层]
Hook[composables/useUpload.ts]
State[Reactive: status/progress/error]
Hook --> Utils
Hook --> State
end
subgraph Layer0_UI [UI 组件层]
Component[CmcUpload.vue]
BizPage[业务页面]
Component --> Hook
BizPage --> Hook
end
- Layer 0 (UI): 傻瓜式组件,只负责展示和交互。
- Layer 1 (Composables):
useUpload,负责状态管理(Loading、进度、错误处理)。 - Layer 2 (SDK):
Uploader类,核心逻辑所在,处理切片、Hash、并发、重试。 - Layer 3 (API): 统一接口定义,屏蔽 HTTP 细节。
💻 核心代码实现
1. 策略模式:自动决策上传方式
我们定义一个 Uploader 类,根据文件大小自动判断走“直传”还是“分片上传”。
// src/utils/upload/core.ts
export class Uploader {
// ...
async upload(file: File, options?: UploadOptions) {
const opts = { ...this.options, ...options }
// 策略决策:文件大小 > 阈值 (如 10MB) 且开启分片,则走分片上传
if (opts.useChunk && file.size > (opts.chunkThreshold || 10 * 1024 * 1024)) {
return await this.uploadChunked(file, opts)
} else {
return await this.uploadSimple(file, opts)
}
}
}
2. Hash 计算优化:抽样读取
对于超大文件(如 1GB+),全量计算 MD5 非常耗时。我们采用抽样计算策略:读取文件的前 2MB、中间 2MB 和最后 2MB 来计算指纹,牺牲极小概率的碰撞风险,换取极大的速度提升。
// src/utils/upload/hash.ts
import CryptoJS from 'crypto-js'
export function calculateFileHash(file: File): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader()
const chunkSize = 2 * 1024 * 1024 // 2MB
// 小文件全量计算,大文件抽样计算
if (file.size <= chunkSize * 2) {
reader.readAsArrayBuffer(file)
} else {
const chunks: Blob[] = []
chunks.push(file.slice(0, chunkSize)) // 头
chunks.push(file.slice(Math.floor(file.size / 2), Math.floor(file.size / 2) + chunkSize)) // 中
chunks.push(file.slice(file.size - chunkSize, file.size)) // 尾
reader.readAsArrayBuffer(new Blob(chunks))
}
reader.onload = (e) => {
const wordArray = CryptoJS.lib.WordArray.create(e.target.result)
resolve(CryptoJS.MD5(wordArray).toString())
}
})
}
3. 分片上传与并发控制
分片上传的核心在于:切片 -> 并发上传 -> 合并。同时通过 init 接口检查服务端是否已存在该文件(秒传)。
// 核心逻辑伪代码
private async uploadChunked(task: UploadTask, opts: UploadOptions) {
// 1. 计算 Hash
const fileHash = await calculateFileHash(task.file)
// 2. 初始化 (检查秒传)
const initRes = await initMultipartUploadAPI({ ... })
if (initRes.shouldUpload === false) {
return initRes.url // 🔥 秒传成功!
}
// 3. 切片
const chunks = this.createChunks(task.file, opts.chunkSize)
// 4. 过滤已上传的分片 (断点续传)
const todoChunks = chunks.filter(c => !initRes.uploadedChunks.includes(c.index))
// 5. 并发上传 (控制并发数,防止浏览器卡死)
const concurrency = 3
const pool = []
for (const chunk of todoChunks) {
const p = this.uploadSingleChunk(chunk, fileHash)
pool.push(p)
if (pool.length >= concurrency) {
await Promise.race(pool)
// 清理已完成的任务...
}
}
await Promise.all(pool)
// 6. 发送合并请求
return await completeMultipartUploadAPI({ ... })
}
4. 组合式 API:让 UI 变“傻”
我们将状态管理逻辑封装在 useUpload 中,UI 组件只需关注展示。
// src/composables/core/useUpload.ts
import { ref, computed } from 'vue'
import { upload as uploadFn } from '~/utils/upload'
export function useUpload(defaultOptions = {}) {
const status = ref('pending') // pending | uploading | success | error
const progress = ref({ percentage: 0, speed: 0 })
const upload = async (file: File) => {
status.value = 'uploading'
try {
const res = await uploadFn(file, defaultOptions, (p) => {
progress.value = p
})
status.value = 'success'
return res
} catch (e) {
status.value = 'error'
throw e
}
}
return { status, progress, upload }
}
🎨 最佳实践与思考
1. 秒传与断点续传
- 秒传:本质是“以 Hash 换时间”。前端计算 Hash 发送给后端,后端查询数据库,如果存在相同 Hash 的文件,直接返回 URL。
- 断点续传:后端维护一个
uploadedChunks集合。前端上传前先查询,只上传缺失的分片。
2. 暂停与恢复
利用 AbortController 取消正在进行的 HTTP 请求。恢复时,重新走一遍 uploadChunked 流程,得益于断点续传机制,会自动跳过已上传的部分。
3. 可观测性
我们在 Uploader 类中埋入了统一的监控点:
- 上传开始:记录文件类型、大小。
- 上传结束:记录耗时、平均速度。
- 上传失败:记录错误码、重试次数。
🚀 总结
通过这套统一上传服务,我们实现了:
- 能力复用:无论是 PC 端 Web、移动端 H5 还是小程序,核心 SDK 逻辑可以复用。
- 体验升级:大文件上传成功率从 85% 提升至 99.9%,秒传功能让用户直呼“快得离谱”。
- 维护降本:上传逻辑收口在
utils/upload,接口变动只需改一处。
希望这套架构设计能给你的 Vue3 项目带来一些灵感!如果觉得有用,欢迎点赞收藏~ 👍