← Lập trình JavaScript nâng cao

Bài 3 · Nâng cao · 22 phút

Hoisting & TDZ

Biên soạn bởi Nguyễn Anh Tuấn

Hoisting trong JavaScript nhìn từ engine: pha tạo execution context cẩu var & function; temporal dead zone (TDZ) của let/const; khi nào lợi, khi nào nên tránh.

Đoạn code dưới đây trông như sẽ lỗi, nhưng nó chạy ngon và in ra undefined. Vì sao một biến lại có giá trị trước cả dòng khai báo của nó?

đọc x trước khi khai báo

console.log(x);   // undefined  (khong loi!)
var x = 5;
console.log(x);   // 5

Kết quả khi chạy

undefined
5

Hiện tượng này gọi là hoisting (tạm dịch: "cẩu" khai báo lên đầu phạm vi). Người ta hay nói "JavaScript kéo khai báo varfunction lên đầu" - nhưng đó chỉ là cách nói cho dễ hình dung. Sự thật nằm ở cách engine chuẩn bị code TRƯỚC khi chạy.

  • Đọc một biến var trước dòng khai báo: ra undefined, không phải lỗi.
  • Tên gọi: hoisting - "cẩu" khai báo lên đầu phạm vi.
  • "Di chuyển code lên đầu" chỉ là hình ảnh ẩn dụ - cơ chế thật ở Bước 2.

V8 không chạy code của bạn ngay dòng đầu. Với mỗi đoạn code (cả script lẫn thân mỗi hàm), nó lập một execution context qua hai pha:

PhaEngine làm gì với khai báo
Tạo (creation)Quét cả đoạn, tạo binding: var → gán undefined; function declaration → gán luôn cả hàm; let/const → tạo binding nhưng CHƯA khởi tạo.
Chạy (execution)Chạy từng dòng từ trên xuống, gán giá trị thật khi gặp dòng khai báo.

Vì pha tạo chạy trước, function declaration đã có sẵn cả thân hàm - nên gọi nó trước dòng định nghĩa vẫn chạy:

goi function declaration truoc khi dinh nghia

console.log(tong(2, 3));   // 5  (goi truoc!)

function tong(a, b) {
  return a + b;
}

Kết quả khi chạy

5

Nhưng function expression thì khác: nó chỉ là một biến var được gán một hàm. Ở pha tạo, biến đó mới là undefined - gọi vào sẽ lỗi:

function expression chua duoc gan -> TypeError

try {
  nhan(2, 3);
} catch (e) {
  console.log(e.constructor.name + ": " + e.message);
}

var nhan = function (a, b) { return a * b; };

Kết quả khi chạy

TypeError: nhan is not a function

Trung thực

"Hoisting" không phải một bước riêng trong spec - đó là hệ quả quan sát được của pha tạo execution context. Nói "engine cẩu khai báo lên đầu" là mô tả hiệu ứng, còn cơ chế thật là binding được tạo trước khi dòng code chạy. Hiểu đúng pha tạo thì mọi ca hoisting tự suy ra được, không cần học vẹt.
  • Execution context có 2 pha: tạo (lập binding) rồi mới chạy (gán giá trị).
  • function declaration được cẩu cả tên lẫn thân -> gọi trước vẫn chạy.
  • function expression chỉ là biến var/let -> theo luật hoisting của biến đó.

Một hiểu lầm phổ biến: "let/const không được hoisting". Sai - chúng VẪN được tạo binding ở pha tạo. Khác biệt là chúng chưa được khởi tạo cho tới khi luồng chạy tới dòng khai báo. Khoảng từ đầu phạm vi đến dòng đó gọi là temporal dead zone (TDZ) - chạm vào là ném lỗi:

truy cap let trong TDZ

try {
  console.log(y);
} catch (e) {
  console.log(e.constructor.name + ": " + e.message);
}

let y = 10;

Kết quả khi chạy

ReferenceError: Cannot access 'y' before initialization

TDZ nghiêm tới mức cả typeof cũng không thoát. So sánh: một tên CHƯA hề khai báo thì typeof trả về "undefined" an toàn, nhưng một biến let trong TDZ vẫn ném lỗi:

typeof: chua khai bao vs trong TDZ

console.log(typeof chuaCo);   // "undefined" - chua khai bao bao gio
try {
  console.log(typeof z);      // z dang trong TDZ
} catch (e) {
  console.log(e.constructor.name + ": " + e.message);
}

let z = 1;

Kết quả khi chạy

undefined
ReferenceError: Cannot access 'z' before initialization

TDZ là tính năng, không phải bug

TDZ ra đời cùng let/const (ES2015) chính để chặn lỗi dùng-trước-khai-báo - thứ mà var lặng lẽ cho qua bằng giá trị undefined. Lỗi to tiếng ngay tại chỗ vẫn tốt hơn một undefined trôi đi gây bug khó tìm.
  • let/const VẪN được hoisting (tạo binding), chỉ là chưa khởi tạo.
  • TDZ = khoảng từ đầu phạm vi tới dòng khai báo; chạm vào -> ReferenceError.
  • typeof một biến trong TDZ vẫn lỗi, khác typeof tên chưa khai báo (ra "undefined").

var phạm vi theo HÀM và được cẩu lên đầu hàm, một khai báo var nằm sâu trong nhánh không bao giờ chạy vẫn kịp che mất biến cùng tên ở ngoài - và để lại undefined:

bug: var trong ham bi cau len, che bien ngoai

var trangThai = "active";

function kiemTra() {
  console.log(trangThai);        // undefined, KHONG phai "active"!
  if (false) {
    var trangThai = "inactive";  // bi cau len dau ham
  }
}

kiemTra();

Kết quả khi chạy

undefined

Bên trong kiemTra, engine cẩu var trangThai lên đầu hàm (giá trị undefined), nên dòng console.log thấy biến cục bộ này chứ không thấy "active" ở ngoài. Đổi sang const là hết bug - không còn cẩu kiểu var, và TDZ sẽ tố cáo ngay nếu lỡ dùng sai:

sua bang const - khong con cau, khong con che

const trangThai = "active";

function kiemTra() {
  console.log(trangThai);   // "active"
}

kiemTra();

Kết quả khi chạy

active

Đây là cùng họ với bug var trong vòng lặp mà bạn đã gặp ở bài Hàm, closure & this (khoá JavaScript cơ bản): gốc rễ đều là phạm vi theo hàm + hoisting của var.

  • Mặc định dùng const, đổi sang let khi cần gán lại - bỏ hẳn var.
  • Khai báo SÁT nơi dùng để TDZ làm việc thay bạn, đừng dựa vào hoisting của var.
  • Bật quy tắc lint no-use-before-define để máy bắt lỗi dùng-trước-khai-báo.

Không phải mọi hoisting đều xấu. Hoisting của function declaration là công cụ thật: nó cho hai hàm gọi qua lại nhau (đệ quy lồng nhau, mutual recursion) dù hàm này định nghĩa sau hàm kia, và cho bạn đặt lời gọi chính lên trên cùng, hàm phụ để xuống dưới:

mutual recursion + doc top-down nho hoisting

console.log(laChan(10));   // true
console.log(laChan(7));    // false

function laChan(n) {
  return n === 0 ? true : laLe(n - 1);
}
function laLe(n) {
  return n === 0 ? false : laChan(n - 1);
}

Kết quả khi chạy

true
false

Lời gọi nằm TRÊN cả hai định nghĩa mà vẫn chạy, vì ở pha tạo cả laChan lẫn laLe đã sẵn sàng. Lối viết này giúp đọc code như đọc báo: ý chính trước, chi tiết sau - thay vì bắt người đọc cuộn xuống cuối mới thấy hàm khởi đầu.

Cũng chính cái binding tạo ở pha tạo là thứ một closure "nhớ" được, và là thứ garbage collector dò theo (reachability) để quyết định giữ hay dọn. Hoisting, scope, closure và GC đều là các mặt của cùng một câu chuyện: engine quản lý binding ra sao. Nền tảng let/const này bạn đã đặt từ bài Biến & kiểu dữ liệu JavaScript.

  • function declaration hoisting: hợp cho mutual recursion và lối viết đọc top-down.
  • Binding ở pha tạo chính là thứ closure giữ và GC dò theo reachability.
  • Quy tắc thực dụng: tận dụng hoisting của hàm, tránh hoisting của var.

Tiếp theo: rời phòng máy, ra kho gói

Đã khép lại bộ ba "bản chất" của engine: event loop, garbage collector và giờ là hoisting cùng execution context. Bài kế đổi không khí - ra kho gói: npm nâng cao & package managers - workspaces/monorepo, publish gói của riêng mình, lockfile, và so găng npm/yarn/pnpm/bun.

Câu hỏi thường gặp

Không - đó chỉ là cách mô tả cho dễ hình dung. Engine không sửa thứ tự code của bạn. Trước khi chạy một đoạn (script hay thân hàm), V8 quét qua nó một lượt và TẠO SẴN binding cho mọi khai báo trong execution context; sau đó mới chạy từng dòng. Hiệu ứng "dùng được trước khi khai báo" là vì binding đã có sẵn từ pha tạo, chứ code không nhảy đi đâu.

Cả ba đều được tạo binding ở pha tạo. Khác ở thời điểm KHỞI TẠO: var được gán undefined ngay nên đọc trước khai báo ra undefined; let/const KHÔNG được khởi tạo cho tới khi luồng chạy tới dòng khai báo - khoảng giữa đó là temporal dead zone (TDZ), truy cập vào sẽ ném ReferenceError.

function tong() {} (declaration) được cẩu CẢ tên lẫn thân hàm, nên gọi trước dòng khai báo vẫn chạy. Còn const f = function () {} hay arrow chỉ là một biến được gán hàm - nó theo luật hoisting của var/let/const: với var thì biến là undefined (gọi vào ra TypeError: ... is not a function), với let/const thì rơi vào TDZ.

Ném ReferenceError, vì biến đang trong TDZ. Đây là điểm khác tinh tế: typeof một tên CHƯA HỀ được khai báo trả về chuỗi "undefined" một cách an toàn, nhưng typeof một biến let/const trong TDZ thì vẫn lỗi. TDZ cố ý nghiêm khắc để bắt lỗi dùng-trước-khai-báo sớm.

Tuỳ loại. Hoisting của function declaration là công cụ tốt: cho phép đặt logic chính lên trên, hàm phụ xuống dưới (đọc top-down), và cho hai hàm gọi qua lại nhau (mutual recursion). Còn hoisting của var thì nên tránh - mặc định dùng const, đổi sang let khi cần gán lại, và khai báo sát nơi dùng.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Đoạn console.log(x); var x = 5; in ra gì?

  1. 1

    Đoán rồi chạy: var

    Mèo con viết console.log(a); var a = 7; console.log(a);, ĐOÁN output trước, rồi chạy bằng node.

    Hoàn thành khi: Đoán và kết quả khớp: in ra undefined rồi 7 - không hề có lỗi.

  2. 2

    Declaration vs expression

    Gọi một function declaration TRƯỚC dòng khai báo (chạy được), rồi thử gọi một var f = function(){} trước dòng gán nó.

    Hoàn thành khi: Declaration in ra kết quả; gọi function expression trước khi gán ném TypeError: f is not a function.

  3. 3

    Chạm vào TDZ

    Viết console.log(x); let x = 1; trong một khối, bọc try/catch và in e.message.

    Hoàn thành khi: Bắt được ReferenceError: Cannot access 'x' before initialization.

  4. 4

    typeof: chưa khai báo vs TDZ

    In typeof chuaCo (tên chưa khai báo bao giờ), rồi typeof y với let y = 2; ở dòng sau (bọc try/catch).

    Hoàn thành khi: Cái đầu ra undefined; cái sau ném ReferenceError - chứng minh TDZ nghiêm hơn biến vô danh.

  5. 5

    Chữa bug var che biến

    Tái hiện bug ở Bước 4 (hàm có var trong nhánh không chạy làm biến ngoài thành undefined), rồi sửa cho in đúng giá trị.

    Hoàn thành khi: Bản lỗi in undefined; bản sửa (bỏ var trong hàm hoặc đổi tên/đưa ra const) in đúng giá trị mong đợi.

  6. 6

    Đệ quy lồng nhau

    Viết hai hàm declaration laChan/laLe gọi qua lại, đặt lời gọi laChan(10) LÊN TRÊN cả hai định nghĩa.

    Hoàn thành khi: Chạy ra true dù lời gọi nằm trên - nhờ cả hai declaration được hoisting.