Redux вышел в июне 2015. Flux (предшественник Facebook) опубликован в мае 2014. До этих паттернов команды, строящие SPA, изобретали собственные подходы к управлению состоянием - в каждом проекте по-своему, каждый со своими видами отказов.
Основная проблема
Одностраничное приложение никогда не делает полную перезагрузку страницы. Оно начинается с HTML-оболочки и обновляет DOM в ответ на действия пользователя. Состояние живёт в JavaScript-переменных в браузере на протяжении всей сессии.
Без чёткой архитектуры состояние рассеивалось по кодовой базе:
// 2012 - состояние везде
var currentUser = null; // Глобальная переменная
var cartItems = []; // Ещё одна глобальная
var selectedFilters = {}; // Ещё одна
var isLoading = false;
function renderHeader() {
if (currentUser) { $('#username').text(currentUser.name); }
}
function addToCart(productId) {
cartItems.push(productId);
// ... но знает ли View C об изменении корзины?
}
Баги от этого: View C показывал счётчик корзины, не обновляющийся когда View B добавлял элемент, потому что ничто не говорило View C перерендериться.
Паттерн Event Bus
Наше первое систематическое решение - глобальная шина событий: центральный pub/sub объект, которому любой код мог публиковать или на который мог подписываться:
var EventBus = {
_events: {},
on: function(event, callback) {
if (!this._events[event]) this._events[event] = [];
this._events[event].push(callback);
return this;
},
trigger: function(event, data) {
if (this._events[event]) {
this._events[event].forEach(function(cb) { cb(data); });
}
}
};
// Модуль корзины публикует события
var Cart = {
items: [],
add: function(product) {
this.items.push(product);
EventBus.trigger('cart:changed', { items: this.items, count: this.items.length });
}
};
// Header подписывается на события корзины
EventBus.on('cart:changed', function(data) {
$('#cart-count').text(data.count);
});
// Оверлей корзины тоже подписывается
EventBus.on('cart:changed', function(data) {
renderCartItems(data.items);
});
URL как состояние: Hash Router
var Router = {
routes: {},
on: function(pattern, handler) {
this.routes[pattern] = handler;
},
navigate: function() {
var hash = window.location.hash.slice(1) || '/';
for (var pattern in this.routes) {
var regex = new RegExp('^' + pattern.replace(':id', '([^/]+)') + '$');
var match = hash.match(regex);
if (match) {
this.routes[pattern].apply(null, match.slice(1));
return;
}
}
},
go: function(path) { window.location.hash = path; }
};
window.addEventListener('hashchange', function() { Router.navigate(); });
window.addEventListener('DOMContentLoaded', function() { Router.navigate(); });
Router.on('/', function() { renderHomePage(); });
Router.on('/products', function() { renderProductList(); });
Router.on('/products/:id', function(productId) { renderProductDetail(productId); });
HTML5 pushState для URL без хеша требовал серверной поддержки:
# nginx для SPA-маршрутизации
location / {
try_files $uri $uri/ /index.html;
}
LocalStorage для постоянного состояния
var CartStore = {
items: JSON.parse(localStorage.getItem('cart_items') || '[]'),
add: function(product) {
this.items.push(product);
this._persist();
EventBus.trigger('cart:changed', { items: this.items });
},
_persist: function() {
try {
localStorage.setItem('cart_items', JSON.stringify(this.items));
} catch (e) {
// localStorage заполнен или недоступен (приватный режим)
console.warn('Не удалось сохранить корзину:', e.message);
}
}
};
try/catch был необходим - Safari в режиме приватного просмотра выбрасывал исключение при любой записи в localStorage. Это обнаружили в production после жалоб пользователей.
Что решил Redux
Когда Дэн Абрамов опубликовал Redux в 2015, он решил проблемы, которые мы заклеивали event bus и разрозненным состоянием:
Единственный источник истины: всё состояние в одном store.
Состояние только для чтения: нельзя изменять напрямую - только через dispatch actions. Отладка возможна, потому что можно воспроизвести последовательность действий.
Чистые функции-редьюсеры: переходы состояний - предсказуемые, тестируемые функции.
Оглядываясь на код с event bus и глобальным состоянием 2013 года с точки зрения Redux, понятно, чего не хватало: дисциплины. Паттерн event bus нормальный - это pub/sub. Проблема в том, что публикаторы могли мутировать общее состояние напрямую И публиковать события, так что состояние могло меняться, не проходя через event bus. Redux предотвращает прямую мутацию.
Словарь «однонаправленного потока данных», популяризированный Redux - это то, чего мы пытались достичь вручную в 2012-2013. На небольших приложениях нам в основном удавалось. На больших с несколькими участниками - разваливалось в спагетти.
Redux сделал архитектуру явной, документируемой и принудительной. Вот урок из эпохи до Redux: хорошие архитектурные идеи нуждаются в инструментах для принуждения, особенно по мере роста команд.