前言
在实际工作中经常遇到文件上传,想要比较清楚的掌握,不妨花点时间瞅瞅整篇文章,话不多说,开整
前端:Vue.js Element-Ui
后端:node.js Express multiparty
思路
以下涉及到的知识点:
- 文件上传原理
- 单文件上传 + 上传进度
- 多文件上传 + 上传进度
- 大文件上传之分片上传
- 大文件上传之断点续传
- 拖拽上传
文件上传对于用户体验:
----网络波动时文件上传(能否续传)
----请求超时处理
---- 服务器数据处理能力
文件上传原理
- 根据http协议的规范和定义,完成请求体消息体的封装和消息体的解析,将二进制内容保存到文件
- 文件上传服务器请求头类型
['Content-Type'] = 'multipart/form-data', 同时method必须为post`方法
multipart互联网上的混合资源,就是资源由多种元素组成,form-data表示可以使用HTML Forms 和 POST 方法上传文件,具体的定义可以参考RFC 7578。
- 解析:客户端发送请求将文件上传至服务器,服务器收到请求消息体,对消息体进行解析
使用技术
前后端通信常用数据结构axios二次封装
基于FileReader实现文件读取和相关处理
基于sparkMd5实现文件名自生成
文件上传的两种经典方案:FormData & BASE64
后端至少提供三种接口
- 检查文件接口(文件是否已经上传过,是否需要续传)
- 上传文件接口(上传文件类型FormData & BASE64)
- 合并文件分片接口
axios二次封装
文件上传公共方式:
----文件格式为FormData
let formData = new FormData();
formData.append('file', _file);
formData.append('filename', _file.name);
upload_button_upload.addEventListener('click', function () {
if (upload_button_upload.classList.contains('disable') || upload_button_upload.classList.contains('loading')) return;
if (!_file) {
alert('请您先选择要上传的文件~~');
return;
}
changeDisable(true);
// 把文件传递给服务器:FormData / BASE64
let formData = new FormData();
formData.append('file', _file);
formData.append('filename', _file.name);
instance.post('/upload_single', formData).then(data => {
if (+data.code === 0) {
alert(`文件已经上传成功~~,您可以基于 ${data.servicePath} 访问这个资源~~`);
return;
}
return Promise.reject(data.codeText);
}).catch(reason => {
alert('文件上传失败,请您稍后再试~~');
}).finally(() => {
clearHandle();
changeDisable(false);
});
});
----文件格式为BASE64
let fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = ev => { resolve(ev.target.result); };
// 把选择的文件读取成为BASE64
const changeBASE64 = file => {
return new Promise(resolve => {
let fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = ev => {
resolve(ev.target.result);
};
});
};
upload_inp.addEventListener('change', async function () {
let file = upload_inp.files[0],
BASE64,
data;
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
alert('上传的文件不能超过2MB~~');
return;
}
upload_button_select.classList.add('loading');
BASE64 = await changeBASE64(file);
try {
data = await instance.post('/upload_single_base64', {
file: encodeURIComponent(BASE64),
filename: file.name
}, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
if (+data.code === 0) {
alert(`恭喜您,文件上传成功,您可以基于 ${data.servicePath} 地址去访问~~`);
return;
}
throw data.codeText;
} catch (err) {
alert('很遗憾,文件上传失败,请您稍后再试~~');
} finally {
upload_button_select.classList.remove('loading');
}
});
文件大小限制
对于文件上传大小限制,可以使用 file.size
文件类型限制
上传文件类型限制处理两种方式:
使用正则表达式:
针对相同文件取不同文件名,前端自行处理,避免重复上传
进度管控
此处采用改变进度条样式:
// 文件上传中的回调函数 xhr.upload.onprogress
onUploadProgress(ev) {
let {
loaded,
total
} = ev;
upload_progress.style.display = 'block';
upload_progress_value.style.width = `${loaded/total*100}%`;
}
多文件上传
需要注意:给每个文件添加一个唯一标识key,便于对该文件进行处理,比如移除该文件等
// 获取唯一值
const createRandom = () => {
let ran = Math.random() * new Date();
return ran.toString(16).replace('.', '');
};
upload_inp.addEventListener('change', async function () {
_files = Array.from(upload_inp.files);
if (_files.length === 0) return;
// 我们重构集合的数据结构「给每一项设置一个位置值,作为自定义属性存储到元素上,后期点击删除按钮的时候,我们基于这个自定义属性获取唯一值,再到集合中根据这个唯一值,删除集合中这一项」
_files = _files.map(file => {
return {
file,
filename: file.name,
key: createRandom()
};
});
// 绑定数据
let str = ``;
_files.forEach((item, index) => {
str += `<li key="${item.key}">
<span>文件${index+1}:${item.filename}</span>
<span><em>移除</em></span>
</li>`;
});
upload_list.innerHTML = str;
upload_list.style.display = 'block';
});
拖拽上传
大文件上传
文件切片
- 文件转二进制流格式,使用流可以进行切割成多份
- 固定每个切片大小,切片数量
- 按顺序唯一标记每个切片,便于后期合并切片
- 发送切片合并请求
前后端分工:
前端:
- 文件格式校验
- 文件切片、md5计算
- 发起检查请求,把当前文件的hash发送给服务端,检查是否有相同hash的文件
- 上传进度计算
- 上传完成后通知后端合并切片
// 生成文件名
const changeBuffer = file => {
return new Promise(resolve => {
let fileReader = new FileReader();
fileReader.readAsArrayBuffer(file);
fileReader.onload = ev => {
let buffer = ev.target.result,
spark = new SparkMD5.ArrayBuffer(),
HASH,
suffix;
spark.append(buffer);
HASH = spark.end();
suffix = /\.([a-zA-Z0-9]+)$/.exec(file.name)[1];
resolve({
buffer,
HASH,
suffix,
filename: `${HASH}.${suffix}`
});
};
});
};
进行切片 file.slice()
前端分片完成,向后端发送请求:
当网络较差,出现上传一半失败,为了不重头开始,采用断点续传
断点续传
- 为每一个文件切割块添加不同的标识
- 当上传成功的之后,记录上传成功的标识
- 当我们暂停或者发送失败后,可以重新发送没有上传成功的切割文件
实现方式:
方式一: 在客户端本地保存已经上传成功的分片,使用spark-md5生成文件hash,重新上传的时候,进行和本地分段 hash 值的对比,如果相同的话则跳过,继续下一个分段的上传
注:生成hash会耗时,且本地存储不安全
方式二:服务端已经保存了部分片段,客户端上传前从服务器获取已经上传的分片信息,客户端对比hash,跳过已经上传的部分
从服务器获取 getUploadedFromServer(fileHash)
方式三: 对比是否上传交由后端自行处理(跟后端沟通好就行)
后端:
- 检查接收到的hash是否有相同的文件,并通知前端当前hash是否有未完成的上传
- 接收切片
- 合并所有切片