Tutoriel pour apprendre à mettre en place un flux infini Symfony-React

Jonathan Jalouzot

Pour rendre l'expérience utilisateur de nos applications web toujours plus agréable, nous sommes de plus en plus obligés d'utiliser plusieurs technologies en même temps. C'est par exemple le cas si l'on souhaite mettre en place un flux infini. Pour le rendre simple et performant, nous allons utiliser un backend Symfony et un front en ReactJs. La question se pose alors : comment interfacer les deux technologies ?

Commentez

- Titre : Tutoriel pour apprendre à mettre en place un flux infini Symfony-React

- Auteur : Jonathan Jalouzot

- Parution : 6 décembre 2017

- Licence : Copyright ® 2017 Jonathan Jalouzot. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Mise en place du backend

- II. Mettez en place votre docker (optionnel)

- III. Mettre en place React

- IV. Faire communiquer React et Symfony

- V. On code le flux infini

- V-A. Partie Symfony

- V-B. Partie React

- VI. Remerciements

I - Mise en place du backend

Notre site est tout d'abord un site en Symfony 3.3. La mise en place est assez basique, il vous suffit d'installer Symfony en suivant ce tutoriel sur le site officiel. Pour la suite de notre projet, nous allons avoir besoin de stocker les données du flux, pour cela nous allons mettre en place une base de données Postgresql. Il vous suffit de changer dans votre fichier de configuration les paramètres par défaut de la database doctrine.

1. # config.yml

2. # Doctrine Configuration

3. doctrine:

4. dbal:

5. driver: pdo_pgsql

6. host: '%database_host%'

7. port: '%database_port%'

8. dbname: '%database_name%'

9. user: '%database_user%'

10. password: '%database_password%'

11. charset: UTF8

12. orm:

13. auto_generate_proxy_classes: '%kernel.debug%'

14. naming_strategy: doctrine.orm.naming_strategy.underscore

15. auto_mapping: true

Comme nous aimons le code propre, nous allons utiliser les variables d'environnement pour notre configuration de base de données. Il vous faut alors changer dans votre fichier parameters.yml les valeurs des variables pour aller chercher les valeurs dans votre environnement.

1. # parameters.yml

2. parameters:

3. database_host: '%env(POSTGRES_HOST)%'

4. database_port: '%env(POSTGRES_PORT)%'

5. database_name: '%env(POSTGRES_DB)%'

6. database_user: '%env(POSTGRES_USER)%'

7. database_password: '%env(POSTGRES_PASSWORD)%'

8. secret: '%env(SECRET)%'

Vous pouvez maintenant créer votre fichier .env avec les valeurs de vos variables :

1. // .env

2. # PATH DIR

3. SYMFONY_APP_PATH=./

4. LOGS_DIR=./docker/logs

6. # DATABASE

7. POSTGRES_HOST=postgres

8. POSTGRES_DB=infinite

9. POSTGRES_USER=infinite

10. POSTGRES_PASSWORD=infinitepass

11. POSTGRES_PORT=5432

13. # PORT WEB

14. WEB_PORT=80

16. # SYMFONY

17. SECRET=d3e2fa9715287ba25b2d0fd41685ac031970f555

Si vous avez fait un peu attention, vous avez vu que dans le fichier .env il y a d'autres variables, c'est parce que l'application utilise docker.

II - Mettez en place votre docker (optionnel)

Pour aller plus vite dans la suite de notre projet, nous avons mis en place une architecture docker permettant d'utiliser l'application. La mise en place est optionnelle, mais vous aidera pour avancer dans votre développement.

À la racine de votre projet, ajoutez un dossier docker qui contiendra la configuration de votre stack technique. Pour le projet nous allons utiliser :

1. Php7 pour la partie symfony ;

2. Nodejs pour builder l'application React ;

3. Nginx comme serveur web pour servir le site.

Vous devez créer les trois dossiers suivants à l'intérieur du dossier docker :

- nginx ;

- php7-fpm ;

- node.

Respectivement dans chaque dossier, vous devez ajouter les fichiers Dockerfile suivants :

dans le fichier nginx/Dockerfile :

1. /nginx/Dockerfile

2. FROM debian:jessie

4. MAINTAINER Maxence POUTORD <maxence.poutord@gmail.com>

6. RUN apt-get update && apt-get install -y \

7. nginx

9. ADD nginx.conf /etc/nginx/

10. ADD symfony.conf /etc/nginx/sites-available/

12. RUN ln -s /etc/nginx/sites-available/symfony.conf /etc/nginx/sites-enabled/symfony

13. RUN rm /etc/nginx/sites-enabled/default

15. RUN echo "upstream php-upstream { server php:9000; }" > /etc/nginx/conf.d/upstream.conf

17. RUN usermod -u 1000 www-data

19. CMD ["nginx"]

21. EXPOSE 80

22. EXPOSE 443

dans le fichier php/Dockerfile :

1. /php/Dockerfile

2. # See https://github.com/docker-library/php/blob/4677ca134fe48d20c820a19becb99198824d78e3/7.0/fpm/Dockerfile

3. FROM php:7.1-fpm

5. MAINTAINER Maxence POUTORD <maxence.poutord@gmail.com>

7. RUN apt-get update && apt-get install -y \

8. git \

9. unzip \

10. zlib1g-dev \

11. libpq-dev

13. # Install Composer

14. RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

15. RUN composer --version

16. RUN mkdir /var/www/.composer && chown -R www-data /var/www/.composer

18. # Set timezone

19. RUN rm /etc/localtime

20. RUN ln -s /usr/share/zoneinfo/Europe/Paris /etc/localtime

21. RUN "date"

23. # Type docker-php-ext-install to see available extensions

24. RUN docker-php-ext-install pdo pdo_pgsql zip

26. # install xdebug

27. RUN pecl install xdebug

28. RUN docker-php-ext-enable xdebug

29. RUN echo "error_reporting = E_ALL" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

30. RUN echo "display_startup_errors = On" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

31. RUN echo "display_errors = On" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

32. RUN echo "xdebug.remote_enable=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

33. RUN echo "xdebug.remote_connect_back=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

34. RUN echo "xdebug.idekey=\"PHPSTORM\"" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

35. RUN echo "xdebug.remote_port=9001" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

37. RUN echo 'alias sf3="php bin/console"' >> ~/.bashrc

39. RUN usermod -u 1000 www-data

41. WORKDIR /var/www/symfony

dans le fichier node/Dockerfile :

1. /node/Dockerfile

2. FROM node:8

4. RUN apt-get update && apt-get install -y \

5. curl \

6. apt-transport-https

8. RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \

9. echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

11. RUN apt-get update && apt-get install yarn

13. WORKDIR /var/www/symfony

Il vous faut aussi ajouter la configuration de nginx. Dans le dossier nginx vous devez ajouter la configuration par défaut suivante :

1. /nginx/ngin.conf

2. user www-data;

3. worker_processes 4;

4. pid /run/nginx.pid;

6. events {

7. worker_connections  2048;

8. multi_accept on;

9. use epoll;

10. }

12. http {

13. server_tokens off;

14. sendfile on;

15. tcp_nopush on;

16. tcp_nodelay on;

17. keepalive_timeout 15;

18. types_hash_max_size 2048;

19. include /etc/nginx/mime.types;

20. default_type application/octet-stream;

21. access_log off;

22. error_log off;

23. gzip on;

24. gzip_disable "msie6";

25. include /etc/nginx/conf.d/*.conf;

26. include /etc/nginx/sites-enabled/*;

27. open_file_cache max=100;

28. }

30. daemon off;

Puis ajouter la configuration de votre application symfony :

1. /nginx/symfony.conf

2. server {

3. server_name infinite.dev;

4. root /var/www/symfony/web;

6. location / {

7. try_files $uri @rewriteapp;

8. }

10. location @rewriteapp {

11. rewrite ^(.*)$ /app.php/$1 last;

12. }

14. location ~ ^/(app|app_dev|config)\.php(/|$) {

15. fastcgi_pass php-upstream;

16. fastcgi_split_path_info ^(.+\.php)(/.*)$;

17. include fastcgi_params;

18. fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

19. fastcgi_param HTTPS off;

20. }

22. error_log /var/log/nginx/symfony_error.log;

23. access_log /var/log/nginx/symfony_access.log;

24. }

Il ne vous reste plus qu'à ajouter le fichier docker-compose.yml à la racine de votre projet avec la configuration suivante :

1. version: '2'

3. services:

4. postgres:

5. image: postgres:9.6

6. ports:

7. - ${POSTGRES_PORT}:5432

8. volumes:

9. - ./.data/pgdata:/var/lib/postgresql/data

10. environment:

11. POSTGRES_DB: ${POSTGRES_DB}

12. POSTGRES_USER: ${POSTGRES_USER}

13. POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

14. php:

15. build: docker/php7-fpm

16. env_file: ./.env

17. volumes:

18. - ${SYMFONY_APP_PATH}:/var/www/symfony

19. links:

20. - postgres

21. nginx:

22. build: docker/nginx

23. ports:

24. - ${WEB_PORT}:80

25. volumes_from:

26. - php

27. volumes:

28. - ${LOGS_DIR}/nginx/:/var/log/nginx

29. node:

30. build: docker/node

31. volumes:

32. - ${SYMFONY_APP_PATH}:/var/www/symfony

33. command: bash -c "yarn install && yarn watch"

Ajoutez la ligne suivante dans votre /etc/hosts :

120.0.0.1 infinite.dev

Si vous lancez un docker-compose up puis allez sur la page infinite.dev, vous devriez voir la page suivante vous indiquant que votre site est bien configuré :

Une partie de la configuration est tirée de l'article de Maxence Poutord disponible ici.

Vous pouvez aussi retrouver le code directement dans le projet Infinite github dans la branche configuration.

III - Mettre en place React

Nous allons utiliser React/Redux pour mettre en place notre flux infini. Comme tous les projets node, la première chose à faire est d'ajouter à la racine de votre projet le fichier package.json. Comme nous utiliserons Babel pour la compilation de l'ES2105 il faut le mettre dans la configuration du projet, ainsi que l'utilisation EsLint parce que même dans un tutoriel, nous faisons les choses proprement. Bien sûr il nous faut aussi React et Redux pour avoir notre configuration au complet. Vous pouvez alors ajouter l'ensemble dans votre fichier package.json :

1. {

2. "name": "infinite",

3. "version": "0.0.1",

4. "engines": {

5. "node": "6.x"

6. },

7. "private": true,

8. "description": "Infinite flux",

9. "main": "index.js",

10. "scripts": {

11. "build": "webpack --config app/config/webpack.config.js -p",

12. "lint": "eslint src app/config --ext .js --ext .jsx",

13. "lint:fix": "npm run lint -- --fix",

14. "test": "jest",

15. "test:coverage": "jest --coverage",

16. "watch": "webpack --config app/config/webpack.config.js --devtool source-map --debug --watch --display-error-details",

17. },

18. "repository": {

19. "type": "git",

20. "url": "git+https://github.com/captainjojo/infinite.git"

21. },

22. "bugs": {

23. "url": "https://github.com/captainjojo/infinite/issues"

24. },

25. "homepage": "https://github.com/captainjojo/infinite#readme",

26. "devDependencies": {

27. "babel-core": "^6.14.0",

28. "babel-jest": "^15.0.0",

29. "babel-loader": "^6.2.5",

30. "babel-preset-react": "^6.11.1",

31. "eslint": "^3.2.0",

32. "eslint-config-airbnb": "^10.0.1",

33. "eslint-import-resolver-webpack": "^0.8.0",

34. "eslint-plugin-import": "^1.14.0",

35. "eslint-plugin-jsx-a11y": "^2.2.1",

36. "eslint-plugin-react": "^6.2.0",

37. "jest": "^18.0.0",

38. "react-test-renderer": "^15.3.1"

39. },

40. "dependencies": {

41. "babel-polyfill": "^6.16.0",

42. "babel-preset-es2015": "^6.18.0",

43. "clean-webpack-plugin": "^0.1.14",

44. "lodash": "^4.15.0",

45. "react": "^15.3.1",

46. "react-dom": "^15.3.1",

47. "react-redux": "^4.4.5",

48. "redux": "^3.6.0",

49. "redux-thunk": "^2.1.0",

50. "release-it": "^2.5.1",

51. "webpack": "^v2.2.0-rc.3",

52. "whatwg-fetch": "^2.0.0"

53. },

54. "jest": {

55. "cacheDirectory": "var/jest",

56. "coverageDirectory": "build/coverage/jest",

57. "moduleFileExtensions": [

58. "js",

59. "jsx"

60. ],

61. "transformIgnorePatterns": [

62. "/node_modules/(?!o-.*)/"

63. ],

64. "moduleNameMapper": {

65. "^actions(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/react/actions$1",

66. "^components(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/react/components$1",

67. "^containers(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/react/containers$1",

68. "^helpers(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/helpers$1",

69. "^lib(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/lib$1",

70. "^reducers(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/react/reducers$1",

71. "^store(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/react/store$1"

72. },

73. "testRegex": ".*.(test|spec).js[x]?$"

74. }

75. }

Comme vous pouvez le constater, nous allons utiliser Webpack pour compiler et préparer nos fichiers JavaScript. Vous avez aussi en fin du fichier la configuration de Jest qui sera l'outil qui va nous permettre de mettre en place les tests unitaires et les tests visuels de notre application JavaScript. La configuration Jest permet ici de mapper les noms des modules lors d'un import JavaScript avec l'emplacement des fichiers.

Il faut donc ajouter les fichiers pour la configuration de Babel et d'Eslint. Pour Babel, il vous faut ajouter le fichier .babelrc à la racine de votre projet :

1. {

2. "presets": ["es2015", "react"]

3. }

Pour la configuration d'Eslint, il vous faut ajouter le fichier .eslintrc.yml à la racine de votre projet :

1. ---

2. extends: "airbnb"

3. env:

4. node: true

5. browser: true

6. jest: true

7. settings:

8. import/resolver:

9. webpack:

10. config: 'app/config/webpack.config.js'

Maintenant la grande question qu'il faut se poser c'est : « Où mettre en place l'architecture JavaScript pour qu'elle communique et interagisse facilement avec Symfony ? » La décision n'a pas été facile et n'est pas forcément la meilleure.

Commençons par la mise en place de la configuration webpack, nous la placerons dans le dossier app/config de Symfony. Vous devez ajouter le fichier webpack.config.js suivant :

1. const _ = require('lodash');

2. const fs = require('fs');

3. const resolve = require('path').resolve;

4. const webpack = require('webpack');

6. const CleanWebpackPlugin = require('clean-webpack-plugin');

8. const env = process.env.NODE_ENV === 'prd' ? 'production' : 'development';

9. const root = `${__dirname}/../../`;

10. const paths = {

11. assets: resolve(root, 'src/AppBundle/Resources/views/Assets/'),

12. scripts: resolve(root, 'web/scripts/js'),

13. context: resolve(root, 'src/AppBundle/Resources/scripts/js/'),

14. };

16. const manifestPlugin = (file, path) => ({

17. apply: (compiler) => {

18. compiler.plugin('done', (stats) => {

19. fs.writeFileSync(

20. resolve(path, file),

21. JSON.stringify(

22. _.mapValues(

23. stats.toJson().assetsByChunkName,

24. (chunk) => (env === 'development' ? chunk[0] : chunk)),

25. null,

26. '\t'

27. )

28. );

29. });

30. },

31. });

33. const config = {

34. context: paths.context,

35. entry: {

36. /**

37. * Contain all the vendors entries

38. *

39. * Vendors library (React, Lodash, ...)

40. * Polyfills

41. */

42. vendor: [

43. // Polyfills

44. 'core-js/es6/object',

45. 'core-js/es6/promise',

46. 'whatwg-fetch',

48. // Vendors

49. 'lodash/isEqual',

50. 'react',

51. 'react-dom',

52. 'react-redux',

53. 'redux',

54. 'redux-thunk',

55. ],

56. /**

57. * Each of the following entries represent the single entrypoints

58. * for a given page type

59. */

60. home: [

61. 'entrypoints/latest_news_home.jsx',

62. ],

63. },

64. module: {

65. loaders: [

66. {

67. test: /\.jsx?$/,

68. exclude: /node_modules\/(?!o-.*)/,

69. loader: 'babel-loader',

70. query: {

71. presets: ['react', ['es2015', { modules: false }]],

72. },

73. },

74. ],

75. },

76. output: {

77. filename: '[name].[chunkhash].js',

78. path: paths.scripts,

79. },

80. performance: {

81. hints: env === 'production' ? 'warning' : false,

82. },

83. plugins: [

84. new webpack.optimize.CommonsChunkPlugin({

85. names: ['vendor', 'inlined'],

86. minChunks: Infinity,

87. }),

88. new CleanWebpackPlugin(['**'], {

89. root: paths.scripts,

90. }),

91. new webpack.DefinePlugin({

92. 'process.env': {

93. NODE_ENV: JSON.stringify(env),

94. },

95. }),

96. manifestPlugin('manifest.json', resolve(root, 'var/webpack/')),

97. ],

98. resolve: {

99. alias: {

100. actions: 'react/actions',

101. components: 'react/components',

102. containers: 'react/containers',

103. helpers: 'helpers',

104. lib: 'lib',

105. reducers: 'react/reducers',

106. store: 'react/store',

107. },

108. extensions: ['.js', '.jsx'],

109. modules: [

110. 'node_modules',

111. './src/AppBundle/Resources/scripts/js',

112. ],

113. },

114. target: 'web',

115. };

117. if (env === 'production') {

118. config.plugins.push(

119. new webpack.LoaderOptionsPlugin({

120. minimize: true,

121. })

122. );

123. config.plugins.push(

124. new webpack.optimize.UglifyJsPlugin({

125. minimize: true,

126. compress: {

127. negate_iife: true,

128. unused: true,

129. dead_code: true,

130. drop_console: true,

131. warnings: false,

132. },

133. output: { comments: false },

134. })

135. );

136. }

138. module.exports = config;

Puis comme pour l'ensemble des fichiers JavaScript d'un projet JavaScript, nous allons les mettre dans les ressources du bundle Symfony.

Nous allons créer la coquille d'une architecture Rect/Redux à l'intérieur du dossier src/AppBundle/Resources/scripts/js/react pour cela, créez les dossiers suivants :

- actions

- containers

- reducers

- store

Dans chaque dossier nous allons commencer à créer notre architecture. Le but du tutoriel n'étant pas la compréhension d'une architecture React/Redux, si vous le souhaitez je vous invite à lire ceci.

Ajoutez le fichier index.js dans le dossier store avec le code suivant :

1. import { createStore, applyMiddleware } from 'redux';

2. import thunk from 'redux-thunk';

4. import reducers from 'reducers';

6. export default function configureStore(initial) {

7. return createStore(reducers, initial, applyMiddleware(thunk));

8. }

Le fichier est assez simple et permet seulement de gérer votre store.

Ajoutez le fichier index.js dans le dossier reducers avec le code suivant :

1. import { combineReducers } from 'redux';

3. import latestNews from './latest_news';

5. export default combineReducers({

6. latestNews,

7. });

Nous allons créer le premier reducers du projet qui ensuite contiendra les changements d'état de notre application. Ajoutez le fichier latest_news.js avec le code suivant :

1. import actions from 'actions';

3. export default function latestNews(state = {}, action) {

4. switch (action.type) {

5. default:

6. return state;

7. }

8. }

Vous pouvez maintenant créer les fichiers d'actions en commençant par le fichier index.js :

1. import * as latestNews from './latest_news';

3. const actions = {

4. latestNews,

5. };

7. export default actions;

Puis le fichier latest_news.js qui sera vide.

Terminons par l'affichage en créant un fichier jsx très simple dans le dossier containers, ajoutez le fichier LatestNewsHome.jsx qui contiendra l'affichage :

1. import React, { Component, PropTypes } from 'react';

2. import { connect } from 'react-redux';

4. import actions from 'actions';

6. class LatestNewsHome extends Component {

7. constructor() {

8. super();

9. }

11. componentDidMount() {

12. }

14. render() {

15. return (

16. <div>Coucou</div>

17. );

18. }

19. }

21. LatestNewsHome.propTypes = {

22. };

24. const mapStateToProps = (state) => ({

25. });

27. export default connect(mapStateToProps)(LatestNewsHome);

Voilà notre architecture React/Redux terminée, il nous faut maintenant la faire communiquer avec Symfony.

IV - Faire communiquer React et Symfony

Il vous faut un point d'entrée entre React et Symfony pour vous permettre de lancer votre application React. Dans le dossier src/AppBundle/Resources/scripts/js/entrypoints ajoutez un fichier latest_news_home.jsx contenant l'initialisation de votre React :

1. import React from 'react';

2. import { render } from 'react-dom';

3. import { Provider } from 'react-redux';

5. import LatestNewsHome from 'containers/LatestNewsHome.jsx';

6. import configureStore from 'store';

8. const elements = {

9. latest_news: document.getElementById('react-latest-news-home'),

10. };

12. // eslint-disable-next-line no-underscore-dangle

13. const store = configureStore(window.__INITIAL_STATE__);

15. const component = (

16. <Provider store={store}>

17. <LatestNewsHome />

18. </Provider>

19. );

21. render(component, elements.latest_news);

Comme vous pouvez le voir, nous allons insérer notre composant React dans notre page HTML via la balise avec l'id react-latest-news-home.

Il faut donc dans votre fichier template Twig ajouter cet id.

Pour le tutoriel j'utilise les pages par défaut du projet Symfony, à vous de choisir votre page

Dans la page app/Resources/views/default/index.html.twig changez le block body avec le code suivant :

1. {% extends 'base.html.twig' %}

3. {% block body %}

4. <div id="react-latest-news-home" >

5. <div>

6. {% endblock %}

8. {% block javascripts %}

9. {% endblock  %}

11. {% block stylesheets %}

12. {% endblock %}

Si vous lancez votre site, il ne se passe rien… Eh oui, il manque les appels JavaScript.

Si vous lancez un yarn watch vous verrez que les fichiers sont générés dans votre dossier web/scripts/js sous trois formes :

1. vendor.*.js contenant les bibliothèques React, etc. ;

2. home.*.js contenant le code de votre composant ;

3. inlined.*.js contenant la configuration de webpack.

Le cache busting est déjà intégré dans la configuration Webpack.

Il nous faut maintenant ajouter les balises scripts dans votre page twig. La facilité serait de poser la balise suivante :

1. <script src="http://infinite.dev/scripts/js/inlined.2d4b19e4578c3af04a03.js" defer></script>

2. <script src="http://infinite.dev/scripts/js/vendor.2d4b19e4578c3af04a03.js" defer></script>

3. <script src="http://infinite.dev/scripts/js/home.e428223280fc3028d63f.js" defer></script>

Cela n'est pas très pratique, car vous devez changer vos balises à chaque changement de JavaScript. Nous voulons donc utiliser la fonction asset de twig comme pour tout autre JavaScript. Vous pouvez mettre dans votre block javascript les deux balises suivantes :

1. {% block javascripts %}

2. <script src="{{ asset('inlined.js', 'js') }}" defer></script>

3. <script src="{{ asset('vendor.js', 'js') }}" defer></script>

4. <script src="{{ asset('home.js', 'js') }}" defer></script>

5. {% endblock  %}

Si vous testez maintenant vous avez deux 404. Effectivement asset ne prend pas en compte le cache busting, mais nous allons l'aider.

Nous allons créer un service permettant de gérer l'utilisation du cache busting. Dans votre fichier config.yml vous pouvez surcharger la function asset. Si vous voulez mieux comprendre vous pouvez allez lire l'article de Symfony ici :

1. framework:

2. #esi: ~

3. #translator: { fallbacks: ['%locale%'] }

4. secret: '%secret%'

5. router:

6. resource: '%kernel.project_dir%/app/config/routing.yml'

7. strict_requirements: ~

8. form: ~

9. csrf_protection: ~

10. validation: { enable_annotations: true }

11. #serializer: { enable_annotations: true }

12. templating:

13. engines: ['twig']

14. default_locale: '%locale%'

15. trusted_hosts: ~

16. session:

17. # https://symfony.com/doc/current/reference/configuration/framework.html#handler-id

18. handler_id: session.handler.native_file

19. save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'

20. fragments: ~

21. http_method_override: true

22. assets: ~

23. php_errors:

24. log: true

25. assets:

26. packages:

27. js:

28. base_urls: 'http://infinite.dev/scripts/js'

29. version_strategy: 'app.assets.js.version_strategy'

Puis nous allons ajouter le service dans notre bundle. Commençons par créer le service.yml :

1. services:

2. app.assets.js.version_strategy:

3. class: AppBundle\Service\VersionStrategy\JavascriptBusterVersionStrategy

4. arguments:

5. - '%kernel.root_dir%/../var/webpack/manifest.json'

Nous utiliserons la manifest.json de webpack qui nous permettra de connaître la version actuelle du cache busting.

Il ne vous reste plus qu'à ajouter le fichier src/AppBundle/Service/VersionStrategy/JavascriptBusterVersionStrategy.php avec le code suivant :

1. <?php

3. namespace AppBundle\Service\VersionStrategy;

5. use Symfony\Component\Asset\VersionStrategy\VersionStrategyInterface;

7. class JavascriptBusterVersionStrategy implements VersionStrategyInterface

8. {

9. /**

10. * The manifest object containing for each entry

11. * the filename containing the version.

12. *

13. * @var array

14. */

15. private $hashes = [];

17. /**

18. * The manifest file path.

19. *

20. * @var string

21. */

22. private $path;

24. /**

25. * @param string $path The manifest file path

26. */

27. public function __construct(string $path)

28. {

29. $this->path = $path;

30. }

32. /**

33. * {@inheritdoc}

34. */

35. public function getVersion($asset): string

36. {

37. $this->ensureHashesLoaded();

39. preg_match(

40. // Matches pattern like 'vendor.67e29947398bcbf9b383.js'

41. '/(?:.*)\.([[:alnum:]]*)\.js/',

42. $this->hashes[$this->getEntryName($asset)] ?? '',

43. $matches

44. );

46. return $matches[1] ?? '';

47. }

49. /**

50. * {@inheritdoc}

51. */

52. public function applyVersion($asset): string

53. {

54. $this->ensureHashesLoaded();

56. return $this->hashes[$this->getEntryName($asset)] ?? '';

57. }

59. /**

60. * Return the manifest entry name for the given asset.

61. *

62. * @return string

63. */

64. private function getEntryName($path): string

65. {

66. // Replace pattern like 'vendor.js' into 'vendor'

67. return preg_replace(

68. '/(.*)\.js/',

69. '$1',

70. $path

71. );

72. }

74. /**

75. * Load hashes from manifest if needed.

76. */

77. private function ensureHashesLoaded()

78. {

79. if (empty($this->hashes)) {

80. $this->hashes = json_decode(file_get_contents($this->path), true);

81. }

82. }

83. }

Vous n'avez normalement plus de 404, mais une erreur JavaScript signalant que vous n'avez pas d'__INITIAL_STATE__ pour votre composant React.

Nous allons donc le configurer dans votre fichier Twig, en ajoutant une valeur par défaut :

1. {% block javascripts %}

2. {% set initial_state = {latestNews: {}} %}

3. <script>window.__INITIAL_STATE__ = {{ initial_state|json_encode|raw }};</script>

4. <script src="{{ asset('inlined.js', 'js') }}" defer></script>

5. <script src="{{ asset('vendor.js', 'js') }}" defer></script>

6. <script src="{{ asset('home.js', 'js') }}" defer></script>

7. {% endblock  %}

Vous pouvez aussi retrouver le code directement dans le projet Infinite github dans la branche communication.

V - On code le flux infini

V-A - Partie Symfony

Dans ce flux nous allons mettre des articles contenant seulement une date et un titre. Nous aurons besoin d'initialiser le composant React avec les X premiers articles, puis pour chaque passage sur le voir plus d'un appel vers un webservice qui nous renverra les articles suivants.

Pour ce tutoriel, nous allons seulement utiliser des fixtures à vous de jouer pour le reste.

Nous allons créer l'Entity article en ajoutant le fichier src/AppBundle/Entity/Article.php avec le code suivant :

1. <?php

3. namespace AppBundle\Entity;

5. use Doctrine\ORM\Mapping as ORM;

7. /**

8. * Class Article

9. * @package AppBundle\Entity

10. *

11. * @ORM\Table(name="article")

12. * @ORM\Entity(repositoryClass="AppBundle\Repository\ArticleRepository")

13. */

14. class Article

15. {

16. /**

17. * @var int $id

18. *

19. * @ORM\Column(name="id", type="integer")

20. * @ORM\Id

21. * @ORM\GeneratedValue(strategy="AUTO")

22. */

23. private $id;

25. /**

26. * @var string $title

27. *

28. * @ORM\Column(name="title", type="string", nullable=false)

29. */

30. private $title;

32. /**

33. * @var datetime $created

34. *

35. * @ORM\Column(name="created_at", type="datetime", nullable=false)

36. */

37. protected $createdAt;

40. /**

41. * @return int

42. */

43. public function getId(): int

44. {

45. return $this->id;

46. }

48. /**

49. * @param string $title

50. *

51. * @return Article

52. */

53. public function setTitle(string $title): Article

54. {

55. $this->title = $title;

57. return $this;

58. }

60. /**

61. * @return string

62. */

63. public function getTitle(): string

64. {

65. return $this->title;

66. }

68. /**

69. * @return \DateTime

70. */

71. public function getCreatedAt()

72. {

73. return $this->createdAt;

74. }

76. /**

77. * @param \DateTime $createdAt

78. * @return Article

79. */

80. public function setCreatedAt(\DateTime $createdAt): Article

81. {

82. $this->createdAt = $createdAt;

84. return $this;

85. }

86. }

Ensuite en suivant l'article ici, permettant de mettre en place des fixtures doctrine, vous pouvez ajouter dans votre composer.json :

1. "require-dev": {

2. "doctrine/doctrine-fixtures-bundle": "^2.3"

3. }

Puis dans votre AppKernel.php :

1. <?php

3. use Symfony\Component\HttpKernel\Kernel;

4. use Symfony\Component\Config\Loader\LoaderInterface;

6. class AppKernel extends Kernel

7. {

8. public function registerBundles()

9. {

10. $bundles = [

11. new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),

12. new Symfony\Bundle\SecurityBundle\SecurityBundle(),

13. new Symfony\Bundle\TwigBundle\TwigBundle(),

14. new Symfony\Bundle\MonologBundle\MonologBundle(),

15. new Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle(),

16. new Doctrine\Bundle\DoctrineBundle\DoctrineBundle(),

17. new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),

18. new AppBundle\AppBundle(),

19. ];

21. if (in_array($this->getEnvironment(), ['dev', 'test'], true)) {

22. $bundles[] = new Symfony\Bundle\DebugBundle\DebugBundle();

23. $bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle();

24. $bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle();

25. $bundles[] = new Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle();

27. if ('dev' === $this->getEnvironment()) {

28. $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle();

29. $bundles[] = new Symfony\Bundle\WebServerBundle\WebServerBundle();

30. }

31. }

33. return $bundles;

34. }

36. public function getRootDir()

37. {

38. return __DIR__;

39. }

41. public function getCacheDir()

42. {

43. return dirname(__DIR__).'/var/cache/'.$this->getEnvironment();

44. }

46. public function getLogDir()

47. {

48. return dirname(__DIR__).'/var/logs';

49. }

51. public function registerContainerConfiguration(LoaderInterface $loader)

52. {

53. $loader->load($this->getRootDir().'/config/config_'.$this->getEnvironment().'.yml');

54. }

55. }

Pour terminer, ajoutez le fichier src/AppBundle/DataFixtures/ORM/LoadArticleData.php contenant les données que l'on utilisera pour la suite :

1. <?php

3. namespace AppBundle\DataFixtures\ORM;

5. use AppBundle\Entity\Article;

6. use Doctrine\Common\DataFixtures\AbstractFixture;

7. use Doctrine\Common\DataFixtures\OrderedFixtureInterface;

8. use Doctrine\Common\Persistence\ObjectManager;

10. /**

11. * Class LoadArticleData

12. * @package AppBundle\DataFixtures\ORM

13. *

14. *

15. * @codeCoverageIgnore

16. */

17. class LoadArticleData  extends AbstractFixture implements OrderedFixtureInterface

18. {

19. /**

20. * @param ObjectManager $manager

21. *

22. * @return void

23. */

24. public function load(ObjectManager $manager): void

25. {

26. foreach ($this->getData() as $data) {

27. $article = new Article();

28. $article->setTitle($data['title']);

29. $article->setCreatedAt($data['created_at']);

31. $manager->persist($article);

32. }

34. $manager->flush();

35. $manager->clear();

36. }

38. /**

39. * @return int

40. */

41. public function getOrder(): int

42. {

43. return 1;

44. }

46. /**

47. * @return array

48. */

49. private function getData(): array

50. {

51. return [

52. ['title' => 'MIGRER UNE APPLICATION REACT CLIENT-SIDE EN SERVER-SIDE AVEC NEXT.JS', 'created_at' => new \DateTime('03-09-2017')],

53. ['title' => 'VOTRE CI DE QUALITÉ', 'created_at' => new \DateTime('30-08-2017')],

54. ['title' => 'JSON SERVER', 'created_at' => new \DateTime('25-08-2017')],

55. ['title' => 'BUILD AN API WITH API PLATFORM', 'created_at' => new \DateTime('24-08-2017')],

56. ['title' => 'RETOUR SUR UN LIVE-CODING DE DÉCOUVERTE DU LANGAGE GO', 'created_at' => new \DateTime('23-08-2017')],

57. ['title' => 'FEEDBACK ON A LIVE-CODING TO DISCOVER GO LANGUAGE', 'created_at' => new \DateTime('23-08-2017')],

58. ['title' => 'HOW TO CHECK THE SPELLING OF YOUR DOCS FROM TRAVIS CI?', 'created_at' => new \DateTime('18-08-2017')],

59. ['title' => 'COMMENT VÉRIFIER L\'ORTHOGRAPHE DE VOS DOCS DEPUIS TRAVIS CI ?', 'created_at' => new \DateTime('18-08-2017')],

60. ['title' => 'JSON SERVER', 'created_at' => new \DateTime('16-08-2017')],

61. ['title' => 'ANDROID ET LES DESIGN PATTERNS', 'created_at' => new \DateTime('09-08-2017')],

62. ['title' => 'CONTINUOUS IMPROVEMENT: HOW TO RUN YOUR AGILE RETROSPECTIVE?', 'created_at' => new \DateTime('03-08-2017')],

63. ['title' => 'CONSTRUIRE UNE API EN GO', 'created_at' => new \DateTime('26-07-2017')],

64. ['title' => 'CRÉER UNE API AVEC API PLATFORM', 'created_at' => new \DateTime('25-07-2017')],

65. ['title' => 'LES PRINCIPAUX FORMATS DE FLUX VIDEO LIVE DASH ET HLS', 'created_at' => new \DateTime('19-07-2017')],

66. ['title' => 'MIGRATION DU BLOG', 'created_at' => new \DateTime('11-07-2017')],

67. ['title' => 'TAKE CARE OF YOUR EMAILS', 'created_at' => new \DateTime('05-07-2017')],

68. ['title' => 'ENVOYER DES PUSH NOTIFICATIONS VIA AMAZON SNS EN SWIFT 3', 'created_at' => new \DateTime('28-06-2017')],

69. ['title' => 'CONSTRUCT AND STRUCTURE A GO GRAPHQL API', 'created_at' => new \DateTime('15-06-2017')],

70. ['title' => 'IS AMP THE WEB 3.0', 'created_at' => new \DateTime('14-06-2017')],

71. ['title' => 'CONSTRUIRE ET STRUCTURER UNE API GRAPHQL EN GO', 'created_at' => new \DateTime('07-06-2017')],

72. ];

73. }

74. }

Vous pouvez lancer la commande suivante php bin/console doctrine:fixtures:load pour insérer les données dans votre base de données.

Fonctionnellement nous voulons afficher les articles par ordre de dernière création, l'idée est donc qu'un clic sur le bouton voir plus permette de voir les articles antérieurs :

Mais comment être sur d'avoir les articles les plus anciens ?

En effet, si on utilise seulement l'offset et le limit, il se peut qu'un article s'insère dans notre flux. Nous allons donc prendre les articles qui sont plus anciens que le dernier article qui s'est affiché.

Dans le dossier src/AppBundle/Repository/ArticleRepository.php nous allons mettre la query qu'il faudra utiliser pour récupérer les contenus.

1. <?php

3. namespace AppBundle\Repository;

5. use Doctrine\ORM\EntityRepository;

7. /**

8. * Class ArticleRepository

9. * @package AppBundle\Repository

10. */

11. class ArticleRepository extends EntityRepository

12. {

13. public function getArticles(int $lastNewsDate, int $limit)

14. {

15. $lastNewsDateTime = new \Datetime();

16. $lastNewsDateTime->setTimestamp($lastNewsDate);

18. $qb = $this->createQueryBuilder('b')

19. ->where('b.createdAt < :lastNewsDate')

20. ->setParameter('lastNewsDate', $lastNewsDateTime)

21. ->orderBy('b.createdAt', 'DESC')

22. ->getQuery()

23. ->setMaxResults($limit);

25. return $qb;

26. }

27. }

Maintenant nous allons initialiser le composant React avec les premiers articles. Nous le faisons dans la partie PHP, car nous voulons que l'utilisateur n'ayant pas JavaScript voie tout de même les articles.

Dans votre fichier src/AppBundle/Controller/DefaultController.php vous pouvez changer votre indexAction avec le code suivant :

1. <?php

3. namespace AppBundle\Controller;

5. use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

6. use Symfony\Bundle\FrameworkBundle\Controller\Controller;

7. use Symfony\Component\HttpFoundation\Request;

8. use Symfony\Component\HttpFoundation\JsonResponse;

9. use AppBundle\Entity\Article;

11. class DefaultController extends Controller

12. {

13. /**

14. * @Route("/", name="homepage")

15. */

16. public function indexAction(Request $request)

17. {

18. $articles = $this->getDoctrine()

19. ->getRepository(Article::class)

20. ->getArticles(time(), 3)

21. ->getResult();

23. $jsonArticles = [];

24. foreach ($articles as $article) {

25. $jsonArticles[] = [

26. 'id' => $article->getId(),

27. 'title' => $article->getTitle(),

28. 'createdAt' => $article->getCreatedAt()->getTimestamp()

29. ];

30. }

32. return $this->render('default/index.html.twig', [

33. 'latestNews' => $jsonArticles,

34. 'lastItemDate' => $jsonArticles[count($jsonArticles) - 1]['createdAt']

35. ]);

36. }

Puis nous allons ajouter le webservice dans ce même fichier :

1. <?php

3. namespace AppBundle\Controller;

5. use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

6. use Symfony\Bundle\FrameworkBundle\Controller\Controller;

7. use Symfony\Component\HttpFoundation\Request;

8. use Symfony\Component\HttpFoundation\JsonResponse;

9. use AppBundle\Entity\Article;

11. class DefaultController extends Controller

12. {

13. /****

14. Index Action

15. ****/

17. /**

18. * @Route("/ws", name="ws")

19. */

20. public function wsAction(Request $request)

21. {

22. $lastItemDate = (int) ($request->get('last_item_date') ?? time());

23. $articles = $this->getDoctrine()

24. ->getRepository(Article::class)

25. ->getArticles($lastItemDate, 3)

26. ->getResult();

28. $jsonArticles = [];

29. foreach ($articles as $article) {

30. $jsonArticles[] = [

31. 'id' => $article->getId(),

32. 'title' => $article->getTitle(),

33. 'createdAt' => $article->getCreatedAt()->getTimestamp()

34. ];

35. }

37. return new JsonResponse($jsonArticles);

38. }

39. }

Dernières choses : faire afficher les articles et initialiser votre React. Il vous faut changer votre fichier app/Resources/views/default/index.html.twig :

1. {% extends 'base.html.twig' %}

3. {% block body %}

4. <div id="react-latest-news-home" >

5. <ul>

6. {% for article in latestNews %}

7. <li>

8. {{ article.title }}

9. </li>

10. {% endfor %}

11. <ul>

12. <div>

13. {% endblock %}

15. {% block javascripts %}

16. {% set initial_state = {

17. latestNews: {

18. data: latestNews,

19. lastItemDate: lastItemDate,

20. limit: 3,

21. },

22. } %}

23. <script>window.__INITIAL_STATE__ = {{ initial_state|json_encode|raw }};</script>

24. <script src="{{ asset('inlined.js', 'js') }}" defer></script>

25. <script src="{{ asset('vendor.js', 'js') }}" defer></script>

26. <script src="{{ asset('home.js', 'js') }}" defer></script>

27. {% endblock  %}

29. {% block stylesheets %}

30. {% endblock %}

Si vous affichez votre site, vous devez voir les trois premiers contenus, mais le voir plus ne marchera pas, normal nous n'avons pas fini notre React.

V-B - Partie React

Peu de chose à changer, d'abord commençons par changer src/AppBundle/Resources/scripts/js/react/actions/latest_news.js :

1. export const ERROR = 'latest_news/ERROR';

2. export const FETCH = 'latest_news/FETCH';

3. export const RECEIVE = 'latest_news/RECEIVE';

4. export const SHOW = 'latest_news/SHOW';

6. const error = () => ({

7. type: ERROR,

8. });

10. const fetching = (lastItemDate, limit) => ({

11. lastItemDate,

12. limit,

13. type: FETCH,

14. });

16. const receive = (news) => ({

17. type: RECEIVE,

18. data: news,

19. });

21. const show = () => ({

22. type: SHOW,

23. });

25. /**

26. * Fetch the data latest_news data

27. *

28. * @dispatch latest_news/FETCH

29. * @dispatch latest_news/RECEIVE

30. * @dispatch latest_news/ERROR

31. */

32. export const fetch = () => (dispatch, getState) => {

33. const { isFetching, lastItemDate, limit } = getState().latestNews;

35. if (isFetching) {

36. return Promise.resolve();

37. }

39. let url = `/app_dev.php/ws?last_item_date=${lastItemDate}&limit=${limit}`;

41. dispatch(fetching(lastItemDate, limit));

43. return window.fetch(url, { credentials: 'same-origin' })

44. .then(response => {

45. if (!response.ok) {

46. return Promise.reject();

47. }

49. return response.json();

50. })

51. .then(json => dispatch(receive(json)))

52. .catch(() => dispatch(error()));

53. };

55. /**

56. * Prefetch the data latest_news data

57. * And display the previously prefetched data

58. *

59. * @dispatch latest_news/ERROR

60. * @dispatch latest_news/FETCH

61. * @dispatch latest_news/RECEIVE

62. * @dispatch latest_news/SHOW

63. */

64. export const prefetch = () => (dispatch, getState) => {

65. dispatch(show());

67. return fetch()(dispatch, getState);

68. };

70. /**

71. * Refresh the latest views to update their diff time

72. */

73. export const refresh = () => ({

74. type: REFRESH,

75. });

On y trouve l'appel au webservice lors de l'action Fetch. Pour améliorer les performances, nous allons chercher les données lors de l'affichage des données précédentes qui permet d'avoir un coup d'avance sur l'utilisateur.

Vous pouvez maintenant changer le reducers suivant src/AppBundle/Resources/scripts/js/react/reducers/latest_news.js :

1. import actions from 'actions';

3. export default function latestNews(state = {}, action) {

4. switch (action.type) {

5. case actions.latestNews.FETCH:

6. return Object.assign({}, state, {

7. lastItemDate: action.lastItemDate,

8. limit: action.limit,

9. isFetching: true,

10. });

11. case actions.latestNews.RECEIVE:

12. return Object.assign({}, state, {

13. data: state.data.concat(

14. action.data.map(

15. (article) => Object.assign({}, article, { visible: false })

16. )

17. ),

18. isFetching: false,

19. });

20. case actions.latestNews.SHOW:

21. return Object.assign({}, state, {

22. data: state.data.map(

23. (article) => {

24. if (article.visible === false) {

25. return Object.assign({}, article, { visible: true });

26. }

28. return article;

29. }

30. ),

32. // Items on the next page must be published prior to the last article on the current page.

33. lastItemDate: (state.data.length === 0 ? 0 : state.data[state.data.length - 1].createdAt),

34. });

35. default:

36. return state;

37. }

38. }

Puis terminons les composants. D'abord src/AppBundle/Resources/scripts/js/react/containers/LatestNewsHome.jsx qui est le point d'entrée :

1. import React, { Component, PropTypes } from 'react';

2. import { connect } from 'react-redux';

4. import actions from 'actions';

5. import ThreadSection from 'components/Organisms/ThreadSection.jsx';

7. const REFRESH_INTERVAL = 60; // One minute

9. class LatestNewsHome extends Component {

10. constructor() {

11. super();

13. this.onButtonClick = this.onButtonClick.bind(this);

14. }

16. componentDidMount() {

17. this.props.dispatch(actions.latestNews.fetch());

18. }

20. onButtonClick(event) {

21. this.props.dispatch(actions.latestNews.prefetch());

22. }

24. render() {

25. const filtered = this.props.latestNews

26. .filter((article) => article.visible === undefined || article.visible);

28. return (

29. <ThreadSection

30. articles={filtered}

31. more={!this.props.isFetching}

32. onButtonClick={this.onButtonClick}

33. />

34. );

35. }

36. }

38. LatestNewsHome.propTypes = {

39. dispatch: PropTypes.func.isRequired,

40. isFetching: PropTypes.bool,

41. latestNews: PropTypes.arrayOf(PropTypes.object).isRequired,

42. };

44. const mapStateToProps = (state) => ({

45. isFetching: state.latestNews.isFetching,

46. latestNews: state.latestNews.data,

47. });

49. export default connect(mapStateToProps)(LatestNewsHome);

Puis vous pouvez ajouter dans le dossier src/AppBundle/Resources/scripts/js/react/components les composants suivants :

- src/AppBundle/Resources/scripts/js/react/components/Organisms/ThreadSection.jsx :

1. import React, { PropTypes } from 'react';

3. import Button from 'components/Atoms/Button.jsx';

5. const renderThreadItems = (article, index) => {

6. if (!article.id) {

7. return null;

8. }

10. return (

11. <li key={index}>

12. {article.title}

13. </li>

14. );

15. };

17. const ThreadSection = ({

18. articles = [],

19. more = false,

20. onButtonClick,

21. }) => (

22. <div>

23. <ul>

24. {articles.map((article, index) => renderThreadItems(article, index))}

25. </ul>

26. {more && (

27. <div>

28. <Button

29. text="Voir Plus"

30. onClick={onButtonClick}

31. ariaLabel="Afficher les contenus plus anciens"

32. />

33. </div>

34. )}

35. </div>

36. );

38. ThreadSection.propTypes = {

39. articles: PropTypes.arrayOf(PropTypes.object),

40. more: PropTypes.bool,

41. onButtonClick: PropTypes.func,

42. };

44. export default ThreadSection;

- src/AppBundle/Resources/scripts/js/react/components/Atoms/Button.jsx :

1. import React, { PropTypes } from 'react';

3. const Button = ({onClick = () => {}, text = '', ariaLabel = '' }) => {

4. const attributes = {

5. onClick,

6. };

8. if (ariaLabel !== '') {

9. attributes['aria-label'] = ariaLabel;

10. }

12. return (<a

13. {...attributes}

14. >

15. {text}

16. </a>

17. );

18. };

20. Button.propTypes = {

21. onClick: PropTypes.func,

22. text: PropTypes.string,

23. ariaLabel: PropTypes.string,

24. };

26. export default Button;

Bravo vous avez terminé votre flux infini.

Vous pouvez aussi retrouver le code directement dans le projet Infinite github dans la branche master.

VI - Remerciements

Nous remercions Eleven Labs qui nous autorise à publier ce tutoriel.

Nous tenons également à remercier Winjerome pour la mise au gabarit et Claude LeLoup pour la relecture orthographique.