Bài 4.2: Kiểu dữ liệu và phạm vi trong JavaScript

Chào mừng các bạn quay trở lại với hành trình chinh phục Lập trình Web Front-end cùng FullhouseDev! Sau khi làm quen với những cú pháp cơ bản của JavaScript, hôm nay chúng ta sẽ đào sâu vào hai khái niệm _cốt lõi_ và _quan trọng bậc nhất_ mà mọi lập trình viên JS đều phải nắm vững: Kiểu dữ liệu (Data Types)Phạm vi (Scope).

Hiểu rõ kiểu dữ liệu giúp bạn biết _loại thông tin_ mà biến đang lưu trữ và _những thao tác_ nào có thể thực hiện trên nó. Nắm vững phạm vi giúp bạn quản lý _quyền truy cập_ của biến và hàm trong các phần khác nhau của chương trình, từ đó tránh được những lỗi không đáng có và viết code gọn gàng, dễ bảo trì hơn. Hãy cùng bắt đầu!

1. Kiểu dữ liệu trong JavaScript: Nền tảng của mọi thông tin

Mọi giá trị trong JavaScript đều có một kiểu dữ liệu (data type) nhất định. Kiểu dữ liệu này cho biết bản chất của giá trị đó là gì – có phải là số, là chữ, là một tập hợp, hay là một thứ gì đó khác. JavaScript là ngôn ngữ có kiểu dữ liệu động (dynamically typed), nghĩa là bạn không cần khai báo kiểu dữ liệu khi tạo biến (let, const, var). Kiểu dữ liệu sẽ được xác định tự động dựa trên giá trị mà bạn gán cho biến đó.

JavaScript có hai loại kiểu dữ liệu chính:

  • Kiểu dữ liệu nguyên thủy (Primitive Types): Các giá trị đơn giản, không phải là đối tượng và không có phương thức.
  • Kiểu dữ liệu tham chiếu (Reference Types): Các đối tượng phức tạp hơn.
1.1. Kiểu dữ liệu nguyên thủy (Primitive Types)

Có 7 kiểu dữ liệu nguyên thủy trong JavaScript (tính đến ES11):

a. String (string)

Kiểu string dùng để biểu diễn các chuỗi ký tự, tức là văn bản. Chuỗi ký tự được đặt trong dấu ngoặc đơn (''), ngoặc kép (""), hoặc dấu backtick (``). Dấu backtick cho phép bạn sử dụng template literals, rất tiện lợi để nhúng biến hoặc biểu thức vào chuỗi.

Ví dụ:

let greeting = "Xin chào các bạn!"; // Dùng ngoặc kép
let name = 'FullhouseDev';       // Dùng ngoặc đơn
let message = `Tôi là ${name}. Chào mừng đến với blog!`; // Dùng backtick (template literal)

console.log(greeting);
console.log(message);
console.log(typeof greeting); // Output: string
  • Giải thích: Biến greeting, name, và message đều lưu trữ các giá trị là chuỗi văn bản. typeof là toán tử giúp kiểm tra kiểu dữ liệu của một biến hoặc giá trị. Template literal với dấu backtick `` cho phép bạn dễ dàng kết hợp văn bản cố định với giá trị biến bằng cú pháp ${tenBien}.
b. Number (number)

Kiểu number dùng để biểu diễn các giá trị số, bao gồm cả số nguyên (integers) và số thập phân (floating-point numbers). JavaScript không phân biệt giữa hai loại này như nhiều ngôn ngữ khác.

Ví dụ:

let age = 30;        // Số nguyên
let price = 19.99;   // Số thập phân
let result = age + price; // Thực hiện phép toán

console.log(age);
console.log(price);
console.log(result);
console.log(typeof age);   // Output: number
console.log(typeof price); // Output: number
  • Giải thích: Các biến age, price, và result đều lưu trữ các giá trị số. Bạn có thể thực hiện các phép toán cơ bản (cộng, trừ, nhân, chia, v.v.) trực tiếp trên các giá trị kiểu number.
c. Boolean (boolean)

Kiểu boolean chỉ có hai giá trị khả dĩ: true (đúng) hoặc false (sai). Kiểu này thường được sử dụng trong các cấu trúc điều kiện (if, else, while) để kiểm soát luồng thực thi của chương trình.

Ví dụ:

let isLearning = true;  // Giá trị đúng
let isFinished = false; // Giá trị sai

if (isLearning) {
  console.log("Tuyệt vời, bạn đang học JavaScript!");
} else {
  console.log("Hãy bắt đầu học nào!");
}

console.log(typeof isLearning); // Output: boolean
  • Giải thích: Biến isLearningisFinished lưu trữ giá trị logic đúng/sai. Câu lệnh if kiểm tra giá trị của isLearning để quyết định khối code nào sẽ được thực thi.
d. Undefined (undefined)

Kiểu undefined biểu thị rằng một biến đã được khai báo nhưng _chưa được gán giá trị_ nào. Nó là giá trị mặc định của các biến vừa được khai báo mà không kèm theo giá trị khởi tạo.

Ví dụ:

let myVariable; // Khai báo biến nhưng chưa gán giá trị
console.log(myVariable); // Output: undefined
console.log(typeof myVariable); // Output: undefined

// Nếu bạn khai báo và gán undefined một cách tường minh (không nên làm thường xuyên)
let anotherVariable = undefined;
console.log(anotherVariable); // Output: undefined
  • Giải thích: Khi myVariable được khai báo nhưng không có dấu =... theo sau, giá trị mặc định của nó là undefined. typeof cũng trả về undefined cho kiểu dữ liệu này.
e. Null (object)

Kiểu null biểu thị sự _vắng mặt một cách có chủ ý_ của _bất kỳ giá trị đối tượng_ nào. Nó thường được gán cho một biến để báo hiệu rằng biến đó hiện không trỏ đến đối tượng nào cả. Lưu ý rằng typeof null trả về object, đây là một _lỗi lịch sử_ của JavaScript và không nên hiểu null là một object thực sự.

Ví dụ:

let car = null; // Biến car hiện không trỏ đến đối tượng xe nào
console.log(car);       // Output: null
console.log(typeof car); // Output: object (lỗi lịch sử!)

// Sau này, bạn có thể gán một đối tượng cho nó
// car = { brand: "Toyota", model: "Camry" };
  • Giải thích: Biến car được gán giá trị null để thể hiện rõ ràng rằng nó đang trống rỗng, không lưu trữ thông tin về một chiếc xe nào. Mặc dù typeof nullobject, hãy luôn nhớ null là một kiểu nguyên thủy đặc biệt đại diện cho việc không có giá trị đối tượng.
f. Symbol (symbol)

Kiểu symbol được giới thiệu trong ES6 (ECMAScript 2015) để tạo ra các giá trị _duy nhất_ và _bất biến_. Symbols thường được dùng làm khóa thuộc tính cho các đối tượng để tránh xung đột tên.

Ví dụ:

const id1 = Symbol('id');
const id2 = Symbol('id');

console.log(id1 === id2); // Output: false (mặc dù mô tả giống nhau, chúng là hai Symbol khác nhau)
console.log(typeof id1); // Output: symbol
  • Giải thích: Symbol('id') tạo ra một giá trị Symbol mới. Dù cả hai Symbol đều có mô tả là 'id', chúng hoàn toàn khác nhau và không bằng nhau. Điều này đảm bảo tính duy nhất khi sử dụng chúng làm khóa trong đối tượng.
g. BigInt (bigint)

Kiểu bigint được giới thiệu trong ES11 (ECMAScript 2020) để làm việc với các số nguyên có độ lớn _vượt quá giới hạn_ của kiểu number thông thường (là 2^53 - 1). Bạn tạo một BigInt bằng cách thêm hậu tố n vào cuối một số nguyên hoặc gọi hàm BigInt().

Ví dụ:

const largeNumber = 123456789012345678901234567890n; // Thêm 'n' ở cuối
const anotherLargeNumber = BigInt(123);

console.log(largeNumber);
console.log(anotherLargeNumber);
console.log(typeof largeNumber); // Output: bigint
  • Giải thích: largeNumber là một BigInt vì nó vượt quá giới hạn an toàn của kiểu number. anotherLargeNumber được tạo bằng hàm tạo BigInt(). Kiểu bigint cho phép tính toán chính xác với các số nguyên cực lớn.
1.2. Kiểu dữ liệu tham chiếu (Reference Types)

Các kiểu dữ liệu tham chiếu phức tạp hơn các kiểu nguyên thủy. Khi bạn làm việc với kiểu tham chiếu, biến không lưu trữ trực tiếp giá trị, mà lưu trữ _một tham chiếu (địa chỉ bộ nhớ)_ đến nơi chứa giá trị đó.

Loại tham chiếu phổ biến nhất là Object. Các kiểu dữ liệu khác như Array, Function, Date, RegExp, v.v., thực chất đều là _biến thể_ hoặc _instance_ của Object.

a. Object (object)

Object là kiểu dữ liệu cơ bản nhất trong các kiểu tham chiếu. Nó là một tập hợp các cặp thuộc tính (property), mỗi thuộc tính bao gồm một khóa (key) (thường là chuỗi hoặc Symbol) và một giá trị (value) (có thể là bất kỳ kiểu dữ liệu nào khác).

Ví dụ:

let person = {
  firstName: "Nguyễn Văn",
  lastName: "A",
  age: 25,
  isStudent: false
};

console.log(person.firstName); // Truy cập thuộc tính bằng dấu chấm
console.log(person['age']);   // Truy cập thuộc tính bằng ngoặc vuông
console.log(typeof person);    // Output: object
  • Giải thích: Biến person lưu trữ một đối tượng với các thuộc tính như firstName, lastName, age, isStudent. Các thuộc tính này có thể truy cập bằng dấu chấm (.) hoặc ngoặc vuông ([]).
b. Array (object)

Array là một kiểu đối tượng đặc biệt, được thiết kế để lưu trữ một tập hợp các giá trị _có thứ tự_ theo chỉ mục (index). Chỉ mục của mảng trong JavaScript bắt đầu từ 0.

Ví dụ:

let colors = ["Đỏ", "Xanh lá", "Xanh dương"]; // Mảng các chuỗi
let numbers = [1, 5, 10, 20];             // Mảng các số

console.log(colors[0]);    // Output: Đỏ (truy cập phần tử đầu tiên)
console.log(numbers.length); // Output: 4 (số phần tử trong mảng)
console.log(typeof colors); // Output: object (Array là một loại Object)
  • Giải thích: colorsnumbers là các mảng. Bạn truy cập các phần tử trong mảng bằng cách sử dụng chỉ mục đặt trong ngoặc vuông sau tên biến mảng (colors[0]). Mảng có nhiều phương thức hữu ích để thao tác (ví dụ: push, pop, map, filter, v.v.).
c. Function (function)

Trong JavaScript, hàm cũng là một loại đối tượng đặc biệt (first-class objects). Hàm là một khối code được định nghĩa để thực hiện một nhiệm vụ cụ thể và có thể gọi (thực thi) khi cần. Mặc dù typeof một hàm trả về function, nó vẫn được xem là một loại đối tượng.

Ví dụ:

function greet(name) {
  console.log(`Chào bạn, ${name}!`);
}

greet("Độc giả"); // Gọi (thực thi) hàm
console.log(typeof greet); // Output: function
  • Giải thích: Hàm greet nhận một tham số name và in ra lời chào. Bạn gọi hàm bằng cách sử dụng tên hàm theo sau là dấu ngoặc đơn (), truyền vào các đối số nếu cần.

Sự khác biệt quan trọng giữa Primitive và Reference Types:

  • Primitive Types: Giá trị được lưu trữ _trực tiếp_ trong biến. Khi gán một biến nguyên thủy cho biến khác, giá trị được sao chép.
  • Reference Types: Biến lưu trữ _địa chỉ_ đến giá trị trong bộ nhớ. Khi gán một biến tham chiếu cho biến khác, chỉ địa chỉ được sao chép, cả hai biến cùng trỏ đến _một_ giá trị trong bộ nhớ. Thay đổi giá trị thông qua một biến sẽ ảnh hưởng đến biến còn lại.

Ví dụ về sự khác biệt:

// Primitive
let a = 10;
let b = a; // Sao chép giá trị 10
b = 20;
console.log(a); // Output: 10 (a không bị ảnh hưởng)

// Reference
let obj1 = { value: 10 };
let obj2 = obj1; // Sao chép địa chỉ (cả hai trỏ đến cùng một đối tượng)
obj2.value = 20;
console.log(obj1.value); // Output: 20 (obj1 bị ảnh hưởng vì trỏ đến cùng đối tượng đã bị thay đổi)
  • Giải thích: Với kiểu nguyên thủy (number), thay đổi b không ảnh hưởng đến a. Với kiểu tham chiếu (object), thay đổi thuộc tính của obj2 làm thay đổi đối tượng mà cả obj1obj2 cùng trỏ tới, do đó obj1.value cũng thay đổi.

2. Phạm vi (Scope) trong JavaScript: Ai thấy gì và ở đâu?

Phạm vi (Scope) trong JavaScript xác định _khu vực_ mà các biến và hàm có thể được truy cập và sử dụng. Nó giống như ranh giới giữa các phần khác nhau của chương trình, ngăn chặn việc các biến ở một khu vực can thiệp hoặc xung đột với biến ở khu vực khác.

Hiểu rõ phạm vi giúp bạn:

  • Tránh xung đột tên biến.
  • Quản lý bộ nhớ hiệu quả hơn (biến chỉ tồn tại trong phạm vi của nó).
  • Viết code an toàn và dễ dự đoán hơn.

JavaScript có ba loại phạm vi chính:

a. Phạm vi Toàn cầu (Global Scope)

Các biến được khai báo _ngoài bất kỳ hàm hoặc khối mã ({}) nào_ nằm trong phạm vi toàn cầu. Biến trong phạm vi toàn cầu có thể được truy cập _từ bất kỳ đâu_ trong chương trình JavaScript của bạn.

Ví dụ:

let globalVariable = "Tôi có mặt ở khắp mọi nơi!"; // Khai báo ngoài hàm

function accessGlobal() {
  console.log(globalVariable); // Có thể truy cập biến toàn cầu từ bên trong hàm
}

accessGlobal();
console.log(globalVariable); // Có thể truy cập biến toàn cầu từ bên ngoài hàm
  • Giải thích: globalVariable được khai báo ở cấp cao nhất, không nằm trong hàm hay khối lệnh nào, nên nó là biến toàn cục. Bạn có thể truy cập nó cả bên trong hàm accessGlobal() và ngoài hàm.
b. Phạm vi Hàm (Function Scope)

Các biến được khai báo _bên trong một hàm_ với từ khóa var nằm trong phạm vi hàm. Biến trong phạm vi hàm chỉ có thể được truy cập _từ bên trong hàm đó_ và các hàm lồng bên trong nó. Chúng không thể truy cập từ bên ngoài hàm.

Ví dụ:

function myFunction() {
  var functionScopedVariable = "Tôi chỉ sống trong hàm này";
  console.log(functionScopedVariable); // Có thể truy cập biến trong phạm vi hàm
}

myFunction();
// console.log(functionScopedVariable); // Lỗi! Không thể truy cập biến này từ bên ngoài hàm
  • Giải thích: functionScopedVariable được khai báo bên trong myFunction với var. Nó chỉ tồn tại và có thể truy cập được khi hàm myFunction đang chạy. Cố gắng truy cập nó từ bên ngoài hàm sẽ gây ra lỗi ReferenceError.
c. Phạm vi Khối (Block Scope)

Phạm vi khối được giới thiệu với ES6 thông qua các từ khóa letconst. Các biến được khai báo bằng let hoặc const _bên trong bất kỳ khối mã nào_ (được định nghĩa bởi cặp dấu ngoặc nhọn {}) chỉ có thể được truy cập _từ bên trong khối đó_.

Ví dụ:

function exampleBlockScope() {
  if (true) {
    let blockScopedVariable = "Tôi chỉ trong khối if này";
    const anotherBlockVariable = "Tôi cũng vậy!";
    console.log(blockScopedVariable); // Có thể truy cập biến trong khối
    console.log(anotherBlockVariable);
  }
  // console.log(blockScopedVariable); // Lỗi! Không thể truy cập biến này từ bên ngoài khối if
  // console.log(anotherBlockVariable); // Lỗi!
}

exampleBlockScope();
  • Giải thích: blockScopedVariableanotherBlockVariable được khai báo bên trong khối if bằng letconst. Chúng chỉ có thể truy cập được trong phạm vi của khối {} đó. Sau khi khối kết thúc, chúng không còn tồn tại và không thể truy cập từ bên ngoài.

Sự khác biệt giữa var, let, và const liên quan đến phạm vi:

  • var: Có phạm vi hàm (Function Scope) hoặc phạm vi toàn cầu (Global Scope). Nó _không_ có phạm vi khối. Điều này có thể dẫn đến những hành vi không mong muốn, đặc biệt là trong các vòng lặp hoặc khối if.
  • letconst: Có phạm vi khối (Block Scope). Đây là lý do chính khiến letconst được ưa dùng hơn var trong code JavaScript hiện đại.

Ví dụ minh họa sự khác biệt varlet trong vòng lặp:

// Sử dụng var
for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log('var:', i); // Output: 3, 3, 3 (vì i có phạm vi hàm/toàn cục và giá trị cuối cùng là 3)
  }, 100);
}

// Sử dụng let
for (let j = 0; j < 3; j++) {
  setTimeout(function() {
    console.log('let:', j); // Output: 0, 1, 2 (vì j có phạm vi khối, mỗi lần lặp tạo ra một j mới)
  }, 100);
}
  • Giải thích: Ví dụ kinh điển này cho thấy sự nguy hiểm của var trong vòng lặp khi kết hợp với setTimeout. Vì var i có phạm vi ngoài vòng lặp (phạm vi hàm hoặc toàn cục), khi hàm setTimeout thực thi sau một chút, nó nhìn thấy giá trị _cuối cùng_ của i là 3. Ngược lại, let j tạo ra một biến j mới cho _mỗi lần lặp_, mỗi biến này có phạm vi riêng trong khối lặp đó, do đó khi setTimeout thực thi, nó nhìn thấy giá trị j tương ứng với lần lặp cụ thể đó.
d. Chuỗi phạm vi (Scope Chain)

Khi JavaScript cần tìm kiếm giá trị của một biến, nó tuân theo một quy tắc được gọi là Chuỗi phạm vi (Scope Chain). Quá trình tìm kiếm bắt đầu từ _phạm vi hiện tại_ (nơi biến đang được truy cập). Nếu không tìm thấy biến ở đó, nó sẽ đi lên _phạm vi chứa nó (phạm vi cha)_, rồi phạm vi của phạm vi cha, và cứ thế tiếp tục cho đến _phạm vi toàn cầu_. Nếu biến không được tìm thấy ở bất kỳ đâu trong chuỗi phạm vi, JavaScript sẽ báo lỗi ReferenceError.

Ví dụ về Chuỗi phạm vi:

let globalVar = "Tôi là Global";

function outerFunction() {
  let outerVar = "Tôi là Outer";

  function innerFunction() {
    let innerVar = "Tôi là Inner";
    console.log(innerVar);   // Tìm thấy trong phạm vi hiện tại (innerFunction)
    console.log(outerVar);   // Tìm thấy trong phạm vi cha (outerFunction)
    console.log(globalVar);  // Tìm thấy trong phạm vi ông (Global)
    // console.log(nonExistentVar); // Lỗi ReferenceError
  }

  innerFunction();
  // console.log(innerVar); // Lỗi! innerVar chỉ trong phạm vi innerFunction
}

outerFunction();
  • Giải thích: Bên trong innerFunction, JavaScript tìm innerVar trong phạm vi của chính nó. Khi tìm outerVar, nó không có trong innerFunction, nên nó đi lên phạm vi cha (outerFunction) và tìm thấy. Khi tìm globalVar, nó không có trong cả innerFunction lẫn outerFunction, nên nó đi lên phạm vi toàn cầu và tìm thấy.

Hiểu rõ cách Chuỗi phạm vi hoạt động là cực kỳ quan trọng để debug (gỡ lỗi) các vấn đề liên quan đến biến không tìm thấy hoặc biến có giá trị không mong muốn.

Comments

There are no comments at the moment.