# 如何写一个计时器?setTimeout???

# 用普通的 setTimeout 实现

思路:

  1. 每 50ms 创建一个 setTimeout,更新计数器和真实时间
  2. 通过计数器来计算理想时间
  3. 将理想时间和真实时间作比较
let stoptimer
function commonSetTimeout() {
  const speed = 50
  let counter = 1
  const start = Date.now()

  function update() {
    const ideal = counter * speed //理想时间
    counter++
    const real = Date.now() - start //真实时间
    const diff = real - ideal //相差时间
    oideal.value = ideal //DOM理想时间
    oreal.value = real //DOM真实时间
    odiff.value = diff //DOM相差时间
    stoptimer = setTimeout(update, speed)
  }
  stoptimer = setTimeout(update, speed)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

由于每次 setTimeout 的执行时间实际上是大于 speed 的,所以真实时间和理想时间是有误差的

# 用 while 实现无误差

如何实现无误差呢?答案:用 while 循环

function processWhile(time) {
  const start = Date.now()
  while (true) {
    const now = Date.now()
    oreal.value = now - start //DOM真实时间
    oideal.value = now - start //DOM理想时间
    if (now - start >= time) {
      odiff.value = now - start - time //DOM相差时间
      console.log('误差:', now - start - time)
      return
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

由于 js 是单线程,while 会造成页面卡死,所以考虑用 worker 来实现

# 用 worker+while 实现

思路:

  1. 创建一个 worker,当收到 message 时,负责用 while 循环轮询准确时间
  2. 当达到准确时间时向主线程发送消息更新
// worker生成器
function createWorker(fn, options) {
  const blob = new Blob(['(' + fn.toString() + ')()'])
  const url = URL.createObjectURL(blob)
  if (options) {
    return new Worker(url, options)
  }
  return new Worker(url)
}

const worker = createWorker(function() {
  // worker内监听message事件
  onmessage = function(e) {
    const start = Date.now()
    while (true) {
      const now = Date.now()
      if (now - start >= e.data) {
        postMessage(1)
        return
      }
    }
  }
})
let isStart = false
// 主线程向worker发送间隔时间,并监听message事件,当speed时间到的时候,发送下一个间隔时间
function workerWhile() {
  isStart = true
  const speed = 50
  let counter = 1
  const start = Date.now()
  worker.postMessage(speed)

  worker.onmessage = function(e) {
    console.log(e.data)
    oideal.value = counter * speed //DOM理想时间
    counter++
    oreal.value = Date.now() - start //DOM真实时间
    odiff.value = oreal.value - oideal.value //DOM相差时间
    if (isStart) {
      worker.postMessage(speed)
    }
  }
}
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

虽然我们用 Web Worker 修复时间看似被解决了。但是一方面, worker 线程会被 while 给占用,导致无法接受到信息,多个定时器无法同时执行,另一方面,由于 onmessage 还是属于事件循环内,如果主线程有大量阻塞还是会让时间越差越大,因此这并不是个完美的方案。

# 用 requestAnimationFrame 实现 setTimeout

requestAnimationFrame 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行,回调函数执行次数通常是每秒 60 次,也就是每 16.7ms 执行一次,但是并不一定保证为 16.7 ms。

setTimeout2 的实现思路:每 16.7ms 轮询一次,判断是否达到 delay 时间点,达到执行回调函数 cb,然后终止轮询

function setTimeout2(cb, delay) {
  const start = Date.now()
  loop()

  function loop() {
    let now = Date.now()
    if (now - start >= delay) {
      cb()
      return
    }
    requestAnimationFrame(loop)
  }
}

function requestAnimationFrameWhile() {
  let counter = 1
  const speed = 10
  const start = Date.now()
  isStart = true
  function update() {
    const ideal = counter * speed
    counter++
    const real = Date.now() - start
    const diff = real - ideal
    oideal.value = ideal //DOM理想时间
    oreal.value = real //DOM真实时间
    odiff.value = diff //DOM相差时间
    if (isStart) {
      setTimeout2(update, speed)
    }
  }
  setTimeout2(update, speed)
}
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

发现由于 16.7 ms 间隔执行,在使用间隔很小的定时器,很容易导致时间的不准确。

# setTimeout 系统时间补偿

思路:由于普通的 setTimeout 执行时间偏大于 speed,我们可以将下一次 setTimeout 的执行时间适当缩短也就是下一次 setTimeout 的执行时间等于 speed-diff

function 系统时间补偿模式() {
  const speed = 50
  let counter = 1
  const start = Date.now()

  function update() {
    const ideal = counter * speed //理想时间
    counter++
    const real = Date.now() - start //真实时间
    const diff = real - ideal //相差时间
    oideal.value = ideal //DOM理想时间
    oreal.value = real //DOM真实时间
    odiff.value = diff //DOM相差时间
    stoptimer = setTimeout(update, speed - diff)
  }
  stoptimer = setTimeout(update, speed)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

系统时间补偿是最简单也是最有效的方法,推荐使用

# 测试源码

<!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>
  </head>

  <body>
    <p>
      <label for="ideal">理想时间</label>
      <input type="text" name="ideal" id="ideal" />
    </p>
    <p>
      <label for="real">真实时间</label>
      <input type="text" name="real" id="real" />
    </p>
    <p>
      <label for="diff">时间差</label>
      <input type="text" name="diff" id="diff" />
    </p>
    <button id="commonSetTimeout" onclick="commonSetTimeout()"
      >commonSetTimeout</button
    >
    <button id="processWhile" onclick="processWhile(5000)"
      >processWhile(5秒)</button
    >
    <button id="workerWhile" onclick="workerWhile()">workerWhile</button>
    <button
      id="requestAnimationFrameWhile"
      onclick="requestAnimationFrameWhile()"
      >requestAnimationFrameWhile</button
    >
    <button id="系统时间补偿模式" onclick="系统时间补偿模式()"
      >系统时间补偿模式</button
    >
    <button id="stop" onclick="clearTimer()">stop</button>
    <script>
      const oideal = document.getElementById('ideal')
      const oreal = document.getElementById('real')
      const odiff = document.getElementById('diff')
      const ostop = document.getElementById('stop')

      let stoptimer

      // TODO用普通的setTimeout实现
      // 思路:每50ms创建一个setTimeout,更新计数器和真实时间
      // 通过计数器来计算理想时间
      // 将理想时间和真实时间作比较
      function commonSetTimeout() {
        const speed = 50
        let counter = 1
        const start = Date.now()

        function update() {
          const ideal = counter * speed
          counter++
          const real = Date.now() - start
          const diff = real - ideal
          oideal.value = ideal //理想时间
          oreal.value = real //真实时间
          odiff.value = diff
          stoptimer = setTimeout(update, speed)
        }
        stoptimer = setTimeout(update, speed)
      }

      // 由于每次setTimeout的执行时间实际上是大于speed的,所以真实时间和理想时间是有误差的

      // TODO用while实现无误差
      // 如何实现无误差呢?
      // 用while循环
      function processWhile(time) {
        const start = Date.now()
        while (true) {
          const now = Date.now()
          oreal.value = now - start //真实时间
          oideal.value = now - start //理想时间
          if (now - start >= time) {
            odiff.value = now - start - time
            console.log('误差:', now - start - time)
            return
          }
        }
      }

      // 由于js是单线程,while会造成页面卡死
      // 所以考虑用worker来实现

      // TODO用worker+while实现
      // 思路:
      // 创建一个worker,当收到message时,负责用while循环轮询准确时间
      // 当达到准确时间时向主线程发送消息更新

      // worker生成器
      function createWorker(fn, options) {
        const blob = new Blob(['(' + fn.toString() + ')()'])
        const url = URL.createObjectURL(blob)
        if (options) {
          return new Worker(url, options)
        }
        return new Worker(url)
      }

      const worker = createWorker(function() {
        // worker内监听message事件
        onmessage = function(e) {
          const start = Date.now()
          while (true) {
            const now = Date.now()
            if (now - start >= e.data) {
              postMessage(1)
              return
            }
          }
        }
      })
      let isStart = false
      // 主线程向worker发送间隔时间,并监听message事件,当speed时间到的时候,发送下一个间隔时间
      function workerWhile() {
        isStart = true
        const speed = 50
        let counter = 1
        const start = Date.now()
        worker.postMessage(speed)

        worker.onmessage = function(e) {
          console.log(e.data)
          oideal.value = counter * speed //理想时间
          counter++
          oreal.value = Date.now() - start //真实时间
          odiff.value = oreal.value - oideal.value //真实时间
          if (isStart) {
            worker.postMessage(speed)
          }
        }
      }
      // 虽然我们用 Web Worker 修复时间看似被解决了。
      // 但是一方面, worker 线程会被 while 给占用,导致无法接受到信息,多个定时器无法同时执行,
      // 另一方面,由于 onmessage 还是属于事件循环内,如果主线程有大量阻塞还是会让时间越差越大,因此这并不是个完美的方案。

      // TODO用requestAnimationFrame实现setTimeout
      // requestAnimationFrame
      // 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。
      // 该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行,回调函数执行次数通常是每秒60次,也就是每16.7ms 执行一次,但是并不一定保证为 16.7 ms。

      // setTimeout2的实现思路:
      // 每16.7ms轮询一次,判断是否达到delay时间点,达到执行回调函数cb,然后终止轮询

      function setTimeout2(cb, delay) {
        const start = Date.now()
        loop()

        function loop() {
          let now = Date.now()
          if (now - start >= delay) {
            cb()
            return
          }
          requestAnimationFrame(loop)
        }
      }

      function requestAnimationFrameWhile() {
        let counter = 1
        const speed = 10
        const start = Date.now()
        isStart = true
        function update() {
          const ideal = counter * speed
          counter++
          const real = Date.now() - start
          const diff = real - ideal
          oideal.value = ideal //理想时间
          oreal.value = real //真实时间
          odiff.value = diff
          if (isStart) {
            setTimeout2(update, speed)
          }
        }
        setTimeout2(update, speed)
      }
      // 发现由于 16.7 ms 间隔执行,在使用间隔很小的定时器,很容易导致时间的不准确。

      // TODOsetTimeout 系统时间补偿
      // 思路:由于普通的setTimeout执行时间偏大于speed,我们可以将下一次setTimeout的执行时间适当缩短
      // 也就是下一次setTimeout的执行时间等于speed-diff
      function 系统时间补偿模式() {
        const speed = 50
        let counter = 1
        const start = Date.now()

        function update() {
          const ideal = counter * speed
          counter++
          const real = Date.now() - start
          const diff = real - ideal
          oideal.value = ideal //理想时间
          oreal.value = real //真实时间
          odiff.value = diff
          stoptimer = setTimeout(update, speed - diff)
        }
        stoptimer = setTimeout(update, speed)
      }

      function clearTimer() {
        clearTimeout(stoptimer)
        isStart = false
      }
    </script>
  </body>
</html>
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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
最近更新: 4 小时前