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

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

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

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

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

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

  1. تتطلب كتابة تطبيقات أصليّة Native لنظامي التشغيل iOS وAndroid تقليديًا استخدام لغات برمجة وبيئات تطوير خاصة بكل منصة. فقد استخدمت لغات مثل Objective C وSwift لتطوير تطبيقات iOS، ولغات مبنية على JVM مثل Java وScala وKotlin لتطوير تطبيقات موجّهة لنظام Android. وبالتالي وجب من الناحية التقنية كتابة تطبيقين منفصلين وبلغتي برمجة مختلفتين من أجل العمل على المنصتين السابقتين، وهذا ما تطلب الكثير من الموارد. لقد كان استخدام المتصفح كمحرك لتصيير التطبيقات إحدى المقاربات الأكثر شعبية في توحيد تطوير التطبيقات للمنصات المختلفة. وكانت المنصة Cordova من أشهر المنصات التي استخدمت في تطوير تطبيقات تعمل على منصات مختلفة. إذ تسمح هذه المنصة بتطوير تطبيقات قادرة على العمل على منصات مختلفة اعتمادًا على تقنيات الويب المعيارية مثل HTML5 وCSS3 وJavaScript. تعمل تطبيقات Cordova ضمن المتصفح المدمج في جهاز المستخدم، وبالتالي لن تحقق الأداء ولا المظهر الذي يمنحه استخدام التطبيقات الأصلية التي تستخدم مكوّنات واجهة المستخدم الأصلية. تقدم React Native إطار عمل لتطوير تطبيقات أصلية لمنصتي iOS وAndroid باستخدام JavaScript وReact. حيث تؤمن مجموعة من المكوّنات التي تعمل على منصات مختلفة وتستخدم خلف الستار المكوّنات الأصلية بالمنصة التي يعمل عليها التطبيق. وباستخدام React Native يمكننا استحضار كل الميزات المألوفة التي تقدمها React مثل JSX والمكوّنات components والخصائص props والحالة state والخطافات hooks، لتطوير تطبيقات أصلية لمنصة. وفوق كل ذلك، سنتمكن من استخدام مكتبات مألوفة بالنسبة لنا ضمن بيئة عمل React مثل react-redux وreact-apollo وreact-router وغيرها الكثير. تعتبر سرعة التطوير ومنحى التعلم المتصاعد للمطورين الذين ألفوا React، من أبرز الفوائد التي تمنحها React Native. وإليكم اقتباسًا تشجيعيًا ورد في مقال لمدونة Coinbase بعنوان Onboarding thousands of users with React Native، أو بالعربية "ضم آلاف المستخدمين إلى React Native"، حول فوائد React Native. حول هذا القسم سنتعلم خلال هذا القسم بناء تطبيقات React Native فعلية من البداية إلى النهاية. وسنتعلم مفاهيم مثل مكونات React Native البنيوية core components، وكيفية بناء واجهات مستخدم جميلة، وكيف سنتصل مع الخادم، كما سنتعلم كيفية اختبار تطبيقات React Native. سنعمل على تطوير تطبيق لتقييم مستودعات GitHub. وسيمتلك التطبيق ميزاتٍ كفرز وانتقاء المستودعات التي نقيّمها، وكذلك ميزة تسجيل مستخدم جديد، وتسجيل دخول مستخدم، وإنشاء مذكرة تقييم جديدة. سنستخدم واجهة خلفية جاهزة لكي ينصب تركيزنا على تطوير التطبيق باستخدام React Native فقط. ينبغي أن تسلم كل تمارين هذا القسم إلى مستودع GitHub واحد، سيضم مع الوقت الشيفرة الكلية للتطبيق. ستجد أيضًا حلًا نموذجيًا لكل مقال من هذا الجزء يمكنك استخدامه لإكمال التمارين الناقصة عند تسليمها. تعتمد هيكلية القسم على فكرة تطوير التطبيق تدريجيًا مع تقدمك في قراءة مادة القسم العلمية. فلا تنتظر حتى تصل إلى التمارين لتبدأ بتطوير تطبيقك، بل طوّره بما يتناسب مع المرحلة التي وصلت إليها في قراءة مادة القسم. سيعتمد هذا القسم بشدة على مفاهيم ناقشناها في أقسام سابقة. لذا وقبل أن تبدأ رحلتك هنا، عليك امتلاك بعض المعارف الأساسية في JavaScript وReact وGraphQL. لن تحتاج إلى معرفة عميقة في تطوير التطبيقات من ناحية الخادم، فكل الشيفرة التي تحتاجها للواجهة الخلفية محضرة مسبقًا. مع ذلك، سننفذ طلبات اتصال شبكية من خلال تطبيقات React Native مثل استعلامات GraphQL. ننصحك بإكمال الأقسام Part 1 وPart 2 وPart 5 وPart 7 وPart 8 قبل البدء بهذا القسم. تسليم التمرينات والحصول على الساعات المعتمدة ستُسلّم التمارين إلى منظومة استلام التمارين كما هو الحال في الأقسام السابقة. لكن انتبه إلى أنّ تمارين هذا المقال ستُسلّم إلى نسخةً أخرى من المنهاج تختلف عن النسخة التي سلمنا فيها تمارين الأقسام السابقة. فالأقسام من 1 إلى 4 في منظومة التسليم في هذه النسخة تشير إلى الفصول من a إلى d من هذا القسم. ويعني هذا أن عليك تسليم تمارين كل مقال على حدى ابتداءً من هذا المقال "مدخل إلى React Native" والذي سيحمل الاسم "Part 1" في منظومة تسليم التمارين. ستحصل في هذا القسم على ساعات معتمدة بناء على عدد التمارين التي تُكملها. فستحصل على ساعة معتمدة عند إكمالك 19 تمرينًا على الأقل، وعلى ساعتين معتمدتين عند إكمالك 26 تمرينُا على الأقل. وإن أردت الحصول على الساعات عند إكمالك للتمارين، أعلمنا من خلال منظومة التسليم بأنك أكملت المنهاج. وتشير الملاحظة "exam done in Moodle" إلى امتحان منهاج التطوير الشامل لتطبيقات الويب، والذي عليك اجتيازه حتى تحصل على ساعاتك المعتمدة المخصصة لهذا القسم. يمكنك الحصول على شهادة إكمال هذا القسم بالنقر على إحدى أيقونات "العلم". حيث ترتبط أيقونات الأعلام باللغة التي نُظِّمت بها الشهادة. وانتبه إلى ضرورة إكمال ما يعادل ساعة معتمدة من تمارين هذا القسم لتحصل على الشهادة. تهيئة التطبيق علينا تهيئة بيئة التطوير حتى نبدأ بالعمل مع تطبيقنا. وكنا قد تعاملنا في الأقسام السابقة مع أدوات مفيدة في ضبط وتهيئة تطبيقات React بسرعة مثل برنامج "create react app". تمتلك React Native لحسن الحظ أدواتٍ مشابهة أيضًا. سنستخدم لتطوير تطبيقنا منصة Expo، وهي منصة تبسط إعداد وتطوير وبناء ونشر تطبيقات React Native. لنبدأ إذًا بتثبيت واجهة سطر الأوامر الخاصة بهذه المنصة: npm install --global expo-cli يمكننا الآن تهيئة مشروعنا داخل المجلد "rate-repository-app" بتنفيذ الأمر التالي: expo init rate-repository-app --template expo-template-blank@sdk-38 يضبط الوسيط "sdk-38@" نسخة "Expo SDK" على 38 وهي النسخة التي تدعم النسخة 0.62 من React Native. وقد يتسبب لك استخدام نسخة مختلفة من "Expo SDK" مشاكل عند تتبع المادة التعليمية في هذا القسم. وتجدر الإشارة إلى محدودية Expo في بعض النقاط موازنةً بواجهة اللغة المشتركة CLI التي تستخدمها React Native، لكن لن تؤثر هذه المحدودية على التطبيق الذي سننفذه في هذا القسم. افتح المجلد "rate-repository-app" الذي أنشأناه عند تهيئة التطبيق باستخدام محرر شيفرة مثل Visual Studio Code. قد ترى بعض الملفات والمجلدات المألوفة ضمنه مثل "package.json" و"node_modules"، كما سترى أيضًا الملفات الأكثر ارتباطًا بالتطبيق مثل "app.json" الذي يحتوي على أوامر التهيئة الخاصة بالمنصة Expo، والملف "App.js" الذي يمثل المكوِّن الجذري لتطبيقنا. لا تغير اسم الملف "App.js" ولا تنقله إلى مجلد آخر، لأن Expo سيدرجه لتسجيل المكوّن الجذري. لنلق نظرة على قسم السكربتات في الملف "package.json": { // ... "scripts": { "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", "eject": "expo eject" }, // ... } سيشغّل تنفيذ الأمر npm start المجمّع Metro وهو مُجمّع JavaScript أي JavaScript bundler يستخدم مع React Native، ويمكن أن نعده مشابهًا لبرنامج Webpack لكنه خاص ببيئة React Native. كما ينبغي أن تُشغَّل أدوات تطوير Expo على العنوان http://localhost:19002 من خلال المتصفح. وهذه الأدوات مفيدةً جدًا في عرض سجلات تنفيذ التطبيق، بالإضافة إلى تشغيل التطبيق من خلال المقلّد emulator، أو من خلال تطبيق جوّال Expo. سنعود إلى هذين الأخيرين لاحقًا، إذا علينا أولًا تشغيل التطبيق في المتصفح بالنقر على الزر "Run": ستجد بعد النقر على الرابط أن النص المكتوب في الملف "App.js" سيظهر على نافذة المتصفح. افتح هذا الملف باستخدام أي محرر مناسب واجر تغييرًا بسيطًا على النص الموجود في المكوّن Text. يجب أن تظهر التعديلات التي أجريتها مباشرة على شاشة المتصفح بعد أن تحفظ التغيرات. إعداد بيئة التطوير لقد ألقينا نظرة أولى على تطبيقنا من خلال متصفح Expo. وعلى الرغم من فائدة هذا المتصفح، إلا أنه يقدم محاكاة بدائية للبيئة الأصلية. سنتعرف تاليًا على بعض البدائل المتوفرة لدينا بما يتعلق ببيئة التطوير. يمكن تقليد أجهزة iOS وAndroid كالأجهزة اللوحية والهواتف عن طريق الحاسوب وباستخدام مقلّدات Emulators خاصة. وهذا أمر مفيد جدًا عند تطوير التطبيقات باستخدام React Native. يمكن لمستخدمي نظام التشغيل macOS استخدام مقلدات iOS وAndroid على حواسيبهم، أما مستخدمي أنظمة التشغيل الأخرى مثل Linux وWindows، فليس بوسعهم سوى العمل مع مقلدات Android. اتبع التعليمات التالية حول إعداد المقلّد، بناء على نظام التشغيل الذي يستخدمه حاسوبك: Set up Android emulator with Android Studio لأي نظام تشغيل Set up iOS simulator with Xcode لنظام التشغيل macOS بعد تثبيت المقلّد واختباره، شغّل أدوات تطوير Expo كما فعلنا سابقًا عن طريق الأمر npm start. وبناء على المقلد الذي تستخدمه، إنقر الزر "Run" على مقلد أجهزة Android، أو "Run" على رابط محاكي iOS. ينبغي أن يتصل Expo بالمقلد بعد النقر على الرابط، وسترى التطبيق عليه. كن صبورًا، فقد يستغرق الأمر وقتًا. بالإضافة إلى المقلدات، ستجد أنّ هناك طريقة غاية في الأهمية لتطوير تطبيقات React Native باستخدام Expo، وهي تطبيق جوّال Expo. فباستخدام هذا التطبيق، ستتمكن من استعراض تطبيقك عبر جهازك النقّال الفعلي، وهذا ما يمنحك خبرة أكثر موازنةً بالمقلدات. ثبّت أولًا تطبيق جوّال Expo باتباع التعليمات الموجودة في توثيق Expo. وانتبه إلى أنّ تطبيق جوّال Expo لن يعمل إن لم يكن جهازك النقًال متصلًا بنفس الشبكة المحلية (نفس الشبكة اللاسلكية على سبيل المثال) التي يتصل بها حاسوب التطوير الذي تعمل عليه. شغل تطبيق جوّال Expo عندما تنتهي من تثبيته. إن لم تكن قد شغّلت أدوات تطوير Expo بعد، فافعل ذلك مستخدمًا الأمر npm start. سترى في الزاوية اليسارية السفلى من نافذة أدوات التطوير شيفرة QR. امسح هذه الشيفرة باستخدام تطبيق جوّال Expo عن طريق الضغط على الرابط "Scan QR Code". ينبغي أن يبدأ تطبيق Expo ببناء مجمّع JavaScript، ومن المفترض أن ترى تطبيقك وهو يعمل بعد انتهاء عملية التجميع. وهكذا ستتمكن في كل مرة تفتح فيها تطبيقك باستخدام تطبيق جوّال Expo من الوصول إليه دون الحاجة إلى مسح شيفرة QR مجددًا، وذلك بالضغط عليه في قائمة التطبيقات التي شُغّلت مؤخرًا، والتي تظهر في نافذة العرض "Project". التمرين 10.1 10.1 تهيئة التطبيق استخدم واجهة سطر أوامر Expo لتهيئة تطبيقك، وجهّز بيئة التطوير باستخدام المقلد أو تطبيق جوّال Expo. ننصحك بتجريب الأسلوبين السابقين لتحدد البيئة الأنسب بالنسبة لك. لا تهتم كثيرًا لاسم التطبيق، فيمكنك أن تستخدم الاسم "rate-repository-app". عليك إنشاء مستودع GitHub جديد لتسليم هذا التمرين وغيره من التمارين اللاحقة، ويمكنك تسمية هذا المستودع باسم التطبيق الذي قمت بتهيئته عند تنفيذك الأمر expo init. إن قررت إنشاء مستودع خاص، أضف المستخدم Kaltsoon كمشارك لك collaborator في هذا المستودع، وذلك للتحقق من التمارين التي أنجزتها وسلمتها. نفذ بعد إنشائك المستودع الأمر git init ضمن المجلد الجذري لمشروعك للتأكد من تهيئة المستودع كمستودع Git. ولإضافة المستودع الجديد إلى قائمة العمل عن بعد نفذ الأمر التالي: git remote add origin git@github.com:<YOUR\*GITHUB\*USERNAME>/<NAME\*OF\*YOUR_REPOSITORY>.git وتذكر أن تستبدل القيم داخل الأقواس بما يناسب عندك كتابتك لسطر الأوامر. ادفع أخيرًا بالتغييرات التي أجريتها إلى المستودع وسيكون كل شيء على ما يرام. المدقق ESLint الآن، وقد ألفنا بيئة التطوير سنعمل على تحسين مهاراتنا في التطوير وذلك بتهيئة مدقق للشيفرة. سنستخدم المدقق ESLint الذي تعاملنا معه سابقًا، وكالعادة سنبدأ بتثبيت الاعتماديات اللازمة: npm install --save-dev eslint babel-eslint eslint-plugin-react سنضع القواعد التالية للمدقق داخل الملف ذو اللاحقة "eslintrc." والموجود في المجلد "rate-repository-app": { "plugins": ["react"], "settings": { "react": { "version": "detect" } }, "extends": ["eslint:recommended", "plugin:react/recommended"], "parser": "babel-eslint", "env": { "browser": true }, "rules": { "react/prop-types": "off", "semi": "error" } } ومن ثم سنضيف السكربت "lint" إلى محتويات الملف "package.json"، وذلك للتحقق من تطبيق قواعد المدقق في الملفات المحددة: { // ... "scripts": { "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", "eject": "expo eject", "lint": "eslint ./src/**/*.{js,jsx} App.js --no-error-on-unmatched-pattern" }, // ... } لاحظ أننا استخدمنا الفواصل لإنهاء سطر الشيفرة على نقيض ما فعلنا في الأقسام 1-8، لذلك أضفنا القاعدة semi للتأكد من ذلك. نستطيع الآن التأكد من خضوع ملفات JavaScript الموجودة في المجلد "src" وكذلك الملف "App.js" لقواعد المدقق، وذلك بتنفيذ الأمر npm run lint. سنضع ملفات الشيفرة لاحقًا في المجلد "src"، لكن طالما أننا لم نضف أية ملفات ضمنه حتى اللحظة، سنضطر إلى تفعيل الراية "no-error-on-unmatched-pattern". حاول إن استطعت أيضًا دمج المدقق ESlint مع محرر الشيفرة الذي تعمل عليه. فإن كنت تعمل على Visual Studio Code، يمكنك فعل ذلك بتفقد قسم الموّسعات extensions والتأكد من تثبيت وتفعيل موسِّع ESlint. تمثل قواعد تهيئة المدقق السابقة، القواعد الأساسية للتهيئة. يمكنك أن تعدّل بحرية على هذه القواعد وأن تضيف ملحقات جديدة plugins إن شعرت بالحاجة إلى ذلك. التمرين 10.2 10.2 إعداد وضبط المدقق ESLint هيئ المدقق في مشروعك حتى تتمكن من تدقيق ملفات الشيفرة عند تنفيذك الأمر. ويفضّل عند استخدامك المدقق أن تدمجه مع محررك لكي تستفيد من كامل إمكانياته. هذا هو التمرين الأخير في هذا المقال، وقد حان الوقت لتسليم التمارين إلى GitHub والإشارة إلى أنك أكملتها في منظومة تسليم التمارين. انتبه إلى وضع الحلول في القسم 1 ضمن المنظومة. عرض سجلات العمل يمكن الاستفادة من بيئة تطوير Expo في عرض سجلات العمل للتطبيق، كما يمكن متابعة رسائل الخطأ والتحذير ضمن المقلّد أو ضمن واجهة تطبيق الجوّال. ستظهر رسالة الخطأ باللون الأحمر فوق مكان الخطأ، بينما يمكن عرض رسائل التحذير بالضغط على رسالة التحذير في أسفل الشاشة. كما يمكنك استخدام الأمر console.log لطباعة رسائل محددة، وذلك لغايات التنقيح وكشف الأخطاء. لتجريب ذلك عمليًا، شغّل أدوات تطوير Expo بتنفيذ الأمر npm start، ثم شغّل التطبيق باستخدام المقلّد أو تطبيق الجوّال. ستتمكن عندها من رؤية الأجهزة المتصلة بحاسوبك تحت العنوان "Metro Bundler" في الزاوية العليا اليسارية من نافذة أدوات التطوير: انقر على الجهاز لفتح سجلات العمل عليه. افتح بعد ذلك الملف "App.js" وأضف رسالةً ما باستخدام الأمر console.log إلى المكوِّن App. سترى رسالتك ضمن السجلات بمجرد أن تحفظ التغييرات التي أجريتها على الملف. استخدام منقح الأخطاء Debugger قد يكون فحص الرسائل التي كُتبت باستخدام الأسلوب console.log مفيدًا، لكن سيتطلب الأمر فهمًا أوسع إن كنت تحاول أن تجد ثغرةً ما أو أن تفهم سير عمل التطبيق. فربما سنهتم -على سبيل المثال- بخصائص أو بحالة مكوِّن محدد، أو بالاستجابة لطلبٍ محدد على الشبكة، لذلك استخدمنا أدوات المطوّر التي يؤمنها المتصفح لإنجاز هذا العمل سابقًا. بالمقابل سنجد أنّ المنقّح React Native Debugger سيزوّدك بنفس ميزات التنقيح، لكن لتطبيقات React Native. لنبدأ العمل بتثبيت منقِّح React Native بمساعدة تعليمات التثبيت. شغّل المنقّح عند انتهاء التثبيت، وافتح نافذة تنقيح جديدة (يمكنك استخدام الاختصارات التالية: command+T على macOS وCtrl+T على Windows/Linux)، ثم اضبط رقم منفذ محزِّم React Native على 19001. علينا الآن تشغيل تطبيقنا وربطه مع المنقّح. لننفذ إذًا الأمر npm start أولًا، ثم سنفتح التطبيق ضمن المقلّد أو تطبيق جوّال Expo. افتح قائمة المطوّرين داخل المقلّد أو التطبيق باتباعك الإرشادات الموجودة في توثيق Expo. اختر من قائمة المطوّرين العنصر "Debug remote JS" لتتصل مع المنقّح. إن جرى كل شيء على مايرام سترى شجرة مكونات التطبيق ضمن المنقّح. يمكنك استخدام المنقّح للتحقق من حالة المكوّنات وخصائصها وتغييرها إن أردت. حاول أن تعثر مثلًا على المكوّن Text الذي يصيره المكوّن App باستخدام خيار البحث أو عبر شجرة المكوّنات. انقر المكوّن text وغّير قيمة الخاصية children. من المفترض أن يَظهر التغيير تلقائيًا في نافذة عرض التطبيق. للاطلاع على أدوات تنقيح أكثر فائدة لتطبيقات Expo، اطلع على توثيق عملية التنقيح في Expo. ترجمة -وبتصرف- للفصل Introduction to React Native من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: استخدام TypeScript والأنواع التي توفرها في تطبيقات React. مدخل إلى التحريك في React Native.
  2. يسمح "هجوم الاختطاف بالنقر clickjacking" للصفحات المشبوهة بأن تنقر ضمن الصفحة المستهدفة نيابةً عن الزائر. وقد اختُرقت الكثير من الصفحات بهذه الطريقة، بما في ذلك تويتر وفيسبوك وبيبال وغيرها، وبالطبع أصلحت الثغرات التي سببت الهجوم على تلك المواقع. فكرة الهجوم إن فكرة الهجوم بسيطة جدًا، فقد كانت طريقة الهجوم على فيسبوك مثلًا كما يلي: جُذب المستخدم إلى الصفحة المشبوهة بطريقة ما. احتوت الصفحة رابطًا لا يبدو خطرًا بعناوين، مثل: "طريقك إلى الثروة"، أو "انقر هنا، أمر مضحك جدًا". وضعت الصفحة المشبوهة فوق هذا الرابط نافذةً ضمنيةً <iframe> شفافة ترتبط خاصية src فيه بالموقع "facebook.com"، بحيث ظهر زر "أعجبني" فوق الرابط مباشرةً، وعادة يُنفَّذ ذلك باستخدام الخاصية z-index. وبالتالي نقر الزائر على الزر عندما حاول النقر على الرابط. مثال توضيحي ستبدو الصفحة المشبوهة كالتالي، مع الانتباه إلى أنّ النافذة الضمنية <iframe> هنا نصف شفافة،أما في الصفحات المشبوهة فستكون النافذة الضمنية شفافةً تمامًا: <style> iframe { /* النافذة الضمنية من الصفحة الضحية */ width: 400px; height: 100px; position: absolute; top:0; left:-20px; opacity: 0.5; /* الشفافية 0 في الصفحة المشبوهة */ z-index: 1; } </style> <div>Click to get rich now:</div> <!-- العنوان على الصفحة الضحية --> <iframe src="/clickjacking/facebook.html"></iframe> <button>Click here!</button> <div>...And you're cool (I'm a cool hacker actually)!</div> See the Pen JS-P3-01-The-clickjacking-attack-ex1 by Hsoub (@Hsoub) on CodePen. يحوي الملف "facebook.html" الشيفرة التالية: <!DOCTYPE HTML> <html> <body style="margin:10px;padding:10px"> <input type="button" onclick="alert('Like pressed on facebook.html!')" value="I LIKE IT !"> </body> </html> ويحوي الملف "index.html" الشيفرة التالية: <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <style> iframe { width: 400px; height: 100px; position: absolute; top: 5px; left: -14px; opacity: 0.5; z-index: 1; } </style> <div>Click to get rich now:</div> <!-- The url from the victim site --> <iframe src="facebook.html"></iframe> <button>Click here!</button> <div>...And you're cool (I'm a cool hacker actually)!</div> </body> </html> ستظهر النتيجة كالتالي: تظهر النافذة الضمنية <"iframe src="facebook.html> في المثال السابق نصف شفافة، بحيث يمكن رؤيتها عند تحريك المؤشر فوق الزر، وعند النقر على الزر فإننا ننقر في الواقع على النافذة الضمنية التي ستكون مخفيّةً كونها شفافةً تمامًا. فإن أمكن للمستخدم الوصول إلى حسابه مباشرةً دون الحاجة لتسجيل الدخول (عندما تكون ميزة "تذكرني remember me" مفعلة)، فسيضيف النقر على النافذة الضمنية إعجابًا Like، بينما سينقر في تويتر على زر المتابعة Follow. إليك نتيجة المثال السابق لكن بواقعية أكثر عندما نسند القيمة "0" إلى الخاصية opacity: كل ما يتطلبه شن الهجوم هو وضع النافذة الضمنية iframe في الصفحة المشبوهة بطريقة تجعل الزر فوق الرابط مباشرةً، وبالتالي سيضغط الزائر الزر بدلًا من الرابط، ويُنجز ذلك عادة باستخدام لغة الأنماط الانسيابية CSS. الأسلوب الدفاعي للمدرسة التقليدية يعود جزء من الآلية الدفاعية القديمة إلى JavaScript التي تمنع فتح الصفحة في نافذة ضمنية، وهو ما يُعرف بكسر الإطار framebusting، ويبدو الأمر كالتالي: if (top != window) { top.location = window.location; } بهذه الشيفرة تتحقق النافذة من كونها الأعلى ترتيبًا بين العناصر، فإن لم تكن كذلك فستجعل نفسها الأعلى تلقائيًا، وهذا أسلوب ضعيف في الدفاع يمكن الالتفاف عليه بسهولة، وسنطلع تاليًا على عدة أساليب أخرى. منع الانتقال إلى الأعلى يُمكن منع الانتقال الناتج عن تغيير قيمة الخاصية top.location في معالج الحدث beforeunload. حيث ستحدد الصفحة الموجودة في الأعلى (مرفقة بالصفحة التي تعود للمتسلل) معالجًا يمنع الانتقال إليها كالتالي: window.onbeforeunload = function() { return false; }; عندما تحاول النافذة الضمنية تغيير قيمة top.location، فستظهر رسالة للزائر تسأله إن كان يريد مغادرة الصفحة، وفي معظم الحالات سيرفض الزائر ذلك لأنه يجهل تمامًا وجود هذه النافذة، وكل ما يراه هي الصفحة العليا ولا سبب لمغادرتها، لذا لن تتغير عندها قيمة top.location خلال العملية. سنرى في المثال التالي تنفيذ الفكرة عمليًا، حيث يحتوي الملف "iframe.html" الشيفرة التالية: <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <div>Changes top.location to javascript.info</div> <script> top.location = 'https://javascript.info'; </script> </body> </html> ويحتوي الملف "index.html" الشيفرة التالية: <!doctype html> <html> <head> <meta charset="UTF-8"> <style> iframe { width: 400px; height: 100px; position: absolute; top: 0; left: -20px; opacity: 0; z-index: 1; } </style> <script> function attack() { window.onbeforeunload = function() { window.onbeforeunload = null; return "Want to leave without learning all the secrets (he-he)?"; }; document.body.insertAdjacentHTML('beforeend', '<iframe src="iframe.html">'); } </script> </head> <body> <p>After a click on the button the visitor gets a "strange" question about whether they want to leave.</p> <p>Probably they would respond "No", and the iframe protection is hacked.</p> <button onclick="attack()">Add a "protected" iframe</button> </body> </html> وستكون النتيجة كما يلي: الخاصية Sandbox تقيِّد الخاصية sandbox عدة أشياء منها عملية التنقل، حيث لا يمكن للنافذة الضمنية المقيّدة تغيير قيمة top.location، ولذلك يمكننا استخدام هذه الخاصية لتخفيف القيود بالشكل sandbox="allow-scripts allow-forms"، وهكذا سنسمح باستخدام النماذج والسكربتات، لكن في الوقت نفسه لا نسمح بالتنقل عندما لا نضيف الخيار allow-top-navigation، وبالتالي سنمنع تغيير القيمة top.location. والشيفرة المستخدمة في ذلك هي: <iframe sandbox="allow-scripts allow-forms" src="facebook.html"></iframe> ويمكن طبعًا الالتفاف على أسلوب الحماية البسيط هذا. خيارات X-Frame يمكن للترويسة X-Frame-Options المتعلقة بالواجهة الخلفية أن تسمح بعرض صفحة ضمن نافذة ضمنية أو تمنعه، كما ينبغي أن تُرسَل على شكل ترويسة HTTP حصرًا، إذ سيتجاهلها المتصفح إن وجدها ضمن العنصر meta، أي ستكون الشيفرة التالية بلا فائدة: <meta http-equiv="X-Frame-Options"> يمكن أن تأخذ الترويسة إحدى القيم التالية: DENY: لن تظهر الصفحة ضمن نافذة ضمنية أبدًا. SAMEORIGIN: يمكن أن تُعرَض الصفحة ضمن نافذة ضمنية إن كان للصفحة الأم وللنافذة الضمنية الأصل نفسه. ALLOW-FROM domain: يمكن أن تُعرَض الصفحة داخل نافذة ضمنية إن انتمت الصفحة الأم إلى نطاق محدد سلفًا. مثلًا: يستخدم موقع تويتر الخيار X-Frame-Options: SAMEORIGIN بحيث تكون النتيجة نافذةً ضمنيةً فارغةـ وتكون النتيجة: <iframe src="https://twitter.com"></iframe> ستكون النتيجة حسب المتصفح المستخدَم، فقد تكون نافذةً ضمنيةً فارغةً، أو تنبيهًا للمستخدم بأنّ المتصفح لم يسمح للصفحة أن تُعرض بهذه الطريقة. إظهار النافذة بوظيفة معطلة للترويسة X-Frame-Options تأثير جانبي، حيث لن تتمكن الصفحات الأخرى من عرض صفحتنا ضمن نافذة ضمنية حتى لو كان ذلك لسبب وجيه، لذلك سنجد حلولًا أخرى مثل تغطية الصفحة بعنصر <div> له التنسيق: height: 100%; width: 100%;‎، وعندها سيعترض كل نقرات الفأرة، ثم يُزال هذا العنصر عندما يتحقق الشرط window == top، أو عندما لا نجد مبررًا لحماية الصفحة. سيظهر الأمر كالتالي: <style> #protector { height: 100%; width: 100%; position: absolute; left: 0; top: 0; z-index: 99999999; } </style> <div id="protector"> <a href="/" target="_blank">Go to the site</a> </div> <script> // there will be an error if top window is from the different origin // سيحدث خطأ إذا كانت النافذة العليا من مصدر مختلف، لكن هنا لا مشكلة // but that's ok here if (top.document.domain == document.domain) { protector.remove(); } </script> يوضح المثال التالي ما فعلناه، حيث يحتوي الملف "iframe.html" على الشيفرة التالية: <!doctype html> <html> <head> <meta charset="UTF-8"> <style> #protector { height: 100%; width: 100%; position: absolute; left: 0; top: 0; z-index: 99999999; } </style> </head> <body> <div id="protector"> <a href="/" target="_blank">Go to the site</a> </div> <script> if (top.document.domain == document.domain) { protector.remove(); } </script> This text is always visible. But if the page was open inside a document from another domain, the div over it would prevent any actions. <button onclick="alert(1)">Click wouldn't work in that case</button> </body> </html> وسيحتوي الملف "index.html" على هذه الشيفرة: <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <iframe src="iframe.html"></iframe> </body> </html> وستظهر النتيجة كالتالي: الخاصية samesite لملف تعريف الارتباط يمكن للخاصية samesite أن تمنع هجوم الاختطاف بالنقر، حيث لن يُرسَل ملف تعريف الارتباط cookie الذي يحملها إلى أي موقع إلا إذا فُتح مباشرةً، وليس عن طريق نافذة ضمنية أو أي شيء مشابه. يمكن الاطلاع على معلومات أكثر ضمن مقال "ملفات تعريف الارتباط وضبطها في مستندات JavaScript". فلو امتلك موقع مثل فيسبوك الخاصية samesite ضمن ملف تعريف الارتباط على الشكل التالي: Set-Cookie: authorization=secret; samesite فلن يُرسل ملف تعريف الارتباط عندما يُفتح فيسبوك في نافذة ضمنية عائدة لموقع آخر، وبالتالي سيخفق الهجوم. لن يكون للخاصية samesite فائدة إن لم تُستعمل ملفات تعريف الارتباط، وقد يسمح ذلك لمواقع أخرى بعرض صفحاتنا العامة غير المستوثقة في النوافذ الضمنية، مما سيسمح بحدوث هجمات الاختطاف بالنقر لكن في حالات محدودة، إذ ستبقى المواقع التي تدعم عمليات التصويت مثلًا، مجهولة المصدر وتمنع تكرار التصويت لنفس عنوان IP عرضة لهذه الهجمات، لأنها لا تستوثق المستخدمين من خلال ملفات تعريف الارتباط. الخلاصة يمثل الاختطاف بالنقر طريقةً للاحتيال على المستخدمين للنقر على صفحات مستهدفة دون علمهم، وتأتي خطورتها عند وجود أفعال مهمة تُنفَّذ عند النقر، حيث يمكن للمخترق أن ينشر رابطًا ضمن صفحته المشبوهة ضمن رسالة، أو أن يجذب الزوار إلى صفحته بطرق أخرى. لا يعَد الهجوم "عميقًا" من ناحية أولية، فكل ما يفعله المخترق هو اعتراض نقرة الفأرة، لكن من ناحية أخرى إذا علم المخترق بظهور عملية أخرى بعد النقر، فربما يستخدم رسائل ماكرةً تجبر المستخدم على النقر عليها أيضًا، وهنا سيكون الهجوم خطرًا، إذ لا نخطط لاعتراض هذه الأفعال عند تصميم واجهة المستخدم UI، وقد تظهر نقاط الضعف في أماكن لا يمكن توقعها أبدًا. لهذايُنصح باستخدام الأمر X-Frame-Options: SAMEORIGIN ضمن الصفحات أو المواقع التي لا نريد عرضها ضمن نوافذ ضمنية، واستخدام العنصر <div> لتغطية الصفحة عند عرضها ضمن نوافذ ضمنية، ومع ذلك يجب توخي الحذر. ترجمة -وبتصرف- للفصل clickjacking attack من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: التوابع المتعلقة بالنوافذ والنوافذ المنبثقة Popups في جافاسكريبت احم موقعك من الاختراق ممارسات الأمن والحماية في تطبيقات PHP تنظيم شيفرات SQL وتأمينها
  3. تقيِّد سياسة الأصل المشترك same origin أو سياسة الموقع المشترك same site من إمكانية وصول النوافذ أو الإطارات إلى بعضها إن لم تكن ضمن الموقع نفسه، فلو فتح مستخدم صفحتين، بحيث الأولى مصدرها الموقع academy.hsoub.com، والأخرى مصدرها الموقع gmail.com؛ فلا نريد هنا بالطبع لأية شيفرة من الصفحة academy.hsoub.com أن تقرأ بريده الإلكتروني الذي تعرضه الصفحة gmail.com، فالغاية إذًا من سياسة الأصل المشترك هي حماية المستخدم من لصوص المعلومات. الأصل المشترك نقول أنّ لعنواني URL أصلًا مشتركًا إن كان لهما نفس البروتوكول، والنطاق domain، والمنفذ. لذا يمكن القول أنّ للعناوين التالية أصلًا مشتركًا: http://site.com http://site.com/ http://site.com/my/page.html بينما لا تشترك العناوين التالية مع السابقة بالأصل: http://www.site.com (النطاق مختلف لوجود www هنا). http://site.org (النطاق مختلف لوجود org بدلًا من com). https://site.com (البروتوكول مختلف هنا https). http://site.com:8080 (المنفذ مختلف 8080). وتنص سياسة الأصل المشترك على مايلي: إذا وُجِد مرجع إلى نافذة أخرى، مثل: النوافذ المنبثقة المُنشأة باستخدام الأمر window.open، أو الموجودة داخل وسم النافذة الضمنية <iframe>، وكان للنافذتين الأصل نفسه، فسيمنحنا ذلك إمكانية الوصول الكامل إلى تلك النافذة. إذا لم يكن للنافذتين الأصل نفسه، فلا يمكن لإحداهما الوصول إلى محتوى الأخرى، بما في ذلك المتغيرات والمستندات، ويُستثنى من ذلك موضع النافذة location، إذ يمكننا تغييره (وبالتالي إعادة توجيه المستخدم)، دون القدرة على قراءة موضع النافذة (وبالتالي لا يمكننا معرفة مكان المستخدم الآن، وبالتالي لن تتسرب المعلومات). العمل مع النوافذ الضمنية iframe يستضيف الوسم iframe نافذةً ضمنيةً منفصلةً عن النافذة الأصلية، ولها كائنان، هما document وwindow مستقلان، ويمكن الوصول إليهما بالشكل التالي: iframe.contentWindow للحصول على الكائن window الموجودة ضمن الإطار iframe. iframe.contentDocument للحصول على الكائنdocument ضمن الإطار iframe، وهو اختصار للأمر iframe.contentWindow.document. سيتحقق المتصفح أنّ للإطار والنافذة الأصل نفسه عندما نَلِج إلى شيء ما داخل نافذة مضمّنة، وسيرفض الوصول إن لم يتحقق ذلك (تُستنثنى هنا عملية تغيير الخاصية location فلا تزال مسموحة). لنحاول مثلًا القراءة والكتابة إلى الإطار iframe من مورد لا يشترك معه بالأصل: <iframe src="https://example.com" id="iframe"></iframe> <script> iframe.onload = function() { // يمكننا الحصول على مرجع إلى النافذة الداخلية let iframeWindow = iframe.contentWindow; // صحيح try { // ...لكن ليس للمستند الموجود ضمنه let doc = iframe.contentDocument; // خطأ } catch(e) { alert(e); // (خطأ أمان (أصل مختلف } // لايمكننا أيضًا قراءة العنوان ضمن الإطار try { // Location لا يمكن قراءة عنوان الموقع من كائن الموضع let href = iframe.contentWindow.location.href; // خطأ } catch(e) { alert(e); // Security Error } // يمكن الكتابة ضمن خاصية الموضع وبالتالي تحميل شيء آخر ضمن الإطار iframe.contentWindow.location = '/'; // صحيح iframe.onload = null; //امسح معالج الحدث، لا تنفذه بعد تغيير الموضع }; </script> See the Pen JS-P3-01-cross-window-communications-ex1 by Hsoub (@Hsoub) on CodePen. ستعطي الشيفرة السابقة أخطاءً عند تنفيذها عدا حالتي: الحصول على مرجع إلى النافذة الداخلية iframe.contentWindow فهو أمر مسموح. تغيير الموضع location. وبالمقابل، إن كان للإطار iframe نفس أصل النافذة الداخلية، فيمكننا أن نفعل فيها ما نشاء: <!-- iframe from the same site --> <iframe src="/" id="iframe"></iframe> <script> iframe.onload = function() { // افعل ما تشاء iframe.contentDocument.body.prepend("Hello, world!"); }; </script> See the Pen JS-P3-01-cross-window-communications-ex2 by Hsoub (@Hsoub) on CodePen. الموازنة بين الحدثين iframe.onload وiframe.contentWindow.onload يؤدي الحدث iframe.onload على الوسم iframe مبدئيًا، نفس ما يؤديه الحدث iframe.contentWindow.onload على كائن النافذة المضمنة، إذ يقع الحدث عندما تُحمّل النافذة المضمنة بكل مواردها، لكن لا يمكن الوصول إلى الحدث iframe.contentWindow.onload لإطار من آخر ذي أصل مختلف، وبالتالي سنستخدم عندها iframe.onload. النوافذ ضمن النطاقات الفرعية والخاصية document.domain وجدنا من التعريف أنّ العناوين المختلفة تقود إلى موارد غير مشتركة بالأصل، لكن لو تشاركت النوافذ بالنطاق الفرعي أو النطاق من المستوى الثاني -مثل النطاقين الفرعيين التاليين: john.site.com وpeter.site.com (المشتركان بالنطاق الفرعي site.com)-، فيمكن حينها أن نطلب من المتصفح أن يتجاهل الفرق، وبالتالي سيعدهما من أصل مشترك، وذلك لأغراض التخاطب بين النوافذ. ولكي ننفذ ذلك لا بدّ من وجود الشيفرة التالية في كل نافذة: document.domain = 'site.com'; ستتخاطب النوافذ مع بعضها الآن دون معوقات، ولا بد أن نذكر أن هذا ممكن فقط في الصفحات التي تشترك بنطاق من المستوى الثاني. مشكلة كائن "document" خاطئ في النوافذ الضمنية عندما نتعامل مع نافذة ضمنية لها الأصل نفسه، يمكننا الوصول إلى الكائن document الخاص بها، وعندها ستواجهنا مشكلة لا تتعلق بالأصل المشترك -لكن لا بدّ من الإشارة إليها-، وهي أن النافذة الضمنية سيكون لها كائن document مباشرةً بعد إنشائها، لكنه سيختلف عن الكائن الذي سيُحمَّل ضمنها، وبالتالي قد يضيع الأمر الذي ننفِّذه على الكائن document مباشرةً. لنتأمل الشيفرة التالية: <iframe src="/" id="iframe"></iframe> <script> let oldDoc = iframe.contentDocument; iframe.onload = function() { let newDoc = iframe.contentDocument; // كائن المستند الذي حُمِّل مختلف عن الكائن الأساسي alert(oldDoc == newDoc); // false النتيجة }; </script> See the Pen JS-P3-01-cross-window-communications-ex3 by Hsoub (@Hsoub) on CodePen. لا ينبغي التعامل مع الكائن document لأي إطار قبل التحميل الكامل لمحتوياته لتجنب التعامل مع الكائن الخاطئ، إذ سيتجاهل المتصفح أية معالجات أحداث قد نستخدمها ضمن الكائن. لكن كيف نحدد اللحظة التي يجهز فيها الكائن document؟ سيظهر المستند بالتأكيد عند وقوع الحدث iframe.onload، والذي لا يطلق إلا عندما تُحمَّل موارد الإطار كلها. كما يمكن التقاط اللحظة المناسبة بالتحقق المستمر خلال فترات زمنية محددة عبر التعليمة setInterval: <iframe src="/" id="iframe"></iframe> <script> let oldDoc = iframe.contentDocument; // تحقق أن المستند الجديد جاهز كل 100 ميلي ثانية let timer = setInterval(() => { let newDoc = iframe.contentDocument; if (newDoc == oldDoc) return; alert("New document is here!"); clearInterval(timer); // فلن تحتاجها الآن setInterval ألغ }, 100); </script> See the Pen JS-P3-01-cross-window-communications-ex4 by Hsoub (@Hsoub) on CodePen. استخدام المجموعة window.frames يمكن استخدام طريقة بديلة للوصول إلى الكائن window الخاص بالنافذة الضمنية iframe عبر المجموعة window.frames: باستخدام الرقم: إذ سيعطي الأمر [window.frames[0 كائن window الخاص بأول إطار في المستند. باستخدام الاسم: إذ يعطي الأمر window.frames.iframeName كائن window الخاص بالإطار الذي يحمل الاسم "name="iframeName. مثلًا: <iframe src="/" style="height:80px" name="win" id="iframe"></iframe> <script> alert(iframe.contentWindow == frames[0]); // true alert(iframe.contentWindow == frames.win); // true </script> See the Pen JS-P3-01-cross-window-communications-ex5 by Hsoub (@Hsoub) on CodePen. قد تحوي نافذة ضمنية ما نوافذ ضمنيةً أخرى، وعندها ستكون الكائنات window الناتجة عن الانتقال الشجري بين الإطارات هي: window.frames: وتمثل مجموعة النوافذ الأبناء للإطارات المتداخلة nested. window.parent: المرجع إلى النافذة الأم المباشرة (الخارجية). window.top: المرجع إلى النافذة الأم الأعلى ترتيبًا. مثلًا: window.frames[0].parent === window; // محقق يمكن استخدام الخاصية top للتحقق من أنّ المستند الحالي مفتوح ضمن إطار أم لا: if (window == top) { // current window == window.top? alert('The script is in the topmost window, not in a frame'); } else { alert('The script runs in a frame!'); } See the Pen JS-P3-01-cross-window-communications-ex6 by Hsoub (@Hsoub) on CodePen. الخاصية sandbox المتعلقة بالنافذة الضمنية iframe تسمح الخاصية sandbox باستبعاد تنفيذ أفعال معينة داخل النافذة الضمنية <iframe>، وذلك لمنع تنفيذ الشيفرة غير الموثوقة، إذ تقيَّد الخاصية sandbox للإطار كونه آتيًا من أصل مختلف مع/أو بالإضافة إلى قيود أخرى. تتوفر عدة تقييدات افتراضية ضمن الأمر <"..."=iframe sandbox src>، ويفضل أن نترك فراغًا بين عناصر قائمة القيود التي لا نريد تطبيقها كالتالي: <iframe sandbox="allow-forms allow-popups"> ستطبّق أكثر التقييدات صرامةً عندما تكون الخاصية sandbox فارغة "دون قائمة قيود" ، ويمكننا رفع ما نشاء منها بذكرها ضمن قائمة يفصل بين عناصرها فراغات. وإليك قائمة الخيارات المتعلقة بالتقييدات: allow-same-origin: تجبر الخاصية sandbox المتصفح على احتساب النافذة الضمنية من أصل مختلف تلقائيًا، حتى لو دلت الصفة src على الأصل نفسه، لكن هذا الخيار يزيل التطبيق الافتراضي لهذه الميزة. allow-top-navigation: يسمح هذا الخيار للنافذة الضمنية بتغيير قيمة parent.location. allow-forms: يسمح هذا الخيار بإرسال نماذج forms انطلاقًا من الإطار. allow-scripts: يسمح هذا الخيار بتشغيل سكربتات انطلاقًا من الإطار. allow-popups: يسمح بفتح نوافذ منبثقة باستخدام الأمر window.open انطلاقًا من الإطار. اطلع على تعليمات التنفيذ إن أردت التعرف على المزيد. يُظهر المثال التالي إطارًا مقيَّدًا بمجموعة القيود الافتراضية <"..."=iframe sandbox src>، ويتضمن المثال شيفرةً ونموذجًا وملفين، وستلاحظ أنّ الشيفرة لن تعمل نظرًا لصرامة القيود الافتراضية. الشيفرة الموجودة في الملف "index.html": <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <div>The iframe below has the <code>sandbox</code> attribute.</div> <iframe sandbox src="sandboxed.html" style="height:60px;width:90%"></iframe> </body> </html> وفي الملف "sandbox.html": <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <button onclick="alert(123)">Click to run a script (doesn't work)</button> <form action="http://google.com"> <input type="text"> <input type="submit" value="Submit (doesn't work)"> </form> </body> </html> وستكون النتيجة: ملاحظة: إنّ الغاية من وجود الصفة sandbox هي إضافة المزيد من القيود فقط، إذ لا يمكنها إزالة هذه القيود، لكنها تخفف بعض القيود المتعلقة بالأصل المشترك إن لم يكن للإطار الأصل نفسه. تبادل الرسائل بين النوافذ تسمح واجهة التخاطب postMessage للنوافذ بالتخاطب مع بعضها أيًا كان أصلها، فهي إذًا أسلوب للالتفاف على سياسة "الأصل المشترك"، إذ يمكن لنافذة من النطاق academy.hsoub.comأن تتخاطب مع النافذةgmail.com` وتتبادل المعلومات معها، لكن بعد موافقة كلتا النافذتين ووجود دوال JavaScript الملائمة فيهما، وهذا بالطبع أكثر أمانًا للمستخدم. تتكون الواجهة من قسمين رئيسيين: القسم المتعلق بالتابع postMessage تستدعي النافذة التي تريد إرسال رسالةٍ التابع postMessage العائد للنافذة الهدف، أي إن أردنا إرسال رسالة إلى win، فعلينا تنفيذ الأمر (win.postMessage(data, targetOrigin، وإليك توضيح وسطاء هذا التابع: data: وهي البيانات المراد إرسالها، والتي قد تكون أي كائن. حيث تُنسخ البيانات باستخدام خوارزمية التفكيك البنيوي "structured serialization". لا يدعم المتصفح Internet Explorer سوى النصوص، لذلك لا بدّ من استخدام التابع JSON.stringify مع الكائنات المركبة التي سنرسلها لدعم هذا المتصفح. targetOrigin: يُحدد أصل النافذة الهدف، وبالتالي ستصل الرسالة إلى نافذة من أصل محدد فقط، ويعَدّ هذا مقياسًا للأمان، فعندما تأتي النافذة الهدف من أصل مختلف، فلا يمكن للنافذة المرسِلة قراءة موقعها location، ولن نعرف بالتأكيد الموقع الذي سيُفتح حاليًا في النافذة المحددة، وقد ينجرف المستخدم بعيدًا دون علم النافذة المرسِلة. إذًا سيضمن تحديد الوسيط targetOrigin أن النافذة الهدف ستستقبل البيانات ما دامت في الموقع الصحيح، وهذا ضروري عندما تكون البيانات على درجة من الحساسية. في هذا المثال ستستقبل النافذة الرسالة إن كان مستندها (الكائن document الخاص بها) منتميًا إلى الموقع "http://example.com": <iframe src="http://example.com" name="example"> <script> let win = window.frames.example; win.postMessage("message", "http://example.com"); </script> يمكن إلغاء التحقق من الأصل بإسناد القيمة "*" إلى الوسيط targetOrigin: <iframe src="http://example.com" name="example"> <script> let win = window.frames.example; win.postMessage("message", "*"); </script> القسم المتعلق بالحدث onmessage ينبغي أن تمتلك النافذة التي ستستقبل الرسالة، معالجًا للحدث message، والذي يقع عند استدعاء التابع postMessage (ويجري التحقق بنجاح من قيمة الوسيط targetOrigin). لكائن الحدث خصائص مميزة، هي: data: وهي البيانات التي يحضرها التابع postMessage. origin: أصل المرسل، مثلًا: http://javascript.info. source: المرجع إلى النافذة المرسِلة، إذ يمكننا الرد مباشرة بالشكل التالي (...)source.postMessage إن أردنا. ولتحديد معالج الحدث السابق لا بدّ من استخدام addEventListener، إذ لن يعمل الأمر دونه، فمثلًا: window.addEventListener("message", function(event) { if (event.origin != 'http://javascript.info') { // كائن من نطاق مختلف، سنتجاهله return; } alert( "received: " + event.data ); // يمكن إعادة الإرسال باستخدام event.source.postMessage(...) }); يتكون المثال من الملف "iframe.html": <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> Receiving iframe. <script> window.addEventListener('message', function(event) { alert(`Received ${event.data} from ${event.origin}`); }); </script> </body> </html> والملف "index.html": <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <form id="form"> <input type="text" placeholder="Enter message" name="message"> <input type="submit" value="Click to send"> </form> <iframe src="iframe.html" id="iframe" style="display:block;height:60px"></iframe> <script> form.onsubmit = function() { iframe.contentWindow.postMessage(this.message.value, '*'); return false; }; </script> </body> </html> وستكون النتيجة كالتالي: الخلاصة لاستدعاء التوابع التي تسمح بالوصول إلى نوافذ أخرى، لا بدّ من وجود مرجع إليها، حيث نحصل على هذا المرجع بالنسبة للنوافذ المنبثقة باستخدام: window.open: يُنفَّذ ضمن النافذة الأساسية، وسيفتح نافذة جديدة ويعيد مرجعًا إليها. window.opener: يُنفّذ ضمن النافذة المنبثقة، ويعطي مرجعًا إلى النافذة التي أنشأتها. يمكن الوصول إلى النوافذ الآباء والأبناء ضمن النوافذ الضمنية <iframe> بالشكل التالي: window.frames: تمثل مجموعة من الكائنات window المتداخلة. window.parent وwindow.top: يمثلان مرجعين إلى النافذة الأم المباشرة والنافذة الأم العليا. iframe.contentWindow: تمثل النافذة الموجودة داخل وسم النافذة الضمنية iframe. إذا كان لنافذتين الأصل نفسه (البرتوكول والمنفذ والنطاق)، فيمكنهما التحكم ببعضهما كليًا أما إن لم يكن لهما أصل مشترك، فيمكن إجراء مايلي: تغيير موقع location نافذة أخرى (سماحية للكتابة فقط). إرسال رسالة إليها. لكن توجد بعض الاستثناءات: إذا كان لنافذتين النطاق الفرعي (نطاق من المستوى الثاني) ذاته، مثلًا: a.site.com وb.site.com، فيمكن أن ندفع المتصفح لأن يعدّهما من الأصل ذاته بإضافة الأمر 'document.domain='site.com' في كلتيهما. إن امتلك الإطار الخاصية sandbox، فسيُعَدّ قسرًا من أصل مختلف، إلا في الحالة التي نستخدم فيها القيمة allow-same-origin لهذه الخاصية، ويستخدَم ذلك لتشغيل شيفرة غير موثوقة ضمن نافذة ضمنية على الموقع نفسه. تسمح الواجهة postMessage لنافذتين -ليس لهما بالضرورة الأصل ذاته- بالتخاطب مع بعضهما، حيث: تستدعي النافذة المرسِلة التابع (targetWin.postMessage(data, targetOrigin إن لم يكن للوسيط targetOrigin القيمة "*"، فسيتحقق المتصفح من أن للنافذة الهدف نفس الأصل المحدد كقيمة له. إن كان للنافذتين نفس الأصل أو لم يأخذ الوسيط targetOrigin القيمة "*"، فسيقع الحدث message الذي يمتلك الخصائص التالية: origin: نفس أصل النافذة المرسِلة مثل "http://my.site.com". source: وهو المرجع إلى النافذة المرسِلة. data: البيانات المرسَلة، ويمكن أن تكون كائنًا من أي نوع، عدا في المتصفح Internet Explorer الذي لا يقبل سوى الكائنات النصية. ولا بدّ لنا أيضًا من استخدام addEventListener لضبط معالج هذا الحدث داخل النافذة الهدف. ترجمة -وبتصرف- للفصل cross-window communication من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا الأحداث المتعلقة بدورة حياة صفحة HTML وكيفية التحكم بها عبر جافاسكربت أساسيات بناء تطبيقات الويب 10 أخطاء شائعة عند استخدام إطار العمل Bootstrap
  4. تستخدم الأساليب القديمة في JavaScript النوافذ المنبثقة لإظهار صفحات أو مستندات أخرى للمستخدم، إذ يمكن فتح نافذة جديدة تعرض المورد المرتبط بالعنوان المحدَّد بسهولة عن طريق تنفيذ الأمر التالي: window.open('https://javascript.info/') تعرض معظم المتصفحات الحديثة محتويات الموارد الموجودة على عنوان محدد في نوافذ متجاورة، بدلًا من فتح نافذة جديدة لكل منها. إنّ فكرة النوافذ المنبثقة قديمة، وقد استخدمت لعرض محتوىً مختلف للمستخدم دون إغلاق النافذة الرئيسية، لكن حاليًا تستخدَم أساليب أخرى، حيث يمكن تحميل المحتوى ديناميكيًا من خلال التابع fetch ثم إظهاره ضمن معرَّف <div> يُولَّد ديناميكيًا، لذا لم يعد استخدام النوافذ المنبثقة شائعًا. كما أن استخدامها في الأجهزة النقالة التي لا تعرض عدة نوافذ معًا مربك أحيانًا. مع ذلك يبقى للنوافذ المنبثقة استخداماتها في إنجاز بعض المهام كالاستيثاق "OAuth"، المستخدم لتسجيل الدخول على Google أو Facebook وغيرها، لأنّ: النوافذ المنبثقة هي نوافذ منفصلة لها بيئة JavaScript مستقلة، وبالتالي سيكون فتح نافذة منبثقة مصدرها طرف ثالث أو موقع غير موثوق آمنًا. من السهل جدًا فتح نافذة منبثقة. يمكن للنوافذ المنبثقة الانتقال إلى عنوان آخر، وإرسال رسالة إلى النافذة الأصلية. منع ظهور النوافذ المنبثقة أساءت المواقع المشبوهة استخدام النوافذ المنبثقة سابقًا، حيث أمكنها فتح آلاف النوافذ المنبثقة التي تحمل إعلانات أو منشورات دعائية، لذلك تحاول معظم المتصفحات الحديثة حاليًا منع ظهور هذه النوافذ لحماية المستخدم. تحجب معظم المتصفحات النوافذ المنبثقة إذا استُدعيت خارج شيفرة معالجات الأحداث event handler التي تستجيب لأفعال المستخدِم مثل onClick: // تُحجب النافذة المنبثقة window.open('https://javascript.info'); // يُسمح بظهور النافذة المنبثقة button.onclick = () => { window.open('https://javascript.info'); }; وهكذا ستتمكن المتصفحات من حماية المستخدم نوعًا ما، لكن هذا الأسلوب لن يعطل وظيفة النوافذ المنبثقة كاملةً. لكن ماذا لو فتحنا نافذة منبثقة ضمن شيفرة onClcik، لكن بعد تحديد زمن ظهورها باستخدام التعليمة setTimeOut؟ سيبدو الأمر مربكًا لاختلاف السلوك على متصفحات مختلفة. جرّب الشيفرة التالية: // ستظهر النافذة لثلاث ثوان setTimeout(() => window.open('http://google.com'), 3000); See the Pen JS-P3-01-Popups-and-window-methods-ex1 by Hsoub (@Hsoub) on CodePen. تظهر النافذة المنبثقة على Chrome وسيحجبها Firefox، لكن لو قللنا زمن ظهورها فستظهر أيضًا على Firefox: // ستظهر النافذة لثانية setTimeout(() => window.open('http://google.com'), 1000); إن سبب الاختلاف السابق هو أنّ Firefox سيقبل أية قيمة لزمن الظهور التي تساوي ثانيتين أو أقل، لكنه سيرفع ثقته عن النوافذ المنبثقة معتبرًا أنها ظهرت رغم إرادة المستخدم إن زاد زمن الظهور، لذلك فقد حُجبت في الحالة الأولى وظهرت في الثانية. التابع window.open تُستخدم العبارة البرمجية التالية لفتح نافذة منبثقة: window.open(url, name, params) حيث: url: عنوان المورد الذي سيُحمّل ضمن الصفحة. name: اسم النافذة التي ستُفتح، فلكل نافذة اسم window.name، وبذلك يمكننا تحديد النافذة التي سنستخدمها لإظهار المحتويات المنبثقة، فإذا وجدت نافذة بهذا الاسم فستُحمّل محتويات المورد ضمنها، وإلا ستُفتح نافذة جديدة بالاسم المحدد. params: سلسلة نصية تمثل تعليمات تهيئة النافذة الجديدة، وتحتوي على إعدادات تفصل بينها فاصلة، ولا ينبغي وجود فراغات بين الإعدادات، مثل السلسلة width=200,height=100، والمعاملات params التي يمكن ضبطها هي: الموضع position: left/top: قيمة عددية تمثل إحداثيات الزاوية العليا اليسرى للنافذة على شاشة العرض، لكن لا يمكن وضع النافذة خارج شاشة العرض. width/height: تحدد عرض النافذة الجديدة وارتفاعها، وهناك طبعًا حد أدنى لقيمة كل منهما، فلا يمكن إنشاء نافذة غير مرئية. ميزات النافذة Window features: menubar: تأخذ إحدى القيمتين yes/no، ومهمتها إظهار قائمة المتصفح في النافذة الجديدة أو إخفاؤها. toolbar: تأخذ إحدى القيمتين yes/no، ومهمتها إظهار شريط التنقل navigation bar في النافذة الجديدة أو إخفاؤه. location: تأخذ إحدى القيمتين yes/no، ومهمتها إظهار شريط العنوان في النافذة الجديدة أو إخفاؤه، ويجب الانتباه إلى أن المتصفحين Firefox وInternet Explorer لا يسمحان بذلك افتراضيًا. status: تأخذ إحدى القيمتين yes/no، ومهمتها إظهار شريط الحالة status bar أو إخفاؤه، وتفرض معظم المتصفحات إظهاره في النوافذ الجديدة. resizable: تأخذ إحدى القيمتين yes/no، تمنع تغيير حجم النافذة الجديدة، ولا يُنصح بتمكين هذه الميزة. scrollbars: تأخذ إحدى القيمتين yes/no، تمنع ظهور أشرطة التمرير في النافذة الجديدة، ولا يُنصح بتمكين هذه الميزة. توجد أيضًا مجموعة من الميزات الأقل دعمًا كونها متعلقة بمتصفحات محددة ولا تستخدم عادةً، ويمكن الاطلاع عليها عن طريق البحث عن التابع window.open ضمن شبكة مطوري Mozilla مثال نموذجي لنافذة بأقل ميزات لنفتح نافذةً بأقل مجموعة ممكنة من الميزات، وذلك لمعرفة تلك التي يسمح المتصفح بتعطيلها: let params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no, width=0,height=0,left=-1000,top=-1000`; open('/', 'test', params); See the Pen JS-P3-01-Popups-and-window-methods-ex2 by Hsoub (@Hsoub) on CodePen. عطلنا في الشيفرة السابقة معظم ميزات النافذة ووضعناها خارج مجال عرض الشاشة، إذا نفّذنا الشيفرة السابقة وشاهدنا ما يحدث، فسنجد أن معظم المتصفحات ستعالج تلقائيًا القيم الخاطئة -كإعطاء الطول أو العرض القيمة 0 أو القيمة التي تجعل من الزاوية العليا اليسرى للنافذة خارج مجال العرض- فمثلًا: سيفتح Chrome هذه النوافذ بأعلى قيم ممكنة للطول والعرض لتملأ الشاشة. لنضع الآن النافذة في مكان مناسب ونعطي قيمًا مقبولة للإحداثيات width وheight وleft وtop: let params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no, width=600,height=300,left=100,top=100`; open('/', 'test', params); See the Pen JS-P3-01-Popups-and-window-methods-ex3 by Hsoub (@Hsoub) on CodePen. ستُظهر معظم المتصفحات النافذة بالشكل المطلوب. إليك مجموعة من القواعد عند تجاهل بعض الإعدادات: إذا لم تضع قيمة للوسيط الثالث params عند استدعاء التابع open، فستُستخدم المعاملات الافتراضية للنافذة. إذا وضعت السلسلة النصية التي تصف المعاملات params، لكنك أغفلت بعض الميزات التي تأخذ إحدى القيمتين "yes/no"، فستأخذ هذه الميزات القيمة "no"، وبالتالي إن أردت معاملات محددة، فانتبه إلى التصريح علنًا عن الميزات المطلوبة وإسناد القيمة "yes" لها. إذا لم تحدَّد الخاصية left/top في المعاملات params، فسيحاول المتصفح فتح النافذة الجديدة بجانب آخر نافذة قد فتحتها. إن لم تحدّد الخاصية width/height في المعاملات params، فسيكون حجم النافذة الجديدة مساويًا لحجم آخر نافذة فتحتها. الوصول إلى النافذة المنبثقة من النافذة الأصل يعيد استدعاء التابع open مرجعًا إلى النافذة الجديدة، يمكن استخدامه للتحكم بخصائص تلك النافذة، وتغيير موضعها وأشياء أخرى. سنوّلد في هذا المثال نافذةً منبثقةً ذات محتوىً معين باستخدام JavaScript: let newWin = window.open("about:blank", "hello", "width=200,height=200"); newWin.document.write("Hello, world!"); سنغيّر الآن محتوى النافذة بعد انتهاء تحميله: let newWindow = open('/', 'example', 'width=300,height=300') newWindow.focus(); alert(newWindow.location.href); // (*) about:blank, لم يبدأ التحميل بعد newWindow.onload = function() { let html = `<div style="font-size:30px">Welcome!</div>`; newWindow.document.body.insertAdjacentHTML('afterbegin', html); }; See the Pen JS-P3-01-Popups-and-window-methods-ex4 by Hsoub (@Hsoub) on CodePen. لن يكون محتوى النافذة الجديدة قد حُمِّل بعد استدعاء window.open مباشرةً، وقد وضحنا ذلك باستخدام التنبيه alert في السطر "(*)"، لذا عليك الانتظار حتى يعدل التابع المطلوب، كما يمكننا استخدام معالج الحدث DOMContentLoaded مع الخاصية newWin.document. الوصول إلى نافذة من نافذة منبثقة يمكن أن تصل النافذة المنبثقة إلى النافذة التي أنتجتها باستخدام المرجع الذي يعيده التابع window.opener، والذي يحمل القيمة "null" لجميع النوافذ عدا النوافذ المنبثقة. لاستبدال محتوى النافذة التي أنتجت النافذة المنبثقة بالكلمة "Test" نستخدم الشيفرة التالية : let newWin = window.open("about:blank", "hello", "width=200,height=200"); newWin.document.write( "<script>window.opener.document.body.innerHTML = 'Test'<\/script>" ); See the Pen JS-P3-01-Popups-and-window-methods-ex5 by Hsoub (@Hsoub) on CodePen. إذًا فالعلاقة بين النوافذ الأصلية والمنبثقة ثنائية الاتجاه، لأن كلّا منهما تحمل مرجعًا إلى الأخرى. إغلاق نافذة منبثقة نفذ الأمر win.close لإغلاق نافذة. نفذ الأمر win.closed للتحقق من إغلاق النافذة. يمكن عمليًا لأي نافذة استخدام التابع ()close، لكن معظم المتصفحات ستتجاهله عندما لا تُنشأ النافذة عن طريق التابع ()window.open، وبالتالي سيعمل على النوافذ المنبثقة فقط، وستكون قيمة الخاصية closed هي "true" إن أُغلقت النافذة، ولهذه الخاصية فائدتها في التحقق من إغلاق النوافذ والنوافذ المنبثقة، لذلك ينبغي على الشيفرة أن تأخذ في الحسبان احتمال إغلاق المستخدم للنافذة في أي لحظة. ستحمِّل الشيفرة التالية نافذة ثم ستغلقها: let newWindow = open('/', 'example', 'width=300,height=300'); newWindow.onload = function() { newWindow.close(); alert(newWindow.closed); // true }; See the Pen JS-P3-01-Popups-and-window-methods-ex6 by Hsoub (@Hsoub) on CodePen. تحريك النافذة وتغيير حجمها توجد عدة خيارات لتحريك نافذة أو تغيير حجمها، هي: (win.moveBy(x,y: يحرّك النافذة "x" بيكسل نحو اليمين و"y" بيكسل نحو الأسفل انطلاقًا من الموقع الحالي، ويمكن استخدام قيمة سالبة للتحرك نحو الأعلى واليسار. (win.moveTo(x,y: لنقل النافذة إلى الموقع المحدد بالإحداثيتين (x,y) ضمن الشاشة. (win.resizeBy(width,heigh: تغيير حجم النافذة بتغيير قيمتي الارتفاع والعرض بالنسبة إلى القيمتين الحاليتين، ويُسمح باستخدام قيم سالبة. (win.resizeTo(width,height: تغيير حجم النافذة إلى الأبعاد المحددة. يمكن الاستفادة أيضًا من الحدث window.onresize عندما يتغير حجم النافذة. تمرير محتويات نافذة Scrolling a window تحدثنا سابقًا عن الموضوع في فصل "أحجام النوافذ وقابلية التمرير"، والتوابع التي تنفذ المهمة هي: (win.scrollBy(x,y: يمرر محتويات النافذة "x" بكسل إلى اليمين و"y" بكسل إلى الأسفل، ويسمح التابع باستخدام القيم السالبة. (win.scrollTo(x,y: يمرر محتويات النافذة إلى الموقع الذي إحداثياته (x,y). (elem.scrollIntoView(top = true: يمرر محتويات النافذة ليظهر العنصر elem في أعلى الصفحة، وهي الحالة الافتراضية، أو أسفل الصفحة عندما تكون top=false. كما يمكن الاستفادة من الحدث window.onscroll. نقل تركيز الدخل إلى نافذة وتغشيتها focus/blur يمكن نظريًا استخدام التابعين()window.blur و()window.focus للتركيز على نافذة أو رفع التركيز عنها، كما يمكن استخدام الحدث focus/blur الذي يسمح بالتقاط اللحظة التي ينقل فيها المستخدم التركيز إلى نافذة محددة أو ينتقل إلى أخرى. لكن لو انتقلنا إلى التطبيق العملي فسنجد محدوديةً كبيرةً في تطبيقهما، لأنّ الصفحات المشبوهة أساءت استخدامهما. لنتأمل الشيفرة التالية مثلًا: window.onblur = () => window.focus(); فعندما يحاول المستخدم تبديل النافذة والانتقال إلى أخرى (window.onblur)، ستعيد الشيفرة السابقة التركيز إلى نفس النافذة. لذلك وضعت المتصفحات قيودًا لمنع استخدام الشيفرة بالطريقة السابقة، ولحماية المستخدم من النوافذ الدعائية والصفحات المشبوهة. مثلًا: يتجاهل متصفح الهواتف المحمولة التابع ()window.focus بشكل كامل، كما لا يمكن التركيز على نافذة منبثقة ستظهر في صفحة جديدة ضمن النافذة نفسها بدلًا من ظهورها في نافذة جديدة. ومع ذلك يمكننا الاستفادة من التابعين السابقين في بعض الحالات، فمثلًا: من الجيد تنفيذ الأمر ()newWindow.focusعند فتح نافذة منبثقة جديدة، لضمان وصول المستخدم إلى النافذة الجديدة في بعض المتصفحات العاملة على بعض أنظمة التشغيل. يمكن تعقب تنفيذ الحدثين window.onfocus/onblur إذا أردنا التأكد من أنّ زائري الموقع يستخدمون تطبيقنا أم لا، مما سيسمح بإيقاف أو إعادة استخدام بعض محتويات التطبيق كالرسوم المتحركة وغيرها، مع وجوب الانتباه إلى أنّ الحدث blur سيشير إلى مغادرة الزائر للنافذة المحددة، ومع إمكانية ملاحظة الزائر للنافذة إذا بقيت في خلفية الشاشة. خلاصة لا تستخدم النوافذ المنبثقة إلا نادرًا، لوجود بدائل عنها، مثل: تحميل وإظهار المعلومات ضمن الصفحة أو استخدام إطارات iframe. ويعتبر إبلاغ المستخدم بفتح نافذة منبثقة أسلوبًا جيدًا، إذ يسمح وجود أيقونة "فتح نافذة" بجوار أي رابط أو زر أن يتوقع المستخدم انتقال التركيز من النافذة التي يعمل عليها إلى أخرى، وبالتالي سينتبه إلى كلتيهما. يمكن فتح نافذة منبثقة باستدعاء التابع ( open(url, name, params الذي يعيد مرجعًا إلى النافذة الجديدة. تمنع المتصفحات استدعاء التابع من خلال شيفرة خارج نطاق أفعال المستخدم (شيفرة الاستجابة لأحداث)، كما تُظهر المتصفحات عادة تنبيهًا للمستخدم بهذا الأمر، ويكون الخيار له بالسماح بتنفيذ الشيفرة أم لا. تفتح المتصفحات صفحات جديدة ضمن النافذة نفسها بشكل افتراضي، لكن عندما يُحدد حجم للنافذة ستظهر على شكل نافذة منبثقة. يمكن للنافذة الجديدة الوصول إلى الأصلية باستخدام الخاصية window.opener. يمكن للنافذتين الجديدة والأصلية قراءة وتعديل محتويات الأخرى إذا كان لهما الأصل ذاته، ويمكنهما فقط تغيير موقع بعضهما وتبادل الرسائل إذا لم يكن لهما الأصل نفسه . يستخدم التابع ()close لإغلاق نافذة منبثقة، كما يمكن إغلاقها بالطريقة التقليدية، وفي كلا الحالتين ستكون قيمة الخاصية window.closed هي "true". يستخدم التابعان ()foucs و()blur لإعطاء التركيز إلى نافذة او تحويله عنها، لكنهما لا يعملان كما هو مطلوب دائمًا. يستخدم الحدثان focus وblur لتتبع عدد مرات دخول النافذة أو الخروج منها، مع الانتباه إلى أنّ أي نافذة يمكن أن تبقى مرئية في الخلفية بعد تنفيذ الأمر blur. ترجمة -وبتصرف- للفصل popups and window methods من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال التالي: التخاطب بين النوافذ في جافاسكريبت الأحداث المتعلقة بدورة حياة صفحة HTML وكيفية التحكم بها عبر جافاسكربت الدوال العليا في جافاسكريبت
  5. علينا أولًا وقبل أن ندخل في موضوع استخدام TypeScript مع React تحديد ما يلزمنا وما الذي نهدف لتحقيقه. فعندما يعمل كل شيء كما ينبغي، ستساعدنا TS في التقاط الأخطاء التالية: محاولة تمرير خاصيات Props زائدة أو غير مطلوبة لمكوِّن نسيان تمرير خاصية يحتاجها المكوَّن تمرير خاصية من النوع الخاطئ إلى مكوِّن ستساعدنا TS على التقاط أيًا من الأخطاء السابقة ضمن المحرر مباشرة. وفي حال لم نستخدم TS، سنضطر إلى التقاط هذه الأخطاء لاحقًا عن طريق الاختبارات، وربما سنقضي وقتًا مرهقًا في إيجاد مسبباتها. هذه الأسباب كافية حتى الآن لنبدأ إذًا! إنشاء تطبيق React باستخدام TypeScript يمكننا استخدام create-react-app لإنشاء تطبيق React باستخدام TS بإضافة الوسيط template إلى سكربت التهيئة الأولية. لذا نفّذ الأمر التالي لإنشاء تطبيق create-react-app باستخدام TS: npx create-react-app my-app --template typescript ينبغي بعد تنفيذ الأمر، أن تحصل على تطبيق React مكتمل يستخدم TS. يمكنك أن تشغل التطبيق باستخدام الأمر npm start في جذر المشروع. لو ألقينا نظرة على الملفات والمجلدات، ستجد أن التطبيق لا يختلف كثيرًا عن التطبيقات التي تستخدم JavaScript صرفة. إذ تقتصر الاختلافات على تحول الملفات التي تحمل إحدى اللاحقتين js. و jsx. إلى ملفات باللاحقتين ts.وtsx. وستحتوي هذه الملفات على مسجلات للأنواع، كما سيحتوي المجلد الجذري على الملف tsconfig.json. لنلق نظرة الآن على الملف tsconfig.json الذي أُنشئ نيابة عنا. ينبغي أن تكون قواعد التهيئة ضمنه مناسبة إلى حد ما، إلا أنّ هذه القواعد ستسمح بتصريف ملفات JavaScript، لأن القاعدة allowJs تأخذ القيمة "true". لا بأس بذلك إن كنت ستمزج بين TS و JavaScript (في الحالة التي تعمل فيها مثلًا على تحويل شيفرة JS إلى TS أو ما شابه)، لكننا نريد هنا إنشاء تطبيق TS صرف، لذا سنغير قيمة القاعدة allowJs إلى false. استخدمنا في المشروع السابق المدقق لمساعدتنا في فرض أسلوب برمجة معين، سنفعل ذلك أيضًا في هذا التطبيق. لا حاجة لتثبيت أية اعتماديات لأن create-react-app قد اهتم بكل الترتيبات. يحمل الملف ذو اللاحقة "eslintrc." قواعد eslint التالية: { "env": { "browser": true, "es6": true, "jest": true }, "extends": [ "eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended" ], "plugins": ["react", "@typescript-eslint"], "settings": { "react": { "pragma": "React", "version": "detect" } }, "rules": { "@typescript-eslint/explicit-function-return-type": 0 } } طالما أنّ النوع الذي تعيده جميع مكوّنات React هي عناصر JSX أو القيمة null، سنغير قواعد المدقق قليلًا بإلغاء تفعيل القاعدة explicit-function-return-type. وهكذا لن نحتاج إلى التصريح عن نوع القيمة التي تعيدها الدالة في كل مكان. علينا أيضًا أن نجعل المدقق قادرًا على فهم ملفات "tsx."، وهي المقابل في TS لملفات "jsx." في React. سننفذ ذلك بتغيير السكربت lint في الملف package.json على النحو التالي: { // ... "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "lint": "eslint './src/**/*.{ts,tsx}'" }, // ... } قد تحتاج إلى استخدام علامة التنصيص المزدوجة عند كتابة مسار المدقق: {lint": "eslint './src/**/*.{ts,tsx} وذلك إن كنت تعمل على نظام Windows. لو نفذنا الآن الأمر npm run lint سنحصل على رسالة خطأ من المدقق eslint مجددًا: لماذا يحدث ذلك؟ تخبرنا رسالة الخطأ أنّ الملف serviceWorker.ts لا يتقيد بقواعد المدقق. والسبب في ذلك، أنّ الدالة register ستستخدم دوال أخرى عُرِّفت لاحقًا في الملف نفسه وهذا لا يتوافق مع القاعدةtypescript-eslint/no-use-before-define@ . ولإصلاح المشكلة لا بدّ من نقل الدالة register إلى آخر الملف. لا ينبغي أن تظهر أية أخطاء بعد الآن. لا يشكل الخطأ السابق عائقًا في واقع الأمر، لأننا لن نحتاج إلى الملف serviceWorker.ts. لذا من الأفضل حذفه. مكونات React مع TypeScript لنتأمل المثال التالي لتطبيق React كُتب باستخدام JavaScript: import React from "react"; import ReactDOM from 'react-dom'; import PropTypes from "prop-types"; const Welcome = props => { return <h1>Hello, {props.name}</h1>; }; Welcome.propTypes = { name: PropTypes.string }; const element = <Welcome name="Sara" />; ReactDOM.render(element, document.getElementById("root")); لدينا في هذا المثال المكوًن Welcome الذي نمرر إليه اسمًا كخاصية ليقوم بتصييره على الشاشة. ينبغي أن يكون الاسم من النوع string وقد استخدمنا الحزمة prop-types التي تعرفنا عليها في القسم 5، لكي نحصل على تلميحات حول الأنواع المطلوبة لخصائص المكوّنات وتحذيرات عند استخدام خصائص من النوع الخاطئ. لن نحتاج إلى الحزمة prop-types أبدًا عند استخدام TS. إذ يمكننا تعريف الأنواع بمساعدة TS، وذلك باستخدام واجهة النوع "FunctionComponent" أو باستخدام اسمها المستعار FC. عندما نستخدم TS مع المكوّنات، ستبدو مسجلات الأنواع مختلفة قليلًا عن شيفرات TS الأخرى. حيث نضيف النوع إلى المتغيًر الذي يُسند إليه المكوّن بدلًا من الدالة وخصائصها. يدعى النوع الناتج عن "FunctionComponent" بالنوع المُعمَّم generic. بحيث يمكن أن نمرر لهذه الواجهة نوعًا كمعامل، ثم تستخدمه على أنه النوع الخاص بها. تبدو تصريحات React.FC وReact.FunctionComponent على النحو التالي: type FC<P = {}> = FunctionComponent<P>; interface FunctionComponent<P = {}> { (props: PropsWithChildren<P>, context?: any): ReactElement | null; propTypes?: WeakValidationMap<P>; contextTypes?: ValidationMap<any>; defaultProps?: Partial<P>; displayName?: string; } سترى أولًا أن FC ببساطة هو اسم مستعار لواجهة النوع FunctionComponent، وكلاهما من النوع المعمم الذي يمكن تمييزه بسهولة من قوسي الزاوية "<>" بعد اسم النوع. سترى داخل قوسي الزاوية هذه الشيفرة {}=P. وتعني أنه بالإمكان تمرير نوع كمعامل. سيُعرَف النوع الذي سيُمرَّر بالاسم P، وهو افتراضيًا كائن فارغ {}. لنلق نظرة على السطر الأول من شيفرة FunctionComponent: (props: PropsWithChildren<P>, context?: any): ReactElement | null; تحمل الخصائص النوع PropsWithChildren، وهو أيضًا نوع معمم يُمرَّر إليه النوع P. يمثل النوع "PropsWithChildren" بدوره تقاطعًا intersection بين النوعين بالشكل التالي: type PropsWithChildren<P> = P | { children?: ReactNode }; كل ما نحتاج إليه الآن من هذا الشرح الذي قد يبدو معقدًا بأنه من الممكن تعريف النوع وتمريره إلى واجهة النوع FunctionComponent، حيث ستحمل خصائصها بعد ذلك النوع الذي عرًفناه بالإضافة إلى المكوّنات الأبناء. لنعد إلى مثالنا ونحاول أن نعرًف نوعًا لخصائص المكوًن Welcome باستخدام TS: interface WelcomeProps { name: string; } const Welcome: React.FC<WelcomeProps> = (props) => { return <h1>Hello, {props.name}</h1>; }; const element = <Welcome name="Sara" />; ReactDOM.render(element, document.getElementById("root")); عرًفنا في الشيفرة السابقة واجهة النوع WelcomeProps ومررناها إلى المكوّن Welcome عندما صرّحنا عن نوعه: const Welcome: React.FC<WelcomeProps>; يمكننا كتابة الشيفرة بأسلوب أقصر: const Welcome: React.FC<{ name: string }> = ({ name }) => ( <h1>Hello, {name}</h1> ); سيعرف المحرر الآن أنّ الخاصية من النوع string. لكن المدقق لن يقتنع تمامًا بما فعلنا، وسيعترض بأن الخاصية name غير موجودة عند تقييم الخصائص. ويحدث هذا لأنّ قاعدة المدقق ستتوقع منا أن نعرِّف أنواعًا لجميع الخصائص. فلن يدرك المدقق أننا نستخدم TS في تعريف الأنواع لخصائصنا. لإصلاح الخلل، لا بدّ من إضافة قاعدة تدقيق جديدة إلى الملف "eslintrc.": { // ... "rules": { "react/prop-types": 0, }, // ... } التمرين 9.14 9.14 أنشئ تطبيق create-react-app مستخدمًا TS وهيئ المدقق لمشروعك بالطريقة التي تعلمناها. يشبه هذا التمرين تمرينًا نفَّذناه في القسم 1 من المنهاج لكن باستخدام TS هذه المرة بالإضافة إلى بعض التعديلات. إبدأ بتعديل محتويات الملف "index.tsx" لتصبح على النحو التالي: import React from "react"; import ReactDOM from "react-dom"; const App: React.FC = () => { const courseName = "Half Stack application development"; const courseParts = [ { name: "Fundamentals", exerciseCount: 10 }, { name: "Using props to pass data", exerciseCount: 7 }, { name: "Deeper type usage", exerciseCount: 14 } ]; return ( <div> <h1>{courseName}</h1> <p> {courseParts[0].name} {courseParts[0].exerciseCount} </p> <p> {courseParts[1].name} {courseParts[1].exerciseCount} </p> <p> {courseParts[2].name} {courseParts[2].exerciseCount} </p> <p> Number of exercises{" "} {courseParts.reduce((carry, part) => carry + part.exerciseCount, 0)} </p> </div> ); }; ReactDOM.render(<App />, document.getElementById("root")); واحذف الملفات غير الضرورية. إنّ التطبيق ككل موجود في مكوّن واحد، وهذا ما لا نريده. أعد كتابة الشيفرة لتتوزع على ثلاثة مكوّنات: Header وContent وTotal. ابق على جميع البيانات ضمن المكوّن App الذي سيمرر كل البيانات اللازمة لمكوّن كخصائص. واحرص على تعريف نوع لكل خاصية من خصائص المكوُنات. سيتحمل المكوًن Header مسؤولية تصيير اسم المنهاج، وسيصيّر المكوّن Content أسماء الأقسام المختلفة وعدد التمارين في كل قسم، أما المكوّن Total فسيصيّر مجموع التمارين في كل الأقسام. سيبدو المكوًن App بالشكل التالي تقريبًا: const App = () => { // const-declarations return ( <div> <Header name={courseName} /> <Content ... /> <Total ... /> </div> ) }; استخدام أدق للأنواع يتضمن المثال السابق منهاجًا من ثلاثة أقسام، ويمتلك كل قسم نفس الصفتين name وexcerciseCount. لكن ماذا لو احتجنا إلى صفات أخرى، وتطلّب كل قسم صفات مختلفة عن الآخر؟ كيف ستبدو شيفرة التطبيق؟ لنتأمل المثال التالي: const courseParts = [ { name: "Fundamentals", exerciseCount: 10, description: "This is an awesome course part" }, { name: "Using props to pass data", exerciseCount: 7, groupProjectCount: 3 }, { name: "Deeper type usage", exerciseCount: 14, description: "Confusing description", exerciseSubmissionLink: "https://fake-exercise-submit.made-up-url.dev" } ]; أضفنا في الشيفرة السابقة صفات أخرى إلى كل قسم. يمتلك الآن كل قسم الصفتين name وexerciseCount، ويمتلك القسمان الأول والثالث الصفة description، كما يمتلك القسمان الثاني والثالث بعض الصفات الإضافية الخاصة. لنتخيل أنّ تطبيقنا سيستمر في النمو، وأننا سنضطر إلى تمرير الأقسام المختلفة ضمن الشيفرة. وقد تّضاف أيضًا خصائص أخرى أو أقسام أخرى. كيف سنضمن أن الشيفرة قادرة على التعامل مع كل الأنواع المختلفة للبيانات بشكل صحيح، وأننا لن ننس تصيير أحد أقسام المنهاج على صفحة ما؟ هنا ستظهر فائدة اللغة TS! لنبدأ بتعريف أنواع لأقسام المنهاج المختلفة: interface CoursePartOne { name: "Fundamentals"; exerciseCount: number; description: string; } interface CoursePartTwo { name: "Using props to pass data"; exerciseCount: number; groupProjectCount: number; } interface CoursePartThree { name: "Deeper type usage"; exerciseCount: number; description: string; exerciseSubmissionLink: string; } ستُنشئ الشيفرة التالية نوعًا موّحَدًا من كل الأنواع. وبالتالي يمكننا استخدامه من أجل مصفوفتنا التي يُفترض بها أن تقبل أي نوع من الأنواع التي تحملها أقسام المنهاج: type CoursePart = CoursePartOne | CoursePartTwo | CoursePartThree; يمكننا الأن تحديد نوع المتغيّر Coursepart، وسيحذرنا المحرر تلقائيًا إن استخدمنا النوع الخاطئ لإحدى الصفات، أو استخدمنا صفة زائدة، أو نسينا أن نحدد صفة متوقعة. اختبر ذلك بوضع علامة تعليق قبل أي صفة لأي قسم. وبفضل القيمة النصية الحرفية التي تحملها السمة name، يمكن أن تحدد TS أي قسم سيحتاج إلى أية صفات إضافية، حتى لو عرفنا المتغير على أنه من النوع الموّحد. لم نصل درجة القناعة بتطبيقنا بعد، فلا زال هناك تكرار كثير للأنواع، ونريد أن نتجنب ذلك. لهذا سنبدأ بتعريف الصفات المشتركة بين جميع الأقسام، ثم سنعرِّف نوعًا أساسيًا (Base Type) يحتويها. سنوسع بعد ذلك النوع الأساسي لإنشاء الأنواع الخاصة بكل قسم: interface CoursePartBase { name: string; exerciseCount: number; } interface CoursePartOne extends CoursePartBase { name: "Fundamentals"; description: string; } interface CoursePartTwo extends CoursePartBase { name: "Using props to pass data"; groupProjectCount: number; } interface CoursePartThree extends CoursePartBase { name: "Deeper type usage"; description: string; exerciseSubmissionLink: string; } كيف سنستخدم هذه الأنواع الآن في مكوًناتنا؟ من الطرق المفيدة في استخدام هذه الأنواع هي في عبارة switch case. فعندما تصرُح علنًا عن نوع المتغير أنه من النوع الموّحد أو استدلت TS على ذلك واحتوى كل نوع موجود ضمن النوع الموًحد صفة معينة، يمكن استخدام ذلك كمعرّفات للأنواع. يمكن بعد ذلك استخدام البنية switch case للتنقل بين الصفات وستحدد TS الصفات الموجودة في كل حالة من حالات البنية switch. ستميّز TS في المثال السابق أن المتغير coursePart من النوع CoursePart. ويمكنها عندها أن تستدل أن المتغير part من أحد الأنواع التالية CoursePartOne أو CoursePartTwo أو CoursePartThree. أما الصفة name فهي مختلفة ومميزة لكل نوع، لذلك من الممكن استخدامها لتمييز الأنواع وستكون TS قادرة على تحديد الصفات الموجودة في كل حالة case من حالات البنية switch case. وهكذا ستعطي خطأً إن حاولت على سبيل المثال أن تستخدم الصفة part.descriptionضمن كتلة الحالة Using props to pass data. كيف سنضيف نوعًا جديدًا؟ من الجيد أن نعرف إن كنا قد أنجزنا مسبقًا آليةً للتعامل مع هذا النوع في شيفرتنا، عند إضافة قسم جديد للمنهاج. فإضافة أي نوع جديد في المثال السابق ستجعله موجودًا في الكتلة default من البنية switch case وبالتالي لن يطبع شيء عن هذا النوع. وقد يكون هذا الأمر مقبولًا في بعض الحالات، كالحالة التي نريد فيها التعامل مع نوع واحد محدد من النوع الموّحد، لكن في أغلب الحالات عليك التعامل مع كل الحالات وبشكل منفصل. يمكن في TS أن نستخدم طريقة تدعى "التحقق الشامل من الأنواع". ومبدأ هذه الطريقة: أنه في حال واجهتنا قيمة غير معروفة النوع، نستدعي دالة تقبل قيمة من النوع never وتعيد قيمة من النوع نفسه. تمثل الشيفرة التالية تطبيقًا مباشرًا لهذا المبدأ: /** * Helper function for exhaustive type checking */ const assertNever = (value: never): never => { throw new Error( `Unhandled discriminated union member: ${JSON.stringify(value)}` ); }; لكن لو أردنا استبدال محتويات الكتلة كالتالي: default: return assertNever(part); ووضع علامة تعليق قبل الكتلة Deeper type usage case block سنرى الخطأ التالي: تنص الرسالة أن معاملًا من النوع CoursePartThree لم يُسند إلى معامل من النوع never. أي أننا نستخدم متغيرًا في مكان ما يفترض به أن يكون من النوع never. وهذا ما يدلنا على وجود مشكلة. لكن بمجرد أن نزيل علامة التعليق التي وضعناها على الكتلة Deeper type usage case block سيختفي الخطأ. التمرين 9.15 9.15 أضف في البداية النوع information إلى الملف index.tsx واستبدل المتغير courseParts بالمتغير الموجود في المثال التالي: // new types interface CoursePartBase { name: string; exerciseCount: number; } interface CoursePartOne extends CoursePartBase { name: "Fundamentals"; description: string; } interface CoursePartTwo extends CoursePartBase { name: "Using props to pass data"; groupProjectCount: number; } interface CoursePartThree extends CoursePartBase { name: "Deeper type usage"; description: string; exerciseSubmissionLink: string; } type CoursePart = CoursePartOne | CoursePartTwo | CoursePartThree; // this is the new coursePart variable const courseParts: CoursePart[] = [ { name: "Fundamentals", exerciseCount: 10, description: "This is an awesome course part" }, { name: "Using props to pass data", exerciseCount: 7, groupProjectCount: 3 }, { name: "Deeper type usage", exerciseCount: 14, description: "Confusing description", exerciseSubmissionLink: "https://fake-exercise-submit.made-up-url.dev" } ]; نعلم الآن أن واجهتي النوع CoursePartThree وCoursePartOne يتقاسمان صفة تدعى description بالإضافة إلى الصفات الأساسية (الموجودة في واجهة النوع الأساسية)، وهذه الصفة من النوع string في كلتا الواجهتين. تقتضي مهمتك الأولى أن تصرِّح عن واجهة نوع جديد تتضمن الصفة description وتوسِّع واجهة النوع CoursePartThree. عدّل الشيفرة بعد ذلك بحيث تصبح قادرًا على إزالة الصفة description من الواجهتين دون حدوث أية أخطاء. أنشئ بعد ذلك المكوّن Part الذي يصيّر كل الصفات من كل نوع ضمن أقسام المنهاج. استخدم آلية تحقق شاملة من الأنواع معتمدًا على بنية switch case. استخدم المكوِّن الجديد ضمن المكوّن Content. أضف في النهاية واجهة نوع لقسم جديد يحوي على الأقل الصفات التالية: name وexerciseCount وdescription. ثم أضف واجهة النوع هذه إلى النوع الموّحد CoursePart وأضف البيانات المتعلقة بالقسم إلى المتغير CourseParts. إن لم تكن قد عدّلت المكوّن Content بشكل صحيح، ستحصل على رسالة خطأ، لأنك لم تضف ما يدعم النوع الخاص بالقسم الرابع. أجر التعديلات المناسبة على المكوّن Content لكي تُصيّر كل صفات القسم الجديد دون أخطاء. ملاحظة حول تعريف أنواع للكائنات لقد استخدمنا واجهات النوع لتعريف أنواع للكائنات مثل DiaryEntry من الفقرة السابقة: interface DiaryEntry { id: number; date: string; weather: Weather; visibility: Visibility; comment?: string; } وCoursePart من هذه الفقرة. interface CoursePartBase { name: string; exerciseCount: number; } لقد كان بمقدورنا تنفيذ ذلك باستخدام نوع بديل type alias. type DiaryEntry = { id: number; date: string; weather: Weather; visibility: Visibility; comment?: string; } يمكنك استخدام الأسلوبين Interface وType لإنشاء نوع في معظم الحالات. لكن هنالك بعض النقاط التي ينبغي الانتباه لها. فلو عرَّفت عدة واجهات نوع لها نفس الاسم ستحصل على واجهة نوع مختلطة، بينما لو عرّفت عدة أنواع لها الاسم ذاته ستحصل على رسالة خطأ مفادها أنّ نوعًا يحمل نفس الاسم قد جرى التصريح عنه مسبقًا. ينصحك توثيق TS باستخدام الواجهات في معظم الحالات. العمل مع شيفرة جاهزة من الأفضل عندما تبدأ العمل على شيفرة جاهزة للمرة الأولى أن تلقي نظرة شاملة على هيكلية المشروع وأسلوب العمل. يمكنك البدء بقراءة الملف README.md الموجود في جذر المستودع. يحتوي هذا الملف عادة على وصف موجز للتطبيق ومتطلباته، وكيف سنجعله يعمل لبدء عملية التطوير. إن لم يكن الملف README متاحًا أو أنّ أحدهم فضّل اختصار الوقت ووضعه كمرجع احتياطي، سيكون من الجيد الاطلاع على الملف package.json. ومن الجيد دائمًا تشغيل التطبيق وتجريبه والتحقق من وظائفه. يمكنك أيضًا تصفح هيكيلة مجلد المشروع لتطلع على وظائفه أو/والمعمارية المستخدمة. لكن هذا الأسلوب لن يفيدك دائمًا، فلربما اختار المطوّر أسلوبًا لم تعهده. ركّزنا في تنظيم المشروع التدريبي الذي سنستخدمه في ما تبقى من هذا القسم على الميزات التي يقدمها. حيث يمكنك الاطلاع على الصفحات التي يعرضها التطبيق، وبعض المكوّنات العامة كالوحدات وحالة التطبيق، وتذكر أن للميزات مجالات مختلفة. فالوحدات هي مكونات مرئية على مستوى واجهة المستخدم بينما تحافظ حالة التطبيق على جميع البيانات تحت الستار لكي تستخدمها بقية المكوّنات. تزوّدك TS بالأنواع التي تخبرك عن شكل بنى البيانات والدوال والمكوّنات والحالة التي ستحصل عليها. يمكنك إلقاء نظرة على الملف types.ts أو أي ملف مشابه كبداية. كما سيساعدك VSCode كثيرًا فمجرد توضيحه للمتغيرات والمعاملات سيمنحك رؤية أفضل للشيفرة. ويعتمد هذا كله بالطبع على طريقة استخدام الأنواع في هذا المشروع. إن احتوى المشروع اختبارات أجزاء أو اختبارت تكامل فسيكون الاطلاع عليها مفيدًا بالتأكيد. فالاختبارات أداة مفيدة جدًا عندما تعيد كتابة الشيفرة أو عند كتابة ميزات جديدة للتطبيق. وتأكد من عدم تخريب أية ميزة موجودة عندما تبدأ بالتلاعب بالشيفرة. سترشدك TS أيضًا بما يتعلق بالمعاملات والقيم المعادة عندما تتغير الشيفرة. إن قراءة الشيفرة مهارة بحد ذاتها. لا تقلق إن لم تستطع فهم الشيفرة في البداية، فقد تحتوي الشيفرات على بعض الحالات غير الواضحة أو قد تحتوي أجزاءً أضيفت هنا وهناك خلال عملية التطوير. فلا يمكننا تصور المشاكل التي عانى منها المطوّر السابق لهذه الشيفرة. ويتطلب فهم تفاصيل الشيفرة بشكل كامل الغوص إلى أعماقها وفهم متطلبات المجال الذي تعمل عليه. فكلما قرأت شيفرات أكثر ستصبح أفضل في فهمها واستخدامها. إقرأ شيفرات أكثر مما تكتبه. الواجهة الأمامية لتطبيق إدارة المرضى لقد حان الوقت لإكمال الواجهة الأمامية للواجهة الخلفية التي بنيناها في التمارين السابقة وقبل أن نتعمق في كتابة الشيفرة، سنشغل الواجهتين معًا. إن سار كل شيء على ما يرام، سترى صفحة ويب تضم قائمة بالمرضى. تحضر الصفحة قائمة المرضى من الواجهة الخلفية وتصيّرها على الشاشة ضمن جدول بسيط. ستجد أيضًا زرًا لإضافة مريض جديد إلى الواجهة الخلفية. وطالما أننا نستخدم بيانات وهمية بدلًا من قواعد البيانات، فلن تخزّن البيانات التي أضفناها عند إغلاق الواجهة الخلفية. وطبعًا تقييم تصميم واجهة المستخدم UI ليس في صالح المصمم، لذلك سنهمل موضوع واجهة المستخدم حاليًا. بعد التأكد من كل الأمور، يمكننا الشروع في دراسة الشيفرة. سنجد معظم النقاط المهمة في المجلد /‎src. ولمساعدتك هنالك أيضًا ملف جاهز يصف الأنواع الرئيسية التي يستخدمها التطبيق، والتي علينا ان نوسِّعها أو نعيد كتابتها خلال التمارين. يمكننا من حيث المبدأ استخدام الأنواع نفسها في الواجهتين الأمامية والخلفية، لكن الواجهة الأمامية ستحتوي على بنى بيانات مختلفة وستستخدمها في حالات مختلفة، مما يجعل الأنواع ضمنها مختلفة. فللواجهة الأمامية على سبيل المثال حالة للتطبيق، ولربما أردت تخزين البيانات ضمن كائنات، بينما تستخدم الواجهة الخلفية مصفوفة. وقد لا تحتاج الواجهة الأمامية إلى كل حقول كائن البيانات المخزّن في الواجهة الخلفية، كما قد تضيف حقولًا جديدة لاستخدامها في التصيير. ستبدو هيكلية المجلد على النحو التالي تحتوي الواجهة الخلفية حاليًا مكوّنين هما: AddPatientModal وPatientListPage. يحتوي المجلد state على الشيفرة التي تتعامل مع حالة الواجهة الأمامية. وتقتصر وظيفة الشيفرة في هذا المجلد على إبقاء البيانات في مكان واحد وتزويد الواجهة بأفعال بسيطة لتبديل حالة التطبيق. التعامل مع حالة التطبيق لندرس الطرق المتبعة في التعامل مع حالة التطبيق. إذ يبدو أنّ الكثير من الأشياء تجري خلف الستار، وهي مختلفة قليلًا عن الطرق التي استخدمت سابقًا في المنهاج. بُني أسلوب إدارة الحالة باستخدام الخطافين useContext وuseReducer للمكتبة React. وهذا قرار صائب لأنّ التطبيق صغير نوعًا ما ولن نحتاج إلى Redux أو إلى أية مكتبات أخرى لإدارة الحالة. ستجد على الانترنت مواد تعليمة مفيدة عن استخدام هذه المقاربة. تستخدم هذه المقاربة أيضًا الواجهة البرمجية context العائدة للمكتبة React، إذ ينص توثيق الواجهة أنها: فالبيانات المشتركة في حالتنا هي حالة التطبيق ودالة الإيفاد التي تستخدم لإجراء التعديلات على البيانات. ستعمل شيفرتنا بأسلوب يشابه كثيرًا أسلوب Redux في إدارة الحالة والذي خبرناه في القسم 6، لكنها شيفرة أسرع لأنها لا تحتاج أية مكتبات خارجية. يفترض هذا القسم أنك مطلع على طريقة عمل Redux على الأقل، وبعبارة أخرى من المفترض أنك اطلعت على الفصل الأول من القسم 6. تتضمن الواجهة البرمجية context قناة تضم حالة التطبيق ودالة إيفاد لتغيير الحالة. وقد حُدِّد نوع للحالة على النحو التالي: export type State = { patients: { [id: string]: Patient }; }; فالحالة كما تُظهر الشيفرة هي كائن بمفتاح واحد يدعى Patients يمتلك قاموسًا، أو بعبارة أخرى، يقبل كائنًا له مفاتيح من النوع "string" مع كائن Patient كقيمة له. يمكن لقرينة الفهرسة أن تكون من أحد النوعين "string" أو"number" إذ يمكننا الوصول إلى قيم الكائن باستخدام هذين النوعين. وبهذا نجبر الحالة أن تتقيد بالصيغة التي نريد، وتمنع المطورين من استخدامها بشكل غير صحيح. لكن انتبه إلى نقطة مهمة! عندما يُصرّح عن نوع بالطريقة التي اتبعناها مع patients، فلن تمتلك TS أية طريقة لمعرفة إن كان المفتاح الذي تحاول الوصول إليه موجودًا أم لا. فلو حاولنا الوصول إلى بيانات مريض بمعرِّف غير موجود، سيعتقد المصرّف أن القيمة المعادة من النوع Patient، ولن تلق أية أخطاء عند محاولة الوصول إلى خصائص هذا الكائن: const myPatient = state.patients['non-existing-id']; console.log(myPatient.name); // لا أخطاء إذ يعتقد المصرّف أن القيمة المعادة //patient من النوع لإصلاح الخلل، يمكننا أن نعرّف نوع القيم التي تحمل بيانات المريض على أنها من نوعٍ موحّد بين Patient وundefined كالتالي: export type State = { patients: { [id: string]: Patient | undefined }; }; وجراء هذا الحل، سيحذرنا المصرّف بالرسالة التالية: const myPatient = state.patients['non-existing-id']; console.log(myPatient.name); // error, Object is possibly 'undefined' من الجيد دائمًا إنشاء هذا النوع مع بعض الإضافات الأمنية عندما تستخدم مثلًا بيانات من مصادر خارجية أو قيمة أدخلها المستخدم للوصول إلى بيانات ضمن شيفرتك. لكن إن كنت متأكدًا من أن شيفرتك قادرة على التعامل مع البيانات غير الموجودة، فلا مانع أبدًا من استخدام أول حلٍ قدمناه. وعلى الرغم من عدم استخدام هذا الأسلوب في هذا القسم، ينبغي الإشارة إلى أن استخدام كائنات Map سيؤمن لك طريقة أكثر تشددًا في استخدام الأنواع. حيث يمكنك أن تُصرح عن نوع لكلٍ من المفتاح ومحتواه. تعيد دالة الوصول ()getإلى كائنات Map نوعًا موحّدًا يجمع بين النوع المصرّح عنه وundefined، وبالتالي ستطلب TS تلقائيُا إجراء تحقق من البيانات المستخلصة من كائن Map: interface State { patients: Map<string, Patient>; } ... const myPatient = state.patients.get('non-existing-id'); // type for myPatient is now Patient | undefined console.log(myPatient.name); // error, Object is possibly 'undefined' console.log(myPatient?.name); // valid code, but will log 'undefined' تُنفذ التعديلات على الحالة باستخدام دوال الاختزال، تمامًا كما في Redux. عُرِّفت هذه الدوال في الملف reducer.ts بالإضافة إلى النوع Action الذي يبدو على النحو التالي: export type Action = | { type: "SET_PATIENT_LIST"; payload: Patient[]; } | { type: "ADD_PATIENT"; payload: Patient; }; تبدو دالة الاختزال مشابه تمامًا للدوال التي أنشأناها في القسم 6. حيث تغيّر حالة كل نوع من الأفعال: export const reducer = (state: State, action: Action): State => { switch (action.type) { case "SET_PATIENT_LIST": return { ...state, patients: { ...action.payload.reduce( (memo, patient) => ({ ...memo, [patient.id]: patient }), {} ), ...state.patients } }; case "ADD_PATIENT": return { ...state, patients: { ...state.patients, [action.payload.id]: action.payload } }; default: return state; } }; ينحصر الفرق في أنّ الحالة الآن على شكل قاموس (أو كائن) بدلًا من المصفوفة التي استخدمناها في القسم 6. تجري الكثير من الأمور في الملف state.ts والتي تهيئ سياق العمل. ويعتبر الخطاف useReducer الذي يستخدم لإنشاء الحالة، ودالة الإيفاد المكونان الرئيسيان لإنجاز التغييرات على حالة التطبيق، حيث يمرران إلى التابع context.povider: export const StateProvider: React.FC<StateProviderProps> = ({ reducer, children }: StateProviderProps) => { const [state, dispatch] = useReducer(reducer, initialState); return ( <StateContext.Provider value={[state, dispatch]}> {children} </StateContext.Provider> ); }; يستخدم التابع السابق في تزويد كل المكوّنات بالحالة ودالة الإيفاد، وذلك بفضل الإعدادات الموجودة في الملف index.ts: import { reducer, StateProvider } from "./state"; ReactDOM.render( <StateProvider reducer={reducer}> <App /> </StateProvider>, document.getElementById('root') ); كما يُعرّف أيضًا الخطاف useStateValue: export const useStateValue = () => useContext(StateContext); وتستعمله أيضًا المكوّنات التي تحتاج إلى الحالة أو دالة الإيفاد لتخزينهما: import { useStateValue } from "../state"; // ... const PatientListPage: React.FC = () => { const [{ patients }, dispatch] = useStateValue(); // ... } لا تقلق إن بدا الأمر مربكًا قليلًا، فبالطبع سيبقى كذلك حتى تدرس توثيق context وطريقة استخدامها في إدارة الحالة. لكن ليس عليك فهم كل ذلك بشكل كامل حتى تحل التمارين. من الشائع جدًا أن لا تفهم بشكل كامل ما تفعله الشيفرة خلف الستار، عندما تبدأ العمل على مشروع جاهز. فإن كان المشروع مبني بهيكلية جيدة يمكنك أن تثق أن أية تعديلات مدروسة جيدًا لن تؤثر على عمل التطبيق، على الرغم من عدم إدراكك لكامل آليات العمل التي تجري داخله. مع الوقت ستفهم الأجزاء التي لم تكن مألوفة بالنسبة لك، لكن ذلك لن يحدث بين يوم وليلة وخاصة عندما تعمل على مشروع ذو شيفرة ضخمة. صفحة قائمة المرضى لنتوجه إلى الملف index.ts الموجود في المجلد PateintListPage لنأخذ بعض الأفكار التي تساعدنا على إحضار البيانات من الواجهة الخلفية وتحديث حالة التطبيق. تستخدم الصفحة خطافًا أنشئ خصيصًا لوضع البيانات في الحالة ودالة الإيفاد لتحديث محتوياتها. فعندما ننشئ قائمة بالمرضى لا نحتاج سوى تفكيك خصائص الكائن patients الموجود في حالة التطبيق: import { useStateValue } from "../state"; const PatientListPage: React.FC = () => { const [{ patients }, dispatch] = useStateValue(); // ... } ونستخدم أيضًا الحالة app التي أنشئت باستخدام الخطاف useState لإدارة حالات إظهار الوحدة أو إخفائها، وكذلك معالجة الأخطاء: const [modalOpen, setModalOpen] = React.useState<boolean>(false); const [error, setError] = React.useState<string | undefined>(); نمرر للخطاف useState نوعًا كمعامل، ثم يُطبَّق على الحالة الفعلية. فالمعامل modalOpen من النوع Boolean والمعامل error من النوع string | undefined. وكلا دالتي الضبط اللتان يعيدهما الخطاف useState سيقبلان معاملات من نوع يماثل النوع الذي يمرر إليها من خلال معامل (يمتلك نوعًا). فالنوع الفعلي للدالة setModalOpen هو ما يلي: <<React.Dispatch<React.SetStateAction<boolean. كما نستخدم الدالتين المساعدتين closeModal وopenModal لقراءة البيانات بشكل أفضل وأكثر ملاءمة: const openModal = (): void => setModalOpen(true); const closeModal = (): void => { setModalOpen(false); setError(undefined); }; تتعلق الأنواع في الواجهة الأمامية بما قمت بتطويره بإنشائه عند تطوير الواجهة الخلفية في الفصل السابق. سيحضر المكوّن App عندما يُثبَّت المرضى مستخدمًا المكتبة axios. ويجدر الانتباه إلى أننا مررنا نوعًا على شكل معامل إلى الدالة axios.get لكي نحدد نوع البيانات التي سنحصل عليها من الاستجابة: React.useEffect(() => { axios.get<void>(`${apiBaseUrl}/ping`); const fetchPatientList = async () => { try { const { data: patients } = await axios.get<Patient[]>( `${apiBaseUrl}/patients` ); dispatch({ type: "SET_PATIENT_LIST", payload: patients }); } catch (e) { console.error(e); } }; fetchPatientList(); }, [dispatch]); تحذير! لن تُقيَّم البيانات عندما نمرر النوع كمعامل إلى المكتبة axios. وهو خطر تمامًا وخاصة إن كنت تستخدم واجهة برمجية خارجية. لتفادي ذلك يمكنك إنشاء دوال تقييم مخصصة تتحمل عبء التقييم وتعيد النوع الصحيح، أو يمكنك استخدام "حاميات النوع". ستجد أيضًا عدة مكتبات تزوّدك بأساليب لتقييم البيانات ضمن أنواع مختلفة من التخطيطات، مثل المكتبة io-ts. لكننا وتوخيًا للبساطة سنثق أننا سنحصل على البيانات بشكلها الصحيح من الواجهة الخلفية. وطالما أن تطبيقنا صغير، سنحدِّث الحالة باستدعاء دالة الإيفاد التي يؤمنها لنا الخطاف useStateValue. وسيساعدنا المصرّف بالتأكد من أننا أوفدنا الأفعال بما يوافق النوع Action الذي يضم قيمة محددة مسبقًا من النوع string وحمولة من البيانات payload. dispatch({ type: "SET_PATIENT_LIST", payload: patients }); التمارين 9.16 - 9.18 سنضيف قريبًا النوع Entry إلى التطبيق والذي يمثّل مُدخلًا على شكل ملخّص بسيط عن المريض. يتكون الملخص من نص وصفي، وتاريخ الإنشاء، ومعلومات تتعلق بالاختصاصي الذي أنشأ الملخص ورمز التشخيص المحتمل. ترتبط شيفرة التشخيص بالرموز ICD-10 التي تُعيدها وصلة التخديم api/diagnosis/. سيكون ما ننفذه بسيطًا، إذ يعطي لكل مريض مصفوفة من المُدخلات. سنُجري بعض التحضيرات، قبل الشروع في العمل. 9.16 تطبيق إدارة المرضى: الخطوة 1 أنشئ وصلة تخديم على العنوان api/patients/:id/ تعيد كل المعلومات عن مريض، بما فيها مصفوفة المُدخلات الخاصة به والتي ستبقى فارغة لجميع المرضى. وسّع حاليًا الأنواع في الواجهة الخلفية على النحو التالي: // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface Entry { } export interface Patient { id: string; name: string; ssn: string; occupation: string; gender: Gender; dateOfBirth: string; entries: Entry[]} export type PublicPatient = Omit<Patient, 'ssn' | 'entries' > ينبغي أن تبدو الاستجابة كالتالي: 9.17 تطبيق إدارة المرضى: الخطوة 2. أنشئ صفحة لإظهار المعلومات الكاملة عن مريض ضمن الواجهة الأمامية. ينبغي أن يكون المستخدم قادرًا على الوصول إلى معلومات المريض بالنقر على اسم المريض مثلًا. أحضر البيانات من وصلة التخديم التي أنشأتها في التمرين السابق. أضف المعلومات التي ستحضرها عن المريض إلى حالة التطبيق. لا تحضر المعلومات إن كانت موجودة مسبقًا في حالة التطبيق، أي في الحالة التي يعرض فيها المستخدم معلومات مريض أكثر من مرة. طالما أننا سنستخدم حالة التطبيق ضمن سياق العمل، عليك أن تُعرّف فعلًا جديدًا لتحديث بيانات مريض محدد. يستخدم التطبيق المكتبة Semantic UI React لإضافة التنسيقات إليه. وهي مكتبة مشابهة كثيرًا للمكتبتين React Bootstrap وMaterialUI اللتان تعاملنا معهما في القسم 7. يمكنك استخدامهما لتنسيق المكوّن الجديد، وهذا أمر يعود إليك، فاهتمامنا ينصب الآن على TS. ويستخدم التطبيق أيضًا المكتبة react router للتحكم بإظهار واجهات العرض على الشاشة. عليك إلقاء نظرة على القسم 7 إن لم تكن متمكنًا من فهم آلية عمل الموجّهات routers. ستبدو النتيجة كالتالي: يظهر جنس المريض باستخدام المكون Icon من المكتبة Semantic UI React. ملاحظة: لتصل إلى المُعرِّف بكتابة عنوان المورد، عليك إعطاء الخطاف useParams معامل من نوع مناسب: const { id } = useParams<{ id: string }>(); 9.18 تطبيق إدارة المرضى: الخطوة 3. سننشئ حاليًا كائنات أفعال action في أي مكان نوفد إليه هذه الأفعال. إذ يمتلك المكوّن App مثلًا دالة الإيفاد التالية: dispatch({ type: "SET_PATIENT_LIST", payload: patientListFromApi }); أعد كتابة الشيفرة مستخدمًا دوال توليد الأفعال المُعرّفة ضمن الملف "reducer.tsx". سيتغير المكوِّن App مثلًا على النحو التالي: import { useStateValue, setPatientList } from "./state"; // ... dispatch(setPatientList(patientListFromApi)); مدخلات كاملة أنجزنا في التمرين 9.12 وصلة تخديم لإحضار تشخيص مريض، لكننا لم نستخدمها بعد. من الجيد توسيع بياناتنا قليلُا بعد أن أصبح لدينا صفحة لعرض معلومات المرضى. لنضف إذًا حقلًا من النوع Entry إلى بيانات المريض، تتضمن مدخلاته الدوائية مع التشخيص المحتمل. لنفصل بنية بيانات المرضى السابقة عن الواجهة الخلفية ونستخدم الشكل الجديد الموسَّع. ملاحظة: إن تنسيق البيانات هذه المرة بالصيغة "ts." وليس بالصيغة ".json". كما أن النوعين Gender وPatient جاهزين مسبقًا، لذلك كل ما عليك الآن هو تصحيح مسار إدراجهما إن اقتضت الحاجة. لننشئ النوع Entry بشكل ملائم بناء على البيانات المتوفرة. لو ألقينا الآن نظرة أقرب على البيانات المتوفرة، سنجد أن المُدخلات مختلفة عن بعضها قليلًا. لاحظ الاختلافات بين أول مدخلين على سبيل المثال: { id: 'd811e46d-70b3-4d90-b090-4535c7cf8fb1', date: '2015-01-02', type: 'Hospital', specialist: 'MD House', diagnosisCodes: ['S62.5'], description: "Healing time appr. 2 weeks. patient doesn't remember how he got the injury.", discharge: { date: '2015-01-16', criteria: 'Thumb has healed.', } } ... { id: 'fcd59fa6-c4b4-4fec-ac4d-df4fe1f85f62', date: '2019-08-05', type: 'OccupationalHealthcare', specialist: 'MD House', employerName: 'HyPD', diagnosisCodes: ['Z57.1', 'Z74.3', 'M51.2'], description: 'Patient mistakenly found himself in a nuclear plant waste site without protection gear. Very minor radiation poisoning. ', sickLeave: { startDate: '2019-08-05', endDate: '2019-08-28' } } سنرى أن بعض الحقول متطابقة لكن المُدخل الأول يمتلك الحقل discharge والثاني يمتلك الحقلين employerName وsickLeave. وبشكل عام تحتوي جميع المدخلات حقول مشتركة وحقول خاصة. وبالنظر إلى الحقل type، سنجد ثلاثة أشكال للمدخلات تشير إلى حاجتنا إلى ثلاثة أنواع منفصلة: OccupationalHealthcare Hospital HealthCheck. تشترك المدخلات بعدة حقول، وبالتالي من المناسب إنشاء واجهة نوع أساسي تدعى "Entry" قابلة للتوسّع بإضافة حقول خاصة لكل نوع. تُعَدّ الحقول الآتية مشتركةً بين كل المدخلات: id description date specialist ويبدو أن الحقل diagnosesCodes موجود فقط في المدخلات من النوعين OccupationalHealthcare وHospital. وطالما أنه لا يستخدم دائمًا حتى في هذين النوعين، فسنفترض أنه حقل اختياري. ويمكن إضافته إلى النوع HealthCheck أيضًا، إذ ليس من الضرورة استخدامه في أي من المدخلات الثلاث. ستبدو واجهة النوع الأساسي كالتالي: interface BaseEntry { id: string; description: string; date: string; specialist: string; diagnosisCodes?: string[]; } لو أردنا تهيئة الواجهة بشكل أفضل، ولعلمنا أن النوع Diagnoses قد عُرِّف مسبقًا في الواجهة الخلفية، من الممكن إذًا الإشارة مباشرة إلى حقل الشيفرة للنوع Diagnoses في حال تغيّر نوعه لسبب ما. سننفذ ذلك على النحو التالي: interface BaseEntry { id: string; description: string; date: string; specialist: string; diagnosisCodes?: Array<Diagnosis['code']>; } وتذكّر أن <Array<Type هي صيغة بديلة للتعليمة []Type. ومن الأفضل والأوضح في حالات كهذه أن نستخدم المصفوفة، لأن استخدام الخيار الآخر سيدفعنا إلى تعريف النوع بالعبارة DiagnosisCode والتي تبدو غريبة بعض الشيء. يمكننا الآن وبعد تعريف النوع الأساسي Entry، أن ننشئ الأنواع Entry الموسّعة التي سنستخدمها فعليًا. وسنبدأ بإنشاء النوع HealthCheckEntry. تحتوي المدخلات من النوع HealthCheck على الحقل HealthCheckRating الذي يأخذ قيمًا صحيحة بين 0 و 3. تعني القيمة 0 أن المريض بصحة جيدة، أما القيمة 3 فتعني أن المريض بحالة حرجة. وهذا ما يجعل استخدام التعداد مثاليًا. وبناء على المعطيات السابقة يمكن تعريف النوع HealthCheckEntry على النحو التالي: export enum HealthCheckRating { "Healthy" = 0, "LowRisk" = 1, "HighRisk" = 2, "CriticalRisk" = 3 } interface HealthCheckEntry extends BaseEntry { type: "HealthCheck"; healthCheckRating: HealthCheckRating; } يبقى علينا الآن إنشاء النوعين OccupationalHealthcareEntry وHospitalEntry ومن ثم نضم الأنواع الثلاثة ونصدّرها كنوع موحّد Entry كالتالي: export type Entry = | HospitalEntry | OccupationalHealthcareEntry | HealthCheckEntry; التمارين 9.19 - 9.22 9.19 تطبيق إدارة المرضى: الخطوة 4 عّرف النوعين بما يتلائم مع البيانات الموجودة. تأكد أن الواجهة الخلفية ستعيد المُدخل المناسب عندما تتوجه لإحضار معلومات مريض محدد. استخدم الأنواع بشكل صحيح في الواجهة الخلفية. لا حاجة الآن لتقييم بيانات كل الحقول في الواجهة الخلفية، ويكفي التحقق أن الحقل type سيحتوي قيمة من النوع الصحيح. 9.20 تطبيق إدارة المرضى: الخطوة 5 وسّع صفحة المريض في الواجهة الأمامية لإظهار قائمة تضم تاريخ ووصف ورمز التشخيص لكل مدخلات المريض. يمكنك استخدام نفس النوع Entry الذي عرّفناه الآن ضمن الواجهة الأمامية. ويكفي في هذه التمارين أن ننقل تعريف الأنواع كما هو من الواجهة الخلفية إلى الأمامية (نسخ/لصق). قد يبدو حلك قريبًا من التالي: 9.21 تطبيق إدارة المرضى: الخطوة 6 أحضر التشخيص ثم أضفه إلى حالة التطبيق من وصلة التخديم api/diagnosis/. استخدم بيانات التشخيص الجديدة لإظهار توصيف رموز التشخيصات للمريض. 9.22 تطبيق إدارة المرضى: الخطوة 7 وسّع قائمة المدخلات في صفحة المريض لتتضمن تفاصيل أكثر باستخدام مكوّن جديد يُظهِر بقية المعلومات الموجودة ضمن مدخلات المريض ومميزًا الأنواع عن بعضها. يمكنك أن تستخدمعلى سبيل المثال المكوّن Icon أو أي مكوًن آخر من مكوّنات المكتبة SemanticUI للحصول على مظهر مناسب لقائمتك. ينبغي تنفيذ عملية تصيير شرطية باستخدام البنية switch case وآلية التحقق الشاملة من الأنواع لكي لا تنس أية حالة. تأمل الشكل التالي: ستبدو قائمة المدخلات قريبة من الشكل التالي: نموذج لإضافة مريض قد يكون التعامل مع النماذج مزعجًا أحيانًا في ، لذلك قررنا استخدام الحزمة Formik لإنشاء نموذج لإضافة مريض في تطبيقنا. فيما سيأتي تقديم بسيط اجتزأناه من توثيق : ستنظم Formik الأشياء بتجميع العمليات السابقة في مكان واحد، وستغدو الاختبارات و إعادة كتابة الشيفرة وتقييم النماذج أمرًا يسيرًا. يمكنك إيجاد شيفرة النموذج في الملف src/AddPatientModal/AddPatientForm.tsx ،كما يمكنك إيجاد الدوال المساعدة لهذا النموذج في الملف src/AddPatientModal/FormField.tsx. لو نظرت إلى الملف AddPatientForm.tsx لوجدت أننا أنشأنا نوعًا لقيم النموذج يدعى PatientFormValues. يمثل هذا النوع نسخة معدلة عن النوع Patient بعد حذف الخاصيتين id و entries. فلا نريد أن يرسل المستخدم المعلومات الموجودة ضمن هاتين الخاصيتين عند إنشاء مريض جديد. فالمعرِّف id ستنشئه الواجهة الخلفية والمدخلات entries لا يمكن إضافتها إلا لمريض موجود مسبقًا. export type PatientFormValues = Omit<Patient, "id" | "entries">; سنصرّح تاليًا عن خصائص المكوّن الخاص بالنموذج: interface Props { onSubmit: (values: PatientFormValues) => void; onCancel: () => void; } يتطلب المكوّن وجود خاصيتين: onSubmit وonCancel. وكلاهما دالة استدعاء لا تعيد قيمًا. ينبغي أن تتلقى الدالة كائنًا من النمط "PatientFormValues" كمعامل لكي تتمكن من معالجة قيم النموذج. بالنظر إلى مكوّن الدالة AddPatientForm، ستجد أننا ربطنا به خصائصه، ومن ثم فككنا onSubmit وonCancel من هذه الخصائص. export const AddPatientForm: React.FC<Props> = ({ onSubmit, onCancel }) => { // ... } قبل أن نكمل، لنلق نظرة على الدوال المساعدة في نموذجنا والموجودة في الملف FormField.tsx. لو تحققت من الأشياء التي يصدرها الملف، ستجد النوع GenderOption ومكوني الدوال SelectField وTextField. لنلق نظرة على مكوِّن الدالة SelectField والأنواع المتعلقة به. أنشأنا أولًا نوعًا معممًا لكل كائن خيارات يتضمن قيمة وتسمية. هذه هي أنواع كائنات الخيارات التي سنسمح بها في نموذجنا ضمن حقل الخيارات. وطالما أن الخيار الوحيد الذي سنسمح به هو جنس المريض سنجعل القيمة value من النوع Gender. export type GenderOption = { value: Gender; label: string; }; أعطينا المتغير genderOptions في الملف AddPatientForm.tsx النوع GenderOption وصرحنا أنه مصفوفة تحتوي على كائنات من النوع GenderOption. const genderOptions: GenderOption[] = [ { value: Gender.Male, label: "Male" }, { value: Gender.Female, label: "Female" }, { value: Gender.Other, label: "Other" } ]; لاحظ إيضًا النوع SelectFieldProps. إذ يقوم بتعريف نوع لخصائص مكوِّن الدالة SelectField. سترى أن الخيارات عبارة عن مصفوفة من النوع GenderOption. type SelectFieldProps = { name: string; label: string; options: GenderOption[]; }; إن عمل مكوِّن الدالة SelectField واضح تمامًا. فهو يصيّر التسمية ويختار عنصرًا، ومن ثم يعطي كل الخيارات المتاحة لهذا العنصر ( قيم هذه الخيارات وتسمياتها). export const SelectField: React.FC<SelectFieldProps> = ({ name, label, options }: SelectFieldProps) => ( <Form.Field> <label>{label}</label> <Field as="select" name={name} className="ui dropdown"> {options.map(option => ( <option key={option.value} value={option.value}> {option.label || option.value} </option> ))} </Field> </Form.Field> ); لننتقل الآن إلى مكوِّن الدالة TextField. يُصيِّر هذا المكوّن الكائن Form.Field من المكتبة SemanticUI إلى تسمية ومكوّن Field من المكتبة Formik. يتلقى هذا المكوًن القيمة name والقيمة placeholder كخصائص. interface TextProps extends FieldProps { label: string; placeholder: string; } export const TextField: React.FC<TextProps> = ({ field, label, placeholder }) => ( <Form.Field> <label>{label}</label> <Field placeholder={placeholder} {...field} /> <div style={{ color:'red' }}> <ErrorMessage name={field.name} /> </div> </Form.Field> ); لاحظ أننا استخدمنا المكوّن ErrorMessage من المكتبة Formik لتصيير رسالة خطأ تتعلق بالقيمة المدخلة إن اقتضى الأمر. ينفذ المكوّن كل ما يلزم خلف الستار، فلا حاجة لكتابة شيفرة خاصة لذلك. من الممكن أيضًا الاحتفاظ برسالة الخطأ ضمن المكوّن باستخدام الخاصيّة form. export const TextField: React.FC<TextProps> = ({ field, label, placeholder, form }) => { console.log(form.errors); // ... } سنعود الآن إلى مكوّن النموذج الفعلي AddPatientForm.tsx. سيصيّر مكون الدالة AddPatientForm مكوّن Formik. سيغلّف هذا المكون بقية المكوّنات ويتطلب خاصيتين initialValues وonSubmit. شيفرة دالة الخصائص واضحة تمامًا. سيتعقب مكوِّن Formik حالة النموذج وسيظهر هذه الحالة بالإضافة إلى عدة توابع قابلة لإعادة الاستخدام ومعالجات الأحداث ضمن النموذج عن طريق الخصائص. نستخدم أيضًا الخاصية الاختيارية valiate التي تقبل دالة تقييم وتعيد كائنًا يتضمن الأخطاء المحتملة. نتحقق في نموذجنا أن الحقول النصية ليست فارغة، لكن بالإمكان أن نضيف بعض المعايير الأخرى للتقييم كالتأكد من صحة رقم الضمان الاجتماعي على سبيل المثال. يمكن لرسائل الخطأ التي تحددها دالة التقييم أن تُعرض ضمن مكوّن ErrorMessage الخاص بكل حقل. لنلق نظرة أولًا على المكوّن بشكله الكامل. ثم سنناقش بعدها أقسامه المختلفة بشيء من التفصيل. interface Props { onSubmit: (values: PatientFormValues) => void; onCancel: () => void; } export const AddPatientForm: React.FC<Props> = ({ onSubmit, onCancel }) => { return ( <Formik initialValues={{ name: "", ssn: "", dateOfBirth: "", occupation: "", gender: Gender.Other }} onSubmit={onSubmit} validate={values => { const requiredError = "Field is required"; const errors: { [field: string]: string } = {}; if (!values.name) { errors.name = requiredError; } if (!values.ssn) { errors.ssn = requiredError; } if (!values.dateOfBirth) { errors.dateOfBirth = requiredError; } if (!values.occupation) { errors.occupation = requiredError; } return errors; }} > {({ isValid, dirty }) => { return ( <Form className="form ui"> <Field label="Name" placeholder="Name" name="name" component={TextField} /> <Field label="Social Security Number" placeholder="SSN" name="ssn" component={TextField} /> <Field label="Date Of Birth" placeholder="YYYY-MM-DD" name="dateOfBirth" component={TextField} /> <Field label="Occupation" placeholder="Occupation" name="occupation" component={TextField} /> <SelectField label="Gender" name="gender" options={genderOptions} /> <Grid> <Grid.Column floated="left" width={5}> <Button type="button" onClick={onCancel} color="red"> Cancel </Button> </Grid.Column> <Grid.Column floated="right" width={5}> <Button type="submit" floated="right" color="green" disabled={!dirty || !isValid} > Add </Button> </Grid.Column> </Grid> </Form> ); }} </Formik> ); }; export default AddPatientForm; يمتلك مكوّن التغليف دالة تعيد محتويات النموذج كمكوِّن ابن child component. ونستخدم العنصر Form من المكتبة Formik لتصيير العناصر الفعلية للنموذج. سنضع داخل هذا العنصر مكونات الدوال TextField وSelectField التي أنشأناها في الملف FormField.tsx. سننشئ في النهاية زرين: الأول لإلغاء إرسال معلومات النموذج، وآخر لإرسالها. يستدعي الزر الأول الدالة onCancel مباشرة، ويحرّض الزر الثاني الحدث onSubmitt للمكتبة Formik الذي يستدعي بدوره الدالة onSubmitt من خصائص المكوّن. سيُفعَّل زر الإرسال إن كان النموذج صالحًا (يعيد القيمة valid) و مستعملًا (يعيد القيمة dirty). أي عندما يضيف المستخدم معلومات ضمن بعض الحقول. نعالج عملية إرسال بيانات النموذج باستخدام المكتبة لأنها تسمح لنا باستدعاء دالة التقييم قبل الشروع فعليًا بإرسال البيانات. فإن أعادة دالة التقييم أية أخطاء، سيلغى الإرسال. وُضع الزرين داخل العنصر Grid من المكتبة Formik لضبطهما متجاورين بطريقة سهلة. <Grid> <Grid.Column floated="left" width={5}> <Button type="button" onClick={onCancel} color="red"> Cancel </Button> </Grid.Column> <Grid.Column floated="right" width={5}> <Button type="submit" floated="right" color="green"> Add </Button> </Grid.Column> </Grid> يُمرر الاستدعاء onSubmit من صفحة قائمة المرضى نزولًا إلى نموذج إضافة مريض، ليرسل طلب HTTP-POST إلى الواجهة الخلفية، ويضيف المريض الذي تعيده الواجهة الخلفية إلى حالة التطبيق ويغلق النموذج. إن أعادت الواجهة الخلفية خطأً، سيُعرض الخطأ على النموذج. تمثل الشيفرة التالية دالة الاستدعاء: const submitNewPatient = async (values: FormValues) => { try { const { data: newPatient } = await axios.post<Patient>( `${apiBaseUrl}/patients`, values ); dispatch({ type: "ADD_PATIENT", payload: newPatient }); closeModal(); } catch (e) { console.error(e.response.data); setError(e.response.data.error); } }; ينبغي أن تكون قادرًا على إكمال بقية التمارين في هذا القسم بناء على المعلومات التي قدمناها. وإن راودتك أية شكوك، أعد قراءة الشيفرة الموجودة، فقد تعطيك تلميحًا لكيفية المتابعة. التمارين 9.23 - 9.27 9.23 تطبيق إدارة المرضى: الخطوة 8 صممنا التطبيق بحيث يمكن أن تجد عدة مدخلات لنفس المريض. لا توجد حتى الآن آلية في تطبيقنا لإضافة مُدخلات. عليك الآن أن تضيف وصلة تخديم على العنوان api/patients/:id/entries/ في الواجهة الخلفية، لتتمكن من إضافة مُدخل لمريض. تذكر أن هناك أنواع مختلفة من المدخلات في تطبيقنا، ويجب أن تدعم الواجهة الخلفية كل هذه الأنواع، وتحقق من وجود كل الحقول المطلوبة لكل نوع. 9.24 تطبيق إدارة المرضى: الخطوة 9 ستدعم الواجهة الخلفية الآن إضافة المُدخلات، ونريد أن ننفذ الوظيفة نفسها في الواجهة الأمامية. عليك إذًا إنشاء نموذج لإضافة مُدخل إلى سجل مريض. وتعتبر صفحة المريض مكانًا مناسبًا للوصول إلى هذا النموذج. يكفي في هذا التمرين أن تدعم نوعًا واحدًا من المدخلات، وليس مطلوبًا معالجة كافة الأخطاء الآن. إذ يكفي أن تنشئ بنجاح مدخلًا جديدًا عندما تُملأ حقول النموذج ببيانات صحيحة. ينبغي أن يُضاف مُدخل جديد إلى سجل المريض المحدد بعد نجاح عملية إرسال بيانات النموذج. كما ينبغي تحديث صفحة المريض ليظهر المُدخل الجديد. يمكنك إن أردت استخدام جزء من شيفرة نموذج إضافة مريض، لكن ذلك غير ضروري. انتبه إلى وجود المكوّن الجاهز GiagnosesSelection في الملف FormField.tsx حيث يمكنك استخدامه لضبط الحقل diagnoses بالشكل التالي: const AddEntryForm: React.FC<Props> = ({ onSubmit, onCancel }) => { const [{ diagnoses }] = useStateValue() return ( <Formik initialValues={{ /// ... }} onSubmit={onSubmit} validate={values => { /// ... }} > {({ isValid, dirty, setFieldValue, setFieldTouched }) => { return ( <Form className="form ui"> // ... <DiagnosisSelection setFieldValue={setFieldValue} setFieldTouched={setFieldTouched} diagnoses={Object.values(diagnoses)} /> // ... </Form> ); }} </Formik> ); }; كما يوجد أيضًا مكوّن جاهز يدعى NumberField للقيم العددية ضمن مجال محدد: <Field label="healthCheckRating" name="healthCheckRating" component={NumberField} min={0} max={3} /> 9.25 تطبيق إدارة المرضى: الخطوة 10 وسّع التطبيق ليُظهر رسالة خطأ إن كانت إحدى القيم المطلوبة مفقودة أو بتنسيق غير صحيح. 9.26 تطبيق إدارة المرضى: الخطوة 11 وسّع تطبيقك ليدعم نوعين من المُدخلات، ويُظهر رسالة خطأ إن كانت إحدى القيم المطلوبة مفقودة أو بتنسيق غير صحيح. لا تهتم بالأخطاء المحتملة التي قد تحملها استجابة الخادم. تقتضي الطريقة الأسهل -وبالطبع ليست الأكثر أناقة- لتنفيذ التمرين أن تنشئ نموذجًا منفصلًا لكل مدخل، فقد تواجهك بعض الصعوبات في التعامل مع الأنواع إن استخدمت نمودجًا واحدًا. 9.27 تطبيق إدارة المرضى: الخطوة 12 وسّع التطبيق ليدعم كل أنواع المُدخلات، ويُظهر رسالة خطأ إن كانت إحدى القيم المطلوبة مفقودة أو بتنسيق غير صحيح. لا تهتم بالأخطاء المحتملة التي قد تحملها استجابة الخادم. وهكذا نكون قد وصلنا إلى التمرين الأخير في هذا الفصل وحان الوقت لتسليم الحلول إلى GitHub، والإشارة إلى التمارين التي أنجزتها في منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل React with Types من سلسلة Deep Dive Into Modern Web Development
  6. لقد امتلكنا الآن بعض المفاهيم الأساسية عن عمل TypeScript وعن كيفية استخدامها في إنشاء المشاريع الصغيرة، وأصبحنا قادرين على تنفيذ أشياء مفيدة فعلًا. سنبدأ إذًا بإنشاء مشروع جديد ذو استخدامات أكثر واقعية. سيكون التغيير الرئيسي في هذا القسم عدم استخدام ts-node. وعلى الرغم من أنها أداة مفيدة وتساعدك في الانطلاق، إلا أن استخدام المصرِّف الرسمي للغة هو الخيار المفضل، وخاصة على المدى الطويل لأعمالك. يأتي هذا المصرِّف مع الحزمة typescript، حيث يولّد ويحزم ملفات JavaScript انطلاقًا من ملفات "ts." وبذلك لن تحتوي نسخة الإنتاج بعد ذلك شيفرات TypeScript. وهذا ما نحتاجه فعلًا لأن TypeScript غير قابلة للتنفيذ على المتصفح أو على Node. إعداد المشروع سننشئ مشروعًا من أجل Ilari الذي يحب وضع خطط لرحلات جوية قصيرة، لكنه يعاني من ذكريات مزعجة متعلقة بهذه الرحلات. سيكون هو نفسه من سيكتب شيفرة التطبيق فلن يحتاج إلى واجهة مستخدم، لكنه يرغب باستعمال طلبات HTTP ويبقى خياراته مفتوحة في إضافة واجهة مستخدم مبنية على تقنيات الويب لاحقًا. لنبدأ بإنشاء أول مشروع واقعي لنا بعنوان Ilari flight diaries. وسنثبت كالعادة حزمة typescript باستخدام الأمر npm init ومن ثم npm install typescript. يساعدنا مصرِّف TypeScript الأصلي tsc في تهيئة المشروع من خلال الأمر tsc --init. لكن علينا أولًا أن نضيف الأمر tsc إلى قائمة السكربتات القابلة للتنفيذ داخل الملف package.json (إلا إن كنت قد ثبتَّ TypeScript لكافة المشاريع). وحتى لو ثبتّها لكافة المشاريع لابد من إدراجها كاعتمادية تطوير في مشروعك. سيكون سكربت npm الذي سينفذ الأمر tsc كالتالي: { // .. "scripts": { "tsc": "tsc", }, // .. } يضاف الأمر tsc غالبًا لاستخدامه في سكربتات تنفذ سكربتات أخرى، وبالتالي من الشائع رؤية إعداداته ضمن المشروع بهذا الشكل. سنهيئ الآن إعدادات الملف tsconfig.json بتنفيذ الأمر: npm run tsc -- --init لاحظ وجود المحرفين "--" قبل الوسيط الفعلي init. يُفسَّر الوسيط الذي يقع قبل "--" كأوامر npm والذي يقع بعدها كأوامر تُنفَّذ من قبل السكربت. ينتج عن تنفيذ الأمر السابق الملف tsconfig.ts الذي يضم قائمة طويلة بكل أوامر التهيئة التي يمكن ضبطها. وسترى أن قلة منها فقط لم تُسبق بعلامة التعليق. ستفيدك دراسة هذا الملف في إيجاد بعض تعليمات التهيئة التي قد تحتاجها. يمكنك أيضًا ترك الأسطر التي تبدأ بعلامة التعليق في الملف، فلربما قررت لاحقًا توسيع إعدادات التهيئة الخاصة بمشروعك. إن كل ما نحتاج إليه حاليًا من إعدادات هي: { "compilerOptions": { "target": "ES6", "outDir": "./build/", "module": "commonjs", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true } } لنعرّج على الإعدادات واحدًا تلو الآخر: target: سيخبر المصرِّف عن نسخة ECMAScript التي سيستعملها لتوليد ملفات JavaScript. ولاحظ أن القيمة التي يأخذها هي ES6 التي تدعمها معظم المتصفحات، وهي خيار جيد وآمن. outDir: يحدد المجلد الذي سيحوي الشيفرة المصرِّفة. modules: يخبر المصرِّف أننا سنستخدم وحدات commonjs في الشيفرة المصرِّفة. وهذا يعني أننا نستطيع استخدام require بدلًا من import وهذا الأمر غير مدعوم في النسخ الأقدم من Node مثل النسخة 10. strict: وهو في الواقع اختصار لعدة خيارات منفصلة توجه استخدام ميزات TypeScript بطريقة أكثر تشددًا (يمكنك أن تجد تفاصيل بقية الخيارات في توثيق tsconfig، كما ينصح التوثيق باستخدام strict دائمًا) هي: noImplicitAny: يكون الخيار المألوف بالنسبة لنا هو الأكثر أهمية. ويمنع هذا الخيار تضمين النوع any (إعطاء قيمة ما النوع any)، والذي قد يحدث إن لم تحدد نوعًا لمعاملات دالة على سبيل المثال. noImplicitThis alwayesStrict strictBindCallApply strictNullChecks strictFunctionTypes strictpropertyIntialization noUnUsedLocals: يمنع وجود متغيرات محلية غير مستعملة. nofallThroughCasesInSwitch: ترمي خطأً إن احتوت الدالة على معامل لم يستعمل. esModuleInterop: تتأكد من وجود إحدى التعليمتين return أو break في نهاية الكتلة case، عند استخدام البنية switch case. ModuleInterop: تسمح بالتصريف بين وحدات commonjs و ES. يمكنك الاطلاع أكثر على هذا الإعداد في توثيق tsconfig وبما أننا أنهينا ضبط إعدادات التهيئة التي نريد، سنتابع العمل بتثبيت المكتبة express وتثبيت تعريفات الأنواع الموجودة ضمنها مستخدمين "types/express@". وبما أن المشروع واقعي، أي أنه سينمو مع الوقت، سنستخدم المدقق eslint من البداية: npm install express npm install --save-dev eslint @types/express @typescript-eslint/eslint-plugin @typescript-eslint/parser ستبدو محتويات الملف package.json مشابهة للتالي: { "name": "ilaris-flight-diaries", "version": "1.0.0", "description": "", "main": "index.ts", "scripts": { "tsc": "tsc", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC", "dependencies": { "express": "^4.17.1" }, "devDependencies": { "@types/express": "^4.17.2", "@typescript-eslint/eslint-plugin": "^2.17.0", "@typescript-eslint/parser": "^2.17.0", "eslint": "^6.8.0", "typescript": "^3.7.5" } } كما سننشئ ملف تهيئة المدقق ذو اللاحقة eslintrc ونزوده بالمحتوى التالي: { "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking" ], "plugins": ["@typescript-eslint"], "env": { "browser": true, "es6": true }, "rules": { "@typescript-eslint/semi": ["error"], "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/no-unused-vars": [ "error", { "argsIgnorePattern": "^_" } ], "@typescript-eslint/no-explicit-any": 1, "no-case-declarations": 0 }, "parser": "@typescript-eslint/parser", "parserOptions": { "project": "./tsconfig.json" } } سنتابع الآن العمل بإعداد بيئة التطوير، وسنصبح بعد ذلك جاهزين لكتابة الشيفرة الفعلية. هناك خيارات عدة لإجراء ذلك. فقد نستخدم المكتبة nodemon مع ts-node، لكن وكما رأينا سابقًا فالمكتبة ts-node-dev تؤمن الوظائف نفسها، لذا سنستمر في استخدامها: npm install --save-dev ts-node-dev سنعرّف بعض سكربتات npm: { // ... "scripts": { "tsc": "tsc", "dev": "ts-node-dev index.ts", "lint": "eslint --ext .ts ." }, // ... } هنالك الكثير لتفعله قبل البدء بكتابة الشيفرة. فعندما تعمل على مشروع حقيقي، سيفيدك الإعداد المتأني لبيئة التطوير إلى حد كبير. خذ وقتك في ضبط إعدادات بيئة العمل لك ولفريقك، وسترى أن كل شيء سيسير بسلاسة على المدى الطويل. لنبدأ بكتابة الشيفرة سنبدأ كتابة الشيفرة بإنشاء وصلة تخديم endpoint لتفقد الخادم، لكي نضمن أن كل شيء يعمل بالشكل الصحيح. فيما يلي محتوى الملف index.ts: import express from 'express'; const app = express(); app.use(express.json()); const PORT = 3000; app.get('/ping', (_req, res) => { console.log('someone pinged here'); res.send('pong'); }); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); لو نفذنا الآن الأمر npm run dev سنرى أن الطلب إلى العنوان http://localhost:3000/ping، سيعيد الاستجابة pong، وبالتالي فتهيئة المشروع قد أنجزت بالشكل الصحيح. سيعمل التطبيق بعد تنفيذ الأمر السابق في وضع التطوير. وهذا الوضع لن يكون مناسبًا أبدًا عند الانتقال لاحقًا إلى وضع الإنتاج. لنحاول إنجاز نسخة إنتاج بتشغيل مصرِّف TypeScript. وبما أننا حددنا قيمةً للإعداد outDir في الملف package.json، فلا شيء بقي لنفعله سوى تنفيذ السكربت باستخدام الأمر npm run tsc. ستكون النتيجة نسخة إنتاج مكتوبة بلغة JavaScript صرفة، لواجهة express الخلفية ضمن المجلد build. سيفسر المدقق eslint حاليًًا الملفات ويضعها ضمن المجلد build نفسه. ولا نرغب بالطبع أن يحدث هذا، فمن المفترض أن تكون الشيفرة الموجودة في هذا المجلد ناتجة عن المصرِّف فقط. يمكن منع ذلك بإنشاء ملف تجاهل لاحقته "eslintignore." يضم قائمة بالمحتوى الذي نريد من المدقق أن يتجاهله، كما هي الحال مع git وgitignore. لنضف سكربت npm لتشغيل التطبيق في وضع الإنتاج: { // ... "scripts": { "tsc": "tsc", "dev": "ts-node-dev index.ts", "lint": "eslint --ext .ts .", "start": "node build/index.js" }, // ... } وبتشغيل التطبيق باستخدام الأمر npm start، نتأكد أن نسخة الانتاج ستعمل أيضًا بشكل صحيح. لدينا الآن بيئة تطوير تعمل بالحد الأدنى. وبمساعدة المصرِّف والمدقق eslint سنضمن أن تبقى الشيفرة بمستوًى جيد. وبناء على ما سبق سنتمكن من إنشاء تطبيق قابل للنشر لاحقًا كنسخ إنتاج. التمرينان 9.8 - 9.9 قبل أن تبدأ بالحل ستطوّر خلال مجموعة التمارين هذه واجهة خلفية لمشروع جاهز يدعى Patientor. والمشروع عبارة عن تطبيق سجلات طبية بسيط يستخدمه الأطباء الذين يشخصون الأمراض ويسجلون المعلومات الصحية لمرضاهم. بُنيت الواجهة الأمامية مسبقًا من قبل خبراء خارجيين، ومهمتك أن تطور واجهة خلفية تدعم الشيفرة الموجودة. 9.8 الواجهة الخلفية لتطبيق إدارة المرضى: الخطوة 1 هيئ المشروع الذي ستستخدمه الواجهة الأمامية. ثم هيئ المدقق eslint والملف tsconfig بنفس إعدادات التهيئة التي استخدمناها سابقًا. عرِّف وصلة تخديم تستجيب لطلبات HTTP-GET المرسلة إلى الوجهة ping/. ينبغي أن يعمل المشروع باستخدام سكربتات npm في وضع التطوير، وكشيفرة مصرّفة في وضع الإنتاج. 9.9 الواجهة الخلفية لتطبيق إدارة المرضى: الخطوة 2 انسخ المشروع patientor. شغل بعد ذلك التطبيق مستعينًا بالملف README.md. يجب أن تعمل الواجهة الأمامية دون حاجة لوجود واجهة خلفية وظيفية. تأكد من استجابة الواجهة الخلفية إلى طلب التحقق من الخادم ping الذي ترسله الواجهة الأمامية. وتحقق من أداة التطوير للتأكد من سلامة عملها. من الجيد أن تلقي نظرة على النافذة console في طرفية التطوير. وإن أخفق شيء ما، استعن بمعلومات القسم الثالث المتعلقة بحل هذه المشكلة. كتابة شيفرة وظائف الواجهة الخلفية لنبدأ بالأساسيات. يريد Ilari أن يكون قادرًا على مراجعة ما اختبره خلال رحلاته الجوية. إذ يريد تخزين مدخلات في مذكراته تتضمن المعلومات التالية: تاريخ المُدخل حالة الطقس (جيد، رياح، ماطر، عاصف) مدى الرؤية (جيد، ضعيف) نص يفصِّل تجربته ضمن الرحلة. حصلنا على عينة من المعلومات التي سنستخدمها كأساس نبني عليه. خُزّنت المعلومات بصيغة JSON ويمكن الحصول عليها من GitHub. تبدو البيانات على النحو التالي: [ { "id": 1, "date": "2017-01-01", "weather": "rainy", "visibility": "poor", "comment": "Pretty scary flight, I'm glad I'm alive" }, { "id": 2, "date": "2017-04-01", "weather": "sunny", "visibility": "good", "comment": "Everything went better than expected, I'm learning much" }, // ... ] لننشئ وصلة تخديم تعيد كل مدخلات مذكرة الرحلات. علينا في البداية إتخاذ بعض القرارات المتعلقة بطريقة هيكلة الشيفرة المصدرية. فمن الأفضل في حالتنا وضع الشيفرة بأكملها في مجلد واحد باسم src، وهكذا لن تختلط ملفات الشيفرة مع ملفات التهيئة. إذًا سننقل الملف إلى هذا المجلد ونجري التعديلات اللازمة على سكربت npm. سنضع أيضًا وحدات المتحكمات بالمسار routers المسؤولة عن التعامل مع موارد محددة كمدخلات المذكرات، ضمن المجلد src/routes. يختلف ما فعلناه قليلًا عن المقاربة التي اتبعناها في القسم 4 حيث وضعنا المتحكمات في المجلد src/controllers. ستجد شيفرة المتحكم الذي يتعامل مع جميع وصلات تخديم المرتبطة بالمذكّرات في الملف "src/routes/diaries.ts" الذي يحتوي الشيفرة التالية: import express from 'express'; const router = express.Router(); router.get('/', (_req, res) => { res.send('Fetching all diaries!'); }) router.post('/', (_req, res) => { res.send('Saving a diary!'); }) export default router; سنوجّه كل الطلبات التي تبدأ بالعنوان api/diaries/ إلى متحكم المسار المحدد في الملف index.ts import express from 'express'; import diaryRouter from './routes/diaries'; const app = express(); app.use(express.json()); const PORT = 3000; app.get('/ping', (_req, res) => { console.log('someone pinged here'); res.send('pong'); }); app.use('/api/diaries', diaryRouter); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); وهكذا لو أرسلنا طلبًا إلى العنوان http://localhost:3000/api/diaries فمن المفترض أن نرى الرسالة Fetching all diaries. علينا تاليًا البدء بتقديم البيانات الأساسية (عينة المذكّرات) انطلاقًا من التطبيق. إذ سنحضر البيانات ونخزنها في الملف data/diaries.json. لن نكتب الشيفرة التي تغير البيانات الفعلية في ملف المتحكم بالمسار، بل سننشئ خدمة تهتم بهذا الأمر. ويعتبر فصل "منطق العمل" عن شيفرة متحكمات المسار ضمن وحدات منفصلة أمرًا شائعًا، وتدعى في أغلب الأحيان "خدمات". ويعود أصل هذه التسمية إلى مفهوم التصميم المقاد بالمجال Domain driven design، ومن ثم جعله إطار العمل Spring أكثر شعبية. لننشئ مجلدًا للخدمات يدعى src/services، ونضع الملف diaryService.ts فيه. يحتوي الملف على دالتين لإحضار وتخزين مُدخلات المذكرة: import diaryData from '../../data/diaries.json' const getEntries = () => { return diaryData; }; const addEntry = () => { return null; }; export default { getEntries, addEntry }; لكن سنلاحظ خللًا ما: يخبرنا المحرر أننا قد نحتاج إلى استعمال الإعداد resolveJsonModule في ملف التهيئة tsconfig. سنضيفه إذًا إلى قائمة الإعدادات: { "compilerOptions": { "target": "ES6", "outDir": "./build/", "module": "commonjs", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "resolveJsonModule": true } } وهكذا ستختفي المشكلة: وجدنا في وقت سابق كيف يمكن للمصرِّف أن يقرر نوع المتغير بناء على القيمة التي أسندت إليه. وبالمثل يمكن للمصرِّف تفسير مجموعات كبيرة من البيانات تتضمن كائنات ومصفوفات. وبناء على ذلك يمكن للمصرِّف أن يحذرنا عندما نحاول أن نفعل شيئًا قد يعتبره مريبًا باستخدام بيانات JSON التي نعالجها. فلو كنا نتعامل على سبيل المثال مع مصفوفة من كائنات محددة النوع، وحاولنا إضافة كائن لا يمتلك كل حقول كائنات المصفوفة أو خللًا في نوع الحقل (number بدل string مثلًا)، سيحذرنا المصرِّف مباشرة. وعلى الرغم من فائدة المصرِّف في التحقق من أننا لم نفعل شيئًا خاطئًا، فتحديد أنواع البيانات بأنفسنا هو الخيار الأكثر أمانًا. ما لدينا حاليًا، هو تطبيق express باستخدام TypeScript يعمل بشكل محدود، لكن لا وجود لأية أنواع في شيفرته. وطالما أننا على دراية بأنواع البيانات التي سنستقبلها في حقلي weather، وvisibility، فلا سبب سيدفعنا لإدراج نوعيهما في الشيفرة. لننشئ ملفًا للأنواع يدعى types.ts، وسنعرّف ضمنه كل الأنواع التي سنستخدمها في المشروع. أولًا سنعرف نوعين لقيم weather، وvisibility باستخدام نوع موّحد union type للقيم النصية التي يمكن أن يأخذها الحقلين: export type Weather = 'sunny' | 'rainy' | 'cloudy' | 'windy' | 'stormy'; export type Visibility = 'great' | 'good' | 'ok' | 'poor'; سنتابع بإنشاء واجهة نوع interface باسم DiaryEntry: export interface DiaryEntry { id: number; date: string; weather: Weather; visibility: Visibility; comment: string; } وسنحاول تحديد نوع لبيانات JSON المُدرجة: import diaryData from '../../data/diaries.json'; import { DiaryEntry } from '../types'; const diaries: Array<DiaryEntry> = diaryData; const getEntries = (): Array<DiaryEntry> => { return diaries;}; const addEntry = () => { return null; }; export default { getEntries, addEntry }; ولأننا صرحنا مسبقا عن القيم، فإسناد نوع لها سيوّلد خطأً: تُظهر نهاية رسالة الخطأ المشكلة: الحقل غير ملائم. فقد حددنا نوعه في واجهة النوع على أنه DiaryEntry، لكن المصرِّف استدل من القيمة المسندة إليه أنه من النوع string. يمكن حل المشكلة بتأكيد النوع type assertion. ولا ينبغي فعل ذلك إلا عندما نعلم قطعًا مالذي نفعله. فلو أكدنا أن نوع المتغير diaryData هو DiaryEntry باستخدام التعليمة as سيعمل كل شيء على مايرام. import diaryData from '../../data/entries.json' import { Weather, Visibility, DiaryEntry } from '../types' const diaries: Array<DiaryEntry> = diaryData as Array<DiaryEntry>; const getEntries = (): Array<DiaryEntry> => { return diaries; } const addEntry = () => { return null } export default { getEntries, addEntry }; لا تستخدم أسلوب تأكيد النوع إلا في غياب أية طرق أخرى للمتابعة. فتأكيد نوع غير مناسب سيسبب أخطاءً مزعجة في زمن التشغيل. على الرغم من أن المصرِّف سيثق بك عندما تستخدم as، لكن بعملك هذا لن تكون قد سخرت الإمكانيات الكاملة للغة TypeScript بل اعتمدت على المبرمج لضمان عمل الشيفرة. من الممكن في حالتنا تغيير الطريقة التي نصدّر بها البيانات، وذلك بإعطائها نوعًا. وبما أننا لا نستطيع استخدام الأنواع داخل ملف JSON لابد من تحويله إلى ملف TS الذي يصدِّر البيانات على النحو التالي: import { DiaryEntry } from "../src/types"; const diaryEntries: Array<DiaryEntry> = [ { "id": 1, "date": "2017-01-01", "weather": "rainy", "visibility": "poor", "comment": "Pretty scary flight, I'm glad I'm alive" }, // ... ]; export default diaryEntries; وهكذا سيفسر المصرِّف المصفوفة عند إدراجها بشكل صحيح، وسيفهم نوع الحقلين weather وvisibility: import diaries from '../../data/diaries'; import { DiaryEntry } from '../types'; const getEntries = (): Array<DiaryEntry> => { return diaries; } const addEntry = () => { return null; } export default { getEntries, addEntry }; لو أردت أن تُخزّن مُدخلًا ما باستثناء حقل محدد، يمكنك ضبط نوع هذا الحقل كحقل اختياري بإضافة "؟" إلى تعريف النوع: export interface DiaryEntry { id: number; date: string; weather: Weather; visibility: Visibility; comment?: string; } وحدات Node وJSON من المهم أن تنتبه إلى مشكلة قد تظهر عند استخدام الخيار resolveJsonModule في الملف tsconfig: { "compilerOptions": { // ... "resolveJsonModule": true } } فبناء على توثيق node المتعلق بملفات الوحدات، ستحاول node أن تنفّذ الوحدات وفق ترتيب اللاحقات كالتالي: ["js", "json", "node"] وبالإضافة إلى ذلك ستوسِّع المكتبتان ts-node وts-node-dev قائمة اللاحقات لتصبح كالتالي: ["js", "json", "node", "ts", "tsx"] لنتأمل الهيكيلة التالية لمجلد يحتوي على ملفات: ├── myModule.json └── myModule.ts إن ضبطنا الخيار resolveJsonModule على القيمة true في TypeScript، سيصبح الملف myModule.json وحدة node صالحة للاستخدام. لنتخيل الآن السيناريو الذي نرغب فيه باستخدام الملف myModule.ts: import myModule from "./myModule"; بالنظر إلى ترتيب لاحقات الوحدات في node: ["js", "json", "node", "ts", "tsx"] سنلاحظ أن الأولوية ستكون للملف ذو اللاحقة "json." على الملف ذو اللاحقة "ts."، وبالتالي سيُدرج الملف myModule.json بدلًا من الملف myModule.ts. ولكي نتجنب هذه الثغرة التي ستضيع الكثير من الوقت، يفضل أن نسمي الملفات التي تحمل لاحقة تفهمها node بأسماء مختلفة. الأنواع الخدمية قد نحتاج أحيانًا إلى إجراء تعديلات محددة على نوع. فلنفترض مثلًا وجود صفحة لعرض قائمة من البيانات التي يعتبر بعضها بيانات حساسة وأخرى غير حساسة. ونريد أن نضمن عدم عرض البيانات الحساسة. يمكننا اختيار الحقول التي نريدها من نوع محدد، عن طريق استخدام أحد الأنواع الخدمية Utility type ويدعى Pick. ينبغي علينا في مشروعنا أن نفترض أن Ilari يريد إظهار قائمة بالمذكرات دون أن يُظهر حقل التعليقات، فقد يكتب شيئًا لايريد إظهاره لأحد، وخاصة في الرحلات التي أفزعته بشدة. يسمح النوع الخدمي Pick أن نختار ما نريد استخدامه من حقول نوع محدد. ويستخدم هذا النوع لإنشاء نوع جديد تمامًا أو لإعلام الدالة ما يجب أن تعيده في زمن التشغيل. فالأنواع الخدمية هي صنف خاص من أدوات إنشاء الأنواع، لكن يمكن استخدامها كما نستخدم أي نوع آخر. ولكي ننشئ نسخة خاضعة للرقابة من النوع DiaryEntry، سنستخدم Pick عند تعريف الدالة: const getNonSensitiveEntries = (): Array<Pick<DiaryEntry, 'id' | 'date' | 'weather' | 'visibility'>> => { // ... } سيتوقع المصرِّف أن تعيد الدالة مصفوفة قيم من النوع DiaryEntry المعدّل والذي يتضمن الحقول الأربعة المختارة فقط. وطالما أن النوع Pick سيتطلب أن يكون النوع الذي سيعدّله معطًى كنوع متغير، كما هو حال المصفوفة، سيكون لدينا نوعين متغيرين ومتداخلين، وستبدو العبارة غريبة بعض الشيء. يمكن تحسين القدرة على قراءة الشيفرة باستخدام العبارة البديلة للمصفوفة: const getNonSensitiveEntries = (): Pick<DiaryEntry, 'id' | 'date' | 'weather' | 'visibility'>[] => { // ... } سنحتاج في حالتنا إلى استثناء حقل واحد، لذلك من الأفضل أن نستخدم النوع الخدمي Omit والذي يحدد الحقول التي يجب استبعادها من نوع: const getNonSensitiveEntries = (): Omit<DiaryEntry, 'comment'>[] => { // ... } وكطريقة أخرى، يمكن التصريح عن نوع جديد NonSensitiveDiaryEntry كالتالي: export type NonSensitiveDiaryEntry = Omit<DiaryEntry, 'comment'>; ستصبح الشيفرة الآن على النحو: import diaries from '../../data/diaries'; import { NonSensitiveDiaryEntry, DiaryEntry } from '../types'; const getEntries = (): DiaryEntry[] => { return diaries; }; const getNonSensitiveEntries = (): NonSensitiveDiaryEntry[] => { return diaries; }; const addEntry = () => { return null; }; export default { getEntries, addEntry, getNonSensitiveEntries}; هنالك شيء واحد سيسبب القلق في تطبيقنا. ستعيد الدالة getNonSensitiveEntries كامل حقول المُدخلات عند تنفيذها، ولن يعطينا المصرِّف أية أخطاء على الرغم من تحديد نوع المعطيات المعادة. يحدث هذا لسبب بسيط هو أن TypeScript ستتحقق فقط من وجود كل الحقول المطلوبة، ولن تمنع وجود حقول زائدة. فلن تمنع في حالتنا إعادة كائن من النوع [ ]DiaryEntry، لكن لو حاولنا الوصول إلى الحقل comment فلن نتمكن من ذلك، لأن TypeScript لا تراه على الرغم من وجوده. سيقود ذلك إلى سلوك غير مرغوب إن لم تكن مدركًا ما تفعله. فلو كنا سنعيد كل المُدخلات عند تنفيذ الدالة getNonSensitiveEntries إلى الواجهة الأمامية، ستكون النتيجة وصول قيمٍ لحقول لانريدها إلى المتصفح الذي أرسل الطلب، وهذا مخالف لتعريف نوع القيم المعادة. علينا إذًا استثناء تلك الحقول بأنفسنا، طالما أن TypeScript لا تعدّل البيانات الفعلية المعادة بل نوعها فقط: import diaries from '../../data/entries.js' import { NonSensitiveDiaryEntry, DiaryEntry } from '../types' const getEntries = () : DiaryEntry[] => { return diaries } const getNonSensitiveEntries = (): NonSensitiveDiaryEntry [] => { return diaries.map(({ id, date, weather, visibility }) => ({ id, date, weather, visibility, }));}; const addDiary = () => { return [] } export default { getEntries, getNonSensitiveEntries, addDiary } فلو أردنا الآن إعادة هذه البيانات على أنها من النوع "DiaryEntry" كالتالي: const getNonSensitiveEntries = () : DiaryEntry[] => { سنحصل على الخطأ التالي: سيقدم لنا السطر الأخير من الرسالة هذه المرة أيضًا فكرة الحل. لنتراجع عن هذا التعديل. تتضمن الأنواع الخدمية العديد من الوسائل المفيدة، وتستحق أن نقف عندها لبعض الوقت ونطلع على توثيقها. وأخيرًا يمكننا إكمال المسار الذي يعيد كل المُدخلات: import express from 'express'; import diaryService from '../services/diaryService'; const router = express.Router(); router.get('/', (_req, res) => { res.send(diaryService.getNonSensitiveEntries());}); router.post('/', (_req, res) => { res.send('Saving a diary!'); }); export default router; وستكون الاستجابة كما نريدها: التمرينان 9.10 -9.11 لن نستخدم في هذه التمارين قاعدة بيانات حقيقية في تطبيقنا، بل بيانات محضرة مسبقًا موجودة في الملفين diagnoses.json وpatients.json. نزِّل الملفين وخزنهما في مجلد يدعى data في مشروعك. ستجري التعديلات على البيانات في ذاكرة التشغيل فقط، فلا حاجة للكتابة على الملفين في هذا القسم. 9.10 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 3 أنشئ النوع daiagnoses واستخدمه لإنشاء وصلة التخديم لإحضار كل تشخيصات المريض من خلال الطلب HTTP -GET. اختر هيكيلة مناسبة لشيفرتك عبر اختيار أسماء ملائمة للملفات والمجلدات. ملاحظة: قد يحتوي النوع daiagnoses على الحقل latin وقد لا يحتويه. ربما ستحتاج إلى استخدام الخصائص الاختيارية عند تعريف النوع. 9.11 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 4 أنشئ نوعًا للبيانات باسم Patient ثم أنشئ وصلة تخديم لطلبات GET على العنوان api/patients/ لتعيد بيانات كل المرضى إلى الواجهة الأمامية لكن دون الحقل ssn. استخدم الأنواع الخدمية للتأكد من أنك أعدت الحقول المطلوبة فقط. يمكنك في هذا التمرين افتراض نوع الحقل gender على أنه string. جرب وصلة التخديم من خلال المتصفح، وتأكد أن الحقل ssn غير موجود في بيانات الاستجابة: تأكد من عرض الواجهة الأمامية لقائمة المرضى بعد إنشائك لوصلة التخديم: منع الحصول على نتيجة غير محددة عرضيا سنوسع الواجهة الخلفية لدعم وظيفة الحصول على مُدخل محدد من خلال الطلبGET إلى العنوان api/diaries/:id. سنوسّع الخدمة DiaryService بإنشاء الدالة findById: // ... const findById = (id: number): DiaryEntry => { const entry = diaries.find(d => d.id === id); return entry;}; export default { getEntries, getNonSensitiveEntries, addDiary, findById} مرة أخرى سيواجهنا الخطأ التالي: تظهر المشكلة لأن وجود مُدخل بمعرِّف id محدد غير مضمون. من الجيد أننا أدركنا هذه المشكلة في مرحلة التصريف. فلو لم نستخدم TypeScript لما جرى تحذيرنا من قبل المصرِّف، وقد نعيد في السيناريو الأسوء كائنًا غير محدد بدلًا من تنبيه المستخدم أنه لاتوجد مُدخلات بهذا المعرِّف. نحتاج قبل كل شيء في حالات كهذه إلى اتخاذ قرار بشأن القيمة المعادة إن لم نجد الكائن المطلوب، وكيفية معالجة هذه الحالة. يعيد تابع المصفوفات find كائنًا غير محدد إن لم يجد الكائن المطلوب، ولا مشكلة في ذلك. إذ يمكن حل مشكلتنا بتحدبد نوع للقيمة المعادة على النحو التالي: const findById = (id: number): DiaryEntry | undefined => { const entry = diaries.find(d => d.id === id); return entry; } وسنستخدم معالج المسار التالي: import express from 'express'; import diaryService from '../services/diaryService' router.get('/:id', (req, res) => { const diary = diaryService.findById(Number(req.params.id)); if (diary) { res.send(diary); } else { res.sendStatus(404); } }) // ... export default router; إضافة مذكرة جديدة لنبدأ بإنشاء وصلة تخديم لطلبات HTTP-POST وذلك لإضافة مّدخلات جديدة إلى المذكرات. ينبغي أن تحمل المدخلات النوع ذاته للبيانات المطلوبة. تعالج الشيفرة التالية بيانات الاستجابة: router.post('/', (req, res) => { const { date, weather, visibility, comment } = req.body; const newDiaryEntry = diaryService.addDiary( date, weather, visibility, comment, ); res.json(newDiaryEntry); }); وسيكون التابع الموافق في شيفرة الخدمة DiaryService كالتالي: import { NonSensitiveDiaryEntry, DiaryEntry, Visibility, Weather} from '../types'; const addDiary = ( date: string, weather: Weather, visibility: Visibility, comment: string ): DiaryEntry => { const newDiaryEntry = { id: Math.max(...diaries.map(d => d.id)) + 1, date, weather, visibility, comment, } diaries.push(newDiaryEntry); return newDiaryEntry; }; وكما ترى، سيغدو من الصعب قراءة الدالة addDiary نظرًا لكتابة كل الحقول كمعاملات منفصلة. وقد يكون من الأفضل لو أرسلنا البيانات ضمن كائن واحد إلى الدالة: router.post('/', (req, res) => { const { date, weather, visibility, comment } = req.body; const newDiaryEntry = diaryService.addDiary({ date, weather, visibility, comment, }); res.json(newDiaryEntry); }) لكن ما نوع هذا الكائن؟ فهو ليس من النوع DiaryEntry تمامًا كونه يفتقد الحقل id. من الأفضل هنا لو أنشأنا نوعًا جديدًا باسم NewDiaryEntry للمُدخلات التي لم تُخزَّن بعد. لنعرّف هذا النوع في الملف types.ts مستخدمين النوع DiaryEntry مع النوع الخدمي Omit. export type NewDiaryEntry = Omit<DiaryEntry, 'id'>; يمكننا الآن استخدام النوع الجديد في الخدمة DiaryService، بحيث يُفكَّك الكائن الجديد عند إنشاء مُدخل جديد من أجل تخزينه: import { NewDiaryEntry, NonSensitiveDiaryEntry, DiaryEntry } from '../types'; // ... const addDiary = ( entry: NewDiaryEntry ): DiaryEntry => { const newDiaryEntry = { id: Math.max(...diaries.map(d => d.id)) + 1, ...entry }; diaries.push(newDiaryEntry); return newDiaryEntry; }; ستبدو الشيفرة أوضح الآن! ولتفسير البيانات المستقبلة، علينا تهيئة أداة JSON الوسطية: import express from 'express'; import diaryRouter from './routes/diaries'; const app = express(); app.use(express.json()); const PORT = 3000; app.use('/api/diaries', diaryRouter); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); أصبح التطبيق الآن جاهزًا لاستقبال طلبات HTTP-POST لإنشاء مُدخلات جديدة بالنوع الصحيح. التأكد من الطلب هناك الكثير من الأمور التي قد تسير بشكل خاطئ عند استقبال بيانات من مصدر خارجي. فلا يمكن أن تعمل التطبيقات بنفسها إلا نادرًا، وعلينا أن نقبل حقيقة أن البيانات التي نحصل عليها من خارج منظومة التطبيق غير موثوقة بشكل كامل. فلا يمكن أن تكون البيانات عند استقبالها من مصدر خارجي محددة النوع مسبقًا. وعلينا أن نقرر الأسلوب الذي سنتعامل به مع ضبابية هذه الحالة. تعالج express الموضوع بتأكيد النوع any لكل حقول جسم الطلب. لن يكون هذا السلوك واضحًا للمصرِّف في حالتنا، لكن لو حاولنا إلقاء نظرة أقرب إلى المتغيرات، ومررنا مؤشر الفأرة فوق كل منها، سنجد أن نوع كل منها هو بالفعل any. وبالتالي لن يعترض المحرر أبدًا عندما نمرر هذه البيانات إلى الدالة addDiary كمعاملات. يمكن إسناد القيم من النوع any إلى متغيرات من أي نوع، فلربما تكون هي القيمة المطلوبة. وهذا الأسلوب يفتقر بالتأكيد إلى عامل الأمان، لذا علينا التحقق من القيم المستقبلة سواءً استخدمنا TypeScript أو لا. بالإمكان إضافة آليتي تحقق بسيطتين exist و is_value_valid ضمن الدالة التي تعرّف الوجهة route، لكن من الأفضل كتابة منطق تفسير البيانات وتقييمها في ملف منفصل "utils.ts". لابد من تعريف الدالة toNewDiaryEntry التي تستقبل جسم الطلب كمعامل وتعيد كائن NewDiaryEntry بنوع مناسب. يستخدم تعريف الوجهة هذه الدالة على النحو التالي: import toNewDiaryEntry from '../utils'; // ... router.post('/', (req, res) => { try { const newDiaryEntry = toNewDiaryEntry(req.body); const addedEntry = diaryService.addDiary(newDiaryEntry); res.json(addedEntry); } catch (e) { res.status(400).send(e.message); } }) طالما أننا نحاول الآن كتابة شيفرة آمنة، وأن نتأكد من تلقي البيانات التي نريدها تمامًا من الطلبات، علينا أن نتحقق من ترجمة وتقييم بيانات كل حقل نتوقع أن نستقبله. سيبدو هيكل الدالة toNewDiaryEntry على النحو التالي: import { NewDiaryEntry } from './types'; const toNewDiaryEntry = (object): NewDiaryEntry => { const newEntry: NewDiaryEntry = { // ... } return newEntry; } export default toNewDiaryEntry; على الدالة ترجمة كل حقل والتأكد أن نوع كل حقل من حقول القيمة المعادة هو NewDiaryEntry. ويعني هذا التحقق من كل حقل على حدى. ستواجهنا أيضًا في هذه المرحلة مشاكل تتعلق بالأنواع: ماهو نوع الكائن المعاد، طالما أنه جسم الطلب في الحقيقة من النوع any؟ إن فكرة هذه الدالة، هي الربط بين الحقول مجهولة النوع بحقول من النوع الصحيح والتحقق من أنها معرّفة كحقول متوقعة أولا. وربما تكون هذه الحالة هي الحالة النادرة التي سنسمح فيها باستخدام النوع any. سيعترض المدقق eslint على استخدام النوع any: وسبب ذلك، هو تفعيل القاعدة no-explicit-any التي تمنع التصريح علنًا عن النوع any. وهي في الواقع قاعدة جيدة لكنها غير مطلوبة في هذا الجزء من الملف. يمككنا أن نتجاوز هذه القاعدة بإلغاء تفعيلها عن طريق وضع السطر التالي في ملف الشيفرة: /* eslint-disable @typescript-eslint/no-explicit-any */ سنبدأ الآن بإنشاء المفسّرات لكل حقل من حقول الكائن المُعاد. لتقييم الحقل comment لابد من التحقق أنه موجود وأنه يحمل النوع string. ستبدو الدالة على نحو مماثل لما يلي: const parseComment = (comment: any): string => { if (!comment || !isString(comment)) { throw new Error('Incorrect or missing comment: ' + comment); } return comment; } تتلقى الدالة معاملُا من النوع any وتعيده من النوع string إن كان موجودًا ومن النوع الصحيح. ستبدو دالة تقييم النوع string كالتالي: const isString = (text: any): text is string => { return typeof text === 'string' || text instanceof String; }; تدعى هذه الدالة أيضًا بالدالة حامية النوع type guard. وتعرَّف بأنها دالة تعيد قيمة منطقية ونوعًا إسناديُا (type predicate -يسند نوعا صريحًا لقيمة معادة-). سيكون شكل النوع الإسنادي في حالتنا هو التالي: text is string إن الشكل العام للنوع الإسنادي هو parameterName is Type حيث يمثل parameterName معامل الدالة ويمثل "Type" النوع الذي سيُعاد. فإن أعادت الدالة حامية النوع القيمة true، سيعرف مصرِّف TypeScript أن للمتغير المُختَبَر النوع ذاته الذي حدده النوع الإسنادي. قبل أن تُستدعى الدالة حامية للنوع سيكون النوع الفعلي للمتغير comment مجهولًا. لكن بعد استدعائها سيعلم المصرِّف -في حال أعادت حامية النوع القيمة true، أن المتغير من النوع string. لماذا نرى شرطين في حامية النوع string؟ const isString = (text: any): text is string => { return typeof text === 'string' || text instanceof String;} أليس كافيًا أن نكتب شيفرة الحامية على الشكل: const isString = (text: any): text is string => { return typeof text === 'string'; } إن الشكل الأبسط في أغلب الأحيان كافٍ للتطبيق العملي. لكن إن أردنا أن نكون متأكدين تمامًا لابد من وجود الشرطين. فهنالك طريقتان لإنشاء كائن من النوع string في JavaScript وكلاهما يعمل بطريقة تختلف قليلًا عن الآخر بما يتعلق باستخدام العاملين typeof و instanceof. const a = "I'm a string primitive"; const b = new String("I'm a String Object"); typeof a; --> returns 'string' typeof b; --> returns 'object' a instanceof String; --> returns false b instanceof String; --> returns true لن يفكر في الغالب أحد باستخدام الدالة البانية لإنشاء قيمة نصية، وبالتالي ستكون النسخة الأبسط للدالة الحامية مناسبة. لنتأمل الآن الحقل date. سيُترجم ويُقيَّم هذا الحقل بطريقة مشابه كثيرًا لطريقة تقييم الحقل comment. وطالما أن TypeScript لا تعرف نوعًا للتاريخ، سنضطر للتعامل معه كنص string. ولابد كذلك من التحقق على مستوى JavaScript أن تنسيق التاريخ مقبول. سنضيف الدوال التالية: const isDate = (date: string): boolean => { return Boolean(Date.parse(date)); }; const parseDate = (date: any): string => { if (!date || !isString(date) || !isDate(date)) { throw new Error('Incorrect or missing date: ' + date); } return date; }; لا يوجد شيء مميز في هذه الشيفرة، لكن يجدر الإشارة أنه لا يمكن استعمال الدالة حامية النوع لأن التاريخ هنا سيعتبر من النوع string ليس إلا. ولاحظ على الرغم من أن الدالة parseDate تقبل المتغير date على أنه من النوع any، فسيتغير نوعه إلى string بعد التحقق من نوعه باستخدام isString. وهذا مايفسر الحاجة لقيمة نصية صحيحة التنسيق، عند تمرير المتغير إلى الدالة isDate. سننتقل الآن إلى النوعين الأخيرين Weather و Visibility. نريد أن تجري عمليتي الترجمة والتقييم كالتالي: const parseWeather = (weather: any): Weather => { if (!weather || !isString(weather) || !isWeather(weather)) { throw new Error('Incorrect or missing weather: ' + weather) } return weather; }; لكن كيف يمكننا تقييم نص ما على أنه من تنسيق أو شكل محدد؟ تقتضي أحدى الطرق المتبعة، كتابة دالة حامية للنوع بالشكل التالي: const isWeather = (str: string): str is Weather => { return ['sunny', 'rainy', 'cloudy', 'stormy' ].includes(str); }; سيعمل الحل السابق بشكل جيد، لكن المشكلة أن قائمة الأحوال الجوية المحتملة قد لاتبقى متزامنة مع تعريفات النوع إن حدث أي تبديل فيها (خطأ في الكتابة مثلًا). وهذا بالطبع ليس جيدًا، لأننا نرغب بوجود مصدر وحيد لكل أنواع الطقس الممكنة. من الأفضل في حالتنا أن نحسَّن تعريف أنواع الطقس. فبدلًا من النوع البديل لابد من استخدام تعداد TypeScript الذي يسمح لنا باستخدام القيمة الفعلية في الشيفرة في زمن التشغيل، وليس فقط في مرحلة التصريف. لنعد تعريف النوع Weather ليصبح كالتالي: export enum Weather { Sunny = 'sunny', Rainy = 'rainy', Cloudy = 'cloudy', Stormy = 'stormy', Windy = 'windy', } يمكننا التحقق الآن أن القيمة النصية المستقبلة هي إحدى أنواع الطقس الممكنة، وستكتب الدالة الحامية للنوع كالتالي: const isWeather = (param: any): param is Weather => { return Object.values(Weather).includes(param); }; لاحظ كيف غيرنا نوع المعامل إلى any فلو كان من النوع string، لن يُترجَم التابع includes. وهذا منطقي وخاصة إذا أخذنا في الحسبان إعادة استخدام الدالة لاحقًا. بهذه الطريقة سنكون متأكدين تمامًا أنه أيًا كانت القيمة التي ستمرر إلى الدالة، فستخبرنا إن كانت القيمة مناسبة للطقس أو لا. يمكن تبسيط الدالة قليلًا كما يلي: const parseWeather = (weather: any): Weather => { if (!weather || !isWeather(weather)) { throw new Error('Incorrect or missing weather: ' + weather); } return weather; }; ستظهر مشكلة بعد هذه التغييرات. لن تلائم البيانات الأنواع التي عرّفناه بعد الآن. والسبب أننا لا نستطيع ببساطة افتراض أن القيمة string على أنها تعداد enum. يمكننا حل المشكلة بربط عناصر البيانات الأولية (المحضرة مسبقًا) بالنوع DiaryEntry مستخدمين الدالة toNewDiaryEntry: import { DiaryEntry } from "../src/types"; import toNewDiaryEntry from "../src/utils"; const data = [ { "id": 1, "date": "2017-01-01", "weather": "rainy", "visibility": "poor", "comment": "Pretty scary flight, I'm glad I'm alive" }, // ... ] const diaryEntries: DiaryEntry [] = data.map(obj => { const object = toNewDiaryEntry(obj) as DiaryEntry object.id = obj.id return object }) export default diaryEntries وطالما أن الدالة ستعيد كائنًا من النوع NewDiaryEntry، فعلينا التأكيد على أنه من النوع DiaryEntry باستخدام العامل as. يستخدم التعداد عادة عندما نكون أمام مجموعة من القيم المحددة مسبقًا ولا نتوقع تغييرها في المستقبل، خاصة القيم التي يصعب تغييرها مثل أيام الأسبوع وأسماء الأشهر وغيرها. لكن طالما أنها تقدم أسلوبًا جيدًا في تقييم القيم الواردة فمن المفيد استخدامها في حالتنا. وكذلك الأمر لابد من التعامل مع النوع visibility بنفس الأسلوب. سيبدو التعداد الخاص به كالتالي: export enum Visibility { Great = 'great', Good = 'good', Ok = 'ok', Poor = 'poor', } وستمثل الشيفرة التالية الدالة الحامية للنوع: const isVisibility = (param: any): param is Visibility => { return Object.values(Visibility).includes(param); }; const parseVisibility = (visibility: any): Visibility => { if (!visibility || !isVisibility(visibility)) { throw new Error('Incorrect or missing visibility: ' + visibility); } return visibility; }; وهكذا نكون قد انتهينا من إنشاء الدالة toNewDiaryEntry التي تتحمل مسؤولية تقييم وتفسير حقول بيانات الطلب: const toNewDiaryEntry = (object: any): NewDiaryEntry => { return { date: parseDate(object.date), comment: parseComment(object.comment), weather: parseWeather(object.weather), visibility: parseVisibility(object.visibility) }; }; وبهذا ننهي النسخة الأولى من تطبيق مذكرات الرحلات الجوية! وفي حال أردنا إدخال مذكرة جديدة بحقول مفقودة أو غير صالحة سنحصل على رسالة الخطأ التالية: التمرينان 9.12 - 9.13 9.12 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 5 أنشأ وصلة تخديم للطلب POST على العنوان api/patients/ بغرض إضافة مرضى جدد. وتأكد من إمكانية إضافة مرضى عبر الواجهة الأمامية أيضًا. 9.13 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 6 جد آلية أمنة لتفسير وتقييم وحماية الأنواع لطلبات HTTP-POST إلى العنوان api/patients/ أعد كتابة الحقل Gender مستخدمًا التعداد (enum). تصريف -وبتصرف- للفصل Typing the express app من سلسلة Deep Dive Into Modern Web Development
  7. بعد المقدمة الموجزة التي تحدثنا فيها عن أساسيات اللغة TypeScript في المقال السابق، سنكون جاهزين لبدء رحلتنا لنصبح مطورين شاملين لتطبيقات الويب باستخدام TypeScript. سنركز في هذا القسم على المشاكل التي قد تنشأ عن استخدام اللغة لتطوير تطبيقات express للواجهة الخلفية وReact للواجهة الأمامية، بدلًا من تقديم مدخل موّسع عن كل جوانب اللغة. وسنركز أيضًا على استخدام أدوات التطوير المتاحة بالإضافة إلى الميزات التي تقدمها اللغة. تجهيز كل شيء لبدء العمل ثَبّت ما تحتاجه لاستخدام TypeScript على المحرر الذي تختاره، ستحتاج إلى تثبيت الموسِّع typescript hero إن كنت ستعمل على Visual Studio Code. لا يمكن تنفيذ شيفرة TypeScript مباشرة كما ذكرنا سابقًا، بل علينا تصريفها إلى شيفرة JavaScript قابلة للتنفيذ. عندما تصرّف شيفرة اللغة إلى JavaScript ستصبح عرضًة لإزالة الأنواع. أي ستزال مسجلات النوع والاستدلالات والأسماء المستعارة للأنواع وكل معطيات نظام تحديد النوع الأخرى، وستكون النتيجة شيفرة JavaScript صرفة جاهزة للتنفيذ. نقصد أحيانًا بالحاجة إلى التصريف في بيئات الإنتاج، أن نوجد "خطوة لبناء التطبيق". حيث تُصرّف كل شيفرة TypeScript خلال خطوة بناء التطبيق إلى شيفرة JavaScript ضمن مجلد منفصل، ومن ثم تُنفِّذ بيئة الإنتاج الشيفرة الموجودة ضمن هذا المجلد. يُفضَّل في بيئات الإنتاج أن نستخدم التصريف في الزمن الحقيقي، وإعادة التحميل التلقائي لنتمكن من متابعة النتائج بشكل أسرع. لنبدأ بكتابة أول تطبيق لنا باستخدام TypeScript. ولتبسيط الأمر، سنستخدم حزمة npm تُدعى ts-node. حيث تصرّف هذه المكتبة ملف TypeScript وتنفذه مباشرة. وبالتالي لا حاجة لخطوة تصريف مستقلة. يمكن تثبيت المكتبة ts-node والحزمة الرسمية للغة TypeScript لكافة المشاريع globally كالتالي: npm install -g ts-node typescript إن لم تستطع أو لم ترد تثبيت حزم شاملة، يمكنك إنشاء مشروع npm وتثبت فيه الاعتماديات اللازمة فقط ومن ثم تُنفِّذ شيفرتك ضمنه. كما سنستخدم المقاربة التي اتبعناها في القسم 3، حيث نهيئ مشروع npm بتنفيذ الأمر npm init في مجلد فارغ، ونثبّت بعدها الاعتماديات بتنفيذ الأمر: npm install --save-dev ts-node typescript كما يجب أن نهيئ تعليمة scripts ضمن الملف package.json كالتالي: { // .. "scripts": { "ts-node": "ts-node" }, // .. } يمكنك استخدام ts-node ضمن المجلد بتنفيذ الأمر npm run ts-node. وتجدر الملاحظة أن استخدام ts-node من خلال الملف package.json يقتضي أن تبدأ كل أسطر الأوامر بالرمز "--". لذا سيكون عليك تنفيذ الأمر التالي إن أردت مثلًا تنفيذ الملف file.ts باستخدام ts-node: npm run ts-node -- file.ts نشير أيضًا إلى وجود أرضية عمل خاصة باللغة TypeScript على الإنترنت، حيث يمكنك تجريب شيفرتك والحصول على شيفرة JavaScript الناتجة وأخطاء التصريف المحتملة بسرعة. ملاحظة: يمكن أن تتضمن أرضية العمل قواعد تهيئة مختلفة (وهذا ما سنوضحه لاحقًا) عما هو موجود في بيئة التطوير الخاصة بك، لهذا قد تجد تحذيرات مختلفة. يمكن تعديل قواعد التهيئة لأرضية العمل هذه من خلال القائمة المنسدلة config. ملاحظة عن أسلوب كتابة الشيفرة إن لغة JavaScript بحد ذاتها مريحة الاستخدام، ويمكنك أن تنفذ ما تريده بطرق مختلفة في أغلب الأحيان. فعلى سبيل المثال يمكنك استخدام الدوال named، أو anynamous، وقد تستخدم const، أو var، أو let للتصريح عن متغير، وكذلك الاستخدامات المختلفة للفاصلة المنقوطة. سيختلف هذا القسم عن البقية بطريقة استخدام الفاصلة المنقوطة. ولا يمثل هذا الاختلاف طريقة خاصة باللغة لكنه أكثر ما يكون أسلوبًا لكتابة شيفرة أي نوع من JavaScript. واستخدام هذا التنسيق أو عدمه سيعود كليًا للمبرمج. لكن من المتوقع أن يغير أسلوبه في كتابة الشيفرة لتتماشى مع الأسلوب المتبع في هذا القسم، إذ يجب إنجاز تمارين القسم مستخدمًا الفواصل المنقوطة ومراعيًا أسلوب كتابة الشيفرة المستخدم فيه. كما ستجد اختلافًا في تنسيق الشيفرة في هذا القسم بالمقارنة مع التنسيق الذي اتبعناه في بقية أقسام المنهاج كتسمية المجلدات مثلًا. لنبدأ بإنشاء دالة بسيطة لتنفيذ عملية الضرب. ستبدو تمامًا كما لو أنها مكتوبة بلغة JavaScript: const multiplicator = (a, b, printText) => { console.log(printText, a * b); } multiplicator(2, 4, 'Multiplied numbers 2 and 4, the result is:'); وكما نرى، فالدالة مكتوبة بشيفرة JavaScript عادية دون استخدام ميزات TS. ستصرّف وتنفذ الشيفرة بلا مشاكل باستخدام الأمر npm run ts-node -- multiplier. لكن ما الذي سيحدث إن مررنا نوعًا خاطئًا من المعاملات إلى الدالة؟ لنجرب ذلك! const multiplicator = (a, b, printText) => { console.log(printText, a * b); } multiplicator('how about a string?', 4, 'Multiplied a string and 4, the result is:'); عند تنفيذ الشيفرة، سيكون الخرج: "Multiplied a string and 4, the result is: NaN". أليس من الجيد أن تمنعنا اللغة من ارتكاب هذا الخطأ؟ سترينا هذه المشكلة أولى مزايا TS. لنعرف أنواعًا للمعاملات ونرى ما الذي سيتغير. تدعم اللغة TS أنواعًا مختلفة، مثل: number، وstring، وArray. يمكنك إيجاد قائمة بالأنواع في توثيق TS على الانترنت. كما يمكن إنشاء أنواع مخصصة أكثر تعقيدًا. إنّ أول معاملين لدالة الضرب من النوع number والثالث من النوع string. const multiplicator = (a: number, b: number, printText: string) => { console.log(printText, a * b); } multiplicator('how about a string?', 4, 'Multiplied a string and 4, the result is:'); لم تعد الشيفرة السابقة شيفرة JavaScript صالحة للتنفيذ. فلو حاولنا تنفيذها، لن تُصرَّف: إنّ إحدى أفضل ميزات محرر TS، أنك لن تحتاج إلى تنفيذ الشيفرة حتى ترى المشكلة. فالإضافة VSCode فعالة جدًا، حيث ستبلغك مباشرة إن حاولت استخدام النوع الخاطئ. إنشاء أول نوع خاص بك لنطور دالة الضرب إلى آلة حاسبة تدعم أيضًا الجمع والقسمة. ينبغي أن تقبل الآلة الحاسبة ثلاث معاملات: عددين وعملية قد تكون الضرب multiply، أو الجمع add، أو القسمة divide. تحتاج الشيفرة في JavaScript إلى تقييم إضافي للمعامل الثالث لتتأكد من كونه قيمة نصية. بينما تقدم TS طريقة لتعريف أنواع محددة من الدخل، بحيث تصف تمامًا الأنواع المقبولة. وعلاوة على ذلك، ستظهر لك TS معلومات عن القيم الصالحة للاستخدام ضمن المحرر. يمكن إنشاء نوع باستخدام التعليمة الداخلية type في TS. سنصف فيما سيأتي النوع Operation الذي عرًفناه: type Operation = 'multiply' | 'add' | 'divide'; يقبل النوع Op ثلاثة أشكال من الدخل وهي تمامًا القيم النصية الثلاثة التي نريد. يمكننا باستخدام العامل OR ('|') تعريف متغير يقبل قيمًا متعددة وذلك بإنشاء نوع موحَّد union type.لقد استخدمنا في الحالة السابقة القيمة النصية بحرفيتها (وهذا ما يدعى التعريف المختصر للنوع النصي string literal types)، لكن باستخدام الأنواع الموحَّدة، ستجعل المصرِّف قادرًا على قبول قيمة نصية أو عددية: string | number. تُعرّف التعليمة type اسمًا جديدًا للنوع أو ما يسمى اسمًا مستعارًا للنوع. وطالما أن النوع المُعرَّف هو اتحاد بين ثلاث قيم مقبولة، فمن المفيد إعطاء النوع اسمًا مستعارًا يصفه بشكل جيد. لنلق نظرة على الآلة الحاسبة: type Operation = 'multiply' | 'add' | 'divide'; const calculator = (a: number, b: number, op : Operation) => { if (op === 'multiply') { return a * b; } else if (op === 'add') { return a + b; } else if (op === 'divide') { if (b === 0) return 'can\'t divide by 0!'; return a / b; } } ستلاحظ الآن أنك لو مررت الفأرة فوق النوع Operation في المحرر، ستظهر لك مباشرة اقتراحات عن طريقة استخدامه: ولو أردنا استخدام قيمة غير موجودة ضمن النوع Operation، ستظهر لك مباشرة إشارة التحذير الحمراء ومعلومات إضافية ضمن المحرر: لقد أبلينا جيدًا حتى اللحظة، لكننا لم نطبع بعد النتيجة التي تعيدها دالة الآلة الحاسبة. غالبًا ما نريد معرفة النتيجة التي تعيدها الدالة، ومن الأفضل أن نضمن أن الدالة ستعيد القيمة التي يفترض أن تعيدها. لنضف إذًا القيمة المعادة number إلى الدالة: type Operation = 'multiply' | 'add' | 'divide'; const calculator = (a: number, b: number, op: Operation): number => { if (op === 'multiply') { return a * b; } else if (op === 'add') { return a + b; } else if (op === 'divide') { if (b === 0) return 'this cannot be done'; return a / b; } } سيعترض المصرِّف مباشرة على فعلتنا، ذلك أن الدالة ستعيد في إحدى الحالات (القسمة على صفر) قيمة نصية. هناك عدة طرق لحل المشكلة. إذ يمكن إعادة تعريف النوع المعاد ليتضمن القيم النصية string على النحو التالي: const calculator = (a: number, b: number, op: Operation): number | string => { // ... } أو قد ننشئ نوعًا جديدًا للقيم المعادة يتضمن النوعين اللذين تعيدهما الدالة تمامًا مثل النوع Operation. type Result = string | number const calculator = (a: number, b: number, op: Operation): Result => { // ... } لكن السؤال الذي يطرح نفسه هو: هل من الطبيعي لدالة أن تعيد قيمة نصة؟ عندما تصل أثناء كتابة الشيفرة إلى حالة تضطر فيها إلى التقسيم على صفر، فإن خطأً جسيمًا قد وقع في مرحلة ما، وينبغي أن يلقي التطبيق خطأً ثم يعالج عند استدعاء الدالة. عندما تحاول أن تعيد قيمًا لم تكن تتوقعها أصلًا، ستمنعك التحذيرات التي تراها في محرر TSمن اتخاذ قرارات متسرعة، وتضمن لك عمل شيفرتك كما أردتها. وعليك أن تدرك أمرًا آخر، فحتى لو عرّفنا أنواعًا لمعاملاتنا، فإن شيفرة JavaScript المتولدة عند التصريف لن تكون قادرة على التحقق من هذه الأنواع أثناء التنفيذ. وإن كانت قيمة المعامل الذي يحدد العملية قادمةً من مكتبة خارجية على سبيل المثال، فلن تضمن أنها واحدة من القيم المسموحة. لذلك من الأفضل أن تنشئ معالجات للأخطاء، وأن تكون منتبهًا لحدوث ما لا تتوقعه. وفي الحالة التي تصادفك فيها عدة قيم مقبولة بينما ينبغي رفض بقية القيم وإلقاء خطأ، ستجد أن كتلة switch…case أكثر ملائمة من كتلة "if….else". ستبدو شيفرة الآلة الحاسبة التي طورناها على النحو التالي: type Operation = 'multiply' | 'add' | 'divide'; type Result = number; const calculator = (a: number, b: number, op : Operation) : Result => { switch(op) { case 'multiply': return a * b; case 'divide': if( b === 0) throw new Error('Can\'t divide by 0!'); return a / b; case 'add': return a + b; default: throw new Error('Operation is not multiply, add or divide!'); } } try { console.log(calculator(1, 5 , 'divide')) } catch (e) { console.log('Something went wrong, error message: ', e.message); } لا بأس بما فعلنا حتى الآن، لكن من الأفضل لو استخدمنا سطر الأوامر لإدخال القيم بدلًا من تغييرها ضمن الشيفرة في كل مرة. سنجرب ذلك كما لو كنا ننفذ تطبيق Node نظامي، باستخدام process.argv. لكن مشكلة ستقع: الحصول على الأنواع في حزم npm من خلال Type@ لنعد إلى الفكرة الأساسية للغة TS. تتوقع اللغة أن تكون كل الشيفرة المستخدمة قادرة على تمييز الأنواع، كما تتوقع ذلك من شيفرة مشروعك الخاص الذي هيِّئ بشكل مناسب. ولا تتضمن المكتبة TS بذاتها سوى الأنواع الموجودة في الحزمة TS. من الممكن كتابة أنواع خاصة بك لمكتبة، لكن ذلك غير ضروري إطلاقًا! فمجتمع TS قد فعل ذلك بالنيابة عنّا. وكما هو حال npm، يرحب عالم TS بالشيفرة مفتوحة المصدر. ومجتمع TS مجتمع نشط ويتفاعل دومًا مع آخر التحديثات والتغييرات في حزم npm المستخدمة أكثر. ستجد غالبًا كل ماتحتاجه من أنواع حزم npm، فلا حاجة لإنشاء هذه الأنواع بنفسك لكل اعتمادية قد تستخدمها. عادة ما نحصل على الأنواع المعرّفة في الحزم الموجودة من خلال منظمة Types@ وباستخدام npm. إذ يمكنك إضافة الأنواع الموجودة في حزمة إلى مشروعك بتثبيت حزمة npm لها نفس اسم حزمتك لكنه مسبوق بالبادئة Types@. فعلى سبيل المثال ستُثبَّت الأنواع التي توفرها المكتبة express بتنفيذ الأمر npm install --save-dev @types/express. تجري صيانة وتطوير Types@ من قبل Definitely typed، وهو مشروع مجتمعي يهدف إلى صيانة أنواع كل ما هو موجود في مكان واحد. قد تحتوي حزمة npm في بعض الأحيان الأنواع الخاصة بها ضمن الشيفرة، فلا حاجة في هذه الحالة إلى تثبيت تلك الأنواع بالطريقة التي ذكرنا سابقًا. طالما أن المتغير العام process قد عُرِّف من قبل Node، سنحصل على الأنواع التي يؤمنها بتثبيت الحزمة types/node@: npm install --save-dev @types/node لن يعترض المصرِّف على المتغير process بعد تثبيت الأنواع. ولاحظ أنه لا حاجة لطلب الأنواع ضمن الشيفرة، إذ يكفي تثبيت الحزمة التي تُعرِّفها. تحسينات على المشروع سنضيف تاليًا سيكربت npm لتشغيل كلا من برنامج دالة الضرب، وبرنامج الآلة الحاسبة: { "name": "part1", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "ts-node": "ts-node", "multiply": "ts-node multiplier.ts", "calculate": "ts-node calculator.ts" }, "author": "", "license": "ISC", "devDependencies": { "ts-node": "^8.6.2", "typescript": "^3.8.2" } } سنجعل دالة الضرب تعمل من خلال سطر الأوامر عند إجراء التعديلات التالية: const multiplicator = (a: number, b: number, printText: string) => { console.log(printText, a * b); } const a: number = Number(process.argv[2]) const b: number = Number(process.argv[3]) multiplicator(a, b, `Multiplied ${a} and ${b}, the result is:`); وسننفذ البرنامج كالتالي: npm run multiply 5 2 لو نفذنا البرنامج بمعاملات ليست من النوع الصحيح، كما تُظهر الشيفرة التالية: npm run multiply 5 lol سيعمل البرنامج أيضًا معطيًا الخرج التالي: Multiplied 5 and NaN, the result is: NaN والسبب في ذلك أن تنفيذ الأمر ('Number('lol سيعيد القيمة NaN وهذه القيمة من النوع number. لهذا لا يمكن أن تنقذنا TS من مأزق كهذا. لا بدّ في هذه الحالات من تقييم البيانات التي نحصل عليها من سطر الأوامر لكي نمنع سلوكًا كهذا. ستبدو النسخة المحسّنة من "دالة الضرب" كالتالي: interface MultiplyValues { value1: number; value2: number; } const parseArguments = (args: Array<string>): MultiplyValues => { if (args.length < 4) throw new Error('Not enough arguments'); if (args.length > 4) throw new Error('Too many arguments'); if (!isNaN(Number(args[2])) && !isNaN(Number(args[3]))) { return { value1: Number(args[2]), value2: Number(args[3]) } } else { throw new Error('Provided values were not numbers!'); } } const multiplicator = (a: number, b: number, printText: string) => { console.log(printText, a * b); } try { const { value1, value2 } = parseArguments(process.argv); multiplicator(value1, value2, `Multiplied ${value1} and ${value2}, the result is:`); } catch (e) { console.log('Error, something bad happened, message: ', e.message); } عندما ننفذ البرنامج الآن: npm run multiply 1 lol سنحصل على رسالة خطأ مناسبة للوضع: Error, something bad happened, message: Provided values were not numbers! يحتوي تعرف الدالة parseArguments بعض الأشياء الملفتة: const parseArguments = (args: Array<string>): MultiplyValues => { // ... } سنجد أولًا أنّ المعامل arg هو مصفوفة من النوع "string"، وأنّ القيم المعادة من النوع "MultiplayValues" الذي عُرِّف بالشكل التالي: interface MultiplyValues { value1: number; value2: number; } لقد استخدمنا في تعريف هذا النوع التعليمة Interface للغة TS، وهي إحدى الطرق المتبعة لتحديد شكل الكائن. إذ ينبغي كما هو واضح في حالتنا، أن تكون القيمة المعادة كائنًا له خاصيتين value1 وvalue2 من النوع "number". التمارين 9.1 - 9.3 التثبيت سننجز التمارين من 9.1 إلى 9.7 ضمن مشروع Node واحد. أنشئ المشروع في مجلد فارغ مستخدمًا الأمر npm init وثبِّت حزمتي ts-node وtypescript. أنشئ أيضًا ملفًا باسم tsconfig.json ضمن المجلد بحيث يحتوي الشيفرة التالية: { "compilerOptions": { "noImplicitAny": true, } } يستخدم هذا الملف لتحديد الطريقة التي سيفسر بها مصرِّف TS الشيفرة المكتوبة، وما هو مقدار التشدد الذي سيفرضه المصرِّف، وما هي الملفات التي ينبغي مراقبتها أو تجاهلها، والكثير الكثير من الأشياء. سنستخدم حاليًا خيار المصرِّف noImplicitAny الذي يشترط أن تحمل جميع المتغيرات أنواعًا محددة. 9.1 مؤشر كتلة الجسم ضع شيفرة هذا التمرين في الملف bmiCalculator.ts. اكتب دالة باسم calculateBmi تحسب مؤشر كتلة الجسم BMI بناء على طول محدد (سنتيمتر) ووزن محدد (كيلو غرام)، ثم أعد رسالة مناسبة تحمل النتيجة. استدعي الدالة من شيفرتك ومرر إليها معاملات جاهزة ثم اطبع النتيجة. ينبغي أن تطبع الشيفرة التالية: console.log(calculateBmi(180, 74)) النتيجة التالية: Normal (healthy weight) أنشئ سكربت npm لكي تنفذ البرنامج باستخدام الأمر npm run calculateBmi 9.2 آلة حاسبة لساعات التمرين ضع شيفرة هذا التمرين في الملف exerciseCalculatore.ts. اكتب دالة باسم calculateExercise تحسب متوسط ساعات التمرين اليومية وتقارنها بعدد الساعات التي ينبغي الوصول إليها يوميًا، ثم تعيد كائنًا يتضمن القيم التالية: عدد الأيام عدد أيام التمرين القيمة المستهدفة أساسًا متوسط الوقت المحسوب قيمة منطقية تحدد إن تم تحقيق الهدف أم لا. تقييمًا بين 1-3 يصف حسن التمرين خلال ساعات التمرين. قرر أسلوب التقييم كما تشاء. قيمة نصية تصف التقييم تمرر ساعات التمرين اليومية إلى الدالة مثلل مصفوفة تحتوي على عدد ساعات التمرين كل يوم خلال فترة التمرين. فلو فرضنا أن ساعات التمرين خلال أسبوع موزعة كالتالي: يوم الاثنين 3، يوم الثلاثاء 0، يوم الأربعاء 2، يوم الخميس 4.5 وهكذا، ستكون المصفوفة مشابهة للمصفوفة التالية: [3, 0, 2, 4.5, 0, 3, 1] عليك أن تنشئ واجهة interface من أجل توصيف الكائن الذي سيحمل النتيجة. لو استدعيت الدالة وقد مررت لها المصفوفة [3, 0, 2, 4.5, 0, 3, 1] والقيمة 2 للمعامل الآخر ستكون النتيجة على النحو: { periodLength: 7, trainingDays: 5, success: false, rating: 2, ratingDescription: 'not too bad but could be better', target: 2, average: 1.9285714285714286 } أنشئ سكربت npm لينفذ الأمر npm run calculateExercise الذي يستدعي الدالة بمعاملات قيمها موجودة مسبقًا في الشيفرة. 9.3 سطر الأوامر عدّل التمرينين السابقين بحيث يمكنك تمرير قيم معاملات الدالتين calculateBmi وcalculateExercise من خلال سطر الأوامر. يمكن أن تنفذ برنامجك على سبيل المثال على النحو التالي: $ npm run calculateBmi 180 91 Overweight أو على النحو: $ npm run calculateExercises 2 1 0 2 4.5 0 3 1 0 4 { periodLength: 9, trainingDays: 6, success: false, rating: 2, ratingDescription: 'not too bad but could be better', target: 2, average: 1.7222222222222223 } وانتبه إلى أنّ المعامل الأول للدالة السابقة هو القيمة المستهدفة. تعامل مع الاستثناءات والأخطاء بطريقة مناسبة. وانتبه إلى أنّ الدالة exerciseCalculator ستقبل قيمًا لمعاملاتها بأطوال مختلفة. قرر بنفسك كيف ستجمّع كل البيانات المطلوبة. المزيد حول قواعد تهيئة TS لقد استخدمنا في التمارين السابقة قاعدة تهيئة واحدة هي noImplicitAny. وهي بالفعل نقطة انطلاق جيدة، لكن يجب أن نطلع بشيء من التفصيل على الملف "config". يحتوي الملف tsconfig.json كل التفاصيل الجوهرية التي تحدد الطريقة التي ستنفذ بها TS مشروعك. فيمكنك أن تحدد مقدار التشدد في تفحص الشيفرة، وأن تحدد الملفات التي ستدرجها في المشروع والتي ستستثنيها (يستثنى الملف "node_modules" افتراضيًا)، وأين ستخزّن الملفات المصرِّفة (سنتحدث أكثر عن هذا الموضوع لاحقًا). لنضع القواعد التالية في الملف tsconfig.json: { "compilerOptions": { "target": "ES2020", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true } } لا تقلق بخصوص القواعد في الجزء compilerOptions من الملف، فسنمر عليها بشيئ من التفصيل في القسم 2. يمكنك أن تجد شروحات عن كل قاعدة تهيئة من خلال توثيق TS، أو من خلال صفحة الويب tsconfig page، أو من خلال تعريف تخطيط الملف tsconfig والذي صيغ لسوء الحظ بطريقة أقل وضوحًا من الخيارين السابقين. إضافة المكتبة express إلى الخلطة وصلنا إلى مرحلة لا بأس بها. فمشروعنا قد ثّبت، ويحتوي على نسخة قابلة للتنفيذ من الآلة الحاسبة. لكن طالما أنا نهتم بالتطوير الشامل لتطبيقات الويب، فقد حان الوقت لنعمل مع بعض طلبات HTTP. لنبدأ بتثبيت المكتبة express: npm install express ثم سنضيف سكربت start إلى الملف package.json { // .. "scripts": { "ts-node": "ts-node", "multiply": "ts-node multiplier.ts", "calculate": "ts-node calculator.ts", "start": "ts-node index.ts" }, // .. } سنتمكن الآن من إنشاء الملف index.ts، ثم سنكتب ضمنه طلب HTTP-GET للتحقق من الاتصال بالخادم: const express = require('express'); const app = express(); app.get('/ping', (req, res) => { res.send('pong'); }); const PORT = 3003; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); يبدو أن كل شيء يعمل على ما يرام، لكن كما هو متوقع، لا بدّ من تحديد نوع كل من المعاملين req وres للدالة app.get (لأننا وضعنا قاعدة تهيئة تفرض أن يكون لكل متغير نوع). لو نظرت جيدًا، ستجد أن VSCode سيعترض على إدراج express. ستجد خطًا منقطا أصفر اللون تحت التعليمة require. وعندما نمرر الفأرة فوق الخطأ ستظهر الرسالة التالية: إنّ سبب الاعتراض هو أن require يمكن أن تُحوَّل إلى import. لننفذ النصيحة ونستخدم import كالتالي: import express from 'express'; ملاحظة: تقدم لك VSCode ميزة إصلاح المشاكل تلقائيًا بالنقر على زر "…Quick fix". انتبه إلى تلك الاصلاحات التلقائية، وحاول أن تتابع النصائح التي يقدمها محرر الشيفرة، لأن ذلك سيحسّن من شيفرتك ويجعلها أسهل قراءة. كما يمكن أن يكون الإصلاح التلقائي للأخطاء عاملًا رئيسيًا في توفير الوقت. سنواجه الآن مشكلة جديدة. سيعترض المصرِّف على عبارة import. وكالعادة يمثل المحرر المكان الأفضل لإيجاد حلول للمشاكل: لم نثبّت أنواع express كما يشير المحرر، لنثبتها إذًا: npm install --save-dev @types/express وهكذا سيعمل البرنامج بلا أخطاء. فلو مررنا الفأرة على عبارة require سنرى أن المصرِّف قد فسّر كل ما يتعلق بالمكتبة express على أنه من النوع "any". لكن عند استخدام imports فسيعرف المصرِّف الأنواع الفعلية للمتغيرات: يعتمد استخدام عبارة الإدراج على أسلوب التصدير الذي تعتمده الحزمة التي ندرجها. وكقاعدة أساسية: حاول أن تدرج وحدات الشيفرة باستخدام التعليمة import أولًا. سنستخدم دائمًا هذا الأسلوب عند كتابة شيفرة الواجهة الأمامية. فإن لم تنجح التعليمة import، جرّب طريقة مختلطة بتنفيذ الأمر ('...')import...= require. كما نوصيك بشدة أن تطلع أكثر على وحدات TS. لا تزال هنالك مشكلة عالقة: يحدث هذا لأننا منعنا وجود معاملات غير مستخدمة عند كتابة قواعد التهيئة في الملف tsconfig.json. { "compilerOptions": { "target": "ES2020", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true } } ستسبب لك التهيئة مشاكلًا إن استخدمت دوال معرّفة مسبقُا لمكتبة وتتطلب تصريحًا عن متغير حتى لو لم يستخدم مطلقًا كالحالة التي تواجهنا. ولحسن الحظ فقد حُلًت هذه المشكلة على مستوى قواعد التهيئة. وبمجرد تمرير مؤشر الفأرة فوق الشيفرة التي سببت المشكلة سيعطينا فكرة الحل. وسننجز الحل هذه المرة بالنقر على زر الإصلاح السريع. إن كان من المستحيل التخلص من المتغيرات غير المستخدمة، يمكنك أن تضيف إليها البادئة (_) لإبلاغ المصرِّف بأنك قد فكرت بحل ولم تصل إلى نتيجة! لنعدّل اسم المتغير req ليصبح req_. وأخيرًا سنكون مستعدين لتشغيل البرنامج. ويبدو أن كل شيء على مايرام. ولتبسيط عملية التطوير، ينبغي علينا تمكين ميزة إعادة التحميل التلقائي، وذلك لتحسين انسيابية العمل. لقد استخدمنا سابقًا المكتبة nodemon التي توفر هذه الميزة، لكن البديل عنها في ts-node هي المكتبة ts-node-dev. وقد جرى تصميم هذه الأخيرة لتُستخدم فقط في بيئة التطوير، حيث تتولى أمور إعادة التصريف عند كل تغيير، وبالتالي لا حاجة لإعادة تشغيل التطبيق. لنثبّت المكتبة ts-node-dev كاعتمادية تطوير: npm install --save-dev ts-node-dev ثم علينا إضافة سكربت خاص بها ضمن الملف package.json. { // ... "scripts": { // ... "dev": "ts-node-dev index.ts", }, // ... } وهكذا ستحصل على بيئة تطوير تدعم إعادة التحميل التلقائي للمشروع بتنفيذ الأمر npm run dev. التمرينان 9.4 - 9.5 9.4 استخدام المكتبة Express أضف المكتبة express إلى اعتماديات التطبيق، ثم أنشئ وصلة تخديم endpoint لطلبات HTTP-GET تدعى hello تجيب على الطلب بالعبارة 'Hello FullStack'. ينبغي أن تُشغّل التطبيق باستخدام الأمر npm start وذلك في بيئة الإنتاج وبالأمر npm run dev في بيئة التطوير التي من المفترض أن تستخدم المكتبة ts-node-dev لتشغيل التطبيق. استبدل محتوى الملف tsconfig.json بالمحتوى التالي: { "compilerOptions": { "noImplicitAny": true, "noImplicitReturns": true, "strictNullChecks": true, "strictPropertyInitialization": true, "strictBindCallApply": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitThis": true, "alwaysStrict": true, "esModuleInterop": true, "declaration": true, } } تأكد من عدم وجود أية أخطاء! 9.5 تطبيق ويب لقياس مؤشر الكتلة BMI أضف وصلة تخديم لحاسبة مؤشر الكتلة يمكن استخدامها بإرسال طلبات HTTP-GET إلى وصلة التخديم bmi، بحيث يكون الدخل على شكل معاملات استعلام نصية (query string parameters). فلو أردت مثلًا الحصول على مؤشر كتلة شخص طوله 180 ووزنه 72، ستحصل عليه بطلب العنوان http://localhost:3002/bmi?height=180&weight=72. ستكون الاستجابة بيانات json لها الشكل التالي: { weight: 72, height: 180, bmi: "Normal (healthy weight)" } اطلع على توثيق express لتعرف آلية الوصول إلى بارامترات الاستعلام. إن كانت معاملات الطلب من نوع خاطئ أو مفقودة، أجب برمز حالة مناسب ورسالة الخطأ التالية: { error: "malformatted parameters" } لا تنسخ شيفرة الحاسبة إلى الملف index.ts، بل اجعلها وحدة TS مستقلة يمكن إدراجها ضمن هذا الملف. كوابيس استخدام النوع any قد نلاحظ بعد أن أكملنا تنفيذ أول وصلة تخديم أننا بالكاد استخدمنا TS في هذا الأمثلة الصغيرة. وقد نلاحظ أيضًا بعض النقاط الخطرة عند تفحص الشيفرة بعناية. لنضف وصلة تخديم طلبات HTTP-GET تدعى calculate إلى تطبيقنا: import { calculator } from './calculator' // ... app.get('/calculate', (req, res) => { const { value1, value2, op } = req.query const result = calculator(value1, value2, op) res.send(result); }); عندما نمرر مؤشر الفأرة فوق الدالة calculate، ستجد أنها تحمل نوعًا على الرغم من أن الشيفرة ذاتها لا تتضمن أية أنواع. وبتمرير مؤشر الفأرة فوق القيم المستخلصة من الطلب، ستظهر مشكلة جديدة: ستحمل جميع المتغيرات النوع any. لن نتفاجأ كثيرًا بما حدث، لأننا في الواقع لم نحدد نوعًا لأي قيمة منها. تُحل المشكلة بطرق عدة، لكن علينا أولًا أن نفهم سبب حدوث هذا السلوك وما الذي جعل القيم من النوع any. كل متغير في TS لا يملك نوعًا ولا يمكن الاستدلال على نوعه، سيعتبر من النوع any ضمنًا. وهذا النوع هو بمثابة نوع "بديل" ويعني حرفيًا "أيًا كان النوع". ويحدث هذا الأمر كثيرًا عندما ينسى المبرمج أن يحدد نوعًا للقيم التي تعيدها الدالة ولمعاملاتها. كما يمكن أن نصُرّح بأن نوع المتغيّر هو any. والفرق الوحيد بين التصريح عن هذا النوع أو تضمينه هو في مظهر الشيفرة المكتوبة، فلن يكترث المصرِّف لأي فرق. لكن سينظر المبرمجون إلى هذا الموضوع بشكل مختلف. فتضمين النوع any يوحي بالمشاكل، لأنه ينتج عادة عن نسيان تحديد نوع المتغير أو المعامل، كما يعني أنك لم تستخدم TS بالطريقة الملائمة. وهذا هو السبب في وجود قاعدة التهيئة noImplicitAny على مستوى المصرِّف. ومن الأفضل أن تبقيها دائمًا مفعّلة. في الحالات النادرة التي لا تعرف فيها فعلًا نوع المتغيًر، عليك التصريح على أنه من النوع any. const a : any = /* افعل هذا إن كنت لا تعلم حقًا نوع المتغير */ لقد فعلنا القاعدة في مثالنا، لماذا إذًا لم يعترض المصرِّف على القيم التي تحمل النوع any ضمنًا؟ يعود السبب إلى الحقل query من كائن الطلب العائد للمكتبة express، فهو يمتلك النوع any صراحة. وكذلك الأمر بالنسبة للحقل request.body الذي يُستخدم لإرسال المعلومات إلى التطبيق. هل يمكننا منع المطور من استخدام any نهائيًا؟ لدينا لحسن الحظ طرق أخرى غير استخدام tsconfig.ts لإجبار المطور على اتباع أسلوب محدد في كتابة الشيفرة. إذا يمكننا استخدام المدقق eslint لإدارة أسلوب كتابة الشيفرة. لنثبت eslint إذًا: npm install --save-dev eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser سنُهيئ المدقق بحيث يمنع التصريح بالنوع "any". اكتب القواعد التالية في الملف ذو اللاحقة "eslintrc.": { "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 11, "sourceType": "module" }, "plugins": ["@typescript-eslint"], "rules": { "@typescript-eslint/no-explicit-any": 2 } } لنكتب أيضًا السكربت lint داخل الملف package.json ليتحقق من وجود ملفات لاحقتها "ts.": { // ... "scripts": { "start": "ts-node index.ts", "dev": "ts-node-dev index.ts", "lint": "eslint --ext .ts ." // ... }, // ... } سيعترض المدقق لو حاولنا أن نصرّح عن متغير من النوع any. ستجد في ‎@typescript-eslint الكثير من قواعد المدقق eslint المتعلقة باللغة TS، كما يمكنك استخدام القواعد الأساسية للمدقق في مشاريع هذه اللغة. من الأفضل حاليًا استخدام الإعدادات الموصى بها، ثم يمكننا لاحقًا تعديل القواعد عندما نحتاج إلى ذلك. وعلينا الانتباه إلى التوصيات المتعلقة بأسلوب كتابة الشيفرة بما يتوافق مع الأسلوب الذي يتطلبه هذا القسم وكذلك وضع فاصلة منقوطة في نهاية كل سطر من أسطر الشيفرة. سنستخدم حاليًا ملف قواعد المدقق الذي يتضمن ما يلي: { "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking" ], "plugins": ["@typescript-eslint"], "env": { "node": true, "es6": true }, "rules": { "@typescript-eslint/semi": ["error"], "@typescript-eslint/no-explicit-any": 2, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "no-case-declarations": 0 }, "parser": "@typescript-eslint/parser", "parserOptions": { "project": "./tsconfig.json" } } هناك بعض الفواصل المنقوطة المفقودة، لكن إضافتها أمر سهل. والآن علينا إصلاح كل ما يحتاج إلى إصلاح. التمرينان 9.6 - 9.7 9.6 المدقق ESlint هيئ مشروعك ليستخدم الإعدادات السابقة وأصلح كل المشاكل. 9.7 تطبيق ويب لحساب ساعات التمرين أضف وصلة تخديم إلى تطبيقك الذي يحسب ساعات التمرين اليومية. ينبغي أن تستخدم الوصلة لإرسال طلب HTTP-POST إلى وصلة التخديم المقابلة exercises متضمنة معلومات الدخل في جسم الطلب. { "daily_exercises": [1, 0, 2, 0, 3, 0, 2.5], "target": 2.5 } سيكون الجواب بصيغة json على النحو التالي: { "periodLength": 7, "trainingDays": 4, "success": false, "rating": 1, "ratingDescription": "bad", "target": 2.5, "average": 1.2142857142857142 } إن لم يكن جسم الطلب بالتنسيق الصحيح، أعد رمز الحالة المناسب مع رسالة الخطأ هذه: { error: "parameters missing" } أو هذه: { error: "malformatted parameters" } بناء على الخطأ. ويحدث الخطأ الثاني إن لم تمتلك القيم المدخلة النوع المناسب، كأن لا تكون أرقامًا أو لا يمكن تحويلها إلى أرقام. يمكن أن تجد أن التصريح عن متغير بأنه من النوع any مفيدًا في هذا التمرين عندما تتعامل مع البيانات الموجودة ضمن جسم الطلب. ستمنعك طبعًا قواعد تهيئة المدقق، لكن يمكنك أن تلغي تفعيل هذه القاعدة من أجل سطر محدد من الشيفرة بوضع هذا التعليق قبل السطر المطلوب. // eslint-disable-next-line @typescript-eslint/no-explicit-any انتبه بأنك ستحتاج إلى تهيئة صحيحة لتتمكن من الاحتفاظ ببيانات جسم الطلب. راجع القسم 3. ترجمة -وبتصرف- للفصل First steps with TypeScript من سلسلة Deep Dive Into Modern Web Development
  8. TypeScript هي لغة برمجة مصممة لتنفيذ مشاريع JavaScript ضخمة، صممتها Microsoft. فقد بُنيت على سبيل المثال برنامج مثل Azure Management Portal الذي يبلغ عدد أسطر شيفرته 1.2 مليون، وبرنامج Visiual Studio Code الذي يبلغ عدد أسطر شيفرته 300 ألف باستخدام TypeScript. تقدم TypeScript ميزات عديدة لدعم مشاريع JavaScript الضخمة مثل أدوات تطوير أفضل وتحليل شيفرة ساكنة والتحقق من الأنواع عند الترجمة والتوثيق على مستوى الشيفرة. المبدأ الرئيسي تمثل اللغة TypeScript مجموعة مطوّرة من تعليمات JavaScript قادرة على تمييز الأنواع وتترجم عادة إلى شيفرة JavaScript صرفة. ويمكن للمطور أن يحدد إصدار الشيفرة الناتجة طالما أن إصدار ECMAScript هو الإصدار 3 أو أحدث. ويعني أنّ هذه اللغة مجموعة مطوّرة من تعليمات JavaScript أنها تتضمن كل ميزات JavaScript بالإضافة إلى ميزات خاصة بها. وتعتبر كل شيفرات JavaScript شيفرات TypeScript صحيحة. تتألف TypeScript من ثلاثة أقسام منفصلة لكنها متكاملة مع بعضها: اللغة المصرِّف خدمة اللغة تتألف اللغة من القواعد والكلمات المحجوزة (تعليمات) ومسجلات الأنواع Type annotations وتتشابه قواعدها مع مثيلاتها في JavaScript لكنها ليست متماثلة. وأكثر ما يتعامل معه المبرمجون من تلك الأقسام هي اللغة. تنحصر مهمة المصرِّف في محو المعلومات المتعلقة بالنوع بالإضافة إلى التحويلات على الشيفرة. إذ تمكن عملية تحويل الشيفرة من نقل شيفرة TypeScript إلى شيفرة JavaScript قابلة للتنفيذ. يزيل المصرِّف كل ما يتعلق بالأنواع أثناء الترجمة، وبالتالي لا تميز هذه اللغة الأنواع بشكل فعلي قبل الترجمة. تعني عملية التصريف بالمعنى المتعارف عليه، تحويل الشيفرة من الشكل الذي يمكن للإنسان قراءته وفهمه إلى الشكل الذي تفهمه الآلة. أما عملية الترجمة في TypeScript فهي عملية تحويل الشيفرة من شكل يفهمه الإنسان إلى شكل آخر يفهمه الإنسان أيضًا، لذا من الأنسب أن تسمى هذه العملية بالنقل Transpilling. لكن مصطلح الصريف هو الأشيع في هذا المضمار وسنستخدمه نحن بدورنا. ينفذ المصرِّف أيضًا عملية تحليل الشيفرة ما قبل التنفيذ. إذ يمكنه إظهار رسائل التحذير والخطأ إن كان هناك سبب لذلك، كما يمكن أن يُهيَّئ لتنفيذ مهام إضافية كضم الشيفرة الناتجة في ملف واحد. تجمع خدمة اللغة معلومات عن الأنواع الموجودة في الشيفرة المصدرية. وبالتالي ستتمكن أدوات التطوير من استخدامها في تقديم ميزة إكمال الشيفرة في بيئات التطوير وطباعة التلميحات وتقديم اقتراحات لإعادة كتابة أجزاء من الشيفرة. الميزات الرئيسية للغة TypeScript سنقدم تاليًا وصفًا لبعض ميزات اللغة TypeScript. والغاية من ذلك تزويدك بالأساسيات التي تقدمها اللغة وميزاتها، وذلك للتعامل مع الأفكار التي سنراها تباعًا في المنهاج. مسجلات الأنواع وهي طريقة خفيفة في TypeScript لتسجيل الأنواع التي نريد أن تمرر أو تعاد من الدوال أو أنواع المتغيرات. فلقد عرفنا في المثال التالي على سبيل المثال الدالة birthdayGreeter التي تقبل معاملين أحدهما من النوع string، والآخر من النوع number، وستعيد قيمة من النوع string. const birthdayGreeter = (name: string, age: number): string => { return `Happy birthday ${name}, you are now ${age} years old!`; }; const birthdayHero = "Jane User"; const age = 22; console.log(birthdayGreeter(birthdayHero, 22)); نظام الخصائص المعرفة للأنواع تستخدم اللغة TypeScript الخصائص المعرفة للأنواع structural typing. ويعتبر عنصران في هذا النظام متوافقان إن كان لكل ميزة في النوع الأول ميزة تماثلها تمامًا في النوع الثاني. ويعتبر النوعان متطابقان إذا كانا متوافقان مع بعضهما. الاستدلال على النوع يحاول المصرِّف أن يستدل على نوع المعلومة إن لم يحدد لها نوع. إذ يمكن الاستدلال على نوع المتغير بناء على القيمة التي أسندت له. تحدث هذه العملية عند تهيئة المتغيرات والأعضاء، وعند إسناد القيم الافتراضية للمعاملات، وعند تحديد القيمة التي تعيدها دالة. لنتأمل على سبيل المثال الدالة Add: const add = (a: number, b: number) => { /* تستخدم القيمة المعادة لتحديد نوع القيمة التي تعيدها الدالة*/ return a + b; } يستدل المصرِّف على نوع القيمة المعادة للدالة بتعقب الشيفرة حتى الوصول إلى عبارة return. تعيد هذه العبارة مجموع المعاملين a وb. وكما نرى فكلا المعاملين من النوع number وهكذا سيستدل المصرِّف على أن القيمة التي تعيدها الدالة من النوع number. وكمثال أكثر تعقيدًا، لنتأمل الشيفرة التالية (قد يكون المثال صعب الفهم قليلًا إن لم تكن قد استخدمت TypeScript مسبقًا. يمكنك تخطي هذا المثال حاليًا.) type CallsFunction = (callback: (result: string) => any) => void; const func: CallsFunction = (cb) => { cb('done'); cb(1); } func((result) => { return result; }); بداية نجد تصريحًا لاسم نوع مستعار type alias يدعى CallsFunction. يُمثل نوع دالة تقبل معاملًا واحدًا callback يمثِّل بدوره دالة تتلقى معاملًا من النوع "string" وتعيد قيمة من النوع any. وكما سنرى لاحقًا فالنوع any هو شكل من أشكال الحروف البديلة wildcards والتي يمكن أن تحل محل أي نوع. بعد ذلك نعرّف الدالة func من النوع CallsFunction. يمكننا أن نستدل من نوع الدالة بأن معاملها الدالة cb ستقبل فقط معاملًا من النوع string.ولتوضيح ذلك سنورد مثالًا آخر تُستدعى فيه دالة كمعامل لكن بقيمة من النوع number، وسيسبب هذا الاستدعاء خطأً في TypeScript. وأخيرًا نستدعي الدالة func بعد أن نمرر إليها الدالة التالية كمعامل: (result) => { return result; } وعلى الرغم من عدم تحديد أنواع معاملات الدالة، يمكننا الاستدلال من سياق الاستدعاء أن المعامل result من النوع String. إزالة الأنواع تزيل TypeScript جميع الأنواع التي يبنيها نظام تحديد الأنواع أثناء الترجمة: فلو كانت الشيفرة قبل الترجمة كالتالي: let x: SomeType; ستكون بعد الترجمة: let x; ويعني هذا عدم وجود أية معلومات عن الانواع أثناء التنفيذ، فلا يشير أي شيء على أن متغيرًا X على سبيل المثال قد عُرِّف أنه من نوع ما. سيكون النقص في المعلومات عن الأنواع في زمن التنفيذ مفاجئًا للمبرمجين الذين اعتادوا على استخدام تعليمات التحقق Reflection وغيرها من أنظمة إظهار البيانات الوصفية. هل علينا فعلا استعمال TypeScript؟ إن كنت من متابعي المنتديات البرمجية على الانترنت، ستجد تضاربًا في الآراء الموافقة والمعارضة لاستخدام هذه اللغة. والحقيقة أنّ مدى حاجتك لاستخدام الدوال التي تقدمها TypeScript هي من يحدد وجهة نظرك. سنعرض على أية حال بعض الأسباب التي قد تجعل استخدام هذه اللغة مفيدًا. أولًا: ستقدم ميزة التحقق من الأنواع وميزة التحليل الساكن للشيفرة (قبل الترجمة والتنفيذ). كما يمكننا أن نطلب قيمًا من نوع محدد وأن نتلقى تحذيرات من المصرِّف عندما نستخدم هذه القيم بشكل خاطئ. سيقلل هذا الأمر الأخطاء التي تحصل وقت التنفيذ، كما يمكن أن يقلل من عدد اختبارات الأجزاء unit tests التي يحتاجها التطبيق وخاصة ما يتعلق باختبارات الأنواع الصرفة pure types. ولا تحذرك عملية التحليل الساكن للشيفرة من الاستخدام الخاطئ للأنواع وحسب، بل تشير إلى الأخطاء الأخرى ككتابة اسم المتغير أو الدالة بشكل خاطئ أو استخدام المتغير خارج المجال المعرّف ضمنه. ثانيًا: يمكن لميزة تسجيل الأنواع أن تعمل كشكل من أشكال التوثيق على مستوى الشيفرة. إذ يمكنك أن تتحقق من خلال بصمة الدالة signature من الأنواع التي تتلقاها الدالة والأنواع التي ستعيدها. سيبقى هذا الشكل من التوثيق محدّثًا أثناء تقدم العمل، وسيسهّل ذلك التعامل مع مشاريع جاهزة وخاصة للمبرمجين الجدد. كما ستساعد كثيرًا عند العودة إلى مشاريع قديمة. يمكن إعادة استخدام الأنواع في أي مكان من الشيفرة، وستنتقل أية تغييرات على تعريف النوع إلى أي مكان استخدم فيه هذا النوع. وقد يجادل البعض بأن استخدام JSDoc سينجز التوثيق على مستوى الشيفرة بشكل مماثل، لكنه لا يرتبط بالشيفرة بشكل وثيق كما تفعل أنواع TypeScript، وقد يسبب هذا خللًا في تزامن عرض المعلومات، بالإضافة إلى أنها أطول. ثالثًا: ستقدم بيئة عمل اللغة ميزة إكمال الشيفرة بشكل أفضل لأنها تعلم تمامًا نوع البيانات التي تعالجها. وتعتبر الميزات الثلاث السابقة مفيدة للغاية عند إعادة كتابة الشيفرة. فسيحذرك التحليل الساكن للشيفرة من الأخطاء في شيفرتك، وسيرشدك معالج إكمال الشيفرة إلى الخصائص التي يقدمها المتغير أو الكائن أو حتى خيارات لإعادة كتابة الشيفرة بشكل أنسب، وسيساعدك التوثيق على مستوى الشيفرة في فهم الشيفرة المكتوبة. وبهذه المساعدة التي تقدمها لك بيئة عمل هذه اللغة، سيكون من السهل استخدام الميزات الأحدث من لغة JavaScript وفي أية مرحلة تقريبًا بمجرد تغيير إعدادات التهيئة. ما الأمور التي لا يمكن للغة TypeScript أن توفرها لك؟ لقد ذكرنا سابقًا أن تسجيل الأنواع والتحقق منها فقط في مراحل ما قبل التنفيذ. فحتى لو لم يشر المصرِّف إلى أخطاء فقط تحدث الأخطاء أثناء التنفيذ. تحدث أخطاء زمن التنفيذ بشكل متكرر عند التعامل مع مصدر دخل خارجي كالبيانات المستقبلة كطلب عبر شبكة. وضعنا أخيرًا قائمة بالمشاكل التي قد تواجهنا عند استخدام TypeScript، ويجب الانتباه إليها: الأنواع غير المكتملة أو الخاطئة أو المفقودة التي مصدرها مكتبات خارجية قد تجد في بعض المكتبات الخارجية أنواعًا غير معرّفة أو ذات تعريف خاطئ بشكل أو بآخر. والسبب المرجح أنّ المكتبة لم تُكتَب بلغة Typescript، وأنّ المبرمج الذي صرّح عن النوع يدويًا قد ارتكب خطأً. عليك في هذه الحال كتابة التصريح عن النوع يدويًا بنفسك. لكن هناك فرصة كبيرة أن يكون أحدهم قد فعل ذلك مسبقًا عوضًا عنك، تحقق من موقع DefinitelyTyped أو صفحة GitHub الخاصة بهذا الموقع أولًا. وهذان المصدران هما الأكثر شعبية لإيجاد ملفات تعريف الأنواع. في حال لم يحالفك الحظ، لابدّ أن تبدأ من الصفر بقراءة توثيق TypeScript بما يخص تعريف الأنواع. قد يحتاج الاستدلال على النوع إلى بعض المساعدة إن الاستدلال على النوع في هذه اللغة جيد جدًا لكنه ليس مثاليًا. قد تعتقد أحيانًا أنك صرحت عن نوع بشكل مثالي، لكن المصرِّف يحذرك بشكل مستمر من أن خاصية محددة غير موجودة أو أنّ التصريح بهذه الطريقة غير مسموح. عليك في حالات كهذه مساعدة المصرِّف بتنفيذ عملية تحقق إضافية من النوع الذي صرحت عنه، وخاصة فيما يتعلق بالتحويل بين الأنواع type casting أو حاميات الأنواع type gaurds. عند التحويل بين الأنواع أو عند استخدام الحاميات فأنت بذلك تضمن للمصرِّف أن القيمة التي وضعتها هي بالتأكيد من النوع المصرح عنه وهذا مصدر للأخطاء. ربما عليك الاطلاع على التوثيق فيما يتعلق بحاميات الأنواع أو مؤكدات الأنواع Type Assertions. أخطاء الأنواع الغامضة من الصعب أحيانًا فهم طبيعة الخطأ الذي يعطيه نظام تحديد الأنواع، وخاصة إن استخدمت أنواع معقدة. وكقاعدة أساسية، ستحمل لك رسائل خطأ TypeScript المعلومات الأكثر أهمية في نهاية الرسالة. فعندما تظهر لك رسالة طويلة مزعجة، ابدأ بالقراءة من آخر الرسالة. ترجمة -وبتصرف- للفصل Background and introduction من سلسلة Deep Dive Into Modern Web Development
  9. سنصل قريبًا إلى نهاية هذا المنهاج، لنختم أفكاره باستعراض بعض التفاصيل الأخرى في GraphQL. الاجتزاءات إنّ إعادة عدة استعلامات GraphQL النتيجة نفسها أمر شائع جدًا. فعلى سبيل المثال، فعند تنفيذ الاستعلام الذي يحضر تفاصيل شخص ما كما يلي: query { findPerson(name: "Pekka Mikkola") { name phone address{ street city } } } والاستعلام الذي يعيد جميع الأشخاص كالتالي: query { allPersons { name phone address{ street city } } } ستكون النتيجة نفس الأشخاص في كليهما. وعند اختيار الحقول التي ستُعاد، سيحدد كلا الاستعلامين الحقول نفسها. في مثل هذه الحالات يمكن تسهيل الأمور باستخدام الاجتزاءات fragments. لنعرّف إذا اجتزاءً لاختيار كل حقول الشخص: fragment PersonDetails on Person { name phone address { street city } } يمكننا باستخدام الاجتزاء تنفيذ الاستعلامات بشيفرة مختصرة: query { allPersons { ...PersonDetails } } query { findPerson(name: "Pekka Mikkola") { ...PersonDetails } } لا تُعرّف الاجتزاءات ضمن تخطيط GrapQL، بل ضمن شيفرة العميل. إذ ينبغي التصريح عن الاجتزاء عندما يريد العميل تنفيذ الاستعلام. وكمبدأ، يمكننا التصريح عن الاجتزاء داخل كل استعلام كالتالي: const ALL_PERSONS = gql` { allPersons { ...PersonDetails } } fragment PersonDetails on Person { name phone address { street city } } ` لكن من الأفضل أن تُصرح عن الاجتزاء مرة واحدة ثم تخزنه ضمن متغيّر. const PERSON_DETAILS = gql` fragment PersonDetails on Person { id name phone address { street city } } ` وإن صُرِّح عن الاجتزاء كما سبق، أمكن وضعه ضمن أي استعلام أو طفرة باستخدام إشارة الدولار $ والأقواس المعقوصة: const ALL_PERSONS = gql` { allPersons { ...PersonDetails } } ${PERSON_DETAILS} ` الاشتراكات تقدم GraphQL بالإضافة إلى الاستعلامات والطفرات، آلية ثالثة هي الاشتراكات subscriptions. حيث تُمكّن الاشتراكات العملاء من تتبع التحديثات التي قد تطرأ على الخادم. تختلف الاشتراكات جذريًا عن أية أفكار طرحناها في المنهاج. فحتى الآن، يقتصر التفاعل بين الخادم والمتصفح على تطبيق React يعمل على المتصفح ويرسل طلبات HTTP إلى الخادم. كما تُنفَّذ استعلامات وطفرات GraphQL بالطريقة ذاتها. لكن الوضع سينقلب مع الاشتراكات. فبعد أن يسجل التطبيق اشتراكًا، سيبدأ بالتنصت على التغيرات في الخادم. حيث سيرسل الخادم تنبيهًا إلى كل التطبيقات التي سجلت اشتراكًا في حال حدث أي تغيير. ولو أردنا التحدث بلغة تقنية سنقول أن البروتوكول HTTP لا يلائم تمامًا الاتصال من الخادم إلى المتصفح، لذا فقد استخدمت Apollo تحت الغطاء مايسمى بمقابس الويب WebSockets للاتصال بين المشتركين والخادم. الاشتراكات من جانب الخادم لننجز آلية اشتراك لتلقي تنبيهات عندما يُضاف شخص جديد. لا تغييرات كثيرة على شيفرة الخادم، لكن سيتغير التخطيط قليلًا على النحو التالي: type Subscription { personAdded: Person! } فعندما يُضاف شخص جديد، سترسل جميع تفاصيله إلى جميع المشتركين. يحتاج الاشتراك personAdded إلى محلل. كما يجب تعديل المحلل addPerson لكي يرسل تنبيهًا إلى المشتركين. ستتغير الشيفرة على النحو التالي: const { PubSub } = require('apollo-server')const pubsub = new PubSub() 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, }) } pubsub.publish('PERSON_ADDED', { personAdded: person }) return person }, }, Subscription: { personAdded: { subscribe: () => pubsub.asyncIterator(['PERSON_ADDED']) }, }, يحدث الاتصال في الاشتراكات باستخدام مبدأ نشر- اشتراك publish-subscribe عن طريق كائن يستخدم الواجهة PubSub. حيث ينشر إضافة شخص جديد تنبيهًا عن العملية تصل إلى جميع المشتركين وذلك بالاستفادة من التابع publish العائد للواجهة PubSub. يسجل محلل الاشتراك personAdded جميع المشتركين وذلك بإعادته كائن مكرّر iterator object مناسب. لنجري التعديلات التالية على الشيفرة التي تُشغِّل الخادم: // ... server.listen().then(({ url, subscriptionsUrl }) => { console.log(`Server ready at ${url}`) console.log(`Subscriptions ready at ${subscriptionsUrl} `)}) سنجد أن الخادم يتنصت على الاشتراكات على العنوان ws://localhost:4000/graphql Server ready at http://localhost:4000/ Subscriptions ready at ws://localhost:4000/graphql لا نحتاج لأية تعديلات أخرى على الخادم. يمكن اختبار الاشتراكات باستخدام أرضية عمل GraphQL كالتالي: عندما تضغط على زر التشغيل play فوق الاشتراك، ستنتظر أرضية العمل التنبيهات التي قد تصل إلى الاشتراك. يمكن إيجاد شيفرة الواجهة الخلفية ضمن الفرع part8-6 في المستودع الخاص بالتطبيق على Github. الاشتراكات من جانب العميل لكي نصبح قادرين على استخدام الاشتراكات في تطبيق React، لا بدّ من إجراء بعض التغييرات وخاصة على أوامر التهيئة. ينبغي تعديل أوامر التهيئة الموجودة في الملف "index.js" كالتالي: import { ApolloClient, ApolloProvider, HttpLink, InMemoryCache, split} from '@apollo/client' import { setContext } from 'apollo-link-context' import { getMainDefinition } from '@apollo/client/utilities'import { WebSocketLink } from '@apollo/client/link/ws' 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 wsLink = new WebSocketLink({ uri: `ws://localhost:4000/graphql`, options: { reconnect: true }})const splitLink = split( ({ query }) => { const definition = getMainDefinition(query) return ( definition.kind === 'OperationDefinition' && definition.operation === 'subscription' ); }, wsLink, authLink.concat(httpLink),) const client = new ApolloClient({ cache: new InMemoryCache(), link: splitLink}) ReactDOM.render( <ApolloProvider client={client}> <App /> </ApolloProvider>, document.getElementById('root') ) ولكي تعمل الشيفرة لابدّ من تثبيت بعض الاعتماديات: npm install @apollo/client subscriptions-transport-ws إنّ السبب الكامن خلف إجراء التعديلات على أوامر التهيئة، هو ضرورة امتلاك التطبيق اتصال HTTP بالإضافة إلى مقبس ويب إلى خادم GraphQL. const wsLink = new WebSocketLink({ uri: `ws://localhost:4000/graphql`, options: { reconnect: true } }) const httpLink = createHttpLink({ uri: 'http://localhost:4000', }) تُنفَّذ الاشتراكات باستخدام دالة الخطاف useSubscription. لنعدل الشيفرة كالتالي: export const PERSON_ADDED = gql` subscription { personAdded { ...PersonDetails } } ${PERSON_DETAILS}` import { useQuery, useMutation, useSubscription, useApolloClient} from '@apollo/client' const App = () => { // ... useSubscription(PERSON_ADDED, { onSubscriptionData: ({ subscriptionData }) => { console.log(subscriptionData) } }) // ... } عندما يضاف الآن شخص جديد إلى دليل الهاتف، فستطبع تفاصيل الشخص الجديد على طرفية التطوير بغض النظر أين حدث ذلك: سيرسل الشخص تنبيهًا إلى العميل عندما يُضاف شخص جديد، ثم تستدعى الدالة المعرّفة في الصفة onSubscriptionData وتُزوّد بتفاصيل الشخص الجديد على أساس معاملات. لنوسِّع حلنا بحيث يضاف الشخص الجديد إلى الذاكرة المؤقتة لمكتبة Apollo عندما تُستقبل تفاصيله. لكن لابدّ من الانتباه إلى عدم إضافة الشخص الجديد إلى الذاكرة المؤقته مرتين عندما يُنشئه المستخدم: const App = () => { // ... const updateCacheWith = (addedPerson) => { const includedIn = (set, object) => set.map(p => p.id).includes(object.id) const dataInStore = client.readQuery({ query: ALL_PERSONS }) if (!includedIn(dataInStore.allPersons, addedPerson)) { client.writeQuery({ query: ALL_PERSONS, data: { allPersons : dataInStore.allPersons.concat(addedPerson) } }) } } useSubscription(PERSON_ADDED, { onSubscriptionData: ({ subscriptionData }) => { const addedPerson = subscriptionData.data.personAdded notify(`${addedPerson.name} added`) updateCacheWith(addedPerson) } }) // ... } يمكن استخدام الدالة updateCacheWith ضمن الكائن PersonForm لتحديث الذاكرة المؤقتة: const PersonForm = ({ setError, updateCacheWith }) => { // ... const [ createPerson ] = useMutation(CREATE_PERSON, { onError: (error) => { setError(error.graphQLErrors[0].message) }, update: (store, response) => { updateCacheWith(response.data.addPerson) } }) // .. } يمكن إيجاد شيفرة العميل بشكلها الكامل ضمن الفرع part8-9 في المستودع المخصص للتطبيق على Github. مسألة n+1 لنضف شيئًا ما إلى الواجهة الخلفية. سنعدل التخطيط بحيث يمتلك النوع Person الحقل friendOf الذي يدل على أي قائمة أصدقاء يتواجد الشخص: type Person { name: String! phone: String address: Address! friendOf: [User!]! id: ID! } يجب أن يدعم التطبيق الاستعلام التالي: query { findPerson(name: "Leevi Hellas") { friendOf{ username } } } وبما أنّ الحقل friendOf ليس حقلًا من حقول الكائن Person ضمن قاعدة البيانات، لابدّ من إنشاء محلل للعملية يحل هذا الموضوع. لننشئ أولًا محللًا يعيد قائمة فارغة: Person: { address: (root) => { return { street: root.street, city: root.city } }, friendOf: (root) => { // يعيد قائمة فارغة return [ ] }}, يمثّل المعامل root الكائن الخاص بالشخص الذي تُنشأ من أجله قائمة أصدقاء، وبالتالي سنبحث ضمن كل الكائنات من النوع User على تلك التي تمتلك فيها root معرّفًا فريدًا id كالتالي root._id في قائمة الأصدقاء الخاصة به: Person: { // ... friendOf: async (root) => { const friends = await User.find({ friends: { $in: [root._id] } }) return friends } }, وبهذا سيعمل الآن التطبيق.ويمكننا أيضً أن ننفذ مباشرةً استعلامات أكثر تعقيدًا. فمن الممكن على سبيل المثال إيجاد أصدقاء جميع المستخدمين: query { allPersons { name friendOf { username } } } لكن هناك مشكلة واحدة في حلّنا، فهي تُرسل عددًا غير منطقي من الاستعلامات إلى قاعدة البيانات. فلو سجّلنا كل استعلام، وكان لدينا 5 أشخاص، سنرى التالي: Person.find User.find User.find User.find User.find User.find فعلى الرغم من أننا نفّذنا استعلامًا واحدًا رئيسيًا، فسيُنفِّذ كل شخص ضمن قاعدة البيانات استعلامًا آخر من خلال المحلل الخاص به، وهذا ما يعرف بمشكلة n+1 الشهيرة والتي تظهر بين الفينة والأخرى بشكل مختلف، وقد تتسلل إلى شيفرة المطورين دون الانتباه إليها. يعتمد حل هذه المشكلة على الحالة التي أدت لظهورها. وعادة ما تتطلب استخدام استعلامات مشتركة بدلًا من عدة استعلامات منفصلة. إنّ الحل الأسهل لحالتنا هي تخزين الأشخاص الذين تتواجد قائمة أصدقائهم ضمن كل كائن من النوع Person: 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: 5 }, friendOf: [ { type: mongoose.Schema.Types.ObjectId, ref: 'User' } ], }) وبعدها يمكننا أن ننفذ استعلامًا مشتركًا أو أن نملأ الحقول friendOf للأشخاص عندما نحضر الكائنات Person: Query: { allPersons: (root, args) => { console.log('Person.find') if (!args.phone) { return Person.find({}).populate('friendOf') } return Person.find({ phone: { $exists: args.phone === 'YES' } }) .populate('friendOf') }, // ... } لن نحتاج بعد التغييرات التي أجريناها إلى محلل مستقل للحقل friendOf. لن يسبب الاستعلام allPersons مشكلة n+1 إن أحضرنا اسم الشخص ورقم هاتفه فقط: query { allPersons { name phone } } لو عدّلنا الاستعلام لتنفيذ استعلام مشترك لأنه يسبب أحيانا مشكلة n+1، فسيتطلب تنفيذه جهدًا أكبر، وخاصةً عندما لا نحتاج المعلومات عن الأشخاص المعنيين. يمكن استمثال الاستعلام باستخدام المعامل الرابع لدوال المحللات. إذ يستخدم هذا المعامل للتحقق من الاستعلام بحد ذاته، وبالتالي يمكن تنفيذ الاستعلام المشترك في بعض الحالات فقط عندما نتنبأ بحدوث مشكلة n+1. لكن لا تستعجل بالقفز إلى هذه المرحلة قبل أن تتأكد بأنك تحتاج ذلك فعلًا. وكما يقول دونالد كنوث: تقدم المكتبة DataLoader التي طورتها Facebook حلًا جيدًا لمشكلة n+1 بالإضافة إلى مسائل أخرى. يمكن إيجاد معلومات أكثر عن استخدامها مع Apollo من خلال الانترنت وننصح بالمقالة graphql server data loader caching batching والمقالة batching graphq queries with dataloader. خاتمة لم تخطط هيكلية التطبيق الذي طورناه في هذا القسم بالشكل الأمثل. فيجب على الأقل نقل التخطيط والاستعلامات والطفرات خارج شيفرة التطبيق. يمكنك إيجاد هيكيليات أفضل لتطبيقات GraphQL من خلال الانترنت لشيفرة الخادم أو لشيفرة العميل. لقد أصبحت GraphQL تقنية قديمة نوعًا ما، إذ بدأت Facebbok باستخدامها منذ عام 2012، وقد خضعت بالفعل لاختبارات صعبة. تزايد الاهتمام بهذه المكتبة شيئًا فشيئًا منذ أن نشرتها FaceBook عام 2015، وقد تهدد سيطرة REST في المستقبل القريب. إنه تلاشي متوقع، لكنه لن يحصل قريبًا. وبالتالي تعلم GrapQL أمر يستحق المحاولة بكل تأكيد. تمارين 1 الاشتراكات من جهة الخادم أضف الاشتراك bookAdded إلى الواجهة الخلفية بحيث يعيد تفاصيل كل الكتب الجديدة للمشترك. 2 الاشتراكات من جهة العميل: القسم الأول ابدأ باستخدام الاشتراكات من جهة العميل. أضف الاشتراك bookAdded، وأبلغ المستخدم عندما تُضاف كتب جديدة. يمكنك أن تنبه المستخدم بطرق عدة، مثل استعمال الدالة window.alert على سبيل المثال. 3 الاشتراكات من ناحية العميل: القسم الثاني ابق على واجهة العرض محدَّثةً بعد تنبيه المستخدم عن الكتب الجديدة. يمكنك التحقق من صحة عملك بفتح التطبيق في نافذتين مختلفتين من المتصفح وإضافة كتابة جديد في إحداهما. يجب أن تُحدِّث واجهة العرض في كلتا النافذتين الأخرى عند إضافة الكتاب الجديد. مشكلة n+1 جد حلًا لمشكلة n+1 في الاستعلام التالي بأي طريقة تريد: query { allAuthors { name bookCount } } وهكذا نكون وصلنا إلى آخر تمرين في هذا القسم وحان الوقت لرفع إجاباتك إلى GitHub. لا تنس تحديد التمارين التي أنجزت حلها ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل Fragments and Subscriptions من سلسلة Deep Dive Into Modern Web Development
  10. تُظهر الواجهة الأمامية لتطبيقنا محتويات دليل الهاتف بشكل جيد بمساعدة الخادم الذي يُحدَّث باستمرار. لكن إن أردنا إضافة أشخاص جدد، لابد من إضافة طريقة لتسجيل الدخول إلى الواجهة الأمامية. تسجيل دخول المستخدم لنضف المتغير 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
  11. سنضيف في هذا المقال آليةً لإدارة المستخدمين في تطبيقنا، لكن دعونا أولًا نستخدم قاعدة بيانات لتخزين بيانات التطبيق. استخدام المكتبة 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
  12. سننجز في المرحلة القادمة تطبيق 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
  13. قدم المعيار 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
  14. بالإضافة إلى التمارين الثمانية في الفصلين (المكتبة 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
  15. مكونات الأصناف استخدمنا حتى اللحظة في المنهاج مكوّنات 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
  16. لقد نال تطوير المواقع باستخدام 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
  17. تعرفنا في القسم 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
  18. ستختلف التمارين في هذا القسم عن تمارين القسم السابق. فستجد في هذا الفصل وفي الفصل الذي سبقه تمارين تتعلق بالأفكار التي سنقدمها في هذا الفصل، بالإضافة إلى سلسلة من التمارين التي سنراجع من خلالها ما تعلمناه خلال تقدمنا في مادة المنهاج، وذلك بتعديل التطبيق 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
  19. ستختلف التمارين قليلًا في القسم السابع من منهاج 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
  20. لقد استخدمنا حتى اللحظة مخزن 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
  21. لنوسّع التطبيق الآن بحيث تُخزَّن الملاحظات في الواجهة الخلفية. سنستخدم في عملنا خادم 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
  22. لنتابع عملنا على نسخة 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
  23. لقد اتبعنا حتى هذه اللحظة التقاليد التي تنصح بها 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
  24. اختبرنا حتى هذه اللحظة الواجهة الخلفية ككيان واحد على مستوى الواجهة البرمجية مستخدمين اختبارات التكامل. واختبرنا كذلك بعض مكوِّنات الواجهة الأمامية باستخدام اختبارات الأجزاء. سنلقي نظرة تاليًا على طريقة لاختبار النظام ككل باستخدام الاختبارات المشتركة للواجهتين (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
  25. توجد طرق عدة لاختبار تطبيقات 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
×
×
  • أضف...