React en quelques lignes de jQuery

À titre personnel je n'aime pas React. Mais je n'ai pas toujours le choix car c'est souvent la solution retenue par mes clients…

Je suis tombé sur le billet Boiling React Down to a Few Lines in jQuery.

Il est relativement vieux mais j'aime sa façon d'utiliser jQuery pour expliquer certaines idées de React.

L'essentiel est repris ci-dessous, en conservant le mot clé var, signe d'un autre temps :)

Le concept de state

Ces lignes de jQuery…

<span id="colored-counter">0</span>
<input id="color"></input>
<button id="inc"></button>

<script>
$('#color').on('keyup', function () {
    $('#colored-counter').css('color', this.value);
})

$('#inc').on('click', function () {
    var oldValue = $('#colored-counter').html();
    var newValue = 1 + Number(oldValue);
    $('#colored-counter').html(newValue);
})
</script>

…pourraient être reformulées comme ça :

<span id="colored-counter">0</span>
<input id="color"></input>
<button id="inc"></button>

<script>
var state = {color: '', value: 0};

function updateUI() {
    $('#colored-counter').css('color', state.color);
    $('#colored-counter').html(state.value);
}

$('#color').on('keyup', function () {
    state.color = this.value;
    updateUI();
})

$('#inc').on('click', function () {
    state.value++;
    updateUI();
})
</script>

Cela n'a pas l'air beaucoup mieux, mais on sait qu'au fil du temps :

  • l'UI aura davantage d'événements et d'éléments
  • certains événements mettront à jour plus d'un élément
  • certains éléments seront mis à jour par plus d'un événement

Le concept de state permet de simplifier la complexité.

Pour N événements et M éléments :

  • situation 1 : complexité potentielle de O(N*M) liens
  • situation 2 : complexité potentielle de O(N+M) liens

Comparaison de la complexité entre jQuery et React

Historique des états

Avoir un seul état (state) explicite et découplé de la mise à jour de l'UI (updateUI()) permet de :

  • sauver un état sérialisé dans le local storage et d'obtenir une protection contre le rechargement des pages
  • l'envoyer au serveur et gérer la persistance
  • gérer un historique des états sérialisés pour permettre l'annulation/le déplacement dans le temps

Pour se protéger d'un rechargement accidentel de la page :

function updateUI() {
    // Save latest state to local storage
    LocalStorage.set('state', JSON.stringify(state));
    // ... continue as usual
}

// Load saved state from local storage on page load
$(function () {
    state = JSON.parse(LocalStorage.get('state'));
    updateUI();
});

Pour avoir des "voyages dans le temps" et permettre le débogage :

<span id="time-pos"></span>
<button id="back">Back</button>
<button id="next">Next</button>

<script>
var time = {history: [], pos: 0};

function updateTimeUI() {
    $('#time-pos').html('Position ' + time.pos + ' of ' + time.history.length);
}

function saveState() {
    time.history.push(deepcopy(state));
    time.pos++;
    updateTimeUI();
}

$('#back').on('click', function () {
    // Move history pointer
    time.pos--;
    updateTimeUI();
    // Load historic state
    state = deepcopy(time.history[time.pos]);
    updateUI();
})
$('#next').on('click', function () {
    time.pos++;
    // ... same
})

// ...

function updateUI() {
    // Save state to history on every change
    saveState();
    // ... continue as usual
}
</script>

À chaque changement d'état, on pousse sa copie dans l'historique et, plus tard, on peut le restaurer en le copiant de l'historique et en mettant à jour l'UI.

Notez aussi qu'on utilise le même pattern ici :

  • time est l'état de la sous-application de voyage dans le temps
  • updateTimeUI() est sa fonction de mise à jour

On peut maintenant construire à partir de ça.

En sauvegardant l'historique dans le stockage local, on peut capturer les erreurs et les envoyer avec l'historique sérialisé au gestionnaire de bogues afin de pouvoir reproduire automatiquement les erreurs auxquelles les utilisateurs sont confrontés.

Fonction pure

Les fonctions pures (pure functions) permettent des optimisations et (dans une certaine mesure) des simplifications qui ne sont pas au cœur de l'idée de React.

D'abord, un exemple un peu complexe :

<span id="count">2</span>
<ul>
    <li>hi</li>
    <li>there</li>
</ul>
<button id="add"></button>

<script>
var state = {items: ['hi', 'there']}

function updateUI() {
    $('#count').html(state.items.length);
    // Compare ul.childNodes to state.items and make updates
    // ...
}

$('ul').on('click', 'li', function () {
    state.items.splice($(this).index(), 1);
    updateUI();
})

$('#add').on('click', function () {
    state.items.push(getNextString());
    updateUI();
})
</script>

On remarque :

  • qu'il y a une duplication entre le HTML pré-rendu et l'état initial
  • que la mise à jour est complexe car on doit comparer la structure de données au DOM

Il existe un moyen plus simple :

<div id="ui"></div>
...

<script>
...

function render(state) {
    var span = '<span id="count">' + state.items.length + '</span>';
    var lis = state.items.map(function (item) {
        return '<li>' + item + '</li>';
    });
    return span + '<ul>' + lis.join('') + '</ul>'
}

function updateUI() {
    $('#ui').html(render(state));
}

...
</script>

Ici, render() est une fonction "pure" : l'état est la seule chose qui définit l'UI.

Dans le premier exemple on met à jour l'UI, dans le second on la calcule et on la reconstruit de zéro à chaque fois.

DOM virtuel

Les paires de render() / $().html() construisent une représentation entière à partir de zéro à chaque événement, probablement à chaque pression de touche.

Pour éviter des lenteurs on utilise une autre optimisation, le DOM virtuel :

var root = document.getElementById('ui');
var prevState = state, prevTree = [];

function render(state) {
    // Virtual DOM is really just a tree of JavaScript objects or arrays
    return [
        ['span', {id: 'count'}, state.items.length],
        ['ul', {}, state.items.map(function (item) {
            return  ['li', {}, item]
        })]
    ]
}

function updateUI() {
    var vTree = render(state);
    var diff = vDiff(prevTree, vTree); // Just a diff on data structures
    vApply(root, diff)                 // Apply patches to real DOM

    prevState = deepcopy(state);
    prevTree = vTree;
}

Les diffing/patching sont implémentés par React (ou par des implémentations autonomes de DOM virtuel) sur la base d'algorithmes de diff.

De manière surprenante, la plupart des SPAs fonctionnent très bien sans DOM virtuel, les navigateurs étant extrêmement rapides de nos jours.

Pour une application moyenne, vous pouvez ignorer React ou d'autres DOM virtuels au début et ne les utiliser que lorsqu'ils deviennent trop lents (ou jamais).

Un DOM virtuel peut être la cause de nouveaux problèmes. Par exemple, marquer involontairement des composants comme dirty peut entraîner un nouveau rendu inutile de parties entières de l'arbre du DOM.

Immutabilité

Tous les appels à deepcopy() ressemblent à du gâchis.

L'idée derrière les structures de données immuables est de construire un nouvel état basé sur le précédent au lieu de le copier puis de le muter.

Pour créer des objets immuables en JS :

var object = {
    a: {x: 1, y: 2},
    b: {text: 'hi'}
}

// Now instead of object.a.y = 3 we do
var object2 = {
    a: {x: object.a.x, y: 3},
    b: object.b
}

On réutilise object.a.x et object.b :

  • ça évite de devoir les copier
  • ça rend la détection de différences beaucoup plus rapide : avant de faire une différence entre object.b et object2.b, on vérifie simplement s'il s'agit du même objet (égalité référentielle)

Mais cette approche immuable naïve est inefficace pour les tableaux :

var prev = ['a', 'b', 'c', ..., 'z'];
var next = prev.slice(0, n-1).concat([newValue]).concat(prev.slice(n));

Il faut copier l'ensemble. Voici une meilleure façon d'implémenter les séquences immuables :

var prev = {
    '0-3': {
        '0-1': {0: 'a', 1: 'b'},
        '2-3': {...},
    },
    '4-7': {...}
}
var next = {
    '0-3': {
        '0-1': prev['0-3']['0-1'],
        '2-3': {
            2: 'hey',
            3: prev['0-3']['2-3'][3]
        }
    },
    '4-7': prev['4-7']
}

On ne crée que log N nouveaux objets, le reste est réutilisé, ce qui permet de réduire la copie et d'accélérer le calcul des différences.

Il existe d'excellentes implémentations prêtes à l'emploi comme Immutable.js :

var object = Immutable.fromJS({
    a: {x: 1, y: 2},
    b: {text: 'hi'}
})

var object2 = object.setIn(['a', 'y'], 3); // object remains intact

Avant EditorConfig Après Les bases de la sécurité Web

Tag Kemar Joint