1283 lines
26 KiB
Markdown
1283 lines
26 KiB
Markdown
---
|
||
marp: true
|
||
---
|
||
|
||
|
||
<style>
|
||
pre { font-size: 0.5em !important; }
|
||
table { font-size: 0.6em !important; }
|
||
</style>
|
||
|
||
# React + Redux
|
||
## William Petit - S.C.O.P. Cadoles
|
||
|
||
---
|
||
|
||
## Amorçage d'un projet
|
||
|
||
---
|
||
|
||
<!-- page_number: true -->
|
||
|
||
## Méthode simplifiée: `create-react-app`
|
||
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```js
|
||
// 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'
|
||
})
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
```js
|
||
// 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`
|
||
|
||
```js
|
||
// 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
|
||
|
||
```html
|
||
<!-- 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
|
||
|
||
---
|
||
|
||
```js
|
||
// my-app/src/index.js
|
||
|
||
import ReactDOM from 'react-dom'
|
||
import App from './app'
|
||
|
||
ReactDOM.render(<App />, document.getElementById('app'))
|
||
```
|
||
|
||
```js
|
||
// 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
|
||
|
||
```bash
|
||
npm start # Puis ouvrir http://localhost:8080 dans votre navigateur
|
||
```
|
||
|
||
### Générer une version pour la production
|
||
|
||
```bash
|
||
npm run build # Les fichiers générés seront dans le répertoire my-app/dist
|
||
```
|
||
|
||
---
|
||
|
||
## Les composants
|
||
|
||
---
|
||
|
||
## Différentes notations
|
||
|
||
### Classe ES6
|
||
|
||
```jsx
|
||
// 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
|
||
|
||
```jsx
|
||
// 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'attribut`class` 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.
|
||
|
||
```jsx
|
||
// 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.
|
||
|
||
```jsx
|
||
// 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.
|
||
|
||
```jsx
|
||
// 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.
|
||
|
||
---
|
||
|
||
```jsx
|
||
// 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>
|
||
<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](https://www.styled-components.com/) ou [CSS Modules](https://github.com/css-modules/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
|
||
|
||
```jsx
|
||
// 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'
|
||
}
|
||
}
|
||
```
|
||
|
||
```jsx
|
||
// 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.
|
||
|
||
```jsx
|
||
// 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)
|
||
|
||
```jsx
|
||
// 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
|
||
|
||
```bash
|
||
npm install --save-dev jest react-test-renderer babel-jest
|
||
```
|
||
|
||
### Configurer le script NPM
|
||
|
||
```js
|
||
// 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
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
---
|
||
|
||
## Gestion des routes
|
||
|
||
https://reacttraining.com/react-router/web/
|
||
|
||
### Installation du module
|
||
|
||
```bash
|
||
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...
|
||
|
||
- [Transitions](https://reacttraining.com/react-router/web/example/animated-transitions)
|
||
- [Liens personnalisés](https://reacttraining.com/react-router/web/example/custom-link)
|
||
- [Chemins récursifs...](https://reacttraining.com/react-router/web/example/recursive-paths)
|
||
|
||
---
|
||
|
||
## État de l'application
|
||
|
||
---
|
||
|
||
## Architecture Flux
|
||
|
||
![center 100%](img/flux-simple-f8-diagram-with-client-action-1300w.png)
|
||
|
||
---
|
||
|
||
## Redux
|
||
|
||
https://redux.js.org/
|
||
|
||
---
|
||
|
||
![bg right:75% contain](./img/redux_flow.png?1)
|
||
|
||
## Flot des données
|
||
|
||
---
|
||
|
||
## `store`, `actions` et `reducers`
|
||
|
||
### Générateurs d'actions
|
||
|
||
```js
|
||
// 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
|
||
|
||
```js
|
||
// 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`
|
||
```javascript
|
||
// store.js
|
||
|
||
import { createStore } from 'redux'
|
||
import rootReducer from './reducer'
|
||
|
||
export function configureStore(initialState = { products: [] }) {
|
||
return createStore(
|
||
rootReducer,
|
||
initialState
|
||
)
|
||
}
|
||
```
|
||
---
|
||
## Tester
|
||
|
||
```js
|
||
// 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
|
||
|
||
```bash
|
||
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
|
||
```bash
|
||
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](./img/reduxsaga_flow.png?1)
|
||
|
||
## 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
|
||
|
||
```bash
|
||
npm install --save redux-saga
|
||
```
|
||
|
||
---
|
||
|
||
### Ajout du middleware
|
||
|
||
```js
|
||
// 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_
|
||
|
||
```js
|
||
// 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
|
||
|
||
```js
|
||
// 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
|
||
|
||
```js
|
||
// 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 d’Utilisation Commerciale - Partage dans les Mêmes Conditions 3.0 France](https://creativecommons.org/licenses/by-nc-sa/3.0/fr/) |