This article is an English version of "一个 “倒计时” 引发的思考".
foreword
Recently, the product I am involved in needs a countdown function to lead our users to do something. Because the scene does not require very high time accuracy, so I just use setTimeout to implement.
If we need higher accuracy? For example:
- countdown for a test
- promotions at one point in time(such as double 11 on taobao)
Just when I was about to look up relevant information online, I suddenly remembered that I once asked a similar question on Zhihu:(During that time, I wanted to do some research deeply. Finally I didn't continue it because of my laziness)
zhihu link: www.zhihu.com/question/29…
Base on all kinds of infomation on the Internet, and combined with the answers of the bigwigs in the above posts, I have some new thoughts.
Below I will use these aspects to "analyze/answer" the problems, ideas, and solutions that I personally think are more appropriate:
- What are the problems with
setTimeoutandsetInterval? - Does requestAnimationFrame solve the problem?
- Is "current time" really "current time" ?
- How to choose between front-end perspective and back-end perspective ?
- Can you continue to optimize ?
- Implement a simple chrome extension: used to display how much time is left under the current active tab (assuming you set a countdown of 60 minutes)
- Summarize
setTimeout Or setInterval
For the countdown scene, we will think about using setInterval because of it can implement the function simply under normal conditions.
let restTime = 60 * 60 * 1000 // suppose the countdown is 1 hour
/**
* countdown operation
*/
function countdownOperate() {
restTime -= 1000
}
setInterval(countdownOperate, 1000)
The above code looks so consice and clear, but if this happens?
let count = 0
let startTime = new Date()
let preTime = new Date()
let nowTime
/**
* interval callback
*/
function intervalHandler() {
if (count === 20) {
return
}
if (count === 0) {
for (let i = 0; i < 1000000000; i++) {}
}
nowTime = new Date()
console.log(
`How many milliseconds have passed since the previous time: ${nowTime - preTime}`,
`How many milliseconds have passed since the start time: ${nowTime - startTime}`,
`value of variable 'count' is: ${count}`,
`real value of variable 'count' is: ${Math.floor((new Date() - startTime) / 100) - 1}`)
preTime = nowTime
count++
}
setInterval(intervalHandler, 100)
We excute it on the chrome console and the result is as follows:
The above data is not intuitive enough, let's list it through table:
| nowTime - previousTime | nowTime - startTime | nowCount | realCount | |
|---|---|---|---|---|
| 01 | 860 | 860 | 0 | 7 |
| 02 | 0 | 860 | 1 | 7 |
| 03 | 42 | 902 | 2 | 8 |
| 04 | 102 | 1004 | 3 | 9 |
| 05 | 96 | 1100 | 4 | 10 |
| 06 | 101 | 1201 | 5 | 11 |
| 07 | 101 | 1302 | 6 | 12 |
| 08 | 102 | 1404 | 7 | 13 |
| 09 | 100 | 1504 | 8 | 14 |
| 10 | 96 | 1600 | 9 | 15 |
| 11 | 102 | 1702 | 10 | 16 |
| 12 | 102 | 1804 | 11 | 17 |
| 13 | 97 | 1901 | 12 | 18 |
| 14 | 100 | 2001 | 13 | 19 |
| 15 | 101 | 2102 | 14 | 20 |
| 16 | 99 | 2201 | 15 | 21 |
| 17 | 100 | 2301 | 16 | 22 |
| 18 | 99 | 2400 | 17 | 23 |
| 19 | 101 | 2501 | 18 | 24 |
| 20 | 100 | 2601 | 19 | 25 |
Looking at the above data, starting from the 4th callback, the time interval is relatively stable, around 100ms, but the real count is always 6 smaller than expected (that is, 6 callbacks slower), why is this happening? Let's analyse it with the following diagram:
The first callback is pushed into the execution queue at 100 milliseconds. Because of the empty queue, callback01 is excuted immediately.
callback01 tooks 860 milliseconds. Although callback02 is pushed into the queue at 200 milliseconds, but callback01 is still executing. callback02 can't execute util callback01 is executed.
This is also the reason why there is always 6 gaps behind.
Besides, we found thatcallback 03 - 08 were skipped, why is that?
When using setInterval, the timer callback is pushed into the queue only if there are no other callback instances in the queue.
Quoted from: segmentfault.com/a/119000001… - "Seeing JS threads from setTimeout-setInterval"
In conlusion, setInterval has the following problems:
- Single-threaded effect, the callback of
setIntervalis not sure when it can be executed, and the time interval is only the time when the task is pushed into the execution queue, not the actual execution time. (actually the same forsetTimeout) - If there is a waiting task in the current execution queue (the task is also created by the current setInterval), other tasks triggered at this time will be skipped.
BillAnn: Aside from the first point above, we can just use
setTimeout.Spectators:
BillAnn: Don't believe? Just read the document: developer.mozilla.org/en-US/docs/…
What is this?...
Let's continue. developer.mozilla.org/en-US/docs/…
From the documentation we see the following factors that affect execution:
- Nested timeouts: As specified in the HTML standard, browsers will enforce a minimum timeout of 4 milliseconds once a nested call to
setTimeouthas been scheduled 5 times. - Timeouts in inactive tabs: To reduce the load (and associated battery usage) from background, browsers will enforce a minimum timeout delay in inactive tabs. It may also be waived if a page is playing sound using a web Audio API AudioContext.
- Throttling of trackinng scripts: Firefox enforces additional throttling for scripts that it recognizes as tracking scripts. When running in the foreground, the throttling minimum delay is still 4ms. In background tabs, however, the throttling minimum delay is 10,000 ms, or 10 seconds, which comes into effect 30 seconds after a document has first loaded.
- Extensions Limit: For extensions in firefox,
setTimeoutalso does not perform as expected. Developers should use the alarms API instead.
requestAnimationFrame
requestAnimationFrame(callback)
RequestAnimationFrame will execution the callback before the browser redraws next time. According to the screen refresh rate (usually 60fps), intervals is usually 1/60 s (16.6 ms).
Taking advantage of this feature, we can add a counter to the callback for counting (default is 0). Every time the callback executes, counter is added 1 util counter value is 60. Then counter is reset to 0 and the seconds are decremented by 1.
The code might look like this:
let restTime = 60 * 60 * 1000
let counter = 0
function step () {
counter++
if (counter === 60) {
counter = 0
restTime -= 1000
}
if (restTime !== 0) {
window.requestAnimationFrame(step)
}
}
window.requestAnimationFrame(step)
BillAnn: The code is as simple as that.
Spectators:
BillAnn: What's the problem?
Sepectors:
- Are the screen refresh rates all 60 fps?
- There is a task still executing when the callback is pushed into the queue. Code above is not compatible with this scenario.
BillAnn:
Let's go over it again, just recalculate the time every time the callback is executed, right?
let restTime = 60 * 60 * 1000
const totalTime = restTime
const startTime = new Date()
function step () {
const currentRestTime = totalTime - (new Date() - startTime)
restTime = currentRestTime <= 0 ? 0 : currentRestTime
if (restTime > 0) {
window.requestAnimationFrame(step)
}
}
window.requestAnimationFrame(step)
Sepectors: That way, you don't have to count dozens of times a second... I might as well do it with
setTimeout.BillAnn: Aside from some unusually time-consuming invocation scenarios,
setTimeoutdoes seem to be "more performance efficient" thanrequestAnimationFrame.But with the current equipment's performance, you don't need to worry about it. And don't forget the disadvantage of
setTimeout: its' callback will still execute under the inactive tab.
requestAnimationFrameis completely different. When the page is in an inactive state, the screen refresh task of the page will be suspended by system.In a adition, it is executed every time the screen is refershed. Immediateness and fluency would be better.
For a more intuitive description, I will give some pictures.
Sepectors:
Is "current time" really "current time"?
In the countdown scene, our first reaction is thatnew Date() is the starting point. Is this "starting point" accurate?
new Date() is taken from the system time of the current device, so if we manually changed the time, the "starting point" would be inaccurate.
Since the system time of the current device is unreliable, should I use the time of the backend server directly?
"Indeed, in order to prevent users from manually changing the system time, we can just use the backend time uniformly."
The idea is good, but the reality is cruel. Because of the time costs during response, the time in response body is slower than actual time.
For this problem, is there a solution? Can we get the response cost? At present, there is no relevant method to get the request time-consuming, although we can see the cost in the browser's developer tools...
Front-end perspective & back-end perspective, how to choose?
Earlier, we analyzed the accuracy of the "current time". Whether it is from the front end or back end, there will be some degree of error:
- Inacurracy in back end depends on the request time time-consuming. If the network signal is slow, the error will be large, and if the signal is strong, the error will be small.
- Inacurracy in front end depends on the user-defined system time.
Therefore, the error fluctuations at both ends cannot be determined. And the front end will be more uncontrollable (the system time can be set arbitrarily).
Sepectors: After listening to your nonsense for a long time, please just say what to do.
BillAnn: Don't worry, it's not necessary to understand the cause and effect.
There is definitely a way, but let's determine which time to use first.
Personally, I think the following strategies can be adopted:
-
Most users will not change the system time of their devices, so we can give priority to the front-end time.
-
In order to prevent users from changing the system time, we also require the backend to return its' time. Then we can set a time differentcem. If the frontend time minus the backend time is less than or equal to 1000, then we determine that the frontend time is ok. Then we can use the front-end time, otherwise we use the back-end time.
-
In most scenarios, the above two steps can already meet our needs. But what if it is a scenario like "countdown to answer questions", "grabing coupons at one point in time" and so on ? What should we do in this situation?
- Based on the first two strategies, the error can be controlled in seconds for a scenario like "countdown to answer questions". (Actually, there's nothing wrong with this type of countdown being a few more seconds or less.)
- In the case of "grabing coupons at one point in time", we will first send a request to the backend when we receive coupons. If the backend logic finds that the time has not yet arrived, it can remain in the pending state util the time is up and then return. If the time is up, backend just return the response directly. (That is, adding a back-end verification logic.)
Can we continue to optimize?
After the above analysis, in fact, we have basically solved all the problems. Let's take a look at the remaining problems.
- A fact has to be accepted: the frequency of requestAnimationFrame calls is indeed still a bit high.
In order to solve the above problems, there is actually an idea: use web workers.
<!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>countdown</title>
</head>
<body>
<h2>countdown:</h2>
<p id="countdownBox"></p>
<script src="./index2.js"></script>
</body>
</html>
window.onload = function () {
const countdownBox = document.getElementById('countdownBox')
const workerInstance = new Worker('./worker2.js')
workerInstance.addEventListener('message', function (workerMessage) {
const currentTime = new Date()
const timeMsg = `${currentTime.getHours()}时${currentTime.getMinutes()}分${currentTime.getSeconds()}秒 ${currentTime.getMilliseconds()}毫秒`
countdownBox.innerText = `${workerMessage.data}\n${timeMsg}`
})
workerInstance.postMessage('start')
}
self.requestAnimationFrameInstance = null
self.count = 0
self.timeAction = function () {
self.count++
if (self.count === 60) {
const currentTime = new Date()
const timeMsg = `${currentTime.getHours()}时${currentTime.getMinutes()}分${currentTime.getSeconds()}秒 ${currentTime.getMilliseconds()}毫秒`
self.postMessage(timeMsg)
self.count = 0
}
cancelAnimationFrame(self.requestAnimationFrameInstance)
self.requestAnimationFrameInstance = requestAnimationFrame(self.timeAction)
}
self.addEventListener('message', function (webMessage) {
if (webMessage.data === 'start') {
self.requestAnimationFrameInstance = requestAnimationFrame(self.timeAction)
}
})
The result is as follows:
(gif picture above is a little slower than it actually is)
The figure above shows that:
- the difference of the web worker is basically 0-1 ms, with an occasional error of 2 ms.
Sepectors:
requestAnimationFramestill executed many times...BillAnn: Don't worry about it. Now we start a new thread through the web worker, the content executed in the thread will not affect the execution of our front-end code.
This is like, before one person A "was going to peel the peanuts and put the peanut kernels into the basket", now there are two people A and B together, "B is responsible for peeling the peanuts and A only needs to put the peanut kernels into the basket."
Sepectors: oh oh oh
BillAnn: Of course, we have further optimized the code and only after one second will it rerender the page. (This is just for simple demonstration, so it is performanced at the standard of "60fps")
Do we have to use requestAnimationFrame? The answer is definitely no. For daily development scenarios, it is actually a good choice for us to use setTimeout and setInterval in workers.(There are only our loop logics in the worker thread, so that setInterval will no longer be blocked by other js tasks and causing callback not to be pushed into taskqueue.)
In addition, in order to ensure accuracy, it's actually very simple:
- Under normal circumstances (when the page tab is in the active state, there is no time-consuming js task), there is basically no differences in
setTimeoutandsetIntervaland the error is so small. - When the page tab is in the inactive state, there will be a delay. But we don't have to care about the callbacks of
setTimeoutandsetInterval. After all, we can't see the page at all. - Considering the two situations of "inactive => active" and "blocking of time-consuming js tasks", in order to ensure the accuracy, we need to adjust the time precision in the callback.
In fact, there is still a problem that has not been solved:
There must be some highly time-consuming tasks that will block our "countdown tasks" and cause "stuck" phenomenon.
For these time-consuming tasks, they can also be executed in a worker thread. So that the blocking effect on our "countdown tasks" will be greatly reduced. However, this thread is mainly controlled by developers manually, which is difficult to maintain.
How to implement a simple countdown plugin?
chrome extension development guide: developer.chrome.com/docs/extens…
The code is very simple. Its' repository url is: github.com/TodoHacker/…
The effect diagram is as follows: (click the plug-in icon, a pop-up box will appear. After clicking the button in the pop-up box, a countdown display will be added under the current active tab page)
Last
Scenes like countdown don't appear frequently and its' logics are not very complicated. But after a period of research, we can also gained a lot, right?
Therefore, no matter how small things are, they can actually extend a lot of knowledge and skills, as long as you discover them carefully. In addition, in the state of more business, through the model adopted (thinking, learning, and growing in business) in this article, it's also a good way to quickly improve yourself.
Copyright Notice:Share-Attribution-NonCommercial-NoDerivatives (Attribution-NonCommercial-NoDerivs 3.0 Unported (CC BY-NC-ND 3.0))