Статьи

Глубокое погружение в Redux

Создание современных приложений со сложным состоянием сложно. Когда состояние меняется, приложение становится непредсказуемым и сложным в обслуживании. Вот тут и приходит Redux. Redux — легковесная библиотека, которая занимается состоянием. Думайте об этом как о государственной машине.

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

Для целей данного руководства я предполагаю умеренное знакомство с JavaScript, Node и npm.

Для начала вы можете инициализировать это приложение с помощью npm:

npm init 

Когда вас спросят о тестовой команде, идите вперед и поставьте jest . Это означает, что npm t запустит Jest и запустит все юнит-тесты. Основной файл будет index.js чтобы он был красивым и простым. Не стесняйтесь отвечать на остальные вопросы npm init к своему сердцу.

Я буду использовать TypeScript для проверки типов и закрепления модели данных. Это помогает понять, что мы пытаемся построить.

Чтобы начать работать с TypeScript:

 npm i typescript --save-dev 

Я буду хранить зависимости, которые являются частью рабочего процесса dev, в devDependencies . Это проясняет, какие зависимости предназначены для разработчиков, а какие — в prod. Когда TypeScript готов, добавьте start скрипт в package.json :

 "start": "tsc && node .bin/index.js" 

Создайте файл index.ts в папке src . Это отделяет исходные файлы от остальной части проекта. Если вы npm start , решение не будет выполнено. Это потому, что вам нужно настроить TypeScript.

Создайте файл tsconfig.json со следующей конфигурацией:

 { "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "outDir": ".bin", "sourceMap": true }, "files": [ "src/index" ] } 

Я мог бы поместить эту конфигурацию в tsc командной строки tsc . Например, tsc src/index.ts --strict ... Но гораздо чище пойти дальше и поместить все это в отдельный файл. Обратите внимание, что сценарию start в package.json нужна только одна команда tsc .

Вот разумные параметры компилятора, которые дадут нам хорошую отправную точку, и что означает каждый параметр:

  • строгий : включить все параметры строгой проверки типов, например, --noImplicitAny , --strictNullChecks и т. д.
  • lib : список библиотечных файлов, включенных в компиляцию
  • outDir : перенаправить вывод в этот каталог
  • sourceMap : генерировать исходный файл карты, полезный для отладки.
  • files : входные файлы, подаваемые в компилятор

Поскольку я буду использовать Jest для модульного тестирования, я добавлю его:

 npm i jest ts-jest @types/jest @types/node --save-dev 

Зависимость ts-jest добавляет проверку типа в среду тестирования. Один из способов — добавить конфигурацию jest в package.json :

 "jest": { "preset": "ts-jest" } 

Это делает так, чтобы среда тестирования выбирала файлы TypeScript и знала, как их переносить. Хорошая особенность — вы получаете проверку типов во время выполнения модульных тестов. Чтобы убедиться, что этот проект готов, создайте папку index.test.ts файлом index.test.ts . Затем выполните проверку работоспособности. Например:

 it('is true', () => { expect(true).toBe(true); }); 

Выполнение npm start и npm t теперь выполняется без ошибок. Это говорит нам о том, что мы готовы приступить к созданию решения. Но прежде чем мы это сделаем, давайте добавим Redux в проект:

 npm i redux --save 

Эта зависимость идет в продукт. Таким образом, нет необходимости включать его с --save-dev . Если вы проверяете свой package.json , он попадает в dependencies .

Система расчета заработной платы в действии

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

Итак, это оставляет нам следующие типы действий:

 const BASE_PAY = 'BASE_PAY'; const REIMBURSEMENT = 'REIMBURSEMENT'; const BONUS = 'BONUS'; const STOCK_OPTIONS = 'STOCK_OPTIONS'; const PAY_DAY = 'PAY_DAY'; 

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

Машинопись

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

Так, например, поместите это в src/index.ts :

 interface PayrollAction { type: string; amount?: number; } 

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

Этот интерфейс TypeScript должен сделать это:

 interface PayStubState { basePay: number; reimbursement: number; bonus: number; stockOptions: number; totalPay: number; payHistory: Array<PayHistoryState>; } 

PayStubState — сложный тип, то есть он зависит от контракта другого типа. Итак, определите массив payHistory :

 interface PayHistoryState { totalPay: number; totalCompensation: number; } 

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

Эта идея не сумасшедшая и не радикальная. Вот хорошее объяснение этому в Изучении Redux , Глава 1 (только для членов SitePoint Premium ).

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

Концептуализация движка с типами теперь помогает создавать следующие функции действий:

 export const processBasePay = (amount: number): PayrollAction => ({type: BASE_PAY, amount}); export const processReimbursement = (amount: number): PayrollAction => ({type: REIMBURSEMENT, amount}); export const processBonus = (amount: number): PayrollAction => ({type: BONUS, amount}); export const processStockOptions = (amount: number): PayrollAction => ({type: STOCK_OPTIONS, amount}); export const processPayDay = (): PayrollAction => ({type: PAY_DAY}); 

Что приятно, если вы попытаетесь выполнить processBasePay('abc') , средство проверки типов будет лаять на вас. Нарушение контракта типа добавляет непредсказуемость в контейнер состояния. Я использую контракт с одним действием, такой как PayrollAction чтобы сделать процессор расчета зарплаты более предсказуемым. amount заметки задается в объекте действия через сокращение свойства ES6. Более традиционный подход — это amount: amount , которая скучна. Функция стрелки, например () => ({}) , является одним из кратких способов написания функций, которые возвращают литерал объекта.

Редуктор как чистая функция

Для функций редуктора необходимо state и параметр action . state должно иметь начальное состояние со значением по умолчанию. Итак, вы можете представить, как может выглядеть наше начальное состояние? Я думаю, что нужно начинать с нуля с пустого списка истории выплат.

Например:

 const initialState: PayStubState = { basePay: 0, reimbursement: 0, bonus: 0, stockOptions: 0, totalPay: 0, payHistory: [] }; 

Средство проверки типов проверяет правильность значений, которые принадлежат этому объекту. С начальным состоянием начните создавать функцию редуктора:

 export const payrollEngineReducer = ( state: PayStubState = initialState, action: PayrollAction): PayStubState => { 

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

 let totalPay: number = 0; 

Обратите внимание, что можно изменять локальные переменные, если вы не изменяете глобальное состояние. Я использую оператор let для передачи этой переменной в будущем. Мутирующее глобальное состояние, такое как state или параметр action , приводит к загрязнению редуктора. Эта функциональная парадигма является критической, потому что функции редуктора должны оставаться чистыми. Если вы боретесь с этой парадигмой, ознакомьтесь с этим объяснением от JavaScript Новичок до ниндзя , глава 11 (только для членов SitePoint Premium ).

Запустите оператор switch редуктора для обработки первого варианта использования:

 switch (action.type) { case BASE_PAY: const {amount: basePay = 0} = action; totalPay = computeTotalPay({...state, basePay}); return {...state, basePay, totalPay}; 

Я использую оператор rest ES6, чтобы сохранить свойства состояния одинаковыми. Например, ...state . Вы можете переопределить любые свойства после оператора rest в новом объекте. basePay происходит от деструктуризации, которая во многом похожа на сопоставление с образцом в других языках. Функция computeTotalPay устанавливается следующим образом:

 const computeTotalPay = (payStub: PayStubState) => payStub.basePay + payStub.reimbursement + payStub.bonus - payStub.stockOptions; 

Обратите внимание, что вы stockOptions потому что деньги пойдут на покупку акций компании. Скажем, вы хотите обработать возмещение:

 case REIMBURSEMENT: const {amount: reimbursement = 0} = action; totalPay = computeTotalPay({...state, reimbursement}); return {...state, reimbursement, totalPay}; 

Так как amount является необязательным, убедитесь, что оно имеет значение по умолчанию, чтобы уменьшить количество неудач. Вот где сияет TypeScript, потому что средство проверки типов обнаруживает эту ловушку и лает на вас. Система типов знает определенные факты, поэтому она может делать обоснованные предположения. Скажем, вы хотите обрабатывать бонусы:

 case BONUS: const {amount: bonus = 0} = action; totalPay = computeTotalPay({...state, bonus}); return {...state, bonus, totalPay}; 

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

 case STOCK_OPTIONS: const {amount: stockOptions = 0} = action; totalPay = computeTotalPay({...state, stockOptions}); return {...state, stockOptions, totalPay}; 

Для обработки чека в день оплаты, он должен исключить бонус и возмещение. Эти два свойства не остаются в состоянии за зарплату. И добавьте запись в историю платежей. Базовая оплата и опционы на акции могут оставаться в состоянии, потому что они не меняются так часто в зависимости от зарплаты. Имея это в виду, вот как происходит PAY_DAY :

 case PAY_DAY: const {payHistory} = state; totalPay = state.totalPay; const lastPayHistory = payHistory.slice(-1).pop(); const lastTotalCompensation = (lastPayHistory && lastPayHistory.totalCompensation) || 0; const totalCompensation = totalPay + lastTotalCompensation; const newTotalPay = computeTotalPay({...state, reimbursement: 0, bonus: 0}); const newPayHistory = [...payHistory, {totalPay, totalCompensation}]; return {...state, reimbursement: 0, bonus: 0, totalPay: newTotalPay, payHistory: newPayHistory}; 

В массиве, подобном newPayHistory , используйте оператор spread , обратный к rest . В отличие от отдыха, который собирает свойства объекта, он распределяет элементы. Так, например, [...payHistory] . Хотя оба этих оператора выглядят одинаково, они не одинаковы. Посмотрите внимательно, потому что это может возникнуть в вопросе интервью.

Использование pop() в payHistory не payHistory состояние. Почему? Потому что slice() возвращает новый массив. Массивы в JavaScript копируются по ссылке. Присвоение массива новой переменной не меняет базовый объект. Поэтому нужно быть осторожным при работе с этими типами объектов.

Поскольку существует вероятность того, что lastPayHistory не определено, я использую lastPayHistory бедняков, чтобы инициализировать его нулем. Обратите внимание на (o && o.property) || 0 (o && o.property) || 0 шаблон для объединения. Возможно, в будущей версии JavaScript или даже TypeScript будет более элегантный способ сделать это.

Каждый редуктор Redux должен определять ветку по default . Чтобы убедиться, что состояние не становится undefined :

 default: return state; 

Тестирование функции редуктора

Одним из многих преимуществ написания чистых функций является то, что они тестируемы. Модульный тест — это тот, в котором вы должны ожидать предсказуемого поведения — до такой степени, что вы сможете автоматизировать все тесты как часть сборки. В __tests__/index.test.ts фиктивный тест и импортируйте все интересующие функции:

 import { processBasePay, processReimbursement, processBonus, processStockOptions, processPayDay, payrollEngineReducer } from '../src/index'; 

Обратите внимание, что все функции были настроены на export так что вы можете импортировать их. Для базовой платы запустите механизм расчета зарплаты и протестируйте его:

 it('process base pay', () => { const action = processBasePay(10); const result = payrollEngineReducer(undefined, action); expect(result.basePay).toBe(10); expect(result.totalPay).toBe(10); }); 

Redux устанавливает начальное состояние как undefined . Поэтому всегда полезно указывать значение по умолчанию в функции редуктора. Как насчет обработки возмещения?

 it('process reimbursement', () => { const action = processReimbursement(10); const result = payrollEngineReducer(undefined, action); expect(result.reimbursement).toBe(10); expect(result.totalPay).toBe(10); }); 

Шаблон здесь одинаков для обработки бонусов:

 it('process bonus', () => { const action = processBonus(10); const result = payrollEngineReducer(undefined, action); expect(result.bonus).toBe(10); expect(result.totalPay).toBe(10); }); 

Для опционов на акции:

 it('skip stock options', () => { const action = processStockOptions(10); const result = payrollEngineReducer(undefined, action); expect(result.stockOptions).toBe(0); expect(result.totalPay).toBe(0); }); 

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

 const computeTotalPay = (payStub: PayStubState) => payStub.totalPay >= payStub.stockOptions ? payStub.basePay + payStub.reimbursement + payStub.bonus - payStub.stockOptions : payStub.totalPay; 

Если работник не зарабатывает достаточно денег, чтобы купить акции компании, пропустите вывод. Также убедитесь, что он обнуляет stockOptions :

 case STOCK_OPTIONS: const {amount: stockOptions = 0} = action; totalPay = computeTotalPay({...state, stockOptions}); const newStockOptions = totalPay >= stockOptions ? stockOptions : 0; return {...state, stockOptions: newStockOptions, totalPay}; 

Исправление выясняет, достаточно ли их в newStockOptions . При этом модульные тесты проходят, и код становится здравым и имеет смысл. Мы можем проверить положительный вариант использования, где достаточно денег для вычета:

 it('process stock options', () => { const oldAction = processBasePay(10); const oldState = payrollEngineReducer(undefined, oldAction); const action = processStockOptions(4); const result = payrollEngineReducer(oldState, action); expect(result.stockOptions).toBe(4); expect(result.totalPay).toBe(6); }); 

В день оплаты проведите тестирование с несколькими состояниями и убедитесь, что разовые транзакции не сохраняются:

 it('process pay day', () => { const oldAction = processBasePay(10); const oldState = payrollEngineReducer(undefined, oldAction); const action = processPayDay(); const result = payrollEngineReducer({...oldState, bonus: 10, reimbursement: 10}, action); expect(result.totalPay).toBe(10); expect(result.bonus).toBe(0); expect(result.reimbursement).toBe(0); expect(result.payHistory[0]).toBeDefined(); expect(result.payHistory[0].totalCompensation).toBe(10); expect(result.payHistory[0].totalPay).toBe(10); }); 

Обратите внимание, как я настраиваю oldState для проверки bonus и возврата к нулю.

Как насчет ветки по умолчанию в редукторе?

 it('handles default branch', () => { const action = {type: 'INIT_ACTION'}; const result = payrollEngineReducer(undefined, action); expect(result).toBeDefined(); }); 

Redux устанавливает тип действия как INIT_ACTION в начале. Все, что нас волнует, — это то, что наш редуктор устанавливает некоторое начальное состояние.

Собираем все вместе

В этот момент вы можете начать задаваться вопросом, является ли Redux скорее шаблоном дизайна, чем что-либо еще. Если вы ответите, что это и шаблон, и легкая библиотека, то вы правы. В index.ts импортируйте Redux:

 import { createStore } from 'redux'; 

Следующий пример кода может обернуться вокруг этого оператора if . Это временный промежуток, поэтому модульные тесты не проникают в интеграционные тесты:

 if (!process.env.JEST_WORKER_ID) { } 

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

payrollEngineReducer магазин Redux с помощью payrollEngineReducer :

 const store = createStore(payrollEngineReducer, initialState); const unsubscribe = store.subscribe(() => console.log(store.getState())); 

Каждый store.subscribe() возвращает последующую функцию store.subscribe() полезную для очистки. Отписывает обратные вызовы, когда действия отправляются через магазин. Здесь я вывожу текущее состояние на консоль с помощью store.getState() .

Скажем, у этого сотрудника 300 , у него 50 возмещений, 100 бонусов и 15 на акции компании:

 store.dispatch(processBasePay(300)); store.dispatch(processReimbursement(50)); store.dispatch(processBonus(100)); store.dispatch(processStockOptions(15)); store.dispatch(processPayDay()); 

Чтобы сделать его более интересным, сделайте еще 50 возмещений и обработайте еще одну зарплату:

 store.dispatch(processReimbursement(50)); store.dispatch(processPayDay()); 

Наконец, запустите еще одну зарплату и отмените подписку в магазине Redux:

 store.dispatch(processPayDay()); unsubscribe(); 

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

 { "basePay": 300, "reimbursement": 0, "bonus": 0, "stockOptions": 15, "totalPay": 285, "payHistory": [ { "totalPay": 435, "totalCompensation": 435 }, { "totalPay": 335, "totalCompensation": 770 }, { "totalPay": 285, "totalCompensation": 1055 } ] } 

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

Вывод

Redux имеет простое решение сложной проблемы государственного управления. Он опирается на функциональную парадигму, чтобы уменьшить непредсказуемость. Поскольку редукторы являются чистыми функциями, модульное тестирование очень просто. Я решил использовать Jest, но любой тестовый фреймворк, который поддерживает базовые утверждения, тоже будет работать.

TypeScript добавляет дополнительный уровень защиты с помощью теории типов. Соедините проверку типов с функциональным программированием, и вы получите звуковой код, который вряд ли сломается. Лучше всего, TypeScript остается в стороне при добавлении значения. Если вы заметили, есть небольшое дополнительное кодирование, как только контракты типов будут в наличии. Проверка типов выполняет всю остальную работу. Как любой хороший инструмент, TypeScript автоматизирует кодирование, оставаясь невидимым. TypeScript поставляется с громким лаем, но нежным укусом.

Если вы хотите поиграть с этим проектом (и я надеюсь, что вы это сделаете), вы можете найти исходный код этой статьи на GitHub .