最近在看一些优秀文章的时候,关注到了若川,他组织了一个若川视野X源码共读的活动,每周一起学习200行源码,我觉得这是一个非常不错的机会,不管是对于前端新人,还是工作多年的老手,都能够有一个提升。自然而然我也加入到这个活动里面,这是加入此活动的第一篇笔记。

关于手写一个带取消功能的延迟函数,我在两年前的一次面试中遇到过,这算是一个由浅入深的系列问题,从简单的延迟,到随机延迟,再到取消功能和最后的取消请求。当时没能答的很好,这次刚好源码共读第18期就是一个delay函数的实现,借此机会也复习一下相关知识。

知识点

  • 实现一个完整的延迟函数
  • AbortController如何使用
  • 了解Axios取消请求

实现一个完整的延迟函数

我们来模拟一场面试,来学习如何实现一个完整的延迟函数。前提:面试询问了我一些关于Promise的知识。接着面试官说:小呆你好,我想实现一个闹钟,希望可以在任意时间后打印出“起床啦”,但是我希望你能用Promise实现。我心想,这还不容易,您瞧好嘞!

基本功能

1
2
3
4
5
// 面试官想要的效果
(async () => {
await delay(1000)
console.log('起床啦')
})();

既然要用Promise,那delay最简单的实现肯定是返回一个Promise实例对象。延时效果,我们都知道可以用定时器实现,所以我们只需要在Promise的内部,用定时器包裹一下resolve的执行时机即可。

1
2
3
4
5
6
7
const delay = (ms) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, ms)
})
}

给delay函数传递参数

面试官看完后,微微一笑,说道:很不错,那接下来我们希望能够给这个闹钟传入一些参数,比如说名字,并把它作为结果返回,你可以实现吗?我心想,这是开始增加难度了呀,但是这还难不倒我,看我的。

1
2
3
4
5
// 面试官想要的效果
(async () => {
const result = await delay(1000, { name: '小呆', info: '起床啦' });
console.log('输出结果', result);
})();

这里其实也不难,我们之前只传了一个延迟时间进去,这里只需要多传一个对象进去,在定时器结束时,把数据拼好传给resolve就可以了。

1
2
3
4
5
6
7
const delay = (ms, {name, info} = {}) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(`${name}${info}`)
}, ms)
})
}

这时候面试官说,不错不错,但是可以给你这个函数加一个开关么,当开关关闭时,就给个错误提示。

1
2
3
4
5
6
7
8
9
10
// 面试官想要的效果
(async () => {
try {
const result = await delay(1000, { name: '小呆', info: '起床啦', willResolve: false });
console.log('永远不会输出这句');
}
catch(err) {
console.log('输出结果', err);
}
})();

我一想也是,那就加个参数控制一下,如果开关没开,就执行reject,于是有了下面的代码。

1
2
3
4
5
6
7
8
9
10
11
const delay = (ms, { name, info, willResolve = true } = {}) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (willResolve) {
resolve(`${name}${info}`)
} else {
reject(`今天周末,闹钟没开`)
}
}, ms)
})
}

一定时间范围内随机获得结果

面试官看完代码,说:小呆,假如有一天这个闹钟坏了,它会在一定时间范围内随机获得一个延迟时间,然后叫你起床。并且呢,传这个willResolve也挺麻烦,能不能改改,当我调用delay.reject的时候,默认它关闭了,当我不加reject的时候,就默认它开着。我心想:好家伙,这是放出第一个小boss了么,这必然最后要转化为经验值让我升级呀,那咱就磨刀霍霍向delay

1
2
3
4
5
6
7
8
9
10
11
12
13
// 面试官想要的效果
(async() => {
try {
const result = await delay.reject(1000, { name: '小呆', info: '起床啦' });
console.log('永远不会输出这句');
}
catch(err) {
console.log('输出结果', err);
}

const result2 = await delay.range(10, 20000, { name: '小呆', info: '起床啦' });
console.log('输出结果', result2);
})();

想到这里,我们先来实现面试官的第二个要求,将delay分拆成delaydelay.reject。所以这里我们需要对delay进行封装。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const createDelay =
({ willResolve }) =>
(ms, { name, info } = {}) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (willResolve) {
resolve(`${name}${info}`)
} else {
reject(`今天周末,闹钟没开`)
}
}, ms)
})
}

const createWithTimers = () => {
const delay = createDelay({ willResolve: true })
delay.reject = createDelay({ willResolve: false })
return delay
}

const delay = createWithTimers()

通过上面的一番改造,我们已经实现了对delay的拆分,接下来我们实现面试官的第一个要求,在一定范围内获取随机延迟时间。这里考察的其实是生成一定范围的随机数。那我们第一个想到的一定是使用Math.random方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const createDelay =
({ willResolve }) =>
(ms, { name, info } = {}) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (willResolve) {
resolve(`${name}${info}`)
} else {
reject(`今天周末,闹钟没开`)
}
}, ms)
})
}

const randomInteger = (minimum, maximum) => Math.floor(Math.random() * (maximum - minimum + 1) + minimum)

const createWithTimers = () => {
const delay = createDelay({ willResolve: true })
delay.reject = createDelay({ willResolve: false })
delay.range = (minimum, maximum, options) => delay(randomInteger(minimum, maximum), options)
return delay
}

const delay = createWithTimers()

randominteger函数返回了一个包含n和m的一个随机数,有些同学可能对Math.random不太熟悉,Math.random返回0和1之间的伪随机数,这个随机数可能为0,但总是小于1,表示为[0,1)。而Math.random()*N,表示[0,N)之间的随机数。

举个例子:如果我们想要在0-10之间取一个随机数,那Math.random()*10即可。但是如果我们想要一个5-10的随机数(5,6,7,8,9,10),我们就需要通过以下步骤来获得它:

  1. Math.random() * (maximum - minimum),代入上面的例子,得到的是一个0到5(小于5)之间的小数,所以需要+1来包含5
  2. 但我们希望的是一个5到10之间的随机数[5,11),所以我们需要把这个取到的随机数加上最小值,得到一个[5,11)
  3. 这就是Math.random() * (maximum - minimum + 1) + minimum,但此时随机数可能会落到10-11之间,超过了预期[5,10]
  4. 所以我们需要用Math.floor向下取整(干掉小数),最后我们得到Math.floor(Math.random() * (maximum - minimum + 1) + minimum)这个公式

小呆这里画了张图,可以辅助你理解这个公式:

JS取随机数

提示:取随机数的方法不止一个,大家看情况掌握即可。

提前清除

面试官看到这里,微微一笑,说道:小呆,如果突然下暴雨,你的这个闹铃发现需要提前终止计时并立即叫醒你,你能帮我实现这个功能么?我心想,好家伙,这是“人工智铃”啊,那就试着实现一下吧。

1
2
3
4
5
6
7
8
9
10
11
// 面试官想要的效果
(async () => {
const delayedPromise = delay(1000, {name: '小呆', info: '起床啦'});

setTimeout(() => {
delayedPromise.clear();
}, 300);

// 300 milliseconds later
console.log(await delayedPromise); // '小呆,起床啦'
})();

这个功能主要还是考察对定时器的应用,设定和清除。我们接着改造createDealy函数,在函数内部使用变量将定时器和Promise进行封装,同时新增clear方法用于清除定时器。

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
const createDelay =
({ willResolve }) =>
(ms, { name, info } = {}) => {
let timeoutId
let settle

const delayPromise = new Promise((resolve, reject) => {
settle = () => {
if (willResolve) {
resolve(`${name}${info}`)
} else {
reject(`今天周末,闹钟没开`)
}
}
timeoutId = setTimeout(settle, ms)
})

delayPromise.clear = () => {
clearTimeout(timeoutId)
timeoutId = null
settle()
}

return delayPromise
}

const randomInteger = (minimum, maximum) => Math.floor(Math.random() * (maximum - minimum + 1) + minimum)

const createWithTimers = () => {
const delay = createDelay({ willResolve: true })
delay.reject = createDelay({ willResolve: false })
delay.range = (minimum, maximum, options) => delay(randomInteger(minimum, maximum), options)
return delay
}

const delay = createWithTimers()

取消功能

面试官看到这里,满意的点点头,然后问道:有了解过如何取消取消请求吗?小呆一脸懵逼,答道:并没有了解过,还请面试官给我简单介绍一下。面试官接过代码,说道:可以使用AbortController实现取消功能,我来写,你参考一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 面试官想要的效果
(async () => {
const abortController = new AbortController();

setTimeout(() => {
abortController.abort();
}, 500);

try {
await delay(1000, {signal: abortController.signal});
} catch (error) {
// 500 milliseconds later
console.log(error.name)
//=> 'AbortError'
}
})();

然后我就看面试官写下了如下代码:

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
const createAbortError = () => {
const error = new Error('Delay aborted')
error.name = 'AobrtError'
return error
}

const createDelay =
({ willResolve }) =>
(ms, { name, info, signal } = {}) => {
if (signal && signal.aborted) {
return Promise.reject(createAbortError())
}

let timeoutId
let settle
let rejectFn

const signalListener = () => {
clearTimeout(timeoutId)
rejectFn(createAbortError())
}

const cleanup = () => {
if (signal) {
signal.removeEventListener('abort', signalListener)
}
}

const delayPromise = new Promise((resolve, reject) => {
settle = () => {
cleanup()
if (willResolve) {
resolve(`${name}${info}`)
} else {
reject(`今天周末,闹钟没开`)
}
}
rejectFn = reject
timeoutId = setTimeout(settle, ms)
})

if (signal) {
signal.addEventListener('abort', signalListener, { once: true })
}

delayPromise.clear = () => {
clearTimeout(timeoutId)
timeoutId = null
settle()
}

return delayPromise
}

const randomInteger = (minimum, maximum) => Math.floor(Math.random() * (maximum - minimum + 1) + minimum)

const createWithTimers = () => {
const delay = createDelay({ willResolve: true })
delay.reject = createDelay({ willResolve: false })
delay.range = (minimum, maximum, options) => delay(randomInteger(minimum, maximum), options)
return delay
}

const delay = createWithTimers()

自定义clearTimeout和setTimeout函数

最后面试官询问了最后一个问题,能否传递两个参数,来替代默认的clearTimeoutsetTimeout函数。

1
2
3
4
5
6
7
8
9
// 面试官想要的
const customDelay = delay.createWithTimers({clearTimeout, setTimeout});

(async() => {
const result = await customDelay(100, {name: '小呆', info: '起床啦'});

// Executed after 100 milliseconds
console.log(result); // '小呆,起床啦'
})();

这个功能相对来说还是容易实现的,以下就是完整的delay函数代码:

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
const createAbortError = () => {
const error = new Error('Delay aborted')
error.name = 'AobrtError'
return error
}

const createDelay =
({ clearTimeout: defaultClear, setTimeout: set, willResolve }) =>
(ms, { name, info, signal } = {}) => {
if (signal && signal.aborted) {
return Promise.reject(createAbortError())
}

let timeoutId
let settle
let rejectFn
const clear = defaultClear || clearTimeout

const signalListener = () => {
clear(timeoutId)
rejectFn(createAbortError())
}

const cleanup = () => {
if (signal) {
signal.removeEventListener('abort', signalListener)
}
}

const delayPromise = new Promise((resolve, reject) => {
settle = () => {
cleanup()
if (willResolve) {
resolve(`${name}${info}`)
} else {
reject(`今天周末,闹钟没开`)
}
}
rejectFn = reject
timeoutId = (set || setTimeout)(settle, ms)
})

if (signal) {
signal.addEventListener('abort', signalListener, { once: true })
}

delayPromise.clear = () => {
clear(timeoutId)
timeoutId = null
settle()
}

return delayPromise
}

const randomInteger = (minimum, maximum) => Math.floor(Math.random() * (maximum - minimum + 1) + minimum)

const createWithTimers = clearAndSet => {
const delay = createDelay({ ...clearAndSet, willResolve: true })
delay.reject = createDelay({ ...clearAndSet, willResolve: false })
delay.range = (minimum, maximum, options) => delay(randomInteger(minimum, maximum), options)
return delay
}

const delay = createWithTimers()
delay.createWithTimers = createWithTimers

AbortController如何使用

不懂就问,不懂就查,既然AbortController不会,那我们就来了解并学习一下吧。

AbortController接口表示一个控制器对象,允许你根据需要中止一个或多个Web请求。

简单来说,这个东西能中止Web请求,我们可以向面试官那样,通过new AbortController来创建一个AbortController实例。

1
2
const abortController = new AbortController()
console.log(abortController)

AbortController

通过控制台,我们可以观察到,abortController实例有一个signal属性,值是AobrtSignal对象实例,该对象可以根据需要处理DOM请求通信,既可以建立通信,也可以终止通信。当发送一个请求时,我们可以将AobrtSignal作为参数传给请求,这会将signalcontroller与请求相关联,并允许我们通过调用AbortController.abort()去中止它。

AobrtSignal对象有两个属性:

  1. aborted:表示与之通信的请求是否被终止(true)或未终止(false)
  2. reason: 一旦信号被中止,提供一个使用JavaScript值表示中止原因。

我们可以看MDN的一个示例(为了方便学习,去掉了一些显示效果的代码):

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
<!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">
<title>Document</title>
<style>
.wrapper {
width: 70%;
max-width: 800px;
margin: 0 auto;
}
video {
max-width: 100%;
}
.wrapper>div {
margin-bottom: 10px;
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="wrapper">
<h1>Simple offline video player</h1>
<div class="controls">
<button class="download">Download video</button>
<button class="abort hidden">Cancel download</button>
<p class="reports"></p>
</div>
</div>
<script>
const url = 'https://mdn.github.io/dom-examples/abort-api/sintel.mp4';

const downloadBtn = document.querySelector('.download');
const abortBtn = document.querySelector('.abort');
const reports = document.querySelector('.reports');

let controller;

downloadBtn.addEventListener('click', fetchVideo);

abortBtn.addEventListener('click', () => {
controller.abort();
console.log('Download aborted');
downloadBtn.classList.remove('hidden');
});

function fetchVideo() {
controller = new AbortController();
const signal = controller.signal;

downloadBtn.classList.add('hidden');
abortBtn.classList.remove('hidden');
reports.textContent = 'Video awaiting download...';

fetch(url, { signal }).then((response) => {
if (response.status === 200) {
return response.blob();
} else {
throw new Error('Failed to fetch');
}
}).then((myBlob) => {

}).catch((e) => {
abortBtn.classList.add('hidden');
downloadBtn.classList.remove('hidden');
reports.textContent = 'Download error: ' + e.message;
}).finally(() => {
});
}
</script>
</body>
</html>

重点看fetchVideo函数里的关于AbortController的代码:点击加载按钮,会触发fetchVideo,同时将signal传给了fetch请求,通过控制台可以观察到正在加载一个视频。

AbortController

此时点击取消加载按钮,触发了controller.abort(),观察控制台,状态变成了中止标志。同时fetch请求会进入reject,触发catch回调,展示文案发生变化。

AbortController

同时AbortSignal对象的aborted属性也变为了truereason属性展示了请求被终止的原因。

AbortController

这时再回到上面,我们查看面试官写的代码,就很容易理解delay的改动就是为了实现将signalcontrollerPromise相关联,当我们触发AbortController.abort()时,来实现终止当前Promise并将错误信息传入reject

了解Axios取消请求

Axios的取消请求功能有两种实现:

  1. v0.22.0之前,通过传递config配置cancelToken的形式,来实现取消。判断cancelToken参数,在promise链式调用的dispatchRequest抛出错误,在adapterrequest.abort()取消请求,使promise走向rejected,被用户捕获取消信息。
  2. v0.22.0开始,CancelToken被弃用,开始使用AbortController取消请求,也就是我们上文所学到的。

由于这篇文章的重点在于delay函数的实现,关于Axios早期的取消请求,小呆并没有查看源码进行学习。感兴趣的同学可以查看文末若川写的Axios源码文章进行了解和学习。

总结

这篇文章以面试官六连问的小场景,学习了如何从0到1实现一个完整的delay延迟函数,并了解了如何通过AbortController来实现中止Web请求,以及Axios取消请求的实现原理。文章中的面试对话纯属小呆虚构,主要是为了在一个愉悦的心情下学习,请勿较真。

引用

本文参考了以下内容,感谢!

delay70多行源码

面试官:请手写一个带取消功能的延迟函数,axios 取消功能的原理是什么——作者:若川

学习 axios 源码整体架构,取消模块——作者:若川

关于AbortController:MDN Web文档