Tổng hợp Event (Sự kiện) trong JavaScript

Event là một trong những cơ chế quan trọng nhất của JavaScript khi xây dựng website có tương tác. Người dùng nhấp chuột, nhập dữ liệu, nhấn phím, gửi biểu mẫu, di chuyển chuột, thay đổi kích thước cửa sổ hoặc trang hoàn tất quá trình tải đều có thể tạo ra một sự kiện để JavaScript xử lý.

Nếu chỉ biết cách viết click và gọi một hàm thì chưa đủ để làm việc hiệu quả với Event. Khi ứng dụng lớn hơn, bạn cần hiểu sự kiện được phát sinh ở đâu, JavaScript xác định phần tử nhận sự kiện như thế nào, Event Object chứa những thông tin gì, sự kiện lan truyền ra sao và khi nào nên sử dụng Event Delegation.

Bài viết này của Web Mới hệ thống lại kiến thức Event trong JavaScript theo hướng thực tế: bắt đầu từ nền tảng, đi qua những loại sự kiện thường dùng, sau đó tiến tới cơ chế lan truyền, xử lý nâng cao và các vấn đề về hiệu năng.

Tổng hợp Event (Sự kiện) trong JavaScript
Tổng hợp Event (Sự kiện) trong JavaScript

Event trong JavaScript là gì?

Event có thể hiểu đơn giản là một thông báo về một điều gì đó vừa xảy ra trong môi trường mà JavaScript đang chạy.

Ví dụ, khi người dùng nhấn một nút, trình duyệt phát sinh một sự kiện. JavaScript có thể lắng nghe sự kiện đó và thực hiện một hành động tương ứng.

Mối quan hệ cơ bản có thể hình dung như sau:

  • Event: điều gì đó xảy ra.
  • Event Listener: cơ chế lắng nghe điều đó.
  • Event Handler: hàm được thực thi khi sự kiện xảy ra.
  • Event Object: đối tượng chứa thông tin liên quan đến sự kiện.

Chẳng hạn, người dùng nhấp vào nút "Đăng ký". Sự kiện là hành động nhấp, listener theo dõi hành động đó và handler xử lý công việc như kiểm tra dữ liệu, gửi request hoặc thay đổi giao diện.

Điểm quan trọng là Event không chỉ xuất hiện từ hành động của người dùng. Trình duyệt cũng có thể phát sinh nhiều sự kiện trong quá trình hoạt động của trang.

Vì sao Event quan trọng?

HTML chủ yếu mô tả cấu trúc, CSS đảm nhiệm phần trình bày, còn JavaScript giúp giao diện phản ứng với hành động và trạng thái thay đổi. Event chính là một trong những cầu nối quan trọng để JavaScript biết khi nào cần thực hiện công việc.

Không có cơ chế sự kiện, một giao diện web sẽ rất khó phản ứng linh hoạt với người dùng. Những chức năng quen thuộc như menu mở rộng, modal, tìm kiếm trực tiếp, kiểm tra biểu mẫu, kéo thả, dropdown hoặc các thành phần tương tác đều dựa rất nhiều vào Event.

Cơ chế hoạt động cơ bản của sự kiện

Để hiểu Event tốt, không nên chỉ ghi nhớ tên các sự kiện. Điều quan trọng hơn là hiểu luồng xử lý.

Một tình huống đơn giản thường diễn ra theo các bước:

  1. Người dùng thực hiện một hành động hoặc trình duyệt phát sinh một trạng thái mới.
  2. Trình duyệt tạo thông tin mô tả sự kiện.
  3. Sự kiện được phân phối tới cây DOM theo cơ chế của trình duyệt.
  4. Các listener phù hợp được kiểm tra.
  5. Handler được gọi để xử lý logic.

Ví dụ, khi người dùng nhấp vào một phần tử, JavaScript không đơn giản chỉ "chạy hàm click". Phía sau còn có quá trình xác định mục tiêu của sự kiện và lan truyền sự kiện qua các phần tử liên quan.

Đây là lý do việc hiểu Event Propagation sẽ trở nên rất quan trọng khi bạn bắt đầu xử lý các giao diện phức tạp.

Đăng ký Event Listener bằng addEventListener()

Cách phổ biến và linh hoạt nhất để đăng ký sự kiện là sử dụng addEventListener().

Cú pháp cơ bản:

element.addEventListener("event", handler);

Trong đó:

  • element là đối tượng hoặc phần tử muốn theo dõi.
  • event là tên sự kiện.
  • handler là hàm được thực thi khi sự kiện xảy ra.

Ví dụ:

const button = document.querySelector("#saveButton");

button.addEventListener("click", function () {
    console.log("Nút đã được nhấn");
});

Khi người dùng nhấn vào phần tử có id tương ứng, hàm callback sẽ được gọi.

Truyền hàm xử lý vào listener

Handler có thể được khai báo riêng thay vì viết trực tiếp bên trong addEventListener(). Cách này đặc biệt hữu ích khi một logic cần được tái sử dụng hoặc khi chương trình có nhiều xử lý liên quan.

function handleSave() {
    console.log("Đang lưu dữ liệu");
}

const button = document.querySelector("#saveButton");

button.addEventListener("click", handleSave);

Ở đây cần chú ý rằng phải truyền handleSave chứ không phải gọi handleSave().

Nếu viết:

button.addEventListener("click", handleSave());

hàm sẽ được gọi ngay trong lúc đăng ký listener thay vì chờ sự kiện click xảy ra. Đây là một lỗi cơ bản nhưng rất thường gặp khi mới làm quen với Event.

Event Handler và Event Listener khác nhau thế nào?

Hai khái niệm này thường được sử dụng gần giống nhau nhưng không hoàn toàn đồng nghĩa.

Event Handler thường đề cập đến hàm xử lý được thực thi khi sự kiện xảy ra. Trong khi đó, Event Listener nhấn mạnh cơ chế đăng ký để theo dõi một loại sự kiện.

Ví dụ:

function showMessage() {
    console.log("Xin chào");
}

button.addEventListener("click", showMessage);

Trong ví dụ trên, showMessage là hàm xử lý, còn addEventListener() thiết lập listener cho sự kiện click.

Phân biệt hai khái niệm này sẽ giúp bạn đọc tài liệu JavaScript dễ hơn, đặc biệt khi làm việc với những API có nhiều tùy chọn xử lý sự kiện.

Các cách gắn sự kiện vào phần tử

JavaScript có nhiều cách để xử lý Event, nhưng không phải cách nào cũng phù hợp cho mọi trường hợp.

Gán thông qua thuộc tính sự kiện

Một phần tử có thể được gán handler thông qua thuộc tính như onclick.

const button = document.querySelector("#saveButton");

button.onclick = function () {
    console.log("Đã nhấn nút");
};

Cách này khá dễ hiểu nhưng có hạn chế. Khi gán một handler mới cho cùng thuộc tính, handler cũ sẽ bị thay thế.

button.onclick = function () {
    console.log("Xử lý lần một");
};

button.onclick = function () {
    console.log("Xử lý lần hai");
};

Khi click, chỉ handler thứ hai được thực hiện.

Sử dụng addEventListener()

addEventListener() thường được ưu tiên trong code JavaScript hiện đại vì một phần tử có thể đăng ký nhiều listener cho cùng một loại sự kiện.

button.addEventListener("click", function () {
    console.log("Xử lý lần một");
});

button.addEventListener("click", function () {
    console.log("Xử lý lần hai");
});

Khi sự kiện xảy ra, cả hai listener đều có thể được thực thi theo thứ tự đăng ký phù hợp với cơ chế của Event.

Ngoài ra, addEventListener() còn hỗ trợ nhiều tùy chọn quan trọng như once, capturepassive. Những tùy chọn này sẽ được phân tích sâu hơn ở phần nâng cao.

Event Object chứa những gì?

Khi một sự kiện xảy ra, handler có thể nhận một đối tượng đại diện cho sự kiện. Đối tượng này thường được gọi là Event Object.

button.addEventListener("click", function (event) {
    console.log(event);
});

Đối tượng này cung cấp nhiều thông tin hữu ích, chẳng hạn:

  • Phần tử thực sự phát sinh hoặc nhận sự kiện.
  • Loại sự kiện.
  • Thông tin về chuột.
  • Thông tin về phím được nhấn.
  • Trạng thái của các phím bổ trợ như Shift hoặc Ctrl.
  • Khả năng ngăn hành vi mặc định.
  • Khả năng dừng quá trình lan truyền.

Không phải mọi thuộc tính đều có ý nghĩa với mọi loại Event. Một sự kiện bàn phím sẽ cung cấp những thông tin khác với sự kiện chuột hoặc submit.

event.type

event.type cho biết loại sự kiện đang được xử lý.

button.addEventListener("click", function (event) {
    console.log(event.type);
});

Kết quả trong trường hợp này sẽ là tên của sự kiện click.

Thuộc tính này hữu ích khi một hàm cần xử lý nhiều loại sự kiện hoặc khi debug một hệ thống tương tác phức tạp.

event.target

event.target đại diện cho phần tử mà sự kiện thực sự hướng tới trong luồng sự kiện.

document.addEventListener("click", function (event) {
    console.log(event.target);
});

Điểm cần nhớ là target và phần tử đang chạy listener không nhất thiết phải là cùng một phần tử. Sự khác biệt này trở nên đặc biệt quan trọng khi tìm hiểu Event Bubbling và Event Delegation.

event.currentTarget

event.currentTarget tham chiếu tới phần tử đang có listener được thực thi tại thời điểm đó.

const box = document.querySelector("#box");

box.addEventListener("click", function (event) {
    console.log(event.target);
    console.log(event.currentTarget);
});

Nếu người dùng click vào một phần tử con bên trong box, event.target có thể là phần tử con, trong khi event.currentTarget vẫn là box.

Đây là một trong những điểm cần nắm chắc nếu muốn sử dụng Event Delegation chính xác.

Xóa Event Listener bằng removeEventListener()

Không phải listener nào cũng cần tồn tại trong toàn bộ vòng đời của trang. Khi không còn cần xử lý một sự kiện, bạn có thể loại bỏ listener bằng removeEventListener().

Ví dụ:

function handleClick() {
    console.log("Đã click");
}

button.addEventListener("click", handleClick);

button.removeEventListener("click", handleClick);

Điểm rất quan trọng là hàm truyền vào removeEventListener() phải tham chiếu tới đúng function đã được đăng ký.

Ví dụ sau không hoạt động như nhiều người mới học thường nghĩ:

button.addEventListener("click", function () {
    console.log("Đã click");
});

button.removeEventListener("click", function () {
    console.log("Đã click");
});

Hai function trên là hai đối tượng hàm khác nhau dù phần nội dung giống hệt nhau. Vì vậy listener ban đầu không được loại bỏ.

Khi nào nên dùng addEventListener()?

Trong phần lớn code JavaScript hiện đại, addEventListener() là lựa chọn mặc định để đăng ký sự kiện.

Nó phù hợp khi:

  • Một phần tử cần nhiều listener cho cùng một loại sự kiện.
  • Muốn chủ động quản lý vòng đời của listener.
  • Cần sử dụng các tùy chọn như once, capture hoặc passive.
  • Muốn tách logic xử lý khỏi cấu trúc HTML.
  • Muốn xây dựng code dễ bảo trì hơn khi dự án phát triển.

Đặc biệt, việc tách JavaScript khỏi HTML giúp giao diện có cấu trúc rõ ràng hơn. Thay vì đưa logic trực tiếp vào thuộc tính của phần tử, code có thể được tập trung trong file JavaScript và quản lý theo từng chức năng.

Những sự kiện JavaScript cần biết trước tiên

JavaScript có rất nhiều loại Event. Không cần học thuộc tất cả ngay từ đầu. Quan trọng hơn là nhận diện được nhóm sự kiện phù hợp với từng tình huống.

Nhóm Sự kiện tiêu biểu Mục đích
Chuột click, dblclick, mousedown, mouseup, mousemove Xử lý tương tác bằng chuột
Bàn phím keydown, keyup Theo dõi thao tác bàn phím
Biểu mẫu submit, input, change, reset Xử lý dữ liệu form
Focus focus, blur, focusin, focusout Theo dõi trạng thái tập trung của phần tử
Trang DOMContentLoaded, load, beforeunload Theo dõi trạng thái tài liệu và cửa sổ
Clipboard copy, cut, paste Xử lý thao tác với clipboard
Kéo thả dragstart, dragover, drop Xây dựng giao diện drag & drop

Mỗi nhóm có đặc điểm riêng. Chẳng hạn, click phù hợp với thao tác chọn hoặc kích hoạt, trong khi input phù hợp với việc theo dõi giá trị đang thay đổi trong ô nhập.

Phân biệt click, mousedown và mouseup

Ba sự kiện này liên quan đến thao tác chuột nhưng xảy ra ở những thời điểm khác nhau.

  • mousedown: xảy ra khi nút chuột được nhấn xuống.
  • mouseup: xảy ra khi nút chuột được thả ra.
  • click: đại diện cho thao tác click hoàn chỉnh theo quy tắc của trình duyệt.

Ví dụ:

button.addEventListener("mousedown", function () {
    console.log("Chuột được nhấn xuống");
});

button.addEventListener("mouseup", function () {
    console.log("Chuột được thả ra");
});

button.addEventListener("click", function () {
    console.log("Hoàn tất click");
});

Trong giao diện thông thường, click thường là lựa chọn phù hợp nhất. mousedown và mouseup chỉ nên được sử dụng khi logic thực sự cần phân biệt thời điểm nhấn và thả chuột.

mouseenter, mouseleave và mouseover, mouseout

Các sự kiện liên quan đến việc di chuyển chuột rất dễ gây nhầm lẫn vì tên gần giống nhau.

mouseentermouseleave tập trung vào việc con trỏ đi vào hoặc rời khỏi phần tử.

Trong khi đó, mouseovermouseout có liên quan tới cơ chế lan truyền và có thể được kích hoạt khi con trỏ di chuyển giữa phần tử cha và các phần tử con.

Với một thành phần giao diện đơn giản như tooltip hoặc hiệu ứng hover cần xử lý bằng JavaScript, việc lựa chọn đúng loại Event giúp tránh những lần callback không cần thiết.

Sự kiện bàn phím: keydown và keyup

Đối với bàn phím, hai sự kiện quan trọng nhất cần biết là keydownkeyup.

keydown xảy ra khi phím được nhấn xuống, còn keyup xảy ra khi phím được thả ra.

document.addEventListener("keydown", function (event) {
    console.log(event.key);
});

Thông qua Event Object, bạn có thể xác định phím mà người dùng vừa thao tác.

Ví dụ, một ứng dụng tìm kiếm có thể kiểm tra phím Enter để thực hiện tìm kiếm:

searchInput.addEventListener("keydown", function (event) {
    if (event.key === "Enter") {
        console.log("Thực hiện tìm kiếm");
    }
});

Không nên xây dựng logic bàn phím dựa hoàn toàn vào mã phím cũ khi không cần thiết. Trong nhiều trường hợp, event.key là thông tin dễ hiểu và phù hợp hơn với mục đích xử lý.

Xử lý dữ liệu biểu mẫu với Event

Biểu mẫu là nơi Event xuất hiện với tần suất rất cao. Từ lúc người dùng bắt đầu nhập dữ liệu cho đến khi gửi form, JavaScript có thể theo dõi nhiều trạng thái khác nhau.

Ba sự kiện cần phân biệt rõ là input, changesubmit. Chúng có mục đích khác nhau và việc chọn sai sự kiện có thể khiến giao diện phản hồi không đúng thời điểm mong muốn.

Sự kiện input

input xảy ra khi giá trị của một trường nhập liệu thay đổi do thao tác của người dùng. Đây là lựa chọn phù hợp khi cần phản ứng gần như ngay lập tức với nội dung đang được nhập.

const username = document.querySelector("#username");

username.addEventListener("input", function (event) {
    console.log(event.target.value);
});

Ví dụ, khi xây dựng ô tìm kiếm trực tiếp, bạn có thể sử dụng input để cập nhật kết quả ngay trong lúc người dùng nhập.

const search = document.querySelector("#search");
const result = document.querySelector("#result");

search.addEventListener("input", function () {
    result.textContent = search.value;
});

Ưu điểm của cách này là giao diện phản hồi nhanh. Tuy nhiên, nếu mỗi lần nhập lại thực hiện một request lên máy chủ thì có thể tạo ra quá nhiều request. Trong trường hợp đó, nên kết hợp kỹ thuật debounce hoặc throttle thay vì xử lý nặng ở mọi lần phát sinh Event.

Sự kiện change

change cũng liên quan đến sự thay đổi giá trị nhưng thời điểm phát sinh phụ thuộc vào loại phần tử và cách người dùng tương tác. Nó thường phù hợp khi bạn chỉ cần xử lý sau khi người dùng hoàn tất việc thay đổi một giá trị.

const category = document.querySelector("#category");

category.addEventListener("change", function (event) {
    console.log(event.target.value);
});

Với các phần tử lựa chọn như select, change thường là lựa chọn tự nhiên khi người dùng chọn một giá trị mới.

const category = document.querySelector("#category");

category.addEventListener("change", function () {
    console.log("Danh mục đã thay đổi:", category.value);
});

Không nên mặc định dùng change cho mọi trường nhập liệu. Nếu yêu cầu là phản hồi ngay khi từng ký tự thay đổi, input thường phù hợp hơn.

Sự kiện submit

submit được sử dụng để xử lý thời điểm biểu mẫu được gửi. Đây là điểm rất quan trọng trong các form đăng nhập, đăng ký, liên hệ, đặt hàng hoặc tìm kiếm.

const form = document.querySelector("#registerForm");

form.addEventListener("submit", function (event) {
    event.preventDefault();

    console.log("Form đã được gửi");
});

event.preventDefault() được sử dụng để ngăn hành vi mặc định của trình duyệt. Với form, hành vi mặc định có thể bao gồm việc gửi dữ liệu và điều hướng theo thuộc tính action.

Việc xử lý submit ở cấp form thường tốt hơn việc chỉ bắt click trên nút gửi. Người dùng có thể gửi form bằng nhiều cách, chẳng hạn nhấn Enter trong trường nhập liệu, vì vậy logic nghiệp vụ nên gắn với submit thay vì phụ thuộc riêng vào nút.

preventDefault() và khi nào nên sử dụng?

preventDefault() không xóa Event và cũng không ngăn sự kiện tiếp tục lan truyền. Nó chỉ yêu cầu trình duyệt không thực hiện hành vi mặc định gắn với sự kiện đó.

Ví dụ, với một liên kết:

const link = document.querySelector("#productLink");

link.addEventListener("click", function (event) {
    event.preventDefault();

    console.log("Không chuyển trang");
});

Trong trường hợp này, sự kiện click vẫn xảy ra và listener vẫn chạy, nhưng hành động điều hướng mặc định của liên kết bị ngăn lại.

Điểm này cần phân biệt với stopPropagation(). Hai phương thức có mục đích hoàn toàn khác nhau.

Focus và Blur trong biểu mẫu

Các Event focusblur rất hữu ích khi xây dựng giao diện nhập liệu.

focus xảy ra khi phần tử nhận được trạng thái tập trung. blur xảy ra khi phần tử mất trạng thái tập trung.

const email = document.querySelector("#email");

email.addEventListener("focus", function () {
    console.log("Người dùng đang nhập email");
});

email.addEventListener("blur", function () {
    console.log("Người dùng đã rời khỏi ô email");
});

Đây là cơ sở để triển khai nhiều giao diện như hiển thị hướng dẫn khi người dùng bắt đầu nhập hoặc kiểm tra dữ liệu sau khi họ rời khỏi trường.

Kiểm tra dữ liệu khi người dùng rời trường nhập

Không phải validation nào cũng cần thực hiện sau từng ký tự. Với một số trường, kiểm tra khi blur mang lại trải nghiệm tự nhiên hơn.

const email = document.querySelector("#email");
const message = document.querySelector("#emailMessage");

email.addEventListener("blur", function () {
    if (email.value.trim() === "") {
        message.textContent = "Vui lòng nhập email";
        return;
    }

    message.textContent = "";
});

Tuy nhiên, validation phía trình duyệt chỉ nhằm hỗ trợ trải nghiệm người dùng. Dữ liệu gửi lên máy chủ vẫn cần được kiểm tra lại ở phía server.

Focusin và Focusout khác gì focus và blur?

focusinfocusout cũng liên quan đến việc phần tử nhận hoặc mất focus nhưng có một điểm quan trọng: chúng có thể tham gia vào cơ chế bubbling.

Điều này khiến focusin và focusout đặc biệt hữu ích khi muốn theo dõi nhiều phần tử thông qua một listener đặt ở phần tử cha.

const form = document.querySelector("#userForm");

form.addEventListener("focusin", function (event) {
    console.log("Focus:", event.target);
});

form.addEventListener("focusout", function (event) {
    console.log("Mất focus:", event.target);
});

Cách làm này trở nên có giá trị khi một form chứa nhiều trường và bạn không muốn đăng ký listener riêng cho từng input.

Sự kiện thay đổi kích thước cửa sổ

resize được phát sinh khi kích thước viewport thay đổi. Nó thường được sử dụng khi giao diện cần phản ứng với việc người dùng thay đổi kích thước cửa sổ trình duyệt.

window.addEventListener("resize", function () {
    console.log(window.innerWidth);
    console.log(window.innerHeight);
});

Ví dụ, một thành phần có thể cần thay đổi cách hiển thị dựa trên chiều rộng viewport.

Tuy nhiên, resize có thể phát sinh rất nhiều lần trong quá trình người dùng kéo thay đổi kích thước cửa sổ. Nếu mỗi lần Event lại thực hiện phép tính nặng hoặc cập nhật DOM lớn, hiệu năng có thể bị ảnh hưởng.

Trong những trường hợp như vậy, nên giới hạn tần suất xử lý bằng throttle hoặc thiết kế lại logic để trình duyệt không phải thực hiện công việc không cần thiết.

DOMContentLoaded và load

Hai sự kiện này thường xuất hiện khi khởi tạo ứng dụng nhưng chúng không có cùng ý nghĩa.

DOMContentLoaded xảy ra khi HTML đã được trình duyệt phân tích và DOM đã sẵn sàng để JavaScript thao tác. Nó không yêu cầu tất cả tài nguyên bên ngoài như hình ảnh phải tải xong.

document.addEventListener("DOMContentLoaded", function () {
    console.log("DOM đã sẵn sàng");
});

load thường chờ toàn bộ tài nguyên cần thiết của trang hoàn tất quá trình tải.

window.addEventListener("load", function () {
    console.log("Trang và tài nguyên đã tải xong");
});

Trong nhiều trường hợp cần thao tác với DOM, DOMContentLoaded đã đáp ứng đủ nhu cầu. Không nên sử dụng load chỉ vì muốn "chắc chắn" nếu logic thực tế không phụ thuộc vào hình ảnh hoặc tài nguyên khác.

Sự kiện clipboard

JavaScript cũng cung cấp các Event liên quan đến thao tác clipboard như copy, cutpaste.

Ví dụ, có thể phát hiện khi người dùng dán dữ liệu vào một trường:

const input = document.querySelector("#coupon");

input.addEventListener("paste", function (event) {
    console.log("Người dùng vừa dán dữ liệu");
});

Trong ứng dụng thực tế, clipboard Event có thể được dùng để kiểm soát hoặc phản hồi giao diện khi người dùng sao chép, cắt hoặc dán nội dung.

Tuy nhiên, không nên mặc định ngăn thao tác clipboard chỉ để ép người dùng nhập dữ liệu bằng tay. Việc can thiệp vào hành vi quen thuộc của trình duyệt có thể làm trải nghiệm sử dụng kém đi, đặc biệt với biểu mẫu dài hoặc dữ liệu có tính chính xác cao.

Sự kiện drag và drop

Drag & Drop cho phép xây dựng những giao diện như kéo tệp vào vùng tải lên, sắp xếp danh sách hoặc di chuyển một đối tượng giữa các khu vực.

Một số Event quan trọng gồm:

  • dragstart: bắt đầu thao tác kéo.
  • drag: đang trong quá trình kéo.
  • dragend: kết thúc thao tác kéo.
  • dragenter: đối tượng kéo đi vào vùng nhận.
  • dragover: đối tượng kéo đang di chuyển trên vùng nhận.
  • dragleave: đối tượng rời khỏi vùng nhận.
  • drop: dữ liệu được thả vào vùng nhận.

Vì sao dragover thường đi cùng preventDefault()?

Để một phần tử trở thành vùng có thể nhận thao tác drop theo cơ chế Drag & Drop của trình duyệt, thường cần ngăn hành vi mặc định trong dragover.

const dropZone = document.querySelector("#dropZone");

dropZone.addEventListener("dragover", function (event) {
    event.preventDefault();
});

dropZone.addEventListener("drop", function (event) {
    event.preventDefault();

    console.log("Dữ liệu đã được thả");
});

Đây là ví dụ điển hình cho thấy hiểu hành vi mặc định của trình duyệt quan trọng như thế nào khi làm việc với Event.

Pointer Event và tương tác hiện đại

Ngoài các Event riêng cho chuột, JavaScript còn có Pointer Event. Nhóm này giúp thống nhất cách xử lý nhiều loại thiết bị trỏ như chuột, bút cảm ứng và màn hình cảm ứng.

Một số Event tiêu biểu gồm:

  • pointerdown: thiết bị trỏ bắt đầu tương tác.
  • pointerup: thiết bị trỏ kết thúc tương tác.
  • pointermove: vị trí thiết bị trỏ thay đổi.
  • pointerenter: thiết bị trỏ đi vào vùng phần tử.
  • pointerleave: thiết bị trỏ rời khỏi vùng phần tử.
  • pointercancel: thao tác bị hệ thống hủy.

Ví dụ:

const canvasArea = document.querySelector("#canvasArea");

canvasArea.addEventListener("pointerdown", function (event) {
    console.log("Bắt đầu tương tác:", event.pointerType);
});

event.pointerType có thể giúp ứng dụng biết kiểu thiết bị trỏ đang được sử dụng. Điều này hữu ích khi xây dựng giao diện vẽ, kéo thả hoặc các tương tác cần hỗ trợ nhiều thiết bị.

Sự kiện touch trên thiết bị cảm ứng

Các thiết bị cảm ứng có nhóm Event riêng như touchstart, touchmovetouchend.

Ví dụ:

const slider = document.querySelector("#slider");

slider.addEventListener("touchstart", function () {
    console.log("Bắt đầu chạm");
});

slider.addEventListener("touchend", function () {
    console.log("Kết thúc chạm");
});

Trong những ứng dụng mới cần hỗ trợ nhiều loại thiết bị, Pointer Event thường đáng cân nhắc vì nó cung cấp một mô hình thống nhất hơn thay vì phải xây dựng nhiều nhánh xử lý riêng cho mouse và touch.

Sự kiện double click

dblclick xảy ra khi người dùng thực hiện thao tác nhấp đúp theo cơ chế nhận diện của trình duyệt.

const item = document.querySelector("#item");

item.addEventListener("dblclick", function () {
    console.log("Nhấp đúp vào phần tử");
});

Không nên dùng dblclick nếu cùng một giao diện đã có hành động click quan trọng mà người dùng có thể vô tình kích hoạt trước đó. Với thiết bị cảm ứng, khái niệm nhấp đúp cũng không phải lúc nào mang lại trải nghiệm tương đương chuột.

Nhóm Event cho trạng thái cửa sổ và tài liệu

Ngoài tương tác trực tiếp, JavaScript còn có thể theo dõi trạng thái của tài liệu và cửa sổ trình duyệt.

Một Event đáng chú ý là visibilitychange. Nó cho biết trạng thái hiển thị của tài liệu thay đổi, chẳng hạn người dùng chuyển sang tab khác.

document.addEventListener("visibilitychange", function () {
    if (document.hidden) {
        console.log("Trang đang bị ẩn");
    } else {
        console.log("Trang đang được hiển thị");
    }
});

Cơ chế này có thể hữu ích cho các ứng dụng cần tạm dừng một số công việc khi người dùng rời tab và tiếp tục khi họ quay lại.

Event một lần với tùy chọn once

Có những thao tác chỉ cần xảy ra đúng một lần. Thay vì tự tạo biến để kiểm tra xem handler đã chạy hay chưa, có thể sử dụng tùy chọn once.

const button = document.querySelector("#startButton");

button.addEventListener("click", function () {
    console.log("Chỉ xử lý một lần");
}, {
    once: true
});

Sau khi listener được thực thi lần đầu, trình duyệt sẽ tự động loại bỏ listener đó.

Cách này đặc biệt phù hợp với các hành động như khởi tạo một thành phần, hiển thị hướng dẫn lần đầu hoặc thực hiện một thao tác chỉ được phép kích hoạt một lần trong vòng đời của phần tử.

Event Listener với AbortController

Khi một thành phần giao diện có nhiều listener, việc quản lý và loại bỏ từng listener riêng lẻ có thể trở nên khó chịu. AbortController cung cấp một cách thuận tiện để hủy nhiều listener thông qua cùng một signal.

const controller = new AbortController();

button.addEventListener("click", function () {
    console.log("Click");
}, {
    signal: controller.signal
});

window.addEventListener("resize", function () {
    console.log("Resize");
}, {
    signal: controller.signal
});

controller.abort();

Sau khi gọi abort(), các listener sử dụng signal tương ứng sẽ được loại bỏ.

Cách quản lý này đặc biệt hữu ích trong những ứng dụng có vòng đời component rõ ràng. Khi component bị hủy, chỉ cần abort controller thay vì phải giữ và remove từng listener một cách thủ công.

Event Propagation là gì?

Khi một sự kiện xảy ra trên một phần tử nằm bên trong nhiều phần tử khác, sự kiện không chỉ đơn giản được xử lý tại đúng phần tử đó. Nó còn có thể di chuyển qua các phần tử trong cây DOM theo một quá trình gọi là Event Propagation.

Đây là nền tảng để hiểu vì sao một click trên phần tử con đôi khi lại kích hoạt listener của phần tử cha.

Quá trình lan truyền sự kiện thường được nhìn qua ba giai đoạn:

  1. Capturing: sự kiện đi từ vùng tổ tiên phía trên xuống phần tử mục tiêu.
  2. Target: sự kiện đến phần tử mục tiêu.
  3. Bubbling: sự kiện từ phần tử mục tiêu lan ngược lên các phần tử tổ tiên.

Trong code thực tế, giai đoạn bubbling xuất hiện thường xuyên nhất vì listener mặc định của addEventListener() được xử lý theo cơ chế bubbling.

Event Bubbling

Event Bubbling là quá trình một sự kiện phát sinh ở phần tử con tiếp tục lan lên các phần tử cha nếu sự kiện đó hỗ trợ bubbling và quá trình lan truyền chưa bị dừng.

Giả sử cấu trúc giao diện có một nút nằm trong một vùng chứa:

<div id="box">
    <button id="button">Nhấn</button>
</div>

Nếu cả button và div đều có listener click:

const box = document.querySelector("#box");
const button = document.querySelector("#button");

box.addEventListener("click", function () {
    console.log("Box");
});

button.addEventListener("click", function () {
    console.log("Button");
});

Khi nhấn button, kết quả thường sẽ là:

Button
Box

Nguyên nhân là click bắt đầu tại button rồi tiếp tục bubbling lên phần tử cha.

Vì sao Event Bubbling hữu ích?

Thoạt nhìn, bubbling có vẻ chỉ là một cơ chế cần tránh. Thực tế, đây lại là nền tảng của một kỹ thuật cực kỳ hữu ích là Event Delegation.

Thay vì đăng ký listener cho hàng trăm phần tử con, bạn có thể đăng ký một listener trên phần tử cha và dựa vào event.target để xác định phần tử nào đã được tương tác.

Cách này có thể làm giảm số lượng listener và đặc biệt hữu ích với những danh sách có phần tử được tạo động.

Event Capturing

Event Capturing là giai đoạn sự kiện di chuyển từ phần tử tổ tiên xuống phần tử mục tiêu.

Listener mặc định thường xử lý ở giai đoạn bubbling. Nếu muốn listener tham gia vào capturing, có thể sử dụng tùy chọn capture: true.

const box = document.querySelector("#box");
const button = document.querySelector("#button");

box.addEventListener("click", function () {
    console.log("Box");
}, {
    capture: true
});

button.addEventListener("click", function () {
    console.log("Button");
});

Trong tình huống này, listener của box có thể được thực thi trước listener của button vì nó tham gia giai đoạn capturing.

Capturing không phải là thứ cần sử dụng thường xuyên trong mọi ứng dụng. Tuy nhiên, hiểu nó rất quan trọng khi xử lý các cấu trúc DOM lồng nhau, các component phức tạp hoặc những tình huống cần kiểm soát chính xác thứ tự xử lý Event.

Thứ tự xử lý khi sự kiện đi qua DOM

Giả sử DOM có cấu trúc:

<section id="section">
    <div id="box">
        <button id="button">Nhấn</button>
    </div>
</section>

Click vào button có thể hình dung theo hướng:

  1. section nhận sự kiện ở giai đoạn capturing.
  2. box nhận sự kiện ở giai đoạn capturing.
  3. button trở thành target.
  4. button được xử lý.
  5. box nhận sự kiện ở giai đoạn bubbling.
  6. section nhận sự kiện ở giai đoạn bubbling.

Không phải mọi Event đều có hành vi giống nhau và không phải listener nào cũng chạy ở cả hai giai đoạn. Tuy nhiên, mô hình này giúp giải thích phần lớn các hiện tượng thường gặp khi xử lý Event trong DOM.

event.target và event.currentTarget trong Event Bubbling

Đây là một cặp thuộc tính rất dễ nhầm lẫn.

event.target cho biết phần tử khởi đầu hoặc mục tiêu của sự kiện. event.currentTarget cho biết phần tử đang thực thi listener hiện tại.

Ví dụ:

const box = document.querySelector("#box");

box.addEventListener("click", function (event) {
    console.log("target:", event.target);
    console.log("currentTarget:", event.currentTarget);
});

Nếu click trực tiếp vào box thì hai giá trị có thể cùng tham chiếu tới box.

Nhưng nếu click vào một phần tử con bên trong box, target sẽ là phần tử con còn currentTarget vẫn là box.

Đây chính là nguyên lý mà Event Delegation dựa vào.

Dừng lan truyền với stopPropagation()

stopPropagation() yêu cầu trình duyệt dừng việc lan truyền Event qua các phần tử khác trong luồng propagation.

const box = document.querySelector("#box");
const button = document.querySelector("#button");

box.addEventListener("click", function () {
    console.log("Box");
});

button.addEventListener("click", function (event) {
    event.stopPropagation();

    console.log("Button");
});

Khi click button, listener của button vẫn có thể chạy nhưng sự kiện không tiếp tục bubbling lên box.

Điều quan trọng là không nên sử dụng stopPropagation() như một cách sửa lỗi mặc định. Việc chặn propagation bừa bãi có thể làm phá vỡ logic của các component khác đang phụ thuộc vào Event Bubbling.

stopImmediatePropagation() khác stopPropagation() thế nào?

stopPropagation() ngăn sự kiện tiếp tục lan truyền sang các phần tử khác, nhưng nó không nhất thiết ngăn những listener khác đang được đăng ký trên cùng phần tử.

stopImmediatePropagation() mạnh hơn: nó yêu cầu dừng cả quá trình lan truyền và các listener tiếp theo trên cùng phần tử.

button.addEventListener("click", function (event) {
    console.log("Listener 1");

    event.stopImmediatePropagation();
});

button.addEventListener("click", function () {
    console.log("Listener 2");
});

Trong trường hợp này, listener thứ hai có thể không được thực thi vì quá trình xử lý trên cùng target đã bị dừng ngay lập tức.

Đây là API nên dùng có chủ đích. Nếu chỉ muốn ngăn click tiếp tục bubbling lên phần tử cha, stopPropagation() thường đã đủ.

Event Delegation là gì?

Event Delegation là kỹ thuật đặt listener trên một phần tử cha để xử lý Event phát sinh từ nhiều phần tử con.

Thay vì làm như sau:

const items = document.querySelectorAll(".item");

items.forEach(function (item) {
    item.addEventListener("click", function () {
        console.log("Đã chọn item");
    });
});

Có thể đặt một listener ở phần tử cha:

const list = document.querySelector("#list");

list.addEventListener("click", function (event) {
    if (event.target.matches(".item")) {
        console.log("Đã chọn item");
    }
});

Ở đây, Event Bubbling đưa click từ item lên list. Listener trên list kiểm tra target để xác định phần tử thực sự được tương tác.

Lợi ích của Event Delegation

Event Delegation đặc biệt hữu ích khi có số lượng phần tử con lớn hoặc khi các phần tử được tạo sau khi trang đã khởi tạo.

  • Giảm số lượng listener cần đăng ký.
  • Không cần đăng ký lại listener cho từng phần tử mới được tạo.
  • Phù hợp với danh sách động.
  • Giúp quản lý các nhóm phần tử tương tác tập trung hơn.

Ví dụ một danh sách sản phẩm được thêm bằng JavaScript sau khi người dùng thực hiện tìm kiếm. Nếu listener được gắn trực tiếp vào các phần tử tại thời điểm khởi tạo, những phần tử mới có thể không có listener. Với Event Delegation, listener trên phần tử cha vẫn có thể xử lý chúng nếu cấu trúc DOM phù hợp.

Event Delegation với phần tử con bên trong

Một lỗi phổ biến là kiểm tra event.target bằng matches() rồi cho rằng target luôn là phần tử mong muốn.

Ví dụ:

<button class="item">
    <span>Xem chi tiết</span>
</button>

Nếu người dùng click vào span, event.target có thể là span thay vì button.

Đoạn code sau có thể không hoạt động như mong muốn:

list.addEventListener("click", function (event) {
    if (event.target.matches(".item")) {
        console.log("Đã chọn item");
    }
});

Một cách linh hoạt hơn là tìm phần tử tổ tiên phù hợp bằng closest().

list.addEventListener("click", function (event) {
    const item = event.target.closest(".item");

    if (!item || !list.contains(item)) {
        return;
    }

    console.log("Đã chọn item");
});

Đây là mẫu xử lý rất hữu ích trong các giao diện có button chứa icon, span hoặc nhiều phần tử con khác.

Event Delegation với dữ liệu của phần tử

Khi xây dựng danh sách, mỗi phần tử có thể chứa một mã hoặc thông tin xác định riêng. Event Delegation có thể kết hợp với thuộc tính dữ liệu để biết người dùng vừa chọn đối tượng nào.

<ul id="productList">
    <li class="product" data-id="101">Sản phẩm A</li>
    <li class="product" data-id="102">Sản phẩm B</li>
    <li class="product" data-id="103">Sản phẩm C</li>
</ul>

JavaScript:

const productList = document.querySelector("#productList");

productList.addEventListener("click", function (event) {
    const product = event.target.closest(".product");

    if (!product || !productList.contains(product)) {
        return;
    }

    console.log("Mã sản phẩm:", product.dataset.id);
});

Cách này cho phép một listener duy nhất xử lý toàn bộ danh sách mà vẫn xác định được phần tử cụ thể.

Event Listener Options

Tham số thứ ba của addEventListener() có thể là một object chứa các tùy chọn kiểm soát cách listener hoạt động.

element.addEventListener("click", handler, {
    once: true,
    capture: false,
    passive: true
});

Ba tùy chọn thường gặp là once, capturepassive.

capture

capture: true yêu cầu listener tham gia vào giai đoạn capturing thay vì xử lý theo cơ chế bubbling mặc định.

document.addEventListener("click", function () {
    console.log("Capturing");
}, {
    capture: true
});

Capture hữu ích khi cần quan sát hoặc xử lý Event trước khi nó đến target theo luồng thông thường.

passive

passive: true thông báo rằng listener sẽ không gọi preventDefault() cho Event tương ứng.

window.addEventListener("touchmove", function () {
    console.log("Đang di chuyển");
}, {
    passive: true
});

Tùy chọn này có ý nghĩa đặc biệt với một số Event liên quan đến thao tác cuộn hoặc cảm ứng, nơi trình duyệt có thể tối ưu việc xử lý khi biết listener không cố ngăn hành vi mặc định.

Nếu listener được đăng ký passive, không nên gọi preventDefault() trong listener đó.

Event Listener và hiệu năng

Event thường rất nhẹ nếu handler chỉ thực hiện một công việc đơn giản. Vấn đề bắt đầu xuất hiện khi một Event phát sinh liên tục nhưng handler lại thực hiện nhiều thao tác tốn tài nguyên.

Các Event cần đặc biệt chú ý gồm:

  • mousemove
  • pointermove
  • touchmove
  • scroll
  • resize
  • input trong các chức năng tìm kiếm trực tiếp

Ví dụ, không nên thực hiện một phép tính phức tạp hoặc cập nhật hàng loạt DOM sau mỗi pixel con trỏ di chuyển nếu điều đó không thực sự cần thiết.

Throttle và debounce trong xử lý Event

Throttle giới hạn tần suất một hàm được thực thi trong một khoảng thời gian. Nó phù hợp với các Event diễn ra liên tục khi vẫn muốn phản hồi trong quá trình tương tác.

Debounce trì hoãn việc thực thi cho đến khi Event ngừng xảy ra trong một khoảng thời gian nhất định. Nó phù hợp với những tác vụ chỉ nên chạy sau khi người dùng tạm dừng thao tác.

Ví dụ, tìm kiếm trực tiếp thường có thể dùng debounce:

let timer;

searchInput.addEventListener("input", function () {
    clearTimeout(timer);

    timer = setTimeout(function () {
        console.log("Thực hiện tìm kiếm:", searchInput.value);
    }, 300);
});

Nếu người dùng liên tục nhập, timer cũ bị hủy và chỉ khi họ tạm dừng khoảng thời gian đã định thì logic tìm kiếm mới được thực hiện.

Event Listener có thể gây rò rỉ bộ nhớ không?

Event Listener không tự động đồng nghĩa với memory leak. Tuy nhiên, trong những ứng dụng có vòng đời component phức tạp, việc giữ listener hoặc tham chiếu tới những đối tượng không còn cần thiết có thể khiến việc quản lý bộ nhớ trở nên khó khăn.

Đặc biệt cần chú ý khi:

  • Component được tạo và hủy liên tục.
  • Listener được đăng ký nhiều lần trong quá trình khởi tạo.
  • Listener toàn cục được thêm nhưng không bao giờ được dọn dẹp.
  • Một component giữ tham chiếu tới dữ liệu lớn thông qua closure.

Trong những trường hợp này, removeEventListener() hoặc AbortController có thể giúp quản lý vòng đời listener rõ ràng hơn.

Kiểm tra Event bằng DevTools

Khi một giao diện không phản ứng như mong đợi, việc đoán nguyên nhân thường mất nhiều thời gian hơn việc quan sát trực tiếp Event.

DevTools của trình duyệt có thể hỗ trợ kiểm tra DOM, listener và quá trình thực thi JavaScript.

Một quy trình debug hiệu quả có thể bắt đầu bằng việc kiểm tra:

  1. Phần tử có thực sự tồn tại khi code đăng ký listener không.
  2. Listener có được đăng ký đúng loại Event không.
  3. Handler có thực sự được gọi không.
  4. event.target có đúng phần tử mong muốn không.
  5. Event có bị stopPropagation() hoặc preventDefault() ở nơi khác không.
  6. Listener có bị đăng ký nhiều lần không.

Thay vì thêm hàng loạt console.log() ở mọi nơi, hãy xác định chính xác phần nào trong luồng Event đang không hoạt động. Cách debug theo luồng sẽ đặc biệt hữu ích với những giao diện có nhiều phần tử lồng nhau.

Tạo sự kiện tùy chỉnh với CustomEvent

Không phải mọi hành động trong ứng dụng đều tương ứng với một Event có sẵn của trình duyệt. Khi xây dựng ứng dụng phức tạp, bạn có thể cần tạo một loại sự kiện riêng để thông báo rằng một nghiệp vụ nào đó đã hoàn thành.

JavaScript cung cấp CustomEvent cho mục đích này.

const event = new CustomEvent("productAdded");

document.dispatchEvent(event);

Phần code trên tạo một Event có tên productAdded rồi phát nó trên document.

Để lắng nghe:

document.addEventListener("productAdded", function () {
    console.log("Một sản phẩm vừa được thêm");
});

Custom Event đặc biệt hữu ích khi các phần khác nhau trong ứng dụng cần giao tiếp với nhau mà không muốn phụ thuộc quá chặt vào một hàm cụ thể.

Truyền dữ liệu bằng detail

CustomEvent có thể mang dữ liệu thông qua thuộc tính detail.

const event = new CustomEvent("productAdded", {
    detail: {
        id: 101,
        name: "Bàn phím"
    }
});

document.dispatchEvent(event);

Listener có thể lấy dữ liệu từ event.detail:

document.addEventListener("productAdded", function (event) {
    console.log(event.detail.id);
    console.log(event.detail.name);
});

Cách này cho phép một module phát thông báo mà không cần biết module nhận sẽ xử lý dữ liệu như thế nào.

Phát Event bằng dispatchEvent()

dispatchEvent() cho phép chương trình chủ động phát một Event trên một EventTarget.

const button = document.querySelector("#button");

const event = new Event("customAction");

button.dispatchEvent(event);

Nếu button có listener tương ứng, listener đó sẽ được gọi.

button.addEventListener("customAction", function () {
    console.log("Custom Event đã được phát");
});

dispatchEvent() giúp phân biệt hai khái niệm quan trọng: Event không nhất thiết phải xuất phát trực tiếp từ thao tác của người dùng. Code JavaScript cũng có thể chủ động phát một Event khi một điều kiện nghiệp vụ xảy ra.

EventTarget là nền tảng của hệ thống sự kiện

EventTarget là interface cung cấp cơ chế đăng ký và phát sự kiện. Các đối tượng quen thuộc như element, document và window đều có khả năng sử dụng các phương thức của EventTarget.

Ba phương thức quan trọng nhất là:

  • addEventListener(): đăng ký listener.
  • removeEventListener(): loại bỏ listener.
  • dispatchEvent(): phát một Event.

Hiểu EventTarget giúp bạn nhìn Event dưới góc độ rộng hơn thay vì chỉ nghĩ rằng Event là thứ dành riêng cho HTML element.

Event có thể được hủy hay không?

Một số Event có thể được hủy hành vi mặc định. Khả năng này liên quan tới thuộc tính cancelable.

button.addEventListener("click", function (event) {
    console.log(event.cancelable);
});

Nếu một Event không thể hủy hành vi mặc định, việc gọi preventDefault() sẽ không đạt được mục đích mong muốn.

Do đó, khi xây dựng logic xử lý Event nâng cao, không nên mặc định rằng mọi Event đều có thể ngăn hành vi mặc định.

Kiểm tra Event đã bị ngăn mặc định hay chưa

Thuộc tính defaultPrevented cho biết preventDefault() đã được gọi để ngăn hành vi mặc định của Event hay chưa.

document.addEventListener("click", function (event) {
    if (event.defaultPrevented) {
        console.log("Hành vi mặc định đã bị ngăn");
    }
});

Thuộc tính này có thể hữu ích khi nhiều phần xử lý cùng tham gia vào một Event và cần biết liệu một listener trước đó đã yêu cầu trình duyệt bỏ qua hành vi mặc định hay chưa.

Phân biệt Event Object và phần tử phát sinh Event

Trong các đoạn code Event, rất dễ nhầm giữa đối tượng Event và phần tử DOM.

Ví dụ:

button.addEventListener("click", function (event) {
    console.log(event);
    console.log(event.target);
});

event là đối tượng chứa thông tin về sự kiện. event.target là phần tử liên quan đến mục tiêu của sự kiện.

Vì vậy, không nên coi event như chính phần tử DOM. Chúng có vai trò khác nhau và cung cấp những loại thông tin khác nhau.

Kiểm tra phím bổ trợ trong sự kiện bàn phím

Khi xử lý bàn phím, đôi khi chỉ biết event.key là chưa đủ. Ứng dụng còn cần biết người dùng có giữ Ctrl, Shift, Alt hoặc Meta hay không.

Event Object cung cấp các thuộc tính như:

  • ctrlKey: Ctrl đang được giữ.
  • shiftKey: Shift đang được giữ.
  • altKey: Alt đang được giữ.
  • metaKey: phím Meta đang được giữ.

Ví dụ:

document.addEventListener("keydown", function (event) {
    if (event.ctrlKey && event.key === "s") {
        event.preventDefault();

        console.log("Xử lý thao tác lưu");
    }
});

Đây là cơ sở để xây dựng shortcut cho ứng dụng web. Tuy nhiên, khi thay đổi hành vi của các tổ hợp phím quen thuộc, cần cân nhắc kỹ trải nghiệm người dùng và khả năng truy cập.

Phân biệt key, code và các thông tin bàn phím

Trong Keyboard Event, event.key mô tả phím theo giá trị mà người dùng thao tác, trong khi event.code mô tả vị trí vật lý của phím trên bàn phím.

document.addEventListener("keydown", function (event) {
    console.log("key:", event.key);
    console.log("code:", event.code);
});

Điểm khác biệt này quan trọng khi xây dựng game hoặc những ứng dụng cần phản hồi dựa trên vị trí phím vật lý thay vì ký tự được tạo ra.

Với các biểu mẫu thông thường, event.key thường dễ hiểu và phù hợp hơn. Với điều khiển game hoặc phím tắt dựa trên vị trí, event.code có thể hữu ích hơn.

Event và khả năng truy cập

Một lỗi phổ biến khi xây dựng giao diện là chỉ quan tâm đến click chuột mà bỏ qua cách người dùng tương tác bằng bàn phím.

Ví dụ, nếu một phần tử chỉ phản ứng với click nhưng bản thân nó không phải một control tương tác phù hợp, người dùng bàn phím có thể không sử dụng được chức năng đó.

Thay vì biến một phần tử thông thường thành button bằng JavaScript, nên ưu tiên sử dụng phần tử HTML có ý nghĩa phù hợp ngay từ đầu.

<button id="saveButton">Lưu</button>

Sau đó xử lý click:

const button = document.querySelector("#saveButton");

button.addEventListener("click", function () {
    console.log("Lưu dữ liệu");
});

Phần tử button có hành vi tương tác tự nhiên tốt hơn so với việc dùng một phần tử không mang ý nghĩa tương tác rồi cố gắng mô phỏng toàn bộ hành vi bằng JavaScript.

Không nên lạm dụng Event Listener toàn cục

Việc đăng ký listener trên document hoặc window rất tiện lợi nhưng không nên trở thành lựa chọn mặc định.

Ví dụ:

document.addEventListener("click", function (event) {
    // Xử lý tất cả click trên trang
});

Cách này có thể phù hợp với Event Delegation hoặc một số chức năng toàn cục. Nhưng nếu mọi thành phần đều đưa logic lên document, code sẽ khó theo dõi và dễ phát sinh xung đột.

Một nguyên tắc tốt là đặt listener ở phạm vi nhỏ nhất nhưng vẫn đủ để xử lý yêu cầu.

Tránh đăng ký cùng một Event nhiều lần

Một lỗi thường gặp trong ứng dụng JavaScript là một hàm khởi tạo được gọi nhiều lần và mỗi lần lại đăng ký thêm listener.

function init() {
    button.addEventListener("click", function () {
        console.log("Click");
    });
}

init();
init();
init();

Sau ba lần gọi, button có thể có ba listener khác nhau.

Khi click, callback sẽ được thực thi nhiều lần. Đây là một nguyên nhân phổ biến khiến các chức năng như gửi form, mở modal hoặc gọi API bị thực hiện lặp.

Muốn tránh vấn đề này, cần thiết kế vòng đời khởi tạo rõ ràng, sử dụng named function khi cần removeEventListener() hoặc quản lý listener bằng AbortController.

Không nên đặt logic nghiệp vụ nặng trực tiếp trong Event Handler

Event Handler nên đóng vai trò nhận tương tác và điều phối logic thay vì chứa một khối code khổng lồ.

Ví dụ, thay vì viết mọi thứ trong click handler:

button.addEventListener("click", function () {
    // Kiểm tra dữ liệu
    // Tính toán
    // Gọi API
    // Cập nhật nhiều thành phần
    // Hiển thị thông báo
    // Xử lý lỗi
});

Có thể tách thành các hàm có trách nhiệm rõ ràng:

function saveProduct() {
    // Xử lý nghiệp vụ lưu sản phẩm
}

function showMessage(message) {
    // Hiển thị thông báo
}

button.addEventListener("click", function () {
    saveProduct();
});

Cách tổ chức này giúp Event Handler dễ đọc hơn và logic nghiệp vụ có thể được kiểm thử hoặc tái sử dụng ở những nơi khác.

Event và thao tác DOM

Event thường đi kèm với việc cập nhật DOM. Tuy nhiên, không nên cập nhật giao diện nhiều lần liên tiếp nếu có thể gom các thay đổi lại.

Ví dụ, một handler xử lý danh sách lớn có thể gây tốn tài nguyên nếu liên tục tạo, xóa và cập nhật hàng trăm node sau mỗi Event.

Khi gặp tình huống này, cần xem xét:

  • Có thật sự cần cập nhật ngay sau mỗi Event không.
  • Có thể gom nhiều thay đổi thành một lần render không.
  • Có thể sử dụng Event Delegation không.
  • Có thể giảm tần suất callback bằng debounce hoặc throttle không.
  • Có đang đọc và ghi layout quá nhiều lần trong cùng một vòng xử lý không.

Event hiệu quả không chỉ nằm ở việc listener chạy nhanh mà còn nằm ở việc toàn bộ công việc được kích hoạt bởi listener có hợp lý hay không.

Event và giao tiếp giữa các thành phần

Trong những ứng dụng có nhiều thành phần giao diện, Custom Event có thể được sử dụng như một cơ chế thông báo giữa các module.

Ví dụ, một module giỏ hàng có thể phát thông báo sau khi sản phẩm được thêm:

document.dispatchEvent(
    new CustomEvent("cartUpdated", {
        detail: {
            totalItems: 3
        }
    })
);

Một thành phần khác có thể lắng nghe:

document.addEventListener("cartUpdated", function (event) {
    console.log("Tổng số sản phẩm:", event.detail.totalItems);
});

Điểm mạnh của cách này là bên phát Event không nhất thiết phải biết bên nhận là thành phần nào.

Tuy nhiên, nếu lạm dụng Custom Event để thay thế toàn bộ kiến trúc quản lý trạng thái, ứng dụng có thể trở nên khó truy vết. Event nên được sử dụng khi nó thực sự mô tả một sự kiện hoặc thông báo có ý nghĩa.

Những lỗi Event JavaScript thường gặp

Phần lớn lỗi khi làm việc với Event không nằm ở cú pháp mà nằm ở việc hiểu sai vòng đời hoặc phạm vi của sự kiện.

Gọi handler thay vì truyền handler

button.addEventListener("click", handleClick());

Thay vào đó:

button.addEventListener("click", handleClick);

Dùng sai target trong Event Delegation

Nếu phần tử được click có nhiều phần tử con, event.target có thể không phải phần tử mà bạn mong muốn. Trong trường hợp này, closest() thường là lựa chọn phù hợp hơn.

Lạm dụng preventDefault()

Không nên ngăn hành vi mặc định nếu chưa xác định rõ lý do. Chặn link, form, thao tác cuộn hoặc tương tác bàn phím một cách tùy tiện có thể làm giao diện hoạt động khác với kỳ vọng của người dùng.

Lạm dụng stopPropagation()

stopPropagation() có thể giải quyết một xung đột cụ thể nhưng cũng có thể che giấu nguyên nhân thực sự của vấn đề. Trước khi dùng, nên xác định listener nào đang nhận Event và vì sao nó không nên được thực thi.

Đăng ký listener quá sớm

Nếu JavaScript chạy trước khi phần tử cần thao tác tồn tại, querySelector() có thể trả về null.

const button = document.querySelector("#button");

button.addEventListener("click", function () {
    console.log("Click");
});

Cần bảo đảm DOM đã có phần tử trước khi đăng ký listener hoặc tổ chức script phù hợp với thời điểm khởi tạo giao diện.

Cách lựa chọn Event phù hợp

Không có một Event duy nhất phù hợp cho mọi tình huống. Có thể bắt đầu bằng việc xác định chính xác thời điểm mà logic cần phản ứng.

Nhu cầu Event nên cân nhắc Điểm cần lưu ý
Người dùng nhấn nút click Ưu tiên control HTML phù hợp
Giá trị đang được nhập input Có thể phát sinh liên tục
Giá trị lựa chọn đã thay đổi change Phù hợp với select và nhiều control khác
Người dùng gửi form submit Nên xử lý ở form thay vì chỉ bắt click nút
Người dùng nhấn phím keydown Có thể kiểm tra key và phím bổ trợ
Phần tử nhận focus focus hoặc focusin Chọn theo nhu cầu bubbling
Phần tử mất focus blur hoặc focusout Chọn theo nhu cầu bubbling
Thay đổi kích thước viewport resize Nên kiểm soát tần suất xử lý
Tab thay đổi trạng thái hiển thị visibilitychange Kiểm tra document.hidden
Tương tác nhiều loại thiết bị trỏ Pointer Event Phù hợp với mouse, touch và pen

Nguyên tắc xây dựng hệ thống Event dễ bảo trì

Khi dự án còn nhỏ, việc xử lý Event có thể khá đơn giản. Nhưng khi số lượng component tăng, cách tổ chức listener sẽ ảnh hưởng trực tiếp tới khả năng bảo trì.

Một số nguyên tắc đáng áp dụng:

  • Đặt listener ở nơi có phạm vi hợp lý.
  • Tách handler khỏi logic nghiệp vụ lớn.
  • Ưu tiên addEventListener() thay vì gắn logic trực tiếp vào HTML.
  • Dùng Event Delegation cho các danh sách hoặc nhóm phần tử phù hợp.
  • Loại bỏ listener khi component không còn tồn tại nếu cần.
  • Không lạm dụng stopPropagation().
  • Không gọi preventDefault() khi chưa hiểu hành vi mặc định.
  • Kiểm soát các Event có tần suất phát sinh cao.
  • Ưu tiên HTML semantic để hỗ trợ bàn phím và khả năng truy cập.
  • Đặt tên handler thể hiện rõ mục đích xử lý.

Quy trình debug Event hiệu quả

Khi một tương tác không hoạt động, thay vì sửa ngẫu nhiên, có thể kiểm tra theo một quy trình cố định.

  1. Xác nhận phần tử có tồn tại hay không.
  2. Kiểm tra listener có được đăng ký hay không.
  3. Kiểm tra tên Event có chính xác hay không.
  4. Đặt breakpoint hoặc log ngay đầu handler.
  5. Kiểm tra event.type.
  6. Kiểm tra event.target và event.currentTarget.
  7. Kiểm tra Event có bị dừng propagation hay không.
  8. Kiểm tra hành vi mặc định có bị ngăn hay không.
  9. Kiểm tra handler có bị đăng ký nhiều lần không.
  10. Kiểm tra có vấn đề hiệu năng nếu Event phát sinh liên tục hay không.

Cách tiếp cận này giúp phân biệt được lỗi do DOM, lỗi đăng ký listener, lỗi propagation và lỗi bên trong logic xử lý.

Tư duy đúng khi học Event trong JavaScript

Học Event không nên dừng ở việc ghi nhớ hàng chục tên như click, change, submit hay keydown. Điều quan trọng hơn là hiểu mối quan hệ giữa hành động, Event Object, listener, target và propagation.

Khi đã nắm được nền tảng đó, việc học một Event mới sẽ trở nên đơn giản hơn. Bạn chỉ cần xác định Event đó phát sinh khi nào, có bubbling hay không, có thể preventDefault() hay không, Event Object cung cấp thông tin gì và cách listener nên được quản lý như thế nào.

Với những ứng dụng JavaScript thực tế, bốn kỹ năng đặc biệt đáng đầu tư là Event Delegation, quản lý vòng đời Event Listener, kiểm soát hiệu năngxử lý Event theo hướng accessible. Đây là những kiến thức giúp code không chỉ chạy được mà còn dễ mở rộng và bảo trì.

Tổng kết kiến thức Event

Event là cơ chế trung tâm giúp JavaScript phản ứng với những thay đổi xảy ra trong giao diện và môi trường trình duyệt. Từ một click đơn giản đến hệ thống tương tác phức tạp, phần lớn đều dựa trên mô hình EventTarget và Event Listener.

Những kiến thức cốt lõi cần ghi nhớ gồm:

  • addEventListener() dùng để đăng ký listener.
  • removeEventListener() dùng để loại bỏ listener.
  • event.target cho biết mục tiêu của sự kiện.
  • event.currentTarget cho biết phần tử đang xử lý listener.
  • preventDefault() ngăn hành vi mặc định.
  • stopPropagation() ngăn Event tiếp tục lan truyền.
  • Event Bubbling đưa Event từ phần tử con lên các phần tử cha.
  • Event Capturing xử lý Event theo hướng từ tổ tiên xuống target.
  • Event Delegation tận dụng bubbling để xử lý nhiều phần tử bằng listener ở phần tử cha.
  • CustomEvent cho phép tạo Event mang ý nghĩa riêng của ứng dụng.
  • once, capture, passive và signal giúp kiểm soát vòng đời và hành vi của listener.
  • debounce và throttle giúp hạn chế tác động của những Event phát sinh với tần suất cao.

Nắm chắc những nguyên tắc này sẽ giúp việc xử lý Event trong JavaScript trở nên có hệ thống hơn. Thay vì viết listener theo kiểu thử và sửa, bạn có thể xác định chính xác Event cần bắt, vị trí nên đặt listener, cách Event di chuyển trong DOM và cách kiểm soát hiệu năng ngay từ đầu.

  • 0 Bình luận
Trần Thị Vân | Content Creator Web Mới
Trần Thị Vân
Trần Thị Vân là Content Creator tại Web Mới, phụ trách nghiên cứu, biên soạn nội dung và chia sẻ kiến thức về website, SEO, lập trình cùng các xu hướng công nghệ
Đánh giá
Chia sẻ nội dung đánh giá của bạn về Tổng hợp Event (Sự kiện) trong JavaScript
Email, Điện thoại của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *
Đánh giá của bạn
Tên *
Email
Số điện thoại *
Bình luận, Hỏi đáp
Yêu Cầu Báo Giá
Gửi trang web mẫu cần làm theo, chúng tôi sẽ báo giá đến bạn từ Email (tanlucit09@gmail.com - Bùi Tấn Lực) hoặc Zalo (Lực IT - 0398259259) !