上传原理
简单点说就是根据 http
的 multipart/form-data
协议,浏览器解析文件并打包成二进制数据流,然后再通过 http
把数据流传到服务端multipart/form-data
结构如下:
请求头
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDCntfiXcSkPhS4PN
消息体 - Form Data
------WebKitFormBoundary4Ze9aQ5NKoUihnkZ
Content-Disposition: form-data; name="file"; filename="哟哟哟.txt"
Content-Type: text/plain
------WebKitFormBoundary4Ze9aQ5NKoUihnkZ
原始的文件上传
使用 form 表单上传
<form method="post" action="http://localhost:8100" enctype="multipart/form-data">
<input type="file" name="f1" />
<input type="file" name="f1" multiple /> // 多文件上传可以设置 multiple 属性
<button type="submit" id="btn-0">上传</button>
</form>
局部刷新上传 - iframe
最原始的上传方式,会导致页面刷新,因此需要借用 iframe 实现表面上不刷新页面上传
局部刷新
页面内放置了一个隐藏的 iframe,指定 form 表单的 target 属性值为 iframe 标签的 name 属性值,这样 form 表单的 submit 行为的跳转就会在 iframe 内完成,整体页面不会刷新
拿接口数据
为 iframe 添加 load 事件,得到 iframe 的页面内容,将结果转换为 JSON 对象,这样就拿到了接口的数据
<iframe id="temp-iframe" name="temp-iframe" src="" style="display:none;"></iframe>
<form method="post" target="temp-iframe" action="http://localhost:8100" enctype="multipart/form-data">
<input type="file" name="f1" id="f1" multiple />
<button type="submit" id="btn-0">上传</button>
</form>
<script>
var iframe = document.getElementById('temp-iframe');
iframe.addEventListener('load', function () {
var result = iframe.contentWindow.document.body.innerText;
//接口数据转换为 JSON 对象
var obj = JSON.parse(result);
if (obj && obj.fileUrl.length) {
alert('上传成功')
}
console.log(obj);
});
</script>
无刷新上传
可以自己构造一个 FormData
对象,然后使用 XMLHttpRequest
、fetch
发送数据,这里用我们常用的 XMLHttpRequest
和axios
举个例子
XMLHttpRequest
const fileList = document.getElementById('file').files;
const formData = new FormData();
// 多文件上传需要遍历添加到 fromdata 对象
for(let i =0;i<fileList.length;i++){
fd.append('file', fileList[i]);
}
const xhr = new XMLHttpRequest(); // 创建 XMLHttpRequest 对象
xhr.open('POST', 'http://localhost:8100/', true);
xhr.send(fd); // 发送时 Content-Type 默认就是: multipart/form-data;
xhr.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200) {
const obj = JSON.parse(xhr.responseText); // 返回值
console.log(obj);
}
}
Axios
const handleChange = (e) => {
// 获取文件
const files = e.target.files;
const formData = new FormData();
formData.append('file', files[0]); // 这里就上传一个文件,多个文件的话可以 append 多个
axios({
method: 'post',
url: '/file/upload',
data: formData,
header: {
'content-type': 'multipart/form-data;charset-UTF-8',
}
});
}
const dom = (
<input
ref={uploadRef}
style={{display: 'none'}}
onChange={handleChange}
type={'file'}
accept={'.xlsx,.xls,.csv'}
>
)
上传进度
主要是通过绑定 onprogress
事件实现,主要是以下两种方式:
const setProgress = (event) => {
const {lengthComputable, loaded, total} = event;
}
// 第一种
xhr.upload.onprogress = setProgress
// 第二种
xhr.upload.addEventListener('progress', setProgress)
其中 progress
的回调函数中,有这几个常用的参数:
event.lengthComputable
这是一个状态,表示发送的长度有了变化,可计event.loaded
表示发送了多少字节event.total
表示文件总大小
其中根据 loaded
和 total
就能计算上传的进度了,具体代码如下:
const fileList = document.getElementById('file').files;
const formData = new FormData();
// 多文件上传需要遍历添加到 fromdata 对象
for(let i =0;i<fileList.length;i++){
fd.append('file', fileList[i]);
}
const xhr = new XMLHttpRequest(); // 创建 XMLHttpRequest 对象
xhr.open('POST', 'http://localhost:8100/', true);
xhr.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200) {
const obj = JSON.parse(xhr.responseText); // 返回值
console.log(obj);
}
}
const setProgress = (event) => {
if (event.lengthComputable) {
const progress = (event.loaded / event.total * 100).toFixed(2);
// 更新进度状态 && 进度条样式等等逻辑
}
}
xhr.upload.onprogress = setProgress
// 注意 send 一定要写在最下面,否则 onprogress 只会执行最后一次 也就是100%的时候
xhr.send(fd);
PS:假如使用的是 axios
,有对外暴露一个 onUploadProgress
API,具体可自行查看文档
其他上传文件方式
拖拽上传
- 首先定义一个拖放的区域 div
- 再在 div 监听 drop 事件,获取文件信息
e.dataTransfer.files
(记得取消默认行为和冒泡,否则浏览器会直接打开文件) -
剪切板上传
一般常在可编辑的 div 中使用,即:开启 contenteditable 的 div
首先需要为 div 绑定 paste 事件,并从 event.clipboardData 或 window.clipboardData 获取文件数据
- 这里拿到的数据,需要再使用 getAsFile 方法转换后,才是文件对象数据
- 最后再实现在光标位置插入文件 ```javascript const div = document.getElementById(‘div’);
// 绑定paste事件 div.addEventListener(‘paste’, function (event) { const data = (event.clipboardData || window.clipboardData);
const items = data.items; const fileList = [];//存储文件数据
if (items && items.length) { for (let i = 0; i < items.length; i++) { fileList.push(items[i].getAsFile()); } } uploadFile(fileList) event.preventDefault(); // 阻止默认行为 });
function uploadFile(fileList) { // 这里省略上传文件逻辑
// 光标位置插入文件,举一个图片的例子 const img = document.createElement(‘img’); img.src = ‘xxx’;
insertElement(div, img) }
// 光标位置插入文件 function insertElement(div, ele) { const node = window.getSelection().anchorNode; // 光标起点位置
// 判断是否有光标 if (node != null) { const range = window.getSelection().getRangeAt(0);// 获取光标起始位置 range.insertNode(ele);// 在光标位置插入该对象 } else { div.append(ele); } }
<a name="jUWDZ"></a>
### 大文件-分片上传
如果上传的文件很大,比如几百兆,甚至几 G 的话,用前面常规的方法可能就会导致请求超时,导致无法成功上传文件,因此我们可以采用将文件分片的方式,比如每次只上传很小的一部分,比如 2M,而且可以支持多个并发进行分片上传,提升上传的速度
原理说明:<br />首先了解一下 `Blob` 对象,它表示了一个不可变的原始数据,也就是二进制数据,它提供了对数据进行截取的方法 `slice`,然而我们的 `File` 对象继承了 `Blob` 对象,因此也支持对文件进行截取
具体流程:
1. 前端将大文件进行分片,比如每部分 2M,发送到服务端携带一个标志(可以使用文件的 md5),用来标识一个完整的文件
2. 服务端将各个分片文件保存
3. 前端等所有分片上传完成后,发送给服务端一个合并文件的请求
4. 服务端根据文件标识、类型、各分片顺序进行文件合并
5. 删除分片文件
```javascript
const chunkSize = 2 * 1024 * 1024; // 分片大小 2M
const file = document.getElementById('file').files[0]; // 文件对象
const md5 = getMd5(file); // 可使用 SparkMD5 获取
const chunks = [], // 保存分片数据
const chunkCount = 0;
const sendChunkCount = 0; // 发送完成的分片数
// 文件分片
if(file.size > chunkSize){
let start=0,end=0;
while (true) {
end += chunkSize;
const blob = file.slice(start, end);
start+=chunkSize;
if(!blob.size) {// 截取的数据为空 则结束
break;
}
chunks.push(blob);//保存分段数据
}
} else {
chunks.push(file.slice(0));
}
// 发送分片文件(数量大的话,需要做并发控制,设置最大同时发请求的数量)
for(let i = 0;i < chunks.length; i++) {
const fd = new FormData();
fd.append('token', md5);
fd.append('file', chunks[i]);
fd.append('index', i);
// 发送请求
xhrSend(fd, () => {
sendChunkCount+=1;
if(sendChunkCount=== chunks.length){
//上传完成,发送合并请求
const fdMerge = new FormData();
fdMerge.append('type','merge');
fdMerge.append('token',token);
fdMerge.append('chunkCount',chunks.length);
fdMerge.append('filename',name);
xhrSend(formD);
}
});
}
// 带回调函数的请求方法
function xhrSend(fd, cb) {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://localhost:8100/', true);
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
cb && cb();
}
}
xhr.send(fd);
}
大文件-断点续传
分片上传解决了大文件上传超时和文件上传速度的问题,但假如上传期间断网或者刷新页面了,就需要重新上传,难受,因此就有了断点续传的需求,可以跳过已经上传的部分,只传未上传的部分
实现方式可以有两种
- 浏览器端自行处理
- 服务端返回,告知从哪开始
浏览器端自行处理思路:文件分片上传中,已经实现在将文件进行分片,现在问题就是,断点续传时,如何检测每个分片是否已经上传完成呢?
- 首先,可以使用
spark-md5
来针对每一个分片的文件,生成唯一的hash
值 - 把
hash
值作为key
,将上传成功的分片信息保存在浏览器缓存中 - 重新上传时,和本地分片 hash 值进行对比,如果相同的话则跳过,继续下一个分片的上传 ```javascript // 上传逻辑,支持断点续传 const uploadedInfo = getUploadedFromStorage(); // 获得已上传的分片信息
for(let i = 0; i < chunkList.length; i++) { const hash = chunkList[i].hash; if (uploadedInfo[hash]) { // 判断该分片已完成上传 continue; }
// 。。。。省略上传的逻辑 var fd = new FormData(); //构造FormData对象 xhrSend(fd, (file) => { // 回调函数 // 上传完成一段分片后,缓存信息 setUploadedToStorage(file.hash); }) }
// 获得本地缓存的数据 function getUploadedFromStorage(){ return JSON.parse(localStorage.getItem(‘cacheChunk’) || “{}”); }
// 写入缓存 function setUploadedToStorage(hash) { const obj = getUploadedFromStorage();
obj[hash] = true;
localStorage.setItem(‘cacheChunk’, JSON.stringify(obj));
}
```
参考: