Biến và lệnh gán

Đây là hai cách chính để tạo một biến trong JavaScript:

  • Dùng từ khóa let để khai báo các biến có thể thay đổi giá trị.
  • Dùng từ khóa const để khai báo biến không thể thay đổi giá trị (còn gọi là hằng).

Trước ES6, còn có var. Nhưng hiện nay nó ít được sử dụng vì có nhiều nhược điểm.

let

Biến khai báo qua let có thể thay đổi giá trị:

let i;
i = 0;
i = i + 1;
assert.equal(i, 1);

Bạn có thể khai báo và gán giá trị trong cùng một lệnh:

let i = 0;

const

Biến khai báo qua const không thể thay đổi giá trị. Bạn luôn phải gán giá trị ngay khi khai báo (còn gọi là khởi tạo):

const i = 0; // phải khởi tạo ngay

assert.throws(
  () => { i = i + 1 },
  {
    name: 'TypeError',
    message: 'Assignment to constant variable.',
  }
);

const và sự không thể thay đổi giá trị

Các biến khai báo bằng const không thể thay đổi nội dung của nó. Với các giá trị cơ sở (số, chuỗi, boolean…) giá trị này chính là nội dung của biến. Nhưng với các đối tượng (mảng cũng là đối tượng) thì nội dung của biến là địa chỉ của đối tượng trong bộ nhớ.

Như vậy đối với các đối tượng, sự không thay đổi giá trị của biến, tức nó chỉ có thể tham chiếu tới một đối tượng duy nhất trong bộ nhớ.

Tuy nhiên, ta vẫn có thể thay đổi đối tượng:

const obj = { prop: 0 };

// Được phép: thay đổi thuộc tính của `obj`
// tức được phép thay đổi đối tượng
obj.prop = obj.prop + 1;
assert.equal(obj.prop, 1);

// Không được phép: gán một đối tượng mới cho `obj`
// tức không được phép thay đổi tham chiếu
assert.throws(
  () => { obj = {} },
  {
    name: 'TypeError',
    message: 'Assignment to constant variable.',
  }
);

const và các vòng lặp

Bạn có thể sử dụng const trong vòng lặp for-of, ở đó một hằng mới được tạo ra trong mỗi lần lặp:

const arr = ['hello', 'world'];
for (const elem of arr) {
  console.log(elem);
}
// Output:
// 'hello'
// 'world'

Tròng vòng lặp for, bạn phải sử dụng let, không được phép dùng const:

const arr = ['hello', 'world'];
for (let i=0; i<arr.length; i++) {
  const elem = arr[i];
  console.log(elem);
}

Phạm vi của một biến

Phạm vi (scope) của một biến là vùng chương trình trong đó có thể truy và sử dụng biến này. Phạm vi của biến khai báo qua letconst có phạm vi là khối lệnh trong đó nó được khai báo, cũng như các khối lệnh nằm trong khối lệnh này. Xét đoạn mã sau:

{ // // Scope A. Có thể truy cập: x
  const x = 0;
  assert.equal(x, 0);
  { // Scope B. Có thể truy cập: x, y
    const y = 1;
    assert.equal(x, 0);
    assert.equal(y, 1);
    { // Scope C. Có thể truy cập: x, y, z
      const z = 2;
      assert.equal(x, 0);
      assert.equal(y, 1);
      assert.equal(z, 2);
    }
  }
}
// Bên ngoài. Không thể truy cập: x, y, z
assert.throws(
  () => console.log(x),
  {
    name: 'ReferenceError',
    message: 'x is not defined',
  }
);
  • Scope A là phạm vi (trực tiếp) của biến x
  • Scope B và C là các phạm vi bên trong của scope A
  • Scope A là phạm vi bên ngoài của scope B và scope C

Sự che lấp các biến

JavaScript cho phép bạn khai báo biến cùng tên ở khối lệnh bên trong với một biến ở khối lệnh bên ngoài.

const x = 1;
assert.equal(x, 1);
{
  const x = 2;
  assert.equal(x, 2);
}
assert.equal(x, 1);

Ở trong khối bên trong, biến x ở đó được sử dụng thay cho biến x ở khối bên ngoài. Ta nói biến x bên trong đã che lấp đi biến x bên ngoài.

Tĩnh và động

Có hai thuật ngữ quan trọng trong các ngôn ngữ lập trình:

  • Tĩnh (static): là những thứ được xác định ngay từ mã chương trình.
  • Động (dynamic): là những thứ được xác định khi chạy chương trình.

Phạm vi của biến là tĩnh

Phạm vi của biến được xác định ngay khi ta viết chương trình. Cùng xem đoạn mã sau:

function f() {
  const x = 3;
  // ···
}

x có phạm vĩ tĩnh (statically scoped hay lexically scoped). Phạm vi này có thể được xác định ngay khi viết chương trình và không thể thay đổi khi chạy chương trình.

Các phạm vi của tất cả các biến trong chương trình tạo nên một cấu trúc hình cây. Cây này là cây tĩnh, suy ra được từ mã.

Lời gọi hàm là động

Các lời gọi hàm chỉ được xác định khi chạy chương trình. Cùng xem đoạn mã sau:

function g(x) {}
function h(y) {
  if (Math.random()) g(y); // (A)
}

Việc hàm g() có được gọi bên trong hàm h() hay không chỉ được quyết định khi chạy chương trình, sau một số ngẫu nhiên được sinh ra.

Các lời gọi hàm cũng tạo nên cấu trúc hình cây. Cây này là cây động, chỉ được xác định khi chạy chương trình.

Biến toàn cục và đối tượng toàn cục

Các phạm vi của biến có thể nằm trong một phạm vi của biến khác. Chúng tạo nên một cây:

  • Phạm vi ngoài cùng (lớn nhất) chứa mọi phạm vi còn lại gọi tương ứng với gốc cây.
  • Phạm vì nằm trực tiếp trong phạm vi ngoài cùng gọi là phạm vi con của phạm vi này.
  • Vân vân…

Phạm vi gốc còn được gọi là phạm vi toàn cục (global scope). Trong trình duyệt, nó là top level của các script. Các biến khai báo trong phạm vi toàn cục gọi là biến toàn cục (global variable) và có thể truy cập ở mọi nơi. Có hai kiểu biến toàn cục:

  • Các biến khai báo toàn cục (global declaration variables):
    • Chúng là các biến khai báo trong top level của các script qua let, const và các khai báo lớp.
  • Các biến thuộc đối tượng toàn cục (global object variables): được lưu trong các thuộc tính của đối tượng toàn cục.
    • Chúng được tạo ở top level của một script qua var và các khai báo hàm.
    • Đối tượng toàn cục có thể truy cập qua globalThis. Ta có thể dùng nó để tạo, đọc, và xóa các biến của đối tượng toàn cục.
    • Ngoài những sự khác biệt trên, biến toàn cục loại này hoạt động giống như các biến thông thường.

Đoạn mã HTML sau mô tả globalThis và hai kiểu biến toàn cục:

<script>
  const declarativeVariable = 'd';
  var objectVariable = 'o';
</script>
<script>
  // Tất cả các script chia sử một top level duy nhất:
  console.log(declarativeVariable); // 'd'
  console.log(objectVariable); // 'o'
  
  // Chỉ có biến khai báo bằng var mới nằm trong globalThis
  console.log(globalThis.declarativeVariable); // undefined
  console.log(globalThis.objectVariable); // 'o'
</script>

ES6 giới thiệu module, khác với các script thông thường, mỗi module có top level riêng tức phạm vi toàn cục riêng.

globalThis

globalThis là một tính năng mới để truy cập đối tượng toàn cục trên tất cả các môi trường. Sở dĩ nó có tên như vậy vì nó cùng giá trị với this trong phạm vi toàn cục.

Trước kia khi chưa có globalThis trong mỗi môi trường ta lại truy cập đối tượng toàn cục theo cách khác nhau:

  • Trên trình duyệt là window
  • Trên Web Workers là self
  • Trên Node.js là global

Khai báo: phạm vi và sự kích hoạt

Có hai khía cạnh cốt lỗi về sự khai báo:

  • Phạm vi: Nơi ta thể thấy thực thể được khai báo. Nó là khía cạnh tĩnh.
  • Sự kích hoạt: Khi nào có thể truy cấp thực thể đã được khai báo. Nó là khía cạnh động. Vài thực thể có thể truy cập ngay khi truy cập phạm vi của nó. Vài thực thể khác, chỉ có thể truy cập sau lệnh khái báo.

constlet và khai báo lớp

Các biến được khai báo với const let chỉ có thể truy cập được khi đi vào phạm vi và ở phía sau lệnh khai báo.

if (true) { // vào phạm vi của `tmp`
  // trước lệnh khai báo tmp
  assert.throws(() => (tmp = 'abc'), ReferenceError);
  assert.throws(() => console.log(tmp), ReferenceError);

  let tmp;
  assert.equal(tmp, undefined); // sau lệnh khai báo
}

Tương tự với khai báo lớp:

// trước khai báo lớp
assert.throws(
  () => new MyClass(),
  ReferenceError
);

class MyClass {}
// sau khai báo lớp
assert.equal(new MyClass() instanceof MyClass, true);

var và khai báo hàm

Các biến khai báo bằng var và hàm tạo bởi khai báo hàm. có thể truy cập ngay khi đi vào phạm vi của nó.

assert.equal(foo(), 123); // OK
function foo() { return 123; }

Tương tự với biến khai báo bẳng var:

// trước khai báo
assert.equal(x, undefined);
var x = 123;
// sau khai báo
assert.equal(x, 123);

Chú ý, trước khai báo ta có thể truy cập biến x, tuy nhiên giá trị chưa được khởi tạo nên kết quả là undefined. Ta gọi hiện tượng này đối với biến var và khai báo hàm là hoisting (nổi lên). Tuy nhiên chỉ phần khai báo được nổi lên trên cùng, còn phần gán giá trị vẫn ở vị trí cũ. Sau khi hoisting, đoạn mã trên trông như sau:

var x;
assert.equal(x, undefined);
x = 123;
assert.equal(x, 123);

Ngoài khác với biến khai báo qua letconst biến khai báo qua var chỉ có phạm vi hàm, không có hạm vi khối lệnh:

function f() {
  assert.equal(x, undefined);
  if (true) {
    var x = 123;
    assert.equal(x, 123);
  }
  assert.equal(x, 123);
}

Tương tự là các hàm khai báo qua khai báo hàm:

{
  funtion hello() {
    return 'Hello';
 }
}
assert.equal(hello(), 'Hello');

Các closure

Biến ràng buộc và biến tự do

Với mỗi phạm vi, có hai loại biến có thể truy cập:

  • Biến được khai báo trực tiếp trong phạm vi này như các biến cục bộ hoặc tham số của hàm. Các biến này gọi là biến ràng buộc.
  • Biến được khai báo trong phạm vi bên ngoài. Chúng không phải là biến cục bộ và được gọi là biến tự do.

Xét đoạn mã sau:

let z = 456;
function func(x) {
  const y = 123;
  console.log(z);
}

thì xy là biến ràng buộc trong thân hàm, còn z là biến tự do.

Closure là gì?

Một closure là một hàm cùng với toàn bộ các biến tự do của nó.

Xét ví dụ sau:

function funcFactory(value) {
  return () => {
    return value;
  };
}

const func = funcFactory('abc');
assert.equal(func(), 'abc'); // (A)

Hàm funcFactory tạo ra và trả về một hàm có biến tự do là value. Hàm này là một closure và được gán cho biến func.

Khi gọi func nó vẫn có thể truy cập biến value là tham số của funcFactory mà không cần phải ở trong phạm vi của funcFactory.

Tạo các hàm tăng

Hàm sau đây về các hàm tăng, là một hàm giữ một số. Khi bạn gọi, nó cập nhật số này bằng cách cộng thêm với giá trị đối số và trả về giá trị số khi đã tăng.

function createInc(startValue) {
  return (step) => {
    startValue += step;
    return startValue;
  };
}
const inc = createInc(5);
assert.equal(inc(2), 7);

Như ta thấy hàm tăng là một closure, vẫn còn có thể truy cập biến tự do startValue và cập nhật giá trị của biến tự do này, khi được gọi ở ngoài phạm vi của startVaule.

Đương nhiên, số biến tự do không bị giới hạn, bạn có thể thêm một biến tự do khác cho hàm tăng này, chẳng hạn biến index lưu lại số lần hàm tăng đã được gọi:

function createInc(startValue) {
  let index = 0;
  return (step) => {
    startValue += step;
    index++;
    return [index, startValue];
  };
}
const inc = createInc(5);
assert.equal(inc(2), [1, 7]);
assert.equal(inc(2), [2, 9]);
assert.equal(inc(2), [3, 11]);