Object-oriented programming in JavaScript. It''s really about object.
Trong JavaScript chúng ta thấy object ở khắp mọi nơi. Và tất nhiên lập trình hướng đối tượng là một mô hình lập trình có trong JavaScript. Tuy nhiên nó không giống như mô hình lập trình hướng đối tượng mà bạn thường thấy trong các ngôn ngữ thông dụng như Java, C++ hay C#. Chúng ta hãy cùng tìm hiểu ...
Trong JavaScript chúng ta thấy object ở khắp mọi nơi. Và tất nhiên lập trình hướng đối tượng là một mô hình lập trình có trong JavaScript. Tuy nhiên nó không giống như mô hình lập trình hướng đối tượng mà bạn thường thấy trong các ngôn ngữ thông dụng như Java, C++ hay C#. Chúng ta hãy cùng tìm hiểu về mô hình lập trình rất thú vị của JavaScript trong bài viết ngắn này.
Class
Trừ các primitive value ra thì mọi thứ trong JavaScript đều là object, kể cả function. Cả class luôn. Nói đúng hơn thì chúng ta không có class trong JavaScript. Mô hình lập trình hướng đối tượng của JavaScript dựa trên prototype chứ không phải class như ta thấy trong C++ hay Java. Như ta đã biết class là khuôn mẫu cho các object và class có thể kế thừa từ class khác. Cũng giống thế, prototype là một khuôn mẫu cho các object và prototype có thể kế thừa từ prototype khác. Prototype tất nhiên cũng là một object. Như tên gọi của nó, prototype là một nguyên mẫu và nó hoạt động bình thường như bao object khác.
Prototype chain
Nhắc đến lập trình hướng đối tượng, ta thường nhắc đến tính kế thừa. JavaScript thực hiện tính kế thừa bằng cách sử dụng prototype. Như đã nói từ trước, prototype cũng là một object, nên nó cũng có thể có prototype luôn. Prototype của nó cũng có thể có prototype nữa. Và nữa nữa. Thế nên nó được gọi là chuỗi prototype. Ở cuối cùng của chuỗi prototype đó là một prototype của mọi Object - Object.prototype.
Khi bạn cần truy cập một property của một object, đầu tiên nó sẽ kiểm tra trong trong các property của object đó, nếu không có thì sẽ tìm tiếp trong prototype của nó. Vì prototype cũng là một object nên điều tương tự cũng xảy ra với prototype. Nó sẽ tìm property đó trong prototype, nếu không thấy thì lại tìm tiếp trong prototype của prototype. Cho đến tận khi không còn prototype nào nữa, tức là đã đến cuối của chuỗi prototype hay còn gọi là Object.prototype. Nhờ có chuỗi prototype mà một object có thể kế thừa các thuộc tính từ prototype của nó, và của prototype của prototype của nó...
Constructor function
Mô hình OOP của JavaScript dựa trên prototype. Để tạo một object và sử dụng tính kế thừa, đương nhiên trước hết phải tạo một prototype. Rồi từ prototype đó bằng cách nào đó tạo ra một object (copy các thuộc tính sang chẳng hạn). Logic phải không. Kệ logic, tôi thích làm cho nó giống Java cơ. Dù sao một nửa cái tên JavaScript cũng là Java cơ mà. Đó là những gì JavaScript nghĩ. Tệ thật đấy. Cơ mà cũng dễ hiểu và logic vì cái "The good parts" của JavaScript cũng chỉ bằng chưa đến 1/5 so với toàn bộ những gì nó có ε-(-_-`o). Chúng ta sẽ nhắc đến cái good part đó sau.
Trong phần này chúng ta sẽ dùng từ class thay cho prototype, vì dù sao đó cũng là cái mà JavaScript (và chúng ta) đang cố làm trong phần này mà.
Giống như các ngôn ngữ lập trình khác, để tạo một object thuộc một class, ta dùng đến một function đặc biệt gọi là constructor function. Trong function này ta có thể gán các thuộc tính cho object sẽ được tạo ra. Còn prototype của object sẽ được gán là...thuộc tính prototype của constructor function ε-(-_-`o).
Để tạo một object của một class, bạn cũng dùng từ khóa new.
Dưới đây là ví dụ về một constructor function
let Person = function (name) { this.name = name; }; let john = new Person('John'); // {"name": "John"} let smith = new Person('Smith'); // {"name": "Smith"}
Khi được gọi với từ khóa new, function sẽ được coi như một constructor function và sẽ trả về một object với prototype là thuộc tính prototype của function.
Để gán thuộc tính cho prototype bạn làm thế này
Person.prototype.say = function (something) { console.log(something); } john.say('something') // something
Lưu ý là thuộc tính prototype không phải là prototype của object mà là một thuộc tính có tên là prototype của function. Nó có trong prototype của class Function và được dùng đến khi function được dùng như một constructor function. Prototype của object là một thuộc tính tên là __proto__.
this is not what I want
this là gì trong JavaScript vẫn luôn là một câu hỏi không dễ gì trả lời chính xác. Bạn cũng thấy trong constructor function, từ khóa this được sử dụng theo cách khá quen thuộc như trong các ngôn ngữ lập trình hướng đối tượng khác. Khi một function được gọi với từ khóa new, this sẽ là đối tượng sắp được tạo ra. Kết quả mong đợi cũng giống như trong các ngôn ngữ khác.
Tuy nhiên, constructor function cũng là một function bình thường và có thể được gọi mà không cần từ khóa new. Khi đó this sẽ là window (hay module trong Node). Thay vì nhận được một object mới, bạn sẽ nhận được undefined và vài cái global variable.
Vì vậy để đảm bảo an toàn nếu không may quên mất từ khóa new, ta cần làm thế này.
let Person = function (name) { if (!(this instanceof Persion)) { return new Persone(name); } this.name = name; }; let johnSmith = Person('John Smith'); // {"name": "John Smith"}
Dù vậy cách này vẫn không an toàn tuyệt đối. instanceof thực ra chỉ kiểm tra nếu prototype của object cũng là thuộc tính prototype của constructor function. Nếu bạn nỡ khai báo function theo kiểu arrow function của ES6 thì thuộc tính prototype của nó sẽ là undefined.
Ngoài ra, tuy không liên quan đến trường hợp này nhưng nếu bạn đã thay đổi prototype của object thì instanceof cũng không cho kết quả chính xác nữa. Nói chung là instanceof không phải là cách kiểm tra chắc ăn một object có phải là một instance của class hay không.
Inheritance
Để kế thừa từ một class, ta cần tạo ra một chuỗi prototype. Hay nói cách khác là gán prototype cho prototype của object. Prototype cũng là một object, vậy để tạo ra một object với prototype, ta lại dùng đến constructor function. Ta sẽ làm thế này
let DrunkPerson = function(name) { this.name = `Drunk ${name}`; } DrunkPerson.prototype = new Person(); let drunkJohn = new DrunkPerson('John'); // {"name": "Drunk John"}
Bạn có thể override (property shadowing) property của parent prototype
DrunkPerson.prototype.say = function (something) { console.log('so..em...sh..i.ng'); } drunkJohn.say('something'); // so..em...sh..i.ng
ES6 class, the (not) real class
Chắc bạn cũng cảm thấy cách viết class của JavaScript thật là ngu quá đi. Không chỉ mình bạn mà mọi người đều cảm thấy thế. Vì vậy, trong suốt bao nhiêu năm qua, biết bao nhiêu người đã bỏ công sức để viết ra các thư viện để giúp cho việc sử dụng class thuận tiện. Nhiều đến nỗi cuối cùng, người ta phải đưa ra một cách viết class tử tế hơn và nó đã xuất hiện trong ES6 ヽ(^◇^*)/yay!
Bạn có thể viết nó giống như vẫn thấy trong các ngôn ngữ khác.
class Person { constructor(name) { super(); this.name = name; } function say(something) { console.log(something); } }
Kế thừa cũng giống luôn
class DrunkPerson extends Person { constructor(name) { super(`Drunk ${name}`); } function say(something) { console.log('so..em...sh..i.ng'); } }
Và không còn phải lo quên mất new nữa
let john = Person('John'); // Uncaught TypeError: Class constructor Person cannot be invoked without 'new'
Vậy là cuối cùng chúng ta cũng có class giống như Java (*^。^*). Tất nhiên là không. Mô hình OOP của JavaScript vẫn dựa trên prototype. Bạn vẫn thấy __proto__ trong mỗi object tạo ra. Tính năng class được thêm vào chỉ giúp cho việc viết lách thuận tiện hơn thôi. Những gì nó thực sự giúp giải quyết có lẽ chỉ là cái đống thư viện dùng để tạo class kia thôi.
Tua lại phần trước, bạn còn nhớ chúng ta đã nói rằng cách tạo object của JavaScript chả ăn nhập gì với mô hình prototype-based của nó cả. À, cái class này thậm chí còn tệ hơn. Mình chẳng thấy có chữ prototype nào luôn.
Không phải có mỗi mình thấy như thế. Cái đống drama xung quanh ES6 class đã bắt đầu từ trước khi specification cuối cùng của ES6 được release nữa kia. Nó đã hoàn toàn giấu đi bản chất prototype-based của JavaScript. Và vì thế nhận không ít gạch đá. Kể cũng tiện, người ta muốn có concrete class, giờ thì có tha hồ gạch đá luôn, thỏa ý nguyện (*^。^*).
Factory function
Chúng ta đang nói đến Object-oriented programming. Vậy tất nhiên là cần phải tạo ra object. Dù bằng cách nào đi chăng nữa.
Cách để tạo object? Tất nhiên là Object literal rồi
let john = { name: 'John', say: (something) => { console.log(something); } }
Chẳng cần biết class là gì luôn. Trong JavaScript bạn có thể tạo một object chỉ với mấy dòng đơn giản như trên. Và tất nhiên mọi function đều có thể trả về object nên ai cần đến constructor function để tạo object chứ. Các function trả về object như thế có thể gọi là Factory function. Vì nó không phải là constructor function nên tất nhiên cũng chẳng bao giờ phải lo quên mất new nữa. Nó thậm chí còn chẳng có this nên cũng chẳng cần bận tâm "what is this" luôn.
Thế còn prototype thì sao. Lúc trước chúng ta đã nói rằng cách tạo một object từ một prototype không hợp lý lắm. Douglas Crockford tất nhiên cũng thấy điều này. Thế nên nó đã được thêm vào từ ES5 (trước cả cái class dở hơi kia). Đó là Object.create(). Nó dùng để tạo một object với prototype là một object khác. Nghe giống với cái logic mà chúng ta đã nhắc đến từ trước lắm đúng không.
Chúng ta có thể viết một factory function thế này
let personProto = { say: (something) => { console.log(something); } } let function makePerson(name) { let person = Object.create(personProto); person.name = name; return person; } let john = makePerson('John');
Như bạn thấy prototype được sử dụng là một object hoàn toàn bình thường.
Còn inheritance thì sao. Cũng tương tự, tạo một object từ prototype để làm prototype cho một object khác. Nhưng hãy khoan nhắc đến nó đã. Mà khỏi nhắc đến luôn đi. Vì bạn chẳng có lí do gì phải dùng đến nó cả. Mọi thứ bạn làm được với inheritance thì đều có thể làm được với một cái khác hay ho hơn nhiều.
Composition
ES6 ngoài mang đến class, kèm theo đống drama xung quanh nó, thì còn đem đến một function khác cho prototype của Object - Object.assign(). Thật ra nó cũng không mới lắm vì từ trước đó mọi người đã dùng chán chê hàm extend của lodash hay mấy cái thư viện tương tự rồi. Trong JavaScript, mọi thứ đều có thể bị thay đổi dễ dàng vì mọi thứ đều là object mà thuộc tính của object thì có thể được assign chỉ với 1 câu đơn giản. Vì vậy việc sử dụng Composition cho JavaScript là vô cùng dễ dàng. Bạn có lẽ đã nghe đến nó khá thường xuyên dưới cái tên mixin. Nó giống như trò chơi xếp hình vậy. Bạn lắp ghép các object nhỏ hơn thành một object lớn hơn.
Hãy nhớ đến các ví dụ trước, chúng ta có class Person và một class kế thừa từ nó, DrunkPerson. Thử tưởng tượng bạn cần thêm một class Bussinessman. Kế thừa từ Person phải không, easy. Đến một ngày khác, bạn lại cần có class DrunkBussinessman. Now what?
Khi sử dụng kế thừa, bạn luôn phải dự đoán trước tương lai bạn sẽ cần những gì để lập một cây kế thừa trước khi bắt đầu viết. Và gần như chắc chắn nếu cứ tiếp tục mở rộng thì sẽ có một ngày bạn gặp tình huống như trên và không thể duy trì SOLID hay DRY được nữa. Ngoài ra class con luôn gắn chặt với parent class, cũng sẽ khiến bạn đau đầu khi muốn chỉnh sửa parent class mà không làm cho tất cả class con break hết cả unit test. Tất nhiên nếu bạn vẫn có thể viết một hệ thống tốt với inheritance. Không phải nhiều framework, hay cả các hệ điều hành phổ biến đều dùng các ngôn ngữ OOP với inheritance đó sao. Nhưng nếu bạn có thể dễ dàng dùng Composition thì tại sao lại đi đâm vào cái chỗ đau đầu đấy nhỉ. Vậy nên Composition FTW!.
Hãy thử ví dụ trên với Composition
let drunk = { say: (something) => console.log('so..em...sh..i.ng') } let bussinessman = { wearVest: () => this.clothes = 'vest' } let johnTheDrunkBussinessman = Object.assign({name: 'John'}, drunk, bussinessman);
Conclusion
Bạn không cần đến class. Chúng ta có thể cùng nhau tạo ra object từ các object. Và không cần thừa kế từ thằng nào cả, cứ chơi xếp hình với object là tuyệt nhất. Tóm lại là mọi thứ đều xoay quanh object. Đúng chất Object-oriented luôn.