Оптимистичный интерфейс без несинхронизации: снимки состояния, откаты и прерванные запросы
Научитесь создавать оптимистичные обновления, которые остаются корректными: сохраняйте состояние в моменте, обновляйте данные мгновенно, откатывайтесь при сбоях, отменяйте устаревшие запросы и знайте, когда не следует использовать эту схему.
Источники: Frontend Design Patterns: Optimistic Updates
При быстром соединении время на обратно-прямой запрос при нажатии кнопки «лайк» практически не заметно. Однако в ситуации, когда устройство находится между слабым сигналом LTE и едва работающим 3G, этот же процесс может занять пять–десять секунд, в течение которых пользователь не знает, действительно ли произошло нажатие. Оптимистичные решения сокращают этот разрыв, сначала изменяя интерфейс, а уже затем запрашивая информацию у сервера. В этом руководстве показано, как реализовать такой подход в трех четких шагах, как защитить его от одновременных запросов и как определить, где он будет полезен, а где станет проблемой.
Проблема, скрытая за одним нажатием
Представьте пост с 999 лайками и сердцем в контуре. В строгом варианте реализации нажатие на сердце отправляет запрос на сервер, который может находиться на другом континенте. Сервер проверяет авторизацию пользователя, записывает данные в базу данных и отвечает; только тогда сердце становится красным, а счётчик увеличивается до 1000. Ожидание в несколько сотен миллисекунд ещё терпимо, но пять секунд — уже крайне неприятно. Люди, не видящие реакции, нажимают снова и снова, пока в конце концов не откажутся от приложения; именно поэтому такая задержка является очень эффективным способом потерять пользователей.
Нажатие также запускает больше операций, чем простое изменение значения булевой переменной. Типичная платформа выполняет три действия на серверной стороне:
- Она вызывает конечную точку API, которая увеличивает счётчик лайков для данного поста.
- Она добавляет пост в сохранённую коллекцию на учётной записи пользователя, чтобы его можно было найти позже.
Все три компонента зависят от сети. Однако на фронтенде достаточно изменить всего два параметра состояния: заполнено ли сердечко и какое число рядом с ним. Если интерфейс будет ждать любых изменений с сервера перед тем, как изменить эти значения, приложение будет казаться неисправным даже тогда, когда работает корректно.
Оптимизм как умышленная, восстанавливаемая ложь
Оптимистичное обновление решает эту проблему, заставляя фронтенд немедленно изменить свое собственное состояние, исходя из предположения, что только что отправленный запрос увенчается успехом. Как только палец отпускает экран, сердечко становится красным, а число показывает 1 000, хотя на сервер еще ничего не поступило. Именно эта небольшая ложь создает впечатление мгновенной реакции интерфейса.
Это допустимо только в том случае, если существует план действий на случай неудачи предположения. Поезд может въехать в туннель и прервать соединение, или сервер может вернуть ошибку. Если значение на экране остаётся 1000, в то время как в базе данных указано 999, возникает несогласованность: интерфейс и источник правды расходятся в данных. В такой ситуации фронтенд должен вернуться к реальному состоянию — изображению сердца с контуром и значением 999, — а также уведомить пользователя с помощью краткого сообщения, появляющегося на мгновение и исчезающего, например: «Не удалось сохранить. Попробуйте ещё раз».
Реализация шаблона в трёх шагах
Изменение значения переменной в надежде на успешную обработку запроса — это угадывание, а не проектирование. Надёжная оптимистичная обновление разделяет три задачи: захват текущего состояния, отображение ожидаемого результата и согласование его с ответом сервера.
Шаг 1: захват снимка состояния
Прежде чем что-либо на экране изменится, запишите значения, которые пользователь может видеть в настоящее время: отмечено ли публикация лайком и каково её текущее количество. Обработчик получает публикацию, функцию для изменения состояния и функцию отображения уведомления, а также импортирует API-запрос, который он в конечном итоге совершит:
// likeButton.js
import { updateLikeOnServer } from "./api.js";
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
// act two goes here
// act three goes here
}
После этого шага у вас есть единый неприкосновенный объект, который точно описывает содержимое экрана и готов к восстановлению в случае возникновения проблем. Копирование этих двух полей в новый объект имеет большое значение: если вы сохраните ссылку на изменяемый объект, который позже изменится, то «снимок» также изменится вместе с ним.
Шаг 2: применение ожидаемого результата
Далее обновите интерфейс непосредственно, ещё до того, как начнется сетевой запрос:
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
setPost({ liked: true, likeCount: post.likeCount + 1 });
// act three goes here
}
Теперь на экране отображается то, о чем просил пользователь — красное сердце и увеличенный счетчик, в то время как сервер пока ничего не знает. Одна деталь, на которую стоит обратить внимание при написании собственного кода: в этом примере передается совершенно новый объект, содержащий только поля liked и likeCount. Если метод-установщик заменяет весь объект запроса вместо того, чтобы объединять поля, необходимо распространить существующие данные новому объекту, чтобы свойства вроде id не были потеряны.
Шаг 3: синхронизация с сервером
Наконец, отправляется реальный запрос, и либо сохраняется оптимистичное состояние, либо оно аннулируется. Запрос находится в блоке try; если возникает ошибка, обработчик восстанавливает предыдущее состояние и отображает уведомление:
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
setPost({ liked: true, likeCount: post.likeCount + 1 });
try {
await updateLikeOnServer(post.id);
} catch (error) {
setPost(previousState);
showToast("Couldn't save that. Try again.");
}
}
Вспомогательный инструмент API использует функцию fetch браузера. Следует отметить, что fetch отклоняет запросы только при сетевых сбоях, а не при ошибках HTTP-статусов; поэтому инструмент проверяет значение response.ok и явно выбрасывает исключение. Без этой проверки ответ с кодом 500 будет считаться успешным, и операция отката никогда не произойдёт:
// api.js
export async function updateLikeOnServer(postId) {
const response = await fetch(`/api/posts/${postId}/like`, {
method: "POST",
});
if (!response.ok) {
throw new Error("Like request failed");
}
return response.json();
}
После третьего шага существуют только два возможных исхода: либо запрос был успешным, и оптимистичное состояние стало действительным, либо он провалился, и интерфейс вернулся к состоянию из первого шага. В обоих случаях интерфейс никогда не отображает то, что бэкенд не может поддерживать в течение времени, необходимого для сбоя запроса.
Где оправданы оптимистичные обновления
Дополнительная структура оправдана только там, где операция отката занимает мало времени, а воспринимаемая скородействия имеет большое значение. Три категории постоянно соответствуют этим критериям:
- Социальное взаимодействие. Лайки, подписка и реакции не имеют большой значимости. Если одна из множества попыток нажатия в день не увенчивается успехом, пользователь просто пытается снова.
- Переписка. В чат-приложениях вроде WhatsApp или Slack сообщение, отправленное при плохом соединении, появляется в разговоре сразу же, часто с приглушенным оттенком или маленькой серой иконкой часов. Это оптимистичное обновление с видимым флагом ожидания: сообщение отображается на экране ещё до того, как достигнет сервера.
- Панели с функцией перетаскивания. Перетаскивание карточки в колонку «Готово» на панели Kanban вроде Jira или Trello будет похоже на движение по мокрому песку, если карточке придётся ждать подтверждения. В оптимистичном случае она сразу же попадает в нужную колонку, а при неудачной попытке возвращается обратно в исходную.
Полезным усовершенствованием по сравнению с классическими сообщениями является индикатор ожидания. Он показывает, что действие ещё не подтверждено, что позволяет быть одновременно быстрым и честным, снижая таким образом удивление в случае отката операции.
Когда оптимизм становится опасным
Теперь представьте приложение для банковских операций. Арендатор нажимает «Отправить», чтобы перевести пять тысяч долларов арендной платы владельцу жилья. При оптимистичном обновлении на экране сразу появляется сообщение о том, что перевод прошёл успешно и баланс теперь равен нулю, после чего пользователь закрывает приложение, считая, что арендная плата уплачена.
На самом деле запрос отклоняется; не имеет значения, произошло ли это из-за недостатка средств или отклонённой переводки. Пользователь продолжает верить, что деньги были переведены, пока арендодатель не позвонит с вопросом об аренде. Как минимум это приводит к поступлению заявки на поддержку и разрушению доверия, причём это происходит потому, что шаблон, предназначенный для дешёвых и обратимых операций, был применён к ситуации, где ошибка имеет реальные последствия.
Та же осторожность требуется и в нескольких связанных категориях:
- Финансовые операции. Необходимо показывать индикатор загрузки. Ожидание сигнализирует о том, что система действительно проверила операцию перед её подтверждением.
- Необратимое удаление. Когда кто-то удаляет свой аккаунт, экран не должен становиться пустым до тех пор, пока сервер не подтвердит, что данные удалены.
Решающим фактором является не степень значимости действия, а стоимость ошибки при его выполнении. Лучше заставить пользователя подождать ещё три секунды, пока будет отображаться индикатор загрузки, чем сообщать ему, что деньги переведены, когда этого на самом деле не произошло.
Когда два нажатия соперничают друг с другом
Существует ещё один способ сбоя, который возникает только тогда, когда пользователь совершает действие снова до завершения первого запроса. Возвращаясь к примеру медленного поезда, пользователь ставит лайк посту, повышая его рейтинг с 999 до 1 000, а затем сразу же снимает лайк, возвращая рейтинг к 999. Предположим, что процесс ставки лайка занимает пять секунд, в то время как процесс снятия лайка — всего две; тогда снятие лайка может завершиться раньше.
Затем сервер обрабатывает действие «не нравится» для поста, который он пока не считает оценённым как «нравится», поэтому ничего не происходит, а через несколько секунд обрабатывает действие «нравится». На сервере пост в итоге отмечается как нравящийся, в то время как на экране отображается противоположное тому, что сделал пользователь в последний раз. Когда окончательное состояние определяется в порядке поступления запросов, а не в порядке действий пользователя, возникает ситуация соревнования.
Отмена устаревших запросов с помощью AbortController
Обычным решением является отмена: когда начинается новый запрос к тому же ресурсу, браузеру указывают отказаться от старого. Встроенный AbortController может отменить уже отправленный запрос fetch. В примере сохраняется ссылка на активный контроллер, он отменяется перед началом нового запроса, а выбор метода POST или DELETE осуществляется в зависимости от желаемого состояния:
// likeButton.js
let activeRequest = null;
async function toggleLike(postId, shouldLike) {
if (activeRequest) {
activeRequest.abort();
}
const controller = new AbortController();
activeRequest = controller;
await fetch(`/api/posts/${postId}/like`, {
method: shouldLike ? "POST" : "DELETE",
signal: controller.signal,
});
}
Каждое новое нажатие отменяет предыдущий запрос, поэтому должна выполниться только самая последняя операция. Прежде чем полагаться исключительно на это, стоит учитывать два момента:
- Отмена запроса прерывает ожидание ответа браузером; это не гарантирует, что сервер вообще не получил или не обработал запрос. Для более надежных гарантий необходимо спроектировать конечную точку так, чтобы она всегда находилась в желаемом состоянии (подписано/не подписано), что позволит повторным или переупорядоченным запросам сходиться к одному результату, либо использовать номер последовательности, который сервер сможет применить для игнорирования устаревших данных.
- Отмененный запрос
fetchвозвращает ошибкуAbortError. Если сочетать это с операцией отката из третьего шага, необходимо проверять наличие этой ошибки и пропускать операцию отката, иначе отмена устаревшего запроса может ошибочно аннулировать более новое, оптимистичное состояние.
В приложениях на React библиотеки для работы с данными обрабатывают большую часть таких внутренних процессов. TanStack Query, отвечающий за кэширование серверного состояния и управление жизненным циклом запросов, а также Redux Toolkit — официальный набор инструментов для реализации логики Redux — оба предоставляют встроенную поддержку оптимистичных обновлений и возможность отмены операций, поэтому вам редко приходится вручную писать код с использованием AbortController. Ознакомьтесь с их документацией, чтобы узнать, что именно отменяется в каждом случае; понимание подводных камней всё равно очень важно. Чтобы узнать подробнее о том, как могут возникнуть проблемы с откатах с использованием встроенных хуков React, прочитайте пять способов сбоя при использовании оптимистичных откатов.
Основные выводы
- Сетевой обмен данными может занимать достаточно много времени, из-за чего работающее приложение может казаться неисправным; поэтому оптимистичные обновления сначала показывают ожидаемый результат, а затем подтверждают его.
fetch не отклоняет запросы при ошибках HTTP, поэтому необходимо выбрасывать исключение при условии !response.ok, а также уведомлять пользователя с помощью сообщения при откате.AbortError в логике отката и предпочтения концовок, устанавливающих окончательное состояние, вместо тех, что его меняют.Скорость — это функционал, который сеть не предоставит вам бесплатно. Оптимистичный интерфейс принимает тот факт, что он не может контролировать соединение, тем не менее уверенно отвечает пользователю и готов отменить свой ответ в ту же секунду, когда это окажется неверным.