تصميم أنظمة الواجهات الأمامية
مسائل تصميم أنظمة الواجهة الأمامية الكلاسيكية
هذه المسائل الثلاث تتكرر باستمرار في مقابلات FAANG للواجهة الأمامية. إتقانها يمنحك أنماطًا قابلة للنقل لأي سؤال تصميم واجهة أمامية.
المسألة 1: تصميم الإكمال التلقائي / الكتابة المسبقة
هذا هو السؤال الأكثر شيوعًا في تصميم أنظمة الواجهة الأمامية. يختبر التأجيل والتخزين المؤقت وحالات السباق وإمكانية الوصول.
الهندسة
+--------------------------------------------------+
| SearchBar |
| +--------------------------------------------+ |
| | <input> [laptop_______________] [X مسح] | |
| +--------------------------------------------+ |
| +--------------------------------------------+ |
| | SuggestionsList (role="listbox") | |
| | ┌────────────────────────────────────────┐ | |
| | │ > laptop stand (مُميّز) │ | |
| | │ laptop sleeve │ | |
| | │ laptop charger usb-c │ | |
| | │ laptop backpack │ | |
| | └────────────────────────────────────────┘ | |
| +--------------------------------------------+ |
+--------------------------------------------------+
قرارات التصميم الرئيسية
1. تأجيل الإدخال (300 مللي ثانية)
لا تُطلق استدعاء API عند كل ضغطة مفتاح. انتظر حتى يتوقف المستخدم عن الكتابة:
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function Autocomplete() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
// الجلب فقط عند تغيير debouncedQuery
const { data: suggestions } = useQuery({
queryKey: ['search', debouncedQuery],
queryFn: () => fetchSuggestions(debouncedQuery),
enabled: debouncedQuery.length >= 2,
});
}
2. تخزين النتائج مؤقتًا
خزّن الاستعلامات السابقة مؤقتًا حتى يكون الرجوع إليها فوريًا. يتعامل TanStack Query مع هذا تلقائيًا عبر ذاكرة الاستعلام المؤقتة. للتنفيذ اليدوي:
const cache = new Map<string, string[]>();
async function fetchWithCache(query: string): Promise<string[]> {
if (cache.has(query)) return cache.get(query)!;
const results = await fetch(`/api/suggest?q=${query}`).then(r => r.json());
cache.set(query, results);
// إخلاء المدخلات القديمة إذا تجاوز التخزين 100 عنصر
if (cache.size > 100) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
return results;
}
3. التعامل مع حالات السباق
إذا كتب المستخدم "lap" ثم بسرعة "laptop"، قد يصل رد "lap" بعد رد "laptop". استخدم AbortController لإلغاء الطلبات القديمة:
function useSearchSuggestions(query: string) {
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
if (!query) { setResults([]); return; }
const controller = new AbortController();
fetch(`/api/suggest?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
// إلغاء هذا الطلب إذا تغير الاستعلام قبل اكتماله
return () => controller.abort();
}, [query]);
return results;
}
4. التنقل بلوحة المفاتيح
يجب أن يتمكن المستخدمون من التنقل بين الاقتراحات بدون فأرة:
function SuggestionsList({ suggestions, onSelect }) {
const [activeIndex, setActiveIndex] = useState(-1);
function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, suggestions.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
if (activeIndex >= 0) onSelect(suggestions[activeIndex]);
break;
case 'Escape':
setActiveIndex(-1);
break;
}
}
return (
<ul role="listbox" onKeyDown={handleKeyDown}>
{suggestions.map((item, i) => (
<li
key={item}
role="option"
aria-selected={i === activeIndex}
className={i === activeIndex ? 'highlighted' : ''}
>
{item}
</li>
))}
</ul>
);
}
5. إمكانية الوصول (ARIA combobox)
حقل الإدخال وقائمة الاقتراحات يجب أن يُشكلا نمط combobox:
<div role="combobox" aria-expanded={showSuggestions} aria-haspopup="listbox">
<input
role="searchbox"
aria-autocomplete="list"
aria-controls="suggestions-list"
aria-activedescendant={activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined}
/>
<ul id="suggestions-list" role="listbox">
{suggestions.map((item, i) => (
<li id={`suggestion-${i}`} role="option" aria-selected={i === activeIndex}>
{item}
</li>
))}
</ul>
</div>
المسألة 2: تصميم واجهة تطبيق دردشة
هذا يختبر التواصل الفوري والعرض الافتراضي والتعامل مع عدم الاتصال.
الهندسة
+-----------------------------------------------------------+
| ChatApp |
| +--------------+ +-----------------------------------+ |
| | ConvoList | | ChatPanel | |
| | +---------+ | | +-------------------------------+ | |
| | | أليس | | | | MessageList (عرض افتراضي) | | |
| | | بوب * | | | | [أليس] مرحبًا! 10:01 | | |
| | | كارول | | | | [أنت] أهلاً 10:02 | | |
| | | | | | | [أليس] شاهد هذا.. 10:03 | | |
| | | | | | | ...آلاف الرسائل... | | |
| | +---------+ | | +-------------------------------+ | |
| | | | +-------------------------------+ | |
| | | | | ComposeBar | | |
| | | | | [اكتب رسالة... ] [إرسال] | | |
| | | | +-------------------------------+ | |
| +--------------+ +-----------------------------------+ |
+-----------------------------------------------------------+
قرارات التصميم الرئيسية
1. العرض الافتراضي لقائمة الرسائل
يمكن أن تحتوي الدردشة على عشرات الآلاف من الرسائل. عرضها جميعًا يُعطل المتصفح:
import { useVirtualizer } from '@tanstack/react-virtual';
function MessageList({ messages }: { messages: Message[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // ارتفاع الصف المُقدّر بالبكسل
overscan: 10, // عرض 10 عناصر إضافية أعلى/أسفل
});
return (
<div ref={parentRef} style={{ height: '100%', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: virtualRow.start,
height: virtualRow.size,
}}
>
<MessageBubble message={messages[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
2. التحديثات المتفائلة عند الإرسال
اعرض الرسالة فورًا قبل تأكيد الخادم:
function useSendMessage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newMessage: NewMessage) => api.sendMessage(newMessage),
onMutate: async (newMessage) => {
// إلغاء إعادة الجلب الصادرة
await queryClient.cancelQueries({ queryKey: ['messages', newMessage.chatId] });
// لقطة من القيمة السابقة
const previous = queryClient.getQueryData(['messages', newMessage.chatId]);
// إضافة متفائلة للرسالة بمعرّف مؤقت
queryClient.setQueryData(['messages', newMessage.chatId], (old: Message[]) => [
...old,
{ ...newMessage, id: `temp-${Date.now()}`, status: 'sending' },
]);
return { previous };
},
onError: (err, newMessage, context) => {
// التراجع عند الفشل
queryClient.setQueryData(['messages', newMessage.chatId], context?.previous);
},
onSettled: (data, err, variables) => {
// إعادة الجلب لضمان مزامنة حالة الخادم
queryClient.invalidateQueries({ queryKey: ['messages', variables.chatId] });
},
});
}
3. إدارة اتصال WebSocket
الحفاظ على اتصال مستمر مع إعادة اتصال تلقائية:
class ChatSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private listeners = new Map<string, Set<Function>>();
connect(url: string) {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
const { type, payload } = JSON.parse(event.data);
this.listeners.get(type)?.forEach(cb => cb(payload));
};
this.ws.onclose = () => {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
setTimeout(() => {
this.reconnectAttempts++;
this.connect(url);
}, delay);
}
};
}
on(event: string, callback: Function) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(callback);
}
send(type: string, payload: unknown) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, payload }));
}
}
}
4. طابور الرسائل بدون اتصال
ضع الرسائل في طابور عند عدم الاتصال وأرسلها عند استعادة الاتصال:
class OfflineQueue {
private queue: NewMessage[] = [];
enqueue(message: NewMessage) {
this.queue.push(message);
localStorage.setItem('offlineQueue', JSON.stringify(this.queue));
}
async flush(sendFn: (msg: NewMessage) => Promise<void>) {
const pending = [...this.queue];
this.queue = [];
localStorage.removeItem('offlineQueue');
for (const message of pending) {
await sendFn(message);
}
}
restore() {
const stored = localStorage.getItem('offlineQueue');
if (stored) this.queue = JSON.parse(stored);
}
}
5. إيصالات القراءة
تتبع الرسائل التي تمت مشاهدتها باستخدام IntersectionObserver:
function useReadReceipts(chatId: string) {
const observerRef = useRef<IntersectionObserver>();
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
const visibleIds = entries
.filter(e => e.isIntersecting)
.map(e => e.target.getAttribute('data-message-id'));
if (visibleIds.length > 0) {
api.markAsRead(chatId, visibleIds);
}
},
{ threshold: 0.5 }
);
return () => observerRef.current?.disconnect();
}, [chatId]);
return observerRef;
}
المسألة 3: تصميم محرر مستندات تعاوني
هذا يختبر معرفتك بالتعاون الفوري على مستوى عالٍ. لا يتوقع المحاورون منك تنفيذ OT أو CRDT من الصفر.
الهندسة
+------------------------------------------------------------+
| EditorApp |
| +--------------------------------------------------------+ |
| | Toolbar [B] [I] [U] | H1 H2 H3 | [رابط] [صورة] | |
| +--------------------------------------------------------+ |
| | +----------------------------------------------------+ | |
| | | DocumentCanvas | | |
| | | | | |
| | | الثعلب البني السريع| <-- مؤشرك (أزرق) | | |
| | | يقفز فوق الك|لب <-- مؤشر أليس (أخضر) | | |
| | | الكسول. | | |
| | | | | |
| | +----------------------------------------------------+ | |
| | +----------------------------------------------------+ | |
| | | PresenceBar [أنت (تحرير)] [أليس (تحرير)] | | |
| | +----------------------------------------------------+ | |
| +--------------------------------------------------------+ |
+------------------------------------------------------------+
المفاهيم الرئيسية
1. التحويل العملياتي (OT) مقابل CRDT
هذان النهجان الرئيسيان لحل التعديلات المتزامنة:
| الجانب | OT | CRDT |
|---|---|---|
| كيف يعمل | يُحوّل العمليات ضد بعضها على خادم مركزي | كل حرف له معرّف فريد؛ الدمج تلقائي |
| الخادم | مطلوب (سلطة مركزية) | اختياري (نظير لنظير ممكن) |
| يُستخدم بواسطة | Google Docs | Figma، Notion (مكتبة Yjs) |
| التعقيد | مفهوم أبسط، تحويلات معقدة | بنية بيانات معقدة، دمج أبسط |
| بدون اتصال | محدود (يحتاج الخادم للحل) | ممتاز (يُدمج عند إعادة الاتصال) |
ما تقوله في المقابلة: "سأستخدم مكتبة CRDT مثل Yjs أو Automerge. هذه تتعامل مع حل التعارضات تلقائيًا وتدعم التحرير بدون اتصال. حالة المستند تُمثّل كبنية بيانات CRDT يمكن دمجها بشكل حتمي بغض النظر عن ترتيب وصول العمليات."
2. مزامنة المؤشرات
يجب بث موقع مؤشر كل مستخدم لجميع المشاركين:
interface CursorPosition {
userId: string;
userName: string;
color: string;
index: number; // الموقع في المستند
selection?: {
anchor: number;
head: number;
};
}
// بث موقع المؤشر عند التغيير (مُخنق إلى 50 مللي ثانية)
function useCursorBroadcast(socket: ChatSocket, userId: string) {
const throttledSend = useMemo(
() => throttle((position: CursorPosition) => {
socket.send('cursor:update', position);
}, 50),
[socket]
);
return throttledSend;
}
3. واجهة حل التعارضات
عندما لا يمكن حل التعارضات تلقائيًا، أظهر للمستخدمين ما حدث:
- ميّز النص المُحرر بشكل متزامن بألوان مختلفة
- اعرض إشعارًا: "أليس حررت هذه الفقرة أثناء عدم اتصالك. تم دمج تعديلاتك تلقائيًا."
- وفّر خيار التراجع عن آخر دمج إذا بدت النتيجة خاطئة
- احتفظ بسجل الإصدارات حتى يتمكن المستخدمون من الاسترجاع
نصيحة للمقابلات: لأسئلة المحرر التعاوني، ركّز على القرارات المعمارية والمقايضات. لا يتوقع أي محاور منك تنفيذ OT أو CRDT من الصفر. أظهر معرفة بالمكتبات (Yjs، Automerge، Liveblocks) واشرح كيف ستدمجها.
التالي: سنغطي هندسة مكتبات المكونات وأنظمة التصميم. :::