كيفية بناء نسخة مصغّرة من Hacker News باستخدام React
مقدمة: لماذا نبني نسخة من Hacker News باستخدام React؟
يُعد بناء تطبيق شبيه بمنصة Hacker News مشروعاً ممتازاً لفهم كيفية التعامل مع React في التطبيقات الواقعية، خاصة عند جلب البيانات من واجهات برمجة التطبيقات API، وإدارة الحالة، والتنقل بين الصفحات، وعرض المحتوى بشكل ديناميكي. في هذا الدليل سنبني تطبيقاً مصغّراً يعتمد على React Hooks لعرض أهم القصص وأحدثها وأفضلها من واجهة Hacker News API.
الهدف هنا ليس فقط تقليد الفكرة، بل إنشاء بنية نظيفة وقابلة للتوسعة، بحيث تستطيع لاحقاً إضافة التعليقات أو الترقيم Pagination أو حتى تحسين الأداء وتجربة الاستخدام.

التعرّف إلى API الخاصة بـ Hacker News
سنستخدم واجهة Hacker News API لجلب أنواع متعددة من القصص. توفر الواجهة ثلاث نقاط رئيسية لجلب المعرّفات ID الخاصة بالقصص:
- القصص الأعلى تقييماً:
https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty - أحدث القصص:
https://hacker-news.firebaseio.com/v0/newstories.json?print=pretty - أفضل القصص:
https://hacker-news.firebaseio.com/v0/beststories.json?print=pretty
هذه الروابط لا تعيد بيانات القصة كاملة، بل تعيد مصفوفة من المعرّفات فقط. لذلك نحتاج إلى طلب إضافي لكل قصة من أجل جلب التفاصيل الفعلية.
رابط جلب تفاصيل قصة واحدة يكون كالتالي:
https://hacker-news.firebaseio.com/v0/item/story_id.json?print=pretty
مثال عملي:
https://hacker-news.firebaseio.com/v0/item/26061935.json?print=pretty
إعداد مشروع React من البداية
ابدأ بإنشاء مشروع جديد باستخدام create-react-app:
npx create-react-app hackernews-clone-react-app
بعد إنشاء المشروع، احذف الملفات الموجودة داخل مجلد src، ثم أنشئ الملفات والمجلدات التالية:
- ملف
index.js - ملف
styles.scss - مجلد
components - مجلد
hooks - مجلد
router - مجلد
utils
بعد ذلك ثبّت الاعتماديات المطلوبة:
yarn add axios@0.21.0 bootstrap@4.6.0 node-sass@4.14.1 react-bootstrap@1.4.0 react-router-dom@5.2.0
يعتمد المشروع على SCSS لتنسيق الواجهة، وهو خيار مناسب عندما تريد كتابة أنماط أكثر تنظيماً وقابلية لإعادة الاستخدام.
إنشاء الصفحات الأساسية وهيكل التنقل
مكوّن الترويسة Header.js
أنشئ الملف Header.js داخل مجلد components وضع فيه الكود التالي:
import React from 'react';
import { NavLink } from 'react-router-dom';
const Header = () => {
return (
<React.Fragment>
<h1>Hacker News Clone</h1>
<div className="nav-link">
<NavLink to="/top" activeClassName="active">Top Stories</NavLink>
<NavLink to="/new" activeClassName="active">Latest Stories</NavLink>
<NavLink to="/best" activeClassName="active">Best Stories</NavLink>
</div>
</React.Fragment>
);
};
export default Header;
هذا المكوّن يوفّر قائمة تنقل بين أنواع القصص المختلفة. استخدمنا NavLink بدلاً من Link لأن NavLink يضيف الفئة active تلقائياً عند تطابق المسار الحالي، ما يساعد على إبراز الصفحة النشطة بصرياً.
الصفحة الرئيسية HomePage.js
import React from 'react';
const HomePage = () => {
return <React.Fragment>Home Page</React.Fragment>;
};
export default HomePage;
صفحة عدم العثور على المسار PageNotFound.js
import React from 'react';
import { Link } from 'react-router-dom';
const PageNotFound = () => {
return (
<p>
Page Not found. Go to <Link to="/">Home</Link>
</p>
);
};
export default PageNotFound;
تهيئة التوجيه داخل AppRouter.js
import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import Header from '../components/Header';
import HomePage from '../components/HomePage';
import PageNotFound from '../components/PageNotFound';
const AppRouter = () => {
return (
<BrowserRouter>
<div className="container">
<Header />
<Switch>
<Route path="/" component={HomePage} exact={true} />
<Route component={PageNotFound} />
</Switch>
</div>
</BrowserRouter>
);
};
export default AppRouter;
في هذه المرحلة أضفنا مسارين فقط: الصفحة الرئيسية وصفحة بديلة للمسارات غير الصحيحة. هذا يكفي للانطلاق ثم نوسّع التوجيه لاحقاً لعرض القصص حسب النوع.
ربط التطبيق بنقطة الدخول src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import AppRouter from './router/AppRouter';
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles.scss';
ReactDOM.render(<AppRouter />, document.getElementById('root'));
بعد تشغيل الأمر yarn start سيظهر لك هيكل الواجهة الأولي.

دمج Hacker News API داخل التطبيق
تعريف الرابط الأساسي في constants.js
export const BASE_API_URL = 'https://hacker-news.firebaseio.com/v0';
إنشاء وظائف جلب البيانات في apis.js
import axios from 'axios';
import { BASE_API_URL } from './constants';
const getStory = async (id) => {
try {
const story = await axios.get(`${BASE_API_URL}/item/${id}.json`);
return story;
} catch (error) {
console.log('Error while getting a story.');
}
};
export const getStories = async (type) => {
try {
const { data: storyIds } = await axios.get(`${BASE_API_URL}/${type}stories.json`);
const stories = await Promise.all(storyIds.slice(0, 30).map(getStory));
return stories;
} catch (error) {
console.log('Error while getting list of stories.');
}
};
تأخذ الدالة getStories نوع القصص المطلوب مثل top أو new أو best، ثم ترسل طلباً إلى الرابط المناسب لجلب قائمة المعرّفات. بعد ذلك تستخدم Promise.all لإرسال الطلبات بالتوازي إلى كل قصة بدلاً من تنفيذها واحدة تلو الأخرى، وهو ما يحسّن سرعة التحميل بشكل ملحوظ.
لماذا استخدمنا async/await وميزة destructuring؟
عند تنفيذ السطر التالي:
const { data: storyIds } = await axios.get(`${BASE_API_URL}/${type}stories.json`);
فنحن نستخرج الخاصية data من الاستجابة ثم نعيد تسميتها إلى storyIds. هذه الصياغة أوضح من تسمية المتغير باسم عام مثل data.
وهي تعادل الكود التالي:
const response = await axios.get(`${BASE_API_URL}/${type}stories.json`);
const storyIds = response.data;
كما أن حصر النتائج الأولى باستخدام slice(0, 30) يساعد على جعل الواجهة أسرع وأكثر استجابة، لأن تحميل عدد كبير من القصص دفعة واحدة قد يبطئ التطبيق دون حاجة.
إنشاء Custom Hook مخصص لجلب البيانات
داخل مجلد hooks أنشئ الملف dataFetcher.js:
import { useState, useEffect } from 'react';
import { getStories } from '../utils/apis';
const useDataFetcher = (type) => {
const [stories, setStories] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
getStories(type)
.then((stories) => {
setStories(stories);
setIsLoading(false);
})
.catch(() => {
setIsLoading(false);
});
}, [type]);
return { isLoading, stories };
};
export default useDataFetcher;
هذا Hook المخصص يعزل منطق جلب البيانات عن مكوّنات العرض. وبدلاً من تكرار نفس المنطق في أكثر من مكان، نضعه في وحدة واحدة قابلة لإعادة الاستخدام.
يحتوي هذا Hook على حالتين:
stories: لتخزين بيانات القصص.isLoading: لمعرفة ما إذا كانت البيانات ما تزال تُحمّل.
كما أن إضافة type إلى مصفوفة الاعتماديات داخل useEffect تعني أن جلب البيانات سيُعاد تلقائياً عند تغيّر نوع القصص.
عرض القصص في واجهة المستخدم
إنشاء مكوّن ShowStories.js
import React from 'react';
import Story from './Story';
import useDataFetcher from '../hooks/dataFetcher';
const ShowStories = (props) => {
const { type } = props.match.params;
const { isLoading, stories } = useDataFetcher(type);
return (
<React.Fragment>
{isLoading ? (
<p className="loading">Loading...</p>
) : (
<React.Fragment>
{stories.map(({ data: story }) => (
<Story key={story.id} story={story} />
))}
</React.Fragment>
)}
</React.Fragment>
);
};
export default ShowStories;
يعتمد هذا المكوّن على useDataFetcher للحصول على البيانات وحالة التحميل. إذا كانت البيانات ما تزال تُحمّل، نعرض رسالة بسيطة. وإذا وصلت البيانات، نمرّ على القصص باستخدام map() ونعرض كل عنصر عبر مكوّن منفصل.
إنشاء مكوّن القصة Story.js
import React from 'react';
const Link = ({ url, title }) => (
<a href={url} target="_blank" rel="noreferrer">
{title}
</a>
);
const Story = ({ story: { id, by, title, kids, time, url } }) => {
return (
<div className="story">
<div className="story-title">
<Link url={url} title={title} />
</div>
<div className="story-info">
<span>
by{' '}
<Link
url={`https://news.ycombinator.com/user?id=${by}`}
title={by}
/>
</span>
|{' '}
<span>
{new Date(time * 1000).toLocaleDateString('en-US', {
hour: 'numeric',
minute: 'numeric'
})}
</span>
|{' '}
<span>
<Link
url={`https://news.ycombinator.com/item?id=${id}`}
title={`${kids && kids.length > 0 ? kids.length : 0} comments`}
/>
</span>
</div>
</div>
);
};
export default Story;
يُظهر هذا المكوّن عنوان القصة، واسم الكاتب، ووقت النشر، وعدد التعليقات. كما أنه يستخدم تفكيك الكائن destructuring مباشرة داخل الوسيط، وهي طريقة أنيقة لتقليل التكرار.
فهم بعض الأساليب المستخدمة في الكود
- المكوّن
Linkمكتوب بصيغةArrow Functionمعimplicit return. - الوقت القادم من
APIيكون بالثواني، لذلك نضربه في1000قبل تمريره إلىnew Date(). - استخدمنا
toLocaleDateString()لعرض التاريخ والوقت بصيغة مقروءة.
تحديث نظام التوجيه لعرض القصص ديناميكياً
الآن عدّل ملف AppRouter.js لإضافة مسار خاص بعرض القصص:
import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import Header from '../components/Header';
import HomePage from '../components/HomePage';
import PageNotFound from '../components/PageNotFound';
import ShowStories from '../components/ShowStories';
const AppRouter = () => {
return (
<BrowserRouter>
<div className="container">
<Header />
<Switch>
<Route path="/" component={HomePage} exact={true} />
<Route path="/:type" component={ShowStories} />
<Route component={PageNotFound} />
</Switch>
</div>
</BrowserRouter>
);
};
export default AppRouter;
بعد إعادة تشغيل المشروع ستلاحظ أن التنقل بين /top و/new و/best يعمل بشكل صحيح.

إعادة التوجيه الذكية للمسارات الديناميكية
بدلاً من إبقاء الصفحة الرئيسية فارغة، يمكننا إعادة توجيه المستخدم تلقائياً إلى صفحة /top عند فتح التطبيق. كما يمكننا التحقق من القيم المسموح بها للمسار الديناميكي.
عدّل ملف AppRouter.js ليصبح كالتالي:
import React from 'react';
import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
import Header from '../components/Header';
import PageNotFound from '../components/PageNotFound';
import ShowStories from '../components/ShowStories';
const AppRouter = () => {
return (
<BrowserRouter>
<div className="container">
<Header />
<Switch>
<Route
path="/"
render={() => <Redirect to="/top" />}
exact={true}
/>
<Route
path="/:type"
render={({ match }) => {
const { type } = match.params;
if (!['top', 'new', 'best'].includes(type)) {
return <Redirect to="/" />;
}
return <ShowStories type={type} />;
}}
/>
<Route component={PageNotFound} />
</Switch>
</div>
</BrowserRouter>
);
};
export default AppRouter;
هذه الطريقة أكثر احترافية لأنها تمنع المسارات العشوائية مثل /anything من كسر تجربة الاستخدام، وتعيد المستخدم إلى المسار الصحيح تلقائياً.
كما يجب تعديل ShowStories.js ليستقبل type مباشرة:
import React from 'react';
import Story from './Story';
import useDataFetcher from '../hooks/dataFetcher';
const ShowStories = ({ type }) => {
const { isLoading, stories } = useDataFetcher(type ? type : 'top');
return (
<React.Fragment>
{isLoading ? (
<p className="loading">Loading...</p>
) : (
<React.Fragment>
{stories.map(({ data: story }) => (
<Story key={story.id} story={story} />
))}
</React.Fragment>
)}
</React.Fragment>
);
};
export default ShowStories;
إضافة شاشة تحميل احترافية باستخدام React Portal
رسالة التحميل النصية العادية تعمل، لكنها ليست مثالية من حيث تجربة المستخدم. الأفضل هو عرض طبقة تغطية Overlay تمنع المستخدم من الضغط على الروابط أثناء جلب البيانات.
إنشاء المكوّن Loader.js
import { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
const Loader = (props) => {
const [node] = useState(document.createElement('div'));
const loader = document.querySelector('#loader');
useEffect(() => {
loader.appendChild(node).classList.add('message');
}, [loader, node]);
useEffect(() => {
if (props.show) {
loader.classList.remove('hide');
document.body.classList.add('loader-open');
} else {
loader.classList.add('hide');
document.body.classList.remove('loader-open');
}
}, [loader, props.show]);
return ReactDOM.createPortal(props.children, node);
};
export default Loader;
تعديل ملف public/index.html
<div id="root"></div>
<div id="loader"></div>
تعتمد هذه الفكرة على ReactDOM.createPortal() الذي يتيح لنا عرض عنصر التحميل خارج الشجرة الأساسية للتطبيق. هذا يجعل طبقة التحميل تغطي التطبيق بالكامل بطريقة نظيفة ومنفصلة.
استخدام Loader داخل ShowStories.js
import React from 'react';
import Story from './Story';
import useDataFetcher from '../hooks/dataFetcher';
import Loader from './Loader';
const ShowStories = (props) => {
const { type } = props.match.params;
const { isLoading, stories } = useDataFetcher(type);
return (
<React.Fragment>
<Loader show={isLoading}>Loading...</Loader>
<React.Fragment>
{stories.map(({ data: story }) => (
<Story key={story.id} story={story} />
))}
</React.Fragment>
</React.Fragment>
);
};
export default ShowStories;
الآن سيظهر غطاء تحميل يمنع التفاعل مع الروابط أثناء استرجاع البيانات، وهي لمسة بسيطة لكنها ترفع جودة التطبيق عملياً.

كما ستلاحظ أن روابط المؤلف والتعليقات تنقلك مباشرة إلى صفحاتها في موقع Hacker News.

تحسينات مقترحة لتطوير التطبيق لاحقاً
بعد الانتهاء من النسخة الأساسية، يمكنك تطوير المشروع بعدة اتجاهات مفيدة:
- إضافة ترقيم صفحات
Paginationلتحميل 30 قصة إضافية في كل مرة. - إنشاء صفحة مستقلة لعرض التعليقات داخل التطبيق بدلاً من تحويل المستخدم إلى الموقع الأصلي.
- إضافة معالجة أفضل للأخطاء مع رسائل واضحة للمستخدم عند فشل الاتصال.
- تخزين بعض النتائج مؤقتاً لتقليل عدد الطلبات وتحسين الأداء.
- إضافة مؤشرات تحميل هيكلية
Skeleton UIبدلاً من رسالة التحميل التقليدية.
نصائح تقنية لتحسين الجودة وقابلية القبول في AdSense
- احرص على تقديم شرح أصيل لا يكتفي بعرض الكود، بل يفسّر سبب كل قرار تقني.
- قسّم المقال إلى عناوين واضحة لتسهيل القراءة والأرشفة.
- استخدم أمثلة عملية ومقتطفات قصيرة توضّح المفاهيم مثل
async/awaitوPromise.all(). - اجعل المقال يخدم القارئ المبتدئ والمتوسط معاً عبر تسلسل منطقي في الشرح.
- تجنّب النسخ الحرفي للمصادر، وركّز على إعادة بناء الفكرة بأسلوب تعليمي واضح.
الخلاصة التقنية
بناء نسخة مصغّرة من Hacker News باستخدام React هو تمرين ممتاز لفهم البنية الحديثة لتطبيقات الواجهة الأمامية. المشروع يجمع بين التوجيه باستخدام React Router، وجلب البيانات من API، وإنشاء Custom Hooks، وتحسين تجربة المستخدم عبر Loader Overlay. من الناحية التقنية، أهم ما يميز هذا المشروع هو فصل المسؤوليات بين طبقة الجلب والعرض والتوجيه، وهو ما يجعل التطبيق أسهل في الصيانة والتوسعة مستقبلاً.