大文件上传是前端开发中常见的需求之一,特别是在需要处理较大的Excel表格数据、高清图片、视频或其他大型文件时。优化大文件上传不仅可以提升用户体验,还能有效减轻服务器负担。本文将深入探讨大文件上传的几种常见优化技术,包括文件切片与并发上传、断点续传、后台处理优化、安全性考虑和用户体验优化。
一、前言
在现代Web应用中,用户上传大文件已成为常见需求。然而,直接上传大文件会面临诸多挑战,例如网络不稳定导致上传中断、长时间上传导致用户体验差、服务器压力大等。因此,优化大文件上传性能显得尤为重要。
二、优化方案
1. 文件切片与并发上传
1.1 文件切片原理
文件切片(Chunking)是将大文件分成若干小片段,每个片段独立上传的方法。这样做可以有效减少单次上传的数据量,降低上传失败的概率。
这种方法可以提高上传效率和稳定性,并且支持断点续传。
1.2 实现步骤
- 前端切片:利用
Blob
对象的slice
方法将文件切片。 - 并发上传:使用
Promise.all
实现多个切片并发上传。 - 合并请求:上传完成后,通知服务器合并这些切片。
2. 断点续传
断点续传(Resumable Uploads)可以在上传过程中断(如网络故障、页面关闭等)时,从断点继续上传,避免重新上传整个文件。这通常通过记录已上传的分片索引来实现。
2.1 实现步骤
- 前端记录进度:使用
localStorage
记录已上传的切片信息。这种方式不依赖于服务端,实现起来也比较方便,缺点在于如果用户清除了本地文件,会导致上传记录丢失。 - 断点续传:上传时检查哪些切片未上传,继续上传未完成的部分。
3. 秒传功能
- 在服务端已经存在了上传的资源时,通过文件hash值快速判断文件是否存在,从而避免重复上传,节省时间和流量。
已经上传过的文件,并且在后端已经拼接完成,如果再次上传的话后端不做处理,直接返回拼接好的文件的信息即可,一般主要后端实现。
4. 基于WebWorker的并行处理
- 使用WebWorker来并行计算文件的分片hash值,可以显著提高大文件处理的速度。
WebWorker 实际上是运行在浏览器后台的一个单独的线程,因此可以执行一些耗时的操作而不会阻塞主线程。WebWorker 通过与主线程之间传递消息实现通信,这种通信是双向的。WebWorker不能访问 DOM 不能获取dom对象,也不能使用像 window 对象这样的浏览器接口对象,但可以使用一些WebWorker 标准接口和 Navigator 对象的部分属性和方法。
1、主线程
主线程创建 worker 实例,向子线程通过 postMessage 发送消息,通过 onmessage 监听子线程返回的数据。
/*
在主线程中 通过 new Worker(线上地址服务器资源) 传入文件url来实现
返回 worker实例对象,该对象是主线程和其他线程的通讯桥梁
如果js文件是ES6 module的规范的话,那么new Worker的时候就需要说明类型
const worker = new Worker('http://localhost:5000/worker.js', {type:'module'})
*/
const worker = new Worker('http://localhost:5000/worker.js')
// 监听子线程返回的数据
worker.onmessage = function (e) {
console.log('Fibonacci result:', e.data)
doSomething();
}
function doSomething() {
// 执行任务
worker.postMessage('Work done!');
}
// 主线程通过 postMessage 向子线程发送消息
worker.postMessage('Hello World!')
// Worker 完成任务以后,主线程就可以把它关掉
worker.terminate();
Worker()构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。由于 Worker 不能读取本地文件,所以这个脚本必须来自网络。如果下载没有成功(比如404错误),Worker 就会默默地失败
2、Worker 线程 self
子线程 worker.js
// worker.js
self.postMessage(// 向主线程发送消息,主线程通过onmessage 接收)
self.onmessage = (e) => {
console.log('主线程发送过来的数据',e.data)
}
//还可以通过监听 message 事件来接收消息
self.addEventListener('message', function(e) {
console.log('收到消息:' + e.data);
})
// self代表子线程自身,即子线程的全局对象。因此,等同于下面两种写法。
// 写法一
this.addEventListener('message', function (e) {
this.postMessage('You said: ' + e.data);
}, false);
// 写法二
addEventListener('message', function (e) {
postMessage('You said: ' + e.data);
}, false);
self.close() // 用于在 Worker 内部关闭自身。
除了使用self.addEventListener()指定监听函数,也可以使用self.onmessage指定。监听函数的参数是一个事件对象,它的data属性包含主线程发来的数据。self.postMessage()方法用来向主线程发送消息。
3、Worker 加载脚本
Worker 内部如果要加载其他脚本,有一个专门的方法importScripts()。
importScripts('script1.js');
// 该方法可以同时加载多个脚本
importScripts('script1.js', 'script2.js');
4、错误处理
主线程可以监听 Worker 是否发生错误。如果发生错误,Worker 会触发主线程的error事件。
worker.onerror(function (event) {
console.log([
'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join(''));
});
// 或者
worker.addEventListener('error', function (event) {
// ...
});
5、关闭 Worker
使用完毕,为了节省系统资源,必须关闭 Worker。
// 主线程
worker.terminate();
// Worker 线程
self.close();
6、数据通信
主线程与 Worker 之间的通信内容,可以是文本,也可以是对象。需要注意的是,这种通信是拷贝关系,即是传值而不是传址,Worker 对通信内容的修改,不会影响到主线程。事实上,浏览器内部的运行机制是,先将通信内容串行化,然后把串行化后的字符串发给 Worker,后者再将它还原。
主线程与 Worker 之间也可以交换二进制数据,比如 File、Blob、ArrayBuffer 等类型,也可以在线程之间发送。下面是一个例子。
// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i <>uInt8Array.length; ++i) {
uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
var uInt8Array = e.data;
postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};
7、限制
- 同源限制
分配给 Worker 线程的文件必须与主线程的文件同源 - DOM 限制
Worker 线程不能直接操作 DOM,但Worker 线程可以使用navigator对象和location对象部分属性和方法。 - 通信限制
不再同一个上下文,不能直接通信,须通过 postMessage 、 onmessage完成 - 条数限制
浏览器能创建webworker线程 基本上都在20条以内,每条线程大概5M左右 - 文件限制
线程不能打开本地文件,只能打开网络文件(服务器) - 脚本限制
Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。 - 有些东西是无法通过主线程传给子线程的,如 方法、dom节点、一些对象里的特殊设置(freeze、getter、setter,所以vue的响应式对象是不能传递的)
- 模块的引入问题
如果js文件是ES6 module的规范的话,那么new Worker的时候就需要说明类型
const worker = new Worker('http://localhost:5000/worker.js', {type:'module'})
8、应用场景
- 大量的后台计算
- 一般不会用到 worker,因为浏览器主线程很少会出现很复杂从而导致阻塞的操作。如果真的遇到了,可以用 worker 来创建一个子线程进行处理,提高用户体验
- 随着webgl、canvas等能力的加入,web前端有越来越多的可视化操作。如 在线滤镜、在线绘图、web游戏等。这些东西是非常耗时计算的。
5. 压缩传输数据
- 对传输的数据进行压缩处理,减少传输时间和带宽消耗
6. 分布式存储
- 使用分布式存储系统,提高文件存储和访问的性能和可扩展性,减轻单个服务器的负载压力。
7. 后台处理优化
- 分片接收与合并:服务器需要支持接收分片请求,并在所有分片上传完成后合并文件。可以利用中间件或服务端程序语言实现这一逻辑。
8. 安全性考虑
- 文件类型校验:在前端和后端都应对文件类型进行校验,确保上传的文件类型符合预期。
- 文件大小限制:限制单个文件和总上传文件的大小,防止恶意用户上传过大的文件造成服务器压力。
9. 用户体验优化
- 进度显示:通过显示上传进度条,让用户了解上传进度,提升用户体验。
- 网络波动处理:考虑到用户可能在网络不稳定的环境中上传文件,可以增加失败重试机制。
文件切片完整实例
后端代码(Node.js + Express)
安装依赖
npm init -y
npm install express multer fs
创建服务器文件(server.js)
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const bodyParser = require('body-parser');
const app = express();
const upload = multer({ dest: 'uploads/' });
app.use(bodyParser.json());
// 路由:处理文件切片上传
app.post('/upload', upload.single('chunk'), (req, res) => {
const { index, fileName } = req.body;
const chunkPath = path.join(__dirname, 'uploads', `${fileName}-${index}`);
fs.renameSync(req.file.path, chunkPath);
res.status(200).send('Chunk uploaded');
});
// 路由:合并切片
app.post('/merge', (req, res) => {
const { totalChunks, fileName } = req.body;
const filePath = path.join(__dirname, 'uploads', fileName);
const writeStream = fs.createWriteStream(filePath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(__dirname, 'uploads', `${fileName}-${i}`);
const data = fs.readFileSync(chunkPath);
writeStream.write(data);
fs.unlinkSync(chunkPath);
}
writeStream.end();
res.status(200).send('File merged');
});
app.listen(3000, () => {
console.log('Server started on http://localhost:3000');
});
前端代码(index.html + script.js)
1.创建HTML文件(index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大文件上传</title>
</head>
<body>
<input type="file" id="fileInput">
<progress id="progressBar" value="0" max="100"></progress>
<button onclick="uploadFile()">上传文件</button>
<script src="script.js"></script>
</body>
</html>
2.创建JavaScript文件(script.js)
const fileInput = document.getElementById('fileInput');
const progressBar = document.getElementById('progressBar'); // 进度条
const chunkSize = 5 * 1024 * 1024; // 5MB 每次最大切片的长度
// 每个切片要发送的ajax
const uploadChunk = async (chunk, index, fileName) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', index);
formData.append('fileName', fileName);
// 发送切片上传请求
await fetch('/upload', {
method: 'POST',
body: formData
});
updateProgressBar(index);
};
const updateProgressBar = (index) => {
const uploadedChunks = JSON.parse(localStorage.getItem('uploadedChunks')) || [];
if (!uploadedChunks.includes(index)) {
uploadedChunks.push(index);
progressBar.value = (uploadedChunks.length / totalChunks) * 100;
localStorage.setItem('uploadedChunks', JSON.stringify(uploadedChunks));
}
};
// 上传文件按钮事件
const uploadFile = async () => {
const file = fileInput.files[0];
const totalChunks = Math.ceil(file.size / chunkSize);
const uploadedChunks = JSON.parse(localStorage.getItem('uploadedChunks')) || [];
const promises = [];
for (let i = 0; i < totalChunks; i++) {
if (!uploadedChunks.includes(i)) {
const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
promises.push(uploadChunk(chunk, i, file.name));
}
}
// 多个切片并发上传
await Promise.all(promises);
await fetch('/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ totalChunks, fileName: file.name })
});
localStorage.removeItem('uploadedChunks');
alert('文件上传成功');
};
启动后端服务器
3.在浏览器中打开前端页面
将index.html
文件在浏览器中打开,选择文件并点击“上传文件”按钮即可看到文件上传进度。
node server.js
三、插件
目前成熟的大文件上传方案 目前社区已经存在一些成熟的大文件上传解决方案,也许并不需要我们手动去实现一个简陋的大文件上传库,但是了解其原理还是十分有必要的。
推荐的前端vue组件:vue-simple-uploader,支持vue2,vue3
vue-simple-uploader是基于simple-Uploader.js封装的大文件上传组件,具有以下优点:
- 支持单文件、多文件、文件夹上传;支持拖拽文件、文件夹上传
- 可暂停、继续上传
- 错误处理
- 支持“秒传”,通过文件判断服务端是否已存在从而实现“秒传”
- 分块上传
- 支持进度、预估剩余时间、出错自动重试、重传等操作
安装与配置
npm install vue-simple-uploader --save
然后在你的main.js
中引入并使用它:
import Vue from 'vue';
import uploader from 'vue-simple-uploader';
Vue.use(uploader);
接下来,配置上传选项,这些选项可以根据你的后端接口和业务需求进行调整:
options: {
target: ' http://localhost:8080', // SpringBoot后台接收文件夹数据的接口
simultaneousUploads: 10, // 支持同时上传数量
autoStart: false, // 自动上传
panelShow: false,
allowDuplicateUploads: false, // 上传过得文件不可以再上传
testChunks: false, // 是否分片-不分片
chunkSize: '102400000000', // 块大小
// query参数是带有数据的post的额外参数,policy、OSSAccessKeyId和signature是获取到的后端签名返回,
query: (file) => {
return {
name: file.name,
key: file.key,
policy,
OSSAccessKeyId: accessId,
signature,
success_action_status: 200, // success_action_status需设置为 200
};
},
}
常用方法与事件
vue-simple-uploader提供了多种方法和事件,以便于开发者根据需要进行自定义处理:
- assignBrowse:将非组件按钮绑定为上传按钮。
- getSize:获取上传文件的总大小。
- progress:获取上传进度。
- addFile:手动添加文件到上传队列。
事件处理包括但不限于:
- fileAdded:文件添加到上传队列时触发。
- filesAdded:多文件添加时触发。
- fileSuccess:文件上传成功时触发。
- complete:所有文件上传完成时触发。
- fileError:文件上传失败时触发。
代码实现
以下是vue-simple-uploader
组件的一个基本使用示例,包括组件声明、事件绑定和样式配置:
<template>
<!-- 定义Uploader组件 -->
<uploader
:key="uploader_key" <!-- 使用key确保组件在数据更新时重新渲染 -->
:options="options" <!-- 绑定配置项 -->
class="uploader-example" <!-- 添加自定义类名 -->
@file-added="onFileAdded" <!-- 文件添加时触发的事件 -->
@file-success="onFileSuccess" <!-- 文件上传成功时触发的事件 -->
@upload-start="uploadStr" <!-- 开始上传时触发的事件 -->
@complete="uploadEnd" <!-- 所有文件上传完成时触发的事件 -->
@file-error="fileError" <!-- 文件上传失败时触发的事件 -->
>
<!-- 定义不支持上传的提示 -->
<uploader-unsupport></uploader-unsupport>
<!-- 定义拖拽区域 -->
<uploader-drop>
<!-- 定义上传按钮,使用Element UI的按钮组件 -->
<el-button class="uploaders-btn">
<uploader-btn class="btn" :directory="true"> <!-- 设置为目录上传 -->
<el-icon><Notification /></el-icon> <!-- 使用Element UI的图标组件 -->
<span>上传文件夹</span> <!-- 按钮文本 -->
</uploader-btn>
</el-button>
</uploader-drop>
</uploader>
</template>
<script>
import md5 from "js-md5";
export default {
data() {
return {
// 用于刷新组件的key,每次上传时更改其值以刷新组件状态
uploader_key: new Date().getTime(),
// 配置项,根据后端接口和业务需求进行配置
options: {
//目标上传 URL,默认POST
target: "/api/file/uploadFile", // 后端接收数据的接口
//上传文件时文件内容的参数名,对应chunk里的Multipart对象名,默认对象名为file
ileParameterName: 'upfile',
//失败后最多自动重试上传次数
maxChunkRetries: 3,
query: (file, res, status) => {
// 返回上传所需的额外参数
return {
filePath: "",
identifier: md5(file.uniqueIdentifier),
parentUserFileId: this.firstId,
sourceMenuId: this.findId,
uuid: this.uuid,
};
},
headers: {
"Blade-Auth": "bearer " + getToken(), // 认证信息
},
testChunks: true, // 不分片上传
//分块大小(单位:字节)
chunkSize: '2048000',
},
fileStatusText: {
success: '上传成功',
error: '上传失败',
uploading: '上传中',
paused: '暂停',
waiting: '等待上传'
}
};
},
created() {
// 组件创建时初始化options
this.options = {
// ... 具体配置
};
},
methods: {
// 文件添加到上传队列时的处理函数
onFileAdded(file) {
console.log("文件添加到队列:", file);
// 每次添加文件时生成新的uuid
this.uuid = new Date().getTime();
},
// 文件上传成功时的处理函数
onFileSuccess(rootFile, file, response, chunk) {
console.log("文件上传成功:", file, response);
// 根据服务器返回的response处理业务逻辑
},
// 文件上传失败时的处理函数
fileError(rootFile, file, response, chunk) {
console.error("文件上传失败:", file, response);
// 显示错误信息
this.$message.error("文件夹上传失败");
},
// 开始上传时的处理函数
uploadStr() {
this.loadingFile = true; // 设置加载状态
},
// 所有文件上传完成时的处理函数
uploadEnd() {
this.loadingFile = false; // 重置加载状态
},
},
};
</script>
<style lang="scss" scoped>
/* 自定义样式 */
.uploader-example {
.uploaders-btn {
/* 按钮样式 */
}
.btn {
/* 上传按钮内的图标和文本样式 */
}
}
</style>