Skip to main content
返回博客
JavaScriptWebThird-Party ScriptsFrontendPatterns

在第三方值刚落地 window 的那一刻就捕获它

一个你无法控制的脚本,会在你无法预测的时间点,把结果写入 window 上的全局变量。大多数代码要么用 timeout 跟它赛跑,要么用轮询白白消耗主线程。本文介绍一个 accessor property(访问器属性)技巧,把这次写入变成一个事件——还会讲到你绝不能遗漏的 getter,以及如何彻底替换这个值。

发布于 2026年7月27日9 分钟阅读

迟早你会集成一个你自己并不掌控的脚本——归因 SDK、同意管理工具、A/B 测试框架、通过 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 {};

方案一:固定的 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 秒,你才会有所行动。
  • 所谓“正确”的延迟时长其实无从得知,因为它取决于网络状况和第三方脚本本身——最终你选定的数字,对网速快的客户端来说太长,对网速慢的客户端来说又太短。

方案二:轮询(polling)

针对“太晚”这半个问题,显而易见的修正办法是:不再等待某个固定的时刻,而是反复检查,直到值出现为止,同时设一个放弃等待的截止时间:

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

这样已经好一些了——值一到达,最多在一个轮询间隔内就能做出反应,而不用等完整段延迟。但它仍然是一种妥协:你运行着一个大多数 tick 都在做无用功的定时器;你把延迟问题换成了一个需要调优的间隔时长(间隔太粗糙就显得迟钝,太精细又会拖累主线程);你还在一个热循环里反复读取一个属性,而这个属性对应的事情其实只会发生一次。

诀窍:直接拦截这次写入

以上两种方案都把这次赋值当作事后才需要去发现的事情——要么猜测它何时发生,要么反反复复地问它是否已经发生。但对一个属性的赋值,其实是 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 函数,并把这个值作为参数传进来。没有延迟,没有轮询,也没有猜测。你会在值真正存在的那一瞬间做出反应,不早一个 tick,也不晚一个 tick。timeout 不再是你的主要机制,而是回归了它本该扮演的角色:仅仅是值始终没有到来时的一个 fallback。

固定 timeout 是在猜测值何时到达。轮询是在反复询问它是否已经到达。而 accessor property 则是被直接告知——赋值本身变成了一次同步回调,零延迟,零浪费。

为什么 getter 不是可选项

你可能会很想只定义一个 set——毕竟对写入做出反应才是重点。但千万别这么做。一个只有 setter 没有 getter 的 accessor property,每次读取都会返回 undefined。而第三方脚本(以及页面上的其他任何代码)常常会把自己写入的这个全局变量再读回来——用来检查它、更新它上面的某个字段、或者把它传给另一个模块。一个只有 setter 的 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
}

把提前检查和 setter 结合起来,你就覆盖了整条时间线上的所有情况:值已经在那儿了(现在就读取它),或者它稍后才到达(setter 会被触发),又或者它永远不会到达(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,其生命周期会超出添加它的那个组件,所以用完之后要把它拆除——尤其是在单页应用、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 hook:提前检查、带有配套 getter 的拦截器、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 之外也同样适用——它的活动部件就是一个闭包变量、一个带 get/setObject.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)的属性是无法被替换的——先检查一下描述符,如果确实无法重新定义,就退回去用轮询。
  • getset 保持轻量。它们会在这个全局变量的每一次读写时执行。真正繁重的工作应该只在 done 锁之后执行一次,而不是每次访问都执行。

什么时候该用这个方案

这是一个用于特定场景的精准工具,而不是默认选项。当以下条件全部成立时,它才真正物有所值:

  • 你需要的值,是由你不掌控的代码写入某个全局变量(或者任意对象的属性)的。
  • 写入的时机不可预测,而你希望零延迟地做出反应——或者你需要在其他任何代码读取这个值之前先把它替换掉。
  • 你能够让自己的代码先于写入方运行,或者你愿意在做不到这一点时,配合一次提前检查来兜底。

要点总结

  • 第三方常常通过在不可预测的时刻给某个全局变量赋值,把结果交给你;固定 timeout 是在跟它赛跑,而轮询则会消耗主线程。
  • 在写入方运行之前安装 Object.defineProperty(window, name, { get, set }),能把这次赋值变成一次同步回调——零延迟,零浪费。
  • 永远不要上线一个只有 setter 的 accessor:每个 set 都要配一个返回已存储值的 get,否则你会在不知不觉间破坏写入方自身的回读行为。
  • 为那些在你安装 accessor 之前就已经设置好的值加上提前检查,再为那些始终不会到达的值加上 fallback。
  • 因为存储权在你手里,你可以对这个值做规范化或替换——它是一个拦截器,而不只是一个监听器。
  • 设置 configurable: true,并在清理时还原成普通数据属性,这样下游就不会碰到意外情况。

这些东西没什么稀奇的——不过是把 Object.defineProperty 指向一个全局变量,而不是指向你自己的对象而已。但把“等待一个值”重新表述为“值被写入时得到通知”,就能消除整整一类和时序有关的 bug,并且能让你足够早地拿到这个值,从而有机会修改它。下次当你发现自己又在为等待某个第三方脚本而挑选一个玄学 timeout 数字时,不妨改用 setter 试试。