Học bằng cách đọc theo thứ tự

Scope và hoisting — Biến đọc được ở đâu và từ khi nào

Scope quyết định biến đọc được từ đâu, còn hoisting đăng ký khai báo trước khi code chạy. Kèm shadowing, TDZ của let và lý do bỏ var.

Đem một giá trị được tính bên trong cặp ngoặc nhọn ra dùng ở bên ngoài, chương trình sẽ dừng lại và báo lỗi. Đọc một giá trị cấu hình được khai báo ở dòng bên dưới cũng vậy. Cả hai lỗi đều xoay quanh một câu hỏi: một cái tên dùng được ở đâu và từ lúc nào.

Bài này nói về scope — phạm vi quyết định tên đã khai báo đọc được từ đâu — và hoisting — cơ chế đăng ký khai báo trước khi code chạy.

Biến chỉ dùng được trong dấu ngoặc nhọn — Block scope

Giả sử bạn muốn màn hình giỏ hàng hiển thị khách còn phải mua thêm bao nhiêu để được miễn phí vận chuyển. Số tiền còn thiếu có thể tính ngay trong if dùng để kiểm tra điều kiện miễn phí vận chuyển, nhưng nếu hiển thị giá trị đó ở ngoài dấu ngoặc nhọn thì chương trình dừng lại và báo lỗi ReferenceError.

Scope (phạm vi quyết định một tên đọc được từ đâu) được giới hạn bởi dấu ngoặc nhọn. Phạm vi của một cặp ngoặc nhọn gọi là block scope, và biến khai báo bằng const hoặc let chỉ đọc được bên trong cặp ngoặc nhọn gần nhất chứa dòng khai báo đó.

const cartTotal = 4800;            // Tầng ngoài cùng, không nằm trong ngoặc nhọn nào

function checkShipping(total) {
  const freeLine = 5000;           // Chỉ trong ngoặc nhọn của checkShipping

  if (total < freeLine) {
    const shortage = freeLine - total;  // Chỉ trong ngoặc nhọn của if
    console.log(`Còn ${shortage} yên`);  // Còn 200 yên
  }

  // Biến khai báo ở bên ngoài thì đọc được từ bên trong
  console.log(cartTotal);          // 4800
  // console.log(shortage);        // ReferenceError: shortage is not defined
}

checkShipping(cartTotal);
Biến nào đọc được từ bên ngoài
Tầng ngoài cùng (không nằm trong ngoặc nhọn nào)
  • const cartTotal = 4800 — đọc được từ bất kỳ đâu trong file
  • checkShipping(cartTotal) — gọi hàm từ tầng ngoài cùng
Ngoặc nhọn của checkShipping
  • const freeLine = 5000 — chỉ đọc được trong cặp ngoặc này
  • cartTotal ở bên ngoài vẫn đọc được ở đây
  • console.log(shortage) — đã ra khỏi if nên không đọc được, gây ReferenceError
Ngoặc nhọn của if
  • const shortage = 200 — chỉ đọc được trong cặp ngoặc này
  • Đọc được cả freeLine lẫn cartTotal
Code bên trong đọc được giá trị ở bên ngoài, nhưng giá trị tạo trong if thì không đọc được nữa khi đã ra khỏi dấu ngoặc nhọn. Chỉ đọc được theo một chiều: từ trong ra ngoài.

Tầng ngoài cùng, không nằm trong cặp ngoặc nhọn nào, là global scope, và biến ở đó đọc được từ bất kỳ đâu. Thân hàm cũng là một cặp ngoặc nhọn, nên bạn không thể đọc freeLine từ bên ngoài hàm. Phạm vi giới hạn bởi ngoặc nhọn của một hàm được gọi riêng là function scope.

Hiển thị cho thành viên số điểm còn thiếu để lên hạng tiếp theo. pointsnextRankLine đã được khai báo sẵn.

① Khai báo remaining bằng let để chứa số điểm còn thiếu, ở vị trí mà bên ngoài if cũng đọc được.

② Chỉ khi điểm chưa đủ, hãy gán số điểm còn thiếu vào remaining bên trong ngoặc nhọn của if.

③ Ở bên ngoài if, hiển thị theo dạng "Còn N điểm nữa để lên hạng".

(Nếu chạy đúng, phần giải thích sẽ xuất hiện.)

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Khai báo bên trong được ưu tiên — Shadowing

Khi thêm một hàm tính giá theo thuế suất giảm, có thể bạn sẽ khai báo trong hàm một biến tên taxRate, trùng tên với biến bên ngoài. Nếu không biết giá trị nào được dùng, bạn sẽ khó lần ra vì sao kết quả tính bị lệch.

Nếu scope bên trong khai báo lại một tên đã có ở scope bên ngoài, thì trong cặp ngoặc nhọn đó, khai báo bên trong sẽ được dùng. Hiện tượng này gọi là shadowing (khai báo bên trong che mất biến cùng tên ở bên ngoài), và nó xảy ra với cả ngoặc nhọn của if lẫn ngoặc nhọn của hàm.

const taxRate = 0.1;                 // Thuế suất đặt ở bên ngoài

function withTax(price) {
  return Math.floor(price * (1 + taxRate));
}

function withReducedTax(price) {
  const taxRate = 0.08;              // Khai báo cùng tên bên trong hàm
  return Math.floor(price * (1 + taxRate));
}

console.log(withTax(1000));          // 1100
console.log(withReducedTax(1000));   // 1080
console.log(taxRate);                // 0.1 (biến bên ngoài không đổi)
Cùng một tên, giá trị khác nhau tùy chỗ đọc
const taxRate= 0.1 (ngoài)Đọc trongwithTaxĐọc trongwithReducedTaxĐọc bên ngoàicác hàmBên trong khôngkhai báo: 0.1Tìm thấy 0.08bên trongDùng 0.1bên ngoàiTrả về 1100Trả về 1080Hiển thị 0.1
Cùng là tên taxRate, nhưng khai báo được tìm thấy sẽ khác nhau tùy vị trí đọc. Khai báo bên trong không ghi đè giá trị bên ngoài.

JavaScript tìm một tên bắt đầu từ cặp ngoặc nhọn gần nhất chứa dòng đọc nó, rồi mới ra dần bên ngoài. Trong withReducedTax, việc tìm dừng lại ngay khi gặp 0.08, nên giá trị 0.1 bên ngoài không bao giờ được đọc. Cùng một tên mà mỗi hàm mang một giá trị khác nhau thì code rất khó theo dõi, nên khi tự viết, bạn hãy đặt tên riêng, chẳng hạn reducedTaxRate.

Quên const là gán vào biến bên ngoài

Nếu trong hàm bạn viết taxRate = 0.08; mà không có const, sẽ không có biến mới nào được tạo — đó là phép gán vào taxRate bên ngoài. Nếu biến bên ngoài là const, chương trình dừng lại và báo lỗi TypeError; còn nếu là let, giá trị bị ghi đè, và ngay cả lời gọi withTax(1000) sau đó cũng trả về 1080.

Đổi tiêu đề giá trên trang sản phẩm, nhưng chỉ trong thời gian khuyến mãi. labelisSale đã được khai báo sẵn.

① Khi isSale là true, hãy khai báo label là "Giá khuyến mãi" bên trong ngoặc nhọn của if và hiển thị nó.

② Hiển thị label ở bên ngoài if.

③ Viết thêm một if nữa, trong đó đổi tên biến bên trong thành saleLabel, và hiển thị theo dạng "Giá thường → Giá khuyến mãi".

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Chưa đọc được cho tới dòng khai báo — Hoisting và TDZ

Giả sử bạn muốn gom các giá trị cấu hình như tỷ lệ tích điểm hay phí vận chuyển xuống các dòng phía dưới đoạn code dùng chúng. Như đã thấy ở bài về hàm, khai báo function vẫn gọi được dù viết ở bên dưới, nhưng nếu đọc một giá trị cấu hình let hay const trước dòng khai báo của nó, chương trình sẽ dừng lại và báo lỗi ReferenceError.

Trước khi chạy code trong một scope, JavaScript đăng ký trước tên của mọi khai báo trong scope đó. Cơ chế này gọi là hoisting (khai báo được đăng ký trước khi code chạy). Khai báo function được đăng ký cả tên lẫn thân hàm cùng lúc, nên bạn gọi được nó từ những dòng phía trên khai báo. letconst thì chỉ đăng ký tên; giá trị chỉ được đưa vào khi dòng khai báo chạy.

// Thân hàm đọc pointRate vào lúc hàm được gọi
function calcPoint(amount) {
  return Math.floor(amount * pointRate);
}

// Gọi ở đây thì dòng khai báo pointRate chưa được chạy tới
// console.log(calcPoint(3200));
// → ReferenceError: Cannot access 'pointRate' before initialization

let pointRate = 0.05;              // Gom các giá trị cấu hình xuống dưới

console.log(calcPoint(3200));      // 160 (đọc được vì dòng khai báo đã chạy)
Khi nào giá trị let đọc được
Scope bắtđầu chạyChỉ tên pointRateđược đăng kýCho tới khichạy tới dòng letGọi calcPoint→ ReferenceErrorChạy let pointRate= 0.05Các lần gọi sautrả về 160
Cho tới khi dòng let chạy, giá trị vẫn chưa có, kể cả khi một hàm viết phía trên đọc nó. Đọc được hay không phụ thuộc vào thứ tự chạy, không phải vị trí bạn viết.

Khoảng thời gian từ lúc vào scope cho tới khi dòng khai báo let chạy được gọi là TDZ (Temporal Dead Zone — giai đoạn một tên chưa đọc được cho tới khi dòng khai báo của nó chạy). Nếu thông báo lỗi là is not defined, tên đó hoàn toàn không có trong scope. Nếu là before initialization, tên đã tồn tại nhưng bạn đọc nó trước khi dòng khai báo chạy.

Trên màn hình xác nhận đơn hàng, hiển thị số tiền còn thiếu để được miễn phí vận chuyển và số điểm tích lũy. orderTotal đã được khai báo sẵn.

① Khai báo mức miễn phí vận chuyển freeLine bằng let với giá trị 5000, và ở dòng bên dưới, hiển thị "Còn N yên nữa là được miễn phí vận chuyển".

② Viết trước dòng hiển thị "Điểm tích lũy: N", rồi ở dòng bên dưới, khai báo tỷ lệ tích điểm pointRate bằng let với giá trị 0.05.

③ Kiểm tra xem có bao nhiêu dòng được hiển thị và chương trình dừng lại vì lỗi gì.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Được đăng ký sẵn với undefined — Vì sao không dùng var

Bạn sẽ gặp các biến khai báo bằng var trong những bài viết và thư viện cũ, và khi chép lại code chạy được, var có thể lẫn vào theo. Nhưng var khác constlet ở hai điểm: phạm vi mà biến có hiệu lực, và điều xảy ra khi bạn đọc nó trước dòng khai báo.

Khai báo var cũng được đăng ký trước, nhưng cùng lúc đó giá trị undefined được đưa vào. Vì vậy đọc nó ở dòng phía trên khai báo không gây lỗi — chương trình cứ thế chạy tiếp với undefined. Phạm vi hiệu lực của nó cũng không bị giới hạn bởi ngoặc nhọn, mà là toàn bộ function scope bao quanh.

function checkStock(orderCount) {
  // Đọc trước dòng khai báo cũng không dừng, giá trị là undefined
  console.log(stock);              // undefined

  var stock = 12;
  console.log(stock);              // 12

  if (orderCount <= stock) {
    var status = "Có thể giao hàng";   // Khai báo bằng var
    let note = "Giao từ kho A";        // Khai báo bằng let
  }
  // var có hiệu lực trong toàn bộ ngoặc nhọn của hàm, nên đọc được bên ngoài if
  console.log(status);             // Có thể giao hàng
  // console.log(note);            // ReferenceError: note is not defined
}

checkStock(3);
var không bị giới hạn bởi ngoặc nhọn
Gán var statustrong ifRa khỏi ngoặcnhọn của ifVẫn trong hàmnên đọc đượcHiển thị"Có thể giao hàng"Gán let notetrong ifRa khỏi ngoặcnhọn của ifnote chỉ tồn tạitrong ifBáo lỗiReferenceError
Dù được gán bên trong cùng một if, giá trị var vẫn đọc được ở những dòng sau khi ngoặc nhọn kết thúc. Với let, chương trình báo lỗi ngay ở dòng đọc biến bên ngoài phạm vi.

Mỗi tên var chỉ ứng với một biến duy nhất trong hàm, nên khai báo lại cùng tên bằng var bên trong if không tạo ra shadowing mà ghi đè lên chính biến đó. Bảng dưới đây cho biết, với từng cách khai báo, cái gì được lưu khi tên được đăng ký trước lúc chạy, và bạn nhận được gì khi đọc nó ở dòng phía trên khai báo.

Cách khai báoĐược đăng ký trước khi chạyĐọc ở dòng trên khai báo
var stock = 12Tên và undefinedChạy tiếp với undefined
let stock = 12Chỉ có tên (chưa có giá trị)Báo lỗi ReferenceError
const stock = 12Chỉ có tên (chưa có giá trị)Báo lỗi ReferenceError

Đổi var thành let có thể làm lộ ra lỗi

Khi thay var bằng let trong code cũ, những dòng đọc biến trước dòng khai báo, hoặc đọc ở bên ngoài if, sẽ báo lỗi ReferenceError. Đó là những dòng từ trước tới giờ vẫn âm thầm đọc undefined, hoặc đọc giá trị thuộc một cặp ngoặc nhọn khác. Để sửa, hãy chuyển dòng khai báo lên trên dòng đọc, và đặt nó ở cặp ngoặc nhọn bao quanh cả dòng đọc đó.

Xem biến var hoạt động ra sao tùy vào vị trí khai báo. cartTotal đã được khai báo sẵn.

① Viết trước dòng hiển thị "Cần thanh toán: N yên" (lấy cartTotal trừ discount), rồi bên dưới khai báo discount bằng var với giá trị 300.

② Bên dưới đó, hiển thị lại một lần nữa.

③ Nếu tổng tiền từ 5000 yên trở lên, khai báo lại discount bằng var với giá trị 800 bên trong một if.

④ Bên ngoài if, hiển thị "Số tiền giảm: N yên".

JavaScript / TypeScript Editor

Chạy code để xem đầu ra
QUIZ

Kiểm tra kiến thức

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

Câu 1Điều gì xảy ra nếu bạn hiển thị unit ở dòng ngay sau if (true) { const unit = "yên"; }?

Câu 2Ở gần đầu file, bạn định nghĩa hàm show trả về rate, rồi gọi nó ở một dòng bên dưới let rate = 0.1;. Hàm trả về gì?

Câu 3Nếu bạn viết console.log(count); ở một dòng phía trên var count = 3;, cái gì sẽ được hiển thị?