在第三方值刚落地 window 的那一刻就捕获它
一个你无法控制的脚本,会在你无法预测的时间点,把结果写入 window 上的全局变量。大多数代码要么用 timeout 跟它赛跑,要么用轮询白白消耗主线程。本文介绍一个 accessor property(访问器属性)技巧,把这次写入变成一个事件——还会讲到你绝不能遗漏的 getter,以及如何彻底替换这个值。
迟早你会集成一个你自己并不掌控的脚本——归因 SDK、同意管理工具、A/B 测试框架、通过 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 {};方案一:固定的 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 秒,你才会有所行动。
- 所谓“正确”的延迟时长其实无从得知,因为它取决于网络状况和第三方脚本本身——最终你选定的数字,对网速快的客户端来说太长,对网速慢的客户端来说又太短。
方案二:轮询(polling)
针对“太晚”这半个问题,显而易见的修正办法是:不再等待某个固定的时刻,而是反复检查,直到值出现为止,同时设一个放弃等待的截止时间:
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);这样已经好一些了——值一到达,最多在一个轮询间隔内就能做出反应,而不用等完整段延迟。但它仍然是一种妥协:你运行着一个大多数 tick 都在做无用功的定时器;你把延迟问题换成了一个需要调优的间隔时长(间隔太粗糙就显得迟钝,太精细又会拖累主线程);你还在一个热循环里反复读取一个属性,而这个属性对应的事情其实只会发生一次。
诀窍:直接拦截这次写入
以上两种方案都把这次赋值当作事后才需要去发现的事情——要么猜测它何时发生,要么反反复复地问它是否已经发生。但对一个属性的赋值,其实是 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 函数,并把这个值作为参数传进来。没有延迟,没有轮询,也没有猜测。你会在值真正存在的那一瞬间做出反应,不早一个 tick,也不晚一个 tick。timeout 不再是你的主要机制,而是回归了它本该扮演的角色:仅仅是值始终没有到来时的一个 fallback。
固定 timeout 是在猜测值何时到达。轮询是在反复询问它是否已经到达。而 accessor property 则是被直接告知——赋值本身变成了一次同步回调,零延迟,零浪费。
为什么 getter 不是可选项
你可能会很想只定义一个 set——毕竟对写入做出反应才是重点。但千万别这么做。一个只有 setter 没有 getter 的 accessor property,每次读取都会返回 undefined。而第三方脚本(以及页面上的其他任何代码)常常会把自己写入的这个全局变量再读回来——用来检查它、更新它上面的某个字段、或者把它传给另一个模块。一个只有 setter 的 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
}把提前检查和 setter 结合起来,你就覆盖了整条时间线上的所有情况:值已经在那儿了(现在就读取它),或者它稍后才到达(setter 会被触发),又或者它永远不会到达(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,其生命周期会超出添加它的那个组件,所以用完之后要把它拆除——尤其是在单页应用、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 hook:提前检查、带有配套 getter 的拦截器、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 之外也同样适用——它的活动部件就是一个闭包变量、一个带 get/set 的 Object.defineProperty、一次提前检查、一个 fallback,以及一次拆除。在一个普通模块里,你可以把它包装成一个函数,在 pagehide 时或者你的功能结束时调用拆除逻辑。
Timeout、轮询与拦截器对比
| 关注点 | 固定 Timeout | 轮询 | Accessor 拦截器 |
|---|---|---|---|
| 能在值被设置的瞬间做出反应 | 否——要等完整段延迟 | 接近——在一个间隔以内 | 是——同步反应 |
| 值提前到达时白白浪费的时间 | 整段延迟 | 最多一个间隔 | 无 |
| 等待期间对主线程的消耗 | 无 | 定时器反复读取带来的消耗 | 无 |
| 能捕获在你启动前就已设置好的值 | 是(在最后读取一次) | 是 | 仅在配合显式的提前检查时才行 |
| 能否替换或规范化这个值 | 否 | 否 | 是 |
| 能否处理值始终不到达的情况 | 是(读取后回退) | 是(截止时间分支) | 是(配合 fallback 计时器) |
凡是涉及对写入做出反应的每一行,拦截器都胜出;而只要你补上原本就需要的提前检查和 fallback 计时器,其余各行也能打平。唯一需要牢记的是:它必须在第三方脚本运行之前就安装好——对于由 tag manager 或异步 SDK 交付的值来说,只要你在页面生命周期的早期就完成安装,这一点几乎总能满足。
值得留意的坑
- 安装顺序很关键。拦截器只能捕获在它之后发生的写入。尽可能早地定义这个 accessor,并且始终配合提前检查,以应对那些抢在你前面写入的值。
- 始终设置
configurable: true。没有它,你就无法通过delete来清理这个属性,而且第二次尝试重新定义它会直接抛出异常。 - 为 SSR 做好防护。服务端并不存在
window。在框架中,把这段逻辑放进仅在客户端运行的 effect 里;在普通模块中,则用"undefined" !== "undefined"做守卫。 - 每个属性只能有一个拦截器。如果应用里有两处代码都重新定义了同一个全局变量,后安装的那个会覆盖先安装的那个。要么把这个逻辑集中到一处,要么在安装前先检查
Object.getOwnPropertyDescriptor(window, name)。 - 有些全局变量本身就已经是 accessor。由运行环境定义为 non-configurable(或者本身已带有自己的 getter/setter)的属性是无法被替换的——先检查一下描述符,如果确实无法重新定义,就退回去用轮询。
- 让
get和set保持轻量。它们会在这个全局变量的每一次读写时执行。真正繁重的工作应该只在done锁之后执行一次,而不是每次访问都执行。
什么时候该用这个方案
这是一个用于特定场景的精准工具,而不是默认选项。当以下条件全部成立时,它才真正物有所值:
- 你需要的值,是由你不掌控的代码写入某个全局变量(或者任意对象的属性)的。
- 写入的时机不可预测,而你希望零延迟地做出反应——或者你需要在其他任何代码读取这个值之前先把它替换掉。
- 你能够让自己的代码先于写入方运行,或者你愿意在做不到这一点时,配合一次提前检查来兜底。
要点总结
- 第三方常常通过在不可预测的时刻给某个全局变量赋值,把结果交给你;固定 timeout 是在跟它赛跑,而轮询则会消耗主线程。
- 在写入方运行之前安装
Object.defineProperty(window, name, { get, set }),能把这次赋值变成一次同步回调——零延迟,零浪费。 - 永远不要上线一个只有 setter 的 accessor:每个
set都要配一个返回已存储值的get,否则你会在不知不觉间破坏写入方自身的回读行为。 - 为那些在你安装 accessor 之前就已经设置好的值加上提前检查,再为那些始终不会到达的值加上 fallback。
- 因为存储权在你手里,你可以对这个值做规范化或替换——它是一个拦截器,而不只是一个监听器。
- 设置
configurable: true,并在清理时还原成普通数据属性,这样下游就不会碰到意外情况。
这些东西没什么稀奇的——不过是把 Object.defineProperty 指向一个全局变量,而不是指向你自己的对象而已。但把“等待一个值”重新表述为“值被写入时得到通知”,就能消除整整一类和时序有关的 bug,并且能让你足够早地拿到这个值,从而有机会修改它。下次当你发现自己又在为等待某个第三方脚本而挑选一个玄学 timeout 数字时,不妨改用 setter 试试。