+8

JavaScript Nâng Cao - Kỳ 8

Có một câu nói là: Trên đời chỉ có thứ nhiều người chửi và thứ không ai thèm dùng.

Javascript là một ví dụ điển hình, nó có một số điểm thú vị nhưng cũng khiến chúng ta phải đau đầu. Lý thuyết thì dễ hiểu, nhưng khi thực hành là cả một vấn đề. Vậy nên, mình sẽ cùng các bạn đi sâu vào từng ví dụ cụ thể và phân tích, mổ xẻ nó để hiểu hơn về Javascript nhé.

Series này có thể sẽ khá dài mình không biết sẽ có bao nhiêu Kỳ tuy nhiên để tiện cho các bạn nào không đọc các bài trước đó của mình về JS thì trong loạt bài này mình sẽ giải thích lại toàn bộ. Các lý thuyết trong loạt bài này mình cũng có thể sẽ giải thích lại nhiều lần (tùy hứng) để các bạn có thể năm rõ nó hơn nhé.

Ok vào bài thôi nào... GÉT GÔ 🚀

Nếu có bất kỳ câu hỏi nào đừng ngại hãy bình luận dưới phần comment nhé. Hoặc chỉ cần để lại một comment chào mình là đã giúp mình có thêm động lực hoàn thành series này. Cảm ơn các bạn rất nhiều. 🤗

1. Typeof của typeof

Output của đoạn code sau là gì:

console.log(typeof typeof 1);
  • A: "number"
  • B: "string"
  • C: "object"
  • D: "undefined"
Đáp án của câu hỏi này là 
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
Đáp án: B

Cùng mình đi tìm hiểu tại sao kết quả lại là như vậy nhé ❓️

1.1. Toán tử typeof

Trước tiên, chúng ta cần hiểu về toán tử typeof trong JavaScript. Toán tử này được sử dụng để xác định kiểu dữ liệu của một biến hoặc một biểu thức.

Ví dụ:

console.log(typeof 42);        // "number"
console.log(typeof "hello");   // "string"
console.log(typeof true);      // "boolean"
console.log(typeof undefined); // "undefined"
console.log(typeof null);      // "object" (đây là một lỗi nổi tiếng trong JavaScript)
console.log(typeof {});        // "object"
console.log(typeof []);        // "object"
console.log(typeof function(){}); // "function"

1.2. Phân tích câu hỏi

Bây giờ, hãy xem xét câu hỏi của chúng ta:

console.log(typeof typeof 1);

Để hiểu điều này, chúng ta cần "đọc" từ trong ra ngoài:

  1. typeof 1 sẽ trả về "number", vì 1 là một số.
  2. Sau đó, chúng ta có typeof "number".

Vậy câu hỏi thực sự là: kiểu dữ liệu của chuỗi "number" là gì?

Và câu trả lời là: "string".

1.3. Kết luận

Đó là lý do tại sao kết quả cuối cùng là "string". Toán tử typeof luôn trả về một chuỗi đại diện cho kiểu dữ liệu, và kiểu dữ liệu của một chuỗi là "string".

Điều này có thể gây nhầm lẫn ban đầu, nhưng nó thực sự rất hợp lý khi bạn hiểu cách hoạt động của typeof.

2. Gán giá trị cho phần tử mảng vượt quá độ dài

Output của đoạn code sau là gì:

const numbers = [1, 2, 3];
numbers[10] = 11;
console.log(numbers);
  • A: [1, 2, 3, 7 x null, 11]
  • B: [1, 2, 3, 11]
  • C: [1, 2, 3, 7 x empty, 11]
  • D: SyntaxError
Đáp án của câu hỏi này là 
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
Đáp án: C

Cùng mình đi tìm hiểu tại sao kết quả lại là như vậy nhé ❓️

2.1. Mảng trong JavaScript

Trong JavaScript, mảng là một cấu trúc dữ liệu đặc biệt. Chúng có thể chứa nhiều loại dữ liệu khác nhau và có thể thay đổi kích thước một cách linh hoạt.

2.2. Gán giá trị cho phần tử vượt quá độ dài mảng

Khi bạn gán một giá trị cho một chỉ số vượt quá độ dài hiện tại của mảng, JavaScript sẽ tự động mở rộng mảng để chứa chỉ số mới này. Tuy nhiên, các phần tử giữa phần tử cuối cùng hiện tại và phần tử mới được thêm vào sẽ không được khởi tạo.

2.3. Empty slots (Khoảng trống)

Các phần tử không được khởi tạo này được gọi là "empty slots" (khoảng trống). Chúng khác với undefined ở chỗ chúng thực sự không tồn tại trong mảng.

Khi bạn lặp qua mảng bằng các phương thức như forEach, map, filter, v.v., các empty slots này sẽ bị bỏ qua.

2.4. Phân tích kết quả

Trong ví dụ của chúng ta:

const numbers = [1, 2, 3];
numbers[10] = 11;
console.log(numbers);
  • Ban đầu, mảng có 3 phần tử: [1, 2, 3]
  • Sau đó, chúng ta gán giá trị 11 cho phần tử ở vị trí index 10
  • JavaScript mở rộng mảng để chứa phần tử mới này
  • Kết quả là một mảng với 3 phần tử ban đầu, 7 empty slots, và phần tử mới ở cuối

2.5. Cách hiển thị

Cách hiển thị chính xác của mảng này có thể khác nhau tùy thuộc vào môi trường JavaScript bạn đang sử dụng. Trong nhiều trình duyệt và Node.js, nó sẽ được hiển thị như sau:

[1, 2, 3, empty × 7, 11]

Đây chính là lý do tại sao đáp án C là chính xác.

2.6. Lưu ý quan trọng

Mặc dù empty slots không chứa giá trị, nhưng chúng vẫn được tính vào độ dài của mảng. Trong ví dụ của chúng ta, numbers.length sẽ trả về 11.

console.log(numbers.length); // 11

Điều này có thể gây ra một số hành vi không mong muốn khi làm việc với mảng, vì vậy cần phải cẩn thận khi thao tác với các chỉ số vượt quá độ dài hiện tại của mảng.

3. Block-scoped variables trong catch block

Output của đoạn code sau là gì:

(() => {
  let x, y;
  try {
    throw new Error();
  } catch (x) {
    (x = 1), (y = 2);
    console.log(x);
  }
  console.log(x);
  console.log(y);
})();
  • A: 1 undefined 2
  • B: undefined undefined undefined
  • C: 1 1 2
  • D: 1 undefined undefined
Đáp án của câu hỏi này là 
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
Đáp án: A

Cùng mình đi tìm hiểu tại sao kết quả lại là như vậy nhé ❓️

3.1. Block scope và function scope

Trước khi đi vào phân tích, chúng ta cần hiểu về khái niệm block scope và function scope trong JavaScript.

  • Function scope: Các biến được khai báo bằng var có phạm vi hoạt động trong toàn bộ hàm chứa nó.
  • Block scope: Các biến được khai báo bằng letconst có phạm vi hoạt động chỉ trong block (khối) chứa nó.

3.2. Catch block và biến parameter

Trong JavaScript, catch block có một đặc điểm thú vị: tham số của nó (trong trường hợp này là x) được coi như một biến block-scoped mới, chỉ tồn tại trong phạm vi của catch block.

3.3. Phân tích từng dòng code

Hãy đi qua từng bước của đoạn code:

  1. Khai báo hai biến xy ở đầu hàm anonymous. Cả hai đều là undefined ở thời điểm này.

  2. Trong try block, chúng ta ném ra một Error.

  3. catch block bắt Error này. Tham số x của catch là một biến mới, chỉ tồn tại trong catch block. Nó không liên quan gì đến biến x được khai báo ở đầu hàm.

  4. Trong catch block:

    • x = 1: Gán giá trị 1 cho biến x của catch block.
    • y = 2: Gán giá trị 2 cho biến y được khai báo ở đầu hàm.
    • console.log(x): In ra giá trị của biến x trong catch block, tức là 1.
  5. Sau khi thoát khỏi catch block:

    • console.log(x): In ra giá trị của biến x được khai báo ở đầu hàm. Biến này vẫn là undefined vì không bị ảnh hưởng bởi phép gán trong catch block.
    • console.log(y): In ra giá trị của biến y, là 2 (đã được gán trong catch block).

3.4. Tóm lại

Kết quả cuối cùng sẽ là:

  1. 1 (từ console.log(x) trong catch block)
  2. undefined (từ console.log(x) ngoài catch block)
  3. 2 (từ console.log(y) ngoài catch block)

3.5. Bài học rút ra

  1. Block scope trong catch: Tham số của catch block tạo ra một biến mới chỉ tồn tại trong phạm vi của catch, không ảnh hưởng đến biến cùng tên bên ngoài.

  2. Hoisting với let: Mặc dù let cũng được hoisted, nhưng nó không được khởi tạo cho đến khi gặp dòng khai báo. Điều này tạo ra "temporal dead zone" - khu vực mà biến tồn tại nhưng không thể truy cập.

  3. Phạm vi của biến: Biến được khai báo bằng var có phạm vi function scope, trong khi let có phạm vi block scope.

  4. Cẩn trọng với tên biến: Việc sử dụng cùng tên biến ở các phạm vi khác nhau có thể gây nhầm lẫn và khó debug. Nên tránh điều này trong thực tế.

Hiểu rõ về cách JavaScript xử lý biến và phạm vi của chúng sẽ giúp bạn tránh được nhiều lỗi khó hiểu và viết code chặt chẽ hơn.

4. Kiểu dữ liệu trong JavaScript

Mọi thứ trong JavaScript đều là...

  • A: primitives hoặc object
  • B: function hoặc object
  • C: hỏi mẹo khó đấy! chỉ object thôi
  • D: number hoặc object
Đáp án của câu hỏi này là 
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
Đáp án: A

Cùng mình đi tìm hiểu tại sao kết quả lại là như vậy nhé ❓️

4.1. Kiểu dữ liệu trong JavaScript

JavaScript có hai loại kiểu dữ liệu chính: kiểu nguyên thủy (primitives) và đối tượng (objects).

4.1.1. Kiểu nguyên thủy (Primitives)

Có 7 kiểu dữ liệu nguyên thủy trong JavaScript:

  1. Number: Đại diện cho cả số nguyên và số thực.

    let age = 25;
    let pi = 3.14;
    
  2. String: Chuỗi ký tự.

    let name = "John";
    let greeting = 'Hello, World!';
    
  3. Boolean: Giá trị true hoặc false.

    let isStudent = true;
    let hasJob = false;
    
  4. Undefined: Biến được khai báo nhưng chưa được gán giá trị.

    let undefinedVar;
    
  5. Null: Đại diện cho một giá trị không tồn tại hoặc không hợp lệ.

    let emptyValue = null;
    
  6. Symbol: Kiểu dữ liệu mới được giới thiệu trong ES6, đại diện cho một định danh duy nhất.

    let sym1 = Symbol("id");
    let sym2 = Symbol("id");
    console.log(sym1 === sym2); // false
    
  7. BigInt: Kiểu dữ liệu mới trong ES11, dùng để biểu diễn số nguyên có độ dài tùy ý.

    let bigNumber = 1234567890123456789012345678901234567890n;
    

4.1.2. Đối tượng (Objects)

Mọi thứ không phải là kiểu nguyên thủy đều là đối tượng trong JavaScript. Điều này bao gồm:

  1. Object: Tập hợp các cặp key-value.

    let person = {
      name: "John",
      age: 30
    };
    
  2. Array: Danh sách các giá trị.

    let fruits = ["apple", "banana", "orange"];
    
  3. Function: Trong JavaScript, function cũng là một loại đối tượng.

    function greet(name) {
      console.log("Hello, " + name);
    }
    
  4. Date, RegExp, Error, v.v.: Đều là các loại đối tượng đặc biệt.

4.2. Tại sao đáp án là A?

Đáp án A: "primitives hoặc object" là chính xác vì:

  1. Nó bao quát được tất cả các kiểu dữ liệu trong JavaScript.
  2. Mọi giá trị trong JavaScript hoặc là một kiểu nguyên thủy, hoặc là một đối tượng.

4.3. Tại sao các đáp án khác không chính xác?

  • B: "function hoặc object" - Không chính xác vì nó bỏ qua các kiểu nguyên thủy.
  • C: "chỉ object thôi" - Không chính xác vì nó bỏ qua các kiểu nguyên thủy.
  • D: "number hoặc object" - Không chính xác vì nó chỉ đề cập đến một kiểu nguyên thủy (number) và bỏ qua các kiểu nguyên thủy khác.

4.4. Lưu ý quan trọng

Mặc dù primitives và objects là hai loại dữ liệu chính trong JavaScript, có một số điểm thú vị cần lưu ý:

  1. Wrapper Objects: JavaScript tự động "bọc" (wrap) các giá trị nguyên thủy trong đối tượng tạm thời khi bạn cố gắng gọi các phương thức trên chúng.

    let name = "John";
    console.log(name.toUpperCase()); // "JOHN"
    

    Trong ví dụ trên, JavaScript tạm thời bọc "John" trong một String object để gọi phương thức toUpperCase().

  2. typeof null: Mặc dù null là một kiểu nguyên thủy, typeof null trả về "object". Đây là một lỗi nổi tiếng trong JavaScript mà không thể sửa vì lý do tương thích ngược.

    console.log(typeof null); // "object"
    
  3. Function là Object: Mặc dù functions được liệt kê riêng, chúng thực sự là một loại đặc biệt của object.

    console.log(typeof function(){}); // "function"
    console.log(function(){} instanceof Object); // true
    

4.5. Tóm lại

Hiểu rõ về các kiểu dữ liệu trong JavaScript là nền tảng quan trọng để làm việc hiệu quả với ngôn ngữ này. Việc phân biệt giữa primitives và objects không chỉ giúp bạn hiểu cách JavaScript xử lý dữ liệu, mà còn giúp bạn tránh được nhiều lỗi phổ biến và tối ưu hóa hiệu suất code của mình.

5. Phương thức reduce trong JavaScript

Output của đoạn code sau là gì?

[[0, 1], [2, 3]].reduce(
  (acc, cur) => {
    return acc.concat(cur);
  },
  [1, 2]
);
  • A: [0, 1, 2, 3, 1, 2]
  • B: [6, 1, 2]
  • C: [1, 2, 0, 1, 2, 3]
  • D: [1, 2, 6]
Đáp án của câu hỏi này là 
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
Đáp án: C

Cùng mình đi tìm hiểu tại sao kết quả lại là như vậy nhé ❓️

5.1. Phương thức reduce() trong JavaScript

Trước khi đi vào phân tích đoạn code cụ thể, chúng ta hãy tìm hiểu về phương thức reduce() trong JavaScript.

reduce() là một phương thức của mảng trong JavaScript, được sử dụng để "thu gọn" một mảng thành một giá trị duy nhất. Phương thức này thực thi một hàm reducer do người dùng cung cấp trên mỗi phần tử của mảng, theo thứ tự, truyền kết quả tích lũy từ phép tính trước đó.

Cú pháp cơ bản của reduce():

array.reduce(callback( accumulator, currentValue[, index[, array]] )[, initialValue])

Trong đó:

  • callback: Hàm thực thi trên mỗi phần tử của mảng.
  • accumulator: Giá trị tích lũy được trả về sau mỗi lần gọi callback.
  • currentValue: Phần tử hiện tại đang được xử lý trong mảng.
  • initialValue (tùy chọn): Giá trị được sử dụng làm đối số đầu tiên cho lần gọi đầu tiên của callback.

5.2. Phân tích đoạn code

Bây giờ, hãy phân tích đoạn code trong câu hỏi:

[[0, 1], [2, 3]].reduce(
  (acc, cur) => {
    return acc.concat(cur);
  },
  [1, 2]
);
  1. Mảng ban đầu: [[0, 1], [2, 3]]
  2. Giá trị khởi tạo (initialValue): [1, 2]
  3. Hàm callback: (acc, cur) => { return acc.concat(cur); }

5.3. Các bước thực hiện

Hãy đi qua từng bước của quá trình reduce:

  1. Bước 1:

    • acc (accumulator) = [1, 2] (giá trị khởi tạo)
    • cur (currentValue) = [0, 1] (phần tử đầu tiên của mảng)
    • Kết quả: [1, 2].concat([0, 1]) = [1, 2, 0, 1]
  2. Bước 2:

    • acc = [1, 2, 0, 1] (kết quả từ bước 1)
    • cur = [2, 3] (phần tử thứ hai của mảng)
    • Kết quả: [1, 2, 0, 1].concat([2, 3]) = [1, 2, 0, 1, 2, 3]

5.4. Kết quả cuối cùng

Sau khi hoàn thành cả hai bước, kết quả cuối cùng sẽ là [1, 2, 0, 1, 2, 3].

5.5. Giải thích chi tiết

  1. Giá trị khởi tạo [1, 2] được sử dụng làm giá trị ban đầu cho acc.
  2. Trong lần lặp đầu tiên, [0, 1] được nối vào [1, 2], tạo ra [1, 2, 0, 1].
  3. Trong lần lặp thứ hai, [2, 3] được nối vào [1, 2, 0, 1], tạo ra kết quả cuối cùng [1, 2, 0, 1, 2, 3].

5.6. Lưu ý quan trọng

Phương thức reduce() là một công cụ mạnh mẽ trong JavaScript để xử lý mảng. Nó cho phép chúng ta "thu gọn" một mảng thành một giá trị duy nhất, nhưng như trong ví dụ này, chúng ta cũng có thể sử dụng nó để tạo ra một mảng mới với cấu trúc khác.

Trong trường hợp này, chúng ta đã sử dụng reduce() để "làm phẳng" một mảng hai chiều thành một mảng một chiều. Đây là một kỹ thuật phổ biến được gọi là "flattening" hoặc "làm phẳng" mảng.

5.7. Ví dụ thực tế

Kỹ thuật này có thể hữu ích trong nhiều tình huống thực tế. Ví dụ:

  1. Khi bạn có một danh sách các danh sách (ví dụ: danh sách các lớp học, mỗi lớp có một danh sách học sinh) và bạn muốn tạo ra một danh sách đơn của tất cả học sinh.

  2. Trong xử lý dữ liệu, khi bạn có dữ liệu được nhóm theo một cách nào đó và bạn muốn "làm phẳng" nó để xử lý dễ dàng hơn.

  3. Trong lập trình hàm, kỹ thuật này thường được sử dụng như một phần của quá trình "map-reduce", nơi dữ liệu được ánh xạ (mapped) thành các nhóm và sau đó được giảm (reduced) thành một kết quả cuối cùng.

5.8. Tóm lại

Bạn có thể hiểu đơn giản hàm reduce() là các dùng ngắn gọn khi các bạn muốn thực hiện logic với vòng loop như sau:

const arr = [[0, 1], [2, 3]]
let result = [1, 2]
for (let i = 0; i < arr.length; i++) {
  result = result.concat(arr[i])
}
console.log(result) // [1, 2, 0, 1, 2, 3]

// Nếu sử dụng reduce thì sẽ ngắn gọn hơn
console.log(arr.reduce((acc, cur) => acc.concat(cur), [1, 2]))

Hiểu được cách reduce() hoạt động và cách nó có thể được sử dụng để thao tác với mảng là một kỹ năng quan trọng trong JavaScript. Nó không chỉ giúp bạn viết code ngắn gọn hơn mà còn mở ra nhiều khả năng xử lý dữ liệu phức tạp một cách hiệu quả.

Nếu có bất kỳ câu hỏi nào đừng ngại hãy bình luận dưới phần comment nhé. Hoặc chỉ cần để lại một comment chào mình là đã giúp mình có thêm động lực hoàn thành series này. Cảm ơn các bạn rất nhiều. 🤗


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.