Skip to main content
Назад к блогу
JavaScriptWebThird-Party ScriptsFrontendPatterns

Ловите значение стороннего скрипта в тот самый момент, когда оно попадает в window

Скрипт, который вы не контролируете, записывает свой результат в глобал на window в момент, который невозможно предсказать. Большая часть кода либо проигрывает ему гонку с помощью timeout, либо впустую нагружает main thread полингом. Вот трюк с accessor property, который превращает запись в событие — плюс геттер, о котором нельзя забывать, и как полностью подменить значение.

Опубликовано 27 июля 2026 г.9 мин чтения

Рано или поздно вы интегрируете скрипт, которым не владеете, — attribution SDK, инструмент для consent, A/B-фреймворк, deep-link resolver, вставленный через tag manager, — и он сообщает что-то вашей странице, присваивая значение глобалу на window. Что-то вроде window.SMART_LINK_RESULT = { url: … }. Ваша задача — прочитать это значение и отреагировать на него, как только оно появится.

Загвоздка в том, что вы никак не контролируете, когда произойдёт это присваивание. Оно может случиться до того, как выполнится ваш код, через несколько сотен миллисекунд после — или, если сеть медленная или скрипт вообще не загрузится, не случиться никогда. В этой статье мы разберём два подхода, к которым тянутся первыми, почему оба являются компромиссом, и третий, который однозначно лучше: перехват самого присваивания через accessor property.

Исходные условия: значение, которое приходит, когда захочет

Конкретнее — представьте лёгкую страницу-редирект. Tag manager вставляет скрипт, который вычисляет правильный, полностью атрибутированный URL назначения и, закончив, записывает его в window.SMART_LINK_RESULT. Вы хотите сделать редирект на этот URL в момент, когда он становится доступен, и откатиться на простой URL по умолчанию, если он так и не появится.

Значение — небольшой объект, и мы не владеем скриптом, который его создаёт, — мы знаем лишь форму, которую ожидаем:

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

Попытка 1: фиксированный timeout

Первый инстинкт — подождать «безопасное» количество времени, пока скрипт закончит, затем один раз прочитать глобал и поехать:

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

Это работает достаточно часто, чтобы попасть в прод, а потом тихо начинает вам стоить. Одно чтение за фиксированной задержкой — это гонка, которую вы проигрываете в обе стороны:

  • Слишком рано: если скрипт не успел записать значение к отметке 3 секунды, вы прочитаете undefined и сделаете редирект на fallback — выбросив правильный URL, который появился бы чуть позже.
  • Слишком поздно: если скрипт завершился за 200 мс, пользователь всё равно будет смотреть на пустую страницу-редирект все 3 секунды, прежде чем что-то произойдёт.
  • «Правильную» задержку невозможно узнать заранее, потому что она зависит от сети и стороннего скрипта, — так что в итоге вы выбираете число, которое одновременно слишком велико для быстрых клиентов и слишком мало для медленных.

Попытка 2: полинг

Очевидное решение для половины «слишком поздно» — перестать ждать фиксированный момент и вместо этого проверять раз за разом, пока значение не появится, с дедлайном, после которого можно сдаться:

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

Это лучше — реакция происходит в пределах одного интервала после появления значения, а не после всей задержки целиком. Но это всё равно компромисс: вы гоняете таймер, который на большинстве тиков не делает ничего полезного, вы обменяли латентность на настраиваемый интервал (слишком грубый ощущается как лаг, слишком мелкий палит main thread), и вы в горячем цикле раз за разом читаете свойство ради события, которое происходит ровно один раз.

Трюк: перехватить сам момент записи

Оба подхода относятся к присваиванию как к чему-то, что нужно обнаружить постфактум — угадывая, когда оно произошло, или снова и снова спрашивая, произошло ли оно уже. Но о присваивании свойству JavaScript охотно сам вам расскажет, если определить это свойство как accessor до того, как выполнится скрипт. Замените обычный слот данных на window.SMART_LINK_RESULT парой 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
    }
  },
});

Теперь в тот момент, когда сторонний скрипт выполняет window.SMART_LINK_RESULT = …, он не пишет в обычный слот — он синхронно вызывает вашу функцию set, передавая значение как аргумент. Никакой задержки, никакого полинга, никаких догадок. Вы реагируете в тот самый момент, когда значение появляется, — ни тиком раньше, ни тиком позже. Timeout перестаёт быть вашим основным механизмом и становится тем, чем и должен был быть с самого начала: запасным вариантом на случай, если значение так и не придёт.

Фиксированный timeout угадывает, когда придёт значение. Полинг раз за разом спрашивает, пришло ли оно. Accessor property просто сообщают — присваивание становится синхронным колбеком с нулевой латентностью и без лишней работы.

Почему геттер не опционален

Возникает искушение определить только set — ведь реакция на запись и есть вся суть. Не делайте так. Accessor property с сеттером, но без геттера возвращает undefined на каждое чтение. Сторонний скрипт (да и всё остальное на странице) часто читает свой собственный глобал обратно — чтобы проверить его, обновить в нём поле, передать другому модулю. Set-only accessor молча ломает всё это:

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

Правило простое: если вы перехватываете записи, вы обязаны также обслуживать чтения. Храните последнее значение в переменной-замыкании, возвращайте его из get и обновляйте в set. Для всех остальных свойство ведёт себя точно так же, как обычное свойство-данные, которое оно заменило, — вы лишь добавили побочный эффект на присваивание.

Закрываем гонку с другой стороны: ранняя проверка

Перехватчик ловит только те записи, что происходят после его установки. Если сторонний скрипт быстрый — или был вставлен инлайном раньше вашего кода — значение может уже лежать на window к моменту, когда выполняется ваш код. Поэтому сначала проверьте его наличие и устанавливайте accessor только если его там ещё нет:

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
}

Вместе ранняя проверка и сеттер покрывают весь таймлайн: значение уже было там (читаем сразу), либо оно придёт позже (сработает сеттер), либо не придёт вовсе (fallback-таймер). Нет ни одного промежутка, в котором реальное значение могло бы проскользнуть мимо вас.

Подмена значения, а не просто наблюдение за ним

Поскольку хранилище свойства теперь живёт в вашем замыкании, вы не ограничены наблюдением за значением — вы решаете, что оно на самом деле содержит. В этом и разница между слушателем и перехватчиком. Вы можете нормализовать некорректный payload, убрать поле или заменить дефолт стороннего скрипта своим — ещё до того, как кто-то ниже по потоку его прочитает:

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

Всё, что скрипт присваивает, проходит через ваш set, а то, что возвращает ваш get, — это то, что видит остальная часть страницы. Дефолт, который скрипт собирался установить, может так и не вступить в силу — вступит только то значение, которое вы решите оставить.

Уборка: вернуть свойство на место

Accessor, установленный на глобал, переживает компонент, который его добавил, поэтому демонтируйте его, когда закончите, — особенно в single-page приложениях, React Strict Mode или где угодно, где один и тот же код может выполниться дважды. Удалите accessor и, если значение было, восстановите его как обычное свойство-данные:

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

Восстановление обычного свойства (вместо того, чтобы оставить висящую пару getter/setter или вовсе удалить значение) означает, что всё, что читает глобал впоследствии, снова получает нормальное поведение. Именно configurable: true, указанный при определении свойства, делает этот delete возможным в принципе — без него accessor остаётся навсегда.

Собираем всё вместе

Вот весь паттерн в виде одного React-хука: ранняя проверка, перехватчик с соответствующим геттером, fallback-таймер и уборка, которая восстанавливает обычное свойство. Защёлка done гарантирует, что редирект сработает ровно один раз, независимо от того, какой путь победит.

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

Та же форма работает и за пределами React — движущиеся части — это переменная-замыкание, Object.defineProperty с get/set, ранняя проверка, fallback и демонтаж. В обычном модуле вы бы обернули это в функцию и вызывали демонтаж на pagehide или когда завершается ваша фича.

Timeout против полинга против перехватчика

АспектФиксированный timeoutПолингAccessor-перехватчик
Реагирует в момент установки значенияНет — ждёт всю задержкуПочти — в пределах одного интервалаДа — синхронно
Потерянное время, если значение пришло раноВся задержкаДо одного интервалаНоль
Нагрузка на main thread во время ожиданияНольПовторные чтения по таймеруНоль
Ловит значение, установленное до стартаДа (читает один раз в конце)ДаТолько с явной ранней проверкой
Может подменить или нормализовать значениеНетНетДа
Обрабатывает случай, когда значение не приходитДа (читает, откатывается)Да (ветка дедлайна)Да (с fallback-таймером)

Перехватчик выигрывает во всех строках, касающихся реакции на запись, и сравнивается с остальными, как только вы добавляете раннюю проверку и fallback-таймер, которые ему и так нужны. Единственное, что нужно помнить, — его нужно установить до того, как выполнится сторонний скрипт; а для значения, доставляемого tag manager'ом или асинхронным SDK, это почти всегда так, если установить его рано в жизненном цикле страницы.

Подводные камни, о которых стоит знать

  • Порядок установки важен. Перехватчик ловит только записи, которые происходят после него. Определяйте accessor как можно раньше и всегда сочетайте его с ранней проверкой для значения, которое вас опередило.
  • Всегда указывайте configurable: true. Без этого вы не сможете сделать delete свойства для уборки, а вторая попытка переопределить его выбросит ошибку.
  • Учитывайте SSR. На сервере нет window. Во фреймворке запускайте это в client-only эффекте; в обычном модуле защититесь через "undefined" !== "undefined".
  • Один перехватчик на свойство. Если две части вашего приложения переопределяют один и тот же глобал, вторая затирает первую. Централизуйте это или проверяйте Object.getOwnPropertyDescriptor(window, name) перед установкой.
  • Некоторые глобалы уже являются accessor'ами. Свойство, определённое окружением как non-configurable (или со своими getter/setter), заменить нельзя — сначала проверьте дескриптор и откатитесь на полинг, если переопределить его действительно невозможно.
  • Держите get и set дешёвыми. Они выполняются при каждом чтении и записи глобала. Делайте тяжёлую работу один раз, за защёлкой done, — а не при каждом обращении.

Когда за это браться

Это инструмент точечного применения, а не выбор по умолчанию. Он оправдывает себя, когда верно всё из следующего:

  • Нужное вам значение записывается в глобал (или любое свойство объекта) кодом, который вы не контролируете.
  • Время непредсказуемо, и вы хотите реагировать без задержки — либо вам нужно подменить значение до того, как его прочитает что-то ещё.
  • Вы можете выполнить свой код раньше, чем это сделает записывающий код, — либо готовы дополнить это ранней проверкой на случай, когда не можете.

Главное

  • Сторонний код часто передаёт вам значение, присваивая глобал в момент, который невозможно предсказать; фиксированный timeout проигрывает ему гонку, а полинг палит main thread.
  • Object.defineProperty(window, name, { get, set }), установленный до того, как выполнится записывающий код, превращает присваивание в синхронный колбек — нулевая латентность, никакой лишней работы.
  • Никогда не отправляйте в прод set-only accessor: сочетайте каждый set с get, который возвращает сохранённое значение, иначе вы молча сломаете обратное чтение самого записывающего кода.
  • Добавьте раннюю проверку для значения, установленного до того, как вы поставили accessor, и fallback для значения, которое так и не придёт.
  • Поскольку хранилище принадлежит вам, вы можете нормализовать или заменить значение — это перехватчик, а не просто слушатель.
  • Указывайте configurable: true и восстанавливайте обычное свойство-данные при уборке, чтобы ничего ниже по потоку не осталось с сюрпризом.

Ничего экзотического здесь нет — это просто Object.defineProperty, направленный на глобал вместо ваших собственных объектов. Но переформулировка «ждать значение» как «быть уведомлённым, когда значение записано» устраняет целый класс багов с таймингом и отдаёт вам значение достаточно рано, чтобы его изменить. В следующий раз, когда вы поймаете себя на выборе магического числа для timeout, чтобы переждать сторонний скрипт, возьмитесь вместо этого за сеттер.