Bài 6.1: Let, const và scope trong ES6+

Chào mừng các bạn đến với hành trình khám phá thế giới JavaScript hiện đại! Nếu bạn đã quen với var để khai báo biến, hãy chuẩn bị tinh thần, vì ES6+ đã mang đến hai "ngôi sao" mới: letconst. Chúng không chỉ là cách khai báo biến mới, mà còn thay đổi hoàn toàn cách chúng ta hiểu về phạm vi (scope) trong JavaScript, giúp code của chúng ta trở nên dễ đoán, ít lỗimạnh mẽ hơn rất nhiều!

Bài viết này sẽ đi sâu vào bí ẩn đằng sau let, const và tầm quan trọng của scope, đặc biệt là block scope. Hãy cùng "mổ xẻ" chúng ngay!

1. var - Người Lính Già và Những Câu Chuyện Bất Ngờ

Trước khi ES6 ra đời, var là cách duy nhất để khai báo biến trong JavaScript. Nó hoạt động, nhưng lại mang theo một vài "tính cách" khá đặc biệt mà đôi khi khiến lập trình viên mới "ngã ngửa".

Đặc điểm chính của var:

  • Function Scope (Phạm vi hàm): Biến khai báo bằng var chỉ bị giới hạn trong hàm mà nó được định nghĩa, không phải các khối lệnh ({}).
  • Hoisting: Biến khai báo bằng var được "đưa lên đầu" phạm vi của nó trước khi code thực sự chạy. Tuy nhiên, chỉ có phần khai báo được đưa lên, còn phần gán giá trị thì vẫn ở nguyên vị trí. Điều này có thể gây ra hành vi không mong muốn.

Hãy xem một ví dụ về hoisting với var:

console.log(ten); // Output: undefined
var ten = "FullhouseDev";
console.log(ten); // Output: FullhouseDev

Giải thích: Dù chúng ta gọi console.log(ten) trước khi khai báo var ten, chương trình không báo lỗi. Bởi vì var ten; đã được "hoisting" lên đầu. Biến ten tồn tại, nhưng chưa có giá trị, nên nó là undefined. Sau khi gán giá trị, nó mới có giá trị "FullhouseDev".

Còn đây là ví dụ về Function Scope và sự "bỏ qua" Block Scope của var:

function viDuVarScope() {
    if (true) {
        var message = "Xin chào từ trong IF!";
        console.log(message); // Output: Xin chào từ trong IF!
    }
    console.log(message); // Output: Xin chào từ trong IF!
    // Biến message vẫn tồn tại ngoài khối IF!
}

viDuVarScope();
// console.log(message); // Lỗi: message is not defined (vì nó chỉ nằm trong phạm vi hàm)

Giải thích: Biến message được khai báo bên trong khối if {}. Tuy nhiên, vì nó dùng var, phạm vi của nó là toàn bộ hàm viDuVarScope, chứ không chỉ riêng khối if. Điều này có nghĩa là bạn có thể truy cập message bên ngoài khối if, điều mà đôi khi bạn không mong muốn.

Chính những "tính cách" này của var (hoisting đầy bất ngờ và không tuân thủ block scope) đã dẫn đến sự ra đời của letconst.

2. let - Vị Anh Hùng Của Block Scope

ES6 giới thiệu let như một giải pháp thay thế tốt hơn cho var trong hầu hết các trường hợp. let mang đến một khái niệm quan trọng: Block Scope.

Đặc điểm chính của let:

  • Block Scope (Phạm vi khối): Biến khai báo bằng let chỉ bị giới hạn trong khối lệnh ({}) mà nó được định nghĩa. Khối lệnh có thể là thân hàm, một vòng lặp (for, while), một câu lệnh điều kiện (if, else), hoặc đơn giản chỉ là một cặp ngoặc nhọn độc lập.
  • Không Hoisting (hay TDZ): Về mặt kỹ thuật, let vẫn được "hoisting" đến đầu phạm vi của nó, nhưng nó nằm trong một vùng gọi là Temporal Dead Zone (TDZ). Bạn không thể truy cập biến let trước khi nó được khai báo. Nếu cố gắng, bạn sẽ gặp lỗi ReferenceError.
  • Có thể gán lại giá trị: Giống như var, biến let có thể được gán lại giá trị nhiều lần.

Hãy xem sức mạnh của Block Scope với let:

function viDuLetScope() {
    if (true) {
        let message = "Xin chào từ trong IF!";
        console.log(message); // Output: Xin chào từ trong IF!
    }
    // console.log(message); // Lỗi: message is not defined
    // Biến message chỉ tồn tại bên trong khối IF! Tuyệt vời!
}

viDuLetScope();

Giải thích: Lần này, message được khai báo bằng let bên trong khối if {}. Phạm vi của message chỉ là khối if đó. Khi cố gắng truy cập message bên ngoài khối if, JavaScript báo lỗi, đúng như những gì chúng ta mong đợi để tránh nhầm lẫn.

Và đây là ví dụ về Temporal Dead Zone (TDZ) của let:

// console.log(so); // Lỗi: ReferenceError: Cannot access 'so' before initialization
// Đây là TDZ! Bạn không thể truy cập so ở đây.
let so = 10;
console.log(so); // Output: 10 (Ở đây thì truy cập được)

Giải thích:let so; có thể đã được xử lý ở đâu đó trước khi code chạy, nó nằm trong TDZ. Bạn buộc phải khai báo nó bằng let so = 10; trước khi sử dụng. Điều này giúp ngăn chặn những lỗi phát sinh từ việc sử dụng biến trước khi nó được định nghĩa và gán giá trị một cách rõ ràng, làm cho code dễ hiểudễ debug hơn rất nhiều!

3. const - Người Bạn Đồng Hành Kiên Định

Cùng với let, ES6 còn giới thiệu const. const cũng là một cách khai báo biến với Block ScopeTemporal Dead Zone, giống hệt let. Tuy nhiên, có một điểm khác biệt quan trọng nhất:

Đặc điểm chính của const:

  • Block Scope: Giống let, phạm vi của const là khối lệnh ({}).
  • Không Hoisting (TDZ): Giống let, không thể truy cập trước khi khai báo.
  • Không thể gán lại giá trị (Cannot Be Reassigned): Biến được khai báo bằng const phải được gán giá trị ngay lập tức lúc khai báo và không thể được gán lại giá trị mới.

Hãy xem const hoạt động như thế nào:

const PI = 3.14159;
console.log(PI); // Output: 3.14159

// PI = 3.14; // Lỗi: TypeError: Assignment to constant variable.
// Không thể gán lại giá trị cho biến const!

Giải thích: Biến PI được khai báo bằng const và gán giá trị ngay. Khi cố gắng gán lại giá trị khác cho PI, JavaScript sẽ báo lỗi TypeError.

NHƯNG, có một điều quan trọng cần lưu ý với const và các kiểu dữ liệu tham chiếu (Reference Types) như mảng (Arrays) và đối tượng (Objects)!

const ngăn việc gán lại biến để trỏ tới một vị trí bộ nhớ khác. Nó không ngăn bạn thay đổi nội dung bên trong của đối tượng hoặc mảng mà biến const đang trỏ tới.

const nguoiDung = {
    ten: "Alice",
    tuoi: 30
};

console.log(nguoiDung); // Output: { ten: 'Alice', tuoi: 30 }

// Có thể thay đổi thuộc tính bên trong đối tượng
nguoiDung.tuoi = 31;
nguoiDung.thanhPho = "Hà Nội";

console.log(nguoiDung); // Output: { ten: 'Alice', tuoi: 31, thanhPho: 'Hà Nội' }
// Tuyệt vời! Chúng ta đã thay đổi nội dung bên trong

// NHƯNG, không thể gán lại biến nguoiDung cho một đối tượng hoàn toàn mới
// nguoiDung = { ten: "Bob" }; // Lỗi: TypeError: Assignment to constant variable.

Giải thích: Biến nguoiDungconst, nghĩa là nó luôn luôn trỏ đến cùng một đối tượng trong bộ nhớ. Chúng ta có thể thay đổi các thuộc tính (ten, tuoi, thêm thanhPho) của đối tượng đó, nhưng chúng ta không thể làm cho biến nguoiDung trỏ sang một đối tượng mới toanh khác.

Đây là một điểm hay gây nhầm lẫn cho người mới. Hãy nhớ: const là về việc liên kết (binding) giữa tên biến và vị trí bộ nhớ, không phải về việc làm cho giá trị tại vị trí đó trở nên bất biến (immutable - trừ với kiểu dữ liệu nguyên thủy như số, chuỗi, boolean).

4. Scope - Hiểu Rõ Ranh Giới Của Biến

Scope trong JavaScript định nghĩa khả năng truy cậpvòng đời của biến, hàm và các tài nguyên khác trong code của bạn. Hiểu scope là cực kỳ quan trọng để viết code chính xáctránh lỗi.

Trong JavaScript, có ba loại scope chính (kể từ ES6+):

  1. Global Scope (Phạm vi toàn cục): Biến được khai báo ngoài bất kỳ hàm hoặc khối nào. Chúng có thể được truy cập từ bất cứ đâu trong code của bạn.

    const globalVariable = "Tôi ở khắp mọi nơi!";
    function someFunction() {
        console.log(globalVariable); // Truy cập được
    }
    if (true) {
        console.log(globalVariable); // Truy cập được
    }
    
  2. Function Scope (Phạm vi hàm): Biến được khai báo bên trong một hàm (chủ yếu áp dụng cho var). Chúng chỉ có thể được truy cập từ bên trong hàm đó.

    function functionScopeExample() {
        var functionVariable = "Tôi chỉ ở trong hàm này.";
        console.log(functionVariable); // Truy cập được
    }
    functionScopeExample();
    // console.log(functionVariable); // Lỗi: functionVariable is not defined
    
  3. Block Scope (Phạm vi khối): Biến được khai báo bên trong một cặp ngoặc nhọn {} (áp dụng cho letconst). Chúng chỉ có thể được truy cập từ bên trong khối đó.

    if (true) {
        let blockVariable = "Tôi chỉ ở trong khối IF này.";
        console.log(blockVariable); // Truy cập được
    }
    // console.log(blockVariable); // Lỗi: blockVariable is not defined
    

Lưu ý về Scope Lồng Nhau:

JavaScript sử dụng Lexical Scoping. Điều này có nghĩa là scope được xác định dựa trên vị trí code được viết (lexical structure), chứ không phải cách hàm được gọi. Khi JavaScript cần tìm một biến, nó sẽ tìm kiếm trong scope hiện tại trước. Nếu không tìm thấy, nó sẽ "leo lên" scope cha gần nhất và tìm kiếm ở đó, cứ thế cho đến khi lên đến Global Scope. Nếu vẫn không tìm thấy, nó sẽ báo lỗi ReferenceError.

const bienGlobal = "Tôi là Global";

function outerFunction() {
    const bienOuter = "Tôi là Outer";

    function innerFunction() {
        const bienInner = "Tôi là Inner";
        console.log(bienInner); // Tìm thấy ngay trong scope hiện tại
        console.log(bienOuter); // Tìm thấy trong scope cha (outerFunction)
        console.log(bienGlobal); // Tìm thấy trong scope cha (Global)
    }

    innerFunction();

    console.log(bienOuter); // Tìm thấy trong scope hiện tại
    console.log(bienGlobal); // Tìm thấy trong scope cha (Global)
    // console.log(bienInner); // Lỗi: bienInner is not defined (bienInner chỉ ở trong innerFunction)
}

outerFunction();

console.log(bienGlobal); // Tìm thấy trong scope hiện tại
// console.log(bienOuter); // Lỗi: bienOuter is not defined (bienOuter chỉ ở trong outerFunction)
// console.log(bienInner); // Lỗi: bienInner is not defined (bienInner chỉ ở trong innerFunction)

Giải thích: Ví dụ này minh họa rõ ràng cách scope lồng nhau hoạt động và quy tắc tìm kiếm biến "leo lên" các scope cha. Biến trong scope con luôn luôn có thể truy cập biến trong scope cha, nhưng ngược lại thì không.

5. Tại Sao letconst Lại "Chiến Thắng" var?

Sự ra đời và phổ biến của letconst không phải là ngẫu nhiên. Chúng giải quyết những vấn đề cố hữu của var, mang lại nhiều lợi ích:

  • Giảm lỗi do Hoisting: TDZ của letconst buộc bạn phải khai báo biến trước khi sử dụng, loại bỏ những bug khó chịu do hoisting của var gây ra khi code chạy mà biến chưa được gán giá trị.
  • Code Minh Bạch và Dễ Đoán hơn: Block Scope giúp giới hạn biến chỉ trong phạm vi nó thực sự cần dùng. Điều này làm cho code của bạn dễ đọc hơn, dễ hiểu hơn về nơi biến tồn tại và giảm nguy cơ vô tình ghi đè lên biến ở scope khác.
  • Tăng Tính Ổn Định với const: Sử dụng const cho các giá trị không thay đổi (hoặc các tham chiếu không thay đổi) là một cách tuyệt vời để thể hiện ý định của bạn cho người đọc code và ngăn ngừa việc vô tình gán lại giá trị, giúp code bền vững hơn.

Lời khuyên: Trong JavaScript hiện đại (ES6+), quy tắc bất thành văn là:

  1. Luôn ưu tiên dùng const: Nếu biến đó sẽ không bị gán lại giá trị (hoặc tham chiếu), hãy dùng const. Điều này giúp code rõ ràng hơn rất nhiều.
  2. Nếu cần gán lại, dùng let: Chỉ khi bạn biết chắc chắn biến đó sẽ cần thay đổi giá trị, hãy dùng let.
  3. Tránh dùng var: Gần như không có lý do gì chính đáng để sử dụng var trong code ES6+ mới. letconst là những lựa chọn vượt trội về độ an toàn và minh bạch.

Comments

There are no comments at the moment.