redux.md 18 KB

Redux

Зачем?

Вы могли заметить, что React не предоставляет (но и не навязывает) способов работы со слоем данных и/или бэкэндом на фронте. Поток данных в JSX через props не подходит для этого, так как одна и та же информация с бэка (например о текущем залогиненном пользователе) может применяться в отображении во множестве компонентов разных размеров и глубины вложенности. Попытки передать такую информацию через props из главного компонента App привели бы к сильному загрязнению кода JSX всякими props с данными и множественной копипасте.

Компоненты и хранилище.

Если вы сделали ДЗ, то вы смогли организовать AJAX к бэку напрямую из компонентов. Для несложных проектов из малого количества компонентов этот подход имеет право на жизнь и неплохо ложится на жизненный путь компонента. Однако в более сложных интерфейсах однотипные запросы будут в разных компонентах, да и в целом компоненты должны влиять друг на друга.

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

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

При наличии подобной общей точки для обмена данными между бэком и компонентами (и компонентами между собой) количество связей между частями приложения меньше чем при прямом обращении компонент-компонент.

Связи при наличии хранилища предполагают разделение ответственности:

  • Новые данные посылаются в хранилище потому что так надо; компонент, посылающий такое событие не волнует, как именно это изменение будет обработано.
  • Хранилище знает, как правильно обработать новые данные; Хранилищу не важно, откуда эти данные пришли, и куда уйдут;
  • Все компоненты, которые подписаны на этот вид данных, занимаются отображением и/или изменением логики своей работы, не заморачиваясь с тем, откуда этот сигнал пришел.

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

Как?

  • Само хранилище реализуется объектом, запрещенным к изменению напрямую;
  • Запрос на изменение хранилища присылается в форме объекта (action), который обрабатывается специальной функцией-редьюсером.
  • Редьюсер создает новый объект хранилища и возвращает его.
  • Хранилище обновляется и оповещает подписчиков.

Итого: минимальная реализация redux занимает строк 50.

React

Для связывания компонентов с redux используется подход HOC - high-order components, аналог функций высшего порядка, только для компонентов. Суть в том, что на базе вашего компонента создается компонент-обертка, которая передает в ваш компонент нужные части хранилища и функции для изменения хранилища используя props. Таким образом компонент автоматически отображает изменения в хранилище и имеет возможность послать запрос на изменение в хранилище.

Redux Minimal

npm install --save redux react-redux
import {Provider, connect}   from 'react-redux';
import {createStore, combineReducers} from 'redux';

let store = createStore((state, action) => { //единственный редьюсер данного хранилища
    if (state === undefined){ //redux запускает редьюсер хотя бы раз, что бы инициализировать хранилище
        return {counter: 0};  //обязательно вернуть новый объект, а не изменить текущий state
    }
    if (action.type === 'COUNTER_INC'){ //в каждом action должен быть type
        return {counter: state.counter +1} //создаем новый объект базируясь на данных из предыдущего состояния
    }
    if (action.type === 'COUNTER_DEC'){
        return {counter: state.counter -1}
    }
    return state; //редьюсеров может быть несколько, в таком случае вызываются все редьюсеры, но далеко не всегда action.type будет относится к этому редьюсеру. Тогда редьюсер должен вернуть state как есть. 
})

store.subscribe(()=> console.log(store.getState())) // подписка на обновления store

store.dispatch({
    type: 'COUNTER_INC'
})

store.dispatch({
    type: 'COUNTER_DEC'
})
  • createStore создаёт новое хранилище. В качестве параметра передается одна функция-редьюсер, которая обрабатывает все запросы на изменение хранилища
  • Редьюсер принимает текущее состояние хранилища и объект action - действие над хранилищем.
  • Когда запускается createStore редьюсер запускается с state = undefined для первоначальной инициализации хранилища
  • В action обязательно должно быть поле type
  • Редьюсер обязан возвращать каждый раз новый объект, а не модифицировать старый.
  • Если редьюсер не знает переданный тип действия, он должен вернуть state как есть.
  • метод subscribe позволяет добавить любое количество колбэков, которые будут вызваны после изменения хранилища
  • метод dispatch посылает объект-действие в хранилище для обработки редьюсером

Redux usual.

В обычной ситуации один редьюсер с большим количеством действий смотрится не очень. Посему в комплекте с redux идет функция combineReducers, которая:

  • воспринимает не один редьюсер (как createStore), а объект, ключами в котором являются ветви хранилища, а значениями - редьюсеры, работающие с этой ветвью.
  • Редьюсеры получают не весь state, а только свою ветвь.
  • Редьюсер возвращает не весь state, а только свою ветвь.
  • Однако редьюсеры получают все действия: и свои, и чужие. Посему не забываем возвращать state неизменным если действие не относится к этому редьюсеру.
import {Provider, connect}   from 'react-redux';
import {createStore, combineReducers} from 'redux';

let counterReducer = (state, action) => { //один из редьюсеров данного хранилища
    if (state === undefined){ //redux запускает редьюсер хотя бы раз, что бы инициализировать хранилище
        return {counter: 0};  //обязательно вернуть новый объект, а не изменить текущий state
    }
    if (action.type === 'COUNTER_INC'){ //в каждом action должен быть type
        return {counter: state.counter +1} //создаем новый объект базируясь на данных из предыдущего состояния
    }
    if (action.type === 'COUNTER_DEC'){
        return {counter: state.counter -1}
    }
    return state; //редьюсеров может быть несколько, в таком случае вызываются все редьюсеры, но далеко не всегда action.type будет относится к этому редьюсеру. Тогда редьюсер должен вернуть state как есть. 
}

let booleanReducer = (state, action) => { //второй редьюсер
    if (state === undefined){
        return {boolean: false}
    }
    if (action.type === 'BOOLEAN_SET'){ 
        return {boolean: true} 
    }
    if (action.type === 'BOOLEAN_RESET'){
        return {boolean: false}
    }
    if (action.type === 'BOOLEAN_TOGGLE'){
        return {boolean: !state.boolean}
    }
    return state; //редьюсеров может быть несколько, в таком случае вызываются все редьюсеры, но далеко не всегда action.type будет относится к этому редьюсеру. Тогда редьюсер должен вернуть state как есть. 
}

const reducers = combineReducers({ //создаем функцию-обертку, которая запустит последовательно counterReducer и booleanReducer передав им ветви c и b хранилища и обновив эти же ветви в случае нового состояния.
    c: counterReducer,
    b: booleanReducer
})

const store = createStore(reducers);

store.subscribe(()=> console.log(store.getState())) // подписка на обновления store, теперь тут две ветви.

store.dispatch({
    type: 'COUNTER_INC'
})

store.dispatch({
    type: 'BOOLEAN_SET'
})

store.dispatch({
    type: 'COUNTER_DEC'
})

store.dispatch({
    type: 'BOOLEAN_TOGGLE'
})

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

actionCreators

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

function actionInc(){
    return {
        type: 'COUNTER_INC'
    }
}


function actionDec(){
    return {
        type: 'COUNTER_DEC'
    }
}

store.dispatch(actionInc())
store.dispatch(actionDec())

React-Redux

Компоненты связываются с redux гибким способом:

  • Все компоненты, которые вы хотите присоединить к тому или иному хранилищу должны находится внутри <Provider store = { someStore } />. Таким образом вы можете переподключить все вложенные компоненты к другому хранилищу в одном месте. Хотя обычно предполагается что хранилище одно на приложение.
  • При создании компонента-обертки, связанного с redux вы можете указать какие части хранилища подключить к каким props; а так же передать через props набор функций, создающих действия (actionCreator), для отправки данных из компонента в хранилище.

Таким образом:

  • Всегда можно поменять store в <Provider /> и переподключить компонент на совершенно другое хранилище
  • Всегда можно отладить ваш компонент просто подсовывая ему литерально props, таким образом просимулировав работу с redux.
class Counter extends Component{
    render(){
        return (
            <div>
                <button onClick={this.props.actionInc}>+</button>
                <span>{this.props.counter}</span>
                <button onClick={this.props.actionDec}>-</button>
            </div>
        );
    }
}

let mapStateToProps = state => ({counter: state.c.counter}) //функция должна из state достать нужное значение, которое попадет в props
let mapDispatchToProps = {actionInc, actionDec};     //actionCreator-ы, тут переданные, автоматом оборачиваются в dispatch

let ConnectedCounter = connect(mapStateToProps,mapDispatchToProps)(Counter) //connect возвращает функцию, которая из любого компонента сделает компонент с props из mapStateToProps и mapDispatchToProps

let ConnectedViewCounter = connect(mapStateToProps)(props => <div>{props.counter}</div>) 


class App extends Component {
  render() {
    let counter = 0;
    return (
    <Provider store = {store} >
      <div className="App">
        <Counter counter={counter}
                actionInc={()=>console.log('тут бы inc и setProps')}
                actionDec={()=>console.log('тут бы dec и setProps')}
        />
        <ConnectedCounter />
        <ConnectedViewCounter />
      </div>
    </Provider>
    );
  }
}

Компонет Counter из примера выше готов принять через props 3 параметра - функции для увеличения и уменьшения счетчика, и само значение счетчика. Если его использовать напрямую, то каждый раз придется эти параметры явно указывать, что и проиллюстрировано.

Используя connect можно получить компонент-обертку (тут этот класс хранится в переменной ConnectedCounter), который будет использовать ваш компонент, но передавать ему нужные данные из store и обеспечивать возможностью отправить данные в store.

connect

  • Первый параметр - функция, которая собирает нужную информацию из хранилища (в функцию передается текущее состояние хранилища) и возвращает объект с ключами, которые вам нужны в props.`
  • Второй параметр - объект, в котором ключами будут опять же свойства в props, а значениями - actionCreator-ы. connect автоматически обернет ваши actionCreator в dispatch, после чего можно спокойно вызывать this.props.actionName() - это вызовет store.dispatch(actionName())

connect возвращает функцию, которая из любого компонента сделает компонент-обертку с вышеуказанными настройками.

На самом деле connect более всеяден в плане параметров, но вы всегда можете это загуглить.

ConnectedViewCounter

Данный компонент сделан прямо на месте (литерально). Функции-результату connect передается не имя класса, а компонент-функция. Данный компонент иллюстрирует, что теперь два компонента на странице связаны с общими данными и работают синхронно, не зная друг о друге.

Почитать

https://getinstance.info/articles/react/learning-react-redux/