EduFlow

الوحدة 2 · التخطيط والتنسيق

قوائم تبقى سريعة: FlatList وSectionList

22 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

اعرض سجلّ الرحلات المتنامي في Trailhead باستخدام FlatList وSectionList، وافهم الـ virtualization، وطبّق التحسينات القليلة التي تُبقي القوائم الطويلة سلسة على الهواتف متوسطة الفئة.

  1. 1FlatList: ارسم ما هو مرئي
  2. 2كيف يعمل الـ virtualization
  3. 3الحفاظ على السلاسة
  4. 4SectionList: بيانات مجمّعة

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

يبدأ سجلّ Trailhead بنحو اثنتي عشرة رحلة. المتنزّه المتحمّس يسجّل ثلاث رحلات أسبوعيًا؛ وبعد أربع سنوات يتجاوز السجلّ 600 صف، لكلٍّ منها صورة مصغّرة وعنوان وبعض الإحصاءات. اعرض ذلك باستخدام ScrollView و.map()، وسيبني التطبيق الـ native views لكل صف قبل أن يعرض أول إطار. على هاتف رائد قد لا تلاحظ. أما على هواتف Android متوسطة الفئة التي يستخدمها معظم العالم، فستحصل على شاشة فارغة وتقطّع.

FlatList: ارسم ما هو مرئي

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

src/app/index.tsx
import { FlatList, Text, View } from 'react-native';
import { HikeRow } from '../components/HikeRow';
import { useHikes } from '../state/hikes';

export default function HikeLog() {
  const hikes = useHikes();

  return (
    <FlatList
      data={hikes}
      keyExtractor={(hike) => hike.id}
      renderItem={({ item }) => <HikeRow hike={item} />}
      ItemSeparatorComponent={() => <View style={{ height: 1, backgroundColor: '#e2e8e4', marginLeft: 80 }} />}
      ListEmptyComponent={<Text style={{ padding: 24, textAlign: 'center' }}>No hikes yet. Log your first one!</Text>}
      contentContainerStyle={{ paddingBottom: 24 }}
    />
  );
}

مثل ScrollView، يحتاج FlatList إلى ارتفاع محدود، لذا يجب أن يعطيه أبوه flex: 1 (والشاشة في Expo Router تفعل ذلك أصلًا). تستقبل renderItem الكائن { item, index }. ويتيح لك ListHeaderComponent وListFooterComponent أن تتمرّر بطاقة ملخّص أو مؤشّر «تحميل المزيد» مع القائمة، بدلًا من تغليف الـ FlatList داخل ScrollView، وهذا يُبطل الـ virtualization كليًا ويُطلق تحذيرًا.

كيف يعمل الـ virtualization

يحتفظ FlatList بـ نافذة رسم (render window): الصفوف الظاهرة على الشاشة، زائد مخزون احتياطي فوقها وتحتها. وأثناء التمرير، تُركَّب الصفوف التي تدخل النافذة وتُزال التي تخرج منها، ويحلّ محلّها فراغ بالارتفاع الصحيح.

من بين 600 رحلة، لا يُركَّب إلا الصفوف الظاهرة على الشاشة ومخزون احتياطي فوقها وتحتها؛ أما الصفوف خارج النافذة فيحلّ محلّها فراغ بالارتفاع نفسه. الصفوف 1–90: فراغ احتياطي (مركَّب) الصفوف المرئية احتياطي (مركَّب) الصفوف 320–600: فراغ شاشة الهاتف منطقة العرض windowSize الافتراضي 21 شاشة initialNumToRender الافتراضي 10 صفوف
يُبقي FlatList نافذة واحدة فقط من الصفوف مركّبة

ينتج عن ذلك أمران. أولًا، يجب أن تكون المفاتيح (keys) ثابتة: يستخدمها FlatList وReact لمعرفة أي صفّ هو أي صفّ بينما تأتي الصفوف وتذهب، فاستخدم معرّفًا من بياناتك. فهارس المصفوفة تنكسر لحظة إدراج رحلة جديدة في الأعلى. ثانيًا، الصفوف تنسى الـ state المحلية حين تُزال. علامة «موسَّع» المحفوظة في useState داخل الصف تعود إلى قيمتها الأولى بعد تمرير طويل. ارفع هذه الـ state إلى الأب (expandedId) أو إلى بياناتك.

الحفاظ على السلاسة

معظم التقطّع في القوائم يأتي من إعادة الـ render لصفوف لم تتغيّر، أو من عمل ثقيل داخلها. بالترتيب بحسب العائد:

  1. طبّق memo على الصف. صدّر HikeRow مغلّفًا بـ memo، فلا يُعاد الـ render للصف إلا حين يتغيّر الـ prop المسمّى hike. (إن كان مشروعك يفعّل React Compiler، فهو يتولّى الـ memoization نيابةً عنك.)
  2. أبقِ renderItem ثابتة. الدالة السهمية المباشرة لا بأس بها مع صفّ مغلّف بـ memo، لكن لا تنشئ كائنات جديدة للـ props في كل render، مثل style={{…}} أو hike={{ ...item }}، فهي تُبطل memo.
  3. لا عمل ثقيل داخل الصفوف. نسّق التواريخ واحسب الإحصاءات مرة واحدة، حين تتغيّر البيانات، لا في الـ render الخاص بكل صف.
  4. صور بالحجم المناسب. صورة بعرض 4000 بكسل مصغّرة داخل صورة مصغّرة بعرض 56 نقطة تكلّف ذاكرة لكل صفّ مرئي. اطلب صورًا مصغّرة من الـ API، أو استخدم expo-image مع التخزين المؤقت.
  5. ارتفاعات ثابتة؟ استخدم getItemLayout. إن كان كل صف 72 نقطة زائد فاصل بنقطة واحدة، يستطيع FlatList تخطّي القياس:
TSX
const ROW = 73;

<FlatList
  // …
  getItemLayout={(_, index) => ({ length: ROW, offset: ROW * index, index })}
/>

وللسجلّات المحفوظة على خادم، لا تجلب الرحلات الـ 600 كلها دفعة واحدة. يُطلَق onEndReached حين يقترب المستخدم من الأسفل بالتمرير (ومدى الاقتراب يحدّده onEndReachedThreshold، ويُقاس بارتفاعات الشاشة)، وهذه هي اللحظة لطلب الصفحة التالية وإلحاقها بـ data. اعرض مؤشّر تحميل في ListFooterComponent أثناء التحميل، واحمِ الكود من إطلاق الطلب مرتين.

ضبط windowSize وinitialNumToRender يأتي في النهاية، وبعد أن تقيس فقط. وللقوائم الضخمة جدًا أو المعقّدة، تنتقل فرق كثيرة إلى FlashList من Shopify، الذي يعيد تدوير views الصفوف بدلًا من تركيب صفوف جديدة، ويحافظ على واجهة برمجية شبيهة بـ FlatList.

SectionList: بيانات مجمّعة

يجمّع Trailhead الرحلات بحسب الشهر، مع ترويسة لاصقة لكل شهر. يفعل SectionList ذلك انطلاقًا من بيانات بهذا الشكل:

TypeScript
const sections = [
  { title: 'October 2026', data: [ridgeLoop, lakeTrail] },
  { title: 'September 2026', data: [oldMill] },
];
TSX
<SectionList
  sections={sections}
  keyExtractor={(hike) => hike.id}
  renderItem={({ item }) => <HikeRow hike={item} />}
  renderSectionHeader={({ section }) => <Text style={styles.header}>{section.title}</Text>}
  stickySectionHeadersEnabled
/>

الجزء المثير للاهتمام ليس الـ component؛ بل تحويل مصفوفة مسطّحة من الرحلات إلى شكل sections هذا، مرتّبة بشكل صحيح. وهذا هو تمرينك.

يبدأ القسم 3 بعد ذلك: مع وجود شاشات حقيقية للتنقّل بينها، يحتاج Trailhead إلى نظام تنقّل (navigation).

أهم النقاط

  • ScrollView مع .map() يركّب (mount) كل الصفوف دفعة واحدة؛ أما FlatList فيركّب فقط نافذة من الصفوف حول الجزء المرئي.
  • أعطِ FlatList دالة keyExtractor تُرجع معرّفًا ثابتًا من بياناتك، لا فهرس المصفوفة أبدًا.
  • الصفوف في القائمة الافتراضية (virtualized) تُزال حين تبتعد بالتمرير، لذا احفظ state الصف في بياناتك، لا داخل component الصف.
  • طبّق memo على component الصف، وأبقِ renderItem ثابتة، وأضف getItemLayout حين يكون لكل الصفوف الارتفاع نفسه.
  • يأخذ SectionList الخاصية sections بشكل { title, data } ويعرض قوائم مجمّعة بترويسات لاصقة.

قراءات إضافية

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

في سجلّ Trailhead ستمئة رحلة تُعرض باستخدام ScrollView و.map(). تستغرق الشاشة ثلاث ثوانٍ لتظهر على هاتف Android قديم. لماذا؟

السؤال 2 من 3

لكل صفّ رحلة useState محلية للحالة «موسَّع». يوسّع المستخدمون صفًّا، ثم يمرّرون بعيدًا إلى الأسفل، ثم يعودون، فيجدونه مطويًّا من جديد. لماذا؟

السؤال 3 من 3

ارتفاع كل صفّ في Trailhead 72 نقطة بالضبط. أيّ prop يتيح لـ FlatList تخطّي قياس الصفوف والقفز مباشرة إلى الصف 400؟

تدريب

بيئة تجريب الكود

جمّع الرحلات في أقسام SectionList

اكتب groupHikesByMonth(hikes) ليتمكّن الـ SectionList في Trailhead من عرض السجلّ مجمّعًا بحسب الشهر. أرجع مصفوفة من الأقسام بالشكل { title, data } بحيث:

  • title هو اسم الشهر والسنة، مثل "October 2026" (استخدم المصفوفة MONTHS)،
  • الأقسام مرتّبة من الشهر الأحدث أولًا، وOctober 2026 وOctober 2025 قسمان مختلفان،
  • الرحلات داخل كل قسم مرتّبة من الأحدث أولًا،
  • لا تُعدَّل مصفوفة المدخلات.

التواريخ نصوص بصيغة YYYY-MM-DD. في الكود المبدئي عدّة أخطاء؛ شغّله وقارن مخرجاته بما تحتاج إليه القائمة.

const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];

const hikes = [
  { id: 'h1', name: 'Ridge Loop', date: '2026-10-04', distanceKm: 8.4 },
  { id: 'h2', name: 'Old Mill Creek', date: '2026-09-12', distanceKm: 5.1 },
  { id: 'h3', name: 'Lake Trail', date: '2026-10-19', distanceKm: 11.2 },
  { id: 'h4', name: 'Eagle Point', date: '2025-10-02', distanceKm: 14.0 },
  { id: 'h5', name: 'Fern Gully', date: '2026-09-28', distanceKm: 3.6 },
];

// Return SectionList sections: [{ title: 'October 2026', data: [...] }, ...]
function groupHikesByMonth(hikes) {
  const sections = {};
  for (const hike of hikes) {
    const month = MONTHS[Number(hike.date.slice(5, 7))];
    if (!sections[month]) sections[month] = [];
    sections[month].push(hike);
  }
  return Object.keys(sections).map((title) => ({ title, data: sections[title] }));
}

console.log(JSON.stringify(groupHikesByMonth(hikes), null, 2));
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

الـ console

(لا مخرجات)

الفحوص

  • عناوين الأقسام تتضمّن السنة ومرتّبة من الشهر الأحدث أولًا
  • الرحلات داخل كل قسم مرتّبة من الأحدث أولًا
  • يحصل يناير وديسمبر على اسمَي الشهرين الصحيحين
  • تحتفظ مصفوفة الرحلات الأصلية بترتيبها
  • السجلّ الفارغ يعطي مصفوفة أقسام فارغة