最近在看一些优秀文章的时候,关注到了若川,他组织了一个若川视野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
分拆成delay
和delay.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),我们就需要通过以下步骤来获得它:
Math.random() * (maximum - minimum)
,代入上面的例子,得到的是一个0到5(小于5)之间的小数,所以需要+1来包含5
- 但我们希望的是一个5到10之间的随机数[5,11),所以我们需要把这个取到的随机数加上最小值,得到一个[5,11)
- 这就是
Math.random() * (maximum - minimum + 1) + minimum
,但此时随机数可能会落到10-11之间,超过了预期[5,10]
- 所以我们需要用
Math.floor
向下取整(干掉小数),最后我们得到Math.floor(Math.random() * (maximum - minimum + 1) + minimum)
这个公式
小呆这里画了张图,可以辅助你理解这个公式:
提示:取随机数的方法不止一个,大家看情况掌握即可。
提前清除
面试官看到这里,微微一笑,说道:小呆,如果突然下暴雨,你的这个闹铃发现需要提前终止计时并立即叫醒你,你能帮我实现这个功能么?我心想,好家伙,这是“人工智铃”啊,那就试着实现一下吧。
1 2 3 4 5 6 7 8 9 10 11
| (async () => { const delayedPromise = delay(1000, {name: '小呆', info: '起床啦'});
setTimeout(() => { delayedPromise.clear(); }, 300);
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) { console.log(error.name) } })();
|
然后我就看面试官写下了如下代码:
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函数
最后面试官询问了最后一个问题,能否传递两个参数,来替代默认的clearTimeout
和setTimeout
函数。
1 2 3 4 5 6 7 8 9
| const customDelay = delay.createWithTimers({clearTimeout, setTimeout});
(async() => { const result = await customDelay(100, {name: '小呆', info: '起床啦'});
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
实例有一个signal
属性,值是AobrtSignal
对象实例,该对象可以根据需要处理DOM请求通信,既可以建立通信,也可以终止通信。当发送一个请求时,我们可以将AobrtSignal
作为参数传给请求,这会将signal
和controller
与请求相关联,并允许我们通过调用AbortController.abort()
去中止它。
AobrtSignal
对象有两个属性:
aborted
:表示与之通信的请求是否被终止(true)或未终止(false)
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
请求,通过控制台可以观察到正在加载一个视频。
此时点击取消加载按钮,触发了controller.abort()
,观察控制台,状态变成了中止标志。同时fetch
请求会进入reject
,触发catch
回调,展示文案发生变化。
同时AbortSignal
对象的aborted
属性也变为了true
,reason
属性展示了请求被终止的原因。
这时再回到上面,我们查看面试官写的代码,就很容易理解delay
的改动就是为了实现将signal
和controller
与Promise
相关联,当我们触发AbortController.abort()
时,来实现终止当前Promise
并将错误信息传入reject
。
了解Axios取消请求
Axios
的取消请求功能有两种实现:
- 在
v0.22.0
之前,通过传递config
配置cancelToken
的形式,来实现取消。判断cancelToken
参数,在promise
链式调用的dispatchRequest
抛出错误,在adapter
中request.abort()
取消请求,使promise
走向rejected
,被用户捕获取消信息。
- 从
v0.22.0
开始,CancelToken
被弃用,开始使用AbortController
取消请求,也就是我们上文所学到的。
由于这篇文章的重点在于delay
函数的实现,关于Axios
早期的取消请求,小呆并没有查看源码进行学习。感兴趣的同学可以查看文末若川写的Axios
源码文章进行了解和学习。
总结
这篇文章以面试官六连问的小场景,学习了如何从0到1实现一个完整的delay
延迟函数,并了解了如何通过AbortController
来实现中止Web请求,以及Axios
取消请求的实现原理。文章中的面试对话纯属小呆虚构,主要是为了在一个愉悦的心情下学习,请勿较真。
引用
本文参考了以下内容,感谢!
delay70多行源码
面试官:请手写一个带取消功能的延迟函数,axios 取消功能的原理是什么——作者:若川
学习 axios 源码整体架构,取消模块——作者:若川
关于AbortController:MDN Web文档