文件上传与下载

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
  • BlobFile类型可以通过slice进行切片,通过FormData的搭载来上传到服务器
let formData = new FormData();
formData.append("Content-Disposition", `attachment; filename="${fileName}"`);
formData.append('key', key);
formData.append('file', file);
image.png
  • FileReader读取一个 txt格式的File对象(编码为UTF-8):
    readAsText.png
readAsArrayBuffer.png

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. 分片上传 - 本文最下面有伪代码

分片上传视频

image.png
  1. slice将文件切分为多个Blob类型对象,存储在数组里
  2. 计算文件的哈希值,先询问服务端,该文件是否上传过,上传到第几片了
  3. 按分片顺序,循环-依次传给服务器(携带文件的哈希值,用以标识文件),同时携带当前处于第几分片
  4. 当最后一片上传成功后,发送一个结束上传的请求 - 携带文件哈希值- 后端返回文件地址

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版本监听的是ajaxprogress事件
  • 客户端版本是调客户端方法进行的分片,回调里面通知前端 “文件的总大小、当前进度、文件的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 了
    }
}
image.png

6.3 否则尝试以html格式解析文本:getAsStringnew DOMParser().parseFromString(a, 'text/html'),拿到内部img标签的src属性

image.png
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 可能是:

  1. file:///C:/Users/cache/Image/2008208101184.png —— 【本地文件系统】的地址
    (本地路径我们是无法展示的,目前借助客户端,先把本地路径给客户端,客户端上传后返给我们在线链接)
  2. https://upload-images.jianshu.io/upload_images/1552a9bb5bd4.png —— 【在线】地址

7. 在浏览器地址栏输入文件地址时,为什么有的是预览,有的直接会下载

服务器可以通过设置响应头来指定文件的 MIME 类型和处理方式。如果服务器发送的响应头中包含了 "Content-Disposition: attachment",则浏览器会将其视为需要下载的文件,而不是直接打开预览。

image.png
image.png

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. 下载碰到过的一个问题

image.png

下载文件时发现含有中文逗号的无法下载。而这个报错码表示服务器在响应头部中设置了多个 Content-Disposition 字段。

原因:上传文件的时候会把到时下载要用的Content-Disposition头的值传给后端,但是由于filename没有包双引号,导致设置请求头的时候变成 a=xxxx,aaaa,文件名中的逗号当作属性分割了。

修改:上传时用双引号包上文件名,前后对比:

-
image.png
-
image.png
-

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()

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容