Tổng hợp DOM trong JavaScript

DOM là cầu nối giữa JavaScript và cấu trúc HTML của một trang web. Nhờ DOM, JavaScript không chỉ đọc được những gì đang xuất hiện trên trang mà còn có thể tìm phần tử, thay đổi nội dung, cập nhật thuộc tính, tạo thành phần mới, xóa thành phần cũ và phản hồi hành động của người dùng.

Nếu chỉ học từng phương thức DOM riêng lẻ, người mới rất dễ rơi vào tình trạng nhớ được querySelector() nhưng không hiểu lúc nào nên dùng, biết innerHTML nhưng không phân biệt được với textContent, hoặc biết addEventListener() nhưng chưa hiểu sự kiện thực sự di chuyển trong cây tài liệu như thế nào.

Vì vậy, Web Mới tổng hợp DOM theo hướng từ nền tảng đến thực hành. Thay vì liệt kê hàng loạt API, nội dung tập trung vào cách DOM được hình thành, cách JavaScript truy cập cây tài liệu và những nguyên tắc cần nắm trước khi đi vào các kỹ thuật thao tác giao diện phức tạp hơn.

Tổng hợp DOM trong JavaScript
Tổng hợp DOM trong JavaScript

DOM thực chất là gì?

DOM là viết tắt của Document Object Model, tức mô hình đối tượng của tài liệu. Khi trình duyệt nhận một tài liệu HTML, nó phân tích nội dung và xây dựng một cấu trúc mà chương trình có thể truy cập thông qua JavaScript.

Có thể hình dung HTML là bản mô tả cấu trúc ban đầu, còn DOM là cấu trúc đang được trình duyệt quản lý trong bộ nhớ. JavaScript có thể truy cập cấu trúc này và làm cho nó thay đổi trong quá trình trang web hoạt động.

Ví dụ, HTML:

<div id="message">
    <p>Xin chào Web Mới</p>
</div>

Có thể được hình dung đơn giản thành cây:

document
└── div
    └── p
        └── "Xin chào Web Mới"

Trong cấu trúc trên, div chứa p, còn đoạn văn bản nằm bên trong p. JavaScript có thể đi vào từng phần của cây để đọc hoặc thay đổi dữ liệu.

DOM không phải là HTML

HTML và DOM có quan hệ rất chặt chẽ nhưng không phải hai khái niệm đồng nhất. HTML là ngôn ngữ đánh dấu được sử dụng để mô tả tài liệu, trong khi DOM là mô hình mà trình duyệt tạo ra để biểu diễn tài liệu đó dưới dạng các đối tượng và quan hệ giữa chúng.

Sự khác biệt này trở nên rõ ràng khi JavaScript thay đổi giao diện. Một đoạn HTML ban đầu có thể không thay đổi trong file nguồn, nhưng DOM hiện tại đã khác vì JavaScript vừa thêm một phần tử mới hoặc xóa một phần tử đang tồn tại.

Do đó, khi debug một giao diện động, cần phân biệt giữa HTML nguồnDOM hiện tại.

DOM có vai trò gì trong website?

DOM cho phép JavaScript thực hiện những thao tác mà HTML tĩnh không thể tự làm. Chẳng hạn, khi người dùng bấm nút, JavaScript có thể tìm một khu vực trên trang rồi cập nhật nội dung của khu vực đó mà không cần viết lại toàn bộ tài liệu.

Các chức năng phổ biến dựa trên DOM gồm:

  • Hiển thị hoặc ẩn nội dung.
  • Thay đổi văn bản trên giao diện.
  • Thêm hoặc xóa phần tử.
  • Cập nhật class của phần tử.
  • Thay đổi thuộc tính HTML.
  • Xử lý dữ liệu người dùng nhập vào biểu mẫu.
  • Phản hồi thao tác click, nhập liệu, kéo thả và nhiều sự kiện khác.
  • Xây dựng các thành phần giao diện được tạo động bằng JavaScript.

Cây DOM được tổ chức như thế nào?

DOM sử dụng mô hình cây. Mỗi thành phần trong tài liệu có thể có quan hệ với thành phần khác thông qua các khái niệm như cha, con và anh em.

Ví dụ:

<main>
    <section>
        <h2>Danh sách sản phẩm</h2>
        <p>Các sản phẩm mới nhất.</p>
    </section>
</main>

Cấu trúc có thể hình dung như sau:

main
└── section
    ├── h2
    └── p

main là phần tử cha của section. Hai phần tử h2p là con của section, đồng thời chúng là các phần tử anh em vì có cùng phần tử cha.

Hiểu được mối quan hệ này rất quan trọng vì nhiều API DOM hoạt động dựa trên chính cấu trúc cây đó.

Node là gì?

Node là khái niệm tổng quát dùng để chỉ một đơn vị trong cây DOM. Một node không nhất thiết phải là phần tử HTML.

Một số loại node thường gặp gồm:

  • Document: đại diện cho toàn bộ tài liệu.
  • Element: đại diện cho một phần tử HTML.
  • Text: chứa nội dung văn bản.
  • Comment: đại diện cho chú thích trong tài liệu.

Đây là lý do cần phân biệt NodeElement. Element là một loại node, nhưng node có thể là text hoặc comment chứ không nhất thiết là element.

Element khác Node ở điểm nào?

Giả sử có đoạn HTML:

<p>Nội dung bài viết</p>

Phần tử p là một element. Dòng chữ Nội dung bài viết nằm bên trong đó được biểu diễn bởi một text node.

Vì vậy, khi làm việc với DOM, cần xác định mình đang muốn thao tác với element hay với node. Sự khác biệt này đặc biệt quan trọng khi duyệt các node con.

Đối tượng document

document là một trong những đối tượng quan trọng nhất khi JavaScript làm việc với DOM. Nó đại diện cho tài liệu hiện tại và cung cấp nhiều phương thức để tìm kiếm, tạo và thao tác với các thành phần trong trang.

Ví dụ, có thể tìm một phần tử theo ID:

const title = document.getElementById("title");

Hoặc tìm phần tử theo CSS selector:

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

Sau khi lấy được element, biến JavaScript đang giữ tham chiếu đến đối tượng đại diện cho phần tử đó trong DOM.

document.documentElement

document.documentElement đại diện cho phần tử gốc của tài liệu HTML.

const root = document.documentElement;

console.log(root);

Trong tài liệu HTML thông thường, phần tử này tương ứng với html. API này hữu ích khi cần truy cập phạm vi cao nhất của cây tài liệu.

document.body

document.body tham chiếu đến phần tử body của tài liệu.

const page = document.body;

console.log(page);

Nó thường được sử dụng khi cần thao tác với vùng nội dung chính của trang hoặc kiểm tra trạng thái của tài liệu.

document.title

Tiêu đề tài liệu có thể được đọc và thay đổi thông qua document.title.

console.log(document.title);

document.title = "Trang sản phẩm";

Đây là ví dụ đơn giản cho thấy JavaScript có thể thay đổi trạng thái của tài liệu đang hiển thị mà không cần chỉnh sửa trực tiếp file HTML nguồn.

Tìm phần tử bằng JavaScript

Trước khi thay đổi một thành phần giao diện, JavaScript thường phải lấy được tham chiếu đến thành phần đó. Đây là bước đầu tiên trong phần lớn các thao tác DOM.

Hai phương pháp rất quan trọng cần nắm chắc là querySelector()querySelectorAll(). Bên cạnh đó, getElementById(), getElementsByClassName()getElementsByTagName() vẫn có những trường hợp sử dụng phù hợp.

getElementById()

Phương thức getElementById() tìm một element dựa trên giá trị của thuộc tính id.

HTML:

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

JavaScript:

const saveButton = document.getElementById("save");

console.log(saveButton);

Nếu không tìm thấy phần tử phù hợp, kết quả là null. Vì vậy, không nên mặc định rằng kết quả truy vấn luôn là một element.

const button = document.getElementById("save");

if (button) {
    console.log("Đã tìm thấy nút");
}

querySelector()

querySelector() nhận vào một CSS selector và trả về element đầu tiên phù hợp với selector đó.

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

Điểm mạnh của phương thức này là selector có thể phức tạp hơn một ID hoặc class đơn giản.

const item = document.querySelector(".product-list li");
const activeLink = document.querySelector("nav a.active");
const email = document.querySelector("form input[name='email']");

Selector càng cụ thể thì ý định của đoạn code càng rõ, nhưng không nên tạo selector quá phụ thuộc vào cấu trúc HTML nếu cấu trúc đó thường xuyên thay đổi.

querySelectorAll()

querySelectorAll() trả về tất cả element phù hợp với selector.

const buttons = document.querySelectorAll(".button");

console.log(buttons.length);

Kết quả là một NodeList. Với NodeList chứa các phần tử, có thể sử dụng forEach() để duyệt.

const buttons = document.querySelectorAll(".button");

buttons.forEach(function(button) {
    console.log(button.textContent);
});

Đây là cách thường gặp khi cần xử lý một nhóm phần tử cùng loại.

getElementsByClassName()

getElementsByClassName() tìm các element dựa trên class.

const cards = document.getElementsByClassName("card");

console.log(cards.length);

Kết quả là một HTMLCollection. Một đặc điểm đáng chú ý là HTMLCollection có thể mang tính chất live, nghĩa là tập hợp có thể phản ánh thay đổi của DOM sau khi được lấy ra.

Đặc điểm này cần được lưu ý khi vừa duyệt vừa thay đổi cấu trúc DOM.

getElementsByTagName()

getElementsByTagName() tìm các element dựa trên tên thẻ.

const paragraphs = document.getElementsByTagName("p");

console.log(paragraphs.length);

Phương thức này phù hợp khi mục tiêu là lấy các element cùng loại. Tuy nhiên, nếu cần kết hợp nhiều điều kiện chọn phần tử, CSS selector thường mang lại cách biểu đạt linh hoạt hơn.

So sánh các phương thức truy vấn

Phương thức Cách tìm Kết quả
getElementById() Theo ID Một element hoặc null
querySelector() Theo CSS selector Element đầu tiên hoặc null
querySelectorAll() Theo CSS selector NodeList
getElementsByClassName() Theo class HTMLCollection
getElementsByTagName() Theo tên thẻ HTMLCollection

Không nên học các phương thức này theo kiểu phương thức nào mới hơn thì luôn tốt hơn. Điều quan trọng là hiểu loại dữ liệu nhận được và mục tiêu của truy vấn.

Giới hạn phạm vi tìm kiếm

Một truy vấn DOM không nhất thiết phải bắt đầu từ document. Sau khi có một element cha, có thể sử dụng chính element đó làm phạm vi để tìm những phần tử nằm bên trong.

Ví dụ:

<div class="product">
    <h3 class="product-title">Sản phẩm A</h3>
    <p class="price">500000</p>
</div>

JavaScript:

const product = document.querySelector(".product");
const title = product.querySelector(".product-title");
const price = product.querySelector(".price");

Cách này đặc biệt hữu ích khi trang có nhiều khu vực sử dụng cùng tên class. Thay vì tìm toàn bộ tài liệu, code có thể xác định một container rồi tìm phần tử bên trong container đó.

Tại sao nên thu hẹp phạm vi?

Thu hẹp phạm vi giúp đoạn code thể hiện rõ mối quan hệ giữa các thành phần. Khi nhìn vào code, người đọc có thể hiểu rằng product-title thuộc về một product cụ thể chứ không phải bất kỳ tiêu đề nào trên trang.

Ví dụ với nhiều sản phẩm:

const products = document.querySelectorAll(".product");

products.forEach(function(product) {
    const title = product.querySelector(".product-title");

    console.log(title.textContent);
});

Cách tổ chức này cũng giúp hạn chế tình trạng lấy nhầm element khi trang có nhiều component tương tự nhau.

Quan hệ cha, con và anh em

Do DOM là một cây, JavaScript có thể di chuyển từ một element đến những element có quan hệ trực tiếp với nó. Nhóm API này rất hữu ích khi không muốn thực hiện thêm một truy vấn selector.

parentElement

parentElement trả về element cha của element hiện tại.

const title = document.querySelector(".product-title");
const product = title.parentElement;

console.log(product);

children

children trả về các element con trực tiếp của một element.

const product = document.querySelector(".product");

console.log(product.children);

Điểm cần nhớ là children chỉ lấy các element con, không lấy text node.

firstElementChild

firstElementChild trả về element con đầu tiên.

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

const firstItem = list.firstElementChild;

lastElementChild

lastElementChild trả về element con cuối cùng.

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

const lastItem = list.lastElementChild;

nextElementSibling

nextElementSibling cho phép lấy element anh em đứng ngay sau element hiện tại.

const current = document.querySelector(".active");

const next = current.nextElementSibling;

previousElementSibling

previousElementSibling lấy element anh em đứng ngay trước element hiện tại.

const current = document.querySelector(".active");

const previous = current.previousElementSibling;

children và childNodes

Hai thuộc tính này thường gây nhầm lẫn vì đều liên quan đến các node con. Tuy nhiên, chúng phục vụ hai mục đích khác nhau.

children chỉ trả về các element con.

childNodes trả về các node con, vì vậy có thể bao gồm text node và comment node.

Ví dụ:

<div>
    <p>Nội dung</p>
</div>

Các khoảng trắng và xuống dòng trong HTML có thể được trình duyệt biểu diễn thành text node. Vì vậy, kết quả từ childNodes có thể nhiều hơn số element mà người lập trình nhìn thấy.

Nếu mục tiêu chỉ là thao tác với các phần tử HTML con, children thường phù hợp hơn.

Thời điểm DOM sẵn sàng

Một đoạn JavaScript có thể đúng về cú pháp nhưng vẫn hoạt động sai nếu chạy trước thời điểm phần tử cần tìm xuất hiện trong DOM.

Ví dụ, nếu code được thực thi trước khi HTML tương ứng được phân tích:

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

console.log(button);

Kết quả có thể là null vì phần tử chưa tồn tại tại thời điểm truy vấn.

Đây là lý do thời điểm thực thi JavaScript cần được xem xét cùng với cách truy vấn DOM.

DOMContentLoaded

Sự kiện DOMContentLoaded xảy ra sau khi tài liệu HTML đã được trình duyệt phân tích và DOM được xây dựng.

document.addEventListener("DOMContentLoaded", function() {
    const button = document.querySelector("#submit");

    console.log(button);
});

Cách này phù hợp khi cần đảm bảo cấu trúc HTML đã sẵn sàng trước khi thực hiện các thao tác DOM.

defer

Script có thể được tải với thuộc tính defer:

<script src="app.js" defer></script>

Cách này cho phép trình duyệt tiếp tục phân tích HTML trong khi tải file JavaScript và thực thi script sau khi việc phân tích tài liệu hoàn tất.

Trong dự án thực tế, nên lựa chọn cách tổ chức script nhất quán thay vì đồng thời sử dụng nhiều cơ chế chỉ vì muốn tránh lỗi truy vấn DOM.

Quy trình cơ bản khi thao tác DOM

Một bài toán DOM thông thường có thể được chia thành một chuỗi bước rõ ràng:

  1. Xác định thành phần giao diện cần thao tác.
  2. Tìm element bằng phương thức phù hợp.
  3. Kiểm tra trường hợp element không tồn tại nếu cần.
  4. Đọc hoặc thay đổi trạng thái của element.
  5. Kết nối thao tác đó với sự kiện nếu giao diện cần phản hồi người dùng.
  6. Kiểm tra kết quả trên DOM thực tế.

Ví dụ, nếu muốn thay đổi tiêu đề khi người dùng bấm nút, đừng bắt đầu bằng việc tìm một API bất kỳ. Hãy phân tích bài toán: cần một element nút, cần một element tiêu đề, cần lắng nghe sự kiện click và cuối cùng cập nhật nội dung của tiêu đề.

Đây là cách tiếp cận quan trọng hơn việc học thuộc thật nhiều phương thức DOM.

Muốn làm chủ DOM, hãy hiểu cây tài liệu trước, sau đó mới học cách tìm, đọc, thay đổi và phản ứng với từng phần của cây.

Đọc và thay đổi nội dung phần tử

Sau khi tìm được một element, thao tác phổ biến tiếp theo là đọc hoặc cập nhật nội dung bên trong nó. JavaScript có nhiều API cho nhiệm vụ này, nhưng chúng không hoàn toàn tương đương. Lựa chọn sai có thể khiến code xử lý cả HTML ngoài ý muốn hoặc lấy nội dung không giống những gì người dùng thực sự nhìn thấy.

textContent

textContent dùng để lấy hoặc thiết lập toàn bộ nội dung văn bản của một node và các node con của nó. Khi gán một chuỗi mới, nội dung cũ được thay thế bằng văn bản đó.

<p id="message">Xin chào</p>
const message = document.querySelector("#message");

console.log(message.textContent);

message.textContent = "Nội dung mới";

Nếu giá trị mới chứa ký tự giống cú pháp HTML, textContent vẫn xem đó là văn bản thay vì phân tích thành phần tử HTML.

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

message.textContent = "<strong>Xin chào</strong>";

Kết quả hiển thị là chuỗi chứa nội dung HTML chứ không biến thành một phần tử strong. Đây là đặc điểm rất quan trọng khi dữ liệu đến từ người dùng hoặc nguồn không hoàn toàn đáng tin cậy.

innerHTML

innerHTML cho phép đọc hoặc thiết lập HTML nằm bên trong một element.

<div id="content">
    <p>Nội dung cũ</p>
</div>
const content = document.querySelector("#content");

content.innerHTML = "<p>Nội dung mới</p>";

Khác với textContent, chuỗi được gán vào innerHTML sẽ được trình duyệt phân tích như HTML. Vì vậy, API này mạnh nhưng cần sử dụng cẩn thận.

Không nên đưa trực tiếp dữ liệu do người dùng kiểm soát vào innerHTML nếu chưa có cơ chế xử lý phù hợp. Việc chèn HTML không an toàn có thể tạo ra lỗ hổng XSS.

innerText

innerText tập trung vào phần văn bản được thể hiện theo cách gần với nội dung người dùng nhìn thấy trên giao diện. Kết quả có thể chịu ảnh hưởng bởi trạng thái hiển thị và CSS.

const content = document.querySelector(".content");

console.log(content.innerText);

Đây là điểm khiến innerText khác với textContent. textContent quan tâm đến nội dung text trong cây DOM, còn innerText phản ánh nhiều hơn cách nội dung được trình bày trên trang.

So sánh ba API nội dung

API Mục đích Đặc điểm chính
textContent Đọc hoặc ghi văn bản Không phân tích chuỗi thành HTML
innerHTML Đọc hoặc ghi HTML bên trong Chuỗi được phân tích thành DOM
innerText Đọc hoặc ghi nội dung văn bản theo cách hiển thị Có liên quan đến trạng thái hiển thị của nội dung

Trong nhiều trường hợp, nếu chỉ cần thay đổi một đoạn chữ, textContent là lựa chọn rõ ràng và an toàn hơn. Chỉ nên dùng innerHTML khi thực sự cần tạo hoặc thay thế cấu trúc HTML.

Đọc và thay đổi thuộc tính HTML

Element không chỉ chứa nội dung. Nó còn có các thuộc tính như id, class, href, src, alt, title và nhiều thuộc tính khác.

JavaScript có thể đọc hoặc thay đổi những thuộc tính này thông qua các API DOM.

getAttribute()

getAttribute() lấy giá trị của một thuộc tính trên element.

<a id="link" href="/san-pham">Xem sản phẩm</a>
const link = document.querySelector("#link");

const url = link.getAttribute("href");

console.log(url);

Nếu thuộc tính không tồn tại, kết quả thông thường là null.

setAttribute()

setAttribute() dùng để tạo hoặc cập nhật một thuộc tính.

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

link.setAttribute("href", "/lien-he");
link.setAttribute("title", "Liên hệ Web Mới");

Phương thức này đặc biệt hữu ích khi thuộc tính cần thao tác được xác định bằng tên động hoặc khi làm việc với các thuộc tính HTML không có API thuộc tính trực tiếp thuận tiện.

hasAttribute()

hasAttribute() kiểm tra element có thuộc tính được chỉ định hay không.

const image = document.querySelector("img");

if (image.hasAttribute("alt")) {
    console.log("Ảnh đã có alt");
}

Điều này hữu ích khi cần kiểm tra trạng thái trước khi thực hiện thay đổi.

removeAttribute()

removeAttribute() xóa một thuộc tính khỏi element.

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

input.removeAttribute("disabled");

Cần phân biệt việc xóa thuộc tính với việc thay đổi giá trị thuộc tính. removeAttribute() loại bỏ hoàn toàn thuộc tính khỏi element.

Thuộc tính và phương thức truy cập trực tiếp

Nhiều thuộc tính HTML có thể được truy cập trực tiếp thông qua object đại diện cho element.

Ví dụ:

const link = document.querySelector("a");

console.log(link.href);

link.href = "/san-pham";

Với input:

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

console.log(input.value);

input.value = "Nguyễn Văn A";

Cách truy cập trực tiếp thường ngắn gọn và thuận tiện khi làm việc với những thuộc tính phổ biến.

Tuy nhiên, không nên mặc định rằng thuộc tính JavaScript và thuộc tính HTML luôn là một thứ hoàn toàn giống nhau. Một số property phản ánh trạng thái hiện tại của object, trong khi attribute thuộc về markup của element.

Attribute và property

Đây là khái niệm quan trọng khi đi sâu vào DOM. Attribute là thông tin được khai báo trong HTML, còn property là dữ liệu được cung cấp trên object element trong JavaScript.

Ví dụ:

<input id="username" value="Lực">

Có thể đọc attribute:

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

console.log(input.getAttribute("value"));

Đồng thời có thể đọc property:

console.log(input.value);

Hai giá trị này có thể giống nhau ở thời điểm ban đầu nhưng không nhất thiết luôn đồng bộ sau khi người dùng tương tác với form. Đây là lý do khi xử lý trạng thái hiện tại của input, property như value thường quan trọng hơn việc đọc attribute value.

Làm việc với class bằng classList

Class là một trong những cơ chế phổ biến nhất để JavaScript thay đổi trạng thái giao diện. Thay vì tự xử lý chuỗi class bằng các phép nối hoặc cắt chuỗi, DOM cung cấp classList để thao tác trực tiếp với từng class.

add()

classList.add() thêm một hoặc nhiều class.

const menu = document.querySelector(".menu");

menu.classList.add("active");

Có thể thêm nhiều class trong một lần gọi:

menu.classList.add("active", "visible");

remove()

classList.remove() xóa class khỏi element.

menu.classList.remove("active");

toggle()

classList.toggle() là một trong những phương thức hữu ích nhất khi xây dựng giao diện tương tác. Nếu class chưa tồn tại, nó được thêm vào; nếu đã tồn tại, nó bị xóa.

const menu = document.querySelector(".menu");

menu.classList.toggle("active");

Cách này rất phù hợp cho các trạng thái bật/tắt như menu mobile, hộp thoại, accordion hoặc trạng thái mở rộng của một khu vực nội dung.

contains()

classList.contains() kiểm tra element có class cụ thể hay không.

const menu = document.querySelector(".menu");

if (menu.classList.contains("active")) {
    console.log("Menu đang mở");
}

replace()

classList.replace() thay một class hiện tại bằng class khác.

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

button.classList.replace("button-primary", "button-secondary");

API này hữu ích khi giao diện có các trạng thái loại trừ nhau và cần chuyển trực tiếp từ trạng thái này sang trạng thái khác.

Đọc và thay đổi thuộc tính className

Ngoài classList, element còn có className. Thuộc tính này biểu diễn toàn bộ chuỗi class của element.

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

console.log(box.className);

Có thể gán lại toàn bộ class:

box.className = "box active";

Tuy nhiên, cách này sẽ thay thế toàn bộ chuỗi class hiện tại. Nếu chỉ muốn thêm hoặc xóa một class mà không ảnh hưởng các class khác, classList thường là lựa chọn an toàn và dễ đọc hơn.

Thay đổi CSS trực tiếp bằng style

JavaScript có thể thay đổi style inline của một element thông qua thuộc tính style.

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

box.style.width = "300px";
box.style.padding = "20px";
box.style.backgroundColor = "lightgray";

Cách đặt tên property CSS trong JavaScript sử dụng dạng camelCase. Chẳng hạn, background-color trở thành backgroundColor, còn font-size trở thành fontSize.

Khi nào nên dùng style?

Thay đổi style trực tiếp phù hợp với những giá trị thực sự mang tính động, chẳng hạn vị trí được tính toán, kích thước phụ thuộc dữ liệu hoặc một giá trị được xác định trong quá trình chạy.

Đối với trạng thái giao diện thông thường, việc thêm hoặc xóa class thường dễ bảo trì hơn.

element.classList.add("is-active");

Thay vì viết hàng loạt style:

element.style.display = "block";
element.style.opacity = "1";
element.style.visibility = "visible";

CSS có thể quản lý trạng thái:

.is-active {
    display: block;
    opacity: 1;
    visibility: visible;
}

JavaScript khi đó chỉ chịu trách nhiệm thay đổi trạng thái, còn CSS chịu trách nhiệm trình bày. Cách phân chia này thường giúp code dễ bảo trì hơn.

Đọc style đang được trình duyệt áp dụng

Thuộc tính element.style chủ yếu đại diện cho inline style được đặt trực tiếp trên element. Nó không phải lúc nào cũng cho biết đầy đủ giá trị cuối cùng mà trình duyệt đang áp dụng sau khi kết hợp nhiều stylesheet.

Để lấy style đã được tính toán, có thể sử dụng getComputedStyle().

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

const styles = getComputedStyle(box);

console.log(styles.width);
console.log(styles.display);

API này hữu ích khi cần kiểm tra trạng thái hiển thị thực tế của một element, đặc biệt khi CSS đến từ stylesheet bên ngoài thay vì inline style.

Thao tác với data attribute

HTML cho phép lưu những dữ liệu tùy chỉnh thông qua các thuộc tính bắt đầu bằng data-. JavaScript có thể truy cập chúng bằng dataset.

<button data-product-id="25" data-category="laptop">
    Xem sản phẩm
</button>

JavaScript:

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

console.log(button.dataset.productId);
console.log(button.dataset.category);

Tên thuộc tính data-product-id được ánh xạ thành dataset.productId.

Ghi dữ liệu vào dataset

Có thể tạo hoặc thay đổi data attribute thông qua dataset.

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

button.dataset.productId = "50";
button.dataset.status = "active";

Cách này hữu ích khi một element cần mang theo một lượng dữ liệu nhỏ phục vụ logic giao diện.

Tuy nhiên, không nên biến data-* thành nơi lưu trữ toàn bộ dữ liệu của ứng dụng. Với dữ liệu lớn hoặc trạng thái phức tạp, nên quản lý dữ liệu ở tầng JavaScript thay vì nhồi quá nhiều thông tin vào HTML.

Kiểm tra trạng thái phần tử

DOM không chỉ cho phép thay đổi nội dung. JavaScript còn có thể kiểm tra trạng thái hiện tại của element.

Ví dụ với checkbox:

<input type="checkbox" id="agree">
const checkbox = document.querySelector("#agree");

console.log(checkbox.checked);

Có thể thay đổi trạng thái:

checkbox.checked = true;

Tương tự, một input có thể kiểm tra trạng thái bị vô hiệu hóa thông qua property:

console.log(input.disabled);

input.disabled = true;

Điểm đáng chú ý là những property như checked, disabled hoặc value thường phản ánh trạng thái hiện tại của element, rất phù hợp khi xử lý tương tác của người dùng.

Thực hành: cập nhật một khu vực giao diện

Một ví dụ đơn giản có thể kết hợp việc truy vấn element, đọc dữ liệu và cập nhật nội dung.

<div class="profile">
    <h3 class="profile-name">Nguyễn Văn A</h3>
    <p class="profile-status">Đang hoạt động</p>
</div>
const profile = document.querySelector(".profile");
const name = profile.querySelector(".profile-name");
const status = profile.querySelector(".profile-status");

name.textContent = "Trần Văn B";
status.textContent = "Đã cập nhật";

Ví dụ này cho thấy một nguyên tắc quan trọng: sau khi xác định container, có thể tiếp tục truy vấn các thành phần bên trong container đó thay vì liên tục tìm kiếm trên toàn bộ document.

Thực hành: thay đổi trạng thái bằng class

Với một giao diện có trạng thái bật và tắt, JavaScript nên tập trung vào việc thay đổi trạng thái thay vì điều khiển từng thuộc tính CSS riêng lẻ.

<button class="menu-button">Menu</button>
<nav class="menu">
    <a href="/san-pham">Sản phẩm</a>
    <a href="/lien-he">Liên hệ</a>
</nav>
const button = document.querySelector(".menu-button");
const menu = document.querySelector(".menu");

button.addEventListener("click", function() {
    menu.classList.toggle("active");
});

Phần CSS có thể quyết định trạng thái active hiển thị như thế nào. Nhờ đó, JavaScript không cần biết chi tiết về màu sắc, kích thước hay hiệu ứng của menu.

Nguyên tắc an toàn khi đưa dữ liệu vào DOM

Mọi dữ liệu đi vào DOM đều cần được xem xét về nguồn gốc và cách xử lý. Đặc biệt, không nên xem innerHTML như phiên bản mạnh hơn của textContent rồi sử dụng nó cho mọi tình huống.

Nếu chỉ cần hiển thị một chuỗi:

element.textContent = userInput;

thường là cách phù hợp hơn so với:

element.innerHTML = userInput;

Khi dữ liệu không đáng tin cậy được đưa trực tiếp vào HTML, ứng dụng có thể đối mặt với các vấn đề bảo mật nghiêm trọng. Vì vậy, việc lựa chọn API DOM cũng là một phần của thiết kế bảo mật chứ không chỉ là vấn đề phong cách lập trình.

Nếu dữ liệu chỉ cần được hiển thị như văn bản, hãy xử lý nó như văn bản. Chỉ tạo HTML khi ứng dụng thực sự cần tạo cấu trúc HTML.

Tạo phần tử mới trong DOM

Đọc và thay đổi phần tử có sẵn mới chỉ là một phần của DOM. Trong các giao diện động, JavaScript còn phải tạo thêm những phần tử chưa tồn tại trong tài liệu ban đầu. Đây là kỹ thuật nền tảng khi xây dựng danh sách sản phẩm, thông báo, kết quả tìm kiếm, mục menu hoặc các thành phần được sinh ra từ dữ liệu.

API quan trọng nhất cho công việc này là document.createElement(). Phương thức này tạo ra một element mới trong bộ nhớ, nhưng element đó chưa tự động xuất hiện trên giao diện. Muốn người dùng nhìn thấy nó, cần đưa nó vào một vị trí trong cây DOM.

createElement()

document.createElement() nhận tên element cần tạo và trả về một đối tượng element mới.

const paragraph = document.createElement("p");

paragraph.textContent = "Đây là nội dung được tạo bằng JavaScript";

Ở thời điểm này, phần tử p đã tồn tại trong DOM dưới dạng đối tượng mà JavaScript đang tham chiếu, nhưng chưa được gắn vào tài liệu đang hiển thị.

Có thể kiểm tra đối tượng vừa tạo:

console.log(paragraph);

Để đưa nó vào trang, cần sử dụng một phương thức chèn node.

Đưa phần tử vào cây DOM

Sau khi tạo element, bước tiếp theo là xác định vị trí mà nó sẽ xuất hiện. DOM cung cấp nhiều phương thức chèn khác nhau. Mỗi phương thức thể hiện một vị trí cụ thể trong quan hệ giữa các node.

appendChild()

appendChild() thêm một node vào cuối danh sách node con của element hiện tại.

<ul id="products"></ul>
const list = document.querySelector("#products");

const item = document.createElement("li");
item.textContent = "Sản phẩm mới";

list.appendChild(item);

Kết quả là phần tử li được đặt sau các node con đang có trong danh sách.

Một đặc điểm quan trọng của appendChild() là nó nhận node. Vì vậy, có thể dùng nó với cả element và một số loại node khác.

append()

append() cũng cho phép thêm nội dung vào cuối element nhưng linh hoạt hơn appendChild(). Nó có thể nhận nhiều đối số và có thể nhận cả node lẫn chuỗi.

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

const item = document.createElement("li");
item.textContent = "Sản phẩm mới";

list.append(item);

Có thể thêm nhiều nội dung:

list.append(item1, item2);

Hoặc:

list.append("Nội dung bổ sung");

Nếu chỉ làm việc với một element được tạo sẵn, cả hai phương thức đều có thể phù hợp. append() thường thuận tiện hơn trong những đoạn code hiện đại cần chèn nhiều loại nội dung.

prepend()

prepend() thêm nội dung vào vị trí đầu tiên bên trong element.

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

const item = document.createElement("li");
item.textContent = "Sản phẩm ưu tiên";

list.prepend(item);

Trong khi append() đưa nội dung xuống cuối, prepend() đưa nội dung lên đầu. Điều này rất hữu ích khi cần hiển thị bản ghi mới nhất ở vị trí đầu danh sách.

Chèn phần tử trước và sau một element

Không phải lúc nào element mới cũng được đặt bên trong một container. Có những trường hợp cần chèn một node ngay trước hoặc ngay sau một element cụ thể.

before()

before() chèn nội dung ngay trước element hiện tại trong cây DOM.

const title = document.querySelector(".title");

const message = document.createElement("p");
message.textContent = "Thông báo quan trọng";

title.before(message);

after()

after() chèn nội dung ngay sau element hiện tại.

const title = document.querySelector(".title");

const message = document.createElement("p");
message.textContent = "Thông tin bổ sung";

title.after(message);

Hai phương thức này giúp code thể hiện trực tiếp vị trí cần chèn mà không phải truy cập element cha rồi tính toán vị trí con.

Di chuyển một phần tử

Một đặc điểm quan trọng của DOM là khi đưa một element đã tồn tại vào vị trí mới, JavaScript không tạo ra bản sao tự động. Element đó được di chuyển khỏi vị trí cũ và trở thành con của vị trí mới.

Ví dụ:

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

target.append(item);

Nếu item trước đó nằm trong một container khác, nó sẽ được chuyển sang target.

Đây là một đặc tính rất hữu ích khi xây dựng giao diện kéo thả hoặc các thao tác sắp xếp lại thành phần.

Xóa phần tử khỏi DOM

JavaScript cũng có thể loại bỏ một element khỏi cây DOM khi nó không còn cần thiết.

remove()

remove() cho phép element tự loại bỏ khỏi cây DOM.

const message = document.querySelector(".message");

message.remove();

Cách viết này trực tiếp và dễ đọc: lấy phần tử cần xóa rồi gọi remove() trên chính phần tử đó.

removeChild()

removeChild() được gọi từ phần tử cha để loại bỏ một node con.

const list = document.querySelector("#products");
const item = list.querySelector("li");

list.removeChild(item);

Trong code mới, remove() thường thuận tiện hơn khi đã có sẵn element cần xóa. removeChild() vẫn quan trọng khi cần làm việc theo mô hình cha - con hoặc khi tương thích với cách tổ chức code cũ.

Thay thế một phần tử

Trong một số giao diện, thay vì chỉnh sửa từng thuộc tính của element hiện tại, có thể cần thay toàn bộ element đó bằng một element khác.

replaceWith()

replaceWith() thay element hiện tại bằng node hoặc nội dung mới.

const oldMessage = document.querySelector(".message");

const newMessage = document.createElement("p");
newMessage.textContent = "Thông báo mới";

oldMessage.replaceWith(newMessage);

Cách này hữu ích khi hai trạng thái giao diện có cấu trúc hoàn toàn khác nhau và việc sửa từng phần của element cũ sẽ làm code phức tạp.

replaceChild()

replaceChild() được gọi từ node cha để thay một node con bằng node khác.

const container = document.querySelector(".container");
const oldItem = container.querySelector(".old-item");

const newItem = document.createElement("div");
newItem.textContent = "Phần tử mới";

container.replaceChild(newItem, oldItem);

Giống với removeChild(), phương thức này thể hiện rõ mô hình thao tác từ phần tử cha.

Thay đổi vị trí phần tử bằng DOM

DOM không chỉ dùng để tạo giao diện mà còn có thể thay đổi thứ tự các thành phần. Khi một node được append vào một container khác hoặc append lại vào chính container hiện tại, node đó có thể được đưa sang vị trí mới.

Ví dụ, đưa một mục lên cuối danh sách:

const list = document.querySelector(".list");
const firstItem = list.firstElementChild;

list.append(firstItem);

Không cần tạo bản sao, phần tử đầu tiên được di chuyển xuống cuối danh sách.

Đây là một kỹ thuật đơn giản nhưng rất hữu ích khi xây dựng chức năng sắp xếp, ưu tiên hoặc thay đổi thứ tự hiển thị.

Sao chép phần tử bằng cloneNode()

Trong trường hợp cần tạo một bản sao thay vì di chuyển element gốc, có thể sử dụng cloneNode().

Sao chép element không bao gồm node con

Gọi cloneNode(false) tạo bản sao của chính element nhưng không sao chép các node con.

const original = document.querySelector(".card");

const copy = original.cloneNode(false);

Sao chép toàn bộ cây con

Gọi cloneNode(true) sẽ sao chép element cùng các node con bên trong nó.

const original = document.querySelector(".card");

const copy = original.cloneNode(true);

document.querySelector(".container").append(copy);

Điểm cần chú ý là việc sao chép DOM không đồng nghĩa với việc sao chép toàn bộ trạng thái JavaScript liên quan đến element. Những event listener được gắn bằng addEventListener() không được sao chép theo cách này.

Tạo văn bản bằng createTextNode()

document.createTextNode() tạo một text node riêng biệt.

const text = document.createTextNode("Xin chào");

const paragraph = document.createElement("p");
paragraph.append(text);

document.querySelector(".content").append(paragraph);

Trong các trường hợp đơn giản, sử dụng textContent thường ngắn gọn hơn. Tuy nhiên, createTextNode() giúp hiểu rõ cách văn bản tồn tại như một node trong cây DOM và hữu ích trong những thao tác xây dựng node phức tạp.

Tạo cấu trúc DOM nhiều tầng

Khi xây dựng một component bằng JavaScript, thường không chỉ tạo một element đơn lẻ mà cần tạo cả một cấu trúc gồm cha và nhiều phần tử con.

Ví dụ:

const card = document.createElement("article");
const title = document.createElement("h3");
const description = document.createElement("p");

title.textContent = "Sản phẩm mới";
description.textContent = "Thông tin sản phẩm.";

card.append(title, description);

document.querySelector(".products").append(card);

Cách làm này tạo ra cấu trúc DOM bằng các object thay vì nối một chuỗi HTML lớn.

Ưu điểm là từng node được tạo và kiểm soát riêng. Điều này đặc biệt hữu ích khi nội dung đến từ dữ liệu bên ngoài và cần hạn chế việc đưa chuỗi HTML trực tiếp vào DOM.

Đưa dữ liệu vào cấu trúc được tạo động

Giả sử dữ liệu sản phẩm được lưu trong JavaScript:

const product = {
    name: "Laptop",
    price: 15000000
};

Có thể tạo giao diện:

const card = document.createElement("article");
const name = document.createElement("h3");
const price = document.createElement("p");

name.textContent = product.name;
price.textContent = product.price + " VNĐ";

card.append(name, price);

document.querySelector(".products").append(card);

Ở đây, dữ liệu được đưa vào textContent thay vì ghép trực tiếp vào chuỗi HTML. Cách này giúp tách dữ liệu khỏi cấu trúc và giảm rủi ro khi dữ liệu có nội dung đặc biệt.

DocumentFragment và xây dựng nhiều phần tử

Khi cần tạo một số lượng lớn node, việc đưa từng node vào DOM ngay sau khi tạo có thể làm code kém hiệu quả hơn. DocumentFragment cung cấp một vùng chứa tạm thời để xây dựng cấu trúc trước khi đưa nó vào tài liệu.

Tạo DocumentFragment

const fragment = document.createDocumentFragment();

const item1 = document.createElement("li");
item1.textContent = "Sản phẩm A";

const item2 = document.createElement("li");
item2.textContent = "Sản phẩm B";

fragment.append(item1, item2);

Sau khi hoàn thành:

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

list.append(fragment);

Các phần tử bên trong fragment được đưa vào danh sách, còn fragment không trở thành một phần tử HTML trung gian trong cây DOM.

Vì sao DocumentFragment hữu ích?

DocumentFragment phù hợp khi cần xây dựng nhiều node trước khi chèn chúng vào tài liệu. Thay vì liên tục thao tác với cây DOM đang hiển thị, có thể chuẩn bị cấu trúc ở một vùng tạm rồi đưa kết quả vào vị trí cần thiết.

Điều này không có nghĩa mọi thao tác tạo nhiều phần tử đều bắt buộc phải dùng DocumentFragment. Với số lượng nhỏ, cách viết đơn giản bằng append() thường đã đủ rõ ràng. Fragment đặc biệt có giá trị khi xây dựng một nhóm node lớn hoặc khi muốn tách quá trình tạo cấu trúc khỏi quá trình đưa cấu trúc vào tài liệu.

Xây dựng danh sách động từ một mảng

Đây là tình huống rất phổ biến trong ứng dụng web. Dữ liệu nằm trong một mảng JavaScript và giao diện cần tạo ra một danh sách tương ứng.

const products = [
    "Laptop",
    "Bàn phím",
    "Chuột"
];

Có thể tạo danh sách bằng DOM:

const list = document.querySelector(".products");
const fragment = document.createDocumentFragment();

products.forEach(function(product) {
    const item = document.createElement("li");

    item.textContent = product;
    fragment.append(item);
});

list.append(fragment);

Quy trình ở đây khá rõ ràng: duyệt dữ liệu, tạo node, đưa dữ liệu vào node, gom các node lại và cuối cùng chèn chúng vào giao diện.

Không nên nhầm dữ liệu với giao diện

Một lỗi thiết kế phổ biến là trộn dữ liệu và HTML vào cùng một chuỗi quá sớm. Khi dữ liệu tăng lên hoặc yêu cầu giao diện thay đổi, cách làm này dễ trở nên khó bảo trì.

Thay vào đó, nên xác định rõ:

  • Dữ liệu nằm ở đâu.
  • Cấu trúc giao diện gồm những node nào.
  • Node nào nhận dữ liệu nào.
  • Khi dữ liệu thay đổi thì phần nào của giao diện cần cập nhật.

DOM chỉ là lớp biểu diễn giao diện. Việc giữ ranh giới tương đối rõ giữa dữ liệu và cấu trúc giao diện sẽ giúp JavaScript dễ mở rộng hơn.

insertAdjacentHTML() và khi nào nên dùng

insertAdjacentHTML() cho phép chèn một chuỗi HTML vào một vị trí xác định quanh element hiện tại. Đây là API hữu ích khi thực sự cần chèn HTML dưới dạng chuỗi nhưng không muốn thay thế toàn bộ nội dung của element bằng innerHTML.

Cú pháp có bốn vị trí chính: beforebegin, afterbegin, beforeendafterend.

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

list.insertAdjacentHTML(
    "beforeend",
    "<li>Sản phẩm mới</li>"
);

Trong ví dụ này, HTML được chèn vào cuối nội dung của list.

API này có thể rất tiện khi có một mẫu HTML cố định, nhưng nguyên tắc an toàn vẫn giống innerHTML: không nên đưa dữ liệu không đáng tin cậy trực tiếp vào chuỗi HTML.

Chọn giữa createElement và HTML string

Cách làm Phù hợp khi Lưu ý
createElement() Cần kiểm soát từng node và dữ liệu Code dài hơn nhưng rõ cấu trúc
append() Cần chèn node hoặc text API đơn giản và linh hoạt
innerHTML Cần thay đổi một cấu trúc HTML bằng chuỗi Phải kiểm soát dữ liệu đầu vào
insertAdjacentHTML() Cần chèn HTML tại vị trí cụ thể Không đưa dữ liệu không tin cậy trực tiếp vào HTML

Không có quy tắc rằng createElement() luôn tốt hơn hoặc innerHTML luôn xấu. Lựa chọn phù hợp phụ thuộc vào dữ liệu, cấu trúc giao diện, yêu cầu hiệu năng và mức độ kiểm soát đối với nội dung được đưa vào DOM.

Những lỗi thường gặp khi tạo và xóa node

Một lỗi phổ biến là tạo element nhưng quên đưa nó vào tài liệu.

const item = document.createElement("li");

item.textContent = "Sản phẩm mới";

Đoạn code trên tạo element nhưng không có thao tác append(), appendChild() hoặc phương thức chèn tương ứng. Vì vậy, người dùng sẽ không thấy phần tử đó trên trang.

Một lỗi khác là cố xóa element mà biến đang chứa giá trị null.

const item = document.querySelector(".not-found");

if (item) {
    item.remove();
}

Việc kiểm tra này giúp code tránh lỗi khi element không tồn tại.

Ngoài ra, khi dùng cloneNode(), cần nhớ rằng bản sao DOM không tự động mang theo các event listener đã đăng ký bằng JavaScript. Nếu bản sao cần tương tác giống bản gốc, logic sự kiện phải được tổ chức lại phù hợp.

Nguyên tắc xây dựng DOM dễ bảo trì

Thao tác DOM càng nhiều thì việc tổ chức code càng quan trọng. Một đoạn JavaScript có thể chạy đúng nhưng vẫn khó bảo trì nếu mọi thứ được viết trong một khối lớn với quá nhiều truy vấn và thao tác lồng nhau.

Nên tách những nhiệm vụ rõ ràng thành các hàm nhỏ. Chẳng hạn, một hàm chỉ chịu trách nhiệm tạo card, một hàm chịu trách nhiệm render danh sách và một phần khác xử lý sự kiện.

function createProductItem(name) {
    const item = document.createElement("li");

    item.textContent = name;

    return item;
}

function renderProducts(products, list) {
    const fragment = document.createDocumentFragment();

    products.forEach(function(product) {
        fragment.append(createProductItem(product));
    });

    list.replaceChildren(fragment);
}

Cách tổ chức này giúp phần tạo node và phần đưa dữ liệu lên giao diện có ranh giới rõ hơn.

Sự kiện trong giao diện web

DOM không chỉ đại diện cho cấu trúc tài liệu mà còn cho phép JavaScript phản ứng khi người dùng hoặc trình duyệt thực hiện một hành động. Những hành động đó được gọi là sự kiện, chẳng hạn người dùng nhấp chuột, nhập văn bản, gửi biểu mẫu, thay đổi lựa chọn hoặc di chuyển con trỏ.

Trong một ứng dụng web, phần lớn tương tác đều có thể được nhìn dưới dạng một chuỗi đơn giản: một sự kiện xảy ra → JavaScript nhận sự kiện → xác định phần tử liên quan → cập nhật trạng thái hoặc giao diện.

addEventListener()

addEventListener() là cách phổ biến để đăng ký một hàm xử lý sự kiện cho element.

<button id="save">Lưu dữ liệu</button>
const button = document.querySelector("#save");

button.addEventListener("click", function() {
    console.log("Đã nhấn nút");
});

Khi người dùng nhấp vào nút, hàm được đăng ký sẽ được gọi.

Cách tiếp cận này có ưu điểm là tách hành vi JavaScript khỏi markup HTML. Một element cũng có thể có nhiều listener cho những mục đích khác nhau.

Tham số của addEventListener()

Ở dạng cơ bản, addEventListener() nhận loại sự kiện và hàm xử lý.

element.addEventListener("event", handler);

Tham số đầu tiên là tên sự kiện, chẳng hạn click. Tham số thứ hai là callback được gọi khi sự kiện xảy ra.

Trong trường hợp cần cấu hình thêm hành vi của listener, tham số thứ ba có thể là một object tùy chọn.

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

Với tùy chọn once, listener chỉ được thực thi một lần rồi tự động bị gỡ.

Đối tượng Event

Khi một sự kiện xảy ra, trình duyệt truyền thông tin về sự kiện đó vào hàm xử lý. Đối tượng này thường được đặt tên là event hoặc e.

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

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

Đối tượng event chứa nhiều thông tin hữu ích về sự kiện, chẳng hạn element phát sinh sự kiện, loại sự kiện và vị trí con trỏ trong những trường hợp phù hợp.

event.target

event.target cho biết node nơi sự kiện thực sự bắt đầu.

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

Đây là một thuộc tính đặc biệt quan trọng khi sử dụng event delegation vì listener có thể được đặt trên một element cha nhưng sự kiện lại bắt đầu từ một element con.

event.currentTarget

event.currentTarget đại diện cho element đang sở hữu listener được thực thi.

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

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

Nếu người dùng nhấp trực tiếp vào button, hai giá trị có thể trỏ đến cùng một element. Nhưng trong cấu trúc có nhiều phần tử con, chúng có thể khác nhau.

Ngăn hành vi mặc định của trình duyệt

Một số sự kiện đi kèm hành vi mặc định của trình duyệt. Ví dụ, nhấp vào liên kết có thể điều hướng đến URL mới, còn gửi form có thể khiến trình duyệt thực hiện quá trình submit theo cơ chế mặc định.

preventDefault() cho phép ngăn hành vi mặc định đó.

const link = document.querySelector("a");

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

    console.log("Đã chặn điều hướng");
});

Với form:

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

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

    console.log("Xử lý form bằng JavaScript");
});

Điều này thường được sử dụng khi ứng dụng cần kiểm tra dữ liệu hoặc gửi dữ liệu bằng JavaScript thay vì để trình duyệt thực hiện hành vi mặc định.

Ngăn sự kiện lan truyền

Sự kiện trong DOM có thể lan truyền qua nhiều cấp của cây tài liệu. Trong những tình huống nhất định, có thể cần ngăn sự kiện tiếp tục lan truyền bằng stopPropagation().

const child = document.querySelector(".child");

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

    console.log("Sự kiện dừng tại đây");
});

Không nên sử dụng stopPropagation() chỉ để xử lý những vấn đề chưa hiểu rõ về event. Việc chặn propagation có thể ảnh hưởng đến những listener khác đang phụ thuộc vào quá trình lan truyền sự kiện.

stopImmediatePropagation()

stopImmediatePropagation() mạnh hơn stopPropagation(). Nó ngăn sự kiện tiếp tục lan truyền và đồng thời ngăn những listener khác trên cùng element tiếp tục được gọi.

element.addEventListener("click", function(event) {
    event.stopImmediatePropagation();
});

Đây là API nên sử dụng có chủ đích vì nó can thiệp mạnh vào quá trình xử lý sự kiện trên element hiện tại.

Event bubbling

Một trong những đặc điểm quan trọng của DOM event là bubbling. Khi sự kiện xảy ra trên một phần tử con, sự kiện có thể tiếp tục lan lên các phần tử cha.

Ví dụ:

<div class="box">
    <button>Nhấn vào đây</button>
</div>
const box = document.querySelector(".box");
const button = box.querySelector("button");

box.addEventListener("click", function() {
    console.log("Box nhận sự kiện");
});

button.addEventListener("click", function() {
    console.log("Button nhận sự kiện");
});

Khi nhấp vào button, listener của button được thực hiện rồi sự kiện có thể tiếp tục bubbling lên box.

Hiểu bubbling là điều kiện quan trọng để sử dụng event delegation đúng cách.

Event capturing

Quá trình truyền sự kiện không chỉ có chiều từ phần tử con lên cha. Trình duyệt cũng có giai đoạn sự kiện đi từ các node cấp cao hơn xuống node mục tiêu, được gọi là capturing.

Có thể đăng ký listener ở giai đoạn capturing bằng tùy chọn capture.

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

Trong phần lớn thao tác giao diện thông thường, bubbling là cơ chế được sử dụng nhiều hơn. Capturing hữu ích khi cần kiểm soát thứ tự xử lý sự kiện ở những cấu trúc phức tạp.

Ba giai đoạn của sự kiện

Có thể hình dung quá trình xử lý event thành ba giai đoạn:

  1. Capturing: sự kiện đi từ vùng document xuống phía 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 đi ngược từ phần tử mục tiêu lên các phần tử cha.

Không phải mọi sự kiện đều có hành vi bubbling giống nhau, vì vậy không nên giả định mọi event đều lan truyền theo cùng một cách.

Event delegation

Event delegation là kỹ thuật tận dụng bubbling để xử lý sự kiện của nhiều phần tử con bằng một listener đặt trên phần tử cha.

Ví dụ có một danh sách:

<ul id="products">
    <li>Laptop</li>
    <li>Bàn phím</li>
    <li>Chuột</li>
</ul>

Thay vì gắn listener riêng cho từng li, có thể đặt một listener trên ul:

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

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

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

    console.log(item.textContent);
});

Kỹ thuật này đặc biệt hữu ích với danh sách có số lượng phần tử lớn hoặc những phần tử được tạo thêm sau khi listener ban đầu đã được đăng ký.

Delegation với phần tử được tạo động

Giả sử danh sách ban đầu trống và JavaScript thêm các item về sau. Nếu listener được gắn trực tiếp vào những item chưa tồn tại thì không thể đăng ký trước cho chúng.

Delegation giải quyết vấn đề này bằng cách đặt listener lên container đã tồn tại:

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

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

    if (!item) {
        return;
    }

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

Khi item mới được thêm vào bên trong list, sự kiện click của item vẫn có thể bubbling lên container và được listener xử lý.

closest()

closest() tìm element gần nhất từ element hiện tại lên phía tổ tiên, dựa trên một CSS selector.

const item = event.target.closest(".product");

API này rất hữu ích khi sự kiện có thể bắt đầu từ một element con nằm sâu bên trong component.

Ví dụ:

<button class="product">
    <span>
        <strong>Xem sản phẩm</strong>
    </span>
</button>

Nếu người dùng nhấp trực tiếp vào strong, event.target có thể là element strong. Tuy nhiên:

const product = event.target.closest(".product");

vẫn có thể tìm được button gần nhất phù hợp với selector.

contains()

contains() kiểm tra một node có nằm bên trong một node khác hay không.

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

if (container.contains(item)) {
    console.log("Item thuộc container");
}

API này thường hữu ích khi xử lý event delegation hoặc kiểm tra quan hệ giữa các node trước khi thực hiện thao tác.

Các sự kiện chuột phổ biến

Nhóm sự kiện chuột thường xuất hiện trong những giao diện tương tác cơ bản.

Sự kiện Ý nghĩa
click Người dùng kích hoạt thao tác nhấp
dblclick Nhấp đúp
mousedown Nút chuột được nhấn xuống
mouseup Nút chuột được thả ra
mouseenter Con trỏ đi vào vùng của element
mouseleave Con trỏ rời khỏi vùng của element
mousemove Con trỏ di chuyển trong vùng của element

Không nên dùng mousemove cho những tác vụ nặng mà không cân nhắc hiệu năng, bởi sự kiện này có thể phát sinh với tần suất rất cao khi người dùng di chuyển chuột.

Các sự kiện bàn phím

JavaScript có thể phản ứng với thao tác bàn phím thông qua những sự kiện như keydownkeyup.

keydown

keydown xảy ra khi một phím được nhấn.

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

event.key cho biết phím được kích hoạt theo giá trị mà trình duyệt xác định.

keyup

keyup xảy ra khi phím được thả.

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

Trong các giao diện tìm kiếm, điều khiển bằng phím hoặc shortcut, keydown thường được sử dụng khi cần phản ứng ngay tại thời điểm người dùng nhấn phím.

Xử lý sự kiện nhập liệu

Form là một trong những nơi DOM được sử dụng nhiều nhất. Khi người dùng nhập dữ liệu, JavaScript có thể đọc giá trị hiện tại và cập nhật giao diện theo thời gian thực.

input

Sự kiện input xảy ra khi giá trị của trường nhập liệu thay đổi do thao tác của người dùng.

<input id="search" type="text">
<p id="result"></p>
const search = document.querySelector("#search");
const result = document.querySelector("#result");

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

Đây là nền tảng của nhiều chức năng như tìm kiếm tức thời, đếm ký tự hoặc cập nhật bản xem trước.

change

change thường được sử dụng khi giá trị của một control được thay đổi và trạng thái thay đổi được xác nhận theo cách phù hợp với loại control đó.

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

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

Với các control như select, checkbox hoặc những trường nhập liệu phù hợp, change thường là lựa chọn tự nhiên khi không cần phản ứng ở từng lần gõ.

Sự kiện submit của form

Thay vì chỉ lắng nghe click trên nút gửi, nên xử lý sự kiện submit của chính form. Điều này phản ánh đúng hành động mà ứng dụng cần quan tâm và cũng bao quát những trường hợp form được gửi bằng bàn phím.

<form id="contact-form">
    <input name="email" type="email">
    <button type="submit">Gửi</button>
</form>
const form = document.querySelector("#contact-form");

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

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

Sau khi ngăn hành vi mặc định, JavaScript có thể kiểm tra dữ liệu, hiển thị lỗi hoặc thực hiện quá trình gửi dữ liệu theo logic của ứng dụng.

Đọc dữ liệu form bằng FormData

Khi form có nhiều trường, việc lấy từng value thủ công có thể làm code dài. FormData cung cấp cách thu thập dữ liệu từ form theo cấu trúc phù hợp.

const form = document.querySelector("#contact-form");

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

    const data = new FormData(form);

    console.log(data.get("email"));
});

Có thể duyệt toàn bộ dữ liệu:

const data = new FormData(form);

for (const [name, value] of data.entries()) {
    console.log(name, value);
}

Cách này đặc biệt hữu ích khi form có nhiều trường hoặc khi dữ liệu cần được chuyển tiếp tới API.

Quản lý listener bằng removeEventListener()

Trong một số trường hợp, listener cần được gỡ bỏ sau khi không còn cần thiết. Để làm điều đó, cần truyền đúng function đã được đăng ký.

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

button.addEventListener("click", handleClick);

button.removeEventListener("click", handleClick);

Không thể tạo một function mới có nội dung giống hệt rồi mong rằng listener cũ sẽ được gỡ:

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

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

Hai function trên là hai object function khác nhau. Vì vậy, khi cần gỡ listener, nên giữ lại reference của callback.

AbortController và quản lý nhiều listener

Khi một component được tạo rồi hủy nhiều lần, việc quản lý event listener có thể trở nên phức tạp. AbortController cho phép liên kết listener với một signal để có thể hủy chúng cùng lúc.

const controller = new AbortController();

button.addEventListener("click", handleClick, {
    signal: controller.signal
});

input.addEventListener("input", handleInput, {
    signal: controller.signal
});

Khi component không còn cần các listener:

controller.abort();

Cách này đặc biệt hữu ích trong những ứng dụng có vòng đời component rõ ràng, nơi listener cần được dọn dẹp khi giao diện bị loại bỏ.

Những lỗi thường gặp khi xử lý event

Một lỗi phổ biến là gắn quá nhiều listener riêng lẻ cho những phần tử có thể được xử lý thông qua delegation. Điều này không phải lúc nào cũng sai, nhưng có thể làm việc quản lý listener trở nên phức tạp khi số lượng phần tử lớn hoặc thay đổi liên tục.

Một lỗi khác là sử dụng preventDefault() mà không hiểu hành vi mặc định đang bị ngăn. Việc này có thể khiến liên kết không điều hướng hoặc form không gửi dữ liệu như mong muốn.

Việc lạm dụng stopPropagation() cũng có thể gây khó debug vì những listener ở cấp cha bất ngờ không còn nhận được sự kiện.

Ngoài ra, khi xử lý danh sách động, không nên giả định rằng các phần tử mới tạo sẽ tự động có listener đã gắn cho những phần tử cũ. Nếu giao diện thường xuyên tạo node mới, event delegation thường là một hướng đáng cân nhắc.

Tư duy xử lý event trong ứng dụng thực tế

Thay vì xem mỗi event là một đoạn code độc lập, nên xem nó như điểm bắt đầu của một luồng xử lý trạng thái.

  1. Xác định hành động của người dùng.
  2. Xác định element hoặc component phát sinh hành động.
  3. Lấy dữ liệu cần thiết từ event và DOM.
  4. Kiểm tra điều kiện trước khi thay đổi trạng thái.
  5. Cập nhật dữ liệu hoặc giao diện.
  6. Dọn dẹp listener nếu component không còn tồn tại.

Với cách tiếp cận này, event không còn là những callback rời rạc mà trở thành một phần có cấu trúc trong vòng đời của giao diện.

  • 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 DOM 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) !