Skip to main content
Retour au blog
JavaScriptWebThird-Party ScriptsFrontendPatterns

Interceptez une valeur tierce dès l'instant où elle arrive sur window

Un script que vous ne contrôlez pas écrit son résultat dans un global de window à un moment que vous ne pouvez pas prévoir. La plupart du code se lance dans une course avec un timeout ou gaspille le main thread en polling. Voici le trick de l'accessor property qui transforme l'écriture en événement — plus le getter que vous ne devez pas oublier, et comment substituer entièrement la valeur.

Publié 27 juillet 20269 min de lecture

Tôt ou tard, vous intégrez un script que vous ne possédez pas — un SDK d'attribution, un outil de consentement, un framework d'A/B testing, un deep-link resolver injecté via un tag manager — et il communique avec votre page en assignant une valeur à un global sur window. Quelque chose comme window.SMART_LINK_RESULT = { url: … }. Votre travail consiste à lire cette valeur et à agir dès qu'elle existe.

Le piège : vous n'avez aucun contrôle sur le moment où cette assignation a lieu. Elle peut survenir avant que votre code s'exécute, quelques centaines de millisecondes après, ou — si le réseau est lent ou si le script ne se charge jamais — jamais du tout. Cet article passe en revue les deux approches vers lesquelles tout le monde se tourne en premier, pourquoi elles sont toutes deux des compromis, et une troisième, strictement meilleure : intercepter l'assignation elle-même avec une accessor property.

Le contexte : une valeur qui arrive quand elle veut

Concrètement, imaginez une page de redirection légère. Un tag manager injecte un script qui calcule l'URL de destination correcte, entièrement attribuée, et, une fois terminé, l'écrit dans window.SMART_LINK_RESULT. Vous voulez rediriger vers cette URL dès qu'elle est disponible, et retomber sur une URL par défaut simple si elle n'apparaît jamais.

La valeur est un petit objet, et nous ne possédons pas le script qui la produit — nous connaissons seulement la forme que nous attendons :

global.d.ts
// A third-party script assigns this global once it has computed a result.
// We don't own the script, only the type we expect on the window.
interface SmartLinkResult {
  url?: string;
  [key: string]: unknown;
}

declare global {
  interface Window {
    SMART_LINK_RESULT?: SmartLinkResult;
  }
}

export {};

Tentative 1 : un timeout fixe

Le premier réflexe est d'attendre une durée « sûre » pour laisser le script terminer, puis de lire le global une seule fois et de partir :

attempt-1-timeout.ts
const FALLBACK_URL = "https://example.com/download";

// Wait a "safe" amount of time, then read the global once and redirect.
setTimeout(() => {
  const url = window.SMART_LINK_RESULT?.url;
  window.location.href = url?.trim() ? url : FALLBACK_URL;
}, 3000);

Ça fonctionne assez souvent pour être livré en production, puis ça vous coûte discrètement. Une lecture unique derrière un délai fixe est une course que vous perdez dans les deux sens :

  • Trop tôt : si le script n'a pas encore écrit la valeur au bout de 3 secondes, vous lisez undefined et redirigez vers le fallback — en jetant l'URL correcte qui serait arrivée un instant plus tard.
  • Trop tard : si le script a terminé en 200 ms, vous laissez tout de même l'utilisateur fixer une page de redirection vide pendant les 3 secondes complètes avant de faire quoi que ce soit.
  • Le « bon » délai est impossible à connaître, car il dépend du réseau et du tiers — vous finissez donc par choisir un chiffre qui est à la fois trop long pour les clients rapides et trop court pour les clients lents.

Tentative 2 : le polling

La correction évidente pour la moitié « trop tard » est d'arrêter d'attendre un moment fixe et de vérifier plutôt à répétition jusqu'à ce que la valeur apparaisse, avec une échéance pour abandonner :

attempt-2-polling.ts
const FALLBACK_URL = "https://example.com/download";
const DEADLINE = 3000;
let elapsed = 0;

const timer = setInterval(() => {
  const url = window.SMART_LINK_RESULT?.url;

  if (url?.trim()) {
    clearInterval(timer);
    window.location.href = url;           // finally showed up
  } else if ((elapsed += 50) >= DEADLINE) {
    clearInterval(timer);
    window.location.href = FALLBACK_URL;  // gave up waiting
  }
}, 50);

C'est mieux — la réaction se produit dans l'intervalle qui suit l'arrivée de la valeur, au lieu d'attendre tout le délai. Mais ça reste un compromis : vous faites tourner un timer qui ne fait rien d'utile sur la plupart de ses ticks, vous avez échangé la latence contre un intervalle réglable (trop grossier donne une impression de lag, trop fin brûle le main thread), et vous lisez frénétiquement une propriété dans une boucle chaude pour quelque chose qui n'arrive qu'une seule fois.

Le trick : intercepter l'écriture elle-même

Les deux approches traitent l'assignation comme quelque chose à découvrir après coup — en devinant quand elle a eu lieu, ou en demandant sans arrêt si elle a déjà eu lieu. Mais une assignation à une propriété est quelque chose que JavaScript veut bien vous signaler, si vous définissez la propriété comme un accessor avant que le script s'exécute. Remplacez le simple slot de données à window.SMART_LINK_RESULT par une paire getter/setter :

interceptor.ts
// Store the current value in a closure so we control what the property holds.
let value: SmartLinkResult | undefined = window.SMART_LINK_RESULT;

Object.defineProperty(window, "SMART_LINK_RESULT", {
  configurable: true,
  get() {
    return value;
  },
  set(next: SmartLinkResult | undefined) {
    value = next; // keep it readable for whoever assigned it
    if (next?.url?.trim()) {
      window.location.href = next.url; // fires the instant the script writes
    }
  },
});

Désormais, à l'instant où le script tiers exécute window.SMART_LINK_RESULT = …, il n'écrit pas dans un simple slot — il appelle votre fonction set, de façon synchrone, avec la valeur comme argument. Aucun délai, aucun polling, aucune supposition. Vous réagissez à l'instant exact où la valeur existe, ni un tick plus tôt ni plus tard. Le timeout cesse d'être votre mécanisme principal et devient ce qu'il aurait toujours dû être : un fallback pour le cas où la valeur n'arrive jamais.

Un timeout fixe devine quand la valeur arrive. Le polling demande sans arrêt si c'est déjà fait. Une accessor property, elle, se contente d'être informée — l'assignation devient un callback synchrone, avec une latence nulle et aucun travail gaspillé.

Pourquoi le getter n'est pas optionnel

Il est tentant de ne définir qu'un set — après tout, réagir à l'écriture est tout l'objectif. Ne le faites pas. Une accessor property avec un setter mais sans getter renvoie undefined à chaque lecture. Le script tiers (et tout autre code sur la page) relit souvent son propre global — pour le vérifier, mettre à jour un champ, le transmettre à un autre module. Un accessor set-only casse silencieusement tout cela :

set-only.ts
// A set-only accessor silently swallows the value on read.
Object.defineProperty(window, "SMART_LINK_RESULT", {
  configurable: true,
  set(next: SmartLinkResult | undefined) {
    if (next?.url) window.location.href = next.url;
  },
});

// The third-party script does its normal thing:
window.SMART_LINK_RESULT = { url: "/go" };

// ...but now anyone reading it back — including the script itself — sees nothing:
console.log(window.SMART_LINK_RESULT); // undefined

La règle est simple : si vous interceptez les écritures, vous devez aussi servir les lectures. Conservez la dernière valeur dans une variable de closure, renvoyez-la depuis get, et mettez-la à jour dans set. Pour tout le reste, la propriété se comporte exactement comme la propriété de données ordinaire qu'elle a remplacée — vous n'avez fait qu'ajouter un effet de bord à l'assignation.

Fermer la course dans l'autre sens : la vérification précoce

Un interceptor ne capture que les écritures qui se produisent après son installation. Si le script tiers est rapide — ou a été inliné avant votre code — la valeur peut déjà se trouver sur window au moment où vous vous exécutez. Vérifiez donc sa présence d'abord, et n'installez l'accessor que si elle n'est pas encore là :

early-check.ts
// The value may already be on the window before our code runs.
// Check first, and only install the interceptor if it isn't there yet.
const existing = window.SMART_LINK_RESULT?.url;

if (existing?.trim()) {
  window.location.href = existing;
} else {
  installInterceptor(); // the Object.defineProperty from above
}

Avec la vérification précoce et le setter combinés, vous couvrez toute la chronologie : soit la valeur était déjà là (vous la lisez maintenant), soit elle arrive plus tard (le setter se déclenche), soit elle n'arrive jamais (le fallback timer). Il n'y a aucune fenêtre où une vraie valeur pourrait vous échapper.

Substituer la valeur, pas seulement l'observer

Comme le stockage de la propriété vit désormais dans votre closure, vous n'êtes pas limité à observer la valeur — vous décidez de ce qu'elle contient réellement. C'est la différence entre un listener et un interceptor. Vous pouvez normaliser un payload malformé, retirer un champ, ou remplacer le défaut du tiers par le vôtre avant que quiconque en aval ne le lise jamais :

substitute.ts
let value: SmartLinkResult | undefined = window.SMART_LINK_RESULT;

Object.defineProperty(window, "SMART_LINK_RESULT", {
  configurable: true,
  get() {
    return value;
  },
  set(next: SmartLinkResult | undefined) {
    // Don't store what they sent verbatim — normalize it, or replace it entirely.
    value = normalize(next) ?? { url: FALLBACK_URL };
  },
});

Tout ce que le script assigne passe par votre set, et tout ce que votre get renvoie est ce que voit le reste de la page. Le défaut que le script avait l'intention d'installer n'a jamais besoin de prendre effet — seule la valeur que vous choisissez de conserver compte.

Nettoyage : remettre la propriété en place

Un accessor installé sur un global survit au composant qui l'a ajouté, donc démontez-le une fois terminé — surtout dans les single-page apps, en React Strict Mode, ou partout où le même code peut s'exécuter deux fois. Supprimez l'accessor et, s'il y avait une valeur, restaurez-la comme une propriété de données ordinaire :

teardown.ts
function uninstall() {
  const current = value;          // whatever the accessor last held
  delete window.SMART_LINK_RESULT; // remove the getter/setter pair
  if (current !== undefined) {
    // Put it back as an ordinary data property so nothing downstream breaks.
    window.SMART_LINK_RESULT = current;
  }
}

Restaurer une propriété ordinaire (plutôt que de laisser un getter/setter en suspens, ou de supprimer purement et simplement la valeur) signifie que tout ce qui lit le global par la suite retrouve un comportement normal. C'est le fait d'avoir défini configurable: true lors de la création de la propriété qui rend ce delete possible au départ — sans cela, l'accessor est permanent.

Assembler le tout

Voici le pattern complet sous la forme d'un unique hook React : vérification précoce, interceptor avec un getter correspondant, un fallback timer, et un nettoyage qui restaure une propriété ordinaire. Un verrou done garantit que la redirection se déclenche exactement une fois, quel que soit le chemin qui gagne.

useSmartLinkRedirect.ts
import { useEffect } from "react";

const FALLBACK_URL = "https://example.com/download";
const FALLBACK_DELAY = 3000;

export function useSmartLinkRedirect() {
  useEffect(() => {
    let done = false;
    let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
    let value: SmartLinkResult | undefined = window.SMART_LINK_RESULT;

    const go = (url: string) => {
      if (done) return;
      done = true;
      if (fallbackTimer) clearTimeout(fallbackTimer);
      window.location.href = url;
    };

    // 1. Already resolved before we mounted? Go now.
    if (value?.url?.trim()) {
      go(value.url);
      return;
    }

    // 2. Otherwise, catch the assignment the moment it happens.
    Object.defineProperty(window, "SMART_LINK_RESULT", {
      configurable: true,
      get() {
        return value;
      },
      set(next: SmartLinkResult | undefined) {
        value = next;
        if (next?.url?.trim()) go(next.url);
      },
    });

    // 3. Never trust the third party to always deliver.
    fallbackTimer = setTimeout(() => go(FALLBACK_URL), FALLBACK_DELAY);

    // 4. Restore a plain property on unmount.
    return () => {
      if (fallbackTimer) clearTimeout(fallbackTimer);
      const current = value;
      delete window.SMART_LINK_RESULT;
      if (current !== undefined) window.SMART_LINK_RESULT = current;
    };
  }, []);
}

La même structure fonctionne hors de React — les pièces mobiles sont une variable de closure, un Object.defineProperty avec get/set, une vérification précoce, un fallback et un teardown. Dans un module ordinaire, vous l'encapsuleriez dans une fonction et appelleriez le teardown sur pagehide ou dès que votre fonctionnalité se termine.

Timeout vs polling vs interceptor

AspectTimeout fixePollingInterceptor accessor
Réagit à l'instant où la valeur est définieNon — attend tout le délaiPresque — dans un intervalleOui — synchrone
Temps perdu quand la valeur arrive tôtTout le délaiJusqu'à un intervalleAucun
Coût main thread pendant l'attenteAucunLectures répétées sur un timerAucun
Capture une valeur définie avant votre démarrageOui (lit une fois à la fin)OuiSeulement avec une vérification précoce explicite
Peut substituer ou normaliser la valeurNonNonOui
Gère le cas où la valeur n'arrive jamaisOui (lit, puis fallback)Oui (branche d'échéance)Oui (avec un fallback timer)

L'interceptor gagne sur chaque ligne qui concerne la réaction à l'écriture, et fait jeu égal sur le reste dès que vous ajoutez la vérification précoce et le fallback timer dont il a besoin de toute façon. La seule chose à retenir, c'est qu'il doit être installé avant que le script tiers s'exécute — ce qui, pour une valeur livrée par un tag manager ou un SDK asynchrone, est presque toujours le cas si vous l'installez tôt dans le cycle de vie de votre page.

Pièges à connaître

  • L'ordre d'installation compte. L'interceptor ne capture que les écritures qui viennent après lui. Définissez l'accessor le plus tôt possible, et associez-le toujours à la vérification précoce pour la valeur qui vous a devancé.
  • Définissez toujours configurable: true. Sans cela, vous ne pouvez pas faire delete sur la propriété pour nettoyer, et une seconde tentative de la redéfinir lève une exception.
  • Protégez-vous contre le SSR. Il n'y a pas de window côté serveur. Dans un framework, exécutez ceci dans un effet client uniquement ; dans un module ordinaire, protégez-vous avec "undefined" !== "undefined".
  • Un seul interceptor par propriété. Si deux parties de votre application redéfinissent toutes deux le même global, la seconde écrase la première. Centralisez cette logique, ou vérifiez Object.getOwnPropertyDescriptor(window, name) avant d'installer.
  • Certains globaux sont déjà des accessors. Une propriété définie par l'environnement comme non-configurable (ou avec son propre getter/setter) ne peut pas être remplacée — vérifiez le descripteur d'abord et retombez sur le polling si vous ne pouvez vraiment pas la redéfinir.
  • Gardez get et set légers. Ils s'exécutent à chaque lecture et écriture du global. Effectuez le travail lourd une seule fois, derrière votre verrou done — pas à chaque accès.

Quand recourir à cette technique

C'est un outil de précision, pas un choix par défaut. Il se justifie quand toutes ces conditions sont réunies :

  • Une valeur dont vous avez besoin est écrite dans un global (ou toute propriété d'objet) par du code que vous ne contrôlez pas.
  • Le timing est imprévisible et vous voulez réagir sans latence — ou vous devez substituer la valeur avant que quoi que ce soit d'autre ne la lise.
  • Vous pouvez exécuter votre code avant celui qui écrit la valeur, ou vous êtes prêt à l'associer à une vérification précoce pour le cas où vous ne le pouvez pas.

À retenir

  • Un tiers vous remet souvent une valeur en assignant un global à un moment que vous ne pouvez pas prévoir ; un timeout fixe se lance dans une course contre lui, et le polling brûle le main thread.
  • Object.defineProperty(window, name, { get, set }), installé avant que celui qui écrit ne s'exécute, transforme l'assignation en callback synchrone — latence nulle, aucun travail gaspillé.
  • Ne livrez jamais un accessor set-only : associez chaque set à un get qui renvoie la valeur stockée, sinon vous cassez silencieusement la relecture propre de celui qui écrit.
  • Ajoutez une vérification précoce pour une valeur définie avant l'installation de l'accessor, et un fallback pour une valeur qui n'arrive jamais.
  • Comme vous possédez le stockage, vous pouvez normaliser ou remplacer la valeur — un interceptor, pas seulement un listener.
  • Définissez configurable: true et restaurez une propriété de données ordinaire lors du nettoyage, afin que rien en aval ne se retrouve avec une surprise.

Rien de tout cela n'est exotique — ce n'est que Object.defineProperty pointé vers un global au lieu de vos propres objets. Mais reformuler « attendre une valeur » en « être informé quand la valeur est écrite » élimine toute une classe de bugs de timing, et vous remet la valeur assez tôt pour la modifier. La prochaine fois que vous vous retrouvez à choisir un nombre magique de timeout pour patienter face à un script tiers, tournez-vous plutôt vers le setter.