Skip to main content
Înapoi la blog
JavaScriptWebThird-Party ScriptsFrontendPatterns

Prinde o valoare de la un script terț chiar în clipa în care ajunge pe window

Un script pe care nu îl controlezi își scrie rezultatul într-un global de pe window la un moment pe care nu îl poți prezice. Cea mai mare parte a codului intră într-o cursă cu el folosind un timeout sau irosește main thread-ul cu polling. Iată trucul cu accessor property care transformă scrierea într-un eveniment — plus getter-ul pe care nu trebuie să-l uiți, și cum să înlocuiești complet valoarea.

Publicat 27 iulie 20269 min de citit

Mai devreme sau mai târziu integrezi un script pe care nu îl deții — un SDK de attribution, un instrument de consent, un framework de A/B testing, un deep-link resolver introdus printr-un tag manager — iar acesta comunică înapoi cu pagina ta atribuind o valoare unui global pe window. Ceva de genul window.SMART_LINK_RESULT = { url: … }. Treaba ta este să citești acea valoare și să acționezi imediat ce există.

Problema: nu ai niciun control asupra momentului în care are loc atribuirea. Se poate întâmpla înainte ca codul tău să ruleze, la câteva sute de milisecunde după, sau — dacă rețeaua e lentă sau scriptul nu se încarcă niciodată — deloc. Acest articol trece în revistă cele două abordări la care apelează toată lumea prima dată, de ce ambele sunt compromisuri, și o a treia care este strict mai bună: interceptarea chiar a atribuirii, printr-un accessor property.

Contextul: o valoare care sosește când vrea ea

Concret, imaginează-ți o pagină de redirect ușoară. Un tag manager injectează un script care calculează URL-ul de destinație corect, complet atribuit, și, când termină, îl scrie în window.SMART_LINK_RESULT. Vrei să faci redirect către acel URL în clipa în care devine disponibil și să revii la un URL implicit simplu dacă nu apare niciodată.

Valoarea este un obiect mic, iar noi nu deținem scriptul care îl produce — cunoaștem doar forma pe care o așteptăm:

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 {};

Încercarea 1: un timeout fix

Primul instinct este să aștepți o durată "sigură" pentru ca scriptul să termine, apoi să citești globalul o singură dată și să mergi mai departe:

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);

Funcționează suficient de des încât să ajungă în producție, iar apoi te costă pe tăcute. O singură citire în spatele unei întârzieri fixe este o cursă pe care o pierzi în ambele direcții:

  • Prea devreme: dacă scriptul nu a scris valoarea până la marcajul de 3 secunde, citești undefined și faci redirect către fallback — aruncând URL-ul corect care ar fi sosit peste o clipă.
  • Prea târziu: dacă scriptul a terminat în 200 ms, tot îl faci pe utilizator să se uite la o pagină de redirect goală timp de 3 secunde întregi înainte să se întâmple ceva.
  • Întârzierea "corectă" nu poate fi cunoscută, pentru că depinde de rețea și de terță parte — așa că ajungi să alegi un număr care e simultan prea lung pentru clienții rapizi și prea scurt pentru cei lenți.

Încercarea 2: polling

Soluția evidentă pentru jumătatea "prea târziu" este să nu mai aștepți un moment fix și, în schimb, să verifici repetat până apare valoarea, cu un deadline pentru a renunța:

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);

E mai bine — reacționează în intervalul unui singur tick de la sosirea valorii, în loc să aștepte întreaga întârziere. Dar tot rămâne un compromis: rulezi un timer care nu face nimic util la majoritatea tick-urilor sale, ai schimbat latența pe un interval reglabil (prea mare se simte lent, prea mic arde main thread-ul), și citești insistent o proprietate într-un loop fierbinte pentru ceva ce se întâmplă exact o singură dată.

Trucul: interceptează chiar scrierea

Ambele abordări tratează atribuirea ca pe ceva ce trebuie descoperit ulterior — ghicind când s-a întâmplat, sau întrebând iar și iar dacă s-a întâmplat deja. Dar o atribuire către o proprietate este ceva despre care JavaScript te anunță cu bucurie, dacă definești proprietatea ca accessor înainte ca scriptul să ruleze. Înlocuiește slotul obișnuit de date de la window.SMART_LINK_RESULT cu o pereche 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
    }
  },
});

Acum, în clipa în care scriptul terț execută window.SMART_LINK_RESULT = …, el nu scrie într-un slot obișnuit — apelează sincron funcția ta set, cu valoarea drept argument. Fără întârziere, fără polling, fără ghicit. Reacționezi exact în clipa în care valoarea există, nici mai devreme, nici mai târziu cu un tick. Timeout-ul încetează să mai fie mecanismul tău principal și devine ceea ce ar fi trebuit să fie de la bun început: un fallback pentru cazul în care valoarea nu vine niciodată.

Un timeout fix ghicește când sosește valoarea. Polling-ul întreabă repetat dacă a sosit. Unui accessor property i se spune, pur și simplu — atribuirea devine un callback sincron, cu latență zero și fără muncă irosită.

De ce getter-ul nu este opțional

E tentant să definești doar un set — la urma urmei, reacția la scriere este tot rostul. Nu o face. Un accessor property cu setter, dar fără getter, returnează undefined la fiecare citire. Scriptul terț (și orice altceva de pe pagină) își citește adesea propriul global înapoi — ca să-l verifice, să-i actualizeze un câmp, să-l predea altui modul. Un accessor doar-cu-set strică pe tăcute tot ce ține de asta:

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

Regula e simplă: dacă interceptezi scrierile, trebuie să servești și citirile. Păstrează ultima valoare într-o variabilă de closure, returneaz-o din get și actualizeaz-o în set. Pentru toți ceilalți, proprietatea se comportă exact ca proprietatea de date obișnuită pe care a înlocuit-o — ai adăugat doar un efect secundar la atribuire.

Închide cursa și din cealaltă parte: verificarea timpurie

Un interceptor prinde doar scrierile care se întâmplă după ce îl instalezi. Dacă scriptul terț este rapid — sau a fost inserat inline înaintea codului tău — valoarea s-ar putea afla deja pe window până rulezi tu. Așa că verifică-i mai întâi prezența și instalează accessor-ul doar dacă nu e deja acolo:

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
}

Cu verificarea timpurie și setter-ul împreună, ai acoperit întreaga cronologie: fie valoarea era deja acolo (o citești acum), fie sosește mai târziu (se declanșează setter-ul), fie nu sosește niciodată (fallback timer-ul). Nu există nicio fereastră în care o valoare reală să-ți poată scăpa.

Înlocuirea valorii, nu doar observarea ei

Pentru că stocarea proprietății trăiește acum în closure-ul tău, nu ești limitat la a observa valoarea — tu decizi ce conține de fapt. Aceasta e diferența dintre un listener și un interceptor. Poți normaliza un payload malformat, elimina un câmp sau înlocui valoarea implicită a terței părți cu propria ta, înainte ca oricine din aval să o citească vreodată:

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 };
  },
});

Orice atribuie scriptul trece prin set-ul tău, iar orice returnează get-ul tău este ceea ce vede restul paginii. Valoarea implicită pe care scriptul intenționa să o instaleze nu trebuie niciodată să intre în vigoare — doar valoarea pe care alegi tu să o păstrezi.

Curățenie: pune proprietatea la loc

Un accessor instalat pe un global supraviețuiește componentei care l-a adăugat, așa că demontează-l când ai terminat — mai ales în single-page apps, în React Strict Mode, sau oriunde același cod poate rula de două ori. Șterge accessor-ul și, dacă a existat o valoare, restaureaz-o ca o proprietate de date obișnuită:

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;
  }
}

Restaurarea unei proprietăți obișnuite (în loc să lași un getter/setter suspendat sau să ștergi valoarea de tot) înseamnă că orice citește globalul ulterior primește înapoi comportamentul normal. Setarea configurable: true când ai definit proprietatea este cea care face posibil acest delete de la bun început — fără ea, accessor-ul este permanent.

Punând totul cap la cap

Iată întregul pattern ca un singur hook React: verificare timpurie, interceptor cu getter-ul potrivit, un fallback timer și o curățenie care restaurează o proprietate obișnuită. O zăvorâre done garantează că redirect-ul se declanșează exact o dată, indiferent care cale câștigă.

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;
    };
  }, []);
}

Aceeași formă funcționează și în afara React — piesele mobile sunt o variabilă de closure, un Object.defineProperty cu get/set, o verificare timpurie, un fallback și un teardown. Într-un modul obișnuit ai încapsula-o într-o funcție și ai apela teardown-ul la pagehide sau oricând se termină funcționalitatea ta.

Timeout vs polling vs interceptor

AspectTimeout fixPollingInterceptor accessor
Reacționează în clipa în care valoarea este setatăNu — așteaptă întreaga întârziereAproape — în limita unui intervalDa — sincron
Timp irosit când valoarea sosește devremeÎntreaga întârzierePână la un intervalDeloc
Cost pe main thread în timpul așteptăriiDelocCitiri repetate pe un timerDeloc
Prinde o valoare setată înainte să porneștiDa (citește o dată la final)DaDoar cu o verificare timpurie explicită
Poate înlocui sau normaliza valoareaNuNuDa
Gestionează cazul în care valoarea nu sosește niciodatăDa (citește, revine la fallback)Da (ramura de deadline)Da (cu un fallback timer)

Interceptorul câștigă la fiecare rând legat de reacția la scriere și e la egalitate la restul, odată ce adaugi verificarea timpurie și fallback timer-ul de care oricum are nevoie. Singurul lucru de reținut este că trebuie instalat înainte ca scriptul terț să ruleze — ceea ce, pentru o valoare livrată de un tag manager sau un SDK asincron, este aproape mereu cazul dacă îl instalezi devreme în ciclul de viață al paginii tale.

Capcane care merită cunoscute

  • Ordinea instalării contează. Interceptorul prinde doar scrierile care vin după el. Definește accessor-ul cât mai devreme posibil și asociază-l mereu cu verificarea timpurie pentru valoarea care te-a devansat.
  • Setează mereu configurable: true. Fără el nu poți face delete proprietății pentru curățenie, iar o a doua încercare de a o redefini aruncă o eroare.
  • Protejează-te pentru SSR. Pe server nu există window. Într-un framework, rulează asta într-un efect exclusiv client-side; într-un modul obișnuit, protejează-te cu "undefined" !== "undefined".
  • Un singur interceptor per proprietate. Dacă două părți din aplicația ta redefinesc același global, a doua o suprascrie pe prima. Centralizează-l, sau verifică Object.getOwnPropertyDescriptor(window, name) înainte de a-l instala.
  • Unele globale sunt deja accessors. O proprietate definită de mediu ca non-configurable (sau cu propriul getter/setter) nu poate fi înlocuită — verifică mai întâi descriptorul și revino la polling dacă chiar nu o poți redefini.
  • Păstrează get și set ieftine. Rulează la fiecare citire și scriere a globalului. Fă munca grea o singură dată, în spatele zăvorârii tale done — nu la fiecare acces.

Când să apelezi la asta

Este un instrument de precizie, nu o alegere implicită. Își justifică existența când toate condițiile de mai jos sunt adevărate:

  • O valoare de care ai nevoie este scrisă într-un global (sau orice proprietate de obiect) de cod pe care nu îl controlezi.
  • Momentul este imprevizibil și vrei să reacționezi fără latență — sau trebuie să înlocuiești valoarea înainte ca altceva să o citească.
  • Poți rula codul tău înaintea celui care scrie, sau ești dispus să-l asociezi cu o verificare timpurie pentru cazul în care nu poți.

Concluzii

  • O terță parte îți oferă adesea o valoare atribuind un global la un moment pe care nu-l poți prezice; un timeout fix intră în cursă cu ea, iar polling-ul arde main thread-ul.
  • Object.defineProperty(window, name, { get, set }), instalat înainte ca cel care scrie să ruleze, transformă atribuirea într-un callback sincron — latență zero, muncă irosită zero.
  • Nu livra niciodată un accessor doar-cu-set: asociază fiecare set cu un get care returnează valoarea stocată, altfel strici pe tăcute propria citire înapoi a celui care scrie.
  • Adaugă o verificare timpurie pentru o valoare care a fost setată înainte să instalezi accessor-ul, și un fallback pentru o valoare care nu sosește niciodată.
  • Pentru că deții stocarea, poți normaliza sau înlocui valoarea — un interceptor, nu doar un listener.
  • Setează configurable: true și restaurează o proprietate de date obișnuită la curățenie, astfel încât nimic din aval să nu rămână cu o surpriză.

Nimic din toate astea nu este exotic — este doar Object.defineProperty îndreptat către un global în loc de propriile tale obiecte. Dar reformularea „așteaptă o valoare” în „fii anunțat când valoarea este scrisă” elimină o întreagă clasă de bug-uri de timing și îți oferă valoarea suficient de devreme încât să o poți schimba. Data viitoare când te trezești alegând un număr magic de timeout ca să aștepți un script terț, apelează în schimb la setter.