Ловіть значення від стороннього скрипта в ту саму мить, коли воно потрапляє у window
Скрипт, якого ви не контролюєте, записує свій результат у глобал на window у момент, який неможливо передбачити. Більшість коду або програє йому гонку через timeout, або марно навантажує main-thread полінгом. Ось трюк з accessor-property, який перетворює присвоєння на подію — плюс геттер, про який не можна забувати, і як повністю підмінити значення.
Рано чи пізно ви інтегруєте скрипт, якого не контролюєте — 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, якщо він так і не з'явиться.
Значення — це невеликий об'єкт, і ми не володіємо скриптом, що його створює, — знаємо лише форму, яку очікуємо:
// 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
Перший інстинкт — почекати «безпечний» проміжок часу, поки скрипт завершиться, потім один раз прочитати глобал і поїхати:
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: полінг
Очевидне виправлення для половини «занадто пізно» — перестати чекати фіксованого моменту й натомість перевіряти раз за разом, поки значення не з'явиться, з дедлайном на випадок, коли пора здаватися:
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:
// 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 мовчки ламає все це:
// 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, лише якщо його ще немає:
// 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, прибрати поле або замінити дефолт стороннього скрипта на власний ще до того, як хтось нижче за потоком його прочитає:
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 і, якщо значення було, поверніть його як звичайну властивість-даних:
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 гарантує, що редірект спрацює рівно один раз, хоч який шлях переможе.
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, аби перечекати сторонній скрипт, — візьміться натомість за сеттер.