لغة TypeScript
بناء مشروع TypeScript متكامل - الجزء 2
بناء مشروع TypeScript متكامل - الجزء 2
في الجزء 2، سنكمل نظام إدارة المهام المتكامل بـ TypeScript من خلال بناء واجهة React الأمامية، تنفيذ ميزات الوقت الفعلي مع WebSockets، إضافة اختبارات شاملة، ونشر التطبيق للإنتاج.
إعداد الواجهة الأمامية
# إنشاء حزمة الواجهة الأمامية مع Vite
cd packages
npm create vite@latest frontend -- --template react-ts
cd frontend
# تثبيت التبعيات
npm install
npm install @task-manager/shared axios react-router-dom
npm install --save-dev @types/react-router-dom
// packages/frontend/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* وضع المُجمِّع */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* الفحص */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
خدمة عميل API
// packages/frontend/src/services/api.ts
import axios, { AxiosInstance, AxiosError } from 'axios';
import type {
UserLogin,
UserCreate,
AuthResponse,
Task,
TaskCreate,
TaskUpdate,
TaskFilter,
ApiResponse,
PaginatedResponse,
ApiError
} from '@task-manager/shared';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
class ApiClient {
private client: AxiosInstance;
private accessToken: string | null = null;
constructor() {
this.client = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json'
}
});
// مُعترض الطلب لإضافة رمز المصادقة
this.client.interceptors.request.use((config) => {
if (this.accessToken) {
config.headers.Authorization = `Bearer ${this.accessToken}`;
}
return config;
});
// مُعترض الاستجابة لمعالجة الأخطاء
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiError>) => {
if (error.response?.status === 401) {
this.clearAuth();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// تحميل الرمز من localStorage
this.loadAuth();
}
private loadAuth(): void {
const token = localStorage.getItem('accessToken');
if (token) {
this.accessToken = token;
}
}
private saveAuth(tokens: { accessToken: string; refreshToken: string }): void {
this.accessToken = tokens.accessToken;
localStorage.setItem('accessToken', tokens.accessToken);
localStorage.setItem('refreshToken', tokens.refreshToken);
}
private clearAuth(): void {
this.accessToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
// نقاط نهاية المصادقة
async register(data: UserCreate): Promise<AuthResponse> {
const response = await this.client.post<ApiResponse<AuthResponse>>(
'/auth/register',
data
);
this.saveAuth(response.data.data.tokens);
return response.data.data;
}
async login(data: UserLogin): Promise<AuthResponse> {
const response = await this.client.post<ApiResponse<AuthResponse>>(
'/auth/login',
data
);
this.saveAuth(response.data.data.tokens);
return response.data.data;
}
logout(): void {
this.clearAuth();
}
// نقاط نهاية المهام
async getTasks(filter?: TaskFilter): Promise<PaginatedResponse<Task>> {
const response = await this.client.get<ApiResponse<PaginatedResponse<Task>>>(
'/tasks',
{ params: filter }
);
return response.data.data;
}
async getTask(id: string): Promise<Task> {
const response = await this.client.get<ApiResponse<Task>>(`/tasks/${id}`);
return response.data.data;
}
async createTask(data: TaskCreate): Promise<Task> {
const response = await this.client.post<ApiResponse<Task>>('/tasks', data);
return response.data.data;
}
async updateTask(id: string, data: TaskUpdate): Promise<Task> {
const response = await this.client.put<ApiResponse<Task>>(`/tasks/${id}`, data);
return response.data.data;
}
async deleteTask(id: string): Promise<void> {
await this.client.delete(`/tasks/${id}`);
}
isAuthenticated(): boolean {
return this.accessToken !== null;
}
}
export const apiClient = new ApiClient();
ملاحظة: عميل API يستخدم الأنواع المشتركة من حزمة
@task-manager/shared، مما يضمن أمان الأنواع عبر حدود الواجهة الأمامية والخلفية.
سياق React للمصادقة
// packages/frontend/src/contexts/AuthContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import type { UserPublic, UserLogin, UserCreate } from '@task-manager/shared';
import { apiClient } from '../services/api';
interface AuthContextType {
user: UserPublic | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (data: UserLogin) => Promise<void>;
register: (data: UserCreate) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserPublic | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// التحقق من مصادقة المستخدم عند التثبيت
if (apiClient.isAuthenticated()) {
// في تطبيق حقيقي، جلب بيانات المستخدم من نقطة نهاية /auth/me
setIsLoading(false);
} else {
setIsLoading(false);
}
}, []);
const login = async (data: UserLogin) => {
const response = await apiClient.login(data);
setUser(response.user);
};
const register = async (data: UserCreate) => {
const response = await apiClient.register(data);
setUser(response.user);
};
const logout = () => {
apiClient.logout();
setUser(null);
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: user !== null,
isLoading,
login,
register,
logout
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
خطافات مخصصة للمهام
// packages/frontend/src/hooks/useTasks.ts
import { useState, useEffect } from 'react';
import type { Task, TaskFilter, TaskCreate, TaskUpdate } from '@task-manager/shared';
import { apiClient } from '../services/api';
export function useTasks(filter?: TaskFilter) {
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadTasks = async () => {
try {
setIsLoading(true);
const response = await apiClient.getTasks(filter);
setTasks(response.data);
setError(null);
} catch (err) {
setError('Failed to load tasks');
console.error(err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadTasks();
}, [JSON.stringify(filter)]);
const createTask = async (data: TaskCreate): Promise<Task> => {
const task = await apiClient.createTask(data);
setTasks((prev) => [task, ...prev]);
return task;
};
const updateTask = async (id: string, data: TaskUpdate): Promise<Task> => {
const task = await apiClient.updateTask(id, data);
setTasks((prev) => prev.map((t) => (t.id === id ? task : t)));
return task;
};
const deleteTask = async (id: string): Promise<void> => {
await apiClient.deleteTask(id);
setTasks((prev) => prev.filter((t) => t.id !== id));
};
return {
tasks,
isLoading,
error,
loadTasks,
createTask,
updateTask,
deleteTask
};
}
مكون المهمة
// packages/frontend/src/components/TaskCard.tsx
import type { Task, TaskUpdate } from '@task-manager/shared';
interface TaskCardProps {
task: Task;
onUpdate: (id: string, data: TaskUpdate) => Promise<void>;
onDelete: (id: string) => Promise<void>;
}
export function TaskCard({ task, onUpdate, onDelete }: TaskCardProps) {
const handleStatusChange = async (status: Task['status']) => {
await onUpdate(task.id, { status });
};
const getPriorityColor = (priority: Task['priority']): string => {
const colors = {
low: '#4caf50',
medium: '#ff9800',
high: '#f44336',
urgent: '#9c27b0'
};
return colors[priority];
};
return (
<div className="task-card">
<div className="task-header">
<h3>{task.title}</h3>
<span
className="priority-badge"
style={{ backgroundColor: getPriorityColor(task.priority) }}
>
{task.priority}
</span>
</div>
{task.description && (
<p className="task-description">{task.description}</p>
)}
<div className="task-footer">
<select
value={task.status}
onChange={(e) => handleStatusChange(e.target.value as Task['status'])}
>
<option value="pending">قيد الانتظار</option>
<option value="in-progress">قيد التنفيذ</option>
<option value="completed">مكتمل</option>
<option value="cancelled">ملغى</option>
</select>
<button onClick={() => onDelete(task.id)} className="delete-btn">
حذف
</button>
</div>
{task.dueDate && (
<div className="task-due-date">
الموعد النهائي: {new Date(task.dueDate).toLocaleDateString('ar')}
</div>
)}
</div>
);
}
تكامل WebSocket
// packages/frontend/src/hooks/useWebSocket.ts
import { useEffect, useRef, useCallback } from 'react';
import type { WsMessage, Task } from '@task-manager/shared';
interface UseWebSocketOptions {
onTaskCreated?: (task: Task) => void;
onTaskUpdated?: (task: Task) => void;
onTaskDeleted?: (taskId: string) => void;
}
export function useWebSocket(options: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const connect = useCallback(() => {
const token = localStorage.getItem('accessToken');
if (!token) return;
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3000';
const ws = new WebSocket(`${wsUrl}?token=${token}`);
ws.onopen = () => {
console.log('WebSocket متصل');
};
ws.onmessage = (event) => {
try {
const message: WsMessage = JSON.parse(event.data);
switch (message.type) {
case 'task:created':
options.onTaskCreated?.(message.payload as Task);
break;
case 'task:updated':
options.onTaskUpdated?.(message.payload as Task);
break;
case 'task:deleted':
options.onTaskDeleted?.(message.payload as string);
break;
}
} catch (error) {
console.error('فشل تحليل رسالة WebSocket:', error);
}
};
ws.onerror = (error) => {
console.error('خطأ WebSocket:', error);
};
ws.onclose = () => {
console.log('WebSocket منفصل');
// إعادة الاتصال بعد 5 ثوانٍ
reconnectTimeoutRef.current = setTimeout(connect, 5000);
};
wsRef.current = ws;
}, [options]);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
wsRef.current?.close();
};
}, [connect]);
return wsRef;
}
الاختبار باستخدام Vitest
# تثبيت تبعيات الاختبار
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom \
@testing-library/user-event jsdom
// packages/frontend/src/services/__tests__/api.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { apiClient } from '../api';
import type { UserLogin, TaskCreate } from '@task-manager/shared';
// محاكاة axios
vi.mock('axios');
describe('ApiClient', () => {
beforeEach(() => {
localStorage.clear();
});
describe('login', () => {
it('يجب تسجيل الدخول بنجاح وحفظ الرموز', async () => {
const loginData: UserLogin = {
email: 'test@example.com',
password: 'password123'
};
const mockResponse = {
user: { id: '1', email: 'test@example.com', name: 'Test User' },
tokens: {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token'
}
};
// سيتم وضع تنفيذ المحاكاة هنا
// هذا مثال مبسط
expect(loginData.email).toBe('test@example.com');
});
});
describe('createTask', () => {
it('يجب إنشاء مهمة بأنواع صحيحة', async () => {
const taskData: TaskCreate = {
title: 'مهمة جديدة',
description: 'وصف المهمة',
priority: 'high',
status: 'pending'
};
// فحص النوع يضمن تجميع هذا بشكل صحيح
expect(taskData.title).toBe('مهمة جديدة');
expect(taskData.priority).toBe('high');
});
});
});
دعم WebSocket للخلفية
// packages/backend/src/websocket.ts
import { WebSocketServer, WebSocket } from 'ws';
import { Server } from 'http';
import { AuthService } from './services/auth.service';
import type { WsMessage } from '@task-manager/shared';
const authService = new AuthService();
interface AuthenticatedWebSocket extends WebSocket {
userId?: string;
}
export function setupWebSocket(server: Server) {
const wss = new WebSocketServer({ server });
wss.on('connection', (ws: AuthenticatedWebSocket, req) => {
// مصادقة الاتصال
const url = new URL(req.url!, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
ws.close(1008, 'Authentication required');
return;
}
try {
const { userId } = authService.verifyAccessToken(token);
ws.userId = userId;
console.log(`WebSocket متصل: ${userId}`);
ws.on('close', () => {
console.log(`WebSocket منفصل: ${userId}`);
});
} catch {
ws.close(1008, 'Invalid token');
}
});
return {
broadcast: (userId: string, message: WsMessage) => {
wss.clients.forEach((client) => {
const authClient = client as AuthenticatedWebSocket;
if (authClient.userId === userId && authClient.readyState === WebSocket.OPEN) {
authClient.send(JSON.stringify(message));
}
});
}
};
}
تكوين النشر
// Dockerfile للخلفية
FROM node:18-alpine AS builder
WORKDIR /app
# نسخ ملفات مساحة العمل
COPY package*.json ./
COPY packages/shared/package*.json ./packages/shared/
COPY packages/backend/package*.json ./packages/backend/
# تثبيت التبعيات
RUN npm ci
# نسخ المصدر
COPY packages/shared ./packages/shared
COPY packages/backend ./packages/backend
# البناء
RUN npm run build -w @task-manager/shared
RUN npm run build -w @task-manager/backend
# صورة الإنتاج
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/backend/dist ./packages/backend/dist
COPY --from=builder /app/packages/backend/package.json ./packages/backend/
EXPOSE 3000
CMD ["node", "packages/backend/dist/server.js"]
// docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: taskmanager
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
backend:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/taskmanager
JWT_SECRET: your-secret-key
JWT_REFRESH_SECRET: your-refresh-secret
depends_on:
- postgres
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
ports:
- "5173:80"
depends_on:
- backend
volumes:
postgres_data:
نصيحة: استخدم متغيرات البيئة لجميع التكوينات. لا تقم أبداً بإيداع الأسرار في نظام التحكم في الإصدارات. استخدم ملفات
.env.example لتوثيق المتغيرات المطلوبة.
التمرين النهائي: أكمل المشروع المتكامل:
- نفذ واجهة React الأمامية مع جميع المكونات
- أضف دعم WebSocket للتحديثات الفورية للمهام
- اكتب اختبارات شاملة للواجهة الأمامية والخلفية
- أعد إعداد Docker وdocker-compose للتطوير المحلي
- انشر إلى مزود سحابي (AWS، Google Cloud، أو Vercel)
- أعد إعداد خط أنابيب CI/CD مع GitHub Actions
- أضف المراقبة وتتبع الأخطاء (مثل Sentry)
الخلاصة
تهانينا! لقد بنيت تطبيق TypeScript متكامل كامل مع:
- اتصال API آمن من حيث النوع باستخدام أنواع مشتركة
- واجهة React أمامية مع خطافات مخصصة وسياق
- تحديثات في الوقت الفعلي مع WebSockets
- إعداد اختبار شامل
- تكوين نشر جاهز للإنتاج
هذا المشروع يوضح قوة TypeScript في بناء تطبيقات متكاملة قوية وقابلة للصيانة. الأنواع المشتركة تضمن الاتساق عبر المكدس بأكمله، بينما نظام أنواع TypeScript يلتقط الأخطاء في وقت الترجمة بدلاً من وقت التشغيل.
خاتمة الدورة
لقد أكملت الآن دورة أساسيات TypeScript! لقد تعلمت:
- أساسيات TypeScript ونظام الأنواع
- الأنواع المتقدمة والأنواع العامة
- البرمجة الموجهة للكائنات مع الفئات
- المزخرفات والبيانات الوصفية
- أنظمة الوحدات والفضاءات
- أنماط البرمجة غير المتزامنة
- التكامل مع React وNode.js وGraphQL
- أفضل الممارسات والأنماط الشائعة
- بناء ونشر تطبيقات الإنتاج
استمر في الممارسة من خلال بناء المزيد من المشاريع، المساهمة في مشاريع TypeScript مفتوحة المصدر، والبقاء على اطلاع بخارطة طريق TypeScript. برمجة سعيدة!