How Browsers Work · Part 3 — HTML Parsing & the DOM
How the renderer turns a byte stream into the DOM tree: tokenization, tree construction, the preload scanner, why scripts block parsing, and how async/defer change the picture.
Phần 2 kết thúc khi renderer nhận stream byte HTML sau khi commit điều hướng. Phần này zoom vào phép biến đổi đầu tiên: biến byte đó thành Document Object Model (DOM) — cây sống trong bộ nhớ mà JavaScript và CSS thực sự thao tác.
HTML bạn viết là văn bản; DOM là cây đối tượng node với quan hệ cha/con/anh em. Parse diễn ra tăng dần — trình duyệt có thể dựng cây và render một phần khi byte đến, dù stylesheet hay script chặn vẫn có thể trì hoãn nội dung nhìn thấy.
1. Từ byte sang ký tự: encoding
HTTP giao HTML dưới dạng byte thô. Trước tokenization, parser phải giải mã byte thành ký tự Unicode.
Ở mức mental model, encoding được chọn theo thứ tự ưu tiên chính:
- BOM (Byte Order Mark) ở đầu stream, nếu có.
- charset trong
Content-Type— vd.text/html; charset=utf-8từ phản hồi HTTP. <meta charset="utf-8">— declaration hợp lệ phải nằm trọn trong 1024 byte đầu.- Fallback của user agent — có thể phụ thuộc môi trường/tài liệu legacy; đừng giả định luôn là UTF-8 nếu không khai báo.
Trong lúc confidence còn “tentative”, parser có thể phải restart nếu prescan/meta hợp lệ phát hiện encoding khác. Chi tiết chuẩn nằm trong thuật toán encoding sniffing của HTML; cách an toàn vẫn là gửi Content-Type đúng và đặt <meta charset="utf-8"> thật sớm.
Raw bytes: 3C 21 44 4F 43 54 59 50 45 …
│
▼ (decode with UTF-8, or declared charset)
Characters: < ! D O C T Y P E …
│
▼
Tokenizer → tokens → Tree builder → DOM
Luôn khai báo charset sớm:
<meta charset="utf-8">là phần tử đầu trong<head>.
2. Tokenization: tách HTML thành mảnh có nghĩa
Tokenizer là máy trạng thái duyệt stream ký tự và phát token — từ vựng của parse HTML.
Các loại token phổ biến:
| Token | Ví dụ input |
|---|---|
| Start tag | <div class="box"> |
| End tag | </div> |
| Character | Hello (text content) |
| Comment | <!-- note --> |
| DOCTYPE | <!DOCTYPE html> |
| EOF | End of stream |
Tokenizer xử lý edge case sẽ làm hỏng regex naïve — thẻ không đóng, nội dung <script> trông như HTML, text thô <textarea>, và tham chiếu ký tự như & → &.
Input: <p>Hello <strong>world</strong></p>
Tokens:
StartTag(p)
Character("Hello ")
StartTag(strong)
Character("world")
EndTag(strong)
EndTag(p)
Tokenization stream — token phát ngay khi đủ ký tự, không chờ </html>.
3. Dựng cây: token thành DOM
Tree builder tiêu thụ token và dựng DOM theo thuật toán parse HTML5. Nó giữ stack phần tử mở và áp dụng quy tắc chèn — ví dụ, thẻ mở <p> bên trong <p> đang mở ngầm đóng đoạn đầu.
Token stream DOM (grows incrementally)
───────────── ────────────────────────────
StartTag(html) → html
StartTag(head) → html
StartTag(title) → head
Character("Page") → title → "Page"
EndTag(title) →
StartTag(body) → html
StartTag(h1) → body
Character("Hi") → h1 → "Hi"
EndTag(h1) →
Kết quả là cây sống các đối tượng node:
Document— gốcElement— thẻ như<div>,<p>Text— nội dung văn bảnComment,DocumentType, v.v.
Mỗi node lộ thuộc tính (nodeType, nodeName, childNodes, …) và API JavaScript dùng (querySelector, appendChild, …). DOM không phải chuỗi nguồn HTML — bạn có thể mutate và HTML serialize sẽ khác.
4. Vì sao <script> chặn parse
Đây là quy tắc parse quan trọng nhất cho hiệu năng frontend: khi tree builder gặp <script> classic (không defer, không async), nó phải tạm dừng parse HTML, fetch script (nếu external), thực thi, rồi mới tiếp tục.
Vì sao? Hai lý do gắn vào nền tảng web:
document.write()— script đồng bộ có thể inject HTML vào stream khi parse tạm dừng, và parser phải chèn HTML đó tại điểm chèn hiện tại.- Run-to-completion và thứ tự tài liệu — script có thể đọc/mutate phần DOM đã dựng tới điểm chèn; parser chỉ được tiếp tục sau khi script kết thúc để giữ semantics quan sát được.
Parser: <html> <head> … </head> <body>
│
▼
<script src="app.js"> ← PAUSE parsing
│
├─ Fetch app.js (network wait)
├─ Execute on main thread (JS wait)
│
▼
Resume parsing → <div> … rest of page
Các node dưới script chưa được parser chính dựng cho đến khi script xong. Tuy vậy, speculative preload scanner vẫn có thể nhìn trước markup tĩnh và fetch một số tài nguyên — chính là Mục 6. Đó là lý do script trong <head> thường dùng defer, async, hoặc module tùy dependency và thứ tự cần thiết.
5. Script async, defer, và module
Thuộc tính hiện đại đổi thời điểm script fetch và chạy:
| Attribute | Fetch | Execute | Order | Chặn parse? |
|---|---|---|---|---|
| (none) | Ngay | Ngay | Theo thứ tự tài liệu | Yes |
defer | Song song | Sau khi parse xong | Theo thứ tự tài liệu | No |
async | Song song | Ngay khi tải xong | Không theo thứ tự | Không (trừ lúc thực thi) |
type="module" | Song song cả dependency graph | Sau parse theo mặc định | Theo semantics module | No |
<!-- Blocks parsing until fetched AND executed -->
<script src="legacy.js"></script>
<!-- Downloads in parallel; runs after DOM is fully parsed, in order -->
<script defer src="app.js"></script>
<script defer src="analytics.js"></script>
<!-- Downloads in parallel; runs whenever ready — order NOT guaranteed -->
<script async src="ads.js"></script>
<!-- ES modules: deferred + strict mode + defer semantics by default -->
<script type="module" src="main.js"></script>
async cho script độc lập không cần thứ tự; defer cho classic script phụ thuộc DOM đầy đủ hay lẫn nhau. Script type="module" được defer mặc định; thêm async khiến cả graph chạy ngay khi sẵn sàng. defer không có tác dụng trên module script. Bảng hành vi chuẩn nằm ở phần script của HTML Standard.
Parser-inserted inline classic script chạy đồng bộ tại điểm gặp;
deferbị bỏ qua trên inline classic. Inline module được defer mặc định và có thể dùngasync.
6. Preload scanner: fetch trước khi bị chặn
Script chặn tạo nghịch lý: parser tạm dừng nên không phát hiện thẻ <img> và <link> phía dưới — nhưng bạn vẫn muốn tài nguyên đó tải song song.
Câu trả lời của trình duyệt là preload scanner (đôi khi gọi “lookahead preloader”) — quét phụ nhẹ trên stream byte thô không dựng node DOM nhưng nhận ra URL tài nguyên.
Main parser (blocked on <script>)
│
│ paused at: <script src="big.js">
│
Preload scanner (runs in parallel on raw bytes ahead)
│
├─ found: <link rel="stylesheet" href="styles.css"> → fetch CSS
├─ found: <img src="hero.jpg"> → fetch image
└─ found: <script defer src="app.js"> → fetch JS (won't execute yet)
Nó phát hiện:
<link rel="stylesheet">,<link rel="preload">,<link rel="modulepreload"><script src>(includingasync,defer, andtype="module")<img src>,<img srcset><video poster>và các resource URL tĩnh khác mà implementation hỗ trợ
Preload scanner là lý do đặt CSS trong <head> và script với defer vẫn đạt song song tốt — tài nguyên được fetch dù script chặn làm parser chính dừng. Nó heuristic — URL inject động (qua JS sau parse) vô hình với nó.
7. CSS, render, và tương tác parse
CSS tương tác với parse HTML theo cách tinh vi:
- CSS không chặn parse HTML — tokenizer và tree builder HTML vẫn chạy khi CSS tải.
- Stylesheet khớp media có thể chặn render — browser chờ những sheet được đánh dấu render-blocking trước lần paint liên quan.
- Stylesheet cũng có thể chặn parser-inserted classic script — script đứng sau sheet phải chờ sheet tải, vì nó có thể đọc computed style; không cần đợi đến lúc code thực sự gọi
offsetWidth.
Timeline (simplified):
HTML parse: ████████████████████░░░░░░ (script pauses ░)
CSS fetch: ██████████████
CSS parse: ████████
JS fetch: ████
JS exec: ████ (may wait for preceding blocking CSS)
First paint: ★ (needs DOM + blocking styles)
Phần 4 đi chi tiết dựng CSSOM. Tạm thời nhớ: parse và render tách nhưng phối hợp — bạn có thể có DOM đầy đủ với style chưa đủ, nhưng không paint đúng cho đến khi style được tính.
8. Khoan dung lỗi và document.write
Parse HTML5 cố ý khoan dung. Trình duyệt không từ chối markup lỗi — chúng áp dụng quy tắc phục hồi:
| HTML của bạn | Parser làm gì |
|---|---|
<p><div></div></p> | <div> tự đóng <p> (block trong inline) |
<table><tr><td>A | Chèn <tbody> thiếu, đóng thẻ |
Unclosed <strong> | Giữ format mở đến khi đóng ngầm |
Đó là lý do <div> trong <p> “chạy” nhưng tạo DOM bất ngờ — <p> bị đóng ngầm trước <div>. Validator bắt lỗi; trình duyệt sửa im lặng.
document.write() là lưỡi dao sắc. Gọi khi đang parse, nó inject HTML vào stream sống; gọi trên document đã load thường ngầm document.open(), xóa tài liệu hiện tại rồi viết lại. Một số lời gọi từ script async/defer có thể bị browser bỏ qua, nên đừng dựa vào nó.
// During parse — inserts markup at current position (blocks parser)
document.write('<div>Injected</div>');
// After parse — WIPES the page and starts over (never do this)
document.write('<p>This destroys everything</p>');
Code hiện đại nên tránh document.write(); nó chủ yếu còn trong quảng cáo legacy và snippet bên thứ ba đồng bộ. Thấy trong codebase, coi là cờ đỏ và kiểm tra cả hiệu năng lẫn rủi ro injection.
Tóm tắt
- Parse HTML tăng dần: byte → ký tự (encoding) → token (tokenizer) → DOM (tree builder).
- DOM là cây sống đối tượng node, không phải chuỗi HTML nguồn — JavaScript đọc và mutate.
<script>classic khôngasync/deferchặn parser để giữdocument.write(), run-to-completion và thứ tự quan sát DOM.deferchạy sau parse theo thứ tự;asyncchạy khi sẵn sàng (không thứ tự);type="module"defer mặc định.- Preload scanner fetch tài nguyên phát hiện song song dù parser chính bị chặn.
- CSS không chặn tokenizer/tree builder nói chung, nhưng stylesheet render-blocking trì hoãn paint và stylesheet đứng trước có thể chặn parser-inserted script.
Phần tiếp theo: CSS & CSSOM — stylesheet thành rule object thế nào, cascade chọn thắng ra sao, và style trở thành layout box như thế nào.