使用 setInterval 建立倒數計時器的最佳實踐
更新日期: 2024 年 11 月 14 日
本文將針對,使用 setInterval
建立倒數計時器的常見錯誤進行探討。
同時會對以下三個觀念進行詳細說明:計時器管理、條件判斷,以及同步和非同步函數的區別。
這些觀念對於撰寫正確、穩定的計時功能至關重要,理解它們能有效地避免代碼中的潛在錯誤。
原始代碼
// 計時器顯示框文字
const timeString = document.querySelector(".timer")
// 秒數轉換成文字格式
function converTimeFormat(numTime) {
let orginMin = 0;
let orginSec = 0;
while (numTime !== 0) {
if (numTime / 60 >= 1) {
const totalMin = Math.floor(numTime / 60);
orginMin = orginMin + totalMin;
numTime = numTime - totalMin * 60;
} else {
orginSec = numTime;
numTime = 0;
}
}
orginMin = orginMin.toString().padStart(2, "0");
orginSec = orginSec.toString().padStart(2, "0");
return `${orginMin}:${orginSec}`;
}
let totalSec = 120;
let check = "active";
// 計時器運作核心
function displayTime(check) {
let countDown;
if (check === "active") {
countDown = setInterval(() => {
if (totalSec > -1) {
timeString.innerHTML = converTimeFormat(totalSec);
totalSec = totalSec - 1;
} else {
clearInterval(countDown);
}
}, 1000);
} else if (check === "default") {
console.log("立即結束");
clearInterval(countDown);
}
}
// 計時器啟用、暫停機制
document.addEventListener("keyup", async (e) => {
if (e.code === "Space") {
if (check === "active") {
await displayTime(check);
check = "default";
} else if (check === "default") {
await displayTime(check);
check = "active";
}
}
});
原始代碼的問題
- 計時器未正確管理:每次按下空白鍵都會產生一個新的計時器,未清除之前的計時器會導致多重計時器同時運行。
- 條件判斷不夠明確:切換計時器的條件判斷邏輯不清晰,無法正確地控制計時器的開啟和關閉。
- 同步與非同步混淆:將
displayTime
當作非同步函數使用,但實際上它並不包含任何異步操作,因而使用async/await
是不必要的。
觀念一:同步與非同步的區別
問題說明
在 JavaScript 中,同步函數是指程式碼會按順序執行,函數的結果會立即返回。
而非同步函數則會等待一些異步操作(如 API 請求、檔案操作、計時器等)完成後才繼續執行。
async
/await
是非同步程式設計中的語法,用來讓程式碼看起來像同步執行,但其實在等待異步操作完成。
在原始代碼中,displayTime
函數內部包含 setInterval
,這是一個非同步方法,但 setInterval
並不會返回一個 Promise
。
因此,displayTime
並不需要使用 async/await
。
如何判斷同步或非同步函數
- 同步函數:立即執行的函數,結果馬上可以取得,典型的例子如數學運算、DOM 操作等。
- 非同步函數:需要等待某些操作完成才繼續執行,這些操作通常是由事件(如
setTimeout
)或外部資源(如 API 請求)驅動的,典型的例子有資料庫查詢、網路請求等。
在代碼中,async/await
應僅在確定需要等待的情境下使用。
setInterval
是同步觸發的,它並不會返回一個 Promise
,因此不需要使用 await
來等待它的結果。
觀念二:條件判斷對程式執行流程的影響
問題說明
在程式邏輯中,條件判斷 (if
、else if
等) 決定了程式碼的執行路徑。
這段程式碼的 displayTime
函數內設計了以下條件判斷:
外部函數設定 check 值狀態改變:
document.addEventListener("keyup", async (e) => {
if (e.code === "Space") {
if (check === "active") {
await displayTime(check);
check = "default";
} else if (check === "default") {
await displayTime(check);
check = "active";
}
}
});
內部函數根據 check 值進行調整:
function displayTime(check) {
let countDown;
if (check === "active") {
countDown = setInterval(() => {
if (totalSec > -1) {
timeString.innerHTML = converTimeFormat(totalSec);
totalSec = totalSec - 1;
} else {
clearInterval(countDown);
}
}, 1000);
} else if (check === "default") {
console.log("立即結束");
clearInterval(countDown);
}
}
displayTime
函數其目的是當 check
狀態為 "active"
時,啟動計時器;而當 check
狀態為 "default"
時,則停止計時器。
然而,由於條件判斷的使用方式,這段代碼在切換狀態時,無法達到預期的效果。
在 displayTime
函數中傳入的 check
值,會決定是否執行計時器啟動或停止的代碼。
然而,這段條件判斷並不會自動再檢查變更後的 check
值。
換句話說,程式不會根據 check
值的變更重複執行 if
/else if
判斷。
結果,當 displayTime(check)
以 "active"
狀態被觸發後,切換到 "default"
並不會使計時器自動停止。
分析程式執行順序的影響
我們來看看程式執行的具體過程:
- 初始狀態
check = "active"
,按下空白鍵觸發displayTime
。 displayTime
判斷check
為"active"
,因此啟動計時器。- 在這之後,
check
變更為"default"
,但此時displayTime
已經執行完畢,並不會再執行else if (check === "default")
的條件去停止計時器。
按下第二次空白鍵時,displayTime
仍然無法進入 else if (check === "default")
的條件區塊來停止計時器。
else if (check === "default") {
await displayTime(check);
check = "active";
}
原因在於每次按下空白鍵時,都重新呼叫了 displayTime
函數。
在原始程式碼中,displayTime
函數被設計成一個「立即執行的判斷」,而不是一個可以隨著 check
狀態變化而重新檢查的機制。
這種設計導致了以下行為:
- 第一次按空白鍵:當
check
為"active"
時,displayTime(check)
會被呼叫並執行if (check === "active")
的邏輯。
此時,計時器會開始運行,並且check
的狀態變更為"default"
。 - 第二次按空白鍵:雖然此時
check
的狀態已經變成"default"
,但每次呼叫displayTime
都是獨立的、無狀態的執行,無法記錄並管理之前的計時器。
因此,displayTime(check)
在check
為"default"
時重新執行並啟動計時器,但無法觸發else if (check === "default")
的條件來停止先前已經在運行的計時器。
問題根源:獨立的 displayTime
呼叫無法管理計時器的開啟與關閉
在這種情況下,displayTime
函數每次執行的時候,只會立即檢查傳入的 check
值,並不會「記住」上一次按鍵時的計時器狀態。
因此,它無法控制之前已經運行的計時器。具體來說:
- 每次按下空白鍵,
displayTime
函數都會根據當前的check
狀態立即執行「啟動或嘗試停止計時器」的代碼塊。 - 但因為計時器的 ID
countDown
是局部變數,所以每次執行displayTime
後,無法再訪問並清除之前啟動的計時器,導致新的計時器被啟動而無法停止舊的計時器。
解決方案:分離啟動和停止函數,並使用全域變數管理計時器
為了解決這個問題,可以將 displayTime
拆分為兩個獨立的函數:startTimer
和 stopTimer
。
這樣,我們就可以根據 check
的狀態明確地控制計時器的啟動與停止。
使用全域變數 countDown
來存儲計時器的 ID,確保計時器的唯一性並能夠被正確清除。
優化後的程式碼
const timeString = document.querySelector(".timer");
let totalSec = 120;
let check = "active";
let countDown; // 將計時器 ID 提升為全域變數
function converTimeFormat(numTime) {
let orginMin = 0;
let orginSec = 0;
while (numTime !== 0) {
if (numTime / 60 >= 1) {
const totalMin = Math.floor(numTime / 60);
orginMin = orginMin + totalMin;
numTime = numTime - totalMin * 60;
} else {
orginSec = numTime;
numTime = 0;
}
}
orginMin = orginMin.toString().padStart(2, "0");
orginSec = orginSec.toString().padStart(2, "0");
return `${orginMin}:${orginSec}`;
}
function startTimer() {
countDown = setInterval(() => {
if (totalSec > -1) {
timeString.innerHTML = converTimeFormat(totalSec);
totalSec -= 1;
} else {
clearInterval(countDown);
}
}, 1000);
}
function stopTimer() {
clearInterval(countDown); // 分割出空白鍵停止計時函數
console.log("計時已停止");
}
document.addEventListener("keyup", (e) => {
if (e.code === "Space") {
if (check === "active") {
startTimer();
check = "default";
} else if (check === "default") {
stopTimer();
check = "active";
}
}
});
優化後的執行流程
- 初始狀態:
check = "active"
,按下空白鍵觸發startTimer()
。startTimer
創建唯一的計時器,開始倒數,並將check
切換為"default"
。
- 第二次按下空白鍵:此時
check = "default"
,觸發stopTimer()
。stopTimer
使用全域變數countDown
清除唯一的計時器,並將check
切換回"active"
。
通過這種設計,按下空白鍵可以交替地啟動和停止計時器,不會因為 displayTime
無法管理計時器狀態而導致多重計時器重疊的問題。