Khám phá thừa kế nguyên mẫu (Prototypal Inheritance) trong JavaScript qua ví dụ thực tế
Thừa kế nguyên mẫu (prototypal inheritance) thường bị xem là một khái niệm phức tạp trong JavaScript. Tuy nhiên, bài viết này sẽ giúp bạn hiểu rõ khái niệm này một cách dễ dàng thông qua các ví dụ thực tế và dễ hiểu.
Giới thiệu về khái niệm thừa kế
Hãy tưởng tượng rằng tôi là cha mẹ và tôi có một đứa con và một đứa cháu. Nếu bạn biểu diễn cây phả hệ của tôi trong một sơ đồ, nó sẽ trông như thế này:
Bạn có thể thấy rằng grandparent nằm ở đầu cây phả hệ này, trong khi the parent là hậu duệ trực tiếp của the grandparent, và the child là hậu duệ của parent.
Nếu bạn cố gắng đi ngược lên trên, bạn sẽ thấy rằng đó grandchildl à phần tử con của parent và phần tử cha của nó là phần tử child của grandparent.
Đối tượng trong JavaScript là gì?
Bạn có thể đã từng nghe qua câu nói này: "Trong JavaScript, gần như mọi thứ đều là một Đối tượng".
Bạn có để ý cách tôi viết hoa chữ "Đối tượng" không? Khi tôi sử dụng "Đối tượng" và "đối tượng" trong suốt bài viết này, chúng sẽ mang ý nghĩa khác nhau.
"Đối tượng" là một hàm tạo (constructor) được sử dụng để tạo ra các "đối tượng". Nói cách khác, một là cha mẹ/tổ tiên và cái còn lại là con cái.
Sử dụng hình minh họa trong hình ảnh ở phía trên, chúng ta hãy thử chứng minh cách cây gia đình hoạt động trong JavaScript.
"Đối tượng" là ông bà.
Các hàm tạo như "Mảng" (Array), "Chuỗi" (String), "Số" (Number), "Hàm" (Function) và "Boolean" đều là con cháu của "Đối tượng".
Tất cả chúng đều tạo ra các kiểu dữ liệu khác nhau: "mảng", "chuỗi", "số", "hàm" và "boolean". Tuy nhiên, nếu bạn truy ngược lại nguồn gốc của chúng, chúng đều là "Đối tượng".
Vì vậy, nếu bạn được hỏi tại sao mọi thứ (ngoại trừ "null" và "undefined") đều được coi là đối tượng trong JavaScript, thì đó là vì chúng đều là con cháu của hàm tạo "Đối tượng".
Các hàm tạo được liệt kê trong hình ảnh ở trên chịu trách nhiệm cho các kiểu dữ liệu khác nhau mà bạn sử dụng trong JavaScript. Tức là, chúng được sử dụng ngầm để tạo ra các kiểu dữ liệu quen thuộc (và bạn cũng có thể sử dụng chúng để tạo ra các giá trị của các kiểu khác nhau một cách rõ ràng).
Bây giờ chúng ta hãy thử một số đoạn mã nhé.
Cách tạo một đối tượng
// Using the regular object syntax
const newObj = {}
// Using the object constructor
const newObjWithConstructor = new Object()
Cách tạo một mảng
// Using the regular array syntax
const newArr = []
// Using the array constructor
const newArrWithConstructor = new Array()
Cách tạo một số
// Using the regular syntax
const num = 3
// Using the number constructor
const numWithConstructor = new Number(3)
Cách tạo một hàm
// Using regular function syntax
function logArg (arg) {
console.log(arg)
}
// Using the Function constructor
const logArgWithConstructor = new Function('arg1', 'console.log(arg1)')
Cách tạo một boolean
// Using the regular boolean syntax
const isValid = true
// Using the Boolean constructor
const isValidWithConstructor = new Boolean(true)
Bạn có thể thấy từ các đoạn mã ví dụ ở trên, có thể tạo các giá trị một cách rõ ràng bằng cách sử dụng hàm tạo thích hợp, hoặc chỉ bằng cách sử dụng cú pháp đơn giản và cho phép JavaScript tự động tạo giá trị với kiểu thích hợp cho chúng ta.
Lưu ý: Điều quan trọng cần lưu ý là mỗi phương thức tạo giá trị đều có trường hợp sử dụng và tác dụng phụ riêng, nhưng chúng ta sẽ không đi sâu vào vấn đề đó trong bài viết này.
Các hàm tạo của những giá trị khác nhau này đều có một thứ gọi là nguyên mẫu (prototype).
Nguyên mẫu của một đối tượng là gì?
Trong JavaScript, có một thứ gọi là "nguyên mẫu". Khái niệm gần gũi nhất với điều này là DNA của con người.
Giống như DNA đóng vai trò là bản thiết kế xác định các đặc điểm được truyền qua nhiều thế hệ của cây gia đình con người, "nguyên mẫu" trong JavaScript được sử dụng để xác định các thuộc tính và phương thức được kế thừa bởi các đối tượng trong cây Đối tượng JavaScript.
Hãy kết hợp Hình 1 và Hình 2 ở phần đầu của bài viết, cập nhật nó ngay bây giờ để phù hợp với khái niệm về DNA và nguyên mẫu.
Trong JavaScript, tất cả các hàm tạo đều có một nguyên mẫu. Nguyên mẫu của một hàm tạo là một từ điển chứa mọi thứ mà các giá trị được tạo bằng hàm tạo đó nên kế thừa.
Hãy coi hàm tạo giống như cha mẹ và nguyên mẫu giống như DNA. Khi hàm tạo (cha mẹ) tạo ra (sinh ra) một đứa con (giá trị), đứa con sẽ kế thừa từ DNA (nguyên mẫu) của cha mẹ nó là hàm tạo.
Chúng ta sẽ xem xét ở một sơ đồ khác:
Từ hình trên, bạn có thể thấy rằng một đứa trẻ được thừa hưởng trực tiếp từ cha mẹ của chúng và cha mẹ của chúng được thừa hưởng các đặc điểm từ ông bà. Trong chuỗi di truyền này, đứa trẻ thực sự được thừa hưởng từ cả ông bà và cha mẹ.
Trên thực tế, các đặc điểm của đứa trẻ bị ảnh hưởng mạnh mẽ bởi sự kết hợp DNA của mọi tổ tiên trước nó.
Đây là cách thức hoạt động của thừa kế nguyên mẫu trong JavaScript.
Các thuộc tính trong nguyên mẫu của một hàm tạo được kế thừa bởi các con được tạo bởi hàm tạo đó. Điều này tiếp tục diễn ra trong chuỗi. Bạn có thể hiểu nó như sau:
Mọi con cháu trong chuỗi kế thừa đều kế thừa mọi thứ có sẵn trong nguyên mẫu của tổ tiên của nó.
Từ sơ đồ trên, bạn có thể thấy rằng tất cả các nguyên mẫu khác đều kế thừa từ nguyên mẫu Object. Do đó, bất kỳ giá trị nào được tạo bằng hàm tạo Array (ví dụ), sẽ kế thừa từ nguyên mẫu Array và cả nguyên mẫu Object.
Điều này là do nguyên mẫu Array kế thừa từ nguyên mẫu Object.
Thuật ngữ nguyên mẫu Array được viết là "Array.prototype" trong JavaScript, trong khi nguyên mẫu Object là "Object.prototype".
Lưu ý: Điều quan trọng cần lưu ý là khái niệm về DNA rất phức tạp, vì vậy nếu mở rộng ra, chúng ta sẽ nhanh chóng phát hiện ra rằng có một số sắc thái và sự khác biệt giữa cách thức hoạt động của DNA và nguyên mẫu, nhưng ở cấp độ cao, chúng rất giống nhau.
Do đó, việc hiểu về sự di truyền trong cây gia đình con người sẽ giúp chúng ta hiểu rõ về thừa kế nguyên mẫu trong JavaScript.
Cách làm việc với ".prototype" của một Constructor
Để xem nội dung nguyên mẫu của một hàm tạo (Constructor), chúng ta chỉ cần viết "theConstructorName.prototype". Ví dụ: "Array.prototype", "Object.prototype", "String.prototype" và v.v.
Bạn đã bao giờ tưởng tượng làm thế nào có thể viết "[2, 8, 10].map(...)" chưa? Điều này là do, trong nguyên mẫu của hàm tạo Array, có một khóa có tên là "map". Vì vậy, mặc dù bạn không tự tạo ra thuộc tính "map", nhưng nó đã được mảng giá trị kế thừa vì giá trị đó được tạo bởi hàm tạo "Array" một cách ngầm.
Hãy đọc câu trên như thế này: Bạn đã bao giờ tự hỏi tại sao bạn lại có nhóm máu cụ thể của mình chưa? Đó là bởi vì bạn nhận được nhóm máu của mình từ các gen mà bạn được thừa hưởng từ cha mẹ của bạn!
Vì vậy, lần sau khi bạn sử dụng các thuộc tính và phương thức như ".length", ".map", ".reduce", ".valueOf", ".find", ".hasOwnProperty" trên một giá trị, chỉ cần nhớ rằng tất cả chúng đều được kế thừa từ nguyên mẫu của hàm tạo hoặc một số nguyên mẫu nào đó trong chuỗi nguyên mẫu (tổ tiên).
Bạn có thể coi nguyên mẫu hàm tạo là nguyên mẫu của thực thể được sử dụng để tạo/xây dựng/tạo ra một giá trị.
Bạn nên biết rằng ".prototype" của mọi hàm tạo đều là một đối tượng. Bản thân hàm tạo là một hàm, nhưng nguyên mẫu của nó là một đối tượng.
console.log(typeof Array) // function
console.log(typeof Array.prototype) // object
Lưu ý: Ngoại lệ cho điều này là nguyên mẫu của hàm tạo Function. Nó là một đối tượng hàm, nhưng nó vẫn có các thuộc tính được gắn với nó và các thuộc tính đó có thể được truy cập giống như cách chúng ta làm với các đối tượng thông thường (sử dụng ký hiệu ".").
Nếu bạn còn nhớ, chúng ta có thể thêm các thuộc tính mới và truy xuất giá trị của các thuộc tính đã tồn tại của đối tượng bằng cách sử dụng ký hiệu dấu chấm ".". Ví dụ: "objectName.propertyName"
const user = {
name: "asoluka_tee",
stack: ["Python", "JavaScript", "Node.js", "React", "MongoDB"],
twitter_url: "https://twitter.com/asoluka_tee"
}
// Using the syntax objectName.propertyName, to access the name key we'll write; user.name
const userName = user.name;
console.log(userName) // asoluka_tee
// To add a new property to the object we'd write;
user.eyeColor = "black"
// If we log the user object to the console now, we should see eyeColor as part of the object properties with the value of 'black'
Bạn đã bao giờ nghe nói đến đột biến DNA chưa? Đó là ý tưởng về việc thay đổi DNA của một người. Trong JavaScript, điều này có thể thực hiện được với các nguyên mẫu.
Giống như đột biến DNA là một việc cực kỳ nguy hiểm để thử và kết quả có thể không chắc chắn hoặc gây ra các tác dụng phụ không mong muốn, việc thay đổi nguyên mẫu của một hàm tạo không phải là một ý tưởng hay trừ khi bạn biết mình đang làm gì.
Cách thay đổi nguyên mẫu của một hàm tạo
Trong JavaScript, có thể thay đổi đối tượng nguyên mẫu của một hàm tạo giống như cách bạn làm với một đối tượng JavaScript thông thường (như được hiển thị ở trên).
Lần này, chúng ta chỉ cần làm theo cú pháp này "constructorName.prototype.newPropertyName = value". Ví dụ: nếu bạn muốn thêm một thuộc tính mới có tên là "currentDate" vào đối tượng nguyên mẫu của hàm tạo Array, bạn sẽ viết:
//constructorName.prototype.newPropertyName
Array.prototype.currentDate = new Date().toDateString();
Từ bây giờ, trong mã của bạn, vì "currentDate" hiện có trong nguyên mẫu của hàm tạo "Array" "(Array.prototype)", nên mọi mảng được tạo trong chương trình của chúng ta đều có thể truy cập nó như thế này: "[1, 2, 3].currentDate" và kết quả sẽ là ngày hôm nay.
Nếu bạn muốn mọi đối tượng trong chương trình JavaScript của mình đều có quyền truy cập vào "currentDate", bạn phải thêm nó vào đối tượng nguyên mẫu của hàm tạo "Object" "(Object.prototype)":
//constructorName.prototype.newPropertyName
Object.prototype.currentDate = new Date().toDateString();
const newArr = [1, 2, 3]
const newObj = {}
const newBool = true
// NB: The date shown is the date of writing this article
console.log(newArr.currentDate) // 'Fri May 10 2024'
console.log(newObj.currentDate) // 'Fri May 10 2024'
console.log(newBool.currentDate) // 'Fri May 10 2024'
Điều này có thể thực hiện được vì đối tượng nguyên mẫu của tất cả các hàm tạo đều kế thừa từ đối tượng nguyên mẫu của hàm tạo "Object".
Hãy viết phiên bản của chúng ta về hai phương thức mảng phổ biến và sử dụng chúng giống như cách chúng ta đã sử dụng bản gốc.
- Array.prototype.reduce: Chúng ta sẽ gọi phương thức của chúng ta là ".reduceV2"
// Add our new function to the prototype object of the Array constructor
Array.prototype.reduceV2 = function (reducer, initialValue) {
let accum = initialValue;
for (let i = 0; i < this.length; i++) {
accum = reducer(accum, this[i]);
}
return accum;
};
// Create an array of scores
let scores = [10, 20, 30, 40, 50];
// Use our own version of Array.prototype.reduce to sum the values of the array
const result = scores.reduceV2(function reducer(accum, curr) {
return accum + curr;
}, 0);
// Log the result to the console
console.log(result);
Trọng tâm ở đây không phải là giải thích toàn bộ cú pháp, mà là cho bạn thấy rằng bằng cách tận dụng chuỗi nguyên mẫu, bạn có thể tạo các phương thức của riêng mình và sử dụng chúng giống như những phương thức mà JavaScript cung cấp.
Lưu ý rằng bạn chỉ cần thay thế ".reduceV2" của chúng ta bằng ".reduce" ban đầu và nó sẽ vẫn hoạt động (các trường hợp đặc biệt không được xử lý ở đây).
- Array.prototype.map: Chúng ta sẽ gọi phương thức của chúng ta là ".mapV2"
// Add mapV2 method to the prototype object of the Array constructor
Array.prototype.mapV2 = function (func) {
let newArray = [];
this.forEach((item, index) => newArray.push(func(item, index)));
return newArray;
};
// Create an array of scores
const scores = [1, 2, 3, 4, 5];
// Use our mapV2 method to increment every item of the scores array by 2
const scoresTimesTwo = scores.mapV2(function (curr, index) {
return curr * 2;
})
// Log the value of scoresTimesTwo to the console.
console.log(scoresTimesTwo)
Lưu ý: Điều quan trọng cần lưu ý là đây không phải là cách triển khai hoàn hảo cho các phiên bản gốc của phương thức "map" của JavaScript. Nó chỉ là một nỗ lực để cho bạn thấy những gì có thể thực hiện được với đối tượng nguyên mẫu của một hàm tạo.
Trước khi kết thúc bài học này, có một điều nữa tôi cần đề cập; Đó là thuộc tính "proto" của mọi đối tượng.
Thuộc tính proto
"proto" là một bộ đặt và bộ lấy cho thuộc tính [[prototype]] của một đối tượng. Điều này có nghĩa là nó được sử dụng để đặt hoặc lấy nguyên mẫu của một đối tượng (ví dụ: đối tượng mà một đối tượng khác kế thừa từ đó).
Hãy xem xét đoạn mã sau;
const user = {}
const scores = []
user.prototype // undefined
scores.prototype // undefined
Trong đoạn mã trên, chúng ta đã cố gắng truy cập trực tiếp vào đối tượng nguyên mẫu từ các giá trị. Điều này là không thể trong JavaScript.
Điều này là hợp lý vì chỉ các hàm tạo mới có thuộc tính "prototype" được gắn với chúng.
Giống như đột biến DNA có rủi ro, việc can thiệp vào đối tượng nguyên mẫu có thể gây ra hỗn loạn nếu bạn không biết chính xác mình đang làm gì.
Trong các trường hợp bình thường, một đứa trẻ không nên cố gắng thay đổi DNA của tổ tiên của nó hoặc thậm chí là xác định xem nên thừa hưởng đặc điểm từ ai.
Tuy nhiên, ngôn ngữ JavaScript cung cấp cho chúng ta một cách để truy cập đối tượng nguyên mẫu từ các giá trị không phải là hàm tạo bằng cách sử dụng thuộc tính "proto".
Đây là một phương pháp không dùng nữa và không nên được sử dụng cho các dự án mới. Tôi đang đề cập đến "proto" vì bạn có thể được tuyển dụng để làm việc trong một cơ sở mã vẫn sử dụng nó.
"proto" cho phép một giá trị truy cập trực tiếp vào đối tượng nguyên mẫu của hàm tạo của nó. Vì vậy, nếu vì bất kỳ lý do gì bạn muốn xem những gì có sẵn trong chuỗi nguyên mẫu của tổ tiên trực tiếp của một giá trị, thuộc tính "proto" có thể được sử dụng cho việc đó.
Bạn cũng có thể sử dụng "proto" để xác định đối tượng mà một giá trị nên kế thừa từ đó.
Ví dụ: chúng ta có một đối tượng có tên là "human" và chúng ta muốn một đối tượng khác có tên là "parent" kế thừa từ "human", điều
này có thể được thực hiện với thuộc tính "proto" của "parent" như sau;
// Create a human object
const human = {
walk: function () { console.log('sleeping') },
talk: function () { console.log('talking') },
sleep: function () { console.log('sleeping') }
}
// Create a parent object and configure it to inherit from human.
const parent = {
__proto__: human
}
// Use a method from the ancestor of parent
parent.sleep() // sleeping
Lưu ý cách chúng ta có thể gọi phương thức "sleep" trên "parent" vì "parent" bây giờ kế thừa từ "human".
Có những phương thức được khuyến nghị hiện đại hơn để sử dụng khi tương tác với đối tượng nguyên mẫu như "Object.getPrototypeOf" và "Object.setPrototypeOf"
const user = {}
const scores = []
// Get the prototype of the user object
console.log(Object.getPrototypeOf(user))
// Change the prototype of the scores array. This is like switching ancestry and should be done with great care.
console.log(Object.setPrototypeOf(scores, {}))
// Check the prototype of scores now
console.log(Object.getPrototypeOf(scores)) // {}
Những phương thức này nên được sử dụng một cách hết sức cẩn thận. Trên thực tế, bạn nên đọc thêm về chúng trong tài liệu MDN JS để biết thêm thông tin về ưu điểm và nhược điểm của chúng.
Nếu bạn đã đọc đến đây, giờ bạn đã biết những kiến thức cơ bản về "Array.prototype" và từ giờ trở đi, việc tìm hiểu về bất kỳ khái niệm nào khác được xây dựng dựa trên kiến thức này trong JavaScript sẽ dễ hiểu hơn.
Cảm ơn các bạn đã theo dõi.
All rights reserved