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. Для всіх інших властивість поводиться точно як звичайна властивість-даних, яку вона замінила, — ви лише додали побічний ефект на присвоєння.

Закрийте гонку з іншого боку: рання перевірка

Перехоплювач ловить лише ті записи, що стаються після його встановлення. Якщо сторонній скрипт швидкий — або був вставлений inline перед вашим кодом — значення може вже лежати у 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, аби перечекати сторонній скрипт, — візьміться натомість за сеттер.