26 KiB
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>
<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
constructor(nextProps, currentState)
static getDerivedStateFromProps(nextProps, currentState)
render()
componentDidMount()
Séquence de mise à jour
static getDerivedStateFromProps(nextProps, currentState)
shouldComponentUpdate(nextProps, nextState)
render()
getSnapshotBeforeUpdate(prevProps, prevState)
componentDidUpdate(prevProps, prevState, snapshot)
Séquence de démontage
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
Redux
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"
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.