1137 lines
23 KiB
Markdown
1137 lines
23 KiB
Markdown
|
<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
|
|||
|
|
|||
|
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`
|
|||
|
|
|||
|
Elle 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() incrémente la valeur du compteur de 1
|
|||
|
decrement() {
|
|||
|
this.setState(prevState => ({ count: prevState.count-1 }))
|
|||
|
}
|
|||
|
}
|
|||
|
```
|
|||
|
|
|||
|
---
|
|||
|
|
|||
|
## Gestion des styles
|
|||
|
|
|||
|
Différentes méthodologies existes 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(props)`
|
|||
|
2. `componentWillMount()`
|
|||
|
3. `render()`
|
|||
|
4. `componentDidMount()`
|
|||
|
|
|||
|
---
|
|||
|
|
|||
|
### Séquence de mise à jour
|
|||
|
|
|||
|
1. `componentWillReceiveProps(nextProps)`
|
|||
|
2. `shouldComponentUpdate(nextProps, nextState)`
|
|||
|
3. `componentWillUpdate(nextProps, nextState)`
|
|||
|
4. `render()`
|
|||
|
5. `componentDidUpdate(prevProps, prevState)`
|
|||
|
|
|||
|
---
|
|||
|
|
|||
|
### 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/
|
|||
|
|
|||
|
---
|
|||
|
|
|||
|
## Flot des données
|
|||
|
|
|||
|
![center 100%](./img/redux_flow.png?1)
|
|||
|
|
|||
|
---
|
|||
|
|
|||
|
## `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 retoure 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éait une instance de notre store
|
|||
|
// avec le state par défaut
|
|||
|
const store = configureStore()
|
|||
|
|
|||
|
// On créait un "faux" subscriber
|
|||
|
// pour vérifier que l'état du store
|
|||
|
// à 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éait 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)
|
|||
|
```
|
|||
|
|
|||
|
---
|
|||
|
|
|||
|
## 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/)
|