利用浏览器本地存储进行大文件上传和断点续传

1,054 阅读5分钟

基本思路

分片上传

利用File对象的slice方法,将文件分割成多个Blob对象,通过XMLHttpRequest异步上传sessionStorage中没有记录已完成的BlobXMLHttpRequest上传完成后触发onload事件,在sessionStorage中记录刚完成的Blob

断点续传即暂停功能

承接上文,在XMLHttpRequest异步上传时,将XMLHttpRequest对象保存于一个正在执行的任务容器中,当XMLHttpRequest上传完成触发onload事件后将其从任务容器中移除。当想要文件暂停上传时,只需要让任务容器中的每个XMLHttpRequest对象被abort即可,重新启动就是重新分片上传,它会略过已经上传的部分,所以可以接着原来的进度进行上传。

前置知识

Web Storage

LocalStorageSessionStorage是HTML5提供的浏览器本地化存储的解决方案之一,存储容量可以达到5MB,使用简单。两者共用相同的API接口,但在作用域和生命周期上具有差别。LocalStorage理论上不手动清理则会永久保存在本地,当页面的协议、主机名、端口相同时,读取到的是同一LocalStorageSessionStorage在页面窗口被关闭时就会被清空,当然关闭浏览器也会清空数据,当页面的窗口、协议、主机名、端口相同时候共用同一SessionStorage

Storage以key, value的形式进行数据存储,格式为字符串,所以存储内容会受到限制,可以使用JSON.stringfy、JSON.parse方法进行转换。

API接口

sessionStorage/localStorage.
                            setItem(key, value)       //添加数据
                            getItem(key)              //查找数据
                            removeItem(key)           //移除数据
                            clear                     //清空数据

File对象

File对象一般来自<input type="file">读取文件后获取的files属性,该属性是以File为元素的FileList对象。File对象是一个特殊的Blob对象,这意味着它可以使用Blob的任何属性和方法,且可以用在任意的Blob类型的上下文中,例如FileReader、XMLHttpRequest.send()

File对象在针对从本地硬盘选择文件的情况,更像是文件描述符,它的命名很容易让人误会File对象已经把硬盘里的文件读取出来了,然而并没有,如果要读取到浏览器内存里,要使用FileReader对象。我们在进行异步发送的时候无需先把文件读到浏览器内存里,再发送,XMLHttpRequest.send会处理File对象,传输时浏览器会将实际的文件发送过去。

常用属性和方法

File.
     name            //返回引用文件的名字
     size            //以字节为单位返回文件大小
     type            //以MIME Type返回文件类型
     slice(start, end, contentType)     //分割文件,三个参数都可选,分别表示文件开始的位置,文件结束的位置,新的文件类型。

FormData对象

FormData可以以一种表示表单数据的键值对key/value的构造方式,key并不需要唯一,通过XMLHttpRequest将数据进行发送,如果送出时的编码类型被设multipart/form-data,它会使用和表单一样的格式。代码里没使用是因为服务端那个文件接收框架默认给req添加了该编码类型,添加了会出现错误。不直接发送File的原因是为了发送的过程中,带一些其他信息,比如文件的序号啊、检验码等。

API接口

new FormData().
               append(name, value [,filename])      //添加数据,当value为blob类型可以通过filename指定blob的文件名
               delete(name)                         //会删除该键名下的所有值
               entries()                            //返回包含所有键值对的可迭代对象
               keys()                               //返回包含所有键的可迭代对象
               values()                             //返回包含所有值的可迭代对象
               get(name)                            //返回与给定键关联的第一个值
               getAll(name)                         //返回与给定键关联的所有值
               set(name, value)                     //存在该键时,直接覆盖原来的值,不存在,则新添加该键值     

代码实现

简易的服务端测试代码

const express = require('express');
const multer = require('multer');

const storage = multer.diskStorage({
    destination: function(req, file, cb) {
        cb(null, './uploads/')
    },
    filename: function ( req, file, cb) {
        cb(null, req.body.name)
    }
})
const upload = multer({ storage })
const app = express();
const port = 5000;

app.use(express.json()) // for parsing application/json
app.use(express.urlencoded({ extended: true })) // for parsing application/x-www-form-urlencoded

app.all('*', function(req, res, next) {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Headers", "X-Requested-With");
    res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS");
    next();
});

app.post('/', upload.any(), (req, res)=> {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Headers", "*");
    res.header('Access-Control-Allow-Methods', 'PUT, GET, POST, DELETE, OPTIONS');
    console.log(req.files);
    res.send('Ok');
})

app.listen(port, ()=> {
    console.log(`Example app listening at http://localhost:${port}`)
})

前端代码

页面部分

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="./upload.js"></script>
    <title>文件上传测试</title>
</head>
<body>
    <div class="upload-container">
        <input type="file" multiple id="filename" name="file"/>
        <span id="progress">0% &nbsp;</span>
        <button onclick="submit()">上传</button>
        <button id='pause'>暂停</button>
        <button id='cancel'>取消</button>
    </div>

    <script>
        var file;
        function submit() {
            file = document.getElementById('filename').files[0]
            file = new webUploader(file, 'http://localhost:5000');
            document.getElementById('pause').addEventListener('click', file.pause.bind(file));
            document.getElementById('cancel').addEventListener('click', file.cancel.bind(file));
        }
    </script>
</body>
</html>

upload.js代码

function webUploader(file, url, headers={}){
    this._tasks = this._init(file);   //切分大文件
    this._exeQueue = {};  //保存正在执行的xhr对象,以供abort使用
    this._status = 'running'; //uploader的状态描述
    this._url = url;
    this._headers = headers;
    this._size = {totalSize: file.size, loadedSize: 0};

    document.getElementById('progress').innerHTML = '0%' + '&nbsp;';
    this._run();    //开始上传
}


//切割大文件
webUploader.prototype._init = function (file) {
    //返回文件切割的可迭代对象 
    let tasks = [];
    let index = 0;
    const SIZE = 20 * 1024 * 1024;
    while(index < file.size) {
        tasks.push({name: file.name + '_' + (index / SIZE + 1), file: file.slice(index, index + SIZE)});
        index += SIZE;
    }
    return tasks;
}

//进行上传
webUploader.prototype._run = async function () {
    let queue = [];
    this._exeQueue = {};
    for(task of this._tasks) {
        //本地已检索到上传记录,则跳过
        if(!sessionStorage.getItem(task.name) || sessionStorage.getItem(task.name)!='done') {
            let formData = new FormData();
            formData.append('name', task.name);
            formData.append('file', task.file);
            queue.push(this._request({url: this._url, formData: formData, headers: this._headers, exeQueue: this._exeQueue}));
        }
    }
    Promise.all(queue).then( value => {
        console.log('上传完毕');
    }).catch (value => {
        console.log('上传失败');
    });
}

//暂停功能
webUploader.prototype.pause = function() {
    if ( this._status == 'running') {
        this._status = 'waiting';
        for(key in this._exeQueue) {
            this._exeQueue[key].abort();
        }
        this._exeQueue = {};
        console.log('已暂停');
    } else if( this._status == 'waiting') {
        this._status = 'running';
        console.log('已重启');
        this._run();
    } else {
    }
}

//取消上传
webUploader.prototype.cancel = function () {
    if(this._status != 'ending'){
        this._status = 'running';
        this.pause();
        this._status = 'ending';
        this._tasks.forEach( el=> {
            sessionStorage.removeItem(el.name);
        });
        this._tasks = [];
        this._url = '';
        this._headers = {};
        this._size = {totalSize: undefined, loadedSize: undefined};
        document.getElementById('progress').innerHTML = '0%' + '&nbsp;';
        console.log('已取消');
    }
}

//简易封装异步请求
webUploader.prototype._request = function({url, formData, headers, exeQueue}) {
    return new Promise( (resolve, reject) => {
        let name = formData.get('name')
        xhr = new XMLHttpRequest();
        xhr.upload.onprogress = this._progressUpdate(this._size);
        xhr.open('post', url);
        Object.keys(headers).forEach( key => {
            xhr.setRequestHeader(key, headers[key])
        });
        xhr.send(formData);
        xhr.onload = e => {
            // 上传完成后用sessionStore 存储任务信息
            sessionStorage.setItem(name, 'done');
            resolve(
                {
                    data: e.target.response
                }
            );
            delete exeQueue[name]; //上传完成后将xhr从中删除
        }
        xhr.onerror = e => {
            reject('error')
        }

        exeQueue[name] = xhr;
    } )
}

//更新进度条使用
webUploader.prototype._progressUpdate = function (size) {
    let oldloaded = 0;
    return e => {
        let progress_dom = document.getElementById('progress');     //测试就直接获取了,简单些
        size.loadedSize += (e.loaded-oldloaded);
        oldloaded = e.loaded;
        progress_dom.innerHTML = Math.floor(( size.loadedSize / size.totalSize) * 100) + '%' + '&nbsp;';
    }
}

参考文章

字节跳动面试官:请你实现一个大文件上传和断点续传

localStorage必知必会