Parfois on a besoin de partager des données ou des états entre des contextes, des fenêtres, des onglets, des extensions différentes d'un même navigateur, peu de gens le savent mais en fait c'est très facile de faire ça !

Pourquoi on voudrait faire ça ?

La première idée qui me vient en tête c'est avoir une application multi-écran : outil de présentation (une fenêtre avec la vue présentateur et une avec la vue speaker (affichage slide en cours + slide suivante + notes + temps écoulé)), application pro qui aurait 2 vues qu'on voudrait avoir en parallèle et synchronisé (par exemple : un éditeur de texte et le rendu en pdf, un lecteur de vidéos/photos et la galerie, une vue avec des données brutes et une vue avec des graphiques qui se mettent à jour en temps réel, etc.), mise à jour en temps réel d'un état entre plusieurs onglets (comme un changement de thème), etc.

À l'inverse on pourrait vouloir bloquer le multi-onglet, car on sait que notre application ne fonctionnera pas bien en multi-onglet pour différente raison, donc on voudra savoir si une session est déjà en cours pour afficher un message ou une vue différente.

Il y a beaucoup de cas d'usage qu'on peut imaginer, c'est super intéressant comme mécanique. Certains ont même réalisé des concepts artistiques comme nonfigurativ.

Solution 0 : Backend

C'est plutôt simple, on a l'habitude de passer par un backend pour partager des données, mais c'est assez coûteux de synchroniser un état avec le serveur en continue si on souhaite uniquement être multi-onglet… Par contre ça offre une liberté assez forte : on peut imaginer du multi-device, du multi-navigateur aussi !

C'est assez simple d'imaginer comment faire ça, donc ce n'est pas très intéressant à montrer ! Par contre je vous conseille de regarder du côté de la bibliothèque TanStack Query qui est justement pensé pour gérer depuis le frontend un état backend super facilement !

Solution 1 : LocalStorage / SessionStorage

En lisant le titre, je vous vois déjà vous dire "non, mais je vais pas faire du polling sur le storage quand même !?", évidemment il y a une meilleure solution (enfin…) ! Et ça tombe bien, parce qu'on a tendance à l'oublier mais y'a pas mal de chose qui sont écoutables avec des Event en JavaScript et l'ajout, la suppression ou la modification du LocalStorage ou SessionStorage en font partie !

La logique est simple :

const eventName = 'MySharedState';
window.addEventListener('storage', (event: StorageEvent) => {
    if (event.key === eventName) {
      console.log(event); // va afficher chaque Event pour la clé "MySharedState"
    }
});
localStorage.setItem(eventName, 'foo');

J'ai pris le LocalStorage pour l'exemple, mais si on a pas besoin de persister l'état hors de la session, le SessionStorage fonctionnera aussi bien !

Comme on le voit c'est super simple, sauf que, y'a un hic : le StorageEvent n'est émis sur pour les autres contextes et pas le contexte courant. Donc en effet si on a deux onglets, une modification de l'onglet 1 sera notifié à l'onglet 2, mais l'onglet 1 doit gérer tout seul le fait que l'état est changé…

C'est un peu dommage, mais on doit gérer ça nous-mêmes, mais on a pas d'autre option. Les méthodes simples pour gérer ça :

  • avoir un événement à nous qui a la même forme qu'un StorageEvent (par exemple un SameTabStorageEvent) qu'on émettra nous-mêmes et qu'on écoutera en parallèle pour avoir un seul flux de traitement (que le changement vienne du même contexte ou d'un autre contexte) ;
  • gérer un état local avec un état interne caché pour ne pas avoir émettre d'événement pour éviter d'aller lire le LocalStorage/SessionStorage à chaque fois qu'on veut lire l'état (en particulier du fait qu'on est obligé de sérialiser notre état sous forme de string pour pouvoir la garder dans le LocalStorage ou SessionStorage) ;

Il y a aussi un cas particulier : les DevTools. Si on modifie la valeur de notre état partagé via les DevTools, aucun StorageEvent n'est émis, donc pour ce cas-là (qui est quand même bien pratique en phase de développement), on aura pas le choix que de faire du polling… Mais on peut avoir un délai pas trop agressif du fait qu'on a pas besoin d'une réactivité à la milliseconde en phase de développement !

Si on prend en compte quelques gardes fous, un mécanisme d'abonnement/désabonnement, un peu d'enrobage pour avoir une classe SharedState qui masque la complexité, toute la phase d'initialisation, les cas particuliers, la gestion du typing (uniquement pour TypeScript), on obtient le code suivant :

export type State<T> = Readonly<T>;
export type SharedStateListener<T> = (event: { data: State<T> }) => void;

export class SharedState<T extends State<T>> {
  private state: T | undefined;
  private listeners: any[] = [];

  constructor(private stateName: string, private defaultState?: T) {
    if (defaultState) {
      this.state = defaultState;
    }
    this.initWithStorageState();
    this.listenStateChange();
  }

  private initWithStorageState() {
    const storageState = localStorage.getItem(this.stateName);
    if (storageState) {
      try {
        this.state = JSON.parse(storageState);
      } catch {
        this.setState(this.defaultState);
      }
    } else {
      this.setState(this.defaultState);
    }
  }

  private onStorageEvent(event: StorageEvent | SameTabStorageEvent) {
    if (event.key !== this.stateName) {
      return;
    }
    if (!event.newValue) {
      this.setInternalState(this.defaultState);
      return;
    }
    this.setInternalState(JSON.parse(event.newValue));
  }

  private listenStateChange() {
    window.addEventListener('storage', this.onStorageEvent.bind(this));
    window.addEventListener('same-tab-storage', this.onStorageEvent.bind(this));
    setInterval(() => {
      // only for storage edition from devtools
      const oldState = JSON.stringify(this.state);
      const newState = localStorage.getItem(this.stateName);
      if (oldState !== newState) {
        window.dispatchEvent(
          new SameTabStorageEvent(this.stateName, oldState, newState)
        );
      }
    }, 3_000);
  }

  getState(): T | undefined {
    return this.state;
  }

  setState(newState: T | undefined) {
    if (!newState) {
      return;
    }
    localStorage.setItem(this.stateName, JSON.stringify(newState));
    this.setInternalState(newState);
  }

  subscribe(listener: SharedStateListener<T>) {
    this.listeners.push(listener);
  }

  unsubscribe(listener: SharedStateListener<T>) {
    this.listeners = this.listeners.filter((l) => l !== listener);
  }

  private setInternalState(newState: T | undefined) {
    const oldValue = JSON.stringify(this.state);
    const newValue = JSON.stringify(newState);
    if (oldValue !== newValue) {
      this.state = newState;
      this.listeners.forEach((listener) => listener({ data: newState }));
      window.dispatchEvent(
        new SameTabStorageEvent(this.stateName, oldValue, newValue)
      );
    }
  }
}

class SameTabStorageEvent extends Event {
  constructor(
    public readonly key: string,
    public readonly oldValue: string | null,
    public readonly newValue: string | null
  ) {
    super('same-tab-storage', { bubbles: true, composed: true });
  }
}

declare global {
  interface Window {
    addEventListener(
      eventType: 'same-tab-storage',
      listener: (event: SameTabStorageEvent) => void
    ): void;
    removeEventListener(
      eventType: 'same-tab-storage',
      listener: (event: SameTabStorageEvent) => void
    ): void;
  }
}

Il faut une centaine de lignes de code pour avoir tout le mécanisme, ce n'est pas si gros que ça et il n'y a rien de trop compliqué non plus ! Sur à l'usage :

// emitter.ts

const themeState = new SharedState('THEME', {
    border: 'gray',
    background: 'white',
    foreground: 'transparent',
    text: 'black',
  } satisfies Theme);

// somewhere in the code
themeState.setState(updatedTheme);
// listener.ts

const themeState = new SharedState<Theme>('THEME');
let initialTheme = themeState.getState();
// do something with initialTheme
themeState.subscribe(({ data }) => {
  // do something with updated value
});

Une partie du code ne servant qu'à gérer les listeners, c'est un peu dommage, car le code du navigateur est toujours plus optimisé que notre code en JavaScript… De plus on doit trier les StorageEvents pour s'assurer qu'on ne prend que ceux qui nous intéressent… Sans compter qu'on surcharge le type de Window ce n'est pas terrible au fond… Et si on faisait un peu mieux ?

Solution 2 : BroadcastChannel

La solution 1 fonctionne très bien, mais ça m'embête de devoir recréer un Event identique à un Event natif et des mécaniques au-dessus des APIs Storage pour gérer de la synchronisation sachant que ce n'est pas vraiment l'objectif du Storage… J'ai repensé à l'API BroadcastChannel que j'avais déjà utilisé pour des WebExtensions !

Un truc qu'on a tendance à beaucoup oublier c'est que le navigateur fourni beaucoup d'API de communication assez puissante ! L'API BroadcastChannel offre quelques moyens de communication très simple d'utilisation ! Par contre en fonction de votre cas d'usage, sa compatibilité peut être un peu juste (elle fait partie de la Baseline 2022 (donc support sur tous les navigateurs plus récents que mars 2022) à cause d'Apple qui a mis 6 ans de plus que ses comparses à ajouter à Safari cette API…).

À l'usage c'est super simple :

const channel = new BroadcastChannel(channelName);
channel.addEventListener('message', ({ data }) => {
    this.setState(JSON.parse(data.newValue));
});
channel.postMessage('foo');

Ici pas de blague du genre limite de contexte, obligation d'utiliser des Event, ou même d'obligation de sérialisation ! Pour garder la persistance de l'état (et la possibilité de directement reprendre l'état au chargement de la page), on peut garder la sauvegarde dans le LocalStorage ou SessionStorage. Garder l'état permet aussi de manipuler cet état avec les DevTools !

À noter qu'il existe aussi une API MessageChannel qui est plus ancienne, qui est compatible très largement mais qui ne fonctionne que pour faire communiquer deux éléments et il faut fournir explicitement à chacun des deux éléments la référence à un port qui est une propriété de l'instance du MessageChannel qu'on va créer. Ça ne permet donc pas de communiquer à travers plusieurs onglets/fenêtres.

En gardant exactement la même signature que la solution 1, on change l'implémentation pour utiliser uniquement un seul BroadcastChannel et 40 lignes de code en moins !

export type State<T> = Readonly<T>;
export type SharedStateListener<T> = (event: { data: State<T> }) => void;

export class SharedState<T extends State<T>> {
  private state: T | undefined;
  private channel: BroadcastChannel;

  constructor(private stateName: string, private defaultState?: T) {
    this.channel = new BroadcastChannel(this.stateName);
    if (defaultState) {
      this.state = defaultState;
    }
    this.initWithStorageState();
    this.listenStateChange();
  }

  private initWithStorageState() {
    const storageState = localStorage.getItem(this.stateName);
    if (storageState) {
      try {
        this.setState(JSON.parse(storageState));
      } catch {
        this.setState(this.defaultState);
      }
    } else {
      this.setState(this.defaultState);
    }
  }

  private listenStateChange() {
    this.channel.addEventListener('message', ({ data }) => {
      this.setState(data);
    });
    // only for storage edition from devtools
    setInterval(this.initWithStorageState.bind(this), 3_000);
  }

  getState(): T | undefined {
    return this.state;
  }

  setState(state: T | undefined) {
    const oldState = JSON.stringify(this.state);
    const newState = JSON.stringify(state);
    if (oldState !== newState) {
      this.state = state;
      localStorage.setItem(this.stateName, newState);
      this.channel.postMessage(state);
    }
  }

  subscribe(listener: SharedStateListener<T>) {
    this.channel.addEventListener('message', listener);
  }

  unsubscribe(listener: SharedStateListener<T>) {
    this.channel.removeEventListener('message', listener);
  }
}

Solution 3 : SharedWorker

Dans l'idée : dans le navigateur on dit tout le temps qu'on a un seul thread, mais ce n'est plus vrai depuis déjà quelques années ! On peut créer des WebWorker, qui offrent une option pour avoir accès à un thread à part pour gérer des traitements lourds. Mais il se trouve que les WebWorker ont une autre caractéristique intéressante : ils peuvent être partagés entre plusieurs contextes (avec la même origine donc même triplet protocole / hôte / port).

Comme on peut envoyer des messages vers ce SharedWorker, on voit très vite comment on peut fonctionner car ça revient à envoyer des messages sur un Channel comme pour la solution 2 mais avec un intermédiaire !

Par contre attention, créer un Worker ou un SharedWorker entraine la création d'un Thread CPU, donc ça consomme "pas mal" de ressource, donc ne le faites pas si ce n'est pas nécessaire à mon avis !

Je n'ai pas implémenté cette solution qui comme je le disais est très proche de la solution 2, mais je vous renvoie vers la démonstration de notachraf qui a fait un article qui vous montre et explique ça !

Conclusion

Parce qu'une image vaut mille mots ! J'ai réalisé une petite démonstration mettant les deux solutions en pratique. Vous pouvez permuter les deux solutions dans Stackblitz en changeant l'import dans les fichiers src/theme-configurator.ts et src/theme-preview.ts.

Démonstration

La démo est codée en vanilla JS mais est très facile à utiliser directement avec la plupart des frameworks, pour React je vous conseille d'aller voir cet article où je vous explique entre autres l'utilisation de useExternalAsyncStore pour avoir un wrapper sous forme de hook !

Le code source est évidemment sur mon Github en accès libre, et quand j'aurai un peu de temps je prendrais le temps d'en faire une petite librairie un peu plus configurable !

Sources :

Crédit photo : Générée via Microsoft Designer avec le prompt suivant (+ Phot.AI pour étendre l'arrière-plan)

tin can phone, no phone, children discussion, wire between can, talk through can, no telephone