200 حركة أبقت الخيط الرئيسي مشغولًا 98% والصفحة ساكنة، ولم أحذف واحدة
حركة واحدة لا يقدر عليها الـ GPU تجعل المتصفح يعيد بناء الصفحة كلها في كل إطار. الأسباب الخمسة التي فعلت ذلك بموقعي، والحقل في trace كروم الذي يسمّيها، والإصلاح: من 98% إلى 18%.
كيف أُبقي وحدي نظامًا محاسبيًا من 322 ألف سطر صحيحًا: بوابة حفظ واحدة، وقيود مالية لا تُعدَّل، وسجل تدقيق ببصمات متسلسلة، واختبارات على قواعد حقيقية، وتدقيق كشف ما فاتها.
لماذا تُفسد طابعات ESC/POS الحروف العربية، وكيف تطبعها صورةً أحادية البت بعرض الرأس الحقيقي، وخلل الأبعاد الذي حوّل إيصالًا طوله 12 سم إلى رولٍ كامل ملقى على الأرض.
فوترة محطات الكهرباء دون اتصال: معرّف واحد يرافق الدفعة من الهاتف إلى الإيصال، ولماذا لا تُجمع إجماليات الفواتير، وطابور يعرف ما يعيده، وخطأ 500 صنع حلقة لا تنتهي.
على شاشة الكمبيوتر بدت الصفحة مكتملة: رموز تتساقط خلف النص، وطرفية تكتب أوامر حقيقية، وحلقات كود تدور خلف صورتي، وهواتف تتمايل في ثلاثة أبعاد.
على الهاتف، تقطّعت الصفحة نفسها.
توقعت صورة ثقيلة أو حزمة JavaScript منتفخة. ما أراني إياه Chrome كان أغرب: والشاشة لا يلمسها أحد، كان الخيط الرئيسي (main thread) مشغولًا 98% من كل ثانية.
فبدأت أطفئ الحركات واحدة تلو الأخرى بحثًا عن المذنبة. لم يتغير شيء. وتبيّن أن هذا الطريق المسدود أثمن دليل في الرحلة كلها.
في آخر هذا المقال ستعرف لماذا تفرض حركة واحدة ضريبة على كل الحركات الأخرى، وكيف تجعل Chrome يسمّي الحركة المذنبة بنفسه، وستخرج بسكربتين يقيسان ذلك عنك.
الاختبار بسيط: أفتح الصفحة على محاكاة هاتف (390×844، كثافة بكسل 3×، ومعالج أبطأ 4 مرات)، أتركها تُكمل التحميل، لا ألمس شيئًا، ثم أسأل Chrome: كم من كل ثانية قضاها الخيط الرئيسي في تنفيذ المهام؟
محاكاة الهاتف، قبل98%
محاكاة الهاتف، بعد18%
الكمبيوتر، قبل43%
الكمبيوتر، بعد8%
نسبة كل ثانية كان الخيط الرئيسي فيها مشغولًا والصفحة ساكنة
عند 98%، كل نقرة وكل تمرير ينتظر في الطابور خلف عمل لا يراه المستخدم. الصفحة تبدو حيّة، وتُحَسّ ميتة.
وعدد الإطارات في الثانية أخفى هذا كله. معظم حركاتي كانت تعمل على الـ compositor، وهو خيط منفصل ظل يحرّك الطبقات بنعومة بينما الخيط الرئيسي يغرق. عدّاد الـ FPS كان يقيس الخيط السليم.
الرقم يأتي من مقياس واحد في بروتوكول أدوات المطوّر (CDP) اسمه TaskDuration: مجموع الثواني التي قضاها الخيط الرئيسي في المهام. اقرأه، انتظر ثلاث ثوانٍ، اقرأه مجددًا، واقسم. السكربت الكامل، بالمقارنة قبل/بعد والوسيط، تجده قرب النهاية.
وتحذير قبل أي رقم: جهازي كان مشغولًا غالبًا بأعمال أخرى، فالقياس المنفرد يكذب. كل مقارنة في هذا المقال كانت قبل/بعد، بالتناوب، ووسيطَ عدة جولات.
أطفأت الحركات مجموعةً مجموعة. أزلت الـ canvas، فبقيت الصفحة عند 98%. أزلت الحلقات، فبقيت عند 98%.
لا مجموعة بمفردها صنعت فرقًا، فعرفت أنني أسأل السؤال الخطأ. الكلفة تصرفت كرسم ثابت، وحفنة من الحركات كانت تُلزم الصفحة كلها بدفعه.
حركة واحدة تحتاج الخيط الرئيسيحلقة canvas، أو setState، أو background-position
Chrome يجدول إطارًا على الخيط الرئيسيمع كل تحديث للشاشة
الصفحة تؤدي عمل الرسم كاملًاالأنماط، والتخطيط، والرسم، وتحديث الطبقات
النقرات والتمرير تنتظرخلف عمل لا يراه أحد
كيف تفرض حركة واحدة ضريبتها على الصفحة كلها
الحركة المركّبة (composited) طبقة يحرّكها كرت الشاشة وحده: يسلّمها الخيط الرئيسي مرة ثم يهدأ. أما الحركة غير المركّبة فتحتاج الخيط الرئيسي ليصنع إطارًا جديدًا مع كل تحديث للشاشة، وهو إطار كامل: حساب الأنماط، ومتابعة التخطيط، والرسم، وتحديث الطبقات، للصفحة كلها.
هكذا دفعت مئتا حركة، معظمها على الـ GPU أصلًا، هذه الكلفة الثابتة، لأن حفنة منها ظلت تطلب إطارًا. وإزالة مجموعة لا تفيد ما دامت أخرى ما زالت تطلب.
فصار السؤال الحقيقي: ما الذي يجبر الخيط الرئيسي على إطار؟ وضعت قائمة بستة مشتبه بهم واستبدلتهم جميعًا. وحين أعدت اختبار كل منهم وحده، ثبتت التهمة على خمسة وبُرّئ السادس. أولهم ملف يَعِدك في سطره الأول بأنه لا يكلّف شيئًا تقريبًا.
#المشتبهون الستة الذين طاردتهم، وما حلّ محل كل منهم
قبلبعد
canvas يُعاد رسمه من requestAnimationFrame26 عمود رموز يحرّكها transform
setState مع كل حرف يُكتبأربع طبقات متداخلة تتحرك بـ transform
ضوء ينزلق بـ background-positionخط يرسم نفسه بـ scaleX والشفافية
شفافية على عنصر داخل SVGمؤشر HTML موضوع فوق الـ SVG
إطار مخروطي يدور بـ @propertyتدرج يدور تحت قناع لا يتحرك
حركتا transform على عنصر واحدحركة واحدة لكل عنصر متداخل
هكذا قال تعليق الملف القديم، وبدا كلامًا مسؤولًا: مطر الرموز كان canvas شفافًا واحدًا بحجم بكسلات CSS، بسقف 24 إطارًا في الثانية على الهاتف و30 على الكمبيوتر، ويتوقف حين يُخفى التبويب.
هذه حلقته:
ts
const tick = (t: number) => { raf = requestAnimationFrame(tick) if (t - last < frameMs) return // مبكر جدًا: تخطَّ هذا الإطار last = t draw()}
الفخ في سطر التخطي. كل استدعاء من هذه، حتى الذي يعود فورًا، طلبٌ لإطار على الخيط الرئيسي. سقف الإطارات وفّر وقت الرسم، ولم يوفّر شيئًا غيره.
البديل لا يرسم شيئًا أثناء التشغيل: ستة وعشرون عمودًا من الرموز، نص كل عمود يُختار مرة واحدة من بذرة ثابتة (فيتطابق الـ HTML المولَّد مسبقًا مع ما يرسمه المتصفح)، وكل عمود ينزل بـ transform في CSS:
css
.rune-col { position: absolute; top: 0; left: var(--x); width: 1ch; word-break: break-all; /* رمز في كل سطر: عمود */ mask-image: linear-gradient(transparent, #000 80%); /* الذيل يتلاشى */ transform: translate3d(0, -110%, 0); animation: rune-fall var(--dur) linear infinite; animation-delay: var(--delay); /* سالب: يتساقط منذ أول رسم */}@keyframes rune-fall { to { transform: translate3d(0, calc(100vh + 10%), 0); }}@media (max-width: 760px) { .rune-col:nth-child(2n) { display: none; } /* نصف الأعمدة على الهاتف */}
كل عمود يأخذ --x خاصًا به، ومدة بين 11 و24 ثانية، وتأخيرًا سالبًا. الشكل نفسه، وصفر سكربت في كل إطار.
الطرفية كانت تكتب باستدعاء setState على مؤقت: كل 42 إلى 82 ملّي ثانية أثناء الكتابة، وكل 18 أثناء المسح. كل استدعاء يعني render وإعادة حساب أنماط وتخطيطًا، لأن عرض النص يتغير في كل مرة.
الآن الكتابة CSS خالصة. نافذة أعرض من الأمر بحرف واحد تنزلق خطوة بحرف، والنص داخلها ينزلق عكسها بالقدر نفسه، فيظهر كل حرف في مكانه بالضبط، ولا يتحرك شيء سوى transform.
.twتُدخل النافذة، حرفًا في كل خطوة
.tw-windowتقصّ، ثم تنزلق خارجةً لتمسح
.tw-text-outتُرجع النص أثناء المسح
.tw-textتُرجع النص أثناء الكتابة، فتبقى الحروف مكانها
import type { CSSProperties } from 'react'const TYPE_MS = 55const ERASE_MS = 18const HOLD_MS = 1700export function TypeLine({ text }: { text: string }) { const timing = { ['--steps' as string]: text.length + 1, // خطوة لكل حرف، وواحدة للمؤشر ['--type' as string]: `${text.length * TYPE_MS}ms`, ['--hold' as string]: `${HOLD_MS}ms`, ['--erase' as string]: `${text.length * ERASE_MS}ms`, } as CSSProperties // الـ key يعيد تركيب الطبقات، فيبدأ كل أمر جديد حركاته من الصفر. return ( <span key={text} className="tw" style={timing}> <span className="tw-window"> <span className="tw-text-out"> <span className="tw-text">{text}</span> </span> <span className="caret" /> </span> </span> )}
يحتاج خطًا أحادي العرض (monospace)، لأن كل خطوة 1ch. بدّل النص بعد text.length * (TYPE_MS + ERASE_MS) + HOLD_MS وفاصل قصير. أي setState واحد كل بضع ثوانٍ، بعد أن كانت عشرات في الثانية.
تأمّل هذه الطبقات الأربع. أول نسخة CSS كانت أقل منها طبقات، وهنا يكمن السبب السادس.
خط كل عنوان قسم كان يمر عليه ضوء ذهبي: تدرّج أعرض من الخط ثلاث مرات، يتحرك بـ background-position. والـ compositor لا يستطيع تحريك خلفية داخل عنصر، فكان ذلك إعادة رسم في كل إطار، لكل عنوان.
css
/* قبل: إعادة رسم في كل إطار */.eyebrow::before { background: linear-gradient(90deg, var(--gold) 0 35%, var(--gold-fill-2) 50%, var(--gold) 65% 100%); background-size: 300% 100%; animation: rule-scan 3.4s linear infinite;}/* بعد: الخط يرسم نفسه امتدادًا وانكماشًا */.eyebrow::before { transform-origin: left center; animation: rule-draw 3.4s ease-in-out infinite alternate;}html[dir='rtl'] .eyebrow::before { transform-origin: right center; /* يبدأ من الجهة التي يبدأ منها النص */}@keyframes rule-draw { from { transform: scaleX(0.35); opacity: 0.55; } to { transform: none; opacity: 1; }}
تأثير مختلف، خط يتنفس امتدادًا وانكماشًا، ولا يكلّف شيئًا في أي إطار. ولاحظ سطر dir='rtl': في العربية يمتد الخط من اليمين، حيث تبدأ القراءة.
المؤشر تحت حرف M في شعاري كان يومض بـ opacity على path داخل الـ SVG. كنت أظن أن ذلك يعيد رسم الـ SVG في كل إطار، فنقلت الوميض إلى عنصر HTML موضوع بالضبط حيث كان الـ SVG سيرسمه. والأرقام مأخوذة من هندسة الـ SVG نفسها: خط طوله 16 وحدة بنهايتين مستديرتين سُمكهما 2.6، داخل viewBox من 64 وحدة يُرسم بعرض 30px، عرضه (16 + 2.6) × 30 ÷ 64 = 8.7px.
ثم أعدت، من أجل هذا المقال، اختبار كل مشتبه به في صفحة وحده. الشعار القديم يومض وحيدًا فلم يسبب أي إطار على الخيط الرئيسي، لا في Chrome 153 ولا في Edge 155، ولم يسجّل الـ trace أي فشل له. هذا كان بريئًا.
المؤشر بـ HTML باقٍ، فالشفافية على عنصر HTML هي بالضبط الحالة التي بُني الـ compositor لها. أما الدرس فهو ما يستحق البقاء: اختبر كل مشتبه به وحده، وإلا نسبت الفضل لإصلاح لم يفعل شيئًا.
هذا السبب ما كنت لأجده بقراءة الكود أبدًا. خرج من trace في Chrome، ومعه رقم: 64.
في أول طرفية بـ CSS، حمل عنصر واحد الحركتين tw-in (انزلاق للداخل أثناء الكتابة) وtw-out (انزلاق للخارج أثناء المسح)، وtw-out لا تبدأ إلا بعد أن تفرغ tw-in من الحركة. ومع ذلك رفض Chrome تسليم أيٍّ منهما للـ compositor. كلتاهما عملت على الخيط الرئيسي، في كل إطار، طوال ظهور الطرفية على الشاشة.
الرقم 64 قناع بتات (bitmask) من شيفرة Chromium المصدرية (compositor_animations.h)، والبت السادس فيه اسمه kTargetHasIncompatibleAnimations. وChromium يسمح فعلًا بعدة حركات transform على عنصر واحد إن استهدفت كلٌّ منها خاصية مختلفة، كـ rotate في واحدة وscale في أخرى. حركتاي كلتاهما حرّكتا transform.
الحل هو الطبقات التي رأيتها: حركة transform واحدة لكل عنصر، بعمق أربع طبقات متداخلة.
[--spin] و[background-position-x] يأتيان من unsupportedProperties. ولاحظ الثاني: Chrome يسمّي الخاصية المفصّلة (longhand)، لا الاختصار الذي كتبته.
حركتا الطرفية كلتاهما تفشلان بـ 64، ولهذا لم تعمل أيٌّ منهما على الـ GPU.
180 استدعاء FireAnimationFrame في 3 ثوانٍ هي حلقة الـ canvas بسقف إطاراتها: واحد مع كل تحديث للشاشة. السقف لم يُنقص الاستدعاءات قط.
UpdateLayoutTree هو إعادة حساب الأنماط في Chrome. و180 منه في 3 ثوانٍ تعني واحدًا في كل إطار.
والصفحة نفسها بكود CSS "بعد" لم تطبع أي فشل، ولا أي استدعاء إطار، و11 إعادة حساب أنماط فقط في 3 ثوانٍ.
سببان لا يظهران أبدًا كأحداث Animation، لأنهما سكربت: حلقة الـ canvas، والمؤقت خلف setState. FireAnimationFrame يعدّ استدعاءات requestAnimationFrame، وTimerFire يعدّ المؤقتات. وفي صفحة ساكنة ينبغي أن يقترب الاثنان من الصفر.
ضبابية الخلفية فوق خلفية متحركة. الشريط العلوي على الكمبيوتر كان عليه backdrop-filter: blur(18px) saturate(140%) فوق خلفية بشفافية 78%. وخلفه لا تكف الرموز عن التساقط، فكانت الضبابية تُحسب من جديد في كل إطار. رفعت الشريط إلى 94% وحذفت الضبابية، وبهذه النسبة لا يميّز أحد الفرق. أما الهاتف فكانت الضبابية مطفأة فيه أصلًا.
الحركات خارج الشاشة تكلّف شيئًا أيضًا، والصفحة الطويلة تُبقي معظم حركاتها خارج الشاشة. مراقب IntersectionObserver واحد يعلّم كل قسم خارج نطاق الرؤية، وCSS يوقف كل ما بداخله:
الصفحة نفسها، والحركات نفسها، على محاكاة الهاتف نفسها:
98% → 18%
انشغال الخيط الرئيسي والصفحة ساكنة
1.2 → 0.1 ث
زمن الحجب الكلي عند التحميل
67 → 17 ms
أسوأ إطار أثناء التمرير
قبل
بعد
انشغال الخيط الرئيسي والصفحة ساكنة
98%
18%
زمن الحجب الكلي عند التحميل
1.2 ث
0.1 ث
أول ظهور للمحتوى
1.6 ث
0.7 ث
أسوأ إطار أثناء التمرير
67 ms
17 ms
الكمبيوتر، انشغال الخيط الرئيسي والصفحة ساكنة
43%
8%
مكاسب التحميل جاءت من ثلاثة تغييرات أخرى: حذفت framer-motion (42 KB مضغوطة) لصالح بضع حركات CSS. وتوقفت الصفحة الرئيسية عن حزم نصوص دراسات الحالة الكاملة، فنزل السكربت الرئيسي من 416 KB إلى 324 KB. وصارت الخطوط تُحمَّل دون أن تعطّل سكربت التطبيق.
التعليقات
شارك في النقاش
سجّل الدخول بحساب Google لتعجب بالمقالات وتعلّق عليها. ضغطة واحدة تكفي.