Przykład praktyczny (2)

W przykładowej aplikacji - gry w kółko i krzyżyk - musieliśmy rozwiązać problem wspólnej pamięci dla komponentów tworzących pola planszy. Redux jest dobrą alternatywą dla zastosowanej pamięci obiektu (planszy).

Jak już wspomniano, musimy zdefiniować:

  • store – stan całej aplikacji
  • reducer – wyliczający nowy stan
  • akcje (dispacher)– czyli funkcje wywołujące pożądane zmiany (reducer dla określonego parametru).

Do tego potrzebujemy trochę „magii”. Poniższa struktura może służyć jako szablon (szkielet) implementacji. Ponieważ przykład jest stosunkowo prosty - pozostawiono szczegóły implementacyjne (całość: https://github.com/galicea/eduprog/tree/master/web/react/redux/xo).

1) Definiujemy identyfikatory akcji (src/types/index.js). Użycie stałych w miejsce nazw (łańcuchów znaków) chroni przed "literówkami":

export const ACTION_ZMIANA = 'ACTION_ZMIANA'

2) Pamięć (plik src/store.js):

import { createStore, } from 'redux';
import * as actionTypes from './types/index';

const StanAplikacji = {
  xo : [], // znak x / o
  kto : '',  // czyj ruch
  stanGry: ''// komunikat
};

function inicjujStanAplikacji() {
  return {
    xo: new Array(9).fill('?'),
    kto: 'x',
    stanGry: '* Kółko i Krzyżyk *'
  };
}

const reducer = (state, action) => {
    if (action.type === actionTypes.ACTION_ZMIANA) {
    let ix = action.y*3 + action.x;
    if (state.xo[ix] !== '?') return state;
    let nxo  = state.xo.slice(); // PŁYTKA KOPIA state.xo.
    nxo[ix] = state.kto;
    let nkto='x';
    let nstan = 'ruch: x';
    if (state.kto==='x') {
      nkto='o';
      nstan = 'ruch: o';
    }
    if (   ((nxo[0] !== '?') && (nxo[0]===nxo[1]) && (nxo[0]===nxo[2]))
         || ((nxo[3] !== '?') && (nxo[3]===nxo[4]) && (nxo[3]===nxo[5]))
         || ((nxo[6] !== '?') && (nxo[6]===nxo[7]) && (nxo[6]===nxo[8]))
         || ((nxo[0] !== '?') && (nxo[0]===nxo[3]) && (nxo[0]===nxo[6]))
         || ((nxo[1] !== '?') && (nxo[1]===nxo[4]) && (nxo[1]===nxo[7]))
         || ((nxo[2] !== '?') && (nxo[2]===nxo[5]) && (nxo[2]===nxo[8]))
         || ((nxo[0] !== '?') && (nxo[0]===nxo[4]) && (nxo[0]===nxo[8]))
         || ((nxo[2] !== '?') && (nxo[2]===nxo[4]) && (nxo[2]===nxo[6]))  ) {
         nstan = 'KONIEC';}
    return {
     ...state, // spread operator https://redux.js.org/recipes/using-object-spread-operator
      xo : nxo,
      kto : nkto,
      stanGry : nstan
    };

  } else {
     return state;
  }
}

export {StanAplikacji};
export const store = createStore(
  reducer,
  inicjujStanAplikacji(),
  // dla debuggera window.__REDUX_DEVTOOLS_EXTENSION__ &&
  // window.__REDUX_DEVTOOLS_EXTENSION__()
);

Kluczowe znaczenie ma reducer, który na podstawie stanu i akcji wypracowuje nowy stan. Struktura action zawiera wcześniej zdefiniowany identyfikator i ewentualne parametry (zobacz poniżej). W większych aplikacjach w miejsce if stosuje się:

switch (action.type) {
case ACTION_<NAZWA_AKCJI>:
default: return state;
}

Pamięć (store) twozymy funkcją createStore z parametrem reducera i początkową wartością..

Fragment wyliczenia nowego stanu może wygladac następująco:

return Object.assign({ }, state, { <NOWY STAN> });

3) Definiujemy kreatory akcji (plik actions/index.js):

import * as actionTypes from '../types/index';

export function zmiana(x,y : string) {
  return {
    type: actionTypes.ACTION_ZMIANA,
    x,
    y
  };
}

4) W aplikacji (App.js) łączymy kontekst z pamięcią Reduxa:

import React  from 'react';
import './App.css';
import XO, {Wynik} from './xo.js';
import { connect } from 'react-redux';


class App extends React.Component {

    render() {
        return (
            <table className="plansza">
                <tbody>
                <tr>
                    <XO x={0} y={0} c={this.props.data.xo[0]} />
                    <XO x={1} y={0} c={this.props.data.xo[1]} />
                    <XO x={2} y={0} c={this.props.data.xo[2]} />
                </tr>
                <tr>
                    <XO x={0} y={1} c={this.props.data.xo[3]} />
                    <XO x={1} y={1} c={this.props.data.xo[4]} />
                    <XO x={2} y={1} c={this.props.data.xo[5]} />
                </tr>
                <tr>
                    <XO x={0} y={2} c={this.props.data.xo[6]} />
                    <XO x={1} y={2} c={this.props.data.xo[7]} />
                    <XO x={2} y={2} c={this.props.data.xo[8]} />
                </tr>
                <tr>
                    <td colSpan="3"><Wynik msg={this.props.data.stanGry}/></td>
                </tr>
                </tbody>
            </table> );
    }
}


const mapStateToProps = state => {
    return {
        data: state
    }
};


export default connect(
    mapStateToProps
)(App);

Najważniejszą zmianą jest wykonanie"wstrzyknięcia" pamięci do własności obiektów - przy pomocy connect:

import {connect} from'react-redux';  
export default connect(  ... )(App);

Ponieważ mapujemy stan (state) Redux'a na własność o nazwie "data" - możemy się odwoływać do wartości tego stanu poprzez this.props.data....

Jest to coś, co nazywa się "wstrzykiwaniem" funkcjonalności do obiektu.

5). Implementacja XO zmieni się tak, by wywołać zdefiniowaną akcję.

Podobnie jak w przypadku App dodajemy tu funkcjonalność poprzez connect. Ale tym razem odwołanie do akcji.
Funkcja connect jako argumenty przyjmuje dwie funkcje zwyczajowo nazywane mapStateToProps i mapDispatchToProps

  • mapStateToProps — jako argument przyjmuje cały stan i musi zwrócić propsy dla danego komponentu
  • mapDispatchToProps — jako argument przyjmuje funkcję lub obiekt z action creators
import React  from 'react';
import {connect} from "react-redux";
import {bindActionCreators} from "redux";
import * as Akcje from './actions';

class Wynik extends React.Component {
    render() {
        return <span>{this.props.msg}</span>
    }
}

export {Wynik};

class XO extends React.Component {
    render() {
      return <td onClick={ () =>  this.props.zmiana(this.props.x,this.props.y) }>
                 {this.props.c}</td>
    }
}

const mapDispatchToProps = dispatch => bindActionCreators(
    Akcje,
    dispatch,
)

export default connect(
    null,
    mapDispatchToProps
)(XO);

W powyższym przykładzie należy dodatkowo zwrócić uwagę na użycie funkcji labda ( () => this.....). Taki zapis sprawia, że zmienna obiektowa this (jak i ewentualne inne zmienne obiektu) pochodzą z obiektu wywołującego. A więc this zawiera własności z XO. Przy innym zapisie, musielibyśmy użyć bind(this).

6) Ostatnią rzeczą, jaką musimy wykonać jest zmiana głównego renderowania (plik index.js) poprzez dodanie providera dostarczającego pamięć:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import { store } from './store.js'
import { Provider } from 'react-redux'
import App from './App';

ReactDOM.render(
<Provider store={store}>
<App klasa="plansza"/>
</Provider>,
document.getElementById('root')
);

Przed ponownym uruchomieniem (yarn start) wykonujemy:

yarn add redux
yarn add react-redux

Zobacz przykład [xo3]