Prototype tạo object mới bằng cách sao chép một object có sẵn thay vì dựng lại từ đầu. Bạn cấu hình một template một lần, rồi clone nó mỗi khi cần thêm một cái, và chỉ chỉnh những gì khác biệt. Khuôn đúc là phiên bản ngoài đời: thiết kế khuôn thì tốn kém, nhưng mỗi lần đúc sau đó đều rẻ và giống hệt nhau.
Độ khó: ★★☆☆☆ về ý tưởng, ★★★☆☆ khi bạn gặp bug sao chép. Độ phổ biến: ★★★☆☆. Trong JavaScript bạn dùng nó liên tục mà không gọi tên, mỗi lần viết { ...defaults, ...overrides }.
Vì sao sao chép thay vì khởi tạo?
Trong JavaScript, new rất rẻ, nên lý do dùng Prototype hiếm khi là tốc độ. Lý do thật sự là:
- State đã cấu hình tốn công để tạo ra. Một template phải tốn một query database, một lần merge config hay một phép tính nặng mới dựng được thì đáng để sao chép hơn là dựng lại.
- Bạn không biết class cụ thể. Code nhận vào "một unit nào đó" hay "một template tài liệu nào đó" có thể bảo nó tự
clone()mà không cầnswitchtheo kiểu. - Bạn muốn nhiều biến thể từ một bản gốc. Một template gốc cộng vài chỗ ghi đè dễ bảo trì hơn hai mươi constructor với hai mươi danh sách tham số.
Ví dụ xuyên suốt là một game chiến thuật sinh unit từ template: cung thủ, hiệp sĩ, thầy thuốc, mỗi loại có chỉ số và kỹ năng do game designer tinh chỉnh.
Vấn đề: template tự thay đổi sau lưng bạn
Phiên bản đầu tiên hiển nhiên nhất là sao chép template bằng spread:
type Stats = { hp: number; attack: number; defense: number };
type UnitData = { name: string; stats: Stats; skills: string[] };
const archerTemplate: UnitData = {
name: "Archer",
stats: { hp: 80, attack: 12, defense: 4 },
skills: ["volley"],
};
const eliteArcher = { ...archerTemplate, name: "Elite archer" };
eliteArcher.stats.attack += 5;
eliteArcher.skills.push("piercing-shot");
console.log(archerTemplate.stats.attack, archerTemplate.skills);
// 17 [ "volley", "piercing-shot" ]
Cung thủ tinh nhuệ thì ổn. Template thì hỏng rồi: mọi cung thủ được sinh ra từ đây về sau đều có 17 attack và một kỹ năng lẽ ra không có. Spread và Object.assign chỉ sao chép tầng trên cùng. stats và skills là reference, nên "bản sao" dùng chung chúng với bản gốc.
Đây là điều quan trọng nhất cần biết về Prototype trong JavaScript, và bug này rất phổ biến trong code thật: object config mặc định bị một request sửa, state Redux được "sao chép" bằng spread rồi bị sửa, test fixture rò rỉ giữa các test. Trên server nó còn tệ hơn trong game, vì template sống trong một process chạy lâu dài: dữ liệu hỏng tồn tại cho tới lần deploy kế tiếp và ảnh hưởng mọi user, và nó thường phụ thuộc vào request nào tình cờ đến trước, nên không tái hiện được ở máy local.
Deep copy với structuredClone
structuredClone, có sẵn toàn cục trong Node.js 17+ và mọi trình duyệt hiện đại, tạo ra một bản deep copy thật sự:
const knightTemplate: UnitData = {
name: "Knight",
stats: { hp: 150, attack: 10, defense: 12 },
skills: ["shield-wall"],
};
const captain = structuredClone(knightTemplate);
captain.name = "Knight captain";
captain.stats.hp += 50;
captain.skills.push("rally");
console.log(knightTemplate.stats.hp, knightTemplate.skills); // 150 [ "shield-wall" ]
Nó cũng xử lý được những thứ mà cách đi vòng qua JSON làm sai: Date, Map, Set, typed array và circular reference đều giữ nguyên. Nhưng nó có những giới hạn bạn cần biết:
- Instance của class trở về thành object thường. Method và prototype bị mất.
- Function không clone được và ném ra
DataCloneError. - Accessor biến thành dữ liệu thường. Getter của chính object được gọi một lần và chỉ giá trị hiện tại được sao chép, property chỉ có setter trở thành
undefined, còn field#privatevà getter định nghĩa trên class thì bị bỏ.
Nó còn có chi phí. Trong một micro-benchmark nhanh trên laptop của mình, clone unit bốn field ở trên mất khoảng 2 micro giây với structuredClone, ít hơn chút với JSON.parse(JSON.stringify(...)), và khoảng 40 nano giây với bản copy viết tay dùng spread ở từng tầng. Hai micro giây chẳng là gì với một lần clone mỗi request. Nhưng nó là 2 giây CPU cho một triệu lần clone trong một batch job hay một tick của game, và đó là chỗ một clone() viết tay đáng giá.
Vậy structuredClone hợp với dữ liệu, và không đủ cho object có hành vi. Đó là lúc pattern thực thụ xuất hiện.
Pattern: object tự biết cách clone chính nó
Cho kiểu dữ liệu một method clone(). Object biết cấu trúc của chính nó, nên có thể tự sao chép cho đúng, kể cả những phần cần cẩn thận: deep copy dữ liệu, giữ class, và cho bản sao một danh tính riêng.
let nextId = 1;
class Unit {
readonly id = nextId++;
constructor(
public name: string,
public stats: Stats,
public skills: string[],
public readonly faction: string,
) {}
clone(changes: Partial<Pick<Unit, "name">> = {}): Unit {
return new Unit(changes.name ?? this.name, { ...this.stats }, [...this.skills], this.faction);
}
levelUp() {
this.stats.attack = Math.round(this.stats.attack * 1.1);
this.stats.hp = Math.round(this.stats.hp * 1.1);
}
describe() {
return `#${this.id} ${this.name} (${this.faction}) hp=${this.stats.hp} atk=${this.stats.attack} [${this.skills.join(", ")}]`;
}
}
stats chỉ có một tầng số, nên { ...this.stats } đã là bản sao đầy đủ và tránh được chi phí của structuredClone. Ngày Stats có thêm một object lồng bên trong, dòng này phải sửa, đó là cái giá của clone viết tay và cũng là lý do bài tập 1 bên dưới tồn tại.
id là một chi tiết quan trọng: bản clone là một object mới và không được dùng chung các field danh tính (ID, thời điểm tạo, khoá database) với template. Hãy quyết định rõ ràng field nào được sao chép, field nào được deep copy và field nào được đặt lại.
Prototype registry
Template thường được tra theo key. Một registry giữ các prototype đã cấu hình và trả ra bản clone, nên code game không bao giờ khởi tạo unit trực tiếp. Trong game thật, các prototype sẽ được load một lần từ file config do designer chỉnh:
class UnitRegistry {
private readonly prototypes = new Map<string, Unit>();
register(key: string, prototype: Unit) {
this.prototypes.set(key, prototype);
}
spawn(key: string, customize?: (unit: Unit) => void): Unit {
const prototype = this.prototypes.get(key);
if (!prototype) throw new Error(`Unknown unit: ${key}`);
const unit = prototype.clone();
customize?.(unit);
return unit;
}
}
const registry = new UnitRegistry();
registry.register("archer", new Unit("Archer", { hp: 80, attack: 12, defense: 4 }, ["volley"], "Sun"));
registry.register("knight", new Unit("Knight", { hp: 150, attack: 10, defense: 12 }, ["shield-wall"], "Sun"));
const a = registry.spawn("archer");
const b = registry.spawn("archer", (unit) => {
unit.name = "Veteran archer";
unit.levelUp();
unit.skills.push("piercing-shot");
});
const c = registry.spawn("archer");
console.log(a.describe()); // #3 Archer (Sun) hp=80 atk=12 [volley]
console.log(b.describe()); // #4 Veteran archer (Sun) hp=88 atk=13 [volley, piercing-shot]
console.log(c.describe()); // #5 Archer (Sun) hp=80 atk=12 [volley]
Thay đổi của cung thủ kỳ cựu không rò rỉ vào template, nên cung thủ thứ ba lại là một cung thủ bình thường. Thêm một loại unit mới chỉ là đăng ký thêm một prototype; không có chỗ nào sinh unit phải sửa.
Bảo vệ template
Một registry chỉ an toàn bằng người gọi kém cẩn thận nhất của nó. Sớm muộn sẽ có ai đó gọi registry.get("archer").skills.push(...) hoặc viết một clone() quên mất một field. Freeze các prototype biến sự hư hỏng âm thầm đó thành một TypeError ngay lập tức (ES module và output của TypeScript chạy ở strict mode, nên gán vào object đã freeze sẽ ném lỗi thay vì bị lờ đi):
function deepFreeze<T>(value: T): Readonly<T> {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
Object.freeze(value);
for (const key of Reflect.ownKeys(value)) deepFreeze((value as Record<PropertyKey, unknown>)[key]);
}
return value;
}
const frozenArcher = deepFreeze({ name: "Archer", stats: { hp: 80, attack: 12, defense: 4 }, skills: ["volley"] });
try {
frozenArcher.skills.push("piercing-shot");
} catch (error) {
console.log((error as Error).message);
// Cannot add property 1, object is not extensible
}
Freeze là một lớp bảo vệ, không phải một bản sao: bản clone vẫn phải tạo bằng clone() hoặc structuredClone, và bản clone của object đã freeze tạo bằng structuredClone sẽ trở về ở dạng không freeze và sửa được, đúng như bạn muốn. Object.freeze không freeze nội dung của Map hay Set, nên template chứa collection cần clone() riêng và một chút kỷ luật. Với một config lớn load một lần lúc khởi động, chi phí freeze chỉ trả một lần; đừng deep freeze trên hot path.
Prototype pattern và prototype chain của JavaScript
Hai cái tên trùng nhau, nhưng là hai ý tưởng khác nhau:
- Prototype pattern sao chép một object. Bản clone độc lập; sửa nó không ảnh hưởng bản gốc.
- Prototype chain của JavaScript uỷ quyền.
Object.create(base)tạo một object mới mà property nào thiếu sẽ được tra trênbase. Không có gì được sao chép.
Gán fontSize bên dưới tạo một property riêng che khuất base. Gán shortcuts.save thì không tìm thấy shortcuts riêng, nên đọc object của base qua chain và sửa luôn nó:
const baseSettings = { theme: "dark", fontSize: 14, shortcuts: { save: "Ctrl+S" } };
const userSettings = Object.create(baseSettings) as typeof baseSettings;
userSettings.fontSize = 16;
userSettings.shortcuts.save = "Cmd+S";
console.log(baseSettings.fontSize, baseSettings.shortcuts.save); // 14 Cmd+S
Uỷ quyền là cách JavaScript chia sẻ method giữa các instance của một class, và nó tiết kiệm bộ nhớ cho việc đó. Với dữ liệu mà mỗi object nên sở hữu riêng, nó gặp đúng vấn đề reference dùng chung ở đầu chương. Hãy dùng structuredClone hoặc method clone() khi bạn muốn các bản sao độc lập.
Trong NestJS
Provider trong Nest mặc định là singleton (chương 6), và điều đó biến mọi object mà một provider trao ra thành một template trên thực tế. Bug Prototype phổ biến nhất mình gặp trong code Nest là một giá trị mặc định dùng chung bị một request sửa:
import { Inject, Injectable, Module } from "@nestjs/common";
type MailOptions = { from: string; to: string[]; tags: string[]; headers: Record<string, string> };
export const MAIL_DEFAULTS = Symbol("MAIL_DEFAULTS");
@Injectable()
export class BrokenMailer {
constructor(@Inject(MAIL_DEFAULTS) private readonly defaults: MailOptions) {}
prepare(to: string, tag: string): MailOptions {
const options = { ...this.defaults, to: [to] };
options.tags.push(tag);
return options;
}
}
@Module({
providers: [
{ provide: MAIL_DEFAULTS, useValue: { from: "no-reply@shop.vn", to: [], tags: ["transactional"], headers: {} } },
BrokenMailer,
],
})
export class MailModule {}
useValue đăng ký đúng object đó; Nest không sao chép nó. Sau một nghìn lần reset mật khẩu, mảng tags mặc định có một nghìn phần tử, và mọi email từ mọi service đều mang theo hết. Bug này lọt qua code review vì { ...this.defaults } trông giống một bản sao.
Cách sửa là coi provider như một prototype và cung cấp việc clone thay vì object gốc. Một registry nhỏ freeze template lúc khởi động và trả ra deep copy là đủ:
import { Injectable, OnModuleInit } from "@nestjs/common";
type TemplateKey = "password-reset" | "order-shipped";
@Injectable()
export class MailTemplateRegistry implements OnModuleInit {
private readonly templates = new Map<TemplateKey, Readonly<MailOptions>>();
onModuleInit() {
const base: MailOptions = { from: "no-reply@shop.vn", to: [], tags: ["transactional"], headers: {} };
this.templates.set("password-reset", deepFreeze({ ...base, tags: [...base.tags, "security"] }));
this.templates.set("order-shipped", deepFreeze({ ...base, headers: { "List-Unsubscribe": "<mailto:unsub@shop.vn>" } }));
}
create(key: TemplateKey, to: string): MailOptions {
const template = this.templates.get(key);
if (!template) throw new Error(`Unknown mail template: ${key}`);
return { ...structuredClone(template), to: [to] };
}
}
Nếu template là cấu hình bạn load từ file hay database, quy tắc tương tự áp dụng cho mọi thứ bạn đọc từ ConfigService: coi object trả về là dùng chung và chỉ đọc, và clone trước khi sửa.
Scope.TRANSIENT không phải Prototype
Developer đến từ Spring hay đi tìm "prototype scope". Trong Spring, prototype scope nghĩa là "một instance mới mỗi lần bean được yêu cầu", và tương đương bên Nest là Scope.TRANSIENT: mỗi nơi inject một provider transient nhận instance riêng. Bất chấp cái tên bên Spring, cả hai đều không sao chép gì. Container gọi lại constructor, nên mọi bước setup tốn kém chạy lại từ đầu, và không có template đã cấu hình nào để sao chép. Dùng TRANSIENT khi một provider không được dùng chung state giữa các nơi dùng (logger lưu tên context của nơi dùng nó là trường hợp kinh điển, thường đi cùng token INQUIRER). Dùng Prototype pattern khi bạn có một object đã cấu hình và muốn nhiều bản sao độc lập của nó lúc runtime, nhiều lần trong mỗi request.
Test để template luôn sạch
Test lẽ ra đã bắt được BrokenMailer rất ngắn: tạo hai object từ cùng một template, sửa cái đầu, và khẳng định cái thứ hai cùng template không bị đụng tới. Chạy nó với registry thật trong một testing module của Nest còn kiểm chứng luôn phần nối dây:
import { Test } from "@nestjs/testing";
describe("MailTemplateRegistry", () => {
it("returns independent copies", async () => {
const moduleRef = await Test.createTestingModule({ providers: [MailTemplateRegistry] }).compile();
await moduleRef.init();
const registry = moduleRef.get(MailTemplateRegistry);
const first = registry.create("password-reset", "an@example.com");
first.tags.push("campaign-42");
const second = registry.create("password-reset", "binh@example.com");
expect(second.tags).toEqual(["transactional", "security"]);
expect(second.to).toEqual(["binh@example.com"]);
});
});
moduleRef.init() là quan trọng: compile() dựng container nhưng các lifecycle hook như onModuleInit chỉ chạy khi module được khởi tạo.
Trade-off mình sẽ nêu khi code review: khi một pull request thêm clone() viết tay cho một kiểu có mười field, mình hỏi liệu kiểu đó có thật sự cần hành vi không. Nếu nó chỉ là dữ liệu thuần, structuredClone ở ranh giới ít dòng hơn và không thể quên một field vào tháng sau; mình chỉ chấp nhận bản viết tay khi có một hot path đã đo đạc hoặc một class mà method phải sống sót qua bản sao.
Bạn gặp nó ở đâu
{ ...defaults, ...options }vàstructuredClone(template)cho cấu hình và option của request.- Cập nhật immutable trong React và Redux: sao chép, rồi sửa bản sao. Immer làm việc này giúp bạn với structural sharing.
- Test data builder bắt đầu từ một object gốc hợp lệ và ghi đè một field cho mỗi test.
Array.prototype.slice(),new Map(existing),new Set(existing): shallow clone của collection.Response.clone()vàRequest.clone()trong Fetch API, để đọc body được hai lần.
Khi nào nên dùng, khi nào không
Dùng khi:
- Object được cấu hình từ dữ liệu (file, database, remote config) và bạn cần nhiều instance độc lập.
- Code cần sao chép object mà không biết kiểu cụ thể của nó.
- Các biến thể chỉ khác bản gốc vài field.
Bỏ qua khi:
- Object đơn giản và dựng rẻ bằng constructor hoặc factory.
- Object giữ tài nguyên không sao chép có nghĩa được: socket, file handle, kết nối database.
Những lỗi thường gặp
- Shallow copy dữ liệu lồng nhau. Spread,
Object.assignvàslicechỉ sao chép một tầng. Mọi thứ lồng bên trong đều dùng chung. - Sao chép danh tính. Bản clone phải có ID, timestamp và khoá mới.
- Clone instance của class bằng
structuredClonehoặc JSON. Bạn nhận về object thường không có method. Hãy càiclone()trên class. - Circular reference và reference dùng chung trong
clone()viết tay. Nếu hai unit cố ý dùng chung một objectSquad, một deep clone ngây thơ sẽ cho mỗi bản sao một squad riêng. Hãy quyết định theo từng field. - Giá trị mặc định có thể sửa trong singleton provider. Một object
useValuehay một field trên singleton service là template cho mọi request. Hãy freeze nó hoặc trao ra bản sao.
Bài tập
- Viết test chứng minh
Unit.clone()không dùng chungstatshayskillsvới prototype của nó. Sau đó thêmresistances: { fire: number }lồng trongStatsvà xem test bắt được bản shallow copy. - Thêm field
equipment: Map<string, { name: string; bonus: number }>vàoUnitvà cập nhậtclone()để equipment được deep copy. - Sửa
UnitRegistryđể load prototype từ file JSON và deep freeze từng cái, để mọi thay đổi vô tình đều ném lỗi. - So sánh
structuredClone,JSON.parse(JSON.stringify(x))và một bản clone viết tay trên object chứa mộtDate, mộtMapvà một circular reference. Cách nào giữ được từng thứ, và mỗi cách mất bao lâu cho 100.000 bản sao? - Tìm một provider
useValuehoặc một object option mặc định trong codebase Nest bạn đang làm, và kiểm tra xem có đường code nào sửa thứ nó nhận được không.