既爱又恨的定时器
2021.01.10
hzzly
前言:前两天开发了一个抽奖功能的需求,也是被定时器折磨了一番。
问题
- React Hooks里的定时器
- 倒计时不准
- 应用退到手机后台后的定时器
接下来咱们一一解决它。
React Hooks里的定时器
对于每个使用 React Hooks 的开发者来说,setInterval 是一个绕不过去的”坑“。由于React Hooks 特有的设计理念,如果用固有的思维模式去写 setInterval,很容易触发意想不到的 bug。
比如下面的错误写法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| function Test() { const [count, setCount] = useState(0); const timer = useRef(); useEffect(() => { if (!timer.current) { timer.current = setInterval(() => { setCount(count + 1); }, 1000); } return () => { if (timer.current) { clearInterval(timer.current); timer.current = null; } } }, []); return <div>{count}</div>; }
|
这样写确实很简洁,也符合我们固有的思维模式,但是它的实现效果却与我们的目标背道而驰。
预期的效果是页面上的数字会每秒增加 1 ,但实际是数字增加到 1 后便静止不动了。由于 useEffect 的依赖为空数组,所以 setInterval 只会在组件完成初次渲染后被调用一次,从而使得回调函数在之后每次被定时调用时,取到的 count 都是初次渲染时的值 0(闭包的原因),页面上的数值也会永远停留在 1。
解决方案:
我们使用useRef来解决来解决这种闭包引起的问题。(useRef 在 React Hooks 中的作用,正如官网说的,它像一个变量,类似于 this ,它就像一个盒子,你可以存放任何东西, useRef 每次都会返回相同的引用)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| function Test() { const [count, setCount] = useState(0); const timer = useRef(); const time = useRef(0); useEffect(() => { if (!timer.current) { timer.current = setInterval(() => { time.current += 1; setCount(time.current); }, 1000); } return () => { if (timer.current) { clearInterval(timer.current); timer.current = null; time.current = null; } } }, []); return <div>{count}</div>; }
|
倒计时不准(定时器不准)
我们知道定时器的执行时间并不是确定的。这是由于 JS 是门非阻塞单线程语言,因为在最初 JS 就是为了和浏览器交互而诞生的。如果 JS 是门多线程的语言话,我们在多个线程中处理 DOM 就可能会发生问题(一个线程中新加节点,另一个线程中删除节点)。那么为什么说定时器的执行时间不是确定的呢?那就得来细数一下轮询(Event loop)了。
Event Loop 的是计算机系统的一种运行机制。JS 语言就采用这种机制,来解决单线程运行带来的一些问题。
在 JavaScript 中,任务被分为两种,一种宏任务(MacroTask),一种叫微任务(MicroTask)。
常见的宏任务有:script全部代码、setTimeout、setInterval、setImmediate(浏览器暂时不支持,只有IE10支持,具体可见MDN)、I/O、UI Rendering。
常见的微任务有:Process.nextTick(Node独有)、Promise、Object.observe(已废弃)、MutationObserver
一次 Event loop 顺序是这样的:
- 执行同步代码,这属于宏任务
- 执行栈为空,查询是否有微任务需要执行
- 执行所有微任务
- 必要的话渲染 UI
- 然后开始下一轮 Event loop,执行宏任务中的异步代码
通过上述的 Event loop 顺序可知,我们实际写的定时器(倒计时)1秒是不确定的。当我们倒计时还在定时器里减1就不准确了。
解决方案:
既然定时器不准,那我们就不能做减1的操作,可以利用时间戳的差值来计算。
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
| function Test() { const [count, setCount] = useState(3600); const timer = useRef(); const time = useRef(0); useEffect(() => { time.current = count; if (!timer.current) { let start = new Date().getTime(); let end = 0; timer.current = setInterval(() => { const s = (end - start) / 1000; time.current -= s; setCount(time.current); start = end; }, 1000); } return () => { if (timer.current) { clearInterval(timer.current); timer.current = null; time.current = null; } } }, []); return <div>{count}</div>; }
|
应用退到手机后台后的定时器
在手机端,如果应用被切换到后台(不是关闭应用,是切到后台),那么这时候定时器就会有问题。PC上的Firefox,Chrome和Safari等浏览器,都会自动把未激活页面中的JavaScript定时器(setTimeout,setInterval)间隔最小值改为1秒以上。这是因为间隔很小的定时器一般用来做UI更新(例如用定时器实现的动画),让用户不可见的页面上的定时器跑慢一些,既节省资源又不会影响体验。对移动浏览器来说,内存,CPU,带宽等资源更加宝贵,设备移动的上浏览器往往会直接冻结所有未激活页面上的所有定时器。
解决方案:
通过监听 visibilitychange 方法,如果退到后台记录下当前时间,等切回来,再算一下当前时间,然后计算时间差,最后用当时定格的那个时间去减去这个时间差,再赋值给这个定时器,就ok了。
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
| let start = 0; let end = 0; let startS = 0; const hiddenProperty = 'hidden' in document ? 'hidden' : 'webkitHidden' in document ? 'webkitHidden' : 'mozHidden' in document ? 'mozHidden' : null; if (hiddenProperty == null) { return false; } const visibilityChangeEvent = hiddenProperty.replace(/hidden/i, 'visibilitychange'); const onVisibilityChange = function () { if (!document[hiddenProperty]) { end = new Date().getTime(); const s = Math.ceil((end - start) / 1000); const timeC = startS - s; if (timeC > 0) { setTime(timeC); } else { setTime(-1); } } else { startS = time; start = new Date().getTime(); } }; document.addEventListener(visibilityChangeEvent, onVisibilityChange);
|
总结
到这里,基本就针对碰到的定时器的三个问题讲了一遍,不对之处,还请多多指正!