كل المقالات

داخل @alikhalilll/a-skeleton، مُحمِّلات skeleton تولّد نفسها بنفسها

كيف تستخرج a-skeleton واجهة الانتظار من المكوّن نفسه. وضع Clone، ووضع Structural، ووضع Mirror، ومسار العمل الذي يلتقط ثم يُخزّن مؤقّتاً ثم يُعيد العرض خلفها.

1 دقيقة قراءة

مُحمِّلات الـ skeleton في النهاية واجهة مكرّرة. تبني بطاقة، ثم تبني بطاقة أخرى من مستطيلات رمادية تشبهها، وكلما عدّلت البطاقة الحقيقية نسيتَ أن تعدّل الـ placeholder معها. بعد ثلاثة أسابيع تجد العنوان محاذيًا لليسار في المكوّن الفعلي وموسّطًا في الـ skeleton، ولا ينتبه أحد حتى يمرّ مصمّم بالصفحة على بيئة الـ staging.

بنيتُ @alikhalilll/a-skeleton على فرضية مختلفة تمامًا: الـ skeleton يُشتقّ من المكوّن الحقيقي نفسه. تلفّ بطاقتك مرة واحدة فقط، وتتكفّل المكتبة باستنتاج الـ placeholder من قراءة البطاقة. لا قالب موازٍ. لا مستطيلات رمادية تلاحقها بالتحديث. أيّ تعديل تجريه على البطاقة يلتقطه الـ skeleton في العرض التالي دون تدخّل منك.

ما أتحدّث عنه هنا هو ما يقع تحت السطح: عقد التأليف الذي يجعل هذا الأمر ممكنًا، واستراتيجيات العرض الثلاث وما تدفعه كلٌّ منها من مقايضات، وخط أنابيب الالتقاط خلف وضع clone تحديدًا. هذا الأخير هو ما سأحمله معي إلى أيّ مشكلة مستقبلية من نوع "أريد نسخة من شكل هذا الشيء الآن".

العقد: أبقِ الوسوم غير مشروطة، وقيّد المحتوى وحده

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

خطأ:

<div v-if="user">
  <h3>{{ user.name }}</h3>
  <p>{{ user.bio }}</p>
</div>

صحيح:

<div>
  <h3>{{ user?.name }}</h3>
  <p>{{ user?.bio }}</p>
</div>

الـ walker، سواء كان يقرأ الـ DOM الحيّ أو شجرة الـ vnode، يحتاج أن يرى الشكل ذاته أثناء التحميل وبعد وصول البيانات. لو غلّفتَ العنوان بـ v-if="user"، فسيرى الـ walker عقدة تعليق أثناء التحميل ثم <h3> بعد ذلك، ولن يستطيع أن يتنبّأ بهندسة الـ placeholder. لكن مع استخدام الاستيفاء الاختياري (optional chaining) يبقى <h3> موجودًا دائمًا؛ يكون نصّه فارغًا أثناء التحميل، ويصنّف الـ walker الوسوم الحاملة للنص الفارغة (<h1>–<h6>, <p>, <span>, وغيرها) على أنها أشرطة نص shimmer بارتفاع العنوان الطبيعي.

الوسوم هي المخطط. المحتوى هو الحمولة. تستطيع أن تُبدّل الحمولة بـ null دون أن يلاحظ المخطط شيئًا. هذا الانضباط البسيط وحده هو ما يفتح الباب لكل ما يأتي بعده.

النصف الآخر من العقد هو مخرج الطوارئ data-skeleton-ignore. لو كان جزء من المكوّن مجرد زخرفة بصرية (خلفية تدرّج، شارة علامة تجارية ثابتة، لمسة SVG) ولا ترغب باستبداله بكتلة رمادية، ضع عليه data-skeleton-ignore فيتجاهله الـ walker تمامًا؛ ويُعرض العنصر الحقيقي كما هو في الحالتين. أما data-skeleton-stop فيقول للـ walker: "لا تدخل داخل هذا العنصر؛ اعرضه ككتلة واحدة". أستخدمه عادةً مع الـ widgets الذرّية حين تكون البنية الداخلية مجرد ضجيج بصري.

قاعدتان اثنتان وسِمَتان اثنتان. هذا هو كل ما يلزمك تعلّمه من سطح التأليف.

وضع Clone: التقاط الرسم النهائي وإعادة عرض البكسلات

Clone هو الوضع الافتراضي، وهو أهمّ ما في المكتبة برأيي. أغلب مكتبات الـ skeleton تتعثّر لأنها تحاول التفكير على مستوى الـ CSS cascade. تنظر إلى أسماء الفئات. تنظر إلى الأنماط المضمّنة. تفترض قيمة الـ border radius بناءً على بادئة rounded-*. باختصار، تخمّن.

وضع clone يتخلّى عن التخمين كليًا. يركّب الـ slot خارج الشاشة داخل capture host مضبوط على visibility: hidden، ويترك المتصفح ينهي كامل الـ layout وحلّ الأنماط، ثم يقرأ النتيجة النهائية المحسوبة لكل عنصر عبر getComputedStyle(). مهما كان نظام التنسيق لديك (أدوات Tailwind، أنماط scoped بأسماء فئات مُجزّأة، CSS-in-JS وقت التشغيل، رموز DaisyUI الدلالية، أو حتى سمات style مضمّنة يمرّرها مكوّن أب)، فبحلول اللحظة التي يعمل فيها getComputedStyle() تكون كلّها قد انهارت إلى إجابة قانونية واحدة لكل خاصية. يأخذ المحرك تلك الإجابة الجاهزة ويعيد عرضها.

السطح الذي يلتقطه المحرك شامل فعلًا. حدود مستقلة لكل حافة (فبطاقة لها border-bottom مختلف تُقرأ كما ينبغي). أنصاف أقطار مستقلة لكل زاوية (فشكل pill غير المتماثل لا يتراجع إلى مجرد مستطيل مدوّر). لون الخلفية وصورتها. Box-shadow. Opacity. Filter. Transform. Typography. Mix-blend-mode. كل عقدة ملتقطة تحمل حمولة أنماط مضمّنة مجمّدة خاصة بها؛ حلقة العرض لا تخصّص شيئًا لأيّ عقدة، بل تقرأ ما هو مجهّز مسبقًا.

النص هو أكثر ما يخطئ فيه الآخرون، وأكثر ما تصيبه a-skeleton بدقّة. بدلًا من تخمين نقاط الالتفاف ("لنفترض أن النص يلتف كل N حرفًا، ولنفترض أن السطر الأخير عرضه 70%")، يستخدم المحرك Range.getClientRects() ليلتقط المستطيل الفعلي المعروض لكل سطر نصّي بدقّة: left, top, width, height. عنوان موسّط من ثلاثة أسطر على سطح المكتب يصبح عنوانًا موسّطًا من سطرين على الجوّال، وتتقلّص المستطيلات الملتقطة معه. مواضع السطر الأخير في اتجاه RTL، والنص المضبوط (justified)، وفواصل الأسطر اليدوية، كلّها تُعاد بنسبة 1:1 لأنها قِيست فعلًا ولم تُخمَّن.

واجهة الـ wrapper لكل هذا هي prop واحدة فقط:

<ASkeleton :loading="loading">
  <SomeRichComponent />
</ASkeleton>

ولو أردتَ بناء تدفّق التقاط وإعادة عرض خاص بك، فالدالة الأساسية معرّضة كتصدير عام:

import { captureSnapshot, type CaptureSnapshot } from '@alikhalilll/a-skeleton';

const snap: CaptureSnapshot = captureSnapshot(myRootEl, {
  maxDepth: 12,
  maxNodes: 800,
  minSize: 4,
});

maxDepth وmaxNodes وminSize هي حدود الميزانية. بفضلها لا يتوقف الخيط الرئيسي عند جدول من 5000 صفّ. عندما يبلغ الـ walk الحدّ يتوقّف، ويضبط truncated: true، ثم يسجّل الـ wrapper console.warn لمرة واحدة لكل cacheKey كي تنتبه للأمر أثناء التطوير لا في الإنتاج. أما minSize فيُسقط أيّ شيء أصغر من أربعة بكسلات CSS على أيّ محور، وهو ما يحذف الخيوط الرفيعة ونقاط الفراغ التي كانت ستظهر لولا ذلك كقصاصات ملوّنة داخل الـ skeleton.

المقايضة التي يدفعها وضع clone هي أنه يعمل على العميل فقط. تحتاج إلى DOM حقيقي، وإلى getComputedStyle()، وإلى متصفح يقرأ الرسم النهائي. على الخادم يُعرض markup الـ slot الحقيقي كالمعتاد، ولا يبدأ الالتقاط وإعادة العرض إلا بعد الـ hydration. لو احتجتَ إلى placeholder جاهز من الخادم قبل انتهاء الـ hydration، فذلك بالضبط ما وُجد وضع mirror من أجله.

وضع Mirror: امشِ على الـ vnodes وسلّمها إلى الخادم

Mirror يبدّل المحرك من أساسه. بدلًا من تركيب الـ slot وقراءة الـ DOM، يمشي على شجرة الـ vnode مباشرةً وقت العرض. لا وصول إلى window. متوافق مع الـ hydration. قابل للعرض من الخادم.

<ASkeleton mode="mirror" :loading="loading">
  <SomeRichComponent />
</ASkeleton>

يحافظ الـ walker على وسم كل عنصر وعلى فئته الأصلية. الأوراق الحاملة للنص تتحوّل إلى <span class="a-skel-text-content"> (نص شفاف فوق خلفية skeleton) مع box-decoration-break: clone كي يلتفّ الـ shimmer حول عرض النص المعروض بدقّة سطرًا سطرًا. أما الوسوم الذرّية والتفاعلية (<img>, <button>, <svg>) فتصبح <div class="a-skel-block"> بأبعاد مستمدّة من فئتها.

مكسب Mirror الأساسي هو دعم الـ SSR. ومقايضته هي الدقّة. لأن الـ walker لا يملك getComputedStyle()، فهو لا يرى إلا ما هو مؤلَّف صراحةً: أسماء الفئات الثابتة والأنماط المضمّنة. لو كانت هوية مكوّنك تأتي من قاعدة CSS-in-JS مُجزّأة أو من كتلة نمط scoped، فلن يلتقطها الـ mirror كما يفعل clone. لكن في معظم التخطيطات تكفي الفئة الثابتة، وأمان الـ SSR يستحق أكثر من آخر 5% من التطابق البكسلي. أما المكوّنات المعقّدة بصريًا فمكانها الطبيعي هو clone.

كلا الوضعين يتشاركان خط أنابيب الحركة نفسه، والـ cache نفسه، وقاعدة الـ a11y نفسها. التبديل بينهما لا يكلّفك أكثر من prop واحدة.

وضع Structural: أشجار في الـ flow الطبيعي، محفوظة بين الجلسات

الاستراتيجية الثالثة ألجأ إليها حين لا يكون الـ wrapper هو وحدة التنسيق المناسبة لديّ: حين أريد لشكل ملتقط أن يعيش عبر عدة نُسخ، أو أن يستمرّ بين الجلسات، أو أن يعيد التدفّق مع العنصر الأب عند تغيير حجم الـ viewport بدل تثبيته على إحداثيات مطلقة. لتلك الحالة أستخدم useSkeleton() مع <ASkeletonLayer>.

<script setup lang="ts">
import { computed, ref } from 'vue';
import { useSkeleton } from '@alikhalilll/a-skeleton';

const props = defineProps<{ userId: string }>();
const user = ref(null);
const loading = computed(() => user.value === null);
const containerRef = ref<HTMLElement | null>(null);

const { shape, clear } = useSkeleton({
  cacheKey: `user-card:${props.userId}`,
  target: () => (loading.value ? null : containerRef.value),
  persist: true,
});

fetchUser(props.userId).then((u) => (user.value = u));
</script>

<template>
  <div ref="containerRef">
    <ASkeletonLayer v-if="loading && shape" :shape="shape" />
    <ColdStartFallback v-else-if="loading" />
    <UserCard v-else :data="user" />
  </div>
</template>

يلتقط walkStructural() الشكل، وهو walker شجريّ يحافظ على وسوم الحاويات وفئاتها الأصلية وعلى CSS الـ layout المحلول (display, flex-*, gap, padding, grid-*, box-sizing). أما الأوراق فتحمل عرضًا وارتفاعًا مضمّنَين إضافةً إلى الإشارات البصرية (background, border, radius). عندما يعيد <ASkeletonLayer> عرض الشجرة، فإنه يفعل ذلك في الـ flow الطبيعي: الـ <div class="flex flex-col gap-4 p-4"> الملتقط يُعرض كحاوية flex فعلية، وكتل الـ shimmer تجلس داخلها كأبناء flex. تغيّر حجم الـ viewport فيعيد الـ layout التدفّق، ولا يتأخّر الـ skeleton خلفه كما يحدث في إعادة عرض clone ذات المواضع المطلقة.

فلو كان لديك هذا الـ markup الفعلي:

<div class="flex flex-col gap-4 p-4">
  <h3>...</h3>
  <p>...</p>
  <button>...</button>
</div>

فإن الـ layer يعيد عرضه هكذا:

<div
  class="flex flex-col gap-4 p-4"
  style="display: flex; flex-direction: column; gap: 16px; padding: 16px; box-sizing: border-box"
>
  <div class="a-skel" style="width: 200px; height: 24px; ..." />
  <div class="a-skel" style="width: 280px; height: 16px; ..." />
  <div class="a-skel" style="width: 120px; height: 36px; ..." />
</div>

تبقى الفئة الأصلية موجودة كي يظلّ نظام الـ CSS الـ utility-first ساريًا، ويُضمَّن CSS الـ layout المحلول كنسخة احتياطية كي يبدو الـ skeleton صحيحًا حتى لو لم تُحمَّل ورقة الأنماط عند نقطة التركيب. يهمّ هذا كثيرًا لو كنت تعرض الشكل الملتقط داخل سياق CSS مختلف، مثل نافذة معاينة على هيئة بريد إلكتروني، أو shell معروض من الخادم لا يجلب Tailwind أصلًا.

الميزة الأخرى التي يمنحك إياها وضع structural هي الاستمرارية. تجعل persist: true الشكل الملتقط ينعكس إلى localStorage تحت البادئة a-skeleton:s:. عند البدء البارد (cold start) في زيارة عائدة، يقرأ الـ cache الشكل المحفوظ ويعيد عرض الـ skeleton في أول رسم مباشرةً، دون وميض "shimmer عام" بينما يُركَّب المكوّن الحقيقي ويقيس نفسه. الـ cache مُصدَّر مخططه (v: 3 لـ structural، وv: 2 للشكل المسطّح القديم)، ولذلك تُطهَّر الإصدارات غير المتطابقة تلقائيًا عند القراءة. لا يمكنك إعادة عرض هندسة الأمس عبر مُعارض اليوم لو تغيّر شكل البيانات.

الاستراتيجيات الثلاث تختار سلوكيات افتراضية مختلفة انطلاقًا من البدائيات نفسها. Clone يعيد عرض بكسلات بمواضع مطلقة. Mirror يعيد عرض vnodes داخل الشجرة الأصلية. Structural يعيد عرض الحاويات في الـ flow الطبيعي. وحدة cache واحدة، وسطح theming واحد، وقاعدة a11y واحدة، وثلاثة محرّكات مختلفة.

خط أنابيب الالتقاط والتخزين المؤقت وإعادة العرض

القصة المعمارية واحدة في الأوضاع الثلاثة، حتى وإن تباينت التنفيذات. إليك دورة تحميل واحدة من أوّلها إلى آخرها.

الرسم الأول، وفشل الـ cache. يُركَّب الـ wrapper، ولا يوجد شكل مخزّن بعد. في وضعَي clone وmirror يمشي المحرك على بنية الـ slot (vnodes في mirror، وتركيب DOM خارج الشاشة في clone) وينتج placeholder يعكس بنية وسوم الـ slot. تحتفظ الحاويات بوسومها الدلالية وبـ CSS الـ layout الخاص بها. أما الأوراق الذرّية والعناصر الحاملة للنص فتتحوّل إلى placeholders من نوع shimmer. يرى المستخدم skeleton بنيويًا يطابق حدود المكوّن الحقيقي على الفور، قبل أن تصل أيّ بيانات.

وصول البيانات. يُعرض المحتوى الحقيقي داخل الـ slot. يجدول ResizeObserver مع requestAnimationFrame تمريرة قياس. في وضع clone تقرأ captureSnapshot() قيمة getComputedStyle() لكل عنصر ومستطيلات النص لكل سطر عبر Range.getClientRects()، ثم تجمّد النتيجة داخل شجرة CaptureSnapshot. في وضع structural تنتج walkStructural() شجرة مجمّدة تحمل فيها الحاويات CSS الـ layout الخاص بها، وتحمل الأوراق أبعادها وإشاراتها البصرية. في الحالتين تصبح اللقطة الملتقطة في الذاكرة الآن (وفي localStorage أيضًا لو كانت persist: true).

التقلّب التالي إلى حالة التحميل. يعود المستخدم إلى الصفحة، أو يطلق إعادة جلب، أو يزور المسار في جلسة لاحقة. تنقلب loading إلى true. يُعاد عرض الشكل المخزّن فورًا، بلا خطوة وسيطة من نوع "خمّن الشكل". في وضع clone يعرض <ASkeletonClone> شجرة من الـ divs ذات المواضع المطلقة، يحمل كلٌّ منها نمطه المضمّن المجمّد مسبقًا؛ وحلقة العرض خالية تمامًا من التخصيص لأن كل حمولة نمط حُسبت وقت الالتقاط. في وضع structural يعرض <ASkeletonLayer> الشجرة في الـ flow الطبيعي، مع تشغيل CSS الـ layout الملتقط لسلوك حاوية flex أو grid.

للميزانيات دور في كل خطوة. maxNodes (افتراضيًا 500–800 بحسب الـ walker) هو حدّ حجم الشجرة. maxDepth (افتراضيًا 12–16) هو حدّ الاستدعاء العودي. minSize (افتراضيًا 4 بكسلات CSS) هو مرشّح لكل ورقة. هذه الأرقام ليست اعتباطية: اخترتُها بحيث تبقى لوحة تحكم مزدحمة بمئات الأوراق قابلة للمشي داخل rAF واحد، وبحيث يتّسع إدخال localStorage للشجرة الملتقطة دون تجاوز الحصّة البالغة 5 ميغابايت. وحين يُقتطع الـ walk، يسجّل الـ wrapper console.warn لمرة واحدة لكل cacheKey كي تكتشف الأمر في التطوير، لا في الإنتاج.

قراءة layout واحدة في البداية، ثم قيم مخزّنة في بقية المشي. تحدث استدعاءات getBoundingClientRect() وgetComputedStyle() في تمريرة واحدة من أعلى إلى أسفل، دون كتابات تتخلّلها، ودون قذف تخطيط: reflow قسري واحد، ثم تصبح اللقطة الملتقطة دائمة بعده.

البدائيات الـ 15 المسمّاة

التوليد الذاتي لا يناسب كل حالة. لو لم يكن لحالة التحميل عندك مكوّن حقيقي يستحق أن يُلَفّ (كأن تعرض الـ skeleton قبل بدء أيّ جلب بيانات، أو يكون الـ placeholder حقًا مختلف الـ markup عن العرض النهائي)، فلا يجد الالتقاط التلقائي شيئًا يقرأه أصلًا. لهذه الحالات تشحن الحزمة 15 متغيّرًا مسمّى: <ASkeletonCard>, <ASkeletonText>, <ASkeletonHeading>, <ASkeletonAvatar>, <ASkeletonImage>, <ASkeletonVideo>, <ASkeletonButton>, <ASkeletonInput>, <ASkeletonChip>, <ASkeletonListItem>, <ASkeletonTable>, <ASkeletonChart>, <ASkeletonForm>, <ASkeletonArticle>, <ASkeletonDivider>.

هذه ليست بديلًا عن مسار الالتقاط التلقائي، بل مخرجًا جانبيًا حين تحتاجه. تشترك في خط أنابيب الحركة نفسه، وفي رموز الـ theming نفسها، وفي قاعدة الـ a11y نفسها. تؤلّفها يدويًا عند الحاجة، وإلا فدع الـ wrapper يقوم بالعمل.

يوجد أيضًا <ASkeletonBlock> للحالة التي لا يناسبها أيّ من المتغيّرات وتريد تركيب نسختك الخاصة:

<div v-if="loading" class="flex items-start gap-4 p-4">
  <ASkeletonBlock type="circle" :w="64" :h="64" />
  <div class="flex-1 space-y-2">
    <ASkeletonBlock type="text" :w="160" :h="18" />
    <ASkeletonBlock type="text" :w="100" :h="12" />
    <ASkeletonBlock type="text" :lines="3" :h="14" class="!mt-3" />
  </div>
</div>
<UserCard v-else :data="user" />

<ASkeletonBlock> صديق للـ flow. يتركّب بسلاسة مع تخطيطات flex وgrid والـ stack. تكدّس prop lines على type="text" عددًا N من الأشرطة مع جعل الأخير بعرض 70%، وهو استدلال يكون صحيحًا بدرجة كافية في أغلب الحالات حين لا يتوفّر مكوّن حقيقي تقيسه.

الحركات والـ theming بوصفها استراتيجيات

تُشحن معنا أربعة أوضاع للحركة: shimmer (تدرّج ماسح على عنصر زائف ::after، محتوى داخل كل كتلة عبر overflow: hidden)، وpulse (تدوير opacity من 1 إلى --ak-skel-pulse-min والعودة)، وwave (تدرّج منزلق عبر background-position)، وnone (كتل ثابتة). كلٌّ منها استراتيجية قائمة بذاتها، وليست فرعًا شرطيًا. تمرّر animation="pulse" فتنخرط فئة الـ CSS المناسبة تلقائيًا. لا سلسلة if (animation === 'pulse') داخل المكوّن.

يعطّل prefers-reduced-motion: reduce كل حركة تلقائيًا. يهبط عنصر shimmer الزائف إلى طبقة ثابتة بشفافية منخفضة، ويتوقّف الـ pulse عن التدوير. لا شيفرة مكوّن معنيّة بذلك أصلًا؛ الأمر مجرد استعلام media في ورقة الأنماط.

الـ theming يقوم على متغيرات CSS. الرموز منطاقة تحت --ak-skel-* (مع أسماء بديلة متوافقة رجعيًا تحت --ak-skeleton-* لمستخدمي الإصدار الأول):

الرمزيُستخدم لأجل
--ak-skel-baseتعبئة الكتلة
--ak-skel-highlightلون مسح shimmer / wave
--ak-skel-radiusنصف قطر حدود الكتلة الافتراضي
--ak-skel-durationطول دورة الحركة
--ak-skel-pulse-minقيمة opacity عند قاع دورة الـ pulse
--ak-skel-ringلون حلقة داخلية خفيفة بعرض 1 بكسل
--ak-skel-iconلون أيقونة placeholder (متغيرات image / video)

تستطيع أن تكتب فوق هذه الرموز على :root، أو على فئة wrapper لأوضاع multi-tenant، أو مضمّنة على نسخة واحدة بعينها:

.tenant-acme {
  --ak-skel-base: hsl(220 30% 18%);
  --ak-skel-highlight: hsl(220 60% 60% / 0.35);
  --ak-skel-radius: 0.5rem;
  --ak-skel-duration: 2s;
}

الوضع الداكن يعمل تلقائيًا عبر محدّدات :where(.dark) التي تعيد تلوين الرموز (فئة .dark الخاصة بـ Tailwind، وفئة shadcn، وفئة nuxt-color-mode، وأيّ شيء يضع سلفًا يحمل .dark في الشجرة)، إضافةً إلى نسخة احتياطية عبر @media (prefers-color-scheme: dark) للحالة التي لا توجد فيها فئة صريحة على الشجرة.

المقصد من هذا التصميم أن لا يضطر مستهلك المكتبة يومًا إلى الكتابة فوق فئة داخلية. كل قرار بصري هو رمز؛ والرموز موروثة عبر الـ cascade؛ والكتابة فوقها لنسخة بعينها تكون بأنماط مضمّنة. المكوّن يملك البنية والحركة، وأنت تملك المظهر.

a11y لا تحتاج إلى تكوينها

الإعدادات الافتراضية هي ذاتها التي كنتَ ستريدها على أيّ حال. يحمل كل wrapper وكل جذر متغيّر السمة role="status" أثناء التحميل، وسمة aria-busy="true" تعكس حالة التحميل، وسمة aria-live="polite" كي تُعلن قارئات الشاشة حالة التحميل دون مقاطعة المستخدم. يُشحن <span class="a-skel-sr-only">Loading...</span> مخفيّ بصريًا مع كل متغيّر ليكون للإعلان نصّ يقرأه.

يحمل كل سطح shimmer مُصدَر السمة aria-hidden="true"، لأن الـ placeholder زخرفة والـ wrapper هو ما يُعلَن. تعطّل skeletons وضع mirror الخصائص user-select وpointer-events على شجرة الـ slot كي لا يستطيع أحد التفاعل مع الـ placeholder في منتصف التحميل. ويزيل prefers-reduced-motion الحركة كلها.

لا شيء من هذا قابل للتكوين. ولا ينبغي أن يكون. الإعدادات الافتراضية هي الإجابة الصحيحة. لو احتجتَ إلى إيقافها، فأنت إما تختبر شيئًا محدّدًا (وفي هذه الحالة animation="none" هو المفتاح المناسب)، أو تحاول شيئًا على مستوى الـ a11y لا أرغب أنا في أن أجعله سهلًا.

ما علّمني إياه وضع Clone

الشيء الذي سأحمله معي إلى المشكلة التالية ليس الـ API. بل هي البصيرة التي يقوم عليها وضع clone. تستطيع أن تتوقف عن التفكير على مستوى الـ cascade. تستطيع أن تتوقف عن محاولة التنبّؤ بما تنتجه فعلًا قيمة border-radius: 0.75rem مخلوطة بـ rounded-2xl مخلوطة بكتابة فوقية scoped. ركّب الشيء. دع المتصفح يطوي كل المدخلات. اقرأ الإجابة النهائية. أعِد عرض الإجابة النهائية.

هذا المبدأ ينطبق في كل موضع لديك فيه مشكلة "أحتاج نسخة من هذا العنصر". معاينات الطباعة. معاينات السحب. معارض المكوّنات التي تعرض الـ widget نفسه في خمسة أوضاع مختلفة. نهج "انظر إلى ما هو موجود فعلًا على الشاشة، لا إلى القواعد التي أنتجته" يمتدّ أبعد مما يُعطى من فضل. كل خط أنابيب CSS، وكل متغيّر، وكل أولوية cascade، وكل إطار حركة، ينتهي إلى رقم واحد لكل خاصية لكل عنصر، وذلك الرقم هو ما سيخبرك به getComputedStyle() متى ما سألته.

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

فكرتان اثنتان، وحزمة واحدة. المستودع مرخّص برخصة MIT، وستجد في الـ README المرجع الطويل لكل prop وslot لو أردتَ التعمّق أكثر. لو رأيتَ شيئًا يمكن أن يكون أكثر حدّة، افتح issue من فضلك. أفضّل بصدق أن أُصحَّح على أن أبقى مرتاحًا.

Ali Khalil

Ali Khalil

مطوّر واجهات أول وقائد فريق

اقرأ كمان

© 2019 - 2026 علي خليل