Le fonctionnement des signaux

Dans cet article, j'espère pouvoir vous montrer à quel point le concept de signal, et son implémentation, sont en réalité d'une simplicité enfantine, et son implication dans les framework modernes.

Le fonctionnement des signaux
Photo by Chinh Le Duc / Unsplash

De Knockout.js à Solid.js en passant par Vue.js, de nombreux frameworks ont aujourd'hui adopté les signaux comme outil principal de gestion de la réactivité des données et de l'interface graphique pour la construction des applications Web.

Mais vous êtes-vous toujours demandé comment cela fonctionnait sous le capot ?

Dans cet article, j'espère pouvoir vous montrer à quel point le concept de signal, et son implémentation, sont en réalité d'une simplicité enfantine, et son implication dans les framework modernes.

Signal ? Ou plutôt Observable ?

On parle énormément (voire trop) des signaux depuis quelques années, mais le concept n'a absolument rien de nouveau (comme pour l'intelligence artificielle). Il a simplement suivi un effet de mode poussé par les signaux, mais le concept derrière fait référence à un patron de conception qui est l'Observable.

Si vous ne voyez pas de quoi je parle, j'en parle justement dans cet article que je vous recommande de lire avant d'avancer dans cet article pour comprendre les fondamentaux de ce qu'est un Observable.

Créons notre propre librairie RxJS à partir de rien
Un observable est un patron de conception permettant de simplifier la gestion d’état asynchrone. Un état asynchrone est une donnée qui peut être accessible immédiatement, ou plus tard, voir jamais (lorsqu’il y a une erreur à la récupération de cet état). C’est le cas par exemple d’une requête HTTP : il

Car un signal n'est ni plus ni moins qu'un observable, exactement. La grande différence réside dans la manière dont il est possible de pouvoir réagir à ce dernier, en utilisant un mécanisme intelligent dit de souscription automatique, ou Auto Subscription pour les intimes.

Créons notre propre signal

Pour pouvoir avoir une compréhension fine de la façon dont fonctionne la souscription automatique, il faut d'abord plonger dans le fonctionnement des signaux, et pour cela, quoi de mieux que de recréer notre propre signal à partir de rien (à 100k vues je renomme l'article).

Voici le code.

function signal<Value>(value: Value) {
  function getter() {
    return value
  }

  function setter(newValue: Value) {
    value = newValue
  }

  return [
    getter,
    setter
  ] as const
}

Cette implémentation n'est pas finale et nous allons encore la modifier dans les prochaines sections.

Détaillons le code source maintenant.

function signal<Value>(value: Value) {
  // ...
}

Nous avons ici créé une fonction nous permettant de créer un nouveau signal. Nous avons pris le parti ici de choisir la voie qui a été celle de Solid.js, à savoir créer des signaux immuables avec un Getter et un Setter. Nous aurions très bien pu faire également comme Vue.js avec des signaux, ou Ref muable, je laisse cet exercice au lecteur de choisir l'un ou l'autre en fonction de ses affinités.

La fonction en l'occurrence est générique et nous permettra de pouvoir accepter n'importe quelle valeur, ainsi que de rester cohérents dans le type qui sera celui adopté par les Getter et Setter qu'on retournera.

  function getter() {
    return value
  }

La fonction getter nous permet tout simplement de récupérer la valeur actuelle du signal. Pas grand-chose de plus à ajouter si ce n'est que cette implémentation va légèrement changer dans les prochaines sections.

  function setter(newValue: Value) {
    value = newValue
  }

La fonction setter est comme son nom l'indique une fonction nous permettant de remplacer l'ancienne valeur du signal par sa nouvelle valeur. Ici, nous allons simplement la remplacer par une mutation directe.

  return [
    getter,
    setter
  ] as const

Enfin, nous retournons simplement les deux fonctions, à la manière de React.js et son fameux hook useState. C'est un parti pris, mais il permet de cette façon aux développeurs React.js de pouvoir effectuer une transition du modèle mental vers Solid.js plus simplement, et je trouve que c'est une excellente manière d'approcher ce problème.

L'indication as const nous permet d'indiquer au compilateur TypeScript de prendre exactement ces deux fonctions, dans cet ordre, et dans ce nombre, pour éviter les types de sortie qui ressemblerait à (() => Value, (x: Value) => void)[], ce qui serait problématique pour pouvoir utiliser correctement les setter et getter.

Pour l'utiliser, nous pouvons écrire un code minimal d'exemple.

const [counter, setCounter] = signal(0)

console.log(`Counter is ${counter()}`)

setCounter(10)

console.log(`Counter is ${counter()}`)

En exécutant ce code, on obtient la sortie suivante.

Counter is 0
Counter is 10

Cela ressemble à ce qu'on imaginait de ce code, le setter fait bien son travail de modifier la valeur, et tout semble fonctionner, plutôt simple non ?

Néanmoins, ce code reste tout à fait classique, et ce n'est ni plus ni moins que la construction d'une variable, avec un affichage et la modification de cette valeur avec des étapes supplémentaires. Donc rien de quoi nous impressionner.

Ce qui serait en revanche intéressant serait de pouvoir réagir automatiquement à un changement de valeur, à la manière d'un observable (vous avez lu mon article n'est-ce pas ? Hein ?). Et donc de ne pas avoir à écrire 15 fois la même ligne d'affichage (ou de modification du DOM), ce qui serait bien pénible (voilà pourquoi on utilise des framework JavaScript, par flemme).

Effectivement

En accord avec ce que nous avons dit précédemment, il serait bien plus intéressant de pouvoir faire en sorte de n'avoir que ces lignes-ci.

const [counter, setCounter] = signal(0)

console.log(`Counter is ${counter()}`)

setCounter(10)
setCounter(20)
setCounter(30)

Et d'avoir une sortie qui ressemblerait à ceci.

Counter is 0
Counter is 10
Counter is 20
Counter is 30

Sauf que si vous comprenez bien le fonctionnement du langage JavaScript, vous savez que le code que nous avons écrit ici est lu de haut en bas, et il n'y a malheureusement aucune chance que cela se produise sans avoir à rajouter de nouveau des lignes identiques...

À moins que l'on puisse s'abonner à des changements sur cette valeur ! C'est là qu'entre en jeu une deuxième primitive des frameworks implémentant les signaux, qui est la fonction effect. Écrivons d'abord son implémentation et voyons comment elle fonctionne.

type Effect = () => void

let currentEffect: Effect | null = null

function effect(newEffect: Effect): void {
  currentEffect = newEffect

  newEffect()
}

Bon, le code n'a pas l'air impressionnant, mais il nous permet de poser les bases qui nous serviront plus tard.

type Effect = () => void

Ce type nous permet de définir ce qu'est un effet. Un effet est une simple fonction, qui va avoir pour effet (jeu de mots intentionnel) d'exécuter du code en réaction au changement d'un signal.

Ce qui peut paraître assez étrange est qu'il n'y a pour l'instant aucune connexion entre un effet et un signal (même pas de tableau de dépendance comme avec React.js et useEffect), et vous allez le voir dans les prochaines sections, il en existe bien une, mais elle est subtile et très bien pensée.

let currentEffect: Effect | null = null

Cette ligne-ci n'est pas très complexe, on stocke l'effet courant qui nous permettra plus tard de savoir ce qu'il faut exécuter. Même chose, si cela ne fait pas de sens tout de suite, vous verrez plus tard qu'elle a tout son sens dans le code source, souvenez-vous de cette ligne bien précise.

function effect(newEffect: Effect): void {
  currentEffect = newEffect

  newEffect()
}

C'est la fonction qui nous permet de créer un effet, nous allons la détailler bien qu'elle ne contient pas beaucoup de lignes.

function effect(newEffect: Effect): void {
  // ...
}

La signature de la fonction est très simple : elle accepte un effet (défini par le type Effect) et elle ne retourne rien. Simple.

  currentEffect = newEffect

Ici, lorsqu'un effet est exécuté, nous stockons ce dernier, pour nous en servir bien plus tard.

  newEffect()

Enfin, nous exécutons l'effet en lui-même, pour pouvoir stocker une première fois l'effet, et surtout faire ce que l'utilisateur souhaite que nous fassions dans son code au moins une fois.

C'est super tout ça, mais ça ne nous donne rien de vraiment concret, ni le lien entre un effet et un signal. Pour cela, un petit exemple de ce que nous souhaterions faire dans l'idéal pour pouvoir exécuter notre code automatiquement.

const [counter, setCounter] = signal(0)

effect(() => {
  console.log(`Counter is ${counter()}`)
})

setCounter(10)
setCounter(20)
setCounter(30)

Bon, nous avons déjà avancé, dans l'idée, nous aimerions que lorsqu'une fonction entre dans un effet, elle puisse s'exécuter autant de fois que sa valeur est changée.

Nous avons déjà enregistré cette fonction, maintenant il faut trouver une façon de pouvoir l'exécuter automatiquement, et de faire le lien entre un effet et un signal.

Signalement

C'est là qu'il va nous falloir modifier légèrement l'implémentation de notre signal.

function signal<Value>(value: Value) {
  const effects: Effect[] = []

  function getter() {
    if (currentEffect) {
      effects.push(currentEffect);
    }

    return value
  }

  function setter(newValue: Value) {
    value = newValue

    effects.forEach(executeEffect => {
      executeEffect()
    })
  }

  return [
    getter,
    setter
  ] as const
}

Voilà, c'est ici que tout le secret réside, et nous n'avons pas eu besoin de rajouter beaucoup plus de code que cela, néanmoins nous allons passer sur chacune des lignes qui ont été modifiées afin de bien comprendre le concept.

  const effects: Effect[] = []

Premièrement, il est nécessaire pour nous de stocker tous les effets qui pourraient avoir un lien avec notre signal, évidemment, dans un code plus complexe, plusieurs effet, dans plusieurs composants différents, pourraient avoir besoin d'un seul et même signal.

    if (currentEffect) {
      effects.push(currentEffect);
    }

Il est là le secret : chaque fois qu'un effet est exécuté, il enregistre sa fonction et surtout, il est exécuté, ce qui permet à notre getter de pouvoir enregistrer dans ses effets, l'effet courant qui a besoin de ce signal.

C'est un mécanisme très intelligent qui permet d'avoir cet effet de souscription automatique, contrairement à React.js qui nous donne un mécanisme de souscription explicite comme ceci.

const [counter, setCounter] = useState(0);

useEffect(() => {
  console.log(`Counter is ${counter}`);
}, [counter]);

Comme on peut le voir avec React.js, le concept est très similaire, cependant l'utilisation pour le développeur est bien différente au niveau de l'effet.

    effects.forEach(executeEffect => {
      executeEffect()
    })

Naturellement, si un setter est appelé, il doit obligatoirement prévenir tous les effets qui ont souscrit implicitement à ce signal que sa valeur a changé. Il est donc nécessaire de parcourir notre tableau d'effet, et de notifier ces derniers.

C'est tout ! C'est littéralement comme cela que fonctionnent les effets. L'implémentation réelle dans le framework Solid.js est plus optimisée pour d'autres cas d'utilisation, mais vous pourriez tout à fait remplacer les effets de Solid.js par votre propre librairie (pour peu que vous implémentiez également une version du rendu de vos composants avec ce dernier).

Voici le code final.

type Effect = () => void

let currentEffect: Effect | null = null;

function effect(newEffect: Effect): void {
  currentEffect = newEffect

  newEffect();
}

function signal<Value>(value: Value) {
  const effects: Effect[] = []

  function getter() {
    if (currentEffect) {
      effects.push(currentEffect);
    }

    return value
  }

  function setter(newValue: Value) {
    value = newValue

    effects.forEach(executeEffect => {
      executeEffect()
    })
  }

  return [
    getter,
    setter
  ] as const
}

const [counter, setCounter] = signal(0)

effect(() => {
  console.log(`Counter is ${counter()}`)
})

setCounter(10)
setCounter(20)
setCounter(30)

Pas mal non ?

Et React.js ?

Nous avons vu dans cet article comment été implémenté un signal, dans sa version la plus simple.

Vous comprenez donc comment fonctionnent des librairies qui les utilisent, comme Vue.js ou Solid.js, et vous êtes mieux équipés pour comprendre les différentes primitives comme createSignal sur Solid.js ou watch sur Vue.js.

D'autres frameworks les implémentent évidemment, comme Svelte ou bien même Angular, bien qu'ils n'étaient pas intégré de base dans le framework.

Néanmoins, le villain petit canard dans toute cette histoire, c'est bien React.js.

En effet, React.js implémente les états et les effets d'une façon similaire, mais bien plus explicite que dans Vue.js ou Solid.js. En somme, il serait tout à fait possible techniquement de pouvoir faire en sorte que les états enregistrent implicitement les effets dans React.js.

Le principal souci se pose ailleurs : avec Solid.js, on obtient une caractéristique du framework qui permet au DOM de se mettre à jour sans avoir besoin de calculer une différence entre un ancien rendu et un nouveau rendu.

C'est ce qui est offert par ce mécanisme de souscription automatique : L'utilisateur peut utiliser un signal dans la fonction de rendu, et en parcourant cette vue, il est possible de savoir de manière très fine où dans le DOM se situe la manipulation à effectuer pour pouvoir réagir aux changements de valeur d'un signal.

Tandis que pour React.js, c'est bien différent car il est nécessaire de parcourir tout le nouveau DOM virtuel afin de savoir quels ont été les changements, vérifier les dépendances des effets à chaque fois, et appliquer ces changements au nouveau DOM.

De mon point de vue, je pense que si un jour React.js va jusqu'à changer fondamentalement la manière dont le framework fonctionne, il sera nécessaire de se rapprocher de la façon dont fonctionne Solid.js actuellement.

Pensez-vous qu'un jour React.js et Solid.js ne pourraient faire plus qu'un ? Ou pensez-vous au contraire que React.js sera amené à innover et à proposer quelque chose d'encore nouveau et bien plus optimisé ?

Voilà qui conclut cet article, et si vous souhaitez aller plus loin, essayez par exemple d'implémenter un mécanisme de mémoisation comme avec la fonction computed par exemple.