+8

JavaScript Nâng Cao - Kỳ 11

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. Tham trị và tham chiếu trong JavaScript

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

function getInfo(member, year) {
  member.name = "Lydia";
  year = "1998";
}

const person = { name: "Sarah" };
const birthYear = "1997";

getInfo(person, birthYear);

console.log(person, birthYear);
  • A: { name: "Lydia" }, "1997"
  • B: { name: "Sarah" }, "1998"
  • C: { name: "Lydia" }, "1998"
  • D: { name: "Sarah" }, "1997"
Đá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é ❓️

1.1. Tham trị (Pass by Value) và Tham chiếu (Pass by Reference)

Trong JavaScript, khi chúng ta truyền đối số vào hàm, có hai cách mà giá trị được xử lý:

  1. Tham trị (Pass by Value): Áp dụng cho các kiểu dữ liệu nguyên thủy (primitive types) như number, string, boolean, null, undefined.

  2. Tham chiếu (Pass by Reference): Áp dụng cho các đối tượng (objects) bao gồm arrays, functions, và object literals.

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

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

const person = { name: "Sarah" };
const birthYear = "1997";

getInfo(person, birthYear);
  • person là một object, nên nó được truyền vào hàm getInfo bằng tham chiếu.
  • birthYear là một string (kiểu nguyên thủy), nên nó được truyền vào hàm getInfo bằng tham trị.

1.3. Điều gì xảy ra trong hàm getInfo?

function getInfo(member, year) {
  member.name = "Lydia";
  year = "1998";
}
  1. member.name = "Lydia": Vì member là tham chiếu đến person, nên thay đổi member.name sẽ thay đổi person.name.

  2. year = "1998": Đây chỉ thay đổi bản sao local của birthYear trong hàm getInfo, không ảnh hưởng đến birthYear bên ngoài.

1.4. Kết quả

Sau khi gọi getInfo(person, birthYear):

  • person đã bị thay đổi: { name: "Lydia" }
  • birthYear vẫn giữ nguyên: "1997"

Vì vậy, khi chúng ta log ra:

console.log(person, birthYear);

Kết quả sẽ là: { name: "Lydia" }, "1997"

1.5. Lưu ý quan trọng

Hiểu rõ về tham trị và tham chiếu là cực kỳ quan trọng trong JavaScript. Nó giúp chúng ta tránh được nhiều lỗi không mong muốn, đặc biệt khi làm việc với objects và arrays.

Ví dụ, nếu bạn muốn thay đổi một object mà không ảnh hưởng đến object gốc, bạn nên tạo một bản sao của object đó:

const originalObj = { x: 1, y: 2 };
const copyObj = { ...originalObj };

copyObj.x = 100;

console.log(originalObj); // { x: 1, y: 2 }
console.log(copyObj);     // { x: 100, y: 2 }

Trong ví dụ trên, chúng ta đã sử dụng spread operator (...) để tạo một bản sao shallow của originalObj. Khi thay đổi copyObj, originalObj không bị ảnh hưởng.

2. Xử lý lỗi với try-catch và throw

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

function greeting() {
  throw "Hello world!";
}

function sayHi() {
  try {
    const data = greeting();
    console.log("It worked!", data);
  } catch (e) {
    console.log("Oh no an error!", e);
  }
}

sayHi();
  • A: "It worked! Hello world!"
  • B: "Oh no an error: undefined
  • C: SyntaxError: can only throw Error objects
  • D: "Oh no an error: Hello world!
Đá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é ❓️

2.1. Từ khóa throw

Trong JavaScript, throw được sử dụng để ném ra một ngoại lệ (exception). Khi một ngoại lệ được ném ra, nó sẽ dừng thực thi chương trình ngay lập tức và chuyển quyền điều khiển cho đoạn code xử lý ngoại lệ đầu tiên trong call stack.

2.2. Cấu trúc try-catch

Cấu trúc try-catch được sử dụng để xử lý các ngoại lệ:

  • Đoạn code trong khối try sẽ được thực thi.
  • Nếu có bất kỳ ngoại lệ nào xảy ra trong khối try, nó sẽ được bắt bởi khối catch.
  • Biến trong catch(e) sẽ chứa thông tin về ngoại lệ đã xảy ra.

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

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

  1. Hàm greeting() ném ra một ngoại lệ với giá trị là chuỗi "Hello world!".

  2. Trong hàm sayHi():

    • Chúng ta cố gắng gọi greeting() trong khối try.
    • greeting() ném ra một ngoại lệ, nên dòng console.log("It worked!", data); sẽ không bao giờ được thực thi.
    • Ngoại lệ được bắt bởi khối catch, và giá trị của ngoại lệ ("Hello world!") được gán cho biến e.
  3. Cuối cùng, chúng ta in ra "Oh no an error!" cùng với giá trị của e, là "Hello world!".

2.4. Lưu ý quan trọng

  1. Trong JavaScript, bạn có thể throw bất kỳ giá trị nào, không chỉ giới hạn ở các đối tượng Error.

  2. Tuy nhiên, một best practice là luôn throw các đối tượng Error hoặc các đối tượng kế thừa từ Error. Điều này cung cấp thêm thông tin như stack trace, giúp debug dễ dàng hơn.

    throw new Error("This is an error message");
    
  3. Khi làm việc với các API bất đồng bộ, bạn có thể sử dụng async/await kết hợp với try-catch:

    async function fetchData() {
      try {
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();
        console.log(data);
      } catch (error) {
        console.error("An error occurred:", error);
      }
    }
    

Hiểu và sử dụng đúng cách try-catch và throw sẽ giúp bạn xử lý lỗi một cách hiệu quả, làm cho code của bạn mạnh mẽ và dễ bảo trì hơn.

3. Constructor Functions và Return

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

function Car() {
  this.make = "Lamborghini";
  return { make: "Maserati" };
}

const myCar = new Car();
console.log(myCar.make);
  • A: "Lamborghini"
  • B: "Maserati"
  • C: ReferenceError
  • D: TypeError
Đá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é ❓️

3.1. Constructor Functions trong JavaScript

Constructor functions là một cách để tạo ra các đối tượng trong JavaScript. Khi chúng ta sử dụng từ khóa new trước một hàm, JavaScript sẽ xem hàm đó như một constructor function và tạo ra một đối tượng mới.

Thông thường, một constructor function sẽ thực hiện các bước sau:

  1. Tạo ra một đối tượng mới.
  2. Gán this trong hàm tới đối tượng mới được tạo.
  3. Thực thi code trong hàm.
  4. Trả về đối tượng mới (trừ khi có một câu lệnh return rõ ràng trả về một đối tượng khác).

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

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

function Car() {
  this.make = "Lamborghini";
  return { make: "Maserati" };
}

Hàm Car này có hai điểm đặc biệt:

  1. Nó gán this.make = "Lamborghini".
  2. Nó có một câu lệnh return rõ ràng trả về một đối tượng khác { make: "Maserati" }.

3.3. Quy tắc đặc biệt với return trong Constructor Functions

Khi một constructor function trả về một đối tượng rõ ràng, đối tượng đó sẽ được ưu tiên thay vì đối tượng mới được tạo ra bởi new. Điều này có nghĩa là:

  • Nếu return một giá trị nguyên thủy (như số, chuỗi, boolean), nó sẽ bị bỏ qua và đối tượng mới vẫn được trả về.
  • Nếu return một đối tượng, đối tượng đó sẽ được trả về thay vì đối tượng mới.

3.4. Kết quả

Trong trường hợp này, mặc dù chúng ta đã gán this.make = "Lamborghini", nhưng vì hàm Car trả về một đối tượng khác { make: "Maserati" }, đối tượng này sẽ được ưu tiên.

Vì vậy, khi chúng ta tạo myCar bằng new Car(), myCar sẽ là đối tượng { make: "Maserati" }.

Khi chúng ta log myCar.make, kết quả sẽ là "Maserati".

3.5. Ví dụ minh họa

Để hiểu rõ hơn, hãy xem xét ví dụ sau:

function House() {
  this.color = "Red";
  return "Blue house";
}

function Apartment() {
  this.floors = 5;
  return { floors: 10 };
}

const myHouse = new House();
const myApartment = new Apartment();

console.log(myHouse.color);    // "Red"
console.log(myApartment.floors);  // 10

Trong ví dụ này:

  • House trả về một giá trị nguyên thủy (chuỗi), nên nó bị bỏ qua và myHouse vẫn có thuộc tính color là "Red".
  • Apartment trả về một đối tượng, nên myApartment sẽ là đối tượng được trả về, có thuộc tính floors là 10.

3.6. Lưu ý quan trọng

Việc trả về một đối tượng khác từ constructor function không phải là một best practice. Nó có thể gây nhầm lẫn và khó debug. Thông thường, chúng ta nên để constructor function tạo và trả về đối tượng mới một cách tự nhiên, không can thiệp vào quá trình này bằng return.

4. Biến toàn cục và từ khóa let

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

(() => {
  let x = (y = 10);
})();

console.log(typeof x);
console.log(typeof y);
  • A: "undefined", "number"
  • B: "number", "number"
  • C: "object", "number"
  • D: "number", "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é ❓️

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

Đoạn code này có vẻ đơn giản, nhưng nó chứa một số điểm thú vị về cách JavaScript xử lý biến và phạm vi (scope). Hãy chia nhỏ nó ra:

(() => {
  let x = (y = 10);
})();

Đây là một Immediately Invoked Function Expression (IIFE). Nó được thực thi ngay lập tức sau khi được định nghĩa.

4.2. Gán giá trị và khai báo biến

Dòng let x = (y = 10); thực sự là một cách viết tắt của:

y = 10;
let x = y;

Điều này có nghĩa là:

  1. y được gán giá trị 10.
  2. x được khai báo với từ khóa let và được gán giá trị của y.

4.3. Phạm vi của biến

  • x được khai báo với let, nên nó chỉ tồn tại trong phạm vi của IIFE.
  • y không được khai báo với bất kỳ từ khóa nào (var, let, hoặc const), nên nó trở thành một biến toàn cục. Nó sẽ tương đương với window.y trong trình duyệt hoặc global.y trong Node.js.

4.4. Kết quả

Khi chúng ta gọi console.log(typeof x);:

  • x không tồn tại bên ngoài IIFE, nên typeof x sẽ trả về "undefined".

Khi chúng ta gọi console.log(typeof y);:

  • y là một biến toàn cục với giá trị 10, nên typeof y sẽ trả về "number".

4.5. Ví dụ minh họa

Để hiểu rõ hơn về cách JavaScript xử lý biến trong trường hợp này, hãy xem xét ví dụ sau:

(() => {
  let a = (b = 5);
  console.log(a); // 5
  console.log(b); // 5
})();

console.log(typeof a); // "undefined"
console.log(typeof b); // "number"

Trong ví dụ này:

  • a chỉ tồn tại trong IIFE.
  • b trở thành biến toàn cục.

4.6. Lưu ý quan trọng

Việc tạo ra biến toàn cục một cách không chủ ý như trong trường hợp của y có thể dẫn đến các lỗi khó phát hiện và gây ô nhiễm không gian tên toàn cục. Đây là một trong những lý do tại sao "use strict" mode được khuyến nghị sử dụng trong JavaScript hiện đại.

Trong strict mode, đoạn code trên sẽ gây ra lỗi vì y không được khai báo trước khi sử dụng:

"use strict";
(() => {
  let x = (y = 10); // Throws ReferenceError: y is not defined
})();

Để tránh những vấn đề như vậy, luôn khai báo biến của bạn với let, const, hoặc var (trong trường hợp cần thiết) trước khi sử dụng.

5. Xóa thuộc tính của đối tượng

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

class Dog {
  constructor(name) {
    this.name = name;
  }
}

Dog.prototype.bark = function() {
  console.log(`Woof I am ${this.name}`);
};

const pet = new Dog("Mara");

pet.bark();

delete Dog.prototype.bark;

pet.bark();
  • A: "Woof I am Mara", TypeError
  • B: "Woof I am Mara","Woof I am Mara"
  • C: "Woof I am Mara", undefined
  • D: TypeError, TypeError
Đá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é ❓️

5.1. Prototype trong JavaScript

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

Mỗi đối tượng trong JavaScript đều có một thuộc tính đặc biệt gọi là prototype. Khi chúng ta tạo một phương thức cho prototype của một lớp, tất cả các thể hiện (instances) của lớp đó đều có thể truy cập phương thức này.

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

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

class Dog {
  constructor(name) {
    this.name = name;
  }
}

Dog.prototype.bark = function() {
  console.log(`Woof I am ${this.name}`);
};

const pet = new Dog("Mara");

pet.bark();

delete Dog.prototype.bark;

pet.bark();
  1. Chúng ta định nghĩa một lớp Dog với một constructor nhận vào tham số name.

  2. Sau đó, chúng ta thêm một phương thức bark vào prototype của lớp Dog. Điều này có nghĩa là tất cả các instance của Dog sẽ có thể truy cập phương thức này.

  3. Chúng ta tạo một instance pet của lớp Dog với tên "Mara".

  4. Khi gọi pet.bark() lần đầu tiên, nó sẽ thực thi bình thường và in ra "Woof I am Mara".

  5. Sau đó, chúng ta xóa phương thức bark khỏi prototype của Dog bằng cách sử dụng delete Dog.prototype.bark.

  6. Khi chúng ta cố gắng gọi pet.bark() lần thứ hai, JavaScript sẽ không tìm thấy phương thức này trong prototype chain và sẽ throw ra một TypeError.

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

Khi chúng ta xóa một phương thức từ prototype, tất cả các instance của lớp đó sẽ mất quyền truy cập vào phương thức đó. Điều này xảy ra vì JavaScript sử dụng cơ chế prototype chain để tìm kiếm các phương thức và thuộc tính.

Khi bạn gọi một phương thức trên một object, JavaScript sẽ:

  1. Tìm kiếm phương thức đó trực tiếp trên object.
  2. Nếu không tìm thấy, nó sẽ tìm kiếm trong prototype của object đó.
  3. Nếu vẫn không tìm thấy, nó sẽ tiếp tục tìm kiếm trong prototype của prototype, và cứ thế tiếp tục cho đến khi đạt đến cuối chuỗi prototype.

Trong trường hợp này, sau khi xóa bark từ Dog.prototype, không còn nơi nào trong chuỗi prototype chứa phương thức bark nữa. Do đó, khi cố gắng gọi pet.bark(), JavaScript không thể tìm thấy phương thức này và throw ra TypeError.

5.4. Ví dụ minh họa

Để hiểu rõ hơn về cách prototype chain hoạt động, hãy xem xét ví dụ sau:

function Animal(name) {
  this.name = name;
}

Animal.prototype.makeSound = function() {
  console.log("Some generic animal sound");
};

function Dog(name) {
  Animal.call(this, name);
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
  console.log("Woof!");
};

const myDog = new Dog("Buddy");

myDog.makeSound(); // "Some generic animal sound"
myDog.bark(); // "Woof!"

delete Dog.prototype.bark;
myDog.bark(); // TypeError: myDog.bark is not a function

delete Animal.prototype.makeSound;
myDog.makeSound(); // TypeError: myDog.makeSound is not a function

Trong ví dụ này, chúng ta có thể thấy rõ cách JavaScript tìm kiếm phương thức trong chuỗi prototype, và điều gì xảy ra khi chúng ta xóa các phương thức từ các mức khác nhau trong chuỗi.

5.5. Tóm lại

Việc hiểu rõ về prototype và cách JavaScript tìm kiếm phương thức trong chuỗi prototype là rất quan trọng. Nó không chỉ giúp chúng ta hiểu cách các object và class hoạt động trong JavaScript, mà còn giúp chúng ta tránh được các lỗi không mong muốn khi làm việc với prototype.

Khi xóa một phương thức từ prototype, hãy cẩn thận vì điều này sẽ ảnh hưởng đến tất cả các instance hiện tại và tương lai của class đó. Trong thực tế, việc xóa phương thức từ prototype không phải là một thực hành phổ biến và nên được thực hiện một cách cẩn thận, chỉ khi thực sự cần thiết.

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 như tham trị và tham chiếu, xử lý lỗi với try-catch, cách hoạt động của constructor functions, sự khác biệt giữa arrow functions và regular functions, và cách JavaScript xử lý prototype chain.

Những kiến thức này không chỉ giúp chúng ta hiểu rõ hơn về cách JavaScript hoạt động, mà còn giúp chúng ta viết code hiệu quả hơn và tránh được nhiều lỗi phổ biến.

Hãy nhớ rằng, JavaScript là một ngôn ngữ rất linh hoạt và có nhiều đặc điểm độc đáo. Việc hiểu rõ những đặc điểm này sẽ giúp bạn trở thành một lập trình viên JavaScript giỏi hơn.

Hy vọng rằng bài viết này đã mang lại cho bạn những kiến thức bổ ích. Hãy tiếp tục thực hành và khám phá thêm về JavaScript nhé!

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í