setTimeout 或者 setInterval,关于 Javascript 计时器:你须要知道的一切都在这里

先来回答一下下面这个问题:对于 setTimeout(function() { console.log('timeout') }, 1000) 这一行代码,你从哪里能够找到 setTimeout 的源代码(一样的问题还会是你从哪里能够看到 setInterval 的源代码)?php

不少时候,能够咱们脑子里面闪过的第一个答案确定是 V8 引擎或者其它 VM们,可是要知道的一点是,全部咱们所见过的 Javascript 计时函数,都没有出如今 ECMAScript 标准中,也没有被任何 Javascript 引擎实现,计时函数,其实都是由浏览器(或者其它运行时,好比 Node.js)实现的,而且,在不一样的运行时下,其表现形式有可能都不一致浏览器

在浏览器中,主计时器函数是 Window 接口的一部分,这保证了包括如 setTimeoutsetInterval 等计时器函数以及其它函数和对象能被全局访问,这才是你能够随时随地使用 setTimeout 的缘由。一样的,在 Node.js 中,setTimeoutglobal 对象的一部分,这拿得你也能够像在浏览器里面同样,随时随地的使用它。async

到如今可能会有一些人感受这个问题其实并无实际的价值,可是做为一个 Javascript 开发者,若是不知道本质,那么就有可能不能彻底的理解 V8 (或者其它VM)是究竟是如何与浏览器或者 Node.js 相互做用的。函数

暂缓一个函数的执行

计时器函数都是更高阶的函数,它们能够用于暂缓一个函数的执行,或者让一个函数重复执行(由他们的第一个参数执行须要执行的函数)。oop

下面这是一个暂缓执行的示例:post

setTimeout(() => {
  console.log('距离函数的调用,已通过去 4 秒了')
}, 4 * 1000)

在上面的示例中, setTimeoutconsole.log 的执行暂缓了 4 * 1000 毫秒,也就是 4 秒钟, setTimeout 的第一个函数,就是须要暂缓执行的函数,它是一个函数的引用,下面这个示例是咱们更加常见到的写法:this

const fn = () => {
  console.log('距离函数的调用,已通过去 4 秒了')
}

setTimeout(fn, 4 * 1000)

传递参数

若是被 setTimeout 暂缓的函数须要接收参数,咱们能够从第三个参数开始添加须要传递给被暂缓函数的参数:code

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) 的整个执行过程:

  1. delay = 4
  2. setTimeout 执行
  3. 4 * 1000 毫秒后, setTimeout 调用 console.log 方法
  4. setTimeout 计算其第三个参数 距离函数的调用,已通过去 ${delay} 秒了 获得 距离函数的调用,已通过去 4 秒了
  5. setTimeout 将计算获得的字符串看成 console.log 的第一个参数
  6. 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 清除掉这个暂缓(将这条任务从队列里面移除)

计时器是没有任何保证的

经过前面的例子,咱们知道了 setTimeoutdelay0 时,并不表示立马就会执行了,它必须等到全部的当前任务(对于一个 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 秒打印一次
  1. 第一秒的时候, log 执行
  2. 第 1300 毫秒时,开始执行 readLargeFileSync 这会须要整整 5 秒钟的时间
  3. 第 2 秒的时候,log 执行时间到了,可是当前任务并无完成,因此,它不会打印
  4. 第 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)

结语

标题是写上了 你须要知道的一切都在这里,可是若是有什么没有考虑到了,欢迎你们指出。

相关文章
相关标签/搜索