انسَ النظريات — إليك ما فعلته فعلاً لتقليل LCP إلى أقل من 2.5 ثانية، وCLS إلى صفر، وINP إلى أقل من 200 مللي ثانية على موقع Next.js إنتاجي حقيقي. تقنيات محددة، وليست نصائح مبهمة.
أمضيت الجزء الأكبر من أسبوعين في جعل هذا الموقع سريعاً. ليس "يبدو سريعاً في تدقيق Lighthouse على جهاز M3 MacBook الخاص بي" سريعاً. سريعاً فعلاً. سريعاً على هاتف Android بـ 150 دولاراً على اتصال 4G متقطع في نفق مترو. سريعاً حيث يهم الأمر.
النتيجة: LCP أقل من 1.8 ثانية، CLS عند 0.00، INP أقل من 120 مللي ثانية. الثلاثة خضراء في بيانات CrUX، وليس فقط درجات المختبر. وتعلّمت شيئاً في العملية — معظم نصائح الأداء على الإنترنت إما قديمة، أو مبهمة، أو كلاهما.
"حسّن صورك" ليست نصيحة. "استخدم التحميل الكسول" بدون سياق أمر خطير. "قلّل JavaScript" واضح لكنه لا يخبرك بشيء عن ما يجب حذفه.
إليك ما فعلته فعلاً، بالترتيب الذي كان مهماً.
دعني أكون صريحاً: تستخدم Google مؤشرات الويب الأساسية كإشارة تصنيف. ليست الإشارة الوحيدة، وليست حتى الأهم. صلة المحتوى، والروابط الخلفية، وسلطة النطاق لا تزال تهيمن. لكن عند الهوامش — حيث تمتلك صفحتان محتوى وسلطة متشابهين — الأداء هو الحكم. وعلى الإنترنت، ملايين الصفحات تعيش عند تلك الهوامش.
لكن انسَ SEO للحظة. السبب الحقيقي للاهتمام بالأداء هو المستخدمون. البيانات لم تتغير كثيراً في السنوات الخمس الأخيرة:
تتكون مؤشرات الويب الأساسية في 2026 من ثلاثة مقاييس:
| المقياس | ما يقيسه | جيد | يحتاج تحسين | سيئ |
|---|---|---|---|---|
| LCP | أداء التحميل | ≤ 2.5 ثانية | 2.5 – 4.0 ثانية | > 4.0 ثانية |
| CLS | الاستقرار البصري | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | الاستجابية | ≤ 200 مللي ثانية | 200 – 500 مللي ثانية | > 500 مللي ثانية |
لم تتغير هذه العتبات منذ أن حل INP محل FID في مارس 2024. لكن التقنيات لتحقيقها تطورت، خاصة في منظومة React/Next.js.
يقيس Largest Contentful Paint متى ينتهي تصيير أكبر عنصر مرئي في إطار العرض. لمعظم الصفحات، يكون هذا صورة بطل (hero image)، أو عنوان، أو كتلة نص كبيرة.
قبل تحسين أي شيء، تحتاج لمعرفة ما هو عنصر LCP الخاص بك. يفترض الناس أنه صورة البطل. أحياناً يكون خط ويب يُصيّر <h1>. أحياناً يكون صورة خلفية مطبّقة عبر CSS. أحياناً يكون إطار ملصق <video>.
افتح Chrome DevTools، اذهب إلى لوحة Performance، سجّل تحميل صفحة، وابحث عن علامة "LCP". ستخبرك بالضبط أي عنصر أطلق LCP.
يمكنك أيضاً استخدام مكتبة web-vitals لتسجيلها برمجياً:
import { onLCP } from "web-vitals";
onLCP((metric) => {
console.log("LCP element:", metric.entries[0]?.element);
console.log("LCP value:", metric.value, "ms");
});في هذا الموقع، تبيّن أن عنصر LCP هو صورة البطل في الصفحة الرئيسية والفقرة الأولى من النص في مقالات المدونة. عنصران مختلفان، استراتيجيتا تحسين مختلفتان.
إذا كان عنصر LCP صورة، فإن الشيء الأكثر تأثيراً الذي يمكنك فعله هو تحميلها مسبقاً (preload). افتراضياً، يكتشف المتصفح الصور عند تحليل HTML، مما يعني أن طلب الصورة لا يبدأ حتى يتم تنزيل HTML وتحليله والوصول إلى وسم <img>. التحميل المسبق ينقل ذلك الاكتشاف إلى البداية تماماً.
في Next.js، يمكنك إضافة رابط تحميل مسبق في التخطيط أو الصفحة:
import Head from "next/head";
export default function HeroSection() {
return (
<>
<Head>
<link rel="preload" as="image" href="/images/hero-optimized.webp" type="image/webp" fetchPriority="high" />
</Head>
<section className="relative h-[600px]">
<img
src="/images/hero-optimized.webp"
alt="Hero banner"
width={1200}
height={600}
fetchPriority="high"
decoding="sync"
/>
</section>
</>
);
}لاحظ fetchPriority="high". هذه هي واجهة Fetch Priority API الأحدث، وهي نقطة تحوّل. بدونها، يستخدم المتصفح استدلالاته الخاصة لترتيب أولويات الموارد — وتلك الاستدلالات غالباً ما تخطئ، خاصة عندما يكون لديك صور متعددة فوق الطية.
في هذا الموقع، أسقطت إضافة fetchPriority="high" لصورة LCP حوالي 400 مللي ثانية من LCP. هذا أكبر مكسب حصلت عليه على الإطلاق من تغيير سطر واحد.
CSS يحجب التصيير. كله. إذا كان لديك ملف أنماط بحجم 200 كيلوبايت محمّل عبر <link rel="stylesheet">، فالمتصفح لن يرسم أي شيء حتى يتم تنزيله وتحليله بالكامل.
الحل ثلاثي الأبعاد:
ضمّن CSS الحرجة — استخرج CSS اللازمة للمحتوى فوق الطية وضمّنها في وسم <style> في <head>. يفعل Next.js هذا تلقائياً عند استخدام CSS Modules أو Tailwind مع تنقية صحيحة.
أجّل CSS غير الحرجة — إذا كانت لديك أوراق أنماط لمحتوى تحت الطية (مكتبة رسوم متحركة لتذييل الصفحة، مكوّن رسوم بيانية)، حمّلها بشكل غير متزامن:
<link rel="preload" href="/styles/charts.css" as="style" onload="this.onload=null;this.rel='stylesheet'" />
<noscript>
<link rel="stylesheet" href="/styles/charts.css" />
</noscript>لا يمكن أن يكون LCP سريعاً إذا كان TTFB بطيئاً. إذا استغرق خادمك 800 مللي ثانية للاستجابة، فسيكون LCP على الأقل 800 مللي ثانية + كل شيء آخر.
في هذا الموقع (Node.js + PM2 + Nginx على VPS)، قست TTFB بحوالي 180 مللي ثانية عند أول زيارة. إليك ما فعلته للحفاظ عليه هناك:
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400 على الصفحات الثابتة.# nginx.conf snippet
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
# Brotli (if ngx_brotli module is installed)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;نتائجي قبل/بعد لـ LCP:
يقيس Cumulative Layout Shift مقدار تحرّك المحتوى المرئي أثناء تحميل الصفحة. CLS بقيمة 0 يعني عدم حدوث أي انزياح. CLS أعلى من 0.1 يعني أن شيئاً ما يزعج مستخدميك بصرياً.
CLS هو المقياس الذي يستهين به معظم المطورين. لا تلاحظه على جهاز التطوير السريع الخاص بك حيث كل شيء مُخزَّن مؤقتاً. مستخدموك يلاحظونه على هواتفهم، على اتصالات بطيئة، حيث تُحمَّل الخطوط متأخرة والصور تظهر واحدة تلو الأخرى.
1. صور بدون أبعاد صريحة
هذا أكثر سبب شائع لـ CLS. عندما تُحمَّل صورة، تدفع المحتوى تحتها للأسفل. الحل بسيط بشكل محرج: حدّد دائماً width و height على وسوم <img>.
// BAD — causes layout shift
<img src="/photo.jpg" alt="Team photo" />
// GOOD — browser reserves space before image loads
<img src="/photo.jpg" alt="Team photo" width={800} height={450} />إذا كنت تستخدم <Image> من Next.js، فإنه يتعامل مع هذا تلقائياً طالما توفر أبعاداً أو تستخدم fill مع حاوية أب ذات أبعاد محددة.
لكن إليك المفاجأة: إذا استخدمت وضع fill، فإن الحاوية الأب يجب أن يكون لها أبعاد صريحة وإلا ستسبب الصورة CLS:
// BAD — parent has no dimensions
<div className="relative">
<Image src="/photo.jpg" alt="Team" fill />
</div>
// GOOD — parent has explicit aspect ratio
<div className="relative aspect-video w-full">
<Image src="/photo.jpg" alt="Team" fill sizes="100vw" />
</div>2. خطوط الويب تسبب FOUT/FOIT
عندما يُحمَّل خط مخصص، يُعاد تصيير النص المُصيَّر بالخط البديل بالخط المخصص. إذا كان للخطين مقاييس مختلفة (وهو الحال دائماً تقريباً)، ينزاح كل شيء.
الحل الحديث هو font-display: swap مع خطوط بديلة معدّلة الحجم:
// Using next/font — the best approach for Next.js
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
// next/font automatically generates size-adjusted fallback fonts
// This eliminates CLS from font swapping
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}next/font هي حقاً واحدة من أفضل الأشياء في Next.js. تُنزّل الخطوط وقت البناء، وتستضيفها ذاتياً (لا طلبات Google Fonts وقت التشغيل)، وتُنشئ خطوطاً بديلة معدّلة الحجم حتى لا يسبب التبديل من البديل إلى المخصص أي انزياح تخطيط. قست CLS من الخطوط بـ 0.00 بعد التحول إلى next/font. قبل ذلك، مع <link> قياسي من Google Fonts، كانت 0.04-0.08.
3. حقن المحتوى الديناميكي
الإعلانات، ولافتات ملفات تعريف الارتباط، وأشرطة الإشعارات — أي شيء يُحقن في DOM بعد التصيير الأولي يسبب CLS إذا دفع المحتوى للأسفل.
الحل: احجز مساحة للمحتوى الديناميكي قبل تحميله.
// Cookie banner — reserve space at the bottom
function CookieBanner() {
const [accepted, setAccepted] = useState(false);
if (accepted) return null;
return (
// Fixed positioning doesn't cause CLS because it
// doesn't affect document flow
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
<p>We use cookies. You know the drill.</p>
<button onClick={() => setAccepted(true)}>Accept</button>
</div>
);
}استخدام position: fixed أو position: absolute للعناصر الديناميكية هو نهج خالٍ من CLS لأن هذه العناصر لا تؤثر على تدفق المستند العادي.
4. خدعة CSS لنسبة العرض إلى الارتفاع
للحاويات المتجاوبة حيث تعرف نسبة العرض إلى الارتفاع لكن ليس الأبعاد الدقيقة، استخدم خاصية CSS aspect-ratio:
// Video embed without CLS
function VideoEmbed({ src }: { src: string }) {
return (
<div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
<iframe
src={src}
className="w-full h-full"
title="Embedded video"
allow="accelerometer; autoplay; clipboard-write; encrypted-media"
allowFullScreen
/>
</div>
);
}أداة aspect-video (وهي aspect-ratio: 16/9) تحجز المقدار الصحيح تماماً من المساحة. لا انزياح عند تحميل iframe.
5. شاشات الهيكل العظمي
للمحتوى الذي يُحمَّل بشكل غير متزامن (بيانات API، مكوّنات ديناميكية)، اعرض هيكلاً عظمياً يطابق الأبعاد المتوقعة:
function PostCardSkeleton() {
return (
<div className="animate-pulse rounded-lg border p-4">
<div className="h-48 w-full rounded bg-gray-200" />
<div className="mt-4 space-y-2">
<div className="h-6 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-5/6 rounded bg-gray-200" />
</div>
</div>
);
}
function PostList() {
const { data: posts, isLoading } = usePosts();
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}المفتاح هو أن PostCardSkeleton و PostCard يجب أن يكون لهما نفس الأبعاد. إذا كان الهيكل العظمي بارتفاع 200 بكسل والبطاقة الفعلية بارتفاع 280 بكسل، ستحصل على انزياح مع ذلك.
نتائج CLS الخاصة بي:
حلّ Interaction to Next Paint محل First Input Delay في مارس 2024، وهو مقياس أصعب جذرياً في التحسين. FID كان يقيس فقط التأخير قبل معالجة أول تفاعل. INP يقيس كل تفاعل طوال دورة حياة الصفحة ويبلّغ عن الأسوأ (عند المئين 75).
هذا يعني أن الصفحة يمكن أن يكون لها FID ممتاز لكن INP رهيب إذا، مثلاً، النقر على قائمة منسدلة بعد 30 ثانية من التحميل يُحدث إعادة تخطيط مدتها 500 مللي ثانية.
offsetHeight) ثم كتابتها (مثل تغيير style.height) في حلقة يجبر المتصفح على إعادة حساب التخطيط بشكل متزامن.أكثر تقنية تأثيراً لـ INP هي تقسيم المهام الطويلة حتى يتمكن المتصفح من معالجة تفاعلات المستخدم بين الأجزاء:
async function processLargeDataset(items: DataItem[]) {
const results: ProcessedItem[] = [];
for (let i = 0; i < items.length; i++) {
results.push(expensiveTransform(items[i]));
// Every 10 items, yield to the browser
// This lets pending user interactions get processed
if (i % 10 === 0 && "scheduler" in globalThis) {
await scheduler.yield();
}
}
return results;
}scheduler.yield() متاحة في Chrome 129+ (سبتمبر 2024) وهي الطريقة الموصى بها للتنازل عن الخيط الرئيسي. للمتصفحات التي لا تدعمها، يمكنك الرجوع إلى غلاف setTimeout(0):
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}تمنحنا React 18+ خطاف useTransition، الذي يخبر React أن تحديثات حالة معينة ليست عاجلة ويمكن مقاطعتها بعمل أكثر أهمية (مثل الاستجابة لمدخلات المستخدم):
import { useState, useTransition } from "react";
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// This update is urgent — the input must reflect the keystroke immediately
setQuery(value);
// This update is NOT urgent — filtering 10,000 items can wait
startTransition(() => {
const filtered = items.filter((item) => item.name.toLowerCase().includes(value.toLowerCase()));
setFilteredItems(filtered);
});
}
return (
<div>
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Search..."
className="w-full rounded border px-4 py-2"
/>
{isPending && <p className="mt-2 text-sm text-gray-500">Filtering...</p>}
<ul className="mt-4 space-y-2">
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}بدون startTransition، الكتابة في حقل البحث ستشعر ببطء لأن React ستحاول تصفية 10,000 عنصر بشكل متزامن قبل تحديث DOM. مع startTransition، يتحدث الحقل فوراً، والتصفية تحدث في الخلفية.
قست INP على صفحة أداة كان لها معالج إدخال معقد. قبل useTransition: 380 مللي ثانية INP. بعده: 90 مللي ثانية INP. هذا تحسين بنسبة 76% من تغيير واجهة React.
للمعالجات التي تُطلق عمليات مكلفة (استدعاءات API، حسابات ثقيلة)، أخّرها:
import { useCallback, useRef } from "react";
function useDebounce<T extends (...args: unknown[]) => void>(fn: T, delay: number): T {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
return useCallback(
((...args: unknown[]) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => fn(...args), delay);
}) as T,
[fn, delay],
);
}
// Usage
function LiveSearch() {
const [results, setResults] = useState<SearchResult[]>([]);
const search = useDebounce(async (query: string) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data.results);
}, 300);
return <input type="text" onChange={(e) => search(e.target.value)} placeholder="Search..." />;
}300 مللي ثانية هي قيمة التأخير المفضلة لديّ. قصيرة بما يكفي بحيث لا يلاحظ المستخدمون التأخير، وطويلة بما يكفي لمنع الإطلاق مع كل ضغطة مفتاح.
إذا كانت لديك حسابات ثقيلة فعلاً (تحليل JSON كبير، معالجة صور، حسابات معقدة)، انقلها من الخيط الرئيسي بالكامل:
// worker.ts
self.addEventListener("message", (event) => {
const { data, operation } = event.data;
switch (operation) {
case "sort": {
// This could take 500ms for large datasets
const sorted = data.sort((a: number, b: number) => a - b);
self.postMessage({ result: sorted });
break;
}
case "filter": {
const filtered = data.filter((item: DataItem) => complexFilterLogic(item));
self.postMessage({ result: filtered });
break;
}
}
});// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
function useWorker() {
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(new URL("../workers/worker.ts", import.meta.url));
return () => workerRef.current?.terminate();
}, []);
const process = useCallback((operation: string, data: unknown): Promise<unknown> => {
return new Promise((resolve) => {
if (!workerRef.current) return;
workerRef.current.onmessage = (event) => {
resolve(event.data.result);
};
workerRef.current.postMessage({ operation, data });
});
}, []);
return { process };
}تعمل Web Workers على خيط منفصل، لذا حتى حساب مدته ثانيتان لن يؤثر على INP إطلاقاً. يبقى الخيط الرئيسي حراً لمعالجة تفاعلات المستخدم.
نتائج INP الخاصة بي:
إذا كنت على Next.js (13+ مع App Router)، لديك وصول إلى بعض البدائيات الأدائية القوية التي لا يستغلها معظم المطورين بالكامل.
next/image ممتازة، لكن التهيئة الافتراضية تترك أداءً على الطاولة:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
minimumCacheTTL: 60 * 60 * 24 * 365, // 1 year
},
};
export default nextConfig;الإعدادات الأساسية:
formats: ["image/avif", "image/webp"] — AVIF أصغر بنسبة 20-50% من WebP. الترتيب مهم: يجرب Next.js AVIF أولاً، ثم يرجع إلى WebP، ثم إلى التنسيق الأصلي.minimumCacheTTL — الافتراضي 60 ثانية. لمدونة، الصور لا تتغير. خزّنها مؤقتاً لسنة.deviceSizes و imageSizes — الافتراضيات تتضمن 3840 بكسل. ما لم تكن تقدم صور 4K، قلّص هذه القائمة. كل حجم يُنشئ صورة مخزّنة مؤقتاً منفصلة، والأحجام غير المستخدمة تهدر مساحة القرص ووقت البناء.واستخدم دائماً خاصية sizes لإخبار المتصفح بالحجم الذي ستُصيَّر به الصورة:
// Full-width hero image
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
sizes="100vw"
priority // LCP image — don't lazy load it!
/>
// Card image in a responsive grid
<Image
src="/card.jpg"
alt="Card"
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>بدون sizes، قد يُنزّل المتصفح صورة بحجم 1200 بكسل لفتحة 300 بكسل. هذا بايتات مهدرة ووقت مهدر.
خاصية priority على صورة LCP حاسمة. تُعطّل التحميل الكسول وتضيف fetchPriority="high" تلقائياً. إذا كان عنصر LCP الخاص بك هو next/image، فقط أضف priority وأنت في معظم الطريق.
غطيت هذا في قسم CLS، لكنه يستحق التأكيد. next/font هو حل تحميل الخطوط الوحيد الذي رأيته يحقق باستمرار صفر CLS:
import { Inter, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}خطان، صفر CLS، صفر طلبات خارجية وقت التشغيل. الخطوط تُنزَّل وقت البناء وتُقدَّم من نطاقك الخاص.
هنا حيث يصبح Next.js مثيراً فعلاً للأداء. مع App Router، يمكنك تدفيق أجزاء الصفحة إلى المتصفح فور جهوزيتها:
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
export default function BlogPage() {
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
{/* This loads fast — stream it immediately */}
<h1 className="text-4xl font-bold">Blog</h1>
{/* This requires a database query — stream it when ready */}
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
<aside>
{/* Sidebar can load independently */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</aside>
</div>
);
}يستقبل المتصفح الهيكل (العنوان، التنقل، التخطيط) فوراً. قائمة المقالات والشريط الجانبي يتدفقان فور توفر بياناتهما. يرى المستخدم تحميلاً أولياً سريعاً، والمحتوى يملأ تدريجياً.
هذا قوي بشكل خاص لـ LCP. إذا كان عنصر LCP هو العنوان (وليس قائمة المقالات)، فإنه يُصيَّر فوراً بغض النظر عن مدة استعلام قاعدة البيانات.
يتيح لك Next.js تهيئة التخزين المؤقت وإعادة التحقق على مستوى جزء المسار:
// app/blog/page.tsx
// Revalidate this page every hour
export const revalidate = 3600;
// app/tools/[slug]/page.tsx
// These tool pages are fully static — generate at build time
export const dynamic = "force-static";
// app/api/search/route.ts
// API route — never cache
export const dynamic = "force-dynamic";في هذا الموقع، مقالات المدونة تستخدم revalidate = 3600 (ساعة واحدة). صفحات الأدوات تستخدم force-static لأن محتواها لا يتغير بين عمليات النشر. واجهة البحث API تستخدم force-dynamic لأن كل طلب فريد.
النتيجة: معظم الصفحات تُقدَّم من الذاكرة المؤقتة الثابتة، TTFB أقل من 50 مللي ثانية للصفحات المخزّنة، والخادم بالكاد يتعرّق.
إدراكك للأداء غير موثوق. جهاز التطوير الخاص بك يحتوي على 32 جيجابايت RAM، وقرص NVMe SSD، واتصال بسرعة جيجابت. مستخدموك لا يملكون ذلك.
1. لوحة Performance في Chrome DevTools
الأداة الأكثر تفصيلاً المتاحة. سجّل تحميل صفحة، انظر إلى مخطط اللهب (flamechart)، حدّد المهام الطويلة، اعثر على الموارد التي تحجب التصيير. هنا حيث أقضي معظم وقت تصحيح الأخطاء.
أشياء أساسية للبحث عنها:
2. Lighthouse
جيد للفحص السريع، لكن لا تُحسّن لدرجات Lighthouse. يعمل Lighthouse في بيئة مُقيّدة محاكاة لا تطابق الظروف الواقعية تماماً. رأيت صفحات تسجل 98 في Lighthouse ولديها 4 ثوانٍ LCP في الميدان.
استخدم Lighthouse للتوجيه الاتجاهي، وليس كلوحة نتائج.
3. PageSpeed Insights
الأداة الأهم للمواقع الإنتاجية لأنها تعرض بيانات CrUX الحقيقية — قياسات فعلية من مستخدمي Chrome حقيقيين على مدار الـ 28 يوماً الماضية. بيانات المختبر تخبرك بما يمكن أن يحدث. بيانات CrUX تخبرك بما يحدث فعلاً.
4. مكتبة web-vitals
أضفها إلى موقعك الإنتاجي لجمع مقاييس المستخدمين الحقيقيين:
// components/analytics/WebVitals.tsx
"use client";
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
function sendToAnalytics(metric: Metric) {
// Send to your analytics endpoint
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
// Use sendBeacon so it doesn't block page unload
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
} else {
fetch("/api/vitals", {
body,
method: "POST",
keepalive: true,
});
}
}
export function WebVitals() {
useEffect(() => {
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}, []);
return null;
}هذا يمنحك بياناتك الخاصة الشبيهة بـ CrUX، لكن بتفصيل أكثر. يمكنك التقسيم حسب الصفحة، ونوع الجهاز، وسرعة الاتصال، والمنطقة الجغرافية — أي شيء تحتاجه.
5. Chrome User Experience Report (CrUX)
مجموعة بيانات CrUX BigQuery مجانية وتحتوي على بيانات متجددة لمدة 28 يوماً لملايين الأصول. إذا حصل موقعك على حركة مرور كافية، يمكنك الاستعلام عن بياناتك الخاصة:
SELECT
origin,
p75_lcp,
p75_cls,
p75_inp,
form_factor
FROM
`chrome-ux-report.materialized.metrics_summary`
WHERE
origin = 'https://yoursite.com'
AND yyyymm = 202603النصوص البرمجية من طرف ثالث هي القاتل الأول للأداء في معظم المواقع. إليك ما وجدته وما فعلته حياله.
GTM نفسه حوالي 80 كيلوبايت. لكن GTM يحمّل نصوصاً أخرى — تحليلات، بكسلات تسويق، أدوات اختبار A/B. رأيت تهيئات GTM تحمّل 15 نصاً إضافياً بإجمالي 2 ميغابايت.
نهجي: لا تستخدم GTM في الإنتاج. حمّل نصوص التحليلات مباشرة، أجّل كل شيء، واستخدم loading="lazy" للنصوص التي يمكنها الانتظار:
// Instead of GTM loading everything
// Load only what you need, when you need it
export function AnalyticsScript() {
return <script defer src="https://analytics.example.com/script.js" data-website-id="your-id" />;
}إذا كان لا بد من استخدام GTM، حمّله بعد أن تصبح الصفحة تفاعلية:
"use client";
import { useEffect } from "react";
export function DeferredGTM({ containerId }: { containerId: string }) {
useEffect(() => {
// Wait until after page load to inject GTM
const timer = setTimeout(() => {
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
script.async = true;
document.head.appendChild(script);
}, 3000); // 3 second delay
return () => clearTimeout(timer);
}, [containerId]);
return null;
}نعم، ستفقد بيانات من المستخدمين الذين يرتدون في أول 3 ثوانٍ. في تجربتي، هذا تنازل يستحق القيام به. هؤلاء المستخدمون لم يكونوا يتحولون على أي حال.
أدوات الدردشة الحية (Intercom، Drift، Crisp) من أسوأ المتسببين. Intercom وحده يحمّل أكثر من 400 كيلوبايت من JavaScript. في صفحة حيث 2% من المستخدمين ينقرون فعلاً على زر الدردشة، هذا 400 كيلوبايت من JavaScript لـ 98% من المستخدمين.
حلّي: حمّل الأداة عند التفاعل.
"use client";
import { useState } from "react";
export function ChatButton() {
const [loaded, setLoaded] = useState(false);
function loadChat() {
if (loaded) return;
// Load the chat widget script only when the user clicks
const script = document.createElement("script");
script.src = "https://chat-widget.example.com/widget.js";
script.onload = () => {
// Initialize the widget after script loads
window.ChatWidget?.open();
};
document.head.appendChild(script);
setLoaded(true);
}
return (
<button
onClick={loadChat}
className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
aria-label="Open chat"
>
{loaded ? "Loading..." : "Chat with us"}
</button>
);
}شغّل Coverage في Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). يُظهر لك بالضبط كم من كل نص برمجي مستخدم فعلاً في الصفحة الحالية.
في موقع Next.js نموذجي، أجد عادة:
Button من مكتبة واجهة مستخدم، لكن المكتبة بأكملها تُضمَّن في الحزمة. الحل: استخدم مكتبات قابلة لإزالة الشجرة (tree-shakeable) أو استورد من مسارات فرعية (import Button from "lib/Button" بدلاً من import { Button } from "lib").Promise، fetch، أو Array.prototype.includes. في 2026، لا تحتاجها.أستخدم محلل حزم Next.js للعثور على الأجزاء الضخمة:
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
// your config
};
export default process.env.ANALYZE === "true" ? withBundleAnalyzer({ enabled: true })(nextConfig) : nextConfig;ANALYZE=true npm run buildهذا يفتح خريطة شجرية مرئية لحزمك. وجدت مكتبة تنسيق تواريخ بحجم 120 كيلوبايت استبدلتها بـ Intl.DateTimeFormat الأصلية. وجدت محلل Markdown بحجم 90 كيلوبايت مستورد في صفحة لا تستخدم Markdown. مكاسب صغيرة تتراكم.
ذكرت هذا في قسم LCP، لكنه يستحق التكرار لأنه شائع جداً. كل <link rel="stylesheet"> في <head> يحجب التصيير. إذا كان لديك خمس أوراق أنماط، ينتظر المتصفح الخمسة جميعاً قبل رسم أي شيء.
يتعامل Next.js مع Tailwind مع هذا جيداً — CSS مُضمَّنة وقليلة. لكن إذا كنت تستورد CSS من طرف ثالث، دقّق فيها:
// BAD — loads entire library CSS on every page
import "some-library/dist/styles.css";
// BETTER — dynamic import so it only loads on pages that need it
const SomeComponent = dynamic(
() =>
import("some-library").then((mod) => {
// CSS is imported inside the dynamic component
import("some-library/dist/styles.css");
return mod.SomeComponent;
}),
{ ssr: false },
);دعني أتتبّع التحسين الفعلي لصفحة الأدوات في هذا الموقع. إنها صفحة بأكثر من 15 أداة تفاعلية، لكل منها مكوّنها الخاص، وبعضها (مثل مُختبر التعبيرات النظامية ومُنسّق JSON) ثقيلة بـ JavaScript.
القياسات الأولية (بيانات CrUX، هاتف، المئين 75):
درجة Lighthouse: 62.
تحليل LCP: كان عنصر LCP هو عنوان الصفحة (<h1>)، الذي يجب أن يُصيَّر فوراً. لكنه تأخر بسبب:
تحليل CLS: ثلاثة مصادر:
تحليل INP: أداة مُختبر التعبيرات النظامية كانت الأسوأ. كل ضغطة مفتاح في حقل إدخال التعبير النظامي كانت تُطلق:
إجمالي الوقت لكل ضغطة مفتاح: 280-400 مللي ثانية.
الأسبوع 1: LCP و CLS
استبدلت CDN لـ Google Fonts بـ next/font. الخط الآن مستضاف ذاتياً، محمّل وقت البناء، مع خط بديل معدّل الحجم. CLS من الخطوط: 0.06 ← 0.00
أزلت CSS مكتبة المكوّنات. أعدت كتابة المكوّنات الثلاثة التي كنت أستخدمها منها بـ Tailwind. إجمالي CSS المُزالة: 180 كيلوبايت. CSS التي تحجب التصيير: أُزيلت
أضفت revalidate = 3600 لصفحة الأدوات وصفحات تفاصيل الأدوات. الزيارة الأولى تُصيَّر على الخادم، الزيارات اللاحقة تُقدَّم من الذاكرة المؤقتة. TTFB: 420 مللي ثانية ← 45 مللي ثانية (مُخزَّن مؤقتاً)
أضفت أبعاداً صريحة لجميع مكوّنات بطاقات الأدوات واستخدمت aspect-ratio للتخطيطات المتجاوبة. CLS من البطاقات: 0.04 ← 0.00
نقلت لافتة ملفات تعريف الارتباط إلى position: fixed في أسفل الشاشة. CLS من اللافتة: 0.02 ← 0.00
الأسبوع 2: INP
startTransition:function RegexTester() {
const [pattern, setPattern] = useState("");
const [testString, setTestString] = useState("");
const [results, setResults] = useState<RegexResult[]>([]);
const [isPending, startTransition] = useTransition();
function handlePatternChange(value: string) {
setPattern(value); // Urgent: update the input
startTransition(() => {
// Non-urgent: compute matches
try {
const regex = new RegExp(value, "g");
const matches = [...testString.matchAll(regex)];
setResults(
matches.map((m) => ({
match: m[0],
index: m.index ?? 0,
groups: m.groups,
})),
);
} catch {
setResults([]);
}
});
}
return (
<div>
<input
value={pattern}
onChange={(e) => handlePatternChange(e.target.value)}
className={isPending ? "opacity-70" : ""}
/>
{/* results rendering */}
</div>
);
}INP على مُختبر التعبيرات النظامية: 380 مللي ثانية ← 85 مللي ثانية
أضفت تأخيراً (debouncing) لمعالج إدخال مُنسّق JSON (تأخير 300 مللي ثانية). INP على مُنسّق JSON: 260 مللي ثانية ← 60 مللي ثانية
نقلت حساب مُولّد الهاش إلى Web Worker. تجزئة SHA-256 للمدخلات الكبيرة تحدث الآن خارج الخيط الرئيسي بالكامل. INP على مُولّد الهاش: 200 مللي ثانية ← 40 مللي ثانية
بعد أسبوعين من التحسين (بيانات CrUX، هاتف، المئين 75):
درجة Lighthouse: 62 ← 97.
جميع المقاييس الثلاثة في نطاق "جيد" بثبات. الصفحة تبدو فورية على الهاتف. وحركة البحث العضوي زادت 12% في الشهر الذي تلا التحسينات (رغم أنني لا أستطيع إثبات السببية — عوامل أخرى كانت تلعب دوراً).
إذا لم تأخذ شيئاً آخر من هذا المقال، إليك القائمة المرجعية التي أمر عليها في كل مشروع:
priority (أو fetchPriority="high") لصورة LCP<head>next/fontwidth و height صريحةnext/font مع خطوط بديلة معدّلة الحجمposition: fixed/absolute أو مساحة محجوزةstartTransitionتحسين الأداء ليس مهمة تُنجز مرة واحدة. إنه انضباط. كل ميزة جديدة، كل اعتمادية جديدة، كل نص برمجي جديد من طرف ثالث هو تراجع محتمل. المواقع التي تبقى سريعة هي تلك التي يراقب فيها شخص ما المقاييس باستمرار، وليس تلك التي قام فيها شخص ما بحملة تحسين لمرة واحدة.
أعدّ مراقبة المستخدمين الحقيقيين. أعدّ تنبيهات عند تراجع المقاييس. اجعل الأداء جزءاً من عملية مراجعة الكود. عندما يضيف شخص مكتبة بحجم 200 كيلوبايت، اسأل إذا كان هناك بديل بحجم 5 كيلوبايت. عندما يضيف شخص حساباً متزامناً في معالج أحداث، اسأل إذا كان يمكن تأجيله أو نقله إلى worker.
التقنيات في هذا المقال ليست نظرية. إنها ما فعلته فعلاً، في هذا الموقع، بأرقام حقيقية تدعمها. نتائجك ستختلف — كل موقع مختلف، كل جمهور مختلف، كل بنية تحتية مختلفة. لكن المبادئ عالمية: حمّل أقل، حمّل بذكاء، لا تحجب الخيط الرئيسي.
مستخدموك لن يرسلوا لك رسالة شكر على موقع سريع. لكنهم سيبقون. سيعودون. وGoogle ستلاحظ.