Design Patterns in TypeScript · Part 8 — Command & Memento
Turn actions into data: the Command pattern for dispatch, queues, and undo/redo, the Memento for snapshotting state, and how reducers (Redux-style) are commands in disguise.
Phần 8/10 trong series Design Patterns in TypeScript. Trước: Tiếp:
Đây là Phần 8 của series 10 bài về các design pattern mà mọi senior web nên nắm — giải thích bằng TypeScript chạy được, use case web thực tế, và bài tập ở cuối mỗi phần. Ở Phần 7 — Adapter & Facade bạn đổi những gì caller thấy ở biên; ở đây bạn đổi khi nào và thế nào việc chạy — thành dữ liệu có thể xếp hàng, ghi log và đảo ngược.
Gọi thẳng editor.insert('hello') ổn cho tới khi bạn cần undo, redo, replay macro, hoặc đồng bộ offline. Invoker (nút, phím tắt, middleware) không xếp hàng được lời gọi method — chỉ giá trị. Hiện thực hóa hành động: gói ý định + tham số (+ cách undo) vào một object truyền đi được.
Ý đồ
Command biến một request thành object độc lập có execute() (và thường undo()). Ba vai:
- Invoker — kích hoạt command không cần biết bên trong (toolbar,
dispatch, job runner). - Command — mang đủ thứ để thực hiện (và đảo) hành động.
- Receiver — object domain thật sự đổi state (document, canvas, giỏ).
Memento là ý tưởng anh em: thay vì lưu cách undo từng bước, lưu snapshot state để khôi phục sau. Command trả lời “đã xảy ra gì?”; memento trả lời “thế giới trông thế nào lúc T?”.
Command có kiểu
Hai hình idiomatic trong TypeScript:
- Discriminated union của value object — serialize được, hợp reducer, log và
JSON.stringify. - Object có
execute()/undo()— GoF kinh điển, tiện khi hành vi nằm trên command.
Ưu tiên union khi command đi qua mạng (stack undo persist, chỉnh sửa cộng tác, event sourcing). Ưu tiên method cho editor in-memory local không cần serialize.
Receiver + command dạng union
// ── Receiver: the document owns the truth ──
export interface TextDocument {
readonly content: string;
insert(at: number, text: string): void;
delete(at: number, length: number): void;
}
export function createTextDocument(initial = ''): TextDocument {
let content = initial;
return {
get content() {
return content;
},
insert(at, text) {
content = content.slice(0, at) + text + content.slice(at);
},
delete(at, length) {
content = content.slice(0, at) + content.slice(at + length);
},
};
}
// ── Commands as data (serializable intent) ──
export type DocCommand =
| { type: 'insert'; at: number; text: string }
| { type: 'delete'; at: number; length: number };
export function applyCommand(doc: TextDocument, cmd: DocCommand): void {
switch (cmd.type) {
case 'insert':
doc.insert(cmd.at, cmd.text);
break;
case 'delete':
doc.delete(cmd.at, cmd.length);
break;
}
}
// Inverse for undo — derived from the forward command, not guessed.
export function invertCommand(cmd: DocCommand): DocCommand {
switch (cmd.type) {
case 'insert':
return { type: 'delete', at: cmd.at, length: cmd.text.length };
case 'delete':
// Undo delete requires the deleted slice — store it on forward delete in production.
throw new Error('delete undo needs captured text; see exercise 3');
}
}
const doc = createTextDocument('hi');
applyCommand(doc, { type: 'insert', at: 2, text: ' there' });
console.log(doc.content); // hi there
Command object kinh điển
export interface Command {
readonly label: string;
execute(): void;
undo(): void;
}
export class InsertTextCommand implements Command {
readonly label = 'Insert text';
constructor(
private readonly doc: TextDocument,
private readonly at: number,
private readonly text: string,
) {}
execute(): void {
this.doc.insert(this.at, this.text);
}
undo(): void {
this.doc.delete(this.at, this.text.length);
}
}
// Invoker only sees Command — not TextDocument internals.
function run(invoker: { push(cmd: Command): void }, cmd: Command): void {
cmd.execute();
invoker.push(cmd);
}
Invoker vẫn “ngu”; command sở hữu tương tác với receiver.
Undo / redo với stack lịch sử
Giữ hai stack: past (command đã chạy) và future (command đã undo chờ redo). Khi execute, đẩy vào past và xóa future. Khi undo, pop past, gọi undo(), đẩy vào future. Khi redo, pop future, execute(), đẩy lại past.
export class CommandHistory {
private readonly past: Command[] = [];
private readonly future: Command[] = [];
execute(cmd: Command): void {
cmd.execute();
this.past.push(cmd);
// New branch invalidates redo timeline — standard editor behavior.
this.future.length = 0;
}
undo(): void {
const cmd = this.past.pop();
if (!cmd) return;
cmd.undo();
this.future.push(cmd);
}
redo(): void {
const cmd = this.future.pop();
if (!cmd) return;
cmd.execute();
this.past.push(cmd);
}
canUndo(): boolean {
return this.past.length > 0;
}
canRedo(): boolean {
return this.future.length > 0;
}
}
// Usage
const doc2 = createTextDocument('');
const history = new CommandHistory();
history.execute(new InsertTextCommand(doc2, 0, 'Hello'));
history.execute(new InsertTextCommand(doc2, 5, ' world'));
history.undo();
console.log(doc2.content); // Hello
history.redo();
console.log(doc2.content); // Hello world
Với union command, stack giữ giá trị DocCommand; undo dùng invertCommand(cmd) thay vì cmd.undo().
Memento
Memento là snapshot bất biến, không trong suốt của state receiver tại một thời điểm. Originator (editor) tạo và khôi phục memento; bên ngoài không sửa ruột snapshot.
// Immutable snapshot — copy data out, never hand out live mutable refs.
export interface DocSnapshot {
readonly content: string;
readonly cursor: number;
}
export class EditorWithMemento {
private content = '';
private cursor = 0;
get text(): string {
return this.content;
}
insert(text: string): void {
this.content =
this.content.slice(0, this.cursor) + text + this.content.slice(this.cursor);
this.cursor += text.length;
}
save(): DocSnapshot {
return { content: this.content, cursor: this.cursor };
}
restore(snapshot: DocSnapshot): void {
// Defensive copy so restore cannot alias live editor buffers.
this.content = snapshot.content;
this.cursor = snapshot.cursor;
}
}
const editor = new EditorWithMemento();
editor.insert('draft v1');
const checkpoint = editor.save();
editor.insert(' — edited');
console.log(editor.text); // draft v1 — edited
editor.restore(checkpoint);
console.log(editor.text); // draft v1
Khi memento thắng: nhiều field liên kết, thao tác khó đảo (layout engine, filter), hoặc “khôi phục autosave”. Khi command thắng: undo chi tiết, audit (“user xóa dòng 42”), bộ nhớ nhỏ nếu mỗi bước nhỏ. Đội thường kết hợp: log command cho cộng tác + memento định kỳ cho phục hồi crash.
Reducer chính là command
Redux và useReducer đã dùng ý Command: action là object mô tả đã xảy ra gì; reducer là hàm thuần áp dụng nó.
interface Todo {
id: string;
text: string;
done: boolean;
}
interface TodoState {
items: Todo[];
}
// Actions = commands (discriminated union)
type TodoAction =
| { type: 'add'; id: string; text: string }
| { type: 'toggle'; id: string }
| { type: 'remove'; id: string };
// Reducer = invoker-side apply, no hidden mutation
export function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'add':
return {
items: [...state.items, { id: action.id, text: action.text, done: false }],
};
case 'toggle':
return {
items: state.items.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t,
),
};
case 'remove':
return { items: state.items.filter((t) => t.id !== action.id) };
}
}
// dispatch(action) is execute(command)
let state: TodoState = { items: [] };
state = todoReducer(state, { type: 'add', id: '1', text: 'Ship Part 8' });
Không có undo() sẵn — bạn tự thêm lịch sử state hoặc action đảo. Event sourcing đẩy xa hơn: persist luồng command làm nguồn sự thật; dựng lại state bằng fold log. Đó là Command + snapshot bất biến ở quy mô lớn.
Use case web thực tế
- Undo/redo trong editor — Figma, doc kiểu Notion, IDE: stack command hoặc OT trên op kiểu command.
- Optimistic UI + rollback — dispatch command, cập nhật UI, đảo bằng inverse command nếu API fail.
- Ghi log / replay action — analytics, support “replay session”, debug time-travel.
- Hàng đợi / sync offline — xếp
{ type: 'addToCart', sku }khi offline; flush khi online. - Phím tắt — một map
Ctrl+Z→undo()không nối từng nút tới từng handler.
type AppCommand =
| { type: 'navigate'; path: string }
| { type: 'theme'; mode: 'dark' | 'light' }
| { type: 'toast'; message: string };
const shortcutMap: Record<string, AppCommand> = {
'mod+z': { type: 'toast', message: 'Undo not wired in demo' },
'mod+shift+t': { type: 'theme', mode: 'dark' },
};
function dispatchFromKeyboard(
event: { key: string; metaKey: boolean; shiftKey: boolean },
run: (cmd: AppCommand) => void,
): void {
const mod = event.metaKey ? 'mod+' : '';
const key = `${mod}${event.shiftKey ? 'shift+' : ''}${event.key.toLowerCase()}`;
const cmd = shortcutMap[key];
if (cmd) run(cmd);
}
Cạm bẫy
- Command không đảo được thật — xóa text mà không lưu đoạn đã xóa; undo thành đoán mò.
- Memento khổng lồ — snapshot canvas 50 MB mỗi phím; dùng delta, log command, hoặc checkpoint định kỳ.
- Command không serialize — closure giữ DOM hoặc instance class phá persist và hydrate SSR.
- Trộn side effect vào reducer —
fetchtrongtodoReducerphá time-travel và test; giữ reducer thuần, effect ở middleware/thunk. - Quên xóa stack redo — sau chỉnh sửa mới sau undo, command
futurecũ phải bỏ.
Bảng tra nhanh
// Union command (serializable) + apply
type Action = { type: 'insert'; at: number; text: string };
function apply(state: State, action: Action): State { /* pure */ }
// Classic command object
interface Command { execute(): void; undo(): void; }
// History: past[] + future[], execute clears future
class History {
execute(cmd: Command) { cmd.execute(); past.push(cmd); future = []; }
undo() { const c = past.pop(); c?.undo(); if (c) future.push(c); }
}
// Memento: immutable snapshot
const snap = originator.save();
originator.restore(snap);
// Reducer: (state, action) => state — action IS the command
Quyết định: cần undo + audit + sync → union; editor OOP local → command object; khôi phục cả state → memento; UI React/Redux → action reducer.
Bài tập / Exercises
1. Cài InsertTextCommand và DeleteTextCommand có execute / undo, và CommandHistory hỗ trợ execute, undo, redo.
Lời giải
class DeleteTextCommand implements Command {
readonly label = 'Delete text';
private removed = '';
constructor(
private readonly doc: TextDocument,
private readonly at: number,
private readonly length: number,
) {}
execute(): void {
this.removed = docSlice(this.doc, this.at, this.length);
this.doc.delete(this.at, this.length);
}
undo(): void {
this.doc.insert(this.at, this.removed);
}
}
function docSlice(doc: TextDocument, at: number, length: number): string {
return doc.content.slice(at, at + length);
}
const doc = createTextDocument('abcdef');
const hist = new CommandHistory();
hist.execute(new DeleteTextCommand(doc, 2, 2));
console.log(doc.content); // abef
hist.undo();
console.log(doc.content); // abcdef
hist.redo();
console.log(doc.content); // abef2. Mô hình hóa cùng thao tác document bằng union DocAction và docReducer(state, action) thuần trả state mới (không mutate).
Lời giải
interface DocState {
content: string;
}
type DocAction =
| { type: 'insert'; at: number; text: string }
| { type: 'delete'; at: number; length: number };
export function docReducer(state: DocState, action: DocAction): DocState {
const { content } = state;
switch (action.type) {
case 'insert':
return {
content: content.slice(0, action.at) + action.text + content.slice(action.at),
};
case 'delete':
return {
content: content.slice(0, action.at) + content.slice(action.at + action.length),
};
}
}
let s: DocState = { content: 'hi' };
s = docReducer(s, { type: 'insert', at: 2, text: '!' });
console.log(s.content); // hi!3. Mở rộng delete dạng data để undo chạy: lưu deletedText trên action delete xuôi và cài invertAction.
Lời giải
type DocAction2 =
| { type: 'insert'; at: number; text: string }
| { type: 'delete'; at: number; deletedText: string };
function applyAction2(state: DocState, action: DocAction2): DocState {
return docReducer(state, {
type: action.type,
at: action.at,
...(action.type === 'insert'
? { text: action.text }
: { length: action.deletedText.length }),
});
}
function invertAction2(action: DocAction2): DocAction2 {
switch (action.type) {
case 'insert':
return { type: 'delete', at: action.at, deletedText: action.text };
case 'delete':
return { type: 'insert', at: action.at, text: action.deletedText };
}
}
let s2: DocState = { content: 'abc' };
const del: DocAction2 = { type: 'delete', at: 1, deletedText: 'b' };
s2 = applyAction2(s2, del);
s2 = applyAction2(s2, invertAction2(del));
console.log(s2.content); // abc4. Thêm save() / restore() memento cho editor nhỏ và chứng minh restore hoàn tác nhiều chỉnh sửa một lần.
Lời giải
class SimpleEditor {
private content = '';
mutate(text: string): void {
this.content += text;
}
read(): string {
return this.content;
}
save(): DocSnapshot {
return { content: this.content, cursor: this.content.length };
}
restore(s: DocSnapshot): void {
this.content = s.content;
}
}
const e = new SimpleEditor();
const snap = e.save();
e.mutate('aaa');
e.mutate('bbb');
e.restore(snap);
console.log(e.read()); // '' — both edits gone5. Vì sao reducer nên thuần còn execute() của command có thể mutate receiver? Mỗi thứ một câu.
Lời giải
Reducer thuần cho time-travel, replay và test đơn giản. execute() mutate receiver khi bạn chọn model mutable vì hiệu năng; command object vẫn ghi intent cho undo.
Nâng cao:persist log DocAction[] vào localStorage, reload, fold bằng docReducer để dựng lại state — ghi field nào phải JSON-safe.
Điểm chính
- Command — đóng gói hành động thành object/giá trị: xếp hàng, log, undo, dispatch từ một chỗ.
- Stack lịch sử —
past+future; execute mới sau undo xóa redo. - Memento — snapshot bất biến khi đảo op khó hoặc cần checkpoint.
- Reducer —
(state, action) => statevớiactionlà command; event sourcing là bản phân tán.
Tiếp theo
Phần 9 — State & State Machines: bỏ soup cờ boolean; mô hình hóa transition được phép để state UI sai không biểu diễn được.