Hoisting và TDZ: vì sao biến ra undefined thay vì báo lỗi
Trước khi chạy bất kỳ dòng code nào trong một execution context, engine có một pha quét toàn bộ scope để đăng ký các khai báo. var và function declaration được đăng ký và khởi tạo ngay trong pha này; let, const, class cũng được đăng ký nhưng cố tình để chưa khởi tạo. Sự khác nhau ở bước "đã khởi tạo hay chưa" là toàn bộ câu chuyện hoisting, và là lý do cùng một lỗi truy cập sớm lúc thì ra undefined âm thầm, lúc thì throw ReferenceError ngay.
Cơ chế hoạt động
Khi vào một context, engine xử lý hai pha. Pha tạo (creation) duyệt qua scope và đăng ký mọi binding. Pha thực thi (execution) mới chạy code theo thứ tự dòng.
Trong pha tạo:
-
var xđược đăng ký và gánundefinedngay. Nên truy cậpxtrước dòng khai báo không lỗi, chỉ raundefined. -
function foo() {}(declaration) được đăng ký và gán luôn cả thân hàm. Nên gọi được trước dòng định nghĩa. -
let y/const z/class Cđược đăng ký nhưng ở trạng thái "uninitialized". Truy cập trước dòng khai báo throwReferenceError. Vùng từ đầu scope tới dòng khai báo gọi là Temporal Dead Zone (TDZ).
console.log(a) // undefined — var đã đăng ký, gán undefined
console.log(typeof b) // ReferenceError — b trong TDZ, typeof cũng không cứu được
var a = 1
let b = 2
Điểm hay bị hiểu sai: hoisting không phải "code bị dời lên đầu". Khai báo vẫn nằm yên chỗ cũ; chỉ là việc đăng ký binding xảy ra trước trong pha tạo. function declaration được hoist cả thân hàm, còn function expression gán vào var/let thì chỉ binding được hoist, thân hàm vẫn nằm ở dòng gán:
greet() // OK — function declaration
hi() // TypeError: hi is not a function — var hi mới là undefined ở đây
function greet() {}
var hi = function () {}
Vấn đề gặp trong production
Failure mode điển hình là một biến var bị che (shadow) trong cùng hàm khiến giá trị ra undefined một cách âm thầm, không có lỗi nào được ném:
function applyDiscount(order) {
if (order.total > 100) {
var rate = 0.1
}
// rate tồn tại ở toàn bộ hàm do var function-scoped, nhưng undefined khi total <= 100
return order.total * (1 - rate) // NaN khi rate undefined
}
var là function-scoped, không block-scoped, nên rate "thoát" ra khỏi khối if. Khi order.total <= 100, rate là undefined, phép nhân ra NaN, và NaN này trôi vào hóa đơn, vào DB, vào báo cáo — không stack trace, không exception, chỉ là một con số sai lặng lẽ. Loại bug này tốn nhiều giờ điều tra vì không có điểm crash để bám vào.
Dùng let/const cắt đứt cả lớp lỗi này: binding bị giới hạn trong block, và TDZ biến "đọc trước khi gán" thành một ReferenceError ngay tại chỗ — fail fast thay vì lan ngầm.
function applyDiscount(order) {
const rate = order.total > 100 ? 0.1 : 0
return order.total * (1 - rate)
}
Một biến thể nguy hiểm khác là TDZ trong vòng đời module/khởi tạo, khi const được tham chiếu trước dòng định nghĩa qua một hàm gọi sớm:
init() // ReferenceError: Cannot access 'CONFIG' before initialization
const CONFIG = loadConfig()
function init() {
return CONFIG.timeout
}
init được hoist (function declaration) nên gọi được, nhưng thân nó chạm vào CONFIG khi CONFIG còn trong TDZ. Bug này hay xuất hiện khi sắp xếp lại thứ tự code trong một file khởi động hoặc khi import vòng (circular import) làm đảo thứ tự đánh giá.
Cách debug và monitor
undefined và NaN xuất hiện ở chỗ đáng lẽ phải có giá trị thường là dấu vết của hoisting var: biến được dùng trước khi nhánh code gán cho nó chạy. ReferenceError: Cannot access 'X' before initialization là chữ ký rõ ràng của TDZ — đọc tên biến trong thông báo rồi tìm dòng let/const/class khai báo nó, lỗi nằm ở một lệnh chạy trước dòng đó.
Phòng tốt hơn chữa: bật lint rule cấm var (no-var) và bắt dùng trước khi định nghĩa (no-use-before-define). Hai rule này chuyển phần lớn lỗi hoisting từ runtime sang lúc viết code, nơi chúng vô hại.
Tradeoff
function declaration được hoist cả thân hàm cho phép gọi hàm trước khi định nghĩa, nên nhiều người đặt các hàm helper xuống cuối file và phần logic chính lên trên cho dễ đọc. Tiện ích này có giá: nó che giấu thứ tự phụ thuộc thật giữa các hàm, và lẫn lộn với function expression (không có đặc tính đó) gây ra TypeError khó hiểu. Lựa chọn an toàn cho code production là const fn = () => {} cho mọi hàm và let/const cho mọi biến — mất khả năng "gọi trước, định nghĩa sau", đổi lại mọi tham chiếu sớm đều fail rõ ràng tại TDZ thay vì chạy ngầm với giá trị sai.
Câu hỏi phỏng vấn
Hoisting khác TDZ thế nào? Vì sao
varđọc sớm raundefinedcònletđọc sớm thì throw?
Cả var và let đều được hoist theo nghĩa được đăng ký trong pha tạo của execution context. Khác biệt ở trạng thái khởi tạo: var được khởi tạo undefined ngay, nên đọc sớm ra undefined không lỗi; let/const/class được đăng ký nhưng để ở trạng thái chưa khởi tạo, và vùng từ đầu scope tới dòng khai báo là TDZ — đọc trong vùng đó throw ReferenceError: Cannot access ... before initialization. Điểm ăn điểm là chỉ ra hệ quả production: var function-scoped thoát khỏi block gây undefined/NaN lan ngầm không stack trace, còn TDZ của let/const biến cùng lỗi thành fail-fast; nên thực tế bỏ var, dùng const mặc định và bật no-var + no-use-before-define.
Hands-on
Lấy một hàm thật có nhánh điều kiện gán biến cấu hình (ví dụ tính phí ship theo vùng, mỗi vùng set một biến rate/fee trong nhánh if), viết bằng var rồi gọi với input rơi vào nhánh không gán. Quan sát kết quả ra NaN mà không có lỗi nào ném. Sau đó đổi sang const với toán tử ba ngôi hoặc gán mặc định, chạy lại để thấy code hoặc cho kết quả đúng hoặc throw rõ ràng. Cuối cùng thêm một hàm khởi tạo gọi trước dòng const CONFIG = ... để tự tay tái tạo ReferenceError của TDZ, rồi sửa bằng cách dời lời gọi xuống sau khai báo — đúng tình huống hay gặp khi refactor file khởi động.
Top comments (0)