Trong bài viết này, chúng ta sẽ quay lại với chủ đề Object & Everything
để tìm hiểu chi tiết hơn về object
.
Trích đoạn bài viết [JavaScript] Bài 4 - Object & Everything:
Một trong những chiều kích quan trọng nhất của trí thông minh mà con người chúng ta được ban tặng, đó là
intellect
- tạm dịch là trí tuệ nhị nguyên. Vớiintellect
thì mọi thứ xung quanh cuộc sống của chúng ta dường như có thể được tách rời riêng biệt và có thể được định nghĩa với một đường viền bao quanh. Dường như bất cứ thứ gì cũng có thể được định nghĩa bởi một vài thuộc tính và khả năng. Ví dụ như một cái cây có thể được xem là một đối tượng hayobject
độc lập với các thuộc tính như: chiều cao, màu sắc, tuổi tác; và khả năng tạo ra thế hệ tiếp theo.Để phản ánh chiều kích này của trí thông minh mà chúng ta sở hữu vào trong môi trường lập trình, những lập trình viên đầu tiên của thế giới đã quyết định cho phép mô tả các đối tượng hay
object
trong code. Điều này khiến cho công việc lập trình trở nên thân thiện hơn và đem đến cho mọi người nhiều khả năng hơn để chuyển tải các ý tưởng vào phần mềm.
Vậy là chúng ta đã biết khái niệm object
xuất hiện từ cuộc sống thực tế và được đem vào không gian lập trình. Do đó các biến được đóng gói bên trong một object
thường được gọi với một cái tên khác là thuộc tính property
, từ này thân thiện hơn và gần gũi hơn với cuộc sống của chúng ta vì khái niệm Biến variable
về cơ bản là vay mượn của toán học. Bên cạnh đó thì các hàm được đóng gói bên trong một object
cũng thường được gọi với một cái tên khác là phương thức method
- tức là cách thức thực hiện một hành động của object
đó.
Do ở thời điểm ban đầu, việc duy trì mọi thứ đơn giản là rất quan trọng để chúng ta có thể tập trung tốt hơn vào việc tìm hiểu logic hoạt động của các công cụ; Chúng ta đã quy ước là giữ nguyên các tên gọi Biến và Hàm. Tuy nhiên, điều này cũng sẽ không phù hợp nữa khi chúng ta mở rộng hiểu biết của mình về object
và class
. Vậy kể từ thời điểm này, hãy cùng sử dụng những cái tên mới là thuộc tính property
và phương thức method
.
Một class có thể được mở rộng
Lần này, vì đã biết object
và class
là cái gì rồi, chúng ta sẽ xuất phát với code định nghĩa của class Thing
trong bài viết lần trước.
class Thing {
constructor(givenColor, givenAge) {
this.color = givenColor;
this.age = givenAge;
}
whisper() {
console.log(this.age + ' years ago...');
console.log(this.color + '...');
}
} // class
Chúng ta đã tạo ra một class Thing
để mô tả chung chung cho mọi thứ xung quanh cuộc sống. Bất kỳ đối tượng object
nào xung quanh chúng ta cũng đều có màu sắc và khoảng thời gian đã tồn tại tính cho đến giờ.
Tuy nhiên bây giờ chúng ta muốn tạo ra một class
mới để mô tả cụ thể hơn một nhóm object
nào đó; Lấy ví dụ là những chiếc laptop đi. Vậy ngoài 2 thuộc tính trên thì có thể chúng ta có quan tâm tới kích thước màn hình hiển thị. Lúc này chúng ta vẫn muốn có các thuộc tính và phương thức của Thing
đã định nghĩa trước đó. Thao tác copy/paste các đoạn code cũng không khó thực hiện, nhưng nếu như chúng ta có 1001 class
muốn sử dụng code của Thing
thì lại là câu chuyện khác.
Thật may mắn là JavaScript và nhiều ngôn ngữ lập trình khác có hỗ trợ tự động hóa thao tác mà chúng ta đang cần thực hiện bằng hình thức có tên gọi là kế thừa inherit
hay mở rộng extends
.
class Laptop
extends Thing {
constructor(givenColor, givenAge, givenScreen) {
super(givenColor, givenAge);
this.screen = givenScreen;
}
} // class
var inspiron = new Laptop('black', 3.5, '14"');
inspiron.whisper();
// '3.5 years ago...'
// 'black...'
Ồ... như vậy là chúng ta không cần phải viết lại code gắn giá trị cho các thuộc tính color
và age
. Và phương thức whisper
vẫn có thể hoạt động khá ổn nhưng vẫn thiếu thuộc tính mới screen
chưa được in ra.
Trong phần code của phương thức khởi tạo constructor
, từ khóa super
dường như được dùng để trỏ về định nghĩa của class
ban đầu là Thing
. Nếu vậy chúng ta sẽ thử dùng nó để tạo ra một phương thức whisper
mới cho Laptop
và tận dụng phương thức whisper
đã định nghĩa ở Thing
.
class Laptop
extends Thing {
constructor(givenColor, givenAge, givenScreen) {
super(givenColor, givenAge);
this.screen = givenScreen;
}
whisper() {
super.whisper();
console.log(this.screen + '...');
}
} // class
var inspiron = new Laptop('black', 3.5, '14"');
inspiron.whisper();
// '3.5 years ago...'
// 'black...'
// '14"...'
Tuyệt vời, mọi thứ đã hoạt động như chúng ta mong muốn. Với tính năng mở rộng extends
này, chúng ta lại có thêm nhiều khả năng hơn để chuyển tải ý tưởng phần mềm của mình thành các dòng code. Tuy nhiên bạn lưu ý là trong JavaScript thì một class con
sẽ chỉ có thể kế thừa từ một class cha
duy nhất.
Làm sao để biết một object có thuộc một class nào đó hay không?
Đây là câu hỏi tiếp theo mà chúng ta đều băn khoăn sau khi biết được tính năng có thể mở rộng extends
của các class
. Bởi vì rồi đây chúng ta sẽ tạo ra rất nhiều các object để lưu chuyển, sử dụng trong một chương trình. Ở một thời điểm nhất định, khi nhận được một object
nào đó từ một hàm đa năng, rất có thể chúng ta sẽ cần thực hiện thao tác kiểm tra để chắc chắn object
nọ thuộc một class
kia.
class Thing {}
class Laptop extends Thing {}
var sky = new Thing();
console.log(sky instanceof Thing);
// true
var inspiron = new Laptop();
console.log(inspiron instanceof Thing);
// true
Bởi vì Laptop
đã thực hiện hành động mở rộng Thing
, do đó object inspiron
cũng thuộc class Thing
. Và đó là cách mà JavaScript suy nghĩ.
class Thing {}
var sky = new Thing();
console.log(sky instanceof Object);
// true
Ồ... vậy là Thing
đã extends
một class có tên là Object
. Nhưng chúng ta đâu có viết ra điều đó ở trong code ví dụ? Thật kỳ lạ.
Sự thật là tất cả các class trong JavaScript sẽ ngầm định kế thừa từ class Object
; Và hiển nhiên sẽ có các thuộc tính và phương thức được định nghĩa bởi class Object
được lập tài liệu ở đây - Tài liệu về class Object của MDN. Như vậy là chúng ta còn có thêm một thư viện các phương thức để làm việc với các object
trong trang tài liệu mới này.
Trong trường hợp muốn biết chính xác tên của class
đã được sử dụng để tạo ra một object
nào đó, chúng ta có thể truy xuất tới thuộc tính constructor
rồi đi tiếp tới thuộc tính name
của constructor
.
class Thing {}
var sky = new Thing();
var className = sky.constructor.name;
console.log(className);
// 'Thing'
Các thuộc tính và phương thức được ẩn khỏi thế giới bên ngoài
Đôi khi chúng ta sẽ muốn tạo ra những thuộc tính hay những phương thức chỉ được sử dụng bên trong code nội bộ của một class. Phiên bản hiện tại của JavaScript cho phép chúng ta tạo ra các thuộc tính và các phương thức như vậy bằng cách mở đầu tên thuộc tính hoặc tên phương thức với dấu #
.
class Thing {
#privateProperty;
constructor(givenColor, givenAge) {
this.color = givenColor;
this.age = givenAge;
this.#privateProperty = 'hidden';
}
whisper() {
console.log(this.age + ' years ago...');
console.log(this.color + '...');
console.log(this.#privateProperty + '...');
}
} // class
var sky = new Thing('blue', 1001);
console.log( sky.#privateProperty );
// console thông báo lỗi
// trường thông tin riêng `#privateProperty` được định nghĩa đóng kín
Và chúng ta đã thấy là thuộc tính #privateProperty
không thể được truy xuất từ phần code ở bên ngoài định nghĩa class
. Tuy nhiên phương thức whisper
thì có thể sử dụng thuộc tính này bình thường.
/* ... */
var sky = new Thing('blue', 1001);
sky.whisper();
// '1001 years ago...'
// 'blue...'
// 'hidden...'
Bây giờ thì chúng ta có thể chắc chắn rằng các thuộc tính và phương thức ẩn của một class
sẽ không thể được sử dụng bởi code ở bên ngoài class
đó. Và thậm chí cả các object
được tạo ra bởi các class con
- ví dụ như Laptop
ở trên - cũng sẽ không thể truy xuất và sử dụng thuộc tính riêng của class cha
- ví dụ như #privateProperty
của Thing
.
Các thuộc tính và phương thức của object bản mẫu static
Đôi khi chúng ta sẽ muốn tạo ra một thư viện các thuộc tính và các phương thức tiện ích để làm việc xoay quanh một class
giống như cách mà JavaScript đã cung cấp các công cụ tiện ích để làm việc xoay quanh các kiểu dữ liệu mặc định. Ví dụ như khi chúng ta muốn tách ra một giá trị số nguyên từ một chuỗi, class Number
có cung cấp một phương thức là Number.parseInt()
.
var ten = Number.parseInt('10.01');
console.log(ten);
// 10
Ở đây chúng ta thấy là phương thức parseInt
được tham chiếu từ object bản mẫu Number
thay vì một object thực thể new Number()
. Để tạo ra các thuộc tính và phương thức gắn với object bản mẫu như vậy, chúng ta cần sử dụng thêm từ khóa static
ở phía trước tên của các thuộc tính và phương thức.
class Thing {
/* --- Dành cho các object thực thể */
#privateProperty;
constructor(givenColor, givenAge) {
this.color = givenColor;
this.age = givenAge;
this.#privateProperty = 'hidden';
}
whisper() {
console.log(this.age + ' years ago...');
console.log(this.color + '...');
console.log(this.#privateProperty + '...');
}
/* --- Dành cho object bản mẫu `Thing` */
static staticProperty;
static {
this.staticProperty = 'static';
}
static staticWhisper() {
console.log(this.staticProperty + '...');
}
} // class
Thing.staticWhisper();
// 'static...'
Để khởi tạo giá trị cho các thuộc tính static
, chúng ta có hàm khởi tạo không dùng từ khóa constructor
nhưng vẫn cần từ khóa static
để gắn với object bản mẫu Thing
. Ngay khi một thành phần static
bất kỳ của Thing
được truy vấn để sử dụng, hàm khởi tạo static
sẽ được khởi chạy trước để thực hiện các thiết lập ban đầu cho các thuộc tính static
hoặc một thao tác nào đó mà bạn cần thực hiện.
Thêm vào đó thì các thuộc tính và phương thức static
cũng có thể được ẩn khỏi không gian code bên ngoài bằng cách mở đầu tên gọi với ký hiệu #
.
Bạn có thấy điều gì hơi kỳ lạ khi chúng ta gặp mặt thêm các phương thức static
không? Con trỏ this
lúc này đã tự động trỏ về object bản mẫu Thing
, chứ không giống như ở các phương thức thông thường.
Con trỏ this
hoạt động như thế nào?
Chúng ta hãy quay trở lại với code định nghĩa Thing
ban đầu để quan sát mọi thứ đơn giản và dễ tìm hiểu vấn đề này hơn.
class Thing {
constructor(givenColor, givenAge) {
this.color = givenColor;
this.age = givenAge;
}
whisper() {
console.log(this.age + ' years ago...');
console.log(this.color + '...');
}
} // class
var sky = new Thing('blue', 1001);
sky.whisper();
// '1001 years ago...'
// 'blue...'
var grass = new Thing('green', 10);
grass.whisper();
// '10 years ago...'
// 'green...'
Lúc này chúng ta đang hiểu đơn giản là - từ khóa this
là con trỏ được sử dụng để tham chiếu tới chính bản thân object
thực thể đang thực hiện hành động whisper()
.
Khi hàm whisper
được khởi chạy bởi sky
, con trỏ this
được sử dụng để tham chiếu tới chính object sky
đang thực hiện hành động, và tương tự với trường hợp của grass
. Vậy chúng ta có thể nghĩ là - mỗi object
hình như sẽ có một con trỏ this
riêng để trỏ tới chính bản thân object
đó, và sử dụng cho các phương thức được chứa bên trong object
đó.
Nhưng bây giờ chúng ta cũng biết rằng về cơ bản phương thức whisper
là một hàm, vậy nó cũng là một object
. Nếu như chúng ta lưu địa chỉ tham chiếu của whisper
vào một biến khác rồi thực hiện chạy hàm, có lẽ kết quả hoạt động của code sẽ không thay đổi?
var sky = new Thing('blue', 1001);
var skyWhisper = sky.whisper;
skyWhisper();
// console thông báo lỗi
// không thể đọc được thuộc tính `age` tại định nghĩa hàm `whisper`
Thật kỳ lạ, chúng ta đâu có thao tác thay đổi điều gì. Tất cả những gì chúng ta vừa làm là sao chép địa chỉ tham chiếu của phương thức whisper
vào biến skyWhisper
, và sau đó thực hiện gọi hàm.
À... có một khả năng - nếu như con trỏ this
trong phần khai báo hàm whisper
được gắn với object sky
ngay từ khi object
này được tạo ra, thì hiển nhiên lời gọi hàm skyWhisper()
sẽ phải hoạt động bình thường chứ không thể có lỗi phát sinh được.
Nếu vậy, có lẽ phép xử lý được biểu thị bằng dấu chấm .
, ngoài việc giúp chúng ta truy xuất tới phương thức whisper
khi thực hiện lệnh sky.whisper()
, đã kiêm thêm công việc kết nối con trỏ this
mà phương thức whisper
đang sử dụng với object sky
đứng phía trước. Hay nói một cách khác, con trỏ this
trong phần khai báo phương thức whisper
chỉ được gắn tạm thời với object sky
tại thời điểm khởi chạy với dấu .
Vậy rất có khả năng là chúng ta có thể định nghĩa hàm whisper
rời ở bên ngoài class Thing
và tìm được cách gọi hàm như thế nào đó để có kết quả hoạt động tương tự.
class Thing {
constructor(givenColor, givenAge) {
this.color = givenColor;
this.age = givenAge;
}
} // Thing
var sky = new Thing('blue', 1001);
const whisper = function() {
console.log(this.age + ' years ago...');
console.log(this.color + '...');
};
whisper.apply(sky);
// '1001 years ago...'
// 'blue...'
Như chúng ta đã biết thì hàm whisper
về cơ bản cũng là một object
và có chứa một số thuộc tính và phương thức bên trong nó. Trong code ví dụ ở trên, phương thức apply
được sử dụng để phát động hàm whisper
thay vì sử dụng cách viết trực tiếp whisper()
; Và sky
được truyền vào phương thức apply
để được gắn tạm thời với con trỏ this
trong định nghĩa của hàm whisper
.
Tuy nhiên khi khai báo hàm whisper
, nếu như chúng ta không sử dụng từ khóa function
mà thay vào đó là sử dụng cú pháp =>
thì kết quả hoạt động lại không được như vậy. Hãy sửa lại code của hàm whisper
một chút, chúng ta sẽ thử với cú pháp =>
và thêm thao tác in con trỏ this
ra console
.
class Thing {
constructor(givenColor, givenAge) {
this.color = givenColor;
this.age = givenAge;
}
} // Thing
const whisper = () => {
console.log(this.age + ' years ago...');
console.log(this.color + '...');
console.log(this);
};
var sky = new Thing('blue', 1001);
whisper.apply(sky);
// 'undefined years ago...'
// 'undefined...'
// object `window`
Thì ra là vậy, khi tạo ra hàm whisper
bằng cú pháp =>
con trỏ this
dường như được gắn cố định ở thời điểm được tạo ra và không thể thay đổi. Vậy đây chính là một điểm khác biệt giữa từ khóa function
và cú pháp =>
mà chúng ta đã để dành ở bài trước.
Vậy chúng ta cùng tổng kết 2 lưu ý quan trọng này nhé:
- Con trỏ
this
hay chủ thể hoạt động của một hàmcó thể
được thay đổi linh động tại thời điểm gọi hàm. Tuy nhiên điều đókhông đúng
với các hàm được tạo ra bằng cú pháp=>
. - Bên cạnh đó thì hàm được tạo ra bằng cú pháp
=>
sẽ có chủ thể hoạt độngthis
được kế thừa của môi trường đang bao quanh phần code định nghĩa và cố định ngay tại thời điểm hàm được tạo ra.
Tới đây thì chúng ta cũng hiểu rằng - Các phương thức được khai báo bên trong định nghĩa class
sẽ được lưu một bản ở đâu đó và sử dụng chung cho các object
thực thể được tạo ra sau này. Và khi các phương thức được gọi với dấu .
đứng trước thì con trỏ this
mới được tạm gắn với object
thực thể đang là chủ thể thực hiện hành động. Vậy thì các object
cũng không cồng kềnh lắm nhỉ?
Phân tách các thuộc tính từ một object
JavaScript có cung cấp một cú pháp giúp chúng ta nhanh chóng tách lấy các thuộc tính mà chúng ta cần sử dụng từ một object
và gán vào các biến ở bên ngoài, thay vì phải viết lại nhiều lần tên biến tham chiếu tới object
đó mỗi khi cần sử dụng tới các thuộc tính.
var sky = {
color: 'blue',
age: 1001,
size: 'unbound'
};
// phân tách các thuộc tính của `sky`
// vào các biến có tên tương ứng
var { color, age, size } = sky;
console.log(color); // 'blue'
console.log(age); // 1001
console.log(size); // 'unbound'
Trong cú pháp này thì dòng khai báo các biến var
được viết với một cặp ngoặc xoắn {}
để mô tả rằng - chúng ta muốn gán giá trị của các thuộc tính tương ứng trong một object
ở phía bên phải phép gán =
. Bạn có cảm thấy cú pháp này rất ngắn gọn và xúc tích không?
Bên cạnh đó, chúng ta cũng có một cú pháp hỗ trợ nhanh chóng sao chép các thuộc tính của một object
bất kỳ vào một object
được khởi tạo trực tiếp không thông qua class
, với sự hỗ trợ của phép dàn trải spread operator
được ký hiệu bởi dấu 3 chấm ...
.
var sky = {
color: 'blue',
age: 1001,
size: 'unbound'
};
// phân tách các thuộc tính của `sky`
// và đặt vào `heaven` với các tên thuộc tính tương ứng
var heaven = {
...sky,
universe: 'tabha'
};
console.log(heaven);
// {color: 'blue', age: 1001, size: 'unbound', universe: 'tabha'}
Tuy nhiên, chúng ta cũng cần lưu ý rằng, về cơ bản thì cú pháp này chỉ là cách viết gọn cho thao tác truy xuất tới các tên thuộc tính, do đó các thuộc tính ẩn #private
(nếu có) sẽ không được sao chép sang object
mới.
Trong trường hợp object
mới khởi tạo trực tiếp có một số thuộc tính trùng lặp tên với object
cũ, thì thuộc tính nào được viết sau sẽ ghi đè thuộc tính được viết trước.
var sky = {
color: 'blue',
age: 1001,
size: 'unbound'
};
var heaven = {
...sky,
color: 'dodgerblue', // ghi đè thuộc tính `color` lấy từ `sky`
universe: 'tabha'
};
console.log(heaven);
// {color: 'dodgerblue', age: 1001, size: 'unbound', universe: 'tabha'}
Bên cạnh đó, nếu được sử dụng trong thao tác phân tách và gán giá trị vào các biến như ban đầu thì ký hiệu ...
có thể giúp chúng ta gom các thuộc tính còn lại của object
ban đầu thành một object
nhỏ hơn. Lúc này ...
được gọi với một cái tên khác là phép lấy phần thừa rest operator
.
var sky = {
color: 'blue',
age: 1001,
size: 'unbound'
};
var { color, ...others } = sky;
console.log(color); // 'blue'
console.log(others); // { age: 1001, size: 'unbound' }
Các class đệ quy
Vẫn lại là khái niệm đệ quy xuất hiện ở mọi nơi trong cuộc sống của chúng ta. Mình đặt phần này ở cuối bài bởi vì chỉ mang tính chất tham khảo chứ không phải là kiến thức quan trọng ở thời điểm hiện tại. Nếu bạn có thời gian để đọc thêm một chút thì mình có code của một class
đệ quy đơn giản ở đây.
Một class
đệ quy được cho là có thể tạo ra các object
đệ quy, các object
này có một thuộc tính tham chiếu tới một object
khác cũng thuộc chính class
đó.
class Just {
constructor(
value = null,
past = null
) {
this.value = value;
this.past = past;
}
getMemory() {
if (this.past == null) return [this.value];
else return this.past.getMemory().concat(this.value);
}
be(value = null) {
var valueIsPrimitive = Just.checkPrimitive(value);
var valueExisted = this.getMemory().includes(value);
// ---
if ( ! valueIsPrimitive) return this;
if (valueExisted) return this;
else return new Just(value, this);
}
static checkPrimitive(value) {
return (typeof value) != 'object';
}
} // Just
var just = new Just();
just = just.be(0)
.be(1)
.be('word')
.be(true)
.be(false)
.be(1001);
console.log(just.getMemory());
// [null, 0, 1, 'word', true, false, 1001]
Trong ví dụ ở trên, chúng ta có một class
đơn giản tên là Just
tạo ra các object
để bọc các giá trị primitive
trong thuộc tính value
. Khi phương thức just.be
được gọi với một giá trị primitive
, nó sẽ tạo ra một phiên bản mới của just
và lưu phiên bản hiện tại vào thuộc tính past
để làm quá khứ của của phiên bản mới.
Mỗi khi chúng ta cần nhìn lại quá khứ và xem xét tập ký ức memory
của just
hiện tại thì chỉ cần gọi phương thức just.getMemory
. Phương thức này sẽ truy xuất tới các phiên bản cũ của just
theo phương cách đệ quy và lấy ra các giá trị để trả về ở dạng một mảng phẳng lỳ. Về cơ bản thì getMemory
vẫn là một hàm đệ quy
như chúng ta đã biết tới trong bài viết trước thôi.
Một class đệ quy chỉ đơn giản là một cấu trúc để lưu trữ dữ liệu khác với mảng Array phẳng lỳ. Chỉ có vậy thôi.
_Một người đang học lập trình
Bài viết của chúng ta về chủ đề Object & Everything
tới đây là kết thúc. Trong bài tiếp theo, chúng ta sẽ quay trở lại với chủ đề xử lý các sự kiện người dùng trong trình duyệt web, đã được nói tới trong bài JavaScript số 5; Và sau đó chúng ta sẽ cùng xây dựng một thanh điều hướng phụ sidebar
có tính năng lọc nhanh nội dung trong danh sách liên kết khi người dùng nhập từ khóa vào ô truy vấn.
Hẹn gặp lại bạn trong bài viết tiếp theo.