تخطَّ إلى المحتوى

affix تثبيت —— تخطيط

عندما تتصفّح صفحة، هناك عناصر يجب «تثبيتها»: شريط التنقّل العلوي، وشريط العمليات السفلي، ودليل الحاشية الجانبي. عندما يجتاز عنصر إطار العرض، يحوّله affix إلى تحديد موضع ثابت (fixed)، وعند عودته يُعيد وضعه الأصلي. والصعوبة ليست في عملية التبديل ذاتها، بل في لحظة التحديد وعنصر نائب يمنع الانهيار——فالدالة الأساسية (core) تهتم فقط بالتحديد والأنماط، أما الاستماع والتحويط فمن مسؤولية طبقة واجهة المستخدم.

  • لتطبيق <LhAffix> في كل طرف: تثبيت شريط التنقّل في أعلى الصفحة، وتثبيت شريط عمليات النموذج في الأسفل، وتثبيت دليل حاشية الصفحة الطويلة ضمن إطار العرض.
  • السيناريوهات التي تتطلّب بقاء العنصر مرئيًا أثناء التمرير.
  • لا ينبغي استخدامه: الصفحات القصيرة التي تكون عناصرها داخل إطار العرض أصلاً (لا حاجة للتثبيت)؛ والسيناريوهات التي تحتاج إلى قناع ملء الشاشة (استخدم fullscreen الخاص بـ loading)؛ وتخطيطات الشريط الجانبي الثابت بالكامل (استخدام position: sticky في CSS أبسط مباشرة——قيمة affix تكمن في سلوكه الشرطي «التثبيت عند تجاوز العتبة فقط»).
import {
getAffixClassNames,
isAffixFixed,
getAffixFixedStyle,
} from '@lh-ui/share/core/affix'
import type { LhAffixState } from '@lh-ui/share/core/affix'

top و bottom وضعان متبادلان: عندما يكون offsetTop > 0 يُحكم على «هل تجاوز الحافة العليا عتبة إطار العرض العليا»، وعندما يكون offsetBottom > 0 يُحكم على «هل تجاوزت الحافة السفلى عتبة إطار العرض السفلى». لا يمكن للقيمتين أن تعملا معًا——يستخدم isAffixFixed عبارة if (offsetBottom > 0) لإعطاء الأولوية لوضع الأسفل. دلاليًا لا يمكن تثبيت العنصر إلا على حافة واحدة.

تحديد الأسفل يجب أن يأخذ ارتفاع إطار العرض في الحسبان: rect.bottom > viewportHeight - offsetBottom ——يجب أن تتجاوز الحافة السفلى للعنصر النائب «إطار العرض السفلي ناقص العتبة» لكي يُعتبر مثبّتًا. هذه العتبة ديناميكية (تتغيّر viewportHeight مع حجم النافذة)، لذا على طبقة واجهة المستخدم أن تمرّر ارتفاع إطار العرض الحالي في كل استدعاء، ولا يجوز تخزينه مؤقتًا.

فصل دالة التحديد عن آلية الاستماع: تأخذ isAffixFixed كائنًا مستطيلًا { top, bottom } ولا تهتم بمصدره——سواءً جاء من boundingClientRect الخاص بمدخلات IntersectionObserver في رد النداء، أو من الاحتياطي getBoundingClientRect() في أحداث resize/scroll، فإن كلا المسارين يتشاركان نفس منطق التحديد.

top: 'auto' / bottom: 'auto' في getAffixFixedStyle: عند التثبيت يُضبط المحور الآخر صراحةً على auto، لمنع العنصر من حمل top و bottom معًا (في تحديد الموضع الثابت، وجود قيمتين معًا يمطّ ارتفاع العنصر).

اسم صنف للحالة الثابتة بدلاً من تغيير الأنماط مباشرة: تقرّر SCSS تفاصيل التثبيت (z-index، الظل، انتقال الحركة) لـ lh-affix--fixed، بينما تحسب getAffixFixedStyle الخاصة بـ core قيم البكسل المحددة لـ top/bottom——أي فصل مفتاح تحديد الموضع عن مقدار الإزاحة.

الدالة التوقيع الناتج الدور
getAffixClassNames (state: LhAffixState) => string[] أسماء الأصناف حالة fixed
isAffixFixed (rect: { top, bottom }, offsetTop, offsetBottom, viewportHeight) => boolean هل هو مثبّت تحديد تجاوز إطار العرض
getAffixFixedStyle (offsetTop, offsetBottom) => Record<string, string> أنماط مضمّنة إزاحة تحديد الموضع الثابت
export interface LhAffixState {
fixed: boolean // هل العنصر في الحالة الثابتة حاليًا (يحرّك أسماء الأصناف)
}

affix هو تحديد موضع بصري خالص، لا يغيّر دلالة المحتوى، وليس له سمات aria مخصصة. هناك ممارستان ذواتا صلة:

  • لا تغيّر حالة التثبيت ترتيب DOM: يبقى العنصر في موقعه الأصلي في DOM حتى بعد تثبيته، ويقرأ قارئ الشاشة العناصر بترتيب المصدر. إذا نُقل بصريًا إلى نهاية body (teleport)، سيتقفز ترتيب الإعلان——يجب أن يغيّر تنفيذ affix الموضع فقط دون لمس DOM.
  • رؤية العنصر المثبّت: العنصر المثبّت يكون داخل إطار العرض دائمًا، ولا يجب أن تتغيّر قيمة aria-hidden مع التمرير (فهو مرئي طوال الوقت أصلاً).
  • خطر فخ التركيز: شريط عمليات «حفظ/إرسال» المثبّت في الأسفل مناسب في النماذج الطويلة، لكن انتبه إلى أن العنصر الثابت قد يحجب محتوى أسفل الصفحة (الزر يغطي آخر سطر دائمًا)——يجب على طبقة واجهة المستخدم إضافة padding-bottom إلى body أو حاوية المحتوى لترك مساحة، وهذا أمر تخطيط لا أمر إمكانية وصول، لكنه يؤثر في قابلية الاستخدام.
import { isAffixFixed, getAffixFixedStyle } from '@lh-ui/share/core/affix'
// الوضع العلوي: الحافة العليا تجاوزت العتبة 80px
isAffixFixed({ top: 50, bottom: 200 }, 80, 0, 900) // false (50 < 80 شرط التثبيت، 50 لم تتجاوز)
isAffixFixed({ top: 100, bottom: 300 }, 80, 0, 900) // true (100 > 80)
// انتبه لاتجاه التحديد: top < offsetTop يكون صحيحًا —— أي أن الحافة العليا للعنصر **أعلى** من
// خط العتبة (تم لفّه للأعلى)
// الوضع السفلي: الحافة السفلى تجاوزت «إطار العرض السفلي ناقص العتبة»
isAffixFixed({ top: 700, bottom: 950 }, 0, 60, 900) // true (950 > 900-60=840)
isAffixFixed({ top: 500, bottom: 700 }, 0, 60, 900) // false (700 < 840، لم تتجاوز بعد)
// تمرير قيمتي offset معًا: الأولوية للأسفل
isAffixFixed({ top: 100, bottom: 950 }, 80, 60, 900) // true (يأخذ الفرع السفلي)
getAffixFixedStyle(80, 0) // { position: 'fixed', top: '80px', bottom: 'auto' }
getAffixFixedStyle(0, 60) // { position: 'fixed', bottom: '60px', top: 'auto' }
getAffixFixedStyle(0, 0) // { position: 'fixed', top: '0px', bottom: 'auto' } (تثبيت علوي افتراضي)
// طبقة واجهة المستخدم: المسار الرئيسي IntersectionObserver + عنصر نائب يمنع الانهيار
const placeholder = document.createElement('div') // عند التثبيت يملأ الموضع الأصلي لمنع قفز المحتوى
let observer: IntersectionObserver | undefined
function observe() {
observer = new IntersectionObserver(
(entries) => {
const rect = entries[0].boundingClientRect
const fixed = isAffixFixed(
{ top: rect.top, bottom: rect.bottom },
props.offsetTop ?? 0,
props.offsetBottom ?? 0,
window.innerHeight,
)
if (fixed !== state.fixed) {
state.fixed = fixed
if (fixed) targetEl.before(placeholder) // عنصر نائب
else placeholder.remove() // إزالة العنصر النائب
}
},
{ threshold: [0, 1] },
)
observer.observe(targetEl)
}
<!-- Vue3 -->
<template>
<LhAffix :offset-top="64">
<nav class="page-nav">…</nav>
</LhAffix>
</template>
// React: useEffect يُسجِّل IntersectionObserver؛ ويُتجاوز في SSR (لا يوجد IntersectionObserver)،
// ويُركَّب بعد التنشيط من جهة العميل
// uniapp: IntersectionObserver في جانب التطبيقات المصغّرة هو wx.createIntersectionObserver؛
// وفي جانب h5 يُستخدم واجهة المتصفح الأصلية. دالة التحديد في core مشتركة بين الجانبين
اسم الصنف الدلالة
.lh-affix الحاوية المغلِّفة (في الموضع الأصلي)
.lh-affix--fixed الحالة الثابتة (position: fixed + z-index + ظل/انتقال اختياري)

z-index --lh-z-index-affix؛ ظل الحالة الثابتة --lh-box-shadow-affix (اختياري، شريط ظل رفيع في الأسفل عند التثبيت العلوي لتمييز مستوى الطبقة).

  • العنصر النائب ضروري: بمجرد تثبيت العنصر يخرج من تدفق المستند، ويتقلّص المحتوى الذي بعده ليملأ الفراغ——قفز عنيف في الصفحة. يجب على طبقة واجهة المستخدم وضع عنصر نائب بنفس الحجم (كما في المثال أعلاه). يجب أن يساوي ارتفاع العنصر النائب ارتفاع العنصر قبل التثبيت.
  • ارتفاع إطار العرض في وضع الأسفل يجب أن يكون لحظيًا: يجب تمرير القيمة الحالية في معامل viewportHeight الخاص بـ isAffixFixed عند كل استدعاء، وإعادة التحديد عند resize النافذة (قد تنتقل حالة affix من الثابت إلى غير الثابت).
  • offsetTop / offsetBottom اختر واحدًا منهما: عند تمريرهما معًا تكون الأولوية للأسفل وتُلغى عتبة الأعلى. ورغم أن الـ API يسمح بتمريرهما معًا، إلا أن الدلالة الصحيحة هي تكوين واحد فقط.
  • انهيار العرض في الحالة الثابتة: بعد التثبيت لم يعد عرض العنصر مقيّدًا بالحاوية الأب (العناصر الكتلية المثبّة تنكمش إلى عرض المحتوى). عادةً ما تسجّل SCSS العرض الأصلي في --fixed وتُعيد استخدامه، أو تكتب طبقة واجهة المستخدم العرض في الأنماط المضمّنة.
  • حاويات التمرير المتداخلة: عندما تحتوي الصفحة على حاوية ذات تمرير محلي (وليس تمرير window)، يجب تعيين root الخاص بـ IntersectionObserver على تلك الحاوية——كما يجب استبدال معامل ارتفاع إطار العرض في isAffixFixed بـ clientHeight الخاص بالحاوية. لا يفرّق core بين الحالتين، وعلى المُستدعي ضمان الاتساق.
  • توافق IntersectionObserver: جميع الأطراف المستهدفة تدعمه؛ ولتوافق البيئات القديمة جدًا استخدم أحداث scroll + getBoundingClientRect() كاحتياطي (يمكن إعادة استخدام دالة التحديد مباشرة لأنها تأخذ كائنًا مستطيلًا فقط).
  • الاختيار بين affix و sticky: position: sticky في CSS يغطي معظم احتياجات «التثبيت العلوي» وبصفر JavaScript. القيمة المضافة لـ affix هي العتبة القابلة للتحكّم (التثبيت عند تجاوز 64px وليس عند ملامسة الحافة) ووضع الأسفل (sticky يلتصق بالحافة القريبة فقط من اتجاه التمرير). في السيناريوهات البسيطة لا تلجأ إلى affix.
@lh-ui/share/core/affix → isAffixFixed( )
返回值
(填好参数后点运行)

Copyright © 2024-2026 FengMo WenXue