شرح React Activity Component: Stateful
١٢ يونيو ٢٠٢٦
مكون <Activity> في React (المستقر منذ إصدار React 19.2) يقوم بإخفاء واجهة المستخدم دون إلغاء تركيبها (unmounting): يحتفظ الأبناء بحالتهم (state) وعناصر DOM الخاصة بهم، ويتم إخفاؤهم بصرياً باستخدام display: none، مع تنظيف الـ Effects الخاصة بهم أثناء الإخفاء. قم بضبط mode="hidden" عند الرندر الأولي وسيقوم مكون Activity بعمل رندر مسبق (pre-render) للمحتوى بأولوية منخفضة قبل أن يراه المستخدمون.
ملخص
في هذا الدرس التعليمي حول مكون Activity في React، ستقوم ببناء واجهة مستخدم للملاحظات بنظام التبويبات (tabs) بثلاث طرق: الرندر الشرطي (الذي يفقد الحالة)، وإخفاء CSS باستخدام display: none (الذي يسرب الاشتراكات النشطة)، ومكون <Activity> (الذي يحفظ الحالة ويوقف الـ Effects مؤقتاً) — ثم ستقوم بعمل رندر مسبق لتبويب مخفي ليظهر بشكل أسرع عندما يفتحه المستخدم. تم تنفيذ كل ادعاء سلوكي أدناه كمجموعة اختبارات Vitest + مكتبة اختبارات React مقابل إصدار React 19.2.7: نجحت 9/9 اختبارات، مع نظافة تامة من تحذيرات TypeScript. ميزانية وقت البناء حوالي 25-30 دقيقة.
ما ستتعلمه
- لماذا يدمر الرندر الشرطي حالة التبويب — ولماذا الإخفاء عبر CSS أسوأ مما يبدو
- كيف يعمل وضع الإخفاء في Activity في React: الحفاظ على الحالة، الحفاظ على DOM، وتنظيف الـ Effects
- كيفية الحفاظ على حالة التبويب في React باستخدام
<Activity mode>— تغيير في ثلاثة أسطر فقط - كيفية عمل رندر مسبق لواجهة مستخدم مخفية بحيث يظهر التبويب التالي بوقت تحميل أقل
- كيفية إثبات كل ذلك بالاختبارات، بالإضافة إلى المحاذير الموثقة التي تتجاهلها معظم الدروس (الأبناء النصيون فقط، الآثار الجانبية لـ
<video>، والجلب المعتمد على Effect)
المتطلبات الأساسية
- Node.js 22+ (يوصى بـ Node 24 LTS)
- React و React-dom إصدار 19.2 أو أحدث — هذا الدرس يثبت الإصدار 19.2.7 (الأحدث حالياً، المنشور في 1 يونيو 2026)1. مكون
Activityهو تصدير مستقر من المستوى الأعلى في 19.2؛ لم يعد هناك بادئةunstable_بعد الآن2 - TypeScript 6.0.3، Vitest 4.1.8، @testing-library/React 16.3.2، jsdom 29.1.1 لمجموعة التحقق
- الإلمام بـ
useStateوتنظيفuseEffect. إذا كان تنظيف الـ Effect غير واضح بالنسبة لك، فاقرأ دليل useEffect حول مصفوفات التبعية والتنظيف أولاً
قم بإعداد مشروع تجريبي:
mkdir activity-demo && cd activity-demo
npm init -y && npm pkg set type=module
npm install React@19.2.7 React-dom@19.2.7
npm install -D TypeScript@6.0.3 vitest@4.1.8 @testing-library/React@16.3.2 \
jsdom@29.1.1 @types/React@19.2.17 @types/React-dom@19.2.3 @vitejs/plugin-React@6.0.2
أضف ملف vitest.config.ts:
import { defineConfig } from 'vitest/config';
import React from '@vitejs/plugin-React';
export default defineConfig({
plugins: [React()],
test: { environment: 'jsdom', globals: true },
});
وملف tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "React-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["vitest/globals"]
},
"include": ["src"]
}
الخطوة 1 — بناء تبويب بحالة تستحق الحفاظ عليها
ما هو الغرض من مكون Activity في React؟ وظيفته الأساسية هي واجهة المستخدم التي تريد إخفاءها دون نسيانها. لذا أولاً، قم ببناء مكون بنوعين من الحالات القابلة للنسيان — حالة React (مسودة مُتحكم بها) واشتراك يُدار بواسطة Effect. احفظ هذا كـ src/TabPanel.tsx:
import { useEffect, useState } from 'React';
export const connectionLog: string[] = [];
export function NotesTab({ channel }: { channel: string }) {
const [draft, setDraft] = useState('');
useEffect(() => {
connectionLog.push(`subscribe:${channel}`);
return () => {
connectionLog.push(`unsubscribe:${channel}`);
};
}, [channel]);
return (
<div>
<label htmlFor="draft">Draft note</label>
<textarea
id="draft"
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<p data-testid="draft-echo">{draft}</p>
</div>
);
}
مصفوفة connectionLog المصدرة هي ثغرة اختبار مقصودة: يقوم الـ Effect بدفع مدخلات subscribe:/unsubscribe:، بحيث يمكن للاختبار التأكد تماماً من وقت قيام React بتركيب الاشتراك وتنظيفه. في تطبيق حقيقي، سيكون هذا اشتراك WebSocket أو اشتراك في مخزن بيانات (store).
الخطوة 2 — الفشل الذي تعرفه: الرندر الشرطي يفقد الحالة
نمط التبويبات القياسي يقوم بتركيب التبويب النشط فقط:
{tab === 'notes' && <NotesTab channel="notes-feed" />}
اكتب مسودة، بدل التبويبات، ثم عد مرة أخرى — ستجد المسودة قد اختفت. إلغاء تركيب المكون يدمر حالته الداخلية3. يثبت الاختبار المنفذ كلاً من فقدان الحالة وتكرار الاشتراك:
test('conditional rendering DESTROYS state: draft is lost on unmount/remount', () => {
render(<App />);
fireEvent.change(screen.getByLabelText('Draft note'), {
target: { value: 'half-written thought' },
});
fireEvent.click(screen.getByText('Home'));
expect(document.querySelector('textarea')).toBeNull(); // اختفى
fireEvent.click(screen.getByText('Notes'));
expect(
(screen.getByLabelText('Draft note') as HTMLTextAreaElement).value,
).toBe(''); // فُقدت الحالة
expect(connectionLog).toEqual([
'subscribe:notes-feed',
'unsubscribe:notes-feed',
'subscribe:notes-feed', // اتصال جديد في كل مرة يعود فيها المستخدم
]);
});
الخطوة 3 — الفشل الذي لا تعرفه: display none يبقي الـ Effects حية
الحل البديل الكلاسيكي — والسبب في أن البحث عن "مكون Activity في React مقابل display none" شائع جداً — هو الإخفاء عبر CSS:
<div style={{ display: tab === 'notes' ? 'block' : 'none' }}>
<NotesTab channel="notes-feed" />
</div>
الحالة تنجو الآن — لكن React ليس لديه أدنى فكرة أن الشجرة الفرعية مخفية. يظل المكون مركباً بالكامل: يظل اشتراكه نشطاً، وتستمر الـ Effects الخاصة به في العمل، ويقوم بإعادة الرندر بكامل الأولوية مع بقية الشجرة. يظهر الاختبار المنفذ التسريب الصامت:
test('CSS display:none KEEPS the effect subscribed while hidden', () => {
render(<App />);
expect(connectionLog).toEqual(['subscribe:notes-feed']);
fireEvent.click(screen.getByText('Home'));
// مخفي، لكن لم يتم إجراء أي تنظيف — الاشتراك لا يزال نشطاً
expect(connectionLog).toEqual(['subscribe:notes-feed']);
});
تبويب مخفي واحد يبقي WebSocket مفتوحاً هو إزعاج. عشر لوحات مخفية كل منها يحتفظ باشتراكات ومؤقتات ومستمعين هي مشكلة في الأداء والصحة.
الخطوة 4 — الحل: وضع Activity في React (visible/hidden)
هل يقوم Activity في React بإلغاء تركيب الـ effects عند الإخفاء؟ نعم — هذا هو جوهر التصميم. عندما يتم إخفاء حدود Activity، يقوم React بإخفاء أبنائه بصرياً باستخدام display: none، ويدمر الـ Effects الخاصة بهم (بتشغيل كل عمليات التنظيف)، ويحافظ على كل من حالة React وعناصر DOM. أثناء الإخفاء، لا يزال الأبناء يعيدون الرندر استجابةً للـ props الجديدة، ولكن بأولوية أقل من المحتوى المرئي. عندما تصبح الحدود مرئية مرة أخرى، يكشف React عن الأبناء بحالتهم السابقة ويعيد إنشاء الـ Effects الخاصة بهم3.
التغيير عن الخطوة 2 هو ثلاثة أسطر فقط:
import { Activity, useState } from 'React';
import { NotesTab } from './TabPanel';
export function TabApp() {
const [tab, setTab] = useState<'home' | 'notes'>('notes');
return (
<>
<button onClick={() => setTab('home')}>Home</button>
<button onClick={() => setTab('notes')}>Notes</button>
<Activity mode={tab === 'notes' ? 'visible' : 'hidden'}>
<NotesTab channel="notes-feed" />
</Activity>
</>
);
}
يقبل mode القيمتين 'visible' أو 'hidden'، ويتحول افتراضياً إلى 'visible' إذا تم حذفه3. يؤكد اختباران منفذان جميع الوعود الثلاثة. أولاً، تنظيف الـ Effects عند الإخفاء وإعادة إنشائها عند الكشف:
test('hiding cleans up Effects; revealing re-creates them', () => {
render(<TabApp />);
expect(connectionLog).toEqual(['subscribe:notes-feed']);
fireEvent.click(screen.getByText('Home'));
expect(connectionLog).toEqual([
'subscribe:notes-feed',
'unsubscribe:notes-feed', // قام Activity بتشغيل عملية التنظيف
]);
fireEvent.click(screen.getByText('Notes'));
expect(connectionLog).toEqual([
'subscribe:notes-feed',
'unsubscribe:notes-feed',
'subscribe:notes-feed', // وأعاد إنشاء الـ Effect
]);
});
...والحفاظ على الحالة و DOM، مع تنفيذ الإخفاء كـ display: none مضمن على العنصر المضيف للابن:
test('React state AND DOM survive hide/show', () => {
render(<TabApp />);
const textarea = screen.getByLabelText('Draft note') as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: 'half-written thought' } });
fireEvent.click(screen.getByText('Home'));
expect(document.querySelector('textarea')).not.toBeNull(); // تم الحفاظ على DOM
const wrapper = document.querySelector('textarea')!.closest('div')!;
expect(wrapper.style.display).toBe('none'); // مخفي، لم يُحذف
fireEvent.click(screen.getByText('Notes'));
expect(
(screen.getByLabelText('Draft note') as HTMLTextAreaElement).value,
).toBe('half-written thought'); // نجت المسودة
});
لأن عقدة DOM نفسها تنجو، تنجو حالة مستوى DOM أيضاً: قيمة مسودة مدخل غير مُتحكم به، أو موضع تشغيل عنصر <video>3. تضع وثائق React النموذج الذهني بدقة: من الناحية المفاهيمية، يجب أن تفكر في الـ Activities المخفية على أنها غير مركبة (unmounted) — يتم حفظ الحالة لوقت لاحق، ولكن لا ينبغي أن يكون هناك شيء قيد التشغيل3.
الخطوة 5 — عمل رندر مسبق لواجهة مستخدم مخفية قبل أن يطلبها المستخدم
الاستخدام الثاني لوضع الإخفاء في Activity يتطلع للأمام، وليس للخلف: المحتوى الذي لم يره المستخدم بعد. إذا كانت حدود Activity مخفية أثناء الرندر الأولي، فلن يكون أبناؤها مرئيين — لكن سيتم عمل رندر لهم، بأولوية منخفضة، دون تركيب الـ Effects الخاصة بهم3. هذا هو الرندر المسبق: يتم بناء شجرة المكونات و DOM للتبويب المخفي في الخلفية، بحيث عندما تصبح الحدود مرئية، يمكن لأبنائها الظهور بشكل أسرع، مع تقليل أوقات التحميل3.
<Suspense fallback={<h1>Loading…</h1>}>
<Activity mode={tab === 'home' ? 'visible' : 'hidden'}>
<Home />
</Activity>
<Activity mode={tab === 'notes' ? 'visible' : 'hidden'}>
<NotesTab channel="notes-feed" />
</Activity>
</Suspense>
ينجح كلا الاختبارين المنفذين على الإصدار 19.2.7 — يوجد DOM قبل الكشف، ولكن لم يتم تشغيل أي Effect:
test('hidden-on-initial-render pre-renders DOM but does NOT mount Effects', () => {
render(
<Activity mode="hidden">
<NotesTab channel="prerender" />
</Activity>,
);
expect(document.querySelector('textarea')).not.toBeNull(); // تم الرندر المسبق
expect(connectionLog).toEqual([]); // لم يتم تشغيل أي Effect
});
test('flipping a pre-rendered boundary to visible mounts Effects', () => {
render(<App />); // يبدأ مخفياً
expect(connectionLog).toEqual([]);
fireEvent.click(screen.getByText('Reveal'));
expect(connectionLog).toEqual(['subscribe:prerender']);
});
ملاحظة هامة حول النطاق تشير إليها الوثائق وتتجاهلها معظم المقالات: أثناء الرندر المسبق، يتم جلب مصادر البيانات التي تدعم Suspense فقط — مثل أطر العمل التي تدعم Suspense مثل Relay و Next.js، وتقسيم الكود باستخدام lazy، وقراءة وعد (promise) مخزن مؤقتاً باستخدام use. لا يكتشف Activity البيانات التي يتم جلبها داخل Effect3. إذا كان التبويب المخفي الخاص بك يقوم بالجلب في useEffect، فسيقوم الرندر المسبق ببناء DOM الخاص به ولكنه لن يقوم بتجهيز بياناته — وهذا مقصود، لأن الـ Effects لا يتم تركيبها أثناء الإخفاء.
تشارك حدود Activity أيضًا في Selective Hydration في التطبيقات التي يتم تقديمها عبر الخادم: مثل حدود Suspense، فهي تقسم الشجرة إلى وحدات قابلة للـ hydration بشكل مستقل، بحيث يمكن لـ React جعل أزرار التبويب تفاعلية قبل عمل hydration لمحتوى التبويب الثقيل — وتشير الوثائق إلى أنه يمكنك إضافة حدود Activity مرئية دائمًا فقط من أجل مكسب الـ hydration هذا، حتى حول المحتوى الذي لا تخفيه أبدًا3. إذا كنت تستخدم App Router، فإن هذا يتوافق مع أنماط الـ streaming من درس Next.js 16 للـ streaming و use cache.
Activity مقابل العرض الشرطي مقابل display: none
ما الفرق بين وضع Activity المخفي والعرض الشرطي (conditional rendering)؟ العرض الشرطي يدمر كل شيء عند الإخفاء؛ أما الإخفاء عبر CSS فيحافظ على كل شيء، بما في ذلك العمل الذي أردت إيقافه؛ بينما Activity يوازن بينهما عمدًا — فهو يحافظ على الأجزاء الخاملة (الحالة، DOM) ويوقف الأجزاء النشطة (الـ Effects، والتقديم بكامل الأولوية).
| السلوك عند الإخفاء | {cond && <X />} | CSS display: none | <Activity mode="hidden"> |
|---|---|---|---|
| حالة React | تُدمر | تُحفظ | تُحفظ |
| حالة DOM (مثل مسودة في input) | تُدمر | تُحفظ | تُحفظ |
| الـ Effects / الاشتراكات | يتم تنظيفها | لا تزال تعمل | يتم تنظيفها |
| إعادة التقديم عند تغيير الـ props | لا (غير مُثبت) | نعم، بأولوية كاملة | نعم، بأولوية أقل |
| إمكانية التقديم المسبق لمحتوى غير مرئي | لا | يُقدم فورًا، مع الـ Effects وكل شيء | نعم، بدون Effects |
تنبثق ثلاث قواعد عملية من هذا الجدول. استخدم Activity عندما يكون من المحتمل أن يعود المستخدم إلى واجهة المستخدم المخفية وسيلاحظ إعادة التعيين — لوحات التبويب، النماذج متعددة الخطوات، الأشرطة الجانبية القابلة للتوسيع، لوحة البحث مع الفلاتر. التزم بالعرض الشرطي العادي عندما يجب إعادة تعيين الحالة المخفية (مربع حوار يجب أن يفتح جديدًا في كل مرة) أو عندما تكون الشجرة الفرعية رخيصة التكلفة لإعادة البناء — حيث يحتفظ Activity بالأشجار المخفية في الذاكرة، وهذا يمثل تكلفة حقيقية للمحتوى الذي لن يزوره المستخدم مرة أخرى. وتعامل مع الإخفاء الخام عبر CSS كأداة تخطيط (layout)، وليس كأداة لدورة الحياة: في اللحظة التي تمتلك فيها الشجرة الفرعية المخفية اشتراكًا أو مؤقتًا أو فاصلًا زمنيًا، فأنت تدفع ثمن عمل لا يمكن لأحد رؤيته.
هناك سلوك آخر من الجدول يستحق المشاهدة بشكل ملموس: الأطفال المخفيون ليسوا مجمدين. لا يزالون يعيدون التقديم (re-render) عندما تتغير الـ props الخاصة بهم — يقوم React فقط بجدولة هذا العمل بأولوية أقل من التحديثات المرئية3. يؤكد اختبار سجل التقديم المنفذ أن الطفل المخفي يتلقى قيم props جديدة:
test('hidden children still re-render in response to new props', () => {
render(<App />); // <Probe value={v}> inside a hidden Activity
expect(renderLog).toContain('a');
fireEvent.click(screen.getByText('bump')); // setV('b')
expect(renderLog).toContain('b'); // hidden, but not stale
});
لذا فإن التبويب المخفي يتتبع أحدث الـ props والحالة لتطبيقك طوال الوقت الذي يكون فيه مخفيًا — بأولوية أقل من المحتوى المرئي، وبدون تشغيل الـ Effects. هذا ما يجعل الكشف سريعًا وصحيحًا في نفس الوقت: المحتوى محدث بالفعل عندما يعود المستخدم إليه.
التحقق
قم بتشغيل المجموعة الكاملة من جذر المشروع:
npx vitest run && npx tsc --noEmit
المتوقع: 9 passed (9) عبر اختبارات failure-arc و Activity، وفحص أنواع صامت (نظيف). تغطي المجموعة المنفذة لهذا الدرس: تنظيف الـ effect عند الإخفاء، إعادة إنشاء الـ effect عند الكشف، الحفاظ على الحالة + DOM، الـ display: none المضمن، التقديم المسبق بدون Effects، التثبيت عند الكشف، تنبيه الطفل النصي فقط، الوضع الافتراضي، وإعادة تقديم الأطفال المخفيين عند تغيير الـ props (تم التحقق بسجل تقديم؛ حدوث ذلك بأولوية أقل هو وفقًا لوثائق React، وليس شيئًا يمكن لـ jsdom ملاحظته).
الأخطاء الشائعة
الـ <video> في التبويب المخفي يستمر في العمل. لا يتم تدمير DOM الخاص بالمكون المخفي، لذا تستمر الآثار الجانبية على مستوى DOM — استمرار تشغيل <video> أو <audio> أو <iframe> خلف display: none. حل الوثائق: قم بإيقافه مؤقتًا في تنظيف الـ Effect، باستخدام useLayoutEffect لأن التنظيف مرتبط بكون واجهة المستخدم مخفية بصريًا ولا ينبغي تأخيره بواسطة حدود re-suspending أو View Transition3:
useLayoutEffect(() => {
const video = ref.current;
return () => {
video?.pause();
};
}, []);
مكوني المخفي لا يقدم شيئًا على الإطلاق. إذا كان الطفل يقدم نصًا فقط — بدون عنصر DOM مغلف — فإن Activity المخفي لا ينتج أي مخرجات، لأنه لا يوجد عنصر لتطبيق display: none عليه3. تم التحقق: <Activity> مخفي حول مكون يعيد سلسلة نصية مجردة يقدم حاوية فارغة. قم بتغليف الأطفال النصيين فقط في عنصر.
الـ Effects التي توقعتها لا تعمل أثناء الإخفاء. هذه ميزة وليست خطأ. يتم تنظيف جميع Effects الأطفال أثناء الإخفاء. إذا كنت تعتمد على تثبيت Effect للتراجع عن شيء ما، فانقل هذا العمل إلى وظيفة التنظيف بدلاً من ذلك3.
الاشتراكات تسيء التصرف بعد دورات الإخفاء/الإظهار. تميل دورات الإخفاء/الإظهار إلى كشف الـ Effects التي تفتقر إلى التنظيف المناسب. للعثور عليها مبكرًا، غلف تطبيقك بـ <StrictMode> — توصي الوثائق بذلك لأنه يقوم بعمليات إلغاء تثبيت وتثبيت لـ Activity في بيئة التطوير لاكتشاف الآثار الجانبية غير المتوقعة3. نفس نظافة الـ Effect التي تصلح اضطراب إعادة الاتصال في درس useEffectEvent هي ما يجعل المكونات آمنة للاستخدام مع Activity.
حالة التبويب تختلط بين التبويبات المقدمة ديناميكيًا. مثل كل حالات React، ترتبط الحالة المحفوظة بواسطة Activity بموقع الشجرة. إذا كنت تقدم حدود Activity في حلقة (loop)، فامنحها props key مستقرة.
الخطوات التالية
هناك امتدادان طبيعيان من هنا. أولاً، اربط Activity مع <ViewTransition> — حيث يؤدي جعل Activity مرئيًا داخل أحدهما (عبر startTransition) إلى تشغيل حركة الدخول الخاصة به، ويؤدي الإخفاء إلى تشغيل حركة الخروج3. ثانيًا، راجع نظافة الـ Effect لديك من خلال درس React useEffectEvent — يفترض Activity أن عمليات التنظيف صحيحة، وتجعل أحداث الـ effect هذه العمليات صغيرة. بالنسبة لنطاق 19.2 الأوسع، يغطي منشور الإصدار الرسمي ما تم شحنه إلى جانب Activity2.
Footnotes
-
بيانات npm الوصفية لـ
React، تم التحقق منها في 12 يونيو 2026 —latestهو 19.2.7، نُشر في 1 يونيو 2026؛ و 19.3 موجود فقط كإصدارات canary. ↩ -
منشور إصدار React 19.2 (1 أكتوبر 2025) — تم شحن
<Activity />كـ API مستقر في 19.2 إلى جانبuseEffectEventوcacheSignal. ↩ ↩2 -
<Activity>— مرجع React الرسمي — الـ props، دلالات الوضع المخفي ("تدمير الـ Effects الخاصة بها"، "أولوية أقل"،display: "none")، العرض المسبق (pre-rendering)، ملاحظة مصدر البيانات المدعوم بـ Suspense، الـ Selective Hydration، ومدخلات استكشاف الأخطاء وإصلاحها. تم جلب البيانات في 12 يونيو 2026. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15