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

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

التمرير السلس لا يكسر روابط المِرساة عندك — preventDefault هو الذي يكسرها

نقرة على جزء العنوان هي خمسة سلوكيات متصفّح متخفّية في معطف واحد، وواحد منها فقط هو التمرير. بالقياس في Chromium 151 مقابل نسخة Lenis مثبَّتة: المكتبة بإعداداتها الافتراضية تُبقي الخمسة كلّها سليمة، والمقتطف الذي يلجأ إليه الجميع لإصلاحها يُتلف أربعة منها في سطر واحد.

باختصار

التمرير السلس نادرًا ما يوقف حركة الصفحة — بل يمنع المتصفّح من فعل الأشياء الأربعة الأخرى التي يفعلها التنقّل إلى جزء العنوان. فخوارزمية التمرير إلى الجزء في معيار HTML تضبط عنصر الهدف في المستند، وهو ما يقرأه :target، وتكشف الأسلاف المخفيّة، وتُمرّر العنصر إلى داخل الرؤية محترمةً scroll-margin، وتُنفّذ خطوات التركيز، وتنقل نقطة بدء التنقّل التسلسلي بالتركيز. واستدعاء preventDefault يُلغي ذلك كلّه دفعةً واحدة، ولا يُعاد عادةً إلا التمرير. بالقياس في Chromium 151، المقتطف المتداوَل يُضيّع الهاش، ويُنزل history.length من 3 إلى 2، ويترك :target فارغًا، ويُعيد ضغطة Tab التالية إلى الرابط الذي غادره القارئ للتوّ. وإضافة pushState تستعيد العنوان ومدخلة السجلّ، ولا تستعيد أيًّا من الاثنين الآخرين.

ما ينبغي أن تخرج به

  1. التمرير السلس يُحرّك الصفحة في الغالب دائمًا؛ وما يُلغيه preventDefault هو الهاش، ومدخلة السجلّ، و:target، وscroll-margin، وموضع لوحة المفاتيح، وأربعة منها لا يُعيدها أحد.
  2. Lenis بإعداداته الافتراضية لا يعترض النقرة أصلًا — بالقياس على النسخة المثبَّتة 1.1.20 يفشل في حالة واحدة بالضبط، حين تكون حركة من عنده جارية بالفعل.
  3. pushState يستعيد العنوان ومدخلة رجوع، ولا يُطلق hashchange أبدًا ولا يجعل :target يطابق، فتموت قاعدة الإبراز بصمت لحظة يعترض مُعالِج ما.
  4. scroll-margin-top خاصيّة لتمرير عنصر إلى داخل الرؤية، لا لتمرير إلى إحداثي، فكل مُنعِّم يُحرّك رقمًا يُخطئه بمقدار الهامش كاملًا — 120 px على صفحة الاختبار.
  5. هذا الصنف كلّه من العيوب غير مرئي للأدوات: 144 فحصًا بـ axe على 72 وثيقة في هذا الموقع أبلغت عن خمس مخالفات، ولم يكن بوسعها أن تُبلغ عن هذا العيب، لأن axe لا يستطيع الضغط على Tab.

Chromium 151 · Lenis 1.1.20 · 2026-08-26

هل تنكسر روابط المِرساة فعلًا مع التمرير السلس؟

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

العنوان يكتسب الهاش، و:target يطابق، والصفحة تستقرّ عند 1,336 px مع احترام scroll-margin-top المُصرَّح به على الهدف، وضغطة Tab واحدة بعدها تُتابع داخل القسم بدل أن ترتدّ إلى الرابط. لا شيء مكسور لأن لا شيء اعتُرِض — فمُعالِج المِرساة في المكتبة لا يحتوي على preventDefault إطلاقًا، وتنقّل الجزء الخاص بالمتصفّح يجري تحته.

هناك حالة واحدة بالضبط يفشل فيها، ويبلغها القارئ في نحو ثانية. فبينما تكون حركة Lenis جارية بالفعل، تصل النقرة في منتصف المنحنى: على صفحة الاختبار كانت العجلة قد ضبطت هدفًا عند 2,500 px، وهبطت النقرة عند 1,052 px مع قراءة isScrolling بقيمة smooth، فنفّذ المتصفّح قفزته إلى 1,336، ثم كتب إطار الحركة التالي فوقها. استقرّت الصفحة عند 2,500 والهاش الصحيح في شريط العنوان — عنوان يكذب على القارئ بشأن موضعه بمقدار 1,164 px.

رابط واحد داخل الصفحة، وأربع تهيئات للمكتبة المثبَّتة، إضافةً إلى المتصفّح وحده. صفحة اصطناعية، حشو بارتفاع 1,400 px، وهدف يحمل scroll-margin-top بقيمة 120px، وإطار عرض 1200 في 800، والنقرات مُرسَلة من سكربت حتى لا يُحرّك الصفحةَ شيء آخر.
التهيئة العنوان بعد النقرة يستقرّ عند (px)
بلا مكتبة، ولا اعتراض الهاش مضبوط، و:target يطابق 1,336
anchors بقيمة false، نقرة والصفحة ساكنة الهاش مضبوط، و:target يطابق 1,336
anchors بقيمة false، نقرة أثناء الحركة الهاش مضبوط، و:target يطابق 2,500
anchors بقيمة true، نقرة والصفحة ساكنة الهاش مضبوط، و:target يطابق 1,456
anchors بقيمة true، نقرة أثناء الحركة الهاش مضبوط، و:target يطابق 1,456

الآليّة سطر واحد. فـ Lenis يوفّق بين موضعه الداخلي وإزاحة التمرير الحقيقية للمستند داخل حارسٍ يشترط أن تكون isScrolling بقيمة false أو السلسلة native — عند lenis.mjs:640 في النسخة المنسوخة داخل المستودع. وأثناء الحركة تُقرأ قفزة الجزء التي ينفّذها المتصفّح على أنها ضجيج فتُهمَل، ويعيد الإطار الذي يليها كتابة رقم المكتبة نفسه. والمسألة رقم 150 في متتبّع المشروع تصف العَرَض من الخارج، بكلمات المُبلِّغ: الصفحة لا تنتقل إلى الموضع المطلوب إلا بعد أن تتوقّف حركة التمرير.

الصفّان الرابع والخامس هما العلاج الذي يحمل عيبه معه. فضبط anchors على true يُصلح التوقيت ويُنزل القارئ 120 px أخفض مما يفعل المتصفّح، لأن ذلك المسار يحلّ العنصر إلى أعلى مستطيله المحيط زائدًا التمرير المُحرَّك، ولا يقرأ scroll-margin-top أبدًا. عيب يُستبدل بعيب، وهذا هو النمط الذي تدور حوله المقالة كلّها.

إحساس الحركة التي يركبها هذان الصفّان — أُطر الاستقرار، وذروة السرعة، وما تكلّفه وهي متوقّفة — مقيسٌ في Lenis مقابل التمرير الأصلي، بالقياس.

HTML Standard §7.4.6.4, read 2026-08-26

ماذا يفعل المتصفّح حين تنقر على هاش؟

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

والخطوة التي لا يتوقّعها أحد هي الأخيرة. فبالقياس على الصفّ الضابط، تترك نقرة الجزء الأصلية document.activeElement على عنصر body — إذ لا ينتقل التركيز إلى هدف غير قابل للتركيز — ومع ذلك تهبط ضغطة Tab التالية على رابط داخل القسم لا بعد الرابط الذي نُقر عليه. لقد نقل المتصفّح نقطة بدء التنقّل التسلسلي بالتركيز، وهي حالة منفصلة لا تكشفها أي خاصيّة في DOM.

  1. اضبط عنصر الهدف في المستند هذه هي الحالة التي يقرأها الصنف الزائف :target. تُضبط هنا ولا تُضبط في أي مكان آخر — لا بالعنوان، ولا بـ pushState، ولا بضبط location.hash من سكربت.
  2. شغّل خوارزمية كشف الأسلاف افتح عنصر details مغلقًا يحوي الهدف، واكشف المحتوى المُعلَّم بـ hidden-until-found. فالتمرير إلى إحداثي عند عنصر داخل قسم مطوي يصل إلى هدف ما يزال غير مرئي.
  3. مرّر الهدف إلى داخل الرؤية بقيمة behavior auto، وblock start، وinline nearest — ومع احترام منطقة الالتقاط (scroll snap area) للعنصر بدل صندوق حدوده، وهنا يدخل scroll-margin. وهذه هي الخطوة الوحيدة التي يُعيد أحدٌ بناءها.
  4. شغّل خطوات التركيز مع إطار عرض المستند بوصفه الهدف الاحتياطي. وفي حالة عنصر section عادي يقع الاحتياطي، ولهذا تُقرأ activeElement على أنها body بعدها.
  5. انقل نقطة بدء التنقّل التسلسلي بالتركيز هذه تقرّر إلى أين تذهب ضغطة Tab التالية. وهي غير مرئية لـ activeElement، وغير مرئية لأحداث التركيز، وهي سبب كون المِرساة الأصلية سهلة الوصول أصلًا بلا tabindex على الهدف.
  6. وخارج الخوارزمية، سجّل التنقّل نقرة الجزء تنقّلٌ داخل المستند نفسه: العنوان يتغيّر وتظهر مدخلة في سجلّ الجلسة، فيعيدك الرجوع إلى الهاش السابق لا إلى الصفحة السابقة.
الخوارزمية، بحرفيّة معناها، ومعها ما يفعله مُعالِج معترِض بكل خطوة. والخطوتان الأخيرتان هما اللتان لا يُعيد أي مقتطف بناءهما.

قاس Hidde de Vries التباين نفسه في 2017 وقاله بوضوح: التركيز نُقل إلى body لا إلى الهدف، والمتصفّح مع ذلك يتذكّر موضع العنصر المرتبط، فيمضي التنقّل اللاحق بلوحة المفاتيح من هناك. والحدّ الفاصل لم يُحسم حتى الآن — فمستودع HTML يحمل ثلاثة نقاشات مفتوحة حول تنقّل الجزء وترتيب التركيز.

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

4 handlers · 1200×800 · one Tab press

ما تكلفة preventDefault بالضبط؟

وُضِعت أربعة مُعالِجات على صفحة واحدة، كلٌّ منها مربوط برابط مطابق يشير إلى هدف مطابق، وقيس كلٌّ منها على ستّ قراءات: أين انتهت الصفحة، وماذا قال العنوان، وهل ظهرت مدخلة في السجلّ، وهل طابق :target، وماذا حملت document.activeElement، وإلى أين ذهبت ضغطة Tab واحدة بعدها. والعمود الأخير هو الذي لا يظهر أبدًا في التدوينات.

الصفّ A هو المتصفّح بلا أي اعتراض. والصفّ B هو المقتطف الذي يعود أولًا من كل بحث. والصفّ C هو النسخة المصحَّحة التي يشحنها الناس بعد أن يُبلّغ أحدهم عن خلل في العنوان. والصفّ D هو الشكل الذي يستعمله هذا المستودع، في 131 سطرًا داخل src/runtime/scroll/anchors.js.

Chromium 151.0.7922.34، 2026-08-26. الهدف قسمٌ يحمل scroll-margin-top بقيمة 120px وأعلى صندوق حدوده عند 1,456. والرابط الموسوم بـ داخل يقع ضمن القسم الهدف، والرابط الموسوم بـ بعد هو العنصر التالي القابل للتركيز في ترتيب المصدر بعد الذي نُقر عليه.
المُعالِج العنوان، والسجلّ، و:target ضغطة Tab التالية تهبط على يهبط عند (px)
A — الافتراضي في المتصفّح، بلا اعتراض الهاش مضبوط، الطول 3، و:target يطابق الرابط داخل القسم 1,336
B — preventDefault مع scrollIntoView بقيمة behavior smooth لا هاش، الطول 2، و:target فارغ الرابط الذي يلي المنقور 1,336
C — preventDefault مع pushState مع scrollTo الهاش مضبوط، الطول 3، و:target فارغ الرابط الذي يلي المنقور 1,456
D — الصفّ C مع focus بقيمة preventScroll وtabindex مؤقّت الهاش مضبوط، الطول 3، و:target فارغ الرابط داخل القسم 1,456

الصفّ D وحده يُعيد إنتاج المتصفّح، ويحتاج إلى استدعاء focus صريح ليفعل ذلك، لأن نقطة بدء التنقّل التسلسلي بالتركيز لا يمكن ضبطها من سكربت. ونقل التركيز الحقيقي إلى الهدف هو أقرب بديل متاح: يضع القارئ داخل القسم، وهو ما يسمّيه تعليق الوحدة في هذا المستودع نفسه الجزءَ الذي يُخطئ فيه كل تنفيذ للتمرير السلس.

ولاحظ ما لا يستعيده الصفّ D بعد. فـ :target فارغ في كل صفّ معترِض، بما في ذلك الجيّد منها، والصفحة تهبط 120 px أخفض مما يفعل المتصفّح. وكلاهما كلفة دائمة للاعتراض، والأمانة تقتضي تسميتهما لا ترك الصفّ يُقرأ مكسبًا نظيفًا.

src/runtime/scroll/anchors.js:70-96

إعادة كتابة الهاش تستعيد العنوان ولا شيء غيره

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

والأمران اللذان لا يفعلهما pushState مقيسان كلاهما. فهو يُطلق hashchange صفر مرّة — والمواصفة صريحة في أن خطوات تحديث العنوان والسجلّ ليست تنقّلًا ولا اجتيازًا للسجلّ — فيتوقّف أي مستمع يراقب hashchange ليتفاعل مع الحركة داخل الصفحة يوم يبدأ مُعالِج بالاعتراض. وهو يترك :target فارغًا، في Chromium 151 وWebKit 26.5 على السواء، لأن عنصر الهدف في المستند تضبطه خوارزمية الجزء ولا يضبطه شيء سواها.

الأسطر 70-96 من src/runtime/scroll/anchors.js، مع كتابة الدالة السهمية بالكامل. السجلّ أولًا، ثم الحركة، ثم التركيز — والثلاثة نفسها، بلا حركة، عند الاجتياز.
event.preventDefault()
 
// السجلّ أولًا، ليبقى العنوان صحيحًا حتى لو رمى أي شيء تحته خطأً، و
// ليعود الرجوع إلى الهاش السابق لا إلى الصفحة السابقة.
if (view.location.hash !== `#${id}`) {
  view.history.pushState(null, '', `#${id}`)
}
 
// تحت `reduce` هذه قفزة بلا استيفاء — الرحلة نفسها،
// بلا سفر.
scroll.scrollTo(target, { immediate: env.prefersReducedMotion })
moveFocusTo(target)
 
// الرجوع والتقدّم بين الهاشات يجب أن يُحرّكا الصفحة أيضًا: فـ pushState
// أعلاه يعني أن المتصفّح لن يُمرّر نيابةً عنّا.
function onPopState() {
  const id = decodeURIComponent(view.location.hash.slice(1))
  if (!id) return
  const target = doc.getElementById(id)
  if (!target) return
  scroll.scrollTo(target, { immediate: true })
  moveFocusTo(target)
}

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

وثمّة حالة أضيق تستحقّ الفصل. فمؤشّر التمرير الذي يعيد كتابة الهاش كلّما مرّ القارئ بعنوان ينبغي أن يستعمل replaceState لا pushState: فهو يتعقّب موضعًا لا يسجّل وجهة، وملء زرّ الرجوع بعشرين مدخلة لم يخترها القارئ عيبٌ بذاته.

src/runtime/scroll/anchors.js:104-131

كيف تنقل التركيز من دون أن تُقاتل حركتك أنت؟

ثلاث تفاصيل، وكلٌّ منها موجود بسبب فشل مقيس. فـ tabindex بقيمة سالب واحد يُضاف فقط حين يكون العنصر عاجزًا أصلًا عن أخذ التركيز، حتى لا يُمنح هدفٌ من نوع رابط أو زرّ سِمةً لا يحتاجها. واستدعاء focus يمرّر preventScroll، لأن المتصفّح من دونه ينتقل إلى العنصر فورًا فلا يبقى للحركة التي بدأت للتوّ ما تُحرّكه. والسِّمة تُزال مجدّدًا عند blur، حتى لا تبقى في DOM حالةٌ كتبها زمن التشغيل ولم ينظّفها.

وهذه الأخيرة قاعدة في عقد سهولة الوصول لهذا المستودع، K-8، وهي قاعدة بسبب عيب في الموقع القديم: فـ tabindex بقيمة سالب واحد في لوحة القائمة كان يُضاف عند الفتح ولا يُعكس أبدًا. وثمّة حجّة خارجية للشكل نفسه — إذ أُبلغ أن tabindex دائمًا بقيمة سالب واحد على عنصر main يكسر زرّ الرجوع على iOS، وهذا سبب وجيه لإمساك السِّمة بقدر ما يدوم التركيز لا أكثر.

الأسطر 104-131 من src/runtime/scroll/anchors.js، مع كتابة الدالة السهمية دالةً مُسمّاة. سبعة وعشرون سطرًا في الملف، أحد عشر منها هي العمل الفعلي.
function moveFocusTo(target) {
  // لا تستعِر من القابلية للتركيز إلا ما لا يملكه العنصر أصلًا.
  const focusable =
    target.hasAttribute('tabindex') || isNativelyFocusable(target)
 
  if (!focusable) target.setAttribute('tabindex', '-1')
 
  // preventScroll حامل للحِمل: من دونه يقفز المتصفّح إلى
  // العنصر فلا يبقى للحركة التي بدأتها هذه الوحدة ما تُحرّكه.
  target.focus({ preventScroll: true })
 
  // K-8: كل ما يكتبه زمن التشغيل، يزيله زمن التشغيل.
  if (!focusable) {
    target.addEventListener('blur', function drop() {
      target.removeAttribute('tabindex')
    }, { once: true })
  }
}

تبيّن أن رسم حلقة حول التركيز المنقول يتوقّف على طريقة تفعيل الرابط، والمحرّكات تختلف في ذلك. ففي Chromium 151 تترك نقرة الفأرة الهدف مركَّزًا مع :focus-visible بقيمة false وبلا مخطّط، بينما يتركه Tab ثم Enter مركَّزًا مع :focus-visible بقيمة true والمخطّط مرسوم — وهذا هو السلوك المطلوب، بلغناه من دون كتابة قاعدة له. أما WebKit 26.5 بلا واجهة فقد رسم الحلقة على مسار الفأرة أيضًا؛ وهذا التباين غير مُتحقَّق منه مقابل Safari المنشور، وFirefox لم يُقَس أصلًا، فعامِل صفّ Chromium بوصفه المقيس واعتبر ما عداه مفتوحًا.

والعقد الذي كُتبت عليه هذه الوحدة له قاعدة لذلك أيضًا. K-7: التركيز الذي ينقله زمن التشغيل يجب أن يكون قابلًا للإثبات بقراءة document.activeElement بعد ضغطة مفتاح حقيقية، لا بكون focus قد استُدعي. وقد دفع الموقع القديم ثمن هذه الجملة بحلقة إعادة محاولة من ثلاثين إطارًا لم توجد إلا لأن focus يفشل بصمت على عنصر يعبر حالة visibility hidden — نصف ثانية من الاستطلاع للالتفاف على استدعاء أبلغ بالنجاح ولم يفعل شيئًا.

3 declarations · 120 px · 2026-08-26

لماذا يهبط القسم تحت الترويسة الثابتة رغم ذلك؟

لأن scroll-margin خاصيّة لتمرير عنصر إلى داخل الرؤية، والتمرير إلى إحداثي لا يُعطى عنصرًا. فمواصفة CSS Scroll Snap تطلب من وكلاء المستخدم أن يستعملوا منطقة الالتقاط للعنصر بدل صندوق حدوده وحده عند تقرير ما يُجلب إلى الرؤية، حتى حين يكون الالتقاط مُطفأً — وهذا يشمل تنقّل الجزء الخاص بالمتصفّح ويشمل scrollIntoView. أما تمرير رقم إلى window.scrollTo فلا يعطي المتصفّح شيئًا يطبّق عليه هامشًا.

والفجوة هي الهامش المُصرَّح به بالضبط، وهي الفجوة نفسها في كلا المحرّكين اللذين قيسا.

  • 1,336 رابط عميق وscrollIntoView كلاهما يحترم منطقة الالتقاط
  • 1,456 scrollTo مع إحداثي الرقم لا هامش له يُقرأ
  • 120 الفجوة، بالبكسل القيمة المُصرَّح بها بالضبط، في كل مرّة
  • 3 تصريحات في هذا المستودع ومسار النقر لا يقرأ أيًّا منها
عنصر واحد يُصرّح بـ scroll-margin-top بقيمة 120px، وأعلى صندوق حدوده عند 1,456. Chromium 151، 2026-08-26؛ وWebKit 26.5 يوافق في حدود بكسل واحد.

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

والتدقيق الجنائي للموقع القديم تنبّأ بهذا الشكل بالضبط وسجّله فخًّا غير مختبَر: نقرة داخل الصفحة ورابط عميق إلى المُعرّف نفسه يهبطان عند إزاحتين مختلفتين، لأن مسار النقر يستعمل ارتفاع ترويسة محسوبًا بينما تستعمل CSS خاصيّة scroll-margin-top. نظاما إزاحة لا يجتمعان أبدًا.

وإن طرحت ارتفاع الترويسة بنفسك، فقِسه ولا تفترضه. فرمز المسافات في هذا المستودع كان يُظنّ أنه ارتفاع الترويسة إلى أن فُحص: قِيس الشريط عند 79.2 px مقابل 70.1 px للرمز عند عرض 390، و68.8 px مقابل 59.1 px عند 768. وصفحة تواصل بُنيت على ذلك الافتراض ظهرت تحت الشريط.

وصفحات دراسات الحالة هي حيث تؤدّي تلك التصريحات معظم عملها — ثماني مراسي فصول وشريط لاصق في كلٍّ من الأعمال العشرة.

src/runtime/scroll/scroll-controller.js:37-42

ماذا يغيّر تقليل الحركة في المِرساة؟

يزيل السفر ويُبقي الرحلة. فتحت تفضيل reduce تظل المِرساة تُحرّك الصفحة، وتظل تكتب الهاش، وتظل تنقل التركيز — إنما تصل فورًا، والضمان مكتوب في أربعة مواضع مستقلّة حتى لا يكون أيٌّ منها وحده هو ما يفشل.

وسبب هذا التكرار أن التمرير السلس تحت reduce ليس تجاوزًا تجميليًّا؛ إنه الحركة المحدَّدة التي طلب الزائر ألّا يراها، يقدّمها التفاعل الوحيد في الصفحة المضمون أن يقطع مسافة طويلة.

immediate على مسار المِرساة
مُعالِج النقر يمرّر تفضيل reduce كما هو راية immediate، فيخدم مسار الشيفرة نفسه الحالتين، ولا يبقى فرع ثانٍ يُنسى.
behavior auto، لا smooth أبدًا
المسار الأصلي في المتحكّم يُمرّر بقيمة behavior auto ولا يستعمل smooth أبدًا، لأن تمريرًا برمجيًّا سلسًا يُعيد بالضبط ما طلب التفضيل إزالته.
scroll-behavior بقيمة initial
ورقة أنماط المستند تُصرّح بـ scroll-behavior بقيمة initial لا smooth، فلا يوجد مُحرِّك ثانٍ غير قابل للإلغاء على الإزاحة نفسها ليقاتله زمن التشغيل.
طبقة التصفير، مع علامة تعجّب
كتلة reduce الشاملة تعيش في طبقة التصفير حتى لا يتجاوزها شيء بالخصوصية عرَضًا، وهي تفرض scroll-behavior على auto براية important.
لا مكتبة أصلًا
تحت reduce لا يُنشئ هذا المشروع المُمرِّر السلس إطلاقًا. لا شيء يعمل ليجعل مِرساةً تنزلق، وهذا أقوى شكل يمكن أن يتّخذه الضمان.
أربعة مواضع كُتب فيها الضمان نفسه، واستدعاء واحد لا يُجرى عن قصد.

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

وللتفضيل نفسه مهمّة أصعب بكثير حين تكون الحركة لوحة رسم لا إزاحة تمرير — وتلك الحجّة في تقليل الحركة في WebGL وRive واللوحة.

a05-scroll.md §12 · 689 anchors

ما الذي ينبغي أن ترفض بناءه؟

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

فقد استدعت preventDefault وstopPropagation، وكتبت الهاش بـ pushState، وقاست الترويسة الثابتة وأزاحت لأجلها، ونقلت التركيز بـ preventScroll، وأعادت tabindex بعدها. فعلت الأشياء الأربعة كلّها التي تقول هذه المقالة إن المُعالِج مدين لك بها. وما لم تستطعه هو أن تعرف بحلقة الحركة الأخرى التي تكتب موضع التمرير نفسه على الصفحة نفسها، عبر 689 مِرساة هاش.

قفزة 200 px 731 ms
قفزة 1,000 px 1,317 ms
قفزة 3,000 px 1,800 ms
كم استغرقت حركة المِرساة في الموقع القديم، بحسب منحنى مدّتها هو نفسه. المعادلة لوغاريتم للمسافة، فالقفزة القصيرة ليست أسرع كثيرًا من الطويلة.مستخرَجة بالهندسة العكسية من abbod.de قبل إعادة البناء؛ والمُعامل الذي كان يمكن أن يُقصّرها يرد صفر مرّة في المُخرَج المبني.

والنهي الثالث عن الأدوات. فهذا الموقع ينشر رقم سهولة الوصول عنده بدل ادّعاء رقم نظيف: axe-core 4.11.0 على 72 وثيقة بعرضين، 144 فحصًا، خمس مخالفات، كلّها قيم لون على عنصر واحد. ولم يكن بوسع أيٍّ منها أن تكون مِرساةً تُحرّك الصفحة ولا تُحرّك التركيز، وسجلّ القياس يقول السبب في قائمته لما لم يُغطَّ — الجولة بلوحة المفاتيح، لأن axe لا يستطيع الضغط على Tab. فالرابط الذي يترك مستعمل لوحة المفاتيح وراءه يجتاز كل فاحص آلي موجود.

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

المخالفات الخمس الباقية في تلك الجولة، والـ 707 التي تبيّن أنها أثر جانبي للقياس أثناء الحركة، مشروحة في إيجابيات التباين الكاذبة في axe.

والسلوك السليم بلوحة المفاتيح واحد من أربعة أشياء يعدّها هذا الاستوديو جزءًا من البناء لا تدقيقًا بعده — انظر التخصّصات الأربعة.

أسئلة

هل يكسر Lenis روابط المِرساة؟

ليس بالمعنى الذي توحي به العبارة. فبالقياس مقابل النسخة المثبَّتة 1.1.20 في Chromium 151، مع خيار anchors على قيمته الافتراضية والصفحة ساكنة، يعمل الرابط داخل الصفحة عملًا كاملًا: الهاش يُضبط، و:target يطابق، وscroll-margin-top يُحترَم، وTab يُتابع داخل القسم. ويفشل في حالة واحدة فقط، حين تكون حركة من عنده جارية بالفعل، لأن المكتبة لا تقرأ تمريرًا أصليًّا إلا حين لا تكون في منتصف حركة. وعلى صفحة الاختبار وضع ذلك القارئ على بُعد 1,164 px بعد القسم مع الهاش الصحيح في شريط العنوان.

هل تحتاج أهداف المِرساة عندي إلى tabindex بقيمة سالب واحد؟

فقط إن اعترضت النقرة. فمن دون أي اعتراض تبقى document.activeElement على عنصر body، وتظل ضغطة Tab واحدة تهبط داخل القسم الهدف، لأن المتصفّح نقل نقطة بدء التنقّل التسلسلي بالتركيز لا التركيز نفسه. ولحظة تستدعي preventDefault تُلغي تلك الخطوة وعليك أن تُعيدها صراحةً. وفضّل إضافة السِّمة عند النقر وإزالتها عند blur على تثبيتها في الوسوم — فقد أُبلغ أن سِمةً دائمة على عنصر main تكسر زرّ الرجوع على iOS.

أيّهما الصحيح للقفزة داخل الصفحة، pushState أم replaceState؟

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

هل أستطيع استعمال scroll-behavior بقيمة smooth في CSS بدلًا من ذلك؟

ليس إلى جانب مُنعِّم بـ JavaScript. فهو يضع مُحرِّكًا ثانيًا غير قابل للإلغاء على إزاحة التمرير نفسها فيتقاتلان؛ ومتتبّع المكتبة نفسه يحمل بلاغًا عن كسره التمرير كلّيًّا، بما في ذلك الحالة التي يجعل فيها هاشٌ في العنوان تمريرَ المتصفّح السلس ينطلق عند التحميل بلا أي إيماءة من المستخدم. ولهذا يُصرّح هذا الموقع بـ scroll-behavior بقيمة initial، ويؤدّي قفزته عند تقليل الحركة عبر زمن التشغيل، حيث يمكن إلغاؤها.

هل يلتقط تدقيق سهولة الوصول تركيزَ مِرساةٍ مكسورًا؟

لا. فآخر جولة منشورة على هذا الموقع شغّلت axe-core 4.11.0 على 72 وثيقة بعرضين — 144 فحصًا — وأبلغت عن خمس مخالفات، كلٌّ منها قيمة لون. والسجلّ يسرد ما لم تبلغه الجولة، وأوّل بند فيه الجولة بلوحة المفاتيح، لأن axe لا يستطيع الضغط على Tab. والأداة الوحيدة التي تجد هذا العيب هي قراءة document.activeElement بعد ضغطة مفتاح حقيقية.

المصادر

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

  • src/runtime/scroll/anchors.js الجواب كلّه في 131 سطرًا: جُمل الحراسة، وpushState المكتوب أولًا، والحركة الفورية تحت reduce، ودالّة التركيز، والتفكيك الذي يزيل المستمعَين كليهما.
  • src/runtime/scroll/lenis-options.js الخيارات الثمانية المذكورة، وقيمة anchors مع سببها، والافتراضات العشرة الموروثة عن قصد التي يرفض المشروع إعادة ذكرها.
  • src/styles/base/document.css تصريح scroll-behavior بقيمة initial، ومعه اصطدام المُحرِّكَين مكتوبًا بجانبه سببًا.
  • docs/evidence/legacy-forensics/a05-scroll.md مالك المِرساة في الموقع قبل إعادة البناء، مستخرَجًا بالهندسة العكسية كاملًا، وعدد المراسي 689، وعيّنات المدّة، والفخّ غير المختبَر الذي تقيسه هذه المقالة.
  • contracts/published-measurements.json جولة axe-core: 72 وثيقة، وعرضان، و144 فحصًا، وخمس مخالفات، وقائمة ما لم تستطع بلوغه.

مُراجَع مقابل

Alaa Abbod

بقلم

علاء عبّود

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

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

Alaa Abbod

هل مسار لوحة المفاتيح في موقعك بجودة تمريره؟

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

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