С ростом сложности веб-приложений усложняется задача обновления и вывода их базовых данных. Многие подходы по управлению этими данными приводят к сложной паутине представлений. Эти представления могут прослушивать обновления различных моделей, отправляющих свои изменения еще большему количеству представлений. Все это оставляет разработчиков с непрозрачным и непредсказуемым кодом, который практически невозможно изменить, не забыв при этом прикрепить прослушиватель к какому-нибудь важному элементу. Еще хуже, что при этом разработчик может добавить баг в другом, на первый взгляд, совершенно не связанном участке приложения. И вот здесь самое время представить Redux — предсказуемый контейнер состояния для приложения на JavaScript, предлагающий решение этой проблемы.

Redux основан на 3 главных концепциях:

  1. Существует единственный источник правды для всего состояния приложения.
  2. Это состояние только для чтения.
  3. Все изменения в состоянии приложения делаются чистыми функциями.

При использовании в сочетании с лучшими практиками, разработанными сообществом, эти принципы позволяют создавать поддерживаемые и простые в тестировании приложения к радости разработчиков.

Основные концепции

1. Единственный источник истины

Когда вы используете Redux, все основные данные всего приложения представляются единственным объектом JavaScript, ссылающимся на состояние или дерево состояния. Этот объект может быть простым или сложным в зависимости от требований вашего приложения. Например, состояние простого приложения со списком задач может быть простым массивом с объектами задач.

const state = [
    {
        id: 1,
        task: 'Do laundry',
        completed: true
    },
    {
        id: 2,
        task: 'Paint fence',
        completed: false
    }
];

Состоянием для сайта социальных медиа может быть ассоциативный массив, содержащий информацию о постах, уведомлениях, профиле пользователя и иные социальные данные.

const defaultState = {
    posts: [
        // post objects to appear in user's feed
    ],
    notifications: [
        // unread notifications for the user
    ],
    messages: [
        // new messages
    ],
    friends: [
        // other online users
    ],
    profile: null
}

Независимо от размера приложения, все данные состояния хранятся в одном объекте. О технике управления им в большом приложении будет сказано позднее.

2. Состояние только для чтения

Уровень представлений никогда не будет напрямую манипулировать состоянием вашего приложения. Например, обработчик добавления задачи к списку не сможет непосредственно добавить новое задание в массив. Вместо этого обработчик отправит действие, говорящее: “Привет, приложение. Пора добавить задачу “Купить молока” в массив с заданиями.”

Действие в Redux это простой объект JavaScript, выражающий намерение изменить состояние объекта. Он содержит минимальную информацию, требуемую для описания того, что должно измениться в результате действий пользователя. Единственный обязательный атрибут действия это его тип, все остальные данные, включенные в действие, будут специфичны для конкретного приложения и типа произведенного действия. Когда пользователь добавляет задачу “Купить молока”, действие будет выглядеть так:

{
    type: 'ADD_TODO',
    task: 'Buy milk',
    id: 3
}

Пользовательский интерфейс→действия→состояние

3. Изменения производятся чистыми функциями

Итак, что происходит с действиями после того, как они были отправлены пользовательским интерфейсом? Для прослушивания действий есть отдельная функция. Это по сути один большой переключатель switch, реагирующий на поле type действия. Каждое действие, которое может возникнуть в вашем приложении, нуждается в соответствующем case, с функцией рассчитывающей новое состояние приложения на основе текущего состояния и данных действия. И эта функция должна быть чистой. Если вы не знакомы с понятием чистые функции, то можете посмотреть видео, где о них рассказывает Дэн Абрамов, создатель Redux.

Функция является чистой, когда она всегда возвращает одинаковое значение для одинакового набора аргументов.

В чистой функции аргументы A и B всегда ведут к результату C. Если функция не является чистой, то она, получив A и B, может вернуть не только C, но и D. Результат чистой функции предопределен ее входными аргументами и ничем больше. У чистых функций нет побочных эффектов, так как они не совершают сетевых запросов или запросов к базам данных. Также чистые функции не модифицируют переданные им аргументы — вместо этого они рассчитывают результат и возвращают его.

Продолжим с нашим списком задач: функция для действия 'ADD_TODO' не добавляет новое значение в массив заданий. В таком случае она не будет чистой, так как модифицирует массив. Вместо этого функция для 'ADD_TODO' создаст копию массива задач, добавит новое задание в конец этого массива и затем вернет новый массив как новое состояние приложения.

(currentState, action) => {
    switch(action.type){
        case 'ADD_TODO':
            const nextState = [
                ...currentState,
                {
                    id: action.id,
                    task: action.task,
                    completed: false
                }
            ];
            return nextState;
            break;
        default:
            return currentState;
    }
};

Эта чистая функция знающая, как трансформировать текущее состояние приложения со всеми действиями в обновленное состояние приложения, называется корневой редуктор (преобразователь). Тот факт, что корневой редуктор рассчитывает следующее состояние вместо того, чтобы модифицировать текущее, очень важен в Redux. С использованием этого паттерна расчеты состояния остаются быстрыми, так как мы можем просто передать ссылку на любой неизмененный фрагмент данных текущего состояния следующему состоянию. Также мы получаем безопасность за счет объявления нашего состояния иммутабельным, зная, что оно не может быть модифицировано чем-либо, кроме цепочки действие→редуктор.

Круг:состояние→интерфейс пользователя→действия→редукторы

Лучшие практики

Теперь, после рассмотрения базовых концепций Redux, я покажу некоторые из лучших практик, которые улучшат код вашего приложения Redux. Они почерпнуты из видео-серий Дэна Абрамова, документации Redux и опыта моей команды разработчиков по работе над приложениями Redux.

Форма состояния

Плоские (одноуровневые) объекты

Сохранение одноуровневой структуры приложения это отличный способ уменьшить сложность и сделать более легкой разработку, поддержку и отладку. Это таже идея, что лежит в основе нормализации баз данных. Предположим, ваш список задач будет редактироваться миллионом людей и вы хотите отслеживать, кто создал каждую конкретную задачу. Вы могли бы представить эти данные, добавив объект “автор” к каждой задаче:

const state = [
    {
        id: 1,
        task: 'Do laundry',
        completed: true,
        author: {
            name: 'Billy Bob',
            role: 'Assistant Editor'
        }
    },
    {
        id: 2,
        task: 'Paint fence',
        completed: false,
        author: {
            name: 'Billy Bob',
            role: 'Assistant Editor'
        }
    }
];

Это подойдет для маленького приложения, не отягощенного движущимися частями, но нам стоит сделать объекты задач одноуровневыми, если мы работаем над большим приложением, нуждающемся в легкости развития и поддержки. Мы можем вынести из массива задач массив с данными автора и использовать идентификатор автора для ссылки на него.

const state = [
    todos: [
        {
            id: 1,
            task: 'Do laundry',
            completed: true,
            authorId: 1
        },
        {
            id: 2,
            task: 'Paint fence',
            completed: false,
            authorId: 1
        }
    ],
    authorsById: {
        1: {
            id: 1,
            name: 'Billy Bob',
            role: 'Assistant Editor'
        }
    }
];

Мы можем сделать еще один шаг вперед и создать отдельный объект, с индексными идентификаторами для наших задач. Теперь наш список задач будет представляться в виде простого массива идентификаторов.

const state = {
    todos: [1, 2],
    todosById: {
        1: {
            id: 1,
            task: 'Do laundry',
            completed: true,
            authorId: 1
        },
        2: {
            id: 2,
            task: 'Paint fence',
            completed: false,
            authorId: 1
        }
    },
    authorsById: {
        1: {
            id: 1,
            name: 'Billy Bob',
            role: 'Assistant Editor'
        }
    }
};

С такой одномерной структурой у нас остается лишь одно место, которое нуждается в обновлении при изменении основных данных приложения. Разработчики могут быть уверены, что изменения, внесенные в одной части приложения (например, в данных автора) не сломают другую часть приложения (например, порядок в списке задач). Это также делает простым создание нескольких представлений для этих данных и вывод их разными способами.

Если вы окажетесь в поисках способа сделать одноуровневыми запросы JSON API для хранения состояния вашего приложения, вам стоит использовать библиотеку Normalizr, помогающую делать JSON одноуровневым.

Действия

Сохраняйте действия небольшими! Каждое действие должно содержать только минимальную информацию, необходимую для трансформирования состояния приложения. Например, каждая задача в нашем приложении включает логическое значение completed. Так как мы знаем, что поле completed всегда будет в значении false для новой задачи, нам не нужно задавать это поле в действии 'ADD_TODO'.

Также в приложениях React распространен перенос логики для создания действий из кода представлений приложения в функции, которые могут использоваться разными частями приложения. Эти извлеченные функции называются создатели действий. Они должны располагаться отдельно от ваших представлений и редукторов. Создатели действий очень удобны для создания документации, так как они предоставляют полный список действий, которые ваши компоненты могут производить для модификации состояния приложения.

Кнопка, добавляющая задачу в ваше приложение Redux может выглядеть как эта, без использования создателей действий.

<button onclick="dispatch({ type: 'ADD_TODO', task: 'Walk dog', id: nextTodoId++ })">Add Walk Dog Todo</button>
<script>
    // Redux setup code would go here
    let nextTodoId = 0;
</script>

Примечание: dispatch, функция объекта-хранилища Redux, — это то, что вы используете для создания действий по всему приложению. Вот короткое видео, объясняющее, как включить Redux в ваш проект и настроить первоначальный объект-хранилище. Я включу код для настройки хранилища Redux чуть позднее.

Та же логика приложения будет выглядеть следующим образом при использовании создателей действий (в примере создателем действия является функция addTodo).

<button onclick="dispatch(addTodo('Walk dog'))">Add Walk Dog Todo</button>

<script>
    // Redux setup code would go here
    let nextTodoId = 0;
    const addTodo = (task) => {
        return {
            type: 'ADD_TODO',
            id: nextTodoId++,
            task
        };
    };
</script>

Заметили, что кнопке добавления задачи уже не нужно знать следующий идентификатор задачи? Этой информацией теперь занимается создатель действия addTodo, что дает возможность добавлять объекты задач другим представлениям (элементам пользовательского интерфейса). Дополнительно создатель действия addTodo упрощает добавление кнопки ‘Feed Cat’. Скрипт, содержащий создателя действия предоставляет список действий, доступных для наших представлений.

Так как наши редукторы должны быть чистыми, создатели действий это хорошее место для кода с побочными эффектами или вызовами асинхронных функций. И, так как наши создатели действий отделены от логики представлений, это упрощает тестирование всего приложения.

Редукторы

По мере роста вашего приложения также будет расти и его корневой редуктор, обрабатывающий все виды действий. Чтобы идти в ногу с этим ростом, корневой редуктор вашего приложения может передать управление различными частями состояния приложения другим, специализированным редукторам. В нашем примере списка задач корневой редуктор может передать объект с задачами и объект с авторами специализированным редукторам. Этот паттерн разделения и передачи функционала называется композиция редуктора. Это помогает масштабировать разработку, так как ясно разделяет логику приложения на фрагменты, позволяя разным разработчикам заниматься разными фрагментами.

Чтобы оставить этот пример простым, я собираюсь просто включить массив авторов и задач в состояние. Но, как мы уже обсудили, в больших приложениях будет лучше хранить эти данные в объектах с индексными идентификаторами.

В самом начале мы создали редуктор для управления задачами в приложении:

const todos = (currentState = [], action) => {
    switch(action.type){
        case 'ADD_TODO':
            const nextState = [
                ...currentState,
                {
                    id: action.id,
                    task: action.task,
                    completed: false
                }
            ];
            return nextState;
            break;
        default:
            return currentState;
    }
};

Теперь нам надо создать редуктор для управления объектами авторов. Он может выглядеть примерно так:

const authors = (currentState = [], action) => {
    switch(action.type) {
        case 'ADD_AUTHOR':
            const nextState = [
                ...currentState,
                {
                    id: action.id,
                    name: action.name,
                    role: action.role
                }
            ];
            return nextState;
            break;
        default:
            return currentState;
    }
};

Чтобы совместить все это, мы создадим корневой редуктор, объединяющий объекты с соответствующими редукторами в едином объекте состояния.

const todoApp = (currentState = {}, action) => {
    return {
        todos: todos(currentState.todos, action),
        authors: authors(currentState.authors, action),
    }
};

Функция todoApp это наш корневой редуктор, передающий ветку задач и ветку авторов из состояния приложения специализированным редукторам. Этот пример показывает композицию редуктора с использованием целых объектов, но вы можете делать то же самое с массивами и их содержимым. Один редуктор будет знать, как добавить и удалить элементы из массива, а другой редуктор будет знать, как обновить отдельные элементы в массиве. В этом паттерне редуктор “родительского” массива вызывает редуктор элемента, когда ему нужно добавить или модифицировать элементы. В этом видео подробно описана такая разновидность композиции.

Редуктор todoApp это отдельная чистая функция, трансформирующая текущее состояние и действие в следующее состояние приложения. Этот редуктор используется Redux для создания хранилища приложения. У нас крайне простая страница с двумя кнопками, использующая создателей действий и композицию редукторов для добавления авторов и задач в состояние приложения. Если вы сохраните эту страницу в html и откроете в браузере, то сможете добавлять задания и авторов, обозревая состояние приложения в консоли.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Super Simple Redux Example</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/redux/3.5.2/redux.js"></script>
</head>
<body>
    <button onclick="store.dispatch(addTodo('Walk dog')); console.log(store.getState());">Add Walk Dog Todo</button>
    <button onclick="store.dispatch(addAuthor('Billy Bob', 'Assistant Editor')); console.log(store.getState());">Add Billy Bob Author</button>

    <script>
        // Action Creators
        let nextTodoId = 0;
        const addTodo = (task) => {
            return {
                type: 'ADD_TODO',
                id: nextTodoId++,
                task
            };
        };
        let nextAuthorId = 0;
        const addAuthor = (name, role) => {
            return {
                type: 'ADD_AUTHOR',
                id: nextAuthorId++,
                name,
                role,
            };
        };
    </script>
    <script>
        // Reducers
        const todos = (currentState = [], action) => {
            switch(action.type){
                case 'ADD_TODO':
                    const nextState = [
                        ...currentState,
                        {
                            id: action.id,
                            task: action.task,
                            completed: false
                        }
                    ];
                    return nextState;
                    break;
                default:
                    return currentState;
            }
        };
        const authors = (currentState = [], action) => {
            switch(action.type) {
                case 'ADD_AUTHOR':
                    const nextState = [
                        ...currentState,
                        {
                            id: action.id,
                            name: action.name,
                            role: action.role
                        }
                    ];
                    return nextState;
                    break;
                default:
                    return currentState;
            }
        };
        const todoApp = (currentState = {}, action) => {
            return {
                todos: todos(currentState.todos, action),
                authors: authors(currentState.authors, action),
            }
        };
    </script>
    <script>
        // Redux setup
        const { createStore } = Redux;
        const store = createStore(todoApp);
    </script>
</body>
</html>

Напомним краткое содержание лучших практик Redux, о которых мы рассказали:

  1. Сохраняйте объект состояния одноуровневым.
  2. Передавайте минимально возможное количество данных в действиях.
  3. Используйте создателей действий для отправки действий вместо того, чтобы создавать и отправлять их непосредственно из представлений.
  4. Ваш корневой редуктор должен быть составлен из меньших редукторов, управляющих отдельными частями состояния приложения.

Учитывайте эти советы при проектировании и создании приложений с Redux и вы не проиграете.

Тестирование

Написание тестов для приложения Redux оставляет довольно приятные впечатления. Чистые редукторы облегчают понимание того, какой результат должен быть у действия, а создатели действий облегчают изоляцию и тестирование логики приложения, задействованной в ваших представлениях. Я собираюсь использовать библиотеку expect, но это не единственный фреймворк, который вы можете выбрать для тестирования приложения. В документации Redux рекомендуется использовать Mocha в качестве движка для тестирования.

Создатели действий

При тестировании создателей действий в приложении, нам надо обеспечить с их стороны создание правильных действий. Это простая задача, так как создатели действий возвращают простые объекты JavaScript. Вот тест для создателя действий addTodo:

const taskText = 'Walk dog';
const expectedAction = {
    type: 'ADD_TODO',
    task: taskText,
    id: 0
};
expect(addTodo(taskText)).toEqual(expectedAction);

Редукторы

Для тестирования редукторов приложения, просто обеспечьте, чтобы следующее состояние было ожидаемым состоянием, исходя из текущего состояния и конкретного действия. Передача в корневой редуктор undefined в качестве текущего состояния это хороший способ обеспечить правильную настройку первоначального состояния. В наших предыдущих примерах мы использовали пустой массив в качестве текущего состояния по умолчанию для редукторов задач и авторов. Мы можем проверить это следующим тестом:

const initialState = {
    todos: [],
    authors: []
};
expect(todoApp(undefined, {})).toEqual(initialState);

И вот простой тест для проверки корректного добавления автора (Billy Bob) в состояние приложения:

const initialState = {
    todos: [],
    authors: []
};
const newAuthor = {
    name: 'Billy Bob',
    role: 'Assistant Editor',
    id: 0
};
const addAuthorAction = {
    type: 'ADD_AUTHOR',
    name: newAuthor.name,
    role: newAuthor.role,
    id: newAuthor.id
};
expect(todoApp(initialState, addAuthorAction)).toEqual({
    todos: [],
    authors: [ newAuthor ]
});

Самая главная вещь в редукторах это то, что они чистые функции. Поэтому в дополнение к проверке содержания в полученном объекте состояния ожидаемых данных, мы должны также обеспечить то, что наши редукторы не изменяют объект состояния. Мы можем сделать это, вызвав заморозку наших объектов перед передачей редукторам. Таким образом, если они попытаются изменить состояние, наши тесты скажут об этом. Deep freeze это отличная служебная библиотека для рекурсивного вызова метода JavaScript Object.freeze() на нашем объекте состояния.

const initialState = {
    todos: [],
    authors: []
};
const newAuthor = {
    name: 'Billy Bob',
    role: 'Assistant Editor',
    id: 0
};
const addAuthorAction = {
    type: 'ADD_AUTHOR',
    name: newAuthor.name,
    role: newAuthor.role,
    id: newAuthor.id
};
deepFreeze(initialState);
expect(todoApp(initialState, addAuthorAction)).toEqual({
    todos: [],
    authors: [ newAuthor ]
});

Заключение

Такой вот Redux, если описать его кратко. Это отличное решение для управления состоянием веб-приложения, особенно, если вы сталкивались с проблемами альтернативных методов. Единственный источник истины только для чтения, чистые функции редукторы и легкотестируемые компоненты безусловно повысят уверенность и производительность любого разработчика JavaScript. И так как он не привязан к конкретному движку представления (хотя он обычно используется с React), вы и ваша команда можете легко подключить Redux в существующий стек разработки, если в прошлых пректах сталкивались с проблемами при управлении состоянием приложения.