تخطَّ إلى المقال

الحركة نُشِر زمن القراءة 12 د

التمرير الناعم مقيسًا: الكلفة في الإقامة، لا في زمن الإطار

زمن الإطار أثناء التمرير محور خاطئ للقياس، والأرقام هنا تسير في الاتجاه المعاكس. ما يكلّفه مُنعِّم التمرير فعلًا هو الإقامة (residency) — الإطارات التي تظلّ الصفحة تجدولها بعد أن يرفع الزائر يده عنها — وفي هذا الموقع تبيّن أن آخر إطار مقيم لا يخصّ المُنعِّم أصلًا.

باختصار

بالقياس على هدفَي هذا المستودع، كلفة التمرير الناعم ليست زمن الإطار أثناء التمرير: المرشَّح المُدار بـ Lenis نفّذ دفقة عجلة بمقدار 1,200 px بمتوسط إطار 8.54 ms، والمرجع غير المُعاد بناؤه نفّذ الدفقة نفسها عند 49.17 ms، وهذه الفجوة هي ما رسمته الصفحة من أشياء أخرى، لا معامل lerp. أمّا الكلفة التي تُنسَب إلى المُنعِّم فعلًا فهي الإقامة — استدعاءات rAF التي تجدولها الصفحة بينما لا أحد يلمسها، 134.3 في الثانية على المرجع مقابل 0 على المرشَّح المُودَع في 2026-08-20 — يضاف إليها المستمعات التي تستطيع فعلًا أن تحجب التمرير، ثمانية تسجيلات صريحة غير سلبية في الصفحة الرئيسية القديمة مقابل أربعة في البناء الجديد، أربعتها من المكتبة نفسها. وكلاهما قابل للإزالة دون التفريط في الإحساس. أمّا التدقيق الأخير: حين أُغلقت بوّابة المُنعِّم حتى الصفر، كان الإطار الباقي مقيمًا يخصّ GSAP ScrollTrigger، لا Lenis.

الخلاصة

  1. قِس الإقامة لا زمن الإطار: الرقم الذي يفصل مُنعِّم تمرير مُدمَجًا بإتقان عن آخر مُدمَج بسوء هو عدد استدعاءات rAF التي تجدولها الصفحة بينما لا أحد يلمسها، 134.3 في الثانية مقابل 0 هنا.
  2. حدث scroll غير قابل للإلغاء، فراية السلبية عليه بلا أثر — ولا يستطيع حجب التمرير إلا touchstart وtouchmove وwheel وmousewheel، وفي الصفحة الرئيسية القديمة ثمانية تسجيلات تفعل ذلك بالضبط.
  3. نعِّم إزاحة تمرير المستند الحقيقية لا غلافًا مُحوَّلًا، لأن هذا وحده ما يجعل كل قراءة getBoundingClientRect وكل تثبيت لاصق في الصفحة يرث التنعيم مجانًا.
  4. البوّابة التي تُغلق يجب أن تعيد ضبط ساعة المكتبة حين تُفتح: فـ Lenis يخمّد اعتمادًا على فارق زمني يخزّنه بنفسه، وبعد توقّف عشرين ثانية يُنهي ذلك الفارق الحركة كلّها في إطار واحد.
  5. آخر إطار مقيم في هذه الصفحة يخصّ مكتبة الحركة لا المُنعِّم — وإزالة Lenis لن تزيل استدعاء rAF الذي يعيد ScrollTrigger جدولته لنفسه، وهو يأتي بلا أي خيار لتعطيله.
  6. اكتب مصدر الرقم إلى جانبه: رقم اقتُبس في ستة ملفات هنا بوصفه كلفة مستمع تبيّن أنه طابع زمني للتسجيل قُرئ من العمود الخطأ.

tests/golden/scroll-trace.json · 2026-08-20

هل يضرّ التمرير الناعم بالأداء؟

السؤال يُطرح دائمًا تقريبًا عن الكمّية الخطأ. زمن الإطار أثناء التمرير هو ما يقيسه الناس، وعلى هدفَي هذا المستودع جاءت النتيجة معكوسة: المرشَّح المُدار بـ Lenis نفّذ دفقة عجلة بمقدار 1,200 px بمتوسط إطار 8.54 ms، بينما نفّذ المرجع غير المُعاد بناؤه الدفقة نفسها عند 49.17 ms. المرجع يرسم اثنتي عشرة لوحة canvas ومشهد WebGL؛ والمرشَّح، يوم أُخذ ذلك الأثر، لم يرسم شيئًا. الفارق هو الصفحة، لا معامل lerp.

أمّا الكلفة التي تخصّ مُنعِّم التمرير حقًا فهي الإقامة — ما تظلّ الصفحة تطلبه من المتصفّح بعد أن يتوقّف الزائر. ويختصر ذلك في رقم واحد: عدد استدعاءات rAF في الثانية والصفحة واقفة لا يلمسها شيء، والأداة تسعها جملة واحدة: أداة التتبّع تلفّ window.requestAnimationFrame بعدّاد يمرّر إلى التنفيذ الأصلي، وتنتظر ثلاثة آلاف مللي ثانية، ثم تقسم على ثلاثة.

الأثر الذي خرجت منه تلك الأرقام أُودِع في 2026-08-20 ويحمل طابعًا زمنيًا فارغًا للالتقاط، فتاريخ الإيداع هو التاريخ الوحيد الذي يستطيع هذا المقال أن يذكره بأمانة. وهذا مهمّ مرّتين، لأن المرشَّح الذي قِيس يومها لم يكن فيه GSAP ولا WebGL ولا أي لوحة canvas — وهذا نصف سبب قراءة زمن إطاره على هذا النحو.

  • 8.54 ms متوسط الإطار، المرشَّح أثناء دفقة العجلة
  • 49.17 ms متوسط الإطار، المرجع اثنتا عشرة لوحة canvas ومشهد WebGL
  • 134.3 استدعاءات rAF في الثانية عند السكون، المرجع متوقّفة في منتصف المستند، بلا لمس
  • 0 استدعاءات rAF في الثانية عند السكون، المرشَّح المسبار نفسه، والتشغيلة نفسها، ومُنعِّم مُغلَق ببوّابة
دفقة عجلة بمقدار 1,200 px، ثم ثلاث ثوانٍ من السكون، على الهدفين معًا. مقروءة من tests/golden/scroll-trace.json، المُودَع في 2026-08-20.

لا رقم من رقمَي زمن الإطار قياسٌ للمنتج، والمستودع صريح في ذلك: أدوات القياس الذهبية تعمل تحت مُصيِّر برمجي مفروض، حيث تحوم المسارات غير الـ GL حول 120 Hz والمسارات الـ GL حول 16 Hz، وأرقام الـ Hz أثر من آثار تلك الأدوات ولا يجوز أبدًا اقتباسها بوصفها أداء المنتج. ما يبقى بعد التحفّظ هو المقارنة — هدفان، وجهاز واحد، ومنهج واحد.

src/runtime/scroll/lenis-options.js:26-141

بماذا تتنبّأ الإعدادات فعلًا؟

إعدادات التمرير كلّها في هذا الموقع ثمانية خيارات مذكورة في كائن واحد مُجمَّد، لكلٍّ منها مرجعه في سطره الخاص، يضاف إليها جدول من عشرة صفوف لخيارات لم تُمرَّر عمدًا — مُصدَّر كي يستطيع اختبارٌ التحقّق من النفي بدل الوثوق ببقاء تعليق صادقًا. والثمانية هي lerp 0.1 وsmoothWheel true وsyncTouch false وtouchMultiplier 1.25 وautoResize true وautoRaf false وanchors false وinfinite false.

والقاعدة التي تحكم هذا الفصل مكتوبة في رأس ذلك الملف، وهي ادّعاء عن الادّعاءات: الخيار الذي يرثه الموقع القديم بوصفه قيمة افتراضية للمكتبة يُورَث هنا كذلك بدل إعادة ذكره، لأن كتابة syncTouchLerp 0.075 تجمّد قيمة تملكها النسخة المثبَّتة. فإعادة ذكر قيمة افتراضية تحوّل «نحن نقدّم سلوك Lenis 1.1.20» إلى «نحن نقدّم قراءتنا لسلوك Lenis 1.1.20»، وهو ادّعاء مختلف وأسوأ. ومع ذلك ذُكرت ثلاث قيم صراحةً — autoRaf وanchors وinfinite — لأن كلًّا منها قرار لا قبول.

وقيمة lerp 0.1 هي ما يجعل الإحساس حسابًا لا رأيًا. المكتبة تخمّد بمقدار واحد ناقص الأُسّي لسالب lerp مضروبًا في ستين مضروبًا في فارق زمن الإطار، فيكون المنحنى واحدًا ناقص e أُسّ سالب ستة t، وتخرج منه المحطّات: نصف المسافة عند ln 2 على ستة، أي 116 ms، وتسعون بالمئة عند 384 ms، وتسعة وتسعون بالمئة عند 767 ms. وهذه تنبّؤات وُضعت قبل تشغيل التتبّع.

دفقة العجلة نفسها بمقدار 1,200 px على الهدفين، في مقابل ما يتنبّأ به lerp 0.1. والمحطّات بالمللي ثانية لأن الهدفين لا يشتركان في معدّل إطارات.
المحطّة تنبّؤ lerp 0.1 المرشَّح المرجع
50 بالمئة من المسافة 116 ms 141 ms 275 ms
90 بالمئة 384 ms 408 ms 559 ms
99 بالمئة 767 ms 766 ms 950 ms
متوسط الإطار أثناء الدفقة لا تنبّؤ 8.54 ms 49.17 ms
الموضع النهائي بعد 1,200 px لا تنبّؤ 1,197 px 1,200 px

المرشَّح يتأخّر 25 ms عن النظرية عند منتصف المسافة، ويسبقها بمللي ثانية واحدة عند تسعة وتسعين بالمئة. والمرجع أبطأ عند كل محطّة رغم تشغيله معامل lerp نفسه، وهذا أوضح ما في التتبّع: ما يشعر به الزائر هو ما ترسمه الصفحة، لا منحنى التنعيم. وقارِن الاثنين بالإطارات بدل المللي ثانية، يُبلَّغ عن المنحنى المتطابق نفسه بوصفه فرقًا بأربعة أضعاف، في الاتجاه الخطأ — ولهذا تسجّل أداة التتبّع عدد الإطارات وذروة البكسلات لكل إطار دون أن تقارنها يومًا بين الهدفين.

a15 §4.3 passivity census · 433 registrations

كم مستمعًا يستطيع فعلًا أن يحجب التمرير؟

«اجعل مستمعات التمرير سلبية» نصيحة يكرّرها الجميع، وهي على حدث scroll طقسٌ بلا معنى. فحدث scroll كائن Event عام وغير قابل للإلغاء — لا فعل افتراضي يُمنَع — فالمستمع غير السلبي عليه لا يحجب شيئًا على الإطلاق. السلبية (passive) لا تغيّر سلوك المتصفّح إلا في الأحداث التي كانت ستوقف التمرير لو أُلغيت: touchstart وtouchmove وwheel وmousewheel.

الموقع القديم المفحوص هنا يسجّل أحد عشر مستمعًا لحدث scroll في صفحته الرئيسية، وأربعة منها تُذكَر بوصفها غير سلبية. ولم يُسجَّل أيٌّ من الأربعة بـ passive false. هي تسجيلات بسلبية افتراضية — قيمة منطقية مجرّدة أو وسيط ثالث غائب — ولو كانت صريحة لما غيّر ذلك شيئًا. أمّا التسجيلات التي تحجب التمرير فعلًا فهي في مكان آخر من تلك الصفحة، وعددها ثمانية.

touchstart 4
touchmove 3
wheel 1
scroll 0
التسجيلات الصريحة بـ passive false في الصفحة الرئيسية القديمة، بحسب نوع الحدث. والصفر في صفّ scroll هو الحجّة كلّها.docs/evidence/legacy-forensics/a15-template-deps-perf.md:896-918، من أصل 433 تسجيل مستمع في مسار الصفحة الرئيسية.

أثر المُنعِّم نفسه أصغر من الحكاية الشائعة، ويُقرأ في ثابت واحد. المصدر المثبَّت يكتب في متنه كائن خيارات مستمع واحدًا بـ passive false ويستعمله في أربعة تسجيلات بالضبط — wheel وtouchstart وtouchmove وtouchend على العنصر الجذر — وهذه يجب أن تكون غير سلبية، لأن استدعاء preventDefault على حدث wheel هو ما يجعل التنعيم موجودًا أصلًا. وهناك مستمع خامس، scroll على الغلاف، مُسجَّل بقيمة false مجرّدة، ومعالجه لا يحوي أي استدعاء لـ preventDefault من أي نوع. كل أداة تدقيق تعدّه. ولا شيء يفعله يمكن أن يحجب.

مسبار البناء الجديد يُبلغ عن ستة مستمعات لـ scroll وwheel واللمس، أربعة منها غير سلبية، مقابل تسعة وثلاثين وسبعة وعشرين على المرجع. ورقما اللاسلبية كلاهما سقف لا عدّ، والمستودع يقول ذلك في تحفّظاته: المسبار يقرأ السلبية من كون الوسيط الثالث كائنًا فحسب، فتُحصى القيمة المنطقية المجرّدة غير سلبية حتى حيث يطبّق المتصفّح قاعدته في السلبية الافتراضية. فالسبعة والعشرون حدّ أعلى على المرجع؛ والثمانية هو الرقم القابل للدفاع عنه.

وهذا يقودنا إلى أنفع ما في هذه الصفحة، وهو خلل في نصّ هذا المستودع نفسه. ستة ملفات هنا تقول إن اثنين من معالجات التمرير غير السلبية في الموقع القديم يكلّفان نحو 1,470 مللي ثانية. وهما لا يكلّفان ذلك. الرقم 1,469.8 هو المللي ثانية التي سُجِّل عندها آخر المستمعات الأحد عشر، مقروءًا من جدول عموده الأول عنوانه t بالمللي ثانية — طابع زمني لتسجيل، لا كلفة، ولا يوجد في المجموعة كلّها أي قياس لكلفة تلك المعالجات. والقرار الهندسي المرتبط بالرقم ما زال صحيحًا، لأن تلك المعالجات لا تمنع شيئًا وينبغي أن تكون سلبية. أمّا الرقم المرتبط بالقرار فلا يقيس شيئًا.

scroll-controller.js:53-70, :505-522

لماذا ينام المُنعِّم، وما الذي انكسر حين تعلّم النوم؟

لا يلغي Lenis 1.1.20 اشتراكه عند السكون. وإذا تُرك وشأنه أخذ إطارًا كل ستة عشر مللي ثانية ليستكمل موضعًا توقّف عن الحركة، وهذه بالضبط هي الإقامة التي يدور عليها القياس أعلاه. قطعتان تزيلانها: ساعة الإطارات ترفض جدولة إطار حين لا يكون مشتركًا فيها شيء قابل للتشغيل، ومالك التمرير يفتح اشتراكه عند أول إدخال ويغلقه بعد اثني عشر إطارًا ساكنًا من استقرار الموضع.

والثابتان كلاهما مُعلَن في الشيفرة نفسها أنه مُختار لا مقيس، والصراحة هي المقصد. فـ 0.05 px دون أصغر حركة يستطيع أي مستهلك في هذا الموقع أن يتصرّف بناءً عليها، واثنا عشر إطارًا نحو مئتَي مللي ثانية عند 60 Hz — أي بعد ذيل حركة lerp 0.1 بمسافة مريحة، وهي حركة تفقد نحو تسعة وثمانين بالمئة من مسافتها المتبقّية كل اثنين وعشرين إطارًا. والملف يذكر أن كليهما يصير رقمًا مقيسًا يوم يُسجَّل أول أثر لإحساس التمرير.

  1. في السكون، لا اشتراك أصلًا الصفحة المُقلَعة حديثًا لا تحجز أي إطار، عمدًا: لا شيء يُنعَّم قبل أن يتحرّك أحد، والاستيقاظ عند الإقلاع يحرق عشرة إطارات أو أكثر لإثبات أن الموضع ما زال صفرًا. واختبارٌ يتحقّق من أن الساعة فارغة بعد التهيئة.
  2. الإدخال يفتح البوّابة wheel وtouchstart وkeydown وresize، كلّها مُسجَّلة سلبية، يضاف إليها باعث scroll الخاص بالمكتبة — وهو ينطلق للتمريرات التي لم يسبّبها، فسحب شريط التمرير أو لوحة المفاتيح أو مفتاح End يوقظها هو الآخر.
  3. أعِد ضبط ساعة المكتبة أعِد زمنها المخزّن إلى الصفر قبل تشغيل أول إطار. أغفِل هذا يُسلَّم المُنعِّم فارقًا زمنيًا بطول التوقّف، فتهبط الحركة المُنعَّمة قفزةً فورية.
  4. اشترك على طورين الموضع صفر من طور القراءة يقدّم المُنعِّم؛ وطور الحساب ينشر الموضع ويشغّل البوّابة. والموقع القديم يشتري هذا الترتيب براية منطقية على مؤقّت الحركة. أمّا هنا فالترتيب بنيوي، لأن الراية يمكن أن تُنسى والطور لا يُنسى.
  5. قِس كل إطار الانشغال يعني أن المكتبة تُبلغ عن نفسها بأنها تمرّر، أو أن الموضع تحرّك أكثر من 0.05 px. وأي إطار مشغول يعيد عدّاد الخمول إلى الصفر.
  6. نَم بعد اثني عشر إطارًا خاملًا يُغلق الاشتراكان معًا. وعندها تتوقّف الساعة عن الجدولة من تلقاء نفسها، لأن عدد مشتركيها الأحياء بلغ صفرًا ومجدولها يعود مبكرًا.
بوّابة الخمول كما تعمل، من صفحة لم يلمسها أحد إلى صفحة عادت إلى الهدوء. src/runtime/scroll/scroll-controller.js.

الخطوة الثالثة موجودة لأن إغلاق الاشتراك كسر الإحساس، وقِيس الكسر قبل إصلاحه. المكتبة تحسب فارقها الزمني بطرح الطابع الذي خزّنته في الإطار السابق من الطابع الحالي، وبينما ينام الاشتراك لا شيء يقدّم ذلك الطابع المخزّن — فيسلّم أول إطار بعد الاستيقاظ دالّةَ التخميد كامل المدّة التي جلس فيها الزائر ساكنًا. وعند توقّف مدّته عشرون ثانية يكتمل الأُسّي في إطار واحد. والقياس قبل الإصلاح المكوَّن من سطر واحد: عجلة بمقدار 1,200 px وصلت في أربعة إطارات بذروة 1,200 px في الإطار، وهذا ليس تنعيمًا بحال.

والإصلاح يعيد الزمن المخزّن في المكتبة إلى الصفر قبل أول إطار بعد الاستيقاظ، ويثبّته اختبار وحدة: الإطار الأول يتقدّم صفر بكسل بالضبط، والثاني يتقدّم نحو 114 px من أصل 1,200. والتعليق فوق ذلك السطر هو خلاصة هذا المقال كلّه في جملة واحدة — المكتبة التي تملك حلقتها الخاصة لا تواجه هذه المشكلة أبدًا لأنها لا تنام أبدًا، وعدم النوم هو الكلفة التي وُجدت هذه البوّابة لتفاديها.

التنقّل بالمرابط هو النصف الآخر من امتلاك موضع التمرير، وهو حيث تكسر معظم عمليات دمج التمرير الناعم لوحةَ المفاتيح بصمت — التمرير الناعم، والتركيز، والتاريخ.

src/vendor/lenis/lenis.mjs:585-601 · P11-04

ماذا يعيد إليك إطفاء تنعيم اللمس؟

قُلِب syncTouch من true إلى false في هذا الموقع اليوم، والسبب خلل لا تفضيل. العَرَض المُبلَّغ عنه أن الصفحة تبدو مقصوصة من أعلاها وأسفلها على Safari في iOS، وأن واجهة المتصفّح تتصرّف على غير ما تتصرّف به في كل موقع آخر. أمّا الإصلاح البديهي — تغيير وحدات إطار العرض — فكان سيعالج العَرَض ويترك السبب يعمل.

ونصف التشخيص العائد إلى المكتبة قابل للتحقّق سطرًا سطرًا في الملف المثبَّت، المُودَع بايتًا بايتًا ببصمة SHA-256 يفحصها البناء. فمع syncTouch true يجعل حدث اللمس فرعَ التنعيم صحيحًا، فيستدعي المعالج preventDefault، وتقود المكتبة الموضع بنفسها. وكل touchmove على الهاتف يُلغى.

الفرع الذي يدور عليه قرار اللمس كلّه، مقروءًا من المكتبة المثبَّتة في src/vendor/lenis/lenis.mjs. وأرقام الأسطر أرقام الملف نفسه.
204  var listenerOptions = { passive: false };
 
211  el.addEventListener('wheel',      onWheel,      listenerOptions);
212  el.addEventListener('touchstart', onTouchStart, listenerOptions);
217  el.addEventListener('touchmove',  onTouchMove,  listenerOptions);
222  el.addEventListener('touchend',   onTouchEnd,   listenerOptions);
 
448  wrapper.addEventListener('scroll', onNativeScroll, false);
     // الوسيط الثالث هو قيمة الالتقاط المنطقية، فالسلبية هي الافتراضية —
     // وonNativeScroll (:631-660) لا يحوي preventDefault البتّة
 
585  const isSmooth = options.syncTouch  && isTouch
                   || options.smoothWheel && isWheel;
586  if (!isSmooth) {
587    this.isScrolling = 'native';
588    this.animate.stop();
589    event.lenisStopPropagation = true;
590    return;
591  }
 
601  event.preventDefault();   // لا يُبلَغ إلا حين تكون isSmooth صحيحة

أمّا ما يكلّفه touchmove مُلغى فتشخيصٌ من هذا المستودع، وينبغي أن يُقرأ على أنه كذلك. Safari يسحب شريط عنوانه استجابةً لإيماءة تمرير من المستخدم؛ والتمرير البرمجي ليس إيماءة؛ فلا ينسحب الشريط أبدًا، ويصير كل صندوق مقاسه بوحدة vh مقاسًا على إطار العرض الكبير — وهو ارتفاع لا تملكه الصفحة إلا بعد ذهاب الشريط. ولم يظهر أي نصّ أصلي من Apple أو WebKit يقرّر تلك القاعدة. الآلية داخل المكتبة تُقرأ في المصدر؛ أمّا سلوك المتصفّح الذي تعتمد عليه فمُبلَّغ عنه، ومُلاحَظ على نطاق واسع، وغير موثَّق من المُصنِّع.

ومع syncTouch false تسلك الشيفرة نفسها الفرع عند السطر 586، فتُعلِم التمرير بأنه أصلي، وتوقف الحركة، وتعود دون أن تمنع شيئًا. فتستعيد المنصّة زخمها، وارتدادها المطاطي، وشريط عنوانها، وإعدادات إمكانية الوصول فيها، ويبقى تنعيم العجلة كما هو لأن smoothWheel خيار منفصل وما زال true. ولاحظ ما لا يتغيّر: تلك التسجيلات الأربعة ما زالت غير سلبية. الذي يتغيّر هو هل يُستدعى preventDefault أصلًا أم لا.

وشيء واحد وجب إعادة بنائه يدويًا بعد ذلك. على الهاتف، شريط المشاريع مسارٌ لاصق يُمسَح بالتمرير، يُقرأ على أنه شيء تسحبه بإصبعك ولم يكن كذلك، فكان الإصبع الجانبي لا يفعل شيئًا البتّة. والبديل هو المستمع الوحيد غير السلبي صراحةً الذي يضيفه هذا الموقع خارج المُنعِّم: معالج touchmove يمنع فقط ما دام مزلاج المحور الأفقي مُشتبكًا، بعد ثمانية بكسلات من الهامش وداخل مخروط قدره نحو ثلاثين درجة — وهو يحرّك الصفحة لا الشريط، فلا يجتمع مالكان أبدًا على موضع بصري واحد.

والشريط الذي تقوده تلك الإيماءة يقع في فهرس المشاريع، حيث يُطبَّق انضباط القياس نفسه على عشرة أعمال.

src/vendor/gsap/ScrollTrigger.js:61-64

إذا كان المُنعِّم لا يحجز إطارًا، فما الذي ما زال يعمل؟

الصفر عند السكون صحيح على البناء الذي قِيس عليه، وذلك البناء أقدم بخمس وتسعين دقيقة من يوم أُدخل GSAP إلى هذه الشجرة. أمّا في الصفحة كما هي اليوم فالرقم الأمين ليس صفرًا. إنه واحد، وهذا الواحد لا يخصّ المُنعِّم.

يبدأ ScrollTrigger عند تفعيله استدعاء requestAnimationFrame فارغًا يعيد جدولة نفسه، ويشغّله طوال عمر الصفحة، ومعه تعليق المكتبة نفسها يقول إنه موجود لأن إعادة رسم الشاشة لم تكن متّسقة في بعض المتصفّحات ما لم يكن هناك شيء في الطابور. ولا يوجد خيار لتعطيله. وإلى جانبه setInterval يعمل أربع مرّات في الثانية. وإزالة Lenis لن تزيل أيًّا منهما.

ScrollTrigger، حلقة إعادة الرسم
دالّة فارغة تعيد لنفسها استدعاء requestAnimationFrame جديدًا، تبدأ داخل ScrollTrigger.enable ولا تتوقّف أبدًا. والمكتبة لا تقدّم أي راية للتحكّم بها.
ScrollTrigger، فاصل المزامنة
استدعاء setInterval كل 250 ms — أربع مرّات في الثانية طوال عمر الصفحة، عند ScrollTrigger.js:2110.
ساعة الإطارات
لا شيء. مجدولها يعود مبكرًا حين يكون عدد المشتركين الأحياء صفرًا، ولهذا تتوقّف الصفحة المتدهورة كليًّا هي الأخرى بدل أن تظلّ خاملة بأدب.
مالك التمرير
لا شيء، بعد اثني عشر إطارًا ساكنًا من آخر حركة — ولا شيء البتّة في صفحة لم يلمسها أحد بعد، يتحقّق من ذلك اختبار يفحص أن الساعة فارغة بعد التهيئة.
الميزانية التي تحفظ الحدّ
استدعاء واحد على الأكثر من مكتبة مُضمَّنة في كل إطار مع تركيب كل أنظمة المرحلة 13، مسجَّل في مصدر بوّابة السكون نفسها بوصفه رقمًا لكل إطار قدره 1.15 أو أقل.
ما الذي يحجز إطارًا في هذه الصفحة بعد أن يتوقّف الزائر، وما هو كلٌّ منها.

ولهذا أيضًا لا يدّعي البناء الجديد أن لديه حلقة إطارات واحدة. إنه يعلن مصدرَي إطارات بعلاقة مذكورة بينهما: ساعة الإطارات، وهي تملك كل استدعاء rAF من الطرف الأول، ومؤقّت GSAP، وهو يملك تقدّم مكتبة الحركة نفسها ويحتفظ به. ولم يُفرَض GSAP على الساعة، وهذا قرار لا إغفال — فإنشاء أي حركة يوقظ المؤقّت من داخل المكتبة، فلا يصمد تنويمه، والدالّة التي تقوده من الخارج ليست واجهة برمجية عامة.

وما يمنع المصدر الثاني من أن يصير حلقة ثانية دائمة ثلاث خصائص مُتحقَّق منها لا وعد: خاصية autoSleep في المؤقّت نفسه، وأن ScrollTrigger لا يضيف مستمعًا إلى المؤقّت إلا على Safari اللمسي، وميزانية سكون مقيسة عند النقطة التي يُفتَح فيها أكبر عدد من الأنظمة معًا في الصفحة. والغاية من تسمية هذا كلّه أن جواب سؤال «هل يضرّ التمرير الناعم بالأداء؟» في هذه الصفحة هو أن المُنعِّم لا يحجز شيئًا، وأن شيئًا آخر يحجز.

ADR-008 · alternatives considered

هل كان التمرير الأصلي أو مُنعِّم بالتحويل أرخص؟

الخاصية التي حسمت الأمر تسعها جملة واحدة: القيمة المُنعَّمة هي إزاحة تمرير المستند. ولأن المكتبة تحرّك النافذة الحقيقية، فإن كل قراءة getBoundingClientRect في الصفحة وكل تثبيت لاصق يرث التنعيم مجانًا، ويتّفق عشرة قرّاء مستقلّين دون أن يُطلب منهم ذلك. وبالقياس، ساوت window.scrollY قيمةَ التمرير التي تُبلغ عنها المكتبة في نقاط التحقّق السبع والعشرين جميعها عبر ثلاثة مسارات، والسرعة صفر عند كل واحدة منها.

وكل بديل يُنعِّم شيئًا آخر مضطرّ إلى إعادة بناء ذلك الاتّفاق صراحةً، في كل مستهلك، وإلى الأبد. وهذا هو المحور الذي رُتِّب عليه الجدول أدناه — الميكانيكا، لا الذوق.

البدائل، محسومة على الميكانيكا. والعمود الأخير هو ما يُقاس عليه كل ادّعاء أو يُوثَّق به.
المقاربة ما الذي تحرّكه مقيس أو موثَّق
CSS scroll-behavior smooth التنقّل بالمرابط وواجهات تمرير CSSOM فحسب؛ تمريرات المستخدم لا تُمَسّ، والمنحنى يحدّده وكيل المستخدم Baseline منذ مارس 2022
CSS scroll-driven animations تربط حركة بموضع تمرير. ميزة مختلفة، لا تنعيم lerp للعجلة 85.43 بالمئة دعم عالمي
GSAP ScrollSmoother تحويل على محتوى غلاف عبر matrix3d، ينشئ كتلة احتواء جديدة ويعيد تعريف كل قراءة مستطيل في الصفحة 3 سلاسل دفاعية في حزمة الموقع القديم، بلا إضافة
Locomotive Scroll v5 تخلّى عن حاوية التمرير الافتراضية الخاصة به، وأُعيد بناؤه على Lenis، وحُذف تنفيذه المخصّص للتثبيت اللاصق دليل الترقية إلى v5
Lenis على الإزاحة الحقيقية إزاحة تمرير المستند نفسها، وهي ما يقرأه كل مستهلك أصلًا 27 من 27 نقطة تحقّق متّفقة

ويبقى خيارًا أخيرًا أن تكتب حلقة تخميد من ثمانين سطرًا بنفسك، وهي أرخص حتى الأسبوع الثاني فقط. فما تتخلّى عنه هو الاستثناء عبر المسار المركَّب، والتوفيق مع التمريرات الأصلية التي ما زالت المكتبة تستقبلها — سحب شريط التمرير، ولوحة المفاتيح، وHome وEnd — والارتداد المطاطي عند طرفَي المستند، وآلة حالات تقرأها بقيّة الصفحة.

ومسار تقليل الحركة هو حيث يُبرهَن على الاختيار لا يُجادَل فيه. فتحت prefers-reduced-motion reduce لا ينشئ هذا الموقع مُنعِّمًا أصلًا — لا مبنيًّا ثم مهدومًا، ولا موقوفًا — ومستمع التمرير الوحيد المُركَّب في ذلك الوضع سلبي صراحةً، لأن معالجه لا يستدعي preventDefault أبدًا، ولأن تسجيله على غير ذلك كلفة خالصة على الخيط الرئيسي. والتتبّع يقرأ أصناف العنصر الجذر عند ستة أحجام عرض فلا يجد صنف المُنعِّم في أيٍّ منها.

وهذه البوّابة موجودة لأن مسار تقليل الحركة في الموقع القديم كان كارثة مقيسة. فهو يحترم التفضيل باستطلاع ستمئة إطار بحثًا عن متغيّر عام ثم هدم النسخة، لأن الاستدعاء البديهي — إيقاف المُنعِّم — قِيس فتبيّن أنه يترك الصفحة غير قابلة للتمرير بتاتًا: العجلة تحرّكها صفر بكسل، ومفتاح End يحرّكها صفر بكسل. أمّا البناء الجديد فيثبت العكس بالعجلة وبمفتاح End عند أحجام العرض الستة جميعها، وعند كل واحد منها يبلغ مفتاح End أسفلَ المستند تمامًا.

وتقليل الحركة وضعٌ لا مفتاح تشغيل، والنصف الخاص بـ canvas من تلك الحجّة هو ماذا يعني reduce للمُصيِّر.

tools/golden/scroll-trace.mjs, rest.mjs

كيف تقيس هذا في موقعك أنت؟

أمران اثنان ينتجان كل ما سبق. الأول يقود دفقة عجلة بمقدار 1,200 px على الهدفين، ويسجّل المنحنى بالمللي ثانية، وانحدار تقليل الحركة عند ستة أحجام عرض، وكلفة السكون، وسلوك المرابط، وسلبية المستمعات. والثاني يمشي على إحدى عشرة محطّة تمرير، ويستطلع ساعة الإطارات حتى يبلغ عدد مشتركيها الأحياء صفرًا، ثم يقارن النتيجة بقائمة مغلقة بالأقسام المسموح لها بحجز إطار.

والاستطلاع أهمّ ممّا يبدو. فالانتظار الثابت يقيس ما يصادف أن يكون جاريًا: رسم شعار التذييل بناءٌ مدّته 2.8 ثانية، فانتظار استقرار مدّته 1.8 ثانية يمسكه في منتصف الطريق ويُبلغ عن إطار هو حركة لا تسريب. أمّا الاستطلاع حتى الخمول فيفصل نظامًا لا يترك أبدًا عن نظام ما زال يُنهي عمله. وقد ضبطت هذه البوّابة نفسها مرّة — فحصٌ ظلّ ينجح لأن الصفحة كانت بطيئة، ثم بدأ يفشل حين صارت الصفحة أسرع فأعاد تسليح نفسه داخل نافذة كان يفوّتها من قبل.

بالمللي ثانية، لا بالإطارات أبدًا
الهدفان لا يشتركان في معدّل إطارات، والتخميد مستقلّ عن معدّل الإطارات بحكم بنائه. ومقارنة عدد الإطارات بينهما تُبلغ عن منحنى واحد متطابق بوصفه فرقًا بأربعة أضعاف، في الاتجاه الخطأ.
اللاسلبية حدّ أعلى
المسبار يقرأ السلبية من كون الوسيط الثالث كائنًا فحسب، فيزيد في العدّ. فالسبعة والعشرون على المرجع سقف، لا عدّ للحاجبات؛ والرقم القابل للدفاع عنه هو التسجيلات الثمانية الصريحة في إحصاء التدقيق.
كلفة السكون تحتاج ثلاث تشغيلات
ثلاث تشغيلات متطابقة للصفحة القديمة نفسها أعطت 67 و61 و61 استدعاء rAF في نوافذ مدّتها ثانية تقريبًا. وأي مقارنة سكون تحتاج إلى المدى. والرقم 134.3 أعلاه جاء من أداة مختلفة وفي ظرف مختلف، وهو لا يصحّح تلك الأرقام.
المُعلَن ليس مُطبَّقًا
أربع ميزانيات مرشَّحة تقبع في ملف العتبات — استدعاءات السكون عند خمسة في الثانية، ومواضع استدعاء rAF المتمايزة عند اثنين، والمتغيّرات العامة المتسرّبة عند واحد، وأخطاء الطرفية عند صفر — والبحث عن أسمائها في مجلّد الأدوات لا يعيد شيئًا. هي مُعلَنة ولا يقرأها أي فحص.
ما لم يُقَس قطّ
لم يُقَس شيء دون 992 px على أيٍّ من الهدفين، ولا تحوي أيٌّ من المجموعتين رقمًا لـ INP أو CLS أو المهامّ الطويلة أو LCP، لأن المسبار لا يركّب أي PerformanceObserver من أي نوع. وعتبة INP البالغة 200 ms في المصادر أدناه هي المعيار، لا قراءة من هذا الموقع.
التحفّظات التي تجعل هذه الأرقام أمينة لا مُبهرة. وكلٌّ منها مسجَّل في المستودع إلى جانب الرقم الذي يقيّده.

ولكل أداة قياس أرضية ضجيج خاصة بها، وقياس تلك الأرضية مشكلة قائمة بذاتها — أرضية الضجيج في مجموعة اختبارات الانحدار البصري.

والنصف الهندسي من طريقة بناء هذا الموقع مشروح في التخصّصات الأربعة.

أسئلة

كم مستمعًا غير سلبي يركّبه Lenis؟

أربعة، وليست اختيارية. كائن خيارات واحد مكتوب في المتن بـ passive false يُستعمل لـ wheel وtouchstart وtouchmove وtouchend على العنصر الجذر. وهذه يجب أن تكون غير سلبية، لأن استدعاء preventDefault على حدث wheel هو ما يجعل التنعيم موجودًا أصلًا. وهناك مستمع خامس، scroll على الغلاف، مُسجَّل بقيمة false مجرّدة ويعدّه كل مسبار غير سلبي، لكنه لا يحجب شيئًا: فـ scroll غير قابل للإلغاء ومعالجه لا يحوي أي استدعاء لـ preventDefault.

هل يستطيع scroll-behavior في CSS أو الحركات المقودة بالتمرير أن يحلّ محلّ المكتبة؟

لا، في الحالتين. توثيق MDN يحصر scroll-behavior smooth في التنقّل وواجهات تمرير CSSOM، ويقول صراحةً إن التمريرات التي يجريها المستخدم لا تتأثّر، فهو لا يفعل شيئًا للعجلة، والمواصفة تترك المدّة والتخفيف لوكيل المستخدم. أمّا الحركات المقودة بالتمرير فتحلّ مشكلة أخرى — ربط حركة بموضع تمرير — ودعمها العالمي نحو 85 بالمئة. وليس أيٌّ منهما تنعيم lerp للعجلة.

هل المُنعِّم القائم على التحويل خيار مكافئ؟

ليس في صفحة تثبّت بـ sticky وتقيس بـ getBoundingClientRect. توثيق GSAP يقول إن ScrollSmoother يطبّق تحويلات على المحتوى عبر matrix3d، ويحذّر من أن التحويل ينشئ كتلة احتواء جديدة — فتبدأ كل قراءة مستطيل في الصفحة تعني شيئًا آخر. أمّا Lenis فيحرّك إزاحة المستند الحقيقية، ولهذا ترث أي قراءة مستطيل في أي مكان التنعيمَ مجانًا. وقد بلغ Locomotive Scroll الخلاصة نفسها في الإصدار v5، فتخلّى عن حاوية تمريره الافتراضية وحذف تنفيذه الخاص للتثبيت اللاصق.

كيف تقيس العمل عند السكون فعلًا؟

بتزويد requestAnimationFrame بعدّاد وترك الصفحة ساكنة. أداة التتبّع تلفّ الدالّة بعدّاد، وتنتظر ثلاث ثوانٍ بعد إيقاف المؤشّر، ثم تقسم. أمّا بوّابة السكون فتذهب أبعد: تمشي على إحدى عشرة محطّة تمرير وتستطلع عدد المشتركين الأحياء في ساعة الإطارات حتى يبلغ صفرًا، مسجّلةً القراءة النهائية والزمن الذي استغرقته معًا. وهذا الفرق مهمّ — فحركة مدّتها 2.8 ثانية تُقرأ تسريبًا تحت انتظار ثابت، وتُقرأ حركةً تحت استطلاع.

المصادر

مقيس في هذا المستودع

  • src/runtime/scroll/lenis-options.js الخيارات الثمانية المذكورة والعشرة الموروثة عمدًا، لكلٍّ منها سببه في سطره الخاص، والقاعدة التي تقرّر أيّها أيّ.
  • src/runtime/scroll/scroll-controller.js بوّابة الخمول، والثابتان المختاران، ومستمعات الإيقاظ، وسطر إعادة الضبط، ومسار الوضع الأصلي المسلوك تحت تقليل الحركة.
  • tests/golden/scroll-trace.json منحنى العجلة على الهدفين، وكلفة السكون، وأعداد المستمعات، وقراءات تقليل الحركة الست. مُودَع في 2026-08-20 بطابع زمني فارغ للالتقاط.
  • tools/golden/rest.mjs بوّابة السكون بمحطّاتها الإحدى عشرة، ومنهج الاستطلاع حتى الخمول الذي حلّ محلّ الانتظار الثابت، وميزانية استدعاء واحد من مكتبة مُضمَّنة لكل إطار.
  • src/vendor/lenis/lenis.mjs المكتبة المثبَّتة، المُودَعة بايتًا بايتًا ببصمة يتحقّق منها البناء: ثابت المستمع، وفرع التنعيم، والعودة الأصلية مقروءة منها مباشرة.

مُقابَل بـ

Alaa Abbod

بقلم

علاء عبّود

مطوّر إبداعي — هيرنه، ألمانيا

مصمّم ومطوّر يبني مواقع ويب سهلة الوصول وتطبيقات جوال ومتاجر إلكترونية وهويات بصرية كعملٍ واحد، وبيديه. وهذا الموقع يُنشر بالإنجليزية والألمانية والعربية من مصدر واحد، ومن هناك جاء معظم هذه الأسئلة.

Alaa Abbod

هل يحجز شيءٌ في موقعك إطارًا؟

إحساس التمرير وكلفته يُحسمان عادةً في ظهيرة واحدة، على يد من يوصّل المكتبة. وإن أردت أن تُقاس الإقامة قبل أن يُتجادَل فيها، فتلك محادثة قصيرة وقياس أقصر.

من فضلك أدر جهازك،
هذا الموقع مبني عموديًا.