كيف قلّصنا مؤشر LCP من 4.5 ثانية إلى أقل من 2.5 ثانية على موقع تحريري باستخدام Next.js 16

تحرّك عنصر LCP، وكانت هذه أول دلالة
قبل بضعة أشهر، انضممت إلى مهمة إنقاذ للأداء في موقع تحريري كبير الحجم يعمل على Next.js 16 App Router وTurbopack وDocker مستضاف ذاتيًا. كان_score Lighthouse للأداء 79. وكان LCP 4.5 ثانية. وTTFB 1.7 ثانية.
كان عنصر LCP هو عنصر JW Player المضمّن أعلى المقال. هذا مهم لأن مشغّل الفيديو من أسوأ المرشحين المحتملين لـ LCP: فهو يحتاج إلى iframe، وسكربت ثقيل، وصورة poster، وأحيانًا بث autoplay قبل أن يعتبر المتصفح أنه تم عرضه. تقيس Chrome مؤشر LCP لعنصر <video> عند الإطار الأول المعروض أو صورة poster. في الحالتين، أنت تقاتل ترتيب تحميل المشغّل وليس فقط HTML الخاص بك.
لم نبدأ بضغط الصور. بدأنا بمنع المشغّل من التنافس على الرسم الأولي.
لماذا مشغّلات الفيديو تدمر LCP
المواقع التحريرية تضع مشغّلات الفيديو بالقرب من أعلى المقالات لأن المشاهدات تهم. يعامل المتصفح المشغّل على أنه أكبر عنصر مرئي، ولا يمكن لـ LCP أن ينطلق حتى يتم تنزيل السكربت وتنفيذه وعرض الإطار الأول أو صورة poster.
على اتصال بطيء، يمكن لـ jwplayer.js وتبعياته أن يكلفا بسهولة 400–800 ملي ثانية من تنفيذ thread الرئيسي قبل ظهور أي شيء. إذا كان المشغّل يعمل تلقائيًا، يجلب المتصفح أيضًا مقاطع الفيديو. إذا كان يستخدم poster، تصبح هذه الصورة مرشحة LCP ويجب جلبها بأولوية عالية. معظم التطبيقات لا تفعل أيًا من ذلك بشكل جيد.
الحل هو واجهة facade: عرض placeholder خفيف على الخادم، ثم تحميل المشغّل الحقيقي فقط عند تفاعل المستخدم أو بعد أول إشارة منه. يستقر LCP في Chrome على placeholder الثابت، ويدفع المشغّل الكامل ثمنه لاحقًا.
// sanitizedContent.ts
export function lazifyJwPlayer(html: string) {
return html.replace(
/<iframe[^>]+src="(https:\/\/cdn\.jwplayer\.com\/players\/[^"]+)"[^>]*><\/iframe>/g,
`<div class="jw-facade" data-src="$1" style="aspect-ratio:16/9;background:#000">
<button aria-label="تحميل مشغّل الفيديو">▶</button>
</div>`
);
}توقفنا عن تحميل سكربت JW Player أثناء تحميل الصفحة الأولي. عرضت الواجهة مربعًا أسود بنسبة 16:9 مع زر تشغيل. غيّرت هذه التعديلة وحدها LCP من 4.5 ثانية إلى حوالي 2.8 ثانية.
ثم أصبح العنوان هو LCP
بمجرد تأجيل المشغّل، تحوّل عنصر LCP إلى عنوان المقال. بدا ذلك تقدمًا — النص صغير وسريع — لكن العنوان كان يعيش داخل ArticleHero، وهو Client Component. بسبب حدود 'use client'، لم يتم عرض <h1>` إلا بعد أن يقوم React بتنزيل وتحليل وتحميل حزمة الصفحة. على هاتف متوسط المواصفات عبر شبكة 4G بطيئة، أضاف ذلك حوالي 800–1200 ملي ثانية إلى LCP.
كانت الحل تحويل ArticleHero إلى Server Component واستخراج الأجزاء التفاعلية إلى جزيرة عميل صغيرة. في الممارسة، كان ذلك يعني فك تشابك شريط تنقل بطول 354 سطرًا، ومزود سياق ثيمات يلف كامل عنصر <body>، وسكربتات تحليلات تعتقد جميعها أنها يجب أن تعمل قبل أن يرى المستخدم أي شيء.
قمت بتضييق حدود السياق بحيث يلف فقط {children} داخل <main>. واستوردت شريط التنقل ديناميكيًا مع عنصر placeholder بارتفاع 60 بكسل. ونقلت FundingChoices ومحملات الإعلانات والتذييل خارج مسار التحميل الأولي. أصبح العنوان HTML مُصيّرًا من الخادم.
التخزين المؤقت: النصف الآخر من TTFB
لم يكن TTFB البالغ 1.7 ثانية مشكلة قدرة خادم، بل مشكلة تخزين مؤقت. كانت صفحات الفئات تحتوي على `export const dynamic = 'force-dynamic'، مما يعطل كل طبقة تخزين مؤقت يوفرها Next.js.
استبدلت ذلك بـ export const revalidate = 60 ووحّدت قيم TTL الخاصة بـ unstable_cache في نفس النافذة. التفصيل المهم كان إصلاح مفتاح الذاكرة المؤقتة. كان هناك مستدعيان يمرّران نفس الـ slug بتنسيقين مختلفين للشرطة المائلة الأولى، لذا فاتهما كلاهما الذاكرة المؤقتة ونفّذا طلب الواجهة الخلفية الكامل بشكل مستقل. قامت التطبيع إلى posts-by-slug-${cleanedSlug} بإزالة هذا العمل المكرر.
عند التشغيل الذاتي خلف Cloudflare، قد يتجاهل CDN أو يستبدل Cache-Control. أضفت توجيهات CDN صريحة:
// next.config.ts
async headers() {
return [
{
source: "/_next/static/chunks/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
{ key: "CDN-Cache-Control", value: "public, max-age=31536000, immutable" },
{ key: "Surrogate-Control", value: "public, max-age=31536000, immutable" },
],
},
{
source: "/media/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
{ key: "CDN-Cache-Control", value: "public, max-age=31536000, immutable" },
{ key: "Surrogate-Control", value: "public, max-age=31536000, immutable" },
],
},
];
}يُعتبر CDN-Cache-Control وSurrogate-Control مهمان لأن Cloudflare وFastly يحترمانهما حتى عندما يعيدان كتابة Cache-Control القياسي. بالنسبة لصفحات ISR، استخدمت s-maxage=60, stale-while-revalidate=86400 بحيث يمكن للـ Edge تقديم صفحة مخبأة على الفور بينما يتم إعادة التحقق في الخلفية.
انخفض TTFB من 1.7 ثانية إلى حوالي 0.3–0.5 ثانية عند النجاح في الذاكرة المؤقتة.
نظافة صور LCP والخطوط
على الصفحات التي لا تحتوي على فيديو، أصبح عنصر LCP الصورة الأولى للمقال. توقفت عن تحميلها بشكل كسول، وأضفت fetchpriority="high" وloading="eager"، وحدّدت جميع صور المقالات بعرض 1024 بكسل كحد أقصى. انخفضت جودة الصورة من q-90 إلى q-70، ثم إلى q-60، مما أدى إلى انكماش حجم البيانات بنسبة 25–35%.
أظهرت عملية PageSpeed Insights لاحقة تأخير عرض عنصر يبلغ 730 ملي ثانية على <h1>. كانت الأجزاء الفرعية واضحة: TTFB كان 0 ملي ثانية، لذا كان التأخير ناتجًا بالكامل تقريبًا عن CSS المحجوب للعرض. استخرجت حوالي 170 سطرًا من CSS غير حرج إلى ورقة أنماط مؤجلة، وأزلت متغيرات الوضع الداكن غير المستخدمة، وقلّصت Inter من خمسة أوزان إلى واحد، ثم أزلت Inter بالكامل ولجأت إلى مكدس خطوط النظام. كما قمت بتضمين الأنماط الحرجة above-the-fold مباشرة في layout.tsx بحيث يمكن للعنوان العرض بشكل صحيح حتى لو كان CSS الخارجي لا يزال في الطريق.
سكربتات الطرف الثالث وINP
كان أكبر مُسبّب فردي بعد JW Player هو تضمينات Twitter. كان تحميل widgets.js يكلف 816 ملي ثانية من تنفيذ thread الرئيسي، وكان يعمل حتى عندما كان التغريد أسفل الشاشة. بنيت واجهات click-to-load لتضمينات Twitter وInstagram وTikTok. يقوم معقّم المحتوى من جانب الخادم بتعطيل iframes بنقل src إلى data-src؛ وتقوم Client Component بعرض عناصر placeholder وتحميل SDK فقط عند التفاعل.
// SocialEmbedFacades/index.tsx (مبسّط)
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const sdk = entry.target.getAttribute("data-sdk");
if (sdk) loadSdk(sdk);
observer.unobserve(entry.target);
}
});
}, { rootMargin: "300px" });أضفت احتياطًا للتفعيل التلقائي: بعد أول تفاعل للمستخدم، يتم تحميل جميع الواجهات المتبقية بعد ثانية واحدة عبر requestIdleCallback. تم تأجيل GTM خلف load + 3 ثوانٍ + requestIdleCallback. تم تقييد Chartbeat بثلاثة مستويات: lazyOnload، ثم window.load، ثم requestIdleCallback.
من أجل INP، قمت بلف معالج التمرير في مجموعة الفئات داخل requestAnimationFrame مع { passive: true }، وتخزين offsetWidth في الكاروسيلات باستخدام useRef، وتقليل تكرار حجم الشاشة. كما قسمت أجزاء JS أولية باستخدام splitChunks عند 100 كيلوبايت، وأضفت @sentry/nextjs إلى optimizePackageImports.
النتائج
| المقياس | قبل | بعد | | --- | --- | --- | | LCP | 4.5 ثانية | ~2.0–2.5 ثانية | | TTFB | 1.7 ثانية | ~0.3–0.5 ثانية | | Lighthouse | 79 | ~90–95 |
في المرة القادمة سأبدأ بتدقيق الهندسة المعمارية بدلاً من قائمة Lighthouse. معظم المكاسب جاءت من استراتيجية العرض والتخزين المؤقت. وأول شيء كنت سأتحقق منه هو عنصر LCP نفسه: لا يجب أن يملك مشغّل الفيديو الرسم الأولي أبدًا.
Takeaways
- إذا كان عنصر LCP الخاص بك مشغّل فيديو، قم بتأجيله بواجهة. تكلفتها شبه معدومة وتزيل حمولة ضخمة من طرف ثالث من المسار الحرج.
- اجعل عنصر LCP النصي مُصيّرًا من الخادم. إذا كان العنوان يخضع للتحميل الأولي، فأنت تدفع ضريبة لا تستحقها.
- وحّد جميع طبقات التخزين المؤقت. يجب أن يتشارك Next.js و
unstable_cacheوCDN نفس TTL ومفاتيح الذاكرة المؤقتة. - استخدم
CDN-Cache-ControlوSurrogate-Controlعند التشغيل الذاتي خلف Cloudflare أو Fastly؛Cache-Controlالقياسي ليس كافيًا دائمًا. - سكربتات الطرف الثالث هي أسرع طريقة لإفساد ميزانية الأداء. تعامل معها على أنها مذنبة حتى تثبت براءتها.
- يُربح INP بإزالة forced reflow وتقسيم المهام الطويلة، وليس بإضافة مكتبات.
- قس أجزاء LCP الفرعية. TTFB يبلغ 0 ملي ثانية مع تأخير عرض يبلغ 730 ملي ثانية يخبرك بالضبط أين تبحث.

كتبه
Miracle Kalu
Senior Full Stack Engineer
نُشر 13 يونيو 2026 · 5 دقيقة قراءة