كيف تبني تجربة مستخدم استثنائية باستخدام React و TypeScript ومكتبة React Testing Library
بصفتي مهندس برمجيات، يتزايد شغفي بالتعلم المستمر، وأدرك أن هناك الكثير لأكتشفه يوميًا. قبل التوسع في مجالات جديدة، سعيت لإتقان الأساسيات، ولفهم أعمق لكيفية بناء تجارب منتجات مميزة. يمثل هذا المقال محاولة لعرض إثبات مفهوم (Proof of Concept - PoC) قمت بإنشائه لاستكشاف بعض الأفكار. كان لدي عدة محاور رئيسية لهذا المشروع، أهمها:
- توفير برمجيات عالية الجودة.
- تقديم تجربة مستخدم استثنائية.
عندما أتحدث عن البرمجيات عالية الجودة، فإن هذا المصطلح يشمل جوانب متعددة. لكنني ركزت على ثلاثة أجزاء أساسية:
- الكود النظيف (
Clean Code): السعي لكتابة كود سهل القراءة والصيانة، مع فصل واضح للمسؤوليات بين الدوال (functions) والمكونات (components). - تغطية الاختبار الجيدة (
Good test coverage): لا يتعلق الأمر بنسبة التغطية بحد ذاتها، بل بكتابة اختبارات تغطي الجوانب الهامة لسلوك المكونات دون التعمق في تفاصيل التنفيذ الداخلية. - إدارة الحالة المتسقة (
Consistent state management): بناء التطبيق بطريقة تضمن اتساق البيانات وتوقع سلوكها، مما يعزز الموثوقية.
كانت تجربة المستخدم هي المحور الأساسي لهذا الـ PoC. شكلت التقنيات والبرمجيات المستخدمة الأساس لتقديم تجربة ممتازة للمستخدمين. ولضمان اتساق الحالة (state)، اخترت استخدام نظام الأنواع (type system) الذي يوفره TypeScript. كانت هذه تجربتي الأولى في استخدام TypeScript مع React، وقد أتاح لي هذا المشروع أيضًا فرصة بناء واختبار الخطافات المخصصة (custom hooks) بشكل سليم.
إعداد المشروع
اكتشفت مكتبة تُدعى TSDX تقوم بإعداد جميع تكوينات TypeScript تلقائيًا. تُستخدم هذه المكتبة بشكل أساسي لبناء الحزم (packages)، وبما أن هذا كان مشروعًا جانبيًا بسيطًا، فقد قررت تجربتها. بعد التثبيت، اخترت قالب React وأصبحت جاهزًا للبدء في كتابة الكود.
لكن قبل الشروع في الجزء الممتع، أردت أيضًا إعداد تكوين الاختبار. استخدمت مكتبة React Testing Library كمكتبة رئيسية جنبًا إلى جنب مع jest-dom لتوفير بعض الدوال المخصصة الرائعة (أنا معجب بشكل خاص بمطابق toBeInTheDocument). بعد تثبيت كل ذلك، قمت بتجاوز إعدادات Jest بإضافة ملف jest.config.js جديد:
module . exports = { verbose: true ,
setupFilesAfterEnv: [
"./setupTests.ts"
],
};
وأضفت ملف setupTests.ts لاستيراد كل ما أحتاجه:
import "@testing-library/jest-dom" ;
في هذه الحالة، كان علي فقط استيراد مكتبة jest-dom. بهذه الطريقة، لم أعد بحاجة إلى استيراد هذه الحزمة في ملفات الاختبار الخاصة بي، وأصبحت تعمل تلقائيًا.
لاختبار هذا التثبيت والتكوين، قمت ببناء مكون بسيط:
export const Thing = () => <h1>I 'm TK</h1>;
في اختباري، أردت عرضه والتحقق مما إذا كان موجودًا في نموذج كائن المستند (DOM):
import React from 'react' ;
import { render } from '@testing-library/react' ;
import { Thing } from '../index' ;
describe( 'Thing' , () => {
it( 'renders the correct text in the document' , () => {
const { getByText } = render(<Thing />);
expect(getByText( "I'm TK" )).toBeInTheDocument();
});
});
الآن أصبحنا جاهزين للخطوة التالية.
تكوين المسارات (Routes)
في هذه المرحلة، أردت تحديد مسارين فقط: الصفحة الرئيسية (home page) وصفحة البحث (search page)، على الرغم من أنني لن أقوم بتنفيذ أي شيء خاص بالصفحة الرئيسية في الوقت الحالي. لهذا المشروع، استخدمت مكتبة react-router-dom لإدارة جميع الأمور المتعلقة بالمسارات، فهي بسيطة وسهلة وممتعة في الاستخدام.
بعد تثبيتها، أضفت مكونات الموجه (router components) في ملف app.typescript:
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom' ;
export const App = () => (
<Router>
<Switch>
<Route path= "/search" >
<h1>It 's the search!</h1>
</Route>
<Route path="/">
<h1>It' s Home</h1>
</Route>
</Switch>
</Router>
);
الآن، إذا قمنا بزيارة localhost:1234، سنرى العنوان It's Home. وإذا انتقلنا إلى localhost:1234/search، فسنرى النص It's the search!.
قبل أن نبدأ في تنفيذ صفحة البحث، أردت بناء قائمة بسيطة (menu) للتبديل بين الصفحات الرئيسية وصفحات البحث دون الحاجة إلى التلاعب بعنوان URL يدويًا. لهذا المشروع، استخدمت مكتبة Material UI لبناء الأساسيات الرسومية لواجهة المستخدم (UI foundation). في الوقت الحالي، نقوم فقط بتثبيت الحزمة @material-ui/core.
لبناء القائمة، نحتاج إلى زر لفتح خيارات القائمة، والتي ستكون في هذه الحالة “الرئيسية” و”البحث”. ولكن لتحقيق تجريد أفضل للمكونات، أفضل إخفاء المحتوى (الرابط والتسمية) لعناصر القائمة وجعل مكون Menu يستقبل هذه البيانات كـ prop. بهذه الطريقة، لا يحتاج المكون Menu إلى معرفة تفاصيل العناصر، بل سيقوم فقط بالتكرار عبر قائمة العناصر وعرضها. يبدو الأمر كالتالي:
import React, { Fragment, useState, MouseEvent } from 'react' ;
import { Link } from 'react-router-dom' ;
import Button from '@material-ui/core/Button' ;
import MuiMenu from '@material-ui/core/Menu' ;
import MuiMenuItem from '@material-ui/core/MenuItem' ;
import { MenuItem } from '../../types/MenuItem' ;
type MenuPropsType = {
menuItems: MenuItem[];
};
export const Menu = (
{ menuItems }: MenuPropsType
) => {
const [anchorEl, setAnchorEl] = useState< null | HTMLElement>( null );
const handleClick = (event: MouseEvent<HTMLButtonElement>): void => {
setAnchorEl(event.currentTarget);
};
const handleClose = (): void => {
setAnchorEl( null );
};
return (
<Fragment>
<Button aria-controls= "menu" aria-haspopup= "true" onClick={handleClick}>
Open Menu
</Button>
<MuiMenu
id= "simple-menu"
anchorEl={anchorEl}
keepMounted
open={ Boolean (anchorEl)}
onClose={handleClose}
>
{menuItems.map(
( item: MenuItem ) => (
<Link to={item.linkTo} onClick={handleClose} key={item.key}>
<MuiMenuItem>{item.label}</MuiMenuItem>
</Link>
)
)}
</MuiMenu>
</Fragment>
);
};
export default Menu;
لا داعي للذعر! أعلم أنه جزء كبير من الكود، لكنه بسيط جدًا. يغلف Fragment المكونين Button و MuiMenu (يشير Mui إلى Material UI. احتجت إلى إعادة تسمية المكون لأن المكون الذي أبنيه يُدعى أيضًا menu). يستقبل menuItems كـ prop ويقوم بالتكرار عبرها لبناء عناصر القائمة المغلفة بمكون Link. Link هو مكون من react-router للربط بعنوان URL معين.
سلوك القائمة بسيط أيضًا: نربط دالة handleClick بحدث onClick للزر. بهذه الطريقة، يمكننا تغيير anchorEl عند تشغيل الزر (أو النقر عليه إذا أردت). anchorEl هي مجرد حالة مكون تمثل عنصر قائمة Mui لفتح تبديل القائمة، وبالتالي ستفتح عناصر القائمة ليختار المستخدم أحدها.
الآن، كيف نستخدم هذا المكون؟
import { Menu } from './components/Menu' ;
import { MenuItem } from './types/MenuItem' ;
const menuItems: MenuItem[] = [
{
linkTo: '/' ,
label: 'Home' ,
key: 'link-to-home' ,
},
{
linkTo: '/search' ,
label: 'Search' ,
key: 'link-to-search' ,
},
];
<Menu menuItems={menuItems} />
menuItems هي قائمة كائنات. يحتوي الكائن على العقد الصحيح المتوقع من مكون Menu. يضمن النوع MenuItem أن العقد صحيح. إنه مجرد نوع TypeScript بسيط:
export type MenuItem = {
linkTo: string ;
label: string ;
key: string ;
};
صفحة البحث
الآن نحن جاهزون لبناء صفحة البحث بكل المنتجات وتقديم تجربة رائعة. ولكن قبل بناء قائمة المنتجات، أردت إنشاء دالة جلب (fetch function) للتعامل مع طلب المنتجات. بما أنه لا يوجد لدي واجهة برمجة تطبيقات (API) للمنتجات بعد، يمكنني فقط محاكاة طلب الجلب.
في البداية، قمت ببناء عملية الجلب باستخدام useEffect في مكون Search. ستبدو الفكرة كالتالي:
import React, { useState, useEffect } from 'react' ;
import { getProducts } from 'api' ;
export const Search = () => {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState( false );
const [hasError, setHasError] = useState( false );
useEffect( () => {
const fetchProducts = async () => {
try {
setIsLoading( true );
const fetchedProducts = await getProducts();
setIsLoading( false );
setProducts(fetchedProducts);
} catch (error) {
setIsLoading( false );
setHasError( true );
}
};
fetchProducts();
}, []);
};
لدينا:
productsمهيأة كمصفوفة فارغة.isLoadingمهيأة كـfalse.hasErrorمهيأة كـfalse.
fetchProducts هي دالة غير متزامنة (async function) تستدعي getProducts من وحدة api. بما أنه لا يوجد لدينا واجهة API مناسبة للمنتجات بعد، فإن getProducts ستعيد بيانات وهمية (mock data).
عند تنفيذ fetchProducts، نقوم بتعيين isLoading إلى true، ثم نجلب المنتجات، وبعد ذلك نعيد تعيين isLoading إلى false لأن عملية الجلب قد انتهت، ونقوم بتعيين المنتجات التي تم جلبها إلى products لاستخدامها في المكون.
إذا حدث أي خطأ أثناء الجلب، نقوم بالتقاطه (catch)، ونعين isLoading إلى false، و hasError إلى true. في هذا السياق، سيعرف المكون أن هناك خطأ حدث أثناء الجلب ويمكنه التعامل مع هذه الحالة. كل شيء مغلف داخل useEffect لأننا نقوم بتأثير جانبي (side effect) هنا.
للتعامل مع جميع منطق الحالة (state logic) (متى يتم تحديث كل جزء للسياق المحدد)، يمكننا استخراجه إلى مخفض (reducer) بسيط:
import { State, FetchActionType, FetchAction } from './types' ;
export const fetchReducer = (state: State, action: FetchAction): State => {
switch (action.type) {
case FetchActionType.FETCH_INIT:
return {
...state,
isLoading: true ,
hasError: false ,
};
case FetchActionType.FETCH_SUCCESS:
return {
...state,
hasError: false ,
isLoading: false ,
data: action.payload,
};
case FetchActionType.FETCH_ERROR:
return {
...state,
hasError: true ,
isLoading: false ,
};
default :
return state;
}
};
الفكرة هنا هي فصل كل نوع إجراء (action type) والتعامل مع كل تحديث للحالة بشكل منفصل. لذا، سيتلقى fetchReducer الحالة والإجراء وسيعيد حالة جديدة. هذا الجزء مثير للاهتمام لأنه يحصل على الحالة الحالية ثم يعيد حالة جديدة، لكننا نحافظ على عقد الحالة باستخدام النوع State. ولكل نوع إجراء، سنقوم بتحديث الحالة بالطريقة الصحيحة:
FETCH_INIT:isLoadingتكونtrueوhasErrorتكونfalse.FETCH_SUCCESS:hasErrorتكونfalse،isLoadingتكونfalse، ويتم تحديث البيانات (المنتجات).FETCH_ERROR:hasErrorتكونtrueوisLoadingتكونfalse.
في حال عدم تطابق أي نوع إجراء، يتم إرجاع الحالة الحالية ببساطة. FetchActionType هو تعداد (enum) بسيط في TypeScript:
export enum FetchActionType {
FETCH_INIT = 'FETCH_INIT' ,
FETCH_SUCCESS = 'FETCH_SUCCESS' ,
FETCH_ERROR = 'FETCH_ERROR' ,
}
و State هو مجرد نوع بسيط:
export type ProductType = {
name: string ;
price: number ;
imageUrl: string ;
description: string ;
isShippingFree: boolean ;
discount: number ;
};
export type Data = ProductType[];
export type State = {
isLoading: boolean ;
hasError: boolean ;
data: Data;
};
مع هذا المخفض الجديد، يمكننا الآن استخدام useReducer في عملية الجلب الخاصة بنا. نمرر المخفض الجديد والحالة الأولية إليه:
const initialState: State = {
isLoading: false ,
hasError: false ,
data: fakeData,
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect( () => {
const fetchAPI = async () => {
dispatch({ type : FetchActionType.FETCH_INIT });
try {
const payload = await fetchProducts();
dispatch({ type : FetchActionType.FETCH_SUCCESS, payload, });
} catch (error) {
dispatch({ type : FetchActionType.FETCH_ERROR });
}
};
fetchAPI();
}, []);
initialState لها نفس نوع العقد. ونمررها إلى useReducer جنبًا إلى جنب مع fetchReducer الذي قمنا ببنائه للتو. يوفر useReducer الحالة ودالة تسمى dispatch لاستدعاء الإجراءات لتحديث حالتنا:
- جلب الحالة: استدعاء
dispatchللـFETCH_INIT. - انتهاء الجلب: استدعاء
dispatchللـFETCH_SUCCESSمع حمولة المنتجات (products payload). - حدوث خطأ أثناء الجلب: استدعاء
dispatchللـFETCH_ERROR.
أصبحت هذه التجريدات كبيرة جدًا وقد تكون مطولة جدًا في مكوننا. يمكننا استخلاصها كخطاف (hook) منفصل يسمى useProductFetchAPI:
export const useProductFetchAPI = (): State => {
const initialState: State = {
isLoading: false ,
hasError: false ,
data: fakeData,
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect( () => {
const fetchAPI = async () => {
dispatch({ type : FetchActionType.FETCH_INIT });
try {
const payload = await fetchProducts();
dispatch({ type : FetchActionType.FETCH_SUCCESS, payload, });
} catch (error) {
dispatch({ type : FetchActionType.FETCH_ERROR });
}
};
fetchAPI();
}, []);
return state;
};
إنها مجرد دالة تغلف عملية الجلب الخاصة بنا. الآن، في مكون Search، يمكننا استيرادها واستدعائها:
export const Search = () => {
const { isLoading, hasError, data }: State = useProductFetchAPI();
};
لدينا الآن جميع واجهات برمجة التطبيقات (API) المطلوبة: isLoading و hasError و data لاستخدامها في مكوننا. باستخدام هذه الواجهة، يمكننا عرض مؤشر تحميل (loading spinner) أو هيكل عظمي (skeleton) بناءً على بيانات isLoading. يمكننا عرض رسالة خطأ بناءً على قيمة hasError. أو ببساطة عرض قائمة المنتجات باستخدام data.
قبل البدء في تنفيذ قائمة المنتجات الخاصة بنا، أرغب في التوقف وإضافة اختبارات للخطاف المخصص (custom hook) الذي أنشأناه. لدينا جزآن للاختبار هنا: المخفض (reducer) والخطاف المخصص. المخفض أسهل لأنه مجرد دالة نقية (pure function). يستقبل قيمة، يعالجها، ويعيد قيمة جديدة. لا توجد تأثيرات جانبية (side-effect). كل شيء حتمي (deterministic).
لتغطية جميع احتمالات هذا المخفض، أنشأت ثلاثة سياقات: إجراءات FETCH_INIT و FETCH_SUCCESS و FETCH_ERROR. قبل تنفيذ أي شيء، قمت بإعداد البيانات الأولية للعمل بها:
const initialData: Data = [];
const initialState: State = {
isLoading: false ,
hasError: false ,
data: initialData,
};
الآن يمكنني تمرير هذه الحالة الأولية للمخفض جنبًا إلى جنب مع الإجراء المحدد الذي أرغب في تغطيته. بالنسبة لهذا الاختبار الأول، أردت تغطية إجراء FETCH_INIT:
describe( 'when dispatch FETCH_INIT action' , () => {
it( 'returns the isLoading as true without any error' , () => {
const action: FetchAction = {
type : FetchActionType.FETCH_INIT,
};
expect(fetchReducer(initialState, action)).toEqual({
isLoading: true ,
hasError: false ,
data: initialData,
});
});
});
الأمر بسيط جدًا. يستقبل الحالة الأولية والإجراء، ونتوقع القيمة المرجعة الصحيحة: الحالة الجديدة مع isLoading كـ true.
إجراء FETCH_ERROR مشابه جدًا:
describe( 'when dispatch FETCH_ERROR action' , () => {
it( 'returns the isLoading as true without any error' , () => {
const action: FetchAction = {
type : FetchActionType.FETCH_ERROR,
};
expect(fetchReducer(initialState, action)).toEqual({
isLoading: false ,
hasError: true ,
data: [],
});
});
});
لكننا نمرر إجراءً مختلفًا ونتوقع أن تكون hasError كـ true.
إجراء FETCH_SUCCESS أكثر تعقيدًا قليلاً حيث نحتاج فقط إلى بناء حالة جديدة وإضافتها إلى سمة الحمولة (payload attribute) في الإجراء:
describe( 'when dispatch FETCH_SUCCESS action' , () => {
it( 'returns the the API data' , () => {
const product: ProductType = {
name: 'iPhone' ,
price: 3500 ,
imageUrl: 'image-url.png' ,
description: 'Apple mobile phone' ,
isShippingFree: true ,
discount: 0 ,
};
const action: FetchAction = {
type : FetchActionType.FETCH_SUCCESS,
payload: [product],
};
expect(fetchReducer(initialState, action)).toEqual({
isLoading: false ,
hasError: false ,
data: [product],
});
});
});
لكن لا يوجد شيء معقد للغاية هنا. البيانات الجديدة موجودة. قائمة بالمنتجات. في هذه الحالة، منتج واحد فقط، منتج iPhone.
سيغطي الاختبار الثاني الخطاف المخصص الذي قمنا ببنائه. في هذه الاختبارات، كتبت ثلاثة سياقات: طلب مهلة (time-out request)، طلب شبكة فاشل (failed network request)، وطلب ناجح (success request). هنا، بما أنني أستخدم axios لجلب البيانات (عندما يكون لدي واجهة API لجلب البيانات، سأستخدمها بشكل صحيح)، فإنني أستخدم axios-mock-adapter لمحاكاة كل سياق لاختباراتنا.
الإعداد أولاً: تهيئة بياناتنا وإعداد محاكاة axios.
const mock: MockAdapter = new MockAdapter(axios);
const url: string = '/search' ;
const initialData: Data = [];
نبدأ في تنفيذ اختبار لطلب المهلة:
it( 'handles error on timed-out api request' , async () => {
mock.onGet(url).timeout();
const { result, waitForNextUpdate } = renderHook(
() => useProductFetchAPI(url, initialData)
);
await waitForNextUpdate();
const { isLoading, hasError, data }: State = result.current;
expect(isLoading).toEqual( false );
expect(hasError).toEqual( true );
expect(data).toEqual(initialData);
});
لقد قمنا بإعداد المحاكاة لإرجاع مهلة. يستدعي الاختبار useProductFetchAPI، وينتظر تحديثًا، ثم يمكننا الحصول على الحالة. isLoading تكون false، و data لا تزال كما هي (قائمة فارغة)، و hasError أصبحت الآن true كما هو متوقع.
طلب الشبكة له نفس السلوك تقريبًا. الفرق الوحيد هو أن المحاكاة ستواجه خطأ في الشبكة بدلاً من مهلة.
it( 'handles error on failed network api request' , async () => {
mock.onGet(url).networkError();
const { result, waitForNextUpdate } = renderHook(
() => useFetchAPI(url, initialData)
);
await waitForNextUpdate();
const { isLoading, hasError, data }: State = result.current;
expect(isLoading).toEqual( false );
expect(hasError).toEqual( true );
expect(data).toEqual(initialData);
});
وبالنسبة للحالة الناجحة، نحتاج إلى إنشاء كائن منتج لاستخدامه كبيانات استجابة للطلب. نتوقع أيضًا أن تكون data قائمة بكائنات هذا المنتج. hasError و isLoading تكونان false في هذه الحالة.
it( 'gets and updates data from the api request' , async () => {
const product: ProductType = {
name: 'iPhone' ,
price: 3500 ,
imageUrl: 'image-url.png' ,
description: 'Apple mobile phone' ,
isShippingFree: true ,
discount: 0 ,
};
const mockedResponseData: Data = [product];
mock.onGet(url).reply( 200 , mockedResponseData);
const { result, waitForNextUpdate } = renderHook(
() => useFetchAPI(url, initialData)
);
await waitForNextUpdate();
const { isLoading, hasError, data }: State = result.current;
expect(isLoading).toEqual( false );
expect(hasError).toEqual( false );
expect(data).toEqual([product]);
});
رائع. لقد غطينا كل ما نحتاجه لهذا الخطاف المخصص والمخفض الذي أنشأناه. الآن يمكننا التركيز على بناء قائمة المنتجات.
قائمة المنتجات
تتمثل فكرة قائمة المنتجات في عرض المنتجات بمعلومات أساسية مثل العنوان والوصف والسعر والخصم، وما إذا كانت الشحنة مجانية. ستبدو بطاقة المنتج النهائية كالتالي:

لبناء هذه البطاقة، أنشأت الأساس لمكون المنتج (Product component):
const Product = () => (
<Box>
<Image />
<TitleDescription/>
<Price />
<Tag />
</Box>
);
لبناء المنتج، سنحتاج إلى بناء كل مكون من المكونات الداخلية. ولكن قبل البدء في بناء مكون المنتج، أرغب في عرض بيانات JSON التي ستعيدها واجهة برمجة التطبيقات الوهمية (fake API) لنا:
{
imageUrl: 'a-url-for-tokyo-tower.png' ,
name: 'Tokyo Tower' ,
description: 'Some description here' ,
price: 45 ,
discount: 20 ,
isShippingFree: true ,
}
يتم تمرير هذه البيانات من مكون Search إلى مكون ProductList:
export const Search = () => {
const { isLoading, hasError, data }: State = useProductFetchAPI();
if (hasError) {
return <h2> Error </h2>;
}
return <ProductList products={data} isLoading={isLoading} />;
};
بما أنني أستخدم TypeScript، يمكنني فرض الأنواع الثابتة لخصائص المكونات (component props). في هذه الحالة، لدي الخاصية products و isLoading. لقد بنيت نوع ProductListPropsType للتعامل مع خصائص قائمة المنتجات:
type ProductListPropsType = {
products: ProductType[];
isLoading: boolean ;
};
و ProductType هو نوع بسيط يمثل المنتج:
export type ProductType = {
name: string ;
price: number ;
imageUrl: string ;
description: string ;
isShippingFree: boolean ;
discount: number ;
};
لبناء ProductList، سأستخدم مكون Grid من Material UI. أولاً، لدينا حاوية شبكة (grid container)، ثم لكل منتج، سنعرض عنصر شبكة (grid item).
export const ProductList = (
{ products, isLoading }: ProductListPropsType
) => (
<Grid container spacing={ 3 }>
{products.map(
product => (
<Grid
item
xs={ 6 }
md={ 3 }
key={ `grid- ${product.name} - ${product.description} - ${product.price} ` }
>
<Product
key={ `product- ${product.name} - ${product.description} - ${product.price} ` }
imageUrl={product.imageUrl}
name={product.name}
description={product.description}
price={product.price}
discount={product.discount}
isShippingFree={product.isShippingFree}
isLoading={isLoading}
/>
</Grid>
)
)}
</Grid>
);
سيعرض عنصر Grid item منتجين في كل صف للأجهزة المحمولة باستخدام القيمة 6 لكل عمود. وبالنسبة لإصدار سطح المكتب، سيعرض 4 عناصر في كل صف. نكرر عبر قائمة products ونعرض مكون Product مع تمرير جميع البيانات التي سيحتاجها.
الآن يمكننا التركيز على بناء مكون Product. لنبدأ بالأسهل: مكون Tag. سنمرر ثلاث بيانات لهذا المكون: label و isVisible و isLoading. عندما لا يكون مرئيًا، نُرجع null ببساطة لعدم عرضه. إذا كان قيد التحميل، فسنعرض مكون Skeleton من Material UI. ولكن بعد التحميل، نعرض معلومات العلامة مع تسمية “Free Shipping” (شحن مجاني).
export const Tag = (
{ label, isVisible, isLoading }: TagProps
) => {
if (!isVisible) return null ;
if (isLoading) {
return (
<Skeleton width= "110px" height= "40px" data-testid= "tag-skeleton-loader" />
);
}
return (
<Box mt={ 1 } data-testid= "tag-label-wrapper" >
<span style={tabStyle}>{label}</span>
</Box>
);
};
TagProps هو نوع بسيط:
type TagProps = {
label: string ;
isVisible: boolean ;
isLoading: boolean ;
};
أستخدم أيضًا كائنًا لتنسيق span:
const tabStyle = {
padding: '4px 8px' ,
backgroundColor: '#f2f3fe' ,
color: '#87a7ff' ,
borderRadius: '4px' ,
};
أردت أيضًا بناء اختبارات لهذا المكون محاولًا التفكير في سلوكه:
- عندما لا يكون مرئيًا: لن تكون العلامة موجودة في المستند (
document).
describe( 'when is not visible' , () => {
it( 'does not render anything' , () => {
const { queryByTestId } = render(
<Tag label= "a label" isVisible={ false } isLoading={ false } />
);
expect(queryByTestId( 'tag-label-wrapper' )).not.toBeInTheDocument();
});
});
- عندما يكون قيد التحميل: سيكون الهيكل العظمي (
skeleton) موجودًا في المستند.
describe( 'when is loading' , () => {
it( 'renders the tag label' , () => {
const { queryByTestId } = render(
<Tag label= "a label" isVisible isLoading />
);
expect(queryByTestId( 'tag-skeleton-loader' )).toBeInTheDocument();
});
});
- عندما يكون جاهزًا للعرض: ستكون العلامة موجودة في المستند.
describe( 'when is visible and not loading' , () => {
it( 'renders the tag label' , () => {
render(<Tag label= "a label" isVisible isLoading={ false } />);
expect(screen.getByText( 'a label' )).toBeInTheDocument();
});
});
نقطة إضافية: إمكانية الوصول (accessibility). لقد بنيت أيضًا اختبارًا آليًا لتغطية انتهاكات إمكانية الوصول باستخدام jest-axe.
it( 'has no accessibility violations' , async () => {
const { container } = render(
<Tag label= "a label" isVisible isLoading={ false } />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
نحن جاهزون لتنفيذ مكون آخر: TitleDescription. سيعمل بشكل مشابه تقريبًا لمكون Tag. يستقبل بعض الخصائص (props): name و description و isLoading. بما أن لدينا نوع Product مع تعريف النوع لـ name و description، أردت إعادة استخدامه. لقد جربت أشياء مختلفة – ويمكنك إلقاء نظرة هنا لمزيد من التفاصيل – ووجدت نوع Pick. باستخدامه، تمكنت من الحصول على name و description من ProductType:
type TitleDescriptionType = Pick<ProductType, 'name' | 'description' >;
باستخدام هذا النوع الجديد، تمكنت من إنشاء TitleDescriptionPropsType للمكون:
type TitleDescriptionPropsType = TitleDescriptionType & {
isLoading: boolean ;
};
الآن، عند العمل داخل المكون، إذا كانت isLoading صحيحة (true)، يعرض المكون مكون الهيكل العظمي المناسب قبل أن يعرض نصوص العنوان والوصف الفعلية.
if (isLoading) {
return (
<Fragment>
<Skeleton width= "60%" height= "24px" data-testid= "name-skeleton-loader" />
<Skeleton style={descriptionSkeletonStyle} height= "20px" data-testid= "description-skeleton-loader" />
</Fragment>
);
}
إذا لم يكن المكون قيد التحميل بعد الآن، فإننا نعرض نصوص العنوان والوصف. هنا نستخدم مكون Typography.
return (
<Fragment>
<Typography data-testid= "product-name" >{name}</Typography>
<Typography
data-testid= "product-description"
color= "textSecondary"
variant= "body2"
style={descriptionStyle}
>
{description}
</Typography>
</Fragment>
);
بالنسبة للاختبارات، نريد ثلاثة أشياء:
- عندما يكون قيد التحميل، يعرض المكون هياكل عظمية.
- عندما لا يكون قيد التحميل بعد الآن، يعرض المكون النصوص.
- التأكد من أن المكون لا ينتهك إمكانية الوصول.
سنستخدم نفس الفكرة التي استخدمناها لاختبارات Tag: معرفة ما إذا كانت موجودة في المستند أم لا بناءً على الحالة. عندما يكون قيد التحميل، نريد أن نرى ما إذا كان الهيكل العظمي موجودًا في المستند، ولكن نصوص العنوان والوصف ليست كذلك.
describe( 'when is loading' , () => {
it( 'does not render anything' , () => {
const { queryByTestId } = render(
<TitleDescription name={product.name} description={product.description} isLoading />
);
expect(queryByTestId( 'name-skeleton-loader' )).toBeInTheDocument();
expect(queryByTestId( 'description-skeleton-loader' )).toBeInTheDocument();
expect(queryByTestId( 'product-name' )).not.toBeInTheDocument();
expect(queryByTestId( 'product-description' )).not.toBeInTheDocument();
});
});
عندما لا يكون قيد التحميل بعد الآن، فإنه يعرض النصوص في DOM:
describe( 'when finished loading' , () => {
it( 'renders the product name and description' , () => {
render(
<TitleDescription name={product.name} description={product.description} isLoading={ false } />
);
expect(screen.getByText(product.name)).toBeInTheDocument();
expect(screen.getByText(product.description)).toBeInTheDocument();
});
});
واختبار بسيط لتغطية مشكلات إمكانية الوصول:
it( 'has no accessibility violations' , async () => {
const { container } = render(
<TitleDescription name={product.name} description={product.description} isLoading={ false } />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
المكون التالي هو Price. في هذا المكون، سنوفر هيكلًا عظميًا عندما يكون قيد التحميل كما فعلنا في المكون الآخر، وسنضيف ثلاثة مكونات مختلفة هنا:
PriceWithDiscount: نطبق الخصم على السعر الأصلي ونعرضه.OriginalPrice: يعرض سعر المنتج فقط.Discount: يعرض نسبة الخصم عندما يكون للمنتج خصم.
ولكن قبل أن أبدأ في تنفيذ هذه المكونات، أردت هيكلة البيانات المراد استخدامها. قيمتا price و discount هما أرقام. لذا، لنقم ببناء دالة تسمى getPriceInfo تستقبل price و discount وستعيد هذه البيانات:
{
priceWithDiscount, originalPrice, discountOff, hasDiscount,
};
مع عقد النوع هذا:
type PriceInfoType = {
priceWithDiscount: string ;
originalPrice: string ;
discountOff: string ;
hasDiscount: boolean ;
};
في هذه الدالة، ستحصل على discount وتحولها إلى قيمة منطقية (boolean)، ثم تطبق discount لبناء priceWithDiscount، وتستخدم hasDiscount لبناء نسبة الخصم، وتبني originalPrice بعلامة الدولار:
export const applyDiscount = (price: number , discount: number ): number =>
price - (price * discount) / 100 ;
export const getPriceInfo = (
price: number ,
discount: number
): PriceInfoType => {
const hasDiscount: boolean = Boolean (discount);
const priceWithDiscount: string = hasDiscount ? `$ ${applyDiscount(price, discount)} ` : `$ ${price} ` ;
const originalPrice: string = `$ ${price} ` ;
const discountOff: string = hasDiscount ? ` ${discount} % OFF` : '' ;
return {
priceWithDiscount, originalPrice, discountOff, hasDiscount,
};
};
هنا قمت أيضًا ببناء دالة applyDiscount لاستخراج حساب الخصم. أضفت بعض الاختبارات لتغطية هذه الدوال. بما أنها دوال نقية (pure functions)، نحتاج فقط إلى تمرير بعض القيم ونتوقع بيانات جديدة.
اختبار لـ applyDiscount:
describe( 'applyDiscount' , () => {
it( 'applies 20% discount in the price' , () => {
expect(applyDiscount( 100 , 20 )).toEqual( 80 );
});
it( 'applies 95% discount in the price' , () => {
expect(applyDiscount( 100 , 95 )).toEqual( 5 );
});
});
اختبار لـ getPriceInfo:
describe( 'getPriceInfo' , () => {
describe( 'with discount' , () => {
it( 'returns the correct price info' , () => {
expect(getPriceInfo( 100 , 20 )).toMatchObject({
priceWithDiscount: '$80' ,
originalPrice: '$100' ,
discountOff: '20% OFF' ,
hasDiscount: true ,
});
});
});
describe( 'without discount' , () => {
it( 'returns the correct price info' , () => {
expect(getPriceInfo( 100 , 0 )).toMatchObject({
priceWithDiscount: '$100' ,
originalPrice: '$100' ,
discountOff: '' ,
hasDiscount: false ,
});
});
});
});
الآن يمكننا استخدام getPriceInfo في مكونات Price للحصول على هذه البيانات المهيكلة وتمريرها إلى المكونات الأخرى كالتالي:
export const Price = (
{ price, discount, isLoading }: PricePropsType
) => {
if (isLoading) {
return (
<Skeleton width= "80%" height= "18px" data-testid= "price-skeleton-loader" />
);
}
const { priceWithDiscount, originalPrice, discountOff, hasDiscount,
}: PriceInfoType = getPriceInfo(price, discount);
return (
<Fragment>
<PriceWithDiscount price={priceWithDiscount} />
<OriginalPrice hasDiscount={hasDiscount} price={originalPrice} />
<Discount hasDiscount={hasDiscount} discountOff={discountOff} />
</Fragment>
);
};
كما تحدثنا سابقًا، عندما يكون قيد التحميل، فإننا نعرض مكون Skeleton فقط. عندما ينتهي التحميل، سيقوم ببناء البيانات المهيكلة وعرض معلومات السعر.
لنقم ببناء كل مكون الآن! لنبدأ بالأسهل: OriginalPrice. نحتاج فقط إلى تمرير price كخاصية (prop) ويتم عرضه باستخدام مكون Typography.
type OriginalPricePropsType = {
price: string ;
};
export const OriginalPrice = (
{ price }: OriginalPricePropsType
) => (
<Typography display= "inline" style={originalPriceStyle} color= "textSecondary" >
{price}
</Typography>
);
بسيط جداً! لنضف اختباراً الآن. فقط مرر سعرًا وشاهد ما إذا كان قد تم عرضه في DOM:
it( 'shows the price' , () => {
const price = '$200' ;
render(<OriginalPrice price={price} />);
expect(screen.getByText(price)).toBeInTheDocument();
});
أضفت أيضًا اختبارًا لتغطية مشكلات إمكانية الوصول:
it( 'has no accessibility violations' , async () => {
const { container } = render(<OriginalPrice price= "$200" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
مكون PriceWithDiscount له تطبيق مشابه جدًا، لكننا نمرر القيمة المنطقية hasDiscount لعرض هذا السعر أم لا. إذا كان هناك خصم، اعرض السعر مع الخصم. وإلا، فلن يعرض أي شيء.
type PricePropsType = {
hasDiscount: boolean ;
price: string ;
};
يحتوي نوع الخصائص (props type) على hasDiscount و price. ويعرض المكون الأشياء بناءً على قيمة hasDiscount.
export const PriceWithDiscount = (
{ price, hasDiscount }: PricePropsType
) => {
if (!hasDiscount) {
return null ;
}
return (
<Typography display= "inline" style={priceWithDiscountStyle}>
{price}
</Typography>
);
};
ستغطي الاختبارات هذا المنطق عندما يكون هناك خصم أو لا يوجد. إذا لم يكن هناك خصم، فلن يتم عرض الأسعار.
describe( 'when the product has no discount' , () => {
it( 'shows nothing' , () => {
const { queryByTestId } = render(
<PriceWithDiscount hasDiscount={ false } price= "" />
);
expect(queryByTestId( 'discount-off-label' )).not.toBeInTheDocument();
});
});
إذا كان هناك خصم، فسيتم عرضه في DOM:
describe( 'when the product has a discount' , () => {
it( 'shows the price' , () => {
const price = '$200' ;
render(<PriceWithDiscount hasDiscount price={price} />);
expect(screen.getByText(price)).toBeInTheDocument();
});
});
وكالعادة، اختبار لتغطية انتهاكات إمكانية الوصول:
it( 'has no accessibility violations' , async () => {
const { container } = render(
<PriceWithDiscount hasDiscount price= "$200" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
مكون Discount هو نفسه تقريبًا مثل PriceWithDiscount. يعرض علامة الخصم إذا كان للمنتج خصم:
type DiscountPropsType = {
hasDiscount: boolean ;
discountOff: string ;
};
export const Discount = (
{ hasDiscount, discountOff }: DiscountPropsType
) => {
if (!hasDiscount) {
return null ;
}
return (
<Typography display= "inline" color= "secondary" data-testid= "discount-off-label" >
{discountOff}
</Typography>
);
};
وجميع الاختبارات التي أجريناها للمكونات الأخرى، نقوم بنفس الشيء لمكون Discount:
describe( 'Discount' , () => {
describe( 'when the product has a discount' , () => {
it( 'shows the discount label' , () => {
const discountOff = '20% OFF' ;
render(<Discount hasDiscount discountOff={discountOff} />);
expect(screen.getByText(discountOff)).toBeInTheDocument();
});
});
describe( 'when the product has no discount' , () => {
it( 'shows nothing' , () => {
const { queryByTestId } = render(
<Discount hasDiscount={ false } discountOff= "" />
);
expect(queryByTestId( 'discount-off-label' )).not.toBeInTheDocument();
});
});
it( 'has no accessibility violations' , async () => {
const { container } = render(
<Discount hasDiscount discountOff= "20% OFF" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
الآن سنبني مكون Image. يحتوي هذا المكون على الهيكل العظمي الأساسي (basic skeleton) مثل أي مكون آخر قمنا ببنائه. إذا كان قيد التحميل، ننتظر عرض مصدر الصورة ونعرض الهيكل العظمي بدلاً من ذلك. عندما ينتهي التحميل، سنعرض الصورة، ولكن فقط إذا كان المكون في منطقة تقاطع نافذة المتصفح (intersection of the browser window).
ماذا يعني هذا؟ عندما تكون على موقع ويب على جهازك المحمول، فمن المحتمل أن ترى أول 4 منتجات. ستقوم هذه المنتجات بعرض الهيكل العظمي ثم الصورة. ولكن أسفل هذه المنتجات الأربعة، بما أنك لا تراها، لا يهم ما إذا كنا نعرضها أم لا. ويمكننا اختيار عدم عرضها في الوقت الحالي، بل عند الطلب.
عند التمرير (scrolling)، إذا كانت صورة المنتج عند منطقة تقاطع نافذة المتصفح، نبدأ في عرض مصدر الصورة. بهذه الطريقة نكسب أداءً عن طريق تسريع وقت تحميل الصفحة وتقليل التكلفة عن طريق طلب الصور عند الطلب.
سنستخدم واجهة Intersection Observer API لتنزيل الصور عند الطلب. ولكن قبل كتابة أي كود حول هذه التقنية، لنبدأ ببناء مكوننا مع عرض الصورة والهيكل العظمي.
خصائص الصورة (Image props) ستحتوي على هذا الكائن:
{
imageUrl, imageAlt, width, isLoading, imageWrapperStyle, imageStyle,
}
يتم تمرير الخصائص imageUrl و imageAlt و isLoading بواسطة مكون المنتج. width هي سمة للهيكل العظمي ووسم الصورة (image tag). imageWrapperStyle و imageStyle هما خاصيتان لهما قيمة افتراضية في مكون الصورة. سنتحدث عن هذا لاحقًا.
لنضف نوعًا لهذه الخصائص:
type ImageUrlType = Pick<ProductType, 'imageUrl' >;
type ImageAttrType = {
imageAlt: string ;
width: string
};
type ImageStateType = {
isLoading: boolean
};
type ImageStyleType = {
imageWrapperStyle: CSSProperties;
imageStyle: CSSProperties;
};
export type ImagePropsType = ImageUrlType & ImageAttrType & ImageStateType & ImageStyleType;
الفكرة هنا هي إعطاء معنى للأنواع ثم تجميع كل شيء. يمكننا الحصول على imageUrl من ProductType. سيحتوي نوع السمة على imageAlt و width. تحتوي حالة الصورة على حالة isLoading. ويحتوي نمط الصورة على بعض خصائص CSSProperties.
في البداية، سيبدو المكون كالتالي:
export const Image = (
{ imageUrl, imageAlt, width, isLoading, imageWrapperStyle, imageStyle,
}: ImagePropsType
) => {
if (isLoading) {
<Skeleton variant= "rect" width={width} data-testid= "image-skeleton-loader" />
}
return (
<img src={imageUrl} alt={imageAlt} width={width} style={imageStyle} />
);
};
لنقم ببناء الكود لجعل مراقب التقاطع (intersection observer) يعمل. فكرة مراقب التقاطع هي استقبال هدف للمراقبة ودالة استدعاء (callback function) يتم تنفيذها كلما دخل الهدف المراقب أو خرج من إطار العرض (viewport). لذا، سيكون التنفيذ بسيطًا جدًا:
const observer: IntersectionObserver = new IntersectionObserver(
onIntersect, options
);
observer.observe(target);
نقوم بإنشاء مثيل لفئة IntersectionObserver عن طريق تمرير كائن خيارات (options object) ودالة الاستدعاء. سيقوم observer بمراقبة عنصر target. بما أنه تأثير في DOM، يمكننا تغليفه داخل useEffect.
useEffect( () => {
const observer: IntersectionObserver = new IntersectionObserver(
onIntersect, options
);
observer.observe(target);
return () => {
observer.unobserve(target);
};
}, [target]);
باستخدام useEffect، لدينا شيئان مختلفان هنا: مصفوفة التبعية (dependency array) والدالة المرجعة. نمرر target كدالة تبعية للتأكد من أننا سنعيد تشغيل التأثير إذا تغير target. والدالة المرجعة هي دالة تنظيف (cleanup function). يقوم React بالتنظيف عندما يتم إلغاء تحميل المكون (component unmounts)، لذلك سيقوم بتنظيف التأثير قبل تشغيل تأثير آخر لكل عملية عرض. في دالة التنظيف هذه، نتوقف ببساطة عن مراقبة عنصر target.
عندما يبدأ المكون في العرض، لم يتم تعيين مرجع target بعد، لذلك نحتاج إلى حماية لعدم مراقبة هدف undefined.
useEffect( () => {
if (!target) {
return ;
}
const observer: IntersectionObserver = new IntersectionObserver(
onIntersect, options
);
observer.observe(target);
return () => {
observer.unobserve(target);
};
}, [target]);
بدلاً من استخدام هذا التأثير في مكوننا، يمكننا بناء خطاف مخصص (custom hook) لاستقبال الهدف، وبعض الخيارات لتخصيص التكوين، وسيوفر قيمة منطقية تخبرنا ما إذا كان الهدف في تقاطع إطار العرض أم لا.
export type TargetType = Element | HTMLDivElement | undefined ;
export type IntersectionStatus = {
isIntersecting: boolean ;
};
const defaultOptions: IntersectionObserverInit = {
rootMargin: '0px' ,
threshold: 0.1 ,
};
export const useIntersectionObserver = (
target: TargetType,
options: IntersectionObserverInit = defaultOptions
): IntersectionStatus => {
const [isIntersecting, setIsIntersecting] = useState( false );
useEffect( () => {
if (!target) {
return ;
}
const onIntersect = (
[entry]: IntersectionObserverEntry[]
) => {
setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting) {
observer.unobserve(target);
}
};
const observer: IntersectionObserver = new IntersectionObserver(
onIntersect, options
);
observer.observe(target);
return () => {
observer.unobserve(target);
};
}, [target]);
return { isIntersecting };
};
في دالة الاستدعاء الخاصة بنا، نقوم ببساطة بتعيين ما إذا كان هدف الإدخال (entry target) يتقاطع مع إطار العرض أم لا. setIsIntersecting هي دالة تعيين (setter) من خطاف useState الذي نحدده في الجزء العلوي من خطافنا المخصص. يتم تهيئته كـ false ولكنه سيتحدث إلى true إذا كان يتقاطع مع إطار العرض.
باستخدام هذه المعلومات الجديدة في المكون، يمكننا عرض الصورة أم لا. إذا كانت تتقاطع، يمكننا عرض الصورة. وإذا لم تكن كذلك، فما عليك سوى عرض هيكل عظمي حتى يصل المستخدم إلى تقاطع إطار العرض لصورة المنتج.
كيف يبدو هذا في الممارسة العملية؟
أولاً، نحدد مرجع الغلاف (wrapper reference) باستخدام useState:
const [wrapperRef, setWrapperRef] = useState<HTMLDivElement>();
يبدأ كـ undefined. ثم نبني دالة استدعاء غلاف (wrapper callback) لتعيين عقدة العنصر:
const wrapperCallback = useCallback(
node => {
setWrapperRef(node);
},
[]);
باستخدام ذلك، يمكننا استخدامه للحصول على مرجع الغلاف باستخدام خاصية ref في div الخاص بنا.
<div ref={wrapperCallback}>
بعد تعيين wrapperRef، يمكننا تمريره كـ target لـ useIntersectionObserver ونتوقع حالة isIntersecting كنتيجة:
const { isIntersecting }: IntersectionStatus = useIntersectionObserver(wrapperRef);
باستخدام هذه القيمة الجديدة، يمكننا بناء قيمة منطقية لمعرفة ما إذا كنا سنعرض الهيكل العظمي أو صورة المنتج.
const showImageSkeleton: boolean = isLoading || !isIntersecting;
لذا الآن يمكننا عرض العقدة المناسبة لـ DOM.
<div ref={wrapperCallback} style={imageWrapperStyle}>
{showImageSkeleton ? (
<Skeleton
variant= "rect"
width={width}
height={imageWrapperStyle.height}
style={skeletonStyle}
data-testid= "image-skeleton-loader"
/>
) : (
<img src={imageUrl} alt={imageAlt} width={width} />
)}
</div>
يبدو المكون الكامل كالتالي:
export const Image = (
{ imageUrl, imageAlt, width, isLoading, imageWrapperStyle,
}: ImagePropsType
) => {
const [wrapperRef, setWrapperRef] = useState<HTMLDivElement>();
const wrapperCallback = useCallback(
node => {
setWrapperRef(node);
},
[]);
const { isIntersecting }: IntersectionStatus = useIntersectionObserver(wrapperRef);
const showImageSkeleton: boolean = isLoading || !isIntersecting;
return (
<div ref={wrapperCallback} style={imageWrapperStyle}>
{showImageSkeleton ? (
<Skeleton
variant= "rect"
width={width}
height={imageWrapperStyle.height}
style={skeletonStyle}
data-testid= "image-skeleton-loader"
/>
) : (
<img src={imageUrl} alt={imageAlt} width={width} />
)}
</div>
);
};
رائع، الآن يعمل التحميل عند الطلب بشكل جيد. لكنني أرغب في بناء تجربة أفضل قليلاً. الفكرة هنا هي الحصول على حجمين مختلفين لنفس الصورة. يتم طلب الصورة منخفضة الجودة ونجعلها مرئية، ولكن ضبابية (blur) بينما يتم طلب الصورة عالية الجودة في الخلفية. عندما تنتهي الصورة عالية الجودة أخيرًا من التحميل، ننتقل من الصورة منخفضة الجودة إلى الصورة عالية الجودة بانتقال ease-in/ease-out لجعلها تجربة سلسة.
لنقم ببناء هذا المنطق. يمكننا بناء هذا داخل المكون، ولكن يمكننا أيضًا استخراج هذا المنطق إلى خطاف مخصص (custom hook).
export const useImageOnLoad = (): ImageOnLoadType => {
const [isLoaded, setIsLoaded] = useState( false );
const handleImageOnLoad = () => setIsLoaded( true );
const imageVisibility: CSSProperties = {
visibility: isLoaded ? 'hidden' : 'visible' ,
filter: 'blur(10px)' ,
transition: 'visibility 0ms ease-out 500ms' ,
};
const imageOpactity: CSSProperties = {
opacity: isLoaded ? 1 : 0 ,
transition: 'opacity 500ms ease-in 0ms' ,
};
return { handleImageOnLoad, imageVisibility, imageOpactity };
};
يوفر هذا الخطاف بعض البيانات والسلوك للمكون. handleImageOnLoad التي تحدثنا عنها سابقًا، و imageVisibility لجعل الصورة منخفضة الجودة مرئية أم لا، و imageOpactity لإجراء الانتقال من الشفاف إلى المعتم، وبهذه الطريقة نجعلها مرئية بعد تحميلها. isLoaded هي قيمة منطقية بسيطة للتعامل مع رؤية الصور. تفصيل صغير آخر هو filter: 'blur(10px)' لجعل الصورة منخفضة الجودة ضبابية ثم التركيز ببطء أثناء الانتقال من الصورة منخفضة الجودة إلى الصورة عالية الجودة.
باستخدام هذا الخطاف الجديد، نقوم فقط باستيراده واستدعائه داخل المكون:
const { handleImageOnLoad, imageVisibility, imageOpactity,
}: ImageOnLoadType = useImageOnLoad();
ونبدأ في استخدام البيانات والسلوك الذي قمنا ببنائه.
<Fragment>
<img src={thumbUrl} alt={imageAlt} width={width} style={{ ...imageStyle, ...imageVisibility }} />
<img onLoad={handleImageOnLoad} src={imageUrl} alt={imageAlt} width={width} style={{ ...imageStyle, ...imageOpactity }} />
</Fragment>
الأولى تحتوي على صورة منخفضة الجودة، وهي thumbUrl. والثانية تحتوي على الصورة الأصلية عالية الجودة، وهي imageUrl. عندما يتم تحميل الصورة عالية الجودة، فإنها تستدعي دالة handleImageOnLoad. هذه الدالة ستجعل الانتقال بين صورة وأخرى.
خاتمة
يمثل هذا المقال الجزء الأول من مشروع يهدف إلى التعمق في مفاهيم تجربة المستخدم، واجهات برمجة التطبيقات الأصلية (native APIs)، تطوير الواجهة الأمامية المكتوبة بالأنواع (typed frontend)، والاختبارات. في الجزء التالي من هذه السلسلة، سنتناول الجانب المعماري بشكل أكبر لبناء وظيفة البحث مع الفلاتر، مع الحفاظ على الهدف الأساسي المتمثل في تقديم حلول تقنية تجعل تجربة المستخدم سلسة قدر الإمكان.
يمكنك العثور على مقالات أخرى مماثلة على مدونة TK.
الموارد
- Lazy Loading Images and Video (تحميل الصور والفيديو البطيء)
- Functional Uses for Intersection Observer (استخدامات وظيفية لـ
Intersection Observer) - Tips for rolling your own lazy loading (نصائح لإنشاء تحميلك البطيء الخاص)
- Intersection Observer API – MDN (واجهة
Intersection Observer API–MDN) - React Typescript Cheatsheet (ورقة غش
React Typescript)
الخلاصة التقنية
يُظهر هذا المقال ببراعة تكامل React و TypeScript و React Testing Library لإنشاء تجربة مستخدم متفوقة. من خلال التركيز على الكود النظيف، وإدارة الحالة الفعالة عبر useReducer والخطافات المخصصة، وتقنيات التحميل الكسول للصور، يقدم الكاتب نموذجًا عمليًا لتطوير واجهات أمامية قوية وموثوقة. تُبرز أهمية الاختبارات الشاملة للمكونات والخطافات لضمان الاستقرار والأداء، مما يعكس نهجًا احترافيًا في بناء تطبيقات ويب حديثة.