تطوير الواجهات والجوال Frontend & Mobile الأدلة التقنية

كيف يرسم المتصفح صفحتك؟ من HTML إلى البكسلات

ماذا يفعل المتصفح بين وصول HTML وظهور الصفحة، ولماذا تتجمد صفحة فيها ألفا صف، وكيف تقيس كل مرحلة في Chrome وتصلحها.

كيف يرسم المتصفح صفحتك؟ من HTML إلى البكسلات

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

الإجابة ليست في JavaScript وإنما في المتصفح نفسه، ففي كل دورة من تلك الحلقة كان المتصفح يعيد حساب مواضع ومقاسات الجدول كله من جديد، أي ما يسمى التخطيط Layout، ألفي مرة متتالية. ولكي تفهم لماذا يحدث هذا، ولماذا لا يظهر في الكود، ولماذا تختفي المشكلة بتغيير ترتيب سطرين، تحتاج أن تعرف ما الذي يفعله المتصفح بين لحظة وصول آخر بايت من ملف HTML ولحظة ظهور البكسلات على الشاشة. وهذا هو موضوع هذا المقال، وهو أول مقال في مرحلة هندسة الواجهات وتطبيقات الويب Frontend، ويبدأ من حيث انتهى مقال HTTP وHTTPS ببساطة، فهناك شرحنا الطريق من الرابط إلى السيرفر وعودة الرد، وما يرافق الطلب من ملفات تعريف الارتباط شرحناه في مقال الجلسات وJWT، وقواعد الطلبات بين المواقع في مقال CORS، أما هنا فنبدأ من لحظة وصول الرد إلى المتصفح.

وسوف نناقش في هذا المقال ما يلي:

  • الصورة الكاملة لمسار الرسم Rendering Pipeline: التحليل Parsing ثم DOM وCSSOM ثم حساب الأنماط والتخطيط والرسم والتركيب، ومن ينفذ كل مرحلة.
  • كيف يتحول نص HTML إلى شجرة DOM، ولماذا يوقف Script عادي المحلل، ولماذا يمنع ملف CSS ظهور الصفحة كلها حتى يصل.
  • شجرة التخطيط Layout Tree وما الذي يحصل فيها على صندوق وما الذي لا يحصل، والفرق بين display: none وvisibility: hidden.
  • التخطيط والرسم والطبقات والتركيب Composite، ولماذا يتحرك عنصر بـ transform دون تقطع بينما يتقطع تحريكه بـ left عندما تكون الصفحة مشغولة.
  • الإطار الواحد Frame وميزانية 16 ملي ثانية، ومتى يجبر JavaScript المتصفح على التخطيط فوراً، وما هو اهتزاز التخطيط Layout Thrashing.
  • تجربة عملية كاملة في Chrome بدون واجهة Headless نقيس فيها كل مرحلة عبر بروتوكول أدوات المطور Chrome DevTools Protocol: شجرة التخطيط الحقيقية، وتأثير ملف بطيء، وصفحة لوحة الطلبات وهي تتجمد ثم بعد إصلاحها، وتتبع أداء Performance Trace لتحريك عنصر.
  • طرق الإصلاح، والأخطاء الشائعة، وقائمة فحص تستخدمها في مشروعك.

لماذا يهم المطور ما يحدث داخل المتصفح؟

في تطبيقات الويب الحديثة يعمل نصف التطبيق تقريباً في جهاز المستخدم وليس في سيرفرك، فالسيرفر يرسل البيانات، والمتصفح هو الذي يبني الواجهة ويرسمها ويستجيب للنقرات، وكل ملي ثانية يقضيها المتصفح في عمل غير ضروري يشعر بها المستخدم مباشرة، والسبب أن معظم هذا العمل يجري على خيط واحد. فالمتصفحات الحديثة مثل Chrome تقسم العمل على عدة عمليات Processes، كما تشرح سلسلة Inside look at modern web browser من فريق Chrome، فهناك عملية المتصفح Browser Process التي تدير الشريط العلوي والشبكة، وعملية الرسم Renderer Process لكل موقع تقريباً، وعملية GPU التي تتعامل مع كرت الشاشة. وداخل عملية الرسم يوجد الخيط الرئيسي Main Thread، وهو الذي يحلل HTML ويبني DOM ويحسب الأنماط ويحسب التخطيط ويسجل أوامر الرسم، وهو نفسه الخيط الذي ينفذ كود JavaScript الخاص بك ويستقبل أحداث النقر ولوحة المفاتيح.

وهذه هي النقطة التي يبنى عليها كل ما سيأتي، فالخيط الرئيسي لا يستطيع أن يفعل شيئين في نفس الوقت، فإذا كان مشغولاً بحساب التخطيط ألفي مرة فلن يستطيع الاستجابة لنقرة المستخدم ولا رسم الإطار التالي، وهذا بالضبط ما حدث في لوحة الطلبات. ولهذا السبب أيضاً تقيس Google تجربة المستخدم بمقاييس مثل زمن الاستجابة للتفاعل INP، وهو الزمن بين نقرة المستخدم وظهور الإطار التالي على الشاشة، وتعتبر القيمة الجيدة 200 ملي ثانية أو أقل، فإذا عرفت ما الذي يشغل الخيط الرئيسي عرفت كيف تحسن هذا الرقم.

وقد يتساءل البعض: هل هذا الكلام خاص بـ Chrome؟ والإجابة أن الأسماء والتفاصيل الداخلية تختلف بين المحركات، فـ Chrome يستخدم محرك Blink وFirefox يستخدم Gecko وSafari يستخدم WebKit، ولكن المراحل نفسها موجودة في الجميع لأن معايير HTML وCSS تفرضها، فالتحليل يتبع خوارزمية مكتوبة في معيار HTML، وترتيب العمل في كل إطار يتبع نموذج حلقة الأحداث Event Loop في نفس المعيار. وفي هذا المقال سوف نستخدم أسماء Chrome لأننا نقيس عليه في التجربة، وسوف تجد نفس الأفكار في أدوات المطور في أي متصفح آخر.

الصورة الكاملة: من البايتات إلى البكسلات

قبل أن ندخل في كل مرحلة، لنأخذ الصورة كاملة من البداية، فعندما يصل رد HTML من السيرفر يمر بالمراحل التالية بالترتيب:

  1. التحليل Parsing: يحول المحلل بايتات HTML إلى رموز Tokens ثم إلى شجرة DOM، ويحول ملفات CSS إلى شجرة CSSOM.
  2. حساب الأنماط Style: يحدد لكل عنصر في DOM القيم النهائية لكل خصائص CSS بعد تطبيق القواعد والوراثة.
  3. التخطيط Layout: يحسب موضع ومقاس كل صندوق على الصفحة، ويسمى في بعض المراجع Reflow.
  4. الرسم Paint: يسجل قائمة أوامر رسم، مثل «ارسم مستطيلاً هنا» و«اكتب هذا النص هناك»، ولا يرسم بكسلات بعد.
  5. التقسيم إلى طبقات والتنقيط Layerize وRaster، ثم التركيب Composite: تقسم الصفحة إلى طبقات، وتحول كل طبقة إلى بكسلات في مربعات صغيرة Tiles، ثم يجمع خيط التركيب Compositor Thread الطبقات في إطار واحد ويرسله إلى الشاشة.

والصورة التالية تبين المسار كاملاً، ولاحظ فيها أن المراحل البرتقالية تجري على الخيط الرئيسي، وأن المراحل الخضراء تجري على خيط آخر ومع ال GPU، وهذا الفرق هو الذي يفسر لماذا تكون بعض التغييرات رخيصة وبعضها مكلفاً:

مخطط من جزأين: في الأعلى ملف index.html يتحول إلى رموز Tokens ثم إلى شجرة DOM، وملف style.css يتحول إلى شجرة CSSOM مع ملاحظة أن تحميل CSS يمنع الرسم وأن Script عادي يوقف المحلل، وفي الأسفل خمس مراحل من اليمين إلى اليسار: حساب الأنماط ثم التخطيط ثم الرسم على الخيط الرئيسي، ثم التقسيم والتنقيط ثم التركيب على خيط التركيب وال GPU، ثم نافذة المتصفح
مسار الرسم: كل تغيير في الصفحة يعيد جزءاً من هذا المسار، والسؤال دائماً من أي مرحلة يبدأ

والفكرة الأهم في هذه الصورة أن المسار لا يجري مرة واحدة عند تحميل الصفحة ثم ينتهي، وإنما يتكرر كلما تغير شيء، فإذا غيرت لون زر أعاد المتصفح حساب الأنماط ثم الرسم، وإذا غيرت عرض عنصر أعاد التخطيط أيضاً، وإذا حركت عنصراً بـ transform فقد يكتفي بالتركيب. ولذلك عندما تقرأ عن أداء الواجهات ستجد أن السؤال الذي يتكرر هو: من أي مرحلة يبدأ المتصفح من جديد بعد هذا التغيير؟ وكلما بدأ من مرحلة أبكر كان العمل أكثر.

تحليل HTML Parsing: من البايتات إلى شجرة DOM

أول ما يصل إلى المتصفح بايتات، فيحولها أولاً إلى حروف حسب الترميز Encoding المعلن في ترويسة Content-Type أو في <meta charset="utf-8">، ثم يمرر الحروف إلى مرحلة تقسيم الرموز Tokenization، وهي آلة حالات State Machine معرفة بالتفصيل في معيار HTML، تقرأ حرفاً بعد حرف وتخرج رموزاً مثل «بداية وسم p» و«نص» و«نهاية وسم p». بعد ذلك تأتي مرحلة بناء الشجرة Tree Construction، فتأخذ الرموز وتبني منها كائنات حقيقية في الذاكرة، وتضع كل كائن تحت أبيه، وبهذا الشكل نحصل على شجرة DOM، اختصاراً لـ Document Object Model.

ولاحظ أن المعيار لا يكتفي بوصف HTML الصحيح، وإنما يحدد بالضبط ماذا يفعل المحلل مع HTML الخاطئ: وسم <p> لم يغلق، أو <div> داخل <table> في مكان غير مسموح، أو وسم نهاية بدون بداية. والسبب تاريخي، فالمتصفحات القديمة كانت تتسامح مع الأخطاء كل منها بطريقته، فكانت الصفحة الواحدة تبني شجرة مختلفة في كل متصفح، فجاء المعيار وكتب طريقة واحدة لمعالجة كل خطأ، ولهذا لا ترى رسالة خطأ في المتصفح عندما تنسى إغلاق وسم، ولكنك قد ترى شجرة DOM مختلفة عما كتبته، وهذا سبب كاف لأن تفتح أدوات المطور وتنظر إلى الشجرة الحقيقية بدلاً من ملف HTML عندما يتصرف عنصر بشكل غريب.

DOM هو الواجهة التي يتعامل معها JavaScript

شجرة DOM ليست نسخة من النص، وإنما مجموعة كائنات لها خصائص ودوال، وهي الواجهة البرمجية التي يتعامل معها JavaScript، كما تشرح مقدمة MDN. فعندما تكتب document.querySelector('.card') فأنت تبحث في هذه الشجرة، وعندما تكتب el.textContent = '...' فأنت تعدل عقدة Node فيها، والمتصفح هو الذي يقرر بعد ذلك متى يعيد الرسم. وفي الشجرة أنواع من العقد، أهمها العناصر Elements مثل div وp، وعقد النص Text Nodes، والتعليقات.

وهنا تفصيل يفاجئ الكثيرين، ففي التجربة العملية بنينا صفحة صغيرة فيها 13 عنصراً فقط، ولكن Chrome أعاد 35 عقدة، والسبب أن المسافات والأسطر الجديدة بين الوسوم في ملف HTML تصبح عقد نص حقيقية في الشجرة، ومعها عقدة المستند نفسه وعقدة نوع المستند Doctype ونصوص العناصر، فكل سطر جديد بين </h2> و<p> هو عقدة. وهذا لا يضر في الغالب، ولكنه يفسر لماذا تعطي childNodes عدداً أكبر من children، ولماذا يكبر حجم DOM في الصفحات الكبيرة أكثر مما تتوقع.

لماذا يوقف Script المحلل Parser ولماذا يمنع CSS الرسم؟

المحلل يقرأ HTML من الأعلى إلى الأسفل، وعندما يصل إلى وسم <script src="app.js"> عادي، أي بدون defer أو async، يتوقف تماماً حتى يحمل الملف وينفذه، ثم يكمل. وقد يتساءل البعض: لماذا لا يكمل المحلل بناء الصفحة وينفذ الكود عندما يصل؟ والإجابة أن الكود قد يغير الصفحة التي يبنيها المحلل نفسه، فالدالة document.write() تكتب HTML جديداً في مكان المحلل الحالي، فلو أكمل المحلل قبل تنفيذ الكود لبنى شجرة خاطئة، ولذلك يسمى هذا النوع من الكود موقف المحلل Parser-blocking.

ولكي لا تضيع هذه المدة يعمل في Chrome والمتصفحات الأخرى ماسح مسبق Preload Scanner، وهو محلل خفيف يقرأ ما بعد الكود المتوقف ويبدأ تحميل الملفات التي يجدها مثل الصور وملفات CSS وJS الأخرى، كما يشرح مقال web.dev عن Preload Scanner وصفحة Speculative parsing في MDN. وهذا يعني أن الملفات التي تكتبها في HTML مباشرة تكتشف مبكراً، أما الملفات التي يضيفها JavaScript بعد تشغيله فلا يراها الماسح، وهذا أحد أسباب بطء الصفحات التي تبني كل شيء من JavaScript.

أما CSS فلا يوقف المحلل، فالمحلل يكمل بناء DOM بينما ملف CSS يحمل، ولكن المتصفح لا يرسم أي شيء حتى يصل ملف CSS الموجود في <head> ويحلل، ولذلك يسمى مانعاً للرسم Render-blocking. والسبب أنه لو رسم الصفحة بدون أنماطها ثم وصل CSS لرأى المستخدم الصفحة عارية لجزء من الثانية ثم تقفز إلى شكلها الحقيقي، وهذه الظاهرة معروفة باسم وميض المحتوى غير المنسق FOUC. وهناك تفصيل آخر، وهو أن CSS يمنع تنفيذ أي Script يأتي بعده حتى يحلل، لأن الكود قد يقرأ نمطاً محسوباً مثل getComputedStyle، فيجب أن تكون الأنماط جاهزة قبل أن يعمل.

متى تستخدم defer أو async أو module؟

لكي لا يوقف الكود المحلل يوجد أكثر من خيار، وكل خيار له سلوك مختلف كما تشرح صفحة عنصر script في MDN:

الطريقةهل يوقف المحلل؟متى ينفذ الكود؟هل يحفظ الترتيب؟متى تستخدمها؟
<script src>نعمفور وصولهنعمنادراً، فقط إذا كان الكود يجب أن يعمل قبل بقية الصفحة
<script defer>لابعد اكتمال التحليل وقبل DOMContentLoadedنعمكود التطبيق العادي الذي يحتاج DOM كاملاً
<script async>لا أثناء التحميل، ويوقفه لحظة التنفيذفور وصولهلاالأكواد المستقلة مثل التحليلات Analytics
<script type="module">لامثل defer افتراضياًنعمالكود الحديث المقسم إلى وحدات

وفي التجربة العملية قسنا الفرق، فجعلنا السيرفر يتأخر 1500 ملي ثانية في إرسال ملف CSS أو JS، وفتحنا نفس الصفحة بأربع طرق، فكانت النتيجة أن المستخدم يرى أول محتوى بعد 1556 ملي ثانية مع CSS مانع، وبعد 32 ملي ثانية فقط عندما حملنا نفس الملف بطريقة لا تمنع الرسم، وبعد 1548 ملي ثانية مع Script عادي في <head>، وبعد 40 ملي ثانية مع defer. والصورة التالية تبين الحالات الأربع:

أربعة خطوط زمنية للصفحة نفسها: مع CSS في head ظهر أول رسم FCP عند 1556 ملي ثانية بينما DOMContentLoaded عند 15، ومع CSS لا يمنع الرسم ظهر أول رسم عند 32، ومع Script عادي ظهر أول رسم عند 1548 وDOMContentLoaded عند 1538، ومع defer ظهر أول رسم عند 40 بينما DOMContentLoaded عند 1535
نفس HTML ونفس الملف البطيء: الفرق كله في طريقة كتابة وسم واحد

لاحظ في الصورة شيئاً مهماً، وهو أن DOMContentLoaded في حالة CSS المانع جاء بعد 15 ملي ثانية فقط، أي أن DOM كان جاهزاً تماماً، ومع ذلك بقيت الشاشة بيضاء 1.5 ثانية لأن المتصفح ينتظر الأنماط. وفي حالة defer العكس، فالمستخدم يرى المحتوى بعد 40 ملي ثانية، ولكن DOMContentLoaded تأخر إلى 1535 لأن المتصفح ينتظر تنفيذ الكود المؤجل قبل إطلاق هذا الحدث. وهذا لا يعني أن تحمل كل ملفات CSS بطريقة لا تمنع الرسم، فالأنماط التي يحتاجها الجزء الظاهر من الصفحة أول مرة يجب أن تصل قبل الرسم، وإلا رأى المستخدم الوميض الذي ذكرناه، وإنما يعني أن تفصل الأنماط الأساسية عن غيرها، وأن تجعل ملف CSS الأساسي صغيراً وسريعاً، كما ينصح تدقيق Lighthouse للملفات المانعة للرسم.

CSSOM وحساب الأنماط Style Recalculation: أي قاعدة تنطبق على أي عنصر؟

بالتوازي مع DOM يبني المتصفح من ملفات CSS شجرة CSSOM، اختصاراً لـ CSS Object Model، وهي تمثل كل القواعد في الملفات ووسوم <style> والأنماط المكتوبة على العناصر نفسها. وبعد أن تكون الشجرتان جاهزتين يأتي حساب الأنماط Style Recalculation، وهو المرحلة التي يمر فيها المتصفح على كل عنصر ويحدد القيمة النهائية لكل خاصية فيه، وهذا يشمل ثلاثة أمور:

  • مطابقة المحددات Selector Matching: أي القواعد تنطبق على هذا العنصر، والمتصفح يطابق المحدد من اليمين إلى اليسار، فالمحدد .card p يبدأ بكل p ثم يصعد ليبحث عن أب له الكلاس card.
  • التتالي Cascade والأولوية Specificity: إذا انطبقت قاعدتان على نفس الخاصية فأيهما تفوز، حسب المصدر والأولوية والترتيب.
  • الوراثة Inheritance والقيم المحسوبة Computed Values: خصائص مثل color وfont-family تورث من الأب، والقيم النسبية مثل 2em تتحول إلى قيم محسوبة.

وتكلفة هذه المرحلة تكبر مع عدد العناصر وعدد القواعد وتعقيد المحددات، ولكنها في أغلب الصفحات أرخص بكثير من التخطيط، وفي تتبع الأداء في Chrome تظهر باسم Recalculate Style، واسمها الداخلي في أحداث التتبع UpdateLayoutTree، وسوف نراها بهذا الاسم في مخرجات التجربة. والنقطة العملية هنا أن تغيير كلاس على العنصر body قد يجبر المتصفح على إعادة حساب الأنماط لكل عناصر الصفحة، لأن أي محدد يبدأ بـ body.dark قد ينطبق على أي عنصر تحته، بينما تغيير كلاس على عنصر صغير في أسفل الشجرة يؤثر عليه وعلى أبنائه فقط.

شجرة التخطيط Layout Tree: ما الذي يحصل على صندوق؟

بعد حساب الأنماط يبني المتصفح شجرة ثالثة، وهي شجرة التخطيط Layout Tree، وكانت تسمى في المراجع القديمة شجرة الرسم Render Tree كما في مقال web.dev عن بناء Render Tree. وهذه الشجرة ليست نسخة من DOM، فهي تحتوي فقط على ما سيأخذ مكاناً على الصفحة، وفيها ما ليس في DOM أصلاً. وفي التجربة العملية طلبنا من Chrome عبر الأمر DOMSnapshot.captureSnapshot قائمة كائنات التخطيط Layout Objects لصفحة صغيرة فيها بطاقة طلب، والصورة التالية تبين الشجرتين جنباً إلى جنب:

على اليمين شجرة DOM: html وتحته head وmeta وtitle وstyle بإطار أحمر متقطع لأنها لا تظهر، ثم body وdiv.card وh2 وp.status وp.hidden-note بإطار أحمر لأنها display none، وp.ghost مع visibility hidden، وscript بإطار أحمر، وعلى اليسار شجرة التخطيط: LayoutView بمقاس 1000×700 ثم html وbody وdiv.card بمقاس 404×189 وh2 وp.status وتحته before بلون أخضر لأنه غير موجود في DOM ونص تم الشحن، ثم p.ghost بالرمادي لأنه يحجز مكانه
head وscript والعنصر المخفي بـ display: none لا تحصل على صندوق، بينما ::before يحصل على صندوق وهو غير موجود في DOM

في الصورة أعلاه لاحظ التالي:

  • العناصر head وmeta وtitle وstyle وscript ليس لها صندوق، لأن قيمة display الافتراضية لها none في أنماط المتصفح نفسه.
  • الفقرة p.hidden-note عليها display: none، فهي موجودة في DOM ويمكن أن يقرأها JavaScript، ولكنها غير موجودة في شجرة التخطيط، فلا تأخذ مكاناً ولا يرسم منها شيء.
  • الفقرة p.ghost عليها visibility: hidden، فهي في شجرة التخطيط بمقاس 360×31، أي أنها تحجز مكانها وتدفع ما بعدها، ولكن المتصفح لا يرسمها، وهذا هو الفرق العملي بين الخاصيتين: الأولى تزيل العنصر من التخطيط، والثانية تخفيه فقط.
  • العنصر الزائف ::before الذي يضيف النقطة الخضراء قبل «تم الشحن» غير موجود في DOM، فلا تستطيع أن تصل إليه بـ querySelector، ولكنه حصل على صندوق بمقاس 16×24 في شجرة التخطيط.

ولهذا الفرق أثر خارج الأداء أيضاً، فقارئات الشاشة تبني شجرة سهولة الوصول Accessibility Tree من DOM ومن الأنماط معاً، فالعنصر المخفي بـ display: none لا تقرؤه، والنص الذي تضيفه بـ content في ::before قد تقرؤه وقد لا تقرؤه بحسب المتصفح، وقد شرحنا هذا الجانب في مقال سهولة الوصول Accessibility.

وكل صندوق في شجرة التخطيط يتبع نموذج الصندوق Box Model المعروف: المحتوى Content ثم الحشو Padding ثم الحد Border ثم الهامش Margin. والصورة التالية لقطة حقيقية من Chrome بعد أن طلبنا منه عبر الأمر Overlay.highlightNode أن يظلل بطاقة الطلب كما يفعل لوح العناصر Elements في أدوات المطور:

لقطة من Chrome لبطاقة طلب رقم 1042 مكتوب فيها تم الشحن، والبطاقة مظللة بثلاث طبقات: الهامش بالبرتقالي والحشو بالأخضر والمحتوى بالأزرق، وتحتها نافذة معلومات تقول div#order.card بمقاس 404 × 188.58 والخط 18px system-ui والهامش 20px والحشو 16px 20px، وقسم سهولة الوصول فيه Role generic
ما يرسمه Chrome فوق العنصر عندما تمرر المؤشر عليه في أدوات المطور: الهامش بالبرتقالي والحشو بالأخضر والمحتوى بالأزرق

لاحظ أن عرض البطاقة في نافذة المعلومات 404 بكسل، مع أننا كتبنا في CSS أن العرض 360، والسبب أن width في نموذج الصندوق الافتراضي content-box يحدد عرض المحتوى فقط، فيضاف إليه الحشو 20 من كل جهة والحد 2 من كل جهة، أي 360 + 40 + 4 = 404. ولاحظ أيضاً أن الارتفاع 188.58، أي أن التخطيط يحسب بأجزاء البكسل، وهذه الكسور تأتي من ارتفاع السطر line-height: 1.7 مع خط بحجم 18 بكسل.

التخطيط Layout: حساب المواضع والمقاسات

التخطيط هو المرحلة التي يحسب فيها المتصفح المكان الدقيق لكل صندوق، أي الإحداثيات والعرض والارتفاع. والقاعدة العامة في التدفق العادي Normal Flow أن العرض ينزل من الأب إلى الابن، فالأب يقول لابنه «لديك 952 بكسلاً»، والارتفاع يصعد من الابن إلى الأب، فالابن يقول لأبيه «احتجت 31 بكسلاً»، ولذلك يمر التخطيط على الشجرة من الأعلى إلى الأسفل ثم يعود. وهذه القاعدة تتعقد في Flexbox وGrid والجداول، لأن مقاس العنصر فيها قد يعتمد على إخوته، وسوف نعود إلى الجداول بعد قليل لأنها سبب جزء من مشكلة لوحة الطلبات.

ولكي لا يعيد المتصفح تخطيط الصفحة كلها مع كل تغيير صغير، يحتفظ بعلامة على كل صندوق تقول إنه يحتاج إلى إعادة تخطيط، وتسمى العلامة المتسخة Dirty Bit، فعندما تغير عرض عنصر يضع المتصفح العلامة عليه وعلى آبائه، وفي التخطيط التالي يعيد حساب الصناديق المعلمة فقط وما يتأثر بها، وهذا يسمى التخطيط التدريجي Incremental Layout. ولكن الكلمة المهمة هنا «وما يتأثر بها»، ففي التجربة العملية سجلنا تتبع أداء لدالة لوحة الطلبات، فكان في حدث التخطيط الواحد أن عدد الصناديق المتسخة dirtyObjects كان 9 فقط، بينما عدد الصناديق الكلي totalObjects كان 16015، وجذر التخطيط layoutRoot كان المستند كله #document، واستغرق الحدث الواحد قرابة 10 ملي ثانية. والسبب أن الجدول في وضعه الافتراضي table-layout: auto يحسب عرض أعمدته من محتوى كل الخلايا، كما توضح صفحة table-layout في MDN، فتغيير شريط واحد في صف واحد قد يغير عرض العمود، فيحتاج المتصفح أن يعيد النظر في الجدول كله.

وفي Chrome تغير محرك التخطيط في السنوات الأخيرة إلى LayoutNG ضمن مشروع RenderingNG، ومن أهم أفكاره أن نتيجة التخطيط شجرة أجزاء Fragment Tree غير قابلة للتعديل، تحفظ بحيث يمكن إعادة استخدام نتيجة صندوق لم تتغير مدخلاته، كما تشرح صفحة هياكل البيانات في RenderingNG. وهذا يقلل العمل في كثير من الحالات، ولكنه لا يغير القاعدة الأساسية: إذا كانت مدخلات التخطيط تتغير مع كل سطر من الكود، فسوف يعيد المتصفح الحساب مع كل سطر.

الرسم Paint: قائمة أوامر وليس بكسلات

بعد أن يعرف المتصفح مكان كل صندوق، يأتي الرسم، والاسم هنا مضلل قليلاً، فالرسم في Chrome لا ينتج بكسلات، وإنما ينتج قائمة أوامر رسم Display List، مثل: ارسم مستطيلاً بلون كذا عند هذه الإحداثيات، ثم اكتب هذا النص بهذا الخط، ثم ارسم ظلاً حول هذا الصندوق. والمهم في هذه المرحلة ترتيب الرسم Paint Order، فالعنصر الذي يظهر فوق غيره يجب أن يرسم بعده، والترتيب يحدده سياق التكديس Stacking Context وقيم z-index والعناصر ذات الموضع position وغيرها.

وبعض الخصائص لا تغير التخطيط ولكنها تحتاج إعادة رسم، مثل color وbackground-color وbox-shadow وborder-radius وvisibility، فإذا غيرت لون خلفية زر عند تمرير المؤشر عليه يعيد المتصفح حساب الأنماط ثم الرسم دون التخطيط. وتكلفة الرسم تعتمد على المساحة المرسومة وعلى تعقيد الأوامر، فظل كبير مع تمويه Blur على مساحة واسعة أغلى بكثير من لون خلفية بسيط.

الطبقات Layers والتنقيط Raster والتركيب Composite

بعد قائمة الرسم يقسم المتصفح الصفحة إلى طبقات Layers، فكل طبقة تحتوي جزءاً من الصفحة يمكن تحريكه أو تغيير شفافيته وحده، ثم تحول كل طبقة إلى بكسلات في مرحلة التنقيط Raster، وتقسم الطبقة الكبيرة إلى مربعات صغيرة Tiles لكي يحول منها ما يظهر على الشاشة أولاً، وفي Chrome يجري التنقيط على خيوط أخرى وعلى ال GPU، وليس على الخيط الرئيسي. وأخيراً يأتي التركيب Composite، فيأخذ خيط التركيب الطبقات الجاهزة ويضع كل واحدة في موضعها مع التحويلات Transforms والشفافية، ثم يرسل الإطار إلى عملية GPU لتعرضه.

والفكرة التي تجعل هذه المرحلة مهمة للمطور أن خيط التركيب يستطيع أن يحرك طبقة أو يغير شفافيتها دون أن يسأل الخيط الرئيسي، وبالتالي إذا كان الخيط الرئيسي مشغولاً بكود JavaScript ثقيل يبقى التمرير والحركات المعتمدة على transform وopacity تعمل، وهذا ما يقصده مقال web.dev عن الخصائص التي يكفيها التركيب. أما إذا حركت عنصراً بتغيير left أو top أو width فكل إطار من الحركة يحتاج حساب أنماط وتخطيطاً ورسماً على الخيط الرئيسي، وسوف نرى الفرق بالأرقام في التجربة.

ولكن الطبقات ليست مجانية، فكل طبقة تحتاج ذاكرة لبكسلاتها، وطبقة بحجم الشاشة على شاشة عالية الدقة تأخذ عدة ميغابايتات من ذاكرة ال GPU، ولذلك لا تقم بإضافة will-change: transform على كل عناصر الصفحة لتسريعها، والسبب أن كل عنصر سيحصل على طبقة، فتنفد ذاكرة ال GPU على الأجهزة الضعيفة وتصبح الصفحة أبطأ، وصفحة will-change في MDN تحذر من ذلك صراحة وتصفه بأنه الحل الأخير وليس الأول.

الإطار الواحد Frame وميزانية 16 ملي ثانية

الشاشة العادية تعرض 60 إطاراً في الثانية، أي إطاراً كل 16.7 ملي ثانية تقريباً، وبعض الشاشات تعرض 120 إطاراً فتصبح المدة 8.3 ملي ثانية. وفي كل إطار يحتاج المتصفح أن ينفذ ما عليه من JavaScript، ثم يحسب الأنماط والتخطيط والرسم إذا تغير شيء، ثم يركب الإطار، وكل ذلك داخل هذه الميزانية، وإذا تجاوزها فاته موعد العرض فيرى المستخدم تقطعاً في الحركة Jank. ويذكر مقال web.dev عن أداء الرسم أن المتصفح يحتاج جزءاً من هذه الميزانية لعمله الداخلي، فينصح أن ينتهي عملك في حدود 10 ملي ثانية.

وترتيب العمل داخل الإطار يحدده نموذج حلقة الأحداث في معيار HTML: ينفذ المتصفح مهمة Task من الطابور، مثل معالج نقرة أو مؤقت، ثم المهام الدقيقة Microtasks مثل ردود Promises، ثم إذا حان وقت إطار جديد ينفذ دوال requestAnimationFrame، ثم يحسب الأنماط والتخطيط ويرسم. وتفاصيل الطوابير والمهام الدقيقة موضوع مقال مستقل في هذه المرحلة عن حلقة الأحداث في JavaScript، أما هنا فيكفيك أمر واحد: المتصفح في الحالة الطبيعية لا يحسب التخطيط بعد كل سطر تكتبه، وإنما يجمع التغييرات ويحسب التخطيط مرة واحدة قبل الرسم، وهذا التأجيل هو ما يجعل الصفحة سريعة، ولكن هناك طريقة تجبره على التخلي عنه.

متى يجبر JavaScript المتصفح على التخطيط Layout فوراً؟

لنفرض أنك كتبت السطرين التاليين:

bar.style.width = '120px';            // كتابة: التخطيط الآن قديم
const w = cell.offsetWidth;           // قراءة: يجب أن تكون القيمة صحيحة الآن

السطر الأول لا يحسب شيئاً، وإنما يضع علامة على أن التخطيط قديم، ثم يأتي السطر الثاني ويطلب عرض الخلية، وهذه القيمة يجب أن تكون صحيحة لحظة القراءة، لأن offsetWidth تعيد العرض بعد التخطيط، ولا يستطيع المتصفح أن يعيد قيمة قديمة، فيضطر أن يحسب التخطيط فوراً في منتصف كودك، قبل أن يكمل السطر التالي، وهذا يسمى التخطيط الإجباري المتزامن Forced Synchronous Layout. ومرة واحدة لا تضر، فهي نفس العمل الذي كان المتصفح سيفعله قبل الرسم، ولكن المشكلة عندما تتكرر القراءة والكتابة داخل حلقة، فكل قراءة تأتي بعد كتابة، وكل كتابة تجعل التخطيط قديماً من جديد، فيحسب المتصفح التخطيط في كل دورة، وهذا هو اهتزاز التخطيط Layout Thrashing كما يسميه مقال web.dev عن تجنب التخطيط الكبير والاهتزاز.

والخصائص والدوال التي تجبر التخطيط أكثر مما يتوقع الكثيرون، وأشهرها:

  • المقاسات والمواضع: offsetWidth وoffsetHeight وoffsetTop وclientWidth وscrollWidth وgetBoundingClientRect().
  • التمرير: قراءة scrollTop وscrollY، واستدعاء scrollIntoView().
  • الأنماط المحسوبة: getComputedStyle() عند قراءة خاصية تعتمد على التخطيط مثل width.
  • النص: قراءة innerText، لأنها تعتمد على ما هو ظاهر فعلاً، بعكس textContent.
  • التركيز: focus()، لأن المتصفح قد يحتاج أن يمرر الصفحة إلى العنصر.

ولاحظ أن القراءة وحدها لا تضر، والكتابة وحدها لا تضر، والضرر يأتي من التبادل بينهما، وهذه هي بالضبط مشكلة لوحة الطلبات، وفي القسم التالي سوف نقيسها بالأرقام.

التجربة العملية Lab: نقيس كل مرحلة في Chrome

الآن سوف نقوم بقياس كل ما ذكرناه على متصفح حقيقي، والطريقة التي نستخدمها هي نفس الطريقة التي تعمل بها أدوات المطور في Chrome، وهي بروتوكول أدوات المطور Chrome DevTools Protocol، وهو بروتوكول رسائل JSON عبر WebSocket يتحكم به أي برنامج في المتصفح، وتعتمد عليه أدوات مثل Puppeteer، ويستخدمه Playwright مع Chromium كما رأينا في مقال اختبارات E2E مع Playwright. وفي هذه التجربة لا نحتاج أي مكتبة خارجية، فقط Node.js وChrome المثبت على الجهاز، وأوامر من أربعة أجزاء Domains في البروتوكول:

  • DOMSnapshot: يعيد شجرة DOM مع كائنات التخطيط ومقاساتها في استدعاء واحد.
  • Performance: يعيد عدادات داخلية مثل عدد مرات التخطيط LayoutCount ومدته LayoutDuration وعدد مرات حساب الأنماط RecalcStyleCount.
  • Tracing: يسجل تتبع أداء كاملاً، وهو نفس الملف الذي يسجله لوح الأداء Performance في أدوات المطور.
  • Overlay: يرسم فوق الصفحة ما ترسمه أدوات المطور، مثل نموذج الصندوق ومناطق إعادة الرسم وحدود الطبقات.

الملفات وطريقة التشغيل

التجربة من ملفين: سيرفر صغير يخدم صفحات الاختبار، ويستطيع أن يتأخر عمداً في إرسال ملف CSS أو JS، وبرنامج يشغل Chrome بدون واجهة ويقيس. والسيرفر كما يلي:

// Small static server for the lab pages: node server.mjs [port]
//   /slow.css?ms=1500  answers a stylesheet after a delay (render-blocking test)
//   /slow.js?ms=1500   answers a script after a delay (parser-blocking test)
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { dirname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';

const DIR = join(dirname(fileURLToPath(import.meta.url)), 'pages');
const PORT = Number(process.argv[2] || 19340);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript' };

createServer(async (req, res) => {
    const url = new URL(req.url, `http://localhost:${PORT}`);
    const ms = Number(url.searchParams.get('ms') || 0);
    if (url.pathname === '/slow.css') {
        await sleep(ms);
        res.writeHead(200, { 'content-type': 'text/css', 'cache-control': 'no-store' });
        return res.end('body { background: #FFF8F3; }');
    }
    if (url.pathname === '/slow.js') {
        await sleep(ms);
        res.writeHead(200, { 'content-type': 'text/javascript', 'cache-control': 'no-store' });
        return res.end('window.slowScriptRanAt = performance.now();');
    }
    const file = normalize(join(DIR, url.pathname === '/' ? 'index.html' : url.pathname));
    if (!file.startsWith(DIR)) { res.writeHead(403); return res.end(); }
    try {
        const body = await readFile(file);
        res.writeHead(200, { 'content-type': TYPES[file.slice(file.lastIndexOf('.'))] || 'application/octet-stream', 'cache-control': 'no-store' });
        res.end(body);
    } catch { res.writeHead(404); res.end('not found'); }
}).listen(PORT, '127.0.0.1', () => console.log(`lab server on http://127.0.0.1:${PORT}/`));

أما البرنامج الذي يقيس فيشغل Chrome عبر ملف مساعد صغير في مستودعنا اسمه browser.mjs، وهو يطلق Chrome بالخيار --headless=new و--remote-debugging-port ويتصل به عبر WebSocket ويعيد نتيجة كل أمر، وأي أداة تتحدث البروتوكول تقوم بنفس العمل. والجزء الأول من البرنامج كما يلي:

// Browser internals lab: node content/articles/browser-internals-dom/lab/run.mjs
// Starts the page server (19340) and headless Chrome (DevTools port 19341) through scripts/lab/browser.mjs,
// then measures with the DevTools Protocol: DOM vs layout tree, render-blocking resources, layout thrashing,
// and what an animation costs on the main thread (performance trace).
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { launch } from '../../../../scripts/lab/browser.mjs';
import sharp from 'sharp';

const HERE = dirname(fileURLToPath(import.meta.url));
const IMG = resolve(HERE, '..', 'images');
const OUT = join(HERE, 'out');
mkdirSync(OUT, { recursive: true });
const PORT = 19340, DEVTOOLS = 19341, BASE = `http://127.0.0.1:${PORT}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const only = process.argv[2];
const want = (s) => !only || only === s;

const server = spawn(process.execPath, [join(HERE, 'server.mjs'), String(PORT)], { stdio: 'ignore' });
await sleep(500);
const b = await launch({ port: DEVTOOLS, width: 1000, height: 700 });

// A second DevTools connection to the browser target, to receive tracing events (browser.mjs only returns command results)
async function browserSession() {
    const { webSocketDebuggerUrl } = await (await fetch(`http://127.0.0.1:${DEVTOOLS}/json/version`)).json();
    const ws = new WebSocket(webSocketDebuggerUrl);
    await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
    let seq = 0; const pending = new Map(); const listeners = [];
    ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id) { pending.get(m.id)?.(m); pending.delete(m.id); } else listeners.forEach((f) => f(m)); };
    const send = (method, params = {}) => new Promise((res, rej) => { const id = ++seq; pending.set(id, (m) => (m.error ? rej(new Error(m.error.message)) : res(m.result))); ws.send(JSON.stringify({ id, method, params })); });
    return { send, on: (f) => listeners.push(f), close: () => ws.close() };
}

async function trace(session, fn) {
    const events = [];
    let done;
    const finished = new Promise((r) => (done = r));
    session.on((m) => { if (m.method === 'Tracing.dataCollected') events.push(...m.params.value); if (m.method === 'Tracing.tracingComplete') done(); });
    await session.send('Tracing.start', { transferMode: 'ReportEvents', traceConfig: { includedCategories: ['devtools.timeline', 'disabled-by-default-devtools.timeline'] } });
    await fn();
    await session.send('Tracing.end');
    await finished;
    return events;
}
// Main-thread work by event name in the renderer of our page
function summarise(events, names) {
    const rows = {};
    for (const n of names) rows[n] = { count: 0, ms: 0 };
    for (const e of events) {
        if (!rows[e.name]) continue;
        if (e.ph === 'X') { rows[e.name].count++; rows[e.name].ms += (e.dur || 0) / 1000; }
        else if (e.ph === 'B' || e.ph === 'I' || e.ph === 'i') rows[e.name].count++;
    }
    return rows;
}
const metrics = async () => Object.fromEntries((await b.send('Performance.getMetrics')).metrics.map((m) => [m.name, m.value]));

في الكود أعلاه لاحظ التالي:

  • الدالة browserSession تفتح اتصالاً ثانياً مع Chrome على مستوى المتصفح كله، والسبب أن أحداث التتبع لا تأتي كرد على أمر، وإنما كرسائل متتالية Tracing.dataCollected ثم رسالة Tracing.tracingComplete في النهاية، والملف المساعد يعيد ردود الأوامر فقط، فاحتجنا اتصالاً يستمع للأحداث.
  • الدالة trace تفعل التتبع بالفئتين devtools.timeline وdisabled-by-default-devtools.timeline، وهما الفئتان اللتان يستخدمهما لوح الأداء في أدوات المطور، فتحصل على نفس الأحداث التي تراها هناك، ثم تحفظ الملف بصيغة JSON يمكنك فتحه في لوح الأداء بالسحب والإفلات.
  • الدالة summarise تجمع الأحداث بالاسم، وتعد الأحداث الكاملة ph: 'X' مع مدتها، والأحداث اللحظية ph: 'I' بدون مدة.
  • الدالة metrics تحول رد Performance.getMetrics إلى كائن، فنأخذ القيم قبل الكود وبعده ونطرح، فنعرف كم مرة حسب المتصفح التخطيط بسبب هذا الكود وحده.

ولتشغيل التجربة كاملة:

node content/articles/browser-internals-dom/lab/run.mjs

والسطر الأول من المخرج يبين الإصدار الذي قسنا عليه:

== Chrome: Chrome/156.0.8078.49 | V8 15.6.75.19

الخطوة الأولى: DOM مقابل شجرة التخطيط Layout Tree

الصفحة الأولى بطاقة طلب صغيرة فيها كل الحالات التي تحدثنا عنها، أي عنصر مخفي بـ display: none، وعنصر مخفي بـ visibility: hidden، وعنصر زائف ::before:

<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<title>طلب 1042</title>
<style>
  body { font: 18px/1.7 system-ui, sans-serif; margin: 24px; }
  .card { border: 2px solid #E95420; border-radius: 12px; padding: 16px 20px; margin: 20px; width: 360px; }
  .card h2 { margin: 0 0 8px; font-size: 22px; }
  .status::before { content: "● "; color: #1F8A55; }
  .hidden-note { display: none; }
  .ghost { visibility: hidden; }
</style>
</head>
<body>
  <div class="card" id="order">
    <h2>طلب رقم 1042</h2>
    <p class="status">تم الشحن</p>
    <p class="hidden-note">ملاحظة داخلية للفريق</p>
    <p class="ghost">مكان محجوز</p>
  </div>
  <script>window.ready = true;</script>
</body>
</html>

والجزء الذي يقيسها من البرنامج يطبع شجرة DOM من DOM.getDocument، ثم يطلب لقطة DOMSnapshot.captureSnapshot ويطبع كل كائن تخطيط مع قيم display وvisibility ومقاس الصندوق، ثم العناصر التي ليس لها كائن تخطيط:

    if (want('tree')) {
        console.log('\n== 1) DOM tree vs layout tree (tree.html)');
        await b.goto(`${BASE}/tree.html`, { wait: 500 });
        const { root } = await b.send('DOM.getDocument', { depth: -1 });
        const print = (n, d = 0) => {
            if (n.nodeType === 1) {
                const cls = (n.attributes || []).reduce((a, v, i, arr) => (v === 'class' ? arr[i + 1] : a), '');
                console.log(`${'  '.repeat(d)}<${n.localName}${cls ? '.' + cls.replace(/ /g, '.') : ''}>`);
            }
            (n.children || []).forEach((c) => print(c, d + 1));
        };
        print(root, -1);
        const snap = await b.send('DOMSnapshot.captureSnapshot', { computedStyles: ['display', 'visibility'] });
        const doc = snap.documents[0];
        const str = (i) => (i >= 0 ? snap.strings[i] : '');
        const name = (i) => str(doc.nodes.nodeName[i]);
        const parentCls = (i) => { const p = doc.nodes.parentIndex[i]; const a = doc.nodes.attributes[p] || []; for (let k = 0; k < a.length; k += 2) if (str(a[k]) === 'class') return '.' + str(a[k + 1]); return name(p); };
        const elementCount = doc.nodes.nodeType.filter((t) => t === 1).length;
        console.log(`DOM nodes: ${doc.nodes.nodeName.length} (elements: ${elementCount}), layout objects: ${doc.layout.nodeIndex.length}`);
        doc.layout.nodeIndex.forEach((ni, k) => {
            const [x, y, w, h] = doc.layout.bounds[k].map((v) => Math.round(v));
            const st = doc.layout.styles[k].map(str); st[0] ||= "(inherits)"; st[1] ||= "";
            const label = name(ni) === '#text' ? `#text "${str(doc.layout.text[k]).trim().slice(0, 24)}" in ${parentCls(ni)}` : name(ni);
            console.log(`  layout: ${label.padEnd(44)} display=${st[0].padEnd(6)} visibility=${st[1].padEnd(7)} box=${w}x${h} at (${x},${y})`);
        });
        const inLayout = new Set(doc.layout.nodeIndex);
        const missing = doc.nodes.nodeName.map((n, i) => [n, i]).filter(([, i]) => doc.nodes.nodeType[i] === 1 && !inLayout.has(i)).map(([n, i]) => {
            const a = doc.nodes.attributes[i] || []; let c = ''; for (let k = 0; k < a.length; k += 2) if (str(a[k]) === 'class') c = '.' + str(a[k + 1]);
            return str(n) + c;
        });
        console.log('elements with no layout object:', missing.join(', '));

والمخرج سوف يكون كما يلي:

== 1) DOM tree vs layout tree (tree.html)
<html>
  <head>
    <meta>
    <title>
    <style>
  <body>
    <div.card>
      <h2>
      <p.status>
      <p.hidden-note>
      <p.ghost>
    <script>
DOM nodes: 35 (elements: 13), layout objects: 12
  layout: #document                                    display=(inherits) visibility=        box=1000x700 at (0,0)
  layout: HTML                                         display=block  visibility=visible box=1000x237 at (0,0)
  layout: BODY                                         display=block  visibility=visible box=952x189 at (24,24)
  layout: DIV                                          display=block  visibility=visible box=404x189 at (552,24)
  layout: H2                                           display=block  visibility=visible box=360x37 at (574,42)
  layout: #text "طلب رقم 1042" in H2                   display=block  visibility=visible box=140x30 at (794,45)
  layout: P                                            display=block  visibility=visible box=360x31 at (574,97)
  layout: ::before                                     display=inline visibility=visible box=16x24 at (918,100)
  layout: ::before                                     display=inline visibility=visible box=16x24 at (918,100)
  layout: #text "تم الشحن" in .status                  display=block  visibility=visible box=70x24 at (849,100)
  layout: P                                            display=block  visibility=hidden  box=360x31 at (574,146)
  layout: #text "مكان محجوز" in .ghost                 display=block  visibility=hidden  box=89x24 at (845,149)
elements with no layout object: HEAD, META, TITLE, STYLE, P.hidden-note, SCRIPT

في المخرج أعلاه لاحظ التالي:

  • شجرة DOM فيها 13 عنصراً، وهي نفس ما كتبناه، ولكن عدد العقد 35 لأن المسافات بين الوسوم أصبحت عقد نص كما ذكرنا في قسم DOM.
  • أول كائن تخطيط هو المستند نفسه بمقاس النافذة 1000x700، وهو ما يسميه Chrome في الداخل LayoutView، وبعده HTML وBODY.
  • البطاقة DIV عرضها 404 وموضعها الأفقي 552، أي أنها في الجهة اليمنى من النافذة، والسبب أن الصفحة dir="rtl"، فبدأ التخطيط من اليمين، وعرض BODY 952 لأن الهامش 24 من كل جهة.
  • العنصر ::before ظهر مرتين بنفس المقاس 16x24، والسبب أن العنصر الزائف ينتج صندوقاً له، وصندوقاً آخر للنص الذي بداخله وهو «● »، وكلاهما مرتبط بنفس عقدة العنصر الزائف في اللقطة.
  • الفقرة المخفية بـ visibility: hidden موجودة بمقاس 360x31 وقيمة visibility=hidden، ونصها «مكان محجوز» موجود أيضاً بمقاس، أي أن المتصفح حسب تخطيطه كاملاً ثم لم يرسمه.
  • السطر الأخير يؤكد أن HEAD وMETA وTITLE وSTYLE وSCRIPT والفقرة P.hidden-note ليس لها كائن تخطيط.

وفي نهاية هذه الخطوة يظلل البرنامج البطاقة بالأمر Overlay.highlightNode ويأخذ لقطة الشاشة التي رأيتها في قسم شجرة التخطيط.

الخطوة الثانية: ملف CSS بطيء وملف Script بطيء

في هذه الخطوة نفتح أربع صفحات متطابقة في محتواها، والفرق الوحيد في وسم واحد داخل <head>، والسيرفر يتأخر 1500 ملي ثانية في إرسال الملف:

<!-- blocking-css.html -->
<link rel="stylesheet" href="/slow.css?ms=1500">
<!-- async-css.html -->
<link rel="stylesheet" href="/slow.css?ms=1500" media="print" onload="this.media='all'">
<!-- blocking-js.html -->
<script src="/slow.js?ms=1500"></script>
<!-- defer-js.html -->
<script src="/slow.js?ms=1500" defer></script>

والبرنامج يقرأ القيم من واجهات التوقيت في الصفحة نفسها، أي performance.getEntriesByType('navigation') لوقت انتهاء HTML ووقت DOMContentLoaded ووقت load، وfirst-contentful-paint لوقت أول رسم فيه محتوى FCP:

    if (want('blocking')) {
        console.log('\n== 2) render-blocking CSS and parser-blocking JS (resource delayed 1500 ms)');
        for (const page of ['blocking-css', 'async-css', 'blocking-js', 'defer-js']) {
            await b.goto(`${BASE}/${page}.html`, { wait: 2200 });
            const r = await b.evaluate(`(() => {
                const nav = performance.getEntriesByType('navigation')[0];
                const fcp = performance.getEntriesByName('first-contentful-paint')[0];
                return { responseEnd: nav.responseEnd, dcl: nav.domContentLoadedEventStart, load: nav.loadEventStart, fcp: fcp ? fcp.startTime : null, slowJs: window.slowScriptRanAt ?? null };
            })()`);
            const f = (v) => (v == null ? '-' : v.toFixed(0).padStart(5));
            console.log(`${page.padEnd(13)} html done ${f(r.responseEnd)} ms | first paint ${f(r.fcp)} ms | DOMContentLoaded ${f(r.dcl)} ms | load ${f(r.load)} ms | slow.js ran ${f(r.slowJs)} ms`);
        }
    }

والمخرج سوف يكون كما يلي:

== 2) render-blocking CSS and parser-blocking JS (resource delayed 1500 ms)
blocking-css  html done     8 ms | first paint  1556 ms | DOMContentLoaded    15 ms | load  1538 ms | slow.js ran - ms
async-css     html done     4 ms | first paint    32 ms | DOMContentLoaded    11 ms | load  1530 ms | slow.js ran - ms
blocking-js   html done     5 ms | first paint  1548 ms | DOMContentLoaded  1538 ms | load  1539 ms | slow.js ran  1538 ms
defer-js      html done    13 ms | first paint    40 ms | DOMContentLoaded  1535 ms | load  1535 ms | slow.js ran  1535 ms

في المخرج أعلاه لاحظ التالي:

  • عمود html done بين 4 و13 ملي ثانية في كل الحالات، أي أن ملف HTML نفسه وصل فوراً، فكل التأخير الذي بعده سببه الملف البطيء وطريقة تحميله.
  • في blocking-css جاء DOMContentLoaded بعد 15 ملي ثانية، أي أن المحلل أكمل DOM ولم ينتظر CSS، ولكن أول رسم جاء بعد 1556، وهذا هو معنى «يمنع الرسم ولا يوقف المحلل».
  • في async-css حمل المتصفح نفس الملف بأولوية منخفضة لأن media="print" لا ينطبق على الشاشة، فرسم الصفحة بعد 32 ملي ثانية، ثم عندما وصل الملف غيرت الدالة onload قيمة media إلى all، فطبقت الأنماط وتغير لون الخلفية.
  • في blocking-js تأخر كل شيء، فـ DOMContentLoaded عند 1538 وأول رسم عند 1548، لأن المحلل توقف عند الوسم في <head> ولم يصل إلى <body> أصلاً، فلم يكن هناك محتوى يرسم.
  • في defer-js رسم المتصفح المحتوى بعد 40 ملي ثانية، وعمود slow.js ran يساوي 1535، وهو نفس وقت DOMContentLoaded، وهذا يطابق ما يقوله المعيار: الكود المؤجل ينفذ بعد اكتمال التحليل وقبل إطلاق DOMContentLoaded.

الخطوة الثالثة: لوحة الطلبات بالأرقام

هذه هي صفحة لوحة الطلبات التي بدأنا بها المقال، وفيها جدول يبنى من JavaScript بعدد صفوف يحدده الرابط، وثلاث طرق لضبط عرض الأشرطة. والصفحة كاملة كما يلي:

<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<title>لوحة الطلبات</title>
<style>
  body { font: 15px/1.6 system-ui, sans-serif; margin: 16px; }
  table { border-collapse: collapse; width: 100%; }
  td, th { border-bottom: 1px solid #DADDE3; padding: 4px 8px; text-align: start; }
  .track { background: #F1F2F4; height: 10px; border-radius: 5px; }
  .bar { background: #E95420; height: 10px; border-radius: 5px; width: 0; }
</style>
</head>
<body>
<h1>لوحة الطلبات</h1>
<table><thead><tr><th>الطلب</th><th>العميل</th><th>نسبة التجهيز</th></tr></thead><tbody id="rows"></tbody></table>
<script>
const N = Number(new URLSearchParams(location.search).get('n') || 2000);
const body = document.getElementById('rows');
if (new URLSearchParams(location.search).get('fixed')) document.querySelector('table').style.tableLayout = 'fixed';
let html = '';
for (let i = 0; i < N; i++) html += `<tr><td>#${1000 + i}</td><td>عميل ${i + 1}</td><td><div class="track"><div class="bar" data-pct="${(i * 37) % 100}"></div></div></td></tr>`;
body.innerHTML = html;
const bars = [...document.querySelectorAll('.bar')];

// 1) read then write inside the same loop: every read needs the layout the previous write invalidated
window.thrash = () => {
  const t = performance.now();
  for (const bar of bars) {
    const w = bar.parentElement.offsetWidth;           // read (forces layout)
    bar.style.width = (w * bar.dataset.pct / 100) + 'px'; // write (invalidates layout)
  }
  void document.body.offsetHeight;                       // final layout, same as the browser would do before paint
  return performance.now() - t;
};
// 2) all reads first, then all writes
window.batched = () => {
  const t = performance.now();
  const widths = bars.map(bar => bar.parentElement.offsetWidth);
  bars.forEach((bar, i) => { bar.style.width = (widths[i] * bar.dataset.pct / 100) + 'px'; });
  void document.body.offsetHeight;
  return performance.now() - t;
};
// 3) no reads at all: let CSS compute the width
window.cssOnly = () => {
  const t = performance.now();
  bars.forEach(bar => { bar.style.width = bar.dataset.pct + '%'; });
  void document.body.offsetHeight;
  return performance.now() - t;
};
window.ready = true;
</script>
</body>
</html>

في الكود أعلاه لاحظ التالي:

  • الدالة thrash هي الطريقة التي يكتبها أغلبنا أول مرة: داخل نفس الحلقة نقرأ عرض الخلية بـ offsetWidth ثم نكتب عرض الشريط.
  • الدالة batched تقوم بنفس العمل ونفس الحساب، ولكنها تقرأ كل العروض أولاً في مصفوفة، ثم تكتب كل الأشرطة.
  • الدالة cssOnly لا تقرأ شيئاً، فهي تكتب النسبة المئوية مباشرة، وتترك للمتصفح حساب العرض من عرض الأب، لأن عرض الشريط هنا لا يحتاج JavaScript أصلاً.
  • السطر void document.body.offsetHeight في نهاية كل دالة يجبر تخطيطاً أخيراً، لكي يشمل القياس التخطيط الذي كان المتصفح سيقوم به قبل الرسم، فتكون المقارنة عادلة بين الطرق الثلاث.

والجزء الذي يقيس من البرنامج يفتح الصفحة من جديد قبل كل تشغيل لكي تبدأ كل طريقة من نفس الحالة، ويقرأ عدادات Performance.getMetrics قبل الدالة وبعدها، ثم يسجل تتبع أداء لتشغيل واحد من thrash:

    if (want('thrash')) {
        console.log('\n== 3) layout thrashing: 2000 rows, the same widths three ways (fresh page each run)');
        await b.send('Performance.enable', { timeDomain: 'threadTicks' });
        for (const mode of ['thrash', 'batched', 'cssOnly']) {
            for (let run = 1; run <= 3; run++) {
                await b.goto(`${BASE}/orders.html?n=2000`, { wait: 600 });
                await b.evaluate('document.body.offsetHeight');
                const before = await metrics();
                const ms = await b.evaluate(`${mode}()`);
                const after = await metrics();
                const d = (k) => after[k] - before[k];
                console.log(`${mode.padEnd(8)} run ${run}: ${ms.toFixed(1).padStart(7)} ms | layouts ${String(d('LayoutCount')).padStart(5)} | layout time ${(d('LayoutDuration') * 1000).toFixed(1).padStart(7)} ms | style recalcs ${String(d('RecalcStyleCount')).padStart(4)}`);
            }
        }
        // What the same thrash looks like in a performance trace
        const s = await browserSession();
        await b.goto(`${BASE}/orders.html?n=2000`, { wait: 600 });
        const ev = await trace(s, async () => { await b.evaluate('thrash()'); await sleep(300); });
        writeFileSync(join(OUT, 'thrash-trace.json'), JSON.stringify({ traceEvents: ev }));
        const sum = summarise(ev, ['InvalidateLayout', 'UpdateLayoutTree', 'Layout', 'Paint']);
        console.log('trace of thrash():', Object.entries(sum).map(([k, v]) => `${k} ×${v.count}${v.ms ? ` (${v.ms.toFixed(1)} ms)` : ''}`).join(' | '));
        const L = ev.filter((e) => e.name === 'Layout');
        const one = L[Math.floor(L.length / 2)];
        console.log('one Layout event:', JSON.stringify({ dur_ms: one.dur / 1000, ...one.args.beginData, frame: undefined, root: one.args.endData.layoutRoots[0].nodeName }));
        s.close();
    }

والمخرج سوف يكون كما يلي:

== 3) layout thrashing: 2000 rows, the same widths three ways (fresh page each run)
thrash   run 1: 19522.0 ms | layouts  1980 | layout time 18519.1 ms | style recalcs 2000
thrash   run 2: 22795.6 ms | layouts  1980 | layout time 21407.5 ms | style recalcs 2000
thrash   run 3: 23236.7 ms | layouts  1980 | layout time 21497.2 ms | style recalcs 2000
batched  run 1:    87.2 ms | layouts     1 | layout time    67.5 ms | style recalcs    1
batched  run 2:    82.6 ms | layouts     1 | layout time    66.1 ms | style recalcs    1
batched  run 3:    99.5 ms | layouts     1 | layout time    67.9 ms | style recalcs    1
cssOnly  run 1:    55.9 ms | layouts     1 | layout time    36.7 ms | style recalcs    1
cssOnly  run 2:    54.9 ms | layouts     1 | layout time    43.9 ms | style recalcs    1
cssOnly  run 3:    50.0 ms | layouts     1 | layout time    37.9 ms | style recalcs    1
trace of thrash(): InvalidateLayout ×1980 | UpdateLayoutTree ×2000 (116.2 ms) | Layout ×1980 (22592.9 ms) | Paint ×2 (13.3 ms)
one Layout event: {"dur_ms":9.76,"dirtyObjects":9,"partialLayout":false,"totalObjects":16015,"root":"#document"}

هذه هي العشرون ثانية التي بدأنا بها المقال، وفي المخرج أعلاه لاحظ التالي:

  • الطريقة الأولى أخذت بين 19.5 و23.2 ثانية، وعداد التخطيط زاد 1980 مرة، ومن هذا الوقت ذهب أكثر من 18 ثانية في التخطيط وحده، أي أن JavaScript نفسه لم يكن المشكلة.
  • الطريقة الثانية قامت بنفس الحساب بالضبط في 83 إلى 100 ملي ثانية مع تخطيط واحد، أي أسرع بأكثر من مئتي مرة، والفرق الوحيد هو ترتيب القراءة والكتابة.
  • الطريقة الثالثة أخذت 50 إلى 56 ملي ثانية، لأنها لم تقرأ شيئاً، فوفرت ألفي قراءة لـ offsetWidth، وتخطيطها الوحيد أسرع لأن العرض بالنسبة المئوية لا يعتمد على قيم كتبناها بالبكسل.
  • عدد مرات حساب الأنماط 2000 في الطريقة الأولى، لأن كل كتابة لـ style.width تجعل نمط العنصر قديماً، وكل قراءة تحتاج الأنماط محدثة قبل التخطيط.
  • في سطر التتبع ظهر حدث InvalidateLayout 1980 مرة، وهو الحدث الذي يسجله Chrome عندما تجعل كتابة ما التخطيط قديماً، وظهر Layout بنفس العدد، وبمجموع 22.6 ثانية في هذا التشغيل.
  • السطر الأخير حدث تخطيط واحد من منتصف التتبع: 9.76 ملي ثانية، و9 صناديق متسخة فقط من أصل 16015، وجذر التخطيط المستند كله، وهذا ما شرحناه في قسم التخطيط عن الجداول.

وقد يتساءل البعض: لماذا 1980 وليس 2000؟ والإجابة في الصفحة نفسها، فالنسبة تحسب بالمعادلة (i * 37) % 100، وهي تساوي صفراً في 20 صفاً من كل ألفين، وفي هذه الصفوف كتبنا العرض 0px على شريط عرضه أصلاً صفر، فلم يتغير شيء، فلم يعتبر Chrome التخطيط قديماً، فلم يحتج إلى تخطيط جديد عند القراءة التالية، وهذا يؤكد أن المتصفح يعيد التخطيط فقط عندما يتغير شيء فعلاً. والصورة التالية تلخص الطرق الثلاث ونتائجها:

في الأعلى سلسلة مربعات للدالة thrash بالترتيب قراءة R ثم تخطيط L ثم كتابة W مكررة، وتحتها سلسلة الدالة batched فيها كل القراءات R ثم كل الكتابات W ثم تخطيط واحد L، وفي الوسط أعمدة النتيجة: thrash من 19,522 إلى 23,237 ملي ثانية مع 1980 تخطيطاً، وthrash مع جدول fixed عند 15,770، وbatched من 83 إلى 100 مع تخطيط واحد، وcssOnly من 50 إلى 56، وفي الأسفل الوقت مع عدد الصفوف: 374 ملي ثانية مع 250 صفاً و1,346 مع 500 و5,346 مع 1000
نفس الحساب ونفس النتيجة على الشاشة، والفرق بين 20 ثانية وعشر ثانية هو ترتيب القراءة والكتابة

ما الذي واجهناه أثناء القياس؟

عند كتابة أداة قياس بنفسك عبر البروتوكول سوف تواجه بعض الأمور التي لا تظهر في الوثائق بوضوح، وهذه ثلاثة منها ظهرت في هذه التجربة مع حلها:

  1. عداد InvalidateLayout أعطى صفراً في أول تشغيل: كانت دالة التجميع تعد الأحداث الكاملة ph: 'X' فقط، وInvalidateLayout حدث لحظي ph: 'I' ليس له مدة، فلم يحسب. والحل أن تعد الأحداث اللحظية أيضاً، وأن تفتح ملف التتبع مرة واحدة على الأقل وتنظر إلى قيمة ph في الأحداث التي تهمك قبل أن تكتب أي تجميع.
  2. النتائج لا تأتي كرد على الأمر: الأمر Tracing.end يعيد رداً فارغاً، والبيانات تأتي بعده في أحداث منفصلة، فإذا كانت الأداة التي تستخدمها لا تعطيك مستمعاً للأحداث فلن تحصل على شيء، ولهذا فتحنا اتصالاً ثانياً بالمتصفح، وPuppeteer وPlaywright يقومان بنفس الشيء داخلياً.
  3. التشغيل المتكرر على نفس الصفحة يعطي أرقاماً مضللة: بعد أول تشغيل لـ thrash تكون الأشرطة قد أخذت عرضها، فإذا شغلت الدالة مرة ثانية دون إعادة فتح الصفحة كتبت أغلب القيم كما هي، ولم يتغير شيء في أغلب الصفوف. وقد قسنا ذلك بملف صغير اسمه rerun-check.mjs يشغل الدالة مرتين على نفس الصفحة، والمخرج بعد هذه القائمة.
same page, thrash run 1: 18783.2 ms | layouts 1980
same page, thrash run 2: 1085.4 ms | layouts 126

أي أن التشغيل الثاني بدا أسرع 17 مرة مع أن الكود لم يتغير، ونزل فيه عدد مرات التخطيط من 1980 إلى 126 لأن أغلب الكتابات لم تغير شيئاً، وهذا سبب فتح الصفحة من جديد قبل كل تشغيل في البرنامج. والقاعدة العامة عند قياس أي كود يغير الصفحة: ابدأ كل قياس من نفس الحالة، وكرره ثلاث مرات على الأقل، وانظر إلى المدى وليس إلى رقم واحد.

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

ماذا يحدث إذا قل عدد الصفوف أو ثبتنا عرض أعمدة الجدول؟

قد يفكر البعض في حلين سريعين: الأول أن المشكلة في عدد الصفوف، فإذا عرضنا عدداً أقل اختفت، والثاني أن نجعل الجدول table-layout: fixed لكي لا يحسب عرض الأعمدة من المحتوى، فيصبح كل تخطيط أرخص. والجزء الخامس من البرنامج يجرب الاحتمالين على الطريقة الأولى فقط:

    if (want('vary')) {
        console.log('\n== 5) what happens if: fewer rows, or a fixed table layout (thrash only)');
        await b.send('Performance.enable', { timeDomain: 'threadTicks' });
        for (const q of ['n=250', 'n=500', 'n=1000', 'n=2000&fixed=1']) {
            await b.goto(`${BASE}/orders.html?${q}`, { wait: 600 });
            await b.evaluate('document.body.offsetHeight');
            const before = await metrics();
            const ms = await b.evaluate('thrash()');
            const after = await metrics();
            const d = (k) => after[k] - before[k];
            console.log(`thrash ${q.padEnd(14)}: ${ms.toFixed(1).padStart(8)} ms | layouts ${String(d('LayoutCount')).padStart(5)} | per layout ${(d('LayoutDuration') * 1000 / d('LayoutCount')).toFixed(2)} ms`);
        }
    }

والمخرج سوف يكون كما يلي:

== 5) what happens if: fewer rows, or a fixed table layout (thrash only)
thrash n=250         :    373.7 ms | layouts   247 | per layout 1.38 ms
thrash n=500         :   1346.1 ms | layouts   495 | per layout 2.53 ms
thrash n=1000        :   5346.1 ms | layouts   990 | per layout 5.09 ms
thrash n=2000&fixed=1:  15769.6 ms | layouts  1980 | per layout 7.72 ms

في المخرج أعلاه لاحظ التالي:

  • مع 250 صفاً أخذت الدالة 374 ملي ثانية، وهذا وحده يكفي لأن يشعر المستخدم بالتجمد، فهو تقريباً ضعف حد الاستجابة الجيدة في INP.
  • عندما تضاعف عدد الصفوف من 500 إلى 1000 تضاعف الوقت قرابة أربع مرات، من 1346 إلى 5346، والسبب أن عدد مرات التخطيط يتضاعف، ومدة التخطيط الواحد تتضاعف أيضاً لأن الجدول أكبر، فالتكلفة تكبر بمربع عدد الصفوف تقريباً، ولهذا لا تظهر المشكلة مع عشرين طلباً تجريبياً، ثم تظهر فجأة مع البيانات الحقيقية.
  • الجدول الثابت fixed مع 2000 صف أخذ 15.8 ثانية، ومدة التخطيط الواحد 7.72 ملي ثانية بدلاً من قرابة 10، أي أنه حسن الوضع قليلاً ولكنه لم يحل المشكلة، لأن عدد مرات التخطيط بقي 1980، وجذر التخطيط بقي الجدول كله.

والدرس هنا أن تقليل تكلفة التخطيط الواحد مفيد، ولكن الحل الحقيقي أن يقل عدد مرات التخطيط، أي أن تعالج السبب وهو التبادل بين القراءة والكتابة، وليس العرض وهو بطء كل تخطيط.

الخطوة الأخيرة: تحريك left مقابل transform

في هذه الخطوة نحرك صندوقاً 600 بكسل في الثانية بحركة CSS، مرة بتغيير left ومرة بتغيير transform: translateX()، ونسجل تتبع أداء لمدة ثانيتين لكل واحدة:

@keyframes byLeft { from { left: 0; } to { left: 600px; } }
@keyframes byTransform { from { transform: translateX(0); } to { transform: translateX(600px); } }
.run-left .box { animation: byLeft 1s linear infinite; }
.run-transform .box { animation: byTransform 1s linear infinite; }
    if (want('anim')) {
        console.log('\n== 4) animating left vs transform for 2 s (main-thread work in the trace)');
        const s = await browserSession();
        for (const kind of ['left', 'transform']) {
            await b.goto(`${BASE}/anim.html`, { wait: 400 });
            const ev = await trace(s, async () => {
                await b.evaluate(`document.getElementById('lane').className = 'run-${kind}'`);
                await sleep(2000);
                await b.evaluate(`document.getElementById('lane').className = ''`);
                await sleep(200);
            });
            writeFileSync(join(OUT, `anim-${kind}-trace.json`), JSON.stringify({ traceEvents: ev }));
            const sum = summarise(ev, ['UpdateLayoutTree', 'Layout', 'PrePaint', 'Paint', 'Layerize', 'Commit']);
            console.log(`${kind.padEnd(9)}`, Object.entries(sum).map(([k, v]) => `${k} ×${v.count} (${v.ms.toFixed(1)} ms)`).join(' | '));
        }
        s.close();

والمخرج سوف يكون كما يلي:

== 4) animating left vs transform for 2 s (main-thread work in the trace)
left      UpdateLayoutTree ×123 (8.4 ms) | Layout ×123 (6.2 ms) | PrePaint ×125 (8.1 ms) | Paint ×246 (9.2 ms) | Layerize ×125 (2.7 ms) | Commit ×125 (10.9 ms)
transform UpdateLayoutTree ×8 (0.9 ms) | Layout ×2 (0.2 ms) | PrePaint ×9 (0.4 ms) | Paint ×5 (0.2 ms) | Layerize ×9 (0.2 ms) | Commit ×9 (1.3 ms)

في المخرج أعلاه لاحظ التالي:

  • مع left ظهر Layout 123 مرة في ثانيتين، أي قرابة 60 مرة في الثانية، وهو عدد الإطارات نفسه، فكل إطار من الحركة احتاج حساب أنماط وتخطيطاً وتجهيزاً للرسم PrePaint ورسماً وتقسيماً إلى طبقات Layerize ثم إرسال النتيجة إلى خيط التركيب Commit.
  • Paint ظهر 246 مرة، أي مرتين في كل إطار تقريباً، لأن المتصفح يعيد رسم الجزء الذي غادره الصندوق والجزء الذي وصل إليه.
  • مع transform ظهر Layout مرتين فقط وPaint خمس مرات طوال ثانيتين، وهي عند بداية الحركة ونهايتها عندما تغير الكلاس، أما الحركة نفسها فتولاها خيط التركيب دون أن يطلب أي عمل من الخيط الرئيسي.
  • المدد صغيرة هنا في الحالتين لأن الصفحة صغيرة جداً، ولكن تخيل نفس الحركة في صفحة فيها جدول لوحة الطلبات، فكل إطار من حركة left سوف يدفع ثمن تخطيط الصفحة، بينما حركة transform لن تتأثر.

ولكي ترى الفرق بعينك، فعل البرنامج خيارين من أدوات المطور عبر البروتوكول: Overlay.setShowPaintRects الذي يلون كل منطقة أعيد رسمها بالأخضر، وOverlay.setShowDebugBorders الذي يرسم حدود الطبقات بالبرتقالي وحدود المربعات Tiles بخطوط رفيعة، وهما نفس الخيارين Paint flashing وLayer borders في لوح Rendering في أدوات المطور. والصورة التالية لقطة أثناء حركة left:

لقطة من Chrome أثناء تحريك صندوق برتقالي بخاصية left، وخلفه شريط طويل من المستطيلات الخضراء المتداخلة يغطي كل المسافة التي قطعها الصندوق، والنص صندوق يتحرك 600 بكسل في الثانية في أعلى اليمين، وخطوط رمادية رفيعة تقسم الصفحة إلى مربعات كبيرة
تحريك left: كل مستطيل أخضر منطقة أعاد المتصفح رسمها، وهي تغطي الطريق الذي قطعه الصندوق كله

وهذه لقطة بنفس الإعدادات أثناء حركة transform:

لقطة من Chrome أثناء تحريك نفس الصندوق بخاصية transform، والصندوق محاط بحد برتقالي رفيع يعني أنه في طبقة مستقلة، ولا توجد مستطيلات خضراء على طريقه، إلا مستطيلاً واحداً في مكان البداية
تحريك transform: الصندوق في طبقة مستقلة بحد برتقالي، ولا يعاد رسم أي شيء أثناء الحركة

لاحظ في اللقطة الأولى أن الأخضر يغطي الطريق كله، وفي الثانية أن الصندوق محاط بحد برتقالي يعني أن Chrome رفعه إلى طبقة مستقلة Composited Layer عندما بدأت الحركة، فأصبح خيط التركيب يحركه كصورة جاهزة. ولاحظ أيضاً الخطوط الرمادية التي تقسم الصفحة إلى مربعات كبيرة في اللقطتين، فهي المربعات Tiles التي ذكرناها في قسم التنقيط. والصورة التالية تجمع أرقام التتبع مع القاعدة العامة لكل نوع من الخصائص:

في الأعلى لوحتان: عند تحريك left أعمدة حمراء طويلة UpdateLayoutTree 123 وLayout 123 وPrePaint 125 وPaint 246 وLayerize 125 وCommit 125، وعند تحريك transform أعمدة خضراء قصيرة 8 و2 و9 و5 و9 و9، وفي الأسفل ثلاثة صفوف: خصائص التخطيط مثل width وleft تعيد Style وLayout وPaint وComposite، وخصائص الرسم مثل color وbox-shadow تعيد Style وPaint وComposite، وtransform وopacity على طبقة مستقلة تعيد Style وComposite فقط
عدد الأحداث في ثانيتين من الحركة، ومن أين يبدأ المتصفح من جديد بحسب نوع الخاصية

كيف تصلح صفحة بطيئة الرسم؟

بعد أن رأينا المشكلة بالأرقام، لنعد إلى لوحة الطلبات ونمر على الحلول من الأبسط إلى الأعمق، فكل حل من هذه الحلول يعالج مرحلة مختلفة من مسار الرسم، والقاعدة أن تبدأ بالحل الذي يزيل العمل بدلاً من الحل الذي يسرعه:

  1. افصل القراءة عن الكتابة: هذا هو حل batched في التجربة، فاقرأ كل ما تحتاجه من مقاسات أولاً، ثم اكتب كل التغييرات، وفي الكود الكبير الذي تكتبه عدة مكونات اجعل القراءات في مكان والكتابات في requestAnimationFrame، لكي تجتمع كل الكتابات قبل الإطار التالي ويحسب التخطيط مرة واحدة.
  2. لا تقس ما يستطيع CSS أن يحسبه: هذا حل cssOnly، فأغلب حالات قراءة العرض ثم حساب عرض آخر منه يمكن كتابتها بالنسب المئوية أو بـ Flexbox أو Grid أو calc()، وعندها لا توجد قراءة أصلاً، فلا يوجد تخطيط إجباري.
  3. قلل حجم ما يعاد تخطيطه: الخاصية contain تقول للمتصفح إن ما بداخل العنصر لا يؤثر على ما خارجه، فيستطيع أن يحصر التخطيط والرسم داخله، والخاصية content-visibility: auto تجعل المتصفح يتجاوز تخطيط ورسم الأجزاء البعيدة عن الشاشة حتى يقترب منها المستخدم، وفي مثال web.dev عن content-visibility نزل زمن الرسم عند التحميل في صفحة مدونة من 232 ملي ثانية إلى 30.
  4. لا تعرض ألفي صف أصلاً: المستخدم لا يرى أكثر من ثلاثين صفاً في الشاشة، فإما أن تقسم الجدول إلى صفحات Pagination، أو تستخدم القوائم الافتراضية Virtualization التي تنشئ في DOM الصفوف الظاهرة فقط وتعيد استخدامها أثناء التمرير، وبهذا يصغر حجم DOM والتخطيط معاً، وهذا الحل يغير طبيعة المشكلة بدلاً من تحسينها.
  5. حرك بـ transform وopacity: كما رأينا في التجربة، الحركة بهاتين الخاصيتين يتولاها خيط التركيب، فلا تتأثر بانشغال الخيط الرئيسي، وتفاصيل كتابة الحركات بهذه الطريقة في دليل الحركات في web.dev.
  6. اجعل ما يمنع الرسم صغيراً: ملف CSS الأساسي صغير ومحمل مبكراً، والباقي بطريقة لا تمنع الرسم، وكل Script بـ defer أو type="module" إلا ما يجب أن يعمل قبل الصفحة.

وقد يتساءل البعض: أليست أطر الواجهات مثل React وVue تحل هذه المشكلة تلقائياً لأنها تجمع التغييرات؟ والإجابة أنها تجمع الكتابات إلى DOM فعلاً، فلا تكتب في DOM إلا بعد أن تنتهي من حساب ما تغير، ولكنها لا تمنعك من القراءة من DOM داخل كودك، فإذا قرأت getBoundingClientRect() داخل دالة تعمل بعد كل تحديث في كل مكون من ألفي مكون، فستحصل على نفس الاهتزاز، والفرق أن الكود الذي يسببه سيكون مخفياً أكثر داخل المكونات.

💡
لكي تجد التخطيط الإجباري في صفحتك الحقيقية، افتح لوح الأداء Performance في أدوات المطور وسجل أثناء الضغط على الزر البطيء، فستجد مهمة طويلة Long Task عليها مثلث أحمر، وداخلها أحداث Layout بنفسجية متكررة تحت استدعاء دالتك، وهذا النمط المتكرر هو علامة الاهتزاز، وشرح كل جزء في اللوح موجود في مرجع لوح الأداء في Chrome. ويمكنك أيضاً أن تحفظ ملفات التتبع التي تنتجها التجربة في مجلد out ثم تسحبها إلى نفس اللوح لتراها بنفس الشكل.

أخطاء شائعة

  1. القراءة من DOM داخل حلقة تكتب فيه: هذا هو اهتزاز التخطيط، وتكلفته تكبر بمربع حجم الصفحة تقريباً كما رأينا، فلا تظهر في بيانات الاختبار الصغيرة.
  2. قياس الأداء على جهاز المطور فقط: جهازك أسرع بكثير من جهاز المستخدم المتوسط، فاستخدم خيار إبطاء المعالج CPU Throttling في لوح الأداء، واختبر ببيانات بحجم الإنتاج.
  3. الخلط بين display: none وvisibility: hidden: الأول يزيل العنصر من التخطيط، والثاني يحجز مكانه، فإخفاء عنصر كبير بالثاني يبقي تكلفة تخطيطه كاملة.
  4. تحريك top وleft وwidth وheight: كل إطار منها تخطيط ورسم على الخيط الرئيسي، فحرك بـ transform وغير الحجم بـ scale إذا كان ذلك مقبولاً في التصميم.
  5. وضع will-change على كل شيء: كل طبقة تأخذ ذاكرة، والطبقات الكثيرة تبطئ التركيب نفسه، فاستخدمه على العناصر التي ستتحرك فعلاً، وأزله بعد الحركة إذا أمكن.
  6. Script عادي في <head>: يوقف المحلل حتى يحمل وينفذ، وكما رأينا يؤخر أول رسم بنفس مدة تحميله، فاستخدم defer.
  7. بناء كل الصفحة من JavaScript دون حاجة: الماسح المسبق لا يرى الملفات التي يضيفها الكود، والمستخدم لا يرى شيئاً حتى يحمل الكود وينفذ، فما يمكن أن يأتي في HTML يجب أن يأتي في HTML.
  8. الاعتماد على الوقت وحده عند القياس: الوقت يتغير مع حمل الجهاز، أما عدد مرات التخطيط وحساب الأنماط فلا يتغير، فاجعله الرقم الذي تقارن به قبل الإصلاح وبعده.

قائمة فحص Checklist: من العرض إلى المرحلة إلى الحل

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

ما يراه المستخدمالمرحلة المسؤولة غالباًكيف تتأكدالحل الأول
صفحة بيضاء طويلة ثم يظهر كل شيء مرة واحدةملف CSS أو Script يمنع الرسم أو يوقف المحللوقت FCP مقابل DOMContentLoaded، وتبويب Networkdefer، وتصغير CSS الأساسي، وتحميل الباقي بطريقة لا تمنع الرسم
الصفحة تتجمد ثواني بعد نقرةتخطيط إجباري متكرر أو JavaScript طويللوح الأداء: أحداث Layout بنفسجية متكررة مع Forced reflowفصل القراءة عن الكتابة، أو حساب العرض في CSS
التمرير متقطع في صفحة طويلةتخطيط ورسم لكل الصفحة، أو DOM كبير جداًPaint flashing في لوح Rendering، وعدد عقد DOMcontent-visibility: auto، والقوائم الافتراضية
حركة تتقطع عندما تكون الصفحة مشغولةالحركة على الخيط الرئيسيتتبع أداء أثناء الحركة: Layout وPaint في كل إطارالحركة بـ transform وopacity
الصفحة بطيئة على الجوال فقط وتستهلك الذاكرةطبقات كثيرة أو صور كبيرةLayer borders، ولوح Layers في أدوات المطورإزالة will-change غير الضروري، وتصغير الصور
تغيير بسيط في كلاس يسبب بطئاً واضحاًحساب الأنماط على عدد كبير من العناصرRecalculate Style في التتبع وعدد العناصر المتأثرةتغيير الكلاس على أقرب أب، وتبسيط المحددات

وإذا كنت تستعد لمقابلة عمل في هذا المجال، فسؤال «ماذا يحدث عندما تكتب رابطاً في المتصفح؟» وسؤال «ما الفرق بين Reflow وRepaint؟» من الأسئلة المتكررة، وقد جمعنا أمثالها مع إجاباتها في مقال أسئلة مقابلة مطور Frontend، وهذا المقال يعطيك الجزء الثاني من الإجابة بعد ما شرحناه عن الشبكة في مقال HTTP.

تدريب عملي

  • افتح أي صفحة طويلة تعمل عليها، وفعل Paint flashing من لوح Rendering في أدوات المطور، ثم مرر الصفحة وحرك المؤشر فوق القوائم، وسجل أي جزء يعاد رسمه دون سبب واضح.
  • عدل صفحة لوحة الطلبات في التجربة لكي تكتب الأشرطة داخل requestAnimationFrame بعد أن تقرأ كل العروض، ثم قارن عدد مرات التخطيط مع الدالة batched.
  • أضف content-visibility: auto مع contain-intrinsic-size مناسب على صفوف الجدول، ثم قس زمن أول تخطيط للصفحة بـ 2000 صف قبل التعديل وبعده.
  • غير حركة الصندوق في الخطوة الأخيرة إلى opacity بدلاً من transform، ثم إلى background-color، وقارن عدد أحداث Paint في الحالات الثلاث.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • المتصفح يحول HTML إلى DOM وCSS إلى CSSOM، ثم يحسب الأنماط والتخطيط ويسجل أوامر الرسم على الخيط الرئيسي، ثم ينقط الطبقات ويركبها على خيط التركيب وال GPU، وكل تغيير في الصفحة يعيد جزءاً من هذا المسار.
  • Script عادي يوقف المحلل لأنه قد يغير الصفحة أثناء بنائها، وCSS في <head> لا يوقف المحلل ولكنه يمنع الرسم، وفي التجربة كان الفرق بين 1556 و32 ملي ثانية لأول رسم مع نفس الملف البطيء.
  • شجرة التخطيط تحتوي ما يأخذ مكاناً فقط، فالعنصر بـ display: none خارجها، وبـ visibility: hidden داخلها، و::before داخلها مع أنه ليس في DOM.
  • قراءة مقاس بعد كتابة نمط تجبر المتصفح على التخطيط فوراً، وتكرار ذلك في حلقة هو اهتزاز التخطيط، وفي لوحة الطلبات حول 2000 صف إلى 1980 تخطيطاً وقرابة عشرين ثانية، بينما نفس الحساب بعد فصل القراءة عن الكتابة أخذ أقل من عشر ثانية.
  • تكلفة الاهتزاز تكبر بمربع حجم الصفحة تقريباً، فلا تظهر مع بيانات الاختبار الصغيرة، وتقليل تكلفة التخطيط الواحد مثل الجدول الثابت لا يكفي، والحل أن يقل عدد مرات التخطيط.
  • الحركة بـ left تعني تخطيطاً ورسماً في كل إطار، والحركة بـ transform يتولاها خيط التركيب، وفي التتبع كان الفرق 123 تخطيطاً مقابل 2 في ثانيتين.
  • عند القياس اعتمد على عدد مرات التخطيط وحساب الأنماط، وابدأ كل قياس من نفس الحالة، لأن الوقت وحده يتغير مع حمل الجهاز.

وهذا المقال هو الأساس الذي تبنى عليه بقية مرحلة هندسة الواجهات، ففي المقالات التالية سوف نتحدث بإذن الله عن حلقة الأحداث في JavaScript وما يحدث بين المهام والإطارات، وعن تخطيط الصفحات العربية بـ Flexbox وGrid والخصائص المنطقية، وعن التعريب والنص ثنائي الاتجاه، ومعها المقالات المنشورة التي يكملها هذا المقال:

دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.دليلCSS للعربية واتجاه RTL: الخصائص المنطقية Logical Propertiesكيف تكتب ملف CSS واحداً يعمل بالعربية والإنجليزية، ولماذا تخطئ margin-left في الصفحة العربية، مع قياسات حقيقية من Chrome.دليلالتعريب i18n للمطورين: لماذا ينقلب النص العربي في الواجهة؟لماذا ينقلب النص المختلط في الواجهة العربية وكيف تعزله، وكيف تنسق الأرقام والتواريخ وصيغ الجمع العربية بأدوات المتصفح نفسها.دليلHTTP وHTTPS ببساطة: ماذا يحدث بين الرابط والصفحةماذا يحدث بين كتابة الرابط وظهور الصفحة، وكيف تقرأ الطلب والرد ورموز مثل 502 و504، ولماذا يكلف الاتصال الجديد أكثر.دليلسهولة الوصول Accessibility: موقع وتطبيق يستخدمه الجميعكيف تبني صفحات وتطبيقات يستخدمها الجميع بلوحة المفاتيح وقارئ الشاشة، من عناصر HTML الأصلية إلى النوافذ والنماذج العربية.دليلأسئلة مقابلة مطور Frontend من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور Frontend، لكل سؤال إجابة المبتدئ وإجابة الخبير وأسئلة متابعة، مع تمارين عملية شغلناها فعلاً.دليلاختبارات E2E مع Playwright: من أول اختبار إلى CIكيف تكتب اختبارات متصفح ثابتة لموقع عربي مع Playwright، وتشخص الاختبار المتقلب، وتقسمها على أكثر من جهاز في خط CI.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Google Chrome 156.0.8078.49 بدون واجهة عبر Chrome DevTools Protocol مع Node.js 22.14.0.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.