手写一个带取消功能的延迟函数

644 阅读11分钟

本文首发小呆&小萌的情侣博客,两个前端的学习生活分享小天地,欢迎关注收藏。

前言

最近在看一些优秀文章的时候,关注到了若川,他组织了一个若川视野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分拆成delaydelay.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),我们就需要通过以下步骤来获得它:

  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取随机数

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

提前清除

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

// 面试官想要的效果
(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函数

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

// 面试官想要的
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

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

AobrtSignal对象有两个属性:

  1. aborted:表示与之通信的请求是否被终止(true)或未终止(false)
  2. 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请求,通过控制台可以观察到正在加载一个视频。

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文档