- 上传文件视频
-
File
是Blob(Binry Large Object)
的子类,size
的单位是Byte
new File([u8arr], filename, { type: 'image/jpeg' });
// Blob对象通常用于存储大量二进制数据,可以包含任意类型的数据
new Blob([data], { type: 'application/octet-stream; charset=utf-8' })
// Uint8Array是ArrayBuffer的一种视图类型,是一个类数组对象,
// 每项都是一个8 位无符号整数值,可以直接读取和写入二进制数据。
u8ay = new Uint8Array(arraybuffer);
u8ay[0] = 10
// u8ay.buffer === arraybuffer
-
Blob
和File
类型可以通过slice
进行切片,通过FormData
的搭载来上传到服务器
let formData = new FormData();
formData.append("Content-Disposition", `attachment; filename="${fileName}"`);
formData.append('key', key);
formData.append('file', file);
- 用
FileReader
读取一个txt
格式的File
对象(编码为UTF-8
):
1. 如何将input选择的图片展示在页面上
需要使用fileReader
来转换文件:readAsDataURL
const reader = new FileReader();
reader.readAsDataURL(input.files[0]); // input.files 是一个FileList ,里面可以包含多个File文件
reader.onload = function (e) {
console.log(e.target.result); // 拿到base64,再赋值给img节点的src
}
2. 分片上传 - 本文最下面有伪代码
- 用
slice
将文件切分为多个Blob
类型对象,存储在数组里 - 计算文件的哈希值,先询问服务端,该文件是否上传过,上传到第几片了
- 按分片顺序,循环-依次传给服务器(携带文件的哈希值,用以标识文件),同时携带当前处于第几分片
- 当最后一片上传成功后,发送一个结束上传的请求 - 携带文件哈希值- 后端返回文件地址
3. md5计算文件的哈希值——用以唯一标识文件
- 思路是用
FileReader
将File readAsArrayBuffer,然后再对结果进行md5运算 - 但是大文件不能一次性全部读到内存里来,所以要用切片数组来计算整个文件的哈希值,需要用到
spark-md5
,以通过增量的方式计算整个文件的md5值,这通常需要一些时间 - 所以对文件哈希值的计算可能要使用
Web Worker
开新线程,避免页面卡住
4. 断点续传的触发节点是什么?
4.1. 选择文件上传,服务端检查发现此文件以前上传过一部分(可能原因:1. 之前上传过程中用户关闭页面 2. 之前上传过程中网络故障 3. 之前上传手动暂停)
4.2. 用户手动点了暂停,再点击继续,从暂停的部分继续上传
4.3. window
监听online
事件,发现有未完成的上传任务,继续上传(例如断网导致上传中断,前端记录了断点信息。但现在遇到的用户的操作一般不是单纯的上传,上传完后要拿到url去做后续的操作,例如把文件地址保存到某个单据上, 但如果断网导致上次的用户操作已经中断了,现在即使续传完毕,也不知道把拿到的url怎么办了。既然用户操作中断,下次应该会重新操作,在重新上传文件时触发第一种情况下的断点续传,我觉得这样更合理一点,而不是online时自动继续上传)
5. 上传进度
- axios的
onUploadProgress
选项 - web版本监听的是
ajax
的progress
事件 - 客户端版本是调客户端方法进行的分片,回调里面通知前端 “文件的总大小、当前进度、文件的key”,当进度为100%的时候前端通过key获取文件在线地址
- 自己做分片上传的进度就是已上传的size / 总size
6. 粘贴图片后上传
6.1 监听paste
事件,获取clipboardData
el.addEventListener('paste', function (e) {
const cbd = e.clipboardData;
if (!(cbd && cbd.items)) {
return;
}
let pasteItems = cbd.items;
}, false);
6.2 如果直接能获取到文件数据:getAsFile
可以拿到file
类型,此时就可以上传了,用 FileReader
读取base64
后也能展示到页面上
for (let i = 0, lth = pasteItems.length; i < lth; i++) {
let item = pasteItems[i];
if (item.kind === 'file') {
let file = item.getAsFile(); // 第一步:getAsFile
if (!file || file.size === 0) {
continue;
}
// 第二步:这里再用 FileReader 来读这个 blob: readAsDataURL,结果就能作为 src 赋值给 img 了
}
}
6.3 否则尝试以html格式解析文本:getAsString
、new DOMParser().parseFromString(a, 'text/html')
,拿到内部img
标签的src
属性
for (let i = 0, lth = pasteItems.length; i < lth; i++) {
pasteItems[i].getAsString(function (a) {
if (!a) {
return;
}
let nodes = new DOMParser().parseFromString(a, 'text/html'), // 这步很重要
imgDomList = nodes.getElementsByTagName('img'); // 获取IMG DOM
// 接下来就可以取 IMG 节点的 src 属性了
});
}
从 img dom 取出来的 src 可能是:
-
file:///C:/Users/cache/Image/2008208101184.png
—— 【本地文件系统】的地址
(本地路径我们是无法展示的,目前借助客户端,先把本地路径给客户端,客户端上传后返给我们在线链接) -
https://upload-images.jianshu.io/upload_images/1552a9bb5bd4.png
—— 【在线】地址
7. 在浏览器地址栏输入文件地址时,为什么有的是预览,有的直接会下载
服务器可以通过设置响应头来指定文件的 MIME 类型和处理方式。如果服务器发送的响应头中包含了 "Content-Disposition: attachment"
,则浏览器会将其视为需要下载的文件,而不是直接打开预览。
8. 为什么有的网站图片只能在这个网站内部才能访问?
有的网站设置了防盗链:
- 例如简书通过请求头中的
Referer
来判断来源,不允许的来源就会返回403 Forbidden
(但Referer信息可以伪造,也可以不传,例如<meta name="referrer" content="no-referrer" />
,没传的话就能访问) - 例如群晖是通过一个
session ID
来检查这个资源请求的合法性(这个sid
是要用账号成功登录群晖服务器才能生成并返回的,默认放在cookie
里,也存在 有效期),请求时拼接在【url地址上】或者【通过cookie】带过去(资源请求默认不会携带cookie),服务端校验通过才会返回正确资源。【如果你是通过携带sid参数拼接到链接上的方式去获取,短时别人拿你的请求地址也能访问,不过sid失效后就无法访问了】 - 一个工具:在完整url前添加
https://images.weserv.nl/?url=
可以预览这个图片
9. 下载碰到过的一个问题
下载文件时发现含有中文逗号的无法下载。而这个报错码表示服务器在响应头部中设置了多个 Content-Disposition 字段。
原因:上传文件的时候会把到时下载要用的Content-Disposition
头的值传给后端,但是由于filename没有包双引号,导致设置请求头的时候变成 a=xxxx,aaaa,文件名中的逗号当作属性分割了。
修改:上传时用双引号包上文件名,前后对比:
- |
- |
---|
10. StreamSaver流式下载文件,并读取下载进度
(参见另一篇 service worker)
const fileStream = streamSaver.createWriteStream(fileName); // WritableStream实例
const writer = fileStream.getWriter();
fetch(url).then(res => {
const totalSize = parseInt(res.headers.get('content-length'));
let loadedSize = 0;
const readableStream = res.body;
const reader = readableStream.getReader();
reader.read().then(function processResult(result) {
if (result.done) {
writer.close(); // 下载完成
return;
}
loadedSize += result.value.length;
console.log(`下载进度:${loadedSize / totalSize * 100}%`);
// 读过进度之后,可以继续写入可写流当中
writer.write(result.value).catch(error => {
writer.close();
});
return reader.read().then(processResult);
});
})
11. 分片上传伪代码
const STATE_UPLOADING = 'sending', // 正在上传
STATE_STOP = 'stop', // 各种原因导致的中断
STATE_SUCCESS = 'success'; // 成功
const CHUNK_SIZE = 10 * 1024 * 1024; // 切成10M
// const CHUNK_SIZE = 10 * 1024; // 切成10K
const uploadFile = Symbol('uploadFile');
const continueUpload = Symbol('continueUpload');
const compPort = Symbol('componentPort');
const changeState = Symbol('changeState');
const handleUploadFail = Symbol('handleUploadFail');
const updatePercent = Symbol('updatePercent');
const tools = {
calcSign(file) { },
getFileUrl(fileSign, fileSize) { },
postToServer(fileSign, chunkIndex, chunkFile, signal) { },
checkFileFromServer(fileSign) { }
};
export default class Upload {
state = STATE_UPLOADING
stopChunkIndex = 0
fileSign = null // 真正用的地方就continueUpload一个,调用位置在:初始化、从暂停处继续
file = null
fileUrl = ''
messagePort = null
percent = 0
constructor(f, options = {}) {
this.file = f;
this.fileSign = options.fileSign || tools.calcSign(f);
if (options.startIndex) {
this.stopChunkIndex = options.startIndex;
}
this[uploadFile]();
// 接下来用于实例之间通信:去通知实例上传状态发生变化、进度发生变化
// 实例需要知道的话就用messagePort去监听message事件
const { port1, port2 } = new MessageChannel();
this[compPort] = port1;
this.messagePort = port2;
}
// 暂停上传
suspend() {
this[changeState](STATE_STOP);
this.abortController.abort();
}
// 继续上传
proceed() {
if (this.state === STATE_STOP) {
this[changeState](STATE_UPLOADING);
this[continueUpload]().catch((...args) => {
this[handleUploadFail](...args);
});
}
}
// 上传文件,成功则resolve url,失败会reject error
async [uploadFile]() {
// 检查这个文件是否上传过
let res = await tools.checkFileFromServer(this.fileSign);
// 已上传完毕:秒传
if (res.url) {
this.fileUrl = res.url;
this[updatePercent](100);
this[changeState](STATE_SUCCESS);
return res.url;
}
// 上传了一部分:从上次位置续传
if (res.uploadedChunkCount > 0) {
this.stopChunkIndex = res.uploadedChunkCount;
}
// 没传过:传
return this[continueUpload]().catch((...args) => {
this[handleUploadFail](...args);
});
}
async [continueUpload]() {
this.abortController = new AbortController();
const startIndex = this.stopChunkIndex;
const fileSize = this.file.size;
let transferSize = startIndex * CHUNK_SIZE, // 已上传的部分
chunkIndex = startIndex;
let isErr = false;
while (transferSize < fileSize) {
if (this.state === STATE_STOP) { // 暂停
this.stopChunkIndex = chunkIndex;
return Promise.reject({ code: 300 });
}
let chunkFile = this.file.slice(transferSize, transferSize + CHUNK_SIZE);
try {
let res = await tools.postToServer(this.fileSign, chunkIndex, chunkFile, this.abortController.signal);
if (res.ok) {
transferSize += chunkFile.size;
chunkIndex++;
this[updatePercent](transferSize / fileSize * 100);
} else {
isErr = true;
break;
}
} catch (err) {
console.log('fetch error:', err);
isErr = true;
break;
}
}
if (isErr) { // 分片上传中断
this.stopChunkIndex = chunkIndex;
return Promise.reject({ code: 100 });
}
// 上传完成,获取下载地址
return tools.getFileUrl(this.fileSign, fileSize).then(url => {
this.fileUrl = url;
this[changeState](STATE_SUCCESS);
return url;
}).catch(() => {
return Promise.reject({ code: 400, });
});
}
[changeState](state) {
if (this.state === state) {
return;
}
// 成功了就不能再变更状态了
if (this.state === STATE_SUCCESS) {
return;
}
this.state = state;
this[compPort].postMessage({ action: 'statechange', state });
}
// 该文件的上传进度
[updatePercent](value) {
value = parseInt(value);
value = Math.min(value, 100);
this[compPort].postMessage({ action: 'progress', value, text: value + '%' });
}
// 统一处理上传失败
[handleUploadFail](err) {
const errInfo = {
'100': '分片上传中断',
'400': '已全部上传,但获取文件地址失败',
'300': '用户暂停上传'
};
this[changeState](STATE_STOP);
if (err.code === 100) {
// 存到localStorage,便于下次续传,记录file、fileSign、断点位置
// 在合适的节点监听online-如果localStorage有未完成的任务则继续上传 -> new Upload(file, {fileSign, startIndex})
// localStorage.setItem();
return;
}
}
};
12. 遇到的问题记录:
问题1:通知实例状态的更新和进度的更新
→ 现在用的MessageChannel
,给实例一个port
让它自己去监听messgae
问题2:点了暂停之后进度条还会再更新一次
→ 因为我是在下一个分片发送前才校验并停止发送请求,所以即使点了暂停,本次分片还是会上传完,并且更新一次进度。所以如果在最后一个分片正在上传时点暂停,实际上是没有用的,你看着进度条没满的时候点的暂停,但上传结束了。
→ 所以点暂停时应该abort
当前正在发生的请求(给请求一个abortController.signal
,在点击暂停时调用abortController.abort()
)