Skip to main content
Zurück zum Blog
JavaScriptWebThird-Party ScriptsFrontendPatterns

Einen Drittanbieter-Wert im selben Moment abfangen, in dem er auf window landet

Ein Skript, das du nicht kontrollierst, schreibt sein Ergebnis zu einem unvorhersehbaren Zeitpunkt in eine globale Variable auf window. Der meiste Code liefert sich dabei ein Wettrennen mit einem Timeout oder verschwendet den main thread mit Polling. Hier ist der Accessor-Property-Trick, der das Schreiben in ein Event verwandelt — plus der getter, den du nicht vergessen darfst, und wie du den Wert komplett ersetzen kannst.

Veröffentlicht 27. Juli 20269 Min. Lesezeit

Früher oder später integrierst du ein Skript, das dir nicht gehört — ein Attribution-SDK, ein Consent-Tool, ein A/B-Framework, einen Deep-Link-Resolver, der über einen tag manager eingeschleust wird — und es kommuniziert mit deiner Seite, indem es einer globalen Variable auf window einen Wert zuweist. Etwa so: window.SMART_LINK_RESULT = { url: … }. Deine Aufgabe ist es, diesen Wert zu lesen und zu reagieren, sobald er existiert.

Der Haken: Du hast keine Kontrolle darüber, wann diese Zuweisung passiert. Sie kann vor deinem Code erfolgen, ein paar Hundert Millisekunden danach — oder, wenn das Netzwerk langsam ist oder das Skript nie lädt, überhaupt nicht. Dieser Artikel geht die zwei Ansätze durch, zu denen jeder als Erstes greift, erklärt, warum beide Kompromisse sind, und zeigt einen dritten, der strikt besser ist: das Abfangen der Zuweisung selbst mit einer accessor property.

Die Ausgangslage: ein Wert, der ankommt, wann er will

Konkret: Stell dir eine schlanke Redirect-Seite vor. Ein tag manager injiziert ein Skript, das die korrekte, vollständig attribuierte Ziel-URL berechnet und sie, sobald fertig, in window.SMART_LINK_RESULT schreibt. Du willst in dem Moment, in dem die URL verfügbar ist, dorthin weiterleiten und auf eine einfache Standard-URL zurückfallen, falls sie nie auftaucht.

Der Wert ist ein kleines Objekt, und das Skript, das ihn erzeugt, gehört uns nicht — wir kennen nur die Form, die wir erwarten:

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

Versuch 1: ein fester Timeout

Der erste Instinkt ist, eine „sichere“ Zeitspanne zu warten, bis das Skript fertig ist, dann die globale Variable einmal zu lesen und loszulegen:

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

Es funktioniert oft genug, um es zu shippen — und kostet dich dann still und leise. Ein einzelnes Lesen hinter einer festen Verzögerung ist ein Wettrennen, das du in beide Richtungen verlierst:

  • Zu früh: Hat das Skript den Wert bis zur 3-Sekunden-Marke nicht geschrieben, liest du undefined und leitest zum Fallback weiter — und verwirfst damit die korrekte URL, die einen Moment später eingetroffen wäre.
  • Zu spät: Hat das Skript schon nach 200 ms fertig, lässt du den Nutzer trotzdem die vollen 3 Sekunden auf eine leere Redirect-Seite starren, bevor überhaupt etwas passiert.
  • Die „richtige“ Verzögerung lässt sich nicht wissen, weil sie vom Netzwerk und vom Drittanbieter abhängt — am Ende wählst du also eine Zahl, die für schnelle Clients gleichzeitig zu lang und für langsame zu kurz ist.

Versuch 2: Polling

Die naheliegende Lösung für die Hälfte „zu spät“ ist, nicht mehr auf einen festen Moment zu warten, sondern wiederholt zu prüfen, bis der Wert erscheint — mit einer Deadline, ab der man aufgibt:

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

Das ist besser — die Reaktion erfolgt innerhalb eines Intervalls nach Ankunft des Werts, statt die ganze Verzögerung abzuwarten. Aber es bleibt ein Kompromiss: Du betreibst einen Timer, der bei den meisten seiner Ticks nichts Sinnvolles tut, du hast Latenz gegen ein einstellbares Intervall getauscht (zu grob fühlt sich träge an, zu fein verbrennt den main thread), und du liest in einer heißen Schleife ständig eine Property für etwas, das genau einmal passiert.

Der Trick: das Schreiben selbst abfangen

Beide Ansätze behandeln die Zuweisung als etwas, das man nachträglich entdecken muss — indem man errät, wann sie passiert ist, oder immer wieder nachfragt, ob sie schon passiert ist. Aber eine Zuweisung an eine Property ist etwas, das JavaScript dir gern selbst mitteilt, wenn du die Property als accessor definierst, bevor das Skript läuft. Ersetze den einfachen Daten-Slot bei window.SMART_LINK_RESULT durch ein getter/setter-Paar:

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

In dem Moment, in dem das Drittanbieter-Skript window.SMART_LINK_RESULT = … ausführt, schreibt es nun nicht mehr in einen einfachen Slot — es ruft synchron deine set-Funktion auf, mit dem Wert als Argument. Keine Verzögerung, kein Polling, kein Raten. Du reagierst genau in dem Moment, in dem der Wert existiert, keinen Tick früher oder später. Der Timeout ist nicht mehr dein primärer Mechanismus, sondern wird zu dem, was er von Anfang an hätte sein sollen: ein Fallback für den Fall, dass der Wert nie kommt.

Ein fester Timeout rät, wann der Wert ankommt. Polling fragt immer wieder, ob er schon da ist. Einer accessor property wird es einfach mitgeteilt — die Zuweisung wird zu einem synchronen Callback, mit null Latenz und null verschwendeter Arbeit.

Warum der getter nicht optional ist

Es ist verlockend, nur einen set zu definieren — schließlich ist das Reagieren auf den Schreibvorgang der ganze Sinn der Sache. Tu es nicht. Eine accessor property mit setter, aber ohne getter liefert bei jedem Lesen undefined. Das Drittanbieter-Skript (und alles andere auf der Seite) liest seinen eigenen Global oft zurück — um ihn zu prüfen, ein Feld darauf zu aktualisieren, ihn an ein anderes Modul zu übergeben. Ein set-only accessor bricht das alles stillschweigend:

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

Die Regel ist einfach: Wenn du Schreibvorgänge abfängst, musst du auch Lesevorgänge bedienen. Halte den letzten Wert in einer closure-Variable, gib ihn aus get zurück und aktualisiere ihn in set. Für alle anderen verhält sich die Property genau wie die einfache Daten-Property, die sie ersetzt hat — du hast lediglich einen Seiteneffekt bei der Zuweisung hinzugefügt.

Das Wettrennen von der anderen Seite schließen: die frühe Prüfung

Ein Interceptor fängt nur Schreibvorgänge ab, die nach seiner Installation passieren. Ist das Drittanbieter-Skript schnell — oder wurde inline vor deinem Code eingebunden — kann der Wert bereits auf window liegen, wenn dein Code läuft. Prüfe also zuerst darauf und installiere den accessor nur, wenn er noch nicht da ist:

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
}

Mit der frühen Prüfung und dem setter zusammen hast du die gesamte Timeline abgedeckt: Der Wert war schon da (jetzt lesen), er kommt später (der setter feuert), oder er kommt nie (der fallback-Timer). Es gibt kein Zeitfenster, in dem dir ein echter Wert entgehen kann.

Den Wert ersetzen, nicht nur beobachten

Weil der Speicher der Property jetzt in deiner closure liegt, bist du nicht darauf beschränkt, den Wert zu beobachten — du entscheidest, was er tatsächlich enthält. Das ist der Unterschied zwischen einem listener und einem interceptor. Du kannst einen fehlerhaften payload normalisieren, ein Feld entfernen oder den Default des Drittanbieters durch deinen eigenen ersetzen, bevor irgendjemand nachgelagert ihn überhaupt liest:

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

Was auch immer das Skript zuweist, läuft durch dein set, und was dein get zurückgibt, ist das, was der Rest der Seite sieht. Der Default, den das Skript setzen wollte, muss nie wirksam werden — nur der Wert, den du zu behalten entscheidest.

Aufräumen: die Property zurücksetzen

Ein auf einem Global installierter accessor überlebt die Komponente, die ihn hinzugefügt hat — bau ihn also ab, wenn du fertig bist, besonders in single-page apps, im React Strict Mode oder überall dort, wo derselbe Code zweimal laufen kann. Lösche den accessor und stelle, falls ein Wert vorhanden war, ihn als gewöhnliche Daten-Property wieder her:

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

Eine einfache Property wiederherzustellen (statt einen verwaisten getter/setter hängen zu lassen oder den Wert einfach zu löschen) bedeutet, dass alles, was den Global danach liest, wieder normales Verhalten bekommt. configurable: true beim Definieren der Property ist überhaupt erst das, was dieses delete ermöglicht — ohne es ist der accessor permanent.

Alles zusammensetzen

Hier ist das gesamte Pattern als ein einziger React hook: frühe Prüfung, interceptor mit passendem getter, ein fallback-Timer und ein cleanup, das eine einfache Property wiederherstellt. Eine done-Latch garantiert, dass der Redirect exakt einmal ausgelöst wird, egal welcher Pfad gewinnt.

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

Dieselbe Struktur funktioniert auch außerhalb von React — die beweglichen Teile sind eine closure-Variable, ein Object.defineProperty mit get/set, eine frühe Prüfung, ein fallback und ein teardown. In einem einfachen Modul würdest du das in eine Funktion packen und den teardown bei pagehide oder immer dann aufrufen, wenn dein Feature endet.

Timeout vs. Polling vs. Interceptor

AspektFester TimeoutPollingAccessor-Interceptor
Reagiert in dem Moment, in dem der Wert gesetzt wirdNein — wartet die volle Verzögerung abFast — innerhalb eines IntervallsJa — synchron
Verschwendete Zeit, wenn der Wert früh kommtDie ganze VerzögerungBis zu einem IntervallKeine
Main-thread-Kosten während des WartensKeineWiederholte Reads per TimerKeine
Erfasst einen Wert, der vor dem Start gesetzt wurdeJa (liest am Ende einmal)JaNur mit expliziter früher Prüfung
Kann den Wert ersetzen oder normalisierenNeinNeinJa
Behandelt den Fall, dass der Wert nie ankommtJa (liest, fällt zurück)Ja (Deadline-Zweig)Ja (mit fallback-Timer)

Der interceptor gewinnt jede Zeile, bei der es um Reagieren auf das Schreiben geht, und zieht beim Rest gleich, sobald du die frühe Prüfung und den fallback-Timer hinzufügst, die er ohnehin braucht. Das Einzige, was man sich merken muss: Er muss installiert werden, bevor das Drittanbieter-Skript läuft — was bei einem Wert, der von einem tag manager oder einem asynchronen SDK geliefert wird, fast immer der Fall ist, wenn du ihn früh im Lebenszyklus deiner Seite installierst.

Fallstricke, die man kennen sollte

  • Die Installationsreihenfolge zählt. Der interceptor fängt nur Schreibvorgänge ab, die nach ihm kommen. Definiere den accessor so früh wie möglich und kombiniere ihn immer mit der frühen Prüfung für den Wert, der schneller war als du.
  • Setze immer configurable: true. Ohne das kannst du die Property zum Aufräumen nicht per delete entfernen, und ein zweiter Versuch, sie neu zu definieren, wirft einen Fehler.
  • Absichern für SSR. Auf dem Server gibt es kein window. In einem Framework führe das in einem client-only effect aus; in einem einfachen Modul sichere es mit "undefined" !== "undefined" ab.
  • Ein interceptor pro Property. Wenn zwei Teile deiner App denselben Global neu definieren, überschreibt der zweite den ersten. Zentralisiere das, oder prüfe vor der Installation Object.getOwnPropertyDescriptor(window, name).
  • Manche Globals sind bereits accessors. Eine Property, die von der Umgebung als non-configurable definiert wurde (oder mit eigenem getter/setter), kann nicht ersetzt werden — prüfe zuerst den descriptor und weiche auf Polling aus, wenn du sie wirklich nicht neu definieren kannst.
  • Halte get und set günstig. Sie laufen bei jedem Read und Write des Globals. Erledige aufwendige Arbeit einmal, abgesichert durch deine done-Latch — nicht bei jedem Zugriff.

Wann man dazu greifen sollte

Das ist ein Präzisionswerkzeug, kein Standardgriff. Es lohnt sich, wenn all das zutrifft:

  • Ein Wert, den du brauchst, wird von Code, den du nicht kontrollierst, in einen Global (oder eine beliebige Objekt-Property) geschrieben.
  • Das Timing ist unvorhersehbar, und du willst ohne Latenz reagieren — oder du musst den Wert ersetzen, bevor irgendetwas anderes ihn liest.
  • Du kannst deinen Code vor dem Schreiber ausführen, oder du bist bereit, ihn für den Fall, dass das nicht geht, mit einer frühen Prüfung zu kombinieren.

Das Wichtigste

  • Ein Drittanbieter übergibt dir einen Wert oft durch Zuweisen eines Globals zu einem unvorhersehbaren Zeitpunkt; ein fester Timeout liefert sich damit ein Wettrennen, und Polling verbrennt den main thread.
  • Object.defineProperty(window, name, { get, set }), installiert bevor der Schreiber läuft, verwandelt die Zuweisung in einen synchronen Callback — null Latenz, null verschwendete Arbeit.
  • Liefere niemals einen set-only accessor aus: Kombiniere jedes set mit einem get, das den gespeicherten Wert zurückgibt, sonst brichst du stillschweigend das eigene Zurücklesen des Schreibers.
  • Füge eine frühe Prüfung für einen Wert hinzu, der schon vor der Installation des accessors gesetzt wurde, und einen fallback für einen Wert, der nie ankommt.
  • Weil dir der Speicher gehört, kannst du den Wert normalisieren oder ersetzen — ein interceptor, nicht nur ein listener.
  • Setze configurable: true und stelle beim cleanup eine einfache Daten-Property wieder her, damit niemand nachgelagert eine Überraschung erlebt.

Nichts davon ist exotisch — es ist einfach Object.defineProperty, gerichtet auf einen Global statt auf deine eigenen Objekte. Aber „auf einen Wert warten“ zu „benachrichtigt werden, wenn der Wert geschrieben wird“ umzudeuten, beseitigt eine ganze Klasse von Timing-Bugs und gibt dir den Wert früh genug in die Hand, um ihn zu ändern. Wenn du das nächste Mal eine magische Timeout-Zahl wählst, um ein Drittanbieter-Skript auszusitzen, greife stattdessen zum setter.