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>
Cây DOM dựng từ index.html
Phần tử html — gốc của DOM, truy cập từ document
Phần tử head — thông tin không hiện trên trang
  • meta — mã hóa ký tự là UTF-8
  • title — nội dung là "Xác nhận đơn hàng"
  • link — trỏ tới style.css
Phần tử body — phần hiện trên trang
Phần tử h1
  • Nội dung là "Xác nhận đơn hàng"
Phần tử ul — id là items
  • li — nội dung là "Cốc sứ × 2"
  • li — nội dung là "Đế lót ly × 1"
Phần tử p — id là total
  • Nội dung là "Đang tính…"
  • Phần tử mà app.js muốn thay đổi
head và body nằm cạnh nhau làm con của html, còn các li nằm trong ul. Cách các thẻ lồng nhau trở thành quan hệ cha–con trong DOM.

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')
Khi script chạy, p chưa tồn tại
<link href="style.css">Bắt đầutải CSSVẫn phân tíchtiếp dòng sau<script src="app.js">Dừng phân tích,chạy app.jsChưa có #totalnên trả về null<p id="total">Phân tích tiếp,thêm vào DOMCó #total thìđã trả về null rồi</html>Hết phân tích,DOM hoàn chỉnhVẫn hiện"Đang tính…"
Việc phân tích dừng ở dòng script và chỉ đọc body sau khi app.js chạy xong. Dù app.js báo lỗi, việc phân tích vẫn tiếp tục và trang vẫn được hiển thị.

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
defer tải song song, chạy sau cùng
<script defersrc="app.js">Đọc tiếp, thêmbody vào DOMĐọc hếtđến </html>DOM hoàn chỉnhBắt đầutải app.jsĐang tải,vẫn phân tíchĐã tải xongnhưng chưa chạyChạy app.js,tìm thấy #total
app.js được tải trong lúc việc phân tích vẫn tiếp diễn, và dù tải xong trước, nó vẫn phải chờ mới được chạy. app.js chỉ chạy sau khi DOM đã hoàn chỉnh.

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 scriptKhi app.js chạy#total
<script src="app.js"> trong headNgay lập tức, dừng phân tíchKhông thấy (null)
<script src="app.js"> trước </body>Sau khi đọc pTìm thấy
<script defer src="app.js"> trong headSau khi DOM hoàn chỉnhTìm thấy
Code viết thẳng trong <script defer> ở headNgay lập tức (thiếu src thì defer vô hiệu)Không thấy (null)
<script type="module" src="app.js"> trong headSau DOM, không cần deferTì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
Cùng một biểu thức, trang nạp xong thì trả về p
app.js chạy khitrang đang nạpViệc phân tíchdừng ở headquerySelectortrả về nullTypeErrorở dòng 2Gõ vào Consolesau khi nạp xongHết phân tích,DOM hoàn chỉnhquerySelectortrả về pĐọc được chữ"Đang tính…"
Biểu thức trả về null trong app.js lại trả về phần tử p trong Console. Chạy được trong Console chưa chắc đã chạy được trong app.js.

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>
Trang vô hình các bài tập dùng chung
Trang bài viết — console bài tập
  • Chỉ kết quả in ra của console.log được trả về, dưới dạng chữ
Trang vô hình — một trang cho mọi bài tập
  • Nội dung của document.body — giữ đến lần chạy sau
Lần chạy thứ 1
  • const total — chỉ dùng được trong lần chạy này
  • Gán một p cho document.body.innerHTML
Lần chạy thứ 2
  • 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
Nội dung trong body được giữ lại đến lần chạy sau. Lệnh gán innerHTML ở đầu code sẽ xóa các phần tử còn sót.

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ó.

QUIZ

Kiểm tra kiến thức

Hãy trả lời từng câu hỏi một.

Câu 1Trong script nạp ở head mà không có defer, document.querySelector("#total") trả về gì?

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?