+2

Cùng code Pomodoro Timer để tập chung học về xử lý DOM

Xin chào các bạn, mình là Thái. Vừa để làm 1 công cụ sử dụng hằng ngày, vừa để giúp những bạn mới hiểu về DOM, vanilaJs, ... thì hãy cùng mình đi code một cái Pomodoro Timer nhé. Đây là một phiên bản cơ bản, mình dự định sẽ cải tiến nó trong tương lai, vì vậy nếu quan tâm thì đón đọc những bài viết sau nữa ạ.

Học DOM Thông Qua Việc Xây Dựng Pomodoro Timer

Qua bài học này, chúng ta sẽ:

  • Tìm hiểu cách truy vấn DOM bằng các selector
  • Học các phương pháp cập nhật thuộc tính DOM: thêm, xóa, chuyển đổi class, cập nhật textContent, style, v.v.

Mô tả một chút về cách hoạt động của một Pomodoro Timer đơn giản là:

  • Đặt hẹn giờ (thường là 25 phút) - đây được gọi là một "Pomodoro".
  • Làm việc tập trung vào nhiệm vụ cho đến khi hết giờ.
  • Nghỉ ngắn (thường là 5 phút).
  • Lặp lại việc này

Vậy thì giao diện của mình sẽ trông như thế nào? Mình sẽ chia giao diện thành 3 phần:

  • Đồng hồ đểm ngược
  • Dòng trạng thái (để biết đang ở giai đoạn nào)
  • Khu vực nút bấm: Start hoặc Cancel + Pause/Resume

Ví dụ này tập chung vào DOM nên mình sẽ dùng tailwindcss để tăng tốc ạ

Thiết kế giao diện

Hãy bắt đầu với khung Html với 3 thành phần trên, tạo thư mục pomodoro và tạo file index.html bên trong với nội dung sau:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Pomodoro Timer</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>

<body class="bg-slate-600 transition-all duration-500">
    <div class="text-white font-mono max-w-sm mx-auto">
        <div class="text-xl font-bold text-center p-10">Pomodoro Timer</div>

        <div class="grid gap-6">
            <div id="timer" class="text-8xl text-center"></div>
            <div id="status" class="text-sm text-center">Ready to focus?</div>
            <button id="start" class="text-xl py-2 rounded-full bg-white text-center text-black">Start</button>
            <div id="controls" style="display: none;">
                <div class="grid grid-cols-2 gap-4 w-full">
                    <button id="clear"
                        class="block w-full text-xl py-2 rounded-full bg-white/40 text-center">Clear</button>
                    <button id="pause"
                        class="block w-full text-xl py-2 rounded-full bg-white text-black text-center">Pause</button>
                </div>
            </div>
        </div>
    </div>

    <script>
        // Viết code ở đây
    </script>
</body>

</html>

Ở đoạn code này các bạn sẽ thấy mình đánh id="..." và setup thông tin khởi tạo cho giao diện. Cứ nhớ rằng muốn tương tác với DOM gì thì hay có cái để đánh dấu. Khi run lên sẽ trông như thế này (Các bạn có thể cài extension Live server trên VS code để chạy file HTML nhé ạ):

Và khi đồng hồ chạy thì sẽ như thế này:

Viết logic javascript

Tiếp theo chúng ta sẽ cần code tiếp vào phần Viết code ở đây trong thẻ <script></script>

Cài đặt, thiết lập biến cho Timer

// Làm việt 25 phút
const WORK_TIME = 25 * 60;
// Nghỉ 5 phút
const BREAK_TIME = 5 * 60;

// Thời gian đang đếm, mặc định là chưa làm gì nên = WORK_TIME 
let timeLeft = WORK_TIME;
// Thiết lập xem có đang trong 1 quá trình tính giờ nào không
let isRunning = false;
// Đang tính giờ cho lúc tập chung hay nghỉ ngơi
let runningType = "FOCUSING"; // FOCUSING or BREAKING
// Có đang pause lúc chạy hay không
let isPaused = false;

DOM selector

Để có thể tương tác được với DOM thì ta cần lấy được DOM ra ở code javascript đã, Hãy lấy ra tất cả các dom cần tương tác:

const timerEl = document.getElementById("timer");
const startEl = document.getElementById("start");
const clearEl = document.getElementById("clear");
const pauseEl = document.getElementById("pause");
const controlsEl = document.getElementById("controls");
const statusEl = document.getElementById("status");

Mình đặt id cho các thẻ html với mục đích để xác định được DOM cần tương tác, có một số cách khác để có thể select được DOM các bạn có thể thử như sau:

// Sử dụng classname để lấy, ví dụ thẻ .content sẽ lấy bằng cách
const contentElements = document.getElementsByClassName("content");

// Sử dụng tagname để lấy, ví dụ thẻ <h1> sẽ lấy bằng cách
const h1Elements = document.getElementsByTagName("h1");

// Sử dụng css selector để lấy, ví dụ lấy <div id="start"> hoặc các <button>
const startButton = document.querySelector("#start");
const allButtons = document.querySelectorAll("button");

Sau khi lấy được các selector kia bạn có thể kiểm chứng bằng cách console.log các element vừa lấy ra nhé, đây như là một cách để debug xem đã chọn đúng selector chưa, ví dụ:

console.log("timerEl: ", timerEl);

Thử tương tác với DOM xem nào

Mình sẽ viết một số hàm để update DOM mục đích chúng ta sẽ dùng khi state của timer thay đổi và mình cần update lại UI

// Update lại thời gian đếm lùi theo thời gian còn lại (timeLeft)
// Sử dụng element.textContent dể update text
function updateTimerEl() {
  const minutes = Math.floor(timeLeft / 60)
    .toString()
    .padStart(2, "0");
  const seconds = (timeLeft % 60).toString().padStart(2, "0");
  timerEl.textContent = `${minutes}:${seconds}`;
}

// Update lại dòng Status: Chưa chạy = Ready to start?, đang chạy = Stay focused hoặc Let take a break tuỳ giai đoạn
function updateStatusEl() {
  if (!isRunning) {
    statusEl.textContent = "Ready to start?";
  } else {
    statusEl.textContent =
      runningType === "FOCUSING" ? "Stay focused" : "Let take a break";
  }
}

// Update lại hiển thị nút Start, khi đang running sẽ ẩn đi
// Sử dụng element style để update style
function updateStartEl() {
  if (isRunning) {
    startEl.style.display = "none";
  } else {
    startEl.style.display = "block";
  }
}

// Update lại khu vực nút bấm Cancel + Pause/Resume, khi chưa bắt đầu thì ẩn list nút, khi đã bắt đầu thì hiện
function updateControlsEl() {
  if (isRunning) {
    controlsEl.style.display = "flex";
  } else {
    controlsEl.style.display = "none";
  }
}

// Update lại text của nút Pause/Resume khi pause
function updatePauseEl() {
  if (isPaused) {
    pauseEl.textContent = "Resume";
  } else {
    pauseEl.textContent = "Pause";
  }
}

Sau khi có các hàm update DOM thì các bạn có thể test với các hàm này, ví dụ như sau:

timeLeft = 20*60
updateTimerEl()

Nếu đồng hồ hiện 20:00 tức là bạn đã làm đúng, tương tự với các hàm khác

Add thêm các hàm logic

Đã lấy được DOM và update được UI khi cần, vậy giờ mình sẽ viết các hàm logic để tạo ra những lúc khi cần đó, đó sẽ là một nố hàm sau:

// Init Timer cái này sẽ lấy giờ ở config của bạn và hiện lên phần tử đếm giờ
function initTimer() {
  updateTimerEl();
}

// Hàm này reset state của timer về ban đầu và update các element liên quan
function resetTimer() {
  timeLeft = WORK_TIME;
  runningType = "FOCUSING";
  isRunning = false;
  isPaused = false;

  updateTimerEl();
  updateStatusEl();
  updateControlsEl();
  updateStartEl();
  updatePauseEl();
}

// Hàm này update state isRunning và update các element liên quan
function startTimer() {
  isRunning = true;

  updateBodyBg();
  updateStartEl();
  updateControlsEl();
  updateStatusEl();
}

// Hàm này để Pause/Resume và update các element liên quan
function togglePauseTimer() {
  if (isPaused) {
    isPaused = false;
    updatePauseEl();
  } else {
    isPaused = true;
    updatePauseEl();
  }
}

Add thêm logic đếm lùi cho đồng hồ đã

Vẫn thiếu thiếu, chưa có chỗ nào đếm lùi, giảm thời gian thì khi nào cho chạy được?

Mình sẽ sử dụng setInterval chạy lại mỗi 1s và xử lý logic như sau:

  • Đang pause thì thoát
  • Nếu còn timeLeft thì hãy giảm 1 giây và updte lại timerEl
  • Nếu về 0s thì check xem đã xong hết chưa hay đến lúc BREAK
  • Nếu đến BREAK thì update các state và element liên quan
  • Nếu xong hết thì resetTimer
setInterval(() => {
  if (isRunning) {
    if (isPaused) return;

    if (timeLeft > 0) {
      timeLeft--;
      updateTimerEl();
    } else {
      if (runningType === "FOCUSING") {
        runningType = "BREAKING";
        timeLeft = BREAK_TIME;

        updateStatusEl();
        updateTimerEl();
      } else {
        resetTimer();
        updateStatusEl();
        updateTimerEl();
      }
      updateBodyBg();
    }
  }
}, 1000);

Thêm các lắng nghe sự kiện cho các nút là xong

Chúng ta có thể thấy user có thể bấm 3 nút: Start, Clear, Pause/Resume, vậy chúng ta sẽ thêm 3 lắng nghe sự kiện để gọi đến hàm tương ứng:

startEl.addEventListener("click", startTimer);
clearEl.addEventListener("click", resetTimer);
pauseEl.addEventListener("click", togglePauseTimer);

Tổng kết

Trên đây là một ví dụ làm việc với DOM bằng vanilaJs, sẽ có một vài bước mình nhận thấy sau:

  • Gắn một dấu hiệu gì đó vào element HTML mình cần tương tác
  • Select được ra các element đó
  • Cần update gì ở element?
  • Cần update khi nào?

Mình có add thêm một chút âm thanh và đổi màu nền kèm trạn thái để sản phẩm sinh động hơn (các bạn có thể lên mạng search âm thành mp3 nhé ạ), sau đây là code đầy đủ. Cảm ơn các bạn đã đọc.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Pomodoro Timer</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>

<body class="bg-slate-600 transition-all duration-500">
    <div class="text-white font-mono max-w-sm mx-auto">
        <div class="text-xl font-bold text-center p-10">Pomodoro Timer</div>

        <div class="grid gap-6">
            <div id="timer" class="text-8xl text-center"></div>
            <div id="status" class="text-sm text-center">Ready to focus?</div>
            <button id="start" class="text-xl py-2 rounded-full bg-white text-center text-black">Start</button>
            <div id="controls" style="display: none;">
                <div class="grid grid-cols-2 gap-4 w-full">
                    <button id="clear"
                        class="block w-full text-xl py-2 rounded-full bg-white/40 text-center">Clear</button>
                    <button id="pause"
                        class="block w-full text-xl py-2 rounded-full bg-white text-black text-center">Pause</button>
                </div>
            </div>
        </div>
    </div>

    <audio id="start-sound" src="start.mp3"></audio>
    <audio id="finish-sound" src="finish.mp3"></audio>

    <script>const WORK_TIME = 25 * 60;
        const BREAK_TIME = 5 * 60;

        const bodyEl = document.body;
        const timerEl = document.getElementById("timer");
        const startEl = document.getElementById("start");
        const clearEl = document.getElementById("clear");
        const pauseEl = document.getElementById("pause");
        const controlsEl = document.getElementById("controls");
        const statusEl = document.getElementById("status");
        const startSoundEl = document.getElementById("start-sound");
        const finishSoundEl = document.getElementById("finish-sound");

        let timeLeft = WORK_TIME;
        let isRunning = false;
        let runningType = "FOCUSING"; // FOCUSING or BREAKING
        let isPaused = false;

        // ================== Dom functions ==================

        function updateBodyBg() {
            bodyEl.classList.remove("bg-slate-600", "bg-green-600", "bg-red-600");

            if (isRunning) {
                if (runningType === "FOCUSING") {
                    bodyEl.classList.add("bg-red-600");
                } else {
                    bodyEl.classList.add("bg-green-600");
                }
            } else {
                bodyEl.classList.add("bg-slate-600");
            }
        }

        function updateTimerEl() {
            const minutes = Math.floor(timeLeft / 60)
                .toString()
                .padStart(2, "0");
            const seconds = (timeLeft % 60).toString().padStart(2, "0");
            timerEl.textContent = `${minutes}:${seconds}`;
        }

        function updateStatusEl() {
            if (!isRunning) {
                statusEl.textContent = "Ready to start?";
            } else {
                statusEl.textContent =
                    runningType === "FOCUSING" ? "Stay focused" : "Let take a break";
            }
        }

        function updateControlsEl() {
            if (isRunning) {
                controlsEl.style.display = "flex";
            } else {
                controlsEl.style.display = "none";
            }
        }

        function updateStartEl() {
            if (isRunning) {
                startEl.style.display = "none";
            } else {
                startEl.style.display = "block";
            }
        }

        function updatePauseEl() {
            if (isPaused) {
                pauseEl.textContent = "Resume";
            } else {
                pauseEl.textContent = "Pause";
            }
        }

        // ================== Logic functions ==================

        setInterval(() => {
            if (isRunning) {
                if (isPaused) return;

                if (timeLeft > 0) {
                    timeLeft--;
                    updateTimerEl();
                } else {
                    if (runningType === "FOCUSING") {
                        runningType = "BREAKING";
                        timeLeft = BREAK_TIME;

                        finishSoundEl.play();

                        updateStatusEl();
                        updateTimerEl();
                    } else {
                        resetTimer();
                        finishSoundEl.play();
                        updateStatusEl();
                        updateTimerEl();
                    }
                    updateBodyBg();
                }
            }
        }, 1000);

        function initTimer() {
            updateTimerEl();
        }

        function resetTimer() {
            timeLeft = WORK_TIME;
            runningType = "FOCUSING";
            isRunning = false;
            isPaused = false;

            updateTimerEl();
            updateStatusEl();
            updateControlsEl();
            updateStartEl();
            updatePauseEl();
        }

        function startTimer() {
            isRunning = true;

            startSoundEl.play();

            updateBodyBg();
            updateStartEl();
            updateControlsEl();
            updateStatusEl();
        }

        function togglePauseTimer() {
            if (isPaused) {
                isPaused = false;
                updatePauseEl();
            } else {
                isPaused = true;
                updatePauseEl();
            }
        }

        // ================== Event listeners ==================

        startEl.addEventListener("click", startTimer);
        clearEl.addEventListener("click", resetTimer);
        pauseEl.addEventListener("click", togglePauseTimer);

        initTimer();
    </script>
</body>

</html>

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí