+9

JavaScript Nâng Cao - Kỳ 13

Có một câu nói vui 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. Object.defineProperty và enumerable properties

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

const person = { name: "Lydia" };

Object.defineProperty(person, "age", { value: 21 });

console.log(person);
console.log(Object.keys(person));
  • A: { name: "Lydia", age: 21 }, ["name", "age"]
  • B: { name: "Lydia", age: 21 }, ["name"]
  • C: { name: "Lydia"}, ["name", "age"]
  • D: { name: "Lydia"}, ["age"]
Đá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. Object.defineProperty là gì?

Object.defineProperty() là một phương thức trong JavaScript cho phép chúng ta định nghĩa một thuộc tính mới hoặc sửa đổi một thuộc tính đã tồn tại trên một đối tượng. Phương thức này cho phép chúng ta kiểm soát chi tiết hơn về cách thuộc tính hoạt động.

Cú pháp của nó như sau:

Object.defineProperty(obj, prop, descriptor)

Trong đó:

  • obj: Đối tượng mà chúng ta muốn định nghĩa thuộc tính.
  • prop: Tên của thuộc tính mà chúng ta muốn định nghĩa hoặc sửa đổi.
  • descriptor: Một đối tượng mô tả thuộc tính.

1.2. Enumerable properties là gì?

Trong JavaScript, thuộc tính "enumerable" quyết định xem thuộc tính đó có được liệt kê khi sử dụng các phương thức như Object.keys(), for...in loop, hoặc JSON.stringify() hay không.

Mặc định, khi chúng ta tạo một thuộc tính bằng cách gán trực tiếp, thuộc tính đó sẽ là enumerable:

const obj = {};
obj.prop = 'value';
console.log(Object.keys(obj)); // ['prop']

Tuy nhiên, khi sử dụng Object.defineProperty(), mặc định thuộc tính sẽ không enumerable trừ khi chúng ta chỉ định rõ:

const obj = {};
Object.defineProperty(obj, 'prop', { value: 'value', enumerable: true });
console.log(Object.keys(obj)); // ['prop']

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

Quay lại với đoạn code ban đầu:

const person = { name: "Lydia" };

Object.defineProperty(person, "age", { value: 21 });

console.log(person);
console.log(Object.keys(person));
  1. Đầu tiên, chúng ta tạo một đối tượng person với thuộc tính name.

  2. Sau đó, chúng ta sử dụng Object.defineProperty() để thêm thuộc tính age. Tuy nhiên, chúng ta không chỉ định enumerable: true, nên mặc định nó sẽ là false.

  3. Khi chúng ta console.log(person), nó sẽ hiển thị cả hai thuộc tính: { name: "Lydia", age: 21 }. Điều này là bởi vì console.log() hiển thị tất cả các thuộc tính, bất kể chúng có enumerable hay không.

  4. Tuy nhiên, khi chúng ta sử dụng Object.keys(person), nó chỉ trả về một mảng chứa các thuộc tính enumerable. Trong trường hợp này, chỉ có name là enumerable, nên kết quả là ["name"].

2. JSON.stringify và replacer function

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

const settings = {
  username: "lydiahallie",
  level: 19,
  health: 90
};

const data = JSON.stringify(settings, ["level", "health"]);
console.log(data);
  • A: "{"level":19, "health":90}"
  • B: "{"username": "lydiahallie"}"
  • C: "["level", "health"]"
  • D: "{"username": "lydiahallie", "level":19, "health":90}"
Đá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é ❓️

2.1. JSON.stringify là gì?

JSON.stringify() là một phương thức trong JavaScript được sử dụng để chuyển đổi một giá trị JavaScript thành một chuỗi JSON. Cú pháp cơ bản của nó như sau:

JSON.stringify(value[, replacer[, space]])

Trong đó:

  • value: Giá trị cần chuyển đổi thành chuỗi JSON.
  • replacer (tùy chọn): Có thể là một hàm hoặc một mảng. Nó được sử dụng để kiểm soát quá trình chuyển đổi.
  • space (tùy chọn): Được sử dụng để thêm khoảng trắng vào chuỗi JSON kết quả để làm cho nó dễ đọc hơn.

2.2. Replacer function trong JSON.stringify

Replacer function là một tính năng mạnh mẽ của JSON.stringify(). Nó cho phép chúng ta kiểm soát cách các giá trị được chuyển đổi thành chuỗi JSON.

Khi replacer là một mảng, nó sẽ chỉ định những thuộc tính nào của đối tượng sẽ được bao gồm trong chuỗi JSON kết quả.

Ví dụ:

const obj = { a: 1, b: 2, c: 3 };
console.log(JSON.stringify(obj, ['a', 'c']));
// Kết quả: "{"a":1,"c":3}"

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

Quay lại với đoạn code ban đầu:

const settings = {
  username: "lydiahallie",
  level: 19,
  health: 90
};

const data = JSON.stringify(settings, ["level", "health"]);
console.log(data);
  1. Chúng ta có một đối tượng settings với ba thuộc tính: username, level, và health.

  2. Khi gọi JSON.stringify(), chúng ta truyền vào một mảng ["level", "health"] làm tham số thứ hai (replacer).

  3. Điều này có nghĩa là chỉ các thuộc tính levelhealth sẽ được bao gồm trong chuỗi JSON kết quả.

  4. Thuộc tính username sẽ bị loại bỏ vì nó không có trong mảng replacer.

2.4. Tóm lại

Đáp án đúng là A: "{"level":19, "health":90}".

Kết quả này cho thấy cách mà replacer function trong JSON.stringify() có thể được sử dụng để lọc các thuộc tính của đối tượng khi chuyển đổi thành chuỗi JSON. Đây là một tính năng hữu ích khi bạn muốn kiểm soát chính xác những thông tin nào sẽ được bao gồm trong chuỗi JSON, đặc biệt là khi làm việc với các đối tượng phức tạp hoặc khi bạn muốn loại bỏ các thông tin nhạy cảm trước khi gửi dữ liệu.

3. Postfix increment operator và tham trị

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

let num = 10;

const increaseNumber = () => num++;
const increasePassedNumber = number => number++;

const num1 = increaseNumber();
const num2 = increasePassedNumber(num1);

console.log(num1);
console.log(num2);
  • A: 10, 10
  • B: 10, 11
  • C: 11, 11
  • D: 11, 12
Đá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. Postfix increment operator (++)

Toán tử tăng hậu tố ++ trong JavaScript được sử dụng để tăng giá trị của một biến lên 1. Tuy nhiên, điều quan trọng cần lưu ý là nó trả về giá trị ban đầu của biến trước khi tăng.

Ví dụ:

let x = 5;
console.log(x++); // Outputs: 5
console.log(x);   // Outputs: 6

3.2. Tham trị (Pass by value)

Trong JavaScript, các kiểu dữ liệu nguyên thủy (như số, chuỗi, boolean) được truyền theo giá trị. Điều này có nghĩa là khi bạn truyền một biến kiểu nguyên thủy vào một hàm, hàm đó nhận được một bản sao của giá trị, không phải là tham chiếu đến biến gốc.

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

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

let num = 10;

const increaseNumber = () => num++;
const increasePassedNumber = number => number++;

const num1 = increaseNumber();
const num2 = increasePassedNumber(num1);

console.log(num1);
console.log(num2);
  1. num được khởi tạo với giá trị 10.

  2. increaseNumber là một hàm không nhận tham số, nó tăng giá trị của biến toàn cục num và trả về giá trị ban đầu của num (do sử dụng toán tử hậu tố ++).

  3. increasePassedNumber là một hàm nhận một tham số number, tăng giá trị của tham số đó và trả về giá trị ban đầu (cũng do sử dụng toán tử hậu tố ++).

  4. num1 = increaseNumber():

    • num tăng lên 11
    • Nhưng num1 nhận giá trị 10 (giá trị ban đầu của num trước khi tăng)
  5. num2 = increasePassedNumber(num1):

    • num1 (giá trị 10) được truyền vào hàm
    • Trong hàm, giá trị này tăng lên 11
    • Nhưng hàm trả về 10 (giá trị ban đầu trước khi tăng)
    • num2 nhận giá trị 10

3.4. Tóm lại

Đáp án đúng là A: 10, 10.

Kết quả này cho thấy hai điểm quan trọng trong JavaScript:

  1. Toán tử tăng hậu tố ++ trả về giá trị ban đầu trước khi tăng.
  2. Các kiểu dữ liệu nguyên thủy được truyền theo giá trị, không phải tham chiếu.

Hiểu được những khái niệm này rất quan trọng khi làm việc với JavaScript, đặc biệt là khi xử lý các hàm và toán tử tăng/giảm.

4. Object spread và Object.defineProperty

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

const value = { number: 10 };

const multiply = (x = { ...value }) => {
  console.log((x.number *= 2));
};

multiply();
multiply();
multiply(value);
multiply(value);
  • A: 20, 40, 80, 160
  • B: 20, 40, 20, 40
  • C: 20, 20, 20, 40
  • D: NaN, NaN, 20, 40
Đá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é ❓️

4.1. Object spread operator

Toán tử spread (...) trong JavaScript cho phép chúng ta sao chép nhanh chóng tất cả hoặc một phần của một đối tượng hoặc mảng hiện có sang một đối tượng hoặc mảng khác.

Ví dụ:

const obj1 = { a: 1, b: 2 };
const obj2 = { ...obj1, c: 3 };
console.log(obj2); // { a: 1, b: 2, c: 3 }

4.2. Default parameters

Trong ES6+, chúng ta có thể định nghĩa giá trị mặc định cho tham số của hàm. Giá trị mặc định này sẽ được sử dụng nếu không có đối số nào được truyền vào hoặc nếu đối số là undefined.

function greet(name = "Guest") {
  console.log(`Hello, ${name}!`);
}

greet(); // "Hello, Guest!"
greet("John"); // "Hello, John!"

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

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

const value = { number: 10 };

const multiply = (x = { ...value }) => {
  console.log((x.number *= 2));
};

multiply();
multiply();
multiply(value);
multiply(value);
  1. value là một đối tượng với thuộc tính number có giá trị 10.

  2. Hàm multiply có một tham số x với giá trị mặc định là một bản sao của value (sử dụng spread operator).

  3. Trong hàm, x.number được nhân với 2 và kết quả được in ra.

  4. Lần gọi đầu tiên multiply():

    • Không có đối số được truyền vào, nên x nhận giá trị mặc định { number: 10 }
    • x.number *= 2 tính toán 10 * 2 = 20 và in ra 20
  5. Lần gọi thứ hai multiply():

    • Tương tự lần đầu, x lại nhận một bản sao mới của value
    • Kết quả vẫn là 20
  6. Lần gọi thứ ba multiply(value):

    • value được truyền trực tiếp vào hàm
    • x.number *= 2 tính toán 10 * 2 = 20 và in ra 20
    • Lưu ý rằng value.number giờ đã thay đổi thành 20
  7. Lần gọi thứ tư multiply(value):

    • value (đã bị thay đổi) được truyền vào hàm
    • x.number *= 2 tính toán 20 * 2 = 40 và in ra 40

4.4. Tóm lại

Đáp án đúng là C: 20, 20, 20, 40.

Kết quả này cho thấy một số điểm quan trọng:

  1. Spread operator tạo ra một bản sao nông (shallow copy) của đối tượng.
  2. Giá trị mặc định của tham số được đánh giá mỗi khi hàm được gọi.
  3. Khi truyền một đối tượng vào hàm, các thay đổi trên đối tượng đó sẽ ảnh hưởng đến đối tượng gốc.

Hiểu được cách hoạt động của spread operator, giá trị mặc định của tham số, và cách JavaScript xử lý đối tượng khi truyền vào hàm là rất quan trọng để viết code JavaScript hiệu quả và tránh các lỗi không mong muốn.

5. Array.prototype.reduce và callback function

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

[1, 2, 3, 4].reduce((x, y) => console.log(x, y));
  • A: 1 2 and 3 3 and 6 4
  • B: 1 2 and 2 3 and 3 4
  • C: 1 undefined and 2 undefined and 3 undefined and 4 undefined
  • D: 1 2 and undefined 3 and undefined 4
Đáp án của câu hỏi này là
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
↓↓↓↓↓↓↓↓↓↓
Đáp án: D

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

5.1. Array.prototype.reduce

Phương thức reduce() thực thi một hàm reducer 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 đó. Kết quả cuối cùng của việc chạy reducer trên tất cả các phần tử của mảng là một giá trị duy nhất.

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

arr.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

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

[1, 2, 3, 4].reduce((x, y) => console.log(x, y));
  1. Mảng ban đầu là [1, 2, 3, 4].

  2. Hàm callback chỉ đơn giản là console.log(x, y), không có giá trị trả về rõ ràng.

  3. Không có initialValue được cung cấp.

  4. Khi không có initialValue, phần tử đầu tiên của mảng (1) sẽ được sử dụng làm giá trị khởi tạo cho accumulator (x), và vòng lặp bắt đầu từ phần tử thứ hai.

  5. Các lần gọi callback:

    • Lần 1: x = 1, y = 2 -> In ra 1 2
    • Lần 2: x = undefined (vì callback không trả về giá trị), y = 3 -> In ra undefined 3
    • Lần 3: x = undefined, y = 4 -> In ra undefined 4

5.3. Tại sao x lại là undefined?

Trong trường hợp này, hàm callback chỉ thực hiện console.log(x, y) mà không trả về giá trị nào. Trong JavaScript, khi một hàm không có câu lệnh return rõ ràng, nó sẽ mặc định trả về undefined.

Vì vậy, sau lần gọi đầu tiên của callback, giá trị trả về (sẽ được sử dụng làm accumulator cho lần gọi tiếp theo) là undefined. Điều này dẫn đến việc trong các lần gọi tiếp theo, x (accumulator) luôn là undefined.

5.4. Tóm lại

Đáp án đúng là D: 1 2 and undefined 3 and undefined 4

Điểm quan trọng về reduce():

  1. Khi không có initialValue, phần tử đầu tiên của mảng được sử dụng làm giá trị khởi tạo cho accumulator.

  2. Giá trị trả về của callback function trong mỗi lần lặp sẽ được sử dụng làm accumulator cho lần lặp tiếp theo.

  3. Nếu callback function không trả về giá trị rõ ràng (như trong trường hợp này, chỉ có console.log), undefined sẽ được sử dụng làm accumulator cho các lần lặp tiếp theo.

  4. reduce() tiếp tục thực hiện trên tất cả các phần tử của mảng, ngay cả khi accumulator là undefined.

Hiểu được cách hoạt động này của reduce() rất quan trọng khi sử dụng nó trong thực tế. Thông thường, bạn sẽ muốn trả về một giá trị rõ ràng từ callback function để tích lũy kết quả qua các lần lặp.

Ví dụ, nếu muốn tính tổng các phần tử trong mảng, bạn sẽ viết:

[1, 2, 3, 4].reduce((sum, current) => sum + current, 0);

Trong trường hợp này, mỗi lần lặp sẽ trả về tổng tích lũy, và kết quả cuối cùng sẽ là tổng của tất cả các phần tử trong mảng.

Kết luận

Qua 5 ví dụ trên, chúng ta đã đi sâu vào một số khía cạnh quan trọng của JavaScript:

  1. Cách Object.defineProperty() hoạt động và ảnh hưởng của nó đến tính enumerable của thuộc tính.
  2. Cách sử dụng JSON.stringify() với replacer function để kiểm soát quá trình chuyển đổi JSON.
  3. Cách hoạt động của toán tử tăng hậu tố (++) và tham trị trong JavaScript.
  4. Cách spread operator và default parameters hoạt động trong functions.
  5. Cách Array.prototype.reduce() hoạt động và tầm quan trọng của việc trả về giá trị từ callback function.

Những khái niệm này là nền tảng cho việc hiểu sâu hơn về JavaScript và có thể giúp bạn viết code hiệu quả hơn, tránh được nhiều lỗi phổ biến.

Hãy nhớ rằng, JavaScript có nhiều "góc khuất" và đặc điểm độc đáo. Việc hiểu rõ những chi tiết nhỏ này sẽ giúp bạn trở thành một developer JavaScript giỏi hơn.

Hy vọng bài viết này đã giúp bạn hiểu rõ hơn về JavaScript. Hẹn gặp lại ở các bài viết tiếp theo. 🚀

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
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí