Câu 1Ngay sau const li = document.createElement("li");, li nằm ở đâu trên trang?
Tạo, thay đổi và xóa phần tử — createElement / classList / remove
Thêm phần tử bằng createElement và append, xóa bằng remove, đổi class và thuộc tính bằng classList, dataset, và hiển thị dữ liệu nhập dạng chữ bằng textContent.
Nếu mỗi lần thay đổi, như thêm một mục vào danh sách, đánh dấu một mục đã xong hay xóa một mục, bạn đều gán lại innerHTML, thì cả những li bạn muốn giữ nguyên cũng bị dựng lại. Còn nếu gán dữ liệu người dùng nhập theo cách đó, các thẻ lẫn trong dữ liệu, như <a>, cũng trở thành phần tử.
Bài này nói về cách tạo và xóa phần tử, classList và dataset để thay đổi class và thuộc tính, và cách đưa dữ liệu nhập vào dưới dạng chữ thuần.
Tạo và thêm phần tử — createElement và append
Giả sử bạn muốn thêm "Gửi hóa đơn" vào cuối danh sách công việc. Nếu nối thêm chuỗi vào innerHTML rồi gán lại, mọi li đang có trong danh sách cũng bị dựng lại thành phần tử mới. li mà bạn đã lưu vào biến bằng querySelector trước đó vẫn trỏ tới phần tử cũ, vốn không còn trên trang, nên đổi chữ qua biến đó cũng không làm thay đổi gì trên màn hình.
createElement (phương thức nhận tên thẻ và tạo một phần tử mới chưa nằm ở đâu trong cây DOM) được gọi như document.createElement("li"). Phần tử vừa tạo chỉ trở thành một phần của trang khi bạn thêm nó vào một phần tử cha bằng append (phương thức đặt phần tử nhận làm tham số vào cuối danh sách con của phần tử gọi nó).
document.body.innerHTML = `<ul id="tasks"><li>Lập báo giá</li><li>Chia sẻ biên bản họp</li></ul>`;
const taskList = document.querySelector("#tasks");
// Tạo phần tử li và đưa chữ vào (lúc này nó chưa có trên trang)
const newTask = document.createElement("li");
newTask.textContent = "Gửi hóa đơn";
console.log(newTask.outerHTML); // <li>Gửi hóa đơn</li>
console.log(document.querySelectorAll("li").length); // 2
// Thêm vào cuối danh sách con của ul thì nó trở thành một phần của trang
taskList.append(newTask);
console.log(document.querySelectorAll("li").length); // 3
console.log(taskList.outerHTML);
// <ul id="tasks"><li>Lập báo giá</li><li>Chia sẻ biên bản họp</li><li>Gửi hóa đơn</li></ul>
Biến newTask trỏ tới cùng một phần tử li cả trước lẫn sau khi append. Vì vậy, nếu gán cho newTask.textContent sau khi đã gọi append, chữ của li thứ 3 trên trang sẽ thay đổi.
Đổi dấu hiệu hoàn thành — classList và dataset
Tiếp theo, bạn muốn thêm class done cho công việc đã xong để CSS hiển thị nó với đường gạch ngang. Thuộc tính class của li đó là một chuỗi duy nhất task urgent, nên nếu ghi đè giá trị thành done, bạn cũng mất luôn class task mà CSS dùng để tạo style cho dòng đó.
classList (object để làm việc với class của phần tử theo từng từ) có add để thêm một từ, remove để bỏ một từ, và toggle để thêm nếu chưa có, bỏ đi nếu đã có. Các thuộc tính khác class được ghi bằng setAttribute(tên, giá trị), còn thuộc tính bắt đầu bằng data- thì ghi qua dataset (object để đọc và ghi data attribute theo tên).
document.body.innerHTML = `<ul><li class="task urgent" data-id="T-1">Lập báo giá</li><li class="task urgent" data-id="T-2">Gửi hóa đơn</li></ul>`;
const quote = document.querySelector('[data-id="T-1"]');
const invoice = document.querySelector('[data-id="T-2"]');
// classList thêm và bỏ từng từ một (các class khác vẫn giữ nguyên)
quote.classList.add("done");
console.log(quote.outerHTML); // <li class="task urgent done" data-id="T-1">Lập báo giá</li>
quote.classList.toggle("urgent"); // Đã xong nên bỏ urgent (đang có nên toggle sẽ bỏ nó đi)
console.log(quote.outerHTML); // <li class="task done" data-id="T-1">Lập báo giá</li>
// setAttribute thay toàn bộ giá trị (dùng cho class thì task cũng mất)
invoice.setAttribute("class", "done");
console.log(invoice.outerHTML); // <li class="done" data-id="T-2">Gửi hóa đơn</li>
// Thuộc tính bắt đầu bằng data- thì ghi qua dataset (doneAt trở thành data-done-at)
invoice.dataset.doneAt = "9/10";
console.log(invoice.outerHTML);
// <li class="done" data-id="T-2" data-done-at="9/10">Gửi hóa đơn</li>
Tên dùng với dataset là tên thuộc tính đã bỏ data-, và chữ cái đứng sau mỗi dấu gạch ngang còn lại được viết hoa. Bạn đọc và ghi data-id qua dataset.id, data-done-at qua dataset.doneAt; khi gán giá trị, thuộc tính được ghi vào HTML dưới tên gốc.
dataset không nhận tên có dấu gạch ngang
Nếu đọc mà vẫn giữ dấu gạch ngang, như dataset["done-at"], bạn nhận về undefined chứ không có lỗi nào, nên rất khó nhận ra lỗi gõ sai. Còn gán theo cách đó thì dòng đó báo lỗi. Tên lỗi là SyntaxError, nhưng khác với lỗi cú pháp thật trong code, nó xảy ra lúc chạy nên các dòng phía trước vẫn được thực thi.
Xóa phần tử — remove và tham chiếu còn lại trong biến
Giờ giả sử bạn muốn gỡ một công việc đã xong khỏi danh sách. Nếu viết lại danh sách bằng innerHTML, bạn phải ghi lại cả các class và data attribute đã thêm ở phần trước vào chuỗi HTML, và chỉ cần sót một chỗ là công việc được giữ lại sẽ mất dấu hiệu hoàn thành.
remove (phương thức tách phần tử gọi nó ra khỏi phần tử cha và xóa nó khỏi cây DOM) được gọi không có tham số trên phần tử bạn muốn xóa, như doneItem.remove(). classList.remove("done") ở phần trước chỉ bỏ một từ khỏi class, là phương thức khác hẳn với remove() xóa chính phần tử.
document.body.innerHTML = `<ul id="tasks"><li data-id="T-1">Lập báo giá</li><li data-id="T-2" class="done">Gửi hóa đơn</li><li data-id="T-3">Chia sẻ biên bản họp</li></ul>`;
const tasks = document.querySelectorAll("li");
// Tìm li đã xong và xóa nó khỏi cây DOM
const doneItem = document.querySelector(".done");
doneItem.remove();
console.log(document.querySelectorAll("li").length); // 2
// Phần tử đã xóa và NodeList lấy từ trước đều vẫn còn trong biến
console.log(doneItem.textContent); // Gửi hóa đơn
console.log(tasks.length); // 3 (3 phần tử tìm được lúc lấy)
// Vẫn còn trong biến nên append có thể đưa nó trở lại cuối danh sách
document.querySelector("#tasks").append(doneItem);
console.log(document.querySelectorAll("li").length); // 3
querySelectorAll chỉ trả về các phần tử tìm được ngay lúc bạn gọi nó; nó không theo dõi những thay đổi sau đó của DOM. doneItem đã bị xóa cũng vẫn còn trong biến, nên bạn vẫn đọc được chữ của nó hoặc đưa nó trở lại danh sách bằng append.
Hiển thị dữ liệu nhập dưới dạng chữ — textContent và innerHTML
Hãy hình dung phần đánh giá sản phẩm hiển thị nhận xét do người dùng đăng. Nhận xét được lưu lại, và cùng chuỗi đó cũng hiện trên trang của những người xem khác. Nếu nhận xét chứa thẻ a (thẻ tạo liên kết, đích đến ghi trong thuộc tính href), việc gán nó cho innerHTML sẽ biến liên kết giả do người đăng cài vào thành một liên kết thật trên trang.
Chuỗi gán cho innerHTML được phân tích như HTML, và các thẻ trong đó trở thành phần tử. Chuỗi gán cho textContent thì không được phân tích, nên kể cả < cũng chỉ là một ký tự bình thường. Đưa dữ liệu nhập vào innerHTML không chỉ cho phép người khác cài liên kết giả, mà còn có thể dẫn đến XSS (lỗ hổng khiến code mà người dùng lén chèn vào dữ liệu nhập chạy trên trang của người xem khác).
document.body.innerHTML = `<p class="review"></p>`;
const review = document.querySelector(".review");
// Chuỗi người dùng nhập vào ô nhận xét (có lẫn một liên kết giả)
const reviewText = '<a href="https://example.com/login">Đăng nhập lại</a> để tiếp tục';
// Đưa vào innerHTML thì thẻ a trở thành phần tử a thật
review.innerHTML = reviewText;
console.log(document.querySelectorAll("a").length); // 1
// Đưa vào textContent thì cả thẻ cũng được giữ nguyên dạng chữ
review.textContent = reviewText;
console.log(document.querySelectorAll("a").length); // 0
console.log(review.outerHTML);
// <p class="review"><a href="https://example.com/login">Đăng nhập lại</a> để tiếp tục</p>
- Chữ bên trong là "Đăng nhập lại"
- Nhấn vào sẽ chuyển sang trang web khác
- Nằm sau phần tử a
<a href="…">Đăng nhập lại</a> để tiếp tục- Có 0 phần tử a
< và > trong outerHTML là cách HTML viết < và > dưới dạng chữ; khi đọc lại bằng textContent, bạn nhận về đúng các ký tự đã nhập. Bảng dưới đây tóm tắt các cách thay đổi phần tử trong bài, theo từng đối tượng cần thay đổi.
| Đối tượng thay đổi | Cách viết | Điều cần lưu ý |
|---|---|---|
| Chính phần tử | createElement / append / remove | Chưa có trên trang cho đến khi append |
| class | classList.add / remove / toggle | setAttribute thay toàn bộ giá trị |
| Thuộc tính bắt đầu bằng data- | dataset.doneAt = "9/10" | Bỏ data-, viết hoa chữ sau dấu - |
| Các thuộc tính khác | setAttribute("href", "/tasks/T-2") | Thêm nếu chưa có, có rồi thì thay giá trị |
| Chữ bên trong | textContent | Đưa dữ liệu người dùng nhập vào đây |
| HTML bên trong | innerHTML | Chỉ HTML do bạn tự viết |
Code trong onerror của img vẫn chạy
<script> đưa vào innerHTML sẽ không chạy, nhưng nếu thẻ img (thẻ hiển thị ảnh) được viết như <img src="x" onerror="…"> thì onerror (thuộc tính chứa code chạy khi tải ảnh thất bại) vẫn chạy. Vì vậy, việc <script> không chạy không có nghĩa là đưa dữ liệu nhập vào innerHTML là an toàn.
Kiểm tra kiến thức
Hãy trả lời từng câu hỏi một.
Câu 2Nếu class của một li là task urgent, sau khi gọi setAttribute("class", "done") class sẽ là gì?
Câu 3Sau p.textContent = "<b>Mới</b>";, bên trong p có bao nhiêu phần tử b?