Web PerformanceJune 14, 2026·9 دقيقة قراءة·Miracle KaluMiracle Kalu

أسرع سكربت تابع لجهة خارجية هو الذي لا يعمل أبداً

Aerial view of congested multi-lane highway traffic at dusk.

أسرع نسخة من أي سكربت تابع لجهة خارجية هي تلك التي لا تُنفَّذ أبداً. تعلّمتُ هذا من جديد على موقع إخباري عالي الزيارات، حيث كانت استجابة الخادم جيدة بالفعل ومع ذلك بدت الصفحة معطوبة. كان الـ HTML يصل بسرعة، وكان عنوان المقال موجوداً مباشرةً في الترميز، ورغم ذلك ظلّت الصفحة جامدة ومتقطّعة وبطيئة الاستجابة، بينما كان الخيط الرئيسي يغرق في جافاسكربت الآخرين.

هذه إذن قصة ذلك الموقع. الأرقام التي كشفت المشكلة، والأنماط التي نقلته من محرج إلى سريع، والمواضع القليلة التي تصادم فيها السعيُ نحو السرعة مع ما تريده الشركة فعلاً. لا شيء من هذه التقنيات بارع. الأمر في معظمه يتعلق برفض تحميل الأشياء قبل أن يكون لديك سبب ملموس لذلك.

الخادم السريع ليس صفحة سريعة

كان الموقع يعمل على بنية حديثة مع تصيير من جانب الخادم وطبقة تخزين مؤقت معقولة أمامه. في الأيام الجيدة كان المستند يصل في بضع مئات من المللي ثانية. وفق المقياس الوحيد الذي تهتم به معظم لوحات معلومات الواجهة الخلفية، كان كل شيء على ما يرام.

لم يكن على ما يرام. على هاتف أندرويد متوسط الفئة عبر شبكة 4G مُقيّدة، كانت الصفحة تبدو كالمشي في رمل مبلّل. النقرات لا تفعل شيئاً للحظة. التمرير يتلعثم. عنوان المقال، الموجود في الـ HTML الأولي، كان يستغرق قرابة خمس ثوانٍ كي يظهر فعلاً.

تلك الفجوة بين «الخادم سريع» و«الصفحة تبدو سريعة» هي حيث تعيش جافاسكربت الجهات الخارجية. يقيس Time to First Byte مدى سرعة بدء الخادم بالكلام. لكنه لا يقول شيئاً عمّا يحدث بعد وصول البايتات، حين يتعيّن على المتصفح تحليل وتجميع وتنفيذ كل سكربت تجلبه الصفحة. المقياس الذي يلتقط هذا الألم هو Total Blocking Time، وهو في المختبر مؤشر لا بأس به على مدى بطء استجابة الصفحة قبل أن تستقر. أما في الميدان فتظهر المشكلة نفسها على هيئة Interaction to Next Paint سيّئ.

شكل المشكلة

كان قالب المقال يضمّ الطاقم المعتاد: تغريدات تويتر/إكس، ومنشورات إنستغرام، ومقاطع تيك توك، ومدير وسوم، ومزوّد تحليلات. كلٌّ منها يأتي بحزمة SDK خاصة به، وكل SDK يريد أن يُنفَّذ في اللحظة التي تُحمَّل فيها الصفحة. والمتصفح، بطاعته، يفعل ذلك تماماً.

حين فتحتُ لوحة الأداء وسجّلتُ مقالاً نموذجياً على ملف تعريف مُقيّد، كانت الصورة قاتمة، ولم يكن أيٌّ منها تقريباً من كودي:

  • وحده ملف widgets.js الخاص بتويتر كلّف نحو 816 مللي ثانية من التنفيذ على الخيط الرئيسي.
  • أضافت حزم SDK الاجتماعية مجتمعةً ما يزيد بكثير على ثانية ونصف من العمل الحاجب.
  • أسهم مدير الوسوم وحده بمهمة قدرها 241 مللي ثانية.
  • وكدّس مزوّد التحليلات وبضعة سكربتات أصغر بضع مئات أخرى من المللي ثانية.

الجانب القاسي أن معظم هذه التضمينات كانت تحت الطيّة. كانت قارئة على هاتفها تدفع 800 مللي ثانية لتصيير تغريدة قد لا تمرّر إليها أبداً. كان عنصر Largest Contentful Paint عبارة عن <h1> بسيط موجود أصلاً في HTML الخادم، لكنه لم يكن قادراً على الظهور بينما الخيط الرئيسي مشغول بتجميع وتنفيذ كود تضمين لا علاقة له بالعنوان.

هكذا بدا التحميل تقريباً قبل أي من هذا العمل:

وإليك الجزء المزعج: ميزانية الأداء لديك ليست ملكك حقاً. أنت تؤجّر معظمها لمزوّدين، وهم ينفقونها دون أن يستأذنوا. أي حلّ جادّ يجب أن يبدأ باستعادة تلك الميزانية.

اعثر على المذنبين الحقيقيين قبل أن تلمس أي شيء

من المغري أن تبدأ بحذف السكربتات بحدسك. قاوم ذلك. المهمة الأولى هي العزو، لأن السكربت الذي تفترض أنه المشكلة غالباً ليس هو الذي يكلّفك أكثر شيء.

أداتان أنجزتا معظم العمل هنا. يُظهر لك مخطط اللهب للخيط الرئيسي في لوحة الأداء بالضبط أي سكربت يربض على المعالج وكم من الوقت. جمّع التسجيل حسب المهام الطويلة، تلك التي تتجاوز 50 مللي ثانية، وستفرز الجهات المسيئة نفسها إلى الأعلى. الأداة الثانية هي نسخة العزو من مكتبة web-vitals، التي تخبرك في الميدان ليس فقط بأن LCP كان بطيئاً بل لماذا، مقسّماً إلى Time to First Byte، وتأخير تحميل المورد، وزمن تحميل المورد، وتأخير تصيير العنصر. على هذا الموقع كان رقم تأخير التصيير ضخماً بينما كان كل جزء آخر قريباً من الصفر، وهو ما أشار مباشرةً إلى تنافس على الخيط الرئيسي لا إلى شبكة بطيئة أو صورة ثقيلة.

تريد أرقاماً كهذه مدوّنة قبل كل تغيير وبعده. وإلا فأنت تحسّن بالإحساس، والإحساس لا يصمد حين يسأل صاحب مصلحة إن كان العمل يستحق العناء.

توقّف عن تحميل ما لم يطلبه أحد

الخطوة الحقيقية الأولى هي الأكثر فاعلية أيضاً. لا تُحمّل حزمة SDK الخاصة بتضمين قبل أن يوشك التضمين على أن يُرى. المتصفح يوفّر الأداة لذلك أصلاً: `IntersectionObserver`.

بدلاً من ترك وسوم <script> الخاصة بالمنصة تعمل وقت التحليل، أزِلها من جانب الخادم واحقن مراقباً صغيراً يُحمّل كل SDK فقط حين يقترب تضمينه من إطار العرض. وراقب كل تضمين على حدة أيضاً، كي لا تجرّ تغريدة قرب الأعلى حزمة تيك توك في الأسفل. فالمحمّل المشترك الواحد الذي ينطلق بمجرد ظهور أي تضمين يهدر معظم الفائدة.

type EmbedKind = "twitter" | "instagram" | "tiktok";

const SDK_URL: Record<EmbedKind, string> = {
  twitter: "https://platform.twitter.com/widgets.js",
  instagram: "https://www.instagram.com/embed.js",
  tiktok: "https://www.tiktok.com/embed.js",
};

const loaded = new Set<EmbedKind>();

function loadSdk(kind: EmbedKind): void {
  if (loaded.has(kind)) return; // never inject the same SDK twice
  loaded.add(kind);
  const s = document.createElement("script");
  s.src = SDK_URL[kind];
  s.async = true;
  s.setAttribute("fetchpriority", "low"); // it can wait behind real content
  document.body.appendChild(s);
}

const io = new IntersectionObserver(
  (entries, observer) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      const kind = entry.target.getAttribute("data-embed") as EmbedKind;
      loadSdk(kind);
      observer.unobserve(entry.target); // load once, then stop watching
    }
  },
  { rootMargin: "300px" }, // start a little before the embed is on screen
);

document
  .querySelectorAll<HTMLElement>("[data-embed]")
  .forEach((el) => io.observe(el));

هناك مفتاحان مهمّان. قيمة rootMargin البالغة 300 بكسل تبدأ التحميل قُبيل تمرير التضمين إلى داخل العرض، فيكون المحتوى جاهزاً عادةً حين تصل إليه القارئة بدلاً من أن يظهر متأخراً. وfetchpriority="low" يخبر المتصفح أن هذه الطلبات يمكنها الانتظار خلف الخطوط وملفات CSS وصورة LCP. تُعدّ Priority Hints API من أرخص المكاسب التي ستجدها. وهناك أيضاً بديل احتياطي يجدر الاحتفاظ به: إن لم يكن IntersectionObserver متاحاً، فحمّل حزم SDK على دفعات متباعدة بفواصل زمنية صغيرة بينها بدلاً من تحميلها دفعةً واحدة، كي لا يزحم حتى مسار المتصفحات القديمة الخيط الرئيسي.

هذا التغيير وحده أزاح نحو ثانية ونصف من العمل الحاجب عن المسار الحرج في المقالات المثقلة بالتضمينات. لكن فيه عيباً، والعيب افتراض.

حين يظل الكسولُ متعجّلاً أكثر من اللازم: الواجهة الزائفة

التحميل الكسول عند التمرير يفترض أن القارئة تريد التضمين. كثيرون لا يريدونه. جاؤوا من أجل المقال، يمرّرون متجاوزين التغريدة، ولم يحتاجوا قط إلى 200 كيلوبايت من كود الودجت لأجل ذلك. لأسوأ المخالفين، ذهبتُ أبعد واستبدلتُ بالتضمين الحيّ واجهةً زائفة: HTML ثابت رخيص يشبه التضمين، على ألّا تُحمَّل حزمة SDK الحقيقية إلا عند نقرة أو تفاعل حقيقي.

هذا هو نمط import-on-interaction، مطبَّقاً على كود الآخرين بدلاً من كودك. وإليك دورة الحياة:

استلزم جعل هذا متيناً ثلاثة دروس، تعلّمتُ اثنين منها بالطريقة الصعبة.

الدرس الأول كان عن المصدر الفعلي للتضمينات. كان نظام إدارة المحتوى يعيد بعض التضمينات بترميز <blockquote> وأخرى كعناصر <iframe> كاملة. واجهتي الزائفة الأولى عالجت الـ blockquote فقط، فراحت تضمينات الـ iframe تجلب حمولاتها بسعادة أثناء تحليل الـ HTML، ولم تنفع الواجهة الزائفة بشيء. كان الحل تحييد عناصر iframe على الخادم قبل أن تصل إلى المتصفح أصلاً، بنقل src إلى data-src وعدم استعادته إلا عند التفعيل.

function activateEmbed(container: HTMLElement): void {
  const iframe = container.querySelector<HTMLIFrameElement>("iframe[data-src]");
  if (!iframe) return;

  const run = () => {
    iframe.src = iframe.dataset.src!; // restore the real source
    iframe.removeAttribute("data-src");
  };

  // Schedule during idle time so it never competes with a tap or scroll.
  if ("requestIdleCallback" in window) {
    requestIdleCallback(run, { timeout: 2000 });
  } else {
    setTimeout(run, 1);
  }
}

الدرس الثاني كلّفني عصر يوم كامل. نسختي الأولى كانت تولّد ترميز الواجهة الزائفة على الخادم وتحاول ربط معالِج النقر عبر <script> مضمَّن محقون في HTML المقال. لم يفعل شيئاً بصمت. فReact لا تنفّذ وسوم <script> المُدرَجة عبر dangerouslySetInnerHTML، فلم يُربَط المعالِج قط ولم تُحمَّل التضمينات ببساطة. بدت الواجهات الزائفة صحيحة وكانت ميتة تماماً. كان الحل أن أكفّ عن تهريب السلوك عبر سلاسل HTML وأن أنقل الأمر كله إلى مكوّن عميل حقيقي يعمل عند التركيب، يعثر على الواجهات الزائفة ويربط مستمعيه الخاصة. إن أخذتَ شيئاً عملياً واحداً من هذا المقال، فليكن هذا: السلوك ينتمي إلى المكوّنات، لا إلى HTML تحقنه كسلسلة نصية.

الدرس الثالث كان عن الجدولة. حين يُحمَّل السكربت الحقيقي أخيراً، لفّ الحقن بـ `requestIdleCallback` كي ينزلق إلى لحظة هادئة بدلاً من أن يصارع تفاعلاً تقوم به المستخدمة الآن. النقر للتحميل على أسوأ تضمين منفرد أزال 816 مللي ثانية أخرى من التحميل الأولي، لأن حزمة SDK الخاصة بتويتر لم تعمل ببساطة لمعظم القرّاء.

حيث تصادمت السرعة مع متطلبات العمل

عمل الأداء لا يجري في فراغ، وهذا هو الجزء الذي تتخطّاه معظم التقارير. اثنان من «انتصاراتي» تسبّبا في مشكلات حقيقية.

الأول كان مشغّل الفيديو. استبدلتُ بالمشغّل المضمَّن الثقيل صورة ملصق ثابتة وزرّ تشغيل، وهو القرار الصحيح لزمن التحميل. لكن الواجهة الزائفة أخفت عنوان الفيديو، وتبيّن أن العنوان كان يؤدي عملاً حقيقياً: كان القرّاء يقرّرون التشغيل بناءً عليه. فانخفضت مرات التشغيل. كان الحل الوسط أن أُبقي الواجهة الزائفة الخفيفة لكن أرقّيها تلقائياً إلى المشغّل الكامل بعد ثانية واحدة من أول تفاعل للقارئة مع الصفحة، مجدولةً في وقت الخمول. لا تكلفة خلال النافذة التي تحسم Core Web Vitals، ووظيفية كاملة بعدها مباشرةً.

والمشكلة الثانية أن التضمينات كانت تبقى ميتة حتى يُنقَر عليها. لم يعجب القائمين على التحرير أن يضطر القرّاء إلى نقر كل تغريدة ليروها. فنالت الواجهات الزائفة السلوك نفسه: الإصغاء إلى أول scroll أو click أو keydown أو touchstart، وبعد ثانية تفعيل بقية التضمينات بهدوء في وقت الخمول. النقرات المباشرة لا تزال تعمل فوراً. والنتيجة تُبقي الخيط الرئيسي خالياً طوال نافذة القياس، ثم تستعيد تجربة «كل شيء يُحمَّل تلقائياً» في اللحظة التي تُظهر فيها القارئة أي علامة حياة.

الدرس الذي أعيد تعلّمه باستمرار: حلُّ أداءٍ يكسر المنتج ليس حلاً، بل تراجعٌ بأرقام جيدة. الترقية المُحفَّزة بالتفاعل هي النمط الذي يوفّق بين الأمرين.

السكربتات التي لا يمكنك إزالتها

بعض الأشياء يجب أن تُحمَّل: التحليلات، وإدارة الموافقة، ومدير وسوم يعتمد عليه فريق التسويق. لا يمكنك حذفها، لكن يمكنك أن ترفض تركها تعمل خلال النافذة التي تهمّ.

لتلك، كدّستُ بوابات. الانتظار حتى حدث load، ثم مؤقّت قصير، ثم requestIdleCallback بمهلة كي ينطلق حتى على صفحة مشغولة. انتقل مدير الوسوم من مهمة قدرها 241 مللي ثانية تصارع أول تصيير إلى عمل هادئ يجري بمجرد أن تصبح الصفحة تفاعلية وخاملة.

function loadWhenIdle(src: string, delayMs = 3000): void {
  const inject = () => {
    const s = document.createElement("script");
    s.src = src;
    s.async = true;
    s.setAttribute("fetchpriority", "low");
    document.body.appendChild(s);
  };

  // load event -> short delay -> idle. Each gate pushes the work later.
  window.addEventListener("load", () => {
    setTimeout(() => {
      if ("requestIdleCallback" in window) {
        requestIdleCallback(inject, { timeout: 8000 });
      } else {
        inject();
      }
    }, delayMs);
  });
}

والحيلة الأخرى للسكربتات العالقة معك هي التخزين المؤقت. المزوّد الذي يقدّم ملفه برأس تخزين مؤقت ليوم واحد هو مزوّد تعيد تنزيله باستمرار، ولا سيطرة لك على ذلك الرأس. مرّر الأصل عبر نطاقك الخاص، واضبط عمر تخزين مؤقت معقولاً، فتتحوّل كلفة شبكة متكرّرة إلى كلفة شبه وحيدة. كما يمنحك ذلك رقبةً واحدة تعصرها حين يتعطّل الطرف الثالث. في إطار عمل مثل Next.js يمكنك عمل الوكيل عبر إعادة كتابة وقاعدة لرأس التخزين المؤقت:

// next.config.ts
const nextConfig = {
  async rewrites() {
    return [
      { source: "/_proxy/analytics.js", destination: "https://vendor.example/sdk.js" },
    ];
  },
  async headers() {
    return [
      {
        source: "/_proxy/:path*",
        headers: [
          {
            key: "Cache-Control",
            value: "public, max-age=604800, stale-while-revalidate=31536000",
          },
        ],
      },
    ];
  },
};

export default nextConfig;

طريقة للتفكير في كل سكربت تابع لجهة خارجية

بعد عدد كافٍ من هذه الحالات، تنطوي الحيل المنفردة في قرار واحد يمكنك إجراؤه على أي سكربت قبل أن تدعه يقترب من صفحة.

معظم السكربتات لا تتجاوز السؤال الثاني أبداً، وهذا هو المقصد. الإجابة الافتراضية هي «لا تُحمّل»، وكل «نعم» عليه أن يكسب مكانه.

الأرقام

عبر العمل كله، سحبت تغييرات التضمين والسكربتات ما يزيد كثيراً على ثانيتين من العمل الحاجب من التحميل الأولي لمقال ثقيل. هبط Total Blocking Time في صفحة الاختبار إلى النطاق الأخضر، تحت حاجز الـ 200 مللي ثانية بأريحية، وبدأ العنوان يظهر تقريباً في الزمن الذي كان الخادم يَعِد به دائماً. ارتفعت درجة أداء Lighthouse من السبعينيات العالية إلى التسعينيات المنخفضة، لكن الدرجة لم تكن قط هي المقصد. المقصد أن الصفحة كفّت عن مصارعة القارئة.

ما جعل ذلك يدوم هو قياس كل تغيير على ملف تعريف هاتفي مُقيّد، لا على حاسوب مطوّر. زمن الحجب الذي يفسد التجربة يكاد يكون غير مرئي على العتاد السريع، ولهذا بالضبط يبقى في الإنتاج طويلاً.

الخلاصات

  • عامِل كل سكربت تابع لجهة خارجية كمذنب حتى تثبت ضرورته. ينبغي أن يكون الوضع الافتراضي «لا تُحمّل»، لا «حمّل وتأمّل».
  • اعزُ قبل أن تتحرّك. استخدم مخطط اللهب للخيط الرئيسي وعزو web-vitals لتجد السكربت الذي يكلّفك فعلاً، لا الذي تفترضه.
  • أجّل بحسب الرؤية عبر IntersectionObserver، وأجّل بحسب النيّة عبر الواجهات الزائفة. معظم القرّاء لا يحفّزون المسار المكلف أبداً، وهذا بالضبط ما تريده.
  • استعِن بـ fetchpriority="low" وrequestIdleCallback لإبقاء العمل المؤجَّل خارج النافذة الحرجة. تغييرات من سطر واحد، بأثر هائل.
  • حيّد على الخادم، لا في العميل. بمجرد أن يصل <iframe src> إلى المتصفح، تكون قد خسرت الطلب أصلاً. وتذكّر أن React لا تنفّذ <script> تحقنه كسلسلة نصية.
  • راقب المنتج، لا المقاييس وحدها. الحلُّ الذي يُخفي عنوان فيديو أو يكسر تضميناً هو تراجعٌ بدرجة جميلة. التحميل المحفَّز بالتفاعل هو كيف تحصل على الأمرين معاً.
  • قِس على ملف تعريف هاتف مُقيّد. زمن الحجب الذي يفسد التجربة غير مرئي على العتاد السريع.

كان العنوان في الـ HTML طوال الوقت. العمل الحقيقي كان تعليم المتصفح أن يظهره قبل أن يقضي مشاوير الجميع.

مشاركة:

XLinkedIn
Miracle Kalu

كتبه

Miracle Kalu

Senior Full Stack Engineer

أعجبك ما قرأته؟

أنا متاح لأدوار الهندسة الأولى والاستشارات التقنية. لنتحدث.

تواصل →

نُشر 14 يونيو 2026 · 9 دقيقة قراءة