واجهات GraphQL
بناء تطبيق عميل GraphQL
بناء تطبيق عميل GraphQL
في هذا الدرس، سنبني تطبيق عميل React كاملاً يستهلك واجهة برمجة تطبيقات GraphQL الخاصة بنا. سنستخدم Apollo Client لإدارة الحالة والتخزين المؤقت والتحديثات في الوقت الفعلي.
إعداد المشروع مع Apollo Client
# إنشاء تطبيق React
npx create-react-app blog-client
cd blog-client
# تثبيت تبعيات Apollo Client
npm install @apollo/client graphql
npm install react-router-dom
npm install @apollo/client-react-hooks
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import {
ApolloClient,
InMemoryCache,
ApolloProvider,
createHttpLink,
split,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { createClient } from 'graphql-ws';
import App from './App';
// رابط HTTP للاستعلامات والتحولات
const httpLink = createHttpLink({
uri: 'http://localhost:4000/graphql',
});
// رابط WebSocket للاشتراكات
const wsLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:4000/graphql',
})
);
// رابط المصادقة لإضافة رمز JWT
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
// تقسيم الروابط بناءً على نوع العملية
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
authLink.concat(httpLink)
);
// إنشاء عميل Apollo
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
}),
});
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<ApolloProvider client={client}>
<BrowserRouter>
<App />
</BrowserRouter>
</ApolloProvider>
);
إعداد Apollo Client: تم تكوين العميل برابط HTTP للاستعلامات/التحولات، ورابط WebSocket للاشتراكات، ووسيط المصادقة، والتخزين المؤقت الذكي مع سياسات النوع.
استعلامات وتحولات GraphQL
// src/graphql/queries.js
import { gql } from '@apollo/client';
export const GET_POSTS = gql`
query GetPosts($limit: Int, $offset: Int, $status: PostStatus) {
posts(limit: $limit, offset: $offset, status: $status) {
id
title
slug
excerpt
featuredImage
status
viewCount
createdAt
author {
id
username
avatar
}
}
}
`;
export const GET_POST = gql`
query GetPost($slug: String!) {
post(slug: $slug) {
id
title
slug
content
excerpt
featuredImage
status
viewCount
tags
createdAt
updatedAt
publishedAt
author {
id
username
fullName
avatar
bio
}
comments {
id
content
isEdited
createdAt
author {
id
username
avatar
}
replies {
id
content
createdAt
author {
id
username
avatar
}
}
}
}
}
`;
export const GET_ME = gql`
query GetMe {
me {
id
username
email
fullName
bio
avatar
role
}
}
`;
// src/graphql/mutations.js
import { gql } from '@apollo/client';
export const LOGIN = gql`
mutation Login($input: LoginInput!) {
login(input: $input) {
token
user {
id
username
email
role
}
}
}
`;
export const REGISTER = gql`
mutation Register($input: RegisterInput!) {
register(input: $input) {
token
user {
id
username
email
role
}
}
}
`;
export const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
slug
status
createdAt
}
}
`;
export const UPDATE_POST = gql`
mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
updatePost(id: $id, input: $input) {
id
title
content
status
updatedAt
}
}
`;
export const DELETE_POST = gql`
mutation DeletePost($id: ID!) {
deletePost(id: $id)
}
`;
export const CREATE_COMMENT = gql`
mutation CreateComment($input: CreateCommentInput!) {
createComment(input: $input) {
id
content
createdAt
author {
id
username
avatar
}
}
}
`;
الاستعلام عن البيانات في المكونات
// src/components/PostList.jsx
import React from 'react';
import { useQuery } from '@apollo/client';
import { GET_POSTS } from '../graphql/queries';
import { Link } from 'react-router-dom';
function PostList() {
const { loading, error, data, fetchMore } = useQuery(GET_POSTS, {
variables: { limit: 10, offset: 0, status: 'PUBLISHED' },
});
if (loading) return <div>جاري تحميل المنشورات...</div>;
if (error) return <div>خطأ: {error.message}</div>;
const loadMore = () => {
fetchMore({
variables: {
offset: data.posts.length,
},
});
};
return (
<div className="post-list">
<h1>منشورات المدونة</h1>
{data.posts.map(post => (
<article key={post.id} className="post-card">
{post.featuredImage && (
<img src={post.featuredImage} alt={post.title} />
)}
<h2>
<Link to={`/posts/${post.slug}`}>{post.title}</Link>
</h2>
<p>{post.excerpt}</p>
<div className="post-meta">
<span>بواسطة {post.author.username}</span>
<span>{new Date(post.createdAt).toLocaleDateString('ar')}</span>
<span>{post.viewCount} مشاهدة</span>
</div>
</article>
))}
<button onClick={loadMore}>تحميل المزيد</button>
</div>
);
}
export default PostList;
إنشاء وتحديث البيانات
// src/components/CreatePost.jsx
import React, { useState } from 'react';
import { useMutation } from '@apollo/client';
import { CREATE_POST } from '../graphql/mutations';
import { GET_POSTS } from '../graphql/queries';
import { useNavigate } from 'react-router-dom';
function CreatePost() {
const navigate = useNavigate();
const [formData, setFormData] = useState({
title: '',
content: '',
excerpt: '',
tags: '',
});
const [createPost, { loading, error }] = useMutation(CREATE_POST, {
onCompleted: (data) => {
navigate(`/posts/${data.createPost.slug}`);
},
refetchQueries: [{ query: GET_POSTS }],
});
const handleSubmit = async (e) => {
e.preventDefault();
const tags = formData.tags.split(',').map(tag => tag.trim());
await createPost({
variables: {
input: {
title: formData.title,
content: formData.content,
excerpt: formData.excerpt,
tags,
status: 'DRAFT',
},
},
});
};
return (
<form onSubmit={handleSubmit}>
<h2>إنشاء منشور جديد</h2>
<input
type="text"
placeholder="العنوان"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
<textarea
placeholder="المقتطف"
value={formData.excerpt}
onChange={(e) => setFormData({ ...formData, excerpt: e.target.value })}
/>
<textarea
placeholder="المحتوى (Markdown)"
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
rows={15}
required
/>
<input
type="text"
placeholder="العلامات (مفصولة بفاصلة)"
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
/>
<button type="submit" disabled={loading}>
{loading ? 'جاري الإنشاء...' : 'إنشاء منشور'}
</button>
{error && <div className="error">{error.message}</div>}
</form>
);
}
export default CreatePost;
تحديثات الذاكرة المؤقتة: استخدم `refetchQueries` لتحديث الذاكرة المؤقتة تلقائيًا بعد التحولات، أو قم بتحديث الذاكرة المؤقتة يدويًا باستخدام دالة `update` للحصول على تحديثات واجهة المستخدم المتفائلة.
التحديثات في الوقت الفعلي مع الاشتراكات
// src/graphql/subscriptions.js
import { gql } from '@apollo/client';
export const POST_CREATED_SUBSCRIPTION = gql`
subscription OnPostCreated {
postCreated {
id
title
slug
excerpt
author {
username
}
}
}
`;
export const COMMENT_ADDED_SUBSCRIPTION = gql`
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
createdAt
author {
id
username
avatar
}
}
}
`;
// src/components/PostDetail.jsx
import React, { useEffect } from 'react';
import { useQuery, useSubscription } from '@apollo/client';
import { useParams } from 'react-router-dom';
import { GET_POST } from '../graphql/queries';
import { COMMENT_ADDED_SUBSCRIPTION } from '../graphql/subscriptions';
function PostDetail() {
const { slug } = useParams();
const { loading, error, data } = useQuery(GET_POST, {
variables: { slug },
});
// الاشتراك في التعليقات الجديدة
useSubscription(COMMENT_ADDED_SUBSCRIPTION, {
variables: { postId: data?.post?.id },
skip: !data?.post?.id,
onData: ({ client, data: subData }) => {
// تحديث الذاكرة المؤقتة بالتعليق الجديد
const newComment = subData.data.commentAdded;
client.cache.modify({
id: client.cache.identify(data.post),
fields: {
comments(existingComments = []) {
return [...existingComments, newComment];
},
},
});
},
});
if (loading) return <div>جاري التحميل...</div>;
if (error) return <div>خطأ: {error.message}</div>;
const { post } = data;
return (
<article className="post-detail">
<h1>{post.title}</h1>
{post.featuredImage && (
<img src={post.featuredImage} alt={post.title} />
)}
<div className="post-meta">
<span>بواسطة {post.author.username}</span>
<span>{new Date(post.createdAt).toLocaleDateString('ar')}</span>
<span>{post.viewCount} مشاهدة</span>
</div>
<div className="post-content" dangerouslySetInnerHTML={{ __html: post.content }} />
<div className="post-tags">
{post.tags.map(tag => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
<CommentsSection comments={post.comments} postId={post.id} />
</article>
);
}
export default PostDetail;
معالجة الأخطاء وحالات التحميل
// src/components/ErrorBoundary.jsx
import React from 'react';
import { useQuery } from '@apollo/client';
function ErrorDisplay({ error }) {
if (error.networkError) {
return (
<div className="error-network">
<h3>خطأ في الشبكة</h3>
<p>غير قادر على الاتصال بالخادم. يرجى التحقق من اتصالك.</p>
</div>
);
}
if (error.graphQLErrors) {
return (
<div className="error-graphql">
<h3>خطأ</h3>
{error.graphQLErrors.map((err, i) => (
<p key={i}>{err.message}</p>
))}
</div>
);
}
return (
<div className="error-unknown">
<h3>حدث خطأ</h3>
<p>{error.message}</p>
</div>
);
}
// مكون التحميل
function LoadingSpinner() {
return (
<div className="loading-spinner">
<div className="spinner"></div>
<p>جاري التحميل...</p>
</div>
);
}
export { ErrorDisplay, LoadingSpinner };
استراتيجية التخزين المؤقت من جانب العميل
// تحديثات واجهة المستخدم المتفائلة
const [deletePost] = useMutation(DELETE_POST, {
optimisticResponse: {
deletePost: true,
},
update: (cache, { data }) => {
if (data.deletePost) {
cache.modify({
fields: {
posts(existingPosts = [], { readField }) {
return existingPosts.filter(
postRef => readField('id', postRef) !== postId
);
},
},
});
}
},
});
// استمرارية الذاكرة المؤقتة
import { persistCache } from 'apollo3-cache-persist';
const cache = new InMemoryCache();
await persistCache({
cache,
storage: window.localStorage,
});
اتساق الذاكرة المؤقتة: قم دائمًا بإبطال أو تحديث الذاكرة المؤقتة بعد التحولات للحفاظ على مزامنة واجهة المستخدم. استخدم الاستجابات المتفائلة للحصول على تعليقات فورية للمستخدم قبل تأكيد الخادم.
تمرين عملي:
- نفذ سير عمل المصادقة (تسجيل الدخول/التسجيل/تسجيل الخروج)
- أنشئ نموذج تعليق مع تحديثات في الوقت الفعلي
- أضف ترقيم التمرير اللانهائي للمنشورات
- نفذ تحميل الملفات للصور المميزة
- أضف معالجة الأخطاء ومنطق إعادة المحاولة
- اختبر الاشتراكات في الوقت الفعلي مع علامات تبويب متعددة للمتصفح
جاهز للإنتاج: تطبيق React + Apollo Client الخاص بك الآن لديه تكامل GraphQL كامل مع الاستعلامات والتحولات والاشتراكات والتخزين المؤقت ومعالجة الأخطاء. جاهز للنشر!