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