DEV Community

Execution Context — Hoisting

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. varfunction 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án undefined ngay. Nên truy cập x trước dòng khai báo không lỗi, chỉ ra undefined.
  • 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 throw ReferenceError. 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
Enter fullscreen mode Exit fullscreen mode

Đ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 () {}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

var là function-scoped, không block-scoped, nên rate "thoát" ra khỏi khối if. Khi order.total <= 100, rateundefined, 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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

undefinedNaN 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 ra undefined còn let đọc sớm thì throw?

Cả varlet đề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)