setTimeout 或者 setInterval,关于 Javascript 计时器:你需要知道的一切都在这里
先来回答一下下面这个问题:对于 setTimeout(function() { console.log('timeout') }, 1000)
这一行代码,你从哪里可以找到 setTimeout
的源代码(同样的问题还会是你从哪里可以看到 setInterval
的源代码)?
很多时候,可以我们脑子里面闪过的第一个答案肯定是 V8 引擎或者其它 VM们,但是要知道的一点是,所有我们所见过的 Javascript 计时函数,都没有出现在 ECMAScript 标准中,也没有被任何 Javascript 引擎实现,计时函数,其实都是由浏览器(或者其它运行时,比如 Node.js)实现的,并且,在不同的运行时下,其表现形式有可能都不一致。
在浏览器中,主计时器函数是 Window
接口的一部分,这保证了包括如 setTimeout
、setInterval
等计时器函数以及其它函数和对象能被全局访问,这才是你可以随时随地使用 setTimeout
的原因。同样的,在 Node.js 中,setTimeout
是 global
对象的一部分,这拿得你也可以像在浏览器里面一样,随时随地的使用它。
到现在可能会有一些人感觉这个问题其实并没有实际的价值,但是作为一个 Javascript 开发者,如果不知道本质,那么就有可能不能完全的理解 V8 (或者其它VM)是到底是如何与浏览器或者 Node.js 相互作用的。
暂缓一个函数的执行
计时器函数都是更高阶的函数,它们可以用于暂缓一个函数的执行,或者让一个函数重复执行(由他们的第一个参数执行需要执行的函数)。
下面这是一个暂缓执行的示例:
setTimeout(() => { console.log('距离函数的调用,已经过去 4 秒了') }, 4 * 1000)
在上面的示例中, setTimeout
将 console.log
的执行暂缓了 4 * 1000
毫秒,也就是 4 秒钟, setTimeout
的第一个函数,就是需要暂缓执行的函数,它是一个函数的引用,下面这个示例是我们更加常见到的写法:
const fn = () => { console.log('距离函数的调用,已经过去 4 秒了') } setTimeout(fn, 4 * 1000)
传递参数
如果被 setTimeout
暂缓的函数需要接收参数,我们可以从第三个参数开始添加需要传递给被暂缓函数的参数:
const fn = (name, gender) => { console.log(`I'm ${name}, I'm a ${gender}`) } setTimeout(fn, 4 * 1000, 'Tao Pan', 'male')
上面的 setTimeout
调用,其结果与下面这样调用类似:
setTimeout(() => { fn('Tao Pan', 'male') }, 4 * 1000)
但是记住,只是结果类似,本质上是不一样的,我们可以用伪代码来表示 setTimeout
的函数实现:
const setTimeout = (fn, delay, ...args) => { wait(delay) // 这里表示等待 delay 指定的毫秒数 fn(...args) }
挑战一下
编写一个函数:
- 当
delay
为 4 秒的时候,打印出:距离函数的调用,已经过去 4 秒了 - 当
delay
为 8 秒的时候,打印出:距离函数的调用,已经过去 8 秒了 - 当
delay
为 N 秒的时候,打印出:距离函数的调用,已经过去 N 秒了
下面这个是我的一个实现:
const delayLog = delay => { setTimeout(console.log, delay * 1000, `距离函数的调用,已经过去 ${delay} 秒了`) } delayLog(4) // 输出:距离函数的调用,已经过去 4 秒了 delayLog(8) // 输出:距离函数的调用,已经过去 8 秒了
我们来理一下 delayLog(4)
的整个执行过程:
delay = 4
setTimeout
执行4 * 1000
毫秒后,setTimeout
调用console.log
方法setTimeout
计算其第三个参数距离函数的调用,已经过去 ${delay} 秒了
得到距离函数的调用,已经过去 4 秒了
setTimeout
将计算得到的字符串当作console.log
的第一个参数console.log('距离函数的调用,已经过去 4 秒了')
执行,输出结果
规律性重复一个函数的执行以及停止重复调用
如果我们现在要每 4 秒第印一次呢?这里面就有很多种实现方式了,假如我们还是使用 setTimeout
来实现,我们可以这样做:
const loopMessage = delay => { setTimeout(() => { console.log('这里是由 loopMessage 打印出来的消息') loopMessage(delay) }, delay * 1000) } loopMessage(1) // 此时,每过 1 秒钟,就会打印出一段消息:*这里是由 loopMessage 打印出来的消息*
但是这样有一个问题,就是开始之后,我们就没有办法停止,怎么办?可以稍稍改改实现:
let loopMessageTimer const loopMessage = delay => { loopMessageTimer = setTimeout(() => { console.log('这里是由 loopMessage 打印出来的消息') loopMessage(delay) }, delay * 1000) } loopMessage(1) clearTimeout(loopMessageTimer) // 我们随时都可以使用 `clearTimeout` 清除这个循环
但是这样还是有问题的,如果 loopMessage
被调用多次,那么他们将共用一个 loopMessageTimer
,清除一个,将清除所有,这是肯定不行的,所以,还得再改造一下:
const loopMessage = delay => { let timer const log = () => { timer = setTimeout(() => { console.log(`每 ${delay} 秒打印一次`) log() }, delay * 1000) } log() return () => clearTimeout(timer) } const clearLoopMessage = loopMessage(1) const clearLoopMessage2 = loopMessage(1.5) clearLoopMessage() // 我们在任何时候都可以取消任何一个重复调用,而不影响其它的
这…… 实现是实现了,但是其它有更好的解决办法:
const timer = setInterval(console.log, 1000, '每 1 秒钟打印一次') clearInterval(timer) // 随时可以 `clearInterval` 清除
更加深入了认识取消计时器(Cancel Timers)
上面的示例只是简单的给我们展现了 setTimeout
以及 setInterval
,也看到了,我们可以通过 clearTimeout
或者 clearInterval
取消计时器,但是关于计时器,远远不止这点知识,请看下面的代码(请):
const cancelImmediate = () => { const timerId = setTimeout(console.log, 0, '暂缓了 0 秒执行') clearTimeout(timerId) } cancelImmediate() // 这里并不会有任何输出
或者看下面这样的代码:
const cancelImmediate2 = () => setTimeout(console.log, 0, '暂缓了 0 秒执行') const timerId = cancelImmediate2() clearTimeout(timerId)
请将上面的的任一代码片段同时复制到浏览器的控制台中(有多行复制多行)执行,你会发现,两个代码片段都没有任何输出,这是为什么?
这是因为,Javascript 的运行机制导致,任何时刻都只能存在一个任务在进行,虽然我们调用的是暂缓 0 秒,但是,由于当前的任务还没有执行完成,所以,setTimeout
中被暂缓的函数即使时间到了也不会被执行,必须等到当前的任务完全执行完成,那么,再试着,上面的代码分行复制到控制台,看看结果是不是会打印出 暂缓了 0 秒执行 了?答案是肯定的。
当你一行一行复制执行的时候, cancelImmediate2
执行完成之后,当前任务就已经全部执行完成了,所以开始执行下一个任务(console.log
开始执行)。
从上面的示例中,我们可以看出,setTimeout
其实是将一个任务安排进一个 Javascript 的任务队列里面去,当前面的所有任务都执行完成之后,如果这个任务时间到了,那么就立即执行,否则,继续等待计时结束。
此时,你应该发现,只要是 setTimeout
所暂缓的函数没有被执行(任务还没有完成),那么,我们就可以随时使用 clearTimeout
清除掉这个暂缓(将这条任务从队列里面移除)
计时器是没有任何保证的
通过前面的例子,我们知道了 setTimeout
的 delay
为 0
时,并不表示立马就会执行了,它必须等到所有的当前任务(对于一个 JS 文件来讲,就是需要执行完当前脚本中的所有调用)执行完成之后都会执行,而这里面就包括我们调用的 clearTimeout
。
下面用一个示例来更清楚了说明这个问题:
setTimeout(console.log, 1000, '1 秒后执行的') // 开始时间 const startTime = new Date() // 距离开始时间已经过去几秒 let secondsPassed = 0 while (true) { // 距离开始时间的毫秒数 const duration = new Date() - startTime // 如果距离开始时间超过 5000 毫秒了, 则终止循环 if (duration > 5000) { break } else { // 如果距离开始时间增长一秒,更新 secondsPassed if (Math.floor(duration / 1000) > secondsPassed) { secondsPassed = Math.floor(duration / 1000) console.log(`已经过去 ${secondsPassed} 秒了。`) } } }
你们猜上面这段代码会有什么样的输出?是下面这样的吗?
1 秒后执行的 已经过去 1 秒了。 已经过去 2 秒了。 已经过去 3 秒了。 已经过去 4 秒了。 已经过去 5 秒了。
并不是这样的,而是下面这样的:
已经过去 1 秒了。 已经过去 2 秒了。 已经过去 3 秒了。 已经过去 4 秒了。 已经过去 5 秒了。 1 秒后执行的
怎么会这样?这是因为 while(true)
这个循环必须要执行超过 5 秒钟的时间之后,才算当前所有任务完成,在它 break
之前,其它所有的操作都是没有用的,当然,我们不会在开发的过程中去写这样的代码,但是并不表示就不存在这样的情况,想象以下下面这样的场景:
setTimeout(somethingMustDoAfter1Seconds, 1000) openFileSync('file more then 1gb')
这里面的 openFileSync
只是一个伪代码,它表示我们需要同步进行一个特别费时的操作,这个操作很有可能会超过 1 秒,甚至更长的时间,但是上面那个 somethingMustDoAfter1Seconds
将一直处于挂起状态,只要这个操作完成,它才有可能执行,为什么叫有可能?那是因为,有可能还有别的任务又会占用资源。所以,我们可以将 setTimeout
理解为:计时结束是执行任务的必要条件,但是不是任务是否执行的决定性因素。
setTimeout(somethingMustDoAfter1Seconds, 1000)
的意思是,必须超过 1000 毫秒后,somethingMustDoAfter1Seconds
才允许执行。
再来一个小挑战
那如果我需要每一秒钟都打印一句话怎么办?从上面的示例中,已经很明显的看到了,setTimeout
是肯定解决不了这个问题了,不信我们可以试试下面这个代码片段:
const log = (delay) => { timer = setTimeout(() => { console.log(`每 ${delay} 秒打印一次`) log(delay) }, delay * 1000) } log(1)
上面的代码是没有任何问题的,在浏览器的控制台观察,你会发现确实每一秒钟都打印了一行,但是再试试下面这样的代码:
const log = (delay) => { timer = setTimeout(() => { console.log(`每 ${delay} 秒打印一次`) log(delay) }, delay * 1000) } const readLargeFileSync = () => { // 开始时间 const startTime = new Date() // 距离开始时间已经过去几秒 let secondsPassed = 0 while (true) { // 距离开始时间的毫秒数 const duration = new Date() - startTime // 如果距离开始时间超过 5000 毫秒了, 则终止循环 if (duration > 5000) { break } else { // 如果距离开始时间增长一秒,更新 secondsPassed if (Math.floor(duration / 1000) > secondsPassed) { secondsPassed = Math.floor(duration / 1000) console.log(`已经过去 ${secondsPassed} 秒了。`) } } } } log(1) setTimeout(readLargeFileSync, 1300)
输出结果是:
每 1 秒打印一次 已经过去 1 秒了。 已经过去 2 秒了。 已经过去 3 秒了。 已经过去 4 秒了。 已经过去 5 秒了。 每 1 秒打印一次
- 第一秒的时候,
log
执行 - 第 1300 毫秒时,开始执行
readLargeFileSync
这会需要整整 5 秒钟的时间 - 第 2 秒的时候,
log
执行时间到了,但是当前任务并没有完成,所以,它不会打印 - 第 5 秒的时候,
readLargeFileSync
执行完成了,所以log
继续执行
关于这个具体怎么实现,就不在本文讨论了
最终,到底是谁在调用那个被暂缓的函数?
当我们在一个 function
中调用 this
时,this
关键字会指向当前函数的 caller
:
function whoCallsMe() { console.log('My caller is: ', this) }
当我们在浏览器的控制台中调用 whoCallsMe
时,会打印出 Window
,当在 Node.js 的 REPL 中执行时,会执行出 global
,如果我们将 whoCallsMe
设置为一个对象的属性:
function whoCallsMe() { console.log('My caller is: ', this) } const person = { name: 'Tao Pan', whoCallsMe } person.whoCallsMe()
这会打印出:My caller is: Object { name: "Tao Pan", whoCallsMe: whoCallsMe() }
那么?
function whoCallsMe() { console.log('My caller is: ', this) } const person = { name: 'Tao Pan', whoCallsMe } setTimeout(person.whoCallsMe, 0)
这会打印出什么?这个很容易被忽视的问题,其实真的值得我们去思考。
请直接将上面这个代码片段复制进浏览器的控制台,看执行的结果:
My caller is: Window https://pantao.parcmg.com/admin/write-post.php?cid=2952
再打开系统终端,进入 Node.js REPL 中,执行同样的代码,看执行结果:
My caller is: Timeout { _idleTimeout: 1, _idlePrev: null, _idleNext: null, _idleStart: 7052, _onTimeout: [Function: whoCallsMe], _timerArgs: undefined, _repeat: null, _destroyed: false, [Symbol(refed)]: true, [Symbol(asyncId)]: 221, [Symbol(triggerId)]: 5 }
回到这句话:当我们在一个 function
中调用 this
时,this
关键字会指向当前函数的 caller
,当我们使用 setTimeout
时,这个 caller
是跟当前的运行时有关系的,如果我想 this
总是指向 person
对象呢?
function whoCallsMe() { console.log('My caller is: ', this) } const person = { name: 'Tao Pan' } person.whoCallsMe = whoCallsMe.bind(person) setTimeout(person.whoCallsMe, 0)
结语
标题是写上了 你需要知道的一切都在这里,但是如果有什么没有考虑到了,欢迎大家指出。