Học bằng cách đọc theo thứ tự

Phương thức đặc biệt — toString, toJSON và Symbol.iterator

toString, toJSON và Symbol.iterator là các phương thức JavaScript tự gọi. Bài viết gồm cách quyết định dạng hiển thị, chọn giá trị đưa vào JSON và duyệt class bằng for...of.

Đưa một object đơn hàng vào template literal thì hiện ra [object Object], dùng JSON.stringify thì mất tổng tiền tính bằng getter, còn đưa vào for...of thì báo lỗi TypeError.

Bài này nói về 3 phương thức đặc biệt (phương thức mà JavaScript tìm theo tên và tự gọi trong những tình huống nhất định): phương thức được gọi khi hiển thị, khi chuyển sang JSON và khi lặp.

Quyết định cách hiển thị khi đưa vào chuỗi — toString

Giả sử bạn đưa đơn hàng vào một thông báo bằng cách viết Đã nhận: ${order}. Kết quả hiện ra không phải mã đơn hay số tiền mà là [object Object]. Nếu màn hình nào cũng tự ghép lại từ order.idorder.total, đoạn code ghép chuỗi giống nhau sẽ xuất hiện khắp nơi.

toString (phương thức mà JavaScript gọi khi cần biến một giá trị thành chuỗi, và dùng giá trị trả về làm chuỗi đó) được gọi bởi ${}, String()join của mảng. Nếu class không định nghĩa, toString mặc định ở Object.prototype, nằm xa hơn trên chuỗi prototype, sẽ trả về [object Object].

// Class không có toString
class DraftOrder {
  constructor(id, total) { this.id = id; this.total = total; }
}

// Class có toString
class Order {
  constructor(id, total) { this.id = id; this.total = total; }
  toString() { return `Đơn ${this.id} (${this.total} yên)`; }
}
const draft = new DraftOrder("A-101", 3200);
const order = new Order("A-102", 4800);

// Đưa vào template literal thì toString được gọi
console.log(`Đã nhận: ${draft}`);        // Đã nhận: [object Object]
console.log(`Đã nhận: ${order}`);        // Đã nhận: Đơn A-102 (4800 yên)

// String() và join của mảng cũng dùng cùng toString đó
console.log(String(order));               // Đơn A-102 (4800 yên)
console.log([order, new Order("A-103", 1500)].join(" / "));   // Đơn A-102 (4800 yên) / Đơn A-103 (1500 yên)
toString mà ${} gọi được tìm thấy ở đâu
Đã nhận: ${draft}DraftOrder.prototypekhông có toStringTìm thấy ởObject.prototypeĐã nhận:[object Object]Đã nhận: ${order}Tìm thấy ởOrder.prototypeGọi toStringcủa OrderĐã nhận: ĐơnA-102 (4800 yên)
Cả hai instance đều không tự có toString, nên JavaScript tìm dọc theo chuỗi prototype. Nếu class có định nghĩa, nó được tìm thấy trước Object.prototype.

Ở cả hai hàng, phía gọi đều là ${}. Muốn đổi dạng hiển thị, bạn chỉ cần sửa toString của class ở một chỗ, và mọi dòng nhúng object đó đều thay đổi theo. Khi truyền thẳng instance như console.log(order), toString không được gọi.

Bạn sẽ hiển thị các chuyến xe buýt sắp chạy trên bảng thông tin trước nhà ga. BusDeparture và departures đã được khai báo sẵn.

① Hãy viết phương thức trả về chuỗi theo dạng “Tuyến 42 [Bến 2] đi Bệnh viện Thành phố”.

② Hãy đưa departures[0] vào ${} và in ra sau “Sắp khởi hành: ”.

③ Hãy nối thẳng departures bằng “ / ” rồi in kết quả.

④ Hãy đổi bến của chuyến thứ hai thành 3, rồi đưa chuyến đó vào ${} và in ra.

(Nếu chạy đúng, phần giải thích sẽ xuất hiện.)

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Đưa tổng tiền vào JSON cần lưu — toJSON

Để gửi đơn hàng lên server, bạn chuyển nó thành chuỗi bằng JSON.stringify(order). Với class giữ các dòng hàng trong #lines và tính tổng bằng getter total, cả hai đều không phải property thường của instance, nên dữ liệu gửi đi chỉ là {"id":"A-102"}.

Nếu viết toJSON (phương thức mà JSON.stringify gọi trước khi chuyển một giá trị, rồi chuyển giá trị trả về của nó thay cho giá trị gốc), bạn có thể trả về một object chứa đúng những giá trị muốn gửi. Date trở thành chuỗi ngày giờ trong JSON cũng là vì Date có phương thức này.

class Order {
  #lines;                                        // Các dòng hàng (không đọc được từ bên ngoài)
  constructor(id, lines) { this.id = id; this.#lines = lines; }
  get total() { return this.#lines.reduce((sum, line) => sum + line.price * line.quantity, 0); }

  // Được gọi khi JSON.stringify, giá trị trả về mới là thứ được chuyển thành chuỗi
  toJSON() { return { id: this.id, itemCount: this.#lines.length, total: this.total }; }
}
const order = new Order("A-102", [{ price: 1200, quantity: 2 }, { price: 2400, quantity: 1 }]);
const other = new Order("A-103", [{ price: 1500, quantity: 1 }]);

// Key riêng của instance chỉ có id
console.log(Object.keys(order).join(", "));   // id

// Giá trị trả về của toJSON được chuyển thành chuỗi
console.log(JSON.stringify(order));           // {"id":"A-102","itemCount":2,"total":4800}

// Khi nằm trong mảng, toJSON được gọi cho từng phần tử
console.log(JSON.stringify([order, other]));
// [{"id":"A-102","itemCount":2,"total":4800},{"id":"A-103","itemCount":1,"total":1500}]
Kết quả thay đổi theo toJSON và mảng
Truyền vàoJSON.stringifyorderkhông có toJSONordercó toJSON[order, other](có toJSON)Chỉ đọc id,key duy nhấtĐọc giá trịtoJSON() trả vềGọi toJSON()cho từng phần tử{"id":"A-102"}{"id":"A-102",…"total":4800}[{…"total":4800},{…"total":1500}]
Không có toJSON thì chỉ đọc được id, key duy nhất của instance. Có toJSON thì giá trị trả về của nó được chuyển thành chuỗi, và nó còn được gọi cho từng phần tử của mảng.

#lines vẫn không đọc được từ bên ngoài; chính class quyết định giá trị nào được đưa vào JSON. Ngay cả giá trị mà instance không giữ dưới dạng property, như itemCount, cũng xuất hiện trong JSON nếu bạn thêm nó vào object trả về. Khi dùng JSON.parse để đọc ngược lại, bạn nhận được một object thường có dạng này, chứ không phải instance của Order.

toJSON trả về chuỗi thì bị chuyển hai lần

Nếu trả về một chuỗi như return JSON.stringify({ id: this.id, total: this.total });, chuỗi đó sẽ bị chuyển sang JSON thêm một lần nữa. Dù đã JSON.parse một lần, kết quả vẫn là chuỗi, và .totalundefined. Hãy trả về một object.

Bạn sẽ bỏ số thẻ ra khỏi dữ liệu thành viên gửi lên server dưới dạng JSON. Member, member và sentAt đã được khai báo sẵn.

① Hãy viết phương thức chỉ trả về mã thành viên và tên khi object được chuyển sang JSON.

② Hãy chuyển thẳng member thành chuỗi JSON rồi in ra.

③ Hãy gộp sentAt và member vào một object, chuyển thành chuỗi JSON rồi in ra.

④ Hãy in xem chuỗi ở ③ có chứa số thẻ hay không.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Duyệt các dòng hàng bằng for...of — Symbol.iterator

Tiếp theo, bạn muốn hiển thị lần lượt từng dòng hàng của đơn lên màn hình. Các dòng hàng được giấu trong #lines nên không đọc được từ bên ngoài, và viết for (const line of order) sẽ báo lỗi TypeError: order is not iterable (không lặp được). Kể cả khi giữ các dòng hàng trong một mảng public, bạn vẫn không lặp trực tiếp trên instance được.

Nếu bạn định nghĩa một phương thức có tên là Symbol.iterator (một giá trị có sẵn, không trùng với bất kỳ key nào khác, dùng làm tên của phương thức mà for...of gọi), for...of sẽ nhận từng giá trị một bằng cách gọi next() trên object mà phương thức đó trả về.

Trong class, generator function* tên() được viết là *tên(). Khi viết tên trong ngoặc vuông như [Symbol.iterator], giá trị trong ngoặc sẽ được dùng làm tên phương thức.

class Order {
  #lines;                                        // Các dòng hàng (không đọc được từ bên ngoài)
  constructor(id, lines) { this.id = id; this.#lines = lines; }
  // Phương thức mà for...of gọi. Mỗi dòng hàng được yield sẽ vào biến của vòng lặp
  *[Symbol.iterator]() {
    for (const line of this.#lines) yield line;
  }
}
const order = new Order("A-102", [{ name: "Cà phê phin", quantity: 2 }, { name: "Cốc sứ", quantity: 1 }]);

// Nhận từng dòng hàng một từ bên ngoài class
for (const line of order) {
  console.log(`${line.name} × ${line.quantity}`);      // 2 dòng: Cà phê phin × 2 / Cốc sứ × 1
}

// Mỗi lần spread lại gọi phương thức, nên lần thứ hai cũng được 2 phần tử
console.log([...order].length, [...order].length);   // 2 2
Mỗi lần viết là một generator mới
for (const lineof order)[Symbol.iterator]()→ in 2 dòng[...order]lần 1Gọi lại lần nữanên có 2 phần tử[...order]lần 2Gọi lại lần nữanên vẫn có 2
Cả ba cách viết đều gọi [Symbol.iterator]() ngay tại dòng đó. Lần spread thứ hai cũng bắt đầu lại từ đầu và lấy được cả 2 phần tử.

Generator đã đọc hết thì trở nên rỗng, như bài về iterator và generator đã nói. Phía gọi chỉ nhận được những dòng hàng được yield, không bao giờ nhận chính mảng #lines, nên không thể thêm hay xóa dòng hàng từ bên ngoài.

Trả về nguyên mảng thì for...of báo lỗi

Nếu trả về mảng như return this.#lines;, mảng không có next(), nên for...of báo lỗi TypeError: undefined is not a function. Mảng lặp được (iterable), nhưng bản thân nó không phải iterator. Hãy thêm *yield từng phần tử.

Bạn sẽ làm cho giỏ hàng lặp được mà không để lộ mảng bên trong. Cart và cart đã được khai báo sẵn.

① Hãy làm cho for...of nhận được từng sản phẩm một.

② Hãy truyền thẳng cart vào for...of và in những sản phẩm có số lượng từ 1 trở lên theo dạng “tên × số lượng”.

③ Hãy trải thẳng cart thành mảng và in tổng tiền theo dạng “Tổng: ... yên”.

④ Hãy dùng destructuring trên cart để lấy sản phẩm đầu tiên và in theo dạng “Đầu tiên: tên”.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Tìm phương thức bị thiếu từ kết quả — tổng hợp 3 phương thức

Khi dạng hiển thị hay JSON không ra như mong đợi, những dòng như ${order} hay JSON.stringify(order) không hề ghi tên phương thức mà chúng gọi. Muốn biết mình quên viết phương thức nào, bạn phải lần ngược từ cách gọi và dạng của kết quả.

Ngay cả với instance của class không có phương thức nào trong 3 phương thức trên, mỗi cách gọi vẫn tìm một cái tên cố định của riêng nó dọc theo chuỗi prototype. Chỉ có toString mặc định ở Object.prototype là được tìm thấy, còn toJSON[Symbol.iterator] thì tìm tới tận null cũng không thấy.

// Class không có toString, toJSON hay [Symbol.iterator]
class DraftOrder {
  #lines;
  constructor(id, lines) { this.id = id; this.#lines = lines; }
  get total() { return this.#lines.reduce((sum, line) => sum + line.price * line.quantity, 0); }
}
const draft = new DraftOrder("A-101", [{ name: "Cà phê phin", price: 1200, quantity: 2 }]);

// toString được tìm thấy ở Object.prototype; không có toJSON nên chỉ liệt kê key riêng
console.log(`${draft}`);              // [object Object]
console.log(JSON.stringify(draft));   // {"id":"A-101"}

// [Symbol.iterator] không có ở đâu cả, nên báo lỗi
try {
  console.log([...draft]);
} catch (error) {
  console.log(error.message);         // draft is not iterable
}
Kết quả của draft không có phương thức nào
${draft}JSON.stringify(draft)[...draft]Tìm toStringTìm toJSONTìm[Symbol.iterator]Tìm thấy ởObject.prototypeTìm tới nullvẫn không thấyTìm tới nullvẫn không thấy[object Object]{"id":"A-101"}(không có total)TypeError: draftis not iterable
Ba cách viết tìm ba cái tên khác nhau dọc theo chuỗi prototype. Chỉ [Symbol.iterator] báo lỗi, hai cách còn lại trả về kết quả mặc định.

JSON.stringify không báo lỗi mà trả về {"id":"A-101"}, nên dữ liệu thiếu các dòng hàng trong #linestotal vẫn được gửi đi, và chỉ đến khi phía nhận xử lý mới có người phát hiện. Bảng dưới đây liệt kê những cách gọi khác cũng tìm cùng các tên này, và kết quả khi thiếu phương thức.

Cách gọiPhương thức được tìmKết quả khi thiếu
JSON.stringify([draft])toJSON (từng phần tử)[{"id":"A-101"}]
for (const line of draft)[Symbol.iterator]TypeError: draft is not iterable
const [firstLine] = draft[Symbol.iterator]TypeError: draft is not iterable
QUIZ

Kiểm tra kiến thức

Hãy trả lời từng câu hỏi một.

Câu 1draft là instance của class không có phương thức nào trong 3 phương thức trên. Chạy const [firstLine] = draft; thì điều gì xảy ra?

Câu 2Có một mảng gồm 2 đơn hàng, mỗi đơn có toJSON trả về { id: this.id }. Dùng JSON.stringify cho mảng này thì kết quả thế nào?

Câu 3Với cart*[Symbol.iterator](), nếu viết [...cart] hai lần thì mảng thứ hai dài bao nhiêu?