Câu 1Trong script nạp ở head mà không có defer, document.querySelector("#total") trả về gì?
Cách JavaScript chạy trong trình duyệt
Trình duyệt dựng DOM từ HTML thế nào, thứ tự xử lý link và script, thuộc tính defer, DevTools và cách kiểm tra kết quả dưới dạng chữ.
Cùng một đoạn JavaScript thay đổi chữ trên trang, nhưng tùy vào chỗ bạn nạp nó mà code có lúc chạy, có lúc không. Khi không chạy, trang cũng không hiện lỗi nào, nên bạn không biết được là code sai hay do thứ tự thực thi.
Bài này nói về thứ tự trình duyệt đọc HTML và chạy JavaScript.
Các ví dụ trong bài này được mở dưới dạng trang HTML
Console bài tập không tái hiện được thứ tự nạp HTML, nên bài này không có bài tập. Mỗi ví dụ cho thấy điều xảy ra khi bạn đặt index.html và app.js trong cùng một thư mục rồi mở index.html bằng Chrome; kết quả được ghi trong comment ở cuối dòng. Cách kiểm tra bằng console được nói ở phần cuối bài.
Dựng các phần tử lồng nhau từ HTML — cây DOM
Giả sử bạn muốn app.js đổi dòng "Đang tính…" trên trang xác nhận đơn hàng thành "3.300 yên". Đối tượng mà JavaScript thay đổi lúc này không phải là chữ trong file index.html, mà là một dữ liệu khác do trình duyệt dựng lên sau khi đọc HTML.
Trình duyệt biến từng phần tử HTML (một phần của trang được bao bởi thẻ, như <p>…</p>) thành một object, và toàn bộ quan hệ cha–con giữa chúng được gọi là DOM (Document Object Model, còn gọi là cây DOM; điểm vào là document).
Những gì viết bên trong thẻ như id="total" hay defer được gọi là thuộc tính (attribute). Một giá trị id không được dùng cho hai phần tử trên cùng một trang.
<!-- ---- index.html (trang xác nhận đơn hàng) ---- -->
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>Xác nhận đơn hàng</title>
<link rel="stylesheet" href="style.css"> <!-- CSS quyết định giao diện -->
</head>
<body>
<h1>Xác nhận đơn hàng</h1>
<ul id="items"> <!-- có 2 phần tử con li -->
<li>Cốc sứ × 2</li>
<li>Đế lót ly × 1</li>
</ul>
<p id="total">Đang tính…</p> <!-- phần tử mà app.js muốn thay đổi -->
</body>
</html>
meta— mã hóa ký tự là UTF-8title— nội dung là "Xác nhận đơn hàng"link— trỏ tới style.css
- Nội dung là "Xác nhận đơn hàng"
li— nội dung là "Cốc sứ × 2"li— nội dung là "Đế lót ly × 1"
- Nội dung là "Đang tính…"
- Phần tử mà app.js muốn thay đổi
app.js lấy phần tử p bằng document.querySelector("#total") (tìm phần tử có id viết sau dấu #, trả về null nếu không có) rồi thay đổi textContent (chữ bên trong phần tử) của nó. Vì phần tử p là con của body, tìm được nó hay không phụ thuộc vào việc body đã có trong DOM hay chưa.
Đọc HTML từ trên xuống — thứ tự của link và script
Giả sử bạn nạp app.js, file cập nhật tổng tiền, trong <head> cạnh CSS. index.html rõ ràng có <p id="total">, vậy mà khi mở trang, dòng chữ vẫn là "Đang tính…" và phần thay đổi trong app.js không có tác dụng.
Trong quá trình phân tích HTML (parse: trình duyệt đọc HTML từ đầu và lần lượt thêm từng phần tử tìm thấy vào DOM), khi gặp <link>, trình duyệt vừa tải CSS vừa tiếp tục phân tích. Khi gặp <script src>, nó dừng phân tích, tải JavaScript về và chạy xong rồi mới đọc tiếp phần sau.
// ---- index.html (nạp app.js trong head) ----
// <head>
// <link rel="stylesheet" href="style.css"> ← vẫn tiếp tục phân tích
// <script src="app.js"></script> ← dừng phân tích để chạy
// </head>
// <body>
// <p id="total">Đang tính…</p> ← được thêm vào DOM sau app.js
// </body>
// ---- app.js ----
const total = document.querySelector("#total"); // null (chưa có trong DOM)
total.textContent = "3.300 yên";
// TypeError: Cannot set properties of null (setting 'textContent')
Phần thay đổi không có tác dụng vì lúc app.js chạy, phần tử p chưa có trong DOM. Phần tiếp theo sẽ giữ nguyên 2 dòng của app.js và chỉ dời thời điểm chạy sang lúc DOM đã hoàn chỉnh.
Chạy sau khi DOM hoàn chỉnh — thuộc tính defer
Nếu dời <script> xuống ngay trước </body>, code sẽ chạy sau khi phần tử p đã được đọc nên #total được tìm thấy. Nhưng khi đó, code chạy được hay không lại phụ thuộc vào vị trí đặt thẻ; nếu sau này bạn gom dòng nạp script trở lại <head> cùng với CSS, null lại xuất hiện như cũ.
Thuộc tính defer (thiết lập của <script> để tải JavaScript mà không dừng việc phân tích HTML, rồi chạy khi DOM đã hoàn chỉnh) được viết là <script defer src="app.js"></script> và có thể đặt trong <head> giống như <link> của CSS.
// ---- index.html (thêm defer và nạp trong head) ----
// <head>
// <link rel="stylesheet" href="style.css">
// <script defer src="app.js"></script> ← bắt đầu tải, vẫn tiếp tục phân tích
// </head>
// <body>
// <p id="total">Đang tính…</p> ← app.js chạy sau khi đọc hết đến </html>
// </body>
// ---- app.js (2 dòng như phần trước, thêm 1 dòng để kiểm tra) ----
const total = document.querySelector("#total"); // phần tử p
total.textContent = "3.300 yên"; // chữ trên trang thay đổi
console.log(total.textContent); // 3.300 yên
Khi có nhiều script gắn defer, chúng chạy theo thứ tự viết trong HTML chứ không theo thứ tự tải xong. Bảng dưới đây tóm tắt thời điểm app.js chạy và việc #total có được tìm thấy hay không, ứng với từng cách viết script.
| Cách viết script | Khi app.js chạy | #total |
|---|---|---|
| <script src="app.js"> trong head | Ngay lập tức, dừng phân tích | Không thấy (null) |
| <script src="app.js"> trước </body> | Sau khi đọc p | Tìm thấy |
| <script defer src="app.js"> trong head | Sau khi DOM hoàn chỉnh | Tìm thấy |
| Code viết thẳng trong <script defer> ở head | Ngay lập tức (thiếu src thì defer vô hiệu) | Không thấy (null) |
| <script type="module" src="app.js"> trong head | Sau DOM, không cần defer | Tìm thấy (trừ khi mở file trực tiếp) |
Tìm dòng bị lỗi và xem DOM — DevTools
Với trang chưa thêm defer, app.js báo lỗi TypeError nhưng trên trang không hiện gì cả. Nếu không biết lỗi xảy ra ở file nào, dòng thứ mấy, bạn không thể tìm ra vì sao trang cứ đứng ở "Đang tính…".
DevTools (công cụ có sẵn trong trình duyệt để xem DOM và lỗi của trang) có tab Console; trên Chrome, bạn mở nó bằng Ctrl + Shift + J trên Windows hoặc Command + Option + J trên Mac. Tab này hiển thị kết quả in ra của console.log, cùng với các lỗi kèm tên file và số dòng nơi lỗi xảy ra.
// ---- Lỗi hiện trong tab Console (nhấn vào app.js:2 ở bên phải để mở dòng đó) ----
// Uncaught TypeError: Cannot set properties of null (setting 'textContent')
// at app.js:2:19 ← lỗi ở dòng 2, ký tự thứ 19
// ---- Sau khi trang nạp xong, gõ vào sau dấu > trong Console rồi nhấn Enter ----
document.querySelector("#total"); // <p id="total">Đang tính…</p>
document.querySelector("#total").textContent; // 'Đang tính…'
// ---- Dòng thêm vào sau dòng 1 của app.js (không gõ vào Console) ----
console.log("total lúc chạy:", total); // total lúc chạy: null
Biểu thức gõ vào Console chạy trên DOM ở đúng thời điểm bạn gõ. Muốn kiểm tra một giá trị bên trong app.js, hãy thêm console.log trước dòng bạn nghi ngờ để in ra tab Console giá trị ngay tại thời điểm code chạy.
Kiểm tra kết quả dưới dạng chữ — innerHTML và outerHTML
Trong bài tập ở các bài sau, bạn sẽ tìm, thay đổi và thêm phần tử. Nhưng console bài tập không có màn hình. Cho đến khi bạn tải lại trang, mọi bài tập đều dùng chung một trang vô hình, nên mỗi lần chạy đều bắt đầu trong lúc các phần tử của lần chạy trước vẫn còn nguyên.
Gán giá trị cho innerHTML (property dùng để đọc và ghi nội dung bên trong phần tử dưới dạng chuỗi HTML) sẽ thay toàn bộ nội dung đó. Mỗi bài tập bắt đầu bằng việc gán cho innerHTML của document.body (phần tử body) để xóa các phần tử cũ, và bạn kiểm tra kết quả bằng cách in outerHTML (chuỗi HTML gồm cả thẻ của chính phần tử).
// ① Thay toàn bộ nội dung của body (phần tử còn sót từ lần chạy trước bị xóa ở đây)
document.body.innerHTML = `<p id="total">Đang tính…</p>`;
// ② Tìm phần tử rồi thay đổi chữ bên trong
const total = document.querySelector("#total");
total.textContent = "3.300 yên";
// ③ Trang không hiển thị nên in kết quả ra dưới dạng chuỗi
console.log(total.textContent); // 3.300 yên
console.log(total.outerHTML); // <p id="total">3.300 yên</p>
console.log(document.body.innerHTML); // <p id="total">3.300 yên</p>
- Chỉ kết quả in ra của
console.logđược trả về, dưới dạng chữ
- Nội dung của
document.body— giữ đến lần chạy sau
const total— chỉ dùng được trong lần chạy này- Gán một p cho
document.body.innerHTML
const total— được tạo thành biến mới, khác lần trước- Lệnh gán ở đầu code xóa p của lần 1
Mỗi lần chạy, code trong console được đặt trong một hàm mới, nên const total được tạo thành một biến khác với lần trước và không báo lỗi khai báo lại. Code cũng chạy sau khi trang đã nạp xong, nên bạn không cần nghĩ đến defer.
In thẳng phần tử sẽ ra {}
Console bài tập không có màn hình và chuyển kết quả in ra thành chữ trước khi trả về. Nếu truyền thẳng một phần tử vào console.log, bạn chỉ thấy {} không có nội dung gì, nên không biết thay đổi có tác dụng hay không. Để kiểm tra một phần tử, hãy in outerHTML hoặc textContent của nó.
Kiểm tra kiến thức
Hãy trả lời từng câu hỏi một.
Câu 2Với <script defer src="app.js">, app.js chạy khi nào?
Câu 3Nếu chạy document.body.innerHTML = '<p>Đang tính…</p>'; hai lần, sẽ có bao nhiêu phần tử p?