از رویداد تا معماری قابل کنترل، قسمت نهم: Command Pattern و Undo/Redo
در قسمت هشتم یک عملیات بزرگ را به چند مرحله مستقل تقسیم کردیم. در آن مدل، تابعها اجرا میشدند و مسیر جلو میرفت. حالا میخواهیم یک عملیات را قبل و بعد از اجرا به عنوان یک شیء در اختیار داشته باشیم.
در قسمت نهم مجموعه، عملیات را به command تبدیل میکنیم. با این تغییر میتوانیم آن را در صف یا تاریخچه قرار دهیم و قابلیت Undo و Redo بسازیم.
برای اضافه کردن Undo و Redo باید بدانیم چه کاری انجام شده و چطور میتوان اثرش را برگرداند. اگر عملیات برنامه فقط چند تابع پراکنده باشند، نگهداری این اطلاعات سخت میشود.
Command Pattern هر عملیات را به یک شیء تبدیل میکند. این شیء معمولا یک متد برای اجرا و یک متد برای بازگرداندن دارد. بعد میتوان commandهای اجراشده را در تاریخچه نگه داشت.
در این مدل، کسی که command را اجرا میکند لازم نیست جزئیات عملیات را بداند. History فقط قرارداد execute و undo را میشناسد. خود command میداند روی کدام داده کار کند و برای برگشت چه اطلاعاتی لازم دارد.
مثال ویرایش متن
class AddTextCommand {
constructor(document, text) {
this.document = document;
this.text = text;
}
execute() {
this.document.text += this.text;
}
undo() {
this.document.text = this.document.text.slice(0, -this.text.length);
}
}
class History {
constructor() {
this.undoStack = [];
this.redoStack = [];
}
execute(command) {
command.execute();
this.undoStack.push(command);
this.redoStack = [];
}
undo() {
const command = this.undoStack.pop();
if (!command) return;
command.undo();
this.redoStack.push(command);
}
redo() {
const command = this.redoStack.pop();
if (!command) return;
command.execute();
this.undoStack.push(command);
}
}
const document = { text: "سلام" };
const history = new History();
history.execute(new AddTextCommand(document, " دنیا"));
console.log(document.text); // سلام دنیا
history.undo();
console.log(document.text); // سلام
history.redo();
console.log(document.text); // سلام دنیا
دو stack داریم. با اجرای فرمان، آن را وارد undoStack میکنیم و redoStack پاک میشود. پاک شدن redo مهم است، چون بعد از undo اگر عملیات تازهای انجام شود، مسیر قبلی تاریخچه دیگر معتبر نیست.
Stack ساختار مناسبی است چون آخرین عملیات باید اول برگردد. مثلا اگر ابتدا «دنیا» و بعد علامت تعجب اضافه شده باشد، undo باید اول علامت تعجب را حذف کند. این همان رفتار Last In, First Out است.
Redo خود command قبلی را دوباره اجرا میکند و آن را به undoStack برمیگرداند. در نتیجه history بین دو stack جابهجا میشود، بدون اینکه درباره متن یا نوع فرمان چیزی بداند.
به چه دردی میخورد؟
ویرایشگر متن، ابزار طراحی، مدیریت فایل و فرمهای پیچیده نمونههای رایج هستند. مزیت دیگر این است که command را میتوان در صف گذاشت، ثبت کرد یا بعدا اجرا کرد.
مثلا در یک ابزار طراحی، جابهجایی شکل میتواند commandی باشد که مختصات قبلی و جدید را نگه میدارد. execute شکل را به مختصات جدید میبرد و undo آن را به مختصات قبلی برمیگرداند. تغییر رنگ و حذف شکل هم commandهای جدا خواهند داشت.
بازگرداندن همیشه ساده نیست
در این مثال فقط متنی را که خود command به انتها اضافه کرده حذف میکنیم. برای عملیات واقعی بهتر است command اطلاعات لازم قبل از تغییر را نگه دارد. مثلا فرمان حذف یک آیتم باید خود آیتم و جای قبلی آن را ذخیره کند.
برای کارهایی مثل ارسال ایمیل یا پرداخت، undo واقعی ممکن نیست. در این حالت یک command جبرانی میسازیم، مثلا بازپرداخت به جای پاک کردن پرداخت. پس قبل از افزودن Undo باید معنی دقیق بازگرداندن هر عملیات را مشخص کنیم.
نکات طراحی تاریخچه
فرمان باید اطلاعات لازم برای برگشت را هنگام اجرا ذخیره کند، نه اینکه موقع undo وضعیت قبلی را حدس بزند. اگر چند بخش بتوانند همزمان document را تغییر دهند، فرض slice در نمونه ما دیگر کافی نیست و باید موقعیت و نسخه داده هم ثبت شود.
تاریخچه بینهایت حافظه را پر میکند. برنامه واقعی معمولا تعداد commandها یا حجم snapshotها را محدود میکند. برای تایپ متن هم ذخیره هر کلید به صورت یک command تجربه خوبی نمیسازد؛ چند تغییر نزدیک را میتوان در یک command گروهبندی کرد.
Command Pattern برای هر دکمهای لازم نیست. اگر عملیات ساده است و undo، صف یا ثبت تاریخچه نداریم، یک تابع معمولی روشنتر است. ارزش این الگو زمانی دیده میشود که خود عملیات باید به عنوان داده نگهداری و مدیریت شود.
ادامه دارد
به یک قدمی پایان مجموعه رسیدیم. قسمت دهم و آخر چند روز دیگر منتشر میشود و این مسیر آموزشی را به پایان میرساند.