本文首发小呆&小萌的情侣博客,两个前端的学习生活分享小天地,欢迎关注收藏。
前言
最近在看一些优秀文章的时候,关注到了若川,他组织了一个若川视野X源码共读的活动,每周一起学习200行源码,我觉得这是一个非常不错的机会,不管是对于前端新人,还是工作多年的老手,都能够有一个提升。自然而然我也加入到这个活动里面,这是加入此活动的第一篇笔记。
关于手写一个带取消功能的延迟函数,我在两年前的一次面试中遇到过,这算是一个由浅入深的系列问题,从简单的延迟,到随机延迟,再到取消功能和最后的取消请求。当时没能答的很好,这次刚好源码共读第18期就是一个delay函数的实现,借此机会也复习一下相关知识。
知识点
- 实现一个完整的延迟函数
AbortController如何使用- 了解
Axios取消请求的
实现一个完整的延迟函数
我们来模拟一场面试,来学习如何实现一个完整的延迟函数。前提:面试询问了我一些关于Promise的知识。接着面试官说:小呆你好,我想实现一个闹钟,希望可以在任意时间后打印出“起床啦”,但是我希望你能用Promise实现。我心想,这还不容易,您瞧好嘞!
基本功能
// 面试官想要的效果
(async () => {
await delay(1000)
console.log('起床啦')
})();
既然要用Promise,那delay最简单的实现肯定是返回一个Promise实例对象。延时效果,我们都知道可以用定时器实现,所以我们只需要在Promise的内部,用定时器包裹一下resolve的执行时机即可。
const delay = (ms) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, ms)
})
}
给delay函数传递参数
面试官看完后,微微一笑,说道:很不错,那接下来我们希望能够给这个闹钟传入一些参数,比如说名字,并把它作为结果返回,你可以实现吗?我心想,这是开始增加难度了呀,但是这还难不倒我,看我的。
// 面试官想要的效果
(async () => {
const result = await delay(1000, { name: '小呆', info: '起床啦' });
console.log('输出结果', result);
})();
这里其实也不难,我们之前只传了一个延迟时间进去,这里只需要多传一个对象进去,在定时器结束时,把数据拼好传给resolve就可以了。
const delay = (ms, {name, info} = {}) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(`${name},${info}`)
}, ms)
})
}
这时候面试官说,不错不错,但是可以给你这个函数加一个开关么,当开关关闭时,就给个错误提示。
// 面试官想要的效果
(async () => {
try {
const result = await delay(1000, { name: '小呆', info: '起床啦', willResolve: false });
console.log('永远不会输出这句');
}
catch(err) {
console.log('输出结果', err);
}
})();
我一想也是,那就加个参数控制一下,如果开关没开,就执行reject,于是有了下面的代码。
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。
// 面试官想要的效果
(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进行封装。代码如下:
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方法。
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)这个公式
小呆这里画了张图,可以辅助你理解这个公式:
提示:取随机数的方法不止一个,大家看情况掌握即可。
提前清除
面试官看到这里,微微一笑,说道:小呆,如果突然下暴雨,你的这个闹铃发现需要提前终止计时并立即叫醒你,你能帮我实现这个功能么?我心想,好家伙,这是“人工智铃”啊,那就试着实现一下吧。
// 面试官想要的效果
(async () => {
const delayedPromise = delay(1000, {name: '小呆', info: '起床啦'});
setTimeout(() => {
delayedPromise.clear();
}, 300);
// 300 milliseconds later
console.log(await delayedPromise); // '小呆,起床啦'
})();
这个功能主要还是考察对定时器的应用,设定和清除。我们接着改造createDealy函数,在函数内部使用变量将定时器和Promise进行封装,同时新增clear方法用于清除定时器。
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实现取消功能,我来写,你参考一下。
// 面试官想要的效果
(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'
}
})();
然后我就看面试官写下了如下代码:
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函数。
// 面试官想要的
const customDelay = delay.createWithTimers({clearTimeout, setTimeout});
(async() => {
const result = await customDelay(100, {name: '小呆', info: '起床啦'});
// Executed after 100 milliseconds
console.log(result); // '小呆,起床啦'
})();
这个功能相对来说还是容易实现的,以下就是完整的delay函数代码:
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实例。
const abortController = new AbortController()
console.log(abortController)
通过控制台,我们可以观察到,abortController实例有一个signal属性,值是AobrtSignal对象实例,该对象可以根据需要处理DOM请求通信,既可以建立通信,也可以终止通信。当发送一个请求时,我们可以将AobrtSignal作为参数传给请求,这会将signal和controller与请求相关联,并允许我们通过调用AbortController.abort()去中止它。
AobrtSignal对象有两个属性:
aborted:表示与之通信的请求是否被终止(true)或未终止(false)reason: 一旦信号被中止,提供一个使用JavaScript值表示中止原因。
我们可以看MDN的一个示例(为了方便学习,去掉了一些显示效果的代码):
<!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取消请求的实现原理。文章中的面试对话纯属小呆虚构,主要是为了在一个愉悦的心情下学习,请勿较真。
引用
本文参考了以下内容,感谢!