在Web应用开发中,文件上传是最基础的功能之一。但当遇到大文件(如视频、设计稿、数据库备份等)时,传统的单次上传方式就会暴露出诸多问题:
以我们团队最近开发的在线视频协作平台为例,用户经常需要上传2GB以上的4K素材。实测发现,当文件超过500MB时,传统上传方式的失败率高达65%。这就是为什么需要分片上传技术——将大文件切割成多个小块分别传输,最后在服务端合并。
浏览器端的分片上传主要依赖File API和Blob对象。关键步骤如下:
javascript复制const fileInput = document.getElementById('file-input');
const file = fileInput.files[0];
javascript复制const chunkSize = 2 * 1024 * 1024; // 2MB
const totalChunks = Math.ceil(file.size / chunkSize);
javascript复制for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const chunk = file.slice(start, end);
uploadChunk(chunk, i);
}
注意:slice方法在不同浏览器中的兼容性写法可能不同,建议使用兼容性封装库如resumable.js
服务端需要处理三个核心问题:
python复制# Flask示例
@app.route('/upload', methods=['POST'])
def upload_chunk():
chunk = request.files['file']
chunk_index = int(request.form['index'])
total_chunks = int(request.form['total'])
# 验证分片MD5
if not validate_md5(chunk, request.form['hash']):
return "校验失败", 400
# 保存分片到临时目录
temp_path = f"/tmp/{uuid.uuid4()}_{chunk_index}"
chunk.save(temp_path)
return "上传成功", 200
python复制def merge_chunks(file_name, total_chunks, temp_dir):
with open(file_name, 'wb') as final_file:
for i in range(total_chunks):
chunk_path = f"{temp_dir}/{i}"
with open(chunk_path, 'rb') as chunk:
final_file.write(chunk.read())
os.remove(chunk_path) # 清理临时文件
我们采用Webpack构建的浏览器插件方案,主要模块包括:
code复制src/
├── core/
│ ├── uploader.js // 核心上传逻辑
│ ├── progress.js // 进度管理
│ └── validator.js // 文件校验
├── utils/
│ ├── chunk.js // 分片处理
│ └── network.js // 网络请求封装
└── index.js // 插件入口
核心上传类的实现示例:
javascript复制class ChunkUploader {
constructor(file, options) {
this.file = file;
this.chunkSize = options.chunkSize || 2 * 1024 * 1024;
this.retries = options.retries || 3;
this.chunks = [];
this.uploaded = new Set();
}
prepareChunks() {
const total = Math.ceil(this.file.size / this.chunkSize);
for (let i = 0; i < total; i++) {
this.chunks.push({
index: i,
blob: this.file.slice(
i * this.chunkSize,
Math.min(this.file.size, (i + 1) * this.chunkSize)
)
});
}
}
async startUpload() {
for (const chunk of this.chunks) {
if (this.uploaded.has(chunk.index)) continue;
let retry = 0;
while (retry < this.retries) {
try {
await this.uploadChunk(chunk);
this.uploaded.add(chunk.index);
break;
} catch (err) {
if (++retry === this.retries) throw err;
}
}
}
await this.verifyCompletion();
}
}
python复制# 文件类型白名单验证
ALLOWED_EXTENSIONS = {'mp4', 'mov', 'avi'}
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
# 分片大小限制
MAX_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
通过Promise.all实现可控并发:
javascript复制async function parallelUpload(chunks, maxConcurrent = 3) {
const queue = [];
for (let i = 0; i < chunks.length; i++) {
const task = uploadChunk(chunks[i]).then(() => {
queue.splice(queue.indexOf(task), 1);
});
queue.push(task);
if (queue.length >= maxConcurrent) {
await Promise.race(queue);
}
}
await Promise.all(queue);
}
精确计算整体进度需要考虑:
实现方案:
javascript复制class UploadProgress {
constructor(totalSize) {
this.total = totalSize;
this.loaded = 0;
this.chunkProgress = {};
}
update(chunkIndex, percent) {
const chunkSize = this.chunks[chunkIndex].blob.size;
const oldProgress = this.chunkProgress[chunkIndex] || 0;
this.loaded += (percent - oldProgress) * chunkSize;
this.chunkProgress[chunkIndex] = percent;
return this.loaded / this.total;
}
}
根据网络质量动态调整:
javascript复制function getNetworkQuality() {
const connection = navigator.connection || navigator.mozConnection;
if (connection) {
return {
downlink: connection.downlink, // Mbps
type: connection.effectiveType
};
}
return { downlink: 5, type: '4g' }; // 默认值
}
function adjustChunkSize() {
const { downlink } = getNetworkQuality();
if (downlink > 10) return 5 * 1024 * 1024; // 5MB
if (downlink > 5) return 2 * 1024 * 1024; // 2MB
return 512 * 1024; // 512KB
}
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 分片上传成功但合并失败 | 临时文件被清理 | 增加分片存活时间检查 |
| 进度条回退 | 分片索引重复 | 使用唯一ID代替顺序索引 |
| 上传速度骤降 | 浏览器节流 | 添加时间戳参数避免缓存 |
| 最后1%卡住 | 大小计算误差 | 使用精确字节数而非百分比 |
前端关键日志点:
javascript复制console.debug(`[Upload] 开始分片 #${index}, 大小: ${chunk.size} bytes`);
console.info(`[Upload] 当前进度: ${(loaded/total*100).toFixed(1)}%`);
console.warn(`[Upload] 分片 #${index} 上传失败: ${error.message}`);
服务端监控指标:
我们曾遇到一个典型问题:在Safari浏览器中,超过2GB的文件上传总是失败。经过排查发现:
javascript复制function getSafeChunkSize() {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
return isSafari ? 512 * 1024 : 2 * 1024 * 1024;
}
发布到npm的配置示例:
json复制{
"name": "chunk-uploader",
"version": "1.0.0",
"main": "dist/index.js",
"browser": "dist/index.umd.js",
"types": "types/index.d.ts",
"files": ["dist", "types"],
"scripts": {
"build": "webpack --config webpack.config.js"
}
}
Vue组件封装示例:
javascript复制export default {
props: {
endpoint: String,
maxSize: Number
},
methods: {
async handleUpload(file) {
const uploader = new ChunkUploader(file, {
endpoint: this.endpoint,
chunkSize: this.calculateChunkSize(file)
});
uploader.on('progress', percent => {
this.$emit('progress', percent);
});
try {
await uploader.start();
this.$emit('success');
} catch (err) {
this.$emit('error', err);
}
}
}
}
javascript复制async function encryptChunk(chunk, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
await chunk.arrayBuffer()
);
return { iv, encrypted };
}
javascript复制// worker.js
self.onmessage = async (e) => {
const { chunk, index } = e.data;
const hash = await calculateHash(chunk);
postMessage({ index, hash });
};
// 主线程
const worker = new Worker('worker.js');
worker.postMessage({ chunk, index });