مؤشرات الويب الأساسية في 2026: ما الذي يُحدث فرقاً فعلاً
انسَ النظريات — إليك ما فعلته فعلاً لتقليل LCP إلى أقل من 2.5 ثانية، وCLS إلى صفر، وINP إلى أقل من 200 مللي ثانية على موقع Next.js إنتاجي حقيقي. تقنيات محددة، وليست نصائح مبهمة.
أمضيت الجزء الأكبر من أسبوعين في جعل هذا الموقع سريعاً. ليس "يبدو سريعاً في تدقيق Lighthouse على جهاز M3 MacBook الخاص بي" سريعاً. سريعاً فعلاً. سريعاً على هاتف Android بـ 150 دولاراً على اتصال 4G متقطع في نفق مترو. سريعاً حيث يهم الأمر.
النتيجة: LCP أقل من 1.8 ثانية، CLS عند 0.00، INP أقل من 120 مللي ثانية. الثلاثة خضراء في بيانات CrUX، وليس فقط درجات المختبر. وتعلّمت شيئاً في العملية — معظم نصائح الأداء على الإنترنت إما قديمة، أو مبهمة، أو كلاهما.
"حسّن صورك" ليست نصيحة. "استخدم التحميل الكسول" بدون سياق أمر خطير. "قلّل JavaScript" واضح لكنه لا يخبرك بشيء عن ما يجب حذفه.
إليك ما فعلته فعلاً، بالترتيب الذي كان مهماً.
لماذا لا تزال مؤشرات الويب الأساسية مهمة في 2026#
دعني أكون صريحاً: تستخدم Google مؤشرات الويب الأساسية كإشارة تصنيف. ليست الإشارة الوحيدة، وليست حتى الأهم. صلة المحتوى، والروابط الخلفية، وسلطة النطاق لا تزال تهيمن. لكن عند الهوامش — حيث تمتلك صفحتان محتوى وسلطة متشابهين — الأداء هو الحكم. وعلى الإنترنت، ملايين الصفحات تعيش عند تلك الهوامش.
لكن انسَ SEO للحظة. السبب الحقيقي للاهتمام بالأداء هو المستخدمون. البيانات لم تتغير كثيراً في السنوات الخمس الأخيرة:
- 53% من زيارات الهاتف يُتخلى عنها إذا استغرقت الصفحة أكثر من 3 ثوانٍ للتحميل (بحث Google/SOASTA، لا يزال صحيحاً)
- كل 100 مللي ثانية من التأخير تكلف تقريباً 1% في التحويلات (اكتشاف Amazon الأصلي، تم التحقق منه مراراً)
- المستخدمون الذين يواجهون انزياحات التخطيط أقل احتمالاً بشكل كبير لإتمام عملية شراء أو ملء نموذج
تتكون مؤشرات الويب الأساسية في 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.
LCP: الأهم على الإطلاق#
يقيس Largest Contentful Paint متى ينتهي تصيير أكبر عنصر مرئي في إطار العرض. لمعظم الصفحات، يكون هذا صورة بطل (hero image)، أو عنوان، أو كتلة نص كبيرة.
الخطوة 1: اعثر على عنصر LCP الفعلي#
قبل تحسين أي شيء، تحتاج لمعرفة ما هو عنصر 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 هو صورة البطل في الصفحة الرئيسية والفقرة الأولى من النص في مقالات المدونة. عنصران مختلفان، استراتيجيتا تحسين مختلفتان.
الخطوة 2: حمّل مورد 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. هذا أكبر مكسب حصلت عليه على الإطلاق من تغيير سطر واحد.
الخطوة 3: أزل الموارد التي تحجب التصيير#
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>- أزل CSS غير المستخدمة — يفعل Tailwind CSS v4 هذا تلقائياً بمحركه JIT. لكن إذا كنت تستورد مكتبات CSS من طرف ثالث، دقّق فيها. وجدت مكتبة مكوّنات تستورد 180 كيلوبايت من CSS لمكوّن تلميح واحد. استبدلتها بمكوّن مخصص من 20 سطراً ووفّرت 170 كيلوبايت.
الخطوة 4: زمن استجابة الخادم (TTFB)#
لا يمكن أن يكون LCP سريعاً إذا كان TTFB بطيئاً. إذا استغرق خادمك 800 مللي ثانية للاستجابة، فسيكون LCP على الأقل 800 مللي ثانية + كل شيء آخر.
في هذا الموقع (Node.js + PM2 + Nginx على VPS)، قست TTFB بحوالي 180 مللي ثانية عند أول زيارة. إليك ما فعلته للحفاظ عليه هناك:
- ISR (إعادة التوليد الثابت التزايدي) لمقالات المدونة — الصفحات مُصيَّرة مسبقاً وقت البناء ويُعاد التحقق منها دورياً. الزيارة الأولى تقدم ملفاً ثابتاً مباشرة من ذاكرة الوكيل العكسي لـ Nginx.
- ترويسات التخزين المؤقت —
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400على الصفحات الثابتة. - ضغط Gzip/Brotli في Nginx — يقلل حجم النقل بنسبة 60-80%.
# 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:
- قبل التحسين: 3.8 ثانية (المئين 75، CrUX)
- بعد التحميل المسبق + fetchPriority + الضغط: 1.8 ثانية
- إجمالي التحسين: تقليل بنسبة 53%
CLS: الموت بألف انزياح#
يقيس 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 الخاصة بي:
- قبل: 0.12 (تبديل الخط وحده كان 0.06)
- بعد: 0.00 — حرفياً صفر، عبر آلاف تحميلات الصفحات في بيانات CrUX
INP: الوافد الجديد الذي يعضّ#
حلّ Interaction to Next Paint محل First Input Delay في مارس 2024، وهو مقياس أصعب جذرياً في التحسين. FID كان يقيس فقط التأخير قبل معالجة أول تفاعل. INP يقيس كل تفاعل طوال دورة حياة الصفحة ويبلّغ عن الأسوأ (عند المئين 75).
هذا يعني أن الصفحة يمكن أن يكون لها FID ممتاز لكن INP رهيب إذا، مثلاً، النقر على قائمة منسدلة بعد 30 ثانية من التحميل يُحدث إعادة تخطيط مدتها 500 مللي ثانية.
ما الذي يسبب ارتفاع INP#
- مهام طويلة على الخيط الرئيسي — أي تنفيذ JavaScript يستغرق أكثر من 50 مللي ثانية يحجب الخيط الرئيسي. تفاعلات المستخدم التي تحدث أثناء مهمة طويلة يجب أن تنتظر.
- إعادات التصيير المكلفة في React — تحديث حالة يتسبب في إعادة تصيير 200 مكوّن يستغرق وقتاً. ينقر المستخدم على شيء، React تُصالح، والرسم لا يحدث لمدة 300 مللي ثانية.
- اضطراب التخطيط — قراءة خصائص التخطيط (مثل
offsetHeight) ثم كتابتها (مثل تغييرstyle.height) في حلقة يجبر المتصفح على إعادة حساب التخطيط بشكل متزامن. - DOM كبير — المزيد من عقد DOM يعني إعادة حساب أنماط وتخطيط أبطأ. DOM بـ 5,000 عقدة أبطأ بشكل ملحوظ من واحد بـ 500.
تقسيم المهام الطويلة باستخدام scheduler.yield()#
أكثر تقنية تأثيراً لـ 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));
}useTransition للتحديثات غير العاجلة#
تمنحنا 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.
تأخير معالجات الإدخال (Debouncing)#
للمعالجات التي تُطلق عمليات مكلفة (استدعاءات 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 مللي ثانية هي قيمة التأخير المفضلة لديّ. قصيرة بما يكفي بحيث لا يلاحظ المستخدمون التأخير، وطويلة بما يكفي لمنع الإطلاق مع كل ضغطة مفتاح.
Web Workers للحسابات الثقيلة#
إذا كانت لديك حسابات ثقيلة فعلاً (تحليل 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 الخاصة بي:
- قبل: 340 مللي ثانية (أسوأ تفاعل كان أداة اختبار التعبيرات النظامية مع معالجة إدخال معقدة)
- بعد useTransition + التأخير: 110 مللي ثانية
- التحسين: تقليل بنسبة 68%
المكاسب الخاصة بـ Next.js#
إذا كنت على Next.js (13+ مع App Router)، لديك وصول إلى بعض البدائيات الأدائية القوية التي لا يستغلها معظم المطورين بالكامل.
next/image — لكن مُهيّأة بشكل صحيح#
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 وأنت في معظم الطريق.
next/font — خطوط بدون أي انزياح تخطيط#
غطيت هذا في قسم 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، صفر طلبات خارجية وقت التشغيل. الخطوط تُنزَّل وقت البناء وتُقدَّم من نطاقك الخاص.
التدفق مع Suspense#
هنا حيث يصبح 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)، حدّد المهام الطويلة، اعثر على الموارد التي تحجب التصيير. هنا حيث أقضي معظم وقت تصحيح الأخطاء.
أشياء أساسية للبحث عنها:
- زوايا حمراء على المهام = مهام طويلة (>50 مللي ثانية)
- أحداث Layout/Paint المُطلقة بواسطة JavaScript
- كتل "Evaluate Script" كبيرة (JavaScript كثير جداً)
- شلال الشبكة يُظهر موارد اكتُشفت متأخراً
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قائمة قتل الشلال#
النصوص البرمجية من طرف ثالث هي القاتل الأول للأداء في معظم المواقع. إليك ما وجدته وما فعلته حياله.
Google Tag Manager (GTM)#
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>
);
}JavaScript غير المستخدم#
شغّل Coverage في Chrome DevTools (Ctrl+Shift+P > "Show Coverage"). يُظهر لك بالضبط كم من كل نص برمجي مستخدم فعلاً في الصفحة الحالية.
في موقع Next.js نموذجي، أجد عادة:
- مكتبات مكوّنات محمّلة بالكامل — تستورد
Buttonمن مكتبة واجهة مستخدم، لكن المكتبة بأكملها تُضمَّن في الحزمة. الحل: استخدم مكتبات قابلة لإزالة الشجرة (tree-shakeable) أو استورد من مسارات فرعية (import Button from "lib/Button"بدلاً منimport { Button } from "lib"). - تعويضات لمتصفحات حديثة (Polyfills) — تحقق مما إذا كنت تشحن تعويضات لـ
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. مكاسب صغيرة تتراكم.
CSS التي تحجب التصيير#
ذكرت هذا في قسم 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):
- LCP: 3.8 ثانية — سيئ
- CLS: 0.12 — يحتاج تحسين
- INP: 340 مللي ثانية — سيئ
درجة Lighthouse: 62.
التحقيق#
تحليل LCP: كان عنصر LCP هو عنوان الصفحة (<h1>)، الذي يجب أن يُصيَّر فوراً. لكنه تأخر بسبب:
- ملف CSS بحجم 200 كيلوبايت من مكتبة مكوّنات (يحجب التصيير)
- خط مخصص محمّل عبر CDN لـ Google Fonts (FOIT لمدة 800 مللي ثانية على اتصالات بطيئة)
- TTFB بقيمة 420 مللي ثانية لأن الصفحة كانت تُصيَّر على الخادم مع كل طلب بدون تخزين مؤقت
تحليل CLS: ثلاثة مصادر:
- تبديل الخط من خط Google Fonts البديل إلى الخط المخصص: 0.06
- بطاقات الأدوات تُحمَّل بدون حجز ارتفاع: 0.04
- لافتة ملفات تعريف الارتباط تُحقن في أعلى الصفحة، تدفع كل شيء للأسفل: 0.02
تحليل 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):
- LCP: 3.8 ثانية ← 1.8 ثانية (تحسين بنسبة 53%)
- CLS: 0.12 ← 0.00 (تحسين بنسبة 100%)
- INP: 340 مللي ثانية ← 110 مللي ثانية (تحسين بنسبة 68%)
درجة Lighthouse: 62 ← 97.
جميع المقاييس الثلاثة في نطاق "جيد" بثبات. الصفحة تبدو فورية على الهاتف. وحركة البحث العضوي زادت 12% في الشهر الذي تلا التحسينات (رغم أنني لا أستطيع إثبات السببية — عوامل أخرى كانت تلعب دوراً).
القائمة المرجعية#
إذا لم تأخذ شيئاً آخر من هذا المقال، إليك القائمة المرجعية التي أمر عليها في كل مشروع:
LCP#
- حدّد عنصر LCP باستخدام DevTools
- أضف
priority(أوfetchPriority="high") لصورة LCP - حمّل موارد LCP مسبقاً في
<head> - أزل CSS التي تحجب التصيير
- استضف الخطوط ذاتياً باستخدام
next/font - فعّل ضغط Brotli/Gzip
- استخدم التوليد الثابت أو ISR حيثما أمكن
- عيّن ترويسات تخزين مؤقت عنيفة للأصول الثابتة
CLS#
- جميع الصور لها
widthوheightصريحة - تستخدم
next/fontمع خطوط بديلة معدّلة الحجم - المحتوى الديناميكي يستخدم
position: fixed/absoluteأو مساحة محجوزة - شاشات الهيكل العظمي تطابق أبعاد المكوّن الفعلي
- لا حقن محتوى في أعلى الصفحة بعد التحميل
INP#
- لا مهام طويلة (>50 مللي ثانية) أثناء معالجات التفاعل
- تحديثات الحالة غير العاجلة ملفوفة في
startTransition - معالجات الإدخال مؤخّرة (300 مللي ثانية)
- الحسابات الثقيلة منقولة إلى Web Workers
- حجم DOM أقل من 1,500 عقدة حيثما أمكن
عام#
- النصوص البرمجية من طرف ثالث تُحمَّل بعد أن تصبح الصفحة تفاعلية
- حجم الحزمة تم تحليله وإزالة شجرته
- CSS غير المستخدمة أُزيلت
- الصور تُقدَّم بتنسيق AVIF/WebP
- مراقبة المستخدمين الحقيقيين في الإنتاج (مكتبة web-vitals)
أفكار ختامية#
تحسين الأداء ليس مهمة تُنجز مرة واحدة. إنه انضباط. كل ميزة جديدة، كل اعتمادية جديدة، كل نص برمجي جديد من طرف ثالث هو تراجع محتمل. المواقع التي تبقى سريعة هي تلك التي يراقب فيها شخص ما المقاييس باستمرار، وليس تلك التي قام فيها شخص ما بحملة تحسين لمرة واحدة.
أعدّ مراقبة المستخدمين الحقيقيين. أعدّ تنبيهات عند تراجع المقاييس. اجعل الأداء جزءاً من عملية مراجعة الكود. عندما يضيف شخص مكتبة بحجم 200 كيلوبايت، اسأل إذا كان هناك بديل بحجم 5 كيلوبايت. عندما يضيف شخص حساباً متزامناً في معالج أحداث، اسأل إذا كان يمكن تأجيله أو نقله إلى worker.
التقنيات في هذا المقال ليست نظرية. إنها ما فعلته فعلاً، في هذا الموقع، بأرقام حقيقية تدعمها. نتائجك ستختلف — كل موقع مختلف، كل جمهور مختلف، كل بنية تحتية مختلفة. لكن المبادئ عالمية: حمّل أقل، حمّل بذكاء، لا تحجب الخيط الرئيسي.
مستخدموك لن يرسلوا لك رسالة شكر على موقع سريع. لكنهم سيبقون. سيعودون. وGoogle ستلاحظ.