الوحدة 4 · إطلاق مشروع حقيقي
بناء تطبيق Watchlist
26 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
اجمع Watchlist المكتمل من components صغيرة، مع الـ state عند مالك واحد، وقيم مشتقّة، ومعالجات تُمرَّر إلى الأسفل، وقائمة محفوظة في localStorage.
- 1شكل التطبيق
- 2التخزين، معزولًا
- 3الـ App: المالك
- 4القطع التي تحت
- 5لماذا تصمد هذه البنية
- 6حين تكبر المعالجات
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
كل قطعة من Watchlist موجودة الآن، موزّعة على الدروس: الـ components من القسم 1، والـ props والقوائم من القسم 2، والـ state والنماذج والـ effects من القسم 3. هذا الدرس يجمعها في تطبيق واحد ستكون مرتاحًا لعرضه في مقابلة عمل، ويشرح كل قرار لتتّخذ القرارات نفسها في مشروعك التالي.
شكل التطبيق
هذا هو الترتيب النهائي للملفات في مشروع Vite:
src/
main.jsx
App.jsx state, handlers, derived values
storage.js load and save to localStorage
components/
Header.jsx title and "N to watch"
AddMovieForm.jsx controlled title input
FilterBar.jsx All / To watch / Watched
MovieList.jsx list or empty state
MovieItem.jsx one movie: toggle and removeملفّان فقط فيهما منطق يستحق الاختبار: App.jsx الذي يملك الـ state، وstorage.js. وكل component في components/ يستقبل props ويُرجع JSX. هذا هو التقسيم الذي تريده: طبقة رقيقة من الـ state والمنطق في الأعلى، وطبقة عريضة من القطع البسيطة تحتها.
التخزين، معزولًا
ابدأ بالـ module الذي يتحدّث مع المتصفّح:
const KEY = 'watchlist.movies';
export function loadMovies() {
try {
const saved = JSON.parse(localStorage.getItem(KEY));
return Array.isArray(saved) ? saved : [];
} catch {
return [];
}
}
export function saveMovies(movies) {
try {
localStorage.setItem(KEY, JSON.stringify(movies));
} catch {
// Storage full or blocked: the app still works, it just won't remember.
}
}قد يرمي localStorage أخطاء (محظور في بعض أوضاع الخصوصية، أو امتلأت سعته)، وقد يحتوي على أي شيء، بما في ذلك بيانات من نسخة أقدم من تطبيقك. فحص Array.isArray والتقاط الأخطاء يعنيان أن القيمة المعطوبة تعطيك قائمة فارغة بدل شاشة بيضاء. ولا ترى الـ components أي شيء من هذا.
الـ App: المالك
import { useEffect, useState } from 'react';
import { loadMovies, saveMovies } from './storage.js';
import Header from './components/Header.jsx';
import AddMovieForm from './components/AddMovieForm.jsx';
import FilterBar from './components/FilterBar.jsx';
import MovieList from './components/MovieList.jsx';
export default function App() {
const [movies, setMovies] = useState(loadMovies);
const [filter, setFilter] = useState('all');
useEffect(() => {
saveMovies(movies);
}, [movies]);
const left = movies.filter((m) => !m.watched).length;
const visible = movies.filter((m) =>
filter === 'all' ? true : filter === 'watched' ? m.watched : !m.watched,
);
function handleAdd(title) {
setMovies([...movies, { id: crypto.randomUUID(), title, watched: false }]);
}
function handleToggle(id) {
setMovies(movies.map((m) => (m.id === id ? { ...m, watched: !m.watched } : m)));
}
function handleRemove(id) {
setMovies(movies.filter((m) => m.id !== id));
}
return (
<main className="app">
<Header left={left} total={movies.length} />
<AddMovieForm onAdd={handleAdd} />
<FilterBar value={filter} onChange={setFilter} />
<MovieList movies={visible} onToggle={handleToggle} onRemove={handleRemove} />
</main>
);
}اقرأه من الأعلى إلى الأسفل وستعرف التطبيق كله. قطعتان من الـ state. effect واحد، للشيء الوحيد الموجود خارج React. قيمتان مشتقّتان. ثلاثة معالجات، كلٌّ منها سطر واحد، وكلٌّ منها إجراء مسمّى يستطيع المستخدم القيام به. ثم التخطيط. وحين يصل بلاغ خطأ («حذف فيلم يعيده بعد تحديث الصفحة»)، تعرف بالضبط أي الأسطر قد تكون معنية.
تأتي المعرّفات من crypto.randomUUID()، وتُنشأ مرة واحدة في handleAdd وتُخزَّن مع الفيلم، تمامًا كما أوصى درس الـ keys. وهي تعمل على localhost وعبر HTTPS، وهذا يغطّي التطوير وVercel.
القطع التي تحت
الـ components الخاصة بالقائمة والعنصر قصيرة لأنها لا تفعل سوى عرض الـ props وتمرير الأحداث:
import MovieItem from './MovieItem.jsx';
export default function MovieList({ movies, onToggle, onRemove }) {
if (movies.length === 0) {
return <p className="empty">No movies here yet.</p>;
}
return (
<ul className="movie-list">
{movies.map((movie) => (
<MovieItem key={movie.id} movie={movie} onToggle={onToggle} onRemove={onRemove} />
))}
</ul>
);
}export default function MovieItem({ movie, onToggle, onRemove }) {
return (
<li className={movie.watched ? 'movie watched' : 'movie'}>
<span className="title">{movie.title}</span>
<button aria-pressed={movie.watched} onClick={() => onToggle(movie.id)}>
{movie.watched ? 'Watched' : 'Mark watched'}
</button>
<button aria-label={`Remove ${movie.title}`} onClick={() => onRemove(movie.id)}>
Remove
</button>
</li>
);
}أمّا AddMovieForm وFilterBar فهما النسختان اللتان بنيتهما في الدروس السابقة، وصار Header يستقبل الرقمين اللذين يعرضهما بدل القائمة كاملة. ويحصل زر Remove على aria-label يسمّي الفيلم، لأن مستخدم الـ screen reader الذي يتنقّل بمفتاح Tab بين عشرة أزرار «Remove» يحتاج إلى معرفة الزر الذي يقف عليه.
لماذا تصمد هذه البنية
انظر إلى ما يحتاجه كل component ليُفحص. يحتاج MovieList إلى مصفوفة ودالتين. أعطه مصفوفة فارغة ويجب أن يعرض «No movies here yet.»؛ وأعطه فيلمين ويجب أن يرسم عنصرين. ويحتاج MovieItem إلى فيلم واحد، والنقر على Remove يجب أن يستدعي onRemove بمعرّف ذلك الفيلم. لا يحتاج أيٌّ منهما إلى localStorage، ولا إلى نموذج حقيقي، ولا إلى بقية التطبيق. هذا ما تعنيه «قطع صغيرة قابلة للاختبار» عمليًا: لكل قطعة بضعة مدخلات وبضعة مخرجات مرئية.
وحين تضيف اختبارات آلية لاحقًا (Vitest مع React Testing Library هو الثنائي الشائع لمشاريع Vite)، فهذه هي الاختبارات التي ستكتبها أولًا. وحتى قبل أن تكون لديك اختبارات، تؤتي البنية ثمارها: حين تبدو الحالة الفارغة خاطئة، تفتح ملفًا واحدًا من 10 أسطر، لا component من 300 سطر.
حين تكبر المعالجات
ثلاثة معالجات من سطر واحد سهلة المتابعة. لكن إن أضفت التعديل وإعادة الترتيب والتراجع، فسيمتلئ App بمنطق التحديث. هذه هي اللحظة التي تفكّر فيها في useReducer: إنه ينقل كل قواعد «كيف تتغيّر الأفلام» إلى دالة نقية واحدة، moviesReducer(movies, action)، يمكنك اختبارها من دون رسم أي شيء. لا تبدأ من هناك. ابدأ بـ useState، والجأ إلى reducer حين تحدّث عدّة معالجات الـ state نفسه بطرق مترابطة ويصبح الـ component صعب القراءة.
Watchlist يعمل. لكن شكله ليس مميّزًا بعد، وهذا موضوع الدرس التالي: تنسيقه باستخدام Tailwind CSS.
أهم النقاط
- يحتفظ التطبيق المكتمل بقطعتين فقط من الـ state الحقيقي في App، هما الأفلام والفلتر، ويشتقّ كل ما عداهما أثناء الـ render.
- كل التغييرات على الأفلام تحدث في عدد قليل من المعالجات المسمّاة داخل App، ويستدعيها الأبناء عبر الـ props.
- الـ components العرضية الصغيرة التي لا تفعل سوى استقبال الـ props سهلة القراءة وإعادة الاستخدام والاختبار.
- اعزل واجهات المتصفّح مثل localStorage في module صغير مع معالجة للأخطاء، لتبقى الـ components بسيطة.
- امشِ في كل مسار استخدام بيدك، بما فيه تحديث الصفحة والحالات الفارغة والاستخدام بلوحة المفاتيح، قبل أن تعدّ العمل منتهيًا.