使用 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";
    }
  }
});

原始代碼的問題

  1. 計時器未正確管理:每次按下空白鍵都會產生一個新的計時器,未清除之前的計時器會導致多重計時器同時運行。
  2. 條件判斷不夠明確:切換計時器的條件判斷邏輯不清晰,無法正確地控制計時器的開啟和關閉。
  3. 同步與非同步混淆:將 displayTime 當作非同步函數使用,但實際上它並不包含任何異步操作,因而使用 async/await 是不必要的。

觀念一:同步與非同步的區別

問題說明

在 JavaScript 中,同步函數是指程式碼會按順序執行,函數的結果會立即返回。

而非同步函數則會等待一些異步操作(如 API 請求、檔案操作、計時器等)完成後才繼續執行。

async/await 是非同步程式設計中的語法,用來讓程式碼看起來像同步執行,但其實在等待異步操作完成。

在原始代碼中,displayTime 函數內部包含 setInterval,這是一個非同步方法,但 setInterval 並不會返回一個 Promise

因此,displayTime 並不需要使用 async/await

如何判斷同步或非同步函數

  • 同步函數:立即執行的函數,結果馬上可以取得,典型的例子如數學運算、DOM 操作等。
  • 非同步函數:需要等待某些操作完成才繼續執行,這些操作通常是由事件(如 setTimeout)或外部資源(如 API 請求)驅動的,典型的例子有資料庫查詢、網路請求等。

在代碼中,async/await 應僅在確定需要等待的情境下使用。

setInterval 是同步觸發的,它並不會返回一個 Promise,因此不需要使用 await 來等待它的結果。


觀念二:條件判斷對程式執行流程的影響

問題說明

在程式邏輯中,條件判斷 (ifelse 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" 並不會使計時器自動停止。

分析程式執行順序的影響

我們來看看程式執行的具體過程:

  1. 初始狀態 check = "active",按下空白鍵觸發 displayTime
  2. displayTime 判斷 check"active",因此啟動計時器。
  3. 在這之後,check 變更為 "default",但此時 displayTime 已經執行完畢,並不會再執行 else if (check === "default") 的條件去停止計時器。

按下第二次空白鍵時,displayTime 仍然無法進入 else if (check === "default") 的條件區塊來停止計時器。

else if (check === "default") {
    await displayTime(check);
    check = "active";
}

原因在於每次按下空白鍵時,都重新呼叫了 displayTime 函數

在原始程式碼中,displayTime 函數被設計成一個「立即執行的判斷」,而不是一個可以隨著 check 狀態變化而重新檢查的機制。

這種設計導致了以下行為:

  1. 第一次按空白鍵:當 check"active" 時,displayTime(check) 會被呼叫並執行 if (check === "active") 的邏輯。

    此時,計時器會開始運行,並且 check 的狀態變更為 "default"
  2. 第二次按空白鍵:雖然此時 check 的狀態已經變成 "default",但每次呼叫 displayTime 都是獨立的、無狀態的執行,無法記錄並管理之前的計時器。

    因此,displayTime(check)check"default" 時重新執行並啟動計時器,但無法觸發 else if (check === "default") 的條件來停止先前已經在運行的計時器。

問題根源:獨立的 displayTime 呼叫無法管理計時器的開啟與關閉

在這種情況下,displayTime 函數每次執行的時候,只會立即檢查傳入的 check 值,並不會「記住」上一次按鍵時的計時器狀態。

因此,它無法控制之前已經運行的計時器。具體來說:

  • 每次按下空白鍵,displayTime 函數都會根據當前的 check 狀態立即執行「啟動或嘗試停止計時器」的代碼塊。
  • 但因為計時器的 ID countDown 是局部變數,所以每次執行 displayTime 後,無法再訪問並清除之前啟動的計時器,導致新的計時器被啟動而無法停止舊的計時器。

解決方案:分離啟動和停止函數,並使用全域變數管理計時器

為了解決這個問題,可以將 displayTime 拆分為兩個獨立的函數:startTimerstopTimer

這樣,我們就可以根據 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";
    }
  }
});

優化後的執行流程

  1. 初始狀態check = "active",按下空白鍵觸發 startTimer()
    • startTimer 創建唯一的計時器,開始倒數,並將 check 切換為 "default"
  1. 第二次按下空白鍵:此時 check = "default",觸發 stopTimer()
    • stopTimer 使用全域變數 countDown 清除唯一的計時器,並將 check 切換回 "active"

通過這種設計,按下空白鍵可以交替地啟動和停止計時器,不會因為 displayTime 無法管理計時器狀態而導致多重計時器重疊的問題。

Similar Posts