واجهات GraphQL

بناء تطبيق عميل GraphQL

16 دقيقة الدرس 34 من 35

بناء تطبيق عميل 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, });
اتساق الذاكرة المؤقتة: قم دائمًا بإبطال أو تحديث الذاكرة المؤقتة بعد التحولات للحفاظ على مزامنة واجهة المستخدم. استخدم الاستجابات المتفائلة للحصول على تعليقات فورية للمستخدم قبل تأكيد الخادم.
تمرين عملي:
  1. نفذ سير عمل المصادقة (تسجيل الدخول/التسجيل/تسجيل الخروج)
  2. أنشئ نموذج تعليق مع تحديثات في الوقت الفعلي
  3. أضف ترقيم التمرير اللانهائي للمنشورات
  4. نفذ تحميل الملفات للصور المميزة
  5. أضف معالجة الأخطاء ومنطق إعادة المحاولة
  6. اختبر الاشتراكات في الوقت الفعلي مع علامات تبويب متعددة للمتصفح
جاهز للإنتاج: تطبيق React + Apollo Client الخاص بك الآن لديه تكامل GraphQL كامل مع الاستعلامات والتحولات والاشتراكات والتخزين المؤقت ومعالجة الأخطاء. جاهز للنشر!