# 文件上传

# 一、使用FileReader实现浏览器预览图片

FileReader对象允许Web应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File 或 Blob 对象指定要读取的文件或数据。

其中File对象可以是来自用户在一个<input>元素上选择文件后返回的FileList对象,也可以来自拖放操作生成的 DataTransfer对象,还可以是来自在一个HTMLCanvasElement上执行mozGetAsFile()方法后返回结果。

# 1、FileReader对象语法

# 属性

  • FileReader.error 表示在读文件操作过程中发生的错误。
  • FileReader.readyState 表示FileReader读取数据的状态,有三个值:
EMPTY:没有数据被加载
LOADING:数据正在被加载
LOADING:数据正在被加载
1
2
3
  • FileReader.result 代表数据读取完成后的结果,只有在数据被加载完成后,result属性才有效。

# 事件

  • FileReader.onbort 在FileReader的reading操作被中断的时候触发。
  • FileReader.onerror 在FileReader读取数据过程中发生错误时触发。
  • FileReader.onload 在FileReader读取事件完成后调用。
  • FileReader.onloadstart 在FileReader读取事件开始时调用。
  • FileReader.onloadend 当读取操作完成时调用,不管是成功还是失败.该处理程序在onload或者onerror之后调用。
  • FileReader.onprogress 在FileReader读取数据的过程中调用。

# 方法

(1)readAsText()

读取文本文件。该方法含两个形参,第一个为所要读取的File 或者Blob 对象,第二个为所用的编码格式(可选,默认UTF-8)。鉴于这是一个异步方法我们需要为文件加载结束时添加一个事件监听器。

var reader = new FileReader();
reader.onload = function(e) {
  var text = reader.result;
}
reader.readAsText(file, encoding);
1
2
3
4
5

(2)readAsDataURL()

该方法接收一个文件或Blob并产生一个data URL。通常是一个base64的 文件数据字符。你可以用此data URL去做类似为image设置src属性的事情。

var reader = new FileReader();
reader.onload = function(e) {
  var dataURL = reader.result;
}
reader.readAsDataURL(file);
1
2
3
4
5

(3)readAsBinaryString()

用于读取任何类型的文件。返回文件的原生二进制数据。==如果你用readAsBinaryString() 结合 XMLHttpRequest.sendAsBinary() 方法,你利用js向服务器可以上传任何类型的文件。==

var reader = new FileReader();
reader.onload = function(e) {
  var rawData = reader.result;
}
reader.readAsBinaryString(file);
1
2
3
4
5

(4)readAsArrayBuffer()

一个ArrayBuffer是一个固定长度的二进制数组buffer。在处理文件时(如将JPEG图像转换为PNG),它们可以派上用场。

var reader = new FileReader();
reader.onload = function(e) {
  var arrayBuffer = reader.result;
}
reader.readAsArrayBuffer(file);
1
2
3
4
5

(5)abort() 中断读数据操作,直接返回,readyState将被设置为DONE

# 2、案例:实现浏览器预览图片

index.html

<!DOCTYPE html>
<html>

<head>
    <title></title>
    <meta charset="utf-8">
</head>

<body>
    上传图片或文本文件:<input type="file" id="fileInput">
    <img src="" id="img">
    <p id="text"></p>
    <script type="text/javascript" src="index.js"></script>
</body>

</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

index.js

var fileInput = document.querySelector('#fileInput');
var Img = document.querySelector('#img');
var Text = document.querySelector('#text');
var reader = new FileReader();

fileInput.onchange = function () {
	// 拿到文件对象
	var file = this.files[0];
	// 使用readAsDataURL()方法读取指定的内容,图片,音频
	// 使用readAsBinaryString()方法读取视频内容
	// 使用readAsText(file, 'gb2312')方法读取文本文件内容
	reader.readAsText(file, 'gb2312');
	// 当读取完成时
	reader.onload = function (e) {
		console.log(reader.result);
		//Text.innerHTML = reader.result;  // 设置文本
		Img.setAttribute('src', reader.result) // 设置图片
	};
	// 开始加载
	reader.onloadstart = function (e) {
		console.log('开始加载', e)
	}
	// 加载完
	reader.onloadend = function (e) {
		console.log('加载完', e)
	}
	// 进度
	reader.onprogress = function (e) {
		console.log('进度', e)
		// e.loaded  已经加载好大小
		// e.total  总大小
	}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

# 二、上传大文件(普通文件,音频,视频)

在web开发中,经常遇到处理文件上传的情况。而express框架在4.0版本后就不在支持req.files接收上传文件,对于文件上传,需要加multipart格式数据处理的中间件。multipart数据处理中间件有:busboy, multer, formidable, multiparty, connect-multiparty, pez等。本站使用了formidable插件,比较简单易用。 formidable是一个用于处理文件、图片、视频等数据上传的模块,支持GB级上传数据处理,支持多种客户端数据提交。有极高的测试覆盖率,非常适合在生产环境中使用。

# 案例(express版)

前端代码:

// index.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html">

<head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
    <html>

    <head>
        <title></title>

    </head>

<body>
    <form enctype="multipart/form-data" id="uploadImg">
        上传文件:
        <input name="file" type="file" id="file">
    </form>
    <progress value='0' max='100' id="pro"></progress>
    <span id="val">0%</span>
    <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
    <script>
        $(function () {
            $('input[type="file"]').on('change', function () {
                // 进度条
                $("#pro").attr('value', 0); //已经上传的百分比  
                $("#val").text('0%');
                // 上传
                var file = this.files[0];
                var formData = new FormData();
                formData.append('file', file);
                $.ajax({
                    url: 'http://localhost:3000/upload',
                    type: 'POST',
                    async: true,
                    cache: false,
                    data: formData,
                    processData: false,  // 默认true,会将发送的数据序列化以适应默认的内容类型application/x-www-form-urlencoded
                    contentType: false,  // 不设置数据格式,上传文件时需设置为false
                    xhr: function () {
                        myXhr = $.ajaxSettings.xhr();
                        if (myXhr.upload) {
                            myXhr.upload.addEventListener('progress', function (e) {
                                var loaded = e.loaded; //已经上传大小情况 
                                var total = e.total; //附件总大小 
                                var percent = Math.floor(100 * loaded / total);
                                $("#pro").attr('value', percent); //已经上传的百分比  
                                $("#val").text(percent + '%');
                                console.log("已经上传了:" + percent + '%');
                            }, false);
                        }
                        return myXhr;
                    },
                }).done(function (res) {
                    console.log(res)
                }).fail(function (res) {
                    console.log(res)
                });
            });
        })
    </script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

服务端代码:

const express = require('express');
const app = express();
const fs = require('fs');
const path = require('path');
const bodyParser = require('body-parser');
const formidable = require("formidable"); // 使用formidable处理文件

// 普通post,文件域
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json())
app.use(express.static('./'))

app.post('/upload', (req, res) => {
	// 如果是普通的post(不带文件),则用req.body获取表单数据即可,
	// 如果是带文件的post,则需要使用formidable处理
	const form = new formidable.IncomingForm()
	form.uploadDir = path.join(__dirname, 'tmp');   //文件保存的临时目录为当前项目下的tmp文件夹
	form.maxFileSize = 2000000 * 1024 * 1024; // 设置上传的文件的最大尺寸
	form.parse(req, (err, fields, files) => {
		// fields是字段域数据,files是文件数据
		const { name: fileName, path: tmpPath } = files.file; // 获取文件名称和文件的临时路径
		const targetPath = path.resolve(__dirname, 'upload/' + fileName) // 存放后的文件的路径
        // 上传成功后返回数据
		res.json({ code: 0, fileUrl: '/upload/' + fileName });
		
		/*//方法1,使用fs.rename移动文件
		fs.rename(tmpPath, targetPath, function (err) {
			if (err) {
				console.info(err);
				res.json({ code: -1, message: '操作失败' });
			} else {
				//上传成功,返回文件的相对路径
				var fileUrl = '/upload/' + fileName;
				res.json({ code: 0, fileUrl: fileUrl });
			}
		});
		*/

		//方法2,使用读写流来移动文件
		const readStream = fs.createReadStream(tmpPath)  // 根据临时地址生成读取流
		const writeStream = fs.createWriteStream(targetPath) // 根据目标地址生成写入流
		const reader = readStream.pipe(writeStream);  // 管道流进行连接
		reader.on('close', () => {
			fs.unlinkSync(tmpPath); // 记得同时删除tmp的文件
		});
	})
})
app.listen(3000, () => {
	console.log('监听3000端口');
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

koa版参考

目录结构:

image

# 三、大文件分片上传

# 1、整体思路

前端:核心是利用Blob.prototype.slice方法,和数组的 slice 方法相似,调用的 slice 方法可以返回原文件的某个切片。这样我们就可以根据预先设置好的切片最大数量将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片,这样从原本传一个大文件,变成了同时传多个小的文件切片,可以大大减少上传时间另外由于是并发,传输到服务端的顺序可能会发生变化,所以我们还需要给每个切片记录顺序。

服务端:需要负责接受这些切片,并在接收到所有切片后合并切片。

这里又引伸出两个问题:(1)何时合并切片,即切片什么时候传输完成(2)如何合并切片。

第一个问题需要前端进行配合,前端在每个切片中都携带切片最大数量的信息,当服务端接受到这个数量的切片时自动合并,也可以额外发一个请求主动通知服务端进行切片的合并。

第二个问题,具体如何合并切片呢?这里可以使用 nodejs 的 读写流 (readStream/writeStream),将所有切片的流传输到最终文件的流里。

# 2、前端部分

利用file.slice进行切片,然后调用 createThunkList 将文件切片,切片数量通过文件大小控制,这里设置 40MB,也就是说 100 MB 的文件会被分成 3 个切片.

createThunkList 内使用 while 循环和 slice 方法将切片放入 thunkList 数组中返回。在生成文件切片时,需要给每个切片一个标识作为 hash,这里暂时使用 文件名 + 下标,这样后端可以知道当前切片是第几个切片,之后调 createRequestList 方法上传所有的文件切片,将文件切片,切片 hash,以及文件名放入 FormData 中,再调用上一步的 request 函数返回一个 proimise,最后调用 Promise.all 并发上传所有的切片。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html">
<head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
    <html>
    <head>
        <title></title>
    </head>
<body>
    <!-- 上传文件 -->
    <form enctype="multipart/form-data" id="uploadImg">
        上传文件:<input name="file" type="file" id="file">
    </form>
    <!-- 展示分片进度条 -->
    <div id="wrapper" style="display: none">
        <div class="progress">
            <progress value='0' max='100'></progress>
            <span style="margin-left: 10px">#name</span> <span style="margin-left: 10px">#percent</span>
        </div>
    </div>
    <!-- 展示文件总的进度条 -->
    <div id="wrapperTotal" style="display: none">
        <h3>总进度条</h3>
        <progress value='0' max='100'></progress>
        <span style="margin-left: 10px">#name</span> <span style="margin-left: 10px">#percent</span>
    </div>

    <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
    <script>
        const SIZE = 40 * 1024 * 1024; // 切片大小,40M
        const $wrapper = $("#wrapper");
        const $progress = $wrapper.find('.progress');
        const $wrapperTotal = $("#wrapperTotal");
        $('input[type="file"]').on('change', function () {
            init();
            const file = this.files[0];
            const thunkList = createThunkList(file, SIZE);
            window.loadMap = initProgessBar(file, thunkList); //记录每个分片已经上传好的
            const requestList = createRequestList(file, thunkList);
            Promise.all(requestList).then(ret => {
                console.log(ret);
            })
        });
        // 初始化操作
        function init() {
            $("#pro").attr('value', 0); //已经上传的百分比  
            $("#val").text('0%');
        }
        // 创建切片
        function createThunkList(file, size = SIZE) {
            const thunkList = [];
            let cur = 0, index = 0;
            while (cur < file.size) {
                let thunk = file.slice(cur, cur + size);
                thunkList.push({
                    thunk,
                    hash: file.name + '_' + index,
                    index,
                    percent: 0  // 当前百分比
                });
                cur += size;
                index++;
            }
            return thunkList;
        }
        // 创建多个请求
        function createRequestList(file, thunkList) {
            const thunkLen = thunkList.length;
            const requestList = thunkList.map(({ thunk, hash, index }) => {
                var formData = new FormData();
                formData.append('thunkFile', thunk);
                formData.append('hash', hash);
                formData.append('filename', file.name);
                formData.append('thunkLen', thunkLen);
                formData.append('thunkSize', SIZE);
                return { formData, index };
            }).map(({ formData, index }) => {
                return request(formData, createProgressHandler(index, file));
            })
            return requestList;
        }
        // 进度条事件
        function createProgressHandler(index, file) {
            return e => {
                const { total, loaded } = e;

                // 分片的百分比
                var percent = Math.floor(100 * loaded / total);
                var $curProgress = $('#wrapper').children().eq(index);
                $curProgress.find('progress').attr('value', percent);
                $curProgress.find('span').eq(1).text(percent + '%');

                // 总的百分比
                window.loadMap[index] = loaded;
                var totalLoad = Object.values(window.loadMap).reduce((a, b) => (a + b), 0);
                var totalPercent = Math.floor(100 * totalLoad / file.size);
                $wrapperTotal.find('progress').attr('value', totalPercent);
                $wrapperTotal.find('span').eq(1).text(totalPercent + '%');
            }
        }
        // 设置进度条
        function initProgessBar(file, thunkList) {
            var tplArr = [], loadMap = {}
            thunkList.map((item, index) => {
                var $clone = $progress.clone();
                $clone.find('progress').attr('value', item.percent);
                var html = $clone.prop('outerHTML');
                html = html.replace('#name', item.hash).replace('#percent', item.percent + '%')
                tplArr.push(html);
                loadMap[index] = 0;
            })
            $wrapper.html(tplArr).show();

            // 总的进度条
            $wrapperTotal.show().find('progress').attr('value', 0);
            var $span = $wrapperTotal.find('span');
            $span.eq(0).text(file.name);
            $span.eq(1).text('0%');

            return loadMap;
        }
        // 请求
        function request(formData, callback) {
            return new Promise(resolve => {
                $.ajax({
                    url: 'http://localhost:3000/upload',
                    type: 'POST',
                    async: true,
                    cache: false,
                    data: formData,
                    processData: false,  // 默认true,会将发送的数据序列化以适应默认的内容类型application/x-www-form-urlencoded
                    contentType: false,  // 不设置数据格式,上传文件时需设置为false
                    xhr: function () {
                        myXhr = $.ajaxSettings.xhr();
                        if (myXhr.upload) {
                            myXhr.upload.addEventListener('progress', callback, false);
                        }
                        return myXhr;
                    },
                }).done(function (res) {
                    resolve(res);
                }).fail(function (res) {
                    console.log(res)
                });
            })
        }
    </script>
</body>

</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150

# 3、服务端部分

在接受文件切片时,需要先创建存储切片的文件夹,由于前端在发送每个切片时额外携带了唯一值 hash,所以以 hash 作为文件名,将切片从临时路径移动切片文件夹中。当切片中的数量和前端传递过来的数量一样的时候,将切片文件夹下的所有切片进行合并。合并完后删除切片文件夹即可。

const express = require('express');
const app = express();
const fs = require('fs');
const fse = require('fs-extra');
const path = require('path');
const bodyParser = require('body-parser');
const formidable = require("formidable"); // 使用formidable处理文件
const opn = require('opn');

// 普通post,文件域
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json())
app.use(express.static('./'))

app.post('/upload', (req, res) => {
	// 如果是普通的post(不带文件),则用req.body获取表单数据即可,
	// 如果是带文件的post,则需要使用formidable处理
	const form = new formidable.IncomingForm()
	form.keepExtensions = true;
	form.uploadDir = path.join(__dirname, 'tmp');   //文件保存的临时目录为当前项目下的tmp文件夹
	form.maxFileSize = 2000000 * 1024 * 1024; // 设置上传的文件的最大尺寸
	form.parse(req, (err, fields, files) => {
		// fields是字段域数据,files是文件数据
		const { hash, filename, thunkLen, thunkSize } = fields;
		const { thunkFile } = files; // tmpPath是临时路径

		// 单独创建这个文件的切片文件夹,把切片全部放在这个文件夹里面
		const thunkDir = path.resolve(__dirname, 'tmp/' + filename);
		const thunkDirPath = thunkDir + '/' + hash;
		if (!fs.existsSync(thunkDir)) {
			fs.mkdirSync(thunkDir);
		}
		fs.renameSync(thunkFile.path, thunkDirPath);
		res.json({ code: 0 })

		// 读取切片目录
		let thunkNameArr = fs.readdirSync(thunkDir);
		thunkNameArr = thunkNameArr.sort((a, b) => {
			return a.split('_')[1] - b.split('_')[1];
		})
		// 如果这个目录的切片数量和传递过来的切片数量一样则开始合并
		if (thunkNameArr && thunkNameArr.length == thunkLen) {
			const thunkPathArr = thunkNameArr.map(trunkName => {
				return path.resolve(thunkDir, trunkName);
			});
			const uploadPath = path.resolve('upload', filename);
			// 生成文件
			const pipeStream = (readStream, writeStream) => {
				return new Promise(resolve => {
					const reader = readStream.pipe(writeStream);
					reader.on('close', () => {
						resolve();
					})
				})
			}
			const thunkMovePromiseArr = thunkPathArr.map((thunkPath, index) => pipeStream(
				fs.createReadStream(thunkPath),
				fs.createWriteStream(uploadPath, {
					start: index * thunkSize,
					end: (index + 1) * thunkSize
				})
			))
			Promise.all(thunkMovePromiseArr).then(() => {
				console.log('开始移除切片目录')
				thunkPathArr.map(thunkPath => {
					fs.unlinkSync(thunkPath);
				})
				fs.rmdirSync(thunkDir);
				console.log('移除完成')
			})
		}
	})
})

app.listen(3000, () => {
	console.log('监听3000端口');
	//opn('http://localhost:3000');
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

# 四、断点续传

断点续传的原理在于前端/服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能

  • 前端使用 localStorage 记录已上传的切片 hash
  • 服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片

第一种是前端的解决方案,第二种是服务端,而前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以这里选取后者。

# 生成hash

无论是前端还是服务端,都必须要生成文件和切片的 hash,之前我们使用 文件名 + 切片下标作为切片 hash,这样做文件名一旦修改就失去 了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根 据文件内容生成 hash,所以我们修改一下 hash 的生成规则。

这里用到另一个库 spark-md5,它可以根据文件内容计算出文件的 hash 值,另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互 由于实例化 web-worker 时,参数是一个 js 文件路径且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,另外在 worker 中也是不允许访问 dom 的,但它提供了importScripts 函数用于导入外部脚本,通过它导入 spark-md5

// /public/hash.js
self.importScripts("/spark-md5.min.js"); // 导入脚本

// 生成文件 hash
self.onmessage = e => {
  const { fileChunkList } = e.data;
  const spark = new self.SparkMD5.ArrayBuffer();
  let percentage = 0;
  let count = 0;
  const loadNext = index => {
    const reader = new FileReader();
    reader.readAsArrayBuffer(fileChunkList[index].file);
    reader.onload = e => {
      count++;
      spark.append(e.target.result);
      if (count === fileChunkList.length) {
        self.postMessage({
          percentage: 100,
          hash: spark.end()
        });
        self.close();
      } else {
        percentage += 100 / fileChunkList.length;
        self.postMessage({
          percentage
        });
        // 递归计算下一个切片
        loadNext(count);
      }
    };
  };
  loadNext(0);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

在 worker 线程中,接受文件切片 fileChunkList,利用 FileReader 读取每个切片的 ArrayBuffer 并不断传入 spark-md5 中,每计算完一个切片通过 postMessage 向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程spark-md5 需要根据所有切片才能算出一个 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash,具体可以看官方文档.

服务端则使用 hash 作为切片文件夹名,hash + 下标作为切片名,hash + 扩展名作为文件名,没有新增的逻辑。

# 文件秒传

在实现断点续传前先简单介绍一下文件秒传。所谓的文件秒传,即在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功文件秒传需要依赖上一步生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可。

前端:

const needUpload = await verifyHash(fileHash, file); // 验证hash是否存在
if(!needUpload){
    alert('秒传成功');
    return;
}

 // 验证hash是否存在
function verifyHash(fileHash, file) {
    return new Promise((resolve) => {
        const data = {
            fileHash,
            fileName: file.name
        }
        $.post('http://localhost:3000/verifyHash', data).then(ret => {
            console.log(ret);
            resolve(ret.needUpload);
        })
    })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

服务端:

app.post('/verifyHash', (req, res) => {
	const { fileHash, fileName } = req.body; // 得到文件名和文件的hash
	const ext = fileName.match(/\.\w+$/)[0]; // 得到扩展名
	const filePath = path.resolve('upload', fileHash + ext); // 得到文件路径
	const exist = fs.existsSync(filePath); // 是否存在
	res.json({
		code: 0,
		needUpload: !exist,
		fileUrl: filePath
	})
})
1
2
3
4
5
6
7
8
9
10
11

# 暂停上传

断点续传顾名思义即断点 + 续传,所以我们第一步先实现“断点”,也就是暂停上传。

原理是使用 XMLHttpRequest 的 abort 方法,可以取消一个 xhr 请求的发送,为此我们需要将上传每个切片的 xhr 对象保存起来。

 let xhrArr = []; // 保存所有xhr对象
 
// 改造request
var xhr = $.ajax({
    url: 'http://localhost:3000/upload',
    type: 'POST',
    async: true,
    cache: false,
    data: formData,
    processData: false,  // 默认true,会将发送的数据序列化以适应默认的内容类型application/x-www-form-urlencoded
    contentType: false,  // 不设置数据格式,上传文件时需设置为false
    xhr: function () {
        myXhr = $.ajaxSettings.xhr();
        if (myXhr.upload) {
            myXhr.upload.addEventListener('progress', callback, false);
        }
        return myXhr;
    },
}).done(function (res) {
    xhrArr = xhrArr.filter(t => t !== xhr);
    resolve(res);
}).fail(function (res) {
    console.log(res)
});
xhrArr.push(xhr);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 恢复上传

由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了“续传”的效果。

而这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果:

  • 服务端已存在该文件,不需要再次上传
  • 服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端
app.post('/verifyHash', async (req, res) => {
	const { fileHash, fileName } = req.body; // 得到文件名和文件的hash
	const ext = fileName.match(/\.\w+$/)[0]; // 得到扩展名
	const filePath = path.resolve('upload', fileHash + ext); // 得到文件路径
	const exist = fs.existsSync(filePath); // 是否存在
	res.json({
		code: 0,
		needUpload: !exist,
		fileUrl: filePath,
		uploadedList: createUploadedList(fileHash)
	})
})

const createUploadedList = (fileHash) => {
	const tmpDir = path.resolve('tmp', fileHash); // 得到切片文件夹
	if (fs.existsSync(tmpDir)) {
		return fs.readdirSync(tmpDir);
	} else {
		return []
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

参考

Last Updated: 3/22/2020, 3:45:51 PM