Faire évoluer Symfony vers une application moderne

Avec un peu (beaucoup) de JavaScript...

Aurélien David - @spyl94

Twig et jQuery

ne suffisent pas quand l'application ...

  • cherche à attirer et conserver un maximum d'utilisateurs.
  • se doit d'être fluide, intuitive... moderne !

En gros quand l'User eXperience impacte (fortement) le business.

Et mon code PHP j'en fais quoi ?

Retour d'expérience sur

Startup civique experte en intelligence collective qui développe des applications participatives clé en main.

Un MVP

1~2 dev                    pendant 6 mois 

Aucun test unitaire ou fonctionel :-(

Du                éparpillé dans des fichiers

On met en place des tâches techniques

On teste les fonctionnalités critiques pour éviter les régressions lors de la refonte du front-end.

On modernise la stack JavaScript

En travaillant la Developper eXperience

On watch en ~300ms

On code en ES6

On évite Bower!

On est fin prêts pour un framework Front-End!

  • Branche master utilisée en production par

  • L'API haut niveau < 15 fonctions

On le teste quand même sur un petit projet

 pour confirmer notre choix.

const CommentSection = React.createClass({
  propTypes: {
    post: PropTypes.number.isRequired, // post id, we will use it to fetch the comments
  },

  getInitialState() {
    return {
      comments: [],
      isLoading: true,
    };
  },

  componentDidMount() { // executed after first render
    this.loadComments();
  },

  loadComments() {
    // todo fetch an API
    this.setState({ 
        comments: [{body: 'commentaire 1'}, {body: 'commentaire 2'}],
        isLoading: false,
    });
  },

  render() {
    return (
      <div className="comments__section">
        <CommentForm />
        <Loader show={this.state.isLoading}>
          <CommentList comments={this.state.comments} />
        </Loader>
      </div>
    );
  },

});

export default CommentSection;

On affiche notre composant React

// app.js
import React from 'react';
import ReactDOM from 'react-dom';
import CommentSection from './components/Comment/CommentSection';

if ($('#render-post-comments').length) {
   ReactDOM.render(
      <CommentSection post={$('#render-post-comments').data('post')} />,
      document.getElementById('render-post-comments')
   );
}
{% extends '@App/Blog/index.html.twig' %}


{% block body %}
   <h1>{{ post.title }}</h1>
   <div id="render-post-comments" data-post="{{ post.id }}"></div>
{% endblock %}

On conçoit les APIs avant de les développer

# features/api/post_comments.feature

Feature: Posts comments

  Scenario: API client wants to list comments of a blogpost
    When I send a GET request to "/api/posts/1/comments"
    Then the JSON response should match:
    """
    {
      "comments_count": @[email protected],
      "comments":
      [
        {
          "id": @[email protected],
          "body": @[email protected],
          "created_at": "@[email protected]()",
          "updated_at": "@[email protected]()",
          "votes_count": @[email protected],
          "author": {
            "username": @[email protected],
            "media": @[email protected]
          },
          "can_edit": @[email protected]
        },
        @[email protected]
      ]
    }
    """

On isole les différents scénarii de test

  @security
  Scenario: Anonymous API client wants to add a comment
    When I send a POST request to "/api/posts/1/comments" with json:
    """
    {
      "body": "Vive les SfPots !"
    }
    """
    Then the JSON response status code should be 400


  @database
  Scenario: logged in API client wants to add a comment
    Given I am logged in to api as user
    When I send a POST request to "/api/posts/1/comments" with json:
    """
    {
      "body": "Vive les SfPots ! c'est encore mieux en étant connecté :)"
    }
    """
    Then the JSON response status code should be 201


  @security
  Scenario: logged in API client wants to add an empty comment
    Given I am logged in to api as user
    When I send a POST request to "/api/posts/1/comments" with json:
    """
    {
      "body": ""
    }
    """
    Then the JSON response status code should be 400

Et nos contrôleurs rétrecissent

    /**
     * @Post("/posts/{id}/comments")
     * @ParamConverter("post", options={"mapping": {"id": "id"}})
     * @View(statusCode=201, serializerGroups={"Comments", "UsersInfos"})
     */
    public function postPostCommentsAction(Request $request, BlogPost $post)
    {
        $comment = (new PostComment())
                    ->setAuthor($this->getUser())
                    ->setPost($post);

        $form = $this->createForm(new CommentForm(), $comment);
        $form->handleRequest($request);

        if (!$form->isValid()) {
            return $form;
        }

        $this->getDoctrine()->getManager()->persist($comment);
        $this->getDoctrine()->getManager()->flush();

        $this->get('event_dispatcher')->dispatch(
            AppBundleEvents::COMMENT_ADDED,
            new CommentAddedEvent($comment)
        );
    }

On se construit une boîte à outils en JS

const status = (response) => {
  if (response.status >= 200 && response.status < 300) {
    return response;
  }
  throw new Error(response.statusText);
};
const json = (response) => response ? response.json() : {};
const getHeaders = () => AuthService.isJwtValid() 
                      ? { Authorization: 'Bearer ' + AuthService.getJwt() }
                      : {};

class Fetcher {

  get(uri) {
    return AuthService.login().then(() => {
        return fetch(config.api + uri, {
          method: 'get',
          headers: getHeaders(),
        }).then(status).then(json);
    });
  }

  post(uri, body) {
    return AuthService.login().then(() => {
        return fetch(config.api + uri, {
          method: 'post',
          headers: getHeaders(),
          body: JSON.stringify(body),
        }).then(status);
    });
  }
}

On communique simplement avec notre API

const CommentSection = React.createClass({
   
  // ...

  loadComments() {
    Fetcher
        .get(`posts/${this.props.post}/comments`)
        .then((data) => {
            this.setState({ 
              comments: data.comments,
              isLoading: false,
            });
        })
     ;
  },

});

const CommentForm = React.createClass({
    
    // ...

    handleSubmit() {
      Fetcher
        .post(`posts/${this.props.post}/comments`, this.state.form)
        .then(() => {
            this.setState(this.getInitialState());
        })
      ;
    }

});

Mais nos besoins se complexifient

Paginer, revenir en arrière, communiquer d'un composant enfant vers un composant parent....

Redux evolves the ideas of Flux, but avoids its complexity by taking cues from Elm.

Quelques mois après...

On se retrouve avec une dizaine d'applications React réparties sur le site et plus de 150 composants.

  • La durée de la suite de tests fonctionnels exécutant le JS a explosé (+ de 30 minutes...).

 

  • Les premiers composants nous paraissent mal conçus, ne respectent pas les bonnes pratiques acquises.

 

  • L'équipe produit est satisfaite des améliorations de l'UX, et de la nouvelle façon de développer, malgré les nouveaux bugs souvent liés aux incompatibilités des navigateurs.

On met en place de nouvelles pratiques

// MyComponent-test.js
import React from 'react';
import { shallow } from 'enzyme';
import { expect } from 'chai';

describe('<MyComponent />', () => {

  it('renders three <Foo /> components', () => {
    const wrapper = shallow(<MyComponent />);
    expect(wrapper.find('Foo')).to.have.length(3);
  });

});

Tests unitaires

// .eslintrc
{
  "extends": "jolicode",
  "plugins": [
    "react"
  ],
  "parserOptions": {
    "ecmaVersion": 6,
    "sourceType": "module",
    "ecmaFeatures": {
      "jsx": true
    }
  },
  "rules": {
    "react/prefer-es6-class": 0
  }
}

Linter

Mais...

Au fait le SEO ?

Et les pages blanches quand t'exécutes pas le JS ? (oui, ça existe encore, la preuve au prochain slide)

Quelqu'un a utilisé le site republique-numerique.fr en utilisant mon nom [...]

En vérité, je ne peux pas utiliser ce site car j'en suis techniquement exclu.

[...]

 Le texte des articles n'apparaît pas dans mon navigateur : ce site ne fonctionne pas dans mon cas.

Il requiert l'exécution de programmes privateurs en JavaScript.

[...]

Mais la meilleure solution consiste, sans aucun doute, à faire fonctionner le site sans code JavaScript.

 

Richard Stallman

https://www.april.org/republique-numeriquefr-de-lusurpation-didentite-la-confiscation-de-la-liberte

Le rendu JS serveur depuis Sf

$ composer require limenius/react-bundle
# AppBundle:Blog:show.html.twig

{{ react_component('CommentSectionApp', {'props': commentSectionProps}) }}
    public function showAction(BlogPost $post)
    {
        $serializer = $this->get('jms_serializer');

        $commentSectionProps = $serializer->serialize([
            'comments' => $post->getComments(),
        ], 'json', SerializationContext::create());

        return [
            'post' => $post,
            'commentSectionProps' => $commentSectionProps,
        ];
    }

Un peu de JS car c'est pas magique :)

// app.js

import './registration';

/* the rest of my JS code ... */
// registration.js
import React from 'react';
import ReactOnRails from 'react-on-rails';
import CommentSection from './components/CommentSection';

const CommentSectionApp = (props) => <CommentSection {...props} />;

ReactOnRails.register({ CommentSectionApp });
  • Côté serveur : on génère un autre fichier JS dédié au rendu serveur avec registration.js comme point d'entrée.
limenius_react:
    serverside_rendering:
        # Location of the server bundle, that contains React and React on Rails.
        server_bundle_path: "%kernel.root_dir%/../web/js/server-bundle.js"
  • Côté client : on importe simplement registration.js

On ne passe plus forcément par une API pour afficher les données initiales, on préfère injecter le JSON en props directement.

Ça fonctionne !

On a réussi à rendre nos composants en html avant d'envoyer la page au client, on récupère donc les bénéfices de SEO et on évite les pages blanches pour les utilisateurs sans JS.

Côté client le JS reprend la main et s'exécute normalement, en utilisant le rendu serveur comme état initial.

Mais...

C'est très couteux en performances ! Il faut absolument utiliser un reverse proxy.

En ajoutant un feature flag pour désactiver le rendu serveur, on tient 3 fois plus d'utilisateurs connectés.

{{ react_component('CommentSectionApp', {
    'props': commentSectionProps,
    'rendering': (app.user and not feature_is_active('server_side_rendering')) 
                 ? 'client_side' 
                 : 'both',
   })
}}

D'où l'importance de développer des composants intelligents capables de récupérer les données manquantes !

Bilan

Résultat très satisfaisant

Mais des temps de développement rallongés, donc s'assurer que c'est essentiel à votre business !

Beaucoup de gains en clarté et organisation du code; facilité à tester et plaisir de coder.

Eviter la Reactite aigüe, on peut encore utiliser Twig pour un affichage simple.

Merci!

Questions ?

Aurélien David - @spyl94