Skip to content

需求分析

一个上传组件需要具备哪些功能:

  • 文件格式校验
  • 上传任意资源,包括超大的视频文件(分片)
  • 上传期间若出现断网,再次联网可以继续上传(断点续传)
  • 进度条提示
  • 已上传的同一资源,直接完成(秒传)

要实现以上能力,需要前后端配合,下面我们简单实现一个上传组件。

原理概述

上传文件时,可通过 blob 分成多个块上传,所有分块上传完成之后,将这些块合并成一个文件。在这过程中,如果文件上传停止,下次重新上传时,拿到上次上传文件的块索引,在这个索引叠加上传即可。

上传流程

前端:

  • 文件格式校验
  • 文件切片、md5 计算
  • 发起检查请求,把当前文件的 hash 发送给服务端,检查是否有相同 hash 的文件
  • 上传进度计算
  • 上传完成后通知后端合并切片

后端:

  • 检查接收到的 hash 是否有相同的文件,并通知前端当前 hash 是否有未完成的上传
  • 接收切片
  • 合并所有切片

代码实现

格式校验

对于上传的文件,一般来说,我们要校验其格式,仅需要获取文件的后缀(扩展名),即可判断其是否符合我们的上传限制:

js
// 文件路径
var filePath = 'file://upload/test.png';
// 获取最后一个 . 的位置
var index = filePath.lastIndexOf('.');
// 获取后缀
var ext = filePath.substr(index+1);
// 输出结果
console.log(ext);
// 输出:png

但是,这种方式有个弊端,那就是我们可以随便篡改文件的后缀名,比如:test.mp4,我们可以通过修改其后缀名:test.mp4 -> test.png,这样即可绕过限制进行上传。那有没有更严格的限制方式呢?当然是有的。

那就是通过查看文件的二进制数据来识别其真实的文件类型,因为计算机识别文件类型时,并不是真的通过文件的后缀名来识别的,而是通过「魔数」(Magic Number)来区分,对于某一些类型的文件,起始的几个字节内容都是固定的,根据这几个字节的内容就可以判断文件的类型。借助十六进制编辑器,可以查看一下图片的二进制数据,我们还是以 test.png 为例:

图片二进制数据

由上图可知,PNG 类型的图片前 8 个字节是 0x89 50 4E 47 0D 0A 1A 0A。基于这个结果,我们可以据此来做文件的格式校验,以 vue 项目为例:

vue
<template>
<div>
    <input
        type="file"
        id="inputFile"
        @change="handleChange"
    />
</div>
</template>
<script>
export default {
    name: 'HelloWorld',
    methods: {
        check(headers) {
            return (buffers, options = { offset: 0 }) =>
                headers.every(
                    (header, index) => header === buffers[options.offset + index]
                );
        },
        async handleChange(event) {
            const file = event.target.files[0];
            // 以 PNG 为例,只需要获取前 8 个字节,即可识别其类型
            const buffers = await this.readBuffer(file, 0, 8);
            const uint8Array = new Uint8Array(buffers);
            const isPNG = this.check([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
            // 上传 test.png 后,打印结果为 true
            console.log(isPNG(uint8Array));
        },
        readBuffer(file, start = 0, end = 2) {
            // 获取文件的二进制数据,因为我们只需要校验前几个字节即可,所以并不需要获取整个文件的数据
            return new Promise((resolve, reject) => {
                const reader = new FileReader();
                reader.onload = () => {
                    resolve(reader.result);
                };
                reader.onerror = reject;
                reader.readAsArrayBuffer(file.slice(start, end));
            });
        }
    }
};
</script>

以上为校验文件类型的方法,对于其他类型的文件,比如 mp4,xsl 等,大家感兴趣的话,也可以通过工具查看其二进制数据,以此来做格式校验。

以下为汇总的一些文件的二进制标识:

  • JPEG/JPG - 文件头标识 (2 bytes): ff, d8 文件结束标识 (2 bytes): ff, d9
  • TGA - 未压缩的前 5 字节 00 00 02 00 00 - RLE 压缩的前 5 字节 00 00 10 00 00
  • PNG - 文件头标识 (8 bytes) 89 50 4E 47 0D 0A 1A 0A
  • GIF - 文件头标识 (6 bytes) 47 49 46 38 39 (37) 61
  • BMP - 文件头标识 (2 bytes) 42 4D B M
  • PCX - 文件头标识 (1 bytes) 0A
  • TIFF - 文件头标识 (2 bytes) 4D 4D 或 49 49
  • ICO - 文件头标识 (8 bytes) 00 00 01 00 01 00 20 20
  • CUR - 文件头标识 (8 bytes) 00 00 02 00 01 00 20 20
  • IFF - 文件头标识 (4 bytes) 46 4F 52 4D
  • ANI - 文件头标识 (4 bytes) 52 49 46 46

文件切片

假设我们要把一个 1G 的视频,分割为每块 1MB 的切片,可定义 DefualtChunkSize = 1 * 1024 * 1024,通过 spark-md5 来计算文件内容的 hash 值。那如何分割文件呢,使用文件对象 File 的方法 File.prototype.slice 即可。

需要注意的是,切割一个较大的文件,比如 10G,那分割为 1Mb 大小的话,将会生成一万个切片,众所周知,js 是单线程模型,如果这个计算过程在主线程中的话,那我们的页面必然会直接崩溃,这时,就该我们的 Web Worker 来上场了。

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。

以下为部分关键代码:

js
// upload.js

// 创建一个 worker 对象
const worker = new worker('worker.js');
// 向子线程发送消息,并传入文件对象和切片大小,开始计算分割切片
worker.postMessage(file, DefualtChunkSize);

// 子线程计算完成后,会将切片返回主线程
worker.onmessage = (chunks) => {
    // ...
}

子线程代码:

js
// worker.js

// 接收文件对象及切片大小
onmessage (file, DefualtChunkSize) => {
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
        chunks = Math.ceil(file.size / DefualtChunkSize),
        currentChunk = 0,
        spark = new SparkMD5.ArrayBuffer(),
        fileReader = new FileReader();

    fileReader.onload = function (e) {
        console.log('read chunk nr', currentChunk + 1, 'of');

        const chunk = e.target.result;
        spark.append(chunk);
        currentChunk++;

        if (currentChunk < chunks) {
            loadNext();
        } else {
            let fileHash = spark.end();
            console.info('finished computed hash', fileHash);
            // 此处为重点,计算完成后,仍然通过 postMessage 通知主线程
            postMessage({ fileHash, fileReader });
        }
    };

    fileReader.onerror = function () {
        console.warn('oops, something went wrong.');
    };

    function loadNext() {
        let start = currentChunk * DefualtChunkSize,
        end = ((start + DefualtChunkSize) >= file.size) ? file.size : start + DefualtChunkSize;
        let chunk = blobSlice.call(file, start, end);
        fileReader.readAsArrayBuffer(chunk);
    }

    loadNext();
}

以上利用 worker 线程,我们即可得到计算后的切片,以及 md5 值。

断点续传 + 进度条 + 秒传

在拿到切片和 md5 后,我们首先去服务器查询一下,是否已经存在当前文件。

  1. 如果已存在,并且已经是上传成功的文件,则直接返回前端上传成功,即可实现「秒传」。
  2. 如果已存在,并且有一部分切片上传失败,则返回给前端已经上传成功的切片 name,前端拿到后,根据返回的切片,计算出未上传成功的剩余切片,然后把剩余的切片继续上传,即可实现「断点续传」。
  3. 如果不存在,则开始上传,这里需要注意的是,在并发上传切片时,需要控制并发量,避免一次性上传过多切片,导致崩溃。
js
// 检查是否已存在相同文件
async function checkAndUploadChunk(chunkList, fileMd5Value) {
    const requestList = [];

    // 如果不存在,则上传
    for (let i = 0; i < chunkList; i++) {
        const chunk = chunkList[i];
        requestList.push(upload({ chunkList, chunk, fileMd5Value, i }));
    }

    // 并发上传
    if (requestList?.length) {
        await Promise.all(requestList);
    }
}

// 上传 chunk
function upload({ chunkList, chunk, fileMd5Value, i }) {
    current = 0;
    let form = new FormData();
    form.append('data', chunk); // 切片流
    form.append('total', chunkList.length); // 总片数
    form.append('index', i); // 当前是第几片
    form.append('fileMd5Value', fileMd5Value);

    return axios({
        method: 'post',
        url: BaseUrl + "/upload",
        data: form
    }).then(({ data }) => {
        if (data.stat) {
            current = current + 1;
            // 获取到上传的进度
            const uploadPercent = Math.ceil((current / chunkList.length) * 100);
        }
    });
}

在以上代码中,我们在上传切片的同时,也会告诉后端当前上传切片的 index,后端接收后,记录该 index 以便在合并时知道切片的顺序。

当所有切片上传完成后,再向后端发送一个上传完成的请求,即通知后端把所有切片进行合并,最终完成整个上传流程。

总结

大文件上传:

  • 前端上传大文件时使用 Blob.prototype.slice 将文件切片,并发上传多个切片,最后发送一个合并的请求通知服务端合并切片
  • 服务端接收切片并存储,收到合并请求后使用流将切片合并到最终文件
  • 原生 XMLHttpRequestupload.onprogress 对切片上传进度的监听
  • 使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度

断点续传:

  • 使用 spark-md5 根据文件内容算出文件 hash
  • 通过 hash 可以判断服务端是否已经上传该文件,从而直接提示用户上传成功(秒传)
  • 通过 XMLHttpRequestabort 方法暂停切片的上传
  • 上传前服务端返回已经上传的切片名,前端跳过这些切片的上传

进一步优化

拖拽上传

  • 定义一个允许拖放文件的区域 div.drop-box
  • 取消 drop 事件的默认行为 e.preventDefault(),不然浏览器会直接打开文件
  • 为拖拽区域绑定事件,鼠标在拖拽区域上 dragover,鼠标离开拖拽区域 dragleave,在拖拽区域上释放文件 drop
  • drop 事件内获得文件信息 e.dataTransfer.files
js
 var box = document.getElementById('drop-box');

// 禁用浏览器的拖放默认行为
document.addEventListener('drop',function (e) {
    console.log('document drog');
    e.preventDefault();
});

// 设置拖拽事件
function openDropEvent() {
    box.addEventListener('dragover',function (e) {
        console.log('elemenet dragover');
        box.classList.add('over');
        e.preventDefault();
    });
    box.addEventListener('dragleave', function (e) {
        console.log('elemenet dragleave');
        box.classList.remove('over');
        e.preventDefault();
    });

    box.addEventListener('drop', function (e) {
        e.preventDefault(); // 取消浏览器默认拖拽效果

        var fileList = e.dataTransfer.files; // 获取拖拽中的文件对象
        var len = fileList.length; // 用来获取文件的长度(其实是获得文件数量)

        // 检测是否是拖拽文件到页面的操作
        if (!len) {
            box.classList.remove('over');
            return;
        }

        box.classList.add('over');

        submitUpload(fileList);
    }, false);
}

openDropEvent();

function submitUpload(fileList) {
    // upload...
}

剪贴板上传

  • 页面内增加一个可编辑的编辑区域 div.editor-box,开启 contenteditable
  • div.editor-box 绑定 paste 事件
  • 处理 paste 事件,从 event.clipboardData || window.clipboardData 获得数据
  • 将数据转换为文件 items[i].getAsFile()
  • 实现在编辑区域的光标处插入内容 insertNodeToEditor 方法

HTML:

html
<div class="editor-box" id="editor-box" contenteditable="true" >
    可以直接粘贴图片到这里直接上传
</div>

JS:

js
// 光标处插入 dom 节点
function  insertNodeToEditor(editor,ele) {
    // 插入 dom 节点
    var range; // 记录光标位置对象
    var node = window.getSelection().anchorNode;
    // 这里判断是做是否有光标判断,因为弹出框默认是没有的
    if (node != null) {
        range = window.getSelection().getRangeAt(0); // 获取光标起始位置
        range.insertNode(ele); // 在光标位置插入该对象
    } else {
        editor.append(ele);
    }
}

var box = document.getElementById('editor-box');

// 绑定 paste 事件
box.addEventListener('paste',function (event) {
    var data = (event.clipboardData || window.clipboardData);

    var items = data.items;
    var fileList = []; // 存储文件数据
    if (items && items.length) {
        // 检索剪切板items
        for (var i = 0; i < items.length; i++) {
            console.log(items[i].getAsFile());
            fileList.push(items[i].getAsFile());
        }
    }

    event.preventDefault(); // 阻止默认行为

    submitUpload(fileList);
});

function submitUpload(fileList) {
    // upload...
}

Demo: https://github.com/Jimco/zqd-upload-demo

相关知识

文件类型

JS 提供了一些 API 来处理文件或原始文件数据,例如:File、Blob、FileReader、Base64、ArrayBuffer、Canvas 等。

File API Relationship

类型说明

Blob

Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是 JavaScript 原生格式的数据。File 接口基于 Blob,继承了 blob 的功能并将其扩展以支持用户系统上的文件。

Blob 对象是包含有只读原始数据的类文件对象。简单来说,Blob 对象就是一个不可修改的二进制文件。

  1. 创建 blob
ts
const blob = new Blob(array, options);

有两个参数:

  • array: 是一个由 ArrayBuffer, ArrayBufferView, Blob, DOMString 等对象构成的 Array,或者其他类似对象的混合体;
  • options: 可选的 BlobPropertyBag 字典,它可能会指定如下两个属性:
    • type: 默认值为空字符串,表示将会被放入到 blob 中的数组内容的 MIME 类型;
    • endings: 默认值为 transparent,用于指定包含行结束符 \n 的字符串如何被写入,不常用;

常用的 MIME 类型如下:

类型描述
text/plain纯文本
text/htmlHTML 格式
text/plain纯文本文档
text/htmlHTML文档
text/javascriptJavaScript文件
text/cssCSS文件
application/jsonJSON 文件
application/pdfPDF 文件
application/xmlXML 文件
image/jpegJPEG 图像
image/pngPNG 图像
image/gifGI F图像
image/svg+xmlSVG 图像
audioMP3文件
videoMP4文件

示例:

ts
// 创建一个 blob
const blob = new Blob(['Hello World'], { type: 'text/plain' })
console.log(blob.size) // 11
console.log(blob.type) // "text/plain"

// 使用示例:转化为 ULR,并在 iframe 中加载
const iframe = document.getElementsById('iframe');
iframe.src = URL.createObjectURL(blob);
  1. blob 分片
ts
const blob = instanceOfBlob.slice([start [, end [, contentType]]]);

有三个参数:

  • start: 可选,切片起点,默认值为 0
  • end: 可选,切片结束位置,默认值为 blob.size
  • contentType: 可选,设置新 blob 的 MIME 类型。如果省略 type,则默认为 blob 的原始值;

示例:

ts
// 切片示例:页面上会显示:Hello Wo
const iframe = document.getElementsById('iframe');

const blob = new Blob(['Hello World'], { type: 'text/plain' });
const subBlob = blob.slice(0, 8);
iframe.src = URL.createObjectURL(subBlob);

File

文件(File)接口提供有关文件的信息,并允许网页中的 JavaScript 访问其内容。

File 对象是特殊类型的 Blob,且可以用在任意的 Blob 类型的 context 中。比如说,FileReader, URL.createObjectURL(), createImageBitmap()(en-US), 及 XMLHttpRequest.send() 都能处理 Blob 和 File。

以下为 FileReader 的对象方法:

方法描述
FileReader.abort()中止读取操作,在返回时 readyState 属性为 DONE
FileReader.readAsArrayBuffer()开始读取指定的 Blob 中的内容。完成后,result 属性中保存的将是被读取文件的 ArrayBuffer 数据对象
FileReader.readAsBinaryString()开始读取指定的 Blob 中的内容。完成后,result 属性中将包含所读取文件的原始二进制数据
FileReader.readAsDataURL()开始读取指定的 Blob 中的内容。完成后,result 属性中将包含一个 data: URL 格式的 Base64 字符串以表示所读取文件的内容
FileReader.readAsText()开始读取指定的 Blob 中的内容。一旦完成,result 属性中将包含一个字符串以表示所读取的文件内容
  1. Base64

Base64 是一组相似的二进制到文本(binary-to-text)的编码规则。一个常见应用是对二进制数据进行编码,以便将其纳入 dataURL 中。

在 JavaScript 中,有两个函数被分别用来处理解码和编码 Base64 字符串:

  • atob(): 解码通过 Base-64 编码的字符串数据(「atob」 应读作「ASCII to binary」);
  • btoa(): 从二进制数据「字符串」创建一个 Base-64 编码的 ASCII 字符串(「btoa」应读作「binary to ASCII」);
html
<!-- 读取文件,用 Bse64 展示图片 -->
<!DOCTYPE html>
<html lang="en">
    <body>
        <input type="file" id="input" />
        <img id="img" />
        <script>
            const input = document.getElementById('input');
            const img = document.getElementById('img');

            input.onchange = (e) => {
                const reader = new FileReader();
                reader.readAsDataURL(e.target.files[0]);
                reader.onload = (e) => {
                    img.src = e.target.result;
                    console.log(e.target.result);
                };
            };
        </script>
    </body>
</html>
  1. Object URL

Object URL(MDN 定义名称)又称 Blob URL(W3C 定义名称),是 HTML5 中的新标准。它是一个用来表示 File Object 或 Blob Object 的 URL。

Blob URL/Object URL 是一种伪协议,允许将 Blob 和 File 对象用作图像、二进制数据下载链接等的 URL 源。

html
<!-- 通过 Object URL 展示和下载图片 -->
<!DOCTYPE html>
<html lang="en">
    <body>
        <input type="file" id="input" />
        <img id="img" />
        <script>
        const input = document.getElementById('input');
        const img = document.getElementById('img');

        input.onchange = (e) => {
            const url = URL.createObjectURL(e.target.files[0]);
            img.src = url;

            // 实现下载
            const a = document.createElement('a');
            a.href = url;
            a.download = 'img';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
        };
        </script>
    </body>
</html>
  1. ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。(只读)

类型转换

Blob、File → Base64

js
function fileToDataURL(file) {
    let reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = function (e) {
        return reader.result;
    };
}

Base64 → Blob、File

js
// Base64 转为 Blob
function dataURLToBlob(fileDataURL) {
    let arr = fileDataURL.split(','),
        mime = arr[0].match(/:(.*?);/)[1],
        bStr = atob(arr[1]),
        n = bStr.length,
        u8arr = new Uint8Array(n);

    while (n--) {
        u8arr[n] = bStr.charCodeAt(n);
    }

    return new Blob([u8arr], { type: mime });
}

// Base64 转为 File
function dataURLToBlob(fileDataURL, filename) {
    let arr = fileDataURL.split(','),
        mime = arr[0].match(/:(.*?);/)[1],
        bStr = atob(arr[1]),
        n = bStr.length,
        u8arr = new Uint8Array(n);

    while (n--) {
        u8arr[n] = bStr.charCodeAt(n);
    }

    return new File([u8arr], filename, { type: mime });
}

Blob、File → Object URL

js
// object:用于创建 URL 的 File 对象、Blob 对象
const objectUrl = URL.createObjectURL(object);

ArrayBuffer → Blob

js
const blob = new Blob([new Uint8Array(buffer, byteOffset, length)]);

ArrayBuffer → Base64

js
const base64 = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));

Blob → ArrayBuffer

js
function blobToArrayBuffer (blob) {
    const reader = new FileReader();

    reader.readAsArrayBuffer(blob);
    reader.onload = () => {
        return reader.result;
    };
}

Blob、File 文件数据绘制到 Canvas

js
// 思路:Blob、File ——> dataURL(Base64) ——> canvas
function fileAndBlobToCanvas(fileDataURL) {
    let img = new Image();
    let canvas = document.createElement('canvas');

    img.src = fileDataURL;
    if (!canvas.getContext) {
        alert('浏览器不支持 Canvas');
        return;
    }

    let ctx = canvas.getContext('2d');
    document.getElementById('container').appendChild(canvas);
    img.onload = function () {
        ctx.drawImage(img, 0, 0, img.width, img.height);
    }
}

从 Canvas 中获取文件 Base64

js
function canvasToDataURL() {
    let canvas = document.createElement('canvas');
    let canvasDataURL = canvas.toDataURL('image/png', 1.0);
    return canvasDataURL;
}

参考资料