اذهب إلى المحتوى

ابراهيم الخضور

الأعضاء
  • المساهمات

    164
  • تاريخ الانضمام

  • تاريخ آخر زيارة

كل منشورات العضو ابراهيم الخضور

  1. تُظهر الواجهة الأمامية لتطبيقنا محتويات دليل الهاتف بشكل جيد بمساعدة الخادم الذي يُحدَّث باستمرار. لكن إن أردنا إضافة أشخاص جدد، لابد من إضافة طريقة لتسجيل الدخول إلى الواجهة الأمامية. تسجيل دخول المستخدم لنضف المتغير token إلى حالة التطبيق. سيحتوي هذا المتغير على شهادة تحقق المستخدم عندما يسجل دخوله. فإن لم يكن المتغير token مُعرّفًا، سنُصيّر render المكوّن LoginForm المسؤول عن عملية تسجيل الدخول. سيتلقى المكوّن معاملين هما معالج خطأ والدالة setToken: const App = () => { const [token, setToken] = useState(null) // ... if (!token) { return ( <div> <Notify errorMessage={errorMessage} /> <h2>Login</h2> <LoginForm setToken={setToken} setError={notify} /> </div> ) } return ( // ... ) } سنعرّف تاليًا طفرة لتسجيل الدخول: export const LOGIN = gql` mutation login($username: String!, $password: String!) { login(username: $username, password: $password) { value } } ` سيعمل المكوّن LoginForm بشكل مشابه لبقية المكوّنات التي أنشأناها سابقًا والتي تنفذ طفرات: import React, { useState, useEffect } from 'react' import { useMutation } from '@apollo/client' import { LOGIN } from '../queries' const LoginForm = ({ setError, setToken }) => { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [ login, result ] = useMutation(LOGIN, { onError: (error) => { setError(error.graphQLErrors[0].message) } }) useEffect(() => { if ( result.data ) { const token = result.data.login.value setToken(token) localStorage.setItem('phonenumbers-user-token', token) } }, [result.data]) // ألغيت قاعدة المدقق لهذا السطر const submit = async (event) => { event.preventDefault() login({ variables: { username, password } }) } return ( <div> <form onSubmit={submit}> <div> username <input value={username} onChange={({ target }) => setUsername(target.value)} /> </div> <div> password <input type='password' value={password} onChange={({ target }) => setPassword(target.value)} /> </div> <button type='submit'>login</button> </form> </div> ) } export default LoginForm لقد استعملنا خطاف التأثير effect-hook مجددًا. وقد استخدم لتخزين قيمة شهادة التحقق ضمن حالة المكوِّن App وضمن الذاكرة المحلية بعد أن يستجيب الخادم للطفرة. واستعمال خطاف التأثير ضروري لتلافي حلقات التصيير اللانهائية. لنضف أيضًا زرًَا لتسجيل خروج المسستخدم الذي سجّل دخوله. يغيّر معالج الحدث onClick الخاص بالزر قيمة قطعة الحالة token إلى null، كما يزيل شهادة التحقق المخزنة في الذاكرة المحلية، ويعيد ضبط الذاكرة المؤقتة الخاصة بالمكتبة Apollo client. إن هذه الخطوة الأخيرة مهمة لأن بعض الاستعلامات قد تحضر بيانات إلى الذاكرة المؤقتة والتي لا يجب للمستخدم الوصول إليها قبل تسجيل دخوله. يمكن إعادة ضبط الذاكرة المؤقتة باستخدام التابع resetStore العائد لكائن ApolloClient. ويمكن الوصول إلى هذا الكائن عبر الخطاف useApolloClient: const App = () => { const [token, setToken] = useState(null) const [errorMessage, setErrorMessage] = useState(null) const result = useQuery(ALL_PERSONS) const client = useApolloClient() if (result.loading) { return <div>loading...</div> } const logout = () => { setToken(null) localStorage.clear() client.resetStore() } } يمكنك إيجاد شيفرة التطبيق بوضعه الحالي ضمن الفرع part8-6 في المستودع المخصص للتطبيق على GitHub. إضافة شهادة التحقق إلى الترويسة بعد التغييرات التي طرأت على الواجهة الخلفية، سيتطلب إنشاء أشخاص جدد شهادة تحقق صالحة خاصة بالمستخدم عند إرسال الطلبات إلى الخادم. ولكي نرسل الشهادة، علينا تغيير الطريقة التي عرّفنا بها الكائن ApolloClient في الملف "index.js". import { setContext } from 'apollo-link-context' const authLink = setContext((_, { headers }) => { const token = localStorage.getItem('phonenumbers-user-token') return { headers: { ...headers, authorization: token ? `bearer ${token}` : null, } }}) const httpLink = new HttpLink({ uri: 'http://localhost:4000' }) const client = new ApolloClient({ cache: new InMemoryCache(), link: authLink.concat(httpLink)}) يحدد المعامل link الذي أُسند إلى الكائن client كيف تتصل Apollo مع الخادم. ولاحظ كيف عُدِّل اتصال رابط HTTP لكي تتضمن ترويسة التصريح شهادة التحقق إن كانت مخزّنة في الذاكرة المحليّة. ولابدّ من تثبيت المكتبة اللازمة لهذا التعديل كالتالي: npm install apollo-link-context سنتمكن الآن من إضافة أشخاص جدد وتغيير الأرقام مجددًا. لكن لا تزال أمامنا مشكلة واحدة. فلو حاولنا إضافة شخص بلا رقم هاتف لن نستطيع ذلك. سيخفق تقييم البيانات، ذلك أن الواجهة الأمامية سترسل نصًا فارغًا للحقل phone. لنغيّر إذًا الدالة التي تنشئ الأشخاص الجدد لكي تعطي القيمة null للحقل phone إن لم يدخل المستخدم قيمة له. const PersonForm = ({ setError }) => { // ... const submit = async (event) => { event.preventDefault() createPerson({ variables: { name, street, city, phone: phone.length > 0 ? phone : null } }) // ... } // ... } يمكنك إيجاد شيفرة التطبيق بوضعه الحالي ضمن الفرع part8-7 في المستودع المخصص للتطبيق على GitHub. تحديث الذاكرة المؤقتة (مرور ثان) ينبغي علينا تحديث الذاكرة المؤقتة للمكتبة Apollo client عند إنشاء أشخاص جدد. ويمكننا ذلك باستخدام الخيار refetchQueries للطفرة والذي يسمح بتنفيذ الاستعلام ALL_PERSONS مرة أخرى. const PersonForm = ({ setError }) => { // ... const [ createPerson ] = useMutation(CREATE_PERSON, { refetchQueries: [ {query: ALL_PERSONS} ], onError: (error) => { setError(error.graphQLErrors[0].message) } }) يعتبر ما فعلناه سابقًا مقاربة جيدة، لكن العقبة التي ستعترضنا هي أن الاستعلام سيُنفّذ من جديد عند أي تحديث. يمكن استمثال الحل بمعالجة موضوع التحديث بأنفسنا، وذلك بتعريف دالة استدعاء للتحديث خاصة بالطفرة، بحيث تستدعيها المكتبة Apollo بعد الطفرة: const PersonForm = ({ setError }) => { // ... const [ createPerson ] = useMutation(CREATE_PERSON, { onError: (error) => { setError(error.graphQLErrors[0].message) }, update: (store, response) => { const dataInStore = store.readQuery({ query: ALL_PERSONS }) store.writeQuery({ query: ALL_PERSONS, data: { ...dataInStore, allPersons: [ ...dataInStore.allPersons, response.data.addPerson ] } }) } }) // .. } تُعطى دالة الاستدعاء دلالة مرجعية إلى الذاكرة المؤقتة وإلى البيانات التي تعيدها الطفرة على شكل معاملات. في حالتنا على سبيل المثال، عند إنشاء شخص جديد ستقرأ الشيفرة حالة الذاكرة المؤقتة للاستعلام ALL_PERSONS باستخدام الدالة readQuery، وستحدث الذاكرة المؤقتة باستخدام الدالة writeQuery التي ستضيف الشخص الجديد إليها. انتبه إلى الدالة readQuery التي ستعطي خطأً إن لم تحتوي الذاكرة المؤقتة على كل البيانات التي تلبي متطلبات الاستعلام. يمكن التقاط الخطأ باستخدام الكتلة try/catch. إنّ الحل الأكثر منطقية لتحديث الذاكرة المؤقتة في بعض الحالات هو استخدام دالة استدعاء للتحديث. يمكن عند الضرورة تعطيل الذاكرة المؤقتة للتطبيق ككل أو لاستعلامات مفردة بضبط الحقل fetchPolicy الذي يدير الذاكرة المؤقتة على القيمة no-cache. انتبه دائمًا للذاكرة المؤقتة، فالبيانات القديمة فيها قد تسبب ثغرات صعبة الإيجاد. وكما نعرف إن الحفاظ على الحالة المحدّثة للذاكرة المؤقتة أمر صعب و نستشف ذلك من المقولة التالية لأحد المبرمجين: يمكنك إيجاد شيفرة التطبيق بوضعه الحالي ضمن الفرع part8-8 في المستودع المخصص للتطبيق على GitHub. التمارين 1. إنشاء قائمة كتب لن تعمل قائمة الكتب بعد التغييرات التي أجريناها على الواجهة الخلفية، لذا جد حلًا. 2. تسجيل الدخول لن تعمل وظيفة إضافة كتب جديدة، ولا تغيير عام ميلاد المؤلف لأنها تتطلب تسجيل دخول المستخدِم. أضف وظيفة تسجيل الدخول وأصلح الطفرات، ولا حاجة الآن لتعالج أخطاء التقييم. يمكن أن تقرر الطريقة التي ستعرض بها واجهة تسجيل الدخول، ومن بين الحلول المقترحة هو وضع نموذج التسجيل ضمن واجهة منفصلة يمكن الوصول إليها عبر قائمة من خيارات التنقل. نموذج تسجيل الدخول: عندما يسجل المستخدم دخوله، تتغير قائمة التنقل لإظهار الوظائف التي ينفذها التطبيق فقط عندما يسجل المستخدم دخوله: 3. اختيار الكتب بناء على نوعها: القسم الأول أكمل التطبيق بانتقاء الكتب بناء على نوعها. قد يبدو الحل كالتالي: يمكن إنجاز عملية الانتقاء في هذا التمرين باستخدام React فقط. 4. اختيار الكتب بناء على نوعها: القسم الثاني نفذ آلية لإظهار كل الكتب من النوع المفضل للمستخدم عند تسجيل دخوله. 5. اختار الكتب بناء على نوعها باستخدام GraphQL أمكننا إنجاز عملية الانتقاء في التمرين السابق باستخدام React. لإتمام هذا التمرين، ينبغي عليك انتقاء الكتب في صفحة التوصيات بإرسال استعلام GraphQ إلى الخادم. يمثل هذا التمرين والتمرين الذي يليه تحديًا حقيقيًا كما هو المفترض في هذه المرحلة من المنهاج. ربما عليك إكمال التمارين الأسهل أولًا والموجودة في القسم التالي. هذه بعض النصائح: قد يكون من الأفضل استخدام الخطاف useLazyQuery في الاستعلامات بدلًا من useQuery. من المفيد في بعض الأحيان تخزين نتيجة استعلام GraphQL ضمن حالة المكوِّن. انتبه إلى إمكانية إنجاز استعلامات GraphQL باستخدام الخطاف useEffect. يمكن أن يساعدك المعامل الثاني للخطاف useEffect، وذلك بناء على المقاربة التي ستسلكها في الحل. 6. ذاكرة مؤقتة محدثة والكتب الموصى بها إن أحضرت الكتب الموصى بها مستخدمًا GraphQL، تأكد من أنّ واجهة عرض الكتب ستبقى محدّثة بشكل أو بآخر. فعندما يُضاف كتاب جديد، لابدّ من تحديث واجهة عرض الكتب على الأقل عند الضغط على زر اختيار نوع الكتاب، وإن لم تختر نوعًا محددًا من الكتب، فلا حاجة إذًا لتحديث واجهة عرض الكتب. ترجمة -وبتصرف- للفصل Login and Updating the cache من سلسلة Deep Dive Into Modern Web Development
  2. سنضيف في هذا المقال آليةً لإدارة المستخدمين في تطبيقنا، لكن دعونا أولًا نستخدم قاعدة بيانات لتخزين بيانات التطبيق. استخدام المكتبة Mongoose مع المكتبة Apollo ثبِّت المكتبتين Mongoose، وMongoose-unique-validator كالتالي: npm install mongoose mongoose-unique-validator سنقلّد ما فعلناه في القسمين 3، و4. لقد عرّفنا سابقًا تخطيط الأشخاص كالتالي: const mongoose = require('mongoose') const schema = new mongoose.Schema({ name: { type: String, required: true, unique: true, minlength: 5 }, phone: { type: String, minlength: 5 }, street: { type: String, required: true, minlength: 5 }, city: { type: String, required: true, minlength: 3 }, }) module.exports = mongoose.model('Person', schema) لقد أضفنا أيضًا عدة مقيّمات validator، وهي required:true التي تتحقق من أنّ القيمة موجودة، وطبعًا لا حاجة فعلية لهذا المقيّم لأن GraphQL تتأكد من وجود الحقل تلقائيًا. لكن بالطبع من الجيد وجود مقيّمات في قاعدة البيانات. يمكن تشغيل التطبيق ليعمل عمومًا، بتنفيذ التعديلات التالية: const { ApolloServer, UserInputError, gql } = require('apollo-server') const mongoose = require('mongoose') const Person = require('./models/person') const MONGODB_URI = 'mongodb+srv://fullstack:halfstack@cluster0-ostce.mongodb.net/graphql?retryWrites=true' console.log('connecting to', MONGODB_URI) mongoose.connect(MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false, useCreateIndex: true }) .then(() => { console.log('connected to MongoDB') }) .catch((error) => { console.log('error connection to MongoDB:', error.message) }) const typeDefs = gql` ... ` const resolvers = { Query: { personCount: () => Person.collection.countDocuments(), allPersons: (root, args) => { // filters missing return Person.find({}) }, findPerson: (root, args) => Person.findOne({ name: args.name }) }, Person: { address: root => { return { street: root.street, city: root.city } } }, Mutation: { addPerson: (root, args) => { const person = new Person({ ...args }) return person.save() }, editNumber: async (root, args) => { const person = await Person.findOne({ name: args.name }) person.phone = args.phone return person.save() } } } تُعَدّ التعديلات التي أجريت واضحة تمامًا. لكن سنشير إلى عدة نقاط تستحق الوقوف عندها. كما نتذكر، فإنّ الحقل المُعرِّف لكائن في Mongo يدعى "id_". ولقد كان علينا سابقًا تحويل اسم الحقل إلى المعرّف id بأنفسنا، لكن ستنفّذ لنا المكتبة GraphQL الآن هذا الأمر تلقائيًا؛ أما الملاحظة الأخرى الجديرة بالاهتمام هي أنّ دوال المحللات ستعيد وعودًا promises، وقد كانت تعيد سابقًا كائنات. فعندما يعيد المحلل وعدًا، سيُعيد خادم Apollo القيمة التي يشير إليها الوعد. فلو نُفِّذت على سبيل المثال دالة المحلل التالية: allPersons: (root, args) => { return Person.find({}) }, سينتظر خادم Apollo حتى تجري معالجة الوعد، ومن ثم يعيد النتيجة. إذًا فالمكتبة Apollo تعمل تقريبيًا كالتالي: Person.find({}).then( result => { // يعيد النتيجة }) لنكمل كتابة المحلل allPersons لكي يأخذ المعامل phone في الحسبان: Query: { // .. allPersons: (root, args) => { if (!args.phone) { return Person.find({}) } return Person.find({ phone: { $exists: args.phone === 'YES' }}) }, }, فلو لم يمتلك الاستعلام المعامل phone، ستعيد الاستجابة كل الأشخاص. أما إن امتلك المعامل القيمة YES، ستعيد نتيجة الاستعلام الكائنات التي يمتلك فيها الحقل phone قيمة. Person.find({ phone: { $exists: true }}) وفي حال امتلك المعامل القيمة NO، سيعيد الاستعلام الكائنات التي لا يمتلك فيها الحقل phone قيمة: Person.find({ phone: { $exists: false }}) تقييم صحة البيانات كما هي الحال في GraphQL، سيتم تقييم صحة البيانات المدخلة باستخدام المقيّمات المعرّفة ضمن تخطيط Mongoose. وللتعامل مع الأخطاء الناتجة عن تقييم البيانات في التخطيط، لا بد من إضافة معالجات أخطاء على شكل كتل try/catch إلى التابع save. وفي حال وصل تنفيذ الشيفرة إلى الجزء catch، سنظهر عندها الاستثناء المناسب. Mutation: { addPerson: async (root, args) => { const person = new Person({ ...args }) try { await person.save() } catch (error) { throw new UserInputError(error.message, { invalidArgs: args, }) } return person }, editNumber: async (root, args) => { const person = await Person.findOne({ name: args.name }) person.phone = args.phone try { await person.save() } catch (error) { throw new UserInputError(error.message, { invalidArgs: args, }) } return person } } ستجد شيفرة الواجهة الخلفية ضمن الفرع part8-4 في المستودع المخصص للتطبيق على Github. تسجيل دخول المستخدمين سنضيف الآن آلية لإدارة المستخدمين في تطبيقنا. وتوخيًا للبساطة، سنفترض أن لجميع المستخدمين كلمة المرور نفسها وسنكتبها مسبقًا ضمن الشيفرة. سيكون تخزين كلمات السر الخاصة بكل مستخدم مباشرًا وفق المبدأ الذي اتبعناه في القسم 4، لكن طالما أنّ تركيزنا سينصب على GraphQL فسنترك بقية التفاصيل حتى يحين وقتها. سيكون التخطيط الخاص بالمستخدِم على النحو التالي: const mongoose = require('mongoose') const schema = new mongoose.Schema({ username: { type: String, required: true, unique: true, minlength: 3 }, friends: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Person' } ], }) module.exports = mongoose.model('User', schema) يرتبط كل مستخدم بمجموعة من الأشخاص الموجودين في النظام عن طريق الحقل friends. الغاية من ذلك أنه عندما يضيف مستخدم ما mluukkai على سبيل المثال شخصًا آخر وليكن Arto Hellas إلى القائمة فسيضاف هذا الشخص إلى قائمة الأصدقاء. وهكذا سيكون لكل مستخدم عند تسجيل دخوله واجهة عرض خاصة به في التطبيق. أما عن الآلية التي تتعامل مع تسجيل الدخول والتحقق من المستخدمين فهي نفسها التي اعتمدناها في القسم 4 عندما استخدمنا REST وشهادات التحقق. لنوسّع الآن التخطيط ليصبح كالتالي: type User { username: String! friends: [Person!]! id: ID! } type Token { value: String! } type Query { // .. me: User } type Mutation { // ... createUser( username: String! ): User login( username: String! password: String! ): Token } سيعيد الاستعلام me معلومات المستخدم الذي سجّل دخوله. وتجري إضافة مستخدمين جدد باستخدام الطفرة createUser، كما يجري تسجيل الدخول باستخدام الطفرة login. تمثل الشيفرة التالية شيفرة المحللات للطفرات التي سنستخدمها: const jwt = require('jsonwebtoken') const JWT_SECRET = 'NEED_HERE_A_SECRET_KEY' Mutation: { // .. createUser: (root, args) => { const user = new User({ username: args.username }) return user.save() .catch(error => { throw new UserInputError(error.message, { invalidArgs: args, }) }) }, login: async (root, args) => { const user = await User.findOne({ username: args.username }) if ( !user || args.password !== 'secred' ) { throw new UserInputError("wrong credentials") } const userForToken = { username: user.username, id: user._id, } return { value: jwt.sign(userForToken, JWT_SECRET) } }, }, تُنفّذ الطفرة التي تسجّل مستخدِم جديد بطريقة مباشرة. حيث تتحقق طفرة تسجيل الدخول إن كان الزوج اسم مستخدم/كلمة مرور صالحين، فإن كان كذلك ستعيد شهادة تحقق من النوع "JSON-WEB-TOKEN" والتي تعاملنا معها في القسم 4. وتتكرر الفكرة السابقة عندما استخدمنا REST. حيث يحصل المستخدم الذي سجل دخوله على شهادة تحقق يضيفها إلى جميع الطلبات التي سيرسلها إلى الخادم. ستضاف أيضًا شهادة التحقق إلى استعلامات GraphQL باستخدام الترويسة Authorization. تُضاف الترويسة في أرضية عمل GraphQL إلى الاستعلام كالتالي: لنوسّع الآن تعريف الكائنserver بإضافة معامل ثالث هو context العائد لدوال المحللات إلى استدعاء الدالة البانية: const server = new ApolloServer({ typeDefs, resolvers, context: async ({ req }) => { const auth = req ? req.headers.authorization : null if (auth && auth.toLowerCase().startsWith('bearer ')) { const decodedToken = jwt.verify( auth.substring(7), JWT_SECRET ) const currentUser = await User.findById(decodedToken.id).populate('friends') return { currentUser } } }}) يوزَّع الكائن الذي يعيده Context إلى جميع محللات الطفرات على أساس معامل ثالث. يعتبر تمرير الأشياء إلى المحللات بالاستعانة بالمعامل Context مثاليًا، وخاصةً إذا كانت مشتركةً بين عدة محللات مثل التحقق من المستخدمين. يَعُدّ محلل الطفرة me بسيطًا، فهو يعيد فقط المستخدِم الذي سجل دخوله والذي يحصل عليه من الحقل currentUser للمعامل context لدالة المحلل. لكن إن لم يسجّل أي مستخدم دخوله، أي لا وجود لشهادة تحقق صحيحة مرفقة مع الطلب، سيعيد الاستعلام القيمة null: Query: { // ... me: (root, args, context) => { return context.currentUser } }, قائمة الأصدقاء لنكمل الواجهة الخلفية للتطبيق، بحيث يحتاج إضافة أو تعديل معلومات الشخص تسجيل دخول، وكذلك سنضيف الأشخاص الذين يضيفهم المستخدم تلقائيًا إلى قائمة أصدقائه. لنزل أولًا كل الأشخاص الذين لا يتواجدون ضمن قائمة أصدقاء أي مستخدم في قاعدة البيانات. سنغير الطفرة addPerson كالتالي: Mutation: { addPerson: async (root, args, context) => { const person = new Person({ ...args }) const currentUser = context.currentUser if (!currentUser) { throw new AuthenticationError("not authenticated") } try { await person.save() currentUser.friends = currentUser.friends.concat(person) await currentUser.save() } catch (error) { throw new UserInputError(error.message, { invalidArgs: args, }) } return person }, //... } إن لم نستطع الحصول على تفاصيل المستخدم الذي سجل دخوله من خلال المعامل context، سيُرمى استثناء. يُضاف الشخص الجديد الآن من خلال العبارة async/await لأن الشخص الذي سيُضاف، سيوضع ضمن قائمة أصدقاء المستخدم في حال تمت عملية الإضافة بنجاح. لنضف آلية لإضافة مستخدمين موجودين إلى قائمة الأصدقاء. ستكون الطفرة كالتالي: type Mutation { // ... addAsFriend( name: String! ): User } وستكون دالة المحلل الخاص بالطفرة كالتالي: addAsFriend: async (root, args, { currentUser }) => { const nonFriendAlready = (person) => !currentUser.friends.map(f => f._id).includes(person._id) if (!currentUser) { throw new AuthenticationError("not authenticated") } const person = await Person.findOne({ name: args.name }) if ( nonFriendAlready(person) ) { currentUser.friends = currentUser.friends.concat(person) } await currentUser.save() return currentUser }, لاحظ كيف يفكك المحلل resolver معلومات المستخدم الذي يسجل دخوله والتي يؤمنها المعامل context. لذلك وبدلًا من تخزين محتويات الحقل currentUser ضمن متغير منفصل ضمن الدالة: addAsFriend: async (root, args, context) => { const currentUser = context.currentUser ستمرر مباشرةً ضمن تعريف المعامل في الدالة: addAsFriend: async (root, args, { currentUser }) => { ستجد شيفرة الواجهة الخلفية ضمن الفرع part8-5 في المستودع المخصص للتطبيق على Github. التمارين 1. قاعدة بيانات: القسم الأول عدّل تطبيق المكتبة بحيث يحفظ البيانات ضمن قاعدة بيانات. يمكن أن تجد تخطيط Mongoose للكتب ولمؤلفين على Github. لنغير تخطيط GraphQL للكتب قليلًا: type Book { title: String! published: Int! author: Author! genres: [String!]! id: ID! } لكي يحتوي كائن الكتاب على كل تفاصيل المؤلف وليس الاسم فقط. يمكنك أيضًا أن تفترض أن المستخدم لن يحاول إدخال كتب أو مؤلفين بشكل خاطئ، وبالتالي لن تهتم بمعالجة أخطاء التقييم. لا تهتم بالأمور التالية حاليًا: الاستعلام allBooks مستخدمًا المعاملات. الحقل bookCount من الكائن author. الحقل author من الكتاب. الطفرة editAuthor. 2. قاعدة بيانات: القسم الثاني أكمل تطبيقك بحيث تعمل كل الاستعلامات والطفرات بطريقة صحيحة، ماعدا allBooks مع المعامل author. يمكنك الاستعانة بالمعلومات الواردة في توثيق Mongoose. 3. قاعدة بيانات: القسم الثالث أكمل التطبيق بحيث يصبح قادرًا على معالجة أخطاء التقييم (كتاب بعنوان قصير جدًا أو اسم مؤلف مثلًا) بطريقة منطقية. ويعني ذلك أن تسبب هذه الأخطاء الاستثناء UserInputError الذي يُرمى مع رسالة خطأ. 4. تسجيل دخول المستخدمين أضف آلية لإدارة المستخدمين إلى التطبيق. وسّع التخطيط ليصبح كالتالي: type User { username: String! favoriteGenre: String! id: ID! } type Token { value: String! } type Query { // .. me: User } type Mutation { // ... createUser( username: String! favoriteGenre: String! ): User login( username: String! password: String! ): Token } أنشئ محللاتٍ للاستعلام me وللطفرتين الجديدتين createUser وlogin. يمكنك أيضًا اعتبار أنّ لكل المستخدمين نفس كلمة السر التي تُكتب مسبقًا ضمن الشيفرة. إجعل الطفرتين addBook وeditauthor قابلتين للتنفيذ إذا أرفق الاستعلام بشهادة تحقق صالحة فقط. ترجمة -وبتصرف- للفصل DataBase and user administration من سلسلة Deep Dive Into Modern Web Development
  3. سننجز في المرحلة القادمة تطبيق React باستخدام خادم GraphQL الذي أنشأناه سابقًا. يمكنك أن تجد شيفرة الخادم ضمن الفرع part8-3 في المستودع المخصص على Github. يمكن نظريًا استخدام GraphQL مع طلبات HTTP-POST. تظهر الصورة التالية مثالًا باستخدام Postman. يعمل الاتصال بإرسال طلبات إلى العنوان، وسيكون الاستعلام نصًا مرسلًا كقيمة للمفتاح query. كما يمكن التعامل مع الاتصال بين React-app و GraphQL باستخدام Axios. لكن لا يبدو هذا الخيار منطقيًا في معظم الأحيان. فمن الأفضل استخدام مكتبة ذات إمكانيات أعلى قادرة على إزالة التفاصيل غير الضرورية من الاتصال. ستجد حاليًا خيارين جيدين، أولهما المكتبة Relay من Facebook والمكتبة Apollo Client. وتُعَدّ Apollo الأكثر شعبية بينهما بلا منازع، لذلك سنستخدمها نحن أيضًا. المكتبة Apollo client سنستخدم في منهاجنا النسخة 3.0 بيتا التجريبية من هذه المكتبة. وتعتبر النسخة 3 حتى هذا التاريخ (12.12.2020) هي آخر نسخة رسمية. لذلك تذكر عند قراءة التوثيق أن تختار توثيق النسخة 3: أنشئ تطبيق React-app جديد وثبّت الاعتماديات التي تتطلبها المكتبة Apollo client. ويمكن القيام بذلك كالتالي: npm install @apollo/client graphql سنبدأ تطبيقنا بكتابة الشيفرة التالية: import React from 'react' import ReactDOM from 'react-dom' import App from './App' import { ApolloClient, HttpLink, InMemoryCache, gql } from '@apollo/client' const client = new ApolloClient({ cache: new InMemoryCache(), link: new HttpLink({ uri: 'http://localhost:4000', }) }) const query = gql` query { allPersons { name, phone, address { street, city } id } } ` client.query({ query }) .then((response) => { console.log(response.data) }) ReactDOM.render(<App />, document.getElementById('root')) تنشئ الشيفرة في البداية كائن عميل client، ليُستخدم بعد ذلك لإرسال الاستعلام إلى الخادم: client.query({ query }) .then((response) => { console.log(response.data) }) تُطبع استجابة الخادم على الطرفية كالتالي: يمكن للتطبيق أن يتواصل مع خادم GraphQL بالاستفادة من الكائن client. يمكن أن نجعل هذا الكائن متاحًا لجميع مكوِّنات التطبيق بتغليف المكوِّن الرئيسي App ضمن المكوِّن ApolloProvider. import React from 'react' import ReactDOM from 'react-dom' import App from './App' import { ApolloClient, ApolloProvider, HttpLink, InMemoryCache} from '@apollo/client' const client = new ApolloClient({ cache: new InMemoryCache(), link: new HttpLink({ uri: 'http://localhost:4000', }) }) ReactDOM.render( <ApolloProvider client={client}> <App /> </ApolloProvider>, document.getElementById('root') ) إنشاء الاستعلامات نحن جاهزون الآن لإنجاز واجهة العرض الرئيسية للتطبيق، والتي تُظهر قائمة بأرقام الهواتف. تقدم المكتبة Apollo Client عدة بدائل لإنشاء الاستعلامات. وتُعَدّ الممارسة الأفضل حاليًا هي استخدام دالة الخطاف useQuery. تُظهر الشيفرة التالية الاستعلام الذي ينشئه المكون App: import React from 'react' import { gql, useQuery } from '@apollo/client'; const ALL_PERSONS = gql` query { allPersons { name phone id } } ` const App = () => { const result = useQuery(ALL_PERSONS) if (result.loading) { return <div>loading...</div> } return ( <div> {result.data.allPersons.map(p => p.name).join(', ')} </div> ) } export default App ينفّذ الخطاف hook الذي يُدعى useQuery، عندما يُستدعى، الاستعلام الذي يُمرَّر إليه كمعامل، ويعيد كائنا مؤلفًا من عدة حقول. سيحمل الحقل loading القيمة true إن لم يتلقى الاستعلام استجابة بعد. ثم ستُصيَّر الشيفرة التالية: if ( result.loading ) { return <div>loading...</div> } عندما يتلقى الاستعلام allPersons الاستجابة، يمكن الحصول على النتيجة من الحقل data، كما يمكن تصيير قائمة الأسماء على الشاشة: <div> {result.data.allPersons.map(p => p.name).join(', ')} </div> لنفصل الشيفرة التي تعرض قائمة الأشخاص ضمن مكوِّن خاص: const Persons = ({ persons }) => { return ( <div> <h2>Persons</h2> {persons.map(p => <div key={p.name}> {p.name} {p.phone} </div> )} </div> ) } سيبقى المكوّن App قادرًا على إنشاء الاستعلامات وتمرير النتائج إلى المكوّن الجديد لتُصيَّر: const App = () => { const result = useQuery(ALL_PERSONS) if (result.loading) { return <div>loading...</div> } return ( <Persons persons = {result.data.allPersons}/> ) } الاستعلامات والمتغيرات المسماة لنضف وظيفة إظهار تفاصيل عنوان الشخص. سيفي الاستعلام findPerson بالغرض. لقد وضعنا قيمة جاهزة كمعامل في الاستعلام الذي أنشأناه في الفصل السابق: query { findPerson(name: "Arto Hellas") { phone city street id } } لكن عندما ننجز الاستعلامات برمجيًا، لا بدّ من تمرير المعاملات ديناميكيًا. لهذا سنستخدم متغيرات GraphQL التي ستفي بالغرض. ولكي نستخدم هذه المتغيرات، لا بدّ من تسمية الاستعلامات. تمثل الشيفرة التالية نموذجًا جيد لبناء استعلام: query findPersonByName($nameToSearch: String!) { findPerson(name: $nameToSearch) { name phone address { street city } } } يُسمّى الاستعلام السابق findPersonByName ويقبل المتغير النصي $nameToSearch معاملًا. كما يمكن أن ننجز الاستعلامات مستخدمين أرضية عمل GraphQL. تُعطى المعاملات داخل متغيّرات الاستعلام: يلائم استخدام الخطاف useQuery الحالات التي يُنجز فيها الاستعلام عندما يُصيَّر المكوِّن. لكننا سننفذ الاستعلام الآن عندما يريد المستخدم أن يرى تفاصيل شخص معين فقط، أي سيُنفّذ الاستعلام عند الحاجة. في هذه الحالة ستكون دالة الخطاف useLazyQuery خيارًا جيدًا. سيصبح المكوّن Persons كالتالي: const FIND_PERSON = gql` query findPersonByName($nameToSearch: String!) { findPerson(name: $nameToSearch) { name phone id address { street city } } }` const Persons = ({ persons }) => { const [getPerson, result] = useLazyQuery(FIND_PERSON) const [person, setPerson] = useState(null) const showPerson = (name) => { getPerson({ variables: { nameToSearch: name } }) } useEffect(() => { if (result.data) { setPerson(result.data.findPerson) } }, [result]) if (person) { return( <div> <h2>{person.name}</h2> <div>{person.address.street} {person.address.city}</div> <div>{person.phone}</div> <button onClick={() => setPerson(null)}>close</button> </div> ) } return ( <div> <h2>Persons</h2> {persons.map(p => <div key={p.name}> {p.name} {p.phone} <button onClick={() => showPerson(p.name)} > show address </button> </div> )} </div> ) } export default Persons لقد تغيّرت الشيفرة، ولا تبدو معظم التغيّرات واضحة. فعندما ننقر على زر show address بجوار الشخص، سيُنفَّذ معالج الحدث showPerson الذي ينفِّذ بدوره استعلامًا لإحضار تفاصيل الأشخاص: const [getPerson, result] = useLazyQuery(FIND_PERSON) // ... const showPerson = (name) => { getPerson({ variables: { nameToSearch: name } }) } يتلقى المتغيّر nameToSearch العائد للاستعلام قيمةً عندما يُنفَّذ هذا الاستعلام، وتُخزّن الاستجابة عليه في المتغيّر result، كما تُخزن قيمته في حالة المكوّن person في خطاف التأثير useEffect. useEffect(() => { if (result.data) { setPerson(result.data.findPerson) } }, [result]) يتلقى الخطاف المعامل الثاني result، وبالتالي ستُنفّذ الدالة التي تمرر إلى الخطاف كمعامل ثانٍ في كل مرة يحضر فيها الاستعلام تفاصيل شخص مختلف. ولو لم نتعامل مع التحديث بطريقة قابلة للإدارة باستخدام الخطاف كالسابق، ستسبب العودة من عرض شخص واحد إلى قائمة الأشخاص الكثير من المشاكل. إن احتوت الحالة person قيمة، فستظهر على الشاشة تفاصيل شخص واحد بدلًا من قائمة الأشخاص: عندما يريد المستخدم العودة إلى قائمة الأشخاص، يجب ضبط قيمة الحالة person على null.ولا يعتبر هذا الحل هو الأفضل، لكنه جيد بما يكفي في حالتنا. يمكنك إيجاد التطبيق بوضعه الحالي ضمن الفرع part8-1 في المستودع المخصص للتطبيق على Github. الذاكرة المؤقتة إن نفذّنا عدة استعلامات مثل الاستعلام عن تفاصيل عنوان Arto Hella، فسنلاحظ أمرًا مهمًا: سيُرسل الاستعلام إلى الواجهة الخلفية مرة واحدة فقط. بعد ذلك، وعلى الرغم من تنفيذ الاستعلام مرات عدة من قبل الشيفرة، فلن يُرسل الاستعلام إلى الواجهة الخلفية: تُخزَّن المكتبة الاستجابات على الاستعلامات في الذاكرة المؤقتة cache. ولاستمثال الأداء، لن ترسل المكتبة الاستعلام إن وجدت استجابة مسبقة عليه ضمن الذاكرة المؤقتة. يمكن تثبيت الأداة Apollo Client devtools على المتصفح Chrome لمتابعة حالة الذاكرة المؤقتة: تُنظّم البيانات داخل الذاكرة المؤقتة عن طريق الاستعلام. وطالما أن الكائن Person سيمتلك حقلًا يحتوي على معرّف فريد id من النوع "ID"، فإن أعيد نفس الكائن كاستجابة لعدة استعلامات، يمكن أن تدمجها المكتبة Apollo ضمن كائن واحد. وهكذا فإن تنفيذ الاستعلام findPerson للحصول على تفاصيل "Arto Hellas" سيٌحدّث تفاصيل العنوان حتى من أجل الاستعلام allPersons. تنفيذ الطفرات سنضيف الآن وظيفة إنشاء أشخاص جدد. لقد كتبنا في الفصل السابق المعاملات من أجل الطفرات mutations يدويًا. سنحتاج الآن نسخة من الطفرة addPeson تستخدم المتغيرات: const CREATE_PERSON = gql` mutation createPerson($name: String!, $street: String!, $city: String!, $phone: String) { addPerson( name: $name, street: $street, city: $city, phone: $phone ) { name phone id address { street city } } } ` تؤمن دالة الخطاف useMutation الآلية اللّازمة لإنشاء الطفرات. لننشئ الآن مكوّنًا جديدًا لإضافة شخص جديد: import React, { useState } from 'react' import { gql, useMutation } from '@apollo/client' const CREATE_PERSON = gql` // ... ` const PersonForm = () => { const [name, setName] = useState('') const [phone, setPhone] = useState('') const [street, setStreet] = useState('') const [city, setCity] = useState('') const [ createPerson ] = useMutation(CREATE_PERSON) const submit = (event) => { event.preventDefault() createPerson({ variables: { name, phone, street, city } }) setName('') setPhone('') setStreet('') setCity('') } return ( <div> <h2>create new</h2> <form onSubmit={submit}> <div> name <input value={name} onChange={({ target }) => setName(target.value)} /> </div> <div> phone <input value={phone} onChange={({ target }) => setPhone(target.value)} /> </div> <div> street <input value={street} onChange={({ target }) => setStreet(target.value)} /> </div> <div> city <input value={city} onChange={({ target }) => setCity(target.value)} /> </div> <button type='submit'>add!</button> </form> </div> ) } export default PersonForm إنّ شيفرة النموذج واضحة تمامًا، وقد علّمنا الأسطر التي تحوي نقاط هامة. يمكن تعريف دالة الطفرة باستخدام الخطاف useMutation. سيعيد الخطاف مصفوفة يحوي عنصرها الأول الدالة التي ستسبب الطفرة. const [ createPerson ] = useMutation(CREATE_PERSON) سيتلقى متغير الاستعلام قيمًا عند تنفيذ الاستعلام: createPerson({ variables: { name, phone, street, city } }) سيُضاف الشخص الجديد كما هو مطلوب، لكن الشاشة لن تُحدّث ما يعرض عليها. والسبب في ذلك أن Apollo client لن تحدّث تلقائيًا الذاكرة المؤقتة للتطبيق، وستبقى محتفظة بالحالة التي خزنتها الطفرة السابقة. يمكن تحديث الشاشة بإعادة تحميل الصفحة، حيث يتم تفريغ الذاكرة المؤقتة عندما نفعل ذلك. لكن لابدّ من وجود طريقة أفضل لتحديث الشاشة. تحديث محتويات الذاكرة المؤقتة هناك حلول عدة لإنجاز ذلك. تتمثل إحدى الحلول بحثِّ poll الخادم من قبل الاستعلام، أو تنفيذ الاستعلام بشكل دوري. سيكون التعديل في الشيفرة بسيطًا، إذ سنجعل الاستعلام يحثُّ الخادم كل ثانيتين: const App = () => { const result = useQuery(ALL_PERSONS, { pollInterval: 2000 }) if (result.loading) { return <div>loading...</div> } return ( <div> <Persons persons = {result.data.allPersons}/> <PersonForm /> </div> ) } export default App إن الحل المقدم بسيط، وسيظهر أي شخص جديد يضاف إلى القائمة على الشاشة التي تعرض كل الأشخاص مباشرة. لكن تتمثل الناحية السلبية لهذا الحل بانتقال بيانات بلا فائدة عبر الشبكة. الطريقة السهلة الأخرى لإبقاء الذاكرة المؤقتة متزامنة مع التحديثات التي تجري هي استخدام المعامل refetchQueries لدالة الخطاف useMutation والذي يحدد أنّ الاستعلام سيحضر تفاصيل كل الأشخاص من جديد في كل مرة يُضاف فيها شخص جديد. const ALL_PERSONS = gql` query { allPersons { name phone id } } ` const PersonForm = (props) => { // ... const [ createPerson ] = useMutation(CREATE_PERSON, { refetchQueries: [ { query: ALL_PERSONS } ] }) إن حسنات وسيئات هذا الحل هي على نقيض الحل السابق، إذ لا يوجد هنا انتقال بيانات زائد عبر الشبكة، فلا تُنفَّذ الاستعلامات بشكل دوري لالتقاط أي تغيير في حال حدوثه. لكن إن حدَّث مستخدم حالة الخادم، فلن تظهر التغييرات مباشرة لبقية المستخدمين. لا تزال هناك طرق أخرى لتحديث الذاكرة المؤقتة وسنستعرضها لاحقًا في هذا القسم. تتواجد حاليًا في تطبيقنا شيفرة الاستعلامات وشيفرة المكوّنات في نفس المكان، لنفصل إذًا شيفرة الاستعلامات ونضعها في الملف الجديد "queries.js": import { gql } from '@apollo/client' export const ALL_PERSONS = gql` query { // ... } ` export const FIND_PERSON = gql` query findPersonByName($nameToSearch: String!) { // ... } ` export const CREATE_PERSON = gql` mutation createPerson($name: String!, $street: String!, $city: String!, $phone: String) { // ... } ` يُدرج الآن كل مكوِّن الاستعلامات التي يحتاجها: import { ALL_PERSONS } from './queries' const App = () => { const result = useQuery(ALL_PERSONS) // ... } ستجد الشيفرة الحالية للتطبيق ضمن الفرع part8-2 في المستودع المخصص للتطبيق على Github. التعامل مع أخطاء الطفرات ستسبب محاولة إنشاء شخص جديد ببيانات غير صحيحة خطأً، وسينهار التطبيق ككل: علينا اصطياد هذا الخطأ ومنع وقوعه. يمكن تعريف دالة معالج خطأ للطفرة باستخدام الخيار onError للخطاف useMutaion. لنعرّف معالج خطأ للطفرة يستخدم الدالة setEroor التي تُمرّر إليه كمعامل لتهيئة رسالة خطأ: const PersonForm = ({ setError }) => { // ... const [ createPerson ] = useMutation(CREATE_PERSON, { refetchQueries: [ {query: ALL_PERSONS } ], onError: (error) => { setError(error.graphQLErrors[0].message) } }) // ... } يمكننا الآن تصيير رسالة الخطأ على الشاشة عند الحاجة. const App = () => { const [errorMessage, setErrorMessage] = useState(null) const result = useQuery(ALL_PERSONS) if (result.loading) { return <div>loading...</div> } const notify = (message) => { setErrorMessage(message) setTimeout(() => { setErrorMessage(null) }, 10000) } return ( <div> <Notify errorMessage={errorMessage} /> <Persons persons = {result.data.allPersons} /> <PersonForm setError={notify} /> </div> ) } const Notify = ({errorMessage}) => { if ( !errorMessage ) { return null } return ( <div style={{color: 'red'}}> {errorMessage} </div> )} يُبلّغ المستخدم الآن عن الخطأ المرتكب من خلال تنبيه بسيط. ستجد الشيفرة الحالية للتطبيق ضمن الفرع part8-3 في المستودع المخصص للتطبيق على Github. تحديث رقم الهاتف لنضف إمكانية تغيير رقم هاتف الشخص إلى التطبيق. سيكون الحل مطابقًا تقريبًا لعملية إضافة شخص جديد. تحتاج الطفرة مرة أخرى إلى معاملات. export const EDIT_NUMBER = gql` mutation editNumber($name: String!, $phone: String!) { editNumber(name: $name, phone: $phone) { name phone address { street city } id } } ` إن آلية عمل المكوّن PhoneForm المسؤول عن التغيير مباشرة. إذ يمتلك النموذج حقولًا لاسم الشخص ورقم الهاتف الجديد ويستدعي بعدها الدالة changeNumber. تُنفَّذ الدالة باستخدام الخطاف useMutaion. import React, { useState } from 'react' import { useMutation } from '@apollo/client' import { EDIT_NUMBER } from '../queries' const PhoneForm = () => { const [name, setName] = useState('') const [phone, setPhone] = useState('') const [ changeNumber ] = useMutation(EDIT_NUMBER) const submit = (event) => { event.preventDefault() changeNumber({ variables: { name, phone } }) setName('') setPhone('') } return ( <div> <h2>change number</h2> <form onSubmit={submit}> <div> name <input value={name} onChange={({ target }) => setName(target.value)} /> </div> <div> phone <input value={phone} onChange={({ target }) => setPhone(target.value)} /> </div> <button type='submit'>change number</button> </form> </div> ) } export default PhoneForm لا يبدو المظهر مرضيًا لكن التطبيق يعمل: وبشكل مفاجئ سيظهر الرقم الجديد للشخص تلقائيًا عند تغييره ضمن قائمة الأشخاص التي يصيرها المكوّن Persons. ويحدث هذا لأن كل شخص يمتلك حقلًا مُعرِّفًا فريدًا من النوع ID. وهكذا سوف تُحدَّث بيانات الشخص المخزّنة في الذاكرة المؤقتة تلقائيًا عندما تغيّرها الطفرة. ستجد الشيفرة الحالية للتطبيق ضمن الفرع part8-4 في المستودع المخصص للتطبيق على Github. تبقى هناك ثغرة صغيرة في التطبيق. فلو أردنا تغيير رقم الهاتف لشخص غير موجود، يبدو أن لا شيء سيحدث. وذلك لأن شخصًا باسم غير موجود سيجعل الطفرة تعيد القيمة "null". لا يعتبر هذا الأمر خطأً بالنسبة للمكتبة GraphQL، وبالتالي لا فائدة من تعريف معالج خطأ في هذه الحالة. يمكن الاستفادة من الحقل result الذي يعيده الخطاف كمعامل ثانٍ لتوليد رسالة خطأ: const PhoneForm = ({ setError }) => { const [name, setName] = useState('') const [phone, setPhone] = useState('') const [ changeNumber, result ] = useMutation(EDIT_NUMBER) const submit = (event) => { // ... } useEffect(() => { if (result.data && result.data.editNumber === null) { setError('person not found') } }, [result.data]) // ... } إن لم يستطع التطبيق إيجاد شخص أو كانت نتيجة الأمر result.data.editNumber هيnull، سيستخدم المكوِّن دالة الاستدعاء التي يتلقاها كخاصية لإنشاء رسالة خطأ مناسبة. نريد في التطبيق أن نضبط رسالة الخطأ عندما تتغير نتيجة الطفرة result.data فقط. لذلك نستخدم خطاف التأثير useEffect للتحكم بإنشاء رسالة الخطأ. سيسبب استخدام الخطاف useEffect تحذيرًا يطلقه المدقق ESlint: لا فائدة حقيقية من هذا التحذير و سيكون الحل الأفضل هو تجاهل قاعدة المدقق ESlint في السطر الذي سيولد التحذير: useEffect(() => { if (result.data && !result.data.editNumber) { setError('name not found') } }, [result.data]) // eslint-disable-line يمكننا أيضًا التخلص من هذا التحذير بإضافة الدالة setError إلى مصفوفة المعامل الثاني للخطاف useEffect: useEffect(() => { if (result.data && !result.data.editNumber) { setError('name not found') } }, [result.data, setError]) لن يعمل هذا الحل بالطبع إن كانت الدالة notify غير مضمنة داخل دالة الخطاف useCallback. وإن لم تكن كذلك ستكون النتيجة حلقة فارغة لانهائية. عندما يُصيَّر المكوِن App بعد إزالة التنبيه، سينشأ تنبيه جديد مسببًا إعادة تنفيذ دالة خطاف التأثير، والتي ستولد بدورها تنبيهًا جديدًا وهكذا تستمر الحلقة. ستجد الشيفرة الحالية للتطبيق ضمن الفرع part8-5 في المستودع المخصص للتطبيق على Github. حالة التطبيق عند استخدام المكتبة Apollo client لاحظنا في التطبيق السابق، أنّ إدارة الحالة كانت مسؤولية المكتبة Apollo client. وهذا حل نموذجي لتطبيقات GraphQL. وقد استخدم تطبيقنا حالة مكوِّنات React لإدارة حالة النماذج فقط ولإظهار تنبيهات الأخطاء. فعند استخدامك للمكتبة GraphQL، لا توجد مبررات مقبولة لنقل إدارة حالة التطبيقات إلى Redux إطلاقًا. وعند الحاجة ستخزِّن المكتبة Apollo حالة التطبيق المحليّة في ذاكرتها المؤقتة. التمرينات سننجز خلال التمرينات القادمة واجهة أمامية للمكتبة GraphQl. استخدم المشروع المتعلق بالتمارين كنقطة انطلاق لتطبيقك. يمكنك إنجاز المطلوب باستخدام المكونين Query وMutation القابلين للتصيير في المكتبة Apollo client، كما يمكن استخدام الخطافات التي تؤمنها النسخة Beta 3.0. 1. واجهة عرض للمؤلفين نفّذ واجهة تعرض تفاصيل جميع المؤلفين على الصفحة كما في الشكل التالي: 2. واجهة عرض للكتب نفِّذ واجهة تعرض على الصفحة كل التفاصيل الأخرى للكتب ما عدا نوعها. 3. إضافة كتاب أنجز آلية لإضافة كتب جديدة إلى تطبيقك. يمكن أن تبدو هذه الوظيفة بالشكل التالي: تأكد من أنّ واجهتي عرض المؤلفين والكتب ستبقيان مُحدَّثتين بعد إضافة كتاب جديد. تحقق عبر طرفية التطوير من استجابة الخادم في حال واجهتك المشاكل عند استخدام الاستعلامات أو الطفرات. 4. عام ولادة المؤلف أنجز آلية لإضافة عام ولادة المؤلف. يمكنك إنشاء واجهة عرض جديدة لضبط عام الميلاد، أو يمكنك عرضها ضمن واجهة عرض المؤلفين: تأكد من تحديث واجهة عرض المؤلفين بعد إضافة عام ولادة مؤلف. 5. أسلوب متقدم لإضافة عام ولادة مؤلف عدّل نموذج إضافة عام الميلاد بحيث لا يمكن إضافته إلا لمؤلف موجود مسبقًا. استخدم المكتبتين select-tag، وreact-select أو أية آليات مناسبة أخرى. سيبدو الحل الذي يستخدم React-select مشابهًا للشكل التالي: ترجمة -وبتصرف- للفصل React and GraphQL من سلسلة Deep Dive Into Modern Web Development
  4. قدم المعيار REST -الذي تعرفنا عليه في الأقسام السابقة- ولفترة طويلة أكثر الطرق شعبية في تنفيذ الواجهات التي يقدمها الخادم إلى المتصفح، ولإنجاز التكامل بين تطبيقات مختلفة على الشبكة. وازدادت خلال السنوات الأخيرة شهرة المكتبة GraphQL التي طوّرتها فيسبوك Facebook للاتصال بين الخادم وتطبيق الويب. تختلف فلسفة GraphQL عن REST بشكل واضح. حيث يعتمد المعيار REST على الموارد، ولكل مورد (كالمستخدم مثلًا) عنوانه الخاص الذي يعرّفه users/10/ مثلًا. وتجري كل العمليات على الموارد باستخدام طلب HTTP إلى عنوان موقعه. ويعتمد الفعل الذي سيُنفّذ على نوع الطلب HTTP. تعمل آلية REST بشكل جيد في معظم الحالات. لكنها قد تتصرف بغرابة في بعض الأحيان. لنفترض أن تطبيق المدونات سيمتلك وظيفة مشابهة لوسائل التواصل الاجتماعي، وأردنا تشكيل قائمة تضم أسماء كل المستخدمين الذين علّقوا على المدونات التي نتابعها. فلو استعان الخادم بواجهة برمجية متوافقة مع REST، لكنا سنرسل عدة طلبات HTTP من المتصفح قبل أن نحصل على كل البيانات التي نحتاجها. كما ستعيد الطلبات الكثير من البيانات غير الضرورية، وربما ستكون الشيفرة معقدة على المتصفح. فإن كانت ستستخدم هذه الوظيفة بكثرة، فقد تجد لها نقطة وصل مع الواجهة البرمجية REST. لكن إن كان هنالك العديد من السيناريوهات المماثلة، سيكون مجهدًا إنجاز نقطة وصل لكل منها. وبناء على ما سبق يمكن القول أن خادم GraphQL مناسب جدًا لهذه الحالات. يعتمد المبدأ العام للمكتبة GraphQL على حقيقة أن الشيفرة ستشكل على المتصفح استعلامًا يصف البيانات المطلوبة، ثم سترسلها إلى الواجهة البرمجية ضمن طلب HTTP-POST. وعلى خلاف REST، تُرسل كل الاستعلامات إلى نفس العنوان وستكون جميعها من النوع POST. يمكن أن نحضر البيانات التي ناقشناها في السيناريو السابق كالتالي (تقريبيًا): query FetchBlogsQuery { user(username: "mluukkai") { followedUsers { blogs { comments { user { blogs { title } } } } } } } سيستجيب الخادم بإعادته كائن JSON له تقريبًا البنية التالية: { "data": { "followedUsers": [ { "blogs": [ { "comments": [ { "user": { "blogs": [ { "title": "Goto considered harmful" }, { "title": "End to End Testing with Cypress is most enjoyable" }, { "title": "Navigating your transition to GraphQL" }, { "title": "From REST to GraphQL" } ] } } ] } ] } ] } } وهكذا سيبقى منطق التطبيق بسيطًا، وستتلقى شيفرة المتصفح البيانات التي تحتاجها تمامًا باستعلام وحيد. التخطيطات والاستعلامات سنتعرف على أساسيات GraphQL، بإنجاز نسخة من تطبيق دليل الهاتف الذي عملنا عليه في القسمين 2و3، بالاعتماد على GraphQL. في صميم كل تطبيقات GraphQL تخطيطًا يصف البيانات المرسلة بين العميل والخادم. سيكون التخطيط الأولي لتطبيق دليل الهاتف كالتالي: type Person { name: String! phone: String street: String! city: String! id: ID! } type Query { personCount: Int! allPersons: [Person!]! findPerson(name: String!): Person } يصف التخطيط نوعين. يحد أولهما Person أن للشخص خمسة حقول، أربعة منها "نصية" من النوع "String"، وهو أحد الأنماط السلّمية scalar types في GraphQL. ويجب أن تُعطى كل الحقول النصية قيمًا ما عدا الحقل phone. وستجد أنّ هذه الحقول مُعلّمة بإشارة تعجّب في التخطيط. أما نوع الحقل id فهو "ID". هذا النوع نصي أيضًا، لكن المكتبة GraphQL ستضمن أن قيمه فريدة. أما النوع الثاني فهو Query أي استعلام. حيث يصف كل تخطيط في الواقع استعلامًا يحدد أنواع الاستعلامات التي يمكن طلبها من الواجهة البرمجية API. يصف دليل الهاتف ثلاث استعلامات مختلفة: يعيد الأول personCount عددًا صحيحًا، ويعيد الثاني allPersons قائمة من الكائنات Person، أما الثالث findPerson فيتلقى معاملًا نصيًا ويعيد كائنًا من النوع Person. استخدمنا هنا أيضًا إشارة التعجّب لتحديد أي استعلام سيعيد قيمة، وأية معاملات ينبغي أن تحمل قيمة. سيعيد الاستعلام personCount قيمة صحيحة، ويجب أن يتلقى الاستعلام findPerson معاملًا نصيًا وأن يعيد كائن من النوع Person أو "Null- لاشيء". كما سيعيد الاستعلام allPersons قائمة كائنات من النوع Person بحيث لا تحتوي القائمة على أية قيم من النوع "Null- لاشيء". إذًا سيصف التخطيط الاستعلامات التي يمكن للمستخدم إرسالها إلى الخادم، والمعاملات التي تأخذها، وأنواع المعطيات التي تعيدها. إن أبسط الاستعلامات هو personCount والذي يبدو كالتالي: query { personCount } فلو افترضنا أن تطبيقنا قد حفظ معلومات عن ثلاث أشخاص، ستكون نتيجة الاستعلام كالتالي: { "data": { "personCount": 3 } } أما الاستعلام allPersons الذي يحضر معلومات كل الأشخاص، فهو أعقد بقليل. وطالما أنه سيعيد قائمة من الكائنات، فعليه أن يحدد أية حقول من الكائن سوف يعيدها: query { allPersons { name phone } } يمكن أن تكون الاستجابة على النحو التالي: { "data": { "allPersons": [ { "name": "Arto Hellas", "phone": "040-123543" }, { "name": "Matti Luukkainen", "phone": "040-432342" }, { "name": "Venla Ruuska", "phone": null } ] } } يمكن أن ننفذ الاستعلام لإعادة إي حقل يصفه التخطيط. فالاستعلام التالي على سبيل المثال صحيح: query { allPersons{ name city street } } يظهر المثال السابق استعلامًا يتطلب معاملًا، ويعيد تفاصيل شخص واحد. query { findPerson(name: "Arto Hellas") { phone city street id } } لاحظ كيف نضع المعامل بين قوسين، ثم ترتب حقول القيمة التي ستعاد ضمن قوسين معقوصين. ستكون الاستجابة كالتالي: { "data": { "findPerson": { "phone": "040-123543", "city": "Espoo", "street": "Tapiolankatu 5 A" "id": "3d594650-3436-11e9-bc57-8b80ba54c431" } } } يمكن أن يعيد الاستعلام القيمة "Null"، فلو حاولنا البحث عن شخص غير موجود: query { findPerson(name: "Donald Trump") { phone } } ستكون النتيجة Null { "data": { "findPerson": null } } وكما رأينا، هنالك علاقة مباشرة بين استعلام GraphQL وكائن JSON الذي سيعيده. وبالتالي يمكننا أن نعتبر أن الاستعلام سيحدد نوع البيانات التي يريدها في الاستجابة. إن اختلاف هذا الاستعلام عن استعلام REST كبير، فلا علاقة لعنوان المورد ونوع البيانات التي يعيدها الاستعلام في REST بشكل البيانات المعادة. يصف استعلام GraphQL البيانات المنتقلة بين الخادم والعميل فقط. بينما يمكننا تصنيف البيانات وحفظها كما نريد على الخادم. وعلى الرغم من اسمها، لا تتعلق GraphQL فعليًا بقواعد البيانات، فهي لا تهتم بالطريقة التي تُخزّن فيها البيانات. فقد تُخزّن البيانات التي تستخدمها واجهة GraphQL البرمجية في قاعدة بيانات علاقيّة أو مستنديّة Document database، أو ضمن خوادم أخرى يمكن لخادم GraphQL الوصول إليها مع REST على سبيل المثال. خادم Apollo لننجز خادم GraphQL بمساعدة المكتبة الرائدة في هذا المجال Apollo -server. سننشئ مشروع npm جديد بتنفيذ الأمر npm init وسنثبت اعتمادياته اللازمة: npm install apollo-server graphql ستكون الشيفرة الأولية كالتالي: const { ApolloServer, gql } = require('apollo-server') let persons = [ { name: "Arto Hellas", phone: "040-123543", street: "Tapiolankatu 5 A", city: "Espoo", id: "3d594650-3436-11e9-bc57-8b80ba54c431" }, { name: "Matti Luukkainen", phone: "040-432342", street: "Malminkaari 10 A", city: "Helsinki", id: '3d599470-3436-11e9-bc57-8b80ba54c431' }, { name: "Venla Ruuska", street: "Nallemäentie 22 C", city: "Helsinki", id: '3d599471-3436-11e9-bc57-8b80ba54c431' }, ] const typeDefs = gql` type Person { name: String! phone: String street: String! city: String! id: ID! } type Query { personCount: Int! allPersons: [Person!]! findPerson(name: String!): Person } ` const resolvers = { Query: { personCount: () => persons.length, allPersons: () => persons, findPerson: (root, args) => persons.find(p => p.name === args.name) } } const server = new ApolloServer({ typeDefs, resolvers, }) server.listen().then(({ url }) => { console.log(`Server ready at ${url}`) }) إن جوهر الشيفرة السابقة هو الكائن ApolloServer الذي يتلقى معاملين: const server = new ApolloServer({ typeDefs, resolvers, }) الأول هو typeDefs ويتضمن تخطيط GraphQL، والثاني كائن يحتوي على محللات Resolvers لاستجابة الخادم، وهي شيفرة تحدد كيف سيستجيب الخادم لاستعلامات GraphQL. لشيفرة المحللات resolvers الشكل التالي: وكما نرى، تتعلق المحللات بوصف الاستعلامات الوارد في التخطيط: type Query { personCount: Int! allPersons: [Person!]! findPerson(name: String!): Person } إذًا، هناك حقل لكل استعلام يصفه التخطيط داخل النوع Query. فالاستعلام التالي: query { personCount } سيمتلك المحلل: () => persons.length وبالتالي سيكون طول المصفوفة persons هو الرد على الاستعلام. أما الاستعلام الذي سيحضر بيانات كل الأشخاص: query { allPersons { name } } سيمتلك محللًا يعيد كل الكائنات الموجودة في المصفوفة persons. () => persons أرضية عمل GraphQL عندما يعمل خادم Apollo في وضع التطوير node filename.js، سيُشغّل أرضية عمل(playground (GraphQL على العنوان http://localhost:4000/graphql. إنّ هذه الميزة مفيدة جدًا للمطورين، ويمكن استخدامها لإنشاء استعلامات إلى الخادم. لنجرّب ذلك: يتطلب منك العمل مع الأرضية بعض الدقة. فلو أخطأت في صياغة الاستعلام، فلن تلاحظ رسالة الخطأ، ولن يحدث شيء على الإطلاق عند النقر على الزر "go". ستبقى نتيجة الاستعلام السابق ظاهرة في الجهة اليمنى من أرضية العمل حتى لو كان الاستعلام الحالي خاطئًا. وبوضع مؤشر الفأرة على المكان الصحيح من السطر الذي ارتكبنا فيه الخطأ، ستظهر رسالة الخطأ. إن بدا لك أن أرضية العمل لا تستجيب، فقد يساعدك تحديث الصفحة refresh. سيظهر لك بالنقر على النص DOCS على يمين أرضية العمل تخطيط GraphQL على الخادم. معاملات المحلل يمتلك الاستعلام التالي الذي يحضر بيانات شخص واحد: query { findPerson(name: "Arto Hellas") { phone city street } } محللًا يختلف عن سابقاته بامتلاكه معاملين: (root, args) => persons.find(p => p.name === args.name) يحتوي المعامل الثاني args معاملات الاستعلام. حيث يعيد المحلل عندها شخصًا من قائمة الأشخاص عندما يتطابق اسمه مع القيمة args.name. ولا يحتاج المحلل إلى المعامل الأول root. تعطى كل المحللات عمليًا أربع معاملات. لكن ليس من الضروري في تعريف المعاملات إن لم نحتاجها. وسنرى كيف سنستعمل المعاملين الأول والثالث فقط لاحقًا في هذا القسم. المحلل الافتراضي عندما نرسل الاستعلام التالي على سبيل المثال: query { findPerson(name: "Arto Hellas") { phone city street } } كيف يمكن للخادم أن يعيد الحقول التي يطلبها الاستعلام تمامًا؟ ينبغي على خادم GraphQL أن يحدد محللًا لكل حقل من كل نوع في التخطيط. لقد عرفنا حاليًا محللات للحقول من النوع "Query" فقط وذلك من أجل كل استعلام في التطبيق. ولأننا لم نعرف محللات لحقول النوع "Person"، يحدد لها Apollo محللات افتراضية. تعمل هذه المحللات كالمثال التالي: const resolvers = { Query: { personCount: () => persons.length, allPersons: () => persons, findPerson: (root, args) => persons.find(p => p.name === args.name) }, Person: { name: (root) => root.name, phone: (root) => root.phone, street: (root) => root.street, city: (root) => root.city, id: (root) => root.id }} يعيد المحلل الافتراضي القيمة المرتبطة بالحقل المحدد من الكائن. ويمكن الوصول إلى الكائن نفسه عبر معامل المحلل الأول "root". لا يتوجب عليك تعريف محللات خاصة إن كانت المحللات الافتراضية كافية لتنفيذ المطلوب. كما يمكنك تعريف محللات ترتبط ببعض حقول نوع معين، وترك المحللات الافتراضية تتعامل مع بقية الحقول. يمكنك على سبيل المثال تحديد عنوان واحد لكل الأشخاص وليكن Manhattan New York بكتابته مباشرة ضمن الشيفرة، وكتابة التالي لمحللات الحقلين street وcity العائدين للنوع "Person": Person: { street: (root) => "Manhattan", city: (root) => "New York" } كائنات ضمن الكائنات لنعدّل التخطيط السابق قليلًا: type Address { street: String! city: String! } type Person { name: String! phone: String address: Address! id: ID! } type Query { personCount: Int! allPersons: [Person!]! findPerson(name: String!): Person } سيمتلك الشخص حقلًا من النوع "Address" الذي يحتوي على المدينة والشارع. سيتغير الاستعلام الذي يتطلب العنوان إلى الشكل: query { findPerson(name: "Arto Hellas") { phone address { city street } } } وستكون الاستجابة الآن على شكل كائن من النوع "person" يحتوي على كائن من النوع "Address". { "data": { "findPerson": { "phone": "040-123543", "address": { "city": "Espoo", "street": "Tapiolankatu 5 A" } } } } لكن طريقة تخزين بيانات الأشخاص على الخادم بقيت كما هي. let persons = [ { name: "Arto Hellas", phone: "040-123543", street: "Tapiolankatu 5 A", city: "Espoo", id: "3d594650-3436-11e9-bc57-8b80ba54c431" }, // ... ] إذًا، فالكائن "person" الذي يُخزَّن في الخادم ليس مطابقًا لكائن GraphQL من النوع "person" والموصوف في التخطيط. وعلى النقيض، لا يمتلك النوع "Address" الحقل id لأنه لا يُخزَّن ضمن بنية خاصة به على الخادم. وطالما أنّ الكائنات التي خُزّنت داخل المصفوفة لا تمتلك الحقل address، لن يكون المحلل الافتراضي كافيًا. لنضيف إذًا محللًا للحقل address العائد للنوع "Person". const resolvers = { Query: { personCount: () => persons.length, allPersons: () => persons, findPerson: (root, args) => persons.find(p => p.name === args.name) }, Person: { address: (root) => { return { street: root.street, city: root.city } } }} وهكذا وفي كل مرة يُعاد فيها كائن من النوع "Person"، ستُعاد الحقول name وid وphone باستخدام محللاتها الخاصة أما الحقل address فسيتشكل باستخدام المحلل الذي عرّفناه. وطالما أنّ المعامل root لدالة المحلل هو كائن من النوع "Person"، فيمكن الوصول إلى الشارع والمدينة من حقوله. ستجد شيفرة التطبيق بوضعه الحالي ضمن الفرع part8-1 في المستودع المخصص للتطبيق على GitHub الطفرات لنضف وظيفة إدخال مستخدم جديد إلى دليل الهاتف. تُنفَّذ جميع العمليات التي تسبب تغييرات في GraphQL باستخدام الطفرات (mutations). تُوصف الطفرات كمفاتيح من النوع "’Mutation" ضمن تخطيط GraphQL. سيبدو تخطيط إضافة شخص جديد كالتالي: type Mutation { addPerson( name: String! phone: String street: String! city: String! ): Person } تُعطى الطفرة تفاصيل الشخص كمعاملات. يمكن للمعامل phone فقط أن لا يعيد شيئًا. كما نلاحظ أن الطفرة ستعيد قيمة من النوع "Person"، والغرض من ذلك إعادة بيانات الشخص إن نجحت العملية و"NULL" إن فشلت. لا تمرر قيمة الحقل id كمعامل، فمن الأفضل ترك ذلك للخادم. تتطلب الطفرات محللات أيضًا: const { v1: uuid } = require('uuid') // ... const resolvers = { // ... Mutation: { addPerson: (root, args) => { const person = { ...args, id: uuid() } persons = persons.concat(person) return person } } } تضيف الطفرة الكائن الذي يمرر إليها عبر الوسيط args إلى المصفوفة persons، وتعيد الكائن الذي أضافته إلى المصفوفة. يُعطى الحقل id قيمة فريدة بالاستعانة بالمكتبة uuid. يمكن أن يُضاف شخص جديد من خلال الطفرة التالية: mutation { addPerson( name: "Pekka Mikkola" phone: "045-2374321" street: "Vilppulantie 25" city: "Helsinki" ) { name phone address{ city street } id } } لاحظ أن الشخص الجديد سيوضع ضمن المصفوفة persons على الشكل التالي: { name: "Pekka Mikkola", phone: "045-2374321", street: "Vilppulantie 25", city: "Helsinki", id: "2b24e0b0-343c-11e9-8c2a-cb57c2bf804f" } لكن استجابة الخادم على الطفرة ستكون بالشكل: { "data": { "addPerson": { "name": "Pekka Mikkola", "phone": "045-2374321", "address": { "city": "Helsinki", "street": "Vilppulantie 25" }, "id": "2b24e0b0-343c-11e9-8c2a-cb57c2bf804f" } } } إذًا، سيُنسّق محلل الحقل address للنوع "Person " الكائن الذي يُعيده الخادم بالشكل الصحيح. معالجة الأخطاء إذا لم تتوافق المعاملات عند إنشاء شخص جديد مع الوصف المرافق للتخطيط، سيعطينا الخادم رسالة الخطأ التالية: يمكن معالجة بعض الأخطاء تلقائيًا باستخدام تقييم GraphQL، لكنها لا يمكن أن تعالج كل شيء تلقائيًا. إذ ينبغي إضافة القواعد الأكثر تشددًا لإرسال البيانات إلى الطفرات يدويًا. تُعالج الأخطاء الناتجة عن تلك القواعد وفق آلية معالجة الأخطاء لخادم Apollo. لنمنع إضافة نفس الاسم عدة مرات إلى دليل الهاتف: const { ApolloServer, UserInputError, gql } = require('apollo-server') // ... const resolvers = { // .. Mutation: { addPerson: (root, args) => { if (persons.find(p => p.name === args.name)) { throw new UserInputError('Name must be unique', { invalidArgs: args.name, }) } const person = { ...args, id: uuid() } persons = persons.concat(person) return person } } } فلو كان الاسم موجودًا مسبقًا، فسيرمي التطبيق الخطأ UserInputError. ستجد شيفرة التطبيق بوضعه الحالي ضمن الفرع part8-2 في المستودع المخصص للتطبيق على GitHub. التعداد Enum في GraphQL لنضف إلى التطبيق إمكانية ترشيح الاستعلام الذي يعيد كل الأشخاص وفقًا للمعامل phone، وبالتالي: إما أن يعيد الاستعلام الشخص الذي يمتلك رقم هاتف فقط. query { allPersons(phone: YES) { name phone } } أو الأشخاص الذين لا يمتلكون رقم هاتف. query { allPersons(phone: NO) { name } } ستغير التخطيط على النحو التالي: enum YesNo { YES NO} type Query { personCount: Int! allPersons(phone: YesNo): [Person!]! findPerson(name: String!): Person } يمثل النوع YesNo تعدادًا GraphQL، أو معدِّدًا يأخذ إحدى قيمتين YES أو NO. يعتبر المعامل phone في الاستعلام allPerson من النوع "YesNO"، لكنه قد يحمل القيمة "null". سيتغير المحلل كالتالي: Query: { personCount: () => persons.length, allPersons: (root, args) => { if (!args.phone) { return persons } const byPhone = (person) => args.phone === 'YES' ? person.phone : !person.phone return persons.filter(byPhone) }, findPerson: (root, args) => persons.find(p => p.name === args.name) }, تغيير رقم الهاتف لنضف طفرة لتغيير رقم هاتف شخص محدد. سيبدو تخطيط هذه الطفرة كالتالي: type Mutation { addPerson( name: String! phone: String street: String! city: String! ): Person editNumber( name: String! phone: String! ): Person} وتنفذ هذه الطفرة باستخدام المحلل: Mutation: { // ... editNumber: (root, args) => { const person = persons.find(p => p.name === args.name) if (!person) { return null } const updatedPerson = { ...person, phone: args.phone } persons = persons.map(p => p.name === args.name ? updatedPerson : p) return updatedPerson } } ستجد الطفرة الشخص الذي ستُحدَّث بياناته من خلال الحقل name. ستجد شيفرة التطبيق بوضعه الحالي ضمن الفرع part8-3 في المستودع المخصص للتطبيق على GitHub. المزيد عن الاستعلامات يمكن في GraphQL أن ندمج بين عدة حقول من استعلام، أو بين استعلامات مختلفة ضمن استعلام واحد. فالاستعلام التالي على سبيل المثال سيعيد عدد الأشخاص في دليل الهاتف وأسماءهم: query { personCount allPersons { name } } وستظهر الإجابة على الشكل التالي: { "data": { "personCount": 3, "allPersons": [ { "name": "Arto Hellas" }, { "name": "Matti Luukkainen" }, { "name": "Venla Ruuska" } ] } } ويمكن للاستعلامات المشتركة أن تستخدم نفس بنية الاستعلام مرات عدة بشرط أن تعطي كلًا منها اسمًا مختلفًا كالتالي: query { havePhone: allPersons(phone: YES){ name } phoneless: allPersons(phone: NO){ name } } وستكون الاستجابة كالتالي: { "data": { "havePhone": [ { "name": "Arto Hellas" }, { "name": "Matti Luukkainen" } ], "phoneless": [ { "name": "Venla Ruuska" } ] } } ومن المفيد أن نعطي الاستعلام اسمًا في بعض الأحيان. وخاصة عندما تمتلك الاستعلامات أو الطفرات معاملات. وسنتطرق إلى هذا الموضوع قريبًا. إن ظهرت في شيفرتك استعلامات متعددة، ستسألك أرضية العمل أن تحدد الاستعلام الذي ستنفّذه: التمارين سننجز خلال هذه التمارين واجهة خلفية باستخدام GraphQL لمكتبة صغيرة. استخدم الملف المخصص للتمرين والموجود على GitHub كنقطة انطلاق، وتذكر أن تستخدم أمر التهيئة npm init وأن تُثبّت الاعتماديات. انتبه لفكرة أن الشيفرة لن تعمل بشكلها الحالي لأن تعريف التخطيط الخاص بالواجهة غير مكتمل بعد. 1. عدد الكتب المؤلفين أنجز الاستعلامين bookCount وauthorCount اللذين يعيدان عدد الكتب وعدد المؤلفين. ينبغي للاستعلام التالي: query { bookCount authorCount } أن يعيد النتيجة التالية: { "data": { "bookCount": 7, "authorCount": 5 } } 2. جميع الكتب أنجز الاستعلام allBooks الذي يعيد تفاصيل جميع الكتب. على المستخدم في نهاية المطاف أن يكون قادرًا على تنفيذ الاستعلام التالي: query { allBooks { title author published genres } } 3. جميع المؤلفين أنجز الاستعلام allAuthors الذي يعيد تفاصيل جميع المؤلفين. يجب أن تتضمن الاستجابة الحقل bookCount الذي يضم عدد الكتب التي ألّفها الكاتب. فسيعيد الاستعلام التالي على سبيل المثال: query { allAuthors { name bookCount } } النتيجة التالية: { "data": { "allAuthors": [ { "name": "Robert Martin", "bookCount": 2 }, { "name": "Martin Fowler", "bookCount": 1 }, { "name": "Fyodor Dostoevsky", "bookCount": 2 }, { "name": "Joshua Kerievsky", "bookCount": 1 }, { "name": "Sandi Metz", "bookCount": 1 } ] } } 4. الكتب التي أنجزها مؤلف عدل الاستعلام allBooks لكي يتمكن المستخدم من تمرير المعامل الاختياري author إلى الاستعلام. يجب أن تحتوي الاستجابة على الكتب التي أنجزها المؤلف الذي مُرِّر من خلال المعامل. سيعيد مثلًا الاستعلام التالي: query { allBooks(author: "Robert Martin") { title } } النتيجة التالية: { "data": { "allBooks": [ { "title": "Clean Code" }, { "title": "Agile software development" } ] } } 5. الحصول على الكتب من خلال نوعها عدل الاستعلام allBooks لكي يتمكن المستخدم من تمرير المعامل الاختياري genre إلى الاستعلام. ستحتوي الاستجابة على الكتب التي لها نفس النوع فقط. سيعطي الاستعلام التالي على سبيل المثال: query { allBooks(genre: "refactoring") { title author } } النتيجة التالية: { "data": { "allBooks": [ { "title": "Clean Code", "author": "Robert Martin" }, { "title": "Refactoring, edition 2", "author": "Martin Fowler" }, { "title": "Refactoring to patterns", "author": "Joshua Kerievsky" }, { "title": "Practical Object-Oriented Design, An Agile Primer Using Ruby", "author": "Sandi Metz" } ] } } يجب أن يعمل الاستعلام أيضًا عند تمرير المعاملين السابقين معًا: query { allBooks(author: "Robert Martin", genre: "refactoring") { title author } } 6. إضافة كتاب أنجز الطفرة addBook التي ستستخدم كالتالي: mutation { addBook( title: "NoSQL Distilled", author: "Martin Fowler", published: 2012, genres: ["database", "nosql"] ) { title, author } } من المفترض أن تعمل الطفرة mutation حتى لو لم يُخزَّن اسم المؤلف على الخادم بعد: mutation { addBook( title: "Pimeyden tango", author: "Reijo Mäki", published: 1997, genres: ["crime"] ) { title, author } } إن لم يُخزَّن اسم المؤلف على الخادم بعد، سيُضاف المؤلف الجديد إلى المنظومة. وطالما أن عام ولادة المؤلف لم يُخزَّن على الخادم بعد، فسيعيد الاستعلام التالي: query { allAuthors { name born bookCount } } هذه النتيجة: { "data": { "allAuthors": [ // ... { "name": "Reijo Mäki", "born": null, "bookCount": 1 } ] } } 7. تحديث عام ولادة المؤلف أنجز الطفرة editAuthor التي ستستخدم لتحديد عام ولادة المؤلف. يمكن استخدام الطفرة على النحو التالي: mutation { editAuthor(name: "Reijo Mäki", setBornTo: 1958) { name born } } إن عُثر على المؤلف المطلوب، ستعيد العملية تفاصيل المؤلف وقد عُدِّل عام ولادته: { "data": { "editAuthor": { "name": "Reijo Mäki", "born": 1958 } } } إن لم يكن المؤلف موجودًا، ستعيد العملية القيمة "null". { "data": { "editAuthor": null } } ترجمة -وبتصرف- للفصل GraphQl-Server من سلسلة Deep Dive Into Modern Web Development
  5. بالإضافة إلى التمارين الثمانية في الفصلين (المكتبة React-Router) و(خطافات مخصصة) من القسم السابع، هنالك 13 تمرينًا نتابع فيها العمل مع تطبيق قائمة المدونات الذي بدأناه في القسم 4 والقسم 5 من مادة هذا المنهاج. بعض التمارين التي سنراها هي "ميزات" مستقلة عن بعضها، بمعنى أنه لا يجب عليك إنجاز الحلول بترتيب معين. إذ يمكنك أن تتجاوز جزء من التمارين إن أردت ذلك. إن لم تستخدم تطبيق قائمة المدونات الخاص بك، يمكنك العمل على شيفرة نموذج الحل كنقطة انطلاق. ستتطلب منك العديد من التمرينات في هذا القسم أن تعيد كتابة الشيفرة الموجودة. وهذا الأمر طبيعي عند توسيع أي تطبيق، فإعادة كتابة الشيفرة هو أمر مهم ومهارة ضرورية حتى لو بدا الأمر صعبًا ومزعجًا في بعض الأحيان. وكنصيحة مفيدة بخصوص كتابة شيفرة جديدة أو إعادة كتابة شيفرة موجودة، هي أن تخطو خطوات صغيرة، فستفقد أعصابك بكل تأكيد إن تركت تطبيق ينهار بالكامل لأوقات طويلة أثناء إعادة كتابة الشيفرة. التمارين 7.9 - 7.21 7.9 المكتبة Redux: الخطوة 1 أعد كتابة تطبيقك لكي يستخدم Redux لإدارة حالة التطبيق بدلًا من استخدام الحالة الداخلية لمكونات React. وعدّل في آلية عرض التنبيهات لتستخدم Redux في هذه المرحلة من مجموعة التمرينات. 7.10 المكتبة Redux: الخطوة 2 انتبه إنّ هذا التمرين والتمرينان التاليان له، يتطلبان جهدًا، لكنهما يقدمان فائدة تعليمية كبيرة. خزن المعلومات حول منشورات المدونة في مخزن Redux. يكفي أن ترى المدونات ضمن الواجهة الخلفية، وأن تكون قادرًا على إنشاء مدونة جديدة في هذه المرحلة. لك كامل الحرية في أن تدير حالة تسجيل الدخول وإضافة مدونة جديدة باستخدام Redux أو حالة المكونات الداخلية. 7.11 المكتبة Redux: الخطوة 3 وسع التطبيق لتكون قادرُا من جديد على الإعجاب بالمدونات أو حذفها. 7.12 المكتبة Redux: الخطوة 4 خزّن معلومات تسجيل دخول المستخدم ضمن مخزن Redux. 7.13 واجهة عرض المستخدمين أضف واجهة عرض للتطبيق لكي يعرض جميع المعلومات الأساسية المتعلقة بالمستخدم. 7.14 واجهة عرض لمستخدم واحد أضف واجهة عرض للتطبيق لكي يعرض كل المنشورات المتعلقة بمستخدم واحد. يمكنك الوصول إلى هذه الواجهة بالنقر على اسم المستخدم من واجهة عرض كل المستخدمين: ملاحظة: ستتعثر حتمًا برسالة الخطأ التالية خلال حلك للتمرين: سيحدث هذا الخطأ عندما تُحدِّث صفحة المتصفح وهي تعرض واجهة مستخدم واحد. إنّ سبب هذه المشكلة يتعلق بالانتقال المباشر إلى صفحة المستخدم، فلم تتلقَّ React بعد البيانات من الواجهة الخلفية. ويعتبر التصيير الشرطي أحد الحلول المقترحة للمشكلة: const User = () => { const user = ... if (!user) { return null } return ( <div> // ... </div> ) } 7.15 واجهة عرض المدوّنة أضف واجهة عرض خاصة بمنشورات المدونة. يمكنك تخطيط طريق العرض اعتمادًا على النموذج التالي: يجب أن يكون المستخدم قادرًا على الوصول إلى المنشور بالنقر على اسمه في الواجهة التي تعرض كل المنشورات: بعد إكمالك لهذا التمرين، يمكنك الاستغناء عن الوظيفة التي أضفتها إلى التطبيق في التمرين 5.6. فلا داعي لتوسيع العنصر عند النقر على المنشور لعرض تفاصيله. 7.16 أدوات التنقل أضف قائمة للتنقل في واجهات العرض في التطبيق 7.17 التعليقات: الخطوة 1 أضف وظيفة تجعل المستخدم قادرًا على إدراج تعليق حول المنشور: يجب أن يكون التعليق بلا هوية، أي يجب أن لا يقترن التعليق بالمستخدم الذي كتبه. يكفي في هذه المرحلة أن تعرض الواجهة الأمامية التعليقات التي يتلقاها التطبيق من الواجهة الخلفية. إحدى الطرق المناسبة لإضافة التعليقات على المنشورات بإرسال طلب HTTP-POST إلى عنوان الموقع "api/blogs/:id/comments". 7.18 التعليقات: الخطوة 2 وسّع التطبيق لكي يصبح المستخدم قادرًا على التعليق على منشور من الواجهة الأمامية: 7.19 التنسيقات: الخطوة 1 حسّن من مظهر التطبيق مستخدمُا إحدى الطرق التي تعلمناها في مادة المنهاج. 7.20 التنسيقات: الخطوة 2 يمكنك أن تشير إلى إنجازك لهذا التمرين، إن استغرقت ساعة أو أكثر في تنسيق التطبيق. 7.21 رأيك في المنهاج كيف أبليت حتى الآن؟ أعطنا رأيك بالمنهاج على منصة Moodle. هكذا نكون وصلنا إلى آخر تمرينات هذا القسم، وقد حان الوقت لتسليم إجاباتك إلى GitHub. لا تنس الإشارة إلى كل التمارين التي أنجزتها في منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل exercises extending the bloglist من سلسلة Deep Dive Into Modern Web Development
  6. مكونات الأصناف استخدمنا حتى اللحظة في المنهاج مكوّنات React التي عرفناها على شكل دوال JavaScript. ولم يكن هذا ممكنًا لولا الوظائف التي أمنتها الخطافات التي أتت مع النسخة 16.8 من React. فلقد كان على المطوّر أن يستخدم العبارة Class عندما يعرّف مكونًا له حالة. من المفيد أن نطّلع إلى حدٍّ ما على مكوّنات الأصناف، لأنّ هناك الكثير من تطبيقات React القديمة التي لم تُكتب من جديد باستخدام القواعد الأحدث. لنتعرّف إذًا على الميزات الرئيسية لمكوّنات الأصناف، وذلك باستخدامها مع تطبيق " الطرائف" الذي أصبح مألوفًا. سنخزّن الطرائف في الملف "db.json" مستخدمين خادم JSON. يمكن نسخ محتويات الملف من GitHub تبدو النسخة الأساسية من مكوّن الأصناف كالتالي: import React from 'react' class App extends React.Component { constructor(props) { super(props) } render() { return ( <div> <h1>anecdote of the day</h1> </div> ) } } export default App يمتلك المكوّن الآن دالة بانية، والتي لا تقوم بأي عمل حاليًا، كما يتضمن أيضًا التابع render. سيعرّف التابع render كما توقعنا، كيف وماذا سيُعرض على الشاشة. لنعرّف حالة لقائمة الطرائف وللطرفة التي تُعرض حاليًا. بالمقارنة مع استخدام الخطاف useState فمكوّن الأصناف يحتوي فقط حالة واحدة. فإن تكوّنت الحالة من عدة أجزاء يجب حفظها كخصائص للحالة. سنهيّئ الحالة ضمن البانية: class App extends React.Component { constructor(props) { super(props) this.state = { anecdotes: [], current: 0 } } render() { if (this.state.anecdotes.length === 0 ) { return <div>no anecdotes...</div> } return ( <div> <h1>anecdote of the day</h1> <div> {this.state.anecdotes[this.state.current].content} </div> <button>next</button> </div> ) } } تخزّن حالة المكوّن في المتغيّر this.state. وستكون الحالة عبارة عن كائن بخاصيتين هما this.state.anecdotes وتمثل قائمة الطرائف وthis.state.current والتي تخزن رقم الطرفة المعروضة. إنّ المكان الصحيح لإحضار البيانات من الخادم في مكوّنات الدوال هو داخل خطاف التأثير، والذي يُنفّذ عندما يصيّر المكوّن أو خلال فترات أبعد إن اقتضى الأمر، كالتصيير للمرة الأولى فقط. تقدّم توابع دورة العمل في مكوّنات الأصناف وظائف مقابلة. وسيكون المكان الأمثل لإحضار البيانات من الخادم داخل تابع دور العمل componentDidMount، والذي يُنفَّذ مرة واحدة عند كل تصيير للمكوّن: class App extends React.Component { constructor(props) { super(props) this.state = { anecdotes: [], current: 0 } } componentDidMount = () => { axios.get('http://localhost:3001/anecdotes') .then(response => { this.setState({ anecdotes: response.data }) }) } // ... } تُحدِّث دالة استدعاء طلب HTTP المكوّن باستخدام التابع setState. يؤثر التابع فقط على المفاتيح التي عُرّفت في الكائن الذي سنمرّره إلى التابع كمعامل. وستبقى قيمة المفتاح current كما هي. كما يفعّل استدعاء التابع setState تابع التصيير render. سنكمل عملنا على المكوّن بإضافة إمكانية تغيير الطرفة المعروضة. تمثل الشيفرة التالية المكوّن بالكامل، ولوِّنت الشيفرة التي تنفذ الإضافة السابقة: class App extends React.Component { constructor(props) { super(props) this.state = { anecdotes: [], current: 0 } } componentDidMount = () => { axios.get('http://localhost:3001/anecdotes').then(response => { this.setState({ anecdotes: response.data }) }) } handleClick = () => { const current = Math.floor( Math.random() * this.state.anecdotes.length ) this.setState({ current }) } render() { if (this.state.anecdotes.length === 0 ) { return <div>no anecdotes...</div> } return ( <div> <h1>anecdote of the day</h1> <div>{this.state.anecdotes[this.state.current].content}</div> <button onClick={this.handleClick}>next</button> </div> ) } } وعلى سبيل المقارنة، فالشيفرة التالية هي الشيفرة المقابلة لمكوّنات الدوال: const App = () => { const [anecdotes, setAnecdotes] = useState([]) const [current, setCurrent] = useState(0) useEffect(() =>{ axios.get('http://localhost:3001/anecdotes').then(response => { setAnecdotes(response.data) }) },[]) const handleClick = () => { setCurrent(Math.round(Math.random() * (anecdotes.length - 1))) } if (anecdotes.length === 0) { return <div>no anecdotes...</div> } return ( <div> <h1>anecdote of the day</h1> <div>{anecdotes[current].content}</div> <button onClick={handleClick}>next</button> </div> ) } إنّ الاختلاف بينهما ثانوي فيما يتعلق بتطبيقنا. أما الفرق الجوهري بين المكونين هو أنّ حالة مكوّن الأصناف هي كائن واحد، وأنّ التابع setState هو من يحدث الحالة. بينما يمكن في مكوّنات الدوال تخزين الحالة ضمن عدة متغيرات، لكل منها دالة تحديث خاصة بها. وعلى مستويات مقارنة أعلى، سيقدم خطاف التأثير آلية تحكم أفضل بالتأثيرات الجانبية مقارنة بتوابع دورة العمل التي تأتي مع مكوّنات الأصناف. والفائدة الملحوظة باستخدام مكوّنات الدوال تكمن في انعدام الحاجة لاستخدام المؤشر الذاتي this الذي تستخدمه أصناف JavaScript. لا توفر مكوّنات الأصناف بناء على رأي الكثيرين أية ميزات تفوق بها مكوّنات الدوال المدعّمة بالخطافات، ما عدا آلية محيط الخطأ (error boundary) والتي لم تستخدم حتى الآن من قبل مكوّنات الدوال. لا توجد أسباب منطقية تدفعك لاستخدام مكونات الأصناف عند كتابة شيفرة لتطبيق جديد، إن كان مشروعك سيستخدم React 16.8 أو أعلى. ولا حاجة حاليًا لإعادة كتابة كل شيفرات React القديمة كمكوّنات دوال. تنظيم الشيفرة في تطبيقات React لقد اتبعنا في معظم التطبيقات المبدأ التالي في التنظيم: حيث وضعنا المكوّنات في المجلد "components"، ووضعنا دوال الاختزال في المجلد"reducers"، وضعنا الشيفرة المسؤولة عن الاتصال مع الخادم في المجلد "services". تلائم الطريقة السابقة تنظيم التطبيقات الصغيرة. لكن عند زيادة عدد المكوّنات، سيتطلب الأمر حلولًا أفضل. ليست هنالك طريقة واحدة صحيحة لتنظيم المشروع، لكن قد تقدم لك المقالة التي تحمل العنوان The 100% correct way to structure a React app (or why there’s no such thing) بعض الإضاءات بهذا الشأن. الواجهة الخلفية والأمامية في المجلد نفسه وضعنا حتى اللحظة الواجهتين الأمامية والخلفية في مستودعين منفصلين. وهذه المقاربة تقليدية جدًا. لكننا نقلنا الملف المُجمّع للواجهة الأمامية عند نشر التطبيق إلى مستودع الواجهة الخلفية. وربما يكون نشر شيفرة الواجهة الأمامية بشكل مستقل مقاربة أفضل، وخاصة للتطبيقات التي أنشئت باستخدام creat-react-app لأنها عملية سهلة التنفيذ بفضل المكتبة buildpack. ربما ستصادفنا حالات تقتضي أن يوضع التطبيق بأكمله داخل مستودع واحد. في حالات كهذه، يشيع وضع الملفين في جذر المشروع، ووضع شيفرة الواجهتين الأمامية والخلفية في مجلدات خاصة بكل منهما مثل "client" و"server". يمكن الاطلاع على طريقة لتنظيم الأمور تستخدمها كنقطة انطلاق على GitHub. التغييرات على الخادم إنّ حدثت أية تغيرات في حالة الخادم، كإضافة مدونة من قبل مستخدمين آخرين إلى خدمة قائمة المدونات، لن تتمكن الواجهة الأمامية التي طورناها خلال منهاجنا من تمييز هذه التغيرات حتى تُحمّل الصفحة من جديد. وتظهر المشكلة ذاتها عندما تُفعِّل الواجهة الأمامية عمليات حسابية مستهلكة للوقت ضمن الواجهة الخلفية. فكيف سنعكس نتائج العمليات على الواجهة الأمامية؟ إحدى الطرق المتبعة هي تنفيذ عملية انتخاب في الواجهة الأمامية، حيث تُكرر الطلبات إلى الواجهة البرمجية للواجهة الخلفية باستخدام الأمر setInterval مثلًا. يمكن العمل بطريقة أكثر تعقيدًا باستخدام مقابس الويب (WebSockets) التي تؤمن قناة اتصال باتجاهين بين المتصفح والخادم. لا يضطر المتصفح في هذه الحالة إلى انتخاب الواجهة الخلفية، بل عليه فقط تعريف دوال استدعاء للحالات التي يرسل فيها الخادم بيانات تتعلق بتحديث الحالة مستخدمًا مقبس الويب. ومقابس الويب هي واجهات برمجية يزودنا بها المتصفح، لكنها غير مدعومة بشكل كامل من قبل جميع المتصفحات: ينصح باستخدام المكتبة Socket.io بدلًا من الاستخدام المباشر لواجهة مقبس الويب البرمجية. تؤمن هذه المكتبة عدة خيارات للتراجع إن لم يدعم المتصفح المقابس بشكل كامل. سنتعرف في القسم 8 على GraphQL والتي ستزودنا بآلية جيدة لتنبيه الزبون بأية تغييرات في الواجهة الخلفية. DOM افتراضية يظهر موضوع DOM الافتراضية عندما نناقش تقنيات React. ماذا تعنيه تلك العبارة؟ كما ذكرنا في القسم 0، تزودنا المتصفحات بواجهة برمجية لنموذج DOM، والذي يمكِّن شيفرة JavaScript العاملة ضمن المتصفح من تغيير العناصر التي تحدد مظهر الصفحة. عندما يستخدم المطوّر React، فهو نادرًا ما يعدّل DOM مباشرة أو قد لا يعدلها أبدًا. فالدالة التي تعرّف كائن React يُعيد مجموعة من عناصر React. على الرغم من أن بعضها يبدو كعناصر HTML عادية. const element = <h1>Hello, world</h1> كما أنها أيضًا عناصر React مبنية على شيفرة JavaScript في صميمها. تُشكّل عناصر React التي تحدد مظهر المكونات في التطبيق DOM الافتراضية، والتي تُخزَّن في ذاكرة النظام أثناء تشغيل التطبيق. وتصيّر DOM الافتراضية بمساعدة المكتبة ReactDOM إلى DOM الحقيقية التي يمكن للمتصفح عرضها باستخدام "DOM API". ReactDOM.render( <App />, document.getElementById('root') ) عندما تتغير حالة التطبيق تُعرَّف DOM افتراضية جديدة بواسطة المكوّنات. تمتلك React النسخة السابقة منها في ذاكرة النظام، وبدلًا من تصيير النسخة الجديدة مباشرة باستخدام "DOM API"، تقدر React الطريقة المثلى لتحديث DOM (إزالة أو إضافة أو تعديل العناصر ضمن DOM). وهكذا ستعكس DOM النسخة الجديدة دائمًا. دور React في التطبيقات ربما لم نظهر بوضوح خلال تقدمنا في المنهاج أنّ React هي بشكل أساسي مكتبة لإدارة إنشاء واجهات عرض للتطبيقات. فلو نظرنا إلى النمط التقليدي للمتحكم بنموذج العرض(MVC-Model View Controller)، فسيكون نطاق استخدام React هو العرض(View). وللمكتبة React مجال تطبيق أضيق من Angular على سبيل المثال، والتي تمثل إطار عمل للتحكم بوحدة عرض الواجهات الأمامية. وهكذا لا تمثل React ما ندعوه "إطار عمل"، بل هي مكتبة. تخزّن البيانات التي يتعامل معها تطبيق React في حالة مكوناته، فيمكننا إذًا التفكير بحالة التطبيق على أنها نموذج من معمارية MVC. لا نشير عادة إلى معمارية MVC عند الحديث عن تطبيقات React. فلو استخدمنا Redux فسيتبع التطبيق معمارية Flux وسيركّز دور React هنا أكثر على إنشاء واجهات العرض. إذ ستعالج حالة Redux ومولدات الأفعال منطق التطبيق. أما عند استخدام redux thunk التي تعرفنا عليها في القسم 6، فسنجد أنّ منطق التطبيق سينفصل كليًا عن شيفرة React. وطالما أن React وFlux من إنتاج Facebook، فيمكننا القول أنّ استخدام React كمكتبة للتعامل مع واجهة المستخدم (UI) هي الطريقة التي ينبغي استخدامها، كما أن توافقها مع معمارية Flux سيضيف ميزة إلى التطبيق. لكن لو تحدثنا عن التطبيقات الصغيرة أو النماذج الأولية سيكون من المفيد استعمال React بطريقة "خاطئة" غير التي صممت لأجلها، ذلك أنّ تجاوز الحدود التقنية نادرًا ما يثمر. وكما أشرنا في نهاية الفصل الأخير من القسم 6، تقدم React الواجهة البرمجية والتي تمثل حلًا بديلًا لمركزية إدارة الحالة دون استخدام طرف ثالث مثل Redux. يمكنك الاطلاع على المقالة ?can't replace redux with hooks والمقالة ?how to usecontext with usereducer أمن تطبيقات React/Node لم نتطرق حتى الآن في مادة المنهاج إلى أمن المعلومات. وليس لدينا الوقت الكافي أيضًا. لكن لحسن الحظ قسم علوم الحاسب في جامعة هلسنكي يقدم المنهاج Securing Software لهذا الغرض. ينشر المشروع المفتوح لأمن تطبيقات الويب OWASP قائمة سنوية بأكثر الأخطار الأمنية شيوعًا في تطبيقات الويب. ستجد اللائحة الحالية على الموقع https://owasp.org/www-project-top-ten. ويمكن أن تتكرر الأخطار ذاتها سنويًا. ستتصدر القائمة أخطار الإدخالات المشبوهة (injection). ويعني ذلك تفسير النص الذي أرسل عبر استمارة تطبيق بشكل مختلف كليًا عما أراده المطوّر. وأكثر هذه الإدخالات شهرة هي إدخالات SQL لنفترض على سبيل المثال، أنّ استعلام SQL التالي سيُنفّذ ضمن تطبيق قابل للاختراق: let query = "SELECT * FROM Users WHERE name = '" + userName + "';" لنفترض الآن أنّ المستخدم المشبوه Arto Hellas سيعرّف اسمه بالشكل التالي: Arto Hell-as'; DROP TABLE Users; -- سيحوي الاسم علامة التنصيص المفردة ' والتي تمثل محرف بداية ونهاية السلسلة النصية في لغة SQL. وكنتيجة لتنفيذ عبارتي SQL السابقتين، ستدمر العبارة الثانية الجدول Users من قاعدة البيانات. SELECT * FROM Users WHERE name = 'Arto Hell-as'; DROP TABLE Users; --' يُمنع هذا النوع من الأخطار بتطهير النص المدخل، وذلك بالتحقق أنّ الاستعلام لا يحتوي على محارف ممنوعة الاستخدام، كعلامة التنصيص المفردة في حالتنا. فإن وجدت مثل هذه المحارف ستستبدل ببدائل آمنة لها باستخدام محارف الهروب. يمكن أن يحصل هذا النوع من الهجوم على قواعد بيانات لا تستخدم SQL. فقاعدة البيانات Mongoose تحبط هذا الهجوم بعملية تطهير الاستعلام. يمكنك الاطلاع على المزيد حول هذا الموضوع على الموقع blog.websecurify.com/2014/08/hacking-nodejs-and-mongodb.html. من الأخطار الأخرى هي السكربت العابرة للمواقع (Cross-sites scripting XSS). إذ يمكن لهذا الهجوم أن يدخل شيفرة JavaScript مشبوهة إلى تطبيق ويب شرعي. ستُنفَّذ عندها هذه الشيفرة في متصفح الضحية. فلو حاولنا إدخال الشيفرة التالية داخل تطبيق الملاحظات: <script> alert('Evil XSS attack') </script> لن يتم تنفيذ الشيفرة بل ستصيّر على شكل نص على الصفحة: ذلك أنّ React تُطهّر البيانات التي تحملها المتغيرات. لقد كانت بعض نسخ React عرضة لهجوم XSS. بالطبع تم سد هذه الثغرات، لكن لا أحد يضمن ما سيحدث. على المطور أن يبقى حذرًا عند استخدام المكتبات، وعليه أن يحدّث مكتباته بشكل مستمر إن ظهرت لها تحديثات أمنية جديدة. ستجد التحديثات المتعلقة بالمكتبة Express ضمن توثيق المكتبة، بينما ستجد تحديثات Node ضمن المدونة https://nodejs.org/en/blog. كما يمكنك التحقق من وضع اعتمادياتك باستخدام الأمر: npm outdated --depth 0 لقد احتوت الحلول النموذجية لتمرينات القسم 4 بعض الاعتماديات التي احتاجت إلى تحديث العام الماضي: يمكن تحديث الاعتمادية بتحديث الملف "package.json" وتنفيذ الأمر npm install. وانتبه إلى أنّ النسخ القديمة من الاعتماديات ليست بالضرورة خطرًا أمنيًا. يمكن أن تستخدم الأمر audit للتحقق من أمن الاعتماديات. حيث يقارن أرقام نسخة الاعتمادية في تطبيقك مع قائمة بأرقام النسخ التي ثَبُت احتواؤها على تهديد أمني ضمن قاعدة بيانات مركزية. إنّ تنفيذ الأمر npm audit على تمرين من تمرينات القسم الرابع من منهاج العام الفائت سيطبع لك قائمة طويلة بالمشاكل وطريقة حلها، والتقرير التالي هو جزء من التقرير الكلي الناتج: $ bloglist-backend npm audit === npm audit security report === # Run npm install --save-dev jest@25.1.0 to resolve 62 vulnerabilities SEMVER WARNING: Recommended action is a potentially breaking change ┌───────────────┬──────────────────────────────────────────────────────────────┐ │ Low │ Regular Expression Denial of Service │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Package │ braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Dependency of │ jest [dev] │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Path │ jest > jest-cli > jest-config > babel-jest > │ │ │ babel-plugin-istanbul > test-exclude > micromatch > braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ More info │ https://npmjs.com/advisories/786 │ └───────────────┴──────────────────────────────────────────────────────────────┘ ┌───────────────┬──────────────────────────────────────────────────────────────┐ │ Low │ Regular Expression Denial of Service │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Package │ braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Dependency of │ jest [dev] │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Path │ jest > jest-cli > jest-runner > jest-config > babel-jest > │ │ │ babel-plugin-istanbul > test-exclude > micromatch > braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ More info │ https://npmjs.com/advisories/786 │ └───────────────┴──────────────────────────────────────────────────────────────┘ ┌───────────────┬──────────────────────────────────────────────────────────────┐ │ Low │ Regular Expression Denial of Service │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Package │ braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Dependency of │ jest [dev] │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Path │ jest > jest-cli > jest-runner > jest-runtime > jest-config > │ │ │ babel-jest > babel-plugin-istanbul > test-exclude > │ │ │ micromatch > braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ More info │ https://npmjs.com/advisories/786 │ └───────────────┴──────────────────────────────────────────────────────────────┘ ... found 416 vulnerabilities (65 low, 2 moderate, 348 high, 1 critical) in 20047 scanned packages run `npm audit fix` to fix 354 of them. 62 vulnerabilities require semver-major dependency updates. فخلال عام فقط، امتلأ التطبيق بالتهديدات الأمنية الصغيرة. ولحسن الحظ لم يكن هنالك سوى تهديد خطير واحد. لننفذ الأمر npm audit fix كما يوصي التقرير: $ bloglist-backend npm audit fix + mongoose@5.9.1 added 19 packages from 8 contributors, removed 8 packages and updated 15 packages in 7.325s fixed 354 of 416 vulnerabilities in 20047 scanned packages 1 package update for 62 vulns involved breaking changes (use `npm audit fix --force` to install breaking changes; or refer to `npm audit` for steps to fix these manually) بقيت التهديدات لأن الإصلاح الافتراضي باستخدام audit لا يحدّث الاعتماديات إن زاد الرقم الأساسي لنسخها عن 62. وتحديث هذه الاعتماديات قد يسبب انهيارًا كاملًا في التطبيق. نتجت بقية التهديدات عن اعتمادية التطوير Jest. فرقم نسخة التطبيق هو 23.6.0 بينما تحمل النسخة الآمنة الرقم 25.0.1. وطالما أنها اعتمادية تطوير فلن يكون التهديد موجودًا أصلًا. مع ذلك سنحدّث المكتبة لنبقى في دائرة الأمان: npm install --save-dev jest@25.1.0 سيبدو الوضع جيدًا بعد التحديث: $ blogs-backend npm audit === npm audit security report === found 0 vulnerabilities in 1204443 scanned packages من التهديدات الأخرى المذكورة في قائمة OWASP، سنجد إخفاق التحقق (Broken Authentication) وإخفاق التحكم بالوصول (Broken Access Control). إنّ التحقق المبني على الشهادات الذي استخدمناه قوي بما يكفي إن استخدم مع بروتوكول النقل المشفّر HTTPS. لذلك عندما نضيف ميزة التحكم بالوصول إلى التطبيق، ينبغي أن لا نكتفي بالتحقق من هوية المستخدم على المتصفح، بل على الخادم أيضًا. فمن الأخطاء الأمنية، منع بعض الأفعال من الحدوث بإخفاء خيارات التنفيذ في الشيفرة التي ينفذها المتصفح فقط. ستجد دليلًا جيدًا جدًا عن أمن مواقع الويب على موقع Mozilla's MDN، والذي سيثير الموضوع المهم التالي: يحتوي توثيق Express على فصل حول موضوع الأمن بعنوان Production Best Practices: Security من المفيد الاطلاع عليه. كما ننصحك بإضافة مكتبة تدعى Helmet إلى الواجهة الخلفية. حيث تحتوي هذه المكتبة على أدوات وسطية تزيل بعض الثغرات الأمنية في تطبيقات Express. كما تستحق الأداة security-plugin العائدة للمدقق ESlint التجربة. المسارات الحالية للتطور لنتحدث أخيرًا عن بعض التكنولوجيا المستقبلية (الحالية في الواقع)، والمسارات التي يسلكها تطور الويب. نسخ بمتغيرات نمطية من JavaScript إنّ التحقق الديناميكي من أنماط المتغيرات في JavaScript قد يسبب بعض التغيرات المزعجة. فلقد تحدثنا في القسم 5 بإيجاز عن الخصائص النمطية وهي آلية تمكن المطوّر من التحقق من نمط الخاصية التي ستُمرّر إلى مكوّن React. زاد الاهتمام مؤخرًا بالتحقق الساكن من الأنماط. وتعتبر نسخة المتغيرات النمطية من JavaScript التي قدمتها Microsoft باسم Typescript الأكثر شعبية، وسنتعرف عليها في القسم 9. التصيير من جهة الخادم والتطبيقات الإيزومورفية والشيفرة الموّحدة لا تصيّر مكونات React في نطاق المتصفحات فقط. إذ يمكن أن ينجز التصيير ضمن الخادم. يزداد استخدام هذه المقاربة بحيث يقدم الخادم صفحة مصيّرة مسبقًا باستخدام React عند الدخول إلى التطبيق للمرة الأولى. ومن ثم تُستأنف العمليات بالشكل المعتاد، أي سينفذ المتصفح منطق React الذي يغيّر في DOM التي سيعرضها المتصفح. تدعى عملية التصيير على الخادم بالاسم "التصيير ضمن الخادم". إنّ إحدى دوافع التصيير ضمن الخادم هو استمثال محركات البحث (SEO). فلطالما كانت محركات البحث سيئة في تمييز المحتوى الناتج عن تصيير شيفرة JavaScript. لكن على ما يبدو أنّ هذا الأمر في انحسار. يمكنك الاطلاع على ذلك بزيارة المقالين Will Google find your React content?‎ و SEO vs. React: Web Crawlers are Smarter Than You Think. وبالطبع لا يقتصر التصيير ضمن المخدم على شيفرة React أو JavaScript. فاستخدامنا لنفس لغة البرمجة ضمن منهاجنا من حيث المبدأ، هو لتبسيط تنفيذ المفاهيم، إذ أنّ اللغة نفسها ستعمل على الواجهتين الخلفية والأمامية. إلى جانب التصيير ضمن الخادم، تدور الأحاديث عن ما يسمى بالتطبيقات الإيزومورفية والشيفرة الموحدة، على الرغم من الجدل القائم حول تعريفهما. واستنادًا إلى بعض التعاريف فتطبيق الويب الإيزومورفي، هو التطبيق الذي يُصيّر ضمن كلتا الواجهتين الأمامية والخلفية. بينما تُعرّف الشيفرة الموحدة بأنها شيفرة قابلة للتنفيذ في معظم بيئات التشغيل، أي الواجهتين الأمامية والخلفية. تمنحنا React وNode خيارات مرغوبة في كتابة تطبيقات ايزومورفية بشيفرة موحدة. إنّ كتابة شيفرة موحدة باستخدام React عملية صعبة. لكن أثارت مؤخرًا المكتبة Next.js التي تًدرج أعلى تطبيق React، الكثير من الاهتمام، وتعتبر خيارًا جيدًا في كتابة تطبيقات موحّدة. تطبيقات الويب العصرية بدأ المطورون باستخدام مصطلح تطبيقات الويب العصرية (progressive web app - PWA) الذي أطلقه Google. ونتحدث باختصار عن تطبيقات الويب التي تعمل بأفضل شكل ممكن على كل المنصات مستفيدة من الميزات الأبرز لعناصر المنصة. ولا يجب أن يحد حجم شاشة الأجهزة النقالة من القدرة على استعمال هذه التطبيقات. كما يجب أن تعمل هذه التطبيقات بلا مشاكل دون اتصال أو مع الاتصالات البطيئة بالإنترنت. وينبغي أيضًا أن تُثبّت على الأجهزة النقالة كأي تطبيق آخر. وأخيرًا يجب أن تكون جميع البيانات المنقولة عبرها مشفّرة. تعتبر التطبيقات التي تُنشئها الأداة create-react-app عصرية افتراضيًا. لكن سيتطلب جعل التطبيق الذي يستخدم بيانات مصدرها الخادم عصريًا جهدًا. كما تنجز وظيفة العمل دون اتصال بالاستفادة من الواجهة البرمجية service workers. معمارية الخدمات الدقيقة لقد تعلمنا خلال منهاجنا بعض المواضيع السطحية فقط عن التعامل مع الخادم. فلقد اعتمدت تطبيقاتنا على واجهة خلفية متراصة، بمعنى أنها تتألف من تطبيق واحد يدير كل شيء، ويعمل على خادم واحد، ويؤمن واجهة برمجية تخدم عدة زبائن. لكن ستسبب الواجهة الخلفية المتراصة المشاكل عندما ينمو التطبيق على صعيدي الأداء والصيانة. تعتبر المعمارية الدقيقة (الخدمات الدقيقة-microservices) طريقة لكتابة الواجهة الخلفية لتطبيق على شكل عدة خدمات منفصلة ومستقلة تتواصل مع بعضها عبر شبكة الاتصال. وتكون الغاية من كل خدمة إدارة منطق وظيفة محددة بالكامل. ولا تستخدم الخدمات في معمارية الخدمات الدقيقة النقيّة أية قواعد بيانات مشتركة. فيمكن لتطبيق قائمة المدونات على سبيل المثال أن يتكون من خدمتين: تتعامل الأولى مع المستخدمين والأخرى مع المدونات. وستكون مسؤولية الوظيفة الأولى تسجيل دخول المستخدم والتحقق منه، بينما ستهتم الثانية بمنطق التعامل مع المدونات. يوضح الشكل التالي الاختلاف بين بنية تطبيق تعتمد على معمارية الخدمات الدقيقة وآخر يعتمد على بنية تقليدية متراصة: لا يختلف دور الواجهة الأمامية (محاطة بمربع في الشكل السابق) كثيرًا بين النموذجين. وقد يتواجد أحيانًا ما يسمى بوابة الواجهة البرمجية (API gateway) بين الخدمات الدقيقة والواجهة الأمامية، والتي تعطي انطباعًا بأنها بنية تقليدية، "حيث يوجد كل شيء على الخادم نفسه". تستخدم Netflix وغيرها مقاربة مماثلة لقد تطورت معمارية الخدمات المصغرة لتلبي احتياجات تطبيقات الإنترنت واسعة النطاق. ولقد اعتمدت Amazon هذه الفكرة قبل أن يظهر مصطلح الخدمات الدقيقة بمدة طويلة. وكانت نقطة البدء الحاسمة بريدًا إلكترونيًا أرسله "جيف بيزوس" المدير التنفيذي لشركة Amazon إلى جميع الموظفين عام 2020: تعتبر حاليًا Netflix من الشركات الرائدة في استخدام الخدمات الدقيقة. تأخذ هذه الخدمات وبشكل تدريجي ومستقر طابع الطلقة الرابحة، بمعنى أنها تُوصف كحل لكل المشاكل تقريبًا. لكن بالطبع هنالك العديد من المشاكل عند تطبيق معماريتها، ومن المنطقي أن نجرب البنية المتراصة أولًا ببناء واجهة خلفية تضم كل شيء كبداية. وربما لا. تضاربت الآراء حول الموضوع. ويستشهد كلا الطرفين بما ورد في موقع "مارتن فولر"،فحتى الخبراء لا يمكنهم الجزم أي الطريقتين هي الأصح. لسوء الحظ لا يمكننا الغوص عميقًا في هذه الفكرة المهمة، وحتى لو أردنا إلقاء نظرة سريعة عليها فقد تستغرق 5 أسابيع أخرى. الاستقلال عن الخوادم بدأت معالم مسار جديد في تطوير تطبيقات الويب بالتكوّن، بعد إصدار الخدمة السحابية lambda من قبل Amazon عام 2014. إنّ المعلم الرئيسي في lambda وفي الوظائف السحابية لشركة Google، وكذلك الوظائف السحابية على Azure أنها قادرة على تنفيذ وظائف فردية في السحابة. وقد كانت أصغر وحدة قابلة للتنفيذ على السحابة هي عملية مفردة، أي بيئة تشغيل تعتمد على Node كواجهة خلفية. يمكن على سبيل المثال تصميم تطبيقات مستقلة عن الخادم (serverless) باستخدام بوابة الواجهة البرمجية التي تقدمها Amazon. حيث تستجيب الوظائف السحابية على الطلب المرسل إلى الواجهة البرمجية التي تدير طلبات HTTP. حيث تعمل الوظائف عادة باستخدام البيانات المخزّنة ضمن قاعدة بيانات الخدمة السحابية. لا يعني الاستقلال عن الخوادم عدم وجود خادم في التطبيقات، لكن كيفية تعريف الخادم. فيمكن لمطوري البرمجيات نقل جهودهم البرمجية إلى مستوى أعلى من التجريد، عندما لا تكون هنالك حاجة لتحديد مسارات طلبات HTTP برمجيًا، أو تحديد العلاقات بين قواعد البيانات وغير ذلك، طالما أن البنية التحتية للسحابة ستؤمن كل ذلك. تستطيع الوظائف السحابية إنشاء ودعم الأنظمة واسعة النطاق. فيمكن للخدمة السحابية Lambda تنفيذ كمية هائلة من الوظائف السحابية في الثانية. ويحدث كل ذلك تلقائيًا ضمن البنية التحتية، ولا حاجة لتهيئة أية خوادم إضافية. مكتبات مفيدة وروابط مهمة أنتج مجتمع تطوير JavaScript عددًا ضخمًا من المكتبات المتنوعة المفيدة. فإن كنت بصدد تطوير أي شيء أكثر أهمية، تحقق من وجود حلول جاهزة متاحة، ويمكنك أن تجد الكثير من المكتبات في الموقع https://applibslist.xyz. وستجد في آخر الفقرة بعض المكتبات التي ينصح بها شركاء موثوقين. إن رأيت أن تطبيقك سيعالج بيانات معقدة، فالمكتبة lodash التي نصحنا بها في القسم 4، من المكتبات الجيدة. وإن كنت تفضل البرمجة باستخدام الدوال يمكنك أن تحاول مع المكتبة ramda. إن كنت تتعامل مع الوقت والتاريخ فالمكتبة date-fns أداة جيدة. تساعدك المكتبتان Formik وredux-form في معالجة النماذج بطريقة أسهل. وإن كنت ستستخدم الرسوميات في تطبيقك فهناك خيارات عدة. وننصحك باستخدام recharts وhighcharts. تؤمن المكتبة immutable.js التي طورتها Facebook، وكما يوحي اسمها، إدراج بعض بنى البيانات الثابتة. إذ يمكن استخدام المكتبة عند استخدام Redux، لأنه وكما أشرنا في القسم 6، أن دوال الاختزال يجب أن تكون دوال نقية، بمعنى أنها لن تعدِّل حالة مخزن Redux، بل ستستبداله بآخر جديد عندما تحدث أية تغييرات. لقد أثر ظهور المكتبة Immer على شعبية immutable.js في السنة الماضية. فهي تؤمن نفس الوظائف لكن ضمن حزمة أسهل استخدامًا نوعًا ما. تؤمن المكتبة Redux-saga طرقًا بديلة لإنشاء أفعال غير متزامنة للمكتبة redux thunk التي تعرفنا عليها في القسم 6. إن جمع البيانات التحليلية للتفاعل بين الصفحة في تطبيقات الصفحة الواحدة وبين المستخدمين سيحمل قدرًا من التحدي. ستقدم لك المكتبة React Google Analytics حلًا لتطبيقات الويب التي تُحمِّل الصفحة بأكملها. يمكنك أن تستفيد من خبرتك في React عند تطوير تطبيقات الهواتف النقالة باستخدام مكتبة Facebook المشهورة جدًا React Native. تتقلب الأحوال كثيرًا في مجتمع تطوير JavaScript فيما يتعلق بإدارة تجميع وحزم الملفات المشاريع. وتتغير معايير الممارسة الأفضل بشكل سريع: 2011 Bower 2012 Grunt 2013-14 Gulp 2012-14 Browserify 2015- Webpack فقد البرنامج Hipsters لأهميته بعد أن سيطر Webpack على السوق. ثم بدأ البرنامج Parcel قبل عدة سنوات بكسب عدة جولات لصالحه في السوق، كأداة أبسط وأسرع من Webpack (فالبرنامج Webpack ليس بسيطًا على الإطلاق). لكن بعد انطلاقته الواعدة، لم يستطع البرنامج المنافسة أكثر، ويبدو أن Webpack سيبقى في رأس القائمة. يزودك الموقع https://reactpatterns.com بقائمة تضم الممارسات الأفضل عند تطوير التطبيقات باستخدام React. وقد تعرفنا بالفعل على بعضها ضمن مادة المنهاج. وهنالك قائمة مشابهة لها هي react bits. سيساعدك Reactiflux وهو مجتمع محادثات لمطوري React على Discord، في الحصول على الدعم بعد إكمالك للمنهاج. ستجد على سبيل المثال قنوات مستقلة للتحدث عن عدد هائل من المكتبات. ترجمة -وبتصرف- للفصل Class components, Miscellaneous من سلسلة Deep Dive Into Modern Web Development
  7. لقد نال تطوير المواقع باستخدام React سمعة سيئة نظرًا لحاجته إلى أدوات كثيرة كان من الصعب تهيئتها بالشكل المناسب. أما في أيامنا هذه فقد أصبح استخدام React مريحًا بفضل البرنامج create-react-app. ويمكن القول أنه لم توجد قبله أية آليات مهمة لتطوير المواقع باستخدام JavaScript من جانب المتصفح. لكن لا يمكن الاعتماد إلى الأبد على الحلول السحرية التي يقدمها هذا البرنامج، وعلينا أن نحاول الاطلاع على ما يجري خلف الكواليس. إنّ أحد اللاعبين الأساسيين في جعل تطبيقات React جاهزة للعمل هو برنامج تجميع يدعى webpack. تجميع وحزم الملفات لقد طورنا تطبيقاتنا بتقسيمها إلى وحدات منفصلة، ثم أدرجنا هذه الوحدات في الأماكن التي تتطلبها. لكن ليس لدى المتصفح أية فكرة عن كيفية التعامل مع الشيفرة المجزأة ضمن وحدات، حتى ضمن وحدات ES6 التي عُرّفت في المعيار ECSMAScript. ولهذا السبب، يجب إعادة تجميع الوحدات لكي تتعامل معها المتصفحات، أي يجب تحويل جميع ملفات الشيفرة إلى ملف واحد يحتوي على شيفرة التطبيق بالكامل. فعندما نشرنا نسخة الإنتاج من تطبيق React للواجهة الأمامية في القسم 3، نفذنا عملية تجميع للتطبيق باستخدام الأمر npm run build. حيث جمّع سكربت npm الشيفرة المصدرية باستخدام المجمّع webpack تحت الستار، ونتج عنها مجموعة من الملفات ضمن المجلد "build". ├── asset-manifest.json ├── favicon.ico ├── index.html ├── manifest.json ├── precache-manifest.8082e70dbf004a0fe961fc1f317b2683.js ├── service-worker.js └── static ├── css │ ├── main.f9a47af2.chunk.css │ └── main.f9a47af2.chunk.css.map └── js ├── 1.578f4ea1.chunk.js ├── 1.578f4ea1.chunk.js.map ├── main.8209a8f2.chunk.js ├── main.8209a8f2.chunk.js.map ├── runtime~main.229c360f.js └── runtime~main.229c360f.js.map حيث يمثل الملف "index.html" الموجود في جذر المجلد "build" الملف الرئيسي للتطبيق، فهو الذي يحمّل ملفات JavaScript المجمّعة داخل المعرّف <script> (يوجد في الواقع ملفي JavaScript مجمّعين). <!doctype html><html lang="en"> <head> <meta charset="utf-8"/> <title>React App</title> <link href="/static/css/main.f9a47af2.chunk.css" rel="stylesheet"></head> <body> <div id="root"></div> <script src="/static/js/1.578f4ea1.chunk.js"></script> <script src="/static/js/main.8209a8f2.chunk.js"></script> </body> </html> فيمكن أن نرى من خلال المثال الذي أنشأناه باستخدام create-react-app، أنّ سكربت بناء التطبيق يجمّع أيضًا ملفات تنسيق CSS في ملف واحد باسم "static/css/main.f9a47af2.chunk.css/". تجري عملية التجميع في واقع الأمر لتعريف نقطة دخول إلى التطبيق، وهي عادة الملف "index.js". فعندما يجمّع webpack الشيفرة فإنه يضم كل الشيفرات التي ستدرجها نقطة الدخول والشيفرة التي ستدرج تلك المُدرجات، وهكذا. وطالما أنّ بعض الملفات التي تُدرج ستكون على شكل حزم مثل React وRedux وAxios، فسيضم ملف JavaScript المُجمّع محتوى كل مكتبة من تلك المكتبات. سننشئ تاليًا يدويًا ومن الصفر ملف تهيئة webpack يناسب تطبيق React. لننشئ أولًا مجلدًا جديدًا لمشروعنا يحوي على المجلدين الفرعيين "build" و"src" وداخلهما الملفات التالية: ├── build ├── package.json ├── src │ └── index.js └── webpack.config.js ستكون محتويات الملف "package.json" كالتالي: { "name": "webpack-part7", "version": "0.0.1", "description": "practising webpack", "scripts": {}, "license": "MIT" } لنثبت webpack كالتالي: npm install --save-dev webpack webpack-cli نحدد عمل webpack من خلال الملف "webpack.config.js" والذي نعطيه قيمًا أولية كالتالي: const path = require('path') const config = { entry: './src/index.js', output: { path: path.resolve(__dirname, 'build'), filename: 'main.js' } } module.exports = config سنعرف بعدها سكربت npm يُدعى "build" سينفذ عملية التجميع مع webpack: // ... "scripts": { "build": "webpack --mode=development" }, // ... سنضع بعض الشيفرة في الملف "src/index.js" const hello = name => { console.log(`hello ${name}`) } عندما ننفذ الأمر npm run build، سيجمّع webpack شيفرة تطبيقنا وسينتج عنه ملف جديد "main.js" ستجده ضمن المجلد "build": يحتوي الملف على العديد من النقاط المهمة. كما يمكننا أن نرى الشيفرة التي كتبناها سابقًا في آخر الملف: سنضيف الآن الملف "App.js" إلى المجلد src، وسيحتوي الشيفرة التالية: const App = () => { return null } export default App لندرج الوحدة App ضمن الملف "index.js": import App from './App'; const hello = name => { console.log(`hello ${name}`) } App() عندما نجمع التطبيق من جديد بالأمر npm run build، سنجد أن webpack قد ميّز ملفين: ستجد شيفرة التطبيق في نهاية ملف التجميع وبطريقة مبهمة نوعًا ما: /***/ "./src/App.js": /*!********************!*\ !*** ./src/App.js ***! \********************/ /*! exports provided: default */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; eval("__webpack_require__.r(__webpack_exports__);\nconst App = () => {\n return null\n}\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (App);\n\n//# sourceURL=webpack:///./src/App.js?"); /***/ }), /***/ "./src/index.js": /*!**********************!*\ !*** ./src/index.js ***! \**********************/ /*! no exports provided */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _App__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./App */ \"./src/App.js\");\n\n\nconst hello = name => {\n console.log(`hello ${name}`)\n};\n\nObject(_App__WEBPACK_IMPORTED_MODULE_0__[\"default\"])()\n\n//# sourceURL=webpack:///./src/index.js?"); /***/ }) ملف التهيئة لنلق نظرة على الملف "webpack.config.js": const path = require('path') const config = { entry: './src/index.js', output: { path: path.resolve(__dirname, 'build'), filename: 'main.js' } } module.exports = config كُتب ملف التهيئة باستخدام JavaScript، وصُدّر كائن التهيئة باستخدام عبارة وحدة Node. ستشرح عبارات التهيئة البسيطة التي كتبناها نفسها بنفسها. حيث تحدد الخاصية entry لكائن التهيئة الملف الذي سيستخدم كنقطة دخول إلى التطبيق المُجمّع. بينما تحدد الخاصية output المكان الذي ستُخزّن فيه الشيفرة المجمعة.يجب تعريف المسار الهدف على شكل مسار مطلق، ويسهل عمل ذلك باستخدام التابع path.resolve. كما سنستخدم المتغير العام ‎__dirname في Node والذي سيخزّن مسار الوصول إلى المجلد الحالي. تجميع تطبيق React لنحوّل تطبيقنا إلى تطبيق React بسيط. سنثبّت أولًا المكتبات الضرورية: npm install react react-dom ثم سنحول تطبيقنا إلى تطبيق React بإضافة التعريفات التالية إلى الملف "index.js": import React from 'react' import ReactDOM from 'react-dom' import App from './App' ReactDOM.render(<App />, document.getElementById('root')) كما سنجري التغييرات التالية على الملف "App.js": import React from 'react' const App = () => ( <div>hello webpack</div> ) export default App سنحتاج أيضًا إلى الملف "build/index.html" والذي سيشكل الصفحة الرئيسية للتطبيق والتي ستحمل بدورها شيفرة JavaScript المجمعة لتطبيقنا باستخدام المعرّف script: <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>React App</title> </head> <body> <div id="root"></div> <script type="text/javascript" src="./main.js"></script> </body> </html> ستواجهنا المشكلة التالية عند تجميع التطبيق: المُحمّلات تنص رسالة الخطأ السابقة الناتجة عن webpack، على أننا قد نحتاج إلى مُحمّل مناسب لتجميع الملف App.js بالشكل الصحيح. لا يعرف webpack افتراضيًا سوى تجميع شيفرة JavaScript الأساسية. وربما أغفلنا فكرة أننا نستخدم عمليًا JSX لتصيير واجهات العرض في React، ولتوضيح ذلك، سنعرض الشيفرة التالية والتي لا تمثل شيفرة JavaScript نظامية: const App = () => { return <div>hello webpack</div> } إنّ الشيفرة السابقة مكتوبة باستخدام JSX التي تؤمن طريقة بديلة لتعريف عناصر React ضمن المعرف <div> للغة HTML. يمكن استخدام المُحمّلات لإبلاغ webpack عن الملفات التي ينبغي معالجتها قبل تجميعها. لنهيئ مُحملًا لتطبيقنا، يحوّل شيفرة JSX إلى شيفرة JavaScript نظامية: const config = { entry: './src/index.js', output: { path: path.resolve(__dirname, 'build'), filename: 'main.js', }, module: { rules: [ { test: /\.js$/, loader: 'babel-loader', options: { presets: ['@babel/preset-react'], }, }, ], },} عُرّف المُحمّل ضمن الخاصية module في المصفوفة rules. يتألف تعريف المُحمّل من ثلاثة أقسام: { test: /\.js$/, loader: 'babel-loader', options: { presets: ['@babel/preset-react'] } } تحدد الخاصية test بأن المُحمِّل مخصص للملفات التي تنتهي باللاحقة "js"، بينما تحدد الخاصية loader أن معالجة هذه الملفات ستجري باستخدام babel-loader، أما الخاصية options فتستخدم لتحديد معاملات تهيئ وظيفة المحمّل. لنثبت المُحمِّل والحزم التي يحتاجها كاعتمادية تطوير: npm install @babel/core babel-loader @babel/preset-react --save-dev سينجح الآن تجميع التطبيق. لو عدّلنا قليلًا في شيفرة المكوّن APP، ثم ألقينا نظرة على الشيفرة المجمّعة، سنلاحظ أنّ النسخة المجمّعة من المكوّن ستكون على النحو التالي: const App = () => react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement( 'div', null, 'hello webpack' ) يمكن أن نلاحظ من خلال هذا المثال، أنّ عناصر React قد كُتبت بعد التجميع بشيفرة JavaScript نظامية بدلًا من عبارات JSX، وذلك باستخدام الدالة createElement التي تقدمها React. يمكننا اختبار التطبيق المجمّع بتشغيل الملف "build/index.html" من خلال المتصفح: تجدر الإشارة إلى أن استخدام عبارة awit/async ضمن شيفرة التطبيق، سيمنع بعض المتصفحات من إظهار أي شيء. يقودنا البحث عن رسالة الخطأ الظاهرة على الطرفية إلى المشكلة. علينا تثبيت اعتمادية مفقودة أو أكثر. في حالتنا نجد أن الاعتمادية ‎@babel/polyfill هي العنصر المفقود: npm install @babel/polyfill لنجري التعديلات التالية على الخاصية entry لكائن تهيئة webpack، وذلك ضمن الملف "webpack.config.js": entry: ['@babel/polyfill', './src/index.js'] يحتوي ملف التهيئة الآن كل ما يلزم لتطوير تطبيقات React. نواقل الشيفرة تُعرّف عملة نقل الشيفرة (Transpilling) بأنها عملية تحويل شيفرة JavaScript من شكل إلى آخر. ويشير المصطلح بشكل عام، إلى عملية ترجمة الشيفرة المصدرية بتحويلها من لغة إلى أخرى. عند استخدام تعليمات التهيئة التي شرحناها سابقًا، فإننا نقلنا عمليًا شيفرة JSX إلى شيفرة JavaScript نظامية بمساعدة babel والذي يمثل حاليًا الأداة الأكثر شعبية لهذا الغرض. وكما ذكرنا في القسم 1، أنّ معظم المتصفحات لا تدعم آخر الميزات التي قدمتها ES6 وES7، ولهذا تُنقل الشيفرة عادةً إلى نسخة JavaScript التي تتوافق مع معايير ES5. تُعرّف عملية النقل التي ينفذها Babel كإضافة. إذ يستخدم المطورين في الواقع مُهيَّئات جاهزة (presets) وهي مجموعة من الإضافات المهيئة مسبقًا. سنستخدم حاليًا المُهيَّئة ‎@babel/preset-react لنقل الشيفرة المصدرية لتطبيقنا: { test: /\.js$/, loader: 'babel-loader', options: { presets: ['@babel/preset-react'] } } سنضيف المهيَّئة ‎@babel/preset-env التي تحتوي على كل شيئ قد نحتاجه في نقل الشيفرة التي تستخدم آخر ميزات اللغة إلى معيار ES5. { test: /\.js$/, loader: 'babel-loader', options: { presets: ['@babel/preset-env', '@babel/preset-react'] } } لنثبت المهيِّئة كالتالي: npm install @babel/preset-env --save-dev ستتحول الشيفرة بعد نقلها إلى أسلوب JavaScript القديم. فسيصبح تعريف المكوّن App كالتالي: var App = function App() { return _react2.default.createElement('div', null, 'hello webpack') }; لاحظ أن المتغيرات قد عرفت باستخدام التعليمة var لأن المعيار ES5 لا يفهم التعليمة const. وكذلك لم تُستعمل الدوال السهمية، بل استخدمت التعليمة function. التنسيق باستخدام CSS لنضف بعض تنسيقات CSS إلى التطبيق، وذلك بإنشاء الملف "src/index.css": .container { margin: 10; background-color: #dee8e4; } لننسق المكوّن App: const App = () => { return ( <div className="container"> hello webpack </div> ) } ثم سندرج ملف التنسيق ضمن الملف "index.js": import './index.css' سيسبب ذلك انهيار عملية نقل الشيفرة: إذ علينا عند استخدام CSS أن نستخدم المُحمّلين css وstyle: { rules: [ { test: /\.js$/, loader: 'babel-loader', options: { presets: ['@babel/preset-react', '@babel/preset-env'], }, }, { test: /\.css$/, use: ['style-loader', 'css-loader'], }, ]; } تقتقضي مهمة المحمّل css loader تحميل ملف CSS بينما مهمة style loader هي توليد وإضافة عنصر التنسيق الذي يحتوي على كل التنسيقات التي يستخدمها التطبيق. وهكذا ستُعرّف تنسيقات CSS ضمن الملف الرئيسي للتطبيق "index.html". وبالتالي لا حاجة لإدراج التنسيقات ضمنه. ويمكن عند الحاجة توليد تنسيقات CSS ثم وضعها في ملف منفصل باستخدام الإضافة mini-css-extract-plugin. عند تثبيت المحمَّلين كالتالي: npm install style-loader css-loader --save-dev ستنجح عملية التجميع وسيحمل التطبيق تنسيقًا جديدًا. المكتبة Webpack-dev-server يمكن تطوير التطبيقات باستخدام طرائق التهيئة السابقة لكنها في الواقع عملية مرهقة (مقارنة بسير نفس العمليات في Java). فعلينا في كل مرة نجري فيها تعديلًا، أن نجمّّّع التطبيق من جديد ونحدّث المتصفح لنختبر ما فعلناه. تقدم المكتبة webpack-dev-server حلًا لمشكلتنا. سنثبتها الآن: npm install --save-dev webpack-dev-server لنعرف سكربت npm لتشغيل خادم التطوير (dev-server): { // ... "scripts": { "build": "webpack --mode=development", "start": "webpack serve --mode=development" }, //... } لنضف أيضًا الخاصية devServer إلى كائن التهيئة في الملف "webpack.config.js": const config = { entry: './src/index.js', output: { path: path.resolve(__dirname, 'build'), filename: 'main.js', }, devServer: { contentBase: path.resolve(__dirname, 'build'), compress: true, port: 3000, }, // ... }; سيشغل الأمر خادم التطوير على المنفذ 3000، وبالتالي يمكننا الوصول إلى تطبيقنا على العنوان http://localhost:3000 من خلال المتصفح. وعندما نغيّر في الشيفرة سيُحدّث المتصفح الصفحة تلقائيًا. تتم عملية تحديث الشيفرة بسرعة. فعند استخدام خادم التطوير، لن تجمع الشيفرة بالطريقة المعتادة ضمن الملف "main.js"، بل ستبقى فقط في الذاكرة. لنوسع الشيفرة بتغيير تعريف المكوّن App كالتالي: import React, {useState} from 'react' const App = () => { const [counter, setCounter] = useState(0) return ( <div className="container"> hello webpack {counter} clicks <button onClick={() => setCounter(counter + 1)}> press </button> </div> ) } export default App تجدر الملاحظة أنّ رسائل الخطأ لن تظهر بنفس الطريقة التي ظهرت بها عند استخدام create-react-app في تطوير التطبيق. لهذا السبب يجب أن ننتبه أكثر لما تعرضه الطرفية: سيعمل التطبيق الآن بشكل جيد وستجري العمليات بسلاسة. الدلالات المصدرية لننقل معالج حدث النقر إلى دالة خاصة به، ولنخزّن القيمة السابقة للعداد في حالة مخصصة له تدعى values: const App = () => { const [counter, setCounter] = useState(0) const [values, setValues] = useState() const handleClick = () => { setCounter(counter + 1) setValues(values.concat(counter)) } return ( <div className="container"> hello webpack {counter} clicks <button onClick={handleClick}> press </button> </div> ) } لن يعمل التطبيق الآن، وستعرض الطرفية رسالة الخطأ التالية: نعلم أن الخطأ موجود في التابع onClick، لكن في حال كان التطبيق أضخم، سنجد صعوبة بالغة في تقفي أثر الخطأ: App.js:27 Uncaught TypeError: Cannot read property 'concat' of undefined at handleClick (App.js:27) إن مكان الخطأ الذي تحدده الرسالة لا يتطابق مع الموقع الفعلي للخطأ ضمن شيفرتنا المصدرية. فلو نقرنا على رسالة الخطأ، فلن تجد الشيفرة المعروضة متطابقة مع شيفرة التطبيق: نحتاج بالطبع إلى رؤية شيفرتنا المصدرية الأساسية عند النقر على رسالة الخطأ. ولحسن الحظ فإصلاح رسالة الخطأ مع ذلك عملية سهلة. سنطلب من webpack أن يولد ما يسمى دلالة مصدرية (source map) للمُجمّع، بحيث يغدو ممكنًا الدلالة على الخطأ الذي يحدث عند التجميع ضمن الشيفرة المصدرية الأساسية. يمكن توليد الدلالة المصدرية بإضافة الخاصية devtool إلى كائن التهيئة وإسناد القيمة source-map لها: const config = { entry: './src/index.js', output: { // ... }, devServer: { // ... }, devtool: 'source-map', // .. }; يجب إعادة تشغيل webpack عند حدوث أية تغيرات في تهيئته. كما يمكننا أيضًا تهيئة البرنامج ليراقب بنفسه التغييرات التي تطرأ عليه، لكن لن نفعل ذلك حاليًا. ستصبح رسالة الخطأ الآن أوضح بكثير: طالما أنها تشير إلى الشيفرة التي كتبناها: يجعل توليد الدلالة المصدرية من استخدام منقح Chrome ممكنًا: سنصلح الثغرة الآن بتهيئة القيمة الأولية للحالة values لتكون مصفوفة فارغة: const App = () => { const [counter, setCounter] = useState(0) const [values, setValues] = useState([]) // ... } تصغير الشيفرة عندما سننشر التطبيق في مرحلة الإنتاج، سنستخدم الملف المُجمّع "main.js" الذي ولده webpack. إنّ حجم هذا الملف حوالي 974473 بايت على الرغم من أنّ شيفرة تطبيقنا لا تتجاوز عدة أسطر. إنّ هذا الحجم الكبير للملف يعود في الواقع إلى أنّ الملف المُجمَّع سيحتوي الشيفرة المرجعية لكامل مكتبة React. سيؤثر حجم الملف المجمّع لأن المتصفح سيحمّل الملف كاملًا عند تشغيل التطبيق للمرة الأولى. لن تكون هناك مشكلة بالحجم السابق إن كانت سرعة الاتصال بالإنترنت عالية، لكن الاستمرار في إضافة الاعتماديات سيخلق مشكلة في سرعة التحميل وخاصة لدى مستخدمي الهواتف النقالة. لو تفحصنا محتويات ملف التجميع، سنجد أنه بالإمكان استمثاله بشكل أفضل بكثير بما يخص الحجم، وذلك بإزالة كل التعليقات في الشيفرة. ولا جدوى طبعًا من الاستمثال اليدوي للملف إن كانت هناك أدوات جاهزة لتنفيذ العمل. تدعى عملية استمثال ملفات JavaScript بالتصغير (minification). وتعتبر الأداة UglifyJS من أفضل الأدوات المعدة لهذا الغرض. لم تعد إضافات التصغير في webpack ابتداء من الإصدار 4 بحاجة إلى تهيئة إضافية. عليك فقط تعديل سكربت npm في الملف "package.json" لتحدد أنّ webpack سينفذ عملية التجميع في وضع الإنتاج: { "name": "webpack-part7", "version": "0.0.1", "description": "practising webpack", "scripts": { "build": "webpack --mode=production", "start": "webpack serve --mode=development" }, "license": "MIT", "dependencies": { // ... }, "devDependencies": { // ... } } عندما نجمّع التطبيق ثانيةً، سيقل حجم الملف "main.js" بشكل واضح. $ ls -l build/main.js -rw-r--r-- 1 mluukkai 984178727 132299 Feb 16 11:33 build/main.js يشابه خرج عملية التصغير شيفرة C المكتوبة بالأسلوب القديم. حيث حذفت جميع التعليقات والمساحات البيضاء غير الضرورية ومحارف السطر الجديد، كما تم استبدال أسماء المتغيرات بمحرف واحد. function h(){if(!d){var e=u(p);d=!0;for(var t=c.length;t;){for(s=c,c=[];++f<t;)s&&s[f].run();f=-1,t=c.length}s=null,d=!1,function(e){if(o===clearTimeout)return clearTimeout(e);if((o===l||!o)&&clearTimeout)return o=clearTimeout,clearTimeout(e);try{o(e)}catch(t){try{return o.call(null,e)}catch(t){return o.call(this,e)}}}(e)}}a.nextTick=function(e){var t=new Array(arguments.length-1);if(arguments.length>1) تهيئة نسختي التطوير الإنتاج سنضيف تاليًا واجهة خلفية إلى التطبيق بتغيير غرض الواجهة الخلفية لتطبيق الملاحظات الذي أضحى مألوفًا لدينا. سنخزّن المحتوى التالي ضمن الملف "db.json": { "notes": [ { "important": true, "content": "HTML is easy", "id": "5a3b8481bb01f9cb00ccb4a9" }, { "important": false, "content": "Mongo can save js objects", "id": "5a3b920a61e8c8d3f484bdd0" } ] } هدفنا هنا هو تهيئة webpack بحيث يستخدم التطبيق خادم JSON على المنفذ 3001 كواجهة خلفية، إن تم استعمال webpack محليًا. سيهيّأ الملف المجمّع عندها لاستخدام الواجهة الخلفية الموجودة على العنوان https://blooming-atoll-75500.herokuapp.com/api/notes. سنثبت المكتبة axios ونشغل خادم JSON ومن ثم سنجري التعديلات اللازمة على التطبيق. تتطلب عملية التغيير إحضار الملاحظات من الواجهة الخلفية باستخدام خطاف مخصص يدعى useNotes: import React, { useState, useEffect } from 'react' import axios from 'axios' const useNotes = (url) => { const [notes, setNotes] = useState([]) useEffect(() => { axios.get(url).then(response => { setNotes(response.data) }) },[url]) return notes} const App = () => { const [counter, setCounter] = useState(0) const [values, setValues] = useState([]) const url = 'https://blooming-atoll-75500.herokuapp.com/api/notes' const notes = useNotes(url) const handleClick = () => { setCounter(counter + 1) setValues(values.concat(counter)) } return ( <div className="container"> hello webpack {counter} clicks <button onClick={handleClick} >press</button> <div>{notes.length} notes on server {url}</div> </div> ) } export default App استخدمنا عنوانًا مكتوبًا لخادم الواجهة الخلفية في التطبيق. لكن كيف يمكننا تغيير العنوان بحيث نصبح قادرين على تغييره لكي يدل على خادم الواجهة الخلفية عندما تُجمّع الشيفرة في مرحلة الإنتاج؟ سنغيّر كائن التهيئة في الملف "webpack.config.js" ليصبح دالة بدلًا من كونه تابعًا: const path = require('path'); const config = (env, argv) => { return { entry: './src/index.js', output: { // ... }, devServer: { // ... }, devtool: 'source-map', module: { // ... }, plugins: [ // ... ], } } module.exports = config سيبقى التعريف نفسه تقريبًا ماعدا أن كائن التهيئة سيعاد من قبل الدالة. تتلقى الدالة معاملين هما env وargv. يمكن استخدام المعامل الثاني للوصول إلى الخاصية mode المعرفة في سكربت npm. يمكن استخدام الإضافة DefinePlugin في برنامج webpack لتعريف متغيرات عامة افتراضية يمكن استخدامها ضمن الشيفرة المجمّعة. لنعرف إذًا المتغير العام BACKEND_URL الذي يأخذ قيمًا مختلفة بناء على البيئة التي تجري فيها عملية تجميع الشيفرة: const path = require('path') const webpack = require('webpack') const config = (env, argv) => { console.log('argv', argv.mode) const backend_url = argv.mode === 'production' ? 'https://blooming-atoll-75500.herokuapp.com/api/notes' : 'http://localhost:3001/api/notes' return { entry: './src/index.js', output: { path: path.resolve(__dirname, 'build'), filename: 'main.js' }, devServer: { contentBase: path.resolve(__dirname, 'build'), compress: true, port: 3000, }, devtool: 'source-map', module: { // ... }, plugins: [ new webpack.DefinePlugin({ BACKEND_URL: JSON.stringify(backend_url) }) ] } } module.exports = config يستخدم المتغير العام ضمن الشيفرة بالطريقة التالية: const App = () => { const [counter, setCounter] = useState(0) const [values, setValues] = useState([]) const notes = useNotes(BACKEND_URL) // ... return ( <div className="container"> hello webpack {counter} clicks <button onClick={handleClick} >press</button> <div>{notes.length} notes on server {BACKEND_URL}</div> </div> ) } إن كانت هناك اختلافات واسعة في تهيئة نسختي الإنتاج والتطوير. فمن الأفضل فصل تهيئة كل نسخة ضمن ملف خاص بها. يمكن تحري نسخة الإنتاج المجمّمعة من التطبيق محليًا وذلك بتنفيذ الأمر التالي: npx static-server ستكون هذه النسخة متاحة بشكل افتراضي على العنوان http://localhost:9080. استخدام شيفرة Polyfill انتهى تطبيقنا الآن وأصبح جاهزًا للعمل مع مختلف المتصفحات الحديثة الموجودة حاليًا ماعدا Internet Explorer. وذلك لأن الوعود التي تستخدمها axios غير مدعومة من قبل IE. هنالك العديد من الأمور التي لا يدعمها IE. وبعضها مؤذٍ كالتابع find الذي يستخدم للتعامل مع مصفوفات إذ يفوق قدرة هذا المتصفح. فلا يكفي في هذه الحالة نقل الشيفرة من نسخة JavaScript حديثة إلى قديمة مدعومة على نطاق أوسع من قبل المتصفحات. يفهم المتصفح IE الوعود قواعديًا، لكنه ببساطة لا يمتلك البنية الوظيفية لتنفيذها. فالخاصية find العائدة للمصفوفات غير معرفة على سبيل المثال. فإن أردنا أن يكون تطبيقنا متوافقًا مع IE، سنحتاج إلى شيفرة polyfill، وهي شيفرة تضيف الوظائف غير المدعومة إلى المتصفحات القديمة. يمكن إضافة تلك الشيفرات بمساعدة webpack and Babel أو بتثبيت إحدى مكتباتها. فشيفرة polyfill التي تقدمها المكتبة promise-polyfill سهلة الاستخدام، وليس علينا سوى إضافة ما يلي إلى شيفرتنا: import PromisePolyfill from 'promise-polyfill' if (!window.Promise) { window.Promise = PromisePolyfill } فإن لم يكن الكائن Promise موجودًا، أي انه غير مدعوم من قبل المتصفح، سيُخزّن وعد polyfill في المتغير العام. فإن أضيف وعد polyfill إلى الشيفرة وعمل بالشكل المطلوب، فستعمل بقية الشيفرة بلا أية مشاكل. يمكنك إيجاد قائمة بمكتبات polyfill بزيارة الموقع github.com/Modernizr/Modernizr/wiki/HTML5-Cross-browser-Polyfills، كما يمكنك الاطلاع على توافق المتصفحات مع الواجهات البرمجية المختلفة من خلال زيارة الموقع https://caniuse.com، أو من خلال زيارة موقع Mozilla. إلغاء التهيئة الافتراضية (تحرير المشروع) تستخدم الأداة create-react-app برنامج webpack خلف الستار. فإن لم تجد أنّ التهيئة الافتراضية كافية، يمكنك إلغاءها بعملية تسمى تحرير المشروع (eject)، والتي يتم التخلص فيها من العمليات خلف الكواليس، كما تُخزّن التهيئة الافتراضية في المجلد config وفي ملف package.json معدّل. ولن يكون هناك سبيل إلى العودة عند تحرير المشروع، وعلينا تعديل أو صيانة قيم التهيئة يدويًا بعد ذلك. مع ذلك فالتهيئة الافتراضية ليست بهذه البساطة، وبدلًا من تحرير تطبيقك، يمكنك كتابة تهيئة webpack الخاصة بك من الصفر. كما ننصحك بقراءة ملفات التهيئة للمشاريع المحررة بعناية، فهي ذات قيمة تعليمية كبيرة. ترجمة -وبتصرف- للفصل webpack من سلسلة Deep Dive Into Modern Web Development
  8. تعرفنا في القسم 2 على طريقتين لإضافة التنسيقات إلى التطبيق، الطريقة القديمة باستخدام ملف CSS واحد وطريقة التنسيق المباشر في المكان. مكتبات جاهزة للاستخدام مع واجهة المستخدم UI يمكن استخدام مكتبات جاهزة في إضافة التنسيقات إلى التطبيق. وتعتبر مكتبة الأدوات Bootstrap التي طورتها Twitter، من أكثر إطارات العمل مع واجهة المستخدم شعبية، والتي ستبقى كذلك ربما. ستجد حاليًا عددًا هائلًا من المكتبات التي تنسق واجهة المستخدم، ولديك خيارات واسعة جدًا لا يمكن حصرها في قائمة. تقدم العديد من إطارات عمل UI لمطوري تطبيقات الويب سمات جاهزة و"مكوّنات" كالأزرار والقوائم والجداول. ولقد وضعنا كلمة مكوّنات بين معترضتين لأننا لا نتكلم هنا عن مكوّنات React. تُستخدم إطارات عمل UI عادةً بإدراج ملفات CSS بالإضافة إلى ملفات JavaScript الخاصة بها ضمن التطبيق. تأتي العديد من إطارت عمل UI بنسخ مخصصة للاستخدام مع React، حيث حُوّلت العديد من مكوناتها إلى مكوّنات React. فهنالك نسخ مختلفة من Bootstap مخصصة للعمل مع React مثل reactstrap وreact-bootstrap. سنطلع تاليًا على إطاري العمل Bootstrap وMaterialUI. وسنستخدمهما لإضافة نفس التنسيقات إلى التطبيق الذي أنشأناه في فصل (مكتبة React-Router) من هذا القسم. استخدام إطار العمل React-Bootstrap لنلقي نظرة على Bootstrap مستخدمين الحزمة react-bootstrap. لنثبت هذه الحزمة إذًا: npm install react-bootstrap ثم سنضيف رابطًا لتحميل ملف تنسيق CSS الخاص بالإطار Bootstarp ضمن المعرّف <head> في الملف "public/index.html" <head> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous" /> // ... </head> سنلاحظ أنّ مظهر التطبيق قد أصبح أفضل عند إعادة تحميله: تصيّر جميع محتويات التطبيق عند استخدام Bootstrap داخل حاوية. ويتم ذلك عمليًا بإعطاء الصفة classname للعنصر <div> الجذري القيمة "container". const App = () => { // ... return ( <div className="container"> // ... </div> ) } وسنجد تأثير ذلك واضحًا على مظهر التطبيق، فلم يعد المحتوى ملاصقًا لحافة المتصفح كما كانت من قبل: لنجري بعض التعديلات على المكوّن Notes، بحيث يصيّر قائمة الملاحظات على شكل جدول. حيث تؤمن React-Bootstrap مكوّن الجدول جاهزًا للاستخدام، فلا حاجة لتعريف أية أصناف CSS مفصلة. const Notes = (props) => ( <div> <h2>Notes</h2> <Table striped> <tbody> {props.notes.map(note => <tr key={note.id}> <td> <Link to={`/notes/${note.id}`}> {note.content} </Link> </td> <td> {note.user} </td> </tr> )} </tbody> </Table> </div> ) لقد أصبح تنسيق التطبيق أفضل: لاحظ أنّه عليك إدراج مكونات React-Bootstrap بشكل منفصل من المكتبة كالتالي: import { Table } from 'react-bootstrap' النماذج لنحسّن نموذج تسجيل الدخول بمساعدة نماذج Bootstrap. تأتي المكتبة React-Bootstrap بمكوّنات مدمجة لإنشاء النماذج (على الرغم من النقص الواضح في توثيق هذا الموضوع): let Login = (props) => { // ... return ( <div> <h2>login</h2> <Form onSubmit={onSubmit}> <Form.Group> <Form.Label>username:</Form.Label> <Form.Control type="text" name="username" /> <Form.Label>password:</Form.Label> <Form.Control type="password" /> <Button variant="primary" type="submit"> login </Button> </Form.Group> </Form> </div> )} سيزداد عدد المكونات التي سندرجها شيئًا فشيئًا: import { Table, Form, Button } from 'react-bootstrap' سيبدو التطبيق بعد التحوّل إلى نموذج Bootstrap كالتالي: التنبيهات سنحسّن التنبيهات أيضًا في تطبيقنا بعد أن حسنّا مظهر النماذج.: لنضف رسالة إلى التنبيهات عندما يسجل المستخدم دخوله إلى التطبيق، وسنخزّنها ضمن المتغير message في حالة المكوّن App: const App = () => { const [notes, setNotes] = useState([ // ... ]) const [user, setUser] = useState(null) const [message, setMessage] = useState(null) const login = (user) => { setUser(user) setMessage(`welcome ${user}`) setTimeout(() => { setMessage(null) }, 10000) } // ... } سنصيّر الرسالة على أنها مكون تنبيه Bootstrap. ولاحظ مجددًا أنّ المكتبة React-Bootstrap ستزودنا بالمكون المقابل لمكون Recat. أدوات التنقل لنغيّر أخيرًا قائمة التنقل في التطبيق مستخدمين المكوّن Navbar من Bootstrap. تزوّدنا المكتبة Bootstrap بمكونات مدمجة مقابلة. لقد وجدنا من خلال المحاولة والخطأ حلًا مرضيًا، على الرغم من التوثيق غير الواضح: <Navbar collapseOnSelect expand="lg" bg="dark" variant="dark"> <Navbar.Toggle aria-controls="responsive-navbar-nav" /> <Navbar.Collapse id="responsive-navbar-nav"> <Nav className="mr-auto"> <Nav.Link href="#" as="span"> <Link style={padding} to="/">home</Link> </Nav.Link> <Nav.Link href="#" as="span"> <Link style={padding} to="/notes">notes</Link> </Nav.Link> <Nav.Link href="#" as="span"> <Link style={padding} to="/users">users</Link> </Nav.Link> <Nav.Link href="#" as="span"> {user ? <em>{user} logged in</em> : <Link to="/login">login</Link> } </Nav.Link> </Nav> </Navbar.Collapse> </Navbar> لشريط التنقل الناتج مظهرًا واضحًا ومريحًا: إن تغيّر حجم شاشة العرض على المتصفح، سنجد أن القائمة ستختفي تحت زر "الهامبرغر" وستظهر مجددًا بالنقر عليه: تقدم Bootstrap والعديد من أطر عمل UI تصاميم متجاوبة بحيث تُصيّر التطبيقات بشكل يناسب القياسات المختلفة لشاشات العرض. ستساعدك أدوات تطوير Chrome على محاكاة استخدام التطبيق ضمن متصفحات أنواع مختلفة من الهواتف النقالة: يمكنك أن تجد الشيفرة كاملة على GitHub. إطار العمل Material UI سنلقي نظرة الآن على المكتبة MaterialUI التي تعمل مع React، والتي تستخدم اللغة المرئية لتصميم المواد والمطوّرة من قبل Google. لنثبت هذه المكتبة: npm install @material-ui/core ثم سنضيف السطر التالي إلى المعرّف في الملف "public/index.html"، حيث يحمًل هذا السطر الخط "Roboto" من تصميم Google: <head> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" /> // ... </head> سنستخدم الآن MaterialUI لتنفيذ نفس التغييرات التي أجريناها سابقًا باستخدام Bootstrap. سنصيّر التطبيق كاملًا ضمن حاوية: import Container from '@material-ui/core/Container' const App = () => { // ... return ( <Container> // ... </Container> ) } لنبدأ بالمكوّن Note ونصيّر قائمة الملاحظات ضمن جدول. const Notes = ({notes}) => ( <div> <h2>Notes</h2> <TableContainer component={Paper}> <Table> <TableBody> {notes.map(note => ( <TableRow key={note.id}> <TableCell> <Link to={`/notes/${note.id}`}>{note.content}</Link> </TableCell> <TableCell> {note.name} </TableCell> </TableRow> ))} </TableBody> </Table> </TableContainer> </div> ) ستبدو النتيجة كالتالي: إنّ إدراج كل مكون بشكل منفصل، هي إحدى المزايا غير المرضية في MaterialUI. إذ تكون قائمة المكوّنات التي سندرجها طويلة: import { Container, Table, TableBody, TableCell, TableContainer, TableRow, Paper, } from '@material-ui/core' النماذج سنحسّن مظهر نموذج تسجيل الدخول باستخدام المكوّنين TextField وButton const Login = (props) => { const history = useHistory() const onSubmit = (event) => { event.preventDefault() props.onLogin('mluukkai') history.push('/') } return ( <div> <h2>login</h2> <form onSubmit={onSubmit}> <div> <TextField label="username" /> </div> <div> <TextField label="password" type='password' /> </div> <div> <Button variant="contained" color="primary" type="submit"> login </Button> </div> </form> </div> ) } وستكون النتيجة كالتالي: لا تؤمن MaterialUi مكون خاص بالنماذج كما في Bootstrap. فالنموذج هنا هو نموذج HTML نظامي. وتذكر أن تدرج كل المكوّنات الموجودة في النموذج. التنبيهات يمكن عرض التنبيهات باستخدام المكون Alert والذي يشابه تمامًا المكون المقابل في Bootstarp: <div> {(message && <Alert severity="success"> {message} </Alert> )} </div> لا تتضمن الحزمة الأساسية للإطار MaterialUI المكوّن Alert بعد، لذلك لا بد من تثبيت الحزمة lab لاستخدامه: npm install @material-ui/lab وبعدها يمكننا إدراج المكوّن كالتالي: import { Alert } from '@material-ui/lab' سيبدو التنبيه أنيق المظهر: أدوات التنقل يمكننا إدراج أدوات التنقل باستخدام المكوّن AppBar . فلو استعملنا المثال الوارد في التوثيق: <AppBar position="static"> <Toolbar> <IconButton edge="start" color="inherit" aria-label="menu"> </IconButton> <Button color="inherit"> <Link to="/">home</Link> </Button> <Button color="inherit"> <Link to="/notes">notes</Link> </Button> <Button color="inherit"> <Link to="/users">users</Link> </Button> <Button color="inherit"> {user ? <em>{user} logged in</em> : <Link to="/login">login</Link> } </Button> </Toolbar> </AppBar> سيفي ذلك بالغرض، لكن يمكن أن نحسن شريط التنقل أكثر: حيث ستجد طرقًا أفضل ضمن التوثيق. إذ يمكن استخدام خصائص المكوّن لتحديد الطريقة التي يُصير بها العنصر الجذري لمكوّن MaterialUI، وذلك كما يلي: <Button color="inherit" component={Link} to="/"> home </Button> سيُصيّر المكون Botton بجيث يكون مكوّنه الأب (الجذري) هو المكوّن Link من المكتبة react-router-dom ويستقبل مساره من خلال الخاصية to. ستكون شيفرة أدوات التنقل كالتالي: <AppBar position="static"> <Toolbar> <Button color="inherit" component={Link} to="/"> home </Button> <Button color="inherit" component={Link} to="/notes"> notes </Button> <Button color="inherit" component={Link} to="/users"> users </Button> {user ? <em>{user} logged in</em> : <Button color="inherit" component={Link} to="/login"> login </Button> } </Toolbar> </AppBar> وستظهر النتيجة كالتالي: ستجد الشيفرة كاملة على GitHub. أفكار ختامية ليس هناك خلاف واسع بين react-bootstrap وMaterialUI. وسيكون الخيار لك. ويشير معد هذا المنهاج إلى أنه استخدم MaterialUi كثيرًا وكانت انطباعاته الأولى إيجابية. فتوثيق المكتبة أفضل قليلًا. وبناء على إحصاءات موقع https://www.npmtrends.com/ الذي يتابع شعبية مكتبات npm المختلفة فقد تجاوزت شعبية MaterialUI مكتبةReact-Bootstrap عام 2020: لقد استخدمنا في الأمثلة السابقة إطارات عمل UI بالاستعانة بمكتبات React-integration بدلًا من المكتبة React Bootstrap. وقد كان بمقدورنا استخدامها مباشرةً بتعريف أصناف CSS ضمن عناصر HTML في التطبيق، بدلًا من تعريف الجدول كمكوّن آخر هو Table على سبيل المثال: <Table striped> // ... </Table> حيث يمكننا استخدام جدول HTML اعتيادي وتزويده بصنف CSS المطلوب: <table className="table striped"> // ... </table> لن تتوضح فكرة استخدام React-Bootstrap من خلال هذا المثال. فبالإضافة إلى جعل شيفرة الواجهة الأمامية أكثر اختصارًا وأكثر قابلية للقراءة، فالفائدة الأخرى لاستخدام مكتبات React UI هي أنها تتضمن شيفرة JavaScript اللازمة لعمل بعض المكونات الخاصة. إذ تتطلب بعض مكونات React اعتماديات JavaScript والتي نفضل عدم إدراجها في تطبيقات React. إنّ بعض سلبيات استخدام إطارات عمل UI من خلال مكتبات التكامل بدلًا من استخدامها مباشرةً، هي احتمال وجود واجهات برمجية غير مستقرة ضمن تلك المكتبات أو أنها قد تعاني نقصًا في توثيقها. إلّا أنّ حالة Semantic UI React مثلًا أفضل بكثير من غيرها من إطارت عمل UI، كونها مكتبة تكامل رسمية من مكتبات React. وهنالك أيضًا تساؤلات حول ضرورة أو عدم ضرورة استخدام إطارت عمل UI أصلًا. وهذا أمر عائد لكل شخص. أما بالنسبة إلى الأشخاص الذين تنقصهم المعرفة بتنسيقات CSS وتصميمات الويب فهي قطعًا أدوات مفيدة. أطر عمل UI أخرى سنقدم لك قائمة بأكثر عمل UI قد تناسبك. فإن لم تجد الإطار المفضل لديك موجودًا بينها، فقدم طلب إلغاء هذه القائمة من مادة المنهاج. https://bulma.io https://ant.design https://get.foundation https://chakra-ui.com https://tailwindcss.com المكتبة Styled components هناك أيضًا طرق أخرى في تنسيق تطببيقات React لم نأت على ذكرها بعد. منها المكتبة والتي تقدم طريقة مهمة في تنسيق العناصر من خلال قوالب موسومة مجردة ظهرت للمرة الأولى مع ES6. لنجري بعض التغييرات على تنسيق تطبيقنا بمساعدة المكتبة السابقة، لكن علينا أولًا تثبيت الحزمة كالتالي: npm install styled-components لنعرّف مكوّنين يحملان معلومات تنسيق كالتالي: import styled from 'styled-components' const Button = styled.button` background: Bisque; font-size: 1em; margin: 1em; padding: 0.25em 1em; border: 2px solid Chocolate; border-radius: 3px; ` const Input = styled.input` margin: 0.25em; ` تنشئ الشيفرة السابقة نسختين تحملا تنسيقًا من عنصرين هما input وbutton، ومن ثم تسندهما إلى متغيرين يحملان الاسم ذاته. إنّ الطريقة التي يُعرّف بها التنسيق مميزة، حيث عُرّفت قواعد تنسيق CSS داخل أقواس. وسيعمل المكوّن الذي يحمل تنسيقًا بنفس الطريقة التي يعمل بها الزر أو عنصر الإدخال النظاميين، كما يمكن استخدامهما بالطريقة ذاتها: const Login = (props) => { // ... return ( <div> <h2>login</h2> <form onSubmit={onSubmit}> <div> username: <Input /> </div> <div> password: <Input type='password' /> </div> <Button type="submit" primary=''>login</Button> </form> </div> ) } لننشئ عدة مكونات أخرى لتنسيق هذا التطبيق، وهي عناصر خاضعة للتنسيق كالتالي: const Page = styled.div` padding: 1em; background: papayawhip; ` const Navigation = styled.div` background: BurlyWood; padding: 1em; ` const Footer = styled.div` background: Chocolate; padding: 1em; margin-top: 1em; ` لنستخدم هذه المكوّنات في التطبيق: const App = () => { // ... return ( <Page> <Navigation> <Link style={padding} to="/">home</Link> <Link style={padding} to="/notes">notes</Link> <Link style={padding} to="/users">users</Link> {user ? <em>{user} logged in</em> : <Link style={padding} to="/login">login</Link> } </Navigation> <Switch> <Route path="/notes/:id"> <Note note={note} /> </Route> <Route path="/notes"> <Notes notes={notes} /> </Route> <Route path="/users"> {user ? <Users /> : <Redirect to="/login" />} </Route> <Route path="/login"> <Login onLogin={login} /> </Route> <Route path="/"> <Home /> </Route> </Switch> <Footer> <em>Note app, Department of Computer Science 2020</em> </Footer> </Page> ) } ستظهر النتيجة كالتالي: تزداد شعبية هذه المكتبة في الآونة الأخيرة، وقد اعتبرها العديد من المطورين بأنها أفضل الطرق في تنسيق تطبيقات React. التمارين يمكن أن تجد التمارين التي تتعلق بمواضيع هذا الفصل، في الفصل الأخير من هذا القسم ضمن مجموعة التمارين (توسيع تطبيق قائمة المدونات). ترجمة -وبتصرف- للفصل More About Styles من سلسلة Deep Dive Into Modern Web Development
  9. ستختلف التمارين في هذا القسم عن تمارين القسم السابق. فستجد في هذا الفصل وفي الفصل الذي سبقه تمارين تتعلق بالأفكار التي سنقدمها في هذا الفصل، بالإضافة إلى سلسلة من التمارين التي سنراجع من خلالها ما تعلمناه خلال تقدمنا في مادة المنهاج، وذلك بتعديل التطبيق Bloglist الذي عملنا عليه في القسم 4 والقسم 5 ومراجعته وتطبيق المهارات التي تعلمناها. الخطافات تقدم المكتبة React 10 أنواع من الخطافات المدمجة، من أكثرها شهرة useState وuseEffect والتي استخدمناها كثيرًا. كما استخدمنا في القسم 5 الخطاف useImperativeHandle والذي يسمح للمكوّنات بتقديم الدوال الخاصة بها إلى مكوّنات أخرى. بدأت مكتبات React خلال السنتين الماضيتين بتقديم واجهات برمجية مبنية على الخطافات. فقد استخدمنا في القسم السابق الخطافين useSelector وuseDispatch من المكتبة React-Redux لمشاركة مخزن Redux وإيفاد الدوال إلى المكوّنات. فواجهة Redux البرمجية المبنية على الخطافات أسهل استعمالًا من سابقتها التي لازالت قيد الاستعمال connect. تعتبر الواجهة البرمجية React-router التي قدمناها في الفصل السابق مبنية جزئيًا على أساس الخطافات. حيث تُستعمل خطافاتها للولوج إلى معاملات العناوين وكائن المحفوظات، وتعديل العنوان على المتصفح برمجيًا. لا تعتبر الخطافات دوال عادية كما ذكرنا في القسم 1، ولابد من الخضوع إلى مجموعة من القواعد. لنستحضر تلك القوانين التي نسخناها حرفيًا من توثيق React: لا تستدعي الخطافات من داخل الحلقات أو العبارات الشرطية أو الدوال المتداخلة، بل استخدمها دائمًا عند أعلى مستويات دالة React. لا تستدعي الخطافات من داخل دول JavaScript النظامية، بل يمكنك أن: استدعاء الخطافات من مكوّنات دوال React استدعاء الخطافات من قبل خطافات مخصصة. هنالك قاعدة يقدمها المدقق ESlint للتأكد من استدعاء الخطافات بالشكل الصحيح، حيث تُهيئ التطبيقات المبنية بواسطة create-react-app القاعدة eslint-plugin-react-hooks بشكل دائم، لكي تنبهك عند استعمال الخطاف بطريقة غير مشروعة: خطافات مخصصة تقدم لك Recat ميزة إنشاء خطافات خاصة بك. وتعتبر React أن الغرض الرئيسي من هذه الخطافات هو تسهيل إعادة استخدام شيفرة مكوّن في مكان آخر. إنّ الخطافات المخصصة هي دوال JavaScript نظامية يمكنها استعمال أية خطافات أخرى طالما أنها تخضع لقواعد استخدام الخطافات. بالإضافة إلى ضرورة أن يبدأ اسم الخطاف بالكلمة "use". لقد أنشأنا في القسم 1 تطبيق عداد يمكن زيادة قيمته أو إنقاصها أو تصفيره. وكانت شيفرته كالتالي: import React, { useState } from 'react' const App = (props) => { const [counter, setCounter] = useState(0) return ( <div> <div>{counter}</div> <button onClick={() => setCounter(counter + 1)}> plus </button> <button onClick={() => setCounter(counter - 1)}> minus </button> <button onClick={() => setCounter(0)}> zero </button> </div> ) } لننقل منطق العداد ونضعه ضمن خطاف مخصص. ستبدو شيفرة الخطاف كالتالي: const useCounter = () => { const [value, setValue] = useState(0) const increase = () => { setValue(value + 1) } const decrease = () => { setValue(value - 1) } const zero = () => { setValue(0) } return { value, increase, decrease, zero } } يستخدم الخطاف المخصص خطافًا آخر هو useState. يعيد الخطاف كائنًا تتضمن خصائصه قيمة العداد بالإضافة إلى دوال لتغيير هذه القيمة. يمكن أن يستخدم تطبيق React هذا الخطاف كالتالي: const App = (props) => { const counter = useCounter() return ( <div> <div>{counter.value}</div> <button onClick={counter.increase}> plus </button> <button onClick={counter.decrease}> minus </button> <button onClick={counter.zero}> zero </button> </div> ) } وهكذا يمكننا نقل حالة المكون App ومنطق التعامل معه إلى الخطاف useCounterhook. وسيتولى هذا الخطاف التعامل مع حالة ومنطق التطبيق. يمكن إعادة استخدام هذا الخطاف في التطبيق الذي يراقب عدد نقرات الزر الأيمن والأيسر للفأرة: const App = () => { const left = useCounter() const right = useCounter() return ( <div> {left.value} <button onClick={left.increase}> left </button> <button onClick={right.increase}> right </button> {right.value} </div> ) } يُنشئ التطبيق عدادين منفصلين تمامًا. يُسند الأول إلى المتغيّر left ويُسند الثاني إلى المتغير right. يصعب التعامل أحيانًا مع نماذج React. يستخدم التطبيق التالي نموذجًا يطلب من المستخدم أن يُدخل اسمه وتاريخ ميلاده وطوله: const App = () => { const [name, setName] = useState('') const [born, setBorn] = useState('') const [height, setHeight] = useState('') return ( <div> <form> name: <input type='text' value={name} onChange={(event) => setName(event.target.value)} /> <br/> birthdate: <input type='date' value={born} onChange={(event) => setBorn(event.target.value)} /> <br /> height: <input type='number' value={height} onChange={(event) => setHeight(event.target.value)} /> </form> <div> {name} {born} {height} </div> </div> ) } لكل حقل من النموذج حالته الخاصة. ولكي نبقي حالة النموذج متزامنة مع ما يدخله المستخدم، لابدّ من تعريف معالجًا مناسبًا للحدث onChange للتعامل مع التغيرات في عناصر الدخل. لنعرّف الخطاف المخصص useFieldhook الذي سيسهل إدارة حالة النموذج: const useField = (type) => { const [value, setValue] = useState('') const onChange = (event) => { setValue(event.target.value) } return { type, value, onChange } } تتلقى دالة الخطاف نوع حقل الدخل (مربع النص) كمعامل. وتعيد الدالة كل الصفات التي يتطلبها عنصر الدخل، وهي نوعه وقيمته ومعالج الحدث onChange. يمكن استخدام الخطاف بالطريقة التالية: const App = () => { const name = useField('text') // ... return ( <div> <form> <input type={name.type} value={name.value} onChange={name.onChange} /> // ... </form> </div> ) } نشر الصفات يمكننا تسهيل الأمور أكثر. يحتوي الكائن name على كل الصفات التي يحتاجها عنصر الدخل من خصائص هذا الكائن، وبالتالي يمكننا تمرير هذه الخصائص إلى العنصر باستخدام عبارة النشر كالتالي: <input {...name} /> وكما ستجد في المثال الموجود ضمن توثيق React، سيعطي استخدام أي من الطريقتين التاليتين في تمرير الخصائص النتيجة نفسها: <Greeting firstName='Arto' lastName='Hellas' /> const person = { firstName: 'Arto', lastName: 'Hellas' } <Greeting {...person} /> يمكن تبسيط التطبيق إلى الشكل التالي: const App = () => { const name = useField('text') const born = useField('date') const height = useField('number') return ( <div> <form> name: <input {...name} /> <br/> birthdate: <input {...born} /> <br /> height: <input {...height} /> </form> <div> {name.value} {born.value} {height.value} </div> </div> ) } سيسهل استخدام النماذج عندما نستخدم الخطافات المخصصة في تغليف بعض التفاصيل المزعجة المتعلقة بمزامنة الحالة. ومن الواضح أنّ الخطافات المخصصة ليست فقط أداة لإعادة استخدام الشيفرة، بل تقدم طرق أفضل في تقسيم شيفرتنا إلى وحدات أصغر. المزيد حول الخطافات يمكنك الرجوع إلى قسم الخطافات في موسوعة حسوب ففيه كل ما تحتاج إلى معرفته حول الخطافات بلغة عربية ومع أمثلة عملية ويمكنك الاستزادة من هذه المصادر الأجنبية التي تستحق الاطلاع: Awesome React Hooks Resources Easy to understand React Hook recipes by Gabe Ragland Why Do React Hooks Rely on Call Order التمارين 7.4 - 7.8 سنستمر في العمل على التطبيق الذي ورد في تمارين الفصل السابق React-Router من هذا القسم. 7.4 تطبيق الطرائف باستعمال الخطافات: الخطوة 1 بسط عملية إنشاء طرفة جديدة من خلال النموذج في تطبيقك باستعمال الخطاف المخصص useFieldhook الذي عرفناه سابقًا. المكان الطبيعي لحفظ الخطاف المخصص في تطبيقك قد يكون الملف "src/hooks/index.js/". وانتبه عند استخدام التصدير المحدد بدلًا من الافتراضي: import { useState } from 'react' export const useField = (type) => { const [value, setValue] = useState('') const onChange = (event) => { setValue(event.target.value) } return { type, value, onChange } } // modules can have several named exports export const useAnotherHook = () => { // ... } فستتغير طريقة إدراج الخطاف إلى الشكل: import { useField } from './hooks' const App = () => { // ... const username = useField('text') // ... } 7.5 تطبيق الطرائف باستعمال الخطافات: الخطوة 2 أضف زرًا إلى النموذج بحيث يمكنك مسح كل المعلومات في عناصر الإدخال: وسع وظيفة الخطاف useFieldhook لكي يقدم طريقة لمسح كل بيانات حقول النموذج. وتبعًا لحلك المقترح قد تجد نفسك أمام التحذير التالي على الطرفية: سنعود إلى هذا التحذير في التمرين التالي. 7.6 تطبيق الطرائف باستعمال الخطافات: الخطوة 3 إن لم يظهر التحذير السابق في حلك فقد أنجزت بالفعل حل هذا التمرين. إن ظهر معك ذلك التحذير فعليك إجراء التعديلات المناسبة للتخلص من القيمة غير المناسبة للخاصية reset ضمن المعرّف <input>. إنّ سبب هذا التحذير هو أن تعديل التطبيق ليستعمل نشر الصفات كالتالي: <input {...content}/> مطابق تمامًا للشيفرة التالية: <input value={content.value} type={content.type} onChange={content.onChange} reset={content.reset} /> لكن لا يجب أن نسند الصفة reset إلى عنصر الإدخال input. إنّ أحد الحلول المقترحة هو عدم استعمال صيغة النشر وكتابة الصفات كالتالي: <input value={username.value} type={username.type} onChange={username.onChange} /> إن كنت ستستخدم هذا الحل فستخسر العديد من الميزات التي يقدمها الخطاف useFieldhook. فكّر بدلًا من ذلك بحل يستخدم طريقة النشر. 7.7 خطاف لتطبيق الدول: لنعد إلى التمارين 12 إلى 14 من القسم 2. استعمل الشيفرة الموجودة على GitHub كقاعدة انطلاق. يُستخدم التطبيق للبحث عن تفاصيل دولة محددة بالاستعانة بالواجهة https://restcountries.eu. فإن عُثر على الدولة ستُعرض معلوماتها على الشاشة. وإن لم يُعثر على الدولة ستظهر رسالة تبلغ فيها المستخدم بذلك. إنّ التطبيق السابق مكتمل وجاهز للعمل، لكن عليك في هذه التمرين إنشاء خطاف مخصص باسم useCountry يستخدم للبحث عن تفاصيل الدولة التي تُمرر إلى الخطاف كمعامل. استخدم الوصلة full name من الواجهة البرمجية السابقة لإحضار بيانات الدولة مستخدمًا الخطاف useEffect ضمن خطافك المخصص. ولاحظ أنه من الضروري استخدام مصفوفة المعامل الثاني للخطاف useEffect في هذا التمرين للتحكم بتوقيت تنفيذ دالة المؤثر. 7.8 الخطافات الكاملة ستبدو الشيفرة المسؤولة عن الاتصال مع الواجهة الخلفية لتطبيق الملاحظات من القسم السابق على النحو التالي: import axios from 'axios' const baseUrl = '/api/notes' let token = null const setToken = newToken => { token = `bearer ${newToken}` } const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } const create = async newObject => { const config = { headers: { Authorization: token }, } const response = await axios.post(baseUrl, newObject, config) return response.data } const update = (id, newObject) => { const request = axios.put(`${ baseUrl } /${id}`, newObject) return request.then(response => response.data) } export default { getAll, create, update, setToken } لا توحي الشيفرة السابقة أبدًا بأنّ تطبيقنا هو للتعامل مع الملاحظات. وباستثناء قيمة المتغير baseUrl يمكن استخدام الشيفرة السابقة مع تطبيق قائمة المدونات للاتصال مع الواجهة الخلفية. افصل شيفرة الاتصال مع الواجهة الخلفية ضمن خطاف مخصص لها باسمuseResource. يكفي إحضار كل الموارد بالإضافة إلى إنشاء مورد جديد. يمكنك تنفيذ التمرين من أجل المشروع الموجود في المستودع https://github.com/fullstack-hy2020/ultimate-hooks. للمكوّن App لهذا المشروع الشيفرة التالية: const App = () => { const content = useField('text') const name = useField('text') const number = useField('text') const [notes, noteService] = useResource('http://localhost:3005/notes') const [persons, personService] = useResource('http://localhost:3005/persons') const handleNoteSubmit = (event) => { event.preventDefault() noteService.create({ content: content.value }) } const handlePersonSubmit = (event) => { event.preventDefault() personService.create({ name: name.value, number: number.value}) } return ( <div> <h2>notes</h2> <form onSubmit={handleNoteSubmit}> <input {...content} /> <button>create</button> </form> {notes.map(n => <p key={n.id}>{n.content}</p>)} <h2>persons</h2> <form onSubmit={handlePersonSubmit}> name <input {...name} /> <br/> number <input {...number} /> <button>create</button> </form> {persons.map(n => <p key={n.id}>{n.name} {n.number}</p>)} </div> ) } يعيد الخطاف المخصص مصفوفة من عنصرين كخطاف الحالة. يحتوي العنصر الأول على كل الموارد، بينما يمثل الآخر كائنًا يمكن استخدامه من أجل التغيير في مجموعة الموارد، كإنشاء مورد جديد. إن أضفت الخطاف بشكله الصحيح يمكنك إعادة استخدامه في تطبيق الملاحظات ودليل الهاتف (شغل الخادم باستعمال الأمر npm run server على المنفذ 3005). ترجمة -وبتصرف- للفصل Custom hooks من سلسلة Deep Dive Into Modern Web Development
  10. ستختلف التمارين قليلًا في القسم السابع من منهاج full_stack_101 عن ما سبقها. فستجد في هذا الفصل وفي الفصل الذي يليه تمارين تتعلق بالأفكار التي سنقدمها في هذا الفصل، بالإضافة إلى سلسلة من التمارين التي سنراجع من خلالها ما تعلمناه خلال تقدمنا في مادة المنهاج، وذلك بتوسيع التطبيق Bloglist الذي عملنا عليه في القسمين 4 و5. هيكلية التنقل ضمن التطبيق سنعود حاليًا إلى React دون استخدام Redux. من الشائع أن تحتوي التطبيقات إلى شريط تنقل يساعد على تغيير الصفحات التي يعرضها التطبيق. فيمكن أن يتضمن تطبيقنا صفحة رئيسية: وصفحات منفصلة لعرض معلومات عن الملاحظات والمستخدمين: تتغير الصفحات التي يحتويها تطبيق وفقًا للمدرسة القديمة باستخدام طلب HTTP-GET يرسله المتصفح إلى الخادم ومن ثم يصيّر شيفرة HTML التي تعرض الصفحة المُعادة. لكننا سنبقى عمليًا في نفس الصفحة في التطبيقات وحيدة الصفحة. حيث يوحي تنفيذ شيفرة JavaScript ضمن المتصفح بوجود عدة صفحات. فلو أرسل طلب HTTP عند تغيير الواجهة المعروضة، فهو فقط لإحضار البيانات بصيغة JSON والتي قد يحتاجها التطبيق لعرض الواجهة الجديدة. من السهل جدًا إضافة شريط التنقل أو عرض تطبيق بعدة واجهات استخدام React. تمثل الشيفرة التالية إحدى الطرق: import React, { useState } from 'react' import ReactDOM from 'react-dom' const Home = () => ( <div> <h2>TKTL notes app</h2> </div> ) const Notes = () => ( <div> <h2>Notes</h2> </div> ) const Users = () => ( <div> <h2>Users</h2> </div> ) const App = () => { const [page, setPage] = useState('home') const toPage = (page) => (event) => { event.preventDefault() setPage(page) } const content = () => { if (page === 'home') { return <Home /> } else if (page === 'notes') { return <Notes /> } else if (page === 'users') { return <Users /> } } const padding = { padding: 5 } return ( <div> <div> <a href="" onClick={toPage('home')} style={padding}> home </a> <a href="" onClick={toPage('notes')} style={padding}> notes </a> <a href="" onClick={toPage('users')} style={padding}> users </a> </div> {content()} </div> ) } ReactDOM.render(<App />, document.getElementById('root')) تضاف كل واجهة عرض على هيئة مكوّن خاص. وتُخزّن معلومات المكوّن في حالة التطبيق التي تحمل الاسم page. تخبرنا هذه المعلومات عن المكوّن الذي يمثل الواجهة التي ستعرض أسفل شريط القوائم. لكن هذا الأسلوب ليس الأسلوب الأمثل لتنفيذ ذلك. حيث يمكن أن نلاحظ من الصور التي عرضناها أن العنوان سيبقى نفسه للواجهات المختلفة. ومن المفضل أن تحمل كل واجهة عرض عنوانها الخاص، لتسهيل إنشاء اختصارات لها ضمن المتصفح على سبيل المثال. لن يعمل الزر "back" كما هو متوقع في التطبيق، أي أنه لن ينقلك إلى الواجهة السابقة بل إلى مكان مختلف كليًا. فإن توسع التطبيق وهذا ما نريده، عند إنشاء واجهات منفصلة لكل مستخدم أو ملاحظة فسيغدو هذا الأسلوب الذي اتبعناه في التوجه، والذي يدير آلية التنقل في التطبيق معقدًا بشكل كبير. لحسن الحظ تؤمن لنا المكتبة React router حلًا ممتازًا لإدارة التنقل بين الواجهات في تطبيقات React. لنغيّر التطبيق السابق بحيث يستخدم React-router. إذًا علينا أولًا تثبيت المكتبة بتنفيذ الأمر: npm install react-router-dom تُفعَّل آلية التنقل التي تؤمنها المكتبة السابقة كالتالي: import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom" const App = () => { const padding = { padding: 5 } return ( <Router> <div> <Link style={padding} to="/">home</Link> <Link style={padding} to="/notes">notes</Link> <Link style={padding} to="/users">users</Link> </div> <Switch> <Route path="/notes"> <Notes /> </Route> <Route path="/users"> <Users /> </Route> <Route path="/"> <Home /> </Route> </Switch> <div> <i>Note app, Department of Computer Science 2020</i> </div> </Router> ) } تستخدم آلية التنقل أو التصيير الشرطي للمكوّنات المسؤولة عن عرض الواجهات بناء على عنوانها الظاهر على المتصفح، بوضع هذه المكوّنات كأبناء للمكوّن Router، أي داخل المُعِّرف <Router>. لاحظ أنه على الرغم من أنّ اسم المكوّن Router، فإننا نتحدث في الواقع عن موجِّه المتصفح BrowserRouter حيث غيرنا اسم الكائن عندما أدرجناه: import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom" وفقًا لدليل استخدام المكتبة: يحمّل المتصفح عادة صفحة جديدة عندما يتغير العنوان ضمن شريط العناوين. لكن سيمكننا مكوّن موجّه المتصفح BrowserRouter بالاستفادة من الواجهة البرمجية HTML5 history API من استخدام العنوان الموجود في شريط العناوين للتنقل الداخلي ضمن الواجهات التي يعرضها تطبيق React.فحتى لو تغيّر العنوان ضمن شريط عناوين المتصفح سيتم التلاعب بمحتوى الصفحة باستخدام شيفرة JavaScript ولن يحمّل المتصفح محتوًى جديدًا من الخادم. وسيكون استخدام أفعال التراجع أو التقدم وحفظ الاختصارات منطقيًا كأي صفحة ويب تقليدية. نعرّف داخل الموجّه روابط link لتعديل شريط العناوين بمساعدة المكوّن Link. تنشئ الشيفرة التالية على سبيل المثال: <Link to="/notes">notes</Link> رابطًا داخل التطبيق له النص "notes" يغيّر بالنقر عليه العنوان في شريط عناوين المتصفح إلى العنوان "notes/". تٌعرّف المكوِّنات التي يجري تصييرها وفقًا للعنوان على المتصفح باستخدام المكوّن Route. فوظيفة الشيفرة التالية على سبيل المثال: <Route path="/notes"> <Notes /> </Route> هو تصيير المكوّن Note إن كان العنوان المحدد في المتصفح هو "notes/". سنغلف المكونات التي ستُصيّر بناء على عنوان المتصفح داخل المكوّن Switch: <Switch> <Route path="/notes"> <Notes /> </Route> <Route path="/users"> <Users /> </Route> <Route path="/"> <Home /> </Route> </Switch> يصير المكوّن switch أول مكوّن داخله يتطابق مساره مع العنوان الموجود ضمن شريط عناوين المتصفح. وانتبه إلى أهمية ترتيب المكوّنات. فإن أردنا وضع المكوّن Home ذو المسار"/"=path أولًا، فلن يصير أي مكوّن آخر لأن المسار "/" غير موجود أصلًا فهو بداية كل المسارات: <Switch> <Route path="/"> <Home /> </Route> <Route path="/notes"> <Notes /> </Route> // ... </Switch> إسناد معاملات إلى الموجه لنتفحص النسخة المعدّلة من المثال السابق. يمكنك أن تجد الشيفرة الكاملة للمثال على Github. يعرض التطبيق الآن خمس واجهات مختلفة يتحكم الموجّه بآلية عرضها. فبالإضافة إلى المكونات Home وUser وNotesمن المثال السابق سنجد المكوّن Login الذي يعرض واجهة لتسجيل الدخول والمكوّن Note الذي يعرض ملاحظة واحدة. لم نغير المكونين Home وUsers، لكن Notes أعقد قليلًا لانها تصير قائمة الملاحظات التي تُمرّر إليها كخاصية بطريقة تمكننا من النقر على اسم كل ملاحظة: تأتي إمكانية النقر على اسم الملاحظة من المكوّن Link، فالنقر على اسم الملاحظة التي تحمل المعرّف 3 سيسبب وقوع الحدث الذي يغيّر العنوان في المتصفح إلى "notes/3": const Notes = ({notes}) => ( <div> <h2>Notes</h2> <ul> {notes.map(note => <li key={note.id}> <Link to={`/notes/${note.id}`}>{note.content}</Link> </li> )} </ul> </div> ) نعرّف العناوين ذات المعاملات في الموجّه ضمن المكوّن App كالتالي: <Router> <div> <div> <Link style={padding} to="/">home</Link> <Link style={padding} to="/notes">notes</Link> <Link style={padding} to="/users">users</Link> </div> <Switch> <Route path="/notes/:id"> <Note notes={notes} /> </Route> <Route path="/notes"> <Notes notes={notes} /> </Route> <Route path="/"> <Home /> </Route> </Switch> </Router> نعرّف الموجّه الذي يصير ملاحظة محددة " بتنسيق express" بتعليم المعامل بالوسم "id:": <Route path="/notes/:id"> فعندما ينتقل المتصفح إلى عنوان الملاحظة المحددة، "notes/3/" على سبيل المثال، يُصّير المكوّن Note: import { // ... useParams} from "react-router-dom" const Note = ({ notes }) => { const id = useParams().id const note = notes.find(n => n.id === Number(id)) return ( <div> <h2>{note.content}</h2> <div>{note.user}</div> <div><strong>{note.important ? 'important' : ''}</strong></div> </div> ) } يتلقى المكوّن Notes كل الملاحظات ضمن الخاصيّة notes، ويمكنه بعدها الوصول إلى معامل العنوان (معرّف الملاحظة التي ستُعرض) باستخدام الدالة useParams العائدة للمكتبة react-router. استخدام الدالة useHistory أضفنا أيضًا طريقة بسيطة لتسجيل الدخول في تطبيقنا. فإن سجّل المستخدم دخوله ستُخزّن معلومات تسجيل الدخول في الحقل user من حالة المكوّن App. يُصيّر خيار الانتقال إلى واجهة تسجيل الدخول شرطيًا في القائمة: <Router> <div> <Link style={padding} to="/">home</Link> <Link style={padding} to="/notes">notes</Link> <Link style={padding} to="/users">users</Link> {user ? <em>{user} logged in</em> : <Link style={padding} to="/login">login</Link> } </div> // ... </Router> فلو سجلّ المستخدم دخوله للتو، فسيُظهر التطبيق اسم المستخدم بدلًا من الانتقال إلى واجهة تسجيل الدخول: تعطي الشيفرة التالية وظيفة تسجيل الدخول لتطبيقنا: import { // ... useHistory} from 'react-router-dom' const Login = (props) => { const history = useHistory() const onSubmit = (event) => { event.preventDefault() props.onLogin('mluukkai') history.push('/') } return ( <div> <h2>login</h2> <form onSubmit={onSubmit}> <div> username: <input /> </div> <div> password: <input type='password' /> </div> <button type="submit">login</button> </form> </div> ) } إن ما يلفت الانتباه في هذا المكوّن هو استخدامه الدالة useHistory. حيث يمكن للمكوّن الولوج إلى كائن محفوظات history باستخدام تلك الدالة. ويستخدم كائن المحفوظات لتغيير عنوان المتصفح برمجيًا. فعند تسجيل الدخول، يُستدعى التابع ('/')history.push العائد لكائن المحفوظات والذي يسبب تغييرًا في عنوان المتصفح إلى "/" ويصيِّر بعدها التطبيق المكوّن Home. تمثل كلا الدالتين useParams وuseHistory دوال خطافات تمامًا كالدوال useState وuseEffect والتي استخدمناها عدة مرات سابقًا. وكما أسلفنا في القسم 1 فهنالك الكثير من القواعد لاستخدام دوال الخطافات. وقد هيئت تطبيقات Creat-react-app لتنبيهك إن أغفلت تلك القواعد، كاستدعاء دوال الخطافات من داخل العبارات الشرطية. إعادة التوجيه يبقى هناك تفصيل مهم يتعلق بالمسار Users: <Route path="/users" render={() => user ? <Users /> : <Redirect to="/login" /> } /> إن لم يسجل المستخدم دخوله، فلن يصيّر المكوّن Users. وبدلًا من ذلك سيعاد توجيه المستخدم إلى واجهة تسجيل الدخول باستخدام المكوّن Redirect. <Redirect to="/login" /> من الأفضل في الواقع أن لا نظهر الروابط التي تحتاج إلى تسجيل الدخول في شريط التنقل إن لم يسجل المستخدم دخوله إلى التطبيق. تمثل الشيفرة التالية المكوّن App بشكله الكامل: const App = () => { const [notes, setNotes] = useState([ // ... ]) const [user, setUser] = useState(null) const login = (user) => { setUser(user) } const padding = { padding: 5 } return ( <div> <Router> <div> <Link style={padding} to="/">home</Link> <Link style={padding} to="/notes">notes</Link> <Link style={padding} to="/users">users</Link> {user ? <em>{user} logged in</em> : <Link style={padding} to="/login">login</Link> } </div> <Switch> <Route path="/notes/:id"> <Note notes={notes} /> </Route> <Route path="/notes"> <Notes notes={notes} /> </Route> <Route path="/users"> {user ? <Users /> : <Redirect to="/login" />} </Route> <Route path="/login"> <Login onLogin={login} /> </Route> <Route path="/"> <Home /> </Route> </Switch> </Router> <div> <br /> <em>Note app, Department of Computer Science 2020</em> </div> </div> ) } نعرّف في الشيفرة السابقة مكوّنًا شائع الاستخدام في تطبيقات الويب الحديثة ويدعى footer والذي يعرّف الجزء السفلي من شاشة عرض التطبيق خارج نطاق المكوّن Router، وبالتالي سيظهر دائمًا بغض النظر عن المكوّن الذي سيُعرض. مرور آخر على إسناد المعاملات إلى الموجه لاتزال هناك ثغرة في تطبيقنا. حيث يتلقى المكوّن Note كل الملاحظات على الرغم من أنه سيعرض الملاحظة التي يتطابق معرّفها مع معامل العنوان: const Note = ({ notes }) => { const id = useParams().id const note = notes.find(n => n.id === Number(id)) // ... } هل يمكن أن تعدّل التطبيق لكي يتلقى المكون Note المكوّن الذي سيُعرض فقط؟ const Note = ({ note }) => { return ( <div> <h2>{note.content}</h2> <div>{note.user}</div> <div><strong>{note.important ? 'important' : ''}</strong></div> </div> ) } تعتمد إحدى الطرق المتبعة في تنفيذ ذلك على استخدام الخطاف useRouteMatch لتحديد معرّف الملاحظة التي ستُعرض ضمن المكوّن App. من غير الممكن أن نستخدم الخطاف useRouteMatch في المكوّن الذي يعرّف الشيفرة المسؤولة عن التنقل. لننقل إذًا المكوّن Router خارج المكوّن App: ReactDOM.render( <Router> <App /> </Router>, document.getElementById('root') ) سيصبح المكون App كالتالي: import { // ... useRouteMatch} from "react-router-dom" const App = () => { // ... const match = useRouteMatch('/notes/:id') const note = match ? notes.find(note => note.id === Number(match.params.id)) : null return ( <div> <div> <Link style={padding} to="/">home</Link> // ... </div> <Switch> <Route path="/notes/:id"> <Note note={note} /> </Route> <Route path="/notes"> <Notes notes={notes} /> </Route> // ... </Switch> <div> <em>Note app, Department of Computer Science 2020</em> </div> </div> ) } في كل مرة ستغير فيها العنوان على المتصفح سيجري تنفيذ الأمر التالي: const match = useRouteMatch('/notes/:id') إن تطابق العنوان مع القيمة "notes/:id/"، فسيُسند إلى متغير التطابق كائن يمكنه الولوج إلى القسم الذي يحوي المعامل من مسار العنوان وهو معرّف الملاحظة التي ستُعرض، وبالتالي يمكن إحضار الملاحظة المطلوبة وعرضها. const note = match ? notes.find(note => note.id === Number(match.params.id)) : null ستجد الشيفرة كاملة على GitHub. التمارين 7.1 - 7.3 سنعود إلى العمل في تطبيق الطرائف. استخدم نسخة التطبيق التي لا تعتمد على Redux والموجودة على GitHub كنقطة انطلاق للتمارين. إن نسخت الشيفرة ووضعتها في مستودع git موجود أصلًا، لاتنس حذف ملف تهيئة git لنسختك من التطبيق: Exercises 7.1.-7.3. cd routed-anecdotes // توجه أوّلًا إلى المستودع الذي يحوي نسختك من التطبيق rm -rf .git شغّل التطبيق بالطريقة الاعتيادية، لكن عليك أوّلًا تثبيت الاعتماديات اللازمة: npm install npm start 7.1 تطبيق الطرائف بشريط للتنقل: الخطوة 1 أضف موجّه React إلى التطبيق بحيث يمكن تغيير الواجهة المعروضة عند النقر على الروابط في المكوّن Menu. أظهر قائمة الطرائف عند الوصول إلى جذر التطبيق (الموقع الذي يحمل المسار "/") ينبغي أن يظهر المكوّن Footer بشكل دائم في أسفل الشاشة. كما ينبغي إنشاء الطرفة الجديدة في مسار خاص، ضمن المسار "create" على سبيل المثال: 7.2 تطبيق الطرائف بشريط للتنقل: الخطوة 2 أضف إمكانية عرض طرفة واحدة: انتقل إلى الصفحة التي تعرض طرفة واحدة بالنقر على اسم الطرفة: 7.3 تطبيق الطرائف بشريط للتنقل: الخطوة 3 ستغدو الوظيفة الافتراضية لنموذج إنشاء طرفة جديدة مربكة قليلًا لأن شيئًا لن يحدث عند إنشاء طرفة جديدة باستخدام النموذج. حسّن الأمر بحيث ينتقل التطبيق تلقائيًا إلى إظهار كل الطرائف عند إنشاء الطرفة الجديدة، ويظهر تنبيه للمستخدم مدته عشر ثوان، لإبلاغه بإضافة طرفة جديدة ترجمة -وبتصرف- للفصل React-Router من سلسلة Deep Dive Into Modern Web Development
  11. لقد استخدمنا حتى اللحظة مخزن Redux بمساعدة واجهة خطافات أمنتها المكتبة react-redux. وقد استخدمنا بالتحديد الدالتين useSelector وuseDispatch. ولنكمل هذا القسم علينا الاطلاع على طريقة أقدم وأكثر تعقيدًا لاستخدام Redux، وهي استخدام الدالة connect التي تؤمنها المكتبة Redux. عليك قطعًا استخدام واجهة الخطافات البرمجية عندما تنشئ تطبيقات جديدة، لكن معرفة طريقة عمل connect أمر ضروري عند صيانة مشاريع Redux أقدم. استخدام الدالة connect لمشاركة مخزن Redux بين المكوِّنات لنعدّل المكوِّن Notes بحيث يستخدم الدالة connect بدلًا من واجهة الخطافات البرمجية (بدلًا من استخدام الدالتين useSelector وuseDispatch).علينا تعديل الإجزاء التالية من المكوِّن: import React from 'react' import { useDispatch, useSelector } from 'react-redux'import { toggleImportanceOf } from '../reducers/noteReducer' const Notes = () => { const dispatch = useDispatch() const notes = useSelector(({filter, notes}) => { if ( filter === 'ALL' ) { return notes } return filter === 'IMPORTANT' ? notes.filter(note => note.important) : notes.filter(note => !note.important) }) return( <ul> {notes.map(note => <Note key={note.id} note={note} handleClick={() => dispatch(toggleImportanceOf(note.id)) } /> )} </ul> ) } export default Notes يمكن استخدام الدالة connect لتحويل مكونات React "النظامية" بحيث يتصل المكوِّن بحالة مخزن Redux عن طريق خصائصه. لنستخدم أولًا الدالة connect لتحويل المكوّن Notes إلى مكوّن متّصل (connected component): import React from 'react' import { connect } from 'react-redux'import { toggleImportanceOf } from '../reducers/noteReducer' const Notes = () => { // ... } const ConnectedNotes = connect()(Notes) export default ConnectedNotes تُصدِّر الوحدة المكوِّن المتصل والذي سيعمل حاليًا كالمكوّن النظامي السابق تمامًا. يحتاج المكوِّن إلى قائمة بالملاحظات وإلى قيمة المُرشِّح من مخزن Redux. تستقبل الدالة connect دالة أخرى تدعى mapStateToProps كمعامل أول. حيث تُستخدم هذه الأخيرة في تعريف خصائص المكوِّن المتصل والتي تعتمد على حالة مخزن Redux. لو كتبنا الشيفرة التالية: const Notes = (props) => { const dispatch = useDispatch() const notesToShow = () => { if ( props.filter === 'ALL ') { return props.notes } return props.filter === 'IMPORTANT' ? props.notes.filter(note => note.important) :props.notes.filter(note => !note.important) } return( <ul> {notesToShow().map(note => <Note key={note.id} note={note} handleClick={() => dispatch(toggleImportanceOf(note.id)) } /> )} </ul> ) } const mapStateToProps = (state) => { return { notes: state.notes, filter: state.filter, } } const ConnectedNotes = connect(mapStateToProps)(Notes) export default ConnectedNotes يمكن للمكون أن يلج حالة المخزن مباشرة من خلال الأمرprops.notes والذي يعطينا قائمة الملاحظات. كما يمكن الوصول إلى قيمة المرشِّح من خلال الأمر props.filter. كما يمكن توضيح الحالة التي تنتج عن استخدام الدالة connect مع الدالة mapStateToProps التي عرفناها كما يلي: ويمكن أيضًا للمكوِّن Notes أن يصل مباشرة إلى المخزن بالطريقة التي ذكرناها سابقًا لغرض التحري والتفتيش. لا يحتاج المكوّن NoteList عمليًا إلى أية معلومات حول المُرشِّح المختار، لذلك يمكن نقل منطق عملية الانتقاء إلى مكان آخر. وعلينا فقط تقديم الملاحظات المنتقاة بشكل صحيح للمكوّن من خلال الخاصية note. const Notes = (props) => { const dispatch = useDispatch() return( <ul> {props.notes.map(note => <Note key={note.id} note={note} handleClick={() => dispatch(toggleImportanceOf(note.id)) } /> )} </ul> ) } const mapStateToProps = (state) => { if ( state.filter === 'ALL' ) { return { notes: state.notes } } return { notes: (state.filter === 'IMPORTANT' ? state.notes.filter(note => note.important) : state.notes.filter(note => !note.important) ) }} const ConnectedNotes = connect(mapStateToProps)(Notes) export default ConnectedNotes الدالة mapDispatchToProps لقد تخلصنا الآن من الدالة useSelector، لكن المكوّن Note لايزال يستخدم الخطاف useDispatch ودالة الإيفاد التي تستخدمه: const Notes = (props) => { const dispatch = useDispatch() return( <ul> {props.notes.map(note => <Note key={note.id} note={note} handleClick={() => dispatch(toggleImportanceOf(note.id)) } /> )} </ul> ) } يستخدم المعامل الثاني للدالة connect لتعريف الدالة mapDispatchToProps، ويمثل هذا المعامل مجموعة من الدوال المولدة للأفعال، تُمرّر إلى الدالة connect كخاصية. لنجري التعديلات التالية على عملية الاتصال التي أنشأناها: const mapStateToProps = (state) => { return { notes: state.notes, filter: state.filter, } } const mapDispatchToProps = { toggleImportanceOf,} const ConnectedNotes = connect( mapStateToProps, mapDispatchToProps)(Notes) export default ConnectedNotes يستطيع المكوّن الآن إيفاد الفعل الذي يعّرفه مولد الأفعال toggleImportanceOf مباشرةً، عن طريق طلب الدالة من خلال خصائصها: const Notes = (props) => { return( <ul> {props.notes.map(note => <Note key={note.id} note={note} handleClick={() => props.toggleImportanceOf(note.id)} /> )} </ul> ) } أي بدلًا من إيفاد الفعل بالطريقة التالية: dispatch(toggleImportanceOf(note.id)) يمكننا ببساطة إنجاز ذلك باستخدام connect: props.toggleImportanceOf(note.id) لا حاجة لطلب الدالة dispatch بشكل منفصل، طالما أنّ الدالة connect قد عدلت مولد الفعل toggleImportanceOf إلى الشكل الذي يحتوي dispatch. سيتطلب منك الأمر بعض الوقت لتقلب في ذهنك الطريقة التي تعمل بها الدالة mapDispatchToProps، وخاصة عندما سنلقي نظرة على طرق بديلة لاستخدامها لاحقًا في هذا الفصل. يمكن إظهار النتيجة التي سنحصل عليها باستخدام connect من خلال الشكل التالي: وبالإضافة إلى قدرته على الولوج إلى حالة المخزن، يمكن للمكوِّن الإشارة إلى دالة يمكن أن تستخدم لإيفاد أفعال من النوع TOGGLE_IMPORTANCE من خلال الخاصية toggleImportanceOf. تمثل الشيفرة التالية المكوّن Notes وقد كُتب من جديد: import React from 'react' import { connect } from 'react-redux' import { toggleImportanceOf } from '../reducers/noteReducer' const Notes = (props) => { return( <ul> {props.notes.map(note => <Note key={note.id} note={note} handleClick={() => props.toggleImportanceOf(note.id)} /> )} </ul> ) } const mapStateToProps = (state) => { if ( state.filter === 'ALL' ) { return { notes: state.notes } } return { notes: (state.filter === 'IMPORTANT' ? state.notes.filter(note => note.important) : state.notes.filter(note => !note.important) ) } } const mapDispatchToProps = { toggleImportanceOf } export default connect( mapStateToProps, mapDispatchToProps )(Notes) لنستخدم أيضًا الدالة connect لإنشاء ملاحظة جديدة: import React from 'react' import { connect } from 'react-redux' import { createNote } from '../reducers/noteReducer' const NewNote = (props) => { const addNote = async (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' props.createNote(content) } return ( <form onSubmit={addNote}> <input name="note" /> <button type="submit">add</button> </form> ) } export default connect( null, { createNote })(NewNote) وطالما أن المكوّن لن يحتاج الوصول إلى حالة المخزن، يمكننا أن نمرر ببساطة القيمة null كمعامل أول للدالة connect. يمكنك إيجاد الشيفرة الكاملة لتطبيقنا الحالي في الفرع part6-5 ضمن المستودع المخصص للتطبيق على GitHub. الإشارة المرجعية إلى مولدات الأفعال الممررة كخصائص لنوجه اهتمامنا إلى الميزة الهامة التي يمتلكها المكوّن NewNote: import React from 'react' import { connect } from 'react-redux' import { createNote } from '../reducers/noteReducer' const NewNote = (props) => { const addNote = async (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' props.createNote(content) } return ( <form onSubmit={addNote}> <input name="note" /> <button type="submit">add</button> </form> ) } export default connect( null, { createNote })(NewNote) سيشعر المطورون حديثو المعرفة بالدالة connect بالحيرة أمام نسختي مولد الأفعال createNote في هذا المكوّن. يجب أن يُشار إلى دالة مولد الأفعال السابقة بالأمر props.createNote من خلال خصائص المكوّن، لأنها النسخة التي تنجز الإيفاد الذي تولده الدالة connect تلقائيًا. ونظرًا للطريقة التي يُدرج فيها مولد الفعل: import { createNote } from './../reducers/noteReducer' يمكن أن يُشار إلى مولد الفعل مباشرة بطلب الدالة creatNote. لكن لا تفعل ذلك، لأنها النسخة غير المعدلة من دالة مولد الفعل وبالتالي لا تمتلك آلية الإيفاد التلقائي. إن طبعنا الدالتين على الطرفية (لم نتطرق إلى هذه الحيلة في التنقيح بعد) كالتالي: const NewNote = (props) => { console.log(createNote) console.log(props.createNote) const addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' props.createNote(content) } // ... } يمكن أن نجد الفرق بين الدالتين كما في الشكل التالي: حيث تظهر الدالة الأولى بأنها دالة مولد فعل نظامية، بينما ستحتوي الدالة الثانية آلية إيفاد إلى المخزن، أضافتها الدالة connect. تمثل الدالة connect أداة غاية في الأهمية بالرغم من أنها تبدو صعبة الفهم في البداية، نظرًا لمستوى التجريد العالي الذي تقدمه. طرائق أخرى لاستخدام الدالة mapDispatchToProps لقد عرفنا دالة إيفاد الأفعال من خلال المكوّن المتصل NewNote على النحو التالي: const NewNote = () => { // ... } export default connect( null, { createNote } )(NewNote) تُمكننا عبارة connect السابقة من إيفاد الأفعال بغية إنشاء ملاحظات جديدة، وذلك باستعمال الأمر ('props.createNote('a new note. ينبغي أن تكون الدوال الممررة إلى الدالة mapDispatchToProps مولدات أفعال، أي دوال تعيد أفعال Redux، فلا فائدة من تمرير كائن JavaScript كمعامل لهذه الدالة. فالتعريف التالي: { createNote } هو اختصار لتعريف كائن مجرّد: { createNote: createNote } وهو كائن يمتلك خاصية واحدة هي createNote تأتي مع الدالة createNote كقيمة لها. بدلًا من ذلك، يمكن تمرير التعريف التالي لدالة كمعامل آخر للدالة connect: const NewNote = (props) => { // ... } const mapDispatchToProps = dispatch => { return { createNote: value => { dispatch(createNote(value)) }, }} export default connect( null, mapDispatchToProps )(NewNote) بهذه الطريقة ستدفع الدالة connect بالدالة mapDispatchtoProps بتمريرها لدالة الإيفاد dispatch كمعامل لها. وستكون النتيجة كائن يعرّف مجموعة من الدوال التي ستمرر إلى المكوّن المتصل كخصائص. يعرّف تطبيقنا الدالة التي ستُمرر إلى connect على أنها الخاصية createNote: value => { dispatch(createNote(value)) } حيث توفد ببساطة الفعل الذي أنشأته دالة مولد الأفعال createNote. يشير بعدها المكوِّن إلى الدالة عبر خصائصه من خلال الأمر props.creatNote: const NewNote = (props) => { const addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' props.createNote(content) } return ( <form onSubmit={addNote}> <input name="note" /> <button type="submit">add</button> </form> ) } يحمل هذا المفهوم شيئًا من التعقيد ويصعب شرحه وتوضيحه من خلال كتابته، ويكفي في معظم الحالات استخدام الشكل الأبسط للدالة mapDispatchToProps. لكن تظهر الحاجة في بعض الحالات إلى استخدام الشكل المعقد، كالحالة التي يتطلب فيها إيفاد الفعل إلى الإشارة المرجعية إلى خصائص الكائن. أعدّ مصمم Redux دان آبراموف دورة تعليمية مميزة بعنوان Getting started with Redux يمكن أن تجدها على Egghead.io. ننصح الجميع بالاطلاع عليها. وستركز الفيديوهات الأربعة الأخيرة من الدورة على connect وخاصة الطرق الأكثر تعقيدًا في استخدامها. مرور آخر على المكونات التقديمية ومكونات الحاويات يُركز المكون الذي أعدنا تشكيله كليًا على تصيير الملاحظات، فهو قريب جدًا مما ندعوه بالمكونات التقديمية. وبناء على الوصف الذي قدمه دان آبراموف، فالمكونات التقديمية: تهتم بكيفية عرض الأشياء يمكن أن تضم مكوّنات تقديمية أو حاويات، كما قد تحتوي على شيفرة DOM وتنسيقات CSS خاصة بها. غالبًا ما تسمح باحتواء المكوّنات من خلال الخصائص الأبناء pops.children. لا تتعلق ببقية التطبيق، كأفعال Redux أو مخازنها. لا تحدد الطريقة التي تُحمّل بها البيانات أو كيف تتغير. تستقبل البيانات والاستدعاءات من خلال الخصائص حصرًا. بالنادر ما تمتلك حالة مكوّن خاصة بها، وعندما تمتلك حالة فهي حالة واجهة مستخدم أكثر من كونها حالة تخزين بيانات. تُنشأ كمكوّنات وظيفية، إلّا عندما تحتاج إلى حالة أو خطافات دورة عمل أو استمثال أداء. تحقق المكونات المتصلة التي أنشأتها الدالة connect: const mapStateToProps = (state) => { if ( state.filter === 'ALL' ) { return { notes: state.notes } } return { notes: (state.filter === 'IMPORTANT' ? state.notes.filter(note => note.important) : state.notes.filter(note => !note.important) ) } } const mapDispatchToProps = { toggleImportanceOf, } export default connect( mapStateToProps, mapDispatchToProps )(Notes) وصف مكونات الحاويات، كما جاء في وصف دان آبراموف لمكونات الحاويات: تركز على كيفية عمل الأشياء يمكن أن تضم مكونات تقديمية ومكونات حاويات، لكنها لا تحتوي عادة على شيفرة DOM خاصة بها ماعدا بعض عناصر div التي تغلف أجزاء من الشيفرة، ولا تمتلك أية تنسيقات CSS. تزود المكوِّنات التقديمية أو مكونات الحاويات الأخرى بالبيانات أوتزودها بطريقة سلوكها. تطلب أفعال Redux وتزود المكونات التقديمية بها كاستدعاءات. غالبًا ما تحتوي على حالة، فطبيعتها تجعلها تميل إلى تزويد غيرها بالبيانات. تُنشئها عادة المكونات الأعلى مثل connect العائدة للمكتبة React-Redux إنّ تصنيف المكونات إلى تقديمية ومكونات حاويات هي طريقة لتنظيم هيكلية تطبيقات React. وقد يكون ذلك ميزة تصميمة جيدة وقد لا يكون، حسب الوضع المدروس. وقد أشار آبراموف إلى الحسنات التالية لهذا التقسيم: فصل أفضل للمكونات. فستفهم تطبيقك وواجهة المستخدم التي صممتها بشكل أفضل على هذا النحو. قابلية استخدام أفضل. حيث يمكنك استخدام المكونات التقديمية مع مصادر مختلفة للحالة، وتحويل هذه المكونات إلى مكونات حاويات يمكن إعادة استخدامها من جديد. ستؤمن لك المكوّنات التقديمية بشكل رئيسي أدواتك الفنية. حيث يمكنك وضعهم في صفحة واحدة متيحًا للمصمم أن يغيّر ما يشاء دون المساس بمنطق التطبيق. ويمكنك ذلك من اختبار التغيرات في تصميم الصفحة. ويشير آبراموف إلى مصطلح المكوّن من المرتبة العليا، فالمكوّن Note هو مثال عن المكوّن النظامي، بينما تمثل الدالة connect التي تتبع إلى المكتبة React-Redux مكوّنًا من مرتبة عليا. فالمكونات من مراتب عليا هي بشكل أساسي دوال تقبل مكونات نظامية كمعاملات لها، ومن ثم تعيد مكوّنًا نظاميًا. تمثل المكوّنات من مراتب عليا (HOCs) طريقة في تعريف الدوال المعمّمة التي يمكن أن تُطبق على المكوّنات. وهو مفهوم يعود أصلًا إلى أسلوب البرمجة بالدوال ويقابل بشكل ما مفهوم الوراثة في البرمجة كائنية التوجه. تعتبر في واقع الأمر المكونات من المراتب العليا تعميمًا لمفهوم الدوال من المراتب العليا (HOF). وهي دوال قد تقبل دوال أخرى كمعاملات أو أن تعيد دوال. لقد استخدمنا بشكل مستمر خلال المنهاج هذا النوع من الدوال، وكمثال عليها التوابع التي تتعامل مع المصفوفات مثل map وfilter وfind. انخفضت شعبية HOCs بعد ظهور واجهة الخطافات البرمجية في React. وعُدّلت جميع المكتبات التي اعتمدت عليها لتستخدم الخطافات. فالخطافات في أغلب الأوقات أسهل استعمالًا من HOC كما هي الحال في Redux. المكتبة Redux وحالة المكون لقد قطعنا شوطًا طويلًا في هذا المنهاج، ووصلنا إلى النقطة التي أصبحنا قادرين فيها على استخدام React بالطريقة الصحيحة. ويعني هذا أنّ React ستركز على توليد مظهر التطبيق، وستنفصل حالة التطبيق كليًا عن المكوّنات وتُمرر إلى Redux سواء أفعالها أو دوال الاختزال التي تستخدمها. لكن ما هو وضع الخطاف useState الذي يزوّد المكوّن بحالة خاصة به؟ هل له أي دور في حال استخدم التطبيق Redux أو أي حل آخر لإدارة الحالة؟ إن امتلك التطبيق نماذج أكثر تعقيدًا، فمن الأفضل أن تتمتع بطريقة ذاتية لإدارة حالتها ويكون ذلك باستخدام الدالةuseState. وبالطبع نستطيع الاعتماد أيضًا على Redux لإدارة حالة النماذج، لكن من الأفضل ترك إدارة الحالة للمكوّن عندما تتعلق الحالة بملء حقول النموذج مثلًا. هل ينبغي استخدام Redux دائمًا؟ ربما لا، فقد ناقش دان آبراموف ذلك في مقالته You Might Not Need Redux. تتوفر حاليًا طرق أخرى لإدارة الحالة بطرق مشابهة للمكتبة Redux كاستخدام الواجهة البرمجية التي تؤمنها React واستخدام الخطاف useReducer. يمكن الاطلاع على طريقة عمل الواجهة وعمل الخطاف من خلال الانترنت، كما سنناقش ذلك في قسم لاحق. التمارين 6.19 - 6.21 6.19 تطبيق الطرائف باستخدام connect: الخطوة 1 يصل المكوّن حاليًا إلى مخزن Redux عبر الخطافين useSelector وuseDispatch. عدّل المكوّن AnecdoteList لكي يستخدم الدالة connect بدلًا من الخطافات. يمكنك استخدام الدوال mapStateToProps وmapDispatchToProps بما تراه مناسبًا. 6.20 تطبيق الطرائف باستخدام connect: الخطوة 2 عدّل كما فعلت سابقًا المكوّنين Filter وAncedoteForm. 6.211 تطبيق الطرائف: الخاتمة ربما سنواجه ثغرة مزعجة في التطبيق. إن نقر المستخدم على زر التصويت في سطر واحد عدة مرات ستظهر رسالة التنبيه بشكل مضحك. فإن صوّت المستخدم مرتين خلال ثلاث ثوانٍ، فسيظهر التنبيه الأخير لمدة ثانيتين فقط(على فرض أنّ التنبيه يدوم 5 ثوانٍ بشكل طبيعي). ويحدث ذلك لأنّ إزالة التنبيه الأول يزيل الثاني مصادفةً. أصلح هذه الثغرة، ليبقى التنبيه الصادر عن آخر تصويت مدة خمس ثوانٍ وذلك عند التصويت لعدة مرات. ويُنفّذ الأمر بإزالة التنبيه السابق عندما يتم عرض التنبيه التالي عندما يكون ذلك ضروريًا. يمكنك الاستفادة من توثيق الدالة setTimeout أيضًا. وهكذا نكون قد وصلنا إلى آخر تمارين القسم وحان الوقت لإرسال الحلول إلى GitHub والإشارة إلى أنك أكملت التمارين ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل connect من سلسلة Deep Dive Into Modern Web Development
  12. لنوسّع التطبيق الآن بحيث تُخزَّن الملاحظات في الواجهة الخلفية. سنستخدم في عملنا خادم Json الذي خبرناه في القسم 2. خُزِّنت الحالة الأولية لقاعدة البيانات في الملف "db.json" الموجود في جذر المشروع: { "notes": [ { "content": "the app state is in redux store", "important": true, "id": 1 }, { "content": "state changes are made with actions", "important": false, "id": 2 } ] } سنثبت خادم JSON من أجل مشروعنا: npm install json-server --save-dev أضف الشيفرة التالية إلى قسم السكربت في الملف "package.json": "scripts": { "server": "json-server -p3001 --watch db.json", // ... } سنشغّل خادم JSON باستخدام الأمر npm run server. وسننشئ أيضًا تابعًا في الملف "services/notes.js" الذي سيستخدم المكتبة axios لإحضار البيانات من الواجهة الخلفية: import axios from 'axios' const baseUrl = 'http://localhost:3001/notes' const getAll = async () => { const response = await axios.get(baseUrl) return response.data } export default { getAll } يجب إضافة المكتبة axios إلى المشروع: npm install axios سنغيّر القيمة الأولية للحالة في دالة الاختزال noteReducer بحيث لا تكون هناك أية ملاحظات: const noteReducer = (state = [], action) => { // ... } من الطرق السريعة لإعداد الحالة بناء على البيانات الموجودة في الخادم هو إحضار الملاحظات الموجودة في الملف "index.js" ثم إيفاد الفعلNEW_NOTE لكلٍ منها: // ... import noteService from './services/notes' const reducer = combineReducers({ notes: noteReducer, filter: filterReducer, }) const store = createStore(reducer) noteService .getAll() .then(notes => notes.forEach(note => { store.dispatch({ type: 'NEW_NOTE', data: note }) })) // ... سندعم الفعل INIT_NOTES ضمن دالة الاختزال حتى نستطيع إعادة التهيئة باستخدام عملية إيفاد واحدة. سننشئ كذلك الدالة المولدة للأفعال initializeNotes: // ... const noteReducer = (state = [], action) => { console.log('ACTION:', action) switch (action.type) { case 'NEW_NOTE': return [...state, action.data] case 'INIT_NOTES': return action.data // ... } } export const initializeNotes = (notes) => { return { type: 'INIT_NOTES', data: notes, } } // ... سيصبح الملف "index.js" بالشكل: import noteReducer, { initializeNotes } from './reducers/noteReducer' // ... noteService.getAll().then(notes => store.dispatch(initializeNotes(notes)) ) لكننا قررنا مع ذلك نقل شيفرة التهيئة الأولية للملاحظات إلى المكوّن App، وسنستخدم كالعادة خطاف التأثير لإحضار البيانات من الخادم: import React, {useEffect} from 'react'import NewNote from './components/NewNote' import Notes from './components/Notes' import VisibilityFilter from './components/VisibilityFilter' import noteService from './services/notes' import { initializeNotes } from './reducers/noteReducer'import { useDispatch } from 'react-redux' const App = () => { const dispatch = useDispatch() useEffect(() => { noteService .getAll().then(notes => dispatch(initializeNotes(notes))) }, []) return ( <div> <NewNote /> <VisibilityFilter /> <Notes /> </div> ) } export default App سيعطي المدقق ESlint تحذيرًا عند استخدام خطاف التأثير: يمكن التخلص من هذا التحذير كالتالي: const App = () => { const dispatch = useDispatch() useEffect(() => { noteService .getAll().then(notes => dispatch(initializeNotes(notes))) }, [dispatch]) // ... } أضف المتغير الذي عرفناه ضمن المكوّن App والذي يمثل عمليًا دالة الإيفاد إلى مخزن Redux، إلى المصفوفة التي يستقبلها خطاف التأثير كمعامل. فإن تغيرت قيمة متغير الإيفاد أثناء التشغيل، سيُنفَّذ التأثير مجددًا. لن يحدث هذا في تطبيقنا، لذلك فالتحذير السابق ليس بذي أهمية. يمكن التخلص من التحذير السابق أيضًا بإلغاء تدقيق ذلك السطر: const App = () => { const dispatch = useDispatch() useEffect(() => { noteService .getAll().then(notes => dispatch(initializeNotes(notes))) },[]) // eslint-disable-line react-hooks/exhaustive-deps // ... } ليس جيدًا إلغاء عمل المدقق eslint عندما يعطي إنذارًا، وحتى لو سبب المدقق بعض الإشكاليات سنعتمد الحل الأول. يمكنك الاطلاع على معلومات أكثر عن اعتماديات الخطافات في توثيق React. يمكن أن نفعل المثل عندما ننشئ ملاحظة جديدة. لنوسّع شيفرة الاتصال مع الخادم كالتالي: const baseUrl = 'http://localhost:3001/notes' const getAll = async () => { const response = await axios.get(baseUrl) return response.data } const createNew = async (content) => { const object = { content, important: false } const response = await axios.post(baseUrl, object) return response.data} export default { getAll, createNew, } يتغير التابع addNote العائد للمكوّن NewNote قليلًا: import React from 'react' import { useDispatch } from 'react-redux' import { createNote } from '../reducers/noteReducer' import noteService from '../services/notes' const NewNote = (props) => { const dispatch = useDispatch() const addNote = async (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' const newNote = await noteService.createNew(content) dispatch(createNote(newNote)) } return ( <form onSubmit={addNote}> <input name="note" /> <button type="submit">add</button> </form> ) } export default NewNote سنغير مولد الأفعال createNote لأنّ الواجهة الخلفية هي من تولد المعرفات الخاصة بالملاحظات (id): export const createNote = (data) => { return { type: 'NEW_NOTE', data, } } يمكن تغيير أهمية الملاحظة باستخدام المبدأ نفسه، أي بتنفيذ طلب غير متزامن إلى الخادم ومن ثم إيفاد الفعل المناسب. يمكن إيجاد شيفرة التطبيق بوضعه الحالي في الفرع part6-3 ضمن المجلد الخاص بالتطبيق على GitHub التمرينان 6.13 - 6.14 6.13 تطبيق الطرائف على الواجهة الخلفية: الخطوة 1 أحضر الملاحظات من الواجهة الخلفية التي تعمل على خادم JSON بمجرد تشغيل التطبيق. يمكنك الاستعانة بالشيفرة الموجودة على GitHub. 6.14 تطبيق الطرائف على الواجهة الخلفية: الخطوة 2 عدّل طريقة إنشاء طرفة جديدة لكي تُخزَّن في الواجهة الخلفية. الأفعال غير المتزامنة والمكتبة Redux-Thunk مقاربتنا التي اعتمدناها في تطوير التطبيق جيدة، لكن ليس جيدًا أن يجري الاتصال مع الخادم داخل دالة المكوّن. فمن الأفضل أن نفصل شيفرة الاتصال عن المكوّنات، لكي يكون عملها الوحيد هو طلب دالة مولد الأفعال. في مثالنا السابق، سيهيء المكوّن App حالة التطبيق كالتالي: const App = () => { const dispatch = useDispatch() useEffect(() => { dispatch(initializeNotes())) },[dispatch]) // ... } وسينشئ المكوّن NewNote ملاحظة جديدة كالتالي: const NewNote = () => { const dispatch = useDispatch() const addNote = async (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' dispatch(createNote(content)) } // ... } سيستخدم المكونين الدوال التي تُمرّر إليهما كخصائص فقط، دون الالتفات إلى عملية الاتصال مع الخادم والتي تجري في الكواليس. لنثبت الآن المكتبة redux-thunk التي تُمكِّن من إنشاء أفعال غير متزامنة كالتالي: npm install redux-thunk تدعى المكتبة redux-thunk أحيانًا بأداة Redux الوسطية، والتي يجب أن تهيأ مع المخزن. وطالما وصلنا إلى هذه النقطة، لنفصل إذًا تعريف المخزن ونضعه في ملفه الخاص "src/store.js": import { createStore, combineReducers, applyMiddleware } from 'redux' import thunk from 'redux-thunk' import { composeWithDevTools } from 'redux-devtools-extension' import noteReducer from './reducers/noteReducer' import filterReducer from './reducers/filterReducer' const reducer = combineReducers({ notes: noteReducer, filter: filterReducer, }) const store = createStore( reducer, composeWithDevTools( applyMiddleware(thunk) ) ) export default store سيبدو الملف "src/index.js" بعد التغييرات كالتالي: import React from 'react' import ReactDOM from 'react-dom' import { Provider } from 'react-redux' import store from './store' import App from './App' ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById('root') ) يمكننا الآن باستخدام المكتبة redux-thunk أن نعرّف مولد أفعال يعيد دالة تحتوي التابع dispatch العائد للمكتبة Redux كمعامل لها. ونتيجة لذلك يمكن إنشاء مولدات أفعال غير متزامنة تنتظر حتى تنتهي عملية ما، ثم توفد الفعل الحقيقي. سنعرّف الآن مولد الأفعال initializeNotes الذي يهيئ حالة الملاحظات كالتالي: export const initializeNotes = () => { return async dispatch => { const notes = await noteService.getAll() dispatch({ type: 'INIT_NOTES', data: notes, }) } } كفعل غير متزامن، تحضر العملية كل الملاحظات من الخادم ومن ثم توفدها إلى هذا الفعل الذي يضيفها إلى المخزن. سيُعرَّف المكوّن App الآن كالتالي: const App = () => { const dispatch = useDispatch() useEffect(() => { dispatch(initializeNotes()) },[dispatch]) return ( <div> <NewNote /> <VisibilityFilter /> <Notes /> </div> ) } تقدّم الطريقة السابقة حلًا أنيقًا، حيث فُصل منطق عملية تهيئة الملاحظات كليًا خارج مكوّن React. سيبدو مولد الأفعال createNote الذي يُنشئ الملاحظة الجديدة كالتالي: export const createNote = content => { return async dispatch => { const newNote = await noteService.createNew(content) dispatch({ type: 'NEW_NOTE', data: newNote, }) } } اتبعنا أيضًا المبدأ نفسه، حيث تُنفّذ بداية عملية غير متزامنة، ومن ثم يوفد الفعل الذي سيغير حالة المخزن. سيتغير المكوّن NewNote ليصبح على النحو: const NewNote = () => { const dispatch = useDispatch() const addNote = async (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' dispatch(createNote(content)) } return ( <form onSubmit={addNote}> <input name="note" /> <button type="submit">lisää</button> </form> ) } يمكن إيجاد شيفرة التطبيق بوضعه الحالي في الفرع part6-4 ضمن المجلد الخاص بالتطبيق على GitHub. التمارين 6.15 - 6.18 6.15 تطبيق الطرائف على الواجهة الخلفية: الخطوة 3 عدّل الطريقة التي تجري فيها تهيئة مخزن Redux لتستخدم مولدات الأفعال غير المتزامنة، اعتمادًا على المكتبة Redux-Thunk. 6.16 تطبيق الطرائف على الواجهة الخلفية: الخطوة 4 عدّل أيضًا طريقة إنشاء طرفة جديدة لتستخدم مولدات الأفعال غير المتزامنة بمساعدة المكتبة Redux-Thunk. 6.17 تطبيق الطرائف على الواجهة الخلفية: الخطوة 5 لا تخزّن نتائج عملية التصويت حتى اللحظة ضمن الواجهة الخلفية. أصلح ذلك بمساعدة المكتبة Redux-Thunk. 6.18 تطبيق الطرائف على الواجهة الخلفية: الخطوة 6 لاتزال طريقة إنشاء التنبيهات غير مناسبة، طالما أنها تحتاج إلى فعلين وإلى الدالة setTimeOut حتى تُنفَّذ: dispatch(setNotification(`new anecdote '${content}'`)) setTimeout(() => { dispatch(clearNotification()) }, 5000) أنشئ مولد أفعال غير متزامنة، يؤمن الحصول على التنبيه كما يلي: dispatch(setNotification(`you voted '${anecdote.content}'`, 10)) يمثل النص الذي ينبغي تصييره المعامل الأول، أما المعامل الثاني فهو الوقت الذي يعرض خلاله التنبيه بالثانية. أضف هذا الأسلوب في إظهار التنبيهات إلى تطبيقك. ترجمة -وبتصرف- للفصل communication with server in redux من سلسلة Deep Dive Into Modern Web Development
  13. لنتابع عملنا على نسخة Redux المبسطة من تطبيق الملاحظات. ولكي نسهل الطريق علينا في تطوير المطلوب، سنعدّل دالة الاختزال بحيث نهيئ حالة المخزّن ليحوي ملاحظتين: const initialState = [ { content: 'reducer defines how redux store works', important: true, id: 1, }, { content: 'state of store can contain any data', important: false, id: 2, }, ] const noteReducer = (state = initialState, action) => { // ... } // ... export default noteReducer مخزن يضم حالة مركبة لنضف إمكانية انتقاء الملاحظات التي ستُعرض للمستخدم. ستحتوي واجهة المستخدم أزرار انتقاء (Radio Buttons) لإنجاز المطلوب. لنبدأ بإضافة بسيطة جدًا ومباشرة للشيفرة: import React from 'react' import NewNote from './components/NewNote' import Notes from './components/Notes' const App = () => { const filterSelected = (value) => { console.log(value) } return ( <div> <NewNote /> <div> all <input type="radio" name="filter" onChange={() => filterSelected('ALL')} /> important <input type="radio" name="filter" onChange={() => filterSelected('IMPORTANT')} /> nonimportant <input type="radio" name="filter" onChange={() => filterSelected('NONIMPORTANT')} /> </div> <Notes /> </div> ) } وطالما أنّ الصفة name هي نفسها لكل أزرار الانتقاء، فستشكِّل هذه الأزرار مجموعة واحدة يمكن اختيار أحدها فقط. تمتلك الأزرار معالج تغيرات وظيفته الحالية طباعة النص الذي يظهر بجوار الزر على الطرفية. قررنا إضافة وظيفة لانتقاء الملاحظات بتخزين قيمة مُرشّح الانتقاء (filter) في مخزن Redux إلى جانب الملاحظات. ستبدو حالة المخزن بعد إجراء التعديلات على النحو التالي: { notes: [ { content: 'reducer defines how redux store works', important: true, id: 1}, { content: 'state of store can contain any data', important: false, id: 2} ], filter: 'IMPORTANT' } ستُخزَّن فقط مصفوفة الملاحظات في حالة المُرشِّح بعد إضافة الوظيفة السابقة إلى التطبيق. سيمتلك الآن كائن الحالة خاصيتين هما notes التي تضم مصفوفة الملاحظات، وfilter التي تضم النص الذي يشير إلى الملاحظات التي ينبغي عرضها للمستخدم. دوال الاختزال المدمجة يمكننا تعديل دالة الاختزال في تطبيقنا لتتعامل مع الشكل الجديد للحالة. لكن من الأفضل في وضعنا هذا تعريف دالة اختزال جديدة خاصة بحالة المرشِّح: const filterReducer = (state = 'ALL', action) => { switch (action.type) { case 'SET_FILTER': return action.filter default: return state } } تمثل الشيفرة التالية الأفعال التي ستغير حالة المُرشِّح: { type: 'SET_FILTER', filter: 'IMPORTANT' } لننشئ أيضًا دالة توليد أفعال جديدة. وسنكتب الشيفرة اللازمة في وحدة جديدة نضعها في الملف "src/reducers/filterReducer.js": const filterReducer = (state = 'ALL', action) => { // ... } export const filterChange = filter => { return { type: 'SET_FILTER', filter, } } export default filterReducer يمكننا كتابة دالة الاختزال الفعلية لتطبيقنا بدمج الدالتين السابقتين باستخدام الدالة combineReducers. لنعرِّف دالة الاختزال المدمجة في الملف "index.js": import React from 'react' import ReactDOM from 'react-dom' import { createStore, combineReducers } from 'redux'import { Provider } from 'react-redux' import App from './App' import noteReducer from './reducers/noteReducer' import filterReducer from './reducers/filterReducer' const reducer = combineReducers({ notes: noteReducer, filter: filterReducer}) const store = createStore(reducer) console.log(store.getState()) ReactDOM.render( /* <Provider store={store}> <App /> </Provider>, */ <div />, document.getElementById('root') ) وطالما أنّ تطبيقنا سيخفق تمامًا عند بلوغنا هذه النقطة، سنصيّر عنصر div فارغ بدلًا من المكون App. ستُطبع الآن حالة المخزن على الطرفية: لاحظ أنّ للمخزن الشكل الذي نريده تمامًا. لنلق نظرة أقرب على طريقة إنشاء دالة الاختزال المدمجة: const reducer = combineReducers({ notes: noteReducer, filter: filterReducer, }) إنّ حالة المخزن التي عرفناها في الشيفرة السابقة، هي كائن له خاصيتين: notes وfilter. تُعرَّف قيمة الخاصية من خلال الدالة noteReducer والتي لن تتعامل مع الخواص الأخرى للكائن، كما تتحكم الدالة filterReducer بالخاصية الأخرى filter فقط. قبل أن نتابع سلسلة التغيرات التي نجريها على الشيفرة، لنلق نظرة على طريقة تغيير حالة المخزن باستخدام الأفعال التي تُعرِّفها دوال الاختزال المدمجة. لنضف الشيفرة التالية إلى الملف "index.js": import { createNote } from './reducers/noteReducer' import { filterChange } from './reducers/filterReducer' //... store.subscribe(() => console.log(store.getState())) store.dispatch(filterChange('IMPORTANT')) store.dispatch(createNote('combineReducers forms one reducer from many simple reducers')) بمحاكاة الطريقة التي تُنشأ بها الملاحظة الجديدة ومحاكاة التغييرات التي تطرأ على حالة المرشِّح بهذا الأسلوب، ستُطبع حالة المخزن على الطرفية في كل مرة يحدث فيها تغيير على المخزن: ويجدر بك الآن أن تنتبه إلى تفصيل صغير لكنه هام، فلو أضفنا أمر الطباعة إلى الطرفية في بداية كلتا دالتي الاختزال: const filterReducer = (state = 'ALL', action) => { console.log('ACTION: ', action) // ... } فقد يُهيّأ للشخص بناءً على ما طُبع في الطرفية، أنّ كل فعل قد نُفِّذ مرتين: هل هناك ثغرة ياترى في تطبيقنا؟ الجواب هو لا. إذ تقتضي طريقة عمل دوال الاختزال المدمجة التعامل مع كل فعل في كل جزء من الدالة. ومن المفترض أن تهتم دالة اختزال واحدة بفعل محدد، لكن قد تصادفنا حالات تغيّر فيها عدة دوال اختزال الأجزاء التي تتعامل معها من الحالة اعتمادًا على نفس الفعل. إكمال شيفرة مُرشِّحات الانتقاء لنكمل التطبيق بحيث يستخدم دوال الاختزال المدمجة. سنبدأ بتغيير طريقة تصيير التطبيق، ولنعلّق المخزن ضمنه وذلك بتعديل الملف "index.js" ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById('root') ) سنصلح تاليًا الثغرة التي نتجت عن توقع الشيفرة بأن تكون حالة التطبيق عبارة عن مصفوفة من الملاحظات: يمكن إنجاز ذلك بسهولة، لأنّ الملاحظات مخزنة أصلًا في الحقل notes من المخزن. لذلك سيفي التعديل البسيط التالي على دالة الانتقاء بالغرض: const Notes = () => { const dispatch = useDispatch() const notes = useSelector(state => state.notes) return( <ul> {notes.map(note => <Note key={note.id} note={note} handleClick={() => dispatch(toggleImportanceOf(note.id)) } /> )} </ul> ) } أعادت دالة الانتقاء سابقًا حالة المخزن بأكملها: const notes = useSelector(state => state) بينما ستعيد الآن الحقل notes فقط: const notes = useSelector(state => state.notes) لنفصل مرشّح إظهار الملاحظات في مكوِّن خاص به ضمن الملف "src/components/VisibilityFilter.js": import React from 'react' import { filterChange } from '../reducers/filterReducer' import { useDispatch } from 'react-redux' const VisibilityFilter = (props) => { const dispatch = useDispatch() return ( <div> all <input type="radio" name="filter" onChange={() => dispatch(filterChange('ALL'))} /> important <input type="radio" name="filter" onChange={() => dispatch(filterChange('IMPORTANT'))} /> nonimportant <input type="radio" name="filter" onChange={() => dispatch(filterChange('NONIMPORTANT'))} /> </div> ) } export default VisibilityFilter سيصبح المكوِّن App بعد إنشاء المكوِّن السابق بسيطًا كالتالي: import React from 'react' import Notes from './components/Notes' import NewNote from './components/NewNote' import VisibilityFilter from './components/VisibilityFilter' const App = () => { return ( <div> <NewNote /> <VisibilityFilter /> <Notes /> </div> ) } export default App ستظهر نتيجة الإضافة مباشرة الآن. فبالنقر على أزرار الانتقاء ستتغير حالة الخاصية filter للمخزن. لنغيّر المكوِّن Notes بحيث يتضمن المرشِّح: const Notes = () => { const dispatch = useDispatch() const notes = useSelector(state => { if ( state.filter === 'ALL' ) { return state.notes } return state.filter === 'IMPORTANT' ? state.notes.filter(note => note.important) : state.notes.filter(note => !note.important) }) return( <ul> {notes.map(note => <Note key={note.id} note={note} handleClick={() => dispatch(toggleImportanceOf(note.id)) } /> )} </ul> ) نجري التعديلات على دالة الانتقاء فقط، والتي كانت: useSelector(state => state.notes) لنبسّط دالة الانتقاء بتفكيك حقل الحالة الذي يُمرَّر إليها كمعامل: const notes = useSelector(({ filter, notes }) => { if ( filter === 'ALL' ) { return notes } return filter === 'IMPORTANT' ? notes.filter(note => note.important) : notes.filter(note => !note.important) }) لكن هناك عيب بسيط في التطبيق. فعلى الرغم من ضبط المرشِّح افتراضيًا على القيمة ALL، لن يُختار أي زر من أزراء الانتقاء. يمكننا إيجاد الحل طبعًا، لكننا سنؤجل ذلك إلى وقت لاحق، طالما أنّ الثغرة لن تتسبب بأية مشاكل سوى أنها غير مرغوبة. أدوات تطوير Redux يمكن تثبيت الموسِّع Redux DevTools على المتصفح، الذي يساعد على مراقبة حالة مخزن والأفعال التي تغيّرها من خلال طرفية المتصفح. بالإضافة إلى الموسِّع السابق، يمكننا الاستفادة من المكتبة redux-devtools-extension أثناء التنقيح. لنثبت هذه المكتبة كالتالي: npm install --save-dev redux-devtools-extension سنجري تعديلًا بسيطًا على تعريف المخزن لنتمكن من العمل مع المكتبة: // ... import { createStore, combineReducers } from 'redux' import { composeWithDevTools } from 'redux-devtools-extension' import noteReducer from './reducers/noteReducer' import filterReducer from './reducers/filterReducer' const reducer = combineReducers({ notes: noteReducer, filter: filterReducer }) const store = createStore( reducer, composeWithDevTools()) export default store عندما نفتح الطرفية الآن، ستبدو نافذة Redux كالتالي: يمكن بسهولة مراقبة كل فعل يجري على المخزن كما يُظهر الشكل التالي: كما يمكن إيفاد الأفعال إلى المخزن مستخدمين الطرفية كالتالي: يمكنك إيجاد الشيفرة الكاملة لتطبيقنا الحالي في الفرع part6-2 من المستودع المخصص للتطبيق على GitHub. التمارين 6.9 - 6.12 لنكمل العمل على تطبيق الطرائف باستخدام Redux والذي بدأناه في الفصل السابق. 6.9 تطبيق طرائف أفضل: الخطوة 7 ابدأ باستخدام Redux DevTools. وانقل تعريف مخزن Redux إلى ملف خاص به وسمّه "store.js" 6.10 تطبيق طرائف أفضل: الخطوة 8 يمتلك التطبيق جسمًا معدًا مسبقًا يضم المكوَّن Notification: import React from 'react' const Notification = () => { const style = { border: 'solid', padding: 10, borderWidth: 1 } return ( <div style={style}> render here notification... </div> ) } export default Notification وسّع المكوِّن ليصيّر الرسالة المخزنّة في مخزن Redux، وليأخذ الشكل التالي: import React from 'react' import { useSelector } from 'react-redux' const Notification = () => { const notification = useSelector(/* something here */) const style = { border: 'solid', padding: 10, borderWidth: 1 } return ( <div style={style}> {notification} </div> ) } عليك أن تجري تعديلات على دالة الاختزال الموجودة في التطبيق. أنشئ دالة اختزال جديدة للوظيفة الجديدة، ثم أعد كتابة التطبيق ليستخدم دالة اختزال مدمجة كما فعلنا سابقًا في هذا الفصل. لا تستخدم المكوّن Notification لغير الغرض الذي أنشئ لأجله حاليًا. يكفي أن يُظهر التطبيق القيمة الأولية للرسالة في الدالة notificationReducer. 6.11 تطبيق طرائف أفضل: الخطوة 9 وسّع التطبيق لكي يعرض المكون الرسالة مدة 5 ثوانٍ، وذلك عندما يصوّت المستخدم لصالح طرفة أو عندما ينشئ طرفة جديدة. ننصحك بإنشاء دالة مولد أفعال منفصلة من أجل ضبط و حذف التنبيهات. 6.12 تطبيق طرائف أفضل: الخطوة 10 * أضف وظيفة لانتقاء الطرائف التي ستعرض للمستخدم. خزّن حالة المرشِّح في مخزن Redux. ويفضّل إنشاء دالة اختزال ومولد أفعال جديدين لهذه الغاية. أنشئ المكوّن الجديد Filter لعرض الطرائف التي تم انتقاؤها. يمكنك استخدام الشيفرة التالية كقالب للمكوِّن: import React from 'react' const Filter = () => { const handleChange = (event) => { // input-field value is in variable event.target.value } const style = { marginBottom: 10 } return ( <div style={style}> filter <input onChange={handleChange} /> </div> ) } export default Filter ترجمة -وبتصرف- للفصل Many reducers من سلسلة Deep Dive Into Modern Web Development
  14. لقد اتبعنا حتى هذه اللحظة التقاليد التي تنصح بها React في إدارة الحالة. حيث وضعنا الحالة والتوابع التي تتعامل معها في المكوِّن الجذري للتطبيق. وهكذا أمكننا تمرير الحالة وتوابعها إلى بقية المكونات من خلال الخصائص. يمكن اعتماد الأسلوب السابق إلى حد معين، لكن بمجرد أن ينمو التطبيق، ستواجهنا العديد من التحديات المتعلقة بإدارة الحالة. المعمارية Flux طورت Facebook المعمارية Flux لتسهيل إدارة الحالة في التطبيقات. حيث تُفصل الحالة في هذه المعمارية بشكل كامل عن المكوِّنات ضمن مخازنها الخاصة. لا تتغير الحالة الموجودة في المخازن مباشرة، بل عبر العديد من الأفعال. فعندما يغير الفعل حالة المخزن، يُعاد تصيير المشهد كاملًا: وإن ولدت بعض الأفعال، كنقر زرٍ مثلًا، الحاجة إلى تغيير حالة التطبيق، سيجري هذا التغيير من خلال فعل، ويسبب ذلك أيضًا إعادة تصيير المشهد من جديد: تقدم Flux طريقة معيارية عن كيفية ومكان حفظ الحالة وعن كيفية تعديلها. المكتبة Redux قدّمت FaceBook طريقة لاستخدام معمارية Flux، لكننا سنستخدم المكتبة Redux. تعمل هذه المكتبة وفق المبدأ نفسه، لكنها أسهل قليلًا. كما أنّ Facebook نفسها بدأت باستخدام Redux بدلًا من الطريقة التي ابتكرتها. سنتعرف على أسلوب عمل Redux من خلال إنشاء تطبيق العداد مرة أخرى: أنشئ تطبيقًا باستخدام createreactapp وثبِّت Redux كالتالي: Redux npm install redux تُخزّن الحالة في Redux في المخازن، كما هو الحال في Flux. تُخزَّن حالة التطبيق بأكملها ضمن كائن JavaScript واحد ويوضع في مخزن. لكن طالما أن تطبيقنا سيحتاج إلى قيمة العداد فقط، سنحفظه مباشرة في المخزن (دون الحاجة لوضعه في كائن JavaScript أولًا). بينما لو كانت الحالة أكثر تعقيدًا، لابدّ من حفظ كل جزء ضمن حقل منفصل من حقول كائن JavaScript. تتغير حالة المخزن من خلال الأفعال والتي تُعرّف بأنها كائنات تحتوي على الأقل حقلًا واحدًا يحدد نوع الفعل. سيحتاج تطبيقنا على سبيل المثال هذا النوع من الأفعال: { type: 'INCREMENT' } وإن تطلب الفعل وجود بيانات أخرى، يمكن أن نعرف حقولًا جديدة ونضعها فيها. لكن تطبيق العداد بسيط ولا يتطلب سوى الحقل الذي يحدد نوع الفعل. يُحدَّد تأثير الفعل على حالة التطبيق باستخدام دوال الاختزال reducer. وهي في الواقع دوال يُمرر إليها كل من الحالة في وضعها الراهن والفعل كمعاملين. تعيد هذه الدوال الحالة الجديدة. لنعرّف الآن دالة اختزال في تطبيقنا: const counterReducer = (state, action) => { if (action.type === 'INCREMENT') { return state + 1 } else if (action.type === 'DECREMENT') { return state - 1 } else if (action.type === 'ZERO') { return 0 } return state } نلاحظ أن الوسيط الأول هو الحالة والثاني هو الفعل. تُعيد دالة الاختزال حالة جديدة وفقًا لنوع الفعل. لنغيّر الشيفرة قليلًا. فمن العادة استخدام بنية التعداد switch بدلًا من البنية الشرطية if في دوال الاختزال. ولنجعل أيضًا القيمة 0 قيمة افتراضية للحالة. وهكذا ستعمل دالة الاختزال حتى لو لم تُحَّدد الحالة بعد: const counterReducer = (state = 0, action) => { switch (action.type) { case 'INCREMENT': return state + 1 case 'DECREMENT': return state - 1 case 'ZERO': return 0 default: // إن لم نحصل على إحدى الحالات السابقة return state } } من غير المفترض أن تُستدعى دالة الاختزال مباشرة من خلال شيفرة التطبيق. حيث تُمرَّر هذه الدوال كوسطاء إلى الدالة createStore التي تُنشئ مخزنًا: import { createStore } from 'redux' const counterReducer = (state = 0, action) => { // ... } const store = createStore(counterReducer) سيستخدم المخزن الآن دالة الاختزال للتعامل مع الأفعال التي تُرسل إلى المخزن من خلال التابع dispatch. store.dispatch({type: 'INCREMENT'}) كما يمكن إيجاد حالة مخزن باستخدام الأمر getState. ستطبع على سبيل المثال الشيفرة التالية: const store = createStore(counterReducer) console.log(store.getState()) store.dispatch({type: 'INCREMENT'}) store.dispatch({type: 'INCREMENT'}) store.dispatch({type: 'INCREMENT'}) console.log(store.getState()) store.dispatch({type: 'ZERO'}) store.dispatch({type: 'DECREMENT'}) console.log(store.getState()) المعلومات التالية على الطرفية: 0 3 -1 حيث كانت القيمة الافتراضية للحالة هي 0. لكن بعد ثلاثة أفعال زيادة INCREMENT، أصبحت قيمة الحالة 3. وأخيرُا بعد تنفيذ فعل التصفير ZERO وفعل الإنقاص DECREMENT أصبحت قيمة الحالة -1. يستخدم التابع subscribe الذي يعود لكائن المخزن في إنشاء دوال الاستدعاء التي يستخدمها المخزن عندما تتغير حالته. فلو أضفنا على سبيل المثال الدالة التالية إلى التابع subscribe، فسيُطبع كل تغيير في الحالة على الطرفية: store.subscribe(() => { const storeNow = store.getState() console.log(storeNow) }) وهكذا سيؤدي تنفيذ الشيفرة التالية: const store = createStore(counterReducer) store.subscribe(() => { const storeNow = store.getState() console.log(storeNow) }) store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'INCREMENT' }) store.dispatch({ type: 'ZERO' }) store.dispatch({ type: 'DECREMENT' }) إلى طباعة ما يلي على الطرفية: 1 2 3 0 -1 تمثل الشيفرة التالية شيفرة تطبيق العداد وقد كتبت جميعها في نفس الملف. لاحظ أننا استخدمنا المخازن بشكل مباشر ضمن شيفرة React. سنتعلم لاحقًا طرقًا أفضل لبناء شيفرة React/Redux import React from 'react' import ReactDOM from 'react-dom' import { createStore } from 'redux' const counterReducer = (state = 0, action) => { switch (action.type) { case 'INCREMENT': return state + 1 case 'DECREMENT': return state - 1 case 'ZERO': return 0 default: return state } } const store = createStore(counterReducer) const App = () => { return ( <div> <div> {store.getState()} </div> <button onClick={e => store.dispatch({ type: 'INCREMENT' })} > plus </button> <button onClick={e => store.dispatch({ type: 'DECREMENT' })} > minus </button> <button onClick={e => store.dispatch({ type: 'ZERO' })} > zero </button> </div> ) } const renderApp = () => { ReactDOM.render(<App />, document.getElementById('root')) } renderApp() store.subscribe(renderApp) ستجد عدة نقاط ملفتة في الشيفرة السابقة. سيصير المكوِّن App قيمة العداد بالحصول على قيمته من المخزن باستخدام التابع ()store.getState. كما ستوفد معالجات الأفعال المعرّفة في الأزرار الأفعال المناسبة إلى المخزن. لن تتمكن React من تصيير التطبيق تلقائيًا عندما تتغير حالة المخزن، ولهذا فقد هيئنا الدالة renderApp التي تصيّر التطبيق بأكمله لكي ترصد التغييرات في المخزن عن طريق وضعها ضمن التابع store.subscribe. لاحظ أنه علينا استدعاء التابع renderApp مباشرة، وبدونه لن يُصيَّر المكوِّن App للمرة الأولى. استخدام Redux مع تطبيق الملاحظات هدفنا في الفقرات التالية تعديل تطبيق الملاحظات لاستخدام Redux في إدارة الحالة. لكن قبل ذلك سنغطي بعض المفاهيم المفتاحية عبر إنشاء تطبيق ملاحظات مبسط، بحيث تكون للنسخة الأولى منه الشيفرة التالية: const noteReducer = (state = [], action) => { if (action.type === 'NEW_NOTE') { state.push(action.data) return state } return state } const store = createStore(noteReducer) store.dispatch({ type: 'NEW_NOTE', data: { content: 'the app state is in redux store', important: true, id: 1 } }) store.dispatch({ type: 'NEW_NOTE', data: { content: 'state changes are made with actions', important: false, id: 2 } }) const App = () => { return( <div> <ul> {store.getState().map(note=> <li key={note.id}> {note.content} <strong>{note.important ? 'important' : ''}</strong> </li> )} </ul> </div> ) } لا يمتلك التطبيق بشكله الحالي وظيفة إضافة ملاحظات جديدة، على الرغم من إمكانية إضافتها من خلال إيفاد الفعل NEW_NOTE إلى المخزن. لاحظ امتلاك الأفعال الآن حقلًا للنوع وآخر للبيانات ويحتوي على الملاحظة الجديدة التي سنضيفها: { type: 'NEW_NOTE', data: { content: 'state changes are made with actions', important: false, id: 2 } } الدوال النقيّة والدوال الثابتة للنسخة الأولية من دالة الاختزال الشكل البسيط التالي: const noteReducer = (state = [], action) => { if (action.type === 'NEW_NOTE') { state.push(action.data) return state } return state } تتخذ الحالة الآن شكل مصفوفة. وتسبب الأفعال من النوع NEW_NOTE إضافة ملاحظة جديدة إلى الحالة عبر التابع push. سيعمل التطبيق، لكن دالة الاختزال قد عُرِّفت بطريقة سيئة لأنها ستخرق الافتراض الرئيسي لدوال الاختزال والذي ينص على أن دوال الاختزال يجب أن تكون دوال نقيّة pure functions. الدوال النقية هي دوال لا تسبب أية تأثيرات جانبية وعليها أن تعيد نفس الاستجابة عندما تُستدعى بنفس المعاملات. أضفنا ملاحظة جديدة إلى الحالة مستخدمين التابع (state.push(action.data الذي يغير وضع كائن الحالة. لكن هذا الأمر غير مسموح. يمكن حل المشكلة بسهولة باستخدام التابع concat الذي ينشئ مصفوفة جديدة تحتوي كل عناصر المصفوفة القديمة بالإضافة إلى العنصر الجديد: const noteReducer = (state = [], action) => { if (action.type === 'NEW_NOTE') { return state.concat(action.data) } return state } كما ينبغي أن تتكون دوال الاختزال من كائنات ثابتة (immutable). فلو حدث تغيّر في الحالة، فلن يغير ذلك كائن الحالة، بل سيُستبدل بكائن جديد يحتوي الحالة الجديدة. وهذا ما فعلناه تمامًا بدالة الاختزال الجديدة، حين استبدلنا المصفوفة القديمة بالجديدة. لنوسّع دالة الاختزال لتتمكن من التعامل مع تغيّر أهمية الملاحظة: { type: 'TOGGLE_IMPORTANCE', data: { id: 2 } } وطالما أننا لم نكتب أية شيفرة للاستفادة من هذه الوظيفة، فما فعلناه هو توسيع دالة الاختزال بالأسلوب "المقاد بالاختبار-Test Driven". لنبدأ إذًا بكتابة اختبار يتعامل مع الفعل NEW_NOTE. ولتسهيل الاختبار، سننقل شيفرة دالة الاختزال إلى وحدة مستقلة ضمن الملف "src/reducers/noteReducer.js". سنضيف أيضًا المكتبة deep-freeze والتي تستخدم للتأكد من تعريف دالة الاختزال كدالة ثابتة. سنثبت المكتبة كاعتمادية تطوير كالتالي: npm install --save-dev deep-freeze تمثل الشيفرة التالية شيفرة الاختبار الموجود في الملف src/reducers/noteReducer.test.js: import noteReducer from './noteReducer' import deepFreeze from 'deep-freeze' describe('noteReducer', () => { test('returns new state with action NEW_NOTE', () => { const state = [] const action = { type: 'NEW_NOTE', data: { content: 'the app state is in redux store', important: true, id: 1 } } deepFreeze(state) const newState = noteReducer(state, action) expect(newState).toHaveLength(1) expect(newState).toContainEqual(action.data) }) }) يتوثّق الأمر (deepFreeze(state من أن دالة الاختبار لن تغير حالة المخزن التي تُمرّر إليها كمعامل. وإن استخدمت دالة الاختزال التابع push لتغيير الحالة، سيخفق الاختبار. سنكتب الآن اختبارًا للفعل TOGGLE_IMPORTANT: test('returns new state with action TOGGLE_IMPORTANCE', () => { const state = [ { content: 'the app state is in redux store', important: true, id: 1 }, { content: 'state changes are made with actions', important: false, id: 2 }] const action = { type: 'TOGGLE_IMPORTANCE', data: { id: 2 } } deepFreeze(state) const newState = noteReducer(state, action) expect(newState).toHaveLength(2) expect(newState).toContainEqual(state[0]) expect(newState).toContainEqual({ content: 'state changes are made with actions', important: true, id: 2 }) }) سيغيّر الفعل التالي: { type: 'TOGGLE_IMPORTANCE', data: { id: 2 } } أهمية الملاحظة التي تحمل المعرّف الفريد 2. سنوسع التطبيق كالتالي: const noteReducer = (state = [], action) => { switch(action.type) { case 'NEW_NOTE': return state.concat(action.data) case 'TOGGLE_IMPORTANCE': { const id = action.data.id const noteToChange = state.find(n => n.id === id) const changedNote = { ...noteToChange, important: !noteToChange.important } return state.map(note => note.id !== id ? note : changedNote ) } default: return state } } أنشأنا نسخة من الملاحظة التي غيّرنا أهميتها بالطريقة التي اتبعناها في القسم 2، كما استبدلنا الحالة بأخرى جديدة تضم الملاحظات القديمة التي لم تتغير أهميتها ونسخة عن الملاحظة التي تغيرت أهميتها changedNote. لنلخص ما يجري عند تنفيذ الشيفرة. ففي البداية سنبحث عن كائن الملاحظة التي نريد أن نغير أهميتها: const noteToChange = state.find(n => n.id === id) سننشئ بعد ذلك كائنًا جديدًا يمثل نسخة عن الملاحظة الأصلية، لكن أهميتها قد تغيرت إلى الحالة المعاكسة: const changedNote = { ...noteToChange, important: !noteToChange.important } تُعاد بعد ذلك الحالة الجديدة. وذلك بأخذ جميع الملاحظات القديمة التي لم تتغير حالتها واستبدال الملاحظة التي تغيرت بالنسخة المعدلّة عنها: state.map(note => note.id !== id ? note : changedNote ) العبارات البرمجية لنشر مصفوفة نمتلك الآن اختبارين جيدين لدوال الاختزال، لذلك يمكننا إعادة كتابة الشيفرة لإضافة ملاحظة جديدة تنشئ حالة تعيدها دالة المصفوفات concat. لنلق نظرة على كيفية إنجاز المطلوب باستخدام عامل نشر المصفوفة: const noteReducer = (state = [], action) => { switch(action.type) { case 'NEW_NOTE': return [...state, action.data] case 'TOGGLE_IMPORTANCE': // ... default: return state } } ستعمل دالة نشر المصفوفة على النحو التالي: لو عرفنا المتغير number كالتالي: const numbers = [1, 2, 3] سيفصل الأمر number... المصفوفة إلى عناصرها المفردة بحيث يمكن وضع هذه العناصر في مصفوفة أخرى كالتالي: [...numbers, 4, 5] وستكون النتيجة [1,2,3,4,5]. لكن لو استخدمنا المصفوفة دون نشر كالتالي: [numbers, 4, 5] ستكون النتيجة [4,5,[1,2,3]]. كما ستبدو الشيفرة مشابهة لما سبق، عندما نفكك المصفوفة إلى عناصرها باستخدام التابع destructuring. const numbers = [1, 2, 3, 4, 5, 6] const [first, second, ...rest] = numbers console.log(first) // prints 1 console.log(second) // prints 2 console.log(rest) // prints [3, 4, 5, 6] التمرينات 6.1 - 6.2 لنكتب نسخة بسيطة عن تطبيق unicafe الذي أنشأناه في القسم 1، بحيث ندير الحالة باستخدام Redux. يمكنك الحصول على المشروع من المستودع الخاص بالتطبيق على GitHub لتستعمله كأساس لمشروعك. إبدأ عملك بإزالة تهيئة git للنسخة التي لديك، ثم تثبيت الاعتماديات اللازمة: cd unicafe-redux // تنقلك هذه التعليمة إلى مجلد المستودع الذي يضمن نسختك rm -rf .git npm install 6.1 unicafe مرور ثانٍ: الخطوة 1 قبل أن نضيف الوظائف إلى واجهة المستخدم، سنعمل على إضافة وظيفة مستودع الحالة. إذ علينا أن نخزّن عدد الآراء من كل نوع وسيبدو شكل المخزن كالتالي: { good: 5, ok: 4, bad: 2 } سيكون لدالة الاختزال الأساسية الشكل التالي: const initialState = { good: 0, ok: 0, bad: 0 } const counterReducer = (state = initialState, action) => { console.log(action) switch (action.type) { case 'GOOD': return state case 'OK': return state case 'BAD': return state case 'ZERO': return state } return state } export default counterReducer وتمثل الشيفرة التالية أساسًا للاختبارات: import deepFreeze from 'deep-freeze' import counterReducer from './reducer' describe('unicafe reducer', () => { const initialState = { good: 0, ok: 0, bad: 0 } test('should return a proper initial state when called with undefined state', () => { const state = {} const action = { type: 'DO_NOTHING' } const newState = counterReducer(undefined, action) expect(newState).toEqual(initialState) }) test('good is incremented', () => { const action = { type: 'GOOD' } const state = initialState deepFreeze(state) const newState = counterReducer(state, action) expect(newState).toEqual({ good: 1, ok: 0, bad: 0 }) }) }) إضافة الاختبارات ودالة الاختزال: تأكد من خلال الاختبارات أن دالة الاختزال ثابتة، مستخدمًا المكتبة deep-freeze. وتأكد من نجاح الاختبار الأول، إذ ستتوقع Redux أن تعيد دالة الاختزال قيمة منطقية للحالة الأصلية عند استدعائها، ذلك أن المعامل state الذي يمثل الحالة السابقة، لن يكون معرّفًا في البداية. وسّع بعد ذلك دالة الاختزال بحيث ينجح الاختباران، ثم أضف بقية الاختبارات. وأخيرًا أضف الوظائف التي سُتختبر. يمكنك الاستعانة بنموذج دالة الاختزال الذي اعتمدناه في تطبيق الملاحظات السابق. 6.2 unicafe مرور ثانٍ: الخطوة 2 أضف الآن الوظائف الفعلية للتطبيق. النماذج الحرّة سنضيف تاليًا وظيفةً لإنشاء ملاحظة جديدة وتغيير أهميتها: const generateId = () => Number((Math.random() * 1000000).toFixed(0)) const App = () => { const addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' store.dispatch({ type: 'NEW_NOTE', data: { content, important: false, id: generateId() } }) } const toggleImportance = (id) => { store.dispatch({ type: 'TOGGLE_IMPORTANCE', data: { id } }) } return ( <div> <form onSubmit={addNote}> <input name="note" /> <button type="submit">add</button> </form> <ul> {store.getState().map(note => <li key={note.id} onClick={() => toggleImportance(note.id)} > {note.content} <strong>{note.important ? 'important' : ''}</strong> </li> )} </ul> </div> ) } ستجري إضافة الوظيفتين بشكل مباشر. وانتبه إلى أننا لم نربط حالة حقول النموذج بحالة المكوّن App كما فعلنا سابقًا، ويعرف هذا النوع من النماذج في React بالنماذج الحرة (uncontrolled forms). يمكنك الاطلاع على المزيد حول النماذج الحرة من خلال الانترنت. تجري إضافة ملاحظة جديدة بإسلوب بسيط، حيث تضاف الملاحظة الجديدة حالما يُوفد الفعل: addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' store.dispatch({ type: 'NEW_NOTE', data: { content, important: false, id: generateId() } }) } كما يمكن الحصول على محتوى الملاحظة الجديدة من الحقل المخصص لها في النموذج مباشرةً. ذلك أننا نستطيع الوصول إلى محتويات الحقل من خلال كائن الحدث باستخدام الأمر event.target.note.value، لأن لهذا الحقل اسمًا محددًا. <form onSubmit={addNote}> <input name="note" /> <button type="submit">add</button> </form> يمكن تغيير أهمية الملاحظة بالنقر على اسمها، وسيكون لمعالج الحدث الشكل البسيط التالي: toggleImportance = (id) => { store.dispatch({ type: 'TOGGLE_IMPORTANCE', data: { id } }) } موّلدات الأفعال لقد بدأنا نرى أهمية Redux في تبسيط شيفرة الواجهة الأمامية حتى في التطبيقات البسيطة التي أنشأناها، لكن يمكننا أيضًا فعل الكثير. لا حاجة في الواقع أن يعرف مكوّن React أنواع أفعال Redux وأشكالها. سنفصل تاليًا توليد الأفعال إلى دوال خاصة بكل فعل: const createNote = (content) => { return { type: 'NEW_NOTE', data: { content, important: false, id: generateId() } } } const toggleImportanceOf = (id) => { return { type: 'TOGGLE_IMPORTANCE', data: { id } } } تُدعى الدوال التي تُنشئ الأفعال، مولدات الأفعال. ليس على المكوِّن App أن يعرف أي شيء عن طريقة كتابة الفعل، وكل ما يحتاجه للحصول على الفعل الصحيح هو استدعاء مولد الأفعال. const App = () => { const addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' store.dispatch(createNote(content)) } const toggleImportance = (id) => { store.dispatch(toggleImportanceOf(id)) } // ... } استخدام مخزن Redux ضمن المكونات المختلفة باستثناء دالة الاختزال، فقد كُتب تطبيقنا في ملف واحد. وهذا أمر غير منطقي، لذا علينا فصله إلى وحدات مستقلة. لكن السؤال المطروح حاليًا: كيف يمكن للمكوِّن APP أن يصل إلى مخزن الحالة بعد عملية الفصل؟ وبشكل أعم، لا بد من وجود طريقة لوصول كل المكوِّنات الجديدة الناتجة عن تقسيم المكوّن الأصلي إلى مخزن الحالة. هنالك أكثر من طريقة لمشاركة مخزن Redux بين المكوِّنات. سنلقي نظرة أولًا على أسهل طريقة ممكنة وهي استخدام واجهة الخطافات البرمجية للمكتبة react-redux. لنثبت إذًا هذه المكتبة: npm install react-redux سننقل الآن المكوِّن App إلى وحدة خاصة به يمثلها الملف "App.js"، ولنرى كيف سيؤثر ذلك على بقية ملفات التطبيق. سيصبح الملف "Index.js" على النحو: import React from 'react' import ReactDOM from 'react-dom' import { createStore } from 'redux' import { Provider } from 'react-redux'import App from './App' import noteReducer from './reducers/noteReducer' const store = createStore(noteReducer) ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById('root') ) لاحظ كيف عُرِّف التطبيق الآن كابن لمكوّن التزويد Provider الذي تقدمه المكتبة React-Redux. وكيف مُرِّر مخزن حالة التطبيق إلى مكوّن التزويد عبر الصفة store. نقلنا تعريف مولدات الأفعال إلى الملف "reducers/noteReducer.js" حيث عرفنا سابقًا دالة الاختزال. سيبدو شكل الملف كالتالي: const noteReducer = (state = [], action) => { // ... } const generateId = () => Number((Math.random() * 1000000).toFixed(0)) export const createNote = (content) => { return { type: 'NEW_NOTE', data: { content, important: false, id: generateId() } } } export const toggleImportanceOf = (id) => { return { type: 'TOGGLE_IMPORTANCE', data: { id } } } export default noteReducer إن احتوى التطبيق على عدة مكوّنات يحتاج كل منها إلى الوصول إلى مخزن الحالة، يجب على المكوِّن Appتمرير المخزن store إليها كخاصية. ستحتوي الوحدة الآن على عدة أوامر تصدير، سيعيد أمر التصدير الافتراضي دالة الاختزال، لذلك يمكن إدراجها بالطريقة الاعتيادية: import noteReducer from './reducers/noteReducer' يمكن للوحدة أن تجري عملية تصدير واحدة بشكل افتراضي، وعدة عمليات تصدير اعتيادية. export const createNote = (content) => { // ... } export const toggleImportanceOf = (id) => { // ... } يمكن إدراج الدوال التي صُدرت بعملية تصدير اعتيادية بوضعها بين قوسين معقوصين: import { createNote } from './../reducers/noteReducer' ستصبح شيفرة المكوّن App كالتالي: import React from 'react' import { createNote, toggleImportanceOf } from './reducers/noteReducer' import { useSelector, useDispatch } from 'react-redux' const App = () => { const dispatch = useDispatch() const notes = useSelector(state => state) const addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' dispatch(createNote(content)) } const toggleImportance = (id) => { dispatch(toggleImportanceOf(id)) } return ( <div> <form onSubmit={addNote}> <input name="note" /> <button type="submit">add</button> </form> <ul> {notes.map(note => <li key={note.id} onClick={() => toggleImportance(note.id)} > {note.content} <strong>{note.important ? 'important' : ''}</strong> </li> )} </ul> </div> ) } export default App تجدر ملاحظة عدة نقاط في الشيفرة السابقة. فقد جرى سابقًا إيفاد الأفعال باستخدام التابع dispatch العائد لمخزن Redux: store.dispatch({ type: 'TOGGLE_IMPORTANCE', data: { id } }) أمّا الآن فيجري باستخدام الدالة dispatch العائدة للخطاف useDispatch: import { useSelector, useDispatch } from 'react-redux' const App = () => { const dispatch = useDispatch() // ... const toggleImportance = (id) => { dispatch(toggleImportanceOf(id)) } // ... } يؤمن الخطاف useDispatch إمكانية وصول أي مكون من مكونات React إلى الدالة dispatch العائدة لمخزن Redux والمعرّف في الملف "index.js". يسمح هذا الأمر لكل المكونات أن تغير حالة المخزن، فيمكن للمكوِّن الوصول إلى الملاحظات المحفوظة في المخزن باستخدام الخطاف useSelector العائد للمكتبة React-Redux: import { useSelector, useDispatch } from 'react-redux' const App = () => { // ... const notes = useSelector(state => state) // ... } يتلقى الخطاف useSelector دالة كمعامل. فإما أن تبحث هذه الدالة عن البيانات أو انتقائها من مخزن Redux. سنحتاج هنا إلى جميع الملاحظات، لذلك ستعيد دالة الانتقاء حالة المخزن كاملةً: state => state وهذا الشكل هو اختصار للشيفرة التالية: (state) => { return state } عادةً ما تستخدم دوال الانتقاء للحصول على أجزاء محددة من محتوى المخزن، فيمكننا على سبيل المثال أن نعيد الملاحظات الهامة فقط: const importantNotes = useSelector(state => state.filter(note => note.important)) مكوِّنات أكثر لنفصل شيفرة إنشاء ملاحظة جديدة، ونضعها في مكوّن خاص بها: import React from 'react' import { useDispatch } from 'react-redux'import { createNote } from '../reducers/noteReducer' const NewNote = (props) => { const dispatch = useDispatch() const addNote = (event) => { event.preventDefault() const content = event.target.note.value event.target.note.value = '' dispatch(createNote(content)) } return ( <form onSubmit={addNote}> <input name="note" /> <button type="submit">add</button> </form> ) } export default NewNote وعلى خلاف شيفرة React التي تكتب بمعزل عن Redux، نُقل معالج الحدث الذي يغير حالة التطبيق (والذي يقيم الآن في Redux) من المكوّن الجذري إلى المكوّن الابن. فمنطق تغيير الحالة في Redux يبقى مستقلًا تمامًا عن كامل أجزاء تطبيق React. كما سنفصل أيضًا قائمة الملاحظات وسنعرض كل ملاحظة ضمن مكوّناتها الخاصة (وسيوضع كلاهما في الملف Notes.js): import React from 'react' import { useDispatch, useSelector } from 'react-redux'import { toggleImportanceOf } from '../reducers/noteReducer' const Note = ({ note, handleClick }) => { return( <li onClick={handleClick}> {note.content} <strong> {note.important ? 'important' : ''}</strong> </li> ) } const Notes = () => { const dispatch = useDispatch() const notes = useSelector(state => state) return( <ul> {notes.map(note => <Note key={note.id} note={note} handleClick={() => dispatch(toggleImportanceOf(note.id)) } /> )} </ul> ) } export default Notes ستكون الشيفرة المسؤولة عن تغيير أهمية الملاحظة في المكون الذي يدير قائمة الملاحظات، ولم يبق هناك الكثير من الشيفرة في المكوِّن App: const App = () => { return ( <div> <NewNote /> <Notes /> </div> ) } إن شيفرة المكوّن Note المسؤول عن تصيير ملاحظة واحدة بسيطة جدًا، ولا يمكنها معرفة أن معالج الحدث الذي سيمرر لها كخاصّية سيُوفِد فعلًا. يدعى هذا النوع من المكونات وفق مصطلحات React، بالمكوّن التقديمي presentational. يمثل المكوّن Notes من ناحية أخرى مكوّن احتواء container. فهو يحتوي شيئًا من منطق التطبيق. إذ يُعرِّف ما سيفعله معالج حدث المكوِّن Note ويضبط تهيئة المكوّنات التقديمية وهي في حالتنا المكوّن Note. سنعود إلى مفهومي مكون الاحتواء والمكون التقديمي لاحقًا في هذا القسم. يمكنك الحصول على شيفرة تطبيق Redux في الفرع part6-1 ضمن المستودع الخاص بالتطبيق على Github. التمارين 6.3 - 6.8 لننشئ نسخة جديدة من تطبيق التصويت على الطرائف الذي كتبناه في القسم 1. يمكنك الحصول على المشروع من المستودع التالي https://github.com/fullstack-hy2020/redux-anecdotes كأساس لتطبيقك. إن نسخت المشروع إلى مستودع git موجود مسبقًا أزل تهيئة git لنسختك كالتالي: cd redux-anecdotes // الذهاب إلى مستودع نسختك من التطبيق rm -rf .git يمكن أن تشغل التطبيق بالطريق الاعتيادية، لكن عليك أولًا تثبيت الاعتماديات اللازمة: npm install npm start بعد إكمال هذه التمارين سيكون لتطبيقك شكلًا مشابهًا للتالي: 6.3 طرائف: الخطوة 1 أضف وظيفة التصويت على الطرائف. يجب عليك تخزين نتيجة التصويت في مخزن Redux. 6.4 طرائف: الخطوة 2 أضف وظيفة إنشاء طرفة جديدة، يمكنك أن تبقي النموذج حرًا كما فعلنا سابقًا في هذا الفصل. 6.5 طرائف: الخطوة 3 * تأكد أنّ الطرائف قد رتبت حسب عدد الأصوات. 6.6 طرائف: الخطوة 4 إن لم تكن قد فعلت ذلك، إفصل شيفرة إنشاء كائنات الأفعال وضعها في دالة توليد أفعال، ثم ضعهم في الملف src/reducers/anecdoteReducer.js. يمكنك اتباع الطريقة التي استخدمناها في هذا الفصل، بعد أن تعرفنا على مولدات الأفعال. 6.7 طرائف: الخطوة 7 أنشئ مكوِّنًا جديدًا وسمّه AnecdoteForm، ثم ضع شيفرة إنشاء طرفة جديدة ضمنه. 6.8 طرائف: الخطوة 8 أنشئ مكوّنًا جديدًا وسمّه AnecdoteList، ثم انقل شيفرة تصيير قائمة الطرائف إليه. سيبدو محتوى المكوّن App الآن مشابهًا للتالي: import React from 'react' import AnecdoteForm from './components/AnecdoteForm' import AnecdoteList from './components/AnecdoteList' const App = () => { return ( <div> <h2>Anecdotes</h2> <AnecdoteForm /> <AnecdoteList /> </div> ) } export default App ترجمة -وبتصرف- للفصل Flux-architecture and Redux من سلسلة Deep Dive Into Modern Web Development
  15. اختبرنا حتى هذه اللحظة الواجهة الخلفية ككيان واحد على مستوى الواجهة البرمجية مستخدمين اختبارات التكامل. واختبرنا كذلك بعض مكوِّنات الواجهة الأمامية باستخدام اختبارات الأجزاء. سنلقي نظرة تاليًا على طريقة لاختبار النظام ككل باستخدام الاختبارات المشتركة للواجهتين (end to end). يمكننا تنفيذ الاختبارات المشتركة للواجهتين (E2E) لتطبيق الويب باستخدام متصفح ومكتبة اختبارات. وهناك العديد من المكتبات المتوفرة مثل Selenium التي يمكن استخدامها مع كل المتصفحات تقريبًا. وكخيارٍ للمتصفحات، يمكن اعتماد المتصفحات الاختبارية (Headless Browsers)، وهي متصفحات لا تقدم واجهة تخاطب رسومية. يمكن للمتصفح Chrome أن يعمل كمتصفح اختباري مثلًا. وربما تكون اختبارات E2E هي الأكثر فائدة بين فئات الاختبارات، لأنها تختبر النظام من خلال الواجهة نفسها التي سيستعملها المستخدم الحقيقي. وبالطبع لهذه الاختبارات نقاط ضعفها. فتهيئة هذه الاختبارات يحمل الكثير من التحدي موازنة بالنوعين الآخرين. وقد تكون بطيئة أيضًا، فقد يتطلب تنفيذها إن كان النظام ضخمًا دقائق عدة وحتى ساعات. وهذا الأمر سيء أثناء التطوير، لأننا قد نضطر إلى استخدام الاختبارات بشكل متكرر وخاصة في حالات انهيار الشيفرة. والأسوء من ذلك كله أنها اختبارات غير مستقرة (flaky) فقد تنجح أحيانًا وتفشل في أخرى، على الرغم من عدم تغير الشيفرة. Cypress: مكتبة الاختبارات المشتركة للواجهتين ازدادت شعبية المكتبة Cypress في السنتين الماضيتين. وتتميز هذه المكتبة بالسهولة الكبيرة في الاستخدام، كما لن تعاني معها الكثير من الصداع و الانفعالات موازنة بغيرها من المكتبات مثل Selemium. وطريقة عمل هذه المكتبة مختلف جذريًا عن غيرها من مكتبات الاختبار E2E، لأن اختباراتها تعمل بشكل كامل على المتصفح. بينما تنفذ بقية المكتبات اختباراتها بعملية Node ترتبط بالمتصفح عبر واجهة برمجية. لنجر بعض اختبارات E2E على تطبيق الملاحظات، وسنبدأ بتثبيت المكتبة ضمن الواجهة الأمامية كاعتمادية تطوير: npm install --save-dev cypress و كذلك كتابة سكربت npm لتشغيل الاختبار: { // ... "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "server": "json-server -p3001 db.json", "cypress:open": "cypress open" }, // ... } وعلى خلاف اختبارات الأجزاء، يمكن أن تكون اختبارات Cypress في مستودع الواجهة الخلفية أو الأمامية أو في مستودعها الخاص. وتتطلب أن يكون النظام المختبر في حالة عمل، واختبارات Cypress على خلاف اختبارات تكامل الواجهة الخلفية لن تُشغِّل النظام عندما يبدأ تنفيذها. لنكتب سكربت npm للواجهة الخلفية بحيث تعمل في وضع الاختبار. أي تكون قيمة متغير البيئة NODE_ENV هي test: { // ... "scripts": { "start": "cross-env NODE_ENV=production node index.js", "dev": "cross-env NODE_ENV=development nodemon index.js", "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", "deploy": "git push heroku master", "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", "logs:prod": "heroku logs --tail", "lint": "eslint .", "test": "cross-env NODE_ENV=test jest --verbose --runInBand", "start:test": "cross-env NODE_ENV=test node index.js" }, // ... } عندما تبدأ الواجهتين الأمامية والخلفية بالعمل، يمكن تشغيل Cypress بتنفيذ الأمر: npm run cypress:open تُنشئ Cypress مجلدًا باسم "cypress". يضم هذا المجلد مجلدًا آخر باسم "integration" حيث سنضع اختباراتنا. كما تنشئ كمية من الاختبارات في المجلد "integration/examples". يمكننا حذف هذه الاختبارات وكتابة الاختبارات التي نريد في الملف note_app.spec.js: describe('Note app', function() { it('front page can be opened', function() { cy.visit('http://localhost:3000') cy.contains('Notes') cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') }) }) نبدأ الاختبار من النافذة المفتوحة التالية: يفتح تشغيل الاختبار المتصفح تلقائيًا، بحيث يعرض سلوك التطبيق أثناء الاختبار: من المفترض أن تكون بنية الاختبار مألوفة بالنسبة لك. فهي تستخدم كتلة الوصف (describe) لتجميع الاختبارات التي لها نفس الغاية كما تفعل Jest. تُعرَّف حالات الاختبارات باستخدام التابع it، حيث استعارت هذا الجزء من مكتبة الاختبارات Mocha والذي تستخدمه تحت الستار. لاحظ أن cy.visit وcy.contains هما أمران عائدان للمكتبة Cypress والغرض منهما واضح للغاية. فالأمر cy.visit يفتح عنوان الويب الذي يمرر إليه كمعامل ضمن المتصفح، بينما يبحث الأمر cy.contains عن النص الذي يُمرر إليه كمعامل. يمكننا تعريف الاختبار باستعمال الدالة السهمية: describe('Note app', () => { it('front page can be opened', () => { cy.visit('http://localhost:3000') cy.contains('Notes') cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') }) }) تُفضِّل Mocha عدم استخدام الدوال السهمية لأنها تسبب بعض المشاكل في حالات خاصة. إن لم يعثر cy.contains على النص الذي يبحث عنه، سيفشل الاختبار. لذا لو وسعنا الاختبار كالتالي: describe('Note app', function() { it('front page can be opened', function() { cy.visit('http://localhost:3000') cy.contains('Notes') cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') }) it('front page contains random text', function() { cy.visit('http://localhost:3000') cy.contains('wtf is this app?') })}) سيخفق الاختبار: لنُزِل الشيفرة التي أخفقت من الاختبار. الكتابة في نموذج لنوسع اختباراتنا بحيث يحاول الاختبار تسجيل الدخول إلى التطبيق. سنفترض هنا وجود مستخدم في الواجهة الخلفية باسم "mluukkai" وكلمة سره "Salainen". يبدأ الاختبار بفتح نموذج تسجيل الدخول. describe('Note app', function() { // ... it('login form can be opened', function() { cy.visit('http://localhost:3000') cy.contains('login').click() }) }) يبحث الاختبار في البداية عن زر تسجيل الدخول من خلال النص الظاهر عليه مستخدمًا الأمر cy.click. يبدأ الاختباران بفتح الصفحة http://localhost:3000، لذلك لابد من فصل الجزء المشترك في كتلة beforeEach قبل كل اختبار. describe('Note app', function() { beforeEach(function() { cy.visit('http://localhost:3000') }) it('front page can be opened', function() { cy.contains('Notes') cy.contains('Note app, Department of Computer Science, University of Helsinki 2020') }) it('login form can be opened', function() { cy.contains('login').click() }) }) يحتوي حقل تسجيل الدخول على حقلين لإدخال النصوص، من المفترض أن يملأهما الاختبار. يسمح الأمر cy.get في البحث عن العناصر باستخدام مُحدِّد CSS. يمكننا الوصول إلى أول وآخر حقل إدخال نصي في الصفحة، ومن ثم الكتابة فيهما باستخدام الأمر cy.type كالتالي: it('user can login', function () { cy.contains('login').click() cy.get('input:first').type('mluukkai') cy.get('input:last').type('salainen')}) سيعمل التطبيق بشكل جيد، لكن المشاكل ستظهر إن قررنا لاحقًا إضافة حقول جديدة. ذلك أن الاختبار سيتوقع أن الحقول التي يجب ملؤها هما الحقلان الأول والأخير. لذا من الأفضل أن نعطي عناصر حقول الإدخال معرفات فريدة باستخدام الصفة id. سنغير في نموذج تسجيل الدخول قليلًا: const LoginForm = ({ ... }) => { return ( <div> <h2>Login</h2> <form onSubmit={handleSubmit}> <div> username <input id='username' value={username} onChange={handleUsernameChange} /> </div> <div> password <input id='password' type="password" value={password} onChange={handlePasswordChange} /> </div> <button id="login-button" type="submit"> login </button> </form> </div> ) } سنضيف أيضًا معرفات فريدة إلى زر الإرسال لكي نتمكن من الوصول إليه بمُعرِّفه. سيصبح الاختبار كالتالي: describe('Note app', function() { // .. it('user can log in', function() { cy.contains('login').click() cy.get('#username').type('mluukkai') cy.get('#password').type('salainen') cy.get('#login-button').click() cy.contains('Matti Luukkainen logged in') }) }) تتأكد الدالة السهمية بأن تسجيل الدخول سينجح. لاحظ أننا وضعنا (#) قبل اسم المستخدم وكلمة المرور عندما نريد البحث عنهما بالاستعانة بالمعرف id. ذلك أن محدد التنسيق id يستخدم الرمز (#). بعض الأشياء التي يجدر ملاحظتها ينقر الاختبار أولًا الزر الذي يفتح نموذج تسجيل الدخول: cy.contains('login').click() بعد أن تُملأ حقوله، يُرسل النموذج بالنقر على الزر submit: cy.get('#login-button').click() يحمل كل من الزرين النص ذاته، لكنهما زران منفصلان. وكلاهما في الواقع موجود في نموذج DOM الخاص بالتطبيق، لكن أحدهما فقط سيظهر نظرًا لاستخدام التنسيق display:None. فلو بحثنا عن الزر باستخدام نصه، سيعيد الأمر cy.contains الأول بينهما أو الزر الذي يفتح نموذج تسجيل الدخول. وسيحدث هذا حتى لو لم يكن الزر مرئيًا. لتفادي المشكلة الناتجة عن تضارب الأسماء، أعطينا زر الإرسال الاسم "login-button". سنلاحظ مباشرة الخطأ الذي يعطيه المدقق ESlint حول المتغير cy الذي نستخدمه: يمكن التخلص من هذا الخطأ بتثبيت المكتبة eslint-plugin-cypress كاعتمادية تطوير: npm install eslint-plugin-cypress --save-dev كما علينا تغيير إعدادات التهيئة في الملف eslintrc.js كالتالي: module.exports = { "env": { "browser": true, "es6": true, "jest/globals": true, "cypress/globals": true }, "extends": [ // ... ], "parserOptions": { // ... }, "plugins": [ "react", "jest", "cypress" ], "rules": { // ... } } اختبار نموذج إنشاء ملاحظة جديدة لنضف الآن اختبارًا لإنشاء ملاحظة جديدة: describe('Note app', function() { // .. describe('when logged in', function() { beforeEach(function() { cy.contains('login').click() cy.get('input:first').type('mluukkai') cy.get('input:last').type('salainen') cy.get('#login-button').click() }) it('a new note can be created', function() { cy.contains('new note').click() cy.get('input').type('a note created by cypress') cy.contains('save').click() cy.contains('a note created by cypress') }) })}) عُرِّف الاختبار ضمن كتلة وصف خاص به. يمكن فقط للمستخدمين المسجلين إضافة ملاحظة جديدة، لذلك أضفنا تسجيل الدخول إلى كتلة beforeEach. يتوقع الاختبار وجود حقل نصي واحد لإدخال البيانات في الصفحة، لذا سيبحث عنه كالتالي: cy.get('input') سيفشل الاختبار في حال احتوت الصفحة على أكثر من عنصر إدخال: لذلك من الأفضل إعطاء عنصر الإدخال معرِّف فريد خاص به والبحث عن العنصر باستخدام معرِّفه. ستبدو بنية الاختبار كالتالي: describe('Note app', function() { // ... it('user can log in', function() { cy.contains('login').click() cy.get('#username').type('mluukkai') cy.get('#password').type('salainen') cy.get('#login-button').click() cy.contains('Matti Luukkainen logged in') }) describe('when logged in', function() { beforeEach(function() { cy.contains('login').click() cy.get('input:first').type('mluukkai') cy.get('input:last').type('salainen') cy.get('#login-button').click() }) it('a new note can be created', function() { // ... }) }) }) ستُنفِّذ Cypress الاختبارات وفق ترتيب ظهورها في الشيفرة. ففي البداية ستنفذ المكتبة الاختبار الذي يحمل العنوان "user can log in" حيث سيسجل المستخدم دخوله. وبعدها ستُنفّذ المكتبة الاختبار الذي يحمل العنوان "a new note can be created" والذي سيجعل كتلة beforeEach تُجري تسجيل دخول جديد. لم حدث ذلك طالما أننا سجلنا الدخول أول مرة؟ السبب أن كل اختبار سيبدأ من نقطة الصفر طالما أن المتصفح يتطلب ذلك. وسيعود المتصفح إلى حالته الأصلية بعد انتهاء الاختبار. التحكم بحالة قاعدة البيانات ستغدو الأمور أكثر تعقيدًا عندما يحاول الاختبار إجراء تغييرات على قاعدة بيانات الخادم. إذ ينبغي على قاعدة البيانات أن تبقى كما هي في كل مرة نجري فيها الاختبار، وذلك لتبقى الاختبارات مفيدة و سهلة التكرار. وكما هي حال اختباري الأجزاء والتكامل، تتطلب اختبارات E2E إفراغ قاعدة البيانات أو حتى إعادة تنسيقها أحيانًا قبل كل اختبار. إن التحدي مع هذا النوع من الاختبارات أنها لا تستطيع الولوج إلى قاعدة البيانات. لحل هذه المشكلة لابد من من استخدام طرفيات خدمية على الواجهة الخلفية ترتبط بها الواجهة البرمجية من أجل الاختبار. إذ يمكننا إفراغ قاعدة البيانات باستخدام تلك الطرفيات مثلًا. لننشئ الآن متحكمًا بالمسار لهذه الاختبارات: const router = require('express').Router() const Note = require('../models/note') const User = require('../models/user') router.post('/reset', async (request, response) => { await Note.deleteMany({}) await User.deleteMany({}) response.status(204).end() }) module.exports = router سنضيف المتحكم إلى الواجهة الخلفية عندما يعمل التطبيق في وضع الاختبار فقط: // ... app.use('/api/login', loginRouter) app.use('/api/users', usersRouter) app.use('/api/notes', notesRouter) if (process.env.NODE_ENV === 'test') { const testingRouter = require('./controllers/testing') app.use('/api/testing', testingRouter)} app.use(middleware.unknownEndpoint) app.use(middleware.errorHandler) module.exports = app بعد إجراء التغييرات، يُرسل طلب HTTP-POST إلى الطرفية api/testing/reset/ لتفريغ قاعدة البيانات. يمكنك إيجاد شيفرة الواجهة الخلفية المعدلّة في الفرع part5-1 ضمن المستودع الخاص بالتطبيق على GitHub. سنغير تاليًا كتلة beforeEach بحيث تفرّغ قاعدة البيانات قبل أن تبدأ الاختبارات. لا نستطيع حاليًا إضافة مستخدم جديد من خلال الواجهة الأمامية، لذلك سنضيف مستخدم جديد إلى الواجهة الخلفية من خلال الكتلة beforeEach. describe('Note app', function() { beforeEach(function() { cy.request('POST', 'http://localhost:3001/api/testing/reset') const user = { name: 'Matti Luukkainen', username: 'mluukkai', password: 'salainen' } cy.request('POST', 'http://localhost:3001/api/users/', user) cy.visit('http://localhost:3000') }) it('front page can be opened', function() { // ... }) it('user can login', function() { // ... }) describe('when logged in', function() { // ... }) }) أثناء إعادة التنسيق يرسل الاختبار طلب HTTP إلى الواجهة الخلفية باستخدام cy.request. وعلى عكس ما حصل سابقًا، سيبدأ الاختبار الآن عند الواجهة الخلفية وبنفس الحالة كل مرة. حيث ستحتوي على مستخدم واحد وبدون أية ملاحظات. لنضف اختبارًا آخر نتحقق فيه من إمكانية تغير أهمية الملاحظة. سنغيّر أولًا الواجهة الأمامية بحيث تكون الملاحظة غير مهمة افتراضيًا. أو أن الحقل "importance" يحوي القيمة (خاطئ- false). const NoteForm = ({ createNote }) => { // ... const addNote = (event) => { event.preventDefault() createNote({ content: newNote, important: false }) setNewNote('') } // ... } هناك طرق عدة لاختبار ذلك. نبحث في المثال التالي عن ملاحظة ونضغط على الزر المجاور لها "make importnant". ونتحقق بعد ذلك من احتواء الملاحظة لهذا الزر. describe('Note app', function() { // ... describe('when logged in', function() { // ... describe('and a note exists', function () { beforeEach(function () { cy.contains('new note').click() cy.get('input').type('another note cypress') cy.contains('save').click() }) it('it can be made important', function () { cy.contains('another note cypress') .contains('make important') .click() cy.contains('another note cypress') .contains('make not important') }) }) }) }) يبحث الأمر الأول عن المكوِّن الذي يحتوي النص "another note cypress"، ثم عن الزر "make important" ضمنه. بعد ذلك ينقر على هذا الزر. يتحقق الأمر الثاني أن النص على الزر قد تغير إلى "make not important". يمكن إيجاد الاختبار والشيفرة الحالية للواجهة الأمامية ضمن الفرع part5-9 في المستودع المخصص للتطبيق على GitHub. اختبار إخفاق تسجيل الدخول لنكتب اختبارًا يتحقق من فشل تسجيل الدخول عندما تكون كلمة المرور خاطئة. ستنفذ Cypress كل الاختبارات الموجودة بشكل افتراضي، وطالما أن عدد الاختبارات في ازدياد، سيؤدي ذلك إلى زيادة وقت التنفيذ. لذلك عندما نطور اختبارًا جديدًا أو نحاول إصلاح اختبار، من الأفضل تحديد هذا الاختبار فقط ليُنفّذ وذلك باستخدام الأمر it.only. ويمكننا بعدها إزالة الكلمة only عند نجاح الاختبار. ستبدو النسخة الأولى من الاختبار كالتالي: describe('Note app', function() { // ... it.only('login fails with wrong password', function() { cy.contains('login').click() cy.get('#username').type('mluukkai') cy.get('#password').type('wrong') cy.get('#login-button').click() cy.contains('wrong credentials') }) // ... )} يستخدم الاختبار الأمر cy.contains للتأكد من أن التطبيق سيطبع رسالة خطأ. سيصيّر التطبيق رسالة الخطأ إلى مكوِّن يمتلك صنف تنسيق CSS اسمه "error": const Notification = ({ message }) => { if (message === null) { return null } return ( <div className="error"> {message} </div> ) } يمكننا أن نجعل الاختبار يتحقق من أن رسالة الخطأ قد صُيِّرت إلى المكوِّن الصحيح الذي يمتلك صنف CSS باسم "error". it('login fails with wrong password', function() { // ... cy.get('.error').contains('wrong credentials')}) نستخدم أولًا cy.get للبحث عن المكوّن الذي يمتلك صنف التنسيق "error". بعد ذلك نتحقق أن رسالة الخطأ موجودة ضمن هذا المكوِّن. وانتبه إلى استعمال العبارة 'error.' كمعامل للتابع cy.get ذلك أن محدد الأصناف في تنسيق CSS يبدأ بالمحرف (.). كما يمكن تنفيذ ذلك باستخدام العبارة should: it('login fails with wrong password', function() { // ... cy.get('.error').should('contain', 'wrong credentials')}) على الرغم من أن استعمال should أكثر غموضًا من استخدام contains. لكنها تسمح باختبارات أكثر تنوعًا معتمدةً على المحتوى النصي فقط. يمكنك الاطلاع ضمن مستندات المكتبة Cypress على أكثر المفاتيح استخدامًا مع should. يمكننا أن نتحقق مثلًا من أن رسالة الخطأ ستظهر باللون الأحمر ومحاطة بإطار: it('login fails with wrong password', function() { // ... cy.get('.error').should('contain', 'wrong credentials') cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)') cy.get('.error').should('have.css', 'border-style', 'solid') }) تتطلب Cypress أن تُعطى الألوان بطريقة rgb. وطالما أن جميع الاختبارات ستجري على المكوِّن نفسه باستخدام cy.get سنسلسل الاختبارات باستخدام and. it('login fails with wrong password', function() { // ... cy.get('.error') .should('contain', 'wrong credentials') .and('have.css', 'color', 'rgb(255, 0, 0)') .and('have.css', 'border-style', 'solid') }) لننهي الاختبار بحيث يتحقق أخيرًا أن التطبيق لن يصيّر رسالة النجاح "Matti Luukkainen logged in": it.only('login fails with wrong password', function() { cy.contains('login').click() cy.get('#username').type('mluukkai') cy.get('#password').type('wrong') cy.get('#login-button').click() cy.get('.error') .should('contain', 'wrong credentials') .and('have.css', 'color', 'rgb(255, 0, 0)') .and('have.css', 'border-style', 'solid') cy.get('html').should('not.contain', 'Matti Luukkainen logged in')}) لا ينبغي أن تُقيد الأمر should دائمًا بسلسلة get (أو أية تعليمات قابلة للتسلسل). كما يمكن استخدام الأمر ('cy.get('html للوصول إلى كامل محتويات التطبيق المرئية. تجاوز واجهة المستخدم UI كتبنا حتى اللحظة الاختبارات التالية: describe('Note app', function() { it('user can login', function() { cy.contains('login').click() cy.get('#username').type('mluukkai') cy.get('#password').type('salainen') cy.get('#login-button').click() cy.contains('Matti Luukkainen logged in') }) it.only('login fails with wrong password', function() { // ... }) describe('when logged in', function() { beforeEach(function() { cy.contains('login').click() cy.get('input:first').type('mluukkai') cy.get('input:last').type('salainen') cy.get('#login-button').click() }) it('a new note can be created', function() { // ... }) }) }) اختبرنا بداية تسجيل الدخول. ومن ثم رأينا جملة من الاختبارات الموجودة في كتلة وصف خاصة تتوقع من المستخدم أن يسجل دخوله والذي سيحدث ضمن الكتلة beforeEach. وكما قلنا سابقًا: سيبدأ كل اختبار من الصفر، فلا تبدأ الاختبار أبدًا من حيث ينتهي الاختبار الذي يسبقه. يقدم لنا توثيق Cypress النصيحة التالية: اختبر ثغرات تسجيل الدخول بشكل كامل- لكن لمرة واحدة!. لذا وبدلًا من تسجيل الدخول باستخدام نموذج تسجيل الدخول الموجود في كتلة الاختبارات beforeEach، تنصحنا Cypress بتجاوز واجهة المستخدم و إرسال طلب HTTP إلى الواجهة الخلفية لتسجيل الدخول. وذلك لأن تسجيل الدخول باستخدام طلب HTTP أسرع بكثير من ملئ النموذج وإرساله. لكن وضع تطبيقنا أعقد قليلًا من المثال الذي أورده توثيق Cypress، لأن التطبيق سيخزن تفاصيل المستخدم عند تسجيل الدخول في الذاكرة المحلية. مع ذلك تستطيع Cypress التعامل مع هذا الوضع كالتالي: describe('when logged in', function() { beforeEach(function() { cy.request('POST', 'http://localhost:3001/api/login', { username: 'mluukkai', password: 'salainen' }).then(response => { localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body)) cy.visit('http://localhost:3000') }) }) it('a new note can be created', function() { // ... }) // ... }) يمكننا الوصول إلى استجابة الطلب cy.request باستخدام التابع then. سنجد في جوار cy.request وعدًا كغيره من أوامر Cypress. تخزّن دالة الاستدعاء تفاصيل تسجيل الدخول في الذاكرة المحلية، ثم تعيد تحميل الصفحة. لا أهمية الآن لدخول المستخدم من خلال نموذج تسجيل الدخول في أي اختبار سنجريه على التطبيق، إذ سنستخدم شيفرة تسجيل الدخول في المكان الذي نحتاجه. وعلينا أن نجعل تسجيل الدخول كأمر خاص بالمستخدم. يُصرّح عن أوامر المستخدم الخاصة في الملف commands.js ضمن المجلد cypress/support. ستبدو شيفرة تسجيل الدخول كالتالي: Cypress.Commands.add('login', ({ username, password }) => { cy.request('POST', 'http://localhost:3001/api/login', { username, password }).then(({ body }) => { localStorage.setItem('loggedNoteappUser', JSON.stringify(body)) cy.visit('http://localhost:3000') }) }) سيكون استخدام الأمر الخاص بنا سهلًا، وستغدو الاختبارات أكثر وضوحًا: describe('when logged in', function() { beforeEach(function() { cy.login({ username: 'mluukkai', password: 'salainen' }) }) it('a new note can be created', function() { // ... }) // ... }) ينطبق ذلك تمامًا على إنشاء ملاحظة جديدة. لدينا اختبار إنشاء ملاحظة جديدة باستخدام النموذج. كما سننشئ ملاحظة جديدة ضمن كتلة beforeEach والتي سنختبر من خلالها تغيّر أهمية الملاحظة: describe('Note app', function() { // ... describe('when logged in', function() { it('a new note can be created', function() { cy.contains('new note').click() cy.get('input').type('a note created by cypress') cy.contains('save').click() cy.contains('a note created by cypress') }) describe('and a note exists', function () { beforeEach(function () { cy.contains('new note').click() cy.get('input').type('another note cypress') cy.contains('save').click() }) it('it can be made important', function () { // ... }) }) }) }) لنكتب أمرًا خاصًا بالمستخدم لإنشاء ملاحظة جديدة عبر طلب HTTP-POST: Cypress.Commands.add('createNote', ({ content, important }) => { cy.request({ url: 'http://localhost:3001/api/notes', method: 'POST', body: { content, important }, headers: { 'Authorization': `bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}` } }) cy.visit('http://localhost:3000') }) يتوقع الأمر أن يسجل المستخدم دخوله، وأن تُخزّن التفاصيل في الذاكرة المحلية. ستصبح الآن كتلة التنسيق كالتالي: describe('Note app', function() { // ... describe('when logged in', function() { it('a new note can be created', function() { // ... }) describe('and a note exists', function () { beforeEach(function () { cy.createNote({ content: 'another note cypress', important: false }) }) it('it can be made important', function () { // ... }) }) }) }) ستجد الاختبارات و شيفرة الواجهة الأمامية ضمن الفرع part5-10 في المجلد الخاص بالتطبيق على GitHub تغيير أهمية ملاحظة لنلقي نظرة أخيرًا على الاختبار الذي أجريناه لتغيير أهمية الملاحظة. سنغير أولًا كتلة التنسيق لتنشئ ثلاث ملاحظات بدلًا من واحدة: describe('when logged in', function() { describe('and several notes exist', function () { beforeEach(function () { cy.createNote({ content: 'first note', important: false }) cy.createNote({ content: 'second note', important: false }) cy.createNote({ content: 'third note', important: false }) }) it('one of those can be made important', function () { cy.contains('second note') .contains('make important') .click() cy.contains('second note') .contains('make not important') }) }) }) كيف يعمل الأمر cy.contains في الواقع؟ سننقر الأمر (cy.contains('second note' في مُنفّذ الاختبار (TestRunner) الخاص بالمكتبة Cypress. سنجد أن هذا الأمر سيبحث عن عنصر يحوي على النص "second note". بالنقر على السطر الثاني ('contains('make important. : سنجد الزر "make important" المرتبط بالملاحظة الثانية: يستأنف الأمر contains الثاني عندما يُقيّد في السلسة، البحث في المكوِّن الذي وجده الأمر الأول. فإن لم نقيّد الأمر وكتبنا بدلًا عن ذلك: cy.contains('second note') cy.contains('make important').click() ستكون النتيجة مختلفة تمامًا. حيث سينقر السطر الثاني الزر المتعلق بالملاحظة الخاطئة: عندما تنفذ الشيفرة الاختبارات، عليك التحقق من خلال مُنفِّذ الاختبارات أن الاختبار يجري على المكوِّن الصحيح. لنعدّل المكوِّن Note بحيث يُصيّر نص الملاحظة كنعصر span: const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' : 'make important' return ( <li className='note'> <span>{note.content}</span> <button onClick={toggleImportance}>{label}</button> </li> ) } سيفشل الاختبار! وكما يبين لنا مُنفِّذ الاختبار، سيعيد الأمر ('cy.contains('second note المكوّن الذي يحمل النص الصحيح لكن الزر ليس موجودًا ضمنه. إحدى طرق إصلاح الأمر هي التالية: it('other of those can be made important', function () { cy.contains('second note').parent().find('button').click() cy.contains('second note').parent().find('button') .should('contain', 'make not important') }) نستخدم في السطر الأول الأمر parent للوصول إلى العنصر الأب للعنصر الذي يحتوي على الملاحظة الثانية وسيجد الزر داخله. ننقر بعدها على الزر ونتحقق من تغيّر النص الظاهر عليه. لاحظ أننا نستخدم الأمر find للبحث عن الزر. إذ لا يمكننا استخدام الأمر cy.get لأنه يبحث دائمًا عن المطلوب في الصفحة بأكملها، وسيعيد الأزرار الخمسة الموجودة. لسوء الحظ علينا القيام ببعض عمليات النسخ واللصق في اختبارنا، لأن شيفرة البحث عن الزر الصحيح هي نفسها. في هذه الحالات، يمكن استخدام الأمر as: it.only('other of those can be made important', function () { cy.contains('second note').parent().find('button').as('theButton') cy.get('@theButton').click() cy.get('@theButton').should('contain', 'make not important') }) سيجد السطر الأول الآن الزر الصحيح، ثم يستخدم as لتخزينه بالاسم theButton. ستتمكن السطور التالية من استخدام العنصر السابق بالشكل ('cy.get('@theButton. تنفيذ وتنقيح الاختبارات سنذكر أخيرًا بعض الملاحظات عن عمل Cypress وعن تنقيح الاختبارات. يعطي شكل اختبارت Cypress انطباعًا أن الاختبارات هي شيفرة JavaScript وأنه بالإمكان تنفيذ التالي: const button = cy.contains('login') button.click() debugger() cy.contains('logout').click() لن تعمل الشيفرة السابقة. فعندما تنفذ Cypress اختبارًا فستضيف كل أمر cy إلى صف انتظار لتنفيذه. وعندما تُنفَّذ شيفرة الاختبار، ستُنفّذ الأوامر في الصف واحدًا بعد الآخر. تعيد Cypress دائمًا كائنًا غير محدد، لذلك سيسبب استخدام الأمر()button.click خطأً. ولن يُوقف تشغيل المنقح تنفيذ الشيفرة في الفترة ما بين أمرين، لكن قبل تنفيذ الأوامر كلها. تشابه أوامر Cypress الوعود. فإن أردنا الوصول إلى القيم التي تعيدها، علينا استخدام التابع then. فعلى سبيل المثال، ستطبع الشيفرة التالية عدد الأزرار في التطبيق، وستنقر الزر الأول: it('then example', function() { cy.get('button').then( buttons => { console.log('number of buttons', buttons.length) cy.wrap(buttons[0]).click() }) }) من الممكن إيقاف تنفيذ الشيفرة باستخدام المنقح. إذ يعمل المنقح فقط، إن كانت طرفية تطوير مُنفّذ الاختبارات في Cypress مفتوحةً. تقدم لك طرفية التطوير كل الفائدة عند تنقيح الاختبارات. فيمكن أن تتابع طلبات HTTP التي ترسلها الاختبارات ضمن النافذة Network، كما ستظهر لك الطرفية الكثير من المعلومات حول الاختبارات: نفّذنا حتى اللحظة جميع اختبارات Cypress باستخدام الواجهة الرسومية لمُنفِّذ الاختبار. من الممكن أيضًا تشغيلها من سطر الأوامر. وكل ما علينا هو إضافة سكربت npm التالي: "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "server": "json-server -p3001 --watch db.json", "cypress:open": "cypress open", "test:e2e": "cypress run" }, وهكذا سنتمكن من تنفيذ الاختبارات باستخدام سطر الأوامر بتنفيذ الأمر npm test:e2e انتبه إلى أن الفيديو الذي يوثق عملية التنفيذ سيُخزّن ضمن المجلد cypress/vedios، لذا من المحتمل أن تجعل git يتجاهل هذا الملف. ستجد شيفرة الواجهة الأمامية والاختبارت ضمن الفرع part5-11 في المجلد المخصص للتطبيق على GitHub التمارين 5.17 - 5.22 سننفذ بعض اختبارات E2E في التمارين الأخيرة من هذا القسم على تطبيق المدونة. تكفي مادة هذا القسم لتنفيذ التمارين. وينبغي عليك بالتأكيد أن تتحق من توثيق المكتبة Cypress. ننصح بقراءة المقالة Introduction to Cypress والتي تذكر مقدمتها ما يلي: 5.17 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 1 هيئ Cypress لتعمل على تطبيقك. ثم نفذ اختبارًا يتحقق أن التطبيق سيظهر واجهة تسجيل الدخول بشكل افتراضي. على شيفرة الاختبار أن تكون كالتالي: describe('Blog app', function() { beforeEach(function() { cy.request('POST', 'http://localhost:3001/api/testing/reset') cy.visit('http://localhost:3000') }) it('Login form is shown', function() { // ... }) }) يجب أن تُفرّغ كتلة التنسيق beforeEach قاعدة البيانات كما فعلنا في هذا الفصل سابقًا. 5.18 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 2 أجر اختبارات على تسجيل الدخول. اختبر حالتي التسجيل الناجحة و المخفقة. أنشئ مستخدمًا جديدًا للاختبار عن طريق الكتلة beforeEach. سيتوسع شكل التطبيق ليصبح على النحو: describe('Blog app', function() { beforeEach(function() { cy.request('POST', 'http://localhost:3001/api/testing/reset') // create here a user to backend cy.visit('http://localhost:3000') }) it('Login form is shown', function() { // ... }) describe('Login',function() { it('succeeds with correct credentials', function() { // ... }) it('fails with wrong credentials', function() { // ... }) }) }) تمرين اختياري لعلامة أعلى: تحقق أن التنبيه سيُعرض باللون الأحمر عند إخفاق تسجيل الدخول 5.19 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 3 نفذ اختبارًا يتحقق أن المستخدم الذي يسجل دخوله سيتمكن من إنشاء مدونة جديدة. قد تكون بنية شيفرة الاختبار الآن كالتالي: describe('Blog app', function() { // ... describe.only('When logged in', function() { beforeEach(function() { // log in user here }) it('A blog can be created', function() { // ... }) }) }) ويجب أن يتأكد الاختبار أن المدونة الجديدة ستضاف إلى قائمة جميع المدونات. 5.20 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 4 نفذ اختبارًا يتحقق من إمكانية إعجاب مستخدم بمدونة. 5.21 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 5 نفذ اختبارًا يتحقق من إمكانية حذف المستخدم لمدونة قد أنشأها سابقًا. تمرين اختياري لعلامة أعلى: تحقق أن المستخدمين الآخرين لن يكونوا قادرين على حذفها. 5.22 اختبارت مشتركة للواجهتين لتطبيق المدونة: خطوة 6 نفذ اختبارًا يتحقق أن قائمة المدونات مرتبة حسب تسلسل الإعجابات، من الأكثر إلى الأقل. قد يحمل هذا التمرين بعض الصعوبة. أحد الحلول المقترحة هو إيجاد جميع المدونات، ثم الموازنة بينها باستخدام التابع then. هكذا نكون قد وصلنا إلى التمرين الأخير في القسم، وحان الوقت لتسليم الحلول إلى GitHub، والإشارة إلى أنك سلمت التمارين المنتهية ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل end to end testing من سلسلة Deep Dive Into Modern Web Development
  16. توجد طرق عدة لاختبار تطبيقات React. سنلقي نظرة عليها في المادة القادمة. نضيف الاختبارات عبر مكتبة الاختبارت Jest التي طورتها Facebook والتي استخدمناها في القسم السابق. تُهيئ Jest افتراضيًا في التطبيقات التي تبنى باستخدام create-react-apps. بالإضافة إلى Jest، سنحتاج إلى مكتبة اختبارت أخرى تساعدنا في تصيير المكوِّنات لأغراض الاختبارات. إن أفضل خيار متاح أمامنا حاليًا هي مكتبة اختبارات React تدعى react-testing-library والتي زادت شعبيتها كثيرًا في الآونة الأخيرة. إذًا سنثبت هذه المكتبة بتنفيذ الأمر التالي: npm install --save-dev @testing-library/react @testing-library/jest-dom سنكتب أولًا اختبارات للمكوِّن المسؤول عن تصيير الملاحظة: const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' : 'make important' return ( <li className='note'> {note.content} <button onClick={toggleImportance}>{label}</button> </li> ) } لاحظ أن للعنصر li صنف تنسيق CSS اسمه note، يستخدم للوصول إلى المكوِّن في اختباراتنا. تصيير المكوّن للاختبارات سنكتب اختبارنا في الملف Note.test.js الموجود في نفس مجلد المكوَّن. يتحقق الاختبار الأول من أن المكوّن سيصيّر محتوى الملاحظة: import React from 'react' import '@testing-library/jest-dom/extend-expect' import { render } from '@testing-library/react' import Note from './Note' test('renders content', () => { const note = { content: 'Component testing is done with react-testing-library', important: true } const component = render( <Note note={note} /> ) expect(component.container).toHaveTextContent( 'Component testing is done with react-testing-library' ) }) يصيّر الاختبار المكوِّن بعد التهيئة الأولية باستخدام التابع render العائد للمكتبة react-testing-library: const component = render( <Note note={note} /> ) تُصيّر مكوِّنات React عادة إلى DOM (نموذج كائن document). لكن التابع render سيصيرها إلى تنسيق مناسب للاختبارات دون أن يصيّرها إلى DOM. يعيد التابع render كائنًا له عدة خصائص، تدعى إحداها container وتحتوي كل شيفرة HTML التي يصيّرها المكوِّن. تتأكد التعليمة expect أن المكوِّن سيصيّر النص الصحيح، وهو في حالتنا محتوى الملاحظة. expect(component.container).toHaveTextContent( 'Component testing is done with react-testing-library' ) تنفيذ الاختبارات تهيئ Create-react-app الاختبارت بحيث تُنفَّذ في وضع المراقبة افتراضيُا. ويعني ذلك أن الأمر npm test لن يتوقف بعد أن ينتهي تنفيذ الاختبار، بل ينتظر بدلًا من ذلك أية تغييرات تجري على الشيفرة. وبمجرد حفظ التغييرات الجديدة، سيُنفَّذ الاختبار تلقائيًا من جديد وهكذا. إن أردت تنفيذ الاختبار بالوضع الاعتيادي، نفذ الأمر: CI=true npm test ملاحظة: من الممكن أن ترى تحذيرًا على الطرفية إن لم تكن قد ثبتت Watchman. وهو تطبيق طورته Facebook ليراقب التغيرات التي تحدث في الملفات. سيسرع التطبيق من تنفيذ الاختبارات وسيساعد في التخلص من التحذيرات التي تظهر على الشاشة في وضع المراقبة ابتداء من الإصدار Sierra على الأقل لنظام التشغيل macOS. يمكنك أن تجد تعليمات استخدام تطبيق Watchman في أنظمة التشغيل المختلفة على الموقع الرسمي للتطبيق. موقع ملف الاختبار تتبع React تقليدين مختلفين (على الأقل) لاختيار مكان ملف الاختبار. وقد اتبعنا التقليد الحالي الذي ينص أن يكون ملف الاختبار في نفس مجلد المكوِّن الذي يُختبر. أما التقليد الأخر فينص على وضع ملفات الاختبار في مجلد خاص بها. وأيًا يكن اختيارنا ستجد حتمًا من يعارض ذلك. وقد اخترنا وضع الملف في نفس مجلد المكوِّن لأن createreactapp قد هيأت التطبيق لذلك افتراضيًا. البحث عن محتوى محدد ضمن المكوِّن تقدم الحزمة react-testing-library طرقًا كثيرةً للبحث في محتويات المكوِّنات التي نختبرها. لنوسع تطبيقنا قليلًا: test('renders content', () => { const note = { content: 'Component testing is done with react-testing-library', important: true } const component = render( <Note note={note} /> ) // method 1 expect(component.container).toHaveTextContent( 'Component testing is done with react-testing-library' ) // method 2 const element = component.getByText( 'Component testing is done with react-testing-library' ) expect(element).toBeDefined() // method 3 const div = component.container.querySelector('.note') expect(div).toHaveTextContent( 'Component testing is done with react-testing-library' ) }) تستخدم الطريق الأولى التابع toHaveTextContent للبحث عن نص مطابق لكامل شيفرة HTML التي يصيّرها المكوِّن. وهذا التابع هو واحد من عدة توابع مُطابَقة تقدمها المكتبة jest-dom. بينما تستخدم الطريقة الثانية التابع getByText العائد للكائن الذي يعيده التابع render. حيث يعيد التابع getByText العنصر الذي يحتوي النص الذي نبحث عنه. وسيقع استثناء إن لم يجد عنصرًا مطابقًا. لهذا لسنا بحاجة عمليًا لتخصيص أية استثناءات إضافية. تقتضي الطريقة الثالثة البحث عن عنصر محدد من عناصر HTML التي يصيّرها المكوِّن باستخدام التابع querySelector الذي يستقبل كوسيط مُحدِّد CSS. تستخدم الطريقتين الأخيريتين التابعين getByText وquerySelector لإيجاد العنصر الذي يحقق بعض الشروط في المكوّن المصيَّر. وهناك الكثير من التوابع المتاحة للبحث أيضًا. تنقيح الاختبارات ستواجهنا تقليديًا العديد من المشاكل عند كتابة وتنفيذ الاختبارات. يمتلك الكائن الذي يعيده التابع render التابع debug الذي يمكن استخدامه لطباعة شيفرة HTML التي يصيّرها المكوَّن على الطرفية. لنجرب ذلك بإجراء بعض التعديلات على الشيفرة: test('renders content', () => { const note = { content: 'Component testing is done with react-testing-library', important: true } const component = render( <Note note={note} /> ) component.debug() // ... }) يمكنك أن ترى شيفرة HTML التي يولدها المكوِّن على الطرفية: console.log node_modules/@testing-library/react/dist/index.js:90 <body> <div> <li class="note" > Component testing is done with react-testing-library <button> make not important </button> </li> </div> </body> يمكنك أيضًا البحث عن جزء صغير من المكوّن وطباعة شيفرة HTML التي يحتويها. نستخدم لهذا الغرض التابع prettyDOM الذي يمكن إدراجه من الحزمة testing-library/dom@ التي تُثبـت تلقائيًا مع المكتبة react-testing-library. import React from 'react' import '@testing-library/jest-dom/extend-expect' import { render } from '@testing-library/react' import { prettyDOM } from '@testing-library/dom'import Note from './Note' test('renders content', () => { const note = { content: 'Component testing is done with react-testing-library', important: true } const component = render( <Note note={note} /> ) const li = component.container.querySelector('li') console.log(prettyDOM(li))}) استخدمنا هنا مُحدِّد CSS للعثور على العنصر li داخل المكوِّن، ومن ثم طباعة محتواه: console.log src/components/Note.test.js:21 <li class="note" > Component testing is done with react-testing-library <button> make not important </button> </li> النقر على الأزرار أثناء الاختبارات يتحقق المكون Note، بالإضافة إلى عرضه محتوى الملاحظة، أن النقر على الزر المجاور للملاحظة سيستدعي دالة معالج الحدث toggleImportance. وللتحقق من هذه الوظيفة يمكن تنفيذ الشيفرة التالية: import React from 'react' import { render, fireEvent } from '@testing-library/react'import { prettyDOM } from '@testing-library/dom' import Note from './Note' // ... test('clicking the button calls event handler once', () => { const note = { content: 'Component testing is done with react-testing-library', important: true } const mockHandler = jest.fn() const component = render( <Note note={note} toggleImportance={mockHandler} /> ) const button = component.getByText('make not important') fireEvent.click(button) expect(mockHandler.mock.calls).toHaveLength(1) }) سنجد عدة نقاط هامة متعلقة بهذا الاختبار. فمعالج الحدث هو دالة محاكاة تُعرَّف باستخدام Jest كالتالي: const mockHandler = jest.fn() يعثر الاختبار على الزر عن طريق النص الذي يظهر عليه بعد تصيير المكون ومن ثم سينقره: const button = getByText('make not important') fireEvent.click(button) أما آلية النقر فينفذها التابع fireEvent. تتحقق التعليمة expect في هذا الاختبار من استدعاء دالة المحاكاة مرة واحدة فقط. expect(mockHandler.mock.calls).toHaveLength(1) تستخدم أغراض ودوال المحاكاة كمكوِّنات لأغراض الاختبار وذلك لاستبدال اعتماديات المكوِّنات المختبرة. وتتميز المكوِّنات المحاكية بقدرتها على إعادة استجابة محضّرة مسبقًا، والتحقق من عدد المرات التي استدعيت بها الدالة المحاكية والمعاملات التي مُررت لها. و يعتبر استخدام الدالة المحاكية في مثالنا أمرًا نموذجيًا، لأنه يخبرنا بسهولة أن التابع قد استدعي مرة واحدة بالضبط. اختبارات على المكوِّن Togglable سنكتب عدة اختبارات للمكوِّن togglable. لنضف بداية صنف CSS الذي يدعى togglableContent إلى العنصر div الذي يعيد المكوّنات الأبناء: const Togglable = React.forwardRef((props, ref) => { // ... return ( <div> <div style={hideWhenVisible}> <button onClick={toggleVisibility}> {props.buttonLabel} </button> </div> <div style={showWhenVisible} className="togglableContent"> {props.children} <button onClick={toggleVisibility}>cancel</button> </div> </div> ) }) فيما يلي سنجد شيفرة الاختبارات التي سنجريها: import React from 'react' import '@testing-library/jest-dom/extend-expect' import { render, fireEvent } from '@testing-library/react' import Togglable from './Togglable' describe('<Togglable />', () => { let component beforeEach(() => { component = render( <Togglable buttonLabel="show..."> <div className="testDiv" /> </Togglable> ) }) test('renders its children', () => { expect( component.container.querySelector('.testDiv') ).toBeDefined() }) test('at start the children are not displayed', () => { const div = component.container.querySelector('.togglableContent') expect(div).toHaveStyle('display: none') }) test('after clicking the button, children are displayed', () => { const button = component.getByText('show...') fireEvent.click(button) const div = component.container.querySelector('.togglableContent') expect(div).not.toHaveStyle('display: none') }) }) تُستدعى الدالة beforeEach قبل كل اختبار، ومن ثم تصيّر المكوِّن Togglable إلى المتغير component. يتحقق الاختبار الأول أن المكوّن Togglable سيصيّر المكون الابن <div className="testDiv" /‎>. بينما تستخدم بقية الاختبارات التابع toHaveStyle للتحقق أن المكوِّن الابن للمكوِّن Togglable ليس مرئيًا بشكل افتراضي، بالتحقق أن تنسيق العنصر div يتضمن الأمر { display: 'none' }. اختبار آخر يتحقق من ظهور المكوّن عند النقر على الزر، أي بمعنًى آخر، لم يعد التنسيق الذي يسبب اختفاء المكوِّن مُسندًا إليه. وتذكر أن البحث عن الزر يجري اعتمادًا على النص الذي كُتب عليه، كما يمكن إيجاده اعتمادًا على مُحدِّد CSS. const button = component.container.querySelector('button') يحتوي المكوِّن على زرين، وطالما أن التابع querySelector سيعيد الزر الأول الذي يتطابق مع معيار البحث، فسنكون قد حصلنا على الزر المطلوب مصادفةً. لنكتب اختبارًا يتحقق أن المحتوى المرئي يمكن أن يُخفى بالنقر على الزر الثاني للمكوِّن: test('toggled content can be closed', () => { const button = component.container.querySelector('button') fireEvent.click(button) const closeButton = component.container.querySelector( 'button:nth-child(2)' ) fireEvent.click(closeButton) const div = component.container.querySelector('.togglableContent') expect(div).toHaveStyle('display: none') }) عرّفنا محدِّد تنسيق CSS لكي نستخدمه في إعادة الزر الثاني (button:nth-child(2. فليس من الحكمة الاعتماد على ترتيب الزر في المكوِّن، ومن الأجدى أن نجد الزر بناء على النص الذي يظهر عليه. test('toggled content can be closed', () => { const button = component.getByText('show...') fireEvent.click(button) const closeButton = component.getByText('cancel') fireEvent.click(closeButton) const div = component.container.querySelector('.togglableContent') expect(div).toHaveStyle('display: none') }) وتذكر أن التابع getByText الذي استخدمناه سابقًا هو واحد من توابع كثيرة للاستقصاء تقدمها المكتبة react-testing-library. اختبار النماذج لقد استخدمنا للتو الدالة fireEvent في اختباراتنا السابقة لتنفيذ شيفرة نقر الزر. const button = component.getByText('show...') fireEvent.click(button) نستخدم عمليًا تلك الدالة fireEvent لإنشاء حدث النقر على زر المكوِّن. ويمكننا كذلك محاكاة إدخال نص باستخدامها أيضًا. لنجر اختبارًا على المكوّن NoteForm. تمثل الشيفرة التالية شيفرة هذا المكوِّن: import React, { useState } from 'react' const NoteForm = ({ createNote }) => { const [newNote, setNewNote] = useState('') const handleChange = (event) => { setNewNote(event.target.value) } const addNote = (event) => { event.preventDefault() createNote({ content: newNote, important: Math.random() > 0.5, }) setNewNote('') } return ( <div className="formDiv"> <h2>Create a new note</h2> <form onSubmit={addNote}> <input value={newNote} onChange={handleChange} /> <button type="submit">save</button> </form> </div> ) } export default NoteForm يعمل النموذج باستدعاء الدالة createNote التي تُمرر إليه كخاصية تحمل تفاصيل الملاحظة الجديدة. ستبدو شيفرة الاختبار كالتالي: import React from 'react' import { render, fireEvent } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' import NoteForm from './NoteForm' test('<NoteForm /> updates parent state and calls onSubmit', () => { const createNote = jest.fn() const component = render( <NoteForm createNote={createNote} /> ) const input = component.container.querySelector('input') const form = component.container.querySelector('form') fireEvent.change(input, { target: { value: 'testing of forms could be easier' } }) fireEvent.submit(form) expect(createNote.mock.calls).toHaveLength(1) expect(createNote.mock.calls[0][0].content).toBe('testing of forms could be easier' ) }) يمكننا محاكاة الكتابة إلى حقول النصوص بإنشاء الحدث change لكل حقل، ثم تعريف كائن يحتوي على النص الذي سيكتب في حقل النص. سيُرسل النموذج بمحاكاة عمل الحدث submit الذي يستخدم لإرسال نموذج. تتأكد التعليمة expect للاختبار الأول، أن التابع سيُستدعى عند إرسال النموذج. أما الثانية فستتأكد أن معالج الحدث سيُستدعى بالمعاملات الصحيحة، أي أن محتويات الملاحظة الجديدة التي أنشئت عندما مُلئت حقول النموذج، صحيحة. مدى الاختبار يمكننا أن نجد بسهولة المدى الذي تغطيه اختباراتنا (coverage) بتنفيذها من خلال الأمر: CI=true npm test -- --coverage ستجد تقرير HTML بسيط حول مدى التغطية في المجلد coverage/lcov-report. سيخبرنا التقرير مثلًا عن عدد الأسطر البرمجية التي لم تُختبر في المكوّن: ستجد شيفرة التطبيق بالكامل ضمن الفرع part5-8 في المستودع المخصص للتطبيق على GitHub. التمارين 5.13 - 5.16 5.13 اختبارات على قائمة المدونات: الخطوة 1 اكتب اختبارًا يتحقق أن المكوِّن الذي يعرض المدونة سيصيّر عنوان المدونة ومؤلفها، لكنه لن يصيّر عدد الإعجابات بشكل افتراضي. اضف أصناف CSS إلى المكوّن للمساعدة في الاختبار عند الحاجة. 5.14 اختبارات على قائمة المدونات: الخطوة 2 اكتب اختبارًا يتحقق أن عنوان موقع المدونة (url) وعدد الإعجابات سيظهران عند النقر على الزر الذي يتحكم بإظهار التفاصيل. 5.14 اختبارات على قائمة المدونات: الخطوة 3 اكتب اختبارًا يتأكد أن معالج الحدث الذي يُمّرر إلى المكون كخاصية، سيُستدعى مرتين تمامًا عندما يٌنقر الزر like مرتين. 5.16 اختبارات على قائمة المدونات: الخطوة 4 * اكتب اختبارًا لنموذج إنشاء مدونة جديدة. يتحقق الاختبار أن النموذج سيستدعي معالج الحدث الذي يُمرر كخاصية حاملًا التفاصيل عندما تُنشأ مدونة جديدة. فلو حملت الصفة id للعنصر input مثلًا القيمة "author": <input id='author' value={author} onChange={() => {}} /> يمكنك الوصول إلى محتوى العنصر بالأمر: const author = component.container.querySelector('#author') اختبارات تكامل الواجهة الأمامية كتبنا في القسم السابق من المنهاج اختبارات تكامل للواجهة الخلفية، كان من شأنها اختبار منطق الواجهة والاتصال مع قاعدة البيانات عبر الواجهة البرمجية المخصصة للواجهة الخلفية. وقررنا حينها أننا لن نكتب اختبارات للأجزاء لأن شيفرة الواجهة الخلفية بسيطة نوعًا ما، وأن الثغرات في تطبيقنا ستظهر في السيناريوهات الأكثر تعقيدًا من تلك التي قد تكتشفها اختبارات الأجزاء. وحتى هذه اللحظة فإن كل الاختبارات التي أجريناها على الواجهة الأمامية هي اختبارات للأجزاء التي قيّمت صحة الوظيفة التي يقدمها المكوِّن. إن اختبارات الأجزاء مناسبة في بعض الأحيان، لكن لن تكفي في أحيانٍ أخرى أحدث أساليب اختبارات الأجزاء في تقييم عمل التطبيق ككل. يمكننا أيضًا تنفيذ اختبارات تكامل للواجهة الأمامية. تتحقق هذه الاختبارات من تعاون كل المكونات، وتعتبر أكثر صعوبة من اختبارات الأجزاء لأننا قد نضطر على سبيل المثال، إلى محاكاة البيانات القادمة من الخادم. وقد اخترنا أن نركز على الاختبار المشترك للواجهتين (end to end) لاختبار التطبيق ككل، وسنعمل عليها في الفصل الأخير من هذا القسم. اختبارات اللقطات البرمجية تقدم Jest بديلًا مختلفًا تمامًا عن الاختبارات التقليدية ويدعى اختبارات اللقطات snapshot. والميزة الأهم في هذه اللقطات، أنه ليس على المطورين كتابة أية اختبارت بأنفسهم، وكل ما عليهم هو اختيار هذا الاختبار. يعتمد المبدأ الأساسي لهذه الاختبارات على موازنة شيفرة HTML التي يُعرِّفها المكوّن بعد أن تتغير مع شيفرة HTML قبل التغيير. إن لاحظ اختبار اللقطات تغييرات في الشيفرة، فسيعتبرها إما وظيفة جديدة أو ثغرة حدثت مصادفةً. كما يبلغ الاختبار المطور إن تغيرت شيفرة HTML في المكوّن، وعلى المطور أن يبلغ Jest أن هذه التغيرات مطلوبة أو غير مطلوبة. فإن كان التغيير في الشيفرة غير متوقع، فالاحتمال كبير أن تكون ثغرة وبالتالي سيدرك المطور المشاكل المحتملة عنها بسهولة بفضل هذا الاختبار. ترجمة -وبتصرف- للفصل Testing react apps من سلسلة Deep Dive Into Modern Web Development
  17. عرض واجهة تسجيل الدخول في الحالات الملائمة لنعدل التطبيق بحيث لا تظهر واجهة تسجيل الدخول بشكل افتراضي: ستظهر الواجهة عندما ينقر المستخدم على الزر "Login": يمكن للمستخدم إغلاق الواجهة أيضًا بالنقر على الزر "Cancel". سنبدأ بنقل نموذج تسجيل الدخول إلى المكوِّن الخاص به: import React from 'react' const LoginForm = ({ handleSubmit, handleUsernameChange, handlePasswordChange, username, password }) => { return ( <div> <h2>Login</h2> <form onSubmit={handleSubmit}> <div> username <input value={username} onChange={handleUsernameChange} /> </div> <div> password <input type="password" value={password} onChange={handlePasswordChange} /> </div> <button type="submit">login</button> </form> </div> ) } export default LoginForm عُرّفت الحالة وكل الدوال المتعلقة بالنموذج خارج المكوِّن وتُمرر إليه كخصائص. لاحظ كيف أسندت الخصائص إلى المتغيرات بطريقة الإسناد بالتفكيك. ويعني ذلك أننا عوضًا عن كتابة الشيفرة التالية: pre widget const LoginForm = (props) => { return ( <div> <h2>Login</h2> <form onSubmit={props.handleSubmit}> <div> username <input value={props.username} onChange={props.handleChange} name="username" /> </div> // ... <button type="submit">login</button> </form> </div> ) } حيث نحصل فيها على خصائص الكائنprop باستعمال التابع prop.handleSubmit، سنسند الخصائص مباشرة إلى متغيراتها. إحدى الطرق السريعة في إضافة الوظيفة، هو تعديل الدالة loginForm العائدة للمكوِّن App كالتالي: const App = () => { const [loginVisible, setLoginVisible] = useState(false) // ... const loginForm = () => { const hideWhenVisible = { display: loginVisible ? 'none' : '' } const showWhenVisible = { display: loginVisible ? '' : 'none' } return ( <div> <div style={hideWhenVisible}> <button onClick={() => setLoginVisible(true)}>log in</button> </div> <div style={showWhenVisible}> <LoginForm username={username} password={password} handleUsernameChange={({ target }) => setUsername(target.value)} handlePasswordChange={({ target }) => setPassword(target.value)} handleSubmit={handleLogin} /> <button onClick={() => setLoginVisible(false)}>cancel</button> </div> </div> ) } // ... } تحتوي حالة المكوِّن App الآن على المتغير المنطقي loginVisible الذي سيقرر أتعرض واجهة تسجيل الدخول للمستخدم أم لا. تتبدل قيمة loginVisible باستعمال زرين، ولكل منهما معالج الحدث الخاص به والمعرّف مباشرة داخل المكون: <button onClick={() => setLoginVisible(true)}>log in</button> <button onClick={() => setLoginVisible(false)}>cancel</button> تُحدَّد إمكانية ظهور المكوِّن بتنسيقه ضمن السياق، حيث سيختفي المكوًن إن كانت قيمة الخاصية display هي (none). const hideWhenVisible = { display: loginVisible ? 'none' : '' } const showWhenVisible = { display: loginVisible ? '' : 'none' } <div style={hideWhenVisible}> // button </div> <div style={showWhenVisible}> // button </div> لاحظ أننا استخدمنا مجددًا العامل"؟" ثلاثي المعاملات. إن كان المتغير loginVisible "صحيحًا"، ستحمل قاعدة CSS للمكوِّن القيمة التالية: display: 'none'; وإن كان loginVisible "خاطئًا"، لن تحمل الخاصية display أية قيمة تتعلق بعرض المكوِّن. المكونات الأبناء (أو المصفوفة props.children) يُعتبر منطق الشيفرة التي تتحكم بظهور نموذج تسجيل الدخول مستقل بذاته، لهذا من الأجدى أن ننقلها إلى مكوّن جديد خاص بها. هدفنا حاليًا إضافة مكوِّن متبدّل (Togglable) يُستخدم بالطريقة التالية: <Togglable buttonLabel='login'> <LoginForm username={username} password={password} handleUsernameChange={({ target }) => setUsername(target.value)} handlePasswordChange={({ target }) => setPassword(target.value)} handleSubmit={handleLogin} /> </Togglable> تختلف طريقة استخدام المكوِّن قليلًا عن المكوٍّنات السابقة. فللمكوّن معرفات بداية ونهاية تحيط بمكوّن LoginForm. تصطلح React على تسمية المكوًّن LoginForm بالمكوِّن الإبن للمكوِّن Togglable. يمكننا إضافة عناصر React بين معرفي البداية والنهاية للمكوِّن Togglable كما في المثال التالي: <Togglable buttonLabel="reveal"> <p>this line is at start hidden</p> <p>also this is hidden</p> </Togglable> الشيفرة التالية هي شيفرة المكوِّن Togglable: import React, { useState } from 'react' const Togglable = (props) => { const [visible, setVisible] = useState(false) const hideWhenVisible = { display: visible ? 'none' : '' } const showWhenVisible = { display: visible ? '' : 'none' } const toggleVisibility = () => { setVisible(!visible) } return ( <div> <div style={hideWhenVisible}> <button onClick={toggleVisibility}>{props.buttonLabel}</button> </div> <div style={showWhenVisible}> {props.children} <button onClick={toggleVisibility}>cancel</button> </div> </div> ) } export default Togglable إن الجزء الجديد والمهم في الشيفرة هي الخاصية props.children، والتي يستخدم في الإشارة إلى المكوِّنات الأبناء لمكوِّن، والتي تمثل عناصر React الموجودة ضمن معرفي بداية ونهاية المكوّن. ستصيّر الآن المكونات الأبناء بالشيفرة ذاتها التي تصيّر المكون الأب: <div style={showWhenVisible}> {props.children} <button onClick={toggleVisibility}>cancel</button> </div> وعلى خلاف الخصائص الاعتيادية التي رأيناها سابقًا يضاف "الأبناء" تلقائيًا من قبل React وتكون موجودة دائمًا. لو عرّفنا مكوّنًا له معرف نهاية تلقائي "</" كالمكوِّن التالي: <Note key={note.id} note={note} toggleImportance={() => toggleImportanceOf(note.id)} /> ستشكل الخاصية props.children مصفوفة فارغة. يمكن إعادة استعمال المكوِّن togglable لإضافة خاصية العرض والإخفاء للنموذج الذي استخدمناه في إنشاء ملاحظة جديدة. قبل أن نفعل ذلك، لننقل شيفرة نموذج إنشاء الملاحظات الجديدة إلى مكوِّن مستقل: const NoteForm = ({ onSubmit, handleChange, value}) => { return ( <div> <h2>Create a new note</h2> <form onSubmit={onSubmit}> <input value={value} onChange={handleChange} /> <button type="submit">save</button> </form> </div> ) } سنعرّف تاليًا مكوِّن النموذج داخل المكوّن Togglable: <Togglable buttonLabel="new note"> <NoteForm onSubmit={addNote} value={newNote} handleChange={handleNoteChange} /> </Togglable> يمكنك إيجاد الشيفرة الكاملة لتطبيقنا الحالي ضمن الفرع part5-4 في المستودع المخصص للتطبيق على GitHub. حالة النماذج تتواجد حالة التطبيق في المكون الأعلى App. حيث ينص توثيق React على ما يلي بخصوص مكان تواجد الحالة: لو فكرنا قليلًا بحالة النماذج التي يضمها التطبيق، فلن يحتاج المكوّن App، على سبيل المثال، محتوى الملاحظة الجديدة قبل إنشائها. لذلك يمكننا بالمثل نقل حالة النماذج إلى مكوِّن خاص بها. سيتغيّر مكِّون الملاحظة إلى الشكل التالي: import React, {useState} from 'react' const NoteForm = ({ createNote }) => { const [newNote, setNewNote] = useState('') const handleChange = (event) => { setNewNote(event.target.value) } const addNote = (event) => { event.preventDefault() createNote({ content: newNote, important: Math.random() > 0.5, }) setNewNote('') } return ( <div> <h2>Create a new note</h2> <form onSubmit={addNote}> <input value={newNote} onChange={handleChange} /> <button type="submit">save</button> </form> </div> ) } export default NoteForm نُقلت صفة الحالة newNote ومعالج الحدث المسؤول عن تغييرها من المكوِّن App إلى المكوِّن المسؤول عن نموذج الملاحظات. وبقيت فقط الخاصية createNote وهي دالة يستدعيها النموذج عندما تُنشأ ملاحظة جديدة. سيغدو المكوّن أبسط بعد إزالة الحالة newNote ومعالج الحدث المرتبط بها. ستستقبل الدالة addNote التي تنشئ ملاحظة جديدة هذه الملاحظة كوسيط، وهذه الدالة هي الصفة الوحيدة التي أرسلناها إلى النموذج: const App = () => { // ... const addNote = (noteObject) => { noteService .create(noteObject) .then(returnedNote => { setNotes(notes.concat(returnedNote)) }) } // ... const noteForm = () => ( <Togglable buttonLabel='new note'> <NoteForm createNote={addNote} /> </Togglable> ) // ... } يمكن أن نكرر هذه العملية على نموذج تسجيل الدخول، لكننا سنترك ذلك للتمارين الإختيارية. يمكنك إيجاد شيفرة التطبيق في الفرع part5-5 على GitHub. إنشاء مراجع إلى المكوِّنات باستعمال ref جميع الإضافات التي أجريت على التطبيق جيدة، لكن هناك ناحية واحدة يمكن تحسينها. من المنطقي إخفاء نموذج إضافة الملاحظات بعد أن ننشئ الملاحظة الجديدة، لأن النموذج سيبقى ظاهرًا في الوضع الحالي. لكن هناك مشكلة صغيرة في ذلك، فمن يتحكم بعرض النماذج هو المتغير visible الموجود داخل المكوِّن Tooglable. فما هي الطريقة التي تمكننا من الوصول إليه، وهو خارج مكوّن النموذج؟ هناك طرق عديدة لإغلاق النموذج من المكوّن الأب، لكننا سنستعمل آلية المرجع ref الخاصة بالمكتبة React والتي تؤمن مرجعًا إلى المكوِّن. لنعدّل المكوّن App ليصبح كالتالي: import React, { useState, useRef } from 'react' const App = () => { // ... const noteFormRef = useRef() const noteForm = () => ( <Togglable buttonLabel='new note' ref={noteFormRef}> <NoteForm createNote={addNote} /> </Togglable> ) // ... } يستخدم الخطاف useRef لإنشاء مرجع إلى المتغير noteFormRef ويسند إلى المكوّن Togglable الذي يحتوي نموذج إنشاء ملاحظة. وبالتالي سيعمل المتغير noteFormRef كمرجع إلى المكوّن. يضمن الخطاف أن يبقى المرجع كما هو عند إعادة تصيير المكوِّن. عدّلنا أيضًا المكون Togglable ليصبح على الشكل التالي: import React, { useState, useImperativeHandle } from 'react' const Togglable = React.forwardRef((props, ref) => { const [visible, setVisible] = useState(false) const hideWhenVisible = { display: visible ? 'none' : '' } const showWhenVisible = { display: visible ? '' : 'none' } const toggleVisibility = () => { setVisible(!visible) } useImperativeHandle(ref, () => { return { toggleVisibility } }) return ( <div> <div style={hideWhenVisible}> <button onClick={toggleVisibility}>{props.buttonLabel}</button> </div> <div style={showWhenVisible}> {props.children} <button onClick={toggleVisibility}>cancel</button> </div> </div> ) }) export default Togglable تُغلَّف الدالة التي تنشئ المكوّن داخل استدعاء الدالة forwardRef، وبالتالي سيتمكن المكوّن من الوصول إلى المرجع الذي أسند إليه. يستخدم المكوِّن الخطاف useImperativeHandle ليجعل الدالة toggleVisibility متاحة خارج إطار المكوِّن. سنتمكن الآن من إخفاء النموذج بتنفيذ الأمر ()noteFormRef.current.toggleVisibility بعد أن تُنشأ الملاحظة الجديدة: const App = () => { // ... const addNote = (noteObject) => { noteFormRef.current.toggleVisibility() noteService .create(noteObject) .then(returnedNote => { setNotes(notes.concat(returnedNote)) }) } // ... } ولكي نلخص ما مضى، فإن الدالة useImperativeHandle هي خطاف يستخدم لتعريف دوال داخل المكوِّن يمكن استدعاؤها من خارجه. تنجح هذه الحيلة في تغيير حالة المكوِّن، لكنها مزعجة قليلًا. وقد كان بالإمكان إنجاز الوظيفة نفسها وبأسلوب أوضح مستخدمين طريقة المكوّنات المعتمدة على الأصناف التي اعتمدتها React القديمة. سنلقي نظرة على هذه الأصناف في القسم 7. إذًا فهي الحالة الوحيدة التي يكون فيها استخدام خطافات React أقل وضوحًا من مكوِّنات الأصناف. تستخدم المراجع في عدة حالات أخرى مختلفة غير الوصول إلى مكوِّنات React. يمكنك أن تجد شيفرة التطبيق بأكملها ضمن الفرع part5-6 في المستودع الخاص بالتطبيق على GitHub نقطة أخرى حول المكوّنات عندما نعرّف مكوّنًا في React كالتالي: const Togglable = () => ... // ... } ونستخدمه بالشكل: <div> <Togglable buttonLabel="1" ref={togglable1}> first </Togglable> <Togglable buttonLabel="2" ref={togglable2}> second </Togglable> <Togglable buttonLabel="3" ref={togglable3}> third </Togglable> </div> سنكون قد أنشأنا ثلاثة مكوّنات منفصلة ولكل منها حالته المستقلة: وتستخدم الصفة ref لإسناد مرجع لكل مكوّن ضمن المتغيرات togglable1 وtogglable2 وtogglable3. التمارين 5.5 - 5.10 5.5 واجهة أمامية لقائمة المدونات: الخطوة 5 غيّر نموذج إنشاء مدوّنة لكي يعرض عند الحاجة فقط. استخدم الأسلوب الذي اعتمدناه في بداية هذا الفصل. يمكنك إن أردت استعمال المكوّن Togglable الذي أنشأناه سابقًا. يجب أن لا يظهر النموذج بشكل افتراضي: يظهر النموذج بالنقر على الزر "new note": يختفي النموذج عند إنشاء مدونة جديدة. 5.6 واجهة أمامية لقائمة المدونات: الخطوة 6 افصل شيفرة نموذج إنشاء مدونة جديدة وضعها في مكوِّنها الخاص (إن لم تكن قد فعلت ذلك مسبقًا)، ثم انقل كل الحالات التي يتطلبها إنشاء مدونة جديدة إلى هذا المكوِّن. يجب أن يعمل المكوِّن بشكل مشابه للمكوِّن NoteForm الذي أنشأناه سابقًا في هذا الفصل. 5.7 واجهة أمامية لقائمة المدونات: الخطوة 7 * لنضف زرًا إلى كل مدونة ليتحكم بإظهار كل تفاصيل المدونة أو عدم إظهارها. حيث تظهر كل التفاصيل بالنقر على الزر وتختفي التفاصيل بالنقر على الزر ثانيةً. لا حاجة أن ينفذ الزر "like" أي شيء حاليًا. يستخدم التطبيق في الصورة السابقة بعض قواعد CSS لتحسين المظهر، ومن السهل القيام بذلك إن أدرجت قواعد التنسيق ضمن السياق، كما فعلنا في القسم 2. const Blog = ({ blog }) => { const blogStyle = { paddingTop: 10, paddingLeft: 2, border: 'solid', borderWidth: 1, marginBottom: 5 } return ( <div style={blogStyle}> <div> {blog.title} {blog.author} </div> // ... </div> )} ملاحظة: على الرغم من أن الوظيفة التي أضيفت هنا مطابقة تمامًا للوظيفة التي ينفذها بها المكوِّنToggleable، لا يمكنك استخدام هذا المكوِّن مباشرة لتحقيق المطلوب في هذا التمرين. سيكون الحل الأسهل بإضافة حالة إلى المنشور تتحكم بالنموذج الذي يعرضه. 5.8 واجهة أمامية لقائمة المدونات: الخطوة 8 * اضف وظيفةً لزر الاعجابات. يزداد عدد الإعجابات بإرسال طلب HTTP-PUT إلى عنوان المدونة الفريد في الواجهة الخلفية. وطالما أن العملية في الواجهة الخلفية ستستبدل المنشور كاملًا، عليك إرسال كل حقول المنشور ضمن جسم الطلب. فلو أردت إضافة إعجاب إلى المنشور التالي: { _id: "5a43fde2cbd20b12a2c34e91", user: { _id: "5a43e6b6c37f3d065eaaa581", username: "mluukkai", name: "Matti Luukkainen" }, likes: 0, author: "Joel Spolsky", title: "The Joel Test: 12 Steps to Better Code", url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" }, عليك إرسال طلب HTTP-PUT إلى العنوان api/blogs/5a43fde2cbd20b12a2c34e91/، يتضمن البيانات التالية: { user: "5a43e6b6c37f3d065eaaa581", likes: 1, author: "Joel Spolsky", title: "The Joel Test: 12 Steps to Better Code", url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" } تحذير من جديد: إن لاحظت أنك تستخدم await/async مع then فتأكد أنك سترتكب خطأً ما. استخدم أحد الأسلوبين وليس كلاهما في الوقت ذاته. 5.9 واجهة أمامية لقائمة المدونات: الخطوة 9 * عدّل التطبيق ليرتب المنشورات وفقًا لعدد الإعجابات التي تحملها. يمكن ترتيب المنشورات باستخدام تابع المصفوفات sort. 5.10 واجهة أمامية لقائمة المدونات: الخطوة 10 * أضف زرًا لحذف منشور. وأضف أيضًا وسيلة لحذف المنشورات في الواجهة الخلفية. قد يبدو تطبيقك مشابهًا للشكل التالي: يمكنك إظهار رسالة لتأكيد الحذف باستخدام الدالة window.confirm. أظهر زر حذف المنشور إذا كان المستخدم الحالي للتطبيق هو من أنشأ هذا المنشور. الحزمة PropTypes والخصائص النمطية يفترض المكوِّن Togglable أنه سيحصل على النص الذي سيظهر على الزر عبر الخاصية buttonLabel، فلو نسينا تحديد ذلك في المكوِّن: <Togglable> buttonLabel forgotten... </Togglable> سيعمل التطبيق، لكن سيصيّر المتصفح الزر الذي لا يحمل نصًا. لذلك قد نرغب أن نجعل وجود قيمة للنص الذي سيظهر كعنوان على الزر أمرًا إجباريًا عند استخدام المكوِّن Togglable. يمكننا تحديد الخصائص المتوقعة والضرورية (الخصائص النمطية) باستخدام الحزمة prop-types. لنثبت الحزمة إذًا npm install prop-types يمكننا تحدد الخاصية buttonLabel كخاصية إجبارية أو كخاصية نصية لازمة كالتالي: import PropTypes from 'prop-types' const Togglable = React.forwardRef((props, ref) => { // .. }) Togglable.propTypes = { buttonLabel: PropTypes.string.isRequired } ستعرض الطرفية رسالة الخطأ التالية إذا لم نحدد قيمة الخاصية: سيعمل التطبيق، فلا يمكن إجبارنا على تحديد الخصائص سوى تعريفات الخصائص النمطية PropTypes. لكن تذكر أن ترك رسائل غير مقروءة على طرفية المتصفح، هو أمر غير مهني على الإطلاق. لنعرِّف خصائص نمطية للمكوِّن LoginForm. import PropTypes from 'prop-types' const LoginForm = ({ handleSubmit, handleUsernameChange, handlePasswordChange, username, password }) => { // ... } LoginForm.propTypes = { handleSubmit: PropTypes.func.isRequired, handleUsernameChange: PropTypes.func.isRequired, handlePasswordChange: PropTypes.func.isRequired, username: PropTypes.string.isRequired, password: PropTypes.string.isRequired } إن كان نمط الخاصية الممررة خاطئًا، كأن نعرف مثلًا الخاصية handleSubmit كنص، سيتسبب ذلك برسالة التحذير التالية: المدقق ESlint هيئنا في القسم 3 مدقق تنسيق الشيفرة ESlint ليعمل مع الواجهة الخلفية. سنستخدمه الآن مع الواجهة الأمامية. يثبت البرنامج Create-react-app المدقق ESlint تلقائيًا في المشروع، وكل ما يبقى علينا هو كتابة تعليمات التهيئة المطلوبة داخل الملف eslintrc.js. ملاحظة: لا تنفذ الأمر eslint --init. سيثبت هذا الأمر آخر نسخة من ESlint والتي لن تتوافق مع ملف التهيئة الذي ينشئه createreactapp. سنبدأ تاليًا باختبار الواجهة الأمامية. ولكي نتجنب أخطاء التدقيق غير المرغوبة أو التي لا تتعلق بشيفرتنا، سنثبت الحزمة eslint-jest-plugin: npm add --save-dev eslint-plugin-jest لننشئ الملف eslintrc.js الذي يحتوي الشيفرة التالية: module.exports = { "env": { "browser": true, "es6": true, "jest/globals": true }, "extends": [ "eslint:recommended", "plugin:react/recommended" ], "parserOptions": { "ecmaFeatures": { "jsx": true }, "ecmaVersion": 2018, "sourceType": "module" }, "plugins": [ "react", "jest" ], "rules": { "indent": [ "error", 2 ], "linebreak-style": [ "error", "unix" ], "quotes": [ "error", "single" ], "semi": [ "error", "never" ], "eqeqeq": "error", "no-trailing-spaces": "error", "object-curly-spacing": [ "error", "always" ], "arrow-spacing": [ "error", { "before": true, "after": true } ], "no-console": 0, "react/prop-types": 0 }, "settings": { "react": { "version": "detect" } } } ملاحظة: إن كنت تستخدم ESlint كإضافة مع Visual Studio Code، قد تحتاج إلى إعدادات إضافية لفضاء العمل حتى يعمل. وإن كنت ترى الرسالة: فهذا دليل على أنك تحتاج إلى مزيد من تعليمات التهيئة. قد يؤدي إضافة الأمر "eslint.workingDirectories": [{ "mode": "auto" }] إلى الملف setting.json في فضاء العمل إلى حل المشكلة. يمكنك الاطلاع على المزيد حول الموضوع عبر الانترنت. لننشئ ملف تجاهل قواعد eslint ذو اللاحقة eslintignore. بحيث يحتوي الشيفرة التالية: node_modules build وهكذا لن تخضع المجلدات "build" و"node_modules" لعملية التدقيق. لننشئ أيضًا سكربت npm لتشغيل المدقق: { // ... { "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "server": "json-server -p3001 db.json", "eslint": "eslint ." }, // ... } سيسبب المكوِّن تحذيرًا مزعجًا: "لا يحمل تعريف المكوِّن اسمًا لعرضه" (Component definition is missing display name). وستكشف أداة تطوير React أيضًا أن المكوِّن لا يحمل اسمًا: لكن إصلاح ذلك أمر يسير. import React, { useState, useImperativeHandle } from 'react' import PropTypes from 'prop-types' const Togglable = React.forwardRef((props, ref) => { // ... }) Togglable.displayName = 'Togglable' export default Togglable يمكنك إيجاد شيفرة التطبيق بالكامل ضمن الفرع part5-7 في الستودع الخاص بالتطبيق على GitHub. التمرينان 5.11 - 5.12 5.11 واجهة أمامية لقائمة المدونات: الخطوة 11 عرّف خاصة نمطية propTypes لأحد مكوِّنات التطبيق. 5.12 واجهة أمامية لقائمة المدونات: الخطوة 12 أضف المدقق ESlint إلى التطبيق. أضف ما ترغب من قواعد التهيئة إلى المدقق، وأصلح كل الأخطاء الناتجة عنه. يثبت البرنامج create-react-app المدقق ESlint تلقائيًا في المشروع، وكل ما يبقى عليك هو كتابة تعليمات التهيئة المطلوبة داخل الملف eslintrc.js. ملاحظة: لا تنفذ الأمر eslint --init. سيثبت هذا الأمر آخر نسخة من ESlint والتي لن تتوافق مع ملف التهيئة الذي ينشئه create-react-app. ترجمة -وبتصرف- للفصل props.children, proptypes من سلسلة Deep Dive Into Modern Web Development
  18. لقد انصب اهتمامنا في القسمين السابقين على الواجهة الخلفية بشكل رئيسي، فلم تُدعم الواجهة الأمامية بوظائف إدارة المستخدمين التي أضفناها إلى الواجهة الخلفية في القسم 4 السابق (الذي يبدأ من درس مدخل إلى Node.js وExpress). تُظهر الواجهة الأمامية حاليًا الملاحظات الموجودة، وتعطي المستخدم امكانية تعديل أهمية الملاحظة. لم تعد هناك إمكانية لإضافة ملاحظات جديدة بعد التعديلات التي أجريناها على الواجهة الخلفية في القسم 4. حيث تتوقع الواجهة الخلفية إرفاق شهادة لتأكيد هوية المستخدم مع الملاحظة الجديدة. سنضيف جزءًا من وظيفة إدارة المستخدمين إلى الواجهة الأمامية. سنبدأ بواجهة تسجيل الدخول، وسنفترض عدم إمكانية إضافة مستخدمين جدد من الواجهة الأمامية. أُضيف نموذج تسجيل الدخول إلى أعلى الصفحة، وكذلك نُقل نموذج إضافة ملاحظة جديدة إلى أعلى قائمة الملاحظات سيبدو شكل المكوّن App كالتالي: const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') useEffect(() => { noteService .getAll().then(initialNotes => { setNotes(initialNotes) }) }, []) // ... const handleLogin = (event) => { event.preventDefault() console.log('logging in with', username, password) } return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> <form onSubmit={handleLogin}> <div> username <input type="text" value={username} name="Username" onChange={({ target }) => setUsername(target.value)} /> </div> <div> password <input type="password" value={password} name="Password" onChange={({ target }) => setPassword(target.value)} /> </div> <button type="submit">login</button> </form> // ... </div> ) } export default App يمكن أن تجد شيفرة التطبيق بوضعها الحالي في الفرع part5-1 على GitHub نتعامل مع نموذج تسجيل الدخول بالطريقة ذاتها التي تعاملنا فيها مع النماذج في القسم 2. تحتوي حالة التطبيق على حقول لكلمة السر واسم المستخدم لتخزين البيانات الموجودة في نموذج تسجيل الدخول. ولتلك الحقول معالجات أحداث تُزامن التغيرات في الحقول مع حالة المكون App. تعتمد معالجات الأحداث مبدأً بسيطًا، حيث يمرر كائن لها كوسيط ثم تفكك الحقل عن الكائن وتخزن قيمته في حالة التطبيق. ({ target }) => setUsername(target.value) لم نضف التابع handlelogin المسؤول عن التعامل مع البيانات في النموذج بعد. تجري عملية تسجيل الدخول بإرسال طلب HTTP POST إلى عنوان الموقع api/login/. لهذا سنضع الشيفرة المسؤولة عن هذا الطلب في وحدة مستقلة خاصة باسم login.js ضمن المجلد services. سنستخدم async/await بدلًا من الوعود للتعامل مع طلبات HTTP. import axios from 'axios' const baseUrl = '/api/login' const login = async credentials => { const response = await axios.post(baseUrl, credentials) return response.data } export default { login } يمكن إضافة التابع المسؤول عن تسجيل الدخول كالتالي: import loginService from './services/login' const App = () => { // ... const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [user, setUser] = useState(null) const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) setUser(user) setUsername('') setPassword('') } catch (exception) { setErrorMessage('Wrong credentials') setTimeout(() => { setErrorMessage(null) }, 5000) } } // ... } إن نجح تسجيل الدخول، ستُفرَّغ حقول نموذج التسجيل، وتُحفظ استجابة الخادم (بما فيها الشهادة والتفاصيل) في الحقل user من حالة التطبيق. أما إن فشل تسجيل الدخول، أو نتج خطأ عن تنفيذ التابع loginService.login، سيتلقى المستخدم إشعارًا بذلك، ولن يتلقى المستخدم إشعارًا بنجاح العملية. لنعدّل التطبيق بحيث يظهر نموذج تسجيل الدخول فقط إذا لم يكن قد سجل المستخدم دخوله بعد. أي عندما تكون قيمة الحقل user لاشيء (Null). وكذلك لن يظهر نموذج إضافة ملاحظة جديدة إن لم يسجل المستخدم دخوله، أي يجب أن يحتوي الحقل user تفاصيل المستخدم. سنضيف دالتين مساعدتين إلى المكوِّن App لتوليد النموذجين: const App = () => { // ... const loginForm = () => ( <form onSubmit={handleLogin}> <div> username <input type="text" value={username} name="Username" onChange={({ target }) => setUsername(target.value)} /> </div> <div> password <input type="password" value={password} name="Password" onChange={({ target }) => setPassword(target.value)} /> </div> <button type="submit">login</button> </form> ) const noteForm = () => ( <form onSubmit={addNote}> <input value={newNote} onChange={handleNoteChange} /> <button type="submit">save</button> </form> ) return ( // ... ) } ثم سنصيّرهما شرطيًا: const App = () => { // ... const loginForm = () => ( // ... ) const noteForm = () => ( // ... ) return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> {user === null && loginForm()} {user !== null && noteForm()} <div> <button onClick={() => setShowAll(!showAll)}> show {showAll ? 'important' : 'all'} </button> </div> <ul> {notesToShow.map((note, i) => <Note key={i} note={note} toggleImportance={() => toggleImportanceOf(note.id)} /> )} </ul> <Footer /> </div> ) } سيبدو شكلها غريبًا قليلًا، إلا أنها حيلة في React تُستخدم لتصيير النماذج شرطيًا: { user === null && loginForm() } فإن كانت نتيجة تنفيذ العبارة الأولى (خطأ)، أو كانت قيمتها خاطئة، لن تُنفَّذ العبارة الثانية (العبارة التي تولد النموذج). كما يمكننا تنفيذ ذلك مباشرة باستخدام العامل الشرطي: return ( <div> <h1>Notes</h1> <Notification message={errorMessage}/> {user === null ? loginForm() : noteForm() } <h2>Notes</h2> // ... </div> ) إن كانت نتيجة الموازنة user === null صحيحة، سيُنفَّذ الأمر ()loginForm، وإلا سيُنفَّذ الأمر ()noteForm. سنجري تعديلًا أخيرًا يظهر فيه اسم المستخدم على الشاشة عندما يسجل دخوله: return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> {user === null ? loginForm() : <div> <p>{user.name} logged-in</p> {noteForm()} </div> } <h2>Notes</h2> // ... </div> ) لم نقدم الحل المثالي بعد، لكننا سنكتفي بما فعلناه حاليًا. لقد أصبح المكون الرئيسي ضخمًا، وهذه علامة على ضرورة إعادة كتابة شيفرة النماذج ضمن مكوناتها الخاصة. سنترك هذا الأمر لننفذه لاحقًا ضمن التمارين الاختيارية. ستجد شيفرة التطبيق ضمن الفرع part5-2 على GitHub. إنشاء ملاحظات جديدة تُحفظ الشهادة التي تعاد من الخادم بعد نجاح تسجيل الدخول ضمن الحقل user.token في حالة التطبيق. const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) setUser(user) setUsername('') setPassword('') } catch (exception) { // ... } } لنصلح عملية إنشاء ملاحظة جديدة لتعمل مع الواجهة الخلفية، بإضافة شهادة المستخدم (بعد أن يسجل دخوله) إلى ترويسة تفويض طلب HTTP. ستصبح الوحدة noteService على النحو التالي: import axios from 'axios' const baseUrl = '/api/notes' let token = null const setToken = newToken => { token = `bearer ${newToken} `} const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } const create = async newObject => { const config = { headers: { Authorization: token }, } const response = await axios.post(baseUrl, newObject, config) return response.data } const update = (id, newObject) => { const request = axios.put(`${ baseUrl } /${id}`, newObject) return request.then(response => response.data) } export default { getAll, create, update, setToken } تحتوي الوحدة على المتغير الخاص token، الذي يمكن تغيير قيمته باستخدام الدالة setToken التي يصدّرها النموذج. تضع الدالة الشهادة في ترويسة التفويض باستعمال async/await. ولاحظ أن هذه الدالة ستُمرَّر كمعامل ثالث إلى التابع post العائد للمكتبة axios. ينبغي تغيير معالج الحدث المسؤول عن تسجيل الدخول ليستدعي التابع (noteService.setToken(user.token عند نجاح تسجيل الدخول: const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) noteService.setToken(user.token) setUser(user) setUsername('') setPassword('') } catch (exception) { // ... } } وهكذا سنتمكن من إضافة ملاحظات جديدة. تخزين الشهادة في ذاكرة المتصفح لا تزال هناك ثغرة في التطبيق. فعندما يُعاد تصيير الصفحة ستختفي معلومات المستخدم. ستبطئ هذه الثغرة عملية التطوير. إذا علينا مثلًا أن نسجل الدخول في كل مرة نحاول فيها إنشاء ملاحظة جديدة. تُحل هذه المشكلة بسهولة إذا ما خزّنا تفاصيل عملية تسجيل الدخول ضمن الذاكرة المحلية للمتصفح، وهي قاعدة بيانات في المتصفح تخزن البيانات بطريقة مفتاح-قيمة، واستخدامها سهل للغاية. حيث ترتبط كل قيمة في هذه الذاكرة بمفتاح وتحفظ في قاعدة بيانات المتصفح باستخدام التابع setItem. window.localStorage.setItem('name', 'juha tauriainen') حيث يخزّن التابع في الشيفرة السابقة مثلًا، المعامل الثاني كقيمة للمفتاح name. يمكن الحصول على قيمة المفتاح باستخدام التابع getItem. window.localStorage.getItem('name') كما يمكن حذفه باستخدام التابع removeItem. ستبقى القيم في الذاكرة المحلية حتى لو أعدنا تصيير الصفحة، وهذه ذاكرة خاصة بالمَنشأ، أي أن كل تطبيق سيحتفظ بذاكرته الخاصة. سنوسع التطبيق لكي تُحفظ تفاصيل تسجيل الدخول في الذاكرة المحلية. إن القيم التي تحفظ في الذاكرة من النوع DOMstrings، لذلك لن نتمكن من حفظ كائن JavaScript كما هو. إذًا، يجب تحويل الكائن إلى تنسيق JSON أولًا باستخدام التابع JSON.stringify. وبالعكس لابد من تحويل المعلومات التي تُقرأ من الذاكرة إلى كائن JavaScript باستخدام التابع JSON.parse. ستصبح شيفرة تسجيل الدخول كالتالي: const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) window.localStorage.setItem( 'loggedNoteappUser', JSON.stringify(user) ) noteService.setToken(user.token) setUser(user) setUsername('') setPassword('') } catch (exception) { // ... } } وهكذا سنحتفظ ببيانات تسجيل الدخول في الذاكرة المحلية والتي يمكن عرضها على الطرفية: سنتمكن أيضًا من تحري الذاكرة المحلية باستخدام أدوات التطوير. فإن كنت من مستخدمي Chrome، توجه إلى النافذة Application واختر Local storage (ستجد تفاصيل أكثر على موقع مطوري Google)، بينما لو كنت من مستخدمي Firefox توجه إلى النافذة Storage واختر Local Storage (ستجد تفاصيل أكثر على موقع مطوري Mozilla). بقي علينا أن نعدل التطبيق لكي يتحقق من وجود تفاصيل تسجيل الدخول ضمن الذاكرة المحلية. فإن وُجدت هذه التفاصيل ستُحفظ في حالة التطبيق وفي noteService أيضًا. أما الطريقة الصحيحة لتنفيذ الأمر هي استخدام خطاف التأثير، وهي آلية قدمناها لأول مرة في القسم 2، واستعملناها لإحضار الملاحظات من الخادم. نستطيع استخدام عدة خطافات تأثير، لذلك سننشئ واحدًا جديدًا ليتعامل مع أول تحميل للصفحة. const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [user, setUser] = useState(null) useEffect(() => { noteService .getAll().then(initialNotes => { setNotes(initialNotes) }) }, []) useEffect(() => { const loggedUserJSON= window.localStorage.getItem('loggedNoteappUser') if (loggedUserJSON) { const user = JSON.parse(loggedUserJSON) setUser(user) noteService.setToken(user.token) } }, []) // ... } يضمن تمرير المصفوفة الفارغة إلى خطاف التأثير أن تنفيذه سيتم فقط عندما يُصير المكون للمرة الأولى. وهكذا سيبقى المستخدم في حالة تسجيل دخول دائمة. وهذا ما يجعلنا نفكر بإضافة وظيفة لتسجيل خروج المستخدم ومسح البيانات من الذاكرة المحلية. لكننا نفضل أن نترك ذلك للتمارين. يمكن تسجيل الخروج باستعمال الطرفية وهذا كافٍ حتى اللحظة. حيث يمكن تسجيل الخروج بتنفيذ الأمر: window.localStorage.removeItem('loggedNoteappUser') أو بالأمر التالي الذي يفرّغ الذاكرة المحليّة: window.localStorage.clear() يمكنك إيجاد الشيفرة الحالية للتطبيق ضمن الفرع part5-3 على GitHub. التمارين 5.1 - 5.4 سنطور في هذه التمارين واجهة أمامية لتطبيق قائمة المدونات. يمكنك الاستفادة من التطبيق المساعد الموجود على GitHub كأساس لحلك. يتوقع التمرين أن تعمل الواجهة الخلفية على المنفذ 3001. يكفي أن تسلم التمرين بشكله النهائي، كما يمكنك ترك تعليق بعد كل تمرين، لكن ذلك غير ضروري. تذكرك التمارين القليلة الأولى بكل ما تعلمته حتى الآن عن React. وستحمل أيضًا قدرًا من التحدي، وخاصة إن لم تكن واجهتك الخلفية مكتملة. قد يكون من الأفضل استخدام الواجهة الخلفية لنموذج الحل الذي اعتمدناه في القسم 4. تذكر كل الطرق التي تعلمناها في تنقيح التطبيقات أثناء كتابتك للشيفرة، وابق عينيك على طرفية التطوير دائمًا. تحذير: إن لاحظت أنك تستخدم await/async مع then، فتأكد أنك ستقع في الأخطاء بنسبة %99. استخدم أحد الأسلوبين وليس كلاهما. 5.1 الواجهة الأمامية لقائمة المدونات: الخطوة 1 انسخ التطبيق المساعد من GitHub بتنفيذ الأمر التالي: git clone https://github.com/fullstack-hy2020/bloglist-frontend أزل ملف تهيئة git من التطبيق الذي نسخته: cd bloglist-frontend // go to cloned repository rm -rf .git يمكنك تشغيل التطبيق بالطريقة الاعتيادية، لكن عليك أولًا تثبيت الاعتماديات: npm install npm start أضف وظيفة تسجيل الدخول إلى التطبيق. يجب أن تحفظ تفاصيل تسجيل الدخول الناجح في الحقل user من حالة التطبيق. لن يظهر سوى نموذج تسجيل الدخول إن لم يسجل المستخدم دخوله بعد. ستظهر قائمة المدونات بالإضافة إلى اسم المستخدم عندما ينجح في تسجيل دخوله. لا يجب عليك تخزين تفاصيل تسجيل الدخول ضمن الذاكرة المحلية للمتصفح بعد. ملاحظة: يمكن أن تساعدك الشيفرة التالية في تنفيذ التصيير الشرطي للصفحة عند تسجيل الدخول: if (user === null) { return ( <div> <h2>Log in to application</h2> <form> //... </form> </div> ) } return ( <div> <h2>blogs</h2> {blogs.map(blog => <Blog key={blog.id} blog={blog} /> )} </div> ) } الواجهة الأمامية لقائمة المدونات: الخطوة 2 اجعل تسجيل الدخول مستمرًا مستخدمًا الذاكرة المحلية للمتصفح، وأضف طريقة لتسجيل الخروج. تأكد من عدم تذكر المتصفح أية تفاصيل عن المستخدم بعد تسجيل الخروج. 5.3 الواجهة الأمامية لقائمة المدونات: الخطوة 3 وسع التطبيق بحيث يتمكن المستخدم من إضافة مدونات جديدة. 5.4 الواجهة الأمامية لقائمة المدونات: الخطوة 4 * أضف إشعارات إلى التطبيق في أعلى الصفحة، لإبلاغ المستخدم بحالة تسجيل الدخول إن كانت ناجحة أو فاشلة. على سبيل المثال، سيظهر إشعار كهذا عندما نضيف مدونة: وإشعار كهذا عند فشل تسجيل الدخول: يجب أن يظهر الإشعار لثوان عدة، وليس ضروريًا إضافة الألوان. ترجمة -وبتصرف- للفصل Login in frontend من سلسلة Deep Dive Into Modern Web Development
  19. على المستخدمين تسجيل دخولهم إلى التطبيق، ومن المفترض عندما يحدث ذلك، أن ترتبط معلوماتهم تلقائيًا بالملاحظات التي سينشئونها. لذا سنضيف آلية تحقق مبنية على الاستيثاق إلى الواجهة الخلفية. يمكن تقديم أساسيات التحقق المبني على الاستيثاق من خلال المخطط التتابعي التالي: يسجل المستخدم دخوله أولًا باستخدام نافذة تسجيل الدخول التي أضيفت إلى تطبيق React (سنفعل ذلك في القسم 5). سيدفع تسجيل الدخول شيفرة React إلى إرسال اسم المستخدم وكلمة المرور إلى العنوان api/login/ على الخادم عن طريق طلب HTTP-POST. إن كان اسم المستخدم وكلمة المرور صحيحين، سيوّلد الخادم مفتاح مشفَّر (token) يكون بمثابة شهادة تعرّف بطريقة ما المستخدم الذي سجل دخوله. يتميز المتفاح بأنه معلَّم بعلامة رقمية (يقال عليه توقيع رقمي أحيانًا) مما يجعل تزويره مستحيلًا باستخدام طرق التزوير المعروفة. يستجيب الخادم برمز حالة يشير إلى نجاح العملية ويعيد نسخة مفتاح مشفر مع الاستجابة. يحفظ المتصفح بهذا المفتاح في حالة تطبيق React مثلًا. عندما ينشئ المستخدم ملاحظة جديدة (أو عندما يقوم بأي أمر يتطلب التوثيق)، سترسل React نسخة من المفتاح مع الطلب إلى الخادم. يستخدم عندها الخادم هذا المفتاح للتحقق من المستخدم (العملية مشابهة تقريبًا لفتح باب المنزل بالمفتاح الخاص بصاحب المنزل). ملاحظة: سنطلق على هذا المفتاح المشفر (token) اسم «شهادة» مجازًا لأنه يشهد للمستخدم الحامل له بأنه صاحب الحساب الحقيقي المخول للوصول إلى حسابه وبياناته على الموقع. لنضف أولًا وظيفة تسجيل الدخول. علينا تثبيت المكتبة jsonwebtoken التي تسمح لنا بتوليد شهادات ويب JSON (أي JSON web tokens وتختصر إلى JWT). npm install jsonwebtoken --save نضع شيفرة تسجيل الدخول التالية في الملف login.js ضمن المجلد controllers: const jwt = require('jsonwebtoken') const bcrypt = require('bcrypt') const loginRouter = require('express').Router() const User = require('../models/user') loginRouter.post('/', async (request, response) => { const body = request.body const user = await User.findOne({ username: body.username }) const passwordCorrect = user === null ? false : await bcrypt.compare(body.password, user.passwordHash) if (!(user && passwordCorrect)) { return response.status(401).json({ error: 'invalid username or password' }) } const userForToken = { username: user.username, id: user._id, } const token = jwt.sign(userForToken, process.env.SECRET) response .status(200) .send({ token, username: user.username, name: user.name }) }) module.exports = loginRouter يبدأ عمل الشيفرة بالبحث عن المستخدم ضمن قاعدة البيانات من خلال اسم المستخدم المرفق مع الطلب. بعد ذلك يتحقق من كلمة المرور التي ترفق أيضًا مع الطلب. وبما أن كلمات المرور لا تخزن كما هي في قواعد البيانات بل على شكل رموز محسوبة اعتمادًا عليها، سيتحقق التابع bcrypt.compare من صحة كلمة المرور: await bcrypt.compare(body.password, user.passwordHash) إن لم يُعثَر على المستخدم، أو كانت كلمة المرور خاطئة، سيسيب الخادم للطلب برمز الحالة 401 (غير مخول بالعملية) وسيحدد سبب إخفاق الطلب في جسم الاستجابة. إن كانت كلمة المرور صحيحة، ستنشئ الشهادة باستخدام التابع jwt.sign. تحتوي الشهادة على اسم المستخدم ومعرف المستخدم بصيغة موقعة رقميًا. const userForToken = { username: user.username, id: user._id, } const token = jwt.sign(userForToken, process.env.SECRET) توّقع الشهادة رقميًا باستعمال قيمة نصية تمثل ""السر"" موجودة في متغير البيئة SECRET. يضمن هذا التوقيع عدم قدرة أي فريق لا يعرف كلمة "السر" من توليد شهادة صالحة. يجب أن توضع قيمة متغير البيئة في الملف ذو اللاحقة (env.). يستجيب الخادم لأي طلب صحيح بإرسال رمز الحالة 200 (مناسب). تعاد بعدها الشهادة الناتجة واسم المستخدم ضمن جسم الاستجابة. نضيف شيفرة تسجيل الدخول إلى التطبيق بإضافة متحكم المسار التالي إلى الملف app.js: const loginRouter = require('./controllers/login') //... app.use('/api/login', loginRouter) لنحاول تسجيل الدخول باستخدام VS Code REST-client: لن تنجح العملية وستطبع الرسالة التالية على الطرفية: (node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value at Object.module.exports [as sign] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/sign.js:101:20) at loginRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/login.js:26:21) (node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2) أخفق الأمر (jwt.sign(userForToken, process.env.SECRET لأننا لم نضع قيمة "للسر" في متغير البيئة SECRET. سيعمل التطبيق بمجرد أن نقوم بذلك، وسيعيد تسجيل الدخول الناجح تفاصيل المستخدم والشهادة. سيولد اسم المستخدم الخاطئ أو كلمة المرور الخاطئة رسالة خطأ وسيعيد الخادم رمز الحالة المناسب. حصر تدوين الملاحظات بالمستخدمين المسجلين لنمنع إنشاء ملاحظات جديدة ما لم تكن الشهادة المرفقة مع الطلب صحيحة. بعدها ستحفظ الملاحظة في قائمة ملاحظات المستخدم الذي تعرّفه الشهادة. هناك طرق عديدة لإرسال الشهادة من المتصفح إلى الخادم. سنستخدم منها ترويسة التصريح (ِAuthorization). حيث توضح هذه الترويسة تخطيط الاستيثاق المستخدم. سيكون ذلك ضروريًا إن سمح الخادم بطرق متعددة للتحقق. حيث يوضح التخطيط للخادم الطريقة التي يفسر بها الشهادة المرفقة بالطلب. سيكون التخطيط Bearer مناسبًا لنا. وعمليًا لو كانت قيمة الشهادة على سبيل المثال (eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW) ستكون قيمة ترويسة التصريح: Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW ستتغير شيفرة إنشاء ملاحظة جديدة على النحو التالي: const jwt = require('jsonwebtoken') // ... const getTokenFrom = request => { const authorization = request.get('authorization') if (authorization && authorization.toLowerCase().startsWith('bearer ')) { return authorization.substring(7) } return null} notesRouter.post('/', async (request, response) => { const body = request.body const token = getTokenFrom(request) const decodedToken = jwt.verify(token, process.env.SECRET) if (!token || !decodedToken.id) { return response.status(401).json({ error: 'token missing or invalid' }) } const user = await User.findById(decodedToken.id) const note = new Note({ content: body.content, important: body.important === undefined ? false : body.important, date: new Date(), user: user._id }) const savedNote = await note.save() user.notes = user.notes.concat(savedNote._id) await user.save() response.json(savedNote) }) تعزل الدالة المساعدة getTokenFrom الشهادة عن ترويسة التصريح، ويتحقق التابع jwt.verify من صلاحيتها. يفك التابع تشفيرالشهادة أيضًا أو يعيد الكائن الذي بُنيت على أساسه الشهادة. const decodedToken = jwt.verify(token, process.env.SECRET) يحمل الكائن الناتج عن الشهادة بعد فك شيفرته حقلي اسم المستخدم والمعرف الخاص به، ليخبر الخادم من أرسل الطلب. إن لم تكن هناك شهادة، أو لم يحمل الكائن الناتج عن الشهادة معرف المستخدم، سيعيد الخادم رمز الحالة 401 (غير مصرّح له)، وسيحدد سبب الخطأ في جسم الاستجابة. if (!token || !decodedToken.id) { return response.status(401).json({ error: 'token missing or invalid' }) } حالما يتم التحقق من هوية المستخدم، تتابع العملية التنفيذ بالشكل السابق. يمكن إنشاء ملاحظة جديدة باستخدام Postman إن أعطت ترويسة التصريح القيمة النصية الصحيحة وهي bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ، وستكون القيمة الأخرى هي الشهادة التي تعيدها عملية تسجيل الدخول. ستبدو العملية باستخدام Postman كالتالي: وباستعمال Visual Studio Code REST client كالتالي: معالجة الأخطاء يمكن لعمليات التحقق أن تسبب أخطاء من النوع JsonWebTokenError. فلو أزلنا بعض المحارف من الشهادة وحاولنا كتابة ملاحظة جديدة، هذا ما سيحدث: JsonWebTokenError: invalid signature at /Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:126:19 at getSecret (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:80:14) at Object.module.exports [as verify] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:84:10) at notesRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/notes.js:40:30) هنالك أسباب عديدة لأخطاء فك التشفير. فقد تكون الشهادة خاطئة أو مزورة أو منتهية الصلاحية. إذًا سنوسع الأداة الوسطية لمعالجة الأخطاء لتأخذ بالحسبان أخطاء فك التشفير الممكنة: const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } const errorHandler = (error, request, response, next) => { if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { return response.status(400).json({ error: error.message }) } else if (error.name === 'JsonWebTokenError') { return response.status(401).json({ error: 'invalid token' }) } logger.error(error.message) next(error) } يمكنك إيجاد شيفرة التطبيق في الفرع part4-9 ضمن المستودع المخصص للتطبيق على GitHub. إن احتوى التطبيق على عدة واجهات تتطلب التحقق، يجب أن نضع أدوات التحقق JWT ضمن أداة وسطية مستقلة. ستساعدنا بعض المكتبات الموجودة مثل express-jwt في تحقيق ذلك. ملاحظة ختامية نظرًا للتغيرات الكثيرة التي أجريناها على الشيفرة فقد حدثت الكثير من الأخطاء التقليدية التي تحدث في كل المشاريع البرمجية، ولم ينجح الكثير من الاختبارات. ولأن هذا القسم من المنهاج مليء بالأفكار الجديدة، سنترك مهمة إصلاح الاختبارات إلى التمارين غير الإلزامية. يجب استخدام كلمات السر وأسماء المستخدمين وشهادات التحقق مع البروتوكول HTTPS. يمكننا استخدام HTTPS ضمن Node في تطبيقنا بدلًا من HTTP، لكن ذلك سيتطلب أوامر تهيئة أكثر. من ناحية أخرى، تتواجد نسخة الإنتاج من تطبيقنا على Heroku، وبذلك سيبقى التطبيق آمنًا. فكل المسارات بين المتصفح وخادم Heroku تستخدم HTTPS. سنضيف آلية تسجيل الدخول إلى الواجهة الأمامية في القسم التالي. التمارين 4.15 - 4.22 سنضيف أساسيات إدارة المستخدمين إلى تطبيق قائمة المدونات في التمارين التالية. إن الطريقة الأكثر أمانًا في الحل، هي متابعة المعلومات التي عرضناها في هذا الفصل والفصل السابق من القسم 4. كما يمكنك بالطبع استعمال مخيلتك. تحذير من جديد: إن لاحظت أنك تستخدم await/async مع then فتأكد أنك ستقع في الأخطاء بنسبة %99. استخدم أحد الأسلوبين وليس كلاهما. 4.15 التوسع في قائمة المدونات: الخطوة 3 أضف طريقة لتسجيل مستخدم جديد باستخدام طلب HTTP إلى العنوان api/users/. للمستخدمين اسم حقيقي واسم للدخول وكلمة مرور. لا تخزّن كلمة السر كنص واضح، بل استخدم المكتبة bcrypt بالطريقة التي استخدمناها في القسم4 -فصل (إدارة المستخدمين). ملاحظة: عانى بعض مستخدمي Windows أثناء العمل مع bcrypt. إن واجهتك المشاكل معها، ألغ تثبيتها كالتالي: npm uninstall bcrypt --save ثم ثبت المكتبة bcryptjs بدلًا عنها. أضف طريقة لعرض تفاصيل جميع المستخدمين من خلال طلب HTTP مناسب. يمكن أن تظهر قائمة المستخدمين بالشكل التالي: 4.16 التوسع في قائمة المدونات: الخطوة 4 * أضف ميزة للتطبيق تنفذ التقييدات التالية: اسم المستخدم وكلمة المرور إلزاميتان يجب أن يكون طول كل من كلمة المرور واسم المستخدم ثلاثة محارف على الأقل. يجب أن يكون اسم المستخدم فريدًا (غير مكرر). يجب أن تعيد العملية رمز الحالة المناسب مع رسالة خطأ عند محاولة إنشاء مستخدم جديد مخالف للتقييدات. ملاحظة: لا تختبر تقييد كلمة المرور باستعمال مقيمات Mongoose. لأن كلمة المرور التي تتلقاها الواجهة الخلفية وكلمة السر المرمزة التي تحفظ في قاعدة البيانات أمران مختلفان. يجب أن يقيم طول كلمة المرور باستخدام المتحكم كما فعلنا في القسم 3 قبل أن نستعمل مقيّمات Mongoose. أضف اختبارًا يتأكد من عدم إضافة مستخدم إذا كانت بياناته غير صحيحة. يجب أن تعيد العملية رمز حالة مناسب ورسالة خطأ. 4.17 التوسع في قائمة المدونات: الخطوة 5 وسٍّع المدونات بحيث تحتوي كل مدونة على معلومات عن المستخدم الذي أنشأها. عدل في أسلوب إضافة مدونة جديدة بحيث يُحدد أحد المستخدمين الموجودين في قاعدة البيانات كمنشئ لها (قد يكون أول شخص يظهر مثلًا). نفذ المهمة اعتمادًا على معلومات الفصل الثالث- القسم 4. لا يهم حاليًا إلى أي مستخدم قد نسبت المدونة، لأننا سنهتم بذلك في التمرين 4.19. عدّل طريقة عرض المدونات بحيث تظهر معلومات منشئ المدونة: عدل طريقة عرض قائمة المستخدمين بحيث تظهر المدونات التي أنشأها المستخدم: 4.18 التوسع في قائمة المدونات: الخطوة 6 أضف أسلوب توثيق يعتمد على الشهادات كما فعلنا في بداية هذا الفصل. 4.19 التوسع في قائمة المدونات: الخطوة 7 عدّل في طريقة إضافة مدونة جديدة لتُنفَّذ فقط في الحالة التي تُرسل فيها شهادة صحيحة عبر طلب HTTP-POST. ويعتبر عندها الشخص الذي تحدده الشهادة منشئ المدونة. 4.20 التوسع في قائمة المدونات: الخطوة 8 * راجع المثال الذي أوردناه في هذا الفصل عن استخلاص الشهادة من ترويسة التصريح باستخدام الدالة المساعدة getTokenFrom. إن أردت استخدام الأسلوب ذاته، أعد كتابة الشيفرة التي تأخذ الشهادة إلى أداة وسطية. يجب أن تأخذ الأداة الوسطية الشهادة من ترويسة التصريح وتضعها في الحقل token من كائن الطلب request. يعني هذا: إن صرّحت عن الأداة الوسطية في بداية الملف app.js وقبل كل المسارات كالتالي: app.use(middleware.tokenExtractor) ستتمكن المسارات من الوصول إلى الشهادة باستخدام الأمر request.token: blogsRouter.post('/', async (request, response) => { // .. const decodedToken = jwt.verify(request.token, process.env.SECRET) // .. }) وتذكر أن الأداة الوسطية هي دالة من ثلاث معاملات، تستدعى في نهاية تنفيذها المعامل الأخير next ليمرر التنفيذ إلى الأداة الوسطية التالية: const tokenExtractor = (request, response, next) => { // code that extracts the token next() } 4.21 التوسع في قائمة المدونات: الخطوة 9 * عدّل في عملية حذف مدونة بحيث تحذف من قبل منشئها فقط. وبالتالي ستنفذ عملية الحذف إن كانت الشهادة المرسلة عبر الطلب مطابقة لشهادة المستخدم الذي أنشأ المدونة. إن جرت محاولة حذف المدونة دون شهادة صحيحة أو من قبل المستخدم الخاطئ، على العملية أن تعيد رمز الحالة المناسب. لاحظ أنك لو أحضرت المدونة من قاعدة البيانات كالتالي: const blog = await Blog.findById(...) وأردت أن توازن بين معرف الكائن الذي أحضرته من قاعدة البيانات والقيمة النصية للمعرف، فلن تنجح عملية الموازنة العادية. ذلك أن الحقل blog.user ليس قيمة نصية بل كائن. لذلك لابد من تحويل قيمة المعرف في الكائن الذي أحضر من قاعدة البيانات إلى قيمة نصية أولًا. if ( blog.user.toString() === userid.toString() ) ... 4.22 التوسع في قائمة المدونات: الخطوة 10 * ستفشل اختبارات إضافة مدونة جديدة حالما تستخدم التوثيق المعتمد على الشهادات. أصلح الاختبارت، ثم اكتب اختبارًا جديدًا يتأكد من أن الخادم سيرد برسالة الخطأ 401 (غير مفوض) إن لم ترفق شهادة بالطلب. اطلع على بعض الأفكار المفيدة التي قد تساعدك في إصلاح الاختبارات. هكذا نكون قد وصلنا إلى التمرين الأخير في القسم، وحان الوقت لتسليم الحلول إلى GitHub، والإشارة إلى أنك سلمت التمارين المنتهية ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل Token authentication من سلسلة Deep Dive Into Modern Web Development
  20. سنحتاج إلى وسيلة للتحقق من المستخدم ومن الصلاحيات الممنوحة له في تطبيقنا. وينبغي أن تُحفظ بيانات المستخدمين في قاعدة البيانات وأن ترتبط بالمستخدم الذي أنشأها. فلن يُسمح بتعديل أو حذف الملاحظة إلا من قبل المستخدم الذي أنشأها. لنبدأ بإضافة معلومات عن المستخدمين إلى قاعدة البيانات. حيث ستظهر علاقة من الشكل "واحد إلى عدة" بين المستخدم User و الملاحظة Note: لو كنا نعمل مع قاعدة بيانات علاقيّة لكانت إضافة البيانات السابقة عملية مباشرة. حيث سيمتلك كلا الموردين جداول مستقلة في قاعدة البيانات وسيخزن معرّف المستخدم الذي أنشأ الملاحظة في جدول الملاحظات كمفتاح خارجي (foreign key). سيختلف الأمر قليلًا عند العمل مع قاعدة بيانات المستندات لوجود طرق عدة للقيام بذلك. يقتضي الحل الذي طبقناه في حالة الملاحظات بتخزين كل ملاحظة في تجمع للملاحظات notes collection ضمن قاعدة البيانات. فإن لم نشأ أن نغير التجمع، فمن المنطقي إنشاء تجمع جديد للمستخدمين على سبيل المثال. كما هي العادة في جميع قواعد بيانات المستندات يمكن استخدام معرّف الكائن في Mongo كمرجع للمستندات في التجمعات المختلفة. وهذا أمر مشابه لاستخدام المفتاح الغريب في قواعد البيانات العلاقية. لا تدعم قواعد البيانات التقليدية مثل Mongo الاستقصاء المشترك (joint query) المتاح في القواعد العلاقية، والذي يستخدم في تجميع البيانات من جداول عدة. لكن اعتبارًا من الإصدار 3.2، دعمت Mongo فكرة استقصاءات البحث المجمعة، والتي لن نتعامل معها في منهاجنا. إن احتجنا إلى وظيفة مشابهة للاستقصاء المجمع في تطبيقنا فسنضيفها إلى الشيفرة بتنفيذ استقصاءات متعددة. على الرغم من أن Mongoose تتكفل في بعض الحالات بموضوع ضم وتجميع البيانات بما يوحي بالاستقصاء المشترك، إلا أن ما تجريه في الواقع، هي استقصاءات متعددة في الخفاء. المراجع ما بين التجمعات لو استخدمنا قاعدة بيانات علاقية، فستحتوي الملاحظة على مفتاح مرجعي إلى المستخدم الذي أنشأها. وبالمثل يمكننا القيام بأمر مشابه في قواعد بيانات المستندات. لنفترض أن تجمع المستخدمين users سيضم مستخدمين اثنين: [ { username: 'mluukkai', _id: 123456, }, { username: 'hellas', _id: 141414, }, ]; يضم تجمع الملاحظات notes ثلاثة ملاحظات، لكل منها حقل خاص يرتبط مرجعيًا بمستخدم من تجمع المستخدمين: [ { content: 'HTML is easy', important: false, _id: 221212, user: 123456, }, { content: 'The most important operations of HTTP protocol are GET and POST', important: true, _id: 221255, user: 123456, }, { content: 'A proper dinosaur codes with Java', important: false, _id: 221244, user: 141414, }, ] لا تتطلب قاعدة بيانات المستند تخزين مفتاح خارجي في حقول الملاحظة، بل يمكن أن يُخزّن في تجمع المستخدمين كما يمكن أن يخزن في كلا التجمعين: [ { username: 'mluukkai', _id: 123456, notes: [221212, 221255], }, { username: 'hellas', _id: 141414, notes: [221244], }, ] طالما أن المستخدم قد ينشئ عدة ملاحظات، سنخزن معرفات هذه الملاحظات في مصفوفة ضمن الحقل notes في مورد المستخدم. تقدم قواعد بيانات المستندات أيضًا طريقة مختلفة جذريًا في تنظيم البيانات: فمن المجدي في بعض الحالات، أن نشعِّب مصفوفة الملاحظات التي أنشأها المستخدم بأكملها كجزء من المستند في تجمع المستخدمين: [ { username: 'mluukkai', _id: 123456, notes: [ { content: 'HTML is easy', important: false, }, { content: 'The most important operations of HTTP protocol are GET and POST', important: true, }, ], }, { username: 'hellas', _id: 141414, notes: [ { content: 'A proper dinosaur codes with Java', important: false, }, ], }, ] في هذا التخطيط لقاعدة البيانات، ستُشعّب الملاحظات تحت المستخدم، ولن تولّد قاعدة البيانات حينها أية معرفات لها. إن بنية وتخطيط قواعد بيانات المستندات ليس واضحة بذاتها كما هي الحال في القواعد العلاقيّة. فيجب أن يخدم التخطيط الذي سنستعمله حالات التطبيق جمعيها بأفضل شكل. وبالطبع فهذا القرار ليس بسيطًا على الإطلاق لأن حالات استخدام التطبيق ليست معروفة تمامًا في فترة التصميم. وللمفارقة، ستتطلب قواعد البيانات التي لا تمتلك تخطيطًا مثل Mongoose من المطور أن يتخذ قرارات جذرية جدًا حول تنظيم البيانات منذ البداية خلافًا للقواعد العلاقيّة. حيث تقدم القواعد العلاقية طرقًا أكثر أو أقل في تنظيم البيانات لعدة تطبيقات تخطيط Mongoose للمستخدمين لقد قررنا في حالتنا أن نخزن معرفات الملاحظات التي ينشئها مستخدم في مستند المستخدم. لذا سنعرف نموذجًا يمثل المستخدم ونضعه في الملف user.js ضمن المجلد models: const mongoose = require('mongoose') const userSchema = new mongoose.Schema({ username: String, name: String, passwordHash: String, notes: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } ], }) userSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v // the passwordHash should not be revealed delete returnedObject.passwordHash } }) const User = mongoose.model('User', userSchema) module.exports = User خُزّنت معرفات الملاحظات ضمن مستند المستخدم كمصفوفة معرفات Mongoose. وللتعريف الشكل التالي: { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } إن نمط الحقل الذي يرتبط مرجعيًا بالملاحظات هو ObjectId. لن تكون Mongo قادرة على تميز هذا الحقل على أنه مرجع للملاحظات، لأن العبارة متعلقة ومعرّفة بالمكتبة Mongoose. لنوسع تخطيط الملاحظة المعرّف في الملف notre.js ضمن المجلد model لكي تضم معلومات على المستخدم الذي أنشأها: const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, date: Date, important: Boolean, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }}) وعلى نقيض قواعد البيانات العلاقيّة سنخزن المراجع في كلا المستندين. حيث سترتبط الملاحظة مرجعيًا بالمستخدم الذي أنشأها، كما يمتلك المستخدم مصفوفة من المراجع لكل الملاحظات التي أنشأها أيضًا. تسجيل مستخدمين جدد لنضف مسارًا لإنشاء مستخدمين جدد. يمتلك المستخدم اسمًا واسمًا آخر لتسجيل الدخول (username) ومعلومة أخرى تدعى كلمة السر المرمّزة passwordHash. هذه الكلمة المرمزة هي نتاج دالة الترميز وحيدة الاتجاه عندما نطبقها على كلمة السر. فمن غير الملائم أن تخزن كلمة السر كنص عادي في قاعدة البيانات. لنثبت الحزمة bcrypt التي ستتولى أمر توليد كلمات السر المرمزة: npm install bcrypt --save سننشئ مستخدمين جدد بما يتوافق مع توجيهات RESTful التي ناقشناها في القسم 3، وذلك بإرسال طلب HTTP POST إلى عنوان الموقع users. لنعرف متحكمًا بالمسار يتعامل مع المستخدمين ولنضعه في ملف جديد باسم users.js ضمن المجلد controllers. سنجعله قابلًا للاستخدام بإدراجه في الملف app.js، حيث سيعالج الطلبات المرسلة إلى العنوان api/users/: const usersRouter = require('./controllers/users') // ... app.use('/api/users', usersRouter) يحتوي الملف الذي يعرّف المتحكم بالمسار الشيفرة التالية: const bcrypt = require('bcrypt') const usersRouter = require('express').Router() const User = require('../models/user') usersRouter.post('/', async (request, response) => { const body = request.body const saltRounds = 10 const passwordHash = await bcrypt.hash(body.password, saltRounds) const user = new User({ username: body.username, name: body.name, passwordHash, }) const savedUser = await user.save() response.json(savedUser) }) module.exports = usersRouter لا تُخزَّن كلمة السر التي تُرسل ضمن الطلب في قاعدة البيانات، بل النسخة المعمّاة التي تولّدها الدالة bcrypt.hash. إن أساسيات تخزين كلمات المرور في قواعد البيانات موضوع خارج نطاق منهاجنا، ولن نناقش ما الذي يعنيه إسناد الرقم السحري 10 إلى المتغيّر saltRounds، لكن يمكن الاطلاع على معلومات أكثر عبر الإنترنت. لا تحتوي الشيفرة الحالية على معالجات أخطاء أو معالجات تقييم، لتتحقق من كون اسم المستخدم وكلمة المرور بالشكل الصحيح. يجب أن تُختبر الميزة الجديدة يدويًا بشكل مبدئي باستخدام أدوات مثل Postman. لكن سرعان ما سيغدو الأمر مربكًا وخاصة عندما نضيف الوظيفة التي تجبر المستخدم على إدخال اسم مستخدم فريد. لذلك فكتابة الاختبارات الآلية لن يحتاج جهدًا كبيرًا، وسيجعل تطوير التطبيق أكثر سهولة. قد يبدو اختبارنا المبدئي كالتالي: const bcrypt = require('bcrypt') const User = require('../models/user') //... describe('when there is initially one user in db', () => { beforeEach(async () => { await User.deleteMany({}) const passwordHash = await bcrypt.hash('sekret', 10) const user = new User({ username: 'root', passwordHash }) await user.save() }) test('creation succeeds with a fresh username', async () => { const usersAtStart = await helper.usersInDb() const newUser = { username: 'mluukkai', name: 'Matti Luukkainen', password: 'salainen', } await api .post('/api/users') .send(newUser) .expect(200) .expect('Content-Type', /application\/json/) const usersAtEnd = await helper.usersInDb() expect(usersAtEnd).toHaveLength(usersAtStart.length + 1) const usernames = usersAtEnd.map(u => u.username) expect(usernames).toContain(newUser.username) }) }) تستخدم الاختبارات الدالة المساعدة ()usersInDb التي أضفناها إلى الملف test_helper.js الموجود في المجلد tests. ستساعدنا الدالة على التحقق من حالة قاعدة البيانات عند إنشاء مستخدم جديد: const User = require('../models/user') // ... const usersInDb = async () => { const users = await User.find({}) return users.map(u => u.toJSON()) } module.exports = { initialNotes, nonExistingId, notesInDb, usersInDb, } تُضيف الكتلة beforeEach مستخدمًا له اسم المستخدم إلى قاعدة البيانات. يمكننا كتابة اختبار جديد يتحقق من عدم تسجيل اسم مستخدم جديد موجود أصلًا: describe('when there is initially one user in db', () => { // ... test('creation fails with proper statuscode and message if username already taken', async () => { const usersAtStart = await helper.usersInDb() const newUser = { username: 'root', name: 'Superuser', password: 'salainen', } const result = await api .post('/api/users') .send(newUser) .expect(400) .expect('Content-Type', /application\/json/) expect(result.body.error).toContain('`username` to be unique') const usersAtEnd = await helper.usersInDb() expect(usersAtEnd).toHaveLength(usersAtStart.length) }) }) لن ينجح الاختبار في هذه الحالة حاليًا. نتدرب من خلال الأمثلة السابقة على ما يسمى التطوير المقاد بالاختبارات test-driven development (TDD)، حيث تُكتب الاختبارات للوظائف الجديدة قبل إضافتها إلى التطبيق. لنقيّم تفرّد اسم المستخدم بمساعدة مقيمات المكتبة Mongoose. لا تمتلك المكتبة كما أشرنا في التمرين 3.19 على مقيمات مدمجة معها للتحقق من وحدانية القيمة لحقل محدد. لكن يمكننا أن نجد حلًا جاهزًا في حزمة npm mongoose-unique-validator. لنثبت هذه الحزمة إذًا. npm install --save mongoose-unique-validator كما ينبغي أن نجري التعديلات التالية على الملف user.js الموجود ضمن المجلد models: const mongoose = require('mongoose') const uniqueValidator = require('mongoose-unique-validator') const userSchema = new mongoose.Schema({ username: { type: String, unique: true }, name: String, passwordHash: String, notes: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } ], }) userSchema.plugin(uniqueValidator) // ... يمكننا إضافة العديد من الاختبارت لتقييم حالة المستخدم الجديد، كالتحقق من طول اسم المستخدم إن كان كافيًا، أو أنه يحوي على محارف غير مسموحة، أو أن كلمة السر قوية بما يكفي. لقد تركنا تلك الوظائف كتمرينات اختيارية. قبل أن نكمل طريقنا، سنضيف معالج مسار يعيد كل المستخدمين المسجلين في قاعدة البيانات: usersRouter.get('/', async (request, response) => { const users = await User.find({}) response.json(users) }) ستبدو القائمة كالتالي: يمكنك إيجاد شيفرة التطبيق بأكملها في الفرع part4-7 ضمن المستودع الخاص بالتطبيق على GitHub. إنشاء ملاحظة جديدة ينبغي أن نعدل شيفرة إنشاء ملاحظة جديدة لكي نربطها بالمستخدم الذي أنشأها. لنوسع الشيفرة بحيث تُرسل معلومات عن المستخدم الذي أنشأ الشيفرة ضمن الحقل userId من جسم الطلب: const User = require('../models/user') //... notesRouter.post('/', async (request, response, next) => { const body = request.body const user = await User.findById(body.userId) const note = new Note({ content: body.content, important: body.important === undefined ? false : body.important, date: new Date(), user: user._id }) const savedNote = await note.save() user.notes = user.notes.concat(savedNote._id) await user.save() response.json(savedNote) }) لن يؤثر التغيير الذي حصل على الكائن user في شيء. وسيُخزَّن معرف الملاحظة في الحقل notes. const user = await User.findById(body.userId) // ... user.notes = user.notes.concat(savedNote._id) await user.save() لنحاول إنشاء ملاحظة جديدة: تبدو العملية ناجحة. لنضف ملاحظة أخرى ثم نحضر بيانات كل المستخدمين باستخدام المسار المخصص لذلك: يمكن أن نرى أن المستخدم قد أنشأ ملاحظتين. كذلك الأمر يمكن رؤية معرفات المستخدمين الذين أنشأوا الملاحظات إذا ما أحضرنا كل الملاحظات باستخدام المسار المخصص لذلك. استخدام التابع Populate نريد لواجهتنا البرمجية أن تعمل بطريقة محددة بحيث تضع محتويات الملاحظات التي أنشأها المستخدم داخل الكائن user عند إرسال طلب HTTP GET، وليس فقط المعرفات الفريدة الخاصة بها. يمكن إنجاز ذلك في قواعد البيانات العلاقيّة باستخدام الاستقصاء المشترك. لا تدعم قواعد بيانات المستندات الاستقصاء المشترك بين التجمعات كما أشرنا سابقًا، لكن يمكن للمكتبة Mongoose أن تنفذ ببعض الأمور المشابهة. حيث تنجز المكتبة الاستقصاء المشترك بتنفيذها استقصاءات متعددة تختلف عن الاستقصاء المشترك في القواعد العلاقية. فلا تتأثر حالة هذه الأخيرة عندما نجري الاستقصاء المشترك، بينما لا يمكن أن نضمن ثبات الحالة بين التجمعين اللذين ضمهما الاستقصاء عند استخدام Mongoose. ويعني هذا أن حالة التجمعين قد تتغير أثناء الاستقصاء، إذا قمنا باستقصاء مشترك بين المستخدمين والملاحظات. تستخدم Mongoose التابع populate في ضم التجمعات. لنعدل المسار الذي يعيد كل المستخدمين أولًا: usersRouter.get('/', async (request, response) => { const users = await User.find({}).populate('notes') response.json(users) }) يظهر التابع populate في السلسة بعد أن ينفذ التابع find الاستقصاء. يحدد الوسيط الذي يُمرر إلى التابع أن معرفات كائنات الملاحظة في الحقل notes من المستند user ستُستبدل بمستند note المرتبط معه مرجعيًا. ستبدو النتيجة الآن كما نريد تقريبًا: يمكن استخدام معامل التابع populate لاختيار الحقول التي نريد إضافتها من المستندات المختلفة، ويتم ذلك باستخدام تعبير Mong التالي: usersRouter.get('/', async (request, response) => { const users = await User .find({}).populate('notes', { content: 1, date: 1 }) response.json(users) }); ستكون النتيجة الآن كما نريد تمامًا: لننشر معلومات المستخدم ضمن الملاحظات بشكل ملائم: notesRouter.get('/', async (request, response) => { const notes = await Note .find({}).populate('user', { username: 1, name: 1 }) response.json(notes) }); وهكذا ستُضاف معلومات المستخدم في الحقل user من كائن الملاحظة. من المهم معرفة أن قاعدة البيانات لاتعلم أن المعرفات المخزنة في الحقل user من الملاحظات مرتبطة مرجعيًا مع تجمع المستخدمين. تعتمد وظيفة التابع populate على حقيقة أننا عرّفنا أنماطًا مرجعية في تخطيط Mongoose مزودة بالخيار ref: const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, date: Date, important: Boolean, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' } }) يمكنك إيجاد شيفرة التطبيق بأكملها في الفرع part4-8 ضمن المستودع الخاص بالتطبيق على GitHub. ترجمة -وبتصرف- للفصل User Administration من سلسلة Deep Dive Into Modern Web Development
  21. سنبدأ الآن بكتابة الاختبارات الخاصة بالواجهة الخلفية. وطالما أن منطق شيفرة الواجهة الخلفية ليس بهذا التعقيد، فلن يكون هناك معنى لكتابة اختبارت لأجزاء الشيفرة. لكن الشيء الوحيد الذي يمكن أن نجري عليه اختبار الأجزاء، هو التابع toJSON الذي يستخدم لتنسيق الملاحظات. قد يكون مفيدًا في بعض الأحيان، أن نختبر الواجهة الخلفية بأن نحاكي قاعدة البيانات بدلًا من استخدام القاعدة الأصلية، ويمكن الاستعانة بالمكتبة mongo-mock في ذلك. وطالما أن تطبيقنا لا يزال سهلًا نوعًا ما، سنختبر التطبيق بالكامل عبر واجهته البرمجية REST، وبالتالي سنضمن اختباره مع قاعدة بياناته. يسمى هذا النوع من الاختبارات الذي نجريه على عدة مكونات كمجموعة متكاملة اسم اختبار التكامل (integration testing) بيئة الاختبار أشرنا في أحد الفصول السابقة إلى أن خادم الواجهة الخلفية سيكون في وضع الإنتاج production mode عندما يعمل على Heroku. وتقتضي تقاليد Node تعريف وضع التطبيق عند تنفيذه ضمن متحول البيئة NODE_ENV. نستعمل في تطبيقنا متحولات البيئة المعرفة ضمن الملف env. فقط إن لم يكن التطبيق في وضع الإنتاج. وقد جرت العادة على تعريف أوضاع التطبيق بشكل منفصل في حالتي الاختبار والإنتاج. عدلنا سكربت package.json لكي يأخذ متحول البيئة NODE_ENV القيمة test عندما نجري الاختبارات: { // ... "scripts": { "start": "NODE_ENV=production node index.js", "dev": "NODE_ENV=development nodemon index.js", "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", "deploy": "git push heroku master", "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", "logs:prod": "heroku logs --tail", "lint": "eslint .", "test": "NODE_ENV=test jest --verbose --runInBand" }, // ... } كما أضفنا الخيار runInBand إلى سكربت npm، حيث يمنع هذا الخيار مكتبة Jest من تنفيذ الاختبارات على التوازي. وسنناقش أهمية هذا الخيار عندما نبدأ الاختبارات مستخدمين قاعدة البيانات. حددنا وضع التطبيق في سكربت npm run dev ليكون في وضع التطوير development، بحيث يمكن استخدام المكتبة nodemon. بينما حددنا وضع التطبيق ليكون في وضع الإنتاج production في سكربت npm start والذي يحدد وضع التطبيق عند التشغيل الافتراضي. سنجد أن هناك مشكلة صغيرة في تعريفنا لوضع التطبيق بهذا الشكل، فلن يعمل التطبيق على Windows. ولحل هذه المشكلة علينا تثبيت حزمة cross-env بتنفيذ الأمر التالي: npm install cross-env يمكننا إنجاز العمل بشكل متوافق مع منصات التشغيل المختلفة باستخدام المكتبة cross-env، حيث سنعدل في سكربت npm ضمن الملف package.json ليصبح على الشكل: { // ... "scripts": { "start": "cross-env NODE_ENV=production node index.js", "dev": "cross-env NODE_ENV=development nodemon index.js", // ... "test": "cross-env NODE_ENV=test jest --verbose --runInBand", }, // ... } وهكذا سنصبح قادرين على تشغيل التطبيق بأوضاع مختلفة. حيث نستطيع مثلًا أن نستخدم قاعدة بيانات منفصلة لأغراض الاختبارات عندما نجريها. على الرغم من إمكانية إنشاء قاعدة بيانات للاختبارات على MongoDB Atlas، إلا أن ذلك لن يكون حلًا جيدًا، لأن العديد من المطورين سيعملون على نفس التطبيق وبالتالي قد تظهر المشاكل. فمن المفترض ألا نجري عدة اختبارات في الوقت ذاته مستخدمين قاعدة البيانات ذاتها. ومن الأفضل إنجاز الاختبارات باستخدام قاعدة بيانات مثبتة على جهاز المطور وتعمل عليه. وبالتالي سيكون الخيار الأنسب استخدام قاعدة بيانات منفصلة لكل اختبار نجريه، وسيكون هذا الأمر بسيطًا نوعًا ما عندما نستخدم إحدى الحاويتين running Mongo in-memory أو Docker. لن نعقد الأمور أكثر بل سنستمر باستخدام قاعدة البيانات على MongoDB Atlas. لنجري بعض التعديلات على الوحدة التي تعرف إعدادات التهيئة لتطبيقنا: require('dotenv').config() let PORT = process.env.PORT let MONGODB_URI = process.env.MONGODB_URI if (process.env.NODE_ENV === 'test') { MONGODB_URI = process.env.TEST_MONGODB_URI} module.exports = { MONGODB_URI, PORT } يضم الملف env. متحولات منفصلة لتعريف عناوين قواعد البيانات لأغراض الاختبار والتطوير: MONGODB_URI=mongodb+srv://fullstack:secred@cluster0-ostce.mongodb.net/note-app?retryWrites=true PORT=3001 TEST_MONGODB_URI=mongodb+srv://fullstack:secret@cluster0-ostce.mongodb.net/note-app-test?retryWrites=true تشابه الوحدة config التي أضفناها إلى التطبيق الحزمة node-config قليلًا. لكن إضافة إعداداتنا الخاصة إلى التطبيق له مبرراته طالما أن التطبيق بسيط وأنه مصمم خصيصًا لتعلم نقاط هامة. وهكذا نكون قد أجرينا كل التعديلات المطلوبة على تطبيقنا. يمكنك إيجاد شيفرة التطبيق الحالي بالكامل في الفرع part4-2 ضمن المخزن المخصص على GitHub. استخدام المكتبة supertest سنستخدم الحزمة supertest التي ستساعدنا على إجراء الاختبارات على واجهة تطبيقنا البرمجية، حيث سنثبت الحزمة كاعتمادية تطوير كالتالي: npm install --save-dev supertest لنكتب أول اختباراتنا في الملف note_api.test.js الذي سننشئه في المجلد tests: const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') const api = supertest(app) test('notes are returned as json', async () => { await api .get('/api/notes') .expect(200) .expect('Content-Type', /application\/json/) }) afterAll(() => { mongoose.connection.close() }) تُدرج شيفرة الاختبار تطبيق Express من الوحدة app.js وتغلفها داخل الدالة supertest لنحصل على كائن جديد يدعى superagent. يُسند هذا الكائن إلى المتغير api حيث يستخدمه التطبيق لإرسال طلبات HTTP إلى الواجهة الخلفية. يرسل تطبيقنا طلب HTTP-GET إلى عنوان الموقع api/notes ويتحقق من استجابة الخادم عندما يعيد رمز الحالة (200). كما يتحقق الاختبار من أن قيمة ترويسة "نوع-المحتوى" هي application/json، وبالتالي ستكون البيانات بالتنسيق الصحيح. ويضم التطبيق أيضًا بعض التفاصيل الأخرى التي سنناقشها بعد قليل. تُسبق الدالة السهمية التي تعرف الاختبار بالتعليمة async، وكذلك يُسبق التابع الذي يستدعي الكائن api بالتعليمة await. سنعود إلى هذا الموضوع بعد أن ننفذ عدة اختبارات أولًا. لا تُعر الموضوع اهتمامًا حاليًا، وعليك فقط أن تتأكد بأن الاختبار يجري بنجاح. فالعبارتين awiat/async في واقع الأمر مرتبطتان بعدم تزامن طلبات HTTP إلى الواجهة البرمجية حيث تستخدم Async/await لكتابة شيفرة غير متزامنة في التنفيذ، لكنها توحي بالتزامن. يجب إنهاء الاتصال الذي تستخدمه Mongoose بقاعدة البيانات بمجرد إنتهاء الاختبارات (هناك اختبار واحد حاليًا). تُنجز هذه المهمة باستخدام التابع afterAll: afterAll(() => { mongoose.connection.close() }) من الممكن أن تواجه التحذير التالي على الطرفية عندما تنفذ الاختبارات: إن حدث هذا، علينا اتباع التعليمات وإضافة الملف jest.config.js عند جذر المشروع وبداخله الشيفرة التالية: module.exports = { testEnvironment: 'node' } يجدر بك الانتباه إلى تفصيل صغير ومهم، فلقد وضعنا تطبيق Express ضمن الملف app.js في بداية هذا القسم، وبقي للملف index.js وظيفة تشغيل التطبيق على المنفذ المحدد باستخدام كائن http المدمج في Node: const app = require('./app') // the actual Express app const http = require('http') const config = require('./utils/config') const logger = require('./utils/logger') const server = http.createServer(app) server.listen(config.PORT, () => { logger.info(`Server running on port ${config.PORT}`) }) تستخدم الاختبارات التي نجريها تطبيق express المعرّف في الملف app.js: const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') const api = supertest(app) // ... سنجد في توثيق supertest مايلي: بمعنًى آخر: ستهتم المكتبة بتشغيل التطبيق الذي تختبره على المنفذ الذي يستخدمه داخليًا. لنكتب عدة اختبارات جديدة: test('there are two notes', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(2) }) test('the first note is about HTTP methods', async () => { const response = await api.get('/api/notes') expect(response.body[0].content).toBe('HTML is easy') }) يخزن كلا الاختبارين الاستجابة ضمن المتغير response، وعلى خلاف الاختبار السابق الذي يستخدم توابع المكتبة supertest للتحقق من رمز الحالة الذي يعيده الخادم، سيتحقق الاختباران من البيانات المخزنة ضمن الخاصية response.body. حيث سيتحققا من تنسيق ومحتوى البيانات في الاستجابة باستخدام التابع expect العائد للمكتبة Jest. ستظهر لك الآن فائدة استخدام await/async. فعادة ما كنا نستخدم دوال الاستدعاء للوصول إلى البيانات التي تعيدها الوعود، لكن بهذه الطريقة ستغدو الأمور مريحةً أكثر: const res = await api.get('/api/notes') //http سيكون تنفيذ الشيفرة هنا بعد اكتمال طلب //res تُحفظ نتيجة تنفيذ الطلب في المتغير expect(res.body).toHaveLength(2) ستحجب الأداة الوسيطية المسؤولة عن إظهار معلومات طلب HTTP، المعلومات التي سيظهرها الاختبار، لذا سنعدل الأداة الوسطية logger بحيث لا تطبع أية معلومات على الطرفية أثناء الاختبار: const info = (...params) => { if (process.env.NODE_ENV !== 'test') { console.log(...params) }} const error = (...params) => { console.error(...params) } module.exports = { info, error } إعادة ضبط قاعدة البيانات قبل الاختبارات تبدو الاختبارات حتى هذه اللحظة سهلة وناجحة. لكنها اختبارت سيئة كونها تعتمد على حالة قاعدة البيانات. ولكي نجعل اختباراتنا أكثر واقعية، علينا إعادة ضبط قاعدة البيانات وتوليد بيانات الاختبار المطلوب بطريقة مضبوطة قبل أن نبدأ الاختبار. تستخدم اختباراتنا الدالة afterAll من المكتبة Jest لإنهاء الاتصال مع قاعدة البيانات بعد أن ينتهي تنفيذ الاختبار. لكن هناك العديد من الدوال التي تقدمها Jest والتي يمكنها تنفيذ العديد من المهام قبل أن يُنفذ أي اختبار أو قبل كل مرة يُنفذ فيها الاختبار. سنعيد ضبط قاعدة البيانات قبل كل اختبار مستخدمين الدالة beforeEach: const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') const api = supertest(app) const Note = require('../models/note') const initialNotes = [ { content: 'HTML is easy', date: new Date(), important: false, }, { content: 'Browser can execute only Javascript', date: new Date(), important: true, }, ] beforeEach(async () => { await Note.deleteMany({}) let noteObject = new Note(initialNotes[0]) await noteObject.save() noteObject = new Note(initialNotes[1]) await noteObject.save() }) تُمحى في البداية البيانات الموجودة في قاعدة البيانات، ثم تُحفظ الملاحظتين المُخزنتين في المصفوفة initialNotes فيها. نضمن عند قيامنا بذلك، بقاء حالة قاعدة البيانات كما هي قبل كل اختبار. سنجري بعض التعديلات على الاختبارين الأخيرين: test('all notes are returned', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(initialNotes.length)}) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(contents).toContain( 'Browser can execute only Javascript' ) }) انتبه جيدًا للتعليمة expect في الاختبار الأخير. ينشئ الأمر (response.body.map(r => r.content مصفوفة تضم محتوى كل ملاحظة تعيدها الواجهة البرمجية. بينما يتحقق التابع toContain من وجود الملاحظة التي تُمرر إليه كوسيط، ضمن قائمة الملاحظات التي تعيدها الواجهة البرمجية. إجراء الاختبارات واحدًا تلو الآخر يُنفٍّذ الأمر npm test كل اختبارات التطبيق. لكن من الأفضل عند كتابة الاختبارات أن ننفذ كل اختبار أو اختبارين معًا. تقدم Jest طرقًا عدة لإنجاز الأمر. من هذه الطرق استخدام التابع only، لكنها ليست فعالة عندما يُكتب الاختبار ليُنفَّذ عبر ملفات عدة. أما الطريقة الأفضل فهي تحديد الاختبارات التي نريد تنفيذها كمعاملات للأمر npm test. فالأمر التالي على سبيل المثال ينفذ فقط الاختبارات الموجودة في الملف note_api.test.js ضمن المجلد tests: npm test -- tests/note_api.test.js نستخدم الخيار t- لتنفيذ اختبار يحمل اسمًا محددًا: npm test -- -t 'a specific note is within the returned notes' يمكن للمعامل الذي نمرره، أن يشير إلى اسم الاختبار أو إلى كتلة الوصف describe block. كما يمكن أن يحمل المعامل جزءًا من الاسم فقط. سينفذ الأمر التالي،على سبيل المثال، كل الاختبارات التي تحتوي أسماؤها على الكلمة notes: npm test -- -t 'notes' ملاحظة: يمكن أن يبقى اتصال Mongoose مفتوحًا عندما ننفذ اختبارًا واحدًا، إن لم تكن هناك اختبارات قيد التنفيذ تستعمل الاتصال. قد تكون المشكلة أن supertest ستجهز الاتصال، لكن Jest لا تنفذ شيفرة الدالة afterAll. التعليمتين async/await قبل أن نستأنف كتابة المزيد من الاختبارات، لنلق نظرة على التعليميتن await وasync. قُدٍّمت التعليمتان لأول مرة في الإصدار ES7، حيث مكّنتا من استخدام "الدوال غير المتزامنة التي تعيد وعدًا" بطريقة تبدو وكأنها متزامنة. وكمثال على ما ذكرناه، سيبدو إحضار الملاحظات من قاعدة البيانات باستخدام الوعود كالتالي: Note.find({}).then(notes => { console.log('operation returned the following notes', notes) }) يعيد التابع ()Note.find وعدًا يمكن الوصول إلى نتيجته بالتصريح عن دالة استدعاء كوسيط للتابع then. وسنجد كل الشيفرة التي يجب تنفيذها حالما تنتهي العملية ضمن دالة الاستدعاء. فلو أردنا أن نجري عدة استدعاءات غير متزامنة للدالة بشكل متسلسل، ستسوء الأمور. لأن الطلبات غير المتزامنة يجب أن تتم ضمن دوال استدعاء، وهذا ما سيعقد الشيفرة وقد يقود إلى ما يسمى جحيم الاستدعاءات (callbak hell). يمكن أن نبقي الأمور تحت السيطرة نوعًا ما باستخدام سلاسل الوعود، وبالتالي نتحاشى "جحيم الاستدعاءات" بإنشاء سلسلة واضحة من التوابع then. ولقد صادفنا ذلك مراتٍ عدة في منهاجنا. لتوضيح ذلك، سنقدم مثالًا افتراضيًا عن دالة تحضر جميع الملاحظات وتحذف الأولى: Note.find({}) .then(notes => { return notes[0].remove() }) .then(response => { console.log('the first note is removed') // المزيد من الشيفرة هنا }) لا بأس باستخدام سلسلة التابع then، لكن هناك طريقة أفضل. تؤمن التوابع المولٍّدة (genertaor functions) التي قُدّمت في الإصدار ES6، طريقة ذكية في كتابة الشيفرة غير المتزامنة بطريقة تجعلها "وكأنها متزامنة". لكنها طريقة غريبة ولم تستخدم بشكل واسع. تقدم التعليمتان await/async اللتان ظهرتا للوجود في الإصدار ES7، الوظائف نفسها التي تقدمها المولدات، لكن بطريقة واضحة ومنهجية ومفهومة بالنسبة لكل مستخدمي JavaScript. يمكن إحضار الملاحظات من قاعدة البيانات باستخدام await كالتالي: const notes = await Note.find({}) console.log('operation returned the following notes', notes) تبدو الشيفرة وكأنها متزامنة. يتوقف التنفيذ مؤقتًا عند العبارة ({})const notes= await Note.find حتى يتحقق الوعد، ثم ينتقل التنفيذ بعدها إلى السطر التالي. تُسند نتيجة العملية التي تعيد الوعد بعد متابعة التنفيذ إلى المتغير notes. يمكن أن ننفذ المثال الذي طرحناه سابقًا (والذي يحمل قليلًا من التعقيد) مستخدمين await كالتالي: const notes = await Note.find({}) const response = await notes[0].remove() console.log('the first note is removed') لقد غدا الأمر أسهل وأوضح من طريقة سلسلة then بفضل القاعدة الجديدة. لكن عليك الانتباه إلى بعض التفاصيل المهمة عند استخدام async/await. فيجب على await أن تعيد وعدًا إذا أردت تطبيقها على عملية غير متزامنة. وطبعًا لن يكون الأمر صعبًا، طالما أن الدوال النظامية غير المتزامنة التي تستخدم الاستدعاءات يمكنها تدبر أمر الوعود. كما لا يمكن استعمال await في أي مكان ضمن شيفرة JavaScript، بل فقط ضمن دالة غير متزامنة. أي حتى يعمل المثال السابق لا بد من استعمال دوال غير متزامنة. لنركز الآن على السطر الأول من الدالة السهمية: const main = async () => { const notes = await Note.find({}) console.log('operation returned the following notes', notes) const response = await notes[0].remove() console.log('the first note is removed') } main() تصرح الشيفرة بوضوح أن الدالة السهمية المُسندة إلى main غير متزامنة، ثم تُستدعى الدالة بعد ذلك باستخدام ()main. استخدام async/await في الواجهة الخلفية سنغيّر شيفرة الواجهة الخلفية لتستخدم await وasync. يكفي أن نغير معالج المسار في أي دالة إلى دالة غير متزامنة حتى نستخدم التعليمة await، طالما أن كل العمليات غير المتزامنة ستجري داخل دالة. سنغير المسار الذي يستخدم لإحضار الملاحظات جميعها على النحو التالي: notesRouter.get('/', async (request, response) => { const notes = await Note.find({}) response.json(notes) }) يمكنك التحقق من نجاح الأمر باختبار الشيفرة ضمن المتصفح وكذلك بتنفيذ الاختبارات التي كتبناها سابقًا. يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-3 ضمن المستودع الخاص بالتطبيق على GitHub. اختبارت أكثر وإعادة كتابة الواجهة الخلفية يبقى دائمًا خطر التدهور (regression) قائمًا عند إعادة كتابة الشيفرة. ونقصد بذلك احتمال توقف التطبيق عن تنفيذ الوظيفة المطلوبة. إذُا سنعيد كتابة ما تبقى من عمليات بكتابة اختبار لكل مسار من مسارات الواجهة البرمجية. سنبدأ بعملية إضافة ملاحظة جديدة، حيث سنكتب اختبارًا ينشئ ملاحظة جديدة ويتحقق أن عدد الملاحظات الذي ستعيده الواجهة البرمجية سيزداد، وأن الملاحظة الجديدة موجودة بالفعل ضمن قائمة الملاحظات. test('a valid note can be added', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, } await api .post('/api/notes') .send(newNote) .expect(200) .expect('Content-Type', /application\/json/) const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(response.body).toHaveLength(initialNotes.length + 1) expect(contents).toContain( 'async/await simplifies making async calls' ) }) سيُنفًّذ الاختبار بنجاح كما خططنا وتوقعنا. لنتكب اختبارًا آخر يتحقق من عدم تخزين أية ملاحظة في قاعدة البيانات ما لم يكن فيها محتوًى. test('note without content is not added', async () => { const newNote = { important: true } await api .post('/api/notes') .send(newNote) .expect(400) const response = await api.get('/api/notes') expect(response.body).toHaveLength(initialNotes.length) }) يتحقق كلا الاختبارين من حالة الملاحظة المخزنة في قاعدة البيانات، وذلك بإحضار كل الملاحظات في التطبيق. const response = await api.get('/api/notes') سننفذ بنفس خطوات التحقق السابقة في الاختبارات القادمة، لذا يفضل نقل شيفرة الاختبارات إلى الدوال المساعدة. ولهذا سنضيف دالة تضم الشيفرة السابقة إلى ملف جديد يدعى test_helper.js ونضعه في المجلد tests: const Note = require('../models/note') const initialNotes = [ { content: 'HTML is easy', date: new Date(), important: false }, { content: 'Browser can execute only Javascript', date: new Date(), important: true } ] const nonExistingId = async () => { const note = new Note({ content: 'willremovethissoon', date: new Date() }) await note.save() await note.remove() return note._id.toString() } const notesInDb = async () => { const notes = await Note.find({}) return notes.map(note => note.toJSON()) } module.exports = { initialNotes, nonExistingId, notesInDb } تعرف الوحدة الدالة notesIdDb التي تتحقق من الملاحظات المخزنة في قاعدة البيانات. كما تضم الوحدة المصفوفة initialNotes التي تخزن الحالة الأساسية لقاعدة البيانات.كما تعرف الوحدة الدالة nonExistingId التي يمكن استخدامها لاحقًا في إنشاء كائن مُعرِّف قاعدة اليانات (ID Object)، وهو كائن لا يتبع لأي ملاحظة مخزنة في القاعدة. سنتمكن الآن من استخدام وحدة مساعدة في الاختبارات التي ستصبح على النحو: const supertest = require('supertest') const mongoose = require('mongoose') const helper = require('./test_helper')const app = require('../app') const api = supertest(app) const Note = require('../models/note') beforeEach(async () => { await Note.deleteMany({}) let noteObject = new Note(helper.initialNotes[0]) await noteObject.save() noteObject = new Note(helper.initialNotes[1]) await noteObject.save() }) test('notes are returned as json', async () => { await api .get('/api/notes') .expect(200) .expect('Content-Type', /application\/json/) }) test('all notes are returned', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(helper.initialNotes.length)}) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(contents).toContain( 'Browser can execute only Javascript' ) }) test('a valid note can be added ', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, } await api .post('/api/notes') .send(newNote) .expect(200) .expect('Content-Type', /application\/json/) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) const contents = notesAtEnd.map(n => n.content) expect(contents).toContain( 'async/await simplifies making async calls' ) }) test('note without content is not added', async () => { const newNote = { important: true } await api .post('/api/notes') .send(newNote) .expect(400) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length)}) afterAll(() => { mongoose.connection.close() }) ستعمل الشيفرة التي تعتمد على الوعود وستنجح الاختبارات التي سننفذها، وبالتالي أصبحنا مستعدين لاستخدام العبارة async/await. سنغير في الشيفرة المسؤولة عن إنشاء ملاحظة جديدة، وانتبه إلى أن تعريف معالج المسار سيُسبق بالكلمة async: notesRouter.post('/', async (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) const savedNote = await note.save() response.json(savedNote) }) لاتزال هناك هفوة صغيرة في شيفرتنا، فلم نكتب أي شيء للتعامل مع الأخطاء. كيف سنعالج الأمر إذًا؟ معالجة الأخطاء عند استعمال async/await إن حدث خطأ في معالجة الطلب POST، سنواجهة حالة الخطأ المألوفة التالية: أي سينتهي بنا المطاف أمام حالة رفض للوعد لم يهيئ التطبيق لالتقاطه، ولن تكون هناك أية استجابة للطلب. إن الطريقة المفضلة للتعامل مع الاستثناءات عندما نستعمل async/await هي الطريقة القديمة المألوفة try/catch: notesRouter.post('/', async (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) try { const savedNote = await note.save() response.json(savedNote) } catch(exception) { next(exception) }}) تستدعي الكتلة catch ببساطة الدالة next التي تمرر معالجة الطلب إلى الوحدة الوسطية التي تعالج الأخطاء. ستنجح كل الاختبارات أيضًا عند إجراء التعديلات السابقة. سنكتب تاليًا اختبارات لإحضار وحذف ملاحظة واحدة: test('a specific note can be viewed', async () => { const notesAtStart = await helper.notesInDb() const noteToView = notesAtStart[0] const resultNote = await api .get(`/api/notes/${noteToView.id}`) .expect(200) .expect('Content-Type', /application\/json/) const processedNoteToView = JSON.parse(JSON.stringify(noteToView)) expect(resultNote.body).toEqual(processedNoteToView) }) test('a note can be deleted', async () => { const notesAtStart = await helper.notesInDb() const noteToDelete = notesAtStart[0] await api .delete(`/api/notes/${noteToDelete.id}`) .expect(204) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength( helper.initialNotes.length - 1 ) const contents = notesAtEnd.map(r => r.content) expect(contents).not.toContain(noteToDelete.content) }) نحصل في الاختبار الأول على كائن الملاحظة بعد أن تخضع الاستجابة إلى عملية تقسيم ومعالجة خاصة ببيانات JSON. ستحول عملية المعالجة قيمة الخاصية date لكائن الملاحظة من كائن Date إلى نص. ولهذا، لن نتمكن مباشرة من الموازنة بين resultNote.body وnoteToView. بدلًا من ذلك علينا أن نُخضع noteToView إلى نفس عملية التقسيم والمعالجة كما يفعل الخادم مع كائن الملاحظة. لكلا الاختبارين الهيكل ذاته. ففي مرحلة إعادة الضبط (التصفير) سيحضر الاختبارين الملاحظة من قاعدة البيانات. يستدعي الاختباران بعد ذلك العملية الرئيسية التي يجري اختبارها. وأخيرًا يتحقق الاختباران من أن نتيجة العملية كما هو متوقع أو لا. سينجح الاختباران وبالتالي سنتمكن من إعادة كتابة شيفرة المسارات المختبرة بأسلوب: notesRouter.get('/:id', async (request, response, next) => { try{ const note = await Note.findById(request.params.id) if (note) { response.json(note) } else { response.status(404).end() } } catch(exception) { next(exception) } }) notesRouter.delete('/:id', async (request, response, next) => { try { await Note.findByIdAndRemove(request.params.id) response.status(204).end() } catch (exception) { next(exception) } }) يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-4 ضمن المستودع الخاص بالتطبيق على GitHub. الاستغناء عن الكتلة try/catch سترتب العبارة async/await الشيفرة قليلًا، لكن الثمن هو الحاجة إلى استخدام الكتلة catch لالتقاط الاستثناءات. تشترك معالجات المسار بالبنية نفسها: try { // do the async operations here } catch(exception) { next(exception) } قد يتسائل أحدنا عن إمكانية إعادة كتابة الشيفرة بالشكل الذي نستغني فيه عن كتلة catch. والحل بالطبع موجود في المكتبة express-async-errors. لثبتها إذًا. npm install express-async-errors --save إن استخدام المكتبة سهل للغاية. حيث ستدرج المكتبة ضمن الملف app.js في المجلد src كالتالي: const config = require('./utils/config') const express = require('express') require('express-async-errors') const app = express() const cors = require('cors') const notesRouter = require('./controllers/notes') const middleware = require('./utils/middleware') const logger = require('./utils/logger') const mongoose = require('mongoose') // ... module.exports = app ستلغي هذه المكتبة كتل try/catch كليًا. فلو أخذنا على سبيل المثال المسار الذي يحذف ملاحظة: notesRouter.delete('/:id', async (request, response, next) => { try { await Note.findByIdAndRemove(request.params.id) response.status(204).end() } catch (exception) { next(exception) } }) سيتحول إلى الشكل: notesRouter.delete('/:id', async (request, response) => { await Note.findByIdAndRemove(request.params.id) response.status(204).end() }) لم يعد علينا استدعاء next(exception)‎ بعد الآن، حيث تعالج المكتبة كل ما هو مطلوب في الخفاء. وإن حدث استثناء في مسار async سيُمرر تلقائيًا إلى الأداة الوسطية لمعالجة الأخطاء. ستصبح المسارات الأخرى على النحو: notesRouter.post('/', async (request, response) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) const savedNote = await note.save() response.json(savedNote) }) notesRouter.get('/:id', async (request, response) => { const note = await Note.findById(request.params.id) if (note) { response.json(note) } else { response.status(404).end() } }) ستجد شيفرة التطبيق في الفرع part4-5 ضمن المستودع المخصص على GitHub. تحسين الدالة beforeEach لنعد إلى كتابة الاختبارات ولنحاول أن نلقي نظرةً أقرب إلى الدالة beforeEach التي تضبط الاختبارات: beforeEach(async () => { await Note.deleteMany({}) let noteObject = new Note(helper.initialNotes[0]) await noteObject.save() noteObject = new Note(helper.initialNotes[1]) await noteObject.save() }) تحفظ الدالة أول ملاحظتين من المصفوفة helper.initialNotes ضمن قاعدة البيانات عبر عمليتين منفصلتين. لابأس بذلك، لكن هناك طريقة أفضل لتخزين عدة كائنات في قاعدة البيانات: beforeEach(async () => { await Note.deleteMany({}) console.log('cleared') helper.initialNotes.forEach(async (note) => { let noteObject = new Note(note) await noteObject.save() console.log('saved') }) console.log('done') }) test('notes are returned as json', async () => { console.log('entered test') // ... } لقد خزَّنا الملاحظات الموجودة في المصفوفة ضمن قاعدة البيانات باستعمال حلقة forEach. لن يعمل التطبيق كما أردنا تمامًا، لذلك كان علينا أن نطبع بعض السجلات على الطرفية لتساعدنا على فهم المشكلة. فأظهرت الطرفية ما يلي: cleared done entered test saved saved على الرغم من استخدام async/await، لم يعمل الحل المقترح بالطريقة المتوقعة. بل بدأ الاختبار قبل أن يُعاد ضبط قاعدة البيانات. تكمن المشكلة في أن كل تكرار للحلقة forEach يولد عملية غير متزامنة خاصة به، ولن تنتظر الحلقة forEach كل تكرار لكي يُنهي تنفيذ عمليته. بمعنى آخر، لم توضع التعليمات await الموجودة داخل حلقة forEach في الدالة beforeEach بل في دوال منفصلة لن تنتظرها beforeEach حتى يكتمل تنفيذها. طالما ستُنفَّذ الاختبارات مباشرة بعد الانتهاء من تنفيذ beforeEach، إذًا ستُنفذ قبل إعادة ضبط قاعدة البيانات. إحدى الطرق المتبعة في معالجة الموضوع هي انتظار العمليات غير المتزامنة حتى تنتهي باستعمال التابع Promise.all: beforeEach(async () => { await Note.deleteMany({}) const noteObjects = helper.initialNotes .map(note => new Note(note)) const promiseArray = noteObjects.map(note => note.save()) await Promise.all(promiseArray) }) إن الأسلوب المتبع متقدم على الرغم من مظهره المختصر. نلاحظ كيف يُسند المتغير noteObjects إلى مصفوفة من كائنات Mongoose التي أنشئت باستخدام الدالة البانية Note من أجل كل ملاحظة موجودة في المصفوفة helper.initialNotes. يُنشئ السطر التالي من الشيفرة مصفوفة جديدة تتضمن الوعود التي أُنشئت بدورها باستدعاء التابع save من أجل كل عنصر من عناصر المصفوفة noteOjects. بمعنًى آخر، تمثل المصفوفة مصفوفةً لتخزين كل العناصر ضمن قاعدة البيانات. يستخدم التابع Promise.all لتحويل مصفوفة من الوعود إلى وعد وحيد يتحقق بمجرد تحقق كل الوعود في المصفوفة التي تمرر إلى التابع. ينتظر الأمر (await Promise.all(promiseArray في السطر الأخير من الشيفرة كل وعد سيخزن ملاحظة في قاعدة البيانات حتى يتحقق، ويعني هذا أن قاعدة البيانات قد أعيد ضبطها. ينفذ التابع Promise.all الوعود التي تمرر إليه بالتوازي. وبالتالي إن أردنا تنفيذ الوعود بترتيب معين ستظهر المشاكل. يمكن أن تنفذ العمليات في حالات كهذه ضمن كتلة for…of التي تضمن ترتيبًا محددًا للتنفيذ: beforeEach(async () => { await Note.deleteMany({}) for (let note of helper.initialNotes) { let noteObject = new Note(note) await noteObject.save() } }) قد تقود الطبيعة غير المتزامنة للغة JavaScript إلى سلوك مفاجئ، ولهذا يجب الانتباه والحرص عند استخدام async/await. وعلى الرغم من أن استعمالها يسهل التعامل مع الوعود، لكن يجدر بنا فهم الآلية التي تعمل بها الوعود بشكل جيد. التمارين 4.8 - 4.12 ملاحظة: نستخدم في التمارين تابع المطابقة toContain في أماكن عدة لنتأكد من وجود عنصر معين في مصفوفة. يستخدم التابع السابق العامل (===) في الموازنة والمطابقة بين العناصر، وهذا غير ملائم في العديد من الحالات بما فيها الكشف عن تطابق كائنين. بينما يعتبر تابع المطابقة toContainEqual ملائمًا في معظم الحالات للمقارنة بين الكائنات ضمن المصفوفات. لا يتحقق نموذج الحل من الكائنات في المصفوفات باستخدام توابع المطابقة، لذا فاستخدام هذا الأسلوب ليس ملزمًا في حل التمارين. تحذير: إن وجدت نفسك قد استخدمت async/await مع then في نفس الشيفرة فهذا دليل على ارتكابك خطأً ما. استخدم أحد الأسلوبين وليس كلاهما. 4.8 اختبارات على قائمة المدونات: الخطوة 1 استخدم الحزمة supertest لكتابة اختبار يرسل طلب HTTP GET إلى العنوان api/blogs/. وتحقق أن تطبيق قائمة المدونات سيعيد العدد الصحيح من منشورات المدونات بصيغة JSON. حالما ينتهي الاختبار، أعد كتابة معالج المسار مستخدمًا async/await بدلًا من الوعود. لاحظ أنه عليك إجراء نفس التغيرات التي أجريناها سابقًا على شيفرتك كتحديد بيئة الاختبار، لتتمكن من كتابة اختبارات يتعامل كل منها مع قاعدة بيانات خاصة به. ملاحظة: قد تواجه التحذير التالي عندما تنفذ الاختبار: إن حدث ذلك، اتبع التعليمات وانشئ ملفًا جديدًا باسم jest.config.js عند جذر المشروع بحيث يحتوي الشيفرة التالية: module.exports = { testEnvironment: 'node' } ملاحظة: يفضل عند كتابة الاختبارات أن لا تنفذها دفعة واحدة. اختبر فقط ذلك الذي تعمل عليه. 4.9 اختبارات على قائمة المدونات: الخطوة 2 * اكتب اختبارًا يتحقق من أن اسم المعرِّف الفريد لكل منشور في المدونة هو id. ستسمي قاعدة البيانات هذه الخاصية بالاسم id_ بشكل افتراضي. يمكن تحقيق ذلك بسهولة عند استخدام تابع المطابقة toBeDefined العائد للمكتبة. أجري التعديلات المناسبة على الشيفرة حتي يُنفذ الاختبار بنجاح. قد يكون التابع toJSON الذي قدمناه في القسم3، المكان الأنسب لتعريف المعامل id. 4.10 اختبارات على قائمة المدونات: الخطوة 3 اكتب اختبارًا تتحقق فيه أن طلب HTTP POST إلى العنوان api/blogs/، سينشئ بنجاح منشورًا جديدًا. تحقق على الأقل أن العدد الكلي للمنشورات في المدونة قد ازداد بمقدار 1. كما يمكنك التحقق أيضًا، أن محتوى المنشور قد حُفظ بالشكل الصحيح في قاعدة البيانات. حالما ينجح الاختبار، أعد كتابة الشيفرة مستخدمًا async/await بدلًا من الوعود. 4.11 اختبارت على قائمة المدونات: الخطوة 4 * اكتب اختبارًا تتحقق فيه من وجود الخاصية likes في الطلب، واجلعها 0 إن لم تكن موجودة. لا تختبر بقية خصائص المدونات التي أنشئت. عدل في الشيفرة حتى تنجز الاختبار بنجاح. 4.12 اختبارات على قائمة المدونات: الخطوة 5 * اكتب اختبارًا متعلقًا بإنشاء مدونة جديدة من خلال الوجهة api/blogs/. حيث يتحقق من وجود الخاصيتين title وurl ضمن بيانات الطلب. فإن لم يعثر عليهما ستستجيب الواجهة الخلفية برمز الحالة 400 (طلب سيء). إعادة كتابة الاختبارت لم تكتمل تغطيتنا بعد لموضوع الاختبارات. فلم نختبر طلبات مثل GET /api/notes/:id وDELETE /api/notes/:id عندما ترسل بمعرف غير صالح. كما تحتاج عملية تنظيم وتجميع الاختبارات إلى بعض التحسينات، حيث كتبت جميعها في المستوى الأعلى نفسه ضمن ملف الاختبار. قد تتحسن قابلية قراءة الاختبارات عندما نجمع الاختبارات المترابطة باستخدام كتل الوصف (descripe blocks): const supertest = require('supertest') const mongoose = require('mongoose') const helper = require('./test_helper') const app = require('../app') const api = supertest(app) const Note = require('../models/note') beforeEach(async () => { await Note.deleteMany({}) const noteObjects = helper.initialNotes .map(note => new Note(note)) const promiseArray = noteObjects.map(note => note.save()) await Promise.all(promiseArray) }) describe('when there is initially some notes saved', () => { test('notes are returned as json', async () => { await api .get('/api/notes') .expect(200) .expect('Content-Type', /application\/json/) }) test('all notes are returned', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(helper.initialNotes.length) }) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(contents).toContain( 'Browser can execute only Javascript' ) }) }) describe('viewing a specific note', () => { test('succeeds with a valid id', async () => { const notesAtStart = await helper.notesInDb() const noteToView = notesAtStart[0] const resultNote = await api .get(`/api/notes/${noteToView.id}`) .expect(200) .expect('Content-Type', /application\/json/) const processedNoteToView = JSON.parse(JSON.stringify(noteToView)) expect(resultNote.body).toEqual(processedNoteToView) }) test('fails with statuscode 404 if note does not exist', async () => { const validNonexistingId = await helper.nonExistingId() console.log(validNonexistingId) await api .get(`/api/notes/${validNonexistingId}`) .expect(404) }) test('fails with statuscode 400 id is invalid', async () => { const invalidId = '5a3d5da59070081a82a3445' await api .get(`/api/notes/${invalidId}`) .expect(400) }) }) describe('addition of a new note', () => { test('succeeds with valid data', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, } await api .post('/api/notes') .send(newNote) .expect(200) .expect('Content-Type', /application\/json/) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) const contents = notesAtEnd.map(n => n.content) expect(contents).toContain( 'async/await simplifies making async calls' ) }) test('fails with status code 400 if data invaild', async () => { const newNote = { important: true } await api .post('/api/notes') .send(newNote) .expect(400) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length) }) }) describe('deletion of a note', () => { test('succeeds with status code 204 if id is valid', async () => { const notesAtStart = await helper.notesInDb() const noteToDelete = notesAtStart[0] await api .delete(`/api/notes/${noteToDelete.id}`) .expect(204) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength( helper.initialNotes.length - 1 ) const contents = notesAtEnd.map(r => r.content) expect(contents).not.toContain(noteToDelete.content) }) }) afterAll(() => { mongoose.connection.close() }) جُمّعت مخرجات الاختبارات وفقًا لكتل الوصف: هنالك متسع للتحسينات أيضًا، لكن لابد من المضي قُدمًا. إن هذه الطريقة في الاختبار التي تعتمد على إجراء طلبات HTTP وتحري قواعد البيانات باستخدام Mongoose، ليست الطريقة الوحيدة وليست الأفضل في إنجاز اختبارات التكامل على مستوى الواجهة البرمجية لتطبيقات الخوادم. في الواقع لا توجد طريقة مثلى معتمدة عالميًا لكتابة الاختبارات، لأنها تعتمد على طبيعة التطبيق المختبر والموارد المتاحة. يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-6 ضمن المستودع الخاص بالتطبيق على GitHub. التمارين 4.13 - 4.14 4.13 التوسع في قائمة المدونات: خطوة 1 أضف إلى التطبيق وظيفة حذف منشور واحد. استخدم العبارة async/await. إجعل عملك متوافقًا مع REST عندما تعرّف الواجهة البرمجية لطلبات HTTP. أضف، إن أردت، أية اختبارات للتأكد من عمل الوظيفة، أو استخدم Postman أو أية أداة أخرى. 4.14 التوسع في قائمة المدونات: خطوة 2 أضف إلى التطبيق وظيفة تعديل منشور واحد. استخدم العبارة async/await. أهم ما يتطلبه التطبيق هو تحديث عدد الإعجابات للمنشور. يمكنك إضافة هذه الوظيفة كما حدثنا الملاحظات في القسم 3. أضف، إن أردت، أية اختبارات للتأكد من عمل الوظيفة، أو استخدم Postman أو أية أداة أخرى. ترجمة -وبتصرف- للفصل testing the backend من سلسلة Deep Dive Into Modern Web Development
  22. سنعود للعمل على نسخة الواجهة الخلفية لتطبيق الملاحظات الذي بدأناه في القسم 3 من سلسلة full_stack_101. الهيكل العام للمشروع قبل أن ننتقل لموضوع الاختبارات، سنجري بعض التعديلات على هيكلية مشروعنا لنواكب أفضل المعايير في كتابة تطبيقات Node.js. سينتج عن هذه التعديلات الهيكل التالي لمجلد المشروع: ├── index.js ├── app.js ├── build │ └── ... ├── controllers │ └── notes.js ├── models │ └── note.js ├── package-lock.json ├── package.json ├── utils │ ├── config.js │ ├── logger.js │ └── middleware.js استخدمنا حتى هذه اللحظة الأمرين console.log وconsole.error في طباعة بيانات الشيفرة التي تهمنا على الطرفية. لكن من الأفضل فصل الشيفرات التي تتعلق بأمور الطباعة في وحدة مستقلة خاصة بها سنسميها logger.js، وسنضعها في المجلد utils: const info = (...params) => { console.log(...params) } const error = (...params) => { console.error(...params) } module.exports = { info, error } كما نلاحظ، تحتوي الوحدة على دالتين الأولى info تتولى أمر طباعة الرسائل العادية، والأخرى error تتولى طباعة رسائل الخطأ. لوضع شيفرة الطباعة في وحدة منفصلة مزايا عدة، فلو أردنا مثلًا طباعة السجلات أو الرسائل إلى ملف أو إلى خدمة خارجية لإدارة السجلات مثل graylog أو papertrail، لن يكون علينا سوى تعديل الشيفرة في ملف واحد. سيصبح ملف تشغيل التطبيق index.js على الشكل التالي: const app = require('./app') // the actual Express application const http = require('http') const config = require('./utils/config') const logger = require('./utils/logger') const server = http.createServer(app) server.listen(config.PORT, () => { logger.info(`Server running on port ${config.PORT}`) }) تنحصر وظيفة الملف index.js الآن، بإدراج التطبيق الفعلي من الملف app.js وتشغيله. ستقوم الدالة info بطباعة عبارة على الطرفية تدل على أن التطبيق يعمل. كذلك سننقل الشيفرة التي تتعامل مع متغيرات البيئة إلى وحدة مستقلة اسمها config.js ونضعها في المجلد utils: require('dotenv').config() const PORT = process.env.PORT const MONGODB_URI = process.env.MONGODB_URI module.exports = { MONGODB_URI, PORT } يمكن لأي جزء من التطبيق الوصول إلى متغيرات البيئة بإدراج الوحدة السابقة: const config = require('./utils/config') logger.info(`Server running on port ${config.PORT}`) نقلنا كذلك الشيفرة التي تتعامل مع المسارات إلى وحدة خاصة. وطالما أن مصطلح "متحكمات" يستخدم للإشارة إلى هذه المسارات، سننشئ مجلدًا باسم controller وسنضع فيه الوحدة notes.js التي ستحتوي شيفرة المسارات التي تتعلق بالملاحظات. سيكون محتوى هذه الوحدة كالتالي: const notesRouter = require('express').Router() const Note = require('../models/note') notesRouter.get('/', (request, response) => { Note.find({}).then(notes => { response.json(notes) }) }) notesRouter.get('/:id', (request, response, next) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => next(error)) }) notesRouter.post('/', (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date() }) note.save() .then(savedNote => { response.json(savedNote) }) .catch(error => next(error)) }) notesRouter.delete('/:id', (request, response, next) => { Note.findByIdAndRemove(request.params.id) .then(() => { response.status(204).end() }) .catch(error => next(error)) }) notesRouter.put('/:id', (request, response, next) => { const body = request.body const note = { content: body.content, important: body.important, } Note.findByIdAndUpdate(request.params.id, note, { new: true }) .then(updatedNote => { response.json(updatedNote) }) .catch(error => next(error)) }) module.exports = notesRouter هذه الشيفرة هي محتوى الملف index.js القديم إجمالًا ما عدا بعض التغييرات المهمة. حيث أنشأنا كائن متحكم بالمسار في بداية الملف: const notesRouter = require('express').Router() //... module.exports = notesRouter لقد أدرجت الوحدة متحكمًا بالمسار ليكون متاحًا للاستخدام ضمنها. وهكذا تُستدعى المسارات التي نحتاجها كخصائص لكائن التحكم بالمسار بشكل مماثل لما يفعله الكائن الذي يتحكم بكامل التطبيق. ويجب الانتباه إلى الطريقة التي اختصرنا فيها عنوان المسار. فلقد عرفنا في النسخة السابقة مسارًا على الشكل: app.delete('/api/notes/:id', (request, response) => { بينما أصبح التعريف في النسخة الجديدة كالتالي: notesRouter.delete('/:id', (request, response) => { ما هو إذًا بالتحديد كائن التحكم بالمسار؟ يزودنا توثيق المكتبة express بالتعريف التالي: يمثل المتحكم بالمسار في واقع الأمر أداة وسطية يمكن استخدامها لتعريف العمليات المتعلقة بالمسارات في مكان واحد، وهو عادة وحدة منفصلة خاصة. يستخدم الملف app.js -الذي ينشئ التطبيق الوظيفي المطلوب- هذا المتحكم بالطريقة التالية: const notesRouter = require('./controllers/notes') app.use('/api/notes', notesRouter) يُستخدم المتحكم بالمسار الذي عرفناه في الشيفرة السابقة، إذا كان الطلب موجهًا إلى الموقع الذي يبدأ عنوانه بالصيغة "/api/notes". ولهذا على المتحكم notesRouter أن يعرّف المسارت بعناوين نسبية فقط على شكل مسار فارغ "/" أو أن يذكر المعامل فقط "id:/". سيبدو الملف app.js بعد إجراء التعديلات على النحو التالي: const config = require('./utils/config') const express = require('express') const app = express() const cors = require('cors') const notesRouter = require('./controllers/notes') const middleware = require('./utils/middleware') const logger = require('./utils/logger') const mongoose = require('mongoose') logger.info('connecting to', config.MONGODB_URI) mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true }) .then(() => { logger.info('connected to MongoDB') }) .catch((error) => { logger.error('error connection to MongoDB:', error.message) }) app.use(cors()) app.use(express.static('build')) app.use(express.json()) app.use(middleware.requestLogger) app.use('/api/notes', notesRouter) app.use(middleware.unknownEndpoint) app.use(middleware.errorHandler) module.exports = app ستجد أن التطبيق قد استخدم العديد من الأدوات الوسطية ومنها الأداة notesRouter التي ارتبطت بالمسار "api/notes/". نقلنا أيضًا الأداة الوسطية الخاصة التي أنشأناها في القسم السابق باسم middleware.js إلى المجلد utils بعد إجراء القليل من التعديلات: const logger = require('./logger') const requestLogger = (request, response, next) => { logger.info('Method:', request.method) logger.info('Path: ', request.path) logger.info('Body: ', request.body) logger.info('---') next() } const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } const errorHandler = (error, request, response, next) => { logger.error(error.message) if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { return response.status(400).json({ error: error.message }) } next(error) } module.exports = { requestLogger, unknownEndpoint, errorHandler } أُوكلت مهمة الاتصال مع قاعدة البيانات إلى الوحدة الرئيسية app.js. بينما تتحمل الوحدة note.js الموجودة في المجلد models مسؤولية تعريف تخطيطات Mongoose للملاحظات. const mongoose = require('mongoose') mongoose.set('useFindAndModify', false) const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, date: { type: Date, required: true, }, important: Boolean, }) noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) module.exports = mongoose.model('Note', noteSchema) باختصار، سيبدو هيكل المشروع بشكله الجديد كالتالي: ├── index.js ├── app.js ├── build │ └── ... ├── controllers │ └── notes.js ├── models │ └── note.js ├── package-lock.json ├── package.json ├── utils │ ├── config.js │ ├── logger.js │ └── middleware.js لا نهتم كثيرًا في التطبيقات الصغيرة بهيكلية مشروع التطبيق، لكن بمجرد أن يبدأ التطبيق بالنمو، لابد من تأسيس هيكل بشكل أو بآخر، وتوزيع العمل على وحدات منفصلة. بهذا يغدو تطوير التطبيق أسهل. لا توجد معايير أو تفاهمات خاصة بين المطورين على تسمية مجلدات المشروع عند بناء تطبيقات express. بالمقابل تحتاج بعض المكتبات مثل Ruby on Rails هيكلًا خاصًا. يقدم الهيكل الذي بنيناه لتطبيقنا السابق، واحدًا من أفضل الأمثلة لبناء التطبيقات التي قد تصادفها على الانترنت. ستجد التطبيق بشكله الكامل في الفرع part4-1 على GitHub. إن نسخت المشروع لتحتفظ به لنفسك، نفذ الأمر npm install، قبل أن تشغل التطبيق بالأمر npm run. التمارين 4.1 - 4.2 سنبني خلال تمارين هذا القسم تطبيقًا لإنشاء قائمة مدونات تسمح للمستخدم أن يحفظ معلومات عن مدونات وجدها مهمة خلال تصفحه للإنترنت. سيقوم التطبيق بحفظ اسم المؤلف وعنوان المدونة وعنوان موقعها وعدد التقييمات الإيجابية للمدونة من قبل مستخدمي التطبيق. 4.1 قائمة بالمدونات: الخطوة 1 لنتخيل أنك تلقيت بريدًا إلكترونيًا له جسم التطبيق التالي: const http = require('http') const express = require('express') const app = express() const cors = require('cors') const mongoose = require('mongoose') const blogSchema = new mongoose.Schema({ title: String, author: String, url: String, likes: Number }) const Blog = mongoose.model('Blog', blogSchema) const mongoUrl = 'mongodb://localhost/bloglist' mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }) app.use(cors()) app.use(express.json()) app.get('/api/blogs', (request, response) => { Blog .find({}) .then(blogs => { response.json(blogs) }) }) app.post('/api/blogs', (request, response) => { const blog = new Blog(request.body) blog .save() .then(result => { response.status(201).json(result) }) }) const PORT = 3003 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) حول التطبيق إلى مشروع npm قابل للعمل. هيئ التطبيق ليُنفّذ بمساعدة nodemon. يمكنك أن تنشئ قاعدة بيانات جديدة على MongoDB Atlas، أو استخدم قاعدة البيانات التي أنشأناها في تمرينات القسم السابق. تأكد من إمكانية إضافة مدونات إلى القائمة باستخدام Postman أو VS Code REST client وتأكد كذلك أن التطبيق سيعيد المدونات التي أضيفت إلى الجهة التي طلبتها. 4.2 قائمة بالمدونات: الخطوة 2 قسم التطبيق إلى وحدات منفصلة كما فعلنا سابقًا في هذا القسم. ملاحظة: نفذ التقسيم بخطوات صغيرة خطوة تلو الأخرى، وتأكد أن التطبيق سيعمل بعد كل تغيير. إن حاولت أن تختصر الطريق وتقوم بعدة خطوات في آن واحد سيفشل شيء ما في تطبيقك فهذا السلوك خاضع لقانون مورفي. وستستغرق وقتًا في إيجاد حل لمشكلتك أكثر من الوقت الذي ستستغرقه في تنفيذ التقسيم على خطوات منهجية وصغيرة. حاول أن توضح ما فعلت من خلال التعليقات وخاصة عندما تنجح الخطوة التي نفذتها، سيساعدك هذا في التراجع إلى النقطة التي عَمل فيها التطبيق بشكل جيد. اختبار تطبيقات Node لقد أهملنا كليًا ناحية مهمة من نواحي تطوير البرمجيات وهي الاختبارات المؤتمتة. سنبدأ رحلتنا في مجال الاختبارات، باختبارات الأجزاء unit test. إن منطق التطبيق الذي نعمل عليه بسيط فلن يكون موضوع اختبارات الأجزاء مهمًا لهذه الدرجة. مع ذلك لننشئ ملفًا جديدًا باسم for_testing.js في المجلد utils ولنعرف فيه دالتين نستخدمهما لاختبار بعض نواحي كتابة الشيفرة: const palindrome = (string) => { return string .split('') .reverse() .join('') } const average = (array) => { const reducer = (sum, item) => { return sum + item } return array.reduce(reducer, 0) / array.length } module.exports = { palindrome, average, } هناك الكثير من المكتبات أو أدوات الاختبار التي تُستخدم مع JavaScript. سنستخدم في منهاجنا المكتبة Jest التي طورت واستخدمت من قبل Facebook. وتشابه هذه المكتبة مكتبة Mocha الزعيمة السابقة لأدوات اختبار JavaScript. ومن البدائل المطروحة أيضًا مكتبة ava التي اكتسبت شعبية كبيرة في بعض النطاقات. سيكون اختيار Jest أمرًا طبيعيًا في منهاجنا، فهي تعمل بشكل جيد عند إجراء الاختبارات على الواجهة الخلفية، وتقدم أداء رائعًا عند إجراء اختبارات على تطبيقات React. طالما أن الاختبارات ستنفذ فقط في مرحلة التطوير، سنثبت Jest كاعتمادية للتطوير: npm install --save-dev jest لنعرف سكربت npm باسم test لتنفيذ الاختبارات باستخدام Jest وإنشاء تقرير عن التنفيذ باستخدام الأسلوب verbose: { //... "scripts": { "start": "node index.js", "dev": "nodemon index.js", "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", "deploy": "git push heroku master", "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", "logs:prod": "heroku logs --tail", "lint": "eslint .", "test": "jest --verbose" }, //... } تحتاج المكتبة Jest سكربت لنحدد فيه أن بيئة التنفيذ هي Node. ويتم ذلك بإضافة السطر التالي في نهاية الملف package.json: { //... "jest": { "testEnvironment": "node" } } أو بدلًا عن ذلك، تبحث Jest عن ملف تهيئة باسم jest.config.js، حيث تُعرّف داخله بيئة التنفيذ كالتالي: module.exports = { testEnvironment: 'node', }; سننشئ مجلدًا مستقلًا للاختبارات يدعى tests ونضع فيه الملف palindrome.test.js الذي يحتوي الشيفرة التالية: const palindrome = require('../utils/for_testing').palindrome test('palindrome of a', () => { const result = palindrome('a') expect(result).toBe('a') }) test('palindrome of react', () => { const result = palindrome('react') expect(result).toBe('tcaer') }) test('palindrome of releveler', () => { const result = palindrome('releveler') expect(result).toBe('releveler') }) ستعترض قواعد تهيئة المدقق ESLint الذي أضفناها إلى المشروع في الفصل السابق على الأمرين test و expect في ملف الاختبار، ذلك أن قواعد التهيئة لا تسمح بوجود متغيرات شاملة Globals. لنتخلص من ذلك بإضافة القيمة jest": true" إلى الخاصية env في الملف eslintrc.js: module.exports = { "env": { "commonjs": true "es6": true, "node": true, "jest": true, }, "extends": "eslint:recommended", "rules": { // ... }, }; يدرج الملف الاختبار في السطر الأول الدالة التي سنختبرها، ويسندها إلى متغير يدعى palindrome: const palindrome = require('../utils/for_testing').palindrome تُعرّف الحالات المختبرة بشكل فردي باستخدام الدالة test. تقبل الدالة معاملين، الأول سلسلة نصية تصف الاختبار والثاني دالة تُعرِّف طريقة تنفيذ اختبار الحالة. تبدو طريقة اختبار الحالة الثانية في شيفرة ملف الاختبار palindrome.js كالتالي: () => { const result = palindrome('react') expect(result).toBe('tcaer') } ننفذ أولًا الشيفرة التي سنختبرها، حيث نولد سلسلة معكوسة للنص "react". ثم نتحقق من النتيجة باستخدام الدالة expect. تغلف الدالة expect القيمة الناتجة على شكل كائن يقبل الكثير من توابع المطابقة التي يمكن استخدامها للتحقق من صحة النتيجة. يمكن استخدام تابع المطابقة toBe كون عملية المقارنة في حالتنا بين سلسلتين نصيتين. كما هو متوقع ستنجح جميع الاختبارات: تتوقع المكتبة Jest أن تحتوي أسماء ملفات الاختبارات العبارة "test." وسنلتزم في منهاجنا بهذا التوجيه وسنجعل لاحقة كل ملفات الاختبار على الشكل "test.js." تتمتع Jest بإظهارها رسائل خطأ ممتازة، سنجعل الاختبار يفشل لعرض ذلك: test('palindrom of react', () => { const result = palindrome('react') expect(result).toBe('tkaer') }) عند تنفيذ الاختبار السابق ستظهر رسالة الخطأ التالية: لنضف الآن عدة اختبارات إلى الدالة average في الملف average.test.js الموجود في المجلد tests. const average = require('../utils/for_testing').average describe('average', () => { test('of one value is the value itself', () => { expect(average([1])).toBe(1) }) test('of many is calculated right', () => { expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5) }) test('of empty array is zero', () => { expect(average([])).toBe(0) }) }) سنلاحظ أن الدالة لن تعمل بالشكل الصحيح مع المصفوفة الفارغة، لأن ناتج القسمة على 0 في JavaScript سيعطي القيمة NaN. تصحيح المشكلة السابقة أمر بسيط جدًا: const average = array => { const reducer = (sum, item) => { return sum + item } return array.length === 0 ? 0 : array.reduce(reducer, 0) / array.length } ستعيد الدالة القيمة 0 إذا كان طول المصفوفة 0. ونستخدم في بقية الحالات التابع reduce لحساب المتوسط. يجب الانتباه إلى عدة أمور في الاختبارات التي أجريناها. فلقد أسمينا الكتلة النصية التي تصف الاختبار "average": describe('average', () => { // tests }) تستخدم الكتل التي تصف الاختبارات في تجميع الاختبارات ضمن مجموعات. كما تسمى نتيجة الاختبار في Jest باسم الكتلة أيضًا. وتبرز أهمية الكتل describe عندما نحاول تشغيل بعض الإعدادات المشتركة أو إنهاء العمليات المتعلقة بمجموعة من الاختبارات. وأخيرًا يجدر الانتباه إلى الطريقة المختصرة التي كتبت فيها شيفرة الاختبارات، بحيث لم نسند القيمة الناتجة عن تنفيذ الدالة إلى أي متغير: test('of empty array is zero', () => { expect(average([])).toBe(0) }) التمارين 4.3 - 4.7 لننشئ مجموعة من الدوال المساعدة التي ستُخصص للتعامل مع قائمة المدونات. ضع هذه الدوال في ملف يدعى list_helper.js ضمن مجلد اسمه utils. اكتب الاختبارات في ملف يحمل اسمًا مناسبًا وضعه في المجلد tests. 4.3 الدوال المساعدة واختبارات الأجزاء: الخطوة 1 عرّف في البداية دالةً باسم dummy تستقبل مصفوفة من منشورات مدونة كمعامل وتعيد دائمًا القيمة 1. سيبدو محتوى الملف list_helper.js حتى هذه اللحظة كالتالي: const dummy = (blogs) => { // ... } module.exports = { dummy } تحقق أن معلومات التهيئة لاختبارك ستعمل مع الاختبار التالي: const listHelper = require('../utils/list_helper') test('dummy returns one', () => { const blogs = [] const result = listHelper.dummy(blogs) expect(result).toBe(1) }) 4.4 الدوال المساعدة واختبارات الأجزاء: الخطوة 2 عرف دالةً جديدةً تدعى totalLikes تستقبل قائمة بمنشورات مدونة كمعامل وتعيد مجموع الإعجابات في كل منشورات هذه المدونة. اكتب اختبارًا مناسبًا للدالة. يفضل أن تضع الاختبار ضمن كتلة describe، كي تُجمّع التقارير المتولدة عن الاختبارات بشكل واضح. يمكن أن تُعرّف عناصر دخل الدالة على النحو التالي: describe('total likes', () => { const listWithOneBlog = [ { _id: '5a422aa71b54a676234d17f8', title: 'Go To Statement Considered Harmful', author: 'Edsger W. Dijkstra', url: 'http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html', likes: 5, __v: 0 } ] test('when list has only one blog, equals the likes of that', () => { const result = listHelper.totalLikes(listWithOneBlog) expect(result).toBe(5) }) }) إن وجدت أن إدخال قائمة المدونات يتطلب الكثير من الجهد، استخدم القائمة الجاهزة التي وضعناها على GitHub. ستواجهة المشاكل عندما ستكتب الاختبارات، لذلك تذكر استخدام وسائل التنقيح التي تعلمناها في القسم 3. ويمكنك دائمًا الطباعة على الطرفية مستخدمًا الأمر console.log حتى عند تنفيذ الاختبارات. كما يمكنك أيضًا استخدام المنقحات وستجد الكثير من مواقع الانترنت التي تعطيك إرشادات لاستخدامها. ملاحظة: يفضل في حال أخفقت بعض الاختبارات، أن تشغلها فقط عندما تحاول إصلاح المشكلة. كما يمكنك أن تنفذ اختبارًا واحدًا مستخدمًا التابع only. هناك أسلوب آخر لتنفيذ اختبار محدد بكتابة اسم الكتلة (اسم الاختبار) عند تنفيذ الاختبار مع الصفة t-. npm test -- -t 'when list has only one blog, equals the likes of that' 4.5 الدوال المساعدة واختبارات الأجزاء: الخطوة 3 * عرّف دالة جديدة باسم favoriteBlog تستقبل قائمة بالمدونات كمعامل. تكتشف الدالة المدونة التي تحمل أكبر عدد من الإعجابات. يكفي أن تعيد الدالة مدونة واحدة إن كان هناك أكثر من واحدة. يمكن أن تعيد الدالة قيمة لها الصيغة التالية: { title: "Canonical string reduction", author: "Edsger W. Dijkstra", likes: 12 } ملاحظة: عندما توازن بين الكائنات، استخدم التابع toEqual فهو على الأغلب ما تحتاجه تمامًا، ذلك أن التابع toBe سيحاول أن يتحقق من أن القيمتين متطابقتين بالإضافة إلى امتلاكهما نفس الخصائص. اكتب الاختبار ضمن كتلة describe. كرر ذلك في التمارين الباقية أيضًا. 4.6 الدوال المساعدة واختبارات الأجزاء: الخطوة 4 * يحمل هذا التمرين والتمرين الذي يليه قدرًا من التحدي. وانتبه أن إكمال هذين التمرينين ليس ملزمًا لتتابع تقدمك في مفردات المنهاج. لذلك يفضل العودة إليهما بعد إنهائك مادة هذا القسم بالكامل. يُنجَز هذا التمرين بلا استخدام أية مكتبات إضافية. لكنه في المقابل فرصة مواتية لتعلم استخدام المكتبة Lodash. عرّف دالة اسمها mostBlogs تستقبل مصفوفة من المدونات كمعامل. تعيد الدالة اسم المؤلف الذي لديه العدد الأكبر من المدونات، كما تعيد عدد هذه المدونات: { author: "Robert C. Martin", blogs: 3 } إن وجد أكثر من مؤلف يحقق المطلوب، يكفي أن تعرض أحدهم. 4.7 الدوال المساعدة واختبارات الأجزاء: الخطوة 5 * عرّف دالة باسم mostLikes تستقبل مصفوفة من المدونات كمعامل. تعيد الدالة المؤلف الذي حاز على أكبر عدد من الإعجابات، كما تعيد العدد الكلي لتلك الإعجابات: { author: "Edsger W. Dijkstra", likes: 17 } إن وجد أكثر من مؤلف يحقق المطلوب، يكفي أن تعرض أحدهم. ترجمة -وبتصرف- للفصل Structure of backend application, introduction to testing من سلسلة Deep Dive Into Modern Web Development
  23. علينا في الواقع، أن نطبق مجموعة من القيود على البيانات التي ستخزن في قاعدة بيانات التطبيق. فلا ينبغي أن نسمح بوجود ملاحظات مفقودة المحتوى أو لا تملك الخاصية content لسبب ما. يجري تفقد صلاحية الملاحظة من خلال معالج مسار: app.post('/api/notes', (request, response) => { const body = request.body if (body.content === undefined) { return response.status(400).json({ error: 'content missing' }) } // ... }) إن لم تمتلك الملاحظة الخاصية content، سيستجيب الخادم برمز الحالة 400 (طلب خاطئ). يمكننا أن نستخدم طريقة أفضل في تقييم تنسيق البيانات قبل تخزينها في قاعدة البيانات وهي الوظيفة validation التي تتيحها مكتبة Mongoose. حيث سنعرف معايير تقييم محددة لكل حقل من حقول مخطط قاعدة البيانات: const noteSchema = new mongoose.Schema({ content: { type: String, minlength: 5, required: true }, date: { type: Date, required: true }, important: Boolean }) تحدد القواعد السابقة أن يكون طول المحتوى 5 محارف على الأقل، وأن المحتوى أمر إجباري required:true وبالتالي يجب أن لا يكون مفقودّا، وكذلك التاريخ. لم نضف تقييدات على حقل الأهمية، فلم يتغير تعريفه في المخطط. لاحظ على سبيل المثال أن المقيّم minlength يأتي مدمجًا وجاهزًا للاستخدام مع المكتبة mongoose، لكنها أيضًا تقدم وظيفة المُقيِّمات الخاصة التي تمكننا من إنشاء مُقيِّمات بالطريقة التي نحتاجها إن لم تحقق المقّيمات المدمجة ما نريد. إن حاولنا أن نخزن كائنًا في قاعدة البيانات يخالف أحد التقييدات التي فرضناها عليه، سترمي العملية استثناءً. لنغيّر معالج إنشاء ملاحظة جديدة لكي يمرر أي استثناء محتمل إلى الأداة الوسطية لمعالجة الأخطاء: app.post('/api/notes', (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) note.save() .then(savedNote => { response.json(savedNote.toJSON()) }) .catch(error => next(error))}) لنوسّع معالج الخطأ ليتعامل مع أخطاء التقييم. const errorHandler = (error, request, response, next) => { console.error(error.message) if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { return response.status(400).json({ error: error.message }) } next(error) } عندما يُخفق تقييم الكائن، ستعيد Mongoose رسالة الخطأ التالية: سلاسل الوعود تحوّل معظم معالجات المسار البيانات القادمة مع الاستجابة إلى الصيغة الصحيحة باستخدام التابع toJSON. فعندما ننشئ ملاحظة جديدة، يُستدعى هذا التابع ليعالج الكائن الذي مرر إلى التابع then: app.post('/api/notes', (request, response, next) => { // ... note.save() .then(savedNote => { response.json(savedNote.toJSON()) }) .catch(error => next(error)) }) يمكن أن نحصل على نفس النتيجة وبطريقة أكثر وضوحًا باستخدام سلاسل الوعود: app.post('/api/notes', (request, response, next) => { // ... note .save() .then(savedNote => { return savedNote.toJSON() }) .then(savedAndFormattedNote => { response.json(savedAndFormattedNote) }) .catch(error => next(error)) }) يستقبل أول تابع then الكائن savedNote الذي تعيده Mongoose ثم ينسقه، ويعيد نتيجة هذه العملية. وكما ناقشنا سابقًا يعيد تابع then الذي يليه وعدًا أيضًا بحيث يمكننا الوصول إلى الملاحظة المنسقة بالتصريح عن دالة استدعاء جديدة داخل التابع then الأخير. كما يمكننا توضيح الشيفرة أكثر باستخدام الصيغة المختصرة للدالة السهمية: app.post('/api/notes', (request, response, next) => { // ... note .save() .then(savedNote => savedNote.toJSON()) .then(savedAndFormattedNote => { response.json(savedAndFormattedNote) }) .catch(error => next(error)) }) لم تحقق سلسلة الوعود في هذا المثال الكثير من الفائدة. لكن الفائدة الحقيقة ستظهر عند تنفيذ عدة عمليات غير متزامنة على التسلسل. لن نخوض في هذا الموضوع كثيرًا، بل سنتركه إلى القسم التالي من المنهاج، حيث سنطلع على الصيغة async/await في JavaScript والتي تسهل كتابة العمليات المتسلسلة غير المتزامنة. إنجاز نسخة الإنتاج من الواجهة الخلفية المرتبطة بقاعدة بيانات ينبغي أن يعمل التطبيق كما هو على Heroku. ولا ينبغي أن ننشئ نسخة إنتاج جديدة للواجهة الأمامية نتيجة للتغيرات التي أجريناها عليها. ونشير أيضًا إلى أن استخدام متغيرات البيئة التي عرفناها باستخدام dotenv غير ممكن عندما تكون الواجهة الخلفية في وضع الإنتاج (على Heroku). لقد وضعنا متغيرات البيئة المستخدمة في مرحلة التطوير في الملف env.، لكن ينبغي ضبط متغيرات البيئة التي تعرف عنوان موقع قاعدة البيانات في وضع الإنتاج باستخدام الأمر heroku config:set $ heroku config:set MONGODB_URI=mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true ملاحظة: إن أعطى تنفيذ الأمر خطأً، ضع قيمة MONGO_URI ضمن إشارتي تنصيص مفردتين (' '). $ heroku config:set MONGODB_URI='mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true' ينبغي أن يعمل التطبيق الآن. لكن في بعض الأحيان لا تجري الأمور كما هو مخطط لها. لذلك استفد من سجلات heroku عند تنفيذ الشيفرة. توضح الصورة التالية ما أظهره Heroku عند فشل التطبيق بعد إجراء التغييرات: لسبب ما، ظهر عنوان قاعدة البيانات ككائن غير معرّف. لكن سجلات Heroku قد كشفت أن الخطأ هو أننا أسندنا عنوان موقع قاعدة البيانات إلى متغير البيئة MONGO_URL بينما تتوقع الشيفرة أن يكون العنوان قد أسند إلى متغير البيئة MONGODB_URI. ستجد شيفرة التطبيق بوضعه الحالي في الفرع part3-5 ضمن المستودع الخاص بالقسم على GitHub التمارين 3.19 - 3.21 3.19 دليل هاتف بقاعدة بيانات: الخطوة 7 ضع مقيّمات لتحديد صلاحية المُدخَلات إلى تطبيق دليل الهاتف، لتضمن أن المُدخَل الجديد يحمل اسمًا فريدًا غير مكرر. لن تسمح الواجهة الأمامية الآن أن يدخل المستخدم أسماء مكررة، لكن حاول أن تقوم بذلك مباشرة بوجود Postman أو VS Code REST client. لا تقدم Mongoose مُقيِّمات مدمجة لهذا الغرض، عليك تثبيت حزمة mongoose-unique-validator باستخدام npm. إن حاول طلب HTTP-POST إضافة اسم موجود مسبقًا، على الخادم أن يجيب برمز الحالة المناسب، بالإضافة إلى رسالة خطأ. ملاحظة: سيسبب المعرف الفريد (unique-validator) تحذيرًا سيطبع على الطرفية. (node:49251) DeprecationWarning: collection.ensureIndex is deprecated. Use createIndexes instead. connected to MongoDB اقرأ توثيق Mongoose وجد طريقة للتخلص من هذا التحذير. 3.20 دليل هاتف بقاعدة بيانات: الخطوة 8 * وسع التقييد بحيث لا يقل طول الاسم المخزّن في قاعدة البيانات عن ثلاثة محارف وأن لا يقل طول رقم الهاتف عن 8 أرقام. دع الواجهة الأمامية تظهر رسالة خطأ عندما يحدث خطأ في التقييم. يمكنك أن تعالج الخطأ بإضافة كتلة catch كما يلي: personService .create({ ... }) .then(createdPerson => { // ... }) .catch(error => { // هذه طريقة للوصول إلى رسالة الخطأ console.log(error.response.data) }) يمكنك إظهار رسالة الخطأ الافتراضية التي تعيدها Mongoose علمًا أن قراءتها ليست يسيرة: ملاحظة: أثناء عمليات التحديث لن تعمل مقّيمات Mongoose بشكل افتراضي. اطلع على التوثيق لتتعلم كيفية تمكينها. 3.21 إنجاز نسخة الإنتاج من الواجهة الخلفية المرتبطة بقاعدة بيانات أنشئ نسخة كاملة (full stack) من التطبيق بإنجاز نسخة إنتاج عن الواجهة الأمامية ونسخها إلى مستودع الواجهة الخلفية. تحقق من أن كل شيء يعمل جيدًا باستخدام التطبيق بشكله الكامل على الخادم المحلي الذي عنوانه https://localhost:3001. انقل النسخة النهائية إلى خادم Heroku وتحقق أن كل شيء يعمل بشكل جيد. المدققات (Lints) قبل أن ننتقل إلى القسم التالي من المنهاج، سنلقي نظرة على أداة مهمة تدعى المدقق lint. وجاء في wikipedia عن المدقق ما يلي: يمكن للغات التي تترجم بشكل ساكن كلغة Java وبيئات التطوير مثل NetBeans أن تشير إلى الأخطاء في الشيفرة، حتى تلك الأخطاء التي تعتبر أكثر من أخطاء ترجمة. يمكن استعمال أدوات إضافية لتقوم بالتحليل الساكن مثل checkstyle لتوسيع إمكانيات بيئة التطوير بحيث تصبح قادرةً على الإشارة إلى مشاكل تتعلق حتى بالتنسيقات مثل إزاحة الكلمات ضمن الأسطر. في عالم JavaScript، تعتبر الأداة ESlint هي الرائدة في مجال التدقيق والتحليل الساكن. لنثبت ESlint كملف ارتباط تطوير في مشروع الواجهة الخلفية بتنفيذ الأمر: npm install eslint --save-dev يمكننا بعد ذلك تهيئة المدقق بصيغته الافتراضية بتنفيذ الأمر: node_modules/.bin/eslint --init سنجيب طبعُا عن الأسئلة التالية: ستخزّن إعدادات التهيئة في الملف eslinterc.js: module.exports = { 'env': { 'commonjs': true, 'es6': true, 'node': true }, 'extends': 'eslint:recommended', 'globals': { 'Atomics': 'readonly', 'SharedArrayBuffer': 'readonly' }, 'parserOptions': { 'ecmaVersion': 2018 }, 'rules': { 'indent': [ 'error', 4 ], 'linebreak-style': [ 'error', 'unix' ], 'quotes': [ 'error', 'single' ], 'semi': [ 'error', 'never' ] } } لنغيّر مباشرة القاعدة التي تنظم الإزاحة في السطر الواحد بحيث تكون بمقدار فراغين: "indent": [ "error", 2 ], يمكن تفتيش الملفات مثل index.js والتحقق من صلاحيتها باستخدام الأمر: node_modules/.bin/eslint index.js يفضل أن تنشئ سكربت npm منفصل للتدقيق: { // ... "scripts": { "start": "node index.js", "dev": "nodemon index.js", // ... "lint": "eslint ." }, // ... } سيتحقق الآن الأمر npm run lint من كل ملف في المشروع. كما سيتحقق من الملفات الموجودة في المجلد build، وهذا ما لا نريده. لذلك سنمنع ذلك بإنشاء ملف تجاهل لاحقته eslintignorr.‎ في جذر المشروع و نزوده بالمحتوى التالي: build عندها لن يتحقق ESlint من المجلد build أو محتوياته. سيشير ESlint إلى الكثير من النقاط في شيفرتك: لن نصلح أي شيء الآن. يمكن أن تستخدم طريقة أفضل من سطر الأوامر في تنفيذ التدقيق، وهي تهيئة إضافة للتدقيق eslint-plugin على محررك بحيث تنفذ عملية التدقيق بشكل مستمر. وبالتالي سترى الأخطاء التي ترتكب مباشرة أثناء تحريرك للشيفرة. يمكنك الاطلاع أكثر من خلال الانترنت على الكثير من المعلومات حول الإضافة Visual Studio ESlint plugin. Editor. ستضع الإضافة السابقة خطًا أحمر تحت أخطاء التنسيق: وهكذا سنرصد الأخطاء بشكل أسهل. للمدقق ESlint الكثير من القواعد سهلة الاستخدام والتي يمكن إضافتها في الملف eslintrc.js. لنضع الآن القاعدة eqeqeq التي تحذرنا إن وجدت في الشيفرة عامل الموازنة الثلاثي (===). تضاف القاعدة ضمن الحقل rules في ملف التهيئة: { // ... 'rules': { // ... 'eqeqeq': 'error', }, } وطالما أننا استخدمنا القواعد لنقم بتغييرات أخرى. لنمنع وجود المسافات الفارغة trailing spaces في آخر السطر البرمجي، ولنطلب أيضًا أن يكون هناك فراغ قبل وبعد الأقواس المعقوصة، كذلك وجود فراغ بشكل دائم بين معاملات الدالة السهمية. { // ... 'rules': { // ... 'eqeqeq': 'error', 'no-trailing-spaces': 'error', 'object-curly-spacing': [ 'error', 'always' ], 'arrow-spacing': [ 'error', { 'before': true, 'after': true } ] }, } وتقدم لنا الإعدادات الافتراضية التي اعتمدناها في البداية، الكثير من القواعد التي ينصح بها المدقق، وتُطبق هذه القواعد بكتابة الأمر: 'extends': 'eslint:recommended', تتضمن هذه القواعد تحذيرات تتعلق بأمر الطباعة على الطرفية console.log. يمكن تعطيل أي قاعدة بجعل قيمتها تساوي 0 في ملف التهيئة. لنلغ القاعدة no-console على سبيل المثال: { // ... 'rules': { // ... 'eqeqeq': 'error', 'no-trailing-spaces': 'error', 'object-curly-spacing': [ 'error', 'always' ], 'arrow-spacing': [ 'error', { 'before': true, 'after': true } ], 'no-console': 0 }, } ملاحظة: يفضل، إن أجريت أية تغييرات على الملف ‎.eslintrc.js، أن تشغل المدقق من سطر الأوامر لتتحقق من أن التعليمات في ملف التهيئة مكتوبة بالشكل الصحيح. فإن كانت هناك أية مشاكل في ملف التهيئة، ستتصرف إضافة المدقق بشكل غير مفهوم. تحدد الكثير من الشركات معايير لكتابة الشيفرة وتفرضها على منظمة W3C عبر ملفات تهيئة ESlint. إذًا ليس عليك إعادة اختراع العجلة في كل مرة، ومن الأفضل لك اعتماد ملف تهيئة جاهز أنجزته جهة ما. تعتمد الكثير من المشاريع حاليًا على دليل تنسيق JavaScript الذي قدمته Airbnb، وذلك باستخدامها ملف تهيئة ESlint الذي تعتمده Airbnb. ستجد شيفرة التطبيق كاملًا في الفرع part3-6 ضمن المستودع الخاص بالقسم على GitHub التمرين 3.22 3.22 تهيئة المدقق أضف ESlint إلى تطبيقك وأصلح كل المشاكل. وهكذا نصل إلى آخر تمرينات هذا القسم، وحان الوقت لتسليم حلول التمارين إلى GitHub. لا تنس أن تشير إلى التمارين التي تسلمها أنها مكتملة ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل Validation and ESlint من سلسلة Deep Dive Into Modern Web Development
  24. قبل أن ننتقل إلى الموضوع الرئيسي في هذا الفصل، سنلقي نظرة على بعض الطرق المتبعة في تنقيح تطبيقات Node. تنقيح تطبيقات Node إن تنقيح التطبيقات المبنية باستخدام Node أصعب قليلًا من تنقيح شيفرة JavaScript التي تنفذ على المتصفح. لكن مع ذلك تبقى فكرة الطباعة على الطرفية وأسلوب المحاولة والخطأ طريقة فعالة في حل المشاكل. قد تجد من المطورين من يعتقد أن استخدام أساليب أكثر تطورًا هو أمر ضروري، لكن تذكر أن نخبة مطوري البرمجيات مفتوحة المصدر في العالم يستخدمون تلك الطريقة. برنامج Visual Studio Code ستجد أن المنقح المدمج ضمن هذا البرنامج ذو فائدة كبيرة في بعض الحالات. يمكنك أن تشغل البرنامج على وضع التنقيح كالتالي: ملاحظة: يمكن أن تجد التعليمة Run بدلًا من Debug في الإصدار الأحدث من Visual Studio Code. وقد يكون عليك أيضًا أن تهيئ ملف launch.json لتبدأ التنقيح. يمكن تنفيذ ذلك باختيار الأمر ...Add Configuration من القائمة المنسدلة الموجودة بجانب زر التشغيل الأخضر وفوق قائمة VARIABLES، ثم اختيار الأمر Run "npm start" in a debug terminal. يمكنك إيجاد المزيد من الإرشادات بالاطلاع على توثيق التنقيح لبرنامج Visual Studio Code. تذكر أن لا تشغل البرنامج ضمن أكثر من طرفية لأن ذلك سيحجز المنفذ مسبقًا ولن تتمكن من العمل. تعرض لقطة الشاشة التالية الطرفية وقد أوقفنا التنفيذ مؤقتًا في منتصف عملية حفظ الملاحظة الجديدة: توقَّف التنفيذ عندما وصلنا إلى نقطة التوقف التي وضعناها في السطر 63. يمكنك أن ترى في الطرفية قيمة المتغير note. وفي أعلى يسار النافذة ستجد بعض التفاصيل المتعلقة بحالة التطبيق. تُستخدم الأسهم في الأعلى للتحكم بترتيب عملية التنقيح. أدوات تطوير Chrome من الممكن أن نستخدم أدوات تطوير Chrome لتنقيح تطبيقات Node، وذلك بتشغيل التطبيق مستخدمين الأمر التالي: node --inspect index.js ستدخل إلى المنقح بالضغط على الأيقونة الخضراء (شعار Node) التي تظهر على طرفية تطوير Chrome: يعمل المنقح بنفس الطريقة التي يعمل بها عند تنقيح تطبيقات React. يمكنك استخدام النافذة Sources لزرع نقاط توقف في الشيفرة لإيقاف التنفيذ بشكل مؤقت عندها. ستظهر جميع الرسائل التي يطبعها الأمر console.log في النافذة Console من المنقح. كما يمكنك التحري عن قيم المتغيرات وتنفيذ شيفرة JavaScript إن أردت. تحقق من كل شيء قد يبدو لك تنقيح تطبيقات التطوير الشامل (واجهة خلفية وأمامية) محيّرًا في البداية. وقريبًا سيتواصل التطبيق مع قاعدة بيانات. وبالتالي سيزداد احتمال ظهور الأخطاء في أجزاء عدة من الشيفرة. عندما يتوقف التطبيق عن العمل، يجب علينا أولًا تصور المكان الذي قد تظهر فيه المشكلة. وعادة تظهر المشاكل في الأماكن غير المتوقعة وقد يستغرق إيجادها دقائق أو ساعات أو حتى أيامًا. مفتاح الحل هو تنظيم البحث. فالمشكلة قد تتواجد في أي مكان، لذلك تحقق من كل شيء واستبعد احتمالات المشكلة واحدًا تلو الآخر. ستساعدك الطباعة على شاشة الطرفية وكذلك برنامج Postman والمنقحات الأخرى، كما ستلعب الخبرة دورًا هامًا أيضًا. لا تتابع تطوير التطبيق إن لم تعثر على مصدر الخطأ، فهذه أسوأ استراتيجية. لأن ذلك سيسبب أخطاء أكثر وسيكون التنقيح أصعب. اتبع سياسة شركة Toyota لإنتاج الأنظمة (توقف وأصلح) فهي بالفعل سياسة مجدية جدًا في حالتنا. قاعدة البيانات MongoDB سنحتاج قطعًا إلى قاعدة بيانات لحفظ الملاحظات بشكل دائم. تتعامل معظم مناهج جامعة هلسينكي مع قواعد البيانات العِلاقيّة (Relational Databases)، لكننا سنتعامل في منهاجنا مع قاعدة البيانات MongoDB وهي من نمط قواعد البيانات المستقلة. تختلف قواعد البيانات المستقلة (أو التي تأتي على شكل مستندات منفصلة) عن العِلاقيّة في كيفية تنظيم البيانات ولغة الاستعلام التي تدعمها. وعادة ما تصنف قواعدة البيانات المستقلة تحت مظلة NoSQL أي التي لا تستخدم لغة الاستعلام SQL. اطلع على الفصلين collections و documents من دليل استخدام MongoDB لتتعلم أساسيات تخزين البيانات في قواعد البيانات المستقلة. يمكنك أن تُثبّت وتُشغّل MongoDB على حاسوبك. كما يمكنك الاستفادة من المواقع التي تقدم خدمات MongoDB على الإنترنت. سنتعامل في منهاجنا مع مزود الخدمة MongoDB Atlas. حالما تنشئ حسابًا على الموقع وتسجل الدخول، سينصحك الموقع بإنشاء عنقود: سنختار المزود AWS والمنطقة Frankfurt ثم ننشئ العنقود: انتظر حتى يكتمل العنقود ويصبح جاهزًا. قد يستغرق ذلك 10 دقائق. ملاحظة: لا تتابع قبل أن يصبح العنقود جاهزًا. سنستخدم نافذة database access لإنشاء معلومات التوثيق اللازمة لدخول قاعدة البيانات. وانتبه إلى أنها معلومات توثيق مختلفة عن تلك التي تستخدمها عند تسجيل الدخول، وسيستخدمها تطبيقك عندما يتصل بقاعدة البيانات. لنمنح المستخدم إمكانية القراءة والكتابة إلى قاعدة البيانات: ملاحظة: أبلغَ بعض المستخدمين عن عدم القدرة على الوصول إلى قاعدة البيانات بمعلومات التوثيق التي وضعوها بعد إنشاء القاعدة مباشرة. تريّث، فقد يستغرق الأمر دقائق حتى تفعّل هذه المعلومات. سنعرّف بعد ذلك عناوين IP التي يسمح لها بدخول قاعدة البيانات. ولتسهيل الأمر، سنسمح بالوصول إلى القاعدة من أي عنوان IP: أخيرًا أصبحنا جاهزين للاتصال بقاعدة البيانات، إبدأ بالنقر على connect: اختر Connect your application: سيظهر لك عنوان موقع MongoDB، وهو عنوان قاعدة البيانات التي أنشأناها والتي تزود مكتبة عميل MongoDB التي سنضيفها إلى تطبيقنا بالبيانات. يبدو العنوان كالتالي: mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/test?retryWrites=true نحن الآن جاهزين لاستخدام قاعدة البيانات. يمكننا استخدام قاعدة البيانات مباشرة عبر شيفرة JavaScript بمساعدة المكتبة official MongoDb Node.js driver، لكن استخدامها مربك قليلًا. لذلك سنستخدم بدلًا منها مكتبة Mongoose التي تؤمن واجهة برمجية عالية المستوى. يمكن توصيف Mongoose بأنها رابط (Mapper) لكائنات من النوع document واختصارًا (ODM). وبالتالي سيكون حفظ كائنات JavaScript كمستندات Mongo مباشرًا باستخدام هذه المكتبة. لنثبت الآن Mongoose كالتالي: npm install mongoose --save لن نكتب أية شيفرات تتعامل مع Mongo في الواجهة الخلفية حاليًا، بل سننشئ تطبيقًا تدريبيًا ونضع في مجلده الجذري الملف mongo.js: const mongoose = require('mongoose') if (process.argv.length < 3) { console.log('Please provide the password as an argument: node mongo.js <password>') process.exit(1) } const password = process.argv[2] const url = `mongodb+srv://fullstack:${password}@cluster0-ostce.mongodb.net/test?retryWrites=true` mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) const note = new Note({ content: 'HTML is Easy', date: new Date(), important: true, }) note.save().then(result => { console.log('note saved!') mongoose.connection.close() }) ملاحظة: قد يختلف عنوان موقع MongoDB عن العنوان الذي عرضناه سابقًا بناء على المنطقة التي اخترتها عند إنشائك العنقود. تأكد من استخدامك العنوان الصحيح الذي حصلت عليه من MongoDB Atlas. ستفترض الشيفرة بأنها ستمرر كلمة المرور الموجودة ضمن معلومات التوثيق كمعامل لسطر أوامر. حيث يمكننا الوصول إلى معامل سطر الأوامر كالتالي: const password = process.argv[2] عندما تنفذ الشيفرة باستخدام الأمر node mongo.js password ستضيف Mongo ملفًا جديدًا إلى قاعدة البيانات. ملاحظة: استخدم كلمة السر التي اخترتها عند إنشاء قاعدة البيانات وليست كلمة سر الدخول إلى MongoDB Atlas. وانتبه أيضًا إلى الرموز الخاصة التي قد تضعها في كلمة مرورك فستحتاج عندها إلى تشفير الرموز في كلمة المرور عند كتابة عنوان الموقع. يمكنك الاطلاع على حالة قاعدة البيانات على MongoDB Atlas من Collections في النافذة Overview: وكما هو واضح، أضيف مستند يطابق الملاحظة إلى المجموعة notes في قاعدة البيانات التجريبية. توصي توثيقات Mongo بإعطاء أسماء منطقية لقواعد البيانات. ويمكننا تغيير اسم قاعدة البيانات من عنوان الموقع للقاعدة: سندمّر الآن قاعدة البيانات التجريبية بتغيير اسم قاعدة البيانات التي يشير إليها مؤسس الاتصال (connection string) إلى note-app بمجرد تعديل عنوان موقع القاعدة: mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/note-app?retryWrites=true سننفذ الشيفرة الآن: لقد خُزِّنت الآن البيانات في القاعدة الصحيحة. يمكن باستخدام create database أن ننشئ قواعد بيانات مباشرة على موقع الويب MongoDB Atlas. لكن لا حاجة لذلك طالما أن الموقع ينشئ تلقائيًا قاعدة بيانات جديدة عندما يحاول التطبيق أن يتصل مع قاعدة بيانات غير موجودة. تخطيط قاعدة البيانات بعد تأسيس الاتصال مع قاعدة البيانات، سنعرف تخطيطًا (Schema) للملاحظة ونموذجًا (Model) مطابقًا للتخطيط: const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) عرّفنا أولًا تخطيطًا للملاحظة وأسندناه إلى المتغيّر noteSchema. يخبر التخطيط Mongosse كيف سيُخزّن الكائن الذي يمثل الملاحظة في قاعدة البيانات ثم نعرّف نموذجًا باسم Note يقبل معاملين، الأول هو اسم النموذج المفرد. حيث يختلف الاسم المفرد عن اسم المجموعة بأن الأخير يحمل صيغة الجمع ويكتب بأحرف صغيرة "notes" فهذا عرف تتبعه Mongoose وتقوم به تلقائيًا بينما يشير التخطيط إلى الملاحظات بالاسم المفرد. المعامل الثاني كما هو واضح هو تخطيط الملاحظة. لا تملك قواعد البيانات المستقلة مثل Mongo أية تخطيطات (schemaless). ويعني ذلك أن قاعدة البيانات لا تهتم ببنية البيانات المخزنة فيها، حيث يمكن تخزين ملفات بتخطيطات مختلفة تمامًا ضمن نفس المجموعة. لكن الفكرة وراء منح Mongoose البيانات المخزنة في قاعدة البيانات تخطيطًا على مستوى التطبيق، هي تحديد شكل المستندات المخزنة في مجموعة ما. إنشاء وحفظ الكائنات ينشئ التطبيق في الشيفرة التالية كائن ملاحظة جديد بمساعدة النموذج Note: const note = new Note({ content: 'HTML is Easy', date: new Date(), important: false, }) تدعى النماذج "دوال البناء". فهي التي تنشئ الكائنات الجديدة في JavaScript بناء على المعاملات التي تملكها. وطالما أن الكائنات ستبنى باستخدام الدوال البانية للنماذج، ستمتلك كل خصائص النموذج بما فيها توابع حفظ الكائن في قاعدة البيانات. وتحفظ الكائنات في قواعد البيانات باستخدام التابع save الذي يُزوّد بمعالج حدث ضمن التابع then: note.save().then(result => { console.log('note saved!') mongoose.connection.close() }) بعدما يُحفظ الكائن في قاعدة البيانات، يُستدعَى معالج الحدث المعرّف ضمن التابع then. حيث يغلق معالج الحدث قناة الاتصال مع قاعدة البيانات باستخدام الأمر ()mongoose.connection.close. إن لم يُغلق الاتصال، فلن ينتهي البرنامج من تنفيذ العملية. تُخزّن نتيجة عملية الحفظ في المعامل result لمعالج الحدث، لكنها غير مهمة كثيرًا، خاصة أننا خزّنا كائنًا مفردًا في قاعدة البيانات. يمكنك طباعة الكائن على الطرفية إن أردت التمعن فيه أثناء عمل التطبيق أو أثناء تنقيحه. لنخزّن عدة ملاحظات أخرى في قاعدة البيانات. لكن علينا أولًا تعديل الشيفرة ثم إعادة تنفيذها. ملاحظة: لسوء الحظ، فإن توثيق mongoose ليس مستقرًا جدًا. لقد استخدمت الاستدعاءات في بعض الأجزاء عند عرض الأمثلة بينما استخدمت الأجزاء الأخرى أساليب أخرى. لذلك لا ننصحك بنسخ ولصق الشيفرة مباشرة من تلك التوثيقات. لا تخلط الوعود مع استدعاءات المدرسة التقليدية في نفس الشيفرة فهذا أمر غير محبذ. إحضار كائنات من قاعدة البيانات حَوِّل الشيفرة السابقة التي استخدمناها لإنشاء ملاحظة جديدة إلى تعليقات واستخدم الشيفرة التالية بدلًا منها: Note.find({}).then(result => { result.forEach(note => { console.log(note) }) mongoose.connection.close() }) عندما تُنفّذ الشيفرة سيطبع التطبيق كل الملاحظات الموجودة ضمن قاعدة البيانات: نحصل على الكائنات من قاعدة البيانات مستعملين التابع find العائد للنموذج Note. يقبل التابع السابق معاملًا على هيئة كائن يحتوي على معايير البحث. وطالما أن المعامل في الشيفرة السابقة كائن فارغ ({})، فسنحصل على جميع الملاحظات المخزنة في المجموعة notes. تخضع معايير البحث إلى قواعد الاستعلام في Mongo. ويمكننا تحديد البحث ليشمل مثلًا الملاحظات الهامة فقط على النحو التالي: Note.find({ important: true }).then(result => { // ... }) التمرين 3.12 3.12 قاعدة بيانات بسطر أوامر أنشئ قاعدة بيانات سحابية باستخدام MongoDB لتطبيق دليل الهاتف وذلك على موقع الويب MongoDb Atlas. أنشئ الملف mongo.js في مجلد المشروع، حيث تضيف الشيفرة في الملف مُدخلات إلى دليل الهاتف، وتشكيل قائمة بكل المُدخلات الموجودة في الدليل. ملاحظة: لا تضع كلمة المرور في الملف الذي سترفعه إلى GitHub. يجب أن يعمل التطبيق على النحو التالي: تمرير ثلاثة معاملات إلى سطر الأوامر عند تشغيل التطبيق، على أن تكون كلمة السر هي المعامل الأول كما في المثال التالي: node mongo.js yourpassword Anna 040-1234556 سيطبع التطبيق النتيجة التالية: added Anna number 040-1234556 to phonebook يُخزَّن المُدخل الجديد ضمن قاعدة البيانات. وانتبه إلى وضع الاسم الذي يحتوي على فراغات ضمن قوسي تنصيص مزدوجين: node mongo.js yourpassword "Arto Vihavainen" 045-1232456 إذا شغلت التطبيق بمعامل واحد فقط هو كلمة السر كما يلي: node mongo.js yourpassword على التطبيق عندها أن يعرض كل المُدخَلات في دليل الهاتف: phonebook: Anna 040-1234556 Arto Vihavainen 045-1232456 Ada Lovelace 040-1231236 يمكنك الحصول على معاملات سطر الأوامر من المتغيّر process.argv. ملاحظة: لا تغلق الاتصال في المكان غير المناسب. فلن تعمل على سبيل المثال الشيفرة التالية: Person .find({}) .then(persons=> { // ... }) mongoose.connection.close() سيُنفَّذ الأمر ()mongoose.connection.closeمباشرة بعد أن تبدأ العملية Person.find. أي ستغلق قاعدة البيانات مباشرة قبل أن تنتهي العملية السابقة وتستدعى دالة معالج الحدث. لذلك فالمكان الصحيح لإغلاق قاعدة البيانات سيكون في نهاية معالج الحدث: Person .find({}) .then(persons=> { // ... mongoose.connection.close() } ) ملاحظة: إذا سميت النموذج person، ستسمي mongoose المجموعة المقابلة people. ربط الواجهة الخلفية مع قاعدة البيانات لقد تعلمنا ما يكفي لبدء استخدام Mongo في تطبيقنا. لننسخ ونلصق القيم التي عرفناها في التطبيق التجريبي، ضمن الملف index.js: const mongoose = require('mongoose') // DO NOT SAVE YOUR PASSWORD TO GITHUB!! const url = 'mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) لنغير معالج حدث إحضار كل الملاحظات إلى الشكل التالي: app.get('/api/notes', (request, response) => { Note.find({}).then(notes => { response.json(notes) }) }) يمكن أن نتحقق من عمل الواجهة الخلفية بعرض كل الملاحظات على المتصفح: يعمل التطبيق بشكل ممتاز الآن. تفترض الواجهة الأمامية أن لكل كائن معرفّا فريدًا id في الحقل id. وانتبه إلى أننا لا نريد إعادة حقل إصدار mongo إلى الواجهة الأمامية (v__). إحدى الطرق لنتحكم بصيغة الكائن الذي سنعيده، هي تعديل تخطيط الكائن باستخدام التابع toJSON والذي سنستعمله في كل النماذج التي تُنشأ اعتمادًا على هذا التخطيط. سيكون التعديل على النحو: noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) حتى لو بدت الخاصية (id__) لكائن Mongoose كسلسلة نصية فهي في الواقع كائن. لذلك يحولها التابع toJSON إلى سلسلة نصية حتى نتأكد أنها آمنة. إن لم نفعل ذلك ستظهر المشاكل أمامنا بمجرد أن نبدأ كتابة الاختبارات. ستجيب الواجهة الخلفية على طلب HTTP بقائمة من الكائنات التي أعيدت صياغتها باستخدام التابع toJSON: app.get('/api/notes', (request, response) => { Note.find({}).then(notes => { response.json(notes) }) }) أسندت مصفوفة الكائنات التي أعادتها Mongo إلى المتغير notes. وعندما ترسل الاستجابة بصيغة JSON، يستدعى التابع toJSON تلقائيًا من أجل كل كائن من المصفوفة باستخدام التابع JSON.stringify. تهيئة قاعدة البيانات في وحدة خاصة بها قبل أن نعيد كتابة بقية تطبيق الواجهة الخلفية، لنضع الشيفرة الخاصة بالتعامل مع Mongoose في وحدة مستقلة خاصة بها. لذلك سننشئ مجلدًا جديدًا للوحدة يدعى models ونضيف إليه ملفًا باسم note.js: const mongoose = require('mongoose') const url = process.env.MONGODB_URI console.log('connecting to', url) mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) .then(result => { console.log('connected to MongoDB') }) .catch((error) => { console.log('error connecting to MongoDB:', error.message) }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) module.exports = mongoose.model('Note', noteSchema) يختلف تعريف الوحدات في Node قليلًا عن الطريقة التي عرفنا فيها وحدات ES6 في القسم 2. فتُعرّف الواجهة العامة للوحدة بإسناد قيمة للمتغيّر module.exports. سنسند له إذًا النموذج Note. لن يتمكن مستخدم الوحدة من الوصول أو رؤية الأشياء المعرفة داخلها، كالمتغيرات وmonogose وعنوان موقع القاعدة url. يجري إدراج الوحدة بإضافة السطر التالي إلى الملف index.js: const Note = require('./models/note') وهكذا سيُسند المتغيّر Note إلى نفس الكائن الذي تعرّفه الوحدة. تغيّرت قليلًا الطريقة التي نجري بها الاتصال مع قاعدة البيانات: const url = process.env.MONGODB_URI console.log('connecting to', url) mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) .then(result => { console.log('connected to MongoDB') }) .catch((error) => { console.log('error connecting to MongoDB:', error.message) }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) module.exports = mongoose.model('Note', noteSchema) لا تكتب عنوان الموقع لقاعدة البيانات بشكل مسبق في الشيفرة فهذه فكرة سيئة. بل مرر العنوان إلى التطبيق عبر متغيّر البيئة MONGODB_URI. تقدم الطريقة التي اتبعناها في تأسيس الاتصال دالتين للتعامل مع حالتي نجاح الاتصال وفشله. حيث تطبع كلتا الدالتين رسائل إلى الطرفية لوصف حالة الاتصال. ستجد طرقًا عديدة لتعريف قيمة متغيّر البيئة، إحداها أن تعرّفه عندما تشغل التطبيق: MONGODB_URI=address_here npm run dev الطريقة الأخرى الأكثر تعقيدًا هي استخدام المكتبة dotenv التي يمكنك تثبيتها بتنفيذ الأمر: npm install dotenv --save عليك إنشاء ملف لاحقته env. عند جذر المشروع، ومن ثم تعرف متغيرات البيئة داخله. سيبدو الملف بالشكل التالي: MONGODB_URI='mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' PORT=3001 لاحظ أننا حددنا رقم المنفذ بشكل مسبق ضمن متغير البيئة PORT. ملاحظة تَجاهل الملف ذو اللاحقة "env."، لأننا لا نريد أن ننشر معلومات التوثيق الخاصة بنا في العلن. نستخدم متغيرات البيئة التي عرّفناها في الملف env. بكتابة العبارة ()require('dotenv').config، ثم يمكنك بعدها الإشارة إليهم في الشيفرة بالطريقة المعهودة process.env.MONGODB_URI. لنغيّر الملف index.js على النحو التالي: require('dotenv').config()const express = require('express') const app = express() const Note = require('./models/note') // .. const PORT = process.env.PORTapp.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) من المهم إدراج المكتبة dotenv قبل إدراج الوحدة note، لكي نضمن أن متحولات البيئة التي عرّفت داخلها ستكون متاحة للاستخدام ضمن كامل الشيفرة، وذلك قبل إدراج بقية الوحدات. استخدام قاعدة البيانات مع معالجات المسار لنغيّر بقية الوظائف في تطبيق الواجهة الخلفية ليتعامل مع قواعد البيانات. تضاف ملاحظة جديدة كالتالي: app.post('/api/notes', (request, response) => { const body = request.body if (body.content === undefined) { return response.status(400).json({ error: 'content missing' }) } const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) note.save().then(savedNote => { response.json(savedNote) }) }) تنشئ الدالة البانية للنموذج Note الكائنات التي تمثل الملاحظات. ثم ترسل الاستجابة داخل دالة استدعاء التابع save. يضمن هذا أن الاستجابة لن تُعاد إلى المرسل إن لم تنجح العملية. وسنناقش بعد قليل آلية التعامل مع الأخطاء. يحمل معامل دالة الاستدعاء savedNote الملاحظة الجديدة التي أنشئت. وتذكر أن البيانات التي أعيدت في الاستجابة قد أعيد تنسيقها باستعمال التابع toJSON. response.json(savedNote) تغيرت طريقة إحضار الملاحظات المفردة لتصبح على النحو: app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id).then(note => { response.json(note) }) }) التحقق من تكامل أداء الواجهتين الخلفية والأمامية من الجيد اختبار التطبيق الذي يعمل على الواجهة الخلفية عندما نضيف إليه وظائف جديدة، يمكن أن نستعمل في الاختبار برنامج Postman أو VS Code REST client أو المتصفح الذي تستخدمه. لننشئ ملاحظة جديدة ونخزّنها في قاعدة البيانات: بعد أن نتأكد من أن كل شيء يعمل على ما يرام في الواجهة الخلفية، نختبر تكامل الواجهة الأمامية مع الخلفية. فمن غير الكافي إطلاقًا اختبار الأشياء ضمن الواجهة الأمامية فقط. ربما عليك أن تدرس تكامل الوظائف بين الواجهتين وظيفة تلو الأخرى. فيمكننا أولًا إضافة الشيفرة التي تحضر كل البيانات من قاعدة البيانات ونختبرها على طرفية الواجهة الخلفية ضمن المتصفح. بعدها نتأكد أن الواجهة الأمامية تعمل جيدًا مع الشيفرة الجديدة للواجهة الخلفية. عندما يجري كل شيء بشكل جيد ننتقل إلى الوظيفة التالية. علينا تفقد الحالة الراهنة لقاعدة البيانات بمجرد بدأنا العمل معها. يمكننا القيام بذلك على سبيل المثال، عبر لوحة التحكم في MongoDB Atlas. كما ستفيدك أثناء التطوير بعض برامج Node الصغيرة، كالبرنامج mongo.js الذي كتبناه في هذا الفصل. ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-4 على موقع GitHub. التمارين 3.13 - 3.14 يمثل التمرينين التاليين تطبيقًا مباشرًا وسهلًا لما تعلمناه. لكن إن لم تتكامل الواجهتين معًا أثناء العمل، فستكمن الأهمية في كيفية إيجاد ومعالجة الأخطاء التي سببت ذلك. 3.13 دليل هاتف بقاعدة بيانات: الخطوة 1 غيِّر طريقة إحضار جميع المُدخَلات لكي يتم ذلك من قاعدة بيانات. تأكد أن الواجهة الأمامية ستعمل بعد إجراء هذه التغييرات. سنتكتب شيفرة التعامل مع قاعدة البيانات MongoDB في وحدة خاصة بها خلال التمارين القادمة، كما فعلنا سابقًا في هذا الفصل (تهيئة قاعدة البيانات في وحدة خاصة بها). 3.14 دليل هاتف بقاعدة بيانات: الخطوة 2 غيّر في شيفرة الواجهة الخلفية بحيث تحفظ الأرقام في قاعدة البيانات، وتحقق أن الواجهة الأمامية ستعمل بشكل جيد بعد التغييرات في الواجهة الخلفية. يمكنك في هذه المرحلة أن تجعل المستخدمين يدخلون كل ما يشاؤون في دليل الهاتف. ولاحظ أن دليل الهاتف قد يحوي الاسم نفسه مكررًا مرات عدة. معالجة الأخطاء لو حاولنا الوصول إلى موقع ملاحظة بمعرّف id غير موجود. فستكون الإجابة Null (لا شيء). لنغير ذلك بحيث يستجيب الخادم على هذا الطلب برمز الحالة 404 (غير موجود). سنضيف أيضًا كتلة catch لتتعامل مع الحالات التي يُرفض فيها الوعد الذي يعيده التابع findById: app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => { console.log(error) response.status(500).end() }) }) إن لم يُعثر على تطابق في قاعدة البيانات، ستكون قيمة الكائن note هي null، وبالتالي ستنفذ كتلة else. سينتج عن ذلك استجابة برمز الحالة 404 (غير موجود). وأخيرًا، إن رفض الوعد الذي يعيده التابع findById، ستكون الاستجابة برمز الحالة 500 (خطأ داخلي في الخادم). ستعرض لك طرفية التطوير معلومات مفصلة أكثر عن الخطأ. بالإضافة إلى الخطأ الناتج عن عدم وجود ملاحظة، ستواجه حالة أخرى يتوجب عليك معالجتها. تتلخص هذه الحالة بمحاولة إحضار ملاحظة بمعرّف id من نوع خاطئ لا يطابق تنسيق Mongo للمعرفات IDs. فلو ارتكبنا خطأً كهذا، سنرى الرسالة التالية: Method: GET Path: /api/notes/someInvalidId Body: {} --- { CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id" at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11) at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13) ... إن إعطاء معرّف id بصيغة خاطئة، سيدفع التابع findById لإشهار خطأ يسبب رفضًا للوعد. كنتيجة لذلك ستُستدعى الدالة الموجودة في الكتلة catch. لنغيّر طريقة الاستجابة قليلًا في تلك الكتلة: app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => { console.log(error) response.status(400).send({ error: 'malformatted id' }) }) }) إن لم يكن تنسيق المعرف id صحيحًا، ستنتهي العملية بتنفيذ معالج الخطأ الموجود في catch. إن الاستجابة الملائمة لهذا الخطأ هو رمز الحالة 400 (طلب خاطئ) 400 Bad Request، لأن هذه الحالة تطابق تمامًا الوصف التالي: أضفنا أيضًا بعض البيانات إلى الاستجابة لنلقي الضوء على سبب الخطأ. يفضل دائمًا عند التعامل مع الوعود إضافة معالجات للأخطاء والاستثناءات، لأن إهمال ذلك سيدفعك لمواجهة أخطاء غريبة. ومن الجيد أيضًا طباعة الكائن الذي سبب الاستثناء على طرفية التطوير: .catch(error => { console.log(error) response.status(400).send({ error: 'malformatted id' }) }) قد يُستدعى معالج الخطأ التي زرعته، نتيجة لخطأ مختلف تمامًا عن الخطأ الذي تريد اعتراضه. فإن طبعت الخطأ على الطرفية ستوفر على نفسك عناء جلسات التنقيح المحبطة. وعلاوة على ذلك تزودك معظم الخدمات الحديثة بوسيلة ما لطباعة عمليات النظام، بحيث يمكنك الاطلاع عليها متى أردت والخادم Heroku مثال مهم عليها. وطالما أنك تعمل على الواجهة الخلفية فابق نظرك على الطرفية التي تظهر لك خرج العملية حتى لو عملت على شاشة صغيرة، سيلفت وقوع أي خطأ انتباهك. تحويل معالجات الأخطاء إلى أداة وسطية لقد كتبنا شيفرة ملاحقة الأخطاء ضمن بقية أجزاء الشيفرة، ويبدو الأمر معقولًا أحيانًا، لكن من الأفضل إضافة معالجات الأخطاء في مكان واحد. سيكون هذا الأمر مفيدًا إذا أردنا لاحقًا أن نقدم تقريرًا عن الأخطاء إلى منظومة تتبع أخطاء خارجية مثل Sentry. لنغيّر معالج المسار api/notes/:id/، بحيث يمرر الخطأ إلى الدالة next كمعامل، وتمثل هذه الدالة بدورها المعامل الثالث لمعالج المسار: app.get('/api/notes/:id', (request, response, next) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => next(error))}) عندما تستدعى next دون معامل، سينتقل التنفيذ بكل بساطة إل المسار أو الأداة الوسطية التالية. بينما لو امتلكت هذه الدالة معاملًا فسيتابع التنفيذ إلى الأداة الوسطية لمعالجة الخطأ. معالجات أخطاء المكتبة Express هي أدوات وسطية تعرّف على شكل دالة تقبل أربع معاملات. سيبدو معالج الخطأ بالشكل التالي: const errorHandler = (error, request, response, next) => { console.error(error.message) if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } next(error) } app.use(errorHandler) يتحقق المعالج من طبيعة الخطأ. فإن كان الاستثناء هو خطأ تحويل نوع (CastError)، سنتأكد أن مصدر الخطأ هو كائن id بتنسيق مخالف لقواعد Mongo. وعندها سيرسل معالج الخطأ استجابته إلى المتصفح عبر كائن الاستجابة response الذي يمرر كمعامل للمعالج. أما في بقية الاستثناءات، فسيمرر المعالج الخطأ إلى معالج الخطأ الافتراضي في express. تسلسل استخدام الأدوات الوسطية تنفذ الأدوات الوسطية بنفس تسلسل استخدامها في express، أي بنفس تسلسل ظهور الأمر app.use. لذلك ينبغي الانتباه أثناء تعريفها. سيكون التسلسل الصحيح للاستخدام كالتالي: app.use(express.static('build')) app.use(express.json()) app.use(logger) app.post('/api/notes', (request, response) => { const body = request.body // ... }) const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } // handler of requests with unknown endpoint app.use(unknownEndpoint) const errorHandler = (error, request, response, next) => { // ... } // handler of requests with result to errors app.use(errorHandler) يجب أن تضع الأداة الوسطية json-parser بين الأدوات الوسطية التي تعرّف أولًا. فلو كان الترتيب كالتالي: app.use(logger) // request.body is undefined! app.post('/api/notes', (request, response) => { // request.body is undefined! const body = request.body // ... }) app.use(express.json()) لن تكون البيانات المرسلة عبر طلب HTTP بصيغة JSON متاحة للاستخدام عبر الأداة الوسطية للولوج أو عبر معالج المسار POST، لأن الخاصية request.body ستكون غير محددة undefined في هذه المرحلة. ومن المهم جدًا أن تُعرّف الأداة الوسطية التي تعالج مشكلة المسارات غير المدعومة ضمن التعريفات الأخيرة، تمامًا قبل معالج الأخطاء. سيسبب الترتيب التالي على سبيل المثال مشكلة: const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } // handler of requests with unknown endpoint app.use(unknownEndpoint) app.get('/api/notes', (request, response) => { // ... }) لقد ظهر معالج النهاية غير المحددة (unknown endpoint) قبل معالج طلبات HTTP. وطالما أن معالج النهايات غير المحددة سيستجيب إلى كافة الطلبات بالرمز 404 unknown endpoint، فلن يُستدعى بعدها أي مسار أو أداة وسطية. ويبقى الاستثناء الوحيد هو معالج الخطأ الذي يجب أن يأتي أخيرًا بعد معالج النهايات غير المحددة. خيارات أخرى سنضيف الآن بعض الوظائف التي لم ندرجها بعد، بما فيها الحذف وتحديث ملاحظة مفردة. وأسهل الطرق لحذف ملاحظة من قاعدة البيانات هي استخدام التابع findByIdAndRemove: app.delete('/api/notes/:id', (request, response, next) => { Note.findByIdAndRemove(request.params.id) .then(result => { response.status(204).end() }) .catch(error => next(error)) }) سيستجيب الخادم في كلتا حالتي نجاح عملية الحذف برمز الحالة 202 (لا يوجد محتوى). ونقصد بالحالتين، حذف ملاحظة موجودة فعلًا، أو حذف ملاحظة غير موجودة. يمكن أن نستخدم المعامل result للتحقق من حذف المورد أم لا، وبالتالي سنتمكن من تحديد أي من حالتي النجاح قد أعيدت إن كنا بحاجة ماسة لذلك. ستمرر كل الاستثناءات إلى معالج الأخطاء. يمكن بسهولة تغيير أهمية الملاحظة باستخدام التابع findByIdAndUpdate: app.put('/api/notes/:id', (request, response, next) => { const body = request.body const note = { content: body.content, important: body.important, } Note.findByIdAndUpdate(request.params.id, note, { new: true }) .then(updatedNote => { response.json(updatedNote) }) .catch(error => next(error)) }) تسمح الشيفرة السابقة أيضًا بتعديل محتوى الملاحظة لكنها لا تدعم تغيير تاريخ الإنشاء. ولاحظ كيف يتلقى التابع findByIdAndUpdate معاملًا على هيئة كائن JavaScript نظامي، وليس كائن ملاحظة أنشئ بواسطة الدالة البانية للنموذج Note. يبقى لدينا تفصيل مهم يتعلق بالتابع findByIdAndUpdate. فمعامل معالج الحدث upDateNote سيستقبل الملف الأصلي للملاحظة بلا تعديلات. لذلك وضعنا المعامل الاختياري {new: true}، الذي يسبب استدعاء معالج الحدث بالنسخة المعدَّلة من الملاحظة بدلًا من الأصلية. بعد اختبار الواجهة الخلفية مباشرة مستخدمين Postman أو VS Code REST client، يمكننا التحقق من أنها تعمل بشكل صحيح. وكذلك التحقق من أن الواجهة الأمامية تتكامل مع الخلفية التي تستخدم قاعدة البيانات. لكن عندما نحاول تغيير أهمية ملاحظة، تستظهر على الطرفية رسالة الخطأ التالية: استخدم google للبحث عن سبب الخطأ وسيقودك إلى إرشادات لتصحيحه. واتباعًا للاقتراح الموجود في توثيق Mongoose، أضفنا السطر التالي للملف note.js: const mongoose = require('mongoose') mongoose.set('useFindAndModify', false) // ... module.exports = mongoose.model('Note', noteSchema) ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-5 على موقع GitHub. التمارين 3.15 - 3.18 3.15 دليل هاتف بقاعدة بيانات: الخطوة 3 عدّل شيفرة الواجهة الخلفية لتحذف المُدخَلات مباشرة من قاعدة البيانات. تحقق أن الواجهة الأمامية تعمل بشكل صحيح بعد التعديلات. 3.16 دليل هاتف بقاعدة بيانات: الخطوة 4 حوّل معالج الخطأ في التطبيق إلى أداة وسطية جديدة لمعالجة الأخطاء. 3.17 دليل هاتف بقاعدة بيانات: الخطوة 5 * إذا حاول المستخدم إنشاء مُدخَل جديد إلى الدليل، وكان اسم الشخص موجودًا مسبقًا، ستحاول الواجهة الأمامية تحديث رقم الهاتف للمُدخَل الموجود بإرسال طلب HTTP-PUT إلى عنوان المُدخَل. عدّل الواجهة الخلفية لتدعم هذا الفعل، ثم تأكد أن الواجهة الأمامية ستعمل بعد التعديل. 3.18 دليل هاتف بقاعدة بيانات: الخطوة 6 * عدّل معالج المسار api/persons/:id ومعالج المسار api/persons/info ليستخدما قاعدة البيانات، ثم تحقق من أنهما يعملان جيدًا مستخدمًا المتصفح وPostman وVS Code REST client. سيبدو لك الأمر عند التحقق من مُدخَل فردي إلى دليل الهاتف كما في الشكل التالي: ترجمة -وبتصرف- للفصل saving data to MongoDB من سلسلة Deep Dive Into Modern Web Development
  25. سنربط في الفقرات التالية تطبيق الواجهة الأمامية الذي أنشأناه في القسم السابق من هذه السلسلة مع تطبيق الواجهة الخلفية. رأينا في القسم السابق، أن الواجهة الأمامية قادرة على الوصول إلى قائمة بكل الملاحظات من خادم JSON الذي لعب دور الواجهة الخلفية وذلك بطلب العنوان http://localhost:3001/notes. لكن عنوان الموقع قد تغير قليلًا الآن، وستجد الملاحظات على العنوان http://localhost:3001/api/notes. إذًا، سنغيّر الصفة baseUrl في الملف src/services/notes.js كالتالي: import axios from 'axios' const baseUrl = 'http://localhost:3001/api/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } // ... export default { getAll, create, update } لم يعمل الطلب GET إلى العنوان http://localhost:3001/api/notes لسبب ما. مالذي يحدث؟ يمكننا التحقق بالولوج إلى الواجهة الخلفية عبر المتصفح باستخدام Postman بلا أدنى مشكلة. سياسة الجذر المشترك ومفهوم CORS تأتي التسمية CORS من العبارة Cross-Origin Resource Sharing وتعني هذه العبارة "مشاركة الموارد ذات الجذور المختلطة". ووفقًا لموقع Wikipedia: تظهر هذه المشكلة في حالتنا بالصورة التالية: لا يمكن لشيفرة JavaScript التي نكتبها لتطبيق الواجهة الأمامية أن يعمل افتراضيًا مع خادم حتى يشتركا بالجذر نفسه. حيث يعمل تطبيق الخادم الذي أنجزناه سابقًا على المنفذ 3001، ويعمل تطبيق الواجهة الأمامية على المنفذ 3000. فلا يملكان جذرًا مشتركًا. وتذكر أن هذه السياسة لاتنطبق على React و Node فقط، بل هي سياسة عالمية لتشغيل تطبيقات الويب. يمكننا تخطي ذلك في تطبيقاتنا باستعمال الأداة الوسطية cors التي تقدمها Node. ثَبّت cors باستخدام الأمر التالي: npm install cors --save أدرج الأداة واستعملها على النحو: const cors = require('cors') app.use(cors()) وهكذا سترى أن تطبيق الواجهة الأمامية سيعمل، لكننا لم نضف حتى الآن وظيفة تغيير أهمية الملاحظة إلى الواجهة الخلفية. يمكنك الاطلاع على معلومات أكثر عن الأداة CORS من خلال Mozilla's page. تطبيقات للإنترنت طالما تأكدنا أن التطبيق بشقيه أصبح جاهزًا للعمل، سننقله إلى الإنترنت. سنستعين بالخادم Heroku لتنفيذ ذلك. إن لم تستخدم Heroku من قبل، ستجد تعليمات الاستخدام في توثيق Heroku أو بالبحث عبر الإنترنت. أضف الملف Procfile إلى جذر المشروع لتخبر Heroku كيف سيُشغّل التطبيق. web: npm start غيّر تعريف المنفذ الذي نستخدمه في تطبيقنا أسفل الملف index.js ليصبح كالتالي: const PORT = process.env.PORT || 3001 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) وهكذا فإما أن سنستخدم المنفذ الذي عرّفناه كمتغيير بيئة أو المنفذ 3001 إن لم يُعرّف منفذ من خلال متغير البيئة. يُهيِّئ Heroku منفذ التطبيق بناء على قيمة منفذ متغير البيئة. أنشئ مستودع git في جذر المشروع وأضف الملف ذو اللاحقة gitignore. وفيه المعلومات التالية: node_modules أنشئ تطبيق Heroku بتنفيذ الأمر heroku create، حمل شيفرتك إلى المستودع ثم انقله إلى Heroku بتنفيذ الأمر git push heroku master. إن جرى كل شيء على مايرام، سيعمل التطبيق: إن لم يعمل التطبيق، تحقق من المشكلة بقراءة سجلات Heroku، وذلك بتنفيذ الأمر heroku logs. من المفيد في البداية أن تراقب باستمرار ما يظهره Heroku من سجلات. وأفضل وسيلة للمراقبة تنفيذ الأمر heroku log -t الذي يطبع سجلاته على الطرفية، عندما تحصل مشكلة ما على الخادم. إن كنت ستنشر تطبيقك من مستودع git ولم تكن الشيفرة موجودة في الفرع الرئيسي (إي إن كنت ستعدل مستوع الملاحظات من الدرس السابق)، عليك استخدام الأمر git push heroku HEAD:master. وإن فعلت ذلك مسبقًا، ربما ستحتاج إلى تنفيذ هذا الأمر git push heroku HEAD:master --force. تتكامل الواجهة الخلفية مع الأمامية على Heroku أيضًا. يمكنك التحقق من ذلك بتغيير عنوان الواجهة الخلفية الموجود ضمن شيفرة الواجهة الأمامية، ليصبح نفس عنوان الواجهة الخلفية على Heroku بدلًا من http://localhost:3001 السؤال التالي هو: كيف سننشر تطبيق الواجهة الأمامية على الإنترنت؟ لدينا العديد من الخيارات. بناء نسخة الإنتاج من الواجهة الأمامية لقد أنشأنا حتى هذه اللحظة تطبيقات React في وضعية التطوير (development mode)، حيث يهيأ التطبيق لإعطاء رسائل خطأ واضحة، وتُصيّر الشيفرة مباشرة عند حدوث أية تغييرات وهكذا. لكن عندما يغدو التطبيق جاهزًا للنشر لابد من إنشاء نسخة إنتاج (production build) أو نسخة التطبيق المتمثلة للإنتاج. ننشئ نسخة الإنتاج من التطبيقات التي بنيت باستخدام create-react-app بتنفيذ الأمر npm run build. لننفذ الأمر السابق عند جذر مشروع الواجهة الأمامية. ينشئ تنفيذ الأمر السابق مجلدًا يدعى build (يحوي ملف HTML الوحيد للتطبيق ويدعى index.html) وفي داخله مجلد آخر يدعى static ستوَلَّد فيه نسخة مصغرة عن شيفرة JavaScript للتطبيق. وعلى الرغم من وجود عدة ملفات JavaScript في تطبيقنا، إلا أنها ستُجمّع ضمن ملف مصغّر واحد. وفي واقع الأمر، سيَضم هذا الملف كل محتويات ملفات الارتباط الخاصة بالتطبيق أيضًا. لن يكون فهم أو قراءة محتويات هذا الملف يسيرًا كما ترى: !function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];c<i.length;c++)f=i[c],o[f]&&s.push(o[f][0]),o[f]=0;for(n in l)Object.prototype.hasOwnProperty.call(l,n)&&(e[n]=l[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,i=1;i<t.length;i++){var l=t[i];0!==o[l]&&(n=!1)}n&&(u.splice(r--,1),e=f(f.s=t[0]))}return e}var n={},o={2:0},u=[];function f(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,f),t.l=!0,t.exports}f.m=e,f.c=n,f.d=function(e,r,t){f.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},f.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}) تقديم الملفات ذات المحتوى الثابت من الواجهة الخلفية إن أحد الطرق المتبعة في نشر الواجهة الأمامية هو بناء نسخة إنتاج ووضعها في جذر المستودع الذي يحتوي الواجهة الخلفية. ومن ثم نهيئ الواجهة الخلفية لتعرض الصفحة الرئيسية للواجهة الأمامية (build/index.html). نبدأ العملية بنقل نسخة الإنتاج إلى جذر الواجهة الخلفية. استخدم الأمر التالي في إجراء عملية النسخ إن كنت تستخدم أحد نظامي التشغيل Mac أو Linux cp -r build ../../../osa3/notes-backend واستخدم في النظام windows إحدى التعليميتن copy أو xcopy أو استخدم ببساطة النسخ و اللصق. سيبدو مجلد الواجهة الخلفية كالتالي: سنستخدم أداة وسطية مدمجة مع المكتبة express تعرض الملفات ذات المحتوى الثابت التي تحضرها من الخادم مثل الملف index.html وملفات JavaScript وغيرها، تدعى هذه الأداة static. فعندما نضيف العبارة التالية إلى شيفرة الواجهة الخلفية: app.use(express.static('build')) ستتحقق Express من محتويات المجلد build عندما تتلقى أية طلبات HTTP-GET. فإن وجدت ملفًا مطابقًا للملف المطلوب ستعيده. وهكذا ستظهر الواجهة الأمامية المبنية باستخدام React، عندما يستجيب الخادم إلى طلبات GET إلى العنوان www.serversaddress.com/index.html أو إلى العنوان www.serversaddress.com. بينما تتعامل الواجهة الخلفية مع الطلب GET إلى العنوان www.serversaddress.com/api/notes. في حالتنا هذه سنجد أن للواجهتين العنوان نفسه، لذلك نستطيع أن نعطي للمتغير baseUrl عنوان موقع نسبي، وذلك كي لانذكر القسم الأول من العنوان والمتعلق بالخادم. import axios from 'axios' const baseUrl = '/api/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) // ... } بعد إجراء تلك التغييرات، علينا أن ننشئ نسخة إنتاج ونضعها في جذر مستودع الواجهة الخلفية. وبالتالي سنصبح قادرين على استخدام الواجهة الأمامية بطلب عنوان الواجهة الخلفية http://localhost:3001. وهكذا سيعمل تطبيقنا تمامًا كتطبيق الصفحة الواحدة النموذجي الذي درسناه في القسم 0. فعندما نطلب عنوان الواجهة الخلفية http://localhost:3001 سيعيد الخادم الملف index.html من المجلد build. ستجد محتوى الملف (بشكل مختصر) كالتالي: <head> <meta charset="utf-8"/> <title>React App</title> <link href="/static/css/main.f9a47af2.chunk.css" rel="stylesheet"> </head> <body> <div id="root"></div> <script src="/static/js/1.578f4ea1.chunk.js"></script> <script src="/static/js/main.104ca08d.chunk.js"></script> </body> </html> يتضمن الملف تعليمات لإحضار ملف CSS الذي يعرف تنسيقات عناصر التطبيق ومعرّفي شيفرة (script tag) يوجهان المتصفح إلى إحضار شيفرة JavaScript التي يحتاجها التطبيق، وهي الشيفرة الفعلية لتطبيق React. تحضر شيفرة React الملاحظات من العنوان http://localhost:3001/api/notes، وتصيّرها على الشاشة. يمكنك أن تتابع تفاصيل الاتصال بين المتصفح والخادم من نافذة Network ضمن طرفية التطوير: بعد أن نتأكد من عمل نسخة الإنتاج على الخادم المحلي، انقل المجلد build الذي يحوي الواجهة الأمامية إلى مستودع الواجهة الخلفية، ثم انقل الشيفرة كلها إلى خادم Heroku مجددًا. سيعمل التطبيق بشكل جيد، ماعدا الجزئية التي تتعلق بتغيير أهمية الملاحظات. حتى اللحظة يحفظ تطبيقنا الملاحظات ضمن متغيّر، وبالتالي إذا ما حدث أمر ما وتوقف التطبيق عن العمل ستختفي تلك الملاحظات. إذًا لايزال التطبيق بحاجة إلى قاعدة بيانات. سنشرح تاليًا عددًا من النقاط قبل أن ننتقل إلى قاعدة البيانات. نشر الواجهة الخلفية بطريقة انسيابية سنكتب سكربت npm بسيط لإنشاء نسخة إنتاج من الواجهة الأمامية بأقل جهد ممكن. ضع الشيفرة التالية في ملف package.json الموجود في مستودع الواجهة الخلفية: { "scripts": { //... "build:ui": "rm -rf build && cd ../../osa2/materiaali/notes-new && npm run build --prod && cp -r build ../../../osa3/notes-backend/", "deploy": "git push heroku master", "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && npm run deploy", "logs:prod": "heroku logs --tail" } } عندما ينفذ npm الجزء (build:ui) من السكريبت السابقة بالأمر npm run build:ui، سيبني نسخة إنتاج من الواجهة الأمامية ضمن مستودع الواجهة الخلفية. بينما سينشر الأمر npm run deploy النسخة الحالية للواجهة الخلفية على الخادم Heroku. وأخيرًا يدمج الأمر npm run deploy:full التطبيقين معًا ويزودهما بأوامر git تساعد في تحديث مستودع الواجهة الخلفية. يبقى هناك جزء من سكريبت npm يُنفَّذ بالأمر npm run logs:prod، ويُستخدَم لطباعة سجلات Heroku أثناء التنفيذ. انتبه: تعتمد المسارات المكتوبة في الجزء build:ui من السكربت على موقع المستودعات في منظومة الملفات. تُنفَّذ npm سكريبت في نظام windows باستخدام cmd.exe لأن واجهة النظام (shell) الافتراضية لاتدعم أوامر أو واجهة bash. ولتنفيذ الأوامر السابقة استبدل واجهة النظام بالواجهة bash باستخدام محرر Git الافتراضي في windows كالتالي: npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" الخادم الوكيل لن تعمل الواجهة الأمامية بعد التغييرات التي أجريناها في وضعية التطوير (عندما تُشغَّل باستخدامnpm start)، لأن الاتصال مع الواجهة الخلفية لن يعمل. وذلك لتغيّر عنوان الواجهة الخلفية إلى عنوان نسبي: const baseUrl = '/api/notes' لقد كان عنوان الواجهة الأمامية في مرحلة التطوير localhost:3000، وبالتالي ستُرسل الطلبات الآن إلى الواجهة الخلفية على العنوان الخاطئ localhost:3000/api/notes. وطبعًا الواجهة الخلفية موجودة فعلًا على العنوان localhost:3001. سيكون الحل بسيطًا عندما ننشئ المشروع باستخدام create-react-app. أضف التصريحات التالية إلى الملف package.json الموجود في مستودع الواجهة الأمامية: { "dependencies": { // ... }, "scripts": { // ... }, "proxy": "http://localhost:3001" } بعد إعادة تشغيل التطبيق، ستعمل بيئة تطوير React كخادم وكيل. فلو أرسلت شيفرة React طلبًا إلى خادم على العنوان http://localhost:3000، ولم يكن تطبيق React هو من يدير هذا الخادم (أي في الحالة التي لا تحضر فيها الطلبات ملفات CSS أو JavaScript الخاصة بالتطبيق)، سيعاد توجيه الطلب إلى الخادم الذي عنوانه http://localhost:3001. وهكذا ستعمل الواجهة الأمامية بشكل جيد في وضعي التطوير والإنتاج. إن سلبية هذه المقاربة هي التعقيد الذي تظهره في نشر الواجهة الأمامية على الإنترنت. فنشر نسخة جديدة سيتطلب إنشاء نسخة إنتاج جديدة ونقلها إلى مستودع الواجهة الخلفية. وسيصعّب ذلك إنشاء خطوط نشر آلية. وتعرّف خطوط النشر بأنها طريقة مؤتمتة وقابلة للتحكم لنقل الشيفرة من حاسوب المطور إلى بيئة الإنتاج مرورًا باختبارات مختلفة بالإضافة إلى التحقق من الجودة. يمكن إنجاز ذلك بطرق عدة، منها وضع الواجهتين الخلفية والأمامية في نفس المستودع. لكننا لن نناقش ذلك الآن. في بعض الحالات من المعقول أن ننشر الواجهة الأمامية على شكل تطبيق مستقل وهذا أمر بسيط ويطبق مباشرة إن كتب التطبيق باستخدام create-react-app. ستجد الشيفرة الحالية للواجهة الخلفية في الفرع part3-3 على Github. بينما ستجد التعديلات على الواجهة الأمامية في الفرع part3-1 في مستوودع الواجهة الأمامية. التمارين 3.9 - 3.11 لايتطلب إنجاز التمارين التالية العديد من الأسطر البرمجية. لكنها ستحمل شيئًا من التحدي، لأنها تتطلب فهمًا دقيقًا لما يحدث وأين يحدث، بالإضافة إلى تهيئة التطبيق كما يجب. 3.11 دليل هاتف للواجهة الخلفية: الخطوة 9 اجعل الواجهة الخلفية تتكامل مع الأمامية في نفس التمرين من القسم السابق. لا تضف وظيغة تغيير الأرقام حاليًا، بل سنقوم بذلك لاحقًا. ربما ستغيّر قليلًا في الواجهة الأمامية، على الأقل عناوين مواقع الواجهة الخلفية. وتذكر أن تبقي طرفية التطوير مفتوحة في متصفحك. تحقق مما يحدث إن فشل أي طلب HTTP من خلال النافذة Network، وابق نظرك دائما على طرفية تطوير الواجهة الخلفية في نفس الوقت. إن لم تتمكن من إنجاز التمرين السابق، ستفيدك طباعة بيانات الطلب أوجسم الطلب على الطرفية بزرع تعليمة الطباعة في دالة معالج الحدث المسؤولة عن طلبات POST. 3.10 دليل هاتف للواجهة الخلفية: الخطوة 10 انشر الواجهة الخلفية على الإنترنت (على Heroku مثلًا). ملاحظة: إن لم تستطع لسبب ما تثبيت Heroku على حاسوبك، استخدم الأمر npx heroku-cli. اختبر الواجهة الخلفية التي نشرتها من خلال المتصفح بمساعدة Postman أو VS Code REST client للتأكد من أنها تعمل. نصيحة للاحتراف: عندما تنشر تطبيقك على Heroku، يفضل -على الأقل في البداية- أن تبقي نظرك على ما يطبعه تطبيق Heroku دائمًا، وذلك بتنفيذ الأمر heroku logs -t. تمثل الصورة التالية مايطبعه heroku عندما لا يستطيع إيجاد ملف الارتباط express. والسبب أن الخيار save-- لم يحدد عندما ثُبتت المكتبة express. وبالتالي لم تحفظ معلومات ملف الارتباط ضمن الملف package.json. أحد الأخطاء الأخرى، هو أن التطبيق لم يهيأ لاستعمال المنفذ الذي عُرِّف في متغير البيئة PORT: أنشئ ملفًا باسم README.md في جذر مستودعك، وأضف إليه رابطًا لتطبيقك على الإنترنت. 3.11 دليل هاتف للواجهة الخلفية: الخطوة 11 أنشئ نسخة إنتاج من الواجهة الأمامية، وانشرها على الإنترنت بالأسلوب الذي ناقشناه في هذا الفصل. ملاحظة: تأكد من أن المجلد build ليس ضمن قائمة الملفات التي يهملها git، وهو الملف الذي لاحقته gitignore. تأكد أيضًا أن الواجهة الأمامية لازالت تعمل. ترجمة -وبتصرف- للفصل Deploying App to Internet من سلسلة Deep Dive Into Modern Web Development
×
×
  • أضف...