التقط قيمة من طرف ثالث لحظة وصولها إلى window
سكريبت لا تتحكم فيه يكتب نتيجته في global على window في وقت لا يمكنك التنبؤ به. معظم الأكواد تتسابق معه باستخدام timeout أو تهدر main thread في polling. إليك حيلة accessor property التي تحوّل الكتابة إلى event — بالإضافة إلى getter الذي يجب ألا تنساه، وكيفية استبدال القيمة بالكامل.
عاجلاً أم آجلاً ستدمج سكريبتاً لا تملكه — attribution SDK، أداة consent، إطار عمل A/B، أو deep-link resolver تم إدراجه عبر tag manager — ويتواصل مع صفحتك بإسناد قيمة إلى global على window. شيء من قبيل window.SMART_LINK_RESULT = { url: … }. مهمتك هي قراءة تلك القيمة والتصرف بناءً عليها بمجرد وجودها.
المشكلة: أنت لا تتحكم إطلاقاً في توقيت حدوث هذا الإسناد. قد يحدث قبل تشغيل كودك، أو بعد بضع مئات من الميلي ثانية، أو — إذا كانت الشبكة بطيئة أو لم يتم تحميل السكريبت أصلاً — لا يحدث إطلاقاً. يستعرض هذا المقال الأسلوبين اللذين يلجأ إليهما الجميع أولاً، ولماذا كلاهما حل وسط، وأسلوباً ثالثاً أفضل منهما بشكل قاطع: اعتراض الإسناد نفسه باستخدام accessor property.
الإعداد: قيمة تصل متى شاءت
بشكل ملموس، تخيّل صفحة إعادة توجيه خفيفة. يقوم tag manager بإدراج سكريبت يحسب عنوان URL الوجهة الصحيح والمُنسوب بالكامل، وعند انتهائه، يكتبه في window.SMART_LINK_RESULT. تريد إعادة التوجيه إلى ذلك العنوان لحظة توفره، والرجوع إلى عنوان 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 ثابت
الغريزة الأولى هي الانتظار لمدة "آمنة" حتى ينتهي السكريبت، ثم قراءة الـglobal مرة واحدة والمتابعة:
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 — متجاهلاً العنوان الصحيح الذي كان سيصل بعد لحظة. - متأخراً جداً: إذا انتهى السكريبت خلال 200 مللي ثانية، سيظل المستخدم يحدّق في صفحة إعادة توجيه فارغة طوال الـ3 ثوانٍ كاملة قبل حدوث أي شيء.
- التأخير "الصحيح" غير معروف، لأنه يعتمد على الشبكة وعلى الطرف الثالث — لذا ينتهي بك الأمر باختيار رقم طويل جداً بالنسبة للعملاء السريعين وقصير جداً بالنسبة للبطيئين في آن واحد.
المحاولة 2: 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);هذا أفضل — فهو يتفاعل خلال فاصل زمني واحد من وصول القيمة بدلاً من انتظار التأخير كاملاً. لكنه يبقى حلاً وسطاً: أنت تُشغّل مؤقتاً لا يفعل شيئاً مفيداً في معظم دوراته، وقد استبدلت الكمون بفاصل زمني قابل للضبط (خشن جداً يبدو بطيئاً، دقيق جداً يستهلك 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 الخاصة بك، بشكل متزامن، مع القيمة كوسيط. لا تأخير، لا polling، لا تخمين. أنت تتفاعل في اللحظة بالضبط التي توجد فيها القيمة، لا قبلها بتِك ولا بعدها. يتوقف الـtimeout عن كونه آليتك الأساسية ويصبح ما كان يجب أن يكون عليه منذ البداية: fallback لحالة عدم وصول القيمة إطلاقاً.
الـtimeout الثابت يخمّن متى تصل القيمة. الـpolling يسأل مراراً عمّا إذا كانت قد وصلت. أما accessor property فيُخبَر ببساطة — يصبح الإسناد callback متزامناً، بكمون صفري وبلا أي عمل مهدر.
لماذا الـgetter ليس اختيارياً
قد يكون مغرياً تعريف set فقط — ففي النهاية، التفاعل مع الكتابة هو بيت القصيد. لا تفعل ذلك. accessor property فيه setter بدون getter يُعيد undefined في كل قراءة. غالباً ما يقرأ سكريبت الطرف الثالث (وأي شيء آخر على الصفحة) الـglobal الخاص به مجدداً — للتحقق منه، أو لتحديث حقل فيه، أو لتمريره إلى وحدة أخرى. accessor بـsetter فقط يكسر كل ذلك بصمت:
// 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القاعدة بسيطة: إن اعترضتَ الكتابة، عليك أيضاً خدمة القراءة. احتفظ بآخر قيمة في متغيّر closure، أعِدها من get، وحدّثها في set. بالنسبة لأي طرف آخر، تتصرف الخاصية تماماً مثل خاصية البيانات العادية التي استبدلتها — أنت فقط أضفت أثراً جانبياً عند الإسناد.
أغلق السباق من الجهة الأخرى: الفحص المبكر
يلتقط الـinterceptor فقط عمليات الكتابة التي تحدث بعد تثبيته. إذا كان سكريبت الطرف الثالث سريعاً — أو تم إدراجه 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
}مع الفحص المبكر والـsetter معاً، تكون قد غطيت الخط الزمني كاملاً: كانت القيمة موجودة بالفعل (تقرأها الآن)، أو تصل لاحقاً (يُطلق الـsetter)، أو لا تصل أبداً (مؤقت fallback). لا توجد فجوة يمكن لقيمة حقيقية أن تفلت منك خلالها.
استبدال القيمة، لا مجرد مراقبتها
بما أن تخزين الخاصية أصبح الآن يعيش في closure الخاص بك، فأنت لست مقيداً بمراقبة القيمة فقط — بل تقرر ما تحمله فعلياً. هذا هو الفرق بين listener وinterceptor. يمكنك تطبيع 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 المثبَّت على global يعيش لفترة أطول من المكوّن الذي أضافه، لذا فكّكه عند الانتهاء — خصوصاً في تطبيقات 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 معلّقين، أو حذف القيمة تماماً) تعني أن أي شيء يقرأ الـglobal لاحقاً سيحصل على السلوك الطبيعي مجدداً. ضبط configurable: true عند تعريف الخاصية هو ما يجعل هذا الـdelete ممكناً من الأساس — بدونه، يصبح الـaccessor دائماً.
تجميع كل شيء معاً
إليك النمط بأكمله كـhook واحد في React: فحص مبكر، interceptor مع 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 — الأجزاء المتحركة هي متغيّر closure، وObject.defineProperty مع get/set، وفحص مبكر، وfallback، وتفكيك. في وحدة عادية، ستُغلّف هذا في دالة وتستدعي التفكيك عند pagehide أو عند انتهاء ميزتك.
Timeout مقابل Polling مقابل Interceptor
| الجانب | Timeout ثابت | Polling | Accessor Interceptor |
|---|---|---|---|
| يتفاعل لحظة ضبط القيمة | لا — ينتظر التأخير كاملاً | تقريباً — خلال فاصل زمني واحد | نعم — بشكل متزامن |
| الوقت المهدر عند وصول القيمة مبكراً | التأخير كاملاً | حتى فاصل زمني واحد | لا شيء |
| تكلفة main thread أثناء الانتظار | لا شيء | قراءات متكررة عبر مؤقت | لا شيء |
| يلتقط قيمة تم ضبطها قبل البدء | نعم (يقرأ مرة في النهاية) | نعم | فقط مع فحص مبكر صريح |
| يمكنه استبدال أو تطبيع القيمة | لا | لا | نعم |
| يتعامل مع حالة عدم وصول القيمة إطلاقاً | نعم (يقرأ، ثم fallback) | نعم (فرع الموعد النهائي) | نعم (بمؤقت fallback) |
يفوز الـinterceptor في كل صف يتعلق بـالتفاعل مع الكتابة، ويتعادل في البقية بمجرد إضافة الفحص المبكر ومؤقت الـfallback الذي يحتاجه على أي حال. الشيء الوحيد الذي يجب تذكره هو أنه يجب تثبيته قبل تشغيل سكريبت الطرف الثالث — وهو ما يكون صحيحاً دائماً تقريباً بالنسبة لقيمة يوصّلها tag manager أو SDK غير متزامن، إذا ثبّته مبكراً في دورة حياة صفحتك.
أمور تستحق المعرفة
- ترتيب التثبيت مهم. يلتقط الـinterceptor فقط عمليات الكتابة التي تأتي بعده. عرّف الـaccessor في أبكر وقت ممكن، واقرنه دائماً بالفحص المبكر للقيمة التي سبقتك.
- اضبط دائماً
configurable: true. بدونه لا يمكنك عملdeleteللخاصية من أجل التنظيف، وأي محاولة ثانية لإعادة تعريفها ترمي خطأ. - احترس من SSR. لا يوجد
windowعلى الخادم. في إطار عمل، شغّل هذا في تأثير client-only فقط؛ وفي وحدة عادية، احمِ الكود بـ"undefined" !== "undefined". - interceptor واحد لكل خاصية. إذا أعاد جزءان من تطبيقك تعريف نفس الـglobal، فإن الثاني يطمس الأول. مركِز الأمر، أو تحقق من
Object.getOwnPropertyDescriptor(window, name)قبل التثبيت. - بعض الـglobals هي بالفعل accessors. خاصية عرّفتها البيئة كـnon-configurable (أو بـgetter/setter خاص بها) لا يمكن استبدالها — تحقق من الـdescriptor أولاً وارجع إلى polling إذا تعذّر عليك حقاً إعادة تعريفها.
- حافظ على رخص
getوset. فهما يعملان في كل قراءة وكتابة للـglobal. نفّذ العمل الثقيل مرة واحدة، خلف مزلاجdone— لا في كل وصول.
متى تلجأ إلى هذا
هذه أداة دقيقة، وليست خياراً افتراضياً. تستحق نفسها عندما يتحقق كل ما يلي:
- قيمة تحتاجها تُكتب في global (أو أي خاصية كائن) بواسطة كود لا تتحكم فيه.
- التوقيت غير متوقّع وتريد التفاعل بلا كمون — أو تحتاج إلى استبدال القيمة قبل أن يقرأها أي شيء آخر.
- يمكنك تشغيل كودك قبل من يكتب القيمة، أو أنت مستعد لقرن ذلك بفحص مبكر للحالة التي لا تستطيع فيها ذلك.
الخلاصة
- غالباً ما يمنحك طرف ثالث قيمة بإسناد global في وقت لا يمكنك التنبؤ به؛ الـtimeout الثابت يخسر السباق أمامه، والـpolling يستهلك main thread.
Object.defineProperty(window, name, { get, set })، مثبّت قبل تشغيل من يكتب القيمة، يحوّل الإسناد إلى callback متزامن — كمون صفري، وبلا أي عمل مهدر.- لا تُطلق أبداً accessor بـsetter فقط: اقرن كل
setبـgetيُعيد القيمة المخزَّنة، وإلا ستكسر بصمت قراءة الكاتب لقيمته الخاصة. - أضف فحصاً مبكراً لقيمة تم ضبطها قبل أن تُثبّت الـaccessor، وfallback لقيمة لا تصل أبداً.
- بما أنك تملك التخزين، يمكنك تطبيع القيمة أو استبدالها — فهذا interceptor، لا مجرد listener.
- اضبط
configurable: trueوأعِد خاصية بيانات عادية عند التنظيف حتى لا يبقى أي شيء لاحق مفاجَأً.
ليس في هذا شيء غريب — إنه ببساطة Object.defineProperty موجَّه نحو global بدلاً من كائناتك الخاصة. لكن إعادة صياغة "انتظار قيمة" إلى "أن تُخبَر عند كتابة القيمة" تُزيل فئة كاملة من أخطاء التوقيت، وتمنحك القيمة مبكراً بما يكفي لتغييرها. في المرة القادمة التي تجد فيها نفسك تختار رقم timeout سحري لانتظار سكريبت طرف ثالث، الجأ إلى الـsetter بدلاً من ذلك.