Menu

Prototype trong JavaScript: chuỗi đứng sau mọi đối tượng

Prototype trong JavaScript thực chất là gì, chuỗi prototype tra cứu thuộc tính ra sao, và cú pháp class ánh xạ lên cùng cơ chế bên dưới như thế nào.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

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"):

  1. Một đối tượng mới được tạo ra, với prototype là User.prototype.
  2. User chạy với this gắ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.prototype là 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ằng new User(...).
  • ada.__proto__ (hoặc Object.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, new và extends là 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.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu