阿里面試:寫一個倒計時功能刷掉了80% 的人
共 6910字,需瀏覽 14分鐘
·
2024-05-10 08:50
Excerpt
本文將探討如何實現(xiàn)高性能、穩(wěn)定且準確的倒計時器。我們將深入分析定時器API的選擇、事件循環(huán)的影響、以及時間同步技術,提供最佳實踐和代碼示例,確保倒計時的精確性和效率。
純標題黨!!!,但確實是阿里的大佬自己群里說的在面試時候必問的一個題目,其實這個問題不僅是在面試中,也在我們的業(yè)務里也會經常用到,所以才會寫這么一篇文章,那么到底如何才能寫一個完美的倒計時呢?
首先我們在寫倒計時的時候必須要考慮到兩點:準確性、性能。接下來我們來一步一步實現(xiàn)一個準確的定時器。
setInterval:
我們先來簡單實現(xiàn)一個倒計時的函數(shù):
function example1(leftTime) { let t = leftTime; setInterval(() => { t = t - 1000; console.log(t); }, 1000); } example1(10);
可以看到使用 setInterval 即可,但是 setInterval 真的準確嗎?我們來看一下 MDN 中的說明:
?? 如果你的代碼邏輯執(zhí)行時間可能比定時器時間間隔要長,建議你使用遞歸調用了 setTimeout()[1] 的具名函數(shù)。例如,使用 setInterval() 以 5 秒的間隔輪詢服務器,可能因網絡延遲、服務器無響應以及許多其他的問題而導致請求無法在分配的時間內完成。
簡單來說意思就是,js 因為是單線程的原因,如果前面有阻塞線程的任務,那么就可能會導致 setInterval 函數(shù)延遲,這樣倒計時就肯定會不準確,建議使用 setTimeout 替換 setInterval。
setTimeout:
按照上述的建議將 setInterval 換為 setTimeout 后,我們來看下代碼:
function example2(leftTime) { let t = leftTime; setTimeout(() => { t = t - 1000; if (t > 0) { console.log(t); example2(t); } console.log(t); }, 1000); }
MDN 中也說了,有很多因素會導致 setTimeout 的回調函數(shù)執(zhí)行比設定的預期值更久,比如嵌套超時、非活動標簽超時、追蹤型腳本的節(jié)流、超時延遲等等,詳情見developer.mozilla.org/zh-CN/docs/…[2],總就就是和 setInterval 差不多,時間一長,就會有誤差出現(xiàn),而且 setTimeout有一個很不好的點在于,當你的程序在后臺運行時,setTimeout也會一直執(zhí)行,這樣會嚴重的而浪費性能,那么有什么辦法可以解決這種問題嗎?
requestAnimationFrame
這里就不得不提一個新的方法 requestAnimationFrame,它是一個瀏覽器 API,允許以 60 幀/秒 (FPS) 的速率請求回調,而不會阻塞主線程。通過調用 requestAnimationFrame 方法瀏覽器會在下一次重繪之前執(zhí)行指定的函數(shù),這樣可以確保回調在每一幀之間都能夠得到適時的更新。
我們使用 requestAnimationFrame 結合 setTimeout 來優(yōu)化一下之前的代碼:
function example4(leftTime) { let t = leftTime; function start() { requestAnimationFrame(() => { t = t - 1000; setTimeout(() => { console.log(t); start(); }, 1000); }); } start(); }
為什么要使用 requestAnimationFrame + setTimeout呢?一個是息屏或者切后臺的操作時,requestAnimationFrame 是不會繼續(xù)調用函數(shù)的,但是如果只使用requestAnimationFrame 的話,函數(shù)相當于 1 秒的時候要調用 60 次,太浪費性能。
在切后臺或者息屏的實際執(zhí)行時會發(fā)現(xiàn),當回到頁面時,倒計時會接著切后臺時的時間執(zhí)行,而沒有更新到最新的時間,這樣的bug是接受不了的。
diffTime差值計算:
要解決上述的問題,最通用的辦法就是通過時間差值每次進行對比就可以了。
function example5(leftTime) { const now = performance.now(); function start() { setTimeout(() => { const diff = leftTime - (performance.now() - now); console.log(diff); requestAnimationFrame(start); }, 1000); } start(); }
上面的代碼實現(xiàn)思路其實在實際的業(yè)務中已經能夠滿足我們的使用場景,但其實還是沒有解決setTimeout會延遲的問題,當線程被占用之后,很容易出現(xiàn)誤差,那么有什么更新的辦法進行處理呢?
最佳方案
先要明確的是,setTimeout函數(shù)中執(zhí)行代碼的時間肯定是要大于等于setTimeout時間的,那么就可能出現(xiàn)設定的 1 秒,實際執(zhí)行卻執(zhí)行了 2 秒的情況,那么我們的實現(xiàn)思路也很簡單,每次計算一下setTimeout實際執(zhí)行的時間,然后動態(tài)的調整下一次執(zhí)行的時間,而不是設置固定的值
我們來用圖表舉例推演一下每次執(zhí)行的情況:
| 第n次執(zhí)行 | executionTime 實際執(zhí)行時間 | nextTime 下次需要執(zhí)行的時間 | totleTime 執(zhí)行的總時間 |
|---|---|---|---|
| 0 | 0 | 1000 | 0 |
| 1 | 1200 | 800 | 1200 |
| 2 | 1100 | 700 | 2300 |
| 3 | 1000 | 700 | 3300 |
| 4 | 2200 | 500 | 5500 |
| 5 | 1300 | 200 | 6800 |
| 6 | 1200 | 1000 | 8000 |
| … | … | … | … |
從中可以看到:下次執(zhí)行的時間 nextTime = 1000 - totleTime % 1000;這樣我們就可以得出下次執(zhí)行的時間,從而每次都去動態(tài)的調整多余消耗的時間,大大減小倒計時最終的誤差
還有需要考慮的是,實際業(yè)務中返回的剩余時間肯定不會是整數(shù),所以我們的第一次執(zhí)行的時間最好可以先讓剩余時間變?yōu)檎麛?shù),這樣可以在倒計時到最后一秒時更加的精確。
根據(jù)上述的思路來看一下最終封裝出來的 react hooks:
const useCountDown = ({ leftTime, ms = 1000, onEnd }) => { const countdownTimer = useRef(); const startTimer = useRef(); //記錄初始時間 const startTimeRef = useRef(performance.now()); // 第一次執(zhí)行的時間處理,讓下一次倒計時時調整為整數(shù) const nextTimeRef = useRef(leftTime % ms); const [count, setCount] = useState(leftTime); const clearTimer = () => { countdownTimer.current && clearTimeout(countdownTimer.current); startTimer.current && clearTimeout(startTimer.current); }; const startCountDown = () => { clearTimer(); const currentTime = performance.now(); // 算出每次實際執(zhí)行的時間 const executionTime = currentTime - startTimeRef.current; // 實際執(zhí)行時間大于上一次需要執(zhí)行的時間,說明執(zhí)行時間多了,否則需要補上差的時間 const diffTime = executionTime > nextTimeRef.current ? executionTime - nextTimeRef.current : nextTimeRef.current - executionTime; setCount((count) => { const nextCount = count - (Math.floor(executionTime / ms) || 1) * ms - nt; return nextCount <= 0 ? 0 : nextCount; }); // 算出下一次的時間 nextTimeRef.current = executionTime > nextTimeRef.current ? ms - diffTime : ms + diffTime; // 重置初始時間 startTimeRef.current = performance.now(); countdownTimer.current = setTimeout(() => { requestAnimationFrame(startCountDown); }, nextTimeRef.current); }; useEffect(() => { setCount(leftTime); startTimer.current = setTimeout(startCountDown, nextTimeRef.current); return () => { clearTimer(); }; }, [leftTime]); useEffect(() => { if (count <= 0) { clearTimer(); onEnd && onEnd(); } }, [count]); return count; }; export default useCountDown;
如果想要封裝組件的話,可以在hooks的基礎上進行二次封裝。
到這里,肯定會有人說,做了這么多的操作,有必要嗎,就算差0點幾秒,在實際體驗中用戶完全感受不出來。我想說的是,細節(jié)決定成敗,有可能這零點幾秒的內容就決定了面試的成敗。如果做什么事都只做個差不多,那你永遠不會有自己的"核心科技"。關注細節(jié),從中去學一些解題的思路或者方法,然后積累沉淀,才能讓自己持續(xù)成長。
除了上述的優(yōu)化思路,歡迎大家有更好的想法也可以隨時進行探討~ 歡迎大家關注我的博客:www.lpeakcc.com/[3]
原文: https://juejin.cn/post/7343921389084426277
作者:大橘為重07
https://developer.mozilla.org/zh-CN/docs/Web/API/setTimeout: https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FsetTimeout
[2]https://developer.mozilla.org/zh-CN/docs/Web/API/setTimeout: https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fzh-CN%2Fdocs%2FWeb%2FAPI%2FsetTimeout
[3]https://link.juejin.cn/?target=https%3A%2F%2Fwww.lpeakcc.com%2F: https://link.juejin.cn/?target=https%3A%2F%2Fwww.lpeakcc.com%2F
