Добавить в корзинуПозвонить
Найти в Дзене
Цифровая Переплавка

📌 Магия отмены и повтора: как написать элегантный undo/redo на JavaScript

Практически каждый разработчик хотя бы раз сталкивался с необходимостью реализации функциональности отмены (undo) и повтора (redo) действий в своих приложениях. Это важная функция любого качественного интерфейса, ведь именно она даёт пользователю свободу ошибаться и экспериментировать. Но почему так часто её делают сложно, запутанно и с ошибками? Недавно интересную и компактную реализацию на JavaScript предложил Юлик Тарханов. Давайте разберёмся, в чём секрет его подхода и почему это достойно внимания. Начнём с главных требований к хорошей реализации undo/redo: Юлик отметил интересную деталь: большинство разработчиков при реализации undo-стека используют один массив и указатель, и именно из-за этого возникают типичные ошибки — неправильная индексация, ошибки off-by-one и путаница между методами массива. Автор предложил иную стратегию — использовать два независимых массива: Вот как это работает: На мой взгляд, это блестящая находка, которая радикально упрощает жизнь программисту! Юлик с
Оглавление

Практически каждый разработчик хотя бы раз сталкивался с необходимостью реализации функциональности отмены (undo) и повтора (redo) действий в своих приложениях. Это важная функция любого качественного интерфейса, ведь именно она даёт пользователю свободу ошибаться и экспериментировать. Но почему так часто её делают сложно, запутанно и с ошибками?

Недавно интересную и компактную реализацию на JavaScript предложил Юлик Тарханов. Давайте разберёмся, в чём секрет его подхода и почему это достойно внимания.

🎯 Чего хочется от идеального undo/redo?

Начнём с главных требований к хорошей реализации undo/redo:

  • Простота использования – понятный и минималистичный интерфейс.
  • 📦 Лёгкость реализации – минимум подводных камней в коде.
  • 🔄 Отсутствие ошибок индексации – которые часто возникают при работе с массивами и указателями.

Юлик отметил интересную деталь: большинство разработчиков при реализации undo-стека используют один массив и указатель, и именно из-за этого возникают типичные ошибки — неправильная индексация, ошибки off-by-one и путаница между методами массива.

⚙️ Ключевые идеи в реализации Юлика

Автор предложил иную стратегию — использовать два независимых массива:

  • past (прошлое),
  • future (будущее).

Вот как это работает:

  • Когда действие происходит, оно помещается в past.
  • При отмене действие переносится из past в future.
  • При повторе — обратно из future в past.
  • Если после отмены мы делаем новое действие, future очищается — никаких ветвлений истории.

На мой взгляд, это блестящая находка, которая радикально упрощает жизнь программисту!

🔍 Технические нюансы реализации

Юлик создал аккуратную функцию-фабрику createUndoStack(), которая возвращает удобный API:

function createUndoStack() {
const past = [];
const future = [];

return {
// ➕ Выполнить и запомнить действие
push(doFn, undoFn, ...argsToClone) {
const clonedArgs = structuredClone(argsToClone);
const action = {
doWithData() { doFn(...clonedArgs); },
undoWithData() { undoFn(...clonedArgs); },
};
action.doWithData();
past.push(action);
future.length = 0; // очищаем future
},

// ⬅️ Отменить действие
undo() {
const action = past.pop();
if (action) {
action.undoWithData();
future.unshift(action);
}
},

// ➡️ Повторить действие
redo() {
const action = future.shift();
if (action) {
action.doWithData();
past.push(action);
}
},

// 📗 Можем ли отменить?
get undoAvailable() { return past.length > 0; },

// 📘 Можем ли повторить?
get redoAvailable() { return future.length > 0; },

// 🧹 Очистить историю
clear() {
past.length = 0;
future.length = 0;
return true;
}
};
}

🐞 Важный нюанс с замыканиями и ссылками

В JavaScript почти все типы передаются по ссылке. Это значит, что если вы просто передадите объект как аргумент функции, изменения этого объекта будут видны везде, где он использован. Для undo/redo это большая проблема.

Юлик решает её изящно — с помощью нового метода JS structuredClone(). Он создаёт глубокую копию переданных аргументов и сохраняет её. Теперь при отмене или повторе используется копия, а не текущий объект.

Это избавляет от ошибок с мутацией данных и делает код надёжным.

📚 Как это использовать на практике?

Представьте приложение для рисования:

🎨 Пользователь рисует линию (currentStroke):

let appendStroke = strokes.push.bind(strokes);
undoStack.push(appendStroke, () => strokes.pop(), currentStroke);

  • При отмене вызовется strokes.pop(), удаляющий последнюю линию.
  • При повторе снова вызовется strokes.push() с точной копией того, что было нарисовано.

Просто, понятно и удобно!

💡 Личное мнение автора статьи

На мой взгляд, предложенный подход идеален для фронтенд-разработки:

  • 🚀 Скорость реализации: код пишется и читается максимально просто.
  • 🧑‍💻 Отсутствие распространённых ошибок: никаких запутанных указателей и ошибок с индексами.
  • 🌱 Расширяемость: легко добавить новые возможности и адаптировать под любой проект.

Интересный момент: несмотря на простоту реализации, автор предусмотрел все главные проблемы (мутация объектов, управление историей), избежав типичных ошибок.

На практике именно такие подходы и делают код по-настоящему качественным.

🌟 Заключение

Простота, чёткость, предсказуемость — три столпа качественного кода, и Юлик идеально реализовал их в своей небольшой библиотеке undo/redo.

Я рекомендую всем разработчикам брать на вооружение подобный подход. Не нужно гнаться за сложностью — иногда именно такие простые и изящные решения оказываются наиболее эффективными.

🔗 Полезные ссылки по теме: