> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# كيف يرسم المتصفح صفحتك؟ من HTML إلى البكسلات
- URL: https://arabroot.io/articles/كيف-يعمل-المتصفح-من-الداخل-dom-والتخطيط-والرسم/
- Published: 2026-10-30T12:57:11.000Z
- Updated: 2026-10-30T19:46:03.000Z
- Description: ماذا يفعل المتصفح بين وصول HTML وظهور الصفحة، ولماذا تتجمد صفحة فيها ألفا صف، وكيف تقيس كل مرحلة في Chrome وتصلحها.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, تطوير الواجهات والجوال Frontend & Mobile

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

الإجابة ليست في JavaScript وإنما في المتصفح نفسه، ففي كل دورة من تلك الحلقة كان المتصفح يعيد حساب مواضع ومقاسات الجدول كله من جديد، أي ما يسمى التخطيط Layout، ألفي مرة متتالية. ولكي تفهم لماذا يحدث هذا، ولماذا لا يظهر في الكود، ولماذا تختفي المشكلة بتغيير ترتيب سطرين، تحتاج أن تعرف ما الذي يفعله المتصفح بين لحظة وصول آخر بايت من ملف HTML ولحظة ظهور البكسلات على الشاشة. وهذا هو موضوع هذا المقال، وهو أول مقال في مرحلة [هندسة الواجهات وتطبيقات الويب Frontend](https://arabroot.io/start/#stage-web-frontend)، ويبدأ من حيث انتهى مقال [HTTP وHTTPS ببساطة](https://arabroot.io/articles/http-%D9%88-https-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، فهناك شرحنا الطريق من الرابط إلى السيرفر وعودة الرد، وما يرافق الطلب من ملفات تعريف الارتباط شرحناه في مقال [الجلسات وJWT](https://arabroot.io/articles/%D8%A7%D9%84%D8%AC%D9%84%D8%B3%D8%A7%D8%AA-%D9%88%D9%85%D9%84%D9%81%D8%A7%D8%AA-%D8%AA%D8%B9%D8%B1%D9%8A%D9%81-%D8%A7%D9%84%D8%A7%D8%B1%D8%AA%D8%A8%D8%A7%D8%B7-%D9%88-jwt-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)، وقواعد الطلبات بين المواقع في مقال [CORS](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-cors-%D9%88%D8%B3%D9%8A%D8%A7%D8%B3%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1-%D8%A7%D9%84%D9%88%D8%A7%D8%AD%D8%AF/)، أما هنا فنبدأ من لحظة وصول الرد إلى المتصفح.

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

- الصورة الكاملة لمسار الرسم 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](https://developer.chrome.com/blog/inside-browser-part3?ref=arabroot.io) من فريق Chrome، فهناك عملية المتصفح Browser Process التي تدير الشريط العلوي والشبكة، وعملية الرسم Renderer Process لكل موقع تقريباً، وعملية GPU التي تتعامل مع كرت الشاشة. وداخل عملية الرسم يوجد الخيط الرئيسي Main Thread، وهو الذي يحلل HTML ويبني DOM ويحسب الأنماط ويحسب التخطيط ويسجل أوامر الرسم، وهو نفسه الخيط الذي ينفذ كود JavaScript الخاص بك ويستقبل أحداث النقر ولوحة المفاتيح.

وهذه هي النقطة التي يبنى عليها كل ما سيأتي، فالخيط الرئيسي لا يستطيع أن يفعل شيئين في نفس الوقت، فإذا كان مشغولاً بحساب التخطيط ألفي مرة فلن يستطيع الاستجابة لنقرة المستخدم ولا رسم الإطار التالي، وهذا بالضبط ما حدث في لوحة الطلبات. ولهذا السبب أيضاً تقيس Google تجربة المستخدم بمقاييس مثل [زمن الاستجابة للتفاعل INP](https://web.dev/articles/inp?ref=arabroot.io)، وهو الزمن بين نقرة المستخدم وظهور الإطار التالي على الشاشة، وتعتبر القيمة الجيدة 200 ملي ثانية أو أقل، فإذا عرفت ما الذي يشغل الخيط الرئيسي عرفت كيف تحسن هذا الرقم.

وقد يتساءل البعض: هل هذا الكلام خاص بـ Chrome؟ والإجابة أن الأسماء والتفاصيل الداخلية تختلف بين المحركات، فـ Chrome يستخدم محرك Blink وFirefox يستخدم Gecko وSafari يستخدم WebKit، ولكن المراحل نفسها موجودة في الجميع لأن معايير HTML وCSS تفرضها، فالتحليل يتبع خوارزمية مكتوبة في [معيار HTML](https://html.spec.whatwg.org/multipage/parsing.html?ref=arabroot.io)، وترتيب العمل في كل إطار يتبع [نموذج حلقة الأحداث Event Loop](https://html.spec.whatwg.org/multipage/webappapis.html?ref=arabroot.io#event-loop-processing-model) في نفس المعيار. وفي هذا المقال سوف نستخدم أسماء 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، ثم نافذة المتصفح](https://arabroot.io/content/images/2026/10/browser-internals-dom-01-pipeline.webp)

مسار الرسم: كل تغيير في الصفحة يعيد جزءاً من هذا المسار، والسؤال دائماً من أي مرحلة يبدأ

والفكرة الأهم في هذه الصورة أن المسار لا يجري مرة واحدة عند تحميل الصفحة ثم ينتهي، وإنما يتكرر كلما تغير شيء، فإذا غيرت لون زر أعاد المتصفح حساب الأنماط ثم الرسم، وإذا غيرت عرض عنصر أعاد التخطيط أيضاً، وإذا حركت عنصراً بـ `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](https://developer.mozilla.org/en-US/docs/Web/API/Document%5FObject%5FModel/Introduction?ref=arabroot.io). فعندما تكتب `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()](https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html?ref=arabroot.io) تكتب HTML جديداً في مكان المحلل الحالي، فلو أكمل المحلل قبل تنفيذ الكود لبنى شجرة خاطئة، ولذلك يسمى هذا النوع من الكود موقف المحلل Parser-blocking.

ولكي لا تضيع هذه المدة يعمل في Chrome والمتصفحات الأخرى ماسح مسبق Preload Scanner، وهو محلل خفيف يقرأ ما بعد الكود المتوقف ويبدأ تحميل الملفات التي يجدها مثل الصور وملفات CSS وJS الأخرى، كما يشرح [مقال web.dev عن Preload Scanner](https://web.dev/articles/preload-scanner?ref=arabroot.io) وصفحة [Speculative parsing في MDN](https://developer.mozilla.org/en-US/docs/Glossary/Speculative%5Fparsing?ref=arabroot.io). وهذا يعني أن الملفات التي تكتبها في HTML مباشرة تكتشف مبكراً، أما الملفات التي يضيفها JavaScript بعد تشغيله فلا يراها الماسح، وهذا أحد أسباب بطء الصفحات التي تبني كل شيء من JavaScript.

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

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

لكي لا يوقف الكود المحلل يوجد أكثر من خيار، وكل خيار له سلوك مختلف كما تشرح [صفحة عنصر script في MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script?ref=arabroot.io):

| الطريقة                | هل يوقف المحلل؟                       | متى ينفذ الكود؟                          | هل يحفظ الترتيب؟ | متى تستخدمها؟                                         |
| ---------------------- | ------------------------------------- | ---------------------------------------- | ---------------- | ----------------------------------------------------- |
| <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](https://arabroot.io/content/images/2026/10/browser-internals-dom-04-blocking.webp)

نفس HTML ونفس الملف البطيء: الفرق كله في طريقة كتابة وسم واحد

لاحظ في الصورة شيئاً مهماً، وهو أن `DOMContentLoaded` في حالة CSS المانع جاء بعد 15 ملي ثانية فقط، أي أن DOM كان جاهزاً تماماً، ومع ذلك بقيت الشاشة بيضاء 1.5 ثانية لأن المتصفح ينتظر الأنماط. وفي حالة `defer` العكس، فالمستخدم يرى المحتوى بعد 40 ملي ثانية، ولكن `DOMContentLoaded` تأخر إلى 1535 لأن المتصفح ينتظر تنفيذ الكود المؤجل قبل إطلاق هذا الحدث. وهذا لا يعني أن تحمل كل ملفات CSS بطريقة لا تمنع الرسم، فالأنماط التي يحتاجها الجزء الظاهر من الصفحة أول مرة يجب أن تصل قبل الرسم، وإلا رأى المستخدم الوميض الذي ذكرناه، وإنما يعني أن تفصل الأنماط الأساسية عن غيرها، وأن تجعل ملف CSS الأساسي صغيراً وسريعاً، كما ينصح [تدقيق Lighthouse للملفات المانعة للرسم](https://developer.chrome.com/docs/lighthouse/performance/render-blocking-resources?ref=arabroot.io).

## 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](https://web.dev/articles/critical-rendering-path/render-tree-construction?ref=arabroot.io). وهذه الشجرة ليست نسخة من DOM، فهي تحتوي فقط على ما سيأخذ مكاناً على الصفحة، وفيها ما ليس في DOM أصلاً. وفي التجربة العملية طلبنا من Chrome عبر [الأمر DOMSnapshot.captureSnapshot](https://chromedevtools.github.io/devtools-protocol/tot/DOMSnapshot/?ref=arabroot.io) قائمة كائنات التخطيط 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 بالرمادي لأنه يحجز مكانه](https://arabroot.io/content/images/2026/10/browser-internals-dom-02-dom-vs-layout.webp)

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](https://arabroot.io/articles/%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%85%D9%88%D9%82%D8%B9%D8%A7-%D9%8A%D8%B3%D8%AA%D8%AE%D8%AF%D9%85%D9%87-%D8%A7%D9%84%D8%AC%D9%85%D9%8A%D8%B9/).

وكل صندوق في شجرة التخطيط يتبع نموذج الصندوق Box Model المعروف: المحتوى Content ثم الحشو Padding ثم الحد Border ثم الهامش Margin. والصورة التالية لقطة حقيقية من Chrome بعد أن طلبنا منه عبر [الأمر Overlay.highlightNode](https://chromedevtools.github.io/devtools-protocol/tot/Overlay/?ref=arabroot.io) أن يظلل بطاقة الطلب كما يفعل لوح العناصر Elements في أدوات المطور:

![لقطة من Chrome لبطاقة طلب رقم 1042 مكتوب فيها تم الشحن، والبطاقة مظللة بثلاث طبقات: الهامش بالبرتقالي والحشو بالأخضر والمحتوى بالأزرق، وتحتها نافذة معلومات تقول div#order.card بمقاس 404 × 188.58 والخط 18px system-ui والهامش 20px والحشو 16px 20px، وقسم سهولة الوصول فيه Role generic](https://arabroot.io/content/images/2026/10/browser-internals-dom-03-box-model-overlay.webp)

ما يرسمه 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](https://developer.mozilla.org/en-US/docs/Web/CSS/table-layout?ref=arabroot.io)، فتغيير شريط واحد في صف واحد قد يغير عرض العمود، فيحتاج المتصفح أن يعيد النظر في الجدول كله.

وفي Chrome تغير محرك التخطيط في السنوات الأخيرة إلى LayoutNG ضمن مشروع [RenderingNG](https://developer.chrome.com/docs/chromium/renderingng?ref=arabroot.io)، ومن أهم أفكاره أن نتيجة التخطيط شجرة أجزاء Fragment Tree غير قابلة للتعديل، تحفظ بحيث يمكن إعادة استخدام نتيجة صندوق لم تتغير مدخلاته، كما تشرح [صفحة هياكل البيانات في RenderingNG](https://developer.chrome.com/docs/chromium/renderingng-data-structures?ref=arabroot.io). وهذا يقلل العمل في كثير من الحالات، ولكنه لا يغير القاعدة الأساسية: إذا كانت مدخلات التخطيط تتغير مع كل سطر من الكود، فسوف يعيد المتصفح الحساب مع كل سطر.

## الرسم 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 عن الخصائص التي يكفيها التركيب](https://web.dev/articles/stick-to-compositor-only-properties-and-manage-layer-count?ref=arabroot.io). أما إذا حركت عنصراً بتغيير `left` أو `top` أو `width` فكل إطار من الحركة يحتاج حساب أنماط وتخطيطاً ورسماً على الخيط الرئيسي، وسوف نرى الفرق بالأرقام في التجربة.

ولكن الطبقات ليست مجانية، فكل طبقة تحتاج ذاكرة لبكسلاتها، وطبقة بحجم الشاشة على شاشة عالية الدقة تأخذ عدة ميغابايتات من ذاكرة ال GPU، ولذلك لا تقم بإضافة `will-change: transform` على كل عناصر الصفحة لتسريعها، والسبب أن كل عنصر سيحصل على طبقة، فتنفد ذاكرة ال GPU على الأجهزة الضعيفة وتصبح الصفحة أبطأ، وصفحة [will-change في MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/will-change?ref=arabroot.io) تحذر من ذلك صراحة وتصفه بأنه الحل الأخير وليس الأول.

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

الشاشة العادية تعرض 60 إطاراً في الثانية، أي إطاراً كل 16.7 ملي ثانية تقريباً، وبعض الشاشات تعرض 120 إطاراً فتصبح المدة 8.3 ملي ثانية. وفي كل إطار يحتاج المتصفح أن ينفذ ما عليه من JavaScript، ثم يحسب الأنماط والتخطيط والرسم إذا تغير شيء، ثم يركب الإطار، وكل ذلك داخل هذه الميزانية، وإذا تجاوزها فاته موعد العرض فيرى المستخدم تقطعاً في الحركة Jank. ويذكر [مقال web.dev عن أداء الرسم](https://web.dev/articles/rendering-performance?ref=arabroot.io) أن المتصفح يحتاج جزءاً من هذه الميزانية لعمله الداخلي، فينصح أن ينتهي عملك في حدود 10 ملي ثانية.

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

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

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

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

السطر الأول لا يحسب شيئاً، وإنما يضع علامة على أن التخطيط قديم، ثم يأتي السطر الثاني ويطلب عرض الخلية، وهذه القيمة يجب أن تكون صحيحة لحظة القراءة، لأن [offsetWidth](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetWidth?ref=arabroot.io) تعيد العرض بعد التخطيط، ولا يستطيع المتصفح أن يعيد قيمة قديمة، فيضطر أن يحسب التخطيط فوراً في منتصف كودك، قبل أن يكمل السطر التالي، وهذا يسمى التخطيط الإجباري المتزامن Forced Synchronous Layout. ومرة واحدة لا تضر، فهي نفس العمل الذي كان المتصفح سيفعله قبل الرسم، ولكن المشكلة عندما تتكرر القراءة والكتابة داخل حلقة، فكل قراءة تأتي بعد كتابة، وكل كتابة تجعل التخطيط قديماً من جديد، فيحسب المتصفح التخطيط في كل دورة، وهذا هو اهتزاز التخطيط Layout Thrashing كما يسميه [مقال web.dev عن تجنب التخطيط الكبير والاهتزاز](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing?ref=arabroot.io).

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

- المقاسات والمواضع: `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](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-e2e-%D9%85%D8%B9-playwright-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/). وفي هذه التجربة لا نحتاج أي مكتبة خارجية، فقط Node.js وChrome المثبت على الجهاز، وأوامر من أربعة أجزاء Domains في البروتوكول:

- [DOMSnapshot](https://chromedevtools.github.io/devtools-protocol/tot/DOMSnapshot/?ref=arabroot.io): يعيد شجرة DOM مع كائنات التخطيط ومقاساتها في استدعاء واحد.
- [Performance](https://chromedevtools.github.io/devtools-protocol/tot/Performance/?ref=arabroot.io): يعيد عدادات داخلية مثل عدد مرات التخطيط `LayoutCount` ومدته `LayoutDuration` وعدد مرات حساب الأنماط `RecalcStyleCount`.
- [Tracing](https://chromedevtools.github.io/devtools-protocol/tot/Tracing/?ref=arabroot.io): يسجل تتبع أداء كاملاً، وهو نفس الملف الذي يسجله لوح الأداء Performance في أدوات المطور.
- [Overlay](https://chromedevtools.github.io/devtools-protocol/tot/Overlay/?ref=arabroot.io): يرسم فوق الصفحة ما ترسمه أدوات المطور، مثل نموذج الصندوق ومناطق إعادة الرسم وحدود الطبقات.

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

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

```javascript
// 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 ويعيد نتيجة كل أمر، وأي أداة تتحدث البروتوكول تقوم بنفس العمل. والجزء الأول من البرنامج كما يلي:

```javascript
// 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` إلى كائن، فنأخذ القيم قبل الكود وبعده ونطرح، فنعرف كم مرة حسب المتصفح التخطيط بسبب هذا الكود وحده.

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

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

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

```text
== Chrome: Chrome/156.0.8078.49 | V8 15.6.75.19
```

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

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

```html
<!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` ومقاس الصندوق، ثم العناصر التي ليس لها كائن تخطيط:

```javascript
    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(', '));
```

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

```text
== 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 ملي ثانية في إرسال الملف:

```html
<!-- 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](https://web.dev/articles/fcp?ref=arabroot.io):

```javascript
    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`);
        }
    }
```

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

```text
== 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 بعدد صفوف يحدده الرابط، وثلاث طرق لضبط عرض الأشرطة. والصفحة كاملة كما يلي:

```html
<!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`:

```javascript
    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();
    }
```

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

```text
== 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](https://arabroot.io/content/images/2026/10/browser-internals-dom-05-thrashing.webp)

نفس الحساب ونفس النتيجة على الشاشة، والفرق بين 20 ثانية وعشر ثانية هو ترتيب القراءة والكتابة

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

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

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

```text
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` لكي لا يحسب عرض الأعمدة من المحتوى، فيصبح كل تخطيط أرخص. والجزء الخامس من البرنامج يجرب الاحتمالين على الطريقة الأولى فقط:

```javascript
    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`);
        }
    }
```

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

```text
== 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()`، ونسجل تتبع أداء لمدة ثانيتين لكل واحدة:

```css
@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; }
```

```javascript
    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();
```

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

```text
== 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 في أدوات المطور](https://developer.chrome.com/docs/devtools/rendering/performance?ref=arabroot.io). والصورة التالية لقطة أثناء حركة `left`:

![لقطة من Chrome أثناء تحريك صندوق برتقالي بخاصية left، وخلفه شريط طويل من المستطيلات الخضراء المتداخلة يغطي كل المسافة التي قطعها الصندوق، والنص صندوق يتحرك 600 بكسل في الثانية في أعلى اليمين، وخطوط رمادية رفيعة تقسم الصفحة إلى مربعات كبيرة](https://arabroot.io/content/images/2026/10/browser-internals-dom-06-overlay-left.webp)

تحريك left: كل مستطيل أخضر منطقة أعاد المتصفح رسمها، وهي تغطي الطريق الذي قطعه الصندوق كله

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

![لقطة من Chrome أثناء تحريك نفس الصندوق بخاصية transform، والصندوق محاط بحد برتقالي رفيع يعني أنه في طبقة مستقلة، ولا توجد مستطيلات خضراء على طريقه، إلا مستطيلاً واحداً في مكان البداية](https://arabroot.io/content/images/2026/10/browser-internals-dom-07-overlay-transform.webp)

تحريك 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 فقط](https://arabroot.io/content/images/2026/10/browser-internals-dom-08-cost-by-property.webp)

عدد الأحداث في ثانيتين من الحركة، ومن أين يبدأ المتصفح من جديد بحسب نوع الخاصية

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

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

1. **افصل القراءة عن الكتابة:** هذا هو حل `batched` في التجربة، فاقرأ كل ما تحتاجه من مقاسات أولاً، ثم اكتب كل التغييرات، وفي الكود الكبير الذي تكتبه عدة مكونات اجعل القراءات في مكان والكتابات في `requestAnimationFrame`، لكي تجتمع كل الكتابات قبل الإطار التالي ويحسب التخطيط مرة واحدة.
2. **لا تقس ما يستطيع CSS أن يحسبه:** هذا حل `cssOnly`، فأغلب حالات قراءة العرض ثم حساب عرض آخر منه يمكن كتابتها بالنسب المئوية أو بـ Flexbox أو Grid أو `calc()`، وعندها لا توجد قراءة أصلاً، فلا يوجد تخطيط إجباري.
3. **قلل حجم ما يعاد تخطيطه:** الخاصية [contain](https://developer.mozilla.org/en-US/docs/Web/CSS/contain?ref=arabroot.io) تقول للمتصفح إن ما بداخل العنصر لا يؤثر على ما خارجه، فيستطيع أن يحصر التخطيط والرسم داخله، والخاصية [content-visibility: auto](https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility?ref=arabroot.io) تجعل المتصفح يتجاوز تخطيط ورسم الأجزاء البعيدة عن الشاشة حتى يقترب منها المستخدم، وفي [مثال web.dev عن content-visibility](https://web.dev/articles/content-visibility?ref=arabroot.io) نزل زمن الرسم عند التحميل في صفحة مدونة من 232 ملي ثانية إلى 30.
4. **لا تعرض ألفي صف أصلاً:** المستخدم لا يرى أكثر من ثلاثين صفاً في الشاشة، فإما أن تقسم الجدول إلى صفحات Pagination، أو تستخدم القوائم الافتراضية Virtualization التي تنشئ في DOM الصفوف الظاهرة فقط وتعيد استخدامها أثناء التمرير، وبهذا يصغر حجم DOM والتخطيط معاً، وهذا الحل يغير طبيعة المشكلة بدلاً من تحسينها.
5. **حرك بـ transform وopacity:** كما رأينا في التجربة، الحركة بهاتين الخاصيتين يتولاها خيط التركيب، فلا تتأثر بانشغال الخيط الرئيسي، وتفاصيل كتابة الحركات بهذه الطريقة في [دليل الحركات في web.dev](https://web.dev/articles/animations-guide?ref=arabroot.io).
6. **اجعل ما يمنع الرسم صغيراً:** ملف CSS الأساسي صغير ومحمل مبكراً، والباقي بطريقة لا تمنع الرسم، وكل Script بـ `defer` أو `type="module"` إلا ما يجب أن يعمل قبل الصفحة.

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

💡

لكي تجد التخطيط الإجباري في صفحتك الحقيقية، افتح لوح الأداء Performance في أدوات المطور وسجل أثناء الضغط على الزر البطيء، فستجد مهمة طويلة Long Task عليها مثلث أحمر، وداخلها أحداث Layout بنفسجية متكررة تحت استدعاء دالتك، وهذا النمط المتكرر هو علامة الاهتزاز، وشرح كل جزء في اللوح موجود في [مرجع لوح الأداء في Chrome](https://developer.chrome.com/docs/devtools/performance/reference?ref=arabroot.io). ويمكنك أيضاً أن تحفظ ملفات التتبع التي تنتجها التجربة في مجلد `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، وتبويب Network           | defer، وتصغير CSS الأساسي، وتحميل الباقي بطريقة لا تمنع الرسم |
| الصفحة تتجمد ثواني بعد نقرة                 | تخطيط إجباري متكرر أو JavaScript طويل       | لوح الأداء: أحداث Layout بنفسجية متكررة مع Forced reflow | فصل القراءة عن الكتابة، أو حساب العرض في CSS                  |
| التمرير متقطع في صفحة طويلة                 | تخطيط ورسم لكل الصفحة، أو DOM كبير جداً     | Paint flashing في لوح Rendering، وعدد عقد DOM            | content-visibility: auto، والقوائم الافتراضية                 |
| حركة تتقطع عندما تكون الصفحة مشغولة         | الحركة على الخيط الرئيسي                    | تتبع أداء أثناء الحركة: Layout وPaint في كل إطار         | الحركة بـ transform وopacity                                  |
| الصفحة بطيئة على الجوال فقط وتستهلك الذاكرة | طبقات كثيرة أو صور كبيرة                    | Layer borders، ولوح Layers في أدوات المطور               | إزالة will-change غير الضروري، وتصغير الصور                   |
| تغيير بسيط في كلاس يسبب بطئاً واضحاً        | حساب الأنماط على عدد كبير من العناصر        | Recalculate Style في التتبع وعدد العناصر المتأثرة        | تغيير الكلاس على أقرب أب، وتبسيط المحددات                     |

وإذا كنت تستعد لمقابلة عمل في هذا المجال، فسؤال «ماذا يحدث عندما تكتب رابطاً في المتصفح؟» وسؤال «ما الفرق بين Reflow وRepaint؟» من الأسئلة المتكررة، وقد جمعنا أمثالها مع إجاباتها في مقال [أسئلة مقابلة مطور Frontend](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-frontend-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/)، وهذا المقال يعطيك الجزء الثاني من الإجابة بعد ما شرحناه عن الشبكة في مقال 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 في ثانيتين.
- عند القياس اعتمد على عدد مرات التخطيط وحساب الأنماط، وابدأ كل قياس من نفس الحالة، لأن الوقت وحده يتغير مع حمل الجهاز.

وهذا المقال هو الأساس الذي تبنى عليه بقية مرحلة [هندسة الواجهات](https://arabroot.io/start/#stage-web-frontend)، ففي المقالات التالية سوف نتحدث بإذن الله عن حلقة الأحداث في JavaScript وما يحدث بين المهام والإطارات، وعن تخطيط الصفحات العربية بـ Flexbox وGrid والخصائص المنطقية، وعن التعريب والنص ثنائي الاتجاه، ومعها المقالات المنشورة التي يكملها هذا المقال:

[![](https://arabroot.io/content/images/2026/10/js-event-loop-cover-cover.webp)دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.](https://arabroot.io/articles/%D8%AD%D9%84%D9%82%D8%A9-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-event-loop-%D9%81%D9%8A-javascript/)[![](https://arabroot.io/content/images/2026/10/css-layout-rtl-cover-cover.webp)دليلCSS للعربية واتجاه RTL: الخصائص المنطقية Logical Propertiesكيف تكتب ملف CSS واحداً يعمل بالعربية والإنجليزية، ولماذا تخطئ margin-left في الصفحة العربية، مع قياسات حقيقية من Chrome.](https://arabroot.io/articles/css-%D9%84%D9%84%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-%D8%A7%D9%84%D8%B9%D8%B1%D8%A8%D9%8A%D8%A9-%D8%A7%D9%84%D8%AE%D8%B5%D8%A7%D8%A6%D8%B5-%D8%A7%D9%84%D9%85%D9%86%D8%B7%D9%82%D9%8A%D8%A9-%D9%88-rtl/)[![](https://arabroot.io/content/images/2026/10/i18n-l10n-arabic-cover-cover.webp)دليلالتعريب i18n للمطورين: لماذا ينقلب النص العربي في الواجهة؟لماذا ينقلب النص المختلط في الواجهة العربية وكيف تعزله، وكيف تنسق الأرقام والتواريخ وصيغ الجمع العربية بأدوات المتصفح نفسها.](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%B9%D8%B1%D9%8A%D8%A8-i18n-%D9%88%D8%A7%D9%84%D9%86%D8%B5-%D8%AB%D9%86%D8%A7%D8%A6%D9%8A-%D8%A7%D9%84%D8%A7%D8%AA%D8%AC%D8%A7%D9%87-bidi-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/http-basics-cover-cover.webp)دليلHTTP وHTTPS ببساطة: ماذا يحدث بين الرابط والصفحةماذا يحدث بين كتابة الرابط وظهور الصفحة، وكيف تقرأ الطلب والرد ورموز مثل 502 و504، ولماذا يكلف الاتصال الجديد أكثر.](https://arabroot.io/articles/http-%D9%88-https-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/accessibility-basics-cover-cover.webp)دليلسهولة الوصول Accessibility: موقع وتطبيق يستخدمه الجميعكيف تبني صفحات وتطبيقات يستخدمها الجميع بلوحة المفاتيح وقارئ الشاشة، من عناصر HTML الأصلية إلى النوافذ والنماذج العربية.](https://arabroot.io/articles/%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%85%D9%88%D9%82%D8%B9%D8%A7-%D9%8A%D8%B3%D8%AA%D8%AE%D8%AF%D9%85%D9%87-%D8%A7%D9%84%D8%AC%D9%85%D9%8A%D8%B9/)[![](https://arabroot.io/content/images/2026/10/interview-frontend-cover-cover.webp)دليلأسئلة مقابلة مطور Frontend من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور Frontend، لكل سؤال إجابة المبتدئ وإجابة الخبير وأسئلة متابعة، مع تمارين عملية شغلناها فعلاً.](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-frontend-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/)[![](https://arabroot.io/content/images/2026/10/e2e-playwright-cover-cover.webp)دليلاختبارات E2E مع Playwright: من أول اختبار إلى CIكيف تكتب اختبارات متصفح ثابتة لموقع عربي مع Playwright، وتشخص الاختبار المتقلب، وتقسمها على أكثر من جهاز في خط CI.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-e2e-%D9%85%D8%B9-playwright-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/)

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

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Google Chrome 156.0.8078.49 بدون واجهة عبر Chrome DevTools Protocol مع Node.js 22.14.0.