Mọi đối tượng đều có prototype
JavaScript là ngôn ngữ dựa trên prototype. Nghe có vẻ lạ, nhưng ý tưởng rất đơn giản: mọi đối tượng đều có một liên kết bí mật tới một đối tượng khác, gọi là prototype của nó, và khi bạn hỏi một thuộc tính mà đối tượng không có, JavaScript lần theo liên kết đó và hỏi tiếp ở đấy.
rabbit không có thuộc tính eats của riêng nó. JavaScript kiểm tra rabbit, không thấy, lần theo liên kết prototype tới animal, tìm thấy eats: true và trả về. Với flies, nó đi hết chuỗi, không tìm thấy gì và trả về undefined.
Hành vi tra cứu rồi đi ngược lên này là toàn bộ cơ chế. Kế thừa, phương thức, class: tất cả đều được xây dựng trên nó.
Chuỗi prototype
Chuỗi không dừng sau một bước. Một prototype có thể có prototype của riêng nó, cứ thế cho tới khi gặp null:
Chạy thử, bạn sẽ thấy rabbit, rồi Object.prototype, rồi null. Đó là lý do rabbit.toString() vẫn chạy dù bạn chưa từng định nghĩa toString: nó nằm trên Object.prototype, đỉnh của gần như mọi chuỗi.
Việc tra cứu thuộc tính đi dọc chuỗi này từ dưới lên. Ngược lại, phép gán luôn ghi vào chính đối tượng, không bao giờ với lên trên. Sự bất đối xứng này rất quan trọng và thường xuyên làm người học bất ngờ.
Hàm khởi tạo và .prototype
Trước khi có class, cách chuẩn để tạo nhiều đối tượng giống nhau là một hàm khởi tạo (constructor function) được gọi bằng new:
Có hai việc xảy ra khi bạn gọi new User("Ada"):
- Một đối tượng mới được tạo ra, với prototype là
User.prototype. Userchạy vớithisgắn vào đối tượng mới đó.
greet không được sao chép vào từng instance. Nó chỉ nằm một chỗ trên User.prototype, và cả ada lẫn boris đều tìm thấy nó khi đi dọc chuỗi của mình. Vì vậy dòng cuối in ra true: đó đúng là cùng một hàm.
prototype và __proto__
Hai cái tên này làm ai cũng nhầm. Chúng liên quan với nhau nhưng không phải một.
User.prototypelà thuộc tính trên hàm khởi tạo. Đó là đối tượng sẽ trở thành prototype của các instance tạo bằngnew User(...).ada.__proto__(hoặcObject.getPrototypeOf(ada)) là liên kết trên instance, trỏ ngược lên prototype của nó.
Trong code mới, hãy ưu tiên Object.getPrototypeOf(obj) thay cho obj.__proto__. __proto__ là accessor cũ được giữ lại để tương thích; hàm kia mới là API chính thức.
Class chỉ là cú pháp rút gọn bên trên cơ chế này
JavaScript hiện đại cho bạn viết class, nhưng bên dưới, bạn vẫn đang làm việc với prototype. Hãy đặt hai phiên bản cạnh nhau để so sánh:
greet nằm trên User.prototype, giống hệt như khi bạn tự viết bằng tay. Từ khóa class chủ yếu cho bạn cú pháp gọn hơn, quy tắc chặt hơn (bắt buộc dùng new) và cách viết extends sạch hơn, nhưng mô hình lúc chạy thì y hệt.
Biết điều này rất có ích khi bạn đọc thông báo lỗi hay gỡ lỗi this. Một lỗi nhắc tới "User.prototype.greet" không phải là cái tên nội bộ kỳ quặc nào: đó chính xác là nơi phương thức nằm.
Kế thừa chỉ là chuỗi dài hơn
extends nối một prototype vào prototype khác. Prototype của lớp cha trở thành prototype của prototype lớp con:
Khi tra rex.eat, JavaScript đi qua rex → Dog.prototype → Animal.prototype, tìm thấy eat ở đó và gọi nó với this vẫn gắn vào rex. extends chỉ làm có vậy: dựng sẵn chuỗi cho bạn.
Tạo đối tượng trực tiếp với một prototype
Bạn hoàn toàn không cần hàm khởi tạo. Object.create(proto) tạo một đối tượng mới với prototype bạn chỉ định:
Không class, không new, không hàm khởi tạo. Hai đối tượng dùng chung một phương thức thông qua một prototype chung. Đây là dạng nguyên thủy nhất của kế thừa prototype; mọi thứ khác đều xây dựng bên trên nó.
hasOwnProperty: thuộc tính riêng và thuộc tính kế thừa
Vì việc tra cứu đi dọc chuỗi, "foo" in obj cũng trả về true với thuộc tính kế thừa. Khi cần phân biệt thuộc tính mà đối tượng thực sự sở hữu, hãy dùng Object.hasOwn (hoặc hasOwnProperty cũ hơn):
name nằm trên instance. greet nằm trên prototype. in tìm thấy cả hai; Object.hasOwn chỉ tìm thấy cái đầu. Điều này quan trọng mỗi khi bạn duyệt bằng for...in hay tuần tự hóa một đối tượng: thường bạn chỉ muốn thuộc tính riêng.
Đừng vá prototype có sẵn
Vì Array.prototype được mọi mảng trong chương trình dùng chung, bạn có thể thêm phương thức vào nó:
// Please don't.
Array.prototype.last = function () {
return this[this.length - 1];
};
[1, 2, 3].last(); // 3
Vấn đề không phải là nó không chạy: nó chạy được. Vấn đề là mọi thư viện, mọi package phụ thuộc, mọi phiên bản JavaScript tương lai giờ đều dùng chung không gian tên đó với bạn. Khi Array.prototype.last một ngày được bổ sung thành phương thức thật với ngữ nghĩa hơi khác, code của bạn (hoặc của người khác) sẽ hỏng theo những cách khó nhận ra. Câu chuyện Array.prototype.flatten / Array.prototype.flat là bài học cảnh báo kinh điển.
Hãy giữ các hàm trợ giúp ở dạng hàm độc lập:
Bớt đi một bề mặt dùng chung có thể gây xung đột.
Mô hình tư duy
Gạt hết mọi thứ khác đi, prototype gói gọn trong ba quy tắc:
- Mọi đối tượng đều có một liên kết prototype (có thể là
null). - Đọc thuộc tính thì đi ngược lên chuỗi; ghi thì không.
class,newvàextendslà những cách dựng các chuỗi đó mà không cần tự gõObject.create.
Nhớ ba điều này, bạn sẽ thấy hành vi của this, instanceof, cách tìm phương thức và kế thừa đều trở nên rõ ràng.
Tiếp theo: event loop
Prototype khép lại phần mô hình đối tượng. Chương tiếp theo chuyển sang một chủ đề hoàn toàn khác: JavaScript thực sự chạy code của bạn theo thời gian ra sao. Event loop là thứ khiến bộ hẹn giờ, Promise và async/await hoạt động như vậy, và là nền móng cho mọi thứ bất đồng bộ.
Câu hỏi thường gặp
Prototype trong JavaScript là gì?
Mọi đối tượng JavaScript đều có một liên kết nội bộ tới một đối tượng khác gọi là prototype của nó. Khi bạn truy cập một thuộc tính không có sẵn trên chính đối tượng, JavaScript đi ngược lên theo liên kết đó, tức chuỗi prototype, để tìm. Chuỗi này giúp các phương thức chỉ định nghĩa một lần mà dùng chung được cho nhiều instance.
__proto__ và prototype khác nhau thế nào?
prototype là thuộc tính trên hàm khởi tạo (và trên lớp). Đó là đối tượng sẽ trở thành prototype của các instance tạo bằng new. __proto__ (hoặc Object.getPrototypeOf(obj)) là liên kết thực sự trên một instance, trỏ tới prototype của nó. Vì vậy instance.__proto__ === Constructor.prototype.
Class trong JavaScript có phải chỉ là cú pháp rút gọn cho prototype?
Phần lớn là vậy. class Foo { bar() {} } đặt bar lên Foo.prototype, y như khi bạn viết function Foo(){} và Foo.prototype.bar = function(){}. Lớp bổ sung private field, ngữ nghĩa chặt hơn và cú pháp gọn hơn cho extends và super, nhưng cơ chế bên dưới vẫn là prototype.
Có nên thêm phương thức vào prototype có sẵn như Array.prototype?
Gần như không bao giờ. Sửa Array.prototype hay Object.prototype ảnh hưởng tới mọi mảng hay đối tượng trong chương trình, kể cả của thư viện. Nó có thể xung đột với tính năng ngôn ngữ thêm vào sau này và làm hỏng vòng lặp for...in. Hãy để các hàm trợ giúp riêng trong hàm hoặc module của chúng.