如何中断已经发出去的请求&&大文件上传处理(项目)

总结了最近的一些面试题和之前的知识点


Axios 如何取消重复请求?

在 Web 项目开发过程中,我们经常会遇到重复请求的场景,如果系统不对重复的请求进行处理,则可能会导致系统出现各种问题。比如重复的 post 请求可能会导致服务端产生两笔记录。那么重复请求是如何产生的呢?这里我们举 2 个常见的场景:

假设页面中有一个按钮,用户点击按钮后会发起一个 AJAX 请求。如果未对该按钮进行控制,当用户快速点击按钮时,则会发出重复请求。
假设在考试结果查询页面中,用户可以根据 “已通过”、“未通过” 和 “全部” 3 种查询条件来查询考试结果。如果请求的响应比较慢,当用户在不同的查询条件之前快速切换时,就会产生重复请求。

一、如何取消请求

Axios 是一个基于 Promise 的 HTTP 客户端,同时支持浏览器和 Node.js 环境。它是一个优秀的 HTTP 客户端,被广泛地应用在大量的 Web 项目中。对于浏览器环境来说,Axios 底层是利用 XMLHttpRequest 对象来发起 HTTP 请求。如果要取消请求的话,我们可以通过调用 XMLHttpRequest 对象上的 abort 方法来取消请求:

1
2
3
4
let xhr = new XMLHttpRequest();
xhr.open("GET", "https://developer.mozilla.org/", true);
xhr.send();
setTimeout(() => xhr.abort(), 300);

而对于 Axios 来说,我们可以通过 Axios 内部提供的 CancelToken 来取消请求:

1
2
3
4
5
6
7
8
9
10
const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.post('/user/12345', {
name: 'semlinker'
}, {
cancelToken: source.token
})

source.cancel('Operation canceled by the user.'); // 取消请求,参数是可选的

此外,你也可以通过调用 CancelToken 的构造函数来创建 CancelToken,具体如下所示:

1
2
3
4
5
6
7
8
9
10
const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
cancelToken: new CancelToken(function executor(c) {
cancel = c;
})
});

cancel(); // 取消请求

现在我们已经知道在 Axios 中如何使用 CancelToken 来取消请求了,那么 CancelToken 内部是如何工作的呢?这里我们先记住这个问题,后面揭开 CancelToken 背后的秘密。接下来,我们来分析一下如何判断重复请求。

二、如何判断重复请求

请求方式、请求 URL 地址和请求参数都一样时,我们就可以认为请求是一样的。因此在每次发起请求时,我们就可以根据当前请求的请求方式、请求 URL 地址和请求参数来生成一个唯一的 key,同时为每个请求创建一个专属的 CancelToken,然后把 key 和 cancel 函数以键值对的形式保存到 Map 对象中,使用 Map 的好处是可以快速的判断是否有重复的请求:

1
2
3
4
5
6
7
8
9
10
import qs from 'qs'

const pendingRequest = new Map();
// GET -> params;POST -> data
const requestKey = [method, url, qs.stringify(params), qs.stringify(data)].join('&');
const cancelToken = new CancelToken(function executor(cancel) {
if(!pendingRequest.has(requestKey)){
pendingRequest.set(requestKey, cancel);
}
})

当出现重复请求的时候,我们就可以使用 cancel 函数来取消前面已经发出的请求,在取消请求之后,我们还需要把取消的请求从 pendingRequest 中移除。现在我们已经知道如何取消请求和如何判断重复请求,下面我们来介绍如何取消重复请求。

三、如何取消重复请求

因为我们需要对所有的请求都进行处理,所以我们可以考虑使用 Axios 的拦截器机制来实现取消重复请求的功能。Axios 为开发者提供了请求拦截器和响应拦截器,它们的作用如下:

  1. 请求拦截器:该类拦截器的作用是在请求发送前统一执行某些操作,比如在请求头中添加 token 字段。
  2. 响应拦截器:该类拦截器的作用是在接收到服务器响应后统一执行某些操作,比如发现响应状态码为 401 时,自动跳转到登录页。

流程

四、CancelToken 的工作原理

在前面的示例中,我们是通过调用 CancelToken 构造函数来创建 CancelToken 对象:

1
2
3
4
5
new axios.CancelToken((cancel) => {
if (!pendingRequest.has(requestKey)) {
pendingRequest.set(requestKey, cancel);
}
})

所以接下来,我们来分析 CancelToken 构造函数,该函数被定义在 lib/cancel/CancelToken.js 文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// lib/cancel/CancelToken.js
function CancelToken(executor) {
if (typeof executor !== 'function') {
throw new TypeError('executor must be a function.');
}

var resolvePromise;
this.promise = new Promise(function promiseExecutor(resolve) {
resolvePromise = resolve;
});

var token = this;
executor(function cancel(message) { // 设置cancel对象
if (token.reason) {
return; // Cancellation has already been requested
}
token.reason = new Cancel(message);
resolvePromise(token.reason);
});
}

由以上代码可知,cancel 对象是一个函数,当我们调用该函数后,会创建 Cancel 对象并调用 resolvePromise 方法。该方法执行后,CancelToken 对象上 promise 属性所指向的 promise 对象的状态将变为 resolved。那么这样做的目的是什么呢?这里我们从 lib/adapters/xhr.js 文件中找到了答案:

1
2
3
4
5
6
7
8
9
// lib/adapters/xhr.js 
if (config.cancelToken) {
config.cancelToken.promise.then(function onCanceled(cancel) {
if (!request) { return; }
request.abort(); // 取消请求
reject(cancel);
request = null;
});
}

流程
本文介绍了在 Axios 中如何取消重复请求及 CancelToken 的工作原理,需要注意的是已取消的请求可能已经达到服务端,针对这种情形,服务端的对应接口需要进行幂等控制

参考文章

Axios 如何取消重复请求?

前端上传大文件怎么处理

背景
当我们在做文件的导入功能的时候,如果导入的文件过大,可能会导所需要的时间够长,且失败后需要重新上传,我们需要前后端结合的方式解决这个问题

思路

我们需要做几件事情如下:

  1. 对文件做切片,即将一个请求拆分成多个请求,每个请求的时间就会缩短,且如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始
  2. 通知服务器合并切片,在上传完切片后,前端通知服务器做合并切片操作
  3. 控制多个请求的并发量,防止多个请求同时发送,造成浏览器内存溢出,导致页面卡死
  4. 做断点续传,当多个请求中有请求发送失败,例如出现网络故障、页面关闭等,我们得对失败的请求做处理,让它们重复发送
步骤1-切片,合并切片

在JavaScript中,文件FIle对象是Blob对象的子类,Blob对象包含一个重要的方法slice通过这个方法,我们就可以对二进制文件进行拆分,具体代码如下:

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=s, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script>
</head>
<body>
<input type="file" id="fileInput">
<button id="uploadBtn">上传</button>
</body>
<script>
// 请求基准地址
axios.defaults.baseURL = 'http://localhost:3000'
// 选中的文件
var file = null
// 选择文件
document.getElementById('fileInput').onchange = function({target: {files}}){
file = files[0]
}
// 开始上传
document.getElementById('uploadBtn').onclick = async function(){
if (!file) return
// 创建切片
// let size = 1024 *1024* 10 //10MB 切片大小
let size = 1024 *50 //50KB 切片大小
let fileChunks = []
let index = 0 //切片序号
for(let cur = 0; cur < file.size; cur += size){
fileChunks.push({
hash: index++,
chunk: file.slice(cur, cur + size)
})
}
// 上传切片
const uploadList = fileChunks.map((item, index) => {
let formData = new FormData()
formData.append('filename', file.name)
formData.append('hash', item.hash)
formData.append('chunk', item.chunk)
return axios({
method: 'post',
url: '/upload',
data: formData
})
})
await Promise.all(uploadList)
// 合并切片
await axios({
method: 'get',
url: '/merge',
params: {
filename: file.name
}
});
console.log('上传完成')
}
</script>
</html>
步骤2-并发控制

结合Promise.race和异步函数实现,多个请求同时并发的数量,防止浏览器内存溢出,具体代码如下:

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=s, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script>
</head>
<body>
<input type="file" id="fileInput">
<button id="uploadBtn">上传</button>
</body>
<script>
// 请求基准地址
axios.defaults.baseURL = 'http://localhost:3000'
// 选中的文件
var file = null
// 选择文件
document.getElementById('fileInput').onchange = function({target: {files}}){
file = files[0]
}
// 开始上传
document.getElementById('uploadBtn').onclick = async function(){
if (!file) return
// 创建切片
// let size = 1024*1024*10; //10MB 切片大小
let size = 1024*50 //50KB 切片大小
let fileChunks = []
let index = 0 //切片序号
for(let cur = 0; cur < file.size; cur += size){
fileChunks.push({
hash: index++,
chunk: file.slice(cur, cur + size)
});
}
// 控制并发
let pool = []//并发池
let max = 3 //最大并发量
for(let i=0;i<fileChunks.length;i++){
let item = fileChunks[i]
let formData = new FormData()
formData.append('filename', file.name)
formData.append('hash', item.hash)
formData.append('chunk', item.chunk)
// 上传切片
let task = axios({
method: 'post',
url: '/upload',
data: formData
})
task.then((data)=>{
//请求结束后将该Promise任务从并发池中移除
let index = pool.findIndex(t=> t===task)
pool.splice(index)
})
pool.push(task)
if(pool.length === max){
//每当并发池跑完一个任务,就再塞入一个任务
await Promise.race(pool)
}
}
//所有任务完成,合并切片
await axios({
method: 'get',
url: '/merge',
params: {
filename: file.name
}
});
console.log('上传完成')
}
</script>
</html>
步骤3-断点续传

在单个请求失败后,触发catch的方法的时候,讲当前请求放到失败列表中,在本轮请求完成后,重复对失败请求做处理,具体代码如下:

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=s, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script>
</head>
<body>
<input type="file" id="fileInput">
<button id="uploadBtn">上传</button>
</body>
<script>
// 请求基准地址
axios.defaults.baseURL = 'http://localhost:3000'
// 选中的文件
var file = null
// 选择文件
document.getElementById('fileInput').onchange = function({target: {files}}){
file = files[0]
}
// 开始上传
document.getElementById('uploadBtn').onclick = function(){
if (!file) return;
// 创建切片
// let size = 1024*1024*10; //10MB 切片大小
let size = 1024* 50; //50KB 切片大小
let fileChunks = [];
let index = 0 //切片序号
for(let cur = 0; cur < file.size; cur += size){
fileChunks.push({
hash: index++,
chunk: file.slice(cur, cur + size)
})
}
// 控制并发和断点续传
const uploadFileChunks = async function(list){
if(list.length === 0){
//所有任务完成,合并切片
await axios({
method: 'get',
url: '/merge',
params: {
filename: file.name
}
});
console.log('上传完成')
return
}
let pool = []//并发池
let max = 3 //最大并发量
let finish = 0//完成的数量
let failList = []//失败的列表
for(let i=0;i<list.length;i++){
let item = list[i]
let formData = new FormData()
formData.append('filename', file.name)
formData.append('hash', item.hash)
formData.append('chunk', item.chunk)
// 上传切片
let task = axios({
method: 'post',
url: '/upload',
data: formData
})
task.then((data)=>{
//请求结束后将该Promise任务从并发池中移除
let index = pool.findIndex(t=> t===task)
pool.splice(index)
}).catch(()=>{
failList.push(item)
}).finally(()=>{
finish++
//所有请求都请求完成
if(finish===list.length){
uploadFileChunks(failList)
}
})
pool.push(task)
if(pool.length === max){
//每当并发池跑完一个任务,就再塞入一个任务
await Promise.race(pool)
}
}
}
uploadFileChunks(fileChunks)

}
</script>
</html>

后端

步骤1.安装依赖
1
2
npm i express@4.17.2
npm i multiparty@4.2.2
步骤2.接口实现
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
const express = require('express')
const multiparty = require('multiparty')
const fs = require('fs')
const path = require('path')
const { Buffer } = require('buffer')
// 上传文件最终路径
const STATIC_FILES = path.join(__dirname, './static/files')
// 上传文件临时路径
const STATIC_TEMPORARY = path.join(__dirname, './static/temporary')
const server = express()
// 静态文件托管
server.use(express.static(path.join(__dirname, './dist')))
// 切片上传的接口
server.post('/upload', (req, res) => {
const form = new multiparty.Form();
form.parse(req, function(err, fields, files) {
let filename = fields.filename[0]
let hash = fields.hash[0]
let chunk = files.chunk[0]
let dir = `${STATIC_TEMPORARY}/${filename}`
// console.log(filename, hash, chunk)
try {
if (!fs.existsSync(dir)) fs.mkdirSync(dir)
const buffer = fs.readFileSync(chunk.path)
const ws = fs.createWriteStream(`${dir}/${hash}`)
ws.write(buffer)
ws.close()
res.send(`${filename}-${hash} 切片上传成功`)
} catch (error) {
console.error(error)
res.status(500).send(`${filename}-${hash} 切片上传失败`)
}
})
})
//合并切片接口
server.get('/merge', async (req, res) => {
const { filename } = req.query
try {
let len = 0
const bufferList = fs.readdirSync(`${STATIC_TEMPORARY}/${filename}`).map((hash,index) => {
const buffer = fs.readFileSync(`${STATIC_TEMPORARY}/${filename}/${index}`)
len += buffer.length
return buffer
});
//合并文件
const buffer = Buffer.concat(bufferList, len);
const ws = fs.createWriteStream(`${STATIC_FILES}/${filename}`)
ws.write(buffer);
ws.close();
res.send(`切片合并完成`);
} catch (error) {
console.error(error);
}
})

server.listen(3000, _ => {
console.log('http://localhost:3000/')
})
其他实现

如果使用腾讯云或阿里云文件上传的服务,它们提供了npm库,例如腾讯云的cos-js-sdk-v5,它自身提供的切片相关的配置

参考文章

前端上传大文件怎么处理