當前位置: 妍妍網 > 碼農

阿裏面試:寫一個倒計時功能刷掉了80% 的人

2024-06-06碼農

作者:大橘為重0

來源:https://juejin.cn/post/7343921389084426277

純標題黨!!!,但確實是阿裏的大佬自己群裏說的在面試時候必問的一個題目,其實這個問題不僅是在面試中,也在我們的業務裏也會經常用到,所以才會寫這麽一篇文章,那麽到底如何才能寫一個完美的倒計時呢?

首先我們在寫倒計時的時候必須要考慮到兩點:準確性、效能。接下來我們來一步一步實作一個準確的定時器。

# setInterval:

我們先來簡單實作一個倒計時的函式:

functionexample1(leftTime) {let t = leftTime; setInterval(() => { t = t - 1000;console.log(t); }, 1000);}example1(10);

可以看到使用 setInterval 即可,但是 setInterval 真的準確嗎?我們來看一下 MDN 中的說明:

💡 如果你的程式碼邏輯執行時間可能比定時器時間間隔要長,建議你使用遞迴呼叫了 setTimeout() 的具名函式。例如,使用 setInterval() 以 5 秒的間隔輪詢伺服器,可能因網路延遲、伺服器無響應以及許多其他的問題而導致請求無法在分配的時間內完成。

簡單來說意思就是,js 因為是單執行緒的原因,如果前面有阻塞執行緒的任務,那麽就可能會導致 setInterval 函式延遲,這樣倒計時就肯定會不準確,建議使用 setTimeout 替換 setInterval。

# setTimeout:

按照上述的建議將 setInterval 換為 setTimeout 後,我們來看下程式碼:

functionexample2(leftTime) {let t = leftTime; setTimeout(() => { t = t - 1000;if (t > 0) {console.log(t); example2(t); }console.log(t); }, 1000);}

MDN 中也說了,有很多因素會導致 setTimeout 的回呼函式執行比設定的預期值更久,比如巢狀超時、非活動標簽超時、追蹤型指令碼的節流、超時延遲等等,總就就是和 setInterval 差不多,時間一長,就會有誤差出現,而且 setTimeout有一個很不好的點在於,當你的程式在背景執行時,setTimeout也會一直執行,這樣會嚴重的而浪費效能,那麽有什麽辦法可以解決這種問題嗎?

# requestAnimationFrame

這裏就不得不提一個新的方法 requestAnimationFrame,它是一個瀏覽器 API,允許以 60 幀/秒 (FPS) 的速率請求回呼,而不會阻塞主執行緒。透過呼叫 requestAnimationFrame 方法瀏覽器會在下一次重繪之前執行指定的函式,這樣可以確保回呼在每一幀之間都能夠得到適時的更新。

我們使用 requestAnimationFrame 結合 setTimeout 來最佳化一下之前的程式碼:

functionexample4(leftTime) {let t = leftTime;functionstart() { requestAnimationFrame(() => { t = t - 1000; setTimeout(() => {console.log(t); start(); }, 1000); }); } start();}

為什麽要使用 requestAnimationFrame + setTimeout呢?一個是息屏或者切後台的操作時,requestAnimationFrame 是不會繼續呼叫函式的,但是如果只使用requestAnimationFrame 的話,函式相當於 1 秒的時候要呼叫 60 次,太浪費效能。

在切後台或者息屏的實際執行時會發現,當回到頁面時,倒計時會接著切後台時的時間執行,而沒有更新到最新的時間,這樣的bug是接受不了的。

# diffTime差值計算:

要解決上述的問題,最通用的辦法就是透過時間差值每次進行對比就可以了。

functionexample5(leftTime) {const now = performance.now();functionstart() { setTimeout(() => {const diff = leftTime - (performance.now() - now);console.log(diff); requestAnimationFrame(start); }, 1000); } start();}

上面的程式碼實作思路其實在實際的業務中已經能夠滿足我們的使用場景,但其實還是沒有解決setTimeout會延遲的問題,當執行緒被占用之後,很容易出現誤差,那麽有什麽更新的辦法進行處理呢?

# 最佳方案

先要明確的是,setTimeout函式中執行程式碼的時間肯定是要大於等於setTimeout時間的,那麽就可能出現設定的 1 秒,實際執行卻執行了 2 秒的情況,那麽我們的實作思路也很簡單,每次計算一下setTimeout實際執行的時間,然後動態的調整下一次執行的時間,而不是設定固定的值

我們來用圖表舉例推演一下每次執行的情況:

從中可以看到:下次執行的時間 nextTime = 1000 - totleTime % 1000;這樣我們就可以得出下次執行的時間,從而每次都去動態的調整多余消耗的時間,大大減小倒計時最終的誤差

還有需要考慮的是,實際業務中返回的剩余時間肯定不會是整數,所以我們的第一次執行的時間最好可以先讓剩余時間變為整數,這樣可以在倒計時到最後一秒時更加的精確。

根據上述的思路來看一下最終封裝出來的 react hooks:

const useCountDown = ({ leftTime, ms = 1000, onEnd }: CountDownProps) => {const countdownTimer = useRef<NodeJS.Timeout | null>();const startTimeRef = useRef<number>(performance.now());const nextTimeRef = useRef<number>(leftTime % ms);const totalTimeRef = useRef<number>(0);const [count, setCount] = useState(leftTime);const preLeftTime = usePrevious(leftTime);const clearTimer = useCallback(() => {if (countdownTimer.current) { clearTimeout(countdownTimer.current); countdownTimer.current = null; } }, []);const startCountDown = useCallback((nt: number = 0) => { clearTimer();// 每次實際執行的時間const executionTime = performance.now() - startTimeRef.current; // 1.x totalTimeRef.current = totalTimeRef.current + executionTime;// 剩余時間減去應該執行的時間 setCount((count) => {const nextCount = count - (Math.floor(executionTime / ms) || 1) * ms - nt;return nextCount <= 0 ? 0 : nextCount; });// 算出下一次的時間 nextTimeRef.current = ms - (totalTimeRef.current % ms);// 重設初始時間 startTimeRef.current = performance.now(); countdownTimer.current = setTimeout(() => { requestAnimationFrame(() => startCountDown(0)); }, nextTimeRef.current); }, [ms] ); useEffect(() => {if (preLeftTime !== leftTime && preLeftTime !== undefined) { clearTimer(); setCount(() => leftTime); nextTimeRef.current = leftTime % ms; countdownTimer.current = setTimeout(() => { requestAnimationFrame(() => startCountDown(nextTimeRef.current) ); }, nextTimeRef.current); } }, [leftTime, ms]); useEffect(() => { countdownTimer.current = setTimeout(() => startCountDown(nextTimeRef.current), nextTimeRef.current );return() => { clearTimer(); }; }, []); useEffect(() => {if (count <= 0) { clearTimer(); onEnd && onEnd(); } }, [count]);const formatCount = parseMillisecond(count);return { formatCount, count };};exportdefault useCountDown;

如果想要封裝元件的話,可以在hooks的基礎上進行二次封裝。

到這裏,肯定會有人說,做了這麽多的操作,有必要嗎,就算差0點幾秒,在實際體驗中使用者完全感受不出來。我想說的是,細節決定成敗,有可能這零點幾秒的內容就決定了面試的成敗。如果做什麽事都只做個差不多,那你永遠不會有自己的"核心科技"。

關註細節,從中去學一些解題的思路或者方法,然後積累沈澱,才能讓自己持續成長。

熱門推薦