Ловите значение стороннего скрипта в тот самый момент, когда оно попадает в 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. Для всех остальных свойство ведёт себя точно так же, как обычное свойство-данные, которое оно заменило, — вы лишь добавили побочный эффект на присваивание.
Закрываем гонку с другой стороны: ранняя проверка
Перехватчик ловит только те записи, что происходят после его установки. Если сторонний скрипт быстрый — или был вставлен инлайном раньше вашего кода — значение может уже лежать на 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, чтобы переждать сторонний скрипт, возьмитесь вместо этого за сеттер.