О насБлогКонтакты
Frontend разработка28 августа 2013 г. 4 мин 108Обновлено: 22 июня 2026 г.

Ранние одностраничные приложения: управление состоянием до появления Redux

AunimedaAunimeda
📋 Содержание

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: хорошие архитектурные идеи нуждаются в инструментах для принуждения, особенно по мере роста команд.

Читайте также

React против Angular 2: почему мы выбрали React для CRM-системыaunimeda
Frontend разработка

React против Angular 2: почему мы выбрали React для CRM-системы

В 2016 году мы две недели сравнивали React + Redux и Angular 2 для сложной CRM. Честный разбор: двустороннее связывание против однонаправленного потока данных, и что действительно важно при масштабировании.

Эпоха Backbone.js и Knockout.js: до того, как React и Angular захватили мирaunimeda
Frontend разработка

Эпоха Backbone.js и Knockout.js: до того, как React и Angular захватили мир

С 2010 по 2013 год мы строили одностраничные приложения на Backbone.js и Knockout.js. Никакого виртуального DOM, никаких компонентов, никакой сборки. Только jQuery, события и дисциплина.

React vs Vue vs Angular в 2026: что выбрать для проекта в Бишкекеaunimeda
Frontend разработка

React vs Vue vs Angular в 2026: что выбрать для проекта в Бишкеке

Честное сравнение трёх главных фреймворков от разработчика, работающего с ними ежедневно: React 19, Vue 3.5, Angular 19. Когда что брать, реальные примеры и рынок Кыргызстана.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в Кыргызстане. Бесплатная консультация.

Разработка сайтов

Получить консультацию Все статьи