formations/developpement/framework_web/presentation/slides.md

26 KiB
Raw Permalink Blame History

marp
true

React + Redux

William Petit - S.C.O.P. Cadoles


Amorçage d'un projet


Méthode simplifiée: create-react-app

npm install -g create-react-app # Installation du générateur d'application
create-react-app my-app 	# Génération de l'application
cd my-app			# Se placer dans le répertoire
npm start			# Lancer le serveur de développement

https://github.com/facebook/create-react-app


Méthode manuelle

Installation des dépendances

mkdir my-app				# Création du répertoire de l'application
cd my-app				# Se placer dans le répertoire
npm init				# Initialiser le projet NPM
					# Installation des dépendances NPM
npm install --save \
	react react-dom react-hot-loader \
	webpack webpack-dev-server \
	webpack-cli \
	babel-core babel-loader  \
	babel-preset-env babel-preset-react \
	babel-preset-stage-2 \
	style-loader file-loader css-loader \
	html-webpack-plugin clean-webpack-plugin \
	uglifyjs-webpack-plugin webpack-merge
    

Configuration de Webpack

// my-app/webpack.common.js

const webpack = require('webpack')
const HTMLPlugin = require('html-webpack-plugin')

module.exports = {
  entry: {
    main: './src/index.js'
  },
  module: {
    rules: [
      { test: /\.(js|jsx)$/, exclude: /node_modules/, use: ['babel-loader'] },
      { test: /\.(scss|sass)$/, use: ['style-loader', 'css-loader'] },
      { test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)(\?.*$|$)/, use: ['file-loader'] }
    ]
  },
  resolve: {
    extensions: ['*', '.js', '.jsx']
  },
  plugins: [
    new webpack.ProvidePlugin({
      'React': 'react'
    }),
    new HTMLPlugin({
      title: 'MyApp',
      hash: true,
      template: 'src/index.html'
    })
  ]
}

// my-app/webpack.dev.js

const webpack = require('webpack')
const common = require('./webpack.common.js')
const merge = require('webpack-merge')
const path = require('path')

module.exports = merge(common, {
  devtool: 'inline-source-map',
  mode: 'development',
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ],
  output: {
    path: path.join(__dirname, 'public'),
    publicPath: '/',
    filename: '[name].[hash].js'
  },
  devServer: {
    contentBase: './public',
    hot: true
  }
})

// my-app/webpack.prod.js

const common = require('./webpack.common.js')
const merge = require('webpack-merge')
const path = require('path')
const webpack = require('webpack')
const CleanWebpackPlugin = require('clean-webpack-plugin')
const UglifyJSPlugin = require('uglifyjs-webpack-plugin')

module.exports = merge(common, {
  mode: 'production',
  output: {
    path: path.join(__dirname, 'dist'),
    publicPath: '/',
    filename: '[name].[chunkhash].js'
  },
  devtool: 'source-map',
  plugins: [
    new CleanWebpackPlugin(['dist']),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    }),
    new UglifyJSPlugin({
      sourceMap: true
    })
  ]
})

Compléter le fichier package.json

// my-app/package.json

{

  "scripts": {
    "start": "webpack-dev-server --progress --colors --config webpack.dev.js",
    "build": "webpack --progress --colors --config webpack.prod.js"
  },
  
  // ...
  
  "babel": {
    "presets": [
      "env",
      "react",
      "stage-2"
    ],
    "plugins": ["react-hot-loader/babel"]
  }

}

Création des fichiers source de base

<!-- my-app/src/index.html -->

<!DOCTYPE html>
<html>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>MyApp</title>
  <% for (var css in htmlWebpackPlugin.files.css) { %>
  <link href="<%= htmlWebpackPlugin.files.css[css] %>" rel="stylesheet">
  <% } %>
  <body>
    <div id="app"></div>
  </body>
</html>

https://github.com/jantimon/html-webpack-plugin


// my-app/src/index.js

import ReactDOM from 'react-dom'
import App from './app'

ReactDOM.render(<App />, document.getElementById('app'))
// my-app/src/app.js

import { Component } from 'react'
import { hot } from 'react-hot-loader'

class App extends Component {
  render () {
    return <h1>Hello World !</h1>
  }
}

export default hot(module)(App)

Lancer le serveur de développement

npm start 	# Puis ouvrir http://localhost:8080 dans votre navigateur

Générer une version pour la production

npm run build 	# Les fichiers générés seront dans le répertoire my-app/dist

Les composants


Différentes notations

Classe ES6

// my-app/src/components/hello-world.js
import React from 'react'

class HelloWorld extends React.Component {

  render() {
    return (
      <div className='hello-world'>
        <h1>Hello World</h1>
        <p>Welcome to React</p>
      </div>
    )
  }

}

export default HelloWorld

Notation fonctionnelle

// my-app/src/components/hello-world.js

const HelloWorld = () => {
  return (
    <div className='hello-world'>
      <h1>Hello World</h1>
      <p>Welcome to React</p>
    </div>
  )
}

export default HelloWorld

La syntaxe JSX

C'est une représentation proche du XML d'une arborescence de composants. Il ne faut pas faire d'amalgame, ce n'est pas du HTML.

La définition des attributs propres au DOM se fait via le nom de la propriété Javascript, pas de l'attribut HTML. Par exemple l'attributclass en HTML devient className en JSX.

Il en est de même pour la définition des styles.


props

Elles permettent de passer des données aux composants.

// my-app/src/components/props-example.js

import React from 'react'

class PropsExample extends React.Component {

  render() {
    return (
      <span>{ this.props.text }</span>
    )
  }

}

export default PropsExample

// Exemple d'utilisation

// <PropsExample text="foo bar" />


props.children

La propriété children permet de récupérer les composants 'enfants' qui pourraient être passés au composant lors de son rendu.

// my-app/src/components/my-list.js

import React from 'react'

class MyList extends React.Component {

  render() {
    return (
      <ul>
        { this.props.children }
      </ul>
    )
  }

}

export default PropsExample


// Exemple d'utilisation

<MyList>
  <li>Item 1</li>
  <li>Item 2</li>
</MyList>


state

La propriété state d'un composant représente l'état (au sens données) de celui ci.

// my-app/src/components/clock.js

import React from 'react'

export default class Clock extends React.Component {

  constructor(props) {
    super(props)
    // Initialisation du "state" du composant
    this.state = {
      time: new Date()
    }
    // On appelle la méthode tick() du composant
    // toutes les secondes
    setInterval(this.tick.bind(this), 1000);
  }
	
  // Méthode de rendu du composant
  render() {
    return (
      <div>Time: { this.state.time.toString() }</div>
    )
  }
  
  // La méthode tick() met à jour le state du composant avec
  // la date courante
  tick() {
    this.setState({ time: new Date() });
  }

}

Événements du DOM

Les événements du DOM sont interceptés par le passage de "callbacks" sur les propriétés on<Event> des composants.


// my-app/src/components/counter.js

import React from 'react'

export default class Counter extends React.Component {
  constructor(props) {
    super(props)
    // Initialisation du "state" du composant
    this.state = {
      count: 0
    }
    // On "lie" les méthodes de la classe à l'instance
    this.increment = this.increment.bind(this)
    this.decrement = this.decrement.bind(this)
  }
  // Méthode de rendu du composant
  render() {
    return (
      <div>
      	Count: <span>{ this.state.count }</span>&nbsp;
        <button onClick={ this.increment }>+1</button>
        <button onClick={ this.decrement }>-1</button>
      </div>
    )
  }
  // La méthode increment() incrémente la valeur du compteur de 1
  increment() {
    this.setState(prevState => ({ count: prevState.count+1 }))
  }
  // La méthode decrement() décrémente la valeur du compteur de 1
  decrement() {
    this.setState(prevState => ({ count: prevState.count-1 }))
  }
}

Gestion des styles

Différentes méthodologies existent pour gérer les styles des composants.

La méthode classique CSS (avec ou sans préprocesseur SCSS/SASS) est tout à fait viable.

Les styles des composants sont également souvent déclarés en Javascript et utilisés via les props des composants.

D'autres solutions plus avancées comme styled-components ou CSS Modules existent également. Elles mettent l'accent sur la modularité et la réutilisation des règles de style entre les composants.


Déclaration des styles en Javascript

// my-app/src/components/my-styled-component.styles.js

export default {
  self: {
    backgroundColor: 'red',
    border: '1px solid black',
    padding: '10px'
  },
  content: {
    backgroundColor: 'blue',
    border: '1px dotted white'
  }
}
// my-app/src/components/my-styled-component.js

import React from 'react'
import styles from './my-styled-component.styles.js'

export default class MyStyledComponent extends React.Component {

  render() {
    return (
      <div style={styles.self}>
      	<div style={styles.content}>
          Isnt it pretty ?
        </div>
      </div>
    )
  }

}

Composition des styles en Javascript

En utilisant l'opérateur de décomposition ..., il est possible de composer les styles.

// my-app/src/components/my-styled-component.js

import React from 'react'
import styles from './my-styled-component.styles.js'

export default class MyStyledComponent extends React.Component {

  render() {
    return (
      <div style={{...styles.self, ...this.props.style}}>
      	<div style={{...styles.content, ...this.props.contentStyle}}>
          Isnt it pretty ?
        </div>
      </div>
    )
  }

}

// Exemple d'utilisation

<StyledComponent style={{backgroundColor: 'green'}} contentStyle={{border: 'none'}} />


Cycle de vie et méthodes

https://reactjs.org/docs/react-component.html


Séquence de montage

  1. constructor(nextProps, currentState)
  2. static getDerivedStateFromProps(nextProps, currentState)
  3. render()
  4. componentDidMount()

Séquence de mise à jour

  1. static getDerivedStateFromProps(nextProps, currentState)
  2. shouldComponentUpdate(nextProps, nextState)
  3. render()
  4. getSnapshotBeforeUpdate(prevProps, prevState)
  5. componentDidUpdate(prevProps, prevState, snapshot)

Séquence de démontage

  1. componentWillUnmount()

Gestion des erreurs

componentDidCatch(error, info)

Permet d'intercepter les erreurs qui pourraient être émises dans l'arbre des sous composants.


Composants "contrôlés" (formulaires)

// my-app/src/components/my-form.js
import React from 'react'

export default class MyForm extends React.Component {

  constructor(props) {
    super(props);
    this.state = {name: ''};
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleChange(evt) {
    this.setState({value: evt.target.value});
  }

  handleSubmit(evt) {
    console.log(`Votre nom est ${this.state.name}`);
    evt.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Nom:
          <input type="text" value={this.state.name} onChange={this.handleChange} />
        </label>
        <input type="submit" value="Soumettre" />
      </form>
    );
  }
}

Intégrer React avec d'autres librairies

https://reactjs.org/docs/integrating-with-other-libraries.html


Tester ses composants

https://facebook.github.io/jest/docs/en/tutorial-react.html

Installer Jest

npm install --save-dev jest react-test-renderer babel-jest

Configurer le script NPM

// package.json
{
  // ...
  "scripts" : {
    "test": "jest"
  }
  //...
}

Créer un script de test

// my-app/src/components/my-styled-component.test.js

/* globals test, expect */
import MyStyledComponent from './my-styled-component'
import renderer from 'react-test-renderer'

test('Navbar snapshot', () => {
  const component = renderer.create(<MyStyledComponent />)
  
  let tree = component.toJSON()
  
  // Vérifier que le composant n'a pas changé depuis le dernier
  // snapshot.
  // Voir https://facebook.github.io/jest/docs/en/snapshot-testing.html
  // pour plus d'informations
  expect(tree).toMatchSnapshot()
  
  // L'API expect() de Jest est disponible à l'adresse
  // https://facebook.github.io/jest/docs/en/expect.html
  
  // Il est possible d'effectuer des vérifications plus avancées
  // grâce au projet Enzyme (vérification du DOM, etc)
  // Voir http://airbnb.io/enzyme/ et
  // https://facebook.github.io/jest/docs/en/tutorial-react.html#dom-testing

})

Lancer les tests

npm test

Gestion des routes

https://reacttraining.com/react-router/web/

Installation du module

npm install --save react-router react-router-dom

Déclaration basique des routes

// my-app/src/app.js

import React from 'react'
import { HashRouter } from 'react-router-dom' // ou BrowserRouter
import { Route, Switch, Redirect } from 'react-router'
import { Clock, MyStyledComponent, My404Page } from './components'

export default class App extends React.Component {

  render() {
    <HashRouter>
      <Switch>
      
      	<Route path='/clock' exact component={Clock} />
      	<Route path='/styled' exact component={MyStyledComponent} />
        
        <Redirect from='/old-style' to='/styled' />
        
        <Route component={My404Page} />
        
      </Switch>
    </HashRouter>
  }

}

Liens de navigation

// my-app/src/components/my-linked-component.js

import React from 'react'
import { Link } from 'react-router'

export default class App extends React.Component {
  render() {
    return (
      <div>
        <Link to='/styled' />
      </div>
    )
  }
}

Paramètres de route

// my-app/src/app.js

import React from 'react'
import { HashRouter } from 'react-router-dom' // ou BrowserRouter
import { Route } from 'react-router'
import { MyRouteParameter } from './components'

export default class App extends React.Component {
  render() {
    <HashRouter>
      <Route path='/:myParam' component={MyRouteParameter} />
    </HashRouter>
  }
}
// my-app/src/components/my-route-parameter.js

import React from 'react'
import { HashRouter } from 'react-router-dom' // ou BrowserRouter
import { Route } from 'react-router'
import { MyRouteParameter } from './components'

export default class App extends React.Component {
  render() {
    const { match } = this.props
    <span>Paramètre: { match.params.myParam }</span>
  }
}

Sous routes

// my-app/src/app.js

import React from 'react'
import { HashRouter } from 'react-router-dom'
import { Route, Switch } from 'react-router'
import { ProductPage } from './components'

export default class App extends React.Component {
  render() {
    <HashRouter>
      <Switch>
      	<Route path='/products/:productId' component={ProductPage} />
      </Switch>
    </HashRouter>
  }
}
// my-app/src/component/product.js

import React from 'react'
import { Route, Switch } from 'react-router'
import ProductView from './product-view'
import ProductEdit from './product-edit'

export default class ProductPage extends React.Component {
  render() {
    const { match } = this.props
    return (
      <Switch>
        <Route path={`${match.url}/view`} component={ProductView} />
        <Route path={`${match.url}/edit`} component={ProductEdit} />
      </Switch>
    )
  }
}

Et plus encore...


État de l'application


Architecture Flux

center 100%


Redux

https://redux.js.org/


bg right:75% contain

Flot des données


store, actions et reducers

Générateurs d'actions

// actions.js

export const ADD_PRODUCT = 'ADD_PRODUCT'

export function addProduct (name, price) {
  return {type: ADD_PRODUCT, product: {name, price}}
}

export const REMOVE_PRODUCT = 'REMOVE_PRODUCT'

export function removeProduct (name) {
  return {type: REMOVE_PRODUCT, productName: name}
}

"Reducer" racine

// reducer.js

export default const rootReducer = (state, action) => {
  
  console.log(`Action: ${JSON.stringify(action)}`)
  
  switch (action.type) {
  	
    case ADD_PRODUCT:
      // L'action est de type ADD_PRODUCT
      // On ajoute le produit dans la liste et
      // on retourne un nouvel état modifié
      return {
        products: [...state.products, action.product]
      }
      
    
    case REMOVE_PRODUCT:
      // L'action est de type REMOVE_PRODUCT
      // On filtre la liste des produits et on
      // retourne un nouvel état modifié
      return {
        products: state.products.filter(p => p.name !== action.productName)
      }
  }
  
  // Si l'action n'est pas gérée, on retourne l'état
  // sans le modifier
  return state
  
}

Création du store

// store.js

import { createStore } from 'redux'
import rootReducer from './reducer'

export function configureStore(initialState = { products: [] }) {
  return createStore(
    rootReducer,
    initialState
  )
}

Tester

// store.test.js
/* globals test, expect, jest */
import { addProduct, removeProduct } from './actions'
import { configureStore } from './store'

test('Ajout/suppression des produits', () => {
  // On crée une instance de notre store
  // avec le state par défaut
  const store = configureStore()
  
  // On crée un "faux" subscriber
  // pour vérifier que l'état du store
  // a bien été modifié le nombre de fois voulu
  const subscriber = jest.fn()
  
  // On attache notre faux subscriber
  // au store
  store.subscribe(subscriber)
  
  // On "dispatch" nos actions
  store.dispatch(addProduct('pomme', 5))
  store.dispatch(addProduct('orange', 7))
  store.dispatch(removeProduct('pomme'))
  
  // On s'assure que notre subscriber a bien été
  // appelé
  expect(subscriber).toHaveBeenCalledTimes(3)

  const state = store.getState()
  
  // On s'assure que l'état du store correspond
  // à ce qu'on attend
  expect(state).toMatchObject({
    products: [
      {name: 'orange', price: 7}
    ]
  })
  
})

Actions asynchrones

https://github.com/gaearon/redux-thunk

npm install --save redux-thunk

Ajout du middleware au store

// store.js

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import rootReducer from './reducer'

export function configureStore(initialState = { products: [] }) {
  return createStore(
    rootReducer,
    initialState,
    applyMiddleware(thunk) 	// On ajoute le middleware 'thunk'
  )
}

Générateur d'actions asynchrones

// actions.js

export const SAVE_PRODUCT = 'SAVE_PRODUCT'
export const SAVE_PRODUCT_SUCCESS = 'SAVE_PRODUCT_SUCCESS'
export const SAVE_PRODUCT_FAILURE = 'SAVE_PRODUCT_FAILURE'

export function saveProduct (product) {
  return (dispatch, getState) => {
  
    dispatch({type: SAVE_PRODUCT, product})
    
    return fetch('http://my-api/products', {
    	method: 'POST',
        body: JSON.stringify(product)
      })
      .then(res => res.json())
      .then(res => dispatch({ type: SAVE_PRODUCT_SUCCESS, product: res.data }))
      .catch(error => dispatch({ type: SAVE_PRODUCT_FAILURE, error }))
  }
}

Utilisation avec React

Installer les dépendances

npm install --save react-redux

Déclaration de l'usage du store dans l'application

// my-app/src/index.js

import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import App from './app'
import { configureStore } from './store'

const store = configureStore()

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('app')
)


// my-app/src/app.js

import React from 'react'
import { addProduct } from './actions'
import { connect } from 'react-redux'

class App extends React.Component {

  componentDidMount() {
    // Toutes les 2 secondes, on "dispatch" une nouvelle
    // action qui va ajouter un produit dans le store
    setInterval(() => {
      this.props.dispatch(addProduct(`My product #${Date.now()}`, Math.random()*10|0))
    }, 2000)
  }

  render() {
    // On crée une liste à partir des éléments présents
    // dans le tableaux de produits
    const items = this.props.products.map((p, i) => {
      return <li key={i}>{p.name}</li>
    })
    return (
      <ul>
        {items}
      </ul>
    )
  }
  
}

// Cette fonction permet de "sélectionner" les éléments
// qui nous intéressent dans le state et de les exposer
// en tant que "props" sur notre composant
const mapStateToProps = state => {
  return {
    products: state.products
  }
}

// On "emballe" notre composant dans un "higher order component" (ou H.O.C.) 
// ce qui nous permet de récupérer les données à partir du store
export default connect(mapStateToProps)(App)

Redux-Saga

ou comment gérer les "effets de bord"


bg right:75% contain

Flot des données


Cas d'usage

React-Redux permet d'intégrer au sein du "flux de travail" Redux la gestion des opérations asynchrones.

Exemple Appels à des API distantes, opérations liées au passage du temps, messages websockets...

Notamment, il offre une gestion de la synchronisation d'opérations asynchrones i.e. je dois attendre que l'opération A et l'opération B soient terminées pour effectuer l'opération C.


Installation de la dépendance

npm install --save redux-saga

Ajout du middleware

// src/store/store.js
import { createStore, applyMiddleware, combineReducers, compose } from 'redux'
import myReducer from '../reducers/my'
import rootSaga from '../sagas/root'
import createSagaMiddleware from 'redux-saga'

const sagaMiddleware = createSagaMiddleware()

const rootReducer = combineReducers({
  my: myReducer,
});

export function configureStore(initialState = {}) {
    const store = createStore(
        rootReducer,
        initialState,
        compose(
            applyMiddleware(sagaMiddleware)
        )
    )
    sagaMiddleware.run(rootSaga);
    return store;
}

Création de la saga "racine"

Avec une saga "factice" d'authentification

// src/sagas/root.js
import { all, takeLatest } from 'redux-saga/effects';
import { LOGIN_REQUEST } from '../actions/auth';
import { loginSaga } from './auth';

export default function* rootSaga() {
  yield all([
    takeLatest(LOGIN_REQUEST, loginSaga),
  ]);
}

Définition des actions

// src/actions/auth.js
export const LOGIN_REQUEST = 'LOGIN_REQUEST'
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAILURE = 'LOGIN_FAILURE';

export function login(username, password) {
  return { type: LOGIN_REQUEST, username, password }
}

export function loginFailure(username, error) {
  return { type: LOGIN_FAILURE, username, error }
}

export function loginSuccess(username) {
  return { type: LOGIN_SUCCESS, username }
}

Création de ma saga d'authentification

// src/sagas/auth.js
import { call, put } from 'redux-saga/effects';
import { loginFailure, loginSuccess } from '../actions/auth';

export default function* loginSaga(action) {
  let result;
  try {
    result = yield call(doLogin, action.username, action.password);
  } catch(err) {
    yield put(loginFailure(action.username, err));
  }
  
  if ('error' in result) {
    yield put(loginFailure(action.username, result.error));
    return
  }
  
  yield put(loginSuccess(action.username));
}

function doLogin(username, password) {
  return fetch('http://my-backend.org/login', {
    method: 'POST',
    body: JSON.stringify({
      Username: username,
      Password: password
    }),
    mode: 'cors',
    credentials: 'include'
  }).then(res => res.json())
}

Mise en application


Contexte

Implémenter une application de type "catalogue" comprenant les fonctionnalités suivantes:

  • Créer/modifier/supprimer des fiches "produit"
  • Trouver des produits via un système de recherche multicritères
  • Un mécanisme d'authentification par mot de passe
  • Un système de rôles basiques:
    • Un rôle "administrateur" pouvant effectuer toutes les actions
    • Un rôle "éditeur" pouvant créer/modifier/supprimer des fiches existantes
    • Un rôle "consultant" permettant de lire/rechercher des fiches existantes

Contraintes

Vous pouvez sélectionner n'importe quel type de produit à implémenter (album de musique, collection de pins, bestiaire de jeu de rôle...). Voici cependant les différents types d'attributs qui devront être présents sur une fiche produit:

  • Un attribut de type numérique
  • Un attribut de type "liste de mots clés"
  • Un attribut de type "texte libre"
  • Un attribut de type "fichier"

La partie "backend" est laissée à votre libre choix.


Licence

CC BY-NC-SA 3.0 FR

Creative Commons - Attribution - Pas dUtilisation Commerciale - Partage dans les Mêmes Conditions 3.0 France