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

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

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

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

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

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

  1. ستختلف التمارين في هذا القسم عن تمارين القسم السابق. فستجد في هذا الفصل وفي الفصل الذي سبقه تمارين تتعلق بالأفكار التي سنقدمها في هذا الفصل، بالإضافة إلى سلسلة من التمارين التي سنراجع من خلالها ما تعلمناه خلال تقدمنا في مادة المنهاج، وذلك بتعديل التطبيق 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
  2. ستختلف التمارين قليلًا في القسم السابع من منهاج 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
  3. لقد استخدمنا حتى اللحظة مخزن 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
  4. لنوسّع التطبيق الآن بحيث تُخزَّن الملاحظات في الواجهة الخلفية. سنستخدم في عملنا خادم 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
  5. لنتابع عملنا على نسخة 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
  6. لقد اتبعنا حتى هذه اللحظة التقاليد التي تنصح بها 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
  7. اختبرنا حتى هذه اللحظة الواجهة الخلفية ككيان واحد على مستوى الواجهة البرمجية مستخدمين اختبارات التكامل. واختبرنا كذلك بعض مكوِّنات الواجهة الأمامية باستخدام اختبارات الأجزاء. سنلقي نظرة تاليًا على طريقة لاختبار النظام ككل باستخدام الاختبارات المشتركة للواجهتين (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
  8. توجد طرق عدة لاختبار تطبيقات 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
  9. عرض واجهة تسجيل الدخول في الحالات الملائمة لنعدل التطبيق بحيث لا تظهر واجهة تسجيل الدخول بشكل افتراضي: ستظهر الواجهة عندما ينقر المستخدم على الزر "Login": يمكن للمستخدم إغلاق الواجهة أيضًا بالنقر على الزر "Cancel". سنبدأ بنقل نموذج تسجيل الدخول إلى المكوِّن الخاص به: import React from 'react' const LoginForm = ({ handleSubmit, handleUsernameChange, handlePasswordChange, username, password }) => { return ( <div> <h2>Login</h2> <form onSubmit={handleSubmit}> <div> username <input value={username} onChange={handleUsernameChange} /> </div> <div> password <input type="password" value={password} onChange={handlePasswordChange} /> </div> <button type="submit">login</button> </form> </div> ) } export default LoginForm عُرّفت الحالة وكل الدوال المتعلقة بالنموذج خارج المكوِّن وتُمرر إليه كخصائص. لاحظ كيف أسندت الخصائص إلى المتغيرات بطريقة الإسناد بالتفكيك. ويعني ذلك أننا عوضًا عن كتابة الشيفرة التالية: pre widget const LoginForm = (props) => { return ( <div> <h2>Login</h2> <form onSubmit={props.handleSubmit}> <div> username <input value={props.username} onChange={props.handleChange} name="username" /> </div> // ... <button type="submit">login</button> </form> </div> ) } حيث نحصل فيها على خصائص الكائنprop باستعمال التابع prop.handleSubmit، سنسند الخصائص مباشرة إلى متغيراتها. إحدى الطرق السريعة في إضافة الوظيفة، هو تعديل الدالة loginForm العائدة للمكوِّن App كالتالي: const App = () => { const [loginVisible, setLoginVisible] = useState(false) // ... const loginForm = () => { const hideWhenVisible = { display: loginVisible ? 'none' : '' } const showWhenVisible = { display: loginVisible ? '' : 'none' } return ( <div> <div style={hideWhenVisible}> <button onClick={() => setLoginVisible(true)}>log in</button> </div> <div style={showWhenVisible}> <LoginForm username={username} password={password} handleUsernameChange={({ target }) => setUsername(target.value)} handlePasswordChange={({ target }) => setPassword(target.value)} handleSubmit={handleLogin} /> <button onClick={() => setLoginVisible(false)}>cancel</button> </div> </div> ) } // ... } تحتوي حالة المكوِّن App الآن على المتغير المنطقي loginVisible الذي سيقرر أتعرض واجهة تسجيل الدخول للمستخدم أم لا. تتبدل قيمة loginVisible باستعمال زرين، ولكل منهما معالج الحدث الخاص به والمعرّف مباشرة داخل المكون: <button onClick={() => setLoginVisible(true)}>log in</button> <button onClick={() => setLoginVisible(false)}>cancel</button> تُحدَّد إمكانية ظهور المكوِّن بتنسيقه ضمن السياق، حيث سيختفي المكوًن إن كانت قيمة الخاصية display هي (none). const hideWhenVisible = { display: loginVisible ? 'none' : '' } const showWhenVisible = { display: loginVisible ? '' : 'none' } <div style={hideWhenVisible}> // button </div> <div style={showWhenVisible}> // button </div> لاحظ أننا استخدمنا مجددًا العامل"؟" ثلاثي المعاملات. إن كان المتغير loginVisible "صحيحًا"، ستحمل قاعدة CSS للمكوِّن القيمة التالية: display: 'none'; وإن كان loginVisible "خاطئًا"، لن تحمل الخاصية display أية قيمة تتعلق بعرض المكوِّن. المكونات الأبناء (أو المصفوفة props.children) يُعتبر منطق الشيفرة التي تتحكم بظهور نموذج تسجيل الدخول مستقل بذاته، لهذا من الأجدى أن ننقلها إلى مكوّن جديد خاص بها. هدفنا حاليًا إضافة مكوِّن متبدّل (Togglable) يُستخدم بالطريقة التالية: <Togglable buttonLabel='login'> <LoginForm username={username} password={password} handleUsernameChange={({ target }) => setUsername(target.value)} handlePasswordChange={({ target }) => setPassword(target.value)} handleSubmit={handleLogin} /> </Togglable> تختلف طريقة استخدام المكوِّن قليلًا عن المكوٍّنات السابقة. فللمكوّن معرفات بداية ونهاية تحيط بمكوّن LoginForm. تصطلح React على تسمية المكوًّن LoginForm بالمكوِّن الإبن للمكوِّن Togglable. يمكننا إضافة عناصر React بين معرفي البداية والنهاية للمكوِّن Togglable كما في المثال التالي: <Togglable buttonLabel="reveal"> <p>this line is at start hidden</p> <p>also this is hidden</p> </Togglable> الشيفرة التالية هي شيفرة المكوِّن Togglable: import React, { useState } from 'react' const Togglable = (props) => { const [visible, setVisible] = useState(false) const hideWhenVisible = { display: visible ? 'none' : '' } const showWhenVisible = { display: visible ? '' : 'none' } const toggleVisibility = () => { setVisible(!visible) } return ( <div> <div style={hideWhenVisible}> <button onClick={toggleVisibility}>{props.buttonLabel}</button> </div> <div style={showWhenVisible}> {props.children} <button onClick={toggleVisibility}>cancel</button> </div> </div> ) } export default Togglable إن الجزء الجديد والمهم في الشيفرة هي الخاصية props.children، والتي يستخدم في الإشارة إلى المكوِّنات الأبناء لمكوِّن، والتي تمثل عناصر React الموجودة ضمن معرفي بداية ونهاية المكوّن. ستصيّر الآن المكونات الأبناء بالشيفرة ذاتها التي تصيّر المكون الأب: <div style={showWhenVisible}> {props.children} <button onClick={toggleVisibility}>cancel</button> </div> وعلى خلاف الخصائص الاعتيادية التي رأيناها سابقًا يضاف "الأبناء" تلقائيًا من قبل React وتكون موجودة دائمًا. لو عرّفنا مكوّنًا له معرف نهاية تلقائي "</" كالمكوِّن التالي: <Note key={note.id} note={note} toggleImportance={() => toggleImportanceOf(note.id)} /> ستشكل الخاصية props.children مصفوفة فارغة. يمكن إعادة استعمال المكوِّن togglable لإضافة خاصية العرض والإخفاء للنموذج الذي استخدمناه في إنشاء ملاحظة جديدة. قبل أن نفعل ذلك، لننقل شيفرة نموذج إنشاء الملاحظات الجديدة إلى مكوِّن مستقل: const NoteForm = ({ onSubmit, handleChange, value}) => { return ( <div> <h2>Create a new note</h2> <form onSubmit={onSubmit}> <input value={value} onChange={handleChange} /> <button type="submit">save</button> </form> </div> ) } سنعرّف تاليًا مكوِّن النموذج داخل المكوّن Togglable: <Togglable buttonLabel="new note"> <NoteForm onSubmit={addNote} value={newNote} handleChange={handleNoteChange} /> </Togglable> يمكنك إيجاد الشيفرة الكاملة لتطبيقنا الحالي ضمن الفرع part5-4 في المستودع المخصص للتطبيق على GitHub. حالة النماذج تتواجد حالة التطبيق في المكون الأعلى App. حيث ينص توثيق React على ما يلي بخصوص مكان تواجد الحالة: لو فكرنا قليلًا بحالة النماذج التي يضمها التطبيق، فلن يحتاج المكوّن App، على سبيل المثال، محتوى الملاحظة الجديدة قبل إنشائها. لذلك يمكننا بالمثل نقل حالة النماذج إلى مكوِّن خاص بها. سيتغيّر مكِّون الملاحظة إلى الشكل التالي: import React, {useState} from 'react' const NoteForm = ({ createNote }) => { const [newNote, setNewNote] = useState('') const handleChange = (event) => { setNewNote(event.target.value) } const addNote = (event) => { event.preventDefault() createNote({ content: newNote, important: Math.random() > 0.5, }) setNewNote('') } return ( <div> <h2>Create a new note</h2> <form onSubmit={addNote}> <input value={newNote} onChange={handleChange} /> <button type="submit">save</button> </form> </div> ) } export default NoteForm نُقلت صفة الحالة newNote ومعالج الحدث المسؤول عن تغييرها من المكوِّن App إلى المكوِّن المسؤول عن نموذج الملاحظات. وبقيت فقط الخاصية createNote وهي دالة يستدعيها النموذج عندما تُنشأ ملاحظة جديدة. سيغدو المكوّن أبسط بعد إزالة الحالة newNote ومعالج الحدث المرتبط بها. ستستقبل الدالة addNote التي تنشئ ملاحظة جديدة هذه الملاحظة كوسيط، وهذه الدالة هي الصفة الوحيدة التي أرسلناها إلى النموذج: const App = () => { // ... const addNote = (noteObject) => { noteService .create(noteObject) .then(returnedNote => { setNotes(notes.concat(returnedNote)) }) } // ... const noteForm = () => ( <Togglable buttonLabel='new note'> <NoteForm createNote={addNote} /> </Togglable> ) // ... } يمكن أن نكرر هذه العملية على نموذج تسجيل الدخول، لكننا سنترك ذلك للتمارين الإختيارية. يمكنك إيجاد شيفرة التطبيق في الفرع part5-5 على GitHub. إنشاء مراجع إلى المكوِّنات باستعمال ref جميع الإضافات التي أجريت على التطبيق جيدة، لكن هناك ناحية واحدة يمكن تحسينها. من المنطقي إخفاء نموذج إضافة الملاحظات بعد أن ننشئ الملاحظة الجديدة، لأن النموذج سيبقى ظاهرًا في الوضع الحالي. لكن هناك مشكلة صغيرة في ذلك، فمن يتحكم بعرض النماذج هو المتغير visible الموجود داخل المكوِّن Tooglable. فما هي الطريقة التي تمكننا من الوصول إليه، وهو خارج مكوّن النموذج؟ هناك طرق عديدة لإغلاق النموذج من المكوّن الأب، لكننا سنستعمل آلية المرجع ref الخاصة بالمكتبة React والتي تؤمن مرجعًا إلى المكوِّن. لنعدّل المكوّن App ليصبح كالتالي: import React, { useState, useRef } from 'react' const App = () => { // ... const noteFormRef = useRef() const noteForm = () => ( <Togglable buttonLabel='new note' ref={noteFormRef}> <NoteForm createNote={addNote} /> </Togglable> ) // ... } يستخدم الخطاف useRef لإنشاء مرجع إلى المتغير noteFormRef ويسند إلى المكوّن Togglable الذي يحتوي نموذج إنشاء ملاحظة. وبالتالي سيعمل المتغير noteFormRef كمرجع إلى المكوّن. يضمن الخطاف أن يبقى المرجع كما هو عند إعادة تصيير المكوِّن. عدّلنا أيضًا المكون Togglable ليصبح على الشكل التالي: import React, { useState, useImperativeHandle } from 'react' const Togglable = React.forwardRef((props, ref) => { const [visible, setVisible] = useState(false) const hideWhenVisible = { display: visible ? 'none' : '' } const showWhenVisible = { display: visible ? '' : 'none' } const toggleVisibility = () => { setVisible(!visible) } useImperativeHandle(ref, () => { return { toggleVisibility } }) return ( <div> <div style={hideWhenVisible}> <button onClick={toggleVisibility}>{props.buttonLabel}</button> </div> <div style={showWhenVisible}> {props.children} <button onClick={toggleVisibility}>cancel</button> </div> </div> ) }) export default Togglable تُغلَّف الدالة التي تنشئ المكوّن داخل استدعاء الدالة forwardRef، وبالتالي سيتمكن المكوّن من الوصول إلى المرجع الذي أسند إليه. يستخدم المكوِّن الخطاف useImperativeHandle ليجعل الدالة toggleVisibility متاحة خارج إطار المكوِّن. سنتمكن الآن من إخفاء النموذج بتنفيذ الأمر ()noteFormRef.current.toggleVisibility بعد أن تُنشأ الملاحظة الجديدة: const App = () => { // ... const addNote = (noteObject) => { noteFormRef.current.toggleVisibility() noteService .create(noteObject) .then(returnedNote => { setNotes(notes.concat(returnedNote)) }) } // ... } ولكي نلخص ما مضى، فإن الدالة useImperativeHandle هي خطاف يستخدم لتعريف دوال داخل المكوِّن يمكن استدعاؤها من خارجه. تنجح هذه الحيلة في تغيير حالة المكوِّن، لكنها مزعجة قليلًا. وقد كان بالإمكان إنجاز الوظيفة نفسها وبأسلوب أوضح مستخدمين طريقة المكوّنات المعتمدة على الأصناف التي اعتمدتها React القديمة. سنلقي نظرة على هذه الأصناف في القسم 7. إذًا فهي الحالة الوحيدة التي يكون فيها استخدام خطافات React أقل وضوحًا من مكوِّنات الأصناف. تستخدم المراجع في عدة حالات أخرى مختلفة غير الوصول إلى مكوِّنات React. يمكنك أن تجد شيفرة التطبيق بأكملها ضمن الفرع part5-6 في المستودع الخاص بالتطبيق على GitHub نقطة أخرى حول المكوّنات عندما نعرّف مكوّنًا في React كالتالي: const Togglable = () => ... // ... } ونستخدمه بالشكل: <div> <Togglable buttonLabel="1" ref={togglable1}> first </Togglable> <Togglable buttonLabel="2" ref={togglable2}> second </Togglable> <Togglable buttonLabel="3" ref={togglable3}> third </Togglable> </div> سنكون قد أنشأنا ثلاثة مكوّنات منفصلة ولكل منها حالته المستقلة: وتستخدم الصفة ref لإسناد مرجع لكل مكوّن ضمن المتغيرات togglable1 وtogglable2 وtogglable3. التمارين 5.5 - 5.10 5.5 واجهة أمامية لقائمة المدونات: الخطوة 5 غيّر نموذج إنشاء مدوّنة لكي يعرض عند الحاجة فقط. استخدم الأسلوب الذي اعتمدناه في بداية هذا الفصل. يمكنك إن أردت استعمال المكوّن Togglable الذي أنشأناه سابقًا. يجب أن لا يظهر النموذج بشكل افتراضي: يظهر النموذج بالنقر على الزر "new note": يختفي النموذج عند إنشاء مدونة جديدة. 5.6 واجهة أمامية لقائمة المدونات: الخطوة 6 افصل شيفرة نموذج إنشاء مدونة جديدة وضعها في مكوِّنها الخاص (إن لم تكن قد فعلت ذلك مسبقًا)، ثم انقل كل الحالات التي يتطلبها إنشاء مدونة جديدة إلى هذا المكوِّن. يجب أن يعمل المكوِّن بشكل مشابه للمكوِّن NoteForm الذي أنشأناه سابقًا في هذا الفصل. 5.7 واجهة أمامية لقائمة المدونات: الخطوة 7 * لنضف زرًا إلى كل مدونة ليتحكم بإظهار كل تفاصيل المدونة أو عدم إظهارها. حيث تظهر كل التفاصيل بالنقر على الزر وتختفي التفاصيل بالنقر على الزر ثانيةً. لا حاجة أن ينفذ الزر "like" أي شيء حاليًا. يستخدم التطبيق في الصورة السابقة بعض قواعد CSS لتحسين المظهر، ومن السهل القيام بذلك إن أدرجت قواعد التنسيق ضمن السياق، كما فعلنا في القسم 2. const Blog = ({ blog }) => { const blogStyle = { paddingTop: 10, paddingLeft: 2, border: 'solid', borderWidth: 1, marginBottom: 5 } return ( <div style={blogStyle}> <div> {blog.title} {blog.author} </div> // ... </div> )} ملاحظة: على الرغم من أن الوظيفة التي أضيفت هنا مطابقة تمامًا للوظيفة التي ينفذها بها المكوِّنToggleable، لا يمكنك استخدام هذا المكوِّن مباشرة لتحقيق المطلوب في هذا التمرين. سيكون الحل الأسهل بإضافة حالة إلى المنشور تتحكم بالنموذج الذي يعرضه. 5.8 واجهة أمامية لقائمة المدونات: الخطوة 8 * اضف وظيفةً لزر الاعجابات. يزداد عدد الإعجابات بإرسال طلب HTTP-PUT إلى عنوان المدونة الفريد في الواجهة الخلفية. وطالما أن العملية في الواجهة الخلفية ستستبدل المنشور كاملًا، عليك إرسال كل حقول المنشور ضمن جسم الطلب. فلو أردت إضافة إعجاب إلى المنشور التالي: { _id: "5a43fde2cbd20b12a2c34e91", user: { _id: "5a43e6b6c37f3d065eaaa581", username: "mluukkai", name: "Matti Luukkainen" }, likes: 0, author: "Joel Spolsky", title: "The Joel Test: 12 Steps to Better Code", url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" }, عليك إرسال طلب HTTP-PUT إلى العنوان api/blogs/5a43fde2cbd20b12a2c34e91/، يتضمن البيانات التالية: { user: "5a43e6b6c37f3d065eaaa581", likes: 1, author: "Joel Spolsky", title: "The Joel Test: 12 Steps to Better Code", url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" } تحذير من جديد: إن لاحظت أنك تستخدم await/async مع then فتأكد أنك سترتكب خطأً ما. استخدم أحد الأسلوبين وليس كلاهما في الوقت ذاته. 5.9 واجهة أمامية لقائمة المدونات: الخطوة 9 * عدّل التطبيق ليرتب المنشورات وفقًا لعدد الإعجابات التي تحملها. يمكن ترتيب المنشورات باستخدام تابع المصفوفات sort. 5.10 واجهة أمامية لقائمة المدونات: الخطوة 10 * أضف زرًا لحذف منشور. وأضف أيضًا وسيلة لحذف المنشورات في الواجهة الخلفية. قد يبدو تطبيقك مشابهًا للشكل التالي: يمكنك إظهار رسالة لتأكيد الحذف باستخدام الدالة window.confirm. أظهر زر حذف المنشور إذا كان المستخدم الحالي للتطبيق هو من أنشأ هذا المنشور. الحزمة PropTypes والخصائص النمطية يفترض المكوِّن Togglable أنه سيحصل على النص الذي سيظهر على الزر عبر الخاصية buttonLabel، فلو نسينا تحديد ذلك في المكوِّن: <Togglable> buttonLabel forgotten... </Togglable> سيعمل التطبيق، لكن سيصيّر المتصفح الزر الذي لا يحمل نصًا. لذلك قد نرغب أن نجعل وجود قيمة للنص الذي سيظهر كعنوان على الزر أمرًا إجباريًا عند استخدام المكوِّن Togglable. يمكننا تحديد الخصائص المتوقعة والضرورية (الخصائص النمطية) باستخدام الحزمة prop-types. لنثبت الحزمة إذًا npm install prop-types يمكننا تحدد الخاصية buttonLabel كخاصية إجبارية أو كخاصية نصية لازمة كالتالي: import PropTypes from 'prop-types' const Togglable = React.forwardRef((props, ref) => { // .. }) Togglable.propTypes = { buttonLabel: PropTypes.string.isRequired } ستعرض الطرفية رسالة الخطأ التالية إذا لم نحدد قيمة الخاصية: سيعمل التطبيق، فلا يمكن إجبارنا على تحديد الخصائص سوى تعريفات الخصائص النمطية PropTypes. لكن تذكر أن ترك رسائل غير مقروءة على طرفية المتصفح، هو أمر غير مهني على الإطلاق. لنعرِّف خصائص نمطية للمكوِّن LoginForm. import PropTypes from 'prop-types' const LoginForm = ({ handleSubmit, handleUsernameChange, handlePasswordChange, username, password }) => { // ... } LoginForm.propTypes = { handleSubmit: PropTypes.func.isRequired, handleUsernameChange: PropTypes.func.isRequired, handlePasswordChange: PropTypes.func.isRequired, username: PropTypes.string.isRequired, password: PropTypes.string.isRequired } إن كان نمط الخاصية الممررة خاطئًا، كأن نعرف مثلًا الخاصية handleSubmit كنص، سيتسبب ذلك برسالة التحذير التالية: المدقق ESlint هيئنا في القسم 3 مدقق تنسيق الشيفرة ESlint ليعمل مع الواجهة الخلفية. سنستخدمه الآن مع الواجهة الأمامية. يثبت البرنامج Create-react-app المدقق ESlint تلقائيًا في المشروع، وكل ما يبقى علينا هو كتابة تعليمات التهيئة المطلوبة داخل الملف eslintrc.js. ملاحظة: لا تنفذ الأمر eslint --init. سيثبت هذا الأمر آخر نسخة من ESlint والتي لن تتوافق مع ملف التهيئة الذي ينشئه createreactapp. سنبدأ تاليًا باختبار الواجهة الأمامية. ولكي نتجنب أخطاء التدقيق غير المرغوبة أو التي لا تتعلق بشيفرتنا، سنثبت الحزمة eslint-jest-plugin: npm add --save-dev eslint-plugin-jest لننشئ الملف eslintrc.js الذي يحتوي الشيفرة التالية: module.exports = { "env": { "browser": true, "es6": true, "jest/globals": true }, "extends": [ "eslint:recommended", "plugin:react/recommended" ], "parserOptions": { "ecmaFeatures": { "jsx": true }, "ecmaVersion": 2018, "sourceType": "module" }, "plugins": [ "react", "jest" ], "rules": { "indent": [ "error", 2 ], "linebreak-style": [ "error", "unix" ], "quotes": [ "error", "single" ], "semi": [ "error", "never" ], "eqeqeq": "error", "no-trailing-spaces": "error", "object-curly-spacing": [ "error", "always" ], "arrow-spacing": [ "error", { "before": true, "after": true } ], "no-console": 0, "react/prop-types": 0 }, "settings": { "react": { "version": "detect" } } } ملاحظة: إن كنت تستخدم ESlint كإضافة مع Visual Studio Code، قد تحتاج إلى إعدادات إضافية لفضاء العمل حتى يعمل. وإن كنت ترى الرسالة: فهذا دليل على أنك تحتاج إلى مزيد من تعليمات التهيئة. قد يؤدي إضافة الأمر "eslint.workingDirectories": [{ "mode": "auto" }] إلى الملف setting.json في فضاء العمل إلى حل المشكلة. يمكنك الاطلاع على المزيد حول الموضوع عبر الانترنت. لننشئ ملف تجاهل قواعد eslint ذو اللاحقة eslintignore. بحيث يحتوي الشيفرة التالية: node_modules build وهكذا لن تخضع المجلدات "build" و"node_modules" لعملية التدقيق. لننشئ أيضًا سكربت npm لتشغيل المدقق: { // ... { "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "server": "json-server -p3001 db.json", "eslint": "eslint ." }, // ... } سيسبب المكوِّن تحذيرًا مزعجًا: "لا يحمل تعريف المكوِّن اسمًا لعرضه" (Component definition is missing display name). وستكشف أداة تطوير React أيضًا أن المكوِّن لا يحمل اسمًا: لكن إصلاح ذلك أمر يسير. import React, { useState, useImperativeHandle } from 'react' import PropTypes from 'prop-types' const Togglable = React.forwardRef((props, ref) => { // ... }) Togglable.displayName = 'Togglable' export default Togglable يمكنك إيجاد شيفرة التطبيق بالكامل ضمن الفرع part5-7 في الستودع الخاص بالتطبيق على GitHub. التمرينان 5.11 - 5.12 5.11 واجهة أمامية لقائمة المدونات: الخطوة 11 عرّف خاصة نمطية propTypes لأحد مكوِّنات التطبيق. 5.12 واجهة أمامية لقائمة المدونات: الخطوة 12 أضف المدقق ESlint إلى التطبيق. أضف ما ترغب من قواعد التهيئة إلى المدقق، وأصلح كل الأخطاء الناتجة عنه. يثبت البرنامج create-react-app المدقق ESlint تلقائيًا في المشروع، وكل ما يبقى عليك هو كتابة تعليمات التهيئة المطلوبة داخل الملف eslintrc.js. ملاحظة: لا تنفذ الأمر eslint --init. سيثبت هذا الأمر آخر نسخة من ESlint والتي لن تتوافق مع ملف التهيئة الذي ينشئه create-react-app. ترجمة -وبتصرف- للفصل props.children, proptypes من سلسلة Deep Dive Into Modern Web Development
  10. لقد انصب اهتمامنا في القسمين السابقين على الواجهة الخلفية بشكل رئيسي، فلم تُدعم الواجهة الأمامية بوظائف إدارة المستخدمين التي أضفناها إلى الواجهة الخلفية في القسم 4 السابق (الذي يبدأ من درس مدخل إلى Node.js وExpress). تُظهر الواجهة الأمامية حاليًا الملاحظات الموجودة، وتعطي المستخدم امكانية تعديل أهمية الملاحظة. لم تعد هناك إمكانية لإضافة ملاحظات جديدة بعد التعديلات التي أجريناها على الواجهة الخلفية في القسم 4. حيث تتوقع الواجهة الخلفية إرفاق شهادة لتأكيد هوية المستخدم مع الملاحظة الجديدة. سنضيف جزءًا من وظيفة إدارة المستخدمين إلى الواجهة الأمامية. سنبدأ بواجهة تسجيل الدخول، وسنفترض عدم إمكانية إضافة مستخدمين جدد من الواجهة الأمامية. أُضيف نموذج تسجيل الدخول إلى أعلى الصفحة، وكذلك نُقل نموذج إضافة ملاحظة جديدة إلى أعلى قائمة الملاحظات سيبدو شكل المكوّن App كالتالي: const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') useEffect(() => { noteService .getAll().then(initialNotes => { setNotes(initialNotes) }) }, []) // ... const handleLogin = (event) => { event.preventDefault() console.log('logging in with', username, password) } return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> <form onSubmit={handleLogin}> <div> username <input type="text" value={username} name="Username" onChange={({ target }) => setUsername(target.value)} /> </div> <div> password <input type="password" value={password} name="Password" onChange={({ target }) => setPassword(target.value)} /> </div> <button type="submit">login</button> </form> // ... </div> ) } export default App يمكن أن تجد شيفرة التطبيق بوضعها الحالي في الفرع part5-1 على GitHub نتعامل مع نموذج تسجيل الدخول بالطريقة ذاتها التي تعاملنا فيها مع النماذج في القسم 2. تحتوي حالة التطبيق على حقول لكلمة السر واسم المستخدم لتخزين البيانات الموجودة في نموذج تسجيل الدخول. ولتلك الحقول معالجات أحداث تُزامن التغيرات في الحقول مع حالة المكون App. تعتمد معالجات الأحداث مبدأً بسيطًا، حيث يمرر كائن لها كوسيط ثم تفكك الحقل عن الكائن وتخزن قيمته في حالة التطبيق. ({ target }) => setUsername(target.value) لم نضف التابع handlelogin المسؤول عن التعامل مع البيانات في النموذج بعد. تجري عملية تسجيل الدخول بإرسال طلب HTTP POST إلى عنوان الموقع api/login/. لهذا سنضع الشيفرة المسؤولة عن هذا الطلب في وحدة مستقلة خاصة باسم login.js ضمن المجلد services. سنستخدم async/await بدلًا من الوعود للتعامل مع طلبات HTTP. import axios from 'axios' const baseUrl = '/api/login' const login = async credentials => { const response = await axios.post(baseUrl, credentials) return response.data } export default { login } يمكن إضافة التابع المسؤول عن تسجيل الدخول كالتالي: import loginService from './services/login' const App = () => { // ... const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [user, setUser] = useState(null) const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) setUser(user) setUsername('') setPassword('') } catch (exception) { setErrorMessage('Wrong credentials') setTimeout(() => { setErrorMessage(null) }, 5000) } } // ... } إن نجح تسجيل الدخول، ستُفرَّغ حقول نموذج التسجيل، وتُحفظ استجابة الخادم (بما فيها الشهادة والتفاصيل) في الحقل user من حالة التطبيق. أما إن فشل تسجيل الدخول، أو نتج خطأ عن تنفيذ التابع loginService.login، سيتلقى المستخدم إشعارًا بذلك، ولن يتلقى المستخدم إشعارًا بنجاح العملية. لنعدّل التطبيق بحيث يظهر نموذج تسجيل الدخول فقط إذا لم يكن قد سجل المستخدم دخوله بعد. أي عندما تكون قيمة الحقل user لاشيء (Null). وكذلك لن يظهر نموذج إضافة ملاحظة جديدة إن لم يسجل المستخدم دخوله، أي يجب أن يحتوي الحقل user تفاصيل المستخدم. سنضيف دالتين مساعدتين إلى المكوِّن App لتوليد النموذجين: const App = () => { // ... const loginForm = () => ( <form onSubmit={handleLogin}> <div> username <input type="text" value={username} name="Username" onChange={({ target }) => setUsername(target.value)} /> </div> <div> password <input type="password" value={password} name="Password" onChange={({ target }) => setPassword(target.value)} /> </div> <button type="submit">login</button> </form> ) const noteForm = () => ( <form onSubmit={addNote}> <input value={newNote} onChange={handleNoteChange} /> <button type="submit">save</button> </form> ) return ( // ... ) } ثم سنصيّرهما شرطيًا: const App = () => { // ... const loginForm = () => ( // ... ) const noteForm = () => ( // ... ) return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> {user === null && loginForm()} {user !== null && noteForm()} <div> <button onClick={() => setShowAll(!showAll)}> show {showAll ? 'important' : 'all'} </button> </div> <ul> {notesToShow.map((note, i) => <Note key={i} note={note} toggleImportance={() => toggleImportanceOf(note.id)} /> )} </ul> <Footer /> </div> ) } سيبدو شكلها غريبًا قليلًا، إلا أنها حيلة في React تُستخدم لتصيير النماذج شرطيًا: { user === null && loginForm() } فإن كانت نتيجة تنفيذ العبارة الأولى (خطأ)، أو كانت قيمتها خاطئة، لن تُنفَّذ العبارة الثانية (العبارة التي تولد النموذج). كما يمكننا تنفيذ ذلك مباشرة باستخدام العامل الشرطي: return ( <div> <h1>Notes</h1> <Notification message={errorMessage}/> {user === null ? loginForm() : noteForm() } <h2>Notes</h2> // ... </div> ) إن كانت نتيجة الموازنة user === null صحيحة، سيُنفَّذ الأمر ()loginForm، وإلا سيُنفَّذ الأمر ()noteForm. سنجري تعديلًا أخيرًا يظهر فيه اسم المستخدم على الشاشة عندما يسجل دخوله: return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> {user === null ? loginForm() : <div> <p>{user.name} logged-in</p> {noteForm()} </div> } <h2>Notes</h2> // ... </div> ) لم نقدم الحل المثالي بعد، لكننا سنكتفي بما فعلناه حاليًا. لقد أصبح المكون الرئيسي ضخمًا، وهذه علامة على ضرورة إعادة كتابة شيفرة النماذج ضمن مكوناتها الخاصة. سنترك هذا الأمر لننفذه لاحقًا ضمن التمارين الاختيارية. ستجد شيفرة التطبيق ضمن الفرع part5-2 على GitHub. إنشاء ملاحظات جديدة تُحفظ الشهادة التي تعاد من الخادم بعد نجاح تسجيل الدخول ضمن الحقل user.token في حالة التطبيق. const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) setUser(user) setUsername('') setPassword('') } catch (exception) { // ... } } لنصلح عملية إنشاء ملاحظة جديدة لتعمل مع الواجهة الخلفية، بإضافة شهادة المستخدم (بعد أن يسجل دخوله) إلى ترويسة تفويض طلب HTTP. ستصبح الوحدة noteService على النحو التالي: import axios from 'axios' const baseUrl = '/api/notes' let token = null const setToken = newToken => { token = `bearer ${newToken} `} const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } const create = async newObject => { const config = { headers: { Authorization: token }, } const response = await axios.post(baseUrl, newObject, config) return response.data } const update = (id, newObject) => { const request = axios.put(`${ baseUrl } /${id}`, newObject) return request.then(response => response.data) } export default { getAll, create, update, setToken } تحتوي الوحدة على المتغير الخاص token، الذي يمكن تغيير قيمته باستخدام الدالة setToken التي يصدّرها النموذج. تضع الدالة الشهادة في ترويسة التفويض باستعمال async/await. ولاحظ أن هذه الدالة ستُمرَّر كمعامل ثالث إلى التابع post العائد للمكتبة axios. ينبغي تغيير معالج الحدث المسؤول عن تسجيل الدخول ليستدعي التابع (noteService.setToken(user.token عند نجاح تسجيل الدخول: const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) noteService.setToken(user.token) setUser(user) setUsername('') setPassword('') } catch (exception) { // ... } } وهكذا سنتمكن من إضافة ملاحظات جديدة. تخزين الشهادة في ذاكرة المتصفح لا تزال هناك ثغرة في التطبيق. فعندما يُعاد تصيير الصفحة ستختفي معلومات المستخدم. ستبطئ هذه الثغرة عملية التطوير. إذا علينا مثلًا أن نسجل الدخول في كل مرة نحاول فيها إنشاء ملاحظة جديدة. تُحل هذه المشكلة بسهولة إذا ما خزّنا تفاصيل عملية تسجيل الدخول ضمن الذاكرة المحلية للمتصفح، وهي قاعدة بيانات في المتصفح تخزن البيانات بطريقة مفتاح-قيمة، واستخدامها سهل للغاية. حيث ترتبط كل قيمة في هذه الذاكرة بمفتاح وتحفظ في قاعدة بيانات المتصفح باستخدام التابع setItem. window.localStorage.setItem('name', 'juha tauriainen') حيث يخزّن التابع في الشيفرة السابقة مثلًا، المعامل الثاني كقيمة للمفتاح name. يمكن الحصول على قيمة المفتاح باستخدام التابع getItem. window.localStorage.getItem('name') كما يمكن حذفه باستخدام التابع removeItem. ستبقى القيم في الذاكرة المحلية حتى لو أعدنا تصيير الصفحة، وهذه ذاكرة خاصة بالمَنشأ، أي أن كل تطبيق سيحتفظ بذاكرته الخاصة. سنوسع التطبيق لكي تُحفظ تفاصيل تسجيل الدخول في الذاكرة المحلية. إن القيم التي تحفظ في الذاكرة من النوع DOMstrings، لذلك لن نتمكن من حفظ كائن JavaScript كما هو. إذًا، يجب تحويل الكائن إلى تنسيق JSON أولًا باستخدام التابع JSON.stringify. وبالعكس لابد من تحويل المعلومات التي تُقرأ من الذاكرة إلى كائن JavaScript باستخدام التابع JSON.parse. ستصبح شيفرة تسجيل الدخول كالتالي: const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) window.localStorage.setItem( 'loggedNoteappUser', JSON.stringify(user) ) noteService.setToken(user.token) setUser(user) setUsername('') setPassword('') } catch (exception) { // ... } } وهكذا سنحتفظ ببيانات تسجيل الدخول في الذاكرة المحلية والتي يمكن عرضها على الطرفية: سنتمكن أيضًا من تحري الذاكرة المحلية باستخدام أدوات التطوير. فإن كنت من مستخدمي Chrome، توجه إلى النافذة Application واختر Local storage (ستجد تفاصيل أكثر على موقع مطوري Google)، بينما لو كنت من مستخدمي Firefox توجه إلى النافذة Storage واختر Local Storage (ستجد تفاصيل أكثر على موقع مطوري Mozilla). بقي علينا أن نعدل التطبيق لكي يتحقق من وجود تفاصيل تسجيل الدخول ضمن الذاكرة المحلية. فإن وُجدت هذه التفاصيل ستُحفظ في حالة التطبيق وفي noteService أيضًا. أما الطريقة الصحيحة لتنفيذ الأمر هي استخدام خطاف التأثير، وهي آلية قدمناها لأول مرة في القسم 2، واستعملناها لإحضار الملاحظات من الخادم. نستطيع استخدام عدة خطافات تأثير، لذلك سننشئ واحدًا جديدًا ليتعامل مع أول تحميل للصفحة. const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [user, setUser] = useState(null) useEffect(() => { noteService .getAll().then(initialNotes => { setNotes(initialNotes) }) }, []) useEffect(() => { const loggedUserJSON= window.localStorage.getItem('loggedNoteappUser') if (loggedUserJSON) { const user = JSON.parse(loggedUserJSON) setUser(user) noteService.setToken(user.token) } }, []) // ... } يضمن تمرير المصفوفة الفارغة إلى خطاف التأثير أن تنفيذه سيتم فقط عندما يُصير المكون للمرة الأولى. وهكذا سيبقى المستخدم في حالة تسجيل دخول دائمة. وهذا ما يجعلنا نفكر بإضافة وظيفة لتسجيل خروج المستخدم ومسح البيانات من الذاكرة المحلية. لكننا نفضل أن نترك ذلك للتمارين. يمكن تسجيل الخروج باستعمال الطرفية وهذا كافٍ حتى اللحظة. حيث يمكن تسجيل الخروج بتنفيذ الأمر: window.localStorage.removeItem('loggedNoteappUser') أو بالأمر التالي الذي يفرّغ الذاكرة المحليّة: window.localStorage.clear() يمكنك إيجاد الشيفرة الحالية للتطبيق ضمن الفرع part5-3 على GitHub. التمارين 5.1 - 5.4 سنطور في هذه التمارين واجهة أمامية لتطبيق قائمة المدونات. يمكنك الاستفادة من التطبيق المساعد الموجود على GitHub كأساس لحلك. يتوقع التمرين أن تعمل الواجهة الخلفية على المنفذ 3001. يكفي أن تسلم التمرين بشكله النهائي، كما يمكنك ترك تعليق بعد كل تمرين، لكن ذلك غير ضروري. تذكرك التمارين القليلة الأولى بكل ما تعلمته حتى الآن عن React. وستحمل أيضًا قدرًا من التحدي، وخاصة إن لم تكن واجهتك الخلفية مكتملة. قد يكون من الأفضل استخدام الواجهة الخلفية لنموذج الحل الذي اعتمدناه في القسم 4. تذكر كل الطرق التي تعلمناها في تنقيح التطبيقات أثناء كتابتك للشيفرة، وابق عينيك على طرفية التطوير دائمًا. تحذير: إن لاحظت أنك تستخدم await/async مع then، فتأكد أنك ستقع في الأخطاء بنسبة %99. استخدم أحد الأسلوبين وليس كلاهما. 5.1 الواجهة الأمامية لقائمة المدونات: الخطوة 1 انسخ التطبيق المساعد من GitHub بتنفيذ الأمر التالي: git clone https://github.com/fullstack-hy2020/bloglist-frontend أزل ملف تهيئة git من التطبيق الذي نسخته: cd bloglist-frontend // go to cloned repository rm -rf .git يمكنك تشغيل التطبيق بالطريقة الاعتيادية، لكن عليك أولًا تثبيت الاعتماديات: npm install npm start أضف وظيفة تسجيل الدخول إلى التطبيق. يجب أن تحفظ تفاصيل تسجيل الدخول الناجح في الحقل user من حالة التطبيق. لن يظهر سوى نموذج تسجيل الدخول إن لم يسجل المستخدم دخوله بعد. ستظهر قائمة المدونات بالإضافة إلى اسم المستخدم عندما ينجح في تسجيل دخوله. لا يجب عليك تخزين تفاصيل تسجيل الدخول ضمن الذاكرة المحلية للمتصفح بعد. ملاحظة: يمكن أن تساعدك الشيفرة التالية في تنفيذ التصيير الشرطي للصفحة عند تسجيل الدخول: if (user === null) { return ( <div> <h2>Log in to application</h2> <form> //... </form> </div> ) } return ( <div> <h2>blogs</h2> {blogs.map(blog => <Blog key={blog.id} blog={blog} /> )} </div> ) } الواجهة الأمامية لقائمة المدونات: الخطوة 2 اجعل تسجيل الدخول مستمرًا مستخدمًا الذاكرة المحلية للمتصفح، وأضف طريقة لتسجيل الخروج. تأكد من عدم تذكر المتصفح أية تفاصيل عن المستخدم بعد تسجيل الخروج. 5.3 الواجهة الأمامية لقائمة المدونات: الخطوة 3 وسع التطبيق بحيث يتمكن المستخدم من إضافة مدونات جديدة. 5.4 الواجهة الأمامية لقائمة المدونات: الخطوة 4 * أضف إشعارات إلى التطبيق في أعلى الصفحة، لإبلاغ المستخدم بحالة تسجيل الدخول إن كانت ناجحة أو فاشلة. على سبيل المثال، سيظهر إشعار كهذا عندما نضيف مدونة: وإشعار كهذا عند فشل تسجيل الدخول: يجب أن يظهر الإشعار لثوان عدة، وليس ضروريًا إضافة الألوان. ترجمة -وبتصرف- للفصل Login in frontend من سلسلة Deep Dive Into Modern Web Development
  11. على المستخدمين تسجيل دخولهم إلى التطبيق، ومن المفترض عندما يحدث ذلك، أن ترتبط معلوماتهم تلقائيًا بالملاحظات التي سينشئونها. لذا سنضيف آلية تحقق مبنية على الاستيثاق إلى الواجهة الخلفية. يمكن تقديم أساسيات التحقق المبني على الاستيثاق من خلال المخطط التتابعي التالي: يسجل المستخدم دخوله أولًا باستخدام نافذة تسجيل الدخول التي أضيفت إلى تطبيق React (سنفعل ذلك في القسم 5). سيدفع تسجيل الدخول شيفرة React إلى إرسال اسم المستخدم وكلمة المرور إلى العنوان api/login/ على الخادم عن طريق طلب HTTP-POST. إن كان اسم المستخدم وكلمة المرور صحيحين، سيوّلد الخادم مفتاح مشفَّر (token) يكون بمثابة شهادة تعرّف بطريقة ما المستخدم الذي سجل دخوله. يتميز المتفاح بأنه معلَّم بعلامة رقمية (يقال عليه توقيع رقمي أحيانًا) مما يجعل تزويره مستحيلًا باستخدام طرق التزوير المعروفة. يستجيب الخادم برمز حالة يشير إلى نجاح العملية ويعيد نسخة مفتاح مشفر مع الاستجابة. يحفظ المتصفح بهذا المفتاح في حالة تطبيق React مثلًا. عندما ينشئ المستخدم ملاحظة جديدة (أو عندما يقوم بأي أمر يتطلب التوثيق)، سترسل React نسخة من المفتاح مع الطلب إلى الخادم. يستخدم عندها الخادم هذا المفتاح للتحقق من المستخدم (العملية مشابهة تقريبًا لفتح باب المنزل بالمفتاح الخاص بصاحب المنزل). ملاحظة: سنطلق على هذا المفتاح المشفر (token) اسم «شهادة» مجازًا لأنه يشهد للمستخدم الحامل له بأنه صاحب الحساب الحقيقي المخول للوصول إلى حسابه وبياناته على الموقع. لنضف أولًا وظيفة تسجيل الدخول. علينا تثبيت المكتبة jsonwebtoken التي تسمح لنا بتوليد شهادات ويب JSON (أي JSON web tokens وتختصر إلى JWT). npm install jsonwebtoken --save نضع شيفرة تسجيل الدخول التالية في الملف login.js ضمن المجلد controllers: const jwt = require('jsonwebtoken') const bcrypt = require('bcrypt') const loginRouter = require('express').Router() const User = require('../models/user') loginRouter.post('/', async (request, response) => { const body = request.body const user = await User.findOne({ username: body.username }) const passwordCorrect = user === null ? false : await bcrypt.compare(body.password, user.passwordHash) if (!(user && passwordCorrect)) { return response.status(401).json({ error: 'invalid username or password' }) } const userForToken = { username: user.username, id: user._id, } const token = jwt.sign(userForToken, process.env.SECRET) response .status(200) .send({ token, username: user.username, name: user.name }) }) module.exports = loginRouter يبدأ عمل الشيفرة بالبحث عن المستخدم ضمن قاعدة البيانات من خلال اسم المستخدم المرفق مع الطلب. بعد ذلك يتحقق من كلمة المرور التي ترفق أيضًا مع الطلب. وبما أن كلمات المرور لا تخزن كما هي في قواعد البيانات بل على شكل رموز محسوبة اعتمادًا عليها، سيتحقق التابع bcrypt.compare من صحة كلمة المرور: await bcrypt.compare(body.password, user.passwordHash) إن لم يُعثَر على المستخدم، أو كانت كلمة المرور خاطئة، سيسيب الخادم للطلب برمز الحالة 401 (غير مخول بالعملية) وسيحدد سبب إخفاق الطلب في جسم الاستجابة. إن كانت كلمة المرور صحيحة، ستنشئ الشهادة باستخدام التابع jwt.sign. تحتوي الشهادة على اسم المستخدم ومعرف المستخدم بصيغة موقعة رقميًا. const userForToken = { username: user.username, id: user._id, } const token = jwt.sign(userForToken, process.env.SECRET) توّقع الشهادة رقميًا باستعمال قيمة نصية تمثل ""السر"" موجودة في متغير البيئة SECRET. يضمن هذا التوقيع عدم قدرة أي فريق لا يعرف كلمة "السر" من توليد شهادة صالحة. يجب أن توضع قيمة متغير البيئة في الملف ذو اللاحقة (env.). يستجيب الخادم لأي طلب صحيح بإرسال رمز الحالة 200 (مناسب). تعاد بعدها الشهادة الناتجة واسم المستخدم ضمن جسم الاستجابة. نضيف شيفرة تسجيل الدخول إلى التطبيق بإضافة متحكم المسار التالي إلى الملف app.js: const loginRouter = require('./controllers/login') //... app.use('/api/login', loginRouter) لنحاول تسجيل الدخول باستخدام VS Code REST-client: لن تنجح العملية وستطبع الرسالة التالية على الطرفية: (node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value at Object.module.exports [as sign] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/sign.js:101:20) at loginRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/login.js:26:21) (node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2) أخفق الأمر (jwt.sign(userForToken, process.env.SECRET لأننا لم نضع قيمة "للسر" في متغير البيئة SECRET. سيعمل التطبيق بمجرد أن نقوم بذلك، وسيعيد تسجيل الدخول الناجح تفاصيل المستخدم والشهادة. سيولد اسم المستخدم الخاطئ أو كلمة المرور الخاطئة رسالة خطأ وسيعيد الخادم رمز الحالة المناسب. حصر تدوين الملاحظات بالمستخدمين المسجلين لنمنع إنشاء ملاحظات جديدة ما لم تكن الشهادة المرفقة مع الطلب صحيحة. بعدها ستحفظ الملاحظة في قائمة ملاحظات المستخدم الذي تعرّفه الشهادة. هناك طرق عديدة لإرسال الشهادة من المتصفح إلى الخادم. سنستخدم منها ترويسة التصريح (ِAuthorization). حيث توضح هذه الترويسة تخطيط الاستيثاق المستخدم. سيكون ذلك ضروريًا إن سمح الخادم بطرق متعددة للتحقق. حيث يوضح التخطيط للخادم الطريقة التي يفسر بها الشهادة المرفقة بالطلب. سيكون التخطيط Bearer مناسبًا لنا. وعمليًا لو كانت قيمة الشهادة على سبيل المثال (eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW) ستكون قيمة ترويسة التصريح: Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW ستتغير شيفرة إنشاء ملاحظة جديدة على النحو التالي: const jwt = require('jsonwebtoken') // ... const getTokenFrom = request => { const authorization = request.get('authorization') if (authorization && authorization.toLowerCase().startsWith('bearer ')) { return authorization.substring(7) } return null} notesRouter.post('/', async (request, response) => { const body = request.body const token = getTokenFrom(request) const decodedToken = jwt.verify(token, process.env.SECRET) if (!token || !decodedToken.id) { return response.status(401).json({ error: 'token missing or invalid' }) } const user = await User.findById(decodedToken.id) const note = new Note({ content: body.content, important: body.important === undefined ? false : body.important, date: new Date(), user: user._id }) const savedNote = await note.save() user.notes = user.notes.concat(savedNote._id) await user.save() response.json(savedNote) }) تعزل الدالة المساعدة getTokenFrom الشهادة عن ترويسة التصريح، ويتحقق التابع jwt.verify من صلاحيتها. يفك التابع تشفيرالشهادة أيضًا أو يعيد الكائن الذي بُنيت على أساسه الشهادة. const decodedToken = jwt.verify(token, process.env.SECRET) يحمل الكائن الناتج عن الشهادة بعد فك شيفرته حقلي اسم المستخدم والمعرف الخاص به، ليخبر الخادم من أرسل الطلب. إن لم تكن هناك شهادة، أو لم يحمل الكائن الناتج عن الشهادة معرف المستخدم، سيعيد الخادم رمز الحالة 401 (غير مصرّح له)، وسيحدد سبب الخطأ في جسم الاستجابة. if (!token || !decodedToken.id) { return response.status(401).json({ error: 'token missing or invalid' }) } حالما يتم التحقق من هوية المستخدم، تتابع العملية التنفيذ بالشكل السابق. يمكن إنشاء ملاحظة جديدة باستخدام Postman إن أعطت ترويسة التصريح القيمة النصية الصحيحة وهي bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ، وستكون القيمة الأخرى هي الشهادة التي تعيدها عملية تسجيل الدخول. ستبدو العملية باستخدام Postman كالتالي: وباستعمال Visual Studio Code REST client كالتالي: معالجة الأخطاء يمكن لعمليات التحقق أن تسبب أخطاء من النوع JsonWebTokenError. فلو أزلنا بعض المحارف من الشهادة وحاولنا كتابة ملاحظة جديدة، هذا ما سيحدث: JsonWebTokenError: invalid signature at /Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:126:19 at getSecret (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:80:14) at Object.module.exports [as verify] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:84:10) at notesRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/notes.js:40:30) هنالك أسباب عديدة لأخطاء فك التشفير. فقد تكون الشهادة خاطئة أو مزورة أو منتهية الصلاحية. إذًا سنوسع الأداة الوسطية لمعالجة الأخطاء لتأخذ بالحسبان أخطاء فك التشفير الممكنة: const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } const errorHandler = (error, request, response, next) => { if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { return response.status(400).json({ error: error.message }) } else if (error.name === 'JsonWebTokenError') { return response.status(401).json({ error: 'invalid token' }) } logger.error(error.message) next(error) } يمكنك إيجاد شيفرة التطبيق في الفرع part4-9 ضمن المستودع المخصص للتطبيق على GitHub. إن احتوى التطبيق على عدة واجهات تتطلب التحقق، يجب أن نضع أدوات التحقق JWT ضمن أداة وسطية مستقلة. ستساعدنا بعض المكتبات الموجودة مثل express-jwt في تحقيق ذلك. ملاحظة ختامية نظرًا للتغيرات الكثيرة التي أجريناها على الشيفرة فقد حدثت الكثير من الأخطاء التقليدية التي تحدث في كل المشاريع البرمجية، ولم ينجح الكثير من الاختبارات. ولأن هذا القسم من المنهاج مليء بالأفكار الجديدة، سنترك مهمة إصلاح الاختبارات إلى التمارين غير الإلزامية. يجب استخدام كلمات السر وأسماء المستخدمين وشهادات التحقق مع البروتوكول HTTPS. يمكننا استخدام HTTPS ضمن Node في تطبيقنا بدلًا من HTTP، لكن ذلك سيتطلب أوامر تهيئة أكثر. من ناحية أخرى، تتواجد نسخة الإنتاج من تطبيقنا على Heroku، وبذلك سيبقى التطبيق آمنًا. فكل المسارات بين المتصفح وخادم Heroku تستخدم HTTPS. سنضيف آلية تسجيل الدخول إلى الواجهة الأمامية في القسم التالي. التمارين 4.15 - 4.22 سنضيف أساسيات إدارة المستخدمين إلى تطبيق قائمة المدونات في التمارين التالية. إن الطريقة الأكثر أمانًا في الحل، هي متابعة المعلومات التي عرضناها في هذا الفصل والفصل السابق من القسم 4. كما يمكنك بالطبع استعمال مخيلتك. تحذير من جديد: إن لاحظت أنك تستخدم await/async مع then فتأكد أنك ستقع في الأخطاء بنسبة %99. استخدم أحد الأسلوبين وليس كلاهما. 4.15 التوسع في قائمة المدونات: الخطوة 3 أضف طريقة لتسجيل مستخدم جديد باستخدام طلب HTTP إلى العنوان api/users/. للمستخدمين اسم حقيقي واسم للدخول وكلمة مرور. لا تخزّن كلمة السر كنص واضح، بل استخدم المكتبة bcrypt بالطريقة التي استخدمناها في القسم4 -فصل (إدارة المستخدمين). ملاحظة: عانى بعض مستخدمي Windows أثناء العمل مع bcrypt. إن واجهتك المشاكل معها، ألغ تثبيتها كالتالي: npm uninstall bcrypt --save ثم ثبت المكتبة bcryptjs بدلًا عنها. أضف طريقة لعرض تفاصيل جميع المستخدمين من خلال طلب HTTP مناسب. يمكن أن تظهر قائمة المستخدمين بالشكل التالي: 4.16 التوسع في قائمة المدونات: الخطوة 4 * أضف ميزة للتطبيق تنفذ التقييدات التالية: اسم المستخدم وكلمة المرور إلزاميتان يجب أن يكون طول كل من كلمة المرور واسم المستخدم ثلاثة محارف على الأقل. يجب أن يكون اسم المستخدم فريدًا (غير مكرر). يجب أن تعيد العملية رمز الحالة المناسب مع رسالة خطأ عند محاولة إنشاء مستخدم جديد مخالف للتقييدات. ملاحظة: لا تختبر تقييد كلمة المرور باستعمال مقيمات Mongoose. لأن كلمة المرور التي تتلقاها الواجهة الخلفية وكلمة السر المرمزة التي تحفظ في قاعدة البيانات أمران مختلفان. يجب أن يقيم طول كلمة المرور باستخدام المتحكم كما فعلنا في القسم 3 قبل أن نستعمل مقيّمات Mongoose. أضف اختبارًا يتأكد من عدم إضافة مستخدم إذا كانت بياناته غير صحيحة. يجب أن تعيد العملية رمز حالة مناسب ورسالة خطأ. 4.17 التوسع في قائمة المدونات: الخطوة 5 وسٍّع المدونات بحيث تحتوي كل مدونة على معلومات عن المستخدم الذي أنشأها. عدل في أسلوب إضافة مدونة جديدة بحيث يُحدد أحد المستخدمين الموجودين في قاعدة البيانات كمنشئ لها (قد يكون أول شخص يظهر مثلًا). نفذ المهمة اعتمادًا على معلومات الفصل الثالث- القسم 4. لا يهم حاليًا إلى أي مستخدم قد نسبت المدونة، لأننا سنهتم بذلك في التمرين 4.19. عدّل طريقة عرض المدونات بحيث تظهر معلومات منشئ المدونة: عدل طريقة عرض قائمة المستخدمين بحيث تظهر المدونات التي أنشأها المستخدم: 4.18 التوسع في قائمة المدونات: الخطوة 6 أضف أسلوب توثيق يعتمد على الشهادات كما فعلنا في بداية هذا الفصل. 4.19 التوسع في قائمة المدونات: الخطوة 7 عدّل في طريقة إضافة مدونة جديدة لتُنفَّذ فقط في الحالة التي تُرسل فيها شهادة صحيحة عبر طلب HTTP-POST. ويعتبر عندها الشخص الذي تحدده الشهادة منشئ المدونة. 4.20 التوسع في قائمة المدونات: الخطوة 8 * راجع المثال الذي أوردناه في هذا الفصل عن استخلاص الشهادة من ترويسة التصريح باستخدام الدالة المساعدة getTokenFrom. إن أردت استخدام الأسلوب ذاته، أعد كتابة الشيفرة التي تأخذ الشهادة إلى أداة وسطية. يجب أن تأخذ الأداة الوسطية الشهادة من ترويسة التصريح وتضعها في الحقل token من كائن الطلب request. يعني هذا: إن صرّحت عن الأداة الوسطية في بداية الملف app.js وقبل كل المسارات كالتالي: app.use(middleware.tokenExtractor) ستتمكن المسارات من الوصول إلى الشهادة باستخدام الأمر request.token: blogsRouter.post('/', async (request, response) => { // .. const decodedToken = jwt.verify(request.token, process.env.SECRET) // .. }) وتذكر أن الأداة الوسطية هي دالة من ثلاث معاملات، تستدعى في نهاية تنفيذها المعامل الأخير next ليمرر التنفيذ إلى الأداة الوسطية التالية: const tokenExtractor = (request, response, next) => { // code that extracts the token next() } 4.21 التوسع في قائمة المدونات: الخطوة 9 * عدّل في عملية حذف مدونة بحيث تحذف من قبل منشئها فقط. وبالتالي ستنفذ عملية الحذف إن كانت الشهادة المرسلة عبر الطلب مطابقة لشهادة المستخدم الذي أنشأ المدونة. إن جرت محاولة حذف المدونة دون شهادة صحيحة أو من قبل المستخدم الخاطئ، على العملية أن تعيد رمز الحالة المناسب. لاحظ أنك لو أحضرت المدونة من قاعدة البيانات كالتالي: const blog = await Blog.findById(...) وأردت أن توازن بين معرف الكائن الذي أحضرته من قاعدة البيانات والقيمة النصية للمعرف، فلن تنجح عملية الموازنة العادية. ذلك أن الحقل blog.user ليس قيمة نصية بل كائن. لذلك لابد من تحويل قيمة المعرف في الكائن الذي أحضر من قاعدة البيانات إلى قيمة نصية أولًا. if ( blog.user.toString() === userid.toString() ) ... 4.22 التوسع في قائمة المدونات: الخطوة 10 * ستفشل اختبارات إضافة مدونة جديدة حالما تستخدم التوثيق المعتمد على الشهادات. أصلح الاختبارت، ثم اكتب اختبارًا جديدًا يتأكد من أن الخادم سيرد برسالة الخطأ 401 (غير مفوض) إن لم ترفق شهادة بالطلب. اطلع على بعض الأفكار المفيدة التي قد تساعدك في إصلاح الاختبارات. هكذا نكون قد وصلنا إلى التمرين الأخير في القسم، وحان الوقت لتسليم الحلول إلى GitHub، والإشارة إلى أنك سلمت التمارين المنتهية ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل Token authentication من سلسلة Deep Dive Into Modern Web Development
  12. سنحتاج إلى وسيلة للتحقق من المستخدم ومن الصلاحيات الممنوحة له في تطبيقنا. وينبغي أن تُحفظ بيانات المستخدمين في قاعدة البيانات وأن ترتبط بالمستخدم الذي أنشأها. فلن يُسمح بتعديل أو حذف الملاحظة إلا من قبل المستخدم الذي أنشأها. لنبدأ بإضافة معلومات عن المستخدمين إلى قاعدة البيانات. حيث ستظهر علاقة من الشكل "واحد إلى عدة" بين المستخدم User و الملاحظة Note: لو كنا نعمل مع قاعدة بيانات علاقيّة لكانت إضافة البيانات السابقة عملية مباشرة. حيث سيمتلك كلا الموردين جداول مستقلة في قاعدة البيانات وسيخزن معرّف المستخدم الذي أنشأ الملاحظة في جدول الملاحظات كمفتاح خارجي (foreign key). سيختلف الأمر قليلًا عند العمل مع قاعدة بيانات المستندات لوجود طرق عدة للقيام بذلك. يقتضي الحل الذي طبقناه في حالة الملاحظات بتخزين كل ملاحظة في تجمع للملاحظات notes collection ضمن قاعدة البيانات. فإن لم نشأ أن نغير التجمع، فمن المنطقي إنشاء تجمع جديد للمستخدمين على سبيل المثال. كما هي العادة في جميع قواعد بيانات المستندات يمكن استخدام معرّف الكائن في Mongo كمرجع للمستندات في التجمعات المختلفة. وهذا أمر مشابه لاستخدام المفتاح الغريب في قواعد البيانات العلاقية. لا تدعم قواعد البيانات التقليدية مثل Mongo الاستقصاء المشترك (joint query) المتاح في القواعد العلاقية، والذي يستخدم في تجميع البيانات من جداول عدة. لكن اعتبارًا من الإصدار 3.2، دعمت Mongo فكرة استقصاءات البحث المجمعة، والتي لن نتعامل معها في منهاجنا. إن احتجنا إلى وظيفة مشابهة للاستقصاء المجمع في تطبيقنا فسنضيفها إلى الشيفرة بتنفيذ استقصاءات متعددة. على الرغم من أن Mongoose تتكفل في بعض الحالات بموضوع ضم وتجميع البيانات بما يوحي بالاستقصاء المشترك، إلا أن ما تجريه في الواقع، هي استقصاءات متعددة في الخفاء. المراجع ما بين التجمعات لو استخدمنا قاعدة بيانات علاقية، فستحتوي الملاحظة على مفتاح مرجعي إلى المستخدم الذي أنشأها. وبالمثل يمكننا القيام بأمر مشابه في قواعد بيانات المستندات. لنفترض أن تجمع المستخدمين users سيضم مستخدمين اثنين: [ { username: 'mluukkai', _id: 123456, }, { username: 'hellas', _id: 141414, }, ]; يضم تجمع الملاحظات notes ثلاثة ملاحظات، لكل منها حقل خاص يرتبط مرجعيًا بمستخدم من تجمع المستخدمين: [ { content: 'HTML is easy', important: false, _id: 221212, user: 123456, }, { content: 'The most important operations of HTTP protocol are GET and POST', important: true, _id: 221255, user: 123456, }, { content: 'A proper dinosaur codes with Java', important: false, _id: 221244, user: 141414, }, ] لا تتطلب قاعدة بيانات المستند تخزين مفتاح خارجي في حقول الملاحظة، بل يمكن أن يُخزّن في تجمع المستخدمين كما يمكن أن يخزن في كلا التجمعين: [ { username: 'mluukkai', _id: 123456, notes: [221212, 221255], }, { username: 'hellas', _id: 141414, notes: [221244], }, ] طالما أن المستخدم قد ينشئ عدة ملاحظات، سنخزن معرفات هذه الملاحظات في مصفوفة ضمن الحقل notes في مورد المستخدم. تقدم قواعد بيانات المستندات أيضًا طريقة مختلفة جذريًا في تنظيم البيانات: فمن المجدي في بعض الحالات، أن نشعِّب مصفوفة الملاحظات التي أنشأها المستخدم بأكملها كجزء من المستند في تجمع المستخدمين: [ { username: 'mluukkai', _id: 123456, notes: [ { content: 'HTML is easy', important: false, }, { content: 'The most important operations of HTTP protocol are GET and POST', important: true, }, ], }, { username: 'hellas', _id: 141414, notes: [ { content: 'A proper dinosaur codes with Java', important: false, }, ], }, ] في هذا التخطيط لقاعدة البيانات، ستُشعّب الملاحظات تحت المستخدم، ولن تولّد قاعدة البيانات حينها أية معرفات لها. إن بنية وتخطيط قواعد بيانات المستندات ليس واضحة بذاتها كما هي الحال في القواعد العلاقيّة. فيجب أن يخدم التخطيط الذي سنستعمله حالات التطبيق جمعيها بأفضل شكل. وبالطبع فهذا القرار ليس بسيطًا على الإطلاق لأن حالات استخدام التطبيق ليست معروفة تمامًا في فترة التصميم. وللمفارقة، ستتطلب قواعد البيانات التي لا تمتلك تخطيطًا مثل Mongoose من المطور أن يتخذ قرارات جذرية جدًا حول تنظيم البيانات منذ البداية خلافًا للقواعد العلاقيّة. حيث تقدم القواعد العلاقية طرقًا أكثر أو أقل في تنظيم البيانات لعدة تطبيقات تخطيط Mongoose للمستخدمين لقد قررنا في حالتنا أن نخزن معرفات الملاحظات التي ينشئها مستخدم في مستند المستخدم. لذا سنعرف نموذجًا يمثل المستخدم ونضعه في الملف user.js ضمن المجلد models: const mongoose = require('mongoose') const userSchema = new mongoose.Schema({ username: String, name: String, passwordHash: String, notes: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } ], }) userSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v // the passwordHash should not be revealed delete returnedObject.passwordHash } }) const User = mongoose.model('User', userSchema) module.exports = User خُزّنت معرفات الملاحظات ضمن مستند المستخدم كمصفوفة معرفات Mongoose. وللتعريف الشكل التالي: { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } إن نمط الحقل الذي يرتبط مرجعيًا بالملاحظات هو ObjectId. لن تكون Mongo قادرة على تميز هذا الحقل على أنه مرجع للملاحظات، لأن العبارة متعلقة ومعرّفة بالمكتبة Mongoose. لنوسع تخطيط الملاحظة المعرّف في الملف notre.js ضمن المجلد model لكي تضم معلومات على المستخدم الذي أنشأها: const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, date: Date, important: Boolean, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }}) وعلى نقيض قواعد البيانات العلاقيّة سنخزن المراجع في كلا المستندين. حيث سترتبط الملاحظة مرجعيًا بالمستخدم الذي أنشأها، كما يمتلك المستخدم مصفوفة من المراجع لكل الملاحظات التي أنشأها أيضًا. تسجيل مستخدمين جدد لنضف مسارًا لإنشاء مستخدمين جدد. يمتلك المستخدم اسمًا واسمًا آخر لتسجيل الدخول (username) ومعلومة أخرى تدعى كلمة السر المرمّزة passwordHash. هذه الكلمة المرمزة هي نتاج دالة الترميز وحيدة الاتجاه عندما نطبقها على كلمة السر. فمن غير الملائم أن تخزن كلمة السر كنص عادي في قاعدة البيانات. لنثبت الحزمة bcrypt التي ستتولى أمر توليد كلمات السر المرمزة: npm install bcrypt --save سننشئ مستخدمين جدد بما يتوافق مع توجيهات RESTful التي ناقشناها في القسم 3، وذلك بإرسال طلب HTTP POST إلى عنوان الموقع users. لنعرف متحكمًا بالمسار يتعامل مع المستخدمين ولنضعه في ملف جديد باسم users.js ضمن المجلد controllers. سنجعله قابلًا للاستخدام بإدراجه في الملف app.js، حيث سيعالج الطلبات المرسلة إلى العنوان api/users/: const usersRouter = require('./controllers/users') // ... app.use('/api/users', usersRouter) يحتوي الملف الذي يعرّف المتحكم بالمسار الشيفرة التالية: const bcrypt = require('bcrypt') const usersRouter = require('express').Router() const User = require('../models/user') usersRouter.post('/', async (request, response) => { const body = request.body const saltRounds = 10 const passwordHash = await bcrypt.hash(body.password, saltRounds) const user = new User({ username: body.username, name: body.name, passwordHash, }) const savedUser = await user.save() response.json(savedUser) }) module.exports = usersRouter لا تُخزَّن كلمة السر التي تُرسل ضمن الطلب في قاعدة البيانات، بل النسخة المعمّاة التي تولّدها الدالة bcrypt.hash. إن أساسيات تخزين كلمات المرور في قواعد البيانات موضوع خارج نطاق منهاجنا، ولن نناقش ما الذي يعنيه إسناد الرقم السحري 10 إلى المتغيّر saltRounds، لكن يمكن الاطلاع على معلومات أكثر عبر الإنترنت. لا تحتوي الشيفرة الحالية على معالجات أخطاء أو معالجات تقييم، لتتحقق من كون اسم المستخدم وكلمة المرور بالشكل الصحيح. يجب أن تُختبر الميزة الجديدة يدويًا بشكل مبدئي باستخدام أدوات مثل Postman. لكن سرعان ما سيغدو الأمر مربكًا وخاصة عندما نضيف الوظيفة التي تجبر المستخدم على إدخال اسم مستخدم فريد. لذلك فكتابة الاختبارات الآلية لن يحتاج جهدًا كبيرًا، وسيجعل تطوير التطبيق أكثر سهولة. قد يبدو اختبارنا المبدئي كالتالي: const bcrypt = require('bcrypt') const User = require('../models/user') //... describe('when there is initially one user in db', () => { beforeEach(async () => { await User.deleteMany({}) const passwordHash = await bcrypt.hash('sekret', 10) const user = new User({ username: 'root', passwordHash }) await user.save() }) test('creation succeeds with a fresh username', async () => { const usersAtStart = await helper.usersInDb() const newUser = { username: 'mluukkai', name: 'Matti Luukkainen', password: 'salainen', } await api .post('/api/users') .send(newUser) .expect(200) .expect('Content-Type', /application\/json/) const usersAtEnd = await helper.usersInDb() expect(usersAtEnd).toHaveLength(usersAtStart.length + 1) const usernames = usersAtEnd.map(u => u.username) expect(usernames).toContain(newUser.username) }) }) تستخدم الاختبارات الدالة المساعدة ()usersInDb التي أضفناها إلى الملف test_helper.js الموجود في المجلد tests. ستساعدنا الدالة على التحقق من حالة قاعدة البيانات عند إنشاء مستخدم جديد: const User = require('../models/user') // ... const usersInDb = async () => { const users = await User.find({}) return users.map(u => u.toJSON()) } module.exports = { initialNotes, nonExistingId, notesInDb, usersInDb, } تُضيف الكتلة beforeEach مستخدمًا له اسم المستخدم إلى قاعدة البيانات. يمكننا كتابة اختبار جديد يتحقق من عدم تسجيل اسم مستخدم جديد موجود أصلًا: describe('when there is initially one user in db', () => { // ... test('creation fails with proper statuscode and message if username already taken', async () => { const usersAtStart = await helper.usersInDb() const newUser = { username: 'root', name: 'Superuser', password: 'salainen', } const result = await api .post('/api/users') .send(newUser) .expect(400) .expect('Content-Type', /application\/json/) expect(result.body.error).toContain('`username` to be unique') const usersAtEnd = await helper.usersInDb() expect(usersAtEnd).toHaveLength(usersAtStart.length) }) }) لن ينجح الاختبار في هذه الحالة حاليًا. نتدرب من خلال الأمثلة السابقة على ما يسمى التطوير المقاد بالاختبارات test-driven development (TDD)، حيث تُكتب الاختبارات للوظائف الجديدة قبل إضافتها إلى التطبيق. لنقيّم تفرّد اسم المستخدم بمساعدة مقيمات المكتبة Mongoose. لا تمتلك المكتبة كما أشرنا في التمرين 3.19 على مقيمات مدمجة معها للتحقق من وحدانية القيمة لحقل محدد. لكن يمكننا أن نجد حلًا جاهزًا في حزمة npm mongoose-unique-validator. لنثبت هذه الحزمة إذًا. npm install --save mongoose-unique-validator كما ينبغي أن نجري التعديلات التالية على الملف user.js الموجود ضمن المجلد models: const mongoose = require('mongoose') const uniqueValidator = require('mongoose-unique-validator') const userSchema = new mongoose.Schema({ username: { type: String, unique: true }, name: String, passwordHash: String, notes: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } ], }) userSchema.plugin(uniqueValidator) // ... يمكننا إضافة العديد من الاختبارت لتقييم حالة المستخدم الجديد، كالتحقق من طول اسم المستخدم إن كان كافيًا، أو أنه يحوي على محارف غير مسموحة، أو أن كلمة السر قوية بما يكفي. لقد تركنا تلك الوظائف كتمرينات اختيارية. قبل أن نكمل طريقنا، سنضيف معالج مسار يعيد كل المستخدمين المسجلين في قاعدة البيانات: usersRouter.get('/', async (request, response) => { const users = await User.find({}) response.json(users) }) ستبدو القائمة كالتالي: يمكنك إيجاد شيفرة التطبيق بأكملها في الفرع part4-7 ضمن المستودع الخاص بالتطبيق على GitHub. إنشاء ملاحظة جديدة ينبغي أن نعدل شيفرة إنشاء ملاحظة جديدة لكي نربطها بالمستخدم الذي أنشأها. لنوسع الشيفرة بحيث تُرسل معلومات عن المستخدم الذي أنشأ الشيفرة ضمن الحقل userId من جسم الطلب: const User = require('../models/user') //... notesRouter.post('/', async (request, response, next) => { const body = request.body const user = await User.findById(body.userId) const note = new Note({ content: body.content, important: body.important === undefined ? false : body.important, date: new Date(), user: user._id }) const savedNote = await note.save() user.notes = user.notes.concat(savedNote._id) await user.save() response.json(savedNote) }) لن يؤثر التغيير الذي حصل على الكائن user في شيء. وسيُخزَّن معرف الملاحظة في الحقل notes. const user = await User.findById(body.userId) // ... user.notes = user.notes.concat(savedNote._id) await user.save() لنحاول إنشاء ملاحظة جديدة: تبدو العملية ناجحة. لنضف ملاحظة أخرى ثم نحضر بيانات كل المستخدمين باستخدام المسار المخصص لذلك: يمكن أن نرى أن المستخدم قد أنشأ ملاحظتين. كذلك الأمر يمكن رؤية معرفات المستخدمين الذين أنشأوا الملاحظات إذا ما أحضرنا كل الملاحظات باستخدام المسار المخصص لذلك. استخدام التابع Populate نريد لواجهتنا البرمجية أن تعمل بطريقة محددة بحيث تضع محتويات الملاحظات التي أنشأها المستخدم داخل الكائن user عند إرسال طلب HTTP GET، وليس فقط المعرفات الفريدة الخاصة بها. يمكن إنجاز ذلك في قواعد البيانات العلاقيّة باستخدام الاستقصاء المشترك. لا تدعم قواعد بيانات المستندات الاستقصاء المشترك بين التجمعات كما أشرنا سابقًا، لكن يمكن للمكتبة Mongoose أن تنفذ ببعض الأمور المشابهة. حيث تنجز المكتبة الاستقصاء المشترك بتنفيذها استقصاءات متعددة تختلف عن الاستقصاء المشترك في القواعد العلاقية. فلا تتأثر حالة هذه الأخيرة عندما نجري الاستقصاء المشترك، بينما لا يمكن أن نضمن ثبات الحالة بين التجمعين اللذين ضمهما الاستقصاء عند استخدام Mongoose. ويعني هذا أن حالة التجمعين قد تتغير أثناء الاستقصاء، إذا قمنا باستقصاء مشترك بين المستخدمين والملاحظات. تستخدم Mongoose التابع populate في ضم التجمعات. لنعدل المسار الذي يعيد كل المستخدمين أولًا: usersRouter.get('/', async (request, response) => { const users = await User.find({}).populate('notes') response.json(users) }) يظهر التابع populate في السلسة بعد أن ينفذ التابع find الاستقصاء. يحدد الوسيط الذي يُمرر إلى التابع أن معرفات كائنات الملاحظة في الحقل notes من المستند user ستُستبدل بمستند note المرتبط معه مرجعيًا. ستبدو النتيجة الآن كما نريد تقريبًا: يمكن استخدام معامل التابع populate لاختيار الحقول التي نريد إضافتها من المستندات المختلفة، ويتم ذلك باستخدام تعبير Mong التالي: usersRouter.get('/', async (request, response) => { const users = await User .find({}).populate('notes', { content: 1, date: 1 }) response.json(users) }); ستكون النتيجة الآن كما نريد تمامًا: لننشر معلومات المستخدم ضمن الملاحظات بشكل ملائم: notesRouter.get('/', async (request, response) => { const notes = await Note .find({}).populate('user', { username: 1, name: 1 }) response.json(notes) }); وهكذا ستُضاف معلومات المستخدم في الحقل user من كائن الملاحظة. من المهم معرفة أن قاعدة البيانات لاتعلم أن المعرفات المخزنة في الحقل user من الملاحظات مرتبطة مرجعيًا مع تجمع المستخدمين. تعتمد وظيفة التابع populate على حقيقة أننا عرّفنا أنماطًا مرجعية في تخطيط Mongoose مزودة بالخيار ref: const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, date: Date, important: Boolean, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' } }) يمكنك إيجاد شيفرة التطبيق بأكملها في الفرع part4-8 ضمن المستودع الخاص بالتطبيق على GitHub. ترجمة -وبتصرف- للفصل User Administration من سلسلة Deep Dive Into Modern Web Development
  13. سنبدأ الآن بكتابة الاختبارات الخاصة بالواجهة الخلفية. وطالما أن منطق شيفرة الواجهة الخلفية ليس بهذا التعقيد، فلن يكون هناك معنى لكتابة اختبارت لأجزاء الشيفرة. لكن الشيء الوحيد الذي يمكن أن نجري عليه اختبار الأجزاء، هو التابع toJSON الذي يستخدم لتنسيق الملاحظات. قد يكون مفيدًا في بعض الأحيان، أن نختبر الواجهة الخلفية بأن نحاكي قاعدة البيانات بدلًا من استخدام القاعدة الأصلية، ويمكن الاستعانة بالمكتبة mongo-mock في ذلك. وطالما أن تطبيقنا لا يزال سهلًا نوعًا ما، سنختبر التطبيق بالكامل عبر واجهته البرمجية REST، وبالتالي سنضمن اختباره مع قاعدة بياناته. يسمى هذا النوع من الاختبارات الذي نجريه على عدة مكونات كمجموعة متكاملة اسم اختبار التكامل (integration testing) بيئة الاختبار أشرنا في أحد الفصول السابقة إلى أن خادم الواجهة الخلفية سيكون في وضع الإنتاج production mode عندما يعمل على Heroku. وتقتضي تقاليد Node تعريف وضع التطبيق عند تنفيذه ضمن متحول البيئة NODE_ENV. نستعمل في تطبيقنا متحولات البيئة المعرفة ضمن الملف env. فقط إن لم يكن التطبيق في وضع الإنتاج. وقد جرت العادة على تعريف أوضاع التطبيق بشكل منفصل في حالتي الاختبار والإنتاج. عدلنا سكربت package.json لكي يأخذ متحول البيئة NODE_ENV القيمة test عندما نجري الاختبارات: { // ... "scripts": { "start": "NODE_ENV=production node index.js", "dev": "NODE_ENV=development nodemon index.js", "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", "deploy": "git push heroku master", "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", "logs:prod": "heroku logs --tail", "lint": "eslint .", "test": "NODE_ENV=test jest --verbose --runInBand" }, // ... } كما أضفنا الخيار runInBand إلى سكربت npm، حيث يمنع هذا الخيار مكتبة Jest من تنفيذ الاختبارات على التوازي. وسنناقش أهمية هذا الخيار عندما نبدأ الاختبارات مستخدمين قاعدة البيانات. حددنا وضع التطبيق في سكربت npm run dev ليكون في وضع التطوير development، بحيث يمكن استخدام المكتبة nodemon. بينما حددنا وضع التطبيق ليكون في وضع الإنتاج production في سكربت npm start والذي يحدد وضع التطبيق عند التشغيل الافتراضي. سنجد أن هناك مشكلة صغيرة في تعريفنا لوضع التطبيق بهذا الشكل، فلن يعمل التطبيق على Windows. ولحل هذه المشكلة علينا تثبيت حزمة cross-env بتنفيذ الأمر التالي: npm install cross-env يمكننا إنجاز العمل بشكل متوافق مع منصات التشغيل المختلفة باستخدام المكتبة cross-env، حيث سنعدل في سكربت npm ضمن الملف package.json ليصبح على الشكل: { // ... "scripts": { "start": "cross-env NODE_ENV=production node index.js", "dev": "cross-env NODE_ENV=development nodemon index.js", // ... "test": "cross-env NODE_ENV=test jest --verbose --runInBand", }, // ... } وهكذا سنصبح قادرين على تشغيل التطبيق بأوضاع مختلفة. حيث نستطيع مثلًا أن نستخدم قاعدة بيانات منفصلة لأغراض الاختبارات عندما نجريها. على الرغم من إمكانية إنشاء قاعدة بيانات للاختبارات على MongoDB Atlas، إلا أن ذلك لن يكون حلًا جيدًا، لأن العديد من المطورين سيعملون على نفس التطبيق وبالتالي قد تظهر المشاكل. فمن المفترض ألا نجري عدة اختبارات في الوقت ذاته مستخدمين قاعدة البيانات ذاتها. ومن الأفضل إنجاز الاختبارات باستخدام قاعدة بيانات مثبتة على جهاز المطور وتعمل عليه. وبالتالي سيكون الخيار الأنسب استخدام قاعدة بيانات منفصلة لكل اختبار نجريه، وسيكون هذا الأمر بسيطًا نوعًا ما عندما نستخدم إحدى الحاويتين running Mongo in-memory أو Docker. لن نعقد الأمور أكثر بل سنستمر باستخدام قاعدة البيانات على MongoDB Atlas. لنجري بعض التعديلات على الوحدة التي تعرف إعدادات التهيئة لتطبيقنا: require('dotenv').config() let PORT = process.env.PORT let MONGODB_URI = process.env.MONGODB_URI if (process.env.NODE_ENV === 'test') { MONGODB_URI = process.env.TEST_MONGODB_URI} module.exports = { MONGODB_URI, PORT } يضم الملف env. متحولات منفصلة لتعريف عناوين قواعد البيانات لأغراض الاختبار والتطوير: MONGODB_URI=mongodb+srv://fullstack:secred@cluster0-ostce.mongodb.net/note-app?retryWrites=true PORT=3001 TEST_MONGODB_URI=mongodb+srv://fullstack:secret@cluster0-ostce.mongodb.net/note-app-test?retryWrites=true تشابه الوحدة config التي أضفناها إلى التطبيق الحزمة node-config قليلًا. لكن إضافة إعداداتنا الخاصة إلى التطبيق له مبرراته طالما أن التطبيق بسيط وأنه مصمم خصيصًا لتعلم نقاط هامة. وهكذا نكون قد أجرينا كل التعديلات المطلوبة على تطبيقنا. يمكنك إيجاد شيفرة التطبيق الحالي بالكامل في الفرع part4-2 ضمن المخزن المخصص على GitHub. استخدام المكتبة supertest سنستخدم الحزمة supertest التي ستساعدنا على إجراء الاختبارات على واجهة تطبيقنا البرمجية، حيث سنثبت الحزمة كاعتمادية تطوير كالتالي: npm install --save-dev supertest لنكتب أول اختباراتنا في الملف note_api.test.js الذي سننشئه في المجلد tests: const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') const api = supertest(app) test('notes are returned as json', async () => { await api .get('/api/notes') .expect(200) .expect('Content-Type', /application\/json/) }) afterAll(() => { mongoose.connection.close() }) تُدرج شيفرة الاختبار تطبيق Express من الوحدة app.js وتغلفها داخل الدالة supertest لنحصل على كائن جديد يدعى superagent. يُسند هذا الكائن إلى المتغير api حيث يستخدمه التطبيق لإرسال طلبات HTTP إلى الواجهة الخلفية. يرسل تطبيقنا طلب HTTP-GET إلى عنوان الموقع api/notes ويتحقق من استجابة الخادم عندما يعيد رمز الحالة (200). كما يتحقق الاختبار من أن قيمة ترويسة "نوع-المحتوى" هي application/json، وبالتالي ستكون البيانات بالتنسيق الصحيح. ويضم التطبيق أيضًا بعض التفاصيل الأخرى التي سنناقشها بعد قليل. تُسبق الدالة السهمية التي تعرف الاختبار بالتعليمة async، وكذلك يُسبق التابع الذي يستدعي الكائن api بالتعليمة await. سنعود إلى هذا الموضوع بعد أن ننفذ عدة اختبارات أولًا. لا تُعر الموضوع اهتمامًا حاليًا، وعليك فقط أن تتأكد بأن الاختبار يجري بنجاح. فالعبارتين awiat/async في واقع الأمر مرتبطتان بعدم تزامن طلبات HTTP إلى الواجهة البرمجية حيث تستخدم Async/await لكتابة شيفرة غير متزامنة في التنفيذ، لكنها توحي بالتزامن. يجب إنهاء الاتصال الذي تستخدمه Mongoose بقاعدة البيانات بمجرد إنتهاء الاختبارات (هناك اختبار واحد حاليًا). تُنجز هذه المهمة باستخدام التابع afterAll: afterAll(() => { mongoose.connection.close() }) من الممكن أن تواجه التحذير التالي على الطرفية عندما تنفذ الاختبارات: إن حدث هذا، علينا اتباع التعليمات وإضافة الملف jest.config.js عند جذر المشروع وبداخله الشيفرة التالية: module.exports = { testEnvironment: 'node' } يجدر بك الانتباه إلى تفصيل صغير ومهم، فلقد وضعنا تطبيق Express ضمن الملف app.js في بداية هذا القسم، وبقي للملف index.js وظيفة تشغيل التطبيق على المنفذ المحدد باستخدام كائن http المدمج في Node: const app = require('./app') // the actual Express app const http = require('http') const config = require('./utils/config') const logger = require('./utils/logger') const server = http.createServer(app) server.listen(config.PORT, () => { logger.info(`Server running on port ${config.PORT}`) }) تستخدم الاختبارات التي نجريها تطبيق express المعرّف في الملف app.js: const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') const api = supertest(app) // ... سنجد في توثيق supertest مايلي: بمعنًى آخر: ستهتم المكتبة بتشغيل التطبيق الذي تختبره على المنفذ الذي يستخدمه داخليًا. لنكتب عدة اختبارات جديدة: test('there are two notes', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(2) }) test('the first note is about HTTP methods', async () => { const response = await api.get('/api/notes') expect(response.body[0].content).toBe('HTML is easy') }) يخزن كلا الاختبارين الاستجابة ضمن المتغير response، وعلى خلاف الاختبار السابق الذي يستخدم توابع المكتبة supertest للتحقق من رمز الحالة الذي يعيده الخادم، سيتحقق الاختباران من البيانات المخزنة ضمن الخاصية response.body. حيث سيتحققا من تنسيق ومحتوى البيانات في الاستجابة باستخدام التابع expect العائد للمكتبة Jest. ستظهر لك الآن فائدة استخدام await/async. فعادة ما كنا نستخدم دوال الاستدعاء للوصول إلى البيانات التي تعيدها الوعود، لكن بهذه الطريقة ستغدو الأمور مريحةً أكثر: const res = await api.get('/api/notes') //http سيكون تنفيذ الشيفرة هنا بعد اكتمال طلب //res تُحفظ نتيجة تنفيذ الطلب في المتغير expect(res.body).toHaveLength(2) ستحجب الأداة الوسيطية المسؤولة عن إظهار معلومات طلب HTTP، المعلومات التي سيظهرها الاختبار، لذا سنعدل الأداة الوسطية logger بحيث لا تطبع أية معلومات على الطرفية أثناء الاختبار: const info = (...params) => { if (process.env.NODE_ENV !== 'test') { console.log(...params) }} const error = (...params) => { console.error(...params) } module.exports = { info, error } إعادة ضبط قاعدة البيانات قبل الاختبارات تبدو الاختبارات حتى هذه اللحظة سهلة وناجحة. لكنها اختبارت سيئة كونها تعتمد على حالة قاعدة البيانات. ولكي نجعل اختباراتنا أكثر واقعية، علينا إعادة ضبط قاعدة البيانات وتوليد بيانات الاختبار المطلوب بطريقة مضبوطة قبل أن نبدأ الاختبار. تستخدم اختباراتنا الدالة afterAll من المكتبة Jest لإنهاء الاتصال مع قاعدة البيانات بعد أن ينتهي تنفيذ الاختبار. لكن هناك العديد من الدوال التي تقدمها Jest والتي يمكنها تنفيذ العديد من المهام قبل أن يُنفذ أي اختبار أو قبل كل مرة يُنفذ فيها الاختبار. سنعيد ضبط قاعدة البيانات قبل كل اختبار مستخدمين الدالة beforeEach: const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') const api = supertest(app) const Note = require('../models/note') const initialNotes = [ { content: 'HTML is easy', date: new Date(), important: false, }, { content: 'Browser can execute only Javascript', date: new Date(), important: true, }, ] beforeEach(async () => { await Note.deleteMany({}) let noteObject = new Note(initialNotes[0]) await noteObject.save() noteObject = new Note(initialNotes[1]) await noteObject.save() }) تُمحى في البداية البيانات الموجودة في قاعدة البيانات، ثم تُحفظ الملاحظتين المُخزنتين في المصفوفة initialNotes فيها. نضمن عند قيامنا بذلك، بقاء حالة قاعدة البيانات كما هي قبل كل اختبار. سنجري بعض التعديلات على الاختبارين الأخيرين: test('all notes are returned', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(initialNotes.length)}) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(contents).toContain( 'Browser can execute only Javascript' ) }) انتبه جيدًا للتعليمة expect في الاختبار الأخير. ينشئ الأمر (response.body.map(r => r.content مصفوفة تضم محتوى كل ملاحظة تعيدها الواجهة البرمجية. بينما يتحقق التابع toContain من وجود الملاحظة التي تُمرر إليه كوسيط، ضمن قائمة الملاحظات التي تعيدها الواجهة البرمجية. إجراء الاختبارات واحدًا تلو الآخر يُنفٍّذ الأمر npm test كل اختبارات التطبيق. لكن من الأفضل عند كتابة الاختبارات أن ننفذ كل اختبار أو اختبارين معًا. تقدم Jest طرقًا عدة لإنجاز الأمر. من هذه الطرق استخدام التابع only، لكنها ليست فعالة عندما يُكتب الاختبار ليُنفَّذ عبر ملفات عدة. أما الطريقة الأفضل فهي تحديد الاختبارات التي نريد تنفيذها كمعاملات للأمر npm test. فالأمر التالي على سبيل المثال ينفذ فقط الاختبارات الموجودة في الملف note_api.test.js ضمن المجلد tests: npm test -- tests/note_api.test.js نستخدم الخيار t- لتنفيذ اختبار يحمل اسمًا محددًا: npm test -- -t 'a specific note is within the returned notes' يمكن للمعامل الذي نمرره، أن يشير إلى اسم الاختبار أو إلى كتلة الوصف describe block. كما يمكن أن يحمل المعامل جزءًا من الاسم فقط. سينفذ الأمر التالي،على سبيل المثال، كل الاختبارات التي تحتوي أسماؤها على الكلمة notes: npm test -- -t 'notes' ملاحظة: يمكن أن يبقى اتصال Mongoose مفتوحًا عندما ننفذ اختبارًا واحدًا، إن لم تكن هناك اختبارات قيد التنفيذ تستعمل الاتصال. قد تكون المشكلة أن supertest ستجهز الاتصال، لكن Jest لا تنفذ شيفرة الدالة afterAll. التعليمتين async/await قبل أن نستأنف كتابة المزيد من الاختبارات، لنلق نظرة على التعليميتن await وasync. قُدٍّمت التعليمتان لأول مرة في الإصدار ES7، حيث مكّنتا من استخدام "الدوال غير المتزامنة التي تعيد وعدًا" بطريقة تبدو وكأنها متزامنة. وكمثال على ما ذكرناه، سيبدو إحضار الملاحظات من قاعدة البيانات باستخدام الوعود كالتالي: Note.find({}).then(notes => { console.log('operation returned the following notes', notes) }) يعيد التابع ()Note.find وعدًا يمكن الوصول إلى نتيجته بالتصريح عن دالة استدعاء كوسيط للتابع then. وسنجد كل الشيفرة التي يجب تنفيذها حالما تنتهي العملية ضمن دالة الاستدعاء. فلو أردنا أن نجري عدة استدعاءات غير متزامنة للدالة بشكل متسلسل، ستسوء الأمور. لأن الطلبات غير المتزامنة يجب أن تتم ضمن دوال استدعاء، وهذا ما سيعقد الشيفرة وقد يقود إلى ما يسمى جحيم الاستدعاءات (callbak hell). يمكن أن نبقي الأمور تحت السيطرة نوعًا ما باستخدام سلاسل الوعود، وبالتالي نتحاشى "جحيم الاستدعاءات" بإنشاء سلسلة واضحة من التوابع then. ولقد صادفنا ذلك مراتٍ عدة في منهاجنا. لتوضيح ذلك، سنقدم مثالًا افتراضيًا عن دالة تحضر جميع الملاحظات وتحذف الأولى: Note.find({}) .then(notes => { return notes[0].remove() }) .then(response => { console.log('the first note is removed') // المزيد من الشيفرة هنا }) لا بأس باستخدام سلسلة التابع then، لكن هناك طريقة أفضل. تؤمن التوابع المولٍّدة (genertaor functions) التي قُدّمت في الإصدار ES6، طريقة ذكية في كتابة الشيفرة غير المتزامنة بطريقة تجعلها "وكأنها متزامنة". لكنها طريقة غريبة ولم تستخدم بشكل واسع. تقدم التعليمتان await/async اللتان ظهرتا للوجود في الإصدار ES7، الوظائف نفسها التي تقدمها المولدات، لكن بطريقة واضحة ومنهجية ومفهومة بالنسبة لكل مستخدمي JavaScript. يمكن إحضار الملاحظات من قاعدة البيانات باستخدام await كالتالي: const notes = await Note.find({}) console.log('operation returned the following notes', notes) تبدو الشيفرة وكأنها متزامنة. يتوقف التنفيذ مؤقتًا عند العبارة ({})const notes= await Note.find حتى يتحقق الوعد، ثم ينتقل التنفيذ بعدها إلى السطر التالي. تُسند نتيجة العملية التي تعيد الوعد بعد متابعة التنفيذ إلى المتغير notes. يمكن أن ننفذ المثال الذي طرحناه سابقًا (والذي يحمل قليلًا من التعقيد) مستخدمين await كالتالي: const notes = await Note.find({}) const response = await notes[0].remove() console.log('the first note is removed') لقد غدا الأمر أسهل وأوضح من طريقة سلسلة then بفضل القاعدة الجديدة. لكن عليك الانتباه إلى بعض التفاصيل المهمة عند استخدام async/await. فيجب على await أن تعيد وعدًا إذا أردت تطبيقها على عملية غير متزامنة. وطبعًا لن يكون الأمر صعبًا، طالما أن الدوال النظامية غير المتزامنة التي تستخدم الاستدعاءات يمكنها تدبر أمر الوعود. كما لا يمكن استعمال await في أي مكان ضمن شيفرة JavaScript، بل فقط ضمن دالة غير متزامنة. أي حتى يعمل المثال السابق لا بد من استعمال دوال غير متزامنة. لنركز الآن على السطر الأول من الدالة السهمية: const main = async () => { const notes = await Note.find({}) console.log('operation returned the following notes', notes) const response = await notes[0].remove() console.log('the first note is removed') } main() تصرح الشيفرة بوضوح أن الدالة السهمية المُسندة إلى main غير متزامنة، ثم تُستدعى الدالة بعد ذلك باستخدام ()main. استخدام async/await في الواجهة الخلفية سنغيّر شيفرة الواجهة الخلفية لتستخدم await وasync. يكفي أن نغير معالج المسار في أي دالة إلى دالة غير متزامنة حتى نستخدم التعليمة await، طالما أن كل العمليات غير المتزامنة ستجري داخل دالة. سنغير المسار الذي يستخدم لإحضار الملاحظات جميعها على النحو التالي: notesRouter.get('/', async (request, response) => { const notes = await Note.find({}) response.json(notes) }) يمكنك التحقق من نجاح الأمر باختبار الشيفرة ضمن المتصفح وكذلك بتنفيذ الاختبارات التي كتبناها سابقًا. يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-3 ضمن المستودع الخاص بالتطبيق على GitHub. اختبارت أكثر وإعادة كتابة الواجهة الخلفية يبقى دائمًا خطر التدهور (regression) قائمًا عند إعادة كتابة الشيفرة. ونقصد بذلك احتمال توقف التطبيق عن تنفيذ الوظيفة المطلوبة. إذُا سنعيد كتابة ما تبقى من عمليات بكتابة اختبار لكل مسار من مسارات الواجهة البرمجية. سنبدأ بعملية إضافة ملاحظة جديدة، حيث سنكتب اختبارًا ينشئ ملاحظة جديدة ويتحقق أن عدد الملاحظات الذي ستعيده الواجهة البرمجية سيزداد، وأن الملاحظة الجديدة موجودة بالفعل ضمن قائمة الملاحظات. test('a valid note can be added', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, } await api .post('/api/notes') .send(newNote) .expect(200) .expect('Content-Type', /application\/json/) const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(response.body).toHaveLength(initialNotes.length + 1) expect(contents).toContain( 'async/await simplifies making async calls' ) }) سيُنفًّذ الاختبار بنجاح كما خططنا وتوقعنا. لنتكب اختبارًا آخر يتحقق من عدم تخزين أية ملاحظة في قاعدة البيانات ما لم يكن فيها محتوًى. test('note without content is not added', async () => { const newNote = { important: true } await api .post('/api/notes') .send(newNote) .expect(400) const response = await api.get('/api/notes') expect(response.body).toHaveLength(initialNotes.length) }) يتحقق كلا الاختبارين من حالة الملاحظة المخزنة في قاعدة البيانات، وذلك بإحضار كل الملاحظات في التطبيق. const response = await api.get('/api/notes') سننفذ بنفس خطوات التحقق السابقة في الاختبارات القادمة، لذا يفضل نقل شيفرة الاختبارات إلى الدوال المساعدة. ولهذا سنضيف دالة تضم الشيفرة السابقة إلى ملف جديد يدعى test_helper.js ونضعه في المجلد tests: const Note = require('../models/note') const initialNotes = [ { content: 'HTML is easy', date: new Date(), important: false }, { content: 'Browser can execute only Javascript', date: new Date(), important: true } ] const nonExistingId = async () => { const note = new Note({ content: 'willremovethissoon', date: new Date() }) await note.save() await note.remove() return note._id.toString() } const notesInDb = async () => { const notes = await Note.find({}) return notes.map(note => note.toJSON()) } module.exports = { initialNotes, nonExistingId, notesInDb } تعرف الوحدة الدالة notesIdDb التي تتحقق من الملاحظات المخزنة في قاعدة البيانات. كما تضم الوحدة المصفوفة initialNotes التي تخزن الحالة الأساسية لقاعدة البيانات.كما تعرف الوحدة الدالة nonExistingId التي يمكن استخدامها لاحقًا في إنشاء كائن مُعرِّف قاعدة اليانات (ID Object)، وهو كائن لا يتبع لأي ملاحظة مخزنة في القاعدة. سنتمكن الآن من استخدام وحدة مساعدة في الاختبارات التي ستصبح على النحو: const supertest = require('supertest') const mongoose = require('mongoose') const helper = require('./test_helper')const app = require('../app') const api = supertest(app) const Note = require('../models/note') beforeEach(async () => { await Note.deleteMany({}) let noteObject = new Note(helper.initialNotes[0]) await noteObject.save() noteObject = new Note(helper.initialNotes[1]) await noteObject.save() }) test('notes are returned as json', async () => { await api .get('/api/notes') .expect(200) .expect('Content-Type', /application\/json/) }) test('all notes are returned', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(helper.initialNotes.length)}) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(contents).toContain( 'Browser can execute only Javascript' ) }) test('a valid note can be added ', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, } await api .post('/api/notes') .send(newNote) .expect(200) .expect('Content-Type', /application\/json/) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) const contents = notesAtEnd.map(n => n.content) expect(contents).toContain( 'async/await simplifies making async calls' ) }) test('note without content is not added', async () => { const newNote = { important: true } await api .post('/api/notes') .send(newNote) .expect(400) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length)}) afterAll(() => { mongoose.connection.close() }) ستعمل الشيفرة التي تعتمد على الوعود وستنجح الاختبارات التي سننفذها، وبالتالي أصبحنا مستعدين لاستخدام العبارة async/await. سنغير في الشيفرة المسؤولة عن إنشاء ملاحظة جديدة، وانتبه إلى أن تعريف معالج المسار سيُسبق بالكلمة async: notesRouter.post('/', async (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) const savedNote = await note.save() response.json(savedNote) }) لاتزال هناك هفوة صغيرة في شيفرتنا، فلم نكتب أي شيء للتعامل مع الأخطاء. كيف سنعالج الأمر إذًا؟ معالجة الأخطاء عند استعمال async/await إن حدث خطأ في معالجة الطلب POST، سنواجهة حالة الخطأ المألوفة التالية: أي سينتهي بنا المطاف أمام حالة رفض للوعد لم يهيئ التطبيق لالتقاطه، ولن تكون هناك أية استجابة للطلب. إن الطريقة المفضلة للتعامل مع الاستثناءات عندما نستعمل async/await هي الطريقة القديمة المألوفة try/catch: notesRouter.post('/', async (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) try { const savedNote = await note.save() response.json(savedNote) } catch(exception) { next(exception) }}) تستدعي الكتلة catch ببساطة الدالة next التي تمرر معالجة الطلب إلى الوحدة الوسطية التي تعالج الأخطاء. ستنجح كل الاختبارات أيضًا عند إجراء التعديلات السابقة. سنكتب تاليًا اختبارات لإحضار وحذف ملاحظة واحدة: test('a specific note can be viewed', async () => { const notesAtStart = await helper.notesInDb() const noteToView = notesAtStart[0] const resultNote = await api .get(`/api/notes/${noteToView.id}`) .expect(200) .expect('Content-Type', /application\/json/) const processedNoteToView = JSON.parse(JSON.stringify(noteToView)) expect(resultNote.body).toEqual(processedNoteToView) }) test('a note can be deleted', async () => { const notesAtStart = await helper.notesInDb() const noteToDelete = notesAtStart[0] await api .delete(`/api/notes/${noteToDelete.id}`) .expect(204) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength( helper.initialNotes.length - 1 ) const contents = notesAtEnd.map(r => r.content) expect(contents).not.toContain(noteToDelete.content) }) نحصل في الاختبار الأول على كائن الملاحظة بعد أن تخضع الاستجابة إلى عملية تقسيم ومعالجة خاصة ببيانات JSON. ستحول عملية المعالجة قيمة الخاصية date لكائن الملاحظة من كائن Date إلى نص. ولهذا، لن نتمكن مباشرة من الموازنة بين resultNote.body وnoteToView. بدلًا من ذلك علينا أن نُخضع noteToView إلى نفس عملية التقسيم والمعالجة كما يفعل الخادم مع كائن الملاحظة. لكلا الاختبارين الهيكل ذاته. ففي مرحلة إعادة الضبط (التصفير) سيحضر الاختبارين الملاحظة من قاعدة البيانات. يستدعي الاختباران بعد ذلك العملية الرئيسية التي يجري اختبارها. وأخيرًا يتحقق الاختباران من أن نتيجة العملية كما هو متوقع أو لا. سينجح الاختباران وبالتالي سنتمكن من إعادة كتابة شيفرة المسارات المختبرة بأسلوب: notesRouter.get('/:id', async (request, response, next) => { try{ const note = await Note.findById(request.params.id) if (note) { response.json(note) } else { response.status(404).end() } } catch(exception) { next(exception) } }) notesRouter.delete('/:id', async (request, response, next) => { try { await Note.findByIdAndRemove(request.params.id) response.status(204).end() } catch (exception) { next(exception) } }) يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-4 ضمن المستودع الخاص بالتطبيق على GitHub. الاستغناء عن الكتلة try/catch سترتب العبارة async/await الشيفرة قليلًا، لكن الثمن هو الحاجة إلى استخدام الكتلة catch لالتقاط الاستثناءات. تشترك معالجات المسار بالبنية نفسها: try { // do the async operations here } catch(exception) { next(exception) } قد يتسائل أحدنا عن إمكانية إعادة كتابة الشيفرة بالشكل الذي نستغني فيه عن كتلة catch. والحل بالطبع موجود في المكتبة express-async-errors. لثبتها إذًا. npm install express-async-errors --save إن استخدام المكتبة سهل للغاية. حيث ستدرج المكتبة ضمن الملف app.js في المجلد src كالتالي: const config = require('./utils/config') const express = require('express') require('express-async-errors') const app = express() const cors = require('cors') const notesRouter = require('./controllers/notes') const middleware = require('./utils/middleware') const logger = require('./utils/logger') const mongoose = require('mongoose') // ... module.exports = app ستلغي هذه المكتبة كتل try/catch كليًا. فلو أخذنا على سبيل المثال المسار الذي يحذف ملاحظة: notesRouter.delete('/:id', async (request, response, next) => { try { await Note.findByIdAndRemove(request.params.id) response.status(204).end() } catch (exception) { next(exception) } }) سيتحول إلى الشكل: notesRouter.delete('/:id', async (request, response) => { await Note.findByIdAndRemove(request.params.id) response.status(204).end() }) لم يعد علينا استدعاء next(exception)‎ بعد الآن، حيث تعالج المكتبة كل ما هو مطلوب في الخفاء. وإن حدث استثناء في مسار async سيُمرر تلقائيًا إلى الأداة الوسطية لمعالجة الأخطاء. ستصبح المسارات الأخرى على النحو: notesRouter.post('/', async (request, response) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) const savedNote = await note.save() response.json(savedNote) }) notesRouter.get('/:id', async (request, response) => { const note = await Note.findById(request.params.id) if (note) { response.json(note) } else { response.status(404).end() } }) ستجد شيفرة التطبيق في الفرع part4-5 ضمن المستودع المخصص على GitHub. تحسين الدالة beforeEach لنعد إلى كتابة الاختبارات ولنحاول أن نلقي نظرةً أقرب إلى الدالة beforeEach التي تضبط الاختبارات: beforeEach(async () => { await Note.deleteMany({}) let noteObject = new Note(helper.initialNotes[0]) await noteObject.save() noteObject = new Note(helper.initialNotes[1]) await noteObject.save() }) تحفظ الدالة أول ملاحظتين من المصفوفة helper.initialNotes ضمن قاعدة البيانات عبر عمليتين منفصلتين. لابأس بذلك، لكن هناك طريقة أفضل لتخزين عدة كائنات في قاعدة البيانات: beforeEach(async () => { await Note.deleteMany({}) console.log('cleared') helper.initialNotes.forEach(async (note) => { let noteObject = new Note(note) await noteObject.save() console.log('saved') }) console.log('done') }) test('notes are returned as json', async () => { console.log('entered test') // ... } لقد خزَّنا الملاحظات الموجودة في المصفوفة ضمن قاعدة البيانات باستعمال حلقة forEach. لن يعمل التطبيق كما أردنا تمامًا، لذلك كان علينا أن نطبع بعض السجلات على الطرفية لتساعدنا على فهم المشكلة. فأظهرت الطرفية ما يلي: cleared done entered test saved saved على الرغم من استخدام async/await، لم يعمل الحل المقترح بالطريقة المتوقعة. بل بدأ الاختبار قبل أن يُعاد ضبط قاعدة البيانات. تكمن المشكلة في أن كل تكرار للحلقة forEach يولد عملية غير متزامنة خاصة به، ولن تنتظر الحلقة forEach كل تكرار لكي يُنهي تنفيذ عمليته. بمعنى آخر، لم توضع التعليمات await الموجودة داخل حلقة forEach في الدالة beforeEach بل في دوال منفصلة لن تنتظرها beforeEach حتى يكتمل تنفيذها. طالما ستُنفَّذ الاختبارات مباشرة بعد الانتهاء من تنفيذ beforeEach، إذًا ستُنفذ قبل إعادة ضبط قاعدة البيانات. إحدى الطرق المتبعة في معالجة الموضوع هي انتظار العمليات غير المتزامنة حتى تنتهي باستعمال التابع Promise.all: beforeEach(async () => { await Note.deleteMany({}) const noteObjects = helper.initialNotes .map(note => new Note(note)) const promiseArray = noteObjects.map(note => note.save()) await Promise.all(promiseArray) }) إن الأسلوب المتبع متقدم على الرغم من مظهره المختصر. نلاحظ كيف يُسند المتغير noteObjects إلى مصفوفة من كائنات Mongoose التي أنشئت باستخدام الدالة البانية Note من أجل كل ملاحظة موجودة في المصفوفة helper.initialNotes. يُنشئ السطر التالي من الشيفرة مصفوفة جديدة تتضمن الوعود التي أُنشئت بدورها باستدعاء التابع save من أجل كل عنصر من عناصر المصفوفة noteOjects. بمعنًى آخر، تمثل المصفوفة مصفوفةً لتخزين كل العناصر ضمن قاعدة البيانات. يستخدم التابع Promise.all لتحويل مصفوفة من الوعود إلى وعد وحيد يتحقق بمجرد تحقق كل الوعود في المصفوفة التي تمرر إلى التابع. ينتظر الأمر (await Promise.all(promiseArray في السطر الأخير من الشيفرة كل وعد سيخزن ملاحظة في قاعدة البيانات حتى يتحقق، ويعني هذا أن قاعدة البيانات قد أعيد ضبطها. ينفذ التابع Promise.all الوعود التي تمرر إليه بالتوازي. وبالتالي إن أردنا تنفيذ الوعود بترتيب معين ستظهر المشاكل. يمكن أن تنفذ العمليات في حالات كهذه ضمن كتلة for…of التي تضمن ترتيبًا محددًا للتنفيذ: beforeEach(async () => { await Note.deleteMany({}) for (let note of helper.initialNotes) { let noteObject = new Note(note) await noteObject.save() } }) قد تقود الطبيعة غير المتزامنة للغة JavaScript إلى سلوك مفاجئ، ولهذا يجب الانتباه والحرص عند استخدام async/await. وعلى الرغم من أن استعمالها يسهل التعامل مع الوعود، لكن يجدر بنا فهم الآلية التي تعمل بها الوعود بشكل جيد. التمارين 4.8 - 4.12 ملاحظة: نستخدم في التمارين تابع المطابقة toContain في أماكن عدة لنتأكد من وجود عنصر معين في مصفوفة. يستخدم التابع السابق العامل (===) في الموازنة والمطابقة بين العناصر، وهذا غير ملائم في العديد من الحالات بما فيها الكشف عن تطابق كائنين. بينما يعتبر تابع المطابقة toContainEqual ملائمًا في معظم الحالات للمقارنة بين الكائنات ضمن المصفوفات. لا يتحقق نموذج الحل من الكائنات في المصفوفات باستخدام توابع المطابقة، لذا فاستخدام هذا الأسلوب ليس ملزمًا في حل التمارين. تحذير: إن وجدت نفسك قد استخدمت async/await مع then في نفس الشيفرة فهذا دليل على ارتكابك خطأً ما. استخدم أحد الأسلوبين وليس كلاهما. 4.8 اختبارات على قائمة المدونات: الخطوة 1 استخدم الحزمة supertest لكتابة اختبار يرسل طلب HTTP GET إلى العنوان api/blogs/. وتحقق أن تطبيق قائمة المدونات سيعيد العدد الصحيح من منشورات المدونات بصيغة JSON. حالما ينتهي الاختبار، أعد كتابة معالج المسار مستخدمًا async/await بدلًا من الوعود. لاحظ أنه عليك إجراء نفس التغيرات التي أجريناها سابقًا على شيفرتك كتحديد بيئة الاختبار، لتتمكن من كتابة اختبارات يتعامل كل منها مع قاعدة بيانات خاصة به. ملاحظة: قد تواجه التحذير التالي عندما تنفذ الاختبار: إن حدث ذلك، اتبع التعليمات وانشئ ملفًا جديدًا باسم jest.config.js عند جذر المشروع بحيث يحتوي الشيفرة التالية: module.exports = { testEnvironment: 'node' } ملاحظة: يفضل عند كتابة الاختبارات أن لا تنفذها دفعة واحدة. اختبر فقط ذلك الذي تعمل عليه. 4.9 اختبارات على قائمة المدونات: الخطوة 2 * اكتب اختبارًا يتحقق من أن اسم المعرِّف الفريد لكل منشور في المدونة هو id. ستسمي قاعدة البيانات هذه الخاصية بالاسم id_ بشكل افتراضي. يمكن تحقيق ذلك بسهولة عند استخدام تابع المطابقة toBeDefined العائد للمكتبة. أجري التعديلات المناسبة على الشيفرة حتي يُنفذ الاختبار بنجاح. قد يكون التابع toJSON الذي قدمناه في القسم3، المكان الأنسب لتعريف المعامل id. 4.10 اختبارات على قائمة المدونات: الخطوة 3 اكتب اختبارًا تتحقق فيه أن طلب HTTP POST إلى العنوان api/blogs/، سينشئ بنجاح منشورًا جديدًا. تحقق على الأقل أن العدد الكلي للمنشورات في المدونة قد ازداد بمقدار 1. كما يمكنك التحقق أيضًا، أن محتوى المنشور قد حُفظ بالشكل الصحيح في قاعدة البيانات. حالما ينجح الاختبار، أعد كتابة الشيفرة مستخدمًا async/await بدلًا من الوعود. 4.11 اختبارت على قائمة المدونات: الخطوة 4 * اكتب اختبارًا تتحقق فيه من وجود الخاصية likes في الطلب، واجلعها 0 إن لم تكن موجودة. لا تختبر بقية خصائص المدونات التي أنشئت. عدل في الشيفرة حتى تنجز الاختبار بنجاح. 4.12 اختبارات على قائمة المدونات: الخطوة 5 * اكتب اختبارًا متعلقًا بإنشاء مدونة جديدة من خلال الوجهة api/blogs/. حيث يتحقق من وجود الخاصيتين title وurl ضمن بيانات الطلب. فإن لم يعثر عليهما ستستجيب الواجهة الخلفية برمز الحالة 400 (طلب سيء). إعادة كتابة الاختبارت لم تكتمل تغطيتنا بعد لموضوع الاختبارات. فلم نختبر طلبات مثل GET /api/notes/:id وDELETE /api/notes/:id عندما ترسل بمعرف غير صالح. كما تحتاج عملية تنظيم وتجميع الاختبارات إلى بعض التحسينات، حيث كتبت جميعها في المستوى الأعلى نفسه ضمن ملف الاختبار. قد تتحسن قابلية قراءة الاختبارات عندما نجمع الاختبارات المترابطة باستخدام كتل الوصف (descripe blocks): const supertest = require('supertest') const mongoose = require('mongoose') const helper = require('./test_helper') const app = require('../app') const api = supertest(app) const Note = require('../models/note') beforeEach(async () => { await Note.deleteMany({}) const noteObjects = helper.initialNotes .map(note => new Note(note)) const promiseArray = noteObjects.map(note => note.save()) await Promise.all(promiseArray) }) describe('when there is initially some notes saved', () => { test('notes are returned as json', async () => { await api .get('/api/notes') .expect(200) .expect('Content-Type', /application\/json/) }) test('all notes are returned', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(helper.initialNotes.length) }) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(contents).toContain( 'Browser can execute only Javascript' ) }) }) describe('viewing a specific note', () => { test('succeeds with a valid id', async () => { const notesAtStart = await helper.notesInDb() const noteToView = notesAtStart[0] const resultNote = await api .get(`/api/notes/${noteToView.id}`) .expect(200) .expect('Content-Type', /application\/json/) const processedNoteToView = JSON.parse(JSON.stringify(noteToView)) expect(resultNote.body).toEqual(processedNoteToView) }) test('fails with statuscode 404 if note does not exist', async () => { const validNonexistingId = await helper.nonExistingId() console.log(validNonexistingId) await api .get(`/api/notes/${validNonexistingId}`) .expect(404) }) test('fails with statuscode 400 id is invalid', async () => { const invalidId = '5a3d5da59070081a82a3445' await api .get(`/api/notes/${invalidId}`) .expect(400) }) }) describe('addition of a new note', () => { test('succeeds with valid data', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, } await api .post('/api/notes') .send(newNote) .expect(200) .expect('Content-Type', /application\/json/) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) const contents = notesAtEnd.map(n => n.content) expect(contents).toContain( 'async/await simplifies making async calls' ) }) test('fails with status code 400 if data invaild', async () => { const newNote = { important: true } await api .post('/api/notes') .send(newNote) .expect(400) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length) }) }) describe('deletion of a note', () => { test('succeeds with status code 204 if id is valid', async () => { const notesAtStart = await helper.notesInDb() const noteToDelete = notesAtStart[0] await api .delete(`/api/notes/${noteToDelete.id}`) .expect(204) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength( helper.initialNotes.length - 1 ) const contents = notesAtEnd.map(r => r.content) expect(contents).not.toContain(noteToDelete.content) }) }) afterAll(() => { mongoose.connection.close() }) جُمّعت مخرجات الاختبارات وفقًا لكتل الوصف: هنالك متسع للتحسينات أيضًا، لكن لابد من المضي قُدمًا. إن هذه الطريقة في الاختبار التي تعتمد على إجراء طلبات HTTP وتحري قواعد البيانات باستخدام Mongoose، ليست الطريقة الوحيدة وليست الأفضل في إنجاز اختبارات التكامل على مستوى الواجهة البرمجية لتطبيقات الخوادم. في الواقع لا توجد طريقة مثلى معتمدة عالميًا لكتابة الاختبارات، لأنها تعتمد على طبيعة التطبيق المختبر والموارد المتاحة. يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-6 ضمن المستودع الخاص بالتطبيق على GitHub. التمارين 4.13 - 4.14 4.13 التوسع في قائمة المدونات: خطوة 1 أضف إلى التطبيق وظيفة حذف منشور واحد. استخدم العبارة async/await. إجعل عملك متوافقًا مع REST عندما تعرّف الواجهة البرمجية لطلبات HTTP. أضف، إن أردت، أية اختبارات للتأكد من عمل الوظيفة، أو استخدم Postman أو أية أداة أخرى. 4.14 التوسع في قائمة المدونات: خطوة 2 أضف إلى التطبيق وظيفة تعديل منشور واحد. استخدم العبارة async/await. أهم ما يتطلبه التطبيق هو تحديث عدد الإعجابات للمنشور. يمكنك إضافة هذه الوظيفة كما حدثنا الملاحظات في القسم 3. أضف، إن أردت، أية اختبارات للتأكد من عمل الوظيفة، أو استخدم Postman أو أية أداة أخرى. ترجمة -وبتصرف- للفصل testing the backend من سلسلة Deep Dive Into Modern Web Development
  14. سنعود للعمل على نسخة الواجهة الخلفية لتطبيق الملاحظات الذي بدأناه في القسم 3 من سلسلة full_stack_101. الهيكل العام للمشروع قبل أن ننتقل لموضوع الاختبارات، سنجري بعض التعديلات على هيكلية مشروعنا لنواكب أفضل المعايير في كتابة تطبيقات Node.js. سينتج عن هذه التعديلات الهيكل التالي لمجلد المشروع: ├── index.js ├── app.js ├── build │ └── ... ├── controllers │ └── notes.js ├── models │ └── note.js ├── package-lock.json ├── package.json ├── utils │ ├── config.js │ ├── logger.js │ └── middleware.js استخدمنا حتى هذه اللحظة الأمرين console.log وconsole.error في طباعة بيانات الشيفرة التي تهمنا على الطرفية. لكن من الأفضل فصل الشيفرات التي تتعلق بأمور الطباعة في وحدة مستقلة خاصة بها سنسميها logger.js، وسنضعها في المجلد utils: const info = (...params) => { console.log(...params) } const error = (...params) => { console.error(...params) } module.exports = { info, error } كما نلاحظ، تحتوي الوحدة على دالتين الأولى info تتولى أمر طباعة الرسائل العادية، والأخرى error تتولى طباعة رسائل الخطأ. لوضع شيفرة الطباعة في وحدة منفصلة مزايا عدة، فلو أردنا مثلًا طباعة السجلات أو الرسائل إلى ملف أو إلى خدمة خارجية لإدارة السجلات مثل graylog أو papertrail، لن يكون علينا سوى تعديل الشيفرة في ملف واحد. سيصبح ملف تشغيل التطبيق index.js على الشكل التالي: const app = require('./app') // the actual Express application const http = require('http') const config = require('./utils/config') const logger = require('./utils/logger') const server = http.createServer(app) server.listen(config.PORT, () => { logger.info(`Server running on port ${config.PORT}`) }) تنحصر وظيفة الملف index.js الآن، بإدراج التطبيق الفعلي من الملف app.js وتشغيله. ستقوم الدالة info بطباعة عبارة على الطرفية تدل على أن التطبيق يعمل. كذلك سننقل الشيفرة التي تتعامل مع متغيرات البيئة إلى وحدة مستقلة اسمها config.js ونضعها في المجلد utils: require('dotenv').config() const PORT = process.env.PORT const MONGODB_URI = process.env.MONGODB_URI module.exports = { MONGODB_URI, PORT } يمكن لأي جزء من التطبيق الوصول إلى متغيرات البيئة بإدراج الوحدة السابقة: const config = require('./utils/config') logger.info(`Server running on port ${config.PORT}`) نقلنا كذلك الشيفرة التي تتعامل مع المسارات إلى وحدة خاصة. وطالما أن مصطلح "متحكمات" يستخدم للإشارة إلى هذه المسارات، سننشئ مجلدًا باسم controller وسنضع فيه الوحدة notes.js التي ستحتوي شيفرة المسارات التي تتعلق بالملاحظات. سيكون محتوى هذه الوحدة كالتالي: const notesRouter = require('express').Router() const Note = require('../models/note') notesRouter.get('/', (request, response) => { Note.find({}).then(notes => { response.json(notes) }) }) notesRouter.get('/:id', (request, response, next) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => next(error)) }) notesRouter.post('/', (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date() }) note.save() .then(savedNote => { response.json(savedNote) }) .catch(error => next(error)) }) notesRouter.delete('/:id', (request, response, next) => { Note.findByIdAndRemove(request.params.id) .then(() => { response.status(204).end() }) .catch(error => next(error)) }) notesRouter.put('/:id', (request, response, next) => { const body = request.body const note = { content: body.content, important: body.important, } Note.findByIdAndUpdate(request.params.id, note, { new: true }) .then(updatedNote => { response.json(updatedNote) }) .catch(error => next(error)) }) module.exports = notesRouter هذه الشيفرة هي محتوى الملف index.js القديم إجمالًا ما عدا بعض التغييرات المهمة. حيث أنشأنا كائن متحكم بالمسار في بداية الملف: const notesRouter = require('express').Router() //... module.exports = notesRouter لقد أدرجت الوحدة متحكمًا بالمسار ليكون متاحًا للاستخدام ضمنها. وهكذا تُستدعى المسارات التي نحتاجها كخصائص لكائن التحكم بالمسار بشكل مماثل لما يفعله الكائن الذي يتحكم بكامل التطبيق. ويجب الانتباه إلى الطريقة التي اختصرنا فيها عنوان المسار. فلقد عرفنا في النسخة السابقة مسارًا على الشكل: app.delete('/api/notes/:id', (request, response) => { بينما أصبح التعريف في النسخة الجديدة كالتالي: notesRouter.delete('/:id', (request, response) => { ما هو إذًا بالتحديد كائن التحكم بالمسار؟ يزودنا توثيق المكتبة express بالتعريف التالي: يمثل المتحكم بالمسار في واقع الأمر أداة وسطية يمكن استخدامها لتعريف العمليات المتعلقة بالمسارات في مكان واحد، وهو عادة وحدة منفصلة خاصة. يستخدم الملف app.js -الذي ينشئ التطبيق الوظيفي المطلوب- هذا المتحكم بالطريقة التالية: const notesRouter = require('./controllers/notes') app.use('/api/notes', notesRouter) يُستخدم المتحكم بالمسار الذي عرفناه في الشيفرة السابقة، إذا كان الطلب موجهًا إلى الموقع الذي يبدأ عنوانه بالصيغة "/api/notes". ولهذا على المتحكم notesRouter أن يعرّف المسارت بعناوين نسبية فقط على شكل مسار فارغ "/" أو أن يذكر المعامل فقط "id:/". سيبدو الملف app.js بعد إجراء التعديلات على النحو التالي: const config = require('./utils/config') const express = require('express') const app = express() const cors = require('cors') const notesRouter = require('./controllers/notes') const middleware = require('./utils/middleware') const logger = require('./utils/logger') const mongoose = require('mongoose') logger.info('connecting to', config.MONGODB_URI) mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true }) .then(() => { logger.info('connected to MongoDB') }) .catch((error) => { logger.error('error connection to MongoDB:', error.message) }) app.use(cors()) app.use(express.static('build')) app.use(express.json()) app.use(middleware.requestLogger) app.use('/api/notes', notesRouter) app.use(middleware.unknownEndpoint) app.use(middleware.errorHandler) module.exports = app ستجد أن التطبيق قد استخدم العديد من الأدوات الوسطية ومنها الأداة notesRouter التي ارتبطت بالمسار "api/notes/". نقلنا أيضًا الأداة الوسطية الخاصة التي أنشأناها في القسم السابق باسم middleware.js إلى المجلد utils بعد إجراء القليل من التعديلات: const logger = require('./logger') const requestLogger = (request, response, next) => { logger.info('Method:', request.method) logger.info('Path: ', request.path) logger.info('Body: ', request.body) logger.info('---') next() } const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } const errorHandler = (error, request, response, next) => { logger.error(error.message) if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { return response.status(400).json({ error: error.message }) } next(error) } module.exports = { requestLogger, unknownEndpoint, errorHandler } أُوكلت مهمة الاتصال مع قاعدة البيانات إلى الوحدة الرئيسية app.js. بينما تتحمل الوحدة note.js الموجودة في المجلد models مسؤولية تعريف تخطيطات Mongoose للملاحظات. const mongoose = require('mongoose') mongoose.set('useFindAndModify', false) const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, date: { type: Date, required: true, }, important: Boolean, }) noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) module.exports = mongoose.model('Note', noteSchema) باختصار، سيبدو هيكل المشروع بشكله الجديد كالتالي: ├── index.js ├── app.js ├── build │ └── ... ├── controllers │ └── notes.js ├── models │ └── note.js ├── package-lock.json ├── package.json ├── utils │ ├── config.js │ ├── logger.js │ └── middleware.js لا نهتم كثيرًا في التطبيقات الصغيرة بهيكلية مشروع التطبيق، لكن بمجرد أن يبدأ التطبيق بالنمو، لابد من تأسيس هيكل بشكل أو بآخر، وتوزيع العمل على وحدات منفصلة. بهذا يغدو تطوير التطبيق أسهل. لا توجد معايير أو تفاهمات خاصة بين المطورين على تسمية مجلدات المشروع عند بناء تطبيقات express. بالمقابل تحتاج بعض المكتبات مثل Ruby on Rails هيكلًا خاصًا. يقدم الهيكل الذي بنيناه لتطبيقنا السابق، واحدًا من أفضل الأمثلة لبناء التطبيقات التي قد تصادفها على الانترنت. ستجد التطبيق بشكله الكامل في الفرع part4-1 على GitHub. إن نسخت المشروع لتحتفظ به لنفسك، نفذ الأمر npm install، قبل أن تشغل التطبيق بالأمر npm run. التمارين 4.1 - 4.2 سنبني خلال تمارين هذا القسم تطبيقًا لإنشاء قائمة مدونات تسمح للمستخدم أن يحفظ معلومات عن مدونات وجدها مهمة خلال تصفحه للإنترنت. سيقوم التطبيق بحفظ اسم المؤلف وعنوان المدونة وعنوان موقعها وعدد التقييمات الإيجابية للمدونة من قبل مستخدمي التطبيق. 4.1 قائمة بالمدونات: الخطوة 1 لنتخيل أنك تلقيت بريدًا إلكترونيًا له جسم التطبيق التالي: const http = require('http') const express = require('express') const app = express() const cors = require('cors') const mongoose = require('mongoose') const blogSchema = new mongoose.Schema({ title: String, author: String, url: String, likes: Number }) const Blog = mongoose.model('Blog', blogSchema) const mongoUrl = 'mongodb://localhost/bloglist' mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }) app.use(cors()) app.use(express.json()) app.get('/api/blogs', (request, response) => { Blog .find({}) .then(blogs => { response.json(blogs) }) }) app.post('/api/blogs', (request, response) => { const blog = new Blog(request.body) blog .save() .then(result => { response.status(201).json(result) }) }) const PORT = 3003 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) حول التطبيق إلى مشروع npm قابل للعمل. هيئ التطبيق ليُنفّذ بمساعدة nodemon. يمكنك أن تنشئ قاعدة بيانات جديدة على MongoDB Atlas، أو استخدم قاعدة البيانات التي أنشأناها في تمرينات القسم السابق. تأكد من إمكانية إضافة مدونات إلى القائمة باستخدام Postman أو VS Code REST client وتأكد كذلك أن التطبيق سيعيد المدونات التي أضيفت إلى الجهة التي طلبتها. 4.2 قائمة بالمدونات: الخطوة 2 قسم التطبيق إلى وحدات منفصلة كما فعلنا سابقًا في هذا القسم. ملاحظة: نفذ التقسيم بخطوات صغيرة خطوة تلو الأخرى، وتأكد أن التطبيق سيعمل بعد كل تغيير. إن حاولت أن تختصر الطريق وتقوم بعدة خطوات في آن واحد سيفشل شيء ما في تطبيقك فهذا السلوك خاضع لقانون مورفي. وستستغرق وقتًا في إيجاد حل لمشكلتك أكثر من الوقت الذي ستستغرقه في تنفيذ التقسيم على خطوات منهجية وصغيرة. حاول أن توضح ما فعلت من خلال التعليقات وخاصة عندما تنجح الخطوة التي نفذتها، سيساعدك هذا في التراجع إلى النقطة التي عَمل فيها التطبيق بشكل جيد. اختبار تطبيقات Node لقد أهملنا كليًا ناحية مهمة من نواحي تطوير البرمجيات وهي الاختبارات المؤتمتة. سنبدأ رحلتنا في مجال الاختبارات، باختبارات الأجزاء unit test. إن منطق التطبيق الذي نعمل عليه بسيط فلن يكون موضوع اختبارات الأجزاء مهمًا لهذه الدرجة. مع ذلك لننشئ ملفًا جديدًا باسم for_testing.js في المجلد utils ولنعرف فيه دالتين نستخدمهما لاختبار بعض نواحي كتابة الشيفرة: const palindrome = (string) => { return string .split('') .reverse() .join('') } const average = (array) => { const reducer = (sum, item) => { return sum + item } return array.reduce(reducer, 0) / array.length } module.exports = { palindrome, average, } هناك الكثير من المكتبات أو أدوات الاختبار التي تُستخدم مع JavaScript. سنستخدم في منهاجنا المكتبة Jest التي طورت واستخدمت من قبل Facebook. وتشابه هذه المكتبة مكتبة Mocha الزعيمة السابقة لأدوات اختبار JavaScript. ومن البدائل المطروحة أيضًا مكتبة ava التي اكتسبت شعبية كبيرة في بعض النطاقات. سيكون اختيار Jest أمرًا طبيعيًا في منهاجنا، فهي تعمل بشكل جيد عند إجراء الاختبارات على الواجهة الخلفية، وتقدم أداء رائعًا عند إجراء اختبارات على تطبيقات React. طالما أن الاختبارات ستنفذ فقط في مرحلة التطوير، سنثبت Jest كاعتمادية للتطوير: npm install --save-dev jest لنعرف سكربت npm باسم test لتنفيذ الاختبارات باستخدام Jest وإنشاء تقرير عن التنفيذ باستخدام الأسلوب verbose: { //... "scripts": { "start": "node index.js", "dev": "nodemon index.js", "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", "deploy": "git push heroku master", "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", "logs:prod": "heroku logs --tail", "lint": "eslint .", "test": "jest --verbose" }, //... } تحتاج المكتبة Jest سكربت لنحدد فيه أن بيئة التنفيذ هي Node. ويتم ذلك بإضافة السطر التالي في نهاية الملف package.json: { //... "jest": { "testEnvironment": "node" } } أو بدلًا عن ذلك، تبحث Jest عن ملف تهيئة باسم jest.config.js، حيث تُعرّف داخله بيئة التنفيذ كالتالي: module.exports = { testEnvironment: 'node', }; سننشئ مجلدًا مستقلًا للاختبارات يدعى tests ونضع فيه الملف palindrome.test.js الذي يحتوي الشيفرة التالية: const palindrome = require('../utils/for_testing').palindrome test('palindrome of a', () => { const result = palindrome('a') expect(result).toBe('a') }) test('palindrome of react', () => { const result = palindrome('react') expect(result).toBe('tcaer') }) test('palindrome of releveler', () => { const result = palindrome('releveler') expect(result).toBe('releveler') }) ستعترض قواعد تهيئة المدقق ESLint الذي أضفناها إلى المشروع في الفصل السابق على الأمرين test و expect في ملف الاختبار، ذلك أن قواعد التهيئة لا تسمح بوجود متغيرات شاملة Globals. لنتخلص من ذلك بإضافة القيمة jest": true" إلى الخاصية env في الملف eslintrc.js: module.exports = { "env": { "commonjs": true "es6": true, "node": true, "jest": true, }, "extends": "eslint:recommended", "rules": { // ... }, }; يدرج الملف الاختبار في السطر الأول الدالة التي سنختبرها، ويسندها إلى متغير يدعى palindrome: const palindrome = require('../utils/for_testing').palindrome تُعرّف الحالات المختبرة بشكل فردي باستخدام الدالة test. تقبل الدالة معاملين، الأول سلسلة نصية تصف الاختبار والثاني دالة تُعرِّف طريقة تنفيذ اختبار الحالة. تبدو طريقة اختبار الحالة الثانية في شيفرة ملف الاختبار palindrome.js كالتالي: () => { const result = palindrome('react') expect(result).toBe('tcaer') } ننفذ أولًا الشيفرة التي سنختبرها، حيث نولد سلسلة معكوسة للنص "react". ثم نتحقق من النتيجة باستخدام الدالة expect. تغلف الدالة expect القيمة الناتجة على شكل كائن يقبل الكثير من توابع المطابقة التي يمكن استخدامها للتحقق من صحة النتيجة. يمكن استخدام تابع المطابقة toBe كون عملية المقارنة في حالتنا بين سلسلتين نصيتين. كما هو متوقع ستنجح جميع الاختبارات: تتوقع المكتبة Jest أن تحتوي أسماء ملفات الاختبارات العبارة "test." وسنلتزم في منهاجنا بهذا التوجيه وسنجعل لاحقة كل ملفات الاختبار على الشكل "test.js." تتمتع Jest بإظهارها رسائل خطأ ممتازة، سنجعل الاختبار يفشل لعرض ذلك: test('palindrom of react', () => { const result = palindrome('react') expect(result).toBe('tkaer') }) عند تنفيذ الاختبار السابق ستظهر رسالة الخطأ التالية: لنضف الآن عدة اختبارات إلى الدالة average في الملف average.test.js الموجود في المجلد tests. const average = require('../utils/for_testing').average describe('average', () => { test('of one value is the value itself', () => { expect(average([1])).toBe(1) }) test('of many is calculated right', () => { expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5) }) test('of empty array is zero', () => { expect(average([])).toBe(0) }) }) سنلاحظ أن الدالة لن تعمل بالشكل الصحيح مع المصفوفة الفارغة، لأن ناتج القسمة على 0 في JavaScript سيعطي القيمة NaN. تصحيح المشكلة السابقة أمر بسيط جدًا: const average = array => { const reducer = (sum, item) => { return sum + item } return array.length === 0 ? 0 : array.reduce(reducer, 0) / array.length } ستعيد الدالة القيمة 0 إذا كان طول المصفوفة 0. ونستخدم في بقية الحالات التابع reduce لحساب المتوسط. يجب الانتباه إلى عدة أمور في الاختبارات التي أجريناها. فلقد أسمينا الكتلة النصية التي تصف الاختبار "average": describe('average', () => { // tests }) تستخدم الكتل التي تصف الاختبارات في تجميع الاختبارات ضمن مجموعات. كما تسمى نتيجة الاختبار في Jest باسم الكتلة أيضًا. وتبرز أهمية الكتل describe عندما نحاول تشغيل بعض الإعدادات المشتركة أو إنهاء العمليات المتعلقة بمجموعة من الاختبارات. وأخيرًا يجدر الانتباه إلى الطريقة المختصرة التي كتبت فيها شيفرة الاختبارات، بحيث لم نسند القيمة الناتجة عن تنفيذ الدالة إلى أي متغير: test('of empty array is zero', () => { expect(average([])).toBe(0) }) التمارين 4.3 - 4.7 لننشئ مجموعة من الدوال المساعدة التي ستُخصص للتعامل مع قائمة المدونات. ضع هذه الدوال في ملف يدعى list_helper.js ضمن مجلد اسمه utils. اكتب الاختبارات في ملف يحمل اسمًا مناسبًا وضعه في المجلد tests. 4.3 الدوال المساعدة واختبارات الأجزاء: الخطوة 1 عرّف في البداية دالةً باسم dummy تستقبل مصفوفة من منشورات مدونة كمعامل وتعيد دائمًا القيمة 1. سيبدو محتوى الملف list_helper.js حتى هذه اللحظة كالتالي: const dummy = (blogs) => { // ... } module.exports = { dummy } تحقق أن معلومات التهيئة لاختبارك ستعمل مع الاختبار التالي: const listHelper = require('../utils/list_helper') test('dummy returns one', () => { const blogs = [] const result = listHelper.dummy(blogs) expect(result).toBe(1) }) 4.4 الدوال المساعدة واختبارات الأجزاء: الخطوة 2 عرف دالةً جديدةً تدعى totalLikes تستقبل قائمة بمنشورات مدونة كمعامل وتعيد مجموع الإعجابات في كل منشورات هذه المدونة. اكتب اختبارًا مناسبًا للدالة. يفضل أن تضع الاختبار ضمن كتلة describe، كي تُجمّع التقارير المتولدة عن الاختبارات بشكل واضح. يمكن أن تُعرّف عناصر دخل الدالة على النحو التالي: describe('total likes', () => { const listWithOneBlog = [ { _id: '5a422aa71b54a676234d17f8', title: 'Go To Statement Considered Harmful', author: 'Edsger W. Dijkstra', url: 'http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html', likes: 5, __v: 0 } ] test('when list has only one blog, equals the likes of that', () => { const result = listHelper.totalLikes(listWithOneBlog) expect(result).toBe(5) }) }) إن وجدت أن إدخال قائمة المدونات يتطلب الكثير من الجهد، استخدم القائمة الجاهزة التي وضعناها على GitHub. ستواجهة المشاكل عندما ستكتب الاختبارات، لذلك تذكر استخدام وسائل التنقيح التي تعلمناها في القسم 3. ويمكنك دائمًا الطباعة على الطرفية مستخدمًا الأمر console.log حتى عند تنفيذ الاختبارات. كما يمكنك أيضًا استخدام المنقحات وستجد الكثير من مواقع الانترنت التي تعطيك إرشادات لاستخدامها. ملاحظة: يفضل في حال أخفقت بعض الاختبارات، أن تشغلها فقط عندما تحاول إصلاح المشكلة. كما يمكنك أن تنفذ اختبارًا واحدًا مستخدمًا التابع only. هناك أسلوب آخر لتنفيذ اختبار محدد بكتابة اسم الكتلة (اسم الاختبار) عند تنفيذ الاختبار مع الصفة t-. npm test -- -t 'when list has only one blog, equals the likes of that' 4.5 الدوال المساعدة واختبارات الأجزاء: الخطوة 3 * عرّف دالة جديدة باسم favoriteBlog تستقبل قائمة بالمدونات كمعامل. تكتشف الدالة المدونة التي تحمل أكبر عدد من الإعجابات. يكفي أن تعيد الدالة مدونة واحدة إن كان هناك أكثر من واحدة. يمكن أن تعيد الدالة قيمة لها الصيغة التالية: { title: "Canonical string reduction", author: "Edsger W. Dijkstra", likes: 12 } ملاحظة: عندما توازن بين الكائنات، استخدم التابع toEqual فهو على الأغلب ما تحتاجه تمامًا، ذلك أن التابع toBe سيحاول أن يتحقق من أن القيمتين متطابقتين بالإضافة إلى امتلاكهما نفس الخصائص. اكتب الاختبار ضمن كتلة describe. كرر ذلك في التمارين الباقية أيضًا. 4.6 الدوال المساعدة واختبارات الأجزاء: الخطوة 4 * يحمل هذا التمرين والتمرين الذي يليه قدرًا من التحدي. وانتبه أن إكمال هذين التمرينين ليس ملزمًا لتتابع تقدمك في مفردات المنهاج. لذلك يفضل العودة إليهما بعد إنهائك مادة هذا القسم بالكامل. يُنجَز هذا التمرين بلا استخدام أية مكتبات إضافية. لكنه في المقابل فرصة مواتية لتعلم استخدام المكتبة Lodash. عرّف دالة اسمها mostBlogs تستقبل مصفوفة من المدونات كمعامل. تعيد الدالة اسم المؤلف الذي لديه العدد الأكبر من المدونات، كما تعيد عدد هذه المدونات: { author: "Robert C. Martin", blogs: 3 } إن وجد أكثر من مؤلف يحقق المطلوب، يكفي أن تعرض أحدهم. 4.7 الدوال المساعدة واختبارات الأجزاء: الخطوة 5 * عرّف دالة باسم mostLikes تستقبل مصفوفة من المدونات كمعامل. تعيد الدالة المؤلف الذي حاز على أكبر عدد من الإعجابات، كما تعيد العدد الكلي لتلك الإعجابات: { author: "Edsger W. Dijkstra", likes: 17 } إن وجد أكثر من مؤلف يحقق المطلوب، يكفي أن تعرض أحدهم. ترجمة -وبتصرف- للفصل Structure of backend application, introduction to testing من سلسلة Deep Dive Into Modern Web Development
  15. علينا في الواقع، أن نطبق مجموعة من القيود على البيانات التي ستخزن في قاعدة بيانات التطبيق. فلا ينبغي أن نسمح بوجود ملاحظات مفقودة المحتوى أو لا تملك الخاصية content لسبب ما. يجري تفقد صلاحية الملاحظة من خلال معالج مسار: app.post('/api/notes', (request, response) => { const body = request.body if (body.content === undefined) { return response.status(400).json({ error: 'content missing' }) } // ... }) إن لم تمتلك الملاحظة الخاصية content، سيستجيب الخادم برمز الحالة 400 (طلب خاطئ). يمكننا أن نستخدم طريقة أفضل في تقييم تنسيق البيانات قبل تخزينها في قاعدة البيانات وهي الوظيفة validation التي تتيحها مكتبة Mongoose. حيث سنعرف معايير تقييم محددة لكل حقل من حقول مخطط قاعدة البيانات: const noteSchema = new mongoose.Schema({ content: { type: String, minlength: 5, required: true }, date: { type: Date, required: true }, important: Boolean }) تحدد القواعد السابقة أن يكون طول المحتوى 5 محارف على الأقل، وأن المحتوى أمر إجباري required:true وبالتالي يجب أن لا يكون مفقودّا، وكذلك التاريخ. لم نضف تقييدات على حقل الأهمية، فلم يتغير تعريفه في المخطط. لاحظ على سبيل المثال أن المقيّم minlength يأتي مدمجًا وجاهزًا للاستخدام مع المكتبة mongoose، لكنها أيضًا تقدم وظيفة المُقيِّمات الخاصة التي تمكننا من إنشاء مُقيِّمات بالطريقة التي نحتاجها إن لم تحقق المقّيمات المدمجة ما نريد. إن حاولنا أن نخزن كائنًا في قاعدة البيانات يخالف أحد التقييدات التي فرضناها عليه، سترمي العملية استثناءً. لنغيّر معالج إنشاء ملاحظة جديدة لكي يمرر أي استثناء محتمل إلى الأداة الوسطية لمعالجة الأخطاء: app.post('/api/notes', (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) note.save() .then(savedNote => { response.json(savedNote.toJSON()) }) .catch(error => next(error))}) لنوسّع معالج الخطأ ليتعامل مع أخطاء التقييم. const errorHandler = (error, request, response, next) => { console.error(error.message) if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { return response.status(400).json({ error: error.message }) } next(error) } عندما يُخفق تقييم الكائن، ستعيد Mongoose رسالة الخطأ التالية: سلاسل الوعود تحوّل معظم معالجات المسار البيانات القادمة مع الاستجابة إلى الصيغة الصحيحة باستخدام التابع toJSON. فعندما ننشئ ملاحظة جديدة، يُستدعى هذا التابع ليعالج الكائن الذي مرر إلى التابع then: app.post('/api/notes', (request, response, next) => { // ... note.save() .then(savedNote => { response.json(savedNote.toJSON()) }) .catch(error => next(error)) }) يمكن أن نحصل على نفس النتيجة وبطريقة أكثر وضوحًا باستخدام سلاسل الوعود: app.post('/api/notes', (request, response, next) => { // ... note .save() .then(savedNote => { return savedNote.toJSON() }) .then(savedAndFormattedNote => { response.json(savedAndFormattedNote) }) .catch(error => next(error)) }) يستقبل أول تابع then الكائن savedNote الذي تعيده Mongoose ثم ينسقه، ويعيد نتيجة هذه العملية. وكما ناقشنا سابقًا يعيد تابع then الذي يليه وعدًا أيضًا بحيث يمكننا الوصول إلى الملاحظة المنسقة بالتصريح عن دالة استدعاء جديدة داخل التابع then الأخير. كما يمكننا توضيح الشيفرة أكثر باستخدام الصيغة المختصرة للدالة السهمية: app.post('/api/notes', (request, response, next) => { // ... note .save() .then(savedNote => savedNote.toJSON()) .then(savedAndFormattedNote => { response.json(savedAndFormattedNote) }) .catch(error => next(error)) }) لم تحقق سلسلة الوعود في هذا المثال الكثير من الفائدة. لكن الفائدة الحقيقة ستظهر عند تنفيذ عدة عمليات غير متزامنة على التسلسل. لن نخوض في هذا الموضوع كثيرًا، بل سنتركه إلى القسم التالي من المنهاج، حيث سنطلع على الصيغة async/await في JavaScript والتي تسهل كتابة العمليات المتسلسلة غير المتزامنة. إنجاز نسخة الإنتاج من الواجهة الخلفية المرتبطة بقاعدة بيانات ينبغي أن يعمل التطبيق كما هو على Heroku. ولا ينبغي أن ننشئ نسخة إنتاج جديدة للواجهة الأمامية نتيجة للتغيرات التي أجريناها عليها. ونشير أيضًا إلى أن استخدام متغيرات البيئة التي عرفناها باستخدام dotenv غير ممكن عندما تكون الواجهة الخلفية في وضع الإنتاج (على Heroku). لقد وضعنا متغيرات البيئة المستخدمة في مرحلة التطوير في الملف env.، لكن ينبغي ضبط متغيرات البيئة التي تعرف عنوان موقع قاعدة البيانات في وضع الإنتاج باستخدام الأمر heroku config:set $ heroku config:set MONGODB_URI=mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true ملاحظة: إن أعطى تنفيذ الأمر خطأً، ضع قيمة MONGO_URI ضمن إشارتي تنصيص مفردتين (' '). $ heroku config:set MONGODB_URI='mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true' ينبغي أن يعمل التطبيق الآن. لكن في بعض الأحيان لا تجري الأمور كما هو مخطط لها. لذلك استفد من سجلات heroku عند تنفيذ الشيفرة. توضح الصورة التالية ما أظهره Heroku عند فشل التطبيق بعد إجراء التغييرات: لسبب ما، ظهر عنوان قاعدة البيانات ككائن غير معرّف. لكن سجلات Heroku قد كشفت أن الخطأ هو أننا أسندنا عنوان موقع قاعدة البيانات إلى متغير البيئة MONGO_URL بينما تتوقع الشيفرة أن يكون العنوان قد أسند إلى متغير البيئة MONGODB_URI. ستجد شيفرة التطبيق بوضعه الحالي في الفرع part3-5 ضمن المستودع الخاص بالقسم على GitHub التمارين 3.19 - 3.21 3.19 دليل هاتف بقاعدة بيانات: الخطوة 7 ضع مقيّمات لتحديد صلاحية المُدخَلات إلى تطبيق دليل الهاتف، لتضمن أن المُدخَل الجديد يحمل اسمًا فريدًا غير مكرر. لن تسمح الواجهة الأمامية الآن أن يدخل المستخدم أسماء مكررة، لكن حاول أن تقوم بذلك مباشرة بوجود Postman أو VS Code REST client. لا تقدم Mongoose مُقيِّمات مدمجة لهذا الغرض، عليك تثبيت حزمة mongoose-unique-validator باستخدام npm. إن حاول طلب HTTP-POST إضافة اسم موجود مسبقًا، على الخادم أن يجيب برمز الحالة المناسب، بالإضافة إلى رسالة خطأ. ملاحظة: سيسبب المعرف الفريد (unique-validator) تحذيرًا سيطبع على الطرفية. (node:49251) DeprecationWarning: collection.ensureIndex is deprecated. Use createIndexes instead. connected to MongoDB اقرأ توثيق Mongoose وجد طريقة للتخلص من هذا التحذير. 3.20 دليل هاتف بقاعدة بيانات: الخطوة 8 * وسع التقييد بحيث لا يقل طول الاسم المخزّن في قاعدة البيانات عن ثلاثة محارف وأن لا يقل طول رقم الهاتف عن 8 أرقام. دع الواجهة الأمامية تظهر رسالة خطأ عندما يحدث خطأ في التقييم. يمكنك أن تعالج الخطأ بإضافة كتلة catch كما يلي: personService .create({ ... }) .then(createdPerson => { // ... }) .catch(error => { // هذه طريقة للوصول إلى رسالة الخطأ console.log(error.response.data) }) يمكنك إظهار رسالة الخطأ الافتراضية التي تعيدها Mongoose علمًا أن قراءتها ليست يسيرة: ملاحظة: أثناء عمليات التحديث لن تعمل مقّيمات Mongoose بشكل افتراضي. اطلع على التوثيق لتتعلم كيفية تمكينها. 3.21 إنجاز نسخة الإنتاج من الواجهة الخلفية المرتبطة بقاعدة بيانات أنشئ نسخة كاملة (full stack) من التطبيق بإنجاز نسخة إنتاج عن الواجهة الأمامية ونسخها إلى مستودع الواجهة الخلفية. تحقق من أن كل شيء يعمل جيدًا باستخدام التطبيق بشكله الكامل على الخادم المحلي الذي عنوانه https://localhost:3001. انقل النسخة النهائية إلى خادم Heroku وتحقق أن كل شيء يعمل بشكل جيد. المدققات (Lints) قبل أن ننتقل إلى القسم التالي من المنهاج، سنلقي نظرة على أداة مهمة تدعى المدقق lint. وجاء في wikipedia عن المدقق ما يلي: يمكن للغات التي تترجم بشكل ساكن كلغة Java وبيئات التطوير مثل NetBeans أن تشير إلى الأخطاء في الشيفرة، حتى تلك الأخطاء التي تعتبر أكثر من أخطاء ترجمة. يمكن استعمال أدوات إضافية لتقوم بالتحليل الساكن مثل checkstyle لتوسيع إمكانيات بيئة التطوير بحيث تصبح قادرةً على الإشارة إلى مشاكل تتعلق حتى بالتنسيقات مثل إزاحة الكلمات ضمن الأسطر. في عالم JavaScript، تعتبر الأداة ESlint هي الرائدة في مجال التدقيق والتحليل الساكن. لنثبت ESlint كملف ارتباط تطوير في مشروع الواجهة الخلفية بتنفيذ الأمر: npm install eslint --save-dev يمكننا بعد ذلك تهيئة المدقق بصيغته الافتراضية بتنفيذ الأمر: node_modules/.bin/eslint --init سنجيب طبعُا عن الأسئلة التالية: ستخزّن إعدادات التهيئة في الملف eslinterc.js: module.exports = { 'env': { 'commonjs': true, 'es6': true, 'node': true }, 'extends': 'eslint:recommended', 'globals': { 'Atomics': 'readonly', 'SharedArrayBuffer': 'readonly' }, 'parserOptions': { 'ecmaVersion': 2018 }, 'rules': { 'indent': [ 'error', 4 ], 'linebreak-style': [ 'error', 'unix' ], 'quotes': [ 'error', 'single' ], 'semi': [ 'error', 'never' ] } } لنغيّر مباشرة القاعدة التي تنظم الإزاحة في السطر الواحد بحيث تكون بمقدار فراغين: "indent": [ "error", 2 ], يمكن تفتيش الملفات مثل index.js والتحقق من صلاحيتها باستخدام الأمر: node_modules/.bin/eslint index.js يفضل أن تنشئ سكربت npm منفصل للتدقيق: { // ... "scripts": { "start": "node index.js", "dev": "nodemon index.js", // ... "lint": "eslint ." }, // ... } سيتحقق الآن الأمر npm run lint من كل ملف في المشروع. كما سيتحقق من الملفات الموجودة في المجلد build، وهذا ما لا نريده. لذلك سنمنع ذلك بإنشاء ملف تجاهل لاحقته eslintignorr.‎ في جذر المشروع و نزوده بالمحتوى التالي: build عندها لن يتحقق ESlint من المجلد build أو محتوياته. سيشير ESlint إلى الكثير من النقاط في شيفرتك: لن نصلح أي شيء الآن. يمكن أن تستخدم طريقة أفضل من سطر الأوامر في تنفيذ التدقيق، وهي تهيئة إضافة للتدقيق eslint-plugin على محررك بحيث تنفذ عملية التدقيق بشكل مستمر. وبالتالي سترى الأخطاء التي ترتكب مباشرة أثناء تحريرك للشيفرة. يمكنك الاطلاع أكثر من خلال الانترنت على الكثير من المعلومات حول الإضافة Visual Studio ESlint plugin. Editor. ستضع الإضافة السابقة خطًا أحمر تحت أخطاء التنسيق: وهكذا سنرصد الأخطاء بشكل أسهل. للمدقق ESlint الكثير من القواعد سهلة الاستخدام والتي يمكن إضافتها في الملف eslintrc.js. لنضع الآن القاعدة eqeqeq التي تحذرنا إن وجدت في الشيفرة عامل الموازنة الثلاثي (===). تضاف القاعدة ضمن الحقل rules في ملف التهيئة: { // ... 'rules': { // ... 'eqeqeq': 'error', }, } وطالما أننا استخدمنا القواعد لنقم بتغييرات أخرى. لنمنع وجود المسافات الفارغة trailing spaces في آخر السطر البرمجي، ولنطلب أيضًا أن يكون هناك فراغ قبل وبعد الأقواس المعقوصة، كذلك وجود فراغ بشكل دائم بين معاملات الدالة السهمية. { // ... 'rules': { // ... 'eqeqeq': 'error', 'no-trailing-spaces': 'error', 'object-curly-spacing': [ 'error', 'always' ], 'arrow-spacing': [ 'error', { 'before': true, 'after': true } ] }, } وتقدم لنا الإعدادات الافتراضية التي اعتمدناها في البداية، الكثير من القواعد التي ينصح بها المدقق، وتُطبق هذه القواعد بكتابة الأمر: 'extends': 'eslint:recommended', تتضمن هذه القواعد تحذيرات تتعلق بأمر الطباعة على الطرفية console.log. يمكن تعطيل أي قاعدة بجعل قيمتها تساوي 0 في ملف التهيئة. لنلغ القاعدة no-console على سبيل المثال: { // ... 'rules': { // ... 'eqeqeq': 'error', 'no-trailing-spaces': 'error', 'object-curly-spacing': [ 'error', 'always' ], 'arrow-spacing': [ 'error', { 'before': true, 'after': true } ], 'no-console': 0 }, } ملاحظة: يفضل، إن أجريت أية تغييرات على الملف ‎.eslintrc.js، أن تشغل المدقق من سطر الأوامر لتتحقق من أن التعليمات في ملف التهيئة مكتوبة بالشكل الصحيح. فإن كانت هناك أية مشاكل في ملف التهيئة، ستتصرف إضافة المدقق بشكل غير مفهوم. تحدد الكثير من الشركات معايير لكتابة الشيفرة وتفرضها على منظمة W3C عبر ملفات تهيئة ESlint. إذًا ليس عليك إعادة اختراع العجلة في كل مرة، ومن الأفضل لك اعتماد ملف تهيئة جاهز أنجزته جهة ما. تعتمد الكثير من المشاريع حاليًا على دليل تنسيق JavaScript الذي قدمته Airbnb، وذلك باستخدامها ملف تهيئة ESlint الذي تعتمده Airbnb. ستجد شيفرة التطبيق كاملًا في الفرع part3-6 ضمن المستودع الخاص بالقسم على GitHub التمرين 3.22 3.22 تهيئة المدقق أضف ESlint إلى تطبيقك وأصلح كل المشاكل. وهكذا نصل إلى آخر تمرينات هذا القسم، وحان الوقت لتسليم حلول التمارين إلى GitHub. لا تنس أن تشير إلى التمارين التي تسلمها أنها مكتملة ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل Validation and ESlint من سلسلة Deep Dive Into Modern Web Development
  16. قبل أن ننتقل إلى الموضوع الرئيسي في هذا الفصل، سنلقي نظرة على بعض الطرق المتبعة في تنقيح تطبيقات Node. تنقيح تطبيقات Node إن تنقيح التطبيقات المبنية باستخدام Node أصعب قليلًا من تنقيح شيفرة JavaScript التي تنفذ على المتصفح. لكن مع ذلك تبقى فكرة الطباعة على الطرفية وأسلوب المحاولة والخطأ طريقة فعالة في حل المشاكل. قد تجد من المطورين من يعتقد أن استخدام أساليب أكثر تطورًا هو أمر ضروري، لكن تذكر أن نخبة مطوري البرمجيات مفتوحة المصدر في العالم يستخدمون تلك الطريقة. برنامج Visual Studio Code ستجد أن المنقح المدمج ضمن هذا البرنامج ذو فائدة كبيرة في بعض الحالات. يمكنك أن تشغل البرنامج على وضع التنقيح كالتالي: ملاحظة: يمكن أن تجد التعليمة Run بدلًا من Debug في الإصدار الأحدث من Visual Studio Code. وقد يكون عليك أيضًا أن تهيئ ملف launch.json لتبدأ التنقيح. يمكن تنفيذ ذلك باختيار الأمر ...Add Configuration من القائمة المنسدلة الموجودة بجانب زر التشغيل الأخضر وفوق قائمة VARIABLES، ثم اختيار الأمر Run "npm start" in a debug terminal. يمكنك إيجاد المزيد من الإرشادات بالاطلاع على توثيق التنقيح لبرنامج Visual Studio Code. تذكر أن لا تشغل البرنامج ضمن أكثر من طرفية لأن ذلك سيحجز المنفذ مسبقًا ولن تتمكن من العمل. تعرض لقطة الشاشة التالية الطرفية وقد أوقفنا التنفيذ مؤقتًا في منتصف عملية حفظ الملاحظة الجديدة: توقَّف التنفيذ عندما وصلنا إلى نقطة التوقف التي وضعناها في السطر 63. يمكنك أن ترى في الطرفية قيمة المتغير note. وفي أعلى يسار النافذة ستجد بعض التفاصيل المتعلقة بحالة التطبيق. تُستخدم الأسهم في الأعلى للتحكم بترتيب عملية التنقيح. أدوات تطوير Chrome من الممكن أن نستخدم أدوات تطوير Chrome لتنقيح تطبيقات Node، وذلك بتشغيل التطبيق مستخدمين الأمر التالي: node --inspect index.js ستدخل إلى المنقح بالضغط على الأيقونة الخضراء (شعار Node) التي تظهر على طرفية تطوير Chrome: يعمل المنقح بنفس الطريقة التي يعمل بها عند تنقيح تطبيقات React. يمكنك استخدام النافذة Sources لزرع نقاط توقف في الشيفرة لإيقاف التنفيذ بشكل مؤقت عندها. ستظهر جميع الرسائل التي يطبعها الأمر console.log في النافذة Console من المنقح. كما يمكنك التحري عن قيم المتغيرات وتنفيذ شيفرة JavaScript إن أردت. تحقق من كل شيء قد يبدو لك تنقيح تطبيقات التطوير الشامل (واجهة خلفية وأمامية) محيّرًا في البداية. وقريبًا سيتواصل التطبيق مع قاعدة بيانات. وبالتالي سيزداد احتمال ظهور الأخطاء في أجزاء عدة من الشيفرة. عندما يتوقف التطبيق عن العمل، يجب علينا أولًا تصور المكان الذي قد تظهر فيه المشكلة. وعادة تظهر المشاكل في الأماكن غير المتوقعة وقد يستغرق إيجادها دقائق أو ساعات أو حتى أيامًا. مفتاح الحل هو تنظيم البحث. فالمشكلة قد تتواجد في أي مكان، لذلك تحقق من كل شيء واستبعد احتمالات المشكلة واحدًا تلو الآخر. ستساعدك الطباعة على شاشة الطرفية وكذلك برنامج Postman والمنقحات الأخرى، كما ستلعب الخبرة دورًا هامًا أيضًا. لا تتابع تطوير التطبيق إن لم تعثر على مصدر الخطأ، فهذه أسوأ استراتيجية. لأن ذلك سيسبب أخطاء أكثر وسيكون التنقيح أصعب. اتبع سياسة شركة Toyota لإنتاج الأنظمة (توقف وأصلح) فهي بالفعل سياسة مجدية جدًا في حالتنا. قاعدة البيانات MongoDB سنحتاج قطعًا إلى قاعدة بيانات لحفظ الملاحظات بشكل دائم. تتعامل معظم مناهج جامعة هلسينكي مع قواعد البيانات العِلاقيّة (Relational Databases)، لكننا سنتعامل في منهاجنا مع قاعدة البيانات MongoDB وهي من نمط قواعد البيانات المستقلة. تختلف قواعد البيانات المستقلة (أو التي تأتي على شكل مستندات منفصلة) عن العِلاقيّة في كيفية تنظيم البيانات ولغة الاستعلام التي تدعمها. وعادة ما تصنف قواعدة البيانات المستقلة تحت مظلة NoSQL أي التي لا تستخدم لغة الاستعلام SQL. اطلع على الفصلين collections و documents من دليل استخدام MongoDB لتتعلم أساسيات تخزين البيانات في قواعد البيانات المستقلة. يمكنك أن تُثبّت وتُشغّل MongoDB على حاسوبك. كما يمكنك الاستفادة من المواقع التي تقدم خدمات MongoDB على الإنترنت. سنتعامل في منهاجنا مع مزود الخدمة MongoDB Atlas. حالما تنشئ حسابًا على الموقع وتسجل الدخول، سينصحك الموقع بإنشاء عنقود: سنختار المزود AWS والمنطقة Frankfurt ثم ننشئ العنقود: انتظر حتى يكتمل العنقود ويصبح جاهزًا. قد يستغرق ذلك 10 دقائق. ملاحظة: لا تتابع قبل أن يصبح العنقود جاهزًا. سنستخدم نافذة database access لإنشاء معلومات التوثيق اللازمة لدخول قاعدة البيانات. وانتبه إلى أنها معلومات توثيق مختلفة عن تلك التي تستخدمها عند تسجيل الدخول، وسيستخدمها تطبيقك عندما يتصل بقاعدة البيانات. لنمنح المستخدم إمكانية القراءة والكتابة إلى قاعدة البيانات: ملاحظة: أبلغَ بعض المستخدمين عن عدم القدرة على الوصول إلى قاعدة البيانات بمعلومات التوثيق التي وضعوها بعد إنشاء القاعدة مباشرة. تريّث، فقد يستغرق الأمر دقائق حتى تفعّل هذه المعلومات. سنعرّف بعد ذلك عناوين IP التي يسمح لها بدخول قاعدة البيانات. ولتسهيل الأمر، سنسمح بالوصول إلى القاعدة من أي عنوان IP: أخيرًا أصبحنا جاهزين للاتصال بقاعدة البيانات، إبدأ بالنقر على connect: اختر Connect your application: سيظهر لك عنوان موقع MongoDB، وهو عنوان قاعدة البيانات التي أنشأناها والتي تزود مكتبة عميل MongoDB التي سنضيفها إلى تطبيقنا بالبيانات. يبدو العنوان كالتالي: mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/test?retryWrites=true نحن الآن جاهزين لاستخدام قاعدة البيانات. يمكننا استخدام قاعدة البيانات مباشرة عبر شيفرة JavaScript بمساعدة المكتبة official MongoDb Node.js driver، لكن استخدامها مربك قليلًا. لذلك سنستخدم بدلًا منها مكتبة Mongoose التي تؤمن واجهة برمجية عالية المستوى. يمكن توصيف Mongoose بأنها رابط (Mapper) لكائنات من النوع document واختصارًا (ODM). وبالتالي سيكون حفظ كائنات JavaScript كمستندات Mongo مباشرًا باستخدام هذه المكتبة. لنثبت الآن Mongoose كالتالي: npm install mongoose --save لن نكتب أية شيفرات تتعامل مع Mongo في الواجهة الخلفية حاليًا، بل سننشئ تطبيقًا تدريبيًا ونضع في مجلده الجذري الملف mongo.js: const mongoose = require('mongoose') if (process.argv.length < 3) { console.log('Please provide the password as an argument: node mongo.js <password>') process.exit(1) } const password = process.argv[2] const url = `mongodb+srv://fullstack:${password}@cluster0-ostce.mongodb.net/test?retryWrites=true` mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) const note = new Note({ content: 'HTML is Easy', date: new Date(), important: true, }) note.save().then(result => { console.log('note saved!') mongoose.connection.close() }) ملاحظة: قد يختلف عنوان موقع MongoDB عن العنوان الذي عرضناه سابقًا بناء على المنطقة التي اخترتها عند إنشائك العنقود. تأكد من استخدامك العنوان الصحيح الذي حصلت عليه من MongoDB Atlas. ستفترض الشيفرة بأنها ستمرر كلمة المرور الموجودة ضمن معلومات التوثيق كمعامل لسطر أوامر. حيث يمكننا الوصول إلى معامل سطر الأوامر كالتالي: const password = process.argv[2] عندما تنفذ الشيفرة باستخدام الأمر node mongo.js password ستضيف Mongo ملفًا جديدًا إلى قاعدة البيانات. ملاحظة: استخدم كلمة السر التي اخترتها عند إنشاء قاعدة البيانات وليست كلمة سر الدخول إلى MongoDB Atlas. وانتبه أيضًا إلى الرموز الخاصة التي قد تضعها في كلمة مرورك فستحتاج عندها إلى تشفير الرموز في كلمة المرور عند كتابة عنوان الموقع. يمكنك الاطلاع على حالة قاعدة البيانات على MongoDB Atlas من Collections في النافذة Overview: وكما هو واضح، أضيف مستند يطابق الملاحظة إلى المجموعة notes في قاعدة البيانات التجريبية. توصي توثيقات Mongo بإعطاء أسماء منطقية لقواعد البيانات. ويمكننا تغيير اسم قاعدة البيانات من عنوان الموقع للقاعدة: سندمّر الآن قاعدة البيانات التجريبية بتغيير اسم قاعدة البيانات التي يشير إليها مؤسس الاتصال (connection string) إلى note-app بمجرد تعديل عنوان موقع القاعدة: mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/note-app?retryWrites=true سننفذ الشيفرة الآن: لقد خُزِّنت الآن البيانات في القاعدة الصحيحة. يمكن باستخدام create database أن ننشئ قواعد بيانات مباشرة على موقع الويب MongoDB Atlas. لكن لا حاجة لذلك طالما أن الموقع ينشئ تلقائيًا قاعدة بيانات جديدة عندما يحاول التطبيق أن يتصل مع قاعدة بيانات غير موجودة. تخطيط قاعدة البيانات بعد تأسيس الاتصال مع قاعدة البيانات، سنعرف تخطيطًا (Schema) للملاحظة ونموذجًا (Model) مطابقًا للتخطيط: const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) عرّفنا أولًا تخطيطًا للملاحظة وأسندناه إلى المتغيّر noteSchema. يخبر التخطيط Mongosse كيف سيُخزّن الكائن الذي يمثل الملاحظة في قاعدة البيانات ثم نعرّف نموذجًا باسم Note يقبل معاملين، الأول هو اسم النموذج المفرد. حيث يختلف الاسم المفرد عن اسم المجموعة بأن الأخير يحمل صيغة الجمع ويكتب بأحرف صغيرة "notes" فهذا عرف تتبعه Mongoose وتقوم به تلقائيًا بينما يشير التخطيط إلى الملاحظات بالاسم المفرد. المعامل الثاني كما هو واضح هو تخطيط الملاحظة. لا تملك قواعد البيانات المستقلة مثل Mongo أية تخطيطات (schemaless). ويعني ذلك أن قاعدة البيانات لا تهتم ببنية البيانات المخزنة فيها، حيث يمكن تخزين ملفات بتخطيطات مختلفة تمامًا ضمن نفس المجموعة. لكن الفكرة وراء منح Mongoose البيانات المخزنة في قاعدة البيانات تخطيطًا على مستوى التطبيق، هي تحديد شكل المستندات المخزنة في مجموعة ما. إنشاء وحفظ الكائنات ينشئ التطبيق في الشيفرة التالية كائن ملاحظة جديد بمساعدة النموذج Note: const note = new Note({ content: 'HTML is Easy', date: new Date(), important: false, }) تدعى النماذج "دوال البناء". فهي التي تنشئ الكائنات الجديدة في JavaScript بناء على المعاملات التي تملكها. وطالما أن الكائنات ستبنى باستخدام الدوال البانية للنماذج، ستمتلك كل خصائص النموذج بما فيها توابع حفظ الكائن في قاعدة البيانات. وتحفظ الكائنات في قواعد البيانات باستخدام التابع save الذي يُزوّد بمعالج حدث ضمن التابع then: note.save().then(result => { console.log('note saved!') mongoose.connection.close() }) بعدما يُحفظ الكائن في قاعدة البيانات، يُستدعَى معالج الحدث المعرّف ضمن التابع then. حيث يغلق معالج الحدث قناة الاتصال مع قاعدة البيانات باستخدام الأمر ()mongoose.connection.close. إن لم يُغلق الاتصال، فلن ينتهي البرنامج من تنفيذ العملية. تُخزّن نتيجة عملية الحفظ في المعامل result لمعالج الحدث، لكنها غير مهمة كثيرًا، خاصة أننا خزّنا كائنًا مفردًا في قاعدة البيانات. يمكنك طباعة الكائن على الطرفية إن أردت التمعن فيه أثناء عمل التطبيق أو أثناء تنقيحه. لنخزّن عدة ملاحظات أخرى في قاعدة البيانات. لكن علينا أولًا تعديل الشيفرة ثم إعادة تنفيذها. ملاحظة: لسوء الحظ، فإن توثيق mongoose ليس مستقرًا جدًا. لقد استخدمت الاستدعاءات في بعض الأجزاء عند عرض الأمثلة بينما استخدمت الأجزاء الأخرى أساليب أخرى. لذلك لا ننصحك بنسخ ولصق الشيفرة مباشرة من تلك التوثيقات. لا تخلط الوعود مع استدعاءات المدرسة التقليدية في نفس الشيفرة فهذا أمر غير محبذ. إحضار كائنات من قاعدة البيانات حَوِّل الشيفرة السابقة التي استخدمناها لإنشاء ملاحظة جديدة إلى تعليقات واستخدم الشيفرة التالية بدلًا منها: Note.find({}).then(result => { result.forEach(note => { console.log(note) }) mongoose.connection.close() }) عندما تُنفّذ الشيفرة سيطبع التطبيق كل الملاحظات الموجودة ضمن قاعدة البيانات: نحصل على الكائنات من قاعدة البيانات مستعملين التابع find العائد للنموذج Note. يقبل التابع السابق معاملًا على هيئة كائن يحتوي على معايير البحث. وطالما أن المعامل في الشيفرة السابقة كائن فارغ ({})، فسنحصل على جميع الملاحظات المخزنة في المجموعة notes. تخضع معايير البحث إلى قواعد الاستعلام في Mongo. ويمكننا تحديد البحث ليشمل مثلًا الملاحظات الهامة فقط على النحو التالي: Note.find({ important: true }).then(result => { // ... }) التمرين 3.12 3.12 قاعدة بيانات بسطر أوامر أنشئ قاعدة بيانات سحابية باستخدام MongoDB لتطبيق دليل الهاتف وذلك على موقع الويب MongoDb Atlas. أنشئ الملف mongo.js في مجلد المشروع، حيث تضيف الشيفرة في الملف مُدخلات إلى دليل الهاتف، وتشكيل قائمة بكل المُدخلات الموجودة في الدليل. ملاحظة: لا تضع كلمة المرور في الملف الذي سترفعه إلى GitHub. يجب أن يعمل التطبيق على النحو التالي: تمرير ثلاثة معاملات إلى سطر الأوامر عند تشغيل التطبيق، على أن تكون كلمة السر هي المعامل الأول كما في المثال التالي: node mongo.js yourpassword Anna 040-1234556 سيطبع التطبيق النتيجة التالية: added Anna number 040-1234556 to phonebook يُخزَّن المُدخل الجديد ضمن قاعدة البيانات. وانتبه إلى وضع الاسم الذي يحتوي على فراغات ضمن قوسي تنصيص مزدوجين: node mongo.js yourpassword "Arto Vihavainen" 045-1232456 إذا شغلت التطبيق بمعامل واحد فقط هو كلمة السر كما يلي: node mongo.js yourpassword على التطبيق عندها أن يعرض كل المُدخَلات في دليل الهاتف: phonebook: Anna 040-1234556 Arto Vihavainen 045-1232456 Ada Lovelace 040-1231236 يمكنك الحصول على معاملات سطر الأوامر من المتغيّر process.argv. ملاحظة: لا تغلق الاتصال في المكان غير المناسب. فلن تعمل على سبيل المثال الشيفرة التالية: Person .find({}) .then(persons=> { // ... }) mongoose.connection.close() سيُنفَّذ الأمر ()mongoose.connection.closeمباشرة بعد أن تبدأ العملية Person.find. أي ستغلق قاعدة البيانات مباشرة قبل أن تنتهي العملية السابقة وتستدعى دالة معالج الحدث. لذلك فالمكان الصحيح لإغلاق قاعدة البيانات سيكون في نهاية معالج الحدث: Person .find({}) .then(persons=> { // ... mongoose.connection.close() } ) ملاحظة: إذا سميت النموذج person، ستسمي mongoose المجموعة المقابلة people. ربط الواجهة الخلفية مع قاعدة البيانات لقد تعلمنا ما يكفي لبدء استخدام Mongo في تطبيقنا. لننسخ ونلصق القيم التي عرفناها في التطبيق التجريبي، ضمن الملف index.js: const mongoose = require('mongoose') // DO NOT SAVE YOUR PASSWORD TO GITHUB!! const url = 'mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) لنغير معالج حدث إحضار كل الملاحظات إلى الشكل التالي: app.get('/api/notes', (request, response) => { Note.find({}).then(notes => { response.json(notes) }) }) يمكن أن نتحقق من عمل الواجهة الخلفية بعرض كل الملاحظات على المتصفح: يعمل التطبيق بشكل ممتاز الآن. تفترض الواجهة الأمامية أن لكل كائن معرفّا فريدًا id في الحقل id. وانتبه إلى أننا لا نريد إعادة حقل إصدار mongo إلى الواجهة الأمامية (v__). إحدى الطرق لنتحكم بصيغة الكائن الذي سنعيده، هي تعديل تخطيط الكائن باستخدام التابع toJSON والذي سنستعمله في كل النماذج التي تُنشأ اعتمادًا على هذا التخطيط. سيكون التعديل على النحو: noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) حتى لو بدت الخاصية (id__) لكائن Mongoose كسلسلة نصية فهي في الواقع كائن. لذلك يحولها التابع toJSON إلى سلسلة نصية حتى نتأكد أنها آمنة. إن لم نفعل ذلك ستظهر المشاكل أمامنا بمجرد أن نبدأ كتابة الاختبارات. ستجيب الواجهة الخلفية على طلب HTTP بقائمة من الكائنات التي أعيدت صياغتها باستخدام التابع toJSON: app.get('/api/notes', (request, response) => { Note.find({}).then(notes => { response.json(notes) }) }) أسندت مصفوفة الكائنات التي أعادتها Mongo إلى المتغير notes. وعندما ترسل الاستجابة بصيغة JSON، يستدعى التابع toJSON تلقائيًا من أجل كل كائن من المصفوفة باستخدام التابع JSON.stringify. تهيئة قاعدة البيانات في وحدة خاصة بها قبل أن نعيد كتابة بقية تطبيق الواجهة الخلفية، لنضع الشيفرة الخاصة بالتعامل مع Mongoose في وحدة مستقلة خاصة بها. لذلك سننشئ مجلدًا جديدًا للوحدة يدعى models ونضيف إليه ملفًا باسم note.js: const mongoose = require('mongoose') const url = process.env.MONGODB_URI console.log('connecting to', url) mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) .then(result => { console.log('connected to MongoDB') }) .catch((error) => { console.log('error connecting to MongoDB:', error.message) }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) module.exports = mongoose.model('Note', noteSchema) يختلف تعريف الوحدات في Node قليلًا عن الطريقة التي عرفنا فيها وحدات ES6 في القسم 2. فتُعرّف الواجهة العامة للوحدة بإسناد قيمة للمتغيّر module.exports. سنسند له إذًا النموذج Note. لن يتمكن مستخدم الوحدة من الوصول أو رؤية الأشياء المعرفة داخلها، كالمتغيرات وmonogose وعنوان موقع القاعدة url. يجري إدراج الوحدة بإضافة السطر التالي إلى الملف index.js: const Note = require('./models/note') وهكذا سيُسند المتغيّر Note إلى نفس الكائن الذي تعرّفه الوحدة. تغيّرت قليلًا الطريقة التي نجري بها الاتصال مع قاعدة البيانات: const url = process.env.MONGODB_URI console.log('connecting to', url) mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) .then(result => { console.log('connected to MongoDB') }) .catch((error) => { console.log('error connecting to MongoDB:', error.message) }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) module.exports = mongoose.model('Note', noteSchema) لا تكتب عنوان الموقع لقاعدة البيانات بشكل مسبق في الشيفرة فهذه فكرة سيئة. بل مرر العنوان إلى التطبيق عبر متغيّر البيئة MONGODB_URI. تقدم الطريقة التي اتبعناها في تأسيس الاتصال دالتين للتعامل مع حالتي نجاح الاتصال وفشله. حيث تطبع كلتا الدالتين رسائل إلى الطرفية لوصف حالة الاتصال. ستجد طرقًا عديدة لتعريف قيمة متغيّر البيئة، إحداها أن تعرّفه عندما تشغل التطبيق: MONGODB_URI=address_here npm run dev الطريقة الأخرى الأكثر تعقيدًا هي استخدام المكتبة dotenv التي يمكنك تثبيتها بتنفيذ الأمر: npm install dotenv --save عليك إنشاء ملف لاحقته env. عند جذر المشروع، ومن ثم تعرف متغيرات البيئة داخله. سيبدو الملف بالشكل التالي: MONGODB_URI='mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' PORT=3001 لاحظ أننا حددنا رقم المنفذ بشكل مسبق ضمن متغير البيئة PORT. ملاحظة تَجاهل الملف ذو اللاحقة "env."، لأننا لا نريد أن ننشر معلومات التوثيق الخاصة بنا في العلن. نستخدم متغيرات البيئة التي عرّفناها في الملف env. بكتابة العبارة ()require('dotenv').config، ثم يمكنك بعدها الإشارة إليهم في الشيفرة بالطريقة المعهودة process.env.MONGODB_URI. لنغيّر الملف index.js على النحو التالي: require('dotenv').config()const express = require('express') const app = express() const Note = require('./models/note') // .. const PORT = process.env.PORTapp.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) من المهم إدراج المكتبة dotenv قبل إدراج الوحدة note، لكي نضمن أن متحولات البيئة التي عرّفت داخلها ستكون متاحة للاستخدام ضمن كامل الشيفرة، وذلك قبل إدراج بقية الوحدات. استخدام قاعدة البيانات مع معالجات المسار لنغيّر بقية الوظائف في تطبيق الواجهة الخلفية ليتعامل مع قواعد البيانات. تضاف ملاحظة جديدة كالتالي: app.post('/api/notes', (request, response) => { const body = request.body if (body.content === undefined) { return response.status(400).json({ error: 'content missing' }) } const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) note.save().then(savedNote => { response.json(savedNote) }) }) تنشئ الدالة البانية للنموذج Note الكائنات التي تمثل الملاحظات. ثم ترسل الاستجابة داخل دالة استدعاء التابع save. يضمن هذا أن الاستجابة لن تُعاد إلى المرسل إن لم تنجح العملية. وسنناقش بعد قليل آلية التعامل مع الأخطاء. يحمل معامل دالة الاستدعاء savedNote الملاحظة الجديدة التي أنشئت. وتذكر أن البيانات التي أعيدت في الاستجابة قد أعيد تنسيقها باستعمال التابع toJSON. response.json(savedNote) تغيرت طريقة إحضار الملاحظات المفردة لتصبح على النحو: app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id).then(note => { response.json(note) }) }) التحقق من تكامل أداء الواجهتين الخلفية والأمامية من الجيد اختبار التطبيق الذي يعمل على الواجهة الخلفية عندما نضيف إليه وظائف جديدة، يمكن أن نستعمل في الاختبار برنامج Postman أو VS Code REST client أو المتصفح الذي تستخدمه. لننشئ ملاحظة جديدة ونخزّنها في قاعدة البيانات: بعد أن نتأكد من أن كل شيء يعمل على ما يرام في الواجهة الخلفية، نختبر تكامل الواجهة الأمامية مع الخلفية. فمن غير الكافي إطلاقًا اختبار الأشياء ضمن الواجهة الأمامية فقط. ربما عليك أن تدرس تكامل الوظائف بين الواجهتين وظيفة تلو الأخرى. فيمكننا أولًا إضافة الشيفرة التي تحضر كل البيانات من قاعدة البيانات ونختبرها على طرفية الواجهة الخلفية ضمن المتصفح. بعدها نتأكد أن الواجهة الأمامية تعمل جيدًا مع الشيفرة الجديدة للواجهة الخلفية. عندما يجري كل شيء بشكل جيد ننتقل إلى الوظيفة التالية. علينا تفقد الحالة الراهنة لقاعدة البيانات بمجرد بدأنا العمل معها. يمكننا القيام بذلك على سبيل المثال، عبر لوحة التحكم في MongoDB Atlas. كما ستفيدك أثناء التطوير بعض برامج Node الصغيرة، كالبرنامج mongo.js الذي كتبناه في هذا الفصل. ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-4 على موقع GitHub. التمارين 3.13 - 3.14 يمثل التمرينين التاليين تطبيقًا مباشرًا وسهلًا لما تعلمناه. لكن إن لم تتكامل الواجهتين معًا أثناء العمل، فستكمن الأهمية في كيفية إيجاد ومعالجة الأخطاء التي سببت ذلك. 3.13 دليل هاتف بقاعدة بيانات: الخطوة 1 غيِّر طريقة إحضار جميع المُدخَلات لكي يتم ذلك من قاعدة بيانات. تأكد أن الواجهة الأمامية ستعمل بعد إجراء هذه التغييرات. سنتكتب شيفرة التعامل مع قاعدة البيانات MongoDB في وحدة خاصة بها خلال التمارين القادمة، كما فعلنا سابقًا في هذا الفصل (تهيئة قاعدة البيانات في وحدة خاصة بها). 3.14 دليل هاتف بقاعدة بيانات: الخطوة 2 غيّر في شيفرة الواجهة الخلفية بحيث تحفظ الأرقام في قاعدة البيانات، وتحقق أن الواجهة الأمامية ستعمل بشكل جيد بعد التغييرات في الواجهة الخلفية. يمكنك في هذه المرحلة أن تجعل المستخدمين يدخلون كل ما يشاؤون في دليل الهاتف. ولاحظ أن دليل الهاتف قد يحوي الاسم نفسه مكررًا مرات عدة. معالجة الأخطاء لو حاولنا الوصول إلى موقع ملاحظة بمعرّف id غير موجود. فستكون الإجابة Null (لا شيء). لنغير ذلك بحيث يستجيب الخادم على هذا الطلب برمز الحالة 404 (غير موجود). سنضيف أيضًا كتلة catch لتتعامل مع الحالات التي يُرفض فيها الوعد الذي يعيده التابع findById: app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => { console.log(error) response.status(500).end() }) }) إن لم يُعثر على تطابق في قاعدة البيانات، ستكون قيمة الكائن note هي null، وبالتالي ستنفذ كتلة else. سينتج عن ذلك استجابة برمز الحالة 404 (غير موجود). وأخيرًا، إن رفض الوعد الذي يعيده التابع findById، ستكون الاستجابة برمز الحالة 500 (خطأ داخلي في الخادم). ستعرض لك طرفية التطوير معلومات مفصلة أكثر عن الخطأ. بالإضافة إلى الخطأ الناتج عن عدم وجود ملاحظة، ستواجه حالة أخرى يتوجب عليك معالجتها. تتلخص هذه الحالة بمحاولة إحضار ملاحظة بمعرّف id من نوع خاطئ لا يطابق تنسيق Mongo للمعرفات IDs. فلو ارتكبنا خطأً كهذا، سنرى الرسالة التالية: Method: GET Path: /api/notes/someInvalidId Body: {} --- { CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id" at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11) at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13) ... إن إعطاء معرّف id بصيغة خاطئة، سيدفع التابع findById لإشهار خطأ يسبب رفضًا للوعد. كنتيجة لذلك ستُستدعى الدالة الموجودة في الكتلة catch. لنغيّر طريقة الاستجابة قليلًا في تلك الكتلة: app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => { console.log(error) response.status(400).send({ error: 'malformatted id' }) }) }) إن لم يكن تنسيق المعرف id صحيحًا، ستنتهي العملية بتنفيذ معالج الخطأ الموجود في catch. إن الاستجابة الملائمة لهذا الخطأ هو رمز الحالة 400 (طلب خاطئ) 400 Bad Request، لأن هذه الحالة تطابق تمامًا الوصف التالي: أضفنا أيضًا بعض البيانات إلى الاستجابة لنلقي الضوء على سبب الخطأ. يفضل دائمًا عند التعامل مع الوعود إضافة معالجات للأخطاء والاستثناءات، لأن إهمال ذلك سيدفعك لمواجهة أخطاء غريبة. ومن الجيد أيضًا طباعة الكائن الذي سبب الاستثناء على طرفية التطوير: .catch(error => { console.log(error) response.status(400).send({ error: 'malformatted id' }) }) قد يُستدعى معالج الخطأ التي زرعته، نتيجة لخطأ مختلف تمامًا عن الخطأ الذي تريد اعتراضه. فإن طبعت الخطأ على الطرفية ستوفر على نفسك عناء جلسات التنقيح المحبطة. وعلاوة على ذلك تزودك معظم الخدمات الحديثة بوسيلة ما لطباعة عمليات النظام، بحيث يمكنك الاطلاع عليها متى أردت والخادم Heroku مثال مهم عليها. وطالما أنك تعمل على الواجهة الخلفية فابق نظرك على الطرفية التي تظهر لك خرج العملية حتى لو عملت على شاشة صغيرة، سيلفت وقوع أي خطأ انتباهك. تحويل معالجات الأخطاء إلى أداة وسطية لقد كتبنا شيفرة ملاحقة الأخطاء ضمن بقية أجزاء الشيفرة، ويبدو الأمر معقولًا أحيانًا، لكن من الأفضل إضافة معالجات الأخطاء في مكان واحد. سيكون هذا الأمر مفيدًا إذا أردنا لاحقًا أن نقدم تقريرًا عن الأخطاء إلى منظومة تتبع أخطاء خارجية مثل Sentry. لنغيّر معالج المسار api/notes/:id/، بحيث يمرر الخطأ إلى الدالة next كمعامل، وتمثل هذه الدالة بدورها المعامل الثالث لمعالج المسار: app.get('/api/notes/:id', (request, response, next) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => next(error))}) عندما تستدعى next دون معامل، سينتقل التنفيذ بكل بساطة إل المسار أو الأداة الوسطية التالية. بينما لو امتلكت هذه الدالة معاملًا فسيتابع التنفيذ إلى الأداة الوسطية لمعالجة الخطأ. معالجات أخطاء المكتبة Express هي أدوات وسطية تعرّف على شكل دالة تقبل أربع معاملات. سيبدو معالج الخطأ بالشكل التالي: const errorHandler = (error, request, response, next) => { console.error(error.message) if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } next(error) } app.use(errorHandler) يتحقق المعالج من طبيعة الخطأ. فإن كان الاستثناء هو خطأ تحويل نوع (CastError)، سنتأكد أن مصدر الخطأ هو كائن id بتنسيق مخالف لقواعد Mongo. وعندها سيرسل معالج الخطأ استجابته إلى المتصفح عبر كائن الاستجابة response الذي يمرر كمعامل للمعالج. أما في بقية الاستثناءات، فسيمرر المعالج الخطأ إلى معالج الخطأ الافتراضي في express. تسلسل استخدام الأدوات الوسطية تنفذ الأدوات الوسطية بنفس تسلسل استخدامها في express، أي بنفس تسلسل ظهور الأمر app.use. لذلك ينبغي الانتباه أثناء تعريفها. سيكون التسلسل الصحيح للاستخدام كالتالي: app.use(express.static('build')) app.use(express.json()) app.use(logger) app.post('/api/notes', (request, response) => { const body = request.body // ... }) const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } // handler of requests with unknown endpoint app.use(unknownEndpoint) const errorHandler = (error, request, response, next) => { // ... } // handler of requests with result to errors app.use(errorHandler) يجب أن تضع الأداة الوسطية json-parser بين الأدوات الوسطية التي تعرّف أولًا. فلو كان الترتيب كالتالي: app.use(logger) // request.body is undefined! app.post('/api/notes', (request, response) => { // request.body is undefined! const body = request.body // ... }) app.use(express.json()) لن تكون البيانات المرسلة عبر طلب HTTP بصيغة JSON متاحة للاستخدام عبر الأداة الوسطية للولوج أو عبر معالج المسار POST، لأن الخاصية request.body ستكون غير محددة undefined في هذه المرحلة. ومن المهم جدًا أن تُعرّف الأداة الوسطية التي تعالج مشكلة المسارات غير المدعومة ضمن التعريفات الأخيرة، تمامًا قبل معالج الأخطاء. سيسبب الترتيب التالي على سبيل المثال مشكلة: const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } // handler of requests with unknown endpoint app.use(unknownEndpoint) app.get('/api/notes', (request, response) => { // ... }) لقد ظهر معالج النهاية غير المحددة (unknown endpoint) قبل معالج طلبات HTTP. وطالما أن معالج النهايات غير المحددة سيستجيب إلى كافة الطلبات بالرمز 404 unknown endpoint، فلن يُستدعى بعدها أي مسار أو أداة وسطية. ويبقى الاستثناء الوحيد هو معالج الخطأ الذي يجب أن يأتي أخيرًا بعد معالج النهايات غير المحددة. خيارات أخرى سنضيف الآن بعض الوظائف التي لم ندرجها بعد، بما فيها الحذف وتحديث ملاحظة مفردة. وأسهل الطرق لحذف ملاحظة من قاعدة البيانات هي استخدام التابع findByIdAndRemove: app.delete('/api/notes/:id', (request, response, next) => { Note.findByIdAndRemove(request.params.id) .then(result => { response.status(204).end() }) .catch(error => next(error)) }) سيستجيب الخادم في كلتا حالتي نجاح عملية الحذف برمز الحالة 202 (لا يوجد محتوى). ونقصد بالحالتين، حذف ملاحظة موجودة فعلًا، أو حذف ملاحظة غير موجودة. يمكن أن نستخدم المعامل result للتحقق من حذف المورد أم لا، وبالتالي سنتمكن من تحديد أي من حالتي النجاح قد أعيدت إن كنا بحاجة ماسة لذلك. ستمرر كل الاستثناءات إلى معالج الأخطاء. يمكن بسهولة تغيير أهمية الملاحظة باستخدام التابع findByIdAndUpdate: app.put('/api/notes/:id', (request, response, next) => { const body = request.body const note = { content: body.content, important: body.important, } Note.findByIdAndUpdate(request.params.id, note, { new: true }) .then(updatedNote => { response.json(updatedNote) }) .catch(error => next(error)) }) تسمح الشيفرة السابقة أيضًا بتعديل محتوى الملاحظة لكنها لا تدعم تغيير تاريخ الإنشاء. ولاحظ كيف يتلقى التابع findByIdAndUpdate معاملًا على هيئة كائن JavaScript نظامي، وليس كائن ملاحظة أنشئ بواسطة الدالة البانية للنموذج Note. يبقى لدينا تفصيل مهم يتعلق بالتابع findByIdAndUpdate. فمعامل معالج الحدث upDateNote سيستقبل الملف الأصلي للملاحظة بلا تعديلات. لذلك وضعنا المعامل الاختياري {new: true}، الذي يسبب استدعاء معالج الحدث بالنسخة المعدَّلة من الملاحظة بدلًا من الأصلية. بعد اختبار الواجهة الخلفية مباشرة مستخدمين Postman أو VS Code REST client، يمكننا التحقق من أنها تعمل بشكل صحيح. وكذلك التحقق من أن الواجهة الأمامية تتكامل مع الخلفية التي تستخدم قاعدة البيانات. لكن عندما نحاول تغيير أهمية ملاحظة، تستظهر على الطرفية رسالة الخطأ التالية: استخدم google للبحث عن سبب الخطأ وسيقودك إلى إرشادات لتصحيحه. واتباعًا للاقتراح الموجود في توثيق Mongoose، أضفنا السطر التالي للملف note.js: const mongoose = require('mongoose') mongoose.set('useFindAndModify', false) // ... module.exports = mongoose.model('Note', noteSchema) ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-5 على موقع GitHub. التمارين 3.15 - 3.18 3.15 دليل هاتف بقاعدة بيانات: الخطوة 3 عدّل شيفرة الواجهة الخلفية لتحذف المُدخَلات مباشرة من قاعدة البيانات. تحقق أن الواجهة الأمامية تعمل بشكل صحيح بعد التعديلات. 3.16 دليل هاتف بقاعدة بيانات: الخطوة 4 حوّل معالج الخطأ في التطبيق إلى أداة وسطية جديدة لمعالجة الأخطاء. 3.17 دليل هاتف بقاعدة بيانات: الخطوة 5 * إذا حاول المستخدم إنشاء مُدخَل جديد إلى الدليل، وكان اسم الشخص موجودًا مسبقًا، ستحاول الواجهة الأمامية تحديث رقم الهاتف للمُدخَل الموجود بإرسال طلب HTTP-PUT إلى عنوان المُدخَل. عدّل الواجهة الخلفية لتدعم هذا الفعل، ثم تأكد أن الواجهة الأمامية ستعمل بعد التعديل. 3.18 دليل هاتف بقاعدة بيانات: الخطوة 6 * عدّل معالج المسار api/persons/:id ومعالج المسار api/persons/info ليستخدما قاعدة البيانات، ثم تحقق من أنهما يعملان جيدًا مستخدمًا المتصفح وPostman وVS Code REST client. سيبدو لك الأمر عند التحقق من مُدخَل فردي إلى دليل الهاتف كما في الشكل التالي: ترجمة -وبتصرف- للفصل saving data to MongoDB من سلسلة Deep Dive Into Modern Web Development
  17. سنربط في الفقرات التالية تطبيق الواجهة الأمامية الذي أنشأناه في القسم السابق من هذه السلسلة مع تطبيق الواجهة الخلفية. رأينا في القسم السابق، أن الواجهة الأمامية قادرة على الوصول إلى قائمة بكل الملاحظات من خادم JSON الذي لعب دور الواجهة الخلفية وذلك بطلب العنوان http://localhost:3001/notes. لكن عنوان الموقع قد تغير قليلًا الآن، وستجد الملاحظات على العنوان http://localhost:3001/api/notes. إذًا، سنغيّر الصفة baseUrl في الملف src/services/notes.js كالتالي: import axios from 'axios' const baseUrl = 'http://localhost:3001/api/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } // ... export default { getAll, create, update } لم يعمل الطلب GET إلى العنوان http://localhost:3001/api/notes لسبب ما. مالذي يحدث؟ يمكننا التحقق بالولوج إلى الواجهة الخلفية عبر المتصفح باستخدام Postman بلا أدنى مشكلة. سياسة الجذر المشترك ومفهوم CORS تأتي التسمية CORS من العبارة Cross-Origin Resource Sharing وتعني هذه العبارة "مشاركة الموارد ذات الجذور المختلطة". ووفقًا لموقع Wikipedia: تظهر هذه المشكلة في حالتنا بالصورة التالية: لا يمكن لشيفرة JavaScript التي نكتبها لتطبيق الواجهة الأمامية أن يعمل افتراضيًا مع خادم حتى يشتركا بالجذر نفسه. حيث يعمل تطبيق الخادم الذي أنجزناه سابقًا على المنفذ 3001، ويعمل تطبيق الواجهة الأمامية على المنفذ 3000. فلا يملكان جذرًا مشتركًا. وتذكر أن هذه السياسة لاتنطبق على React و Node فقط، بل هي سياسة عالمية لتشغيل تطبيقات الويب. يمكننا تخطي ذلك في تطبيقاتنا باستعمال الأداة الوسطية cors التي تقدمها Node. ثَبّت cors باستخدام الأمر التالي: npm install cors --save أدرج الأداة واستعملها على النحو: const cors = require('cors') app.use(cors()) وهكذا سترى أن تطبيق الواجهة الأمامية سيعمل، لكننا لم نضف حتى الآن وظيفة تغيير أهمية الملاحظة إلى الواجهة الخلفية. يمكنك الاطلاع على معلومات أكثر عن الأداة CORS من خلال Mozilla's page. تطبيقات للإنترنت طالما تأكدنا أن التطبيق بشقيه أصبح جاهزًا للعمل، سننقله إلى الإنترنت. سنستعين بالخادم Heroku لتنفيذ ذلك. إن لم تستخدم Heroku من قبل، ستجد تعليمات الاستخدام في توثيق Heroku أو بالبحث عبر الإنترنت. أضف الملف Procfile إلى جذر المشروع لتخبر Heroku كيف سيُشغّل التطبيق. web: npm start غيّر تعريف المنفذ الذي نستخدمه في تطبيقنا أسفل الملف index.js ليصبح كالتالي: const PORT = process.env.PORT || 3001 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) وهكذا فإما أن سنستخدم المنفذ الذي عرّفناه كمتغيير بيئة أو المنفذ 3001 إن لم يُعرّف منفذ من خلال متغير البيئة. يُهيِّئ Heroku منفذ التطبيق بناء على قيمة منفذ متغير البيئة. أنشئ مستودع git في جذر المشروع وأضف الملف ذو اللاحقة gitignore. وفيه المعلومات التالية: node_modules أنشئ تطبيق Heroku بتنفيذ الأمر heroku create، حمل شيفرتك إلى المستودع ثم انقله إلى Heroku بتنفيذ الأمر git push heroku master. إن جرى كل شيء على مايرام، سيعمل التطبيق: إن لم يعمل التطبيق، تحقق من المشكلة بقراءة سجلات Heroku، وذلك بتنفيذ الأمر heroku logs. من المفيد في البداية أن تراقب باستمرار ما يظهره Heroku من سجلات. وأفضل وسيلة للمراقبة تنفيذ الأمر heroku log -t الذي يطبع سجلاته على الطرفية، عندما تحصل مشكلة ما على الخادم. إن كنت ستنشر تطبيقك من مستودع git ولم تكن الشيفرة موجودة في الفرع الرئيسي (إي إن كنت ستعدل مستوع الملاحظات من الدرس السابق)، عليك استخدام الأمر git push heroku HEAD:master. وإن فعلت ذلك مسبقًا، ربما ستحتاج إلى تنفيذ هذا الأمر git push heroku HEAD:master --force. تتكامل الواجهة الخلفية مع الأمامية على Heroku أيضًا. يمكنك التحقق من ذلك بتغيير عنوان الواجهة الخلفية الموجود ضمن شيفرة الواجهة الأمامية، ليصبح نفس عنوان الواجهة الخلفية على Heroku بدلًا من http://localhost:3001 السؤال التالي هو: كيف سننشر تطبيق الواجهة الأمامية على الإنترنت؟ لدينا العديد من الخيارات. بناء نسخة الإنتاج من الواجهة الأمامية لقد أنشأنا حتى هذه اللحظة تطبيقات React في وضعية التطوير (development mode)، حيث يهيأ التطبيق لإعطاء رسائل خطأ واضحة، وتُصيّر الشيفرة مباشرة عند حدوث أية تغييرات وهكذا. لكن عندما يغدو التطبيق جاهزًا للنشر لابد من إنشاء نسخة إنتاج (production build) أو نسخة التطبيق المتمثلة للإنتاج. ننشئ نسخة الإنتاج من التطبيقات التي بنيت باستخدام create-react-app بتنفيذ الأمر npm run build. لننفذ الأمر السابق عند جذر مشروع الواجهة الأمامية. ينشئ تنفيذ الأمر السابق مجلدًا يدعى build (يحوي ملف HTML الوحيد للتطبيق ويدعى index.html) وفي داخله مجلد آخر يدعى static ستوَلَّد فيه نسخة مصغرة عن شيفرة JavaScript للتطبيق. وعلى الرغم من وجود عدة ملفات JavaScript في تطبيقنا، إلا أنها ستُجمّع ضمن ملف مصغّر واحد. وفي واقع الأمر، سيَضم هذا الملف كل محتويات ملفات الارتباط الخاصة بالتطبيق أيضًا. لن يكون فهم أو قراءة محتويات هذا الملف يسيرًا كما ترى: !function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];c<i.length;c++)f=i[c],o[f]&&s.push(o[f][0]),o[f]=0;for(n in l)Object.prototype.hasOwnProperty.call(l,n)&&(e[n]=l[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,i=1;i<t.length;i++){var l=t[i];0!==o[l]&&(n=!1)}n&&(u.splice(r--,1),e=f(f.s=t[0]))}return e}var n={},o={2:0},u=[];function f(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,f),t.l=!0,t.exports}f.m=e,f.c=n,f.d=function(e,r,t){f.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},f.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}) تقديم الملفات ذات المحتوى الثابت من الواجهة الخلفية إن أحد الطرق المتبعة في نشر الواجهة الأمامية هو بناء نسخة إنتاج ووضعها في جذر المستودع الذي يحتوي الواجهة الخلفية. ومن ثم نهيئ الواجهة الخلفية لتعرض الصفحة الرئيسية للواجهة الأمامية (build/index.html). نبدأ العملية بنقل نسخة الإنتاج إلى جذر الواجهة الخلفية. استخدم الأمر التالي في إجراء عملية النسخ إن كنت تستخدم أحد نظامي التشغيل Mac أو Linux cp -r build ../../../osa3/notes-backend واستخدم في النظام windows إحدى التعليميتن copy أو xcopy أو استخدم ببساطة النسخ و اللصق. سيبدو مجلد الواجهة الخلفية كالتالي: سنستخدم أداة وسطية مدمجة مع المكتبة express تعرض الملفات ذات المحتوى الثابت التي تحضرها من الخادم مثل الملف index.html وملفات JavaScript وغيرها، تدعى هذه الأداة static. فعندما نضيف العبارة التالية إلى شيفرة الواجهة الخلفية: app.use(express.static('build')) ستتحقق Express من محتويات المجلد build عندما تتلقى أية طلبات HTTP-GET. فإن وجدت ملفًا مطابقًا للملف المطلوب ستعيده. وهكذا ستظهر الواجهة الأمامية المبنية باستخدام React، عندما يستجيب الخادم إلى طلبات GET إلى العنوان www.serversaddress.com/index.html أو إلى العنوان www.serversaddress.com. بينما تتعامل الواجهة الخلفية مع الطلب GET إلى العنوان www.serversaddress.com/api/notes. في حالتنا هذه سنجد أن للواجهتين العنوان نفسه، لذلك نستطيع أن نعطي للمتغير baseUrl عنوان موقع نسبي، وذلك كي لانذكر القسم الأول من العنوان والمتعلق بالخادم. import axios from 'axios' const baseUrl = '/api/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) // ... } بعد إجراء تلك التغييرات، علينا أن ننشئ نسخة إنتاج ونضعها في جذر مستودع الواجهة الخلفية. وبالتالي سنصبح قادرين على استخدام الواجهة الأمامية بطلب عنوان الواجهة الخلفية http://localhost:3001. وهكذا سيعمل تطبيقنا تمامًا كتطبيق الصفحة الواحدة النموذجي الذي درسناه في القسم 0. فعندما نطلب عنوان الواجهة الخلفية http://localhost:3001 سيعيد الخادم الملف index.html من المجلد build. ستجد محتوى الملف (بشكل مختصر) كالتالي: <head> <meta charset="utf-8"/> <title>React App</title> <link href="/static/css/main.f9a47af2.chunk.css" rel="stylesheet"> </head> <body> <div id="root"></div> <script src="/static/js/1.578f4ea1.chunk.js"></script> <script src="/static/js/main.104ca08d.chunk.js"></script> </body> </html> يتضمن الملف تعليمات لإحضار ملف CSS الذي يعرف تنسيقات عناصر التطبيق ومعرّفي شيفرة (script tag) يوجهان المتصفح إلى إحضار شيفرة JavaScript التي يحتاجها التطبيق، وهي الشيفرة الفعلية لتطبيق React. تحضر شيفرة React الملاحظات من العنوان http://localhost:3001/api/notes، وتصيّرها على الشاشة. يمكنك أن تتابع تفاصيل الاتصال بين المتصفح والخادم من نافذة Network ضمن طرفية التطوير: بعد أن نتأكد من عمل نسخة الإنتاج على الخادم المحلي، انقل المجلد build الذي يحوي الواجهة الأمامية إلى مستودع الواجهة الخلفية، ثم انقل الشيفرة كلها إلى خادم Heroku مجددًا. سيعمل التطبيق بشكل جيد، ماعدا الجزئية التي تتعلق بتغيير أهمية الملاحظات. حتى اللحظة يحفظ تطبيقنا الملاحظات ضمن متغيّر، وبالتالي إذا ما حدث أمر ما وتوقف التطبيق عن العمل ستختفي تلك الملاحظات. إذًا لايزال التطبيق بحاجة إلى قاعدة بيانات. سنشرح تاليًا عددًا من النقاط قبل أن ننتقل إلى قاعدة البيانات. نشر الواجهة الخلفية بطريقة انسيابية سنكتب سكربت npm بسيط لإنشاء نسخة إنتاج من الواجهة الأمامية بأقل جهد ممكن. ضع الشيفرة التالية في ملف package.json الموجود في مستودع الواجهة الخلفية: { "scripts": { //... "build:ui": "rm -rf build && cd ../../osa2/materiaali/notes-new && npm run build --prod && cp -r build ../../../osa3/notes-backend/", "deploy": "git push heroku master", "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && npm run deploy", "logs:prod": "heroku logs --tail" } } عندما ينفذ npm الجزء (build:ui) من السكريبت السابقة بالأمر npm run build:ui، سيبني نسخة إنتاج من الواجهة الأمامية ضمن مستودع الواجهة الخلفية. بينما سينشر الأمر npm run deploy النسخة الحالية للواجهة الخلفية على الخادم Heroku. وأخيرًا يدمج الأمر npm run deploy:full التطبيقين معًا ويزودهما بأوامر git تساعد في تحديث مستودع الواجهة الخلفية. يبقى هناك جزء من سكريبت npm يُنفَّذ بالأمر npm run logs:prod، ويُستخدَم لطباعة سجلات Heroku أثناء التنفيذ. انتبه: تعتمد المسارات المكتوبة في الجزء build:ui من السكربت على موقع المستودعات في منظومة الملفات. تُنفَّذ npm سكريبت في نظام windows باستخدام cmd.exe لأن واجهة النظام (shell) الافتراضية لاتدعم أوامر أو واجهة bash. ولتنفيذ الأوامر السابقة استبدل واجهة النظام بالواجهة bash باستخدام محرر Git الافتراضي في windows كالتالي: npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" الخادم الوكيل لن تعمل الواجهة الأمامية بعد التغييرات التي أجريناها في وضعية التطوير (عندما تُشغَّل باستخدامnpm start)، لأن الاتصال مع الواجهة الخلفية لن يعمل. وذلك لتغيّر عنوان الواجهة الخلفية إلى عنوان نسبي: const baseUrl = '/api/notes' لقد كان عنوان الواجهة الأمامية في مرحلة التطوير localhost:3000، وبالتالي ستُرسل الطلبات الآن إلى الواجهة الخلفية على العنوان الخاطئ localhost:3000/api/notes. وطبعًا الواجهة الخلفية موجودة فعلًا على العنوان localhost:3001. سيكون الحل بسيطًا عندما ننشئ المشروع باستخدام create-react-app. أضف التصريحات التالية إلى الملف package.json الموجود في مستودع الواجهة الأمامية: { "dependencies": { // ... }, "scripts": { // ... }, "proxy": "http://localhost:3001" } بعد إعادة تشغيل التطبيق، ستعمل بيئة تطوير React كخادم وكيل. فلو أرسلت شيفرة React طلبًا إلى خادم على العنوان http://localhost:3000، ولم يكن تطبيق React هو من يدير هذا الخادم (أي في الحالة التي لا تحضر فيها الطلبات ملفات CSS أو JavaScript الخاصة بالتطبيق)، سيعاد توجيه الطلب إلى الخادم الذي عنوانه http://localhost:3001. وهكذا ستعمل الواجهة الأمامية بشكل جيد في وضعي التطوير والإنتاج. إن سلبية هذه المقاربة هي التعقيد الذي تظهره في نشر الواجهة الأمامية على الإنترنت. فنشر نسخة جديدة سيتطلب إنشاء نسخة إنتاج جديدة ونقلها إلى مستودع الواجهة الخلفية. وسيصعّب ذلك إنشاء خطوط نشر آلية. وتعرّف خطوط النشر بأنها طريقة مؤتمتة وقابلة للتحكم لنقل الشيفرة من حاسوب المطور إلى بيئة الإنتاج مرورًا باختبارات مختلفة بالإضافة إلى التحقق من الجودة. يمكن إنجاز ذلك بطرق عدة، منها وضع الواجهتين الخلفية والأمامية في نفس المستودع. لكننا لن نناقش ذلك الآن. في بعض الحالات من المعقول أن ننشر الواجهة الأمامية على شكل تطبيق مستقل وهذا أمر بسيط ويطبق مباشرة إن كتب التطبيق باستخدام create-react-app. ستجد الشيفرة الحالية للواجهة الخلفية في الفرع part3-3 على Github. بينما ستجد التعديلات على الواجهة الأمامية في الفرع part3-1 في مستوودع الواجهة الأمامية. التمارين 3.9 - 3.11 لايتطلب إنجاز التمارين التالية العديد من الأسطر البرمجية. لكنها ستحمل شيئًا من التحدي، لأنها تتطلب فهمًا دقيقًا لما يحدث وأين يحدث، بالإضافة إلى تهيئة التطبيق كما يجب. 3.11 دليل هاتف للواجهة الخلفية: الخطوة 9 اجعل الواجهة الخلفية تتكامل مع الأمامية في نفس التمرين من القسم السابق. لا تضف وظيغة تغيير الأرقام حاليًا، بل سنقوم بذلك لاحقًا. ربما ستغيّر قليلًا في الواجهة الأمامية، على الأقل عناوين مواقع الواجهة الخلفية. وتذكر أن تبقي طرفية التطوير مفتوحة في متصفحك. تحقق مما يحدث إن فشل أي طلب HTTP من خلال النافذة Network، وابق نظرك دائما على طرفية تطوير الواجهة الخلفية في نفس الوقت. إن لم تتمكن من إنجاز التمرين السابق، ستفيدك طباعة بيانات الطلب أوجسم الطلب على الطرفية بزرع تعليمة الطباعة في دالة معالج الحدث المسؤولة عن طلبات POST. 3.10 دليل هاتف للواجهة الخلفية: الخطوة 10 انشر الواجهة الخلفية على الإنترنت (على Heroku مثلًا). ملاحظة: إن لم تستطع لسبب ما تثبيت Heroku على حاسوبك، استخدم الأمر npx heroku-cli. اختبر الواجهة الخلفية التي نشرتها من خلال المتصفح بمساعدة Postman أو VS Code REST client للتأكد من أنها تعمل. نصيحة للاحتراف: عندما تنشر تطبيقك على Heroku، يفضل -على الأقل في البداية- أن تبقي نظرك على ما يطبعه تطبيق Heroku دائمًا، وذلك بتنفيذ الأمر heroku logs -t. تمثل الصورة التالية مايطبعه heroku عندما لا يستطيع إيجاد ملف الارتباط express. والسبب أن الخيار save-- لم يحدد عندما ثُبتت المكتبة express. وبالتالي لم تحفظ معلومات ملف الارتباط ضمن الملف package.json. أحد الأخطاء الأخرى، هو أن التطبيق لم يهيأ لاستعمال المنفذ الذي عُرِّف في متغير البيئة PORT: أنشئ ملفًا باسم README.md في جذر مستودعك، وأضف إليه رابطًا لتطبيقك على الإنترنت. 3.11 دليل هاتف للواجهة الخلفية: الخطوة 11 أنشئ نسخة إنتاج من الواجهة الأمامية، وانشرها على الإنترنت بالأسلوب الذي ناقشناه في هذا الفصل. ملاحظة: تأكد من أن المجلد build ليس ضمن قائمة الملفات التي يهملها git، وهو الملف الذي لاحقته gitignore. تأكد أيضًا أن الواجهة الأمامية لازالت تعمل. ترجمة -وبتصرف- للفصل Deploying App to Internet من سلسلة Deep Dive Into Modern Web Development
  18. سنوجّه اهتمامنا في هذا القسم إلى التعامل مع الواجهة الخلفية وآلية تطوير التطبيق بإضافة وظائف جديدة تنفذ هذه المرة من قبل الخادم. سنبني تلك الوظائف باستخدام NodeJS وهي بيئة تشغيل JavaScript مبنية على محرك JavaScript من تصميم Google يدعى Chrome V8. كُتبَت المادة العلمية للمنهاج باستخدام Node.js 10.18.0، وتأكد من أن النسخة المثبتة على جهازك هي على الأقل مطابقة للإصدار السابق، ويمكنك استخدام الأمر node -v للتحقق من الإصدار المثبت لديك. أشرنا سابقًا في مقال أساسيات جافاسكربت اللازمة للعمل مع React أن المتصفحات لا تدعم كامل الميزات الأحدث للغة JavaScript مباشرةً، لذلك من الضروري نقل الشيفرة التي تعمل على المتصفح إلى إصدار أقدم باستخدام babel مثلًا. لكن الأمر سيختلف تمامًا مع JavaScript التي تُنفّذ في الواجهة الخلفية، ذلك أن الإصدار الأحدث من Node.js سيدعم الغالبية العظمى من الميزات الجديدة للغة، فلا حاجة عندها للنقل. سنضيف شيفرة تتعامل مع تطبيق الملاحظات الذي تعرفنا عليه سابقًا في مقالات سابقة من هذه السلسلة لكن تنفيذها سيكون في الواجهة الخلفية. لكن علينا أولًا تعلم الأساسيات من خلال كتابة التطبيق التقليدي "Hello world". ملاحظة: لن تكون جميع التطبيقات والتمارين في هذا القسم تطبيقات React، ولن نستخدم create-react-app في تهيئة المشاريع التي تضم التطبيقات. لقد تعرفنا سابقًا في مقال إحضار البيانات من الخادم في تطبيقات React على مدير الحزم npm وهو أداة لإدارة حزم JavaScript تعود أصلًا إلى بيئة Node.js. انتقل إلى مجلد مناسب وأنشئ قالبًا لتطبيقنا مستخدمًا الأمر npm init. أجب عن الأسئلة التي تولدها الأداة، وستكون النتيجة إنشاء الملف package.json ضمن جذر المشروع يضم معلومات عنه. { "name": "backend", "version": "0.0.1", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "Matti Luukkainen", "license": "MIT" } من هذه المعلومات بالطبع -ستكون قد أدخلتها عند إجابتك عن الأسئلة- اسم المشروع ونقطة انطلاق التطبيق والتي هي الملف index.js في تطبيقنا. لنجري بعض التعديلات على محتوى الكائن scripts في الملف: { // ... "scripts": { "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" }, // ... } لننشئ الإصدار الأول لتطبيقنا بإضافة ملف باسم index.js إلى المجلد الجذري الذي أنشأنا فيه المشروع. اكتب الأمر التالي في الملف: console.log('hello world') يمكن تشغيل التطبيق من node بكتابة التالي في سطر الأوامر: node index.js كما يمكن تشغيله كسكريبت (npm script): npm start ستعمل سكريبت npm لأننا عرفناها ضمن الكائن script في ملف package.json: { // ... "scripts": { "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" }, // ... } سيُنفِّذ npm المشروع عند استدعاء الملف index.js من سطر الأوامر. يعتبر تشغيل التطبيق كسكريبت npm أمرًا اختياريًا. يعرّف ملف package.json افتراضيًا سكريبت npm أخرى تدعى npm test. وطالما أن المشروع لا يضم حتى الآن مكتبة للاختبارات، سينفذ npm الأمر التالي: echo "Error: no test specified" && exit 1 خادم ويب بسيط لنحوّل تطبيقنا إلى خادم ويب: const http = require('http') const app = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('Hello World') }) const PORT = 3001 app.listen(PORT) console.log(`Server running on port ${PORT}`) ستطبع الطرفية الرسالة التالية بمجرد تشغيل التطبيق: Server running on port 3001 سنشغل تطبيقنا المتواضع بطلب عنوان الموقع http://localhost:3001 من المتصفح: سيعمل الخادم كما سبق بغض النظر عن بقية أقسام العنوان، حتى أن كتابة عنوان الموقع بالشكل http://localhost:3001/foo/bar يعطي النتيجة نفسها. ملاحظة: إن كان المنفذ 3001 محجوزًا من قبل تطبيق آخر، سينتج عن تشغيل الخادم رسالة الخطأ التالية: ➜ hello npm start > hello@1.0.0 start /Users/mluukkai/opetus/_2019fullstack-code/part3/hello > node index.js Server running on port 3001 events.js:167 throw er; // Unhandled 'error' event ^ Error: listen EADDRINUSE :::3001 at Server.setupListenHandle [as _listen2] (net.js:1330:14) at listenInCluster (net.js:1378:12) في هذه الحالة ستكون أمام خياران: إما أن تغلق التطبيق الذي يَشغُل المنفذ 3001، أو اختيار منفذ آخر. لنتأمل الآن السطر الأول من شيفرة تطبيق الخادم: const http = require('http') يُدرج التطبيق وحدة خادم الويب web server المدمجة ضمن Node. لقد تعلمنا إدراج الوحدات في شيفرة الواجهة الأمامية لكن بعبارة مختلفة قليلًا: import http from 'http' تُستعمل حاليًا وحدات ES6 ضمن شيفرة الواجهة الأمامية. وتذكّر أنّ تعريف الوحدات يكون باستعمال التعليمة export واستخدامها ضمن الشيفرة باستعمال التعليمة import. تَستعمل Node.js ما يسمى CommonJS (وحدات JavaScript المشتركة). ذلك أن بيئتها تطلبت وجود الوحدات قبل أن تدعمها JavaScript بوقت طويل. بدأت Node.js بالدعم التجريبي لوحدات ES6 مؤخّرًا فقط. لن نجد فرقًا تقريبًا بين وحدات ES6 ووحدات CommonJS، على الأقل ضمن حدود منهاجنا. ستبدو القطعة التالية من الشيفرة على النحو التالي: const app = http.createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'text/plain' }) response.end('Hello World') }) تستخدم الشيفرة التابع createServer الموجود ضمن الوحدة http لإنشاء خادم ويب جديد. تعرّف الشيفرة بعد ذلك معالج حدث ضمن الخادم، يُستدعى كلما ورد طلب HTTP إلى العنوان http://localhost:3001. يستجيب الخادم برمز الحالة (200) معيدًا ترويسة "نوع المحتوى" على أنها text/plain (نص أو فارغ)، وكذلك محتوى صفحة الويب التي ستُعرض، وهذا المحتوى هو العبارة "Hello World". تهيئ أسطر الشيفرة الأخيرة خادم http الذي أُسند إلى المتغيّر App لينصت إلى طلبات HTTP القادمة إلى المنفذ 3001: const PORT = 3001 app.listen(PORT) console.log(`Server running on port ${PORT}`) إنّ الغاية الأساسية من استخدام خادم الواجهة الخلفية في منهاجنا، هو تقديم بيانات خام بصيغة JSON إلى الواجهة الأمامية. ولهذا السبب سنعدّل الخادم (تطبيق الخادم) ليعيد قائمة من الملاحظات المكتوبة مسبقًا بصيغة JSON: const http = require('http') let notes = [ { id: 1, content: "HTML is easy", date: "2019-05-30T17:30:31.098Z", important: true }, { id: 2, content: "Browser can execute only Javascript", date: "2019-05-30T18:39:34.091Z", important: false }, { id: 3, content: "GET and POST are the most important methods of HTTP protocol", date: "2019-05-30T19:20:14.298Z", important: true } ] const app = http.createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'application/json' }) response.end(JSON.stringify(notes)) }) const PORT = 3001 app.listen(PORT) console.log(`Server running on port ${PORT}`) لنعد تشغيل الخادم ولنحدث المتصفح. ملاحظة: يمكنك إغلاق الخادم بالضغط على ctrl+c من طرفية سطر أوامر Node.js. تُعلِم القيمة (application/JSON) الموجودة في ترويسة "نوع المحتوى" متلقي البيانات أنها بصيغة JSON.تُحوَّل المصفوفة notes إلى JSON باستخدام التابع ()JSON.stringify. ستظهر المعلومات على المتصفح تمامًا كما ظهرت في مقال إحضار البيانات من الخادم في تطبيقات React عندما استخدمنا خادم JSON. مكتبة Express يمكن كما رأينا كتابة شيفرة الخادم مباشرة باستخدام الوحدة http المدمجة ضمن Node.js.لكن الأمر سيغدو مربكًا عندما يزداد حجم التطبيق. طوّرت العديد من المكتبات لتسهّل تطوير تطبيقات الواجهة الخلفية باستخدام Node، وذلك بتقديم واجهة أكثر ملائمة للعمل بالموازنة مع وحدة http المدمجة. تعتبر المكتبة express حتى الآن الأكثر شعبية لتحقيق المطلوب. لنضع express موضع التنفيذ بتعريفها كملف اعتمادية dependency وذلك بتنفيذ الأمر: npm install express --save يُضاف ملف الاعتمادية أيضًا إلى الملف package.json: { // ... "dependencies": { "express": "^4.17.1" } } تُثبت الشيفرة المصدرية لملف الاعتمادية ضمن المجلد node_modules الموجود في المجلد الجذري للمشروع. يمكنك إيجاد عدد كبير من ملفات الاعتمادية بالإضافة إلى express ضمن هذا المجلد. في الواقع سيضم المجلد السابق ملفات اعتمادية express وملفات اعتمادية متعلقة بملفات اعتمادية express وهكذا. ندعو هذا الترتيب بملفات الاعتمادية الانتقالية transitive dependencies للمشروع. ثُبِّتت في مشروعنا المكتبة express 4.17.1. لكن ما الذي تعنيه إشارة (^) أمام رقم الإصدار في ملف package.json؟ "express": "^4.17.1" يستخدم npm ما يسمى بآلية الإصدار الدلالية semantic versioning، وتعني الدلالة (^) أنه إذا حُدِّثت ملفات اعتمادية المشروع فإن إصدار express سيبقى 4.17.1. يمكن أن يتغير رقم الدفعة ضمن الإصدار (الرقم الأخير) أو رقم الإصدار الثانوي (الرقم الأوسط) لكن رقم الإصدار الرئيسي (الرقم الأول) يجب أن يبقى كما هو. نستخدم الأمر التالي لتحديث ملفات اعتمادية المشروع: npm update يمكننا تثبيت أحدث ملفات اعتمادية معرفة في ملف package.json إذا أردنا أن نعمل على مشروعنا في حاسوب آخر باستخدام الأمر: npm install وبشكل عام عند تحديث ملف اعتمادية، يدل عدم تغير رقم الإصدار الرئيسي أن الإصدار الأحدث سيبقى متوافقًا مع الإصدار الأقدم دون الحاجة لتغييرات في الشيفرة. بينما تغير رقم الإصدار الرئيسي سيدل أن الإصدار الأحدث قد يحوي تغييرات قد تمنع التطبيق من العمل. فقد لا يعمل تطبيقنا إن كان إصدار المكتبة express فيه 4.17.7 مثلًا وتم تحديثها إلى الإصدار 5.0.0 (الذي قد نراه مستقبلًا)، بينما سيعمل مع الإصدار 4.99.1. استخدام express في تطوير صفحات الويب لنعد إلى تطبيقنا ونجري بعض التعديلات عليه: const express = require('express') const app = express() let notes = [ ... ] app.get('/', (req, res) => { res.send('<h1>Hello World!</h1>') }) app.get('/api/notes', (req, res) => { res.json(notes) }) const PORT = 3001 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) سنعيد تشغيل التطبيق حتى نحصل على النسخة الجديدة منه. طبعًا لن نلاحظ تغييرًا كليًا فلقد أدرجنا express على شكل دالة ستنشئ تطبيق express يُخزّن ضمن المتغيّر App: const express = require('express') const app = express() وبعدها عرّفنا مسارين للتطبيق، يعرّف الأول معالج حدث يتعامل مع طلبات HTTP-GET الموجهة إلى المجلد الجذري للتطبيق (' / '): app.get('/', (request, response) => { response.send('<h1>Hello World!</h1>') }) حيث تقبل دالة المعالج معاملين الأول request ويحوي كل المعلومات عن طلب HTTP، ويستخدم الثاني response لتحديد آلية الاستجابة للطلب. يستجيب الخادم إلى الطلب في شيفرتنا باستخدام التابع send العائد للكائن response. حيث يرسل الخادم عند الاستجابة العبارة النصية <h1>Hello World!</h1>التي مُرّرت كمعامل للتابع send. وطالما أن القيمة المعادة نصية ستَسند express القيمة text/html إلى ترويسة "نوع المحتوى" ويعاد رمز الحالة 200. يمكننا التحقق من ذلك من خلال النافذة Network في طرفية تطوير المتصفح: بالنسبة للمسار الثاني فإنه يعرّف معالج حدث يتعامل مع طلبات HTTP-GET الموجهة إلى موقع وجود الملاحظات، نهاية المسار notes: app.get('/api/notes', (request, response) => { response.json(notes) }) يستجيب الخادم إلى الطلب باستخدام التابع json العائد للكائن response. حيث تُرسل مصفوفة الملاحظات التي مُرّرت للتابع بصيغة JSON النصية. وكذلك ستتكفل express بإسناد القيمة application/json إلى ترويسة "نوع المحتوى". سنلقي تاليًا نظرة سريعة على البيانات التي أرسلت بصيغة JSON. كان علينا سابقًا تحويل البيانات إلى نصوص مكتوبة بصيغة JSON باستخدام التابع JSON.stringify: response.end(JSON.stringify(notes)) لا حاجة لذلك عند استخدام express، فهي تقوم بذلك تلقائيًا. وتجدر الإشارة هنا أنه لا فائدة من كون JSON مجرد نص، بل يجب أن يكون كائن JavaScript مثل notes الذي أُسند إليه. ستشرح لك التجربة الموضحة في الشكل التالي هذه الفكرة: أُنجز الاختبار السابق باستخدام الوحدة التفاعلية node-repl. يمكنك تشغيل هذه الوحدة بكتابة node في سطر الأوامر. تفيدك هذه الوحدة بشكل خاص لتوضيح طريقة عمل الأوامر، وننصح بشدة أن تستخدمها أثناء كتابة الشيفرة. مكتبة nodemon رأينا سابقًا ضرورة إعادة تشغيل التطبيق عند تعديله حتى تظهر نتائج التعديلات. ونقوم بذلك عن طريق إغلاق التطبيق أولًا باستخدام ctrl+c ثم تشغيله من جديد. طبعًا فالأمر مربك بالموازنة مع طريقة عمل React التي تقوم بذلك تلقائيًا بمجرد تغيّر الشيفرة. إن حل هذه المشكلة يكمن في استخدام nodemon. سنثبت الآن nodemon كملف اعتمادية باستخدام الأمر: npm install --save-dev nodemon سيتغير أيضًا محتوى الملف package.json: { //... "dependencies": { "express": "^4.17.1", }, "devDependencies": { "nodemon": "^2.0.2" } } إن أخطأت في كتابة الأمر وظهر ملف اعتمادية nodemon تحت مسمى ملفات الاعتمادية "dependencies" بدلًا من ملفات اعتمادية التطوير"devDependencies"، أصلح الأمر يدويًا في ملف package.json. إن الإشارة لملف اعتمادية على أنه ملف اعتمادية تطوير هو للدلالة على الحاجة له أثناء تطوير التطبيق فقط، ليعيد على سبيل المثال تشغيل التطبيق كما تفعل nodemon. ولن تحتاجها لاحقًا عندما تشغل التطبيق على خادم الاستثمار الفعلي مثل Heroku. لتشغيل التطبيق مع nodemon اكتب الأمر التالي: node_modules/.bin/nodemon index.js سيسبب الآن أي تغيير في الشيفرة إعادة تشغيل الخادم تلقائيًا. وطبعًا لا فائدة من إعادة تشغيل الخادم إن لم نحدث المتصفح الذي يعرض الصفحة، لذلك علينا القيام بذلك يدويًا. فلا نمتلك حاليًا وسيلة لإعادة التحميل الدائم (hot reload) كما في React. يبدو الأمر السابق طويلًا، فلنعرف إذًا سكريبت npm خاصة بتشغيل التطبيق ضمن الملف package.json: { // .. "scripts": { "start": "node index.js", "dev": "nodemon index.js", "test": "echo \"Error: no test specified\" && exit 1" }, // .. } لا حاجة في هذه السكريبت لتحديد المسار التالي node_modules/.bin/nodemon للمكتبة nodemon، لأن npm يعرف أين سيبحث عنها. لنشغل الخادم بوضعية التطوير كما يلي: npm run dev على خلاف مخطوطتي start و test يجب إضافة التعليمة run إلى الأمر. العمل مع واجهة التطبيقات REST لنجعل تطبيقنا قادرًا على تأمين واجهة http متوافقة مع REST كما فعلنا مع خادم JSON. قدم روي فيلدينغ مفهوم نقل حالة العرض (REpresentational State Transfer) واختصارًا REST، عام 2000 في أطروحته للدكتوراه وهي عبارة عن أسلوب تصميمي لبناء تطبيقات ويب بمقاسات قابلة للتعديل. لن نغوص في تعريف REST، أو نهدر وقتنا في التفكير بالواجهات المتوافقة أو غير المتوافقة معها، بل سنعتمد مقاربة ضيقة تهتم فقط بكيفية فهم التوافق مع REST من منظور تطبيقات الويب، فمفهوم REST الأصلي ليس محدودًا بتطبيقات الويب. لقد أشرنا في القسم السابق أن REST تعتبر كل الأشياء الفردية -كالملاحظات في تطبيقنا- موردًا. ولكل مورد موقع محدد URL يمثل العنوان الفريد لهذا المورد. إن أحد الأعراف المتبعة في إنشاء عنوان فريد للمورد هو دمج نوع المورد مع المعرف الفريد له. فلو افترضنا أن الموقع الجذري للخدمة هو www.example.com/api، وأننا عرّفنا نوع المورد الذي يمثل الملاحظات على أنه note وأننا نحتاج إلى المورد note ذو المعرف 10، سيكون عنوان موقع المورد www.example.com/api/notes/10. وسيكون عنوان موقع مجموعة الملاحظات www.example.com/api/notes. يمكننا تنفيذ العديد من العمليات على الموارد، وتعرّف العملية التي نريد تنفيذها على مورد على أنها فعل HTTP: الموقع الفعل الوظيفة notes/10 GET إحضار مورد واحد notes GET إحضار كل موارد المجموعة notes POST إنشاء مورد جديد بناء على البيانات الموجودة في الطلب notes/10 DELETE حذف المورد المحدد notes/10 PUT استبدال كامل المورد المحدد بالبيانات الموجودة في الطلب notes/10 PATCH استبدال جزء من المورد المحدد بالبيانات الموجودة في الطلب table { width: 100%; } thead { vertical-align: middle; text-align: center;} td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } وهكذا نحدد الخطوط العامة لما تعنيه REST كواجهة نموذجية، أي أنها تعرّف أسلوبُا ثابتُا ومنسقًا للواجهات، تجعل الأنظمة المختلفة قادرة على العمل المشترك. إنّ المفهوم الذي اعتمدناه حول REST يصنف كمستوًى ثانٍ من التوافق وفق نموذج توافق ريتشاردسون. مع هذا فنحن إذ عّرفنا واجهة REST، لم نعرفها لتتوافق تمامًا مع المعايير التي قدمها روي فيلدينغ في أطروحته، وهذا حال الغالبية العظمى من الواجهات التي تدعي توافقها الكامل مع REST. يشار إلى نموذجنا الذي يقدم واجهة أساسية (إنشاء-قراءة-تحديث-حذف) (CRUD: Create-Read-Update-Delete) في العديد من المراجع (Richardson, Ruby: RESTful Web Services) على أنه تصميم موجه للعمل مع الموارد resource oriented architecture بدلًا من كونه متوافقًا مع REST. إحضار مورد واحد لنوسع تطبيقنا بحيث يقدم واجهة REST تتعامل مع الملاحظات بشكل فردي. لكن علينا أولًا إنشاء مسار لإحضار المورد. سنعتمد نموذج (نوع/معرف) في الوصول إلى موقع المورد (notes/10 مثلًا). يمكن تعريف معاملات المسار في express كما يلي: app.get('/api/notes/:id', (request, response) => { const id = request.params.id const note = notes.find(note => note.id === id) response.json(note) }) سيتعامل المسار (...,'app.get('/api/notes/:id مع طلبات HTTP-GET التي تأتي بالصيغة api/notes/SOMETHING حيث يشير SOMETHING إلى نص افتراضي. يمكن الوصول إلى المعامل id للمسار من خلال الكائن request: const id = request.params.id نستخدم التابع find الخاص بالمصفوفات للبحث عن الملاحظة من خلال معرفها الفريد id والذي يجب أن يتطابق مع قيمة المعامل، ثم تعاد الملاحظة إلى مرسل الطلب. لكن لو جربنا الشيفرة المكتوبة واستخدمنا المتصفح للوصول إلى العنوان http://localhost:3001/api/notes/1، فلن يعمل التطبيق كما هو متوقع، وستظهر صفحة فارغة. لن يفاجئني هذا كمطور اعتاد على المشاكل، لذا فقد حان وقت التنقيح. لنزرع الأمر console.log كما اعتدنا: app.get('/api/notes/:id', (request, response) => { const id = request.params.id console.log(id) const note = notes.find(note => note.id === id) console.log(note) response.json(note) }) بمجرد انتقال المتصفح إلى العنوان السابق ستُظهر الطرفية الرسالة التالية: لقد مُرِّر المعامل id إلى التطبيق، لكن التابع find لم يجد ما يتطابق معه. وللتعمق في تقصي مصدر الخطأ زرعنا الأمر console.log ضمن الدالة التي تُمرَّر كمعامل للتابع find. وكان علينا لتنفيذ ذلك إعادة كتابة الدالة السهمية بشكلها الموسع واستخدام عبارة return في نهايتها: app.get('/api/notes/:id', (request, response) => { const id = request.params.id const note = notes.find(note => { console.log(note.id, typeof note.id, id, typeof id, note.id === id) return note.id === id }) console.log(note) response.json(note) }) سيطبع لنا الأمر console.log (معرف الملاحظة، نوعه، المتغير id، نوعه، هل هناك تطابق). وعندما نتوجه مجددًا نحو عنوان الملاحظة عبر المتصفح، ستُطبع عبارة مختلفة على الطرفية مع كل استدعاء للدالة: 1 'number' '1' 'string' false 2 'number' '1' 'string' false 3 'number' '1' 'string' false يبدو أن سبب المشكلة قد توضّح الآن. إن المتغيّر id يضم قيمة نصية هي "1"، بينما يحمل معرف الملاحظة قيم صحيحة. حيث يعتبر عامل المساواة الثلاثي === في JavaScript وبشكل افتراضي أن القيم من أنواع مختلفة غير متساوية. لنصحح الخطأ بتحويل قيمة المتغير id إلى عدد: app.get('/api/notes/:id', (request, response) => { const id = Number(request.params.id) const note = notes.find(note => note.id === id) response.json(note) }) لقد تم الأمر! لاتزال هناك مشكلة أخرى في التطبيق. فلو بحثنا عن ملاحظة معّرفها غير موجود أصلًا، سيجيب الخادم كالتالي: يعيد الخادم رمز الحالة 200، وهذا يعني أن الاستجابة قد تمت بنجاح. وطبعًا لا توجد هناك بيانات لإعادتها طالما أن قيمة ترويسة "طول المحتوى" هي 0. يمكن التحقق من ذلك أيضًا عبر المتصفح. إن السبب الكامن خلف هذا السلوك، هو أن المتغير note سيأخذ القيمة undefined إن لم نحصل على تطابق. وبالتالي لابد من التعامل مع هذه المشكلة على الخادم الذي يجب أن يعيد رمز الحالة not found 404 بدلًا من 200. لنعدل الشيفرة كالتالي: app.get('/api/notes/:id', (request, response) => { const id = Number(request.params.id) const note = notes.find(note => note.id === id) if (note) { response.json(note) } else { response.status(404).end() } }) عندما لا تكون هناك بيانات لإعادتها نستخدم التابع status لإعداد حالة التطبيق، والتابع end للاستجابة على الطلب دون إرسال أية بيانات. تظهر لك العبارة الشرطية if أن كل كائنات JavaScript محققة (تعيد القيمة المنطقية "صحيح" في عمليات الموازنة)، بينما يعتبر الكائن undefiend خاطئ. سيعمل التطبيق الآن، وسيرسل رمز الحالة الصحيح إن لم يعثر على الملاحظة المطلوبة. لن يعرض التطبيق شيئًا على الصفحة كغيره من التطبيقات عندما تحاول الوصول إلى مورد غير موجود. وهذا في الواقع أمر طبيعي، فلا حاجة لعرض أي شيء على المتصفح طالما أن REST واجهة معدة للاستخدام برمجيًا، وسيكون رمز الحالة الذي يعيده التطبيق هو كل ما نحتاجه. حذف الموارد لنضف مسارًا لحذف مورد محدد. يتم ذلك من خلال الطلب HTTP-DELETE إلى موقع المورد: app.delete('/api/notes/:id', (request, response) => { const id = Number(request.params.id) notes = notes.filter(note => note.id !== id) response.status(204).end() }) إن نجحت عملية الحذف، أي أن المورد وُجد وحُذف، سيجيب التطبيق على الطلب برمز الحالة no content 204 دون إعادة أية بيانات. لا يوجد رمز حالة محدد لإعادته عند محاولة حذف مورد غير موجود، لذلك ولتبسيط الأمر سنعيد الرمز 204 في الحالتين. اختبار التطبيقات باستخدام Postman كيف نتأكد أن عملية حذف المورد قد تمت فعلًا؟ من السهل إجراء طلب HTTP-GET من خلال المتصفح والتأكد. لكن كتابة شيفرة اختبار ليست الطريقة الأنسب دائمًا. ستجد العديد من الأدوات الجاهزة لتسهيل الاختبارات على الواجهة الخلفية. فمنها على سبيل المثال curl وهو برنامج سطر أوامر أشرنا إليه في القسم السابق، لكننا سنستخدم هنا Postman للقيام بهذه المهمة. إذًا لنثبت Postman: من السهل جدًا استخدام Postman في حالتنا هذه، إذ يكفي أن تختار موقعا وطلب HTTP مناسب (DELETE في حالتنا). يستجيب خادم الواجهة الخلفية بشكل صحيح كما يبدو. فلو أرسلنا إلى الموقع http://localhost:3001/api/notes طلب HTTP-GET سنجد أن الملاحظة التي معرّفها 2 غير موجودة ضمن قائمة الملاحظات، فقد نجحت عملية الحذف. ستعود قائمة الملاحظات إلى وضعها الأصلي عند إعادة تشغيل التطبيق، لأن الملاحظات قد حُفِظت في ذاكرة الجهاز فقط. عميل REST في بيئة التطوير Visual Studio Code إن كنت تستخدم Visual Studio Code في تطوير التطبيقات يمكنك تثبيت الإضافة REST client واستعمالها بدلًا من Postman حيث ننشئ مجلدًا يدعى requests عند جذر التطبيق، ثم نحفظ كل طلبات REST client في ملف ينتهي باللاحقة rest. ونضعه في المجلد السابق. لننشئ الآن الملف getallnotes.rest ونعرّف فيه كل طلبات HTTP التي تحضر الملاحظات: بالنقر على النص Send Requests، سينفذ REST client طلبات HTTP وستظهر استجابة الخادم في نافذة المحرر. استقبال البيانات سنوسع التطبيق بحيث نغدو قادرين على إضافة ملاحظة جديدة إلى الخادم. تنفذ العملية باستخدام طلب HTTP-POST إلى العنوان http://localhost:3001/api/notes، حيث تُرسل المعلومات عن الملاحظة الجديدة بصيغة JSON ضمن جسم الطلب. وحتى نصل إلى معلومات الملاحظة بسهولة سنحتاج إلى مفسّر JSON الذي تقدمه express ويستخدم عبر تنفيذ الأمر (()app.use(express.json. لنستخدم مفسّر JSON ولنضف معالج حدث أوّلي للتعامل مع طلبات HTTP-POST: const express = require('express') const app = express() app.use(express.json()) //... app.post('/api/notes', (request, response) => { const note = request.body console.log(note) response.json(note) }) يمكن لدالة معالج الحدث الوصول إلى البيانات من خلال الخاصية body للكائن request. لكن من غير استخدام مفسر JSON ستكون هذه الخاصية غير معرّفة undefined. فوظيفة المفسر استخلاص بيانات JSON من الطلب وتحويلها إلى كائن JavaScript يرتبط مع الخاصية body للكائن requestوذلك قبل أن تُستدعى الدالة التي تتعامل مع مسار الطلب. حتى هذه اللحظة لا يفعل التطبيق شيئًا سوى استقبال البيانات وطباعتها على الطرفية ثم إعادة إرسالها كاستجابة للطلبات. قبل أن نضيف بقية الشيفرة إلى التطبيق، لنتأكد باستخدام Postman أن الخادم قد استقبل البيانات بالفعل. لكن يجب الانتباه إلى تعريف البيانات التي أرسلناها ضمن جسم الطلب بالإضافة إلى موقع المورد: يطبع البرنامج البيانات التي أرسلناها ضمن الطلب على الطرفية: ملاحظة: ابق الطرفية التي تُشغّل التطبيق مرئية بالنسبة لك طيلة فترة العمل مع الواجهة الخلفية. ستساعدنا Nodemon على إعادة تشغيل التطبيق عند حدوث أية تغييرات في الشيفرة. إذا ما دققت في ما تعرضه الطرفية، ستلاحظ مباشرة وجود أخطاء في التطبيق: من المفيد جدًا التحقق باستمرار من الطرفية لتتأكد من أن كل شيء يسير كما هو متوقع ضمن الواجهة الخلفية وفي مختلف الحالات، كما فعلنا عندما أرسلنا البيانات باستخدام الطلب HTTP-POST. ومن الطبيعي استخدام الأمر console.log في مرحلة التطوير لمراقبة الوضع. من الأسباب المحتملة لظهور الأخطاء هو الضبط الخاطئ لترويسة "نوع المحتوى" في الطلبات. فقد ترى الخطأ التالي مع Postman إذا لم تعرّف نوع جسم الطلب بشكل صحيح: حيث عُرّف نوع المحتوى على أنه text/plain (نص أو فارغ): ويبدو أن الخادم مهيأ لاستقبال مشروع فارغ فقط: لن يتمكن الخادم من تفسير البيانات بشكل صحيح دون وجود القيمة الصحيحة في الترويسة. ولن يخمن حتى صيغة البيانات نظرًا للكمية الهائلة من أنواع المحتوى التي قد تكونها البيانات. إن كنت قد ثبتت VS REST client ستجد أن الطلب HTTP-POST سيرسل بمساعدة REST client كما يلي: حيث أنشأنا الملف create_note.rest وصغنا الطلب كما يوصي توثيق البرنامج. يتمتع REST client بميزة على Postman بأن التعامل يدويًا مع الطلبات متاح في المجلد الجذري للمشروع، ويمكن توزيعها إلى أعضاء فريق التطوير. بينما وإن كان Postman سيسمح بحفظ الطلبات، لكن ستغدو الأمور فوضوية وخاصة إذا استُخدم في تطوير عدة مشاريع غير مترابطة. ملاحظات جانبية مهمة قد تحتاج عند تنقيح التطبيقات إلى معرفة أنواع الترويسات التي ترافق طلبات HTTP. يعتبر استخدام التابع get العائد للكائن request، إحدى الطرق التي تسمح بذلك. حيث يستخدم التابع للحصول على قيمة ترويسة محددة، كما يمتلك الكائن request الخاصية headers التي تحوي كل الترويسات الخاصة بطلب محدد. قد تحدث الأخطاء عند استخدام VS REST client لو أضفت سطرًا فارغًا بين السطر الأعلى والسطر الذي يعرّف ترويسات HTTP. حيث يفسر البرنامج ذلك بأن كل الترويسات قد تُركَت فارغة، وبالتالي لن يعلم خادم الواجهة الخلفية أن البيانات التي استقبلها بصيغة JSON. يمكنك أن ترصد ترويسة "نوع المحتوى" المفقودة إذا ما طبعت كل ترويسات الطلب على الطرفية باستخدام الأمر (console.log(request.headers. لنعد الآن إلى تطبيقنا، طالما تأكدنا أن الخادم قد استقبل البيانات الواردة إليه بالشكل الصحيح، وقد حان الوقت لإتمام معالجة الطلب: app.post('/api/notes', (request, response) => { const maxId = notes.length > 0 ? Math.max(...notes.map(n => n.id)) : 0 const note = request.body note.id = maxId + 1 notes = notes.concat(note) response.json(note) }) نحتاج إلى id فريد للملاحظة الجديدة وللقيام بذلك علينا أن نجد أكبر قيمة id تحملها قائمة الملاحظات ونسند قيمتها للمتغير maxId. هكذا سيكون id الملاحظة الجديدة هو maxId+1. في واقع الأمر، حتى لو استخدمنا هذا الأسلوب حاليًّا، فلا ننصح به، وسنتعلم أسلوبًا أفضل قريبًا. الشيء الآخر أن تطبيقنا بشكله الحالي يعاني مشكلة مفادها أن طلب HTTP-POST يمكن أن يُستخدَم لإضافة كائن له خصائص غير محددة. لذلك سنحسن التطبيق بأن لا نسمح لقيمة الخاصية content أن تكون فارغة، وسنعطي الخاصيتين important وdate قيمًا افتراضية، وسنهمل بقية الخصائص: const generateId = () => { const maxId = notes.length > 0 ? Math.max(...notes.map(n => n.id)) : 0 return maxId + 1 } app.post('/api/notes', (request, response) => { const body = request.body if (!body.content) { return response.status(400).json({ error: 'content missing' }) } const note = { content: body.content, important: body.important || false, date: new Date(), id: generateId(), } notes = notes.concat(note) response.json(note) }) وضعنا الشيفرة التي تولد المعرف id للملاحظة الجديدة ضمن الدالة generateId. فعندما يستقبل الخادم بيانات قيمة الخاصية content لها غير موجودة، سيجيب على الطلب برمز الحالة 400 bad request: if (!body.content) { return response.status(400).json({ error: 'content missing' }) } لاحظ أن استخدام return حيوي جدًا لسير العملية، فلولاها ستُنفّذ الشيفرة تباعًا وستُحفظ الملاحظة ذات الصياغة الخاطئة. وبالطبع ستحفظ الملاحظة التي تحمل فيها الخاصية content قيمة، بما تحويه من بيانات. ونذكّر أيضًا أننا تحدثنا عن ضرورة توليد زمن إنشاء الملاحظة من قبل الخادم وليس جهاز الواجهة الأمامية، لذلك ولّده الخادم. إن لم تحمل الخاصية important قيمة فسنعطيها القيمة الإفتراضية false. لاحظ الطريقة الغريبة التي استخدمناها: important: body.important || false, وتفسر الشيفرة السابقة كالتالي: إن حملت الخاصية important للبيانات المستقبلة قيمة ستعتمد العبارة السابقة هذه القيمة وإلا ستعطيها القيمة false. أمَّا عندما تكون قيمة الخاصية important هي false عندها ستعيد العبارة التالية body.important || false القيمة false بناء على قيمة الطرف الأيمن من العبارة. ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-1 على موقع GitHub. يحوي المسار الرئيسي للمستودع كما ستلاحظ شيفرات للنسخ التي سنطورها لاحقًا، لكن النسخة الحالية في المسار part3-1. إن نسخت المشروع، فتأكد من تنفيذ الأمر npm install قبل أن تشغل التطبيق باستخدام إحدى التعليمتين npm start أو npm run dev. ملاحظة أخرى قبل الشروع بحل التمارين، سيبدو الشكل الحالي لدالة توليد المعرفات IDs كالتالي: const generateId = () => { const maxId = notes.length > 0 ? Math.max(...notes.map(n => n.id)) : 0 return maxId + 1 } يحتوي جسم الدالة سطرًا يبدو غريبًا نوعًا ما: Math.max(...notes.map(n => n.id)) ينشئ التابع (notes.map(n=>n.id مصفوفة جديدة تضم كل قيم المعرفات id للملاحظات. ثم يعيد التابع Math.max أعلى قيمة من القيم التي مُرّرت إليه. وطالما أن نتيجة تطبيق الأمر (notes.map(n=>n.ids ستكون مصفوفة، فمن الممكن تمريرها مباشرة كمعامل للتابع Math.max. وتذكّر أنه يمكن فصل المصفوفة إلى عناصرها باستخدام عامل النشر (…). التمارين 3.1 - 3.6 ملاحظة: من المفضل أن تنفذ تمارين هذا القسم ضمن مستودع خاص على git، وأن تضع الشيفرة المصدرية في جذر المستودع، وإلا ستواجه المشاكل عندما تصل للتمرين 3.10. ملاحظة: لا ننفذ حاليًا مشروعًا للواجهة الأمامية باستخدام React، ولم ننشئ المشروع باستخدام create-react-app. لذلك هيئ المشروع باستعمال الأمر npm init كما شرحنا في هذا الفصل. توصية مهمة: ابق نظرك دائمًا على الطرفية التي تُشغِّل تطبيقك عندما تطور التطبيقات للواجهة الخلفية. 3.1 دليل الهاتف للواجهة الخلفية: الخطوة 1 اكتب تطبيقًا باستخدام Node يعيد قائمة من مدخلات دليل هاتف مكتوبة مسبقًا وموجودة في الموقع http://localhost:3001/api/persons: لاحظ أنه ليس للمحرف '/' في المسار api/persons أي معنى خاص بل هو محرف كباقي المحارف في النص. يجب أن يُشّغل التطبيق باستخدام الأمر npm start. كما ينبغي على التطبيق أن يعمل كنتيجة لتنفيذ الأمر npm run dev وبالتالي سيكون قادرًا على إعادة تشغيل الخادم عند حفظ التغيرات التي قد تحدث في ملف الشيفرة المصدرية. 3.2 دليل الهاتف للواجهة الخلفية: الخطوة 2 أنشئ صفحة ويب عنوانها http://localhost:3001/info بحيث تبدو مشابهةً للصفحة التالية: ستظهر الصفحة الوقت الذي استقبل فيه الخادم الطلب، وعدد مدخلات دليل الهاتف في لحظة معالجة الطلب. 3.3 دليل الهاتف للواجهة الخلفية: الخطوة 3 أضف إمكانية إظهار معلومات مُدخل واحد من مُدخلات دليل الهاتف. يجب أن يكون موقع الحصول على بيانات الشخص الذي معرفه 5 هو http://localhost:3001/api/persons/5. إن لم يكن المُدخَل ذو المعرف المحدد موجودًا، على الخادم أن يستجيب معيدًا رمز الحالة المناسب. 3.4 دليل الهاتف للواجهة الخلفية: الخطوة 4 أضف إمكانية حذف مدخل واحد من مُدخلات دليل الهاتف مستعملًا الطلب HTTP-DELETE إلى عنوان المدخل الذي سيُحذَف ثم اختبر نجاح العملية مستخدمًا Postman أو Visual Studio Code REST client. 3.5 دليل الهاتف للواجهة الخلفية: الخطوة 5 أضف إمكانية إضافة مدخلات إلى دليل الهاتف مستعملًا الطلب HTTP-POST إلى عنوان مجموعة المدخلات http://localhost:3001/api/persons. استعمل الدالة Math.random لتوليد رقم المعرف id للمُدخل الجديد. واحرص أن يكون مجال توليد الأرقام العشوائية كبيرًا ليقلّ احتمال تكرار المعرف نفسه لمدخلين. 3.6 دليل الهاتف للواجهة الخلفية: الخطوة 6 أضف معالج أخطاء للتعامل مع ما قد يحدث عند إنشاء مدخل جديد. حيث لا يُسمح بنجاح العملية إذا كان: اسم الشخص أو رقمه غير موجودين الاسم موجود مسبقًا في الدليل. استجب للحالتين السابقتين برمز حالة مناسب، وأعد كذلك معلومات توضح سبب الخطأ كالتالي: { error: 'name must be unique' } فكرة عن أنواع طلبات HTTP يتحدث معيار HTTP عن خاصيتين متعلقتين بأنواع الطلبات هما الأمان safety والخمول idempotence. فيجب أن يكون طلب HTTP-GET آمنًا: يعني الأمان أن تنفيذ الطلب لن يسبب تأثيرات جانبية على الخادم. ونعني بالتأثيرات الجانبية أن حالة قاعدة البيانات لن تتغير كنتيجة للطلب وأن الاستجابة ستكون على شكل بيانات موجودة مسبقًا على الخادم. لا يمكن أن نضمن أمان الطلب GET وما ذكر مجرد توصية في معيار HTTP. لكن بالتزام مبادئ التوافق مع REST في واجهة تطبيقنا، ستكون طريقة استخدام GET آمنة دائمًا. كما عرّف معيار HTTP نمطًا للطلبات هو HEAD وعلى هذا الأخير أن يكون آمنًا أيضًا. يعمل HEAD تمامًا عمل GET، إلا أنه لا يعيد سوى رمز الحالة وترويسات الاستجابة. لن يعيد الخادم جسمًا للاستجابة عندما تستخدم HEAD. وعلى كل طلبات HTTP أن تكون خاملة ماعدا POST: ويعني هذا أن التأثيرات الجانبية التي يمكن أن يسببها طلب، يجب أن تبقى كما هي، بغض النظر عن عدد المرات التي يرسل فيها الطلب إلى الخادم. فلو أرسلنا الطلب HTTP-PUT إلى العنوان api/notes/10/ حاملًا البيانات {content:"no sideeffects!", important:true} فإن الاستجابة ستكون نفسها بغض النظر عن عدد المرات التي يُرسَل فيها هذا الطلب. وكما أن الأمان في GET لا يتحقق دومًا، كذلك الخمول. فكلاهما مجرد توصيات في معيارHTTP لا يمكن ضمانها معتمدين على نوع الطلب فقط. لكن بالتزام مبادئ التوافق مع REST في واجهة تطبيقنا، سنستخدم GET، HEAD PUT، DELELTE بطريقة تحقق خاصية الخمول. أما الطلب POST فلا يعتبر آمنًا ولا خاملًا. فلو أرسلنا 5 طلبات HTTP-POST إلى الموقع /api/notes، بحيث يضم جسم الطلب البيانات {content: "many same", important: true}، ستكون النتيجة 5 ملاحظات جديدة لها نفس المحتوى. البرمجيات الوسيطة Middleware يصنف مفسّر JSON الذي تقدمه express بأنه أداة وسطية. فالبرمجيات الوسيطة (Middleware) هي دوال تستخدم لمعالجة كائنات الطلبات والاستجابات. فمفسر JSON الذي تعرفنا عليه سابقًا في هذا الفصل سيأخذ بيانات خام من جسم كائن الطلب، ثم يحولها إلى كائن JavaScript ويسندها إلى الكائن request كقيمة للخاصية body. يمكن في الواقع استخدام عدة أدوات وسطية في نفس الوقت. ستنفذ البرمجيات الوسيطة بالتتالي وفق ترتيب استخدامها من قبل express. لنضف أدوات وسطية خاصة بنا لطباعة معلومات حول كل طلب أرسل إلى الخادم. الأداة الوسطية التي سنستعملها دالة تقبل ثلاث معاملات: const requestLogger = (request, response, next) => { console.log('Method:', request.method) console.log('Path: ', request.path) console.log('Body: ', request.body) console.log('---') next() } سنجد في نهاية الدالة، دالة أخرى هي ()next مررت كمعامل لدالة الأداة الوسطية وتستدعى في نهايتها. تنقل الدالة next التحكم إلى الأداة الوسطية التالية. تستخدم الأداة الوسطية كالتالي: app.use(requestLogger) تستدعى دوال البرمجيات الوسيطة بالتتالي وفق ترتيب استخدامها من قبل express عن طريق التابع use. وانتبه إلى أن مفسر JSON سيستخدم قبل الأداة الوسطية requestLogger، وإلا فلن يهيأ جسم الكائن request عندما تُنفّذ الدالة requestLogger. يجب أيضًا تعريف دوال البرمجيات الوسيطة قبل المسارات إن أردنا تنفيذها قبل أن يُستدعى معالج الحدث الخاص بالمسار. لكن قد تكون هناك حالات معاكسة نعرّف فيها الأداة الوسطية بعد تعريف المسار، وخاصة إن أردنا لدالة الأداة الوسطية أن تُنفّذ إن لم تُعرّف أية مسارات لمعالجة طلب HTTP. لنعرف الأداة الوسطية التالية في شيفرتنا بعد تعريف المسار، لالتقاط الطلبات الموجهة إلى مسارات غير موجودة. ستعيد دالة الأداة الوسطية في هذه الحالة رسالة خطأ بصيغة JSON: const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } app.use(unknownEndpoint) ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-2 على موقع GitHub. التمارين 3.7 - 3.8 3.7 دليل هاتف للواجهة الخلفية: الخطوة 7 أضف الأداة الوسطية morgan إلى تطبيقك للمراقبة. اضبط الأداة لطباعة الرسائل على الطرفية وفقًا لنمط التهيئة tiny. لا يقدم لك توثيق Morgan كل ما تريده، وعليك قضاء بعض الوقت لتتعلم تهيئة الأداة بشكل صحيح. وهذا بالطبع حال التوثيقات جميعها، فلا بد من فك رموز هذه التوثيقات. ثبت Morgan كأي مكتبة أخرى باستخدام الأمر npm install. واستخدمها تمامًا كما نستخدم أية أداة وسطية بتنفيذ الأمر app.use. 3.8 دليل هاتف للواجهة الخلفية: الخطوة 8 * هيئ Morgan حتى تعرض لك أيضًا بيانات الطلبات HTTP-POST: قد يكون طباعة بيانات الطلبات خطرًا طالما أنها تحتوي على معلومات حساسة قد تخرق بعض قوانين الخصوصية لبعض البلدان مثل الاتحاد الأوروبي، أو قد تخرق معيار الأعمال. لا تهتم لهذه الناحية في تطبيقنا، لكن عندما تنطلق في حياتك المهنية، تجنب طباعة معلومات حساسة. يحمل التمرين بعض التحديات، علمًا أن الحل لا يتطلب كتابة الكثير من الشيفرة. يمكنك إنجاز التمرين بطرائق عدة، منها استخدام التقنيات التالية: إنشاء مفاتيح جديدة (new token) JSON.stringify ترجمة -وبتصرف- للفصل Node.js, Express من سلسلة Deep Dive Into Modern Web Development
  19. يبدو مظهر تطبيقنا الحالي متواضعًا. لقد طلبنا منك سابقًا في التمرين 0.2 أن تطلع على دورة تدريبة تتعلق بالتنسيقات المتتالية التي سنستخدمها حاليًا. لنلقي نظرة على الطريقة التي نضيف فيها تنسيقات لتغيير مظهر التطبيق قبل الانتقال إلى القسم التالي. هناك طرق كثيرة لتنفيذ ذلك، سنلقي عليها نظرة لاحقًا، لكن في البداية سنضيف شيفرة CSS إلى تطبيقنا بالطريقة التقليدية في ملف منفصل واحد دون استخدام معالجة تحضيرية CSS preprocessor (وهذا ليس صحيحًا تمامًا كما سنرى). سننشئ ملفًا باسم index.css ضمن المجلد src ثم نضيفه إلى التطبيق باستخدام تعليمة الإدراج import: import './index.css' لنضف بعض قواعد التنسيق إلى الملف: h1 { color: green; } تتألف قاعدة التنسيق من المحدد selector والتصريح declaration. يعرّف المحدد العنصر الذي تطبق عليه القاعدة. يطبق المحدد في المثال السابق على عنصر العنوان H1 في التطبيق. بينما ينص التصريح على تغيير خاصية اللون إلى الأخضر. يمكن لقاعدة واحدة من قواعد التنسيق أن تغير أي عدد من الخصائص، لنغير إذًا تنسيق الخط ليصبح مائلًا: h1 { color: green; font-style: italic; } هناك العديد من الطرق لاختيار العناصر التي نريد تنسيقها باستخدام أنواع مختلفة من محددات التنسيق. فإن أردنا مثًلا أن نغير تنسيق كل ملاحظة من الملاحظات، يمكننا اختيار المحدد li طالما أن الملاحظات ستعرض على شكل عنصر li: const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' : 'make important'; return ( <li> {note.content} <button onClick={toggleImportance}>{label}</button> </li> ) } لنضف القاعدة التالية إلى ملف التنسيق: li { color: grey; padding-top: 3px; font-size: 15px; } إن اختيار قواعد التنسيق لتستهدف العناصر سيسبب بعض المشاكل. فلو احتوى تطبيقنا على عناصر li أخرى ستطبق القاعدة عليها أيضًا. ولتطبيق التنسيق على الملاحظات حصرًا، من الأفضل استخدام محددات الأصناف class selectors. تعّرف محددات الأصناف في HTML القياسية كقيمة للصفة class: <li class="note">some text...</li> بينما في React، علينا أن نستخدم الصفة className بدلًا من الصفة class. لنجري الآن بعض التغييرات على المكوِّن Note: 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> ) } لاحظ كيف عرفنا محدد الصنف ضمن الصفة classname: .note { color: grey; padding-top: 5px; font-size: 15px; } لن تتأثر الآن عناصر li الأخرى الآن بهذه القاعدة بل فقط العناصر التي تظهر الملاحظات. رسائل خطأ بمظهر أفضل استخدمنا سابقا التابع alert لإظهار رسالة خطأ عندما يحاول المستخدم تغيير أهمية ملاحظة محذوفة. لننشئ الآن مكوِّنًا خاصًا بإظهار رسائل الخطأ: const Notification = ({ message }) => { if (message === null) { return null } return ( <div className="error"> {message} </div> ) } لن يصيّر شيء على الشاشة إن كانت رسالة الخطأ فارغة، بينما ستصيّر الرسالة في بقية الحالات داخل عنصر div. لنضف قطعة حالة تدعى errorMessage إلى المكوِّن App. ونعطها رسالة خطأ ما كقيمة ابتدائية لنتمكن من اختبارها مباشرة: const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState('some error happened...') // ... return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> <div> <button onClick={() => setShowAll(!showAll)}> show {showAll ? 'important' : 'all' } </button> </div> // ... </div> ) } لنضف قاعدة تنسيق جديدة باسم error تناسب رسالة الخطأ: .error { color: red; background: lightgrey; font-size: 20px; border-style: solid; border-radius: 5px; padding: 10px; margin-bottom: 10px; } سنضيف الآن آلية لظهور رسالة الخطأ، وذلك بتعديل شيفرة الدالة toggleImportanceOf على النحو التالي: const toggleImportanceOf = id => { const note = notes.find(n => n.id === id) const changedNote = { ...note, important: !note.important } noteService .update(changedNote).then(returnedNote => { setNotes(notes.map(note => note.id !== id ? note : returnedNote)) }) .catch(error => { setErrorMessage( `Note '${note.content}' was already removed from server` ) setTimeout(() => { setErrorMessage(null) }, 5000) setNotes(notes.filter(n => n.id !== id)) }) } عندما يقع الخطأ، نضيف وصفًا لهذا الخطأ ضمن قطعة الحالة errorMessage. وفي نفس اللحظة سيعمل مؤقت يعيد قيمة الحالة إلى null (لا شيء) بعد خمس ثوان. ستبدو النتيجة كالتالي: يمكن الحصول على شيفرة التطبيق بوضعه الحالي في الفرع part2-7 على github. التنسيق ضمن السياق تعطي React الإمكانية لتنسيق العناصر مباشرة ضمن سياق الشيفرة أو ما يدعى التنسيق ضمن السياق inline styles. والغاية من ذلك أن نزوّد كل مكوِّن في React بمجموعة من التنسيقات المعرفة داخل كائن JavaScript من خلال الصفة style. تعرّف التنسيقات بشكل مختلف قليلًا في JavaScript عما هي عليه في ملف CSS. فلو أردنا أن يظهر عنصر ما باللون الأخضر وأن يكون مائلًا حجمه 16 بيكسل، سنجد أن هذه القاعدة ستكتب في CSS بالشكل: { color: green; font-style: italic; font-size: 16px; } بينما في React كتنسيق مباشر في المكان ستكون كالتالي: { color: 'green', fontStyle: 'italic', fontSize: 16 } لاحظ كيف عرّفت كل خاصة تنسيق بشكل مستقل ضمن كائن JavaScript، وأن القيم العددية المقدّرة بالبيكسل يمكن تعريفها ببساطة كأعداد صحيحة. أحد الاختلافات الرئيسة بين الأسلوبين هو أن كتابة الخصائص بطريقة أسياخ الشواء (kebab case) المتبعة في CSS ستتغير إلى طريقة سنام الجمل (camelCase) في JavaScript. سنضيف أخيرًا كتلة سفلية لتطبيقنا بإنشاء مكوِّن جديد يدعى Footer وننسقه في مكانه كالتالي: const Footer = () => { const footerStyle = { color: 'green', fontStyle: 'italic', fontSize: 16 } return ( <div style={footerStyle}> <br /> <em>Note app, Department of Computer Science, University of Helsinki 2020</em> </div> ) } const App = () => { // ... return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> // ... <Footer /> </div> ) } يأتي التنسيق ضمن السياق بمحدودية معينة. فلا يمكن استخدام أصناف الحالات الزائفة pseudo-classes مباشرة على سبيل المثال. إنّ استخدام التنسيق ضمن السياق وبعض الطرق الأخرى في تعريف التنسيقات ضمن مكوِّنات React تتعارض مع الأعراف المتبعة سابقًا. فقد اعتبر فصل التنسيقات CSS عن المحتوى HTML وعن الوظائف JavaScript هو العمل المثالي. وكان الهدف من ذلك وفقًا لأفكار المدرسة التقليدية، هو كتابة كل منها في ملفه الخاص. تعارض فلسفة React تمامًا الفكرة السابقة من منطلق أن عملية الفصل في ملفات مختلفة لن تتماشى مع التطبيقات الأكبر. فتقسيم التطبيقات من وجهة نظر React يجب أن يكون على أساس المهام الوظيفية للمكوِّنات التي تمثل التطبيق ككل. فالمكوِّنات هي من يعطي التطبيق قدرته الوظيفية. حيث تستخدم المكوِّنات HTML لبناء المحتوى وتستخدم دوال JavaScript لتنفيذ وظائف التطبيق بالإضافة إلى تنسيق المكوِّنات. والهدف من كل ذلك بناء مكوِّن مستقل بحد ذاته يمكن إعادة استخدامه قدر الإمكان. يمكن الحصول على شيفرة التطبيق بوضعه الحالي في الفرع part2-8 على github. التمارين 2.19- 2.20 2.19 دليل الهاتف: الخطوة 11 استخدم المثال في فقرة رسائل خطأ بمظهر أفضل من هذا الفصل، كدليل يساعدك في إظهار تنبيه لعدة ثوان، بعد تنفيذ عملية بشكل ناجح (إضافة اسم أو رقم). 2.20 دليل الهاتف: الخطوة 12 * افتح تطبيقك ضمن متصفحين. لاحظ إن حذفت شخصًا في المتصفح الأول، ثم أردت بعدها بقليل تغيير رقم الشخص نفسه في المتصفح الثاني ستظهر لك رسالة الخطأ التالية: أصلح هذه المشكلة بالاستفادة من المثال في فقرة الوعود والأخطاء في القسم 2. عدّل تطبيقك بحيث تظهر رسالة خطأ للمستخدم تخبره بفشل العملية. وتذكر أن تظهر رسالتي النجاح والفشل بشكلين مختلفين. ملاحظة: يجب أن تظهر رسالة الخطأ حتى لو تمت معالجة الخطأ. وهكذا نصل إلى التمرين الأخير في هذا القسم، وقد حان الوقت لتسليم الحلول على GitHub. لا تنسى أن تشير إلى التمارين على أنها منجزة ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل Adding styles to react app من سلسلة Deep Dive Into Modern Web Development
  20. من الطبيعي عندما ننشئ ملاحظات جديدة في تطبيقنا، أن نحفظ هذه الملاحظات على الخادم. تَعتبِر حزمة خادم JSON نفسها على أنها واجهة REST أو RESTful كما ورد في توثيقها: لا يتطابق توصيف خادم JSON تمامًا مع تعريف واجهة التطبيقات REST، كما هي حال معظم الواجهات التي تدّعي بأنها متوافقة تمامًا مع REST. سنطلع أكثر على REST في القسم التالي من المنهاج، لكن من المهم أن نتعلم في هذه المرحلة بعض المعايير التوافقية المستخدمة من قبل خادم JSON والواجهة REST بشكل عام. وسنلقي نظرة خاصة على الاستخدام التوافقي للمسارات (Routes) وهي الروابط وطلبات HTTP وفق مفاهيم REST. واجهة التطبيقات REST نشير إلى الكائنات التي تمثل بيانات منفردة -كالملاحظات في تطبيقنا- باسم الموارد (resources) وفقًا لمصطلحات REST. ولكل مورد مكانه المحدد والوحيد الذي يرتبط به (URL الخاص به). وبناء على معيار عام يعتمده خادم JSON، سنتمكن من الحصول على ملاحظة واحدة من الملاحظات من المورد الموجود في الموقع notes/3. حيث يمثل الرقم 3 المعرِّف الفريد للمورد. ومن ناحية أخرى سيمثل موقع المورد notes تجمّع موارد (resource collection) يضم كل الملاحظات. تُحضَر الموارد من الخادم عن طريق الطلبات HTTP-GET. فالطلب HTTP-GET المرسل إلى الموقع notes/3 سيعيد الملاحظة التي معرِّفها 3. بينما لو أُرسل نفس الطلب إلى الموقع notes سيعيد لائحة بالملاحظات الموجودة. يُنشأ مورد جديد لتخزين ملاحظة بإرسال طلب HTTP-POST إلى الموقع notes، وفقًا لمعيار REST الذي يتوافق معه خادم JSON. ترسل البيانات إلى المورد الجديد "الملاحظة الجديدة" ضمن جسم الطلب. يفرض خادم JSON إرسال جميع البيانات بتنسيق JSON. ويقضي ذلك عمليًا أن تكون البيانات على شكل نص منسق بشكل صحيح وأن يضم الطلب ترويسة "نوع المحتوى" بداخلها القيمة application/json. إرسال البيانات إلى الخادم سنعدّل معالج الحدث المسؤول عن إنشاء ملاحظة جديدة كالتالي: addNote = event => { event.preventDefault() const noteObject = { content: newNote, date: new Date(), important: Math.random() < 0.5, } axios .post('http://localhost:3001/notes', noteObject) .then(response => { console.log(response) })} لقد أنشأنا كائنًا جديدًا يحتوي بيانات الملاحظة لكننا أغفلنا الخاصية id، فمن الأفضل أن يولد الخادم قيم المعرّفات الفريدة للموارد id. يُرسل الكائن بعد ذلك إلى الخادم باستخدام التابع post من المكتبة axios. ثم يطبع معالج الحدث المعرّف مسبقًا استجابة الخادم على الطرفية. ستعرض الطرفية البيانات التالية عند إنشاء ملاحظة جديدة: يخزَّن مورد الملاحظة الجديدة في الخاصية data لكائن الاستجابة الذي يعيده الخادم. من المفيد أحيانًا تحرّي طلبات HTTP في النافذة Network من طرفية تطوير المتصفح الخاص بك والتي استخدمناها كثيرًا في مقال أساسيات بناء تطبيقات الويب. باستخدام المفتش inspector سنتمكن من تحري الترويسات التي ترسل عبر طلبات post فيما لو كانت هي تمامًا ما نريده، أو أنّ القيم التي تحملها صحيحة. تسنِد axios تلقائيًا القيمة "application/json" إلى ترويسة "نوع-المحتوى"، طالما أن البيانات المرسلة عبر الطلب post هي كائن JavaScript. لم تصيّر الملاحظة الجديدة على الشاشة بعد، وذلك لأننا لم نحدث حالة المكوِّنApp بعد إنشائها، لذا سنصلح الأمر: addNote = event => { event.preventDefault() const noteObject = { content: newNote, date: new Date(), important: Math.random() > 0.5, } axios .post('http://localhost:3001/notes', noteObject) .then(response => { setNotes(notes.concat(response.data)) setNewNote('') }) } تضاف الملاحظة الجديدة القادمة من الخادم إلى قائمة الملاحظات في تطبيقنا باستخدام الدالة setNotes، وبعدها تصفّر الشيفرة نموذج إنشاء الملاحظات. وتذكر أحد التفاصيل المهمة عند استخدام التابع concat، بأنه لا يغيّر المصفوفة الأصلية وبالتالي لا يغيّر حالة المكوِّن بل ينشئ نسخة عن القائمة الأصلية. سيبدأ تأثير الخادم على سلوك تطبيقنا لحظة إعادته البيانات. وسنكون مباشرة أمام تحديات جديدة منها على سبيل المثال عدم التزامن في الاتصال. لهذا ستظهر الحاجة إلى استراتيجيات جديدة للتنقيح بالإضافة إلى الطباعة على الطرفية، وغيرها من الطرق التنقيح التي تزداد أهميتها مع الوقت. إضافة إلى ذلك لابد من فهمٍ كافٍ لمبادئ بيئة تشغيل JavaScript ومكوِّنات React. فلن يكفي التخمين فقط في حل المشاكل. ومن المفيد أغلب الأحيان التحقق من حالة الخادم من خلال المتصفح: حيث يمكننا التحقق من أنّ جميع البيانات التي أرسلناها قد استقبلها الخادم. سنتعلم في القسم التالي من المنهاج كيف نتعامل بمنطقنا الخاص مع الواجهة الخلفية، وسنلقي نظرة أقرب على أدوات مهمة مثل postman التي ستساعد في تنقيح تطبيقات الواجهة الخلفية. يكفينا حاليًا تحرّي حالة الخادم من خلال المتصفح. يمكن الحصول على شيفرة التطبيق بوضعه الحالي في الفرع part2-5 على github. تغيير مؤشر أهمية الملاحظة لنضف زرًا إلى كل ملاحظة بحيث نتمكن من تغيير حالتها بشكل مستمر، ستتغير الشيفرة في المكوِّن Note كما يلي: const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' : 'make important' return ( <li> {note.content} <button onClick={toggleImportance}>{label}</button> </li> ) } لقد أضفنا عنصر button إلى المكوِّن، وعيّنا الدالة toggleImportance كمعالج لحدث النقر على الزر، ومررناه إلى المكوِّن من خلال الخصائص. يعرّف المكوِّن App نسخة ابتدائية من معالج الحدث toggleImportanceOf، ثم يمرره إلى كل مكوِّن Note: const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) // ... const toggleImportanceOf = (id) => { console.log('importance of ' + id + ' needs to be toggled') } // ... return ( <div> <h1>Notes</h1> <div> <button onClick={() => setShowAll(!showAll)}> show {showAll ? 'important' : 'all' } </button> </div> <ul> {notesToShow.map((note, i) => <Note key={i} note={note} toggleImportance={() => toggleImportanceOf(note.id)} /> )} </ul> // ... </div> ) } لاحظ في الشيفرة السابقة كيف ستحصل كل ملاحظة على معالج حدث فريد خاص بها، ذلك أن قيمة id لكل ملاحظة هي قيمة فريدة. فلو افترضنا أن قيمة id لملاحظة هي 3، ستكون دالة معالج الحدث الخاصة بها والتي تعيدها الدالة (toggleImportance(note.id من الشكل: () => { console.log('importance of 3 needs to be toggled') } وتذكر هنا أننا استخدمنا أسلوبًا مشابهًا للغة Java في طباعة النص على الطرفية، وذلك بجمع السلاسل النصية: console.log('importance of ' + id + ' needs to be toggled') كما يمكنك استخدام القالب النصي الذي أضيف مع ES6، لكتابة سلسلة نصية مماثلة وبطريقة أجمل: console.log(`importance of ${id} needs to be toggled`) إذا وضعنا عبارة JavaScript بين قوسين معقوصين وقبلها رمز الدولار($) ستُنفَّذ هذه العبارة ضمن السلسلة النصية وتُطبَع قيمتها. وانتبه جيدًا إلى علامات التنصيص المستخدمة في القالب النصي (``` نستطيع التعديل على الملاحظة المخزنة على خادم JSON بطريقتين مختلفتين، وذلك بإرسال طلبات HTTP إلى الموقع الفريد للملاحظة. إذ بالإمكان أن نستبدل الملاحظة بالكامل من خلال الطلب HTTP-PUT، أو أن نغير بعض حقولها من خلال الطلب HTTP-PATCH. سيبدو الشكل النهائي لدالة معالج الحدث كالتالي: const toggleImportanceOf = id => { const url = `http://localhost:3001/notes/${id}` const note = notes.find(n => n.id === id) const changedNote = { ...note, important: !note.important } axios.put(url, changedNote).then(response => { setNotes(notes.map(note => note.id !== id ? note : response.data)) }) } يمتلئ جسم الدالة السابقة بالتفاصيل الهامة. حيث يعرّف السطر الأول الموقع الفريد لكل ملاحظة بناء على قيمة id. ويُستخدَم التابع find لإيجاد الملاحظة التي نرغب في تعديلها ثم تُسند قيمته إلى المتغير note. ثم ننشئ بعد ذلك كأئنًا جديدًا يمثل نسخة تطابق الملاحظة الأصلية باستثناء ما يتعلق بخاصية "الأهمية". قد يبدو لك إنشاء كائن جديد بأسلوب الكائن المنشور غريبًا بعض الشيء: const changedNote = { ...note, important: !note.important } تنشئ التعليمة {note...} كائنًا جديدًا يحمل نسخًا عن جميع خصائص (حقول) الكائن note. وعند وضع الخصائص ضمن قوسين معقوصين بعد نشر الكائن على الشكل التالي {note, important: true …}، ستأخذ الخاصة important للكائن الجديد القيمة true. لكن انتبه إلى مثالنا السابق بأننا أخذنا القيمة المعاكسة للخاصة important. هناك نقاط أخرى لابد من الإشارة إليها، فلماذا مثلًا أنشأنا نسخة عن الملاحظة التي نريد تعديلها إن كان بالإمكان تنفيذ ذلك كما يلي: const note = notes.find(n => n.id === id) note.important = !note.important axios.put(url, note).then(response => { // ... لا نفضل استخدام هذا الأسلوب لأن المتغير note يمثل مرجعًا إلى أحد عناصر المصفوفة notes في حالة المكوِّن، وتذكّر أن لا تغيّر حالة المكوِّن بشكل مباشر في تطبيقات React. كما لن يحمل الكائن الجديد الناتج عن تغير الملاحظة بهذه الطريقة أي تغيير فعلي وهذا ما يسمى النسخ السطحي. ويعني هذا أنّ قيم النسخة الجديدة هي نفسها قيم الكائن القديم. حتى لو كانت قيم الكائن القديم هي كائنات بحد ذاتها، ستشير نسخها الموجودة في الكائن الجديد إلى نفس الكائنات القديمة. بالعودة إلى تفاصيل دالة معالج حدث تغيير أهمية الملاحظة، ستُرسل أخيرًا الملاحظة الجديدة إلى الخادم على شكل طلب HTTP-PUT، وتُستبدل الملاحظة القديمة. تضبط دالة الاستدعاء (إعادة النداء) حالة الملاحظات في المكوِّن من جديد على قيم المصفوفة التي تضم جميع الملاحظات ماعدا تلك التي تم استبدالها بالنسخة الجديدة التي يعيدها الخادم: axios.put(url, changedNote).then(response => { setNotes(notes.map(note => note.id !== id ? note : response.data)) }) وينجز ذلك باستخدام التابع map: notes.map(note => note.id !== id ? note : response.data) يعيد التابع map مصفوفة جديدة عن طريق ربط كل عنصر من المصفوفة القديمة بعنصر من المصفوفة الجديدة. لكن إنشاء المصفوفة الجديدة في مثالنا كان شرطيًا، فإن تحقق الشرط التالي note.id !==id ننقل العنصر من المصفوفة القديمة إلى الجديدة، وإن لم يتحقق ستحل محلها الملاحظة الجديدة التي يعيدها الخادم. قد تبدو حيلة map هذه غريبةً قليلًا، لكنها تستحق التوقف عندها وفهمها لأننا سنستخدمها مرات عدة لاحقًا. نقل تعليمات الاتصال مع الواجهة الخلفية إلى وحدة منفصلة لقد أصبح المكوِّنApp مليئًا بالشيفرات بعد إضافة الاتصال مع الواجهة الخلفية. وانسجامًا مع مبدأ المسؤولية الفردية، وجدنا من الحكمة أن ننقل الشيفرات المتعلقة بالاتصال إلى وحدة منفصلة خاصة بها. لننشئ مجلدًا باسم services ضمن المجلد src وننشئ ضمنه الملف notes.js: import axios from 'axios' const baseUrl = 'http://localhost:3001/notes' const getAll = () => { return axios.get(baseUrl) } const create = newObject => { return axios.post(baseUrl, newObject) } const update = (id, newObject) => { return axios.put(`${baseUrl}/${id}`, newObject) } export default { getAll: getAll, create: create, update: update } تعيد الوحدة كائنًا من ثلاث دوال getAll وcreate و update، بالإضافة إلى خصائصه التي تتعامل مع الملاحظات. تعيد تلك الدوال وبشكل مباشر الوعود الناتجة عن تنفيذ توابع المكتبة axios والمتعلقة بالاتصال مع الخادم. يدرج المكوِّن App الوحدة الجديدة باستخدام التعليمة import: import noteService from './services/notes' const App = () => { يمكن استخدام الدوال مباشرة بإسنادها للمتغير noteService عند إدراج الوحدة كما يلي: const App = () => { // ... useEffect(() => { noteService .getAll() .then(response => { setNotes(response.data) }) }, []) const toggleImportanceOf = id => { const note = notes.find(n => n.id === id) const changedNote = { ...note, important: !note.important } noteService .update(id, changedNote) .then(response => { setNotes(notes.map(note => note.id !== id ? note : response.data)) }) } const addNote = (event) => { event.preventDefault() const noteObject = { content: newNote, date: new Date().toISOString(), important: Math.random() > 0.5 } noteService .create(noteObject) .then(response => { setNotes(notes.concat(response.data)) setNewNote('') }) } // ... } export default App سننقل التطبيق خطوة إضافية إلى الأمام بحيث يتلقى المكوِّن App كائنًا يحتوي على الاستجابة الكاملة للخادم على طلب HTTP في حال استخدم المكوِّن الدوال السابقة: noteService .getAll() .then(response => { setNotes(response.data) }) يستخدم المكوِّن App الخاصة response.data فقط من كائن الاستجابة. وبالتالي سيكون استخدام الوحدة مريحًا أكثر لو أمكننا أن نحصل على بيانات الاستجابة فقط بدلًا من الاستجابة كاملةً. ستبدو الشيفرة التي تنفذ ذلك على النحو: noteService .getAll() .then(initialNotes => { setNotes(initialNotes) }) يمكننا إنجاز ذلك بتغيير الشيفرة في الوحدة على النحو التالي: import axios from 'axios' const baseUrl = 'http://localhost:3001/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } const create = newObject => { const request = axios.post(baseUrl, newObject) return request.then(response => response.data) } const update = (id, newObject) => { const request = axios.put(`${baseUrl}/${id}`, newObject) return request.then(response => response.data) } export default { getAll: getAll, create: create, update: update } وهكذا فإن دوال وحدة الاتصال لن تعيد وعود توابع axios مباشرة، بل تسند الوعد إلى المتغيّر request ثم تستدعي التابع then الموافق: const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } بالنسبة للسطر الأخير من الشيفرة السابقة فهو شكل أكثر اختصارًا للشيفرة التالية: const getAll = () => { const request = axios.get(baseUrl) return request.then(response => { return response.data })} تعيد الدالة المعدّلة getAll وعدًا، وكذلك سيفعل التابع then الذي ينتج عن وعد ويعيد وعدًا. فبعد أن نعرّف معامل التابع then ليعيد مباشرة بيانات الاستجابة response.data نكون قد حصلنا على غايتنا من استخدام الدالة getAll. إن نجح طلب HTTP سيعيد الوعد البيانات التي يرسلها الخادم ضمن استجابته للطلب. سنحدّث الآن شيفرة المكوِّن App ليعمل مع التغييرات التي أجريناها على وحدة الاتصال. سنبدأ بإصلاح دوال الاستدعاء التي تُمرّر كمعاملات إلى توابع الكائن noteService حتى تتمكن من إعادة بيانات الاستجابة من الخادم مباشرة: const App = () => { // ... useEffect(() => { noteService .getAll() .then(initialNotes => { setNotes(initialNotes) }) }, []) const toggleImportanceOf = id => { const note = notes.find(n => n.id === id) const changedNote = { ...note, important: !note.important } noteService .update(id, changedNote) .then(returnedNote => { setNotes(notes.map(note => note.id !== id ? note : returnedNote)) }) } const addNote = (event) => { event.preventDefault() const noteObject = { content: newNote, date: new Date().toISOString(), important: Math.random() > 0.5 } noteService .create(noteObject) .then(returnedNote => { setNotes(notes.concat(returnedNote)) setNewNote('') }) } // ... } تبدو الأمور معقدة، وقد تزداد تعقيدًا عندما نحاول أن نتعمق في شرحها، لذلك حاول أن تطلع على هذا الموضوع أكثر. وبالطبع شرحت أكاديمية حسوب شرحًا وافيًا هذا الموقع في مقال، سلسلة الوعود في جافاسكربت وستجد شرحًا وافيًا ومطولًا عن الموضوع في المراجع الأجنبية مثلًا في كتاب "Async performance" وهو أحد كتب سلسلة You do not know JS. إنّ فكرة الوعود هي فكرة مركزية في تطوير تطبيقات JavaScript الحديثة، لذلك ننصحك بشدة أن تقضي وقتًا كافيًا في فهمها. كتابة شيفرة أوضح عند تعريف الكائنات المجرّدة تُصَدِّر الوحدة التي أنشأناها سابقًا والتي تعرّف خدمات تتعلق بالتعامل مع الملاحظات، كائنًا يمتلك الخصائص getAll وcreate وupdate والتي تسند إلى دوال مخصصة للتعامل مع الملاحظات. يبدو تعريف هذه الوحدة بالشكل التالي: import axios from 'axios' const baseUrl = 'http://localhost:3001/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } const create = newObject => { const request = axios.post(baseUrl, newObject) return request.then(response => response.data) } const update = (id, newObject) => { const request = axios.put(`${baseUrl}/${id}`, newObject) return request.then(response => response.data) } export default { getAll: getAll, create: create, update: update } تصدَّر الوحدة الكائن التالي: { getAll: getAll, create: create, update: update } تدعى العبارات التي تقع على يسار العمود في الشيفرة السابقة، مفاتيح الكائن. بينما تدعى العبارات التي تقع على اليمين بمتغيّرات الكائن والتي تُعرّف داخل الوحدة. وطالما أن المفاتيح والمتغيرات تحمل نفس التسمية، يمكننا صياغة الكائن بشكل أكثر إختصارًا: { getAll, create, update } وكنتيجة لذلك سيصبح تعريف الوحدة أبسط: import axios from 'axios' const baseUrl = 'http://localhost:3001/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } const create = newObject => { const request = axios.post(baseUrl, newObject) return request.then(response => response.data) } const update = (id, newObject) => { const request = axios.put(`${baseUrl}/${id}`, newObject) return request.then(response => response.data) } export default { getAll, create, update } إنّ تعريف الكائن بهذه الصيغة المختصرة، يعني استخدامنا لميزة جديدة قدمتها JavaScript عند إطلاق ES6، والتي ستمكننا من تعريف الكائنات التي تستخدم المتغيّرات بطريقة مختصرة أكثر. ولتوضيح هذه الميّزة، دعونا نتأمل حالة معينة تسند فيها القيم التالية إلى متغيرات: const name = 'Leevi' const age = 0 لقد توجب علينا في النسخ القديمة من JavaScript أن نعرف الكائن على النحو التالي: const person = { name: name, age: age } وطالما أن الخصائص والمتغيرات في الكائن لها نفس التسمية، سيكون كافيًا كتابة التعريف باستخدام ES6 كالتالي: const person = { name, age } ستكون النتيجة نفسها في الطريقتين، فكلاهما يعرفان كائنًا له خاصية تدعى name تحمل القيمة "Leevi" وخاصية تدعى "age" وتحمل القيمة 0. الوعود والأخطاء لو سمح تطبيقنا للمستخدم أن يحذف ملاحظات، فقد يواجه حالة يريد فيها تغيير أهمية ملاحظة محذوفة سابقًا من النظام. لنحاكي هذه الحالة بأن نطلب من الدالة getAll أن تعيد ملاحظة مكتوبة مسبقًا لكنها عمليًا غير موجودة على الخادم: const getAll = () => { const request = axios.get(baseUrl) const nonExisting = { id: 10000, content: 'This note is not saved to server', date: '2019-05-30T17:30:31.098Z', important: true, } return request.then(response => response.data.concat(nonExisting)) } عندما نحاول تغيير أهمية تلك الملاحظة، ستظهر لنا رسالة خطأ على شاشة الطرفية مفادها أن الخادم قد استجاب لطلب HTTP برمز الحالة 404 (غير موجود). لذلك ينبغي على التطبيق أن يمنع وقوع هذا النوع من الأخطاء. فلن يعرف المستخدم أنّ هذا الخطأ قد وقع، إلا إذا صدف وكانت طرفية التطوير في متصفحه مفتوحة. الطريقة الوحيدة التي يمكن أن يُلاحظ فيها وقوع الخطأ في التطبيق هو عدم تغيّّر أهمية الملاحظة بعد النقر على الزر. لقد أشرنا سابقًا في القسم الثاني أن للوعد ثلاث حالات مختلفة. حيث يُرفض الوعد إذا فشل طلب HTTP. لم يعالج تطبيقنا الحالة التي يُرفض فيها الوعد. ويعالج ذلك بتزويد التابع then بدالة استدعاء أخرى، تُستدعى في مثل هذه الحالة. نستخدم عادة التابع catch لهذه الغاية ويعرّف كالتالي: axios .get('http://example.com/probably_will_fail') .then(response => { console.log('success!') }) .catch(error => { console.log('fail') }) عندما يخفق الطلب، يستدعى معالج الحدث المعرف ضمن التابع catch الذي يوضع عادة في آخر سلسلة التوابع التي تتعامل مع الوعد. فعندما يرسل التطبيق طلب HTTP، سينشأ في الواقع ما يسمى سلسلة الوعد كما في الشيفرة التالية: axios .put(`${baseUrl}/${id}`, newObject) .then(response => response.data) .then(changedNote => { // ... }) وهكذا يُستدعى التابع catch حالما يرمي أي تابع في سلسلة الوعد خطأ ويرفض الوعد: axios .put(`${baseUrl}/${id}`, newObject) .then(response => response.data) .then(changedNote => { // ... }) .catch(error => { console.log('fail') }) لنستفد من الميزة السابقة ولنعرّّف معالجًا للخطأ في المكوِّن App: const toggleImportanceOf = id => { const note = notes.find(n => n.id === id) const changedNote = { ...note, important: !note.important } noteService .update(id, changedNote).then(returnedNote => { setNotes(notes.map(note => note.id !== id ? note : returnedNote)) }) .catch(error => { alert( `the note '${note.content}' was already deleted from server` ) setNotes(notes.filter(n => n.id !== id)) })} يُعلِمُ التطبيق المستخدمَ بوجود خطأ من خلال النافذة المنبثقة alert، وتستبعد الملاحظة المحذوفة من حالة التطبيق. ويتم استبعاد الملاحظة المحذوفة مسبقًا من حالة التطبيق باستخدام تابع المصفوفات filter والذي يعيد مصفوفة جديدة تضم فقط القيم التي ستعيدها الدالة -التي تُمرّر إليه كمعامل- على أنها صحيحة. notes.filter(n => n.id !== id) قد لا يعتبر استخدام alert مناسبًا في تطبيقات React، لذلك سنتعلم لاحقًا طرقًا متقدمة أكثر في عرض الرسائل والتنبيهات. لا يعني ذلك أن لا نستخدم alert أبدًا فقد تكون مفيدة كنقطة انطلاق. يمكن الحصول على شيفرة التطبيق بوضعه الحالي في الفرع part2-6 على github. التمارين 2.15-2.18 2.15 دليل الهاتف: الخطوة 7 لنعد إلى تطبيق دليل الهاتف الذي بدأناه سابقًا. احفظ رقم الهاتف الذي ندخله في الخادم. 2.16 دليل الهاتف: الخطوة 8 انقل الشيفرات التي تؤمن الاتصال مع الواجهة الخلفية إلى وحدة خاصة بها، بالاستفادة من المثال الذي عرضناه سابقًا في هذا الجزء. 2.17 دليل الهاتف: الخطوة 9 امنح المستخدم إمكانية حذف المدخلات إلى دليل الهاتف. يمكن أن تنفذ ذلك باستخدام زر خاص يظهر بجوار كل اسم في الدليل. يمكنك الطلب من المستخدم تأكيد عملية الحذف باستخدام التابع window.confirm: يمكن حذف المورد المتعلق بشخص من الخادم باستخدام طلب HTTP-DELETE إلى موقع المورد. فلو أردت حذف الشخص الذي قيمة معرّفه id هي 2، نفذ ذلك باستخدام الطلب السابق إلى الموقع localhost:3001/persons/2. وتذكر أنه لا تُرسل أية بيانات مع هذا الطلب. يمكنك استخدام الطلب HTTP-DELETE مع توابع المكتبة axios بنفس الطريقة التي استخدمناها فيها. انتبه لا يمكن اختيار "delete" كاسم لمتغيّر، لأنها كلمة محجوزة في JavaScript. فالشيفرة التالية مثلًا غير صحيحة: // استخدم اسما آخر للمتغير const delete = (id) => { // ... } 2.18 دليل الهاتف: الخطوة 10 * أضف ميزة إلى تطبيقك تستبدل فيها الرقم القديم بالرقم الجديد إذا أضاف المستخدم رقمًا إلى شخص موجود مسبقًا. يفضل استخدام طلب HTTP-PUT لتنفيذ هذا الأمر. ليطلب تطبيقك من المستخدم أن يؤكد العملية إذا كانت معلومات الشخص موجودة مسبقًا. ترجمة -وبتصرف- للفصل Altering Data in Server من سلسلة Deep Dive Into Modern Web Development
  21. انصب تركيزنا حتى هذه اللحظة على كتابة الشيفرة للعمل مع الواجهة الأمامية (العمل من جهة المستخدم أو المتصفح)، وسنبدأ العمل على الواجهة الخلفية (جهة الخادم) عند الوصول إلى الفصل الثالث. مع ذلك سنخطو بداية خطواتنا بتعلم طريقة التواصل بين الشيفرة المكتوبة للمتصفح مع الخادم. سنستعمل عند تطويرنا التطبيقات أداة تدعى JSON Server لتأمين خادم افتراضي على جهازك. أنشئ ملفًا باسم db.json في المجلد الجذري للمشروع يحوي ما يلي: { "notes": [ { "id": 1, "content": "HTML is easy", "date": "2019-05-30T17:30:31.098Z", "important": true }, { "id": 2, "content": "Browser can execute only JavaScript", "date": "2019-05-30T18:39:34.091Z", "important": false }, { "id": 3, "content": "GET and POST are the most important methods of HTTP protocol", "date": "2019-05-30T19:20:14.298Z", "important": true } ] } يمكن تثبيت كامل بيئة JSON على جهازك بتنفيذ الأمر install -g json-server. ويتطلب ذلك امتلاك امتيازات مدير النظام والخبرة بالتأكيد. لا يتطلب الأمر عادة تثبيتًا كاملًا، فيمكنك تثبيت ما يلزم في المجلد الجذري للمشروع بتنفيذ الأمر npx json-server: npx json-server --port 3001 --watch db.json يعمل JSON-server افتراضيًا على المنفذ 3000، ونظرًا لاستخدامنا create-react-app الذي يعمل أيضًا عند نفس المنفذ، لابد من تحديد منفذ جديد للخادم مثل 3001. توجه إلى العنوان http://localhost:3001/notes عبر متصفحك، ستلاحظ كيف سيعرض خادم JSON البيانات التي كتبناها سابقًا في ملف JSON. ثبت ملحقًا (plugin) إلى متصفحك لإظهار البيانات المكتوبة بصيغة JSON مثل JSONView، إن لم يتمكن من عرضها بشكل صحيح. لاحقًا، ستكون المهمة حفظ البيانات على الخادم. طبعًا خادم JSON في حالتنا. حيث تحضر شيفرة React الملاحظات ثم تصيّرها على الشاشة. وكذلك سترسل أية ملاحظات جديدة إلى الخادم لتجعلها "محفورة" في الذاكرة. يخزن خادم JSON البيانات في الملف db.json. في الواقع العملي، ستُخزَّن البيانات ضمن قواعد البيانات، لكن سيبقى هذا الخادم أداة مفيدة في متناول اليد لتجريب العمل مع الواجهة الخلفية خلال مرحلة التطوير دون عناء برمجة أي شيء عليه. سنتعلم المزيد حول مبادئ تطوير التطبيقات للتعامل مع الواجهة الخلفية بتفاصيل أكثر في القسم 3. دور المتصفح كبيئة تشغيل تقتضي مهمتنا الأولى إحضار الملاحظات الموجودة إلى تطبيقنا من العنوان http://localhost:3001/notes. ولقد تعلمنا بالفعل طريقةً لإحضار البيانات من الخادم باستخدام JavaScript في القسم 0. حيث استخدمنا XMLHttpRequest في إحضار البيانات من الخادم وهو مايعرف بطلب HTTP باستخدام الكائن XHR. قُدّمت هذه التقنية عام 1999 وتدعمها حتى اللحظة جميع المتصفحات. لكن لا ينصح حاليًا باستخدام هذا الأسلوب، بل استخدام التابع fetch الذي تدعمه المتصفحات بشكل واسع. وتعتمد طريقة عمله على مايسمى وعودًا promises، بدلًا من الأسلوب المقاد بالأحداث والذي يستخدمه XHR. كتذكرة من القسم 0 (لا يجب استخدام XHR إلا إن كنت بحاجة ماسة لذلك) سنحضر البيانات باستخدام هذا الأسلوب كالتالي: const xhttp = new XMLHttpRequest() xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { const data = JSON.parse(this.responseText) // Data يعالج الاستجابة التي أسندت إلى المتغيّر } } xhttp.open('GET', '/data.json', true) xhttp.send() صرحنا في البداية عن معالج حدث للكائن xhttp الذي يمثل طلب HTML. يُستدعى هذا المعالج من قبل بيئة تشغيل JavaScript عندما تتغير حالة الكائن xhttp. ستُعالج طبعًا البيانات التي أُحضرت بشكل مناسب إذا تغيرت الحالة نتيجة لتلقي استجابة الخادم. ولهذا لا قيمة لأية شيفرة قد نضعها في معالج الحدث قبل إرسال الطلب إلى الخادم. لكن بالطبع سيتم تنفيذها في وقت لاحق. إذًا لا تُنفَّذ الشيفرة بشكل متزامن من الأعلى للأسفل، بل بشكل غير متزامن، وستستدعي JavaScript المعالج المصرح عنه سابقًا في مرحلة ما. تعبر البرمجة بالطريقة غير المتزامنة شائعة الاستخدام في Java، يوضح ذلك المثال التالي (انتبه إلى أن الشيفرة في المثال ليس شيفرة Java قابلة للتنفيذ): HTTPRequest request = new HTTPRequest(); String url = "https://fullstack-exampleapp.herokuapp.com/data.json"; List<Note> notes = request.get(url); notes.forEach(m => { System.out.println(m.content); }); ُتنفَّذ الشيفرة في Java سطرًا تلو الآخر وتتوقف بانتظار نتيجة طلب HTTP، أي إتمام تنفيذ الأمر ()request.get. تُخزَّن بعدها البيانات التي يرسلها الخادم -وهي في حالتنا هذه "الملاحظات"- ليتم التعامل معها بالطريقة المطلوبة. تتبع بيئة تشغيل JavaScript أو محرك JavaScript بالمقابل الأسلوب غير المتزامن. ويتطلب ذلك من حيث المبدأ ألا تعيق عمليات الدخل والخرج IO-operations عملية التنفيذ (مع بعض الاستثناءات). أي ينبغي أن تُستأنف عملية تنفيذ الشيفرة مباشرة بعد استدعاء دالة (الدخل/الخرج) دون انتظار الاستجابة. وعندما تكتمل العملية السابقة أو بالأحرى خلال مرحلة ما بعد اكتمالها، يستدعي محرك JavaScript معالج الحدث المعَّرف سابقًا. تعتبر محركات JavaScript حاليًا أحادية المسلك أو الخيط SingleThreaded، أي أنها لا تستطيع تنفيذ الشيفرات على التوازي. لذلك يفضل عمليًا اعتماد نموذج (دخل/خرج) لا يعيق تنفيذ الشيفرة، وإلا سيجمد المتصفح عند إحضار البيانات من الخادم على سبيل المثال. ومن التبعات الأخرى لاستخدام المسلك الأحادي للمحركات، هو توقف المتصفح عند تنفيذ شيفرات تستغرق وقتًا طويلًا خلال فترة التنفيذ. لو وضعنا الشيفرة التالية في أعلى التطبيق: setTimeout(() => { console.log('loop..') let i = 0 while (i < 50000000000) { i++ } console.log('end') }, 5000) سيعمل التطبيق بشكل طبيعي لمدة خمس ثوان (وهو مُعامل الدالة setTimeOut) قبل أن يدخل في تنفيذ الحلقة الطويلة. حيث سيتوقف المتصفح عن العمل ولن نتمكن حتى من إغلاق المتصفح (على الأقل لا يمكننا ذلك في Chrome). وليبقى المتصفح متجاوبًا بشكل مستمر مع أفعال المستخدم وبسرعة كافية، لابد من اعتماد منطق لا يسمح بحسابات طويلة عند كتابة الشيفرة. ستجد على شبكة الإنترنت العديد من المواد المتعلقة بهذا الموضوع، ونخص بالذكر منها الملاحظات المفتاحية التي قدمها فيليب روبرتس بعنوان What the heck is the event loop anyway والتي تمثل عرضًا واضحًا للموضوع. يمكن تنفيذ الشيفرة على التوازي في المتصفحات الحديثة باستخدام ما يسمى عمال الويب web workers. لكن تنفيذ حلقة الأحداث في كل نافذة على حدى سيبقى بأسلوب المسلك الأحادي npm (مدير الحزم في Node.js) لنعد إلى إحضار البيانات من الخادم. يمكن أن نستخدم -كما أشرنا سابقًا- الدالة fetch التي تعتمد على مفهوم الوعود. وتعتبر هذه الدالة من الأدوات المميزة والمعيارية التي تدعمها كل المتصفحات الحديثة. لكننا مع ذلك سنستخدم دوال المكتبة axios بدلًا منها للتواصل بين المتصفح والخادم. حيث تعمل دوال هذه المكتبة بشكل مشابه للدالة fetch لكن التعامل معها أسهل. والسبب المهم الآخر هو تعلم إضافة مكتبات خارجية والمعروفة باسم حزم npm ضمن مشاريع React. تُعرّف حاليًا كل مشاريع JavaScript باستخدام npm، وكذلك تطبيقات React المبنية باستخدام create-react-app. ويشير وجود الملف package.json في المجلد الجذري للمشروع على استخدام npm. { "name": "notes", "version": "0.1.0", "private": true, "dependencies": { "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.4.0", "@testing-library/user-event": "^7.2.1", "react": "^16.12.0", "react-dom": "^16.12.0", "react-scripts": "3.3.0" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, "eslintConfig": { "extends": "react-app" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } نشير هنا إلى الجزء الأهم من الملف package.json وهو ملفات الارتباط dependencies أو المكتبات الخارجية التي يعتمدها المشروع. سنبدأ الآن باستخدام axios. يمكننا تعريف هذه المكتبة مباشرة في الملف package.jaon، لكن من الأفضل أن نثبتها باستخدام الأمر: npm install axios --save ملاحظة: تنفذ جميع أوامر npm في المجلد الجذري للمشروع، وهو المكان الذي تجد فيه الملف package.jsons. بعد تثبيتها ستظهر axios ضمن ملفات الارتباط: { "dependencies": { "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.4.0", "@testing-library/user-event": "^7.2.1", "axios": "^0.19.2", "react": "^16.12.0", "react-dom": "^16.12.0", "react-scripts": "3.3.0" }, // ... } سُينزِّل أمر التثبيت السابق بالإضافة إلى المكتبة axios شيفرة المكتبة. وكغيرها من المكتبات ستجد شيفراتها ضمن المجلد nodemodules الموجود في المجلد الجذري للمشروع. يمكنك أن تلاحظ ان المجلد nodemodules يضم كميةً لا بأس بها من الأشياء المهمة. لنضف أمرًا آخر. ثبت json-server كملف ارتباط (وهذا فقط أثناء التطوير) باستخدام الأمر: npm install json-server --save-dev ثم عدّل قليلًا القسم Scripts من الملف package.json كما يلي: { // ... "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "server": "json-server -p3001 --watch db.json" }, } يمكننا الآن تشغيل خادم json -وبلا تعريف لأية مُعاملات جديدة- من المجلد الجذري للمشروع باستخدام الأمر: npm run server سنطلع أكثر على الأداة npm في القسم الثالث من منهاجنا. ملاحظة: يجب إيقاف الخادم الذي شغلناه باستخدام الأمر السابق قبل تشغيل خادم جديد وإلا ستنتظرك المشاكل: تخبرنا رسالة الخطأ المطبوعة باللون الآخر عن المشكلة التالية: لا يمكن الارتباط بالمنفذ 3001. اختر رجاء رقمًا آخر للمنفذ من خلال التعليمة port-- أو من خلال ملف التهيئة لخادم json. والسبب أن المنفذ 3001 قد شُغِل من قبل الخادم الذي أنشئ أولًا ولم يتم إيقافه، وبالتالي لم يستطع التطبيق الارتباط بهذا المنفذ. لاحظ أن هناك فرقًا بسيطًا بين أمري التثبيت التاليين: npm install axios --save npm install json-server --save-dev ثُبِّتت المكتبة axios كملف ارتباط (save--) للتطبيق، لأن تنفيذه يتطلب وجود هذه المكتبة. بالمقابل ثُبِّت خادم jason كملف ارتباط للتطوير (save-dev--) لأن البرنامج لا يحتاجه فعلًا حتى يُنفّذ، بل سيساعد فقط خلال عملية تطوير التطبيق. سنتحدث أكثر عن موضوع ملفات الارتباط في القسم التالي. مكتبة Axios والوعود نحن الآن مستعدين للعمل مع axios، وسنفترض أن خادم json يعمل على المنفذ 3001. ملاحظة: لتشغل كل من jason و create-react-ap معًا عليك أن تفتح نافذتين من الطرفية node.js، كل نافذة لبرنامج. يمكن إدراج المكتبة ضمن التطبيق بالطريقة التي أدرجت فيها React وهي استعمال العبارة import. أضف مايلي إلى الملف index.js: import axios from 'axios' const promise = axios.get('http://localhost:3001/notes') console.log(promise) const promise2 = axios.get('http://localhost:3001/foobar') console.log(promise2) من المفترض أن يطبع ما يلي على الشاشة: يعيد التابع get وعدًا. ستجد في التوثيق على موقع موسوعة حسوب حول موضوع الوعود مايلي: وبكلمات أخرى، هو كائن يمثل عملية غير متزامنة يمكن أن يأخذ إحدى الحالات الثلاث التالية: الوعد طور التنفيذ (pending): أي أن القيمة النهائية (والتي تمثلها إحدى الحالتين التاليتين) غير جاهزة حتى اللحظة. الوعد قد تحقق (fulfilled): إي أن العملية قد اكتملت والقيمة النهائية جاهزة. ويعني هذا عمومًا أن العملية قد نجحت. وقد يشار إلى هذه الحالة أحيانًا على أنها منجزة Resolved. الوعد قد رفض (rejected): أي أن خطأً قد منع تحديد القيمة النهائية. ويعني هذا عمومًا إخفاق العملية. تَحقَّق الوعد الأول في مثالنا السابق، ويمثل نجاح الطلب (axios.get(http://localhost:3001/notes، بينما رفض الوعد الثاني. وتُبلغنا الطرفية سبب الرفض بأنها تبدو كمحاولة لإرسال طلب HTTP-GET إلى عنوان غير موجود. إذا أردنا أن نعرف كيف وأين سنحصل على نتيجة العملية التي يمثلها الوعد، لابد من التصريح عن معالج حدث لهذا الوعد. ويتم ذلك باستخدام تابع axios يدعى then: const promise = axios.get('http://localhost:3001/notes') promise.then(response => { console.log(response) }) ستظهر على شاشة الطرفية المعلومات التالية: تستدعي بيئة تشغيل JavaScript الدالة المصرح عنها داخل then وتمرر إليها الكائن response كمُعامل. يحوي المُعامل response كل البيانات الأساسية المتعلقة باستجابة الخادم على طلب HTTP-GET، وهي البيانات المعادة ورمز الحالة والترويسات. لا داعٍ لإسناد كائن الوعد إلى متغير، بل تُستخدم عادة طريقة الاستدعاء المتسلسل للتوابع كالتالي: axios.get('http://localhost:3001/notes').then(response => { const notes = response.data console.log(notes) }) حيث تستخلص دالة الاستدعاء البيانات من الاستجابة التي تحصل عليها، ومن ثم تحفظها في متغير وتطبعها على شاشة الطرفية. ومن المفضل كتابة توابع الاستدعاء المتسلسل بحيث يقع كل تابع في سطر جديد وذلك لتسهيل قراءة الشيفرة: axios .get('http://localhost:3001/notes') .then(response => { const notes = response.data console.log(notes) }) يعيد الخادم البيانات المطلوبة على شكل سلسلة نصية طويلة غير منسقة بعد. ثم تحول axios البيانات إلى مصفوفة JavaScript لأن الخادم قد حدد نمط البيانات المرسلة على أنها application/json بنمط محارف utf-8 وذلك ضمن ترويسة نوع المحتوى content-type. وبهذا سنصبح قادرين أخيرًا على استخدام البيانات القادمة من الخادم. سنحاول الآن طلب الملاحظات من الخادم المحلي ثم تصييرها على شكل مكوِّن App. لكن تذكر أن هذه المقاربة ستعرضك لمشاكل عدة لأننا سنصيّر المكوِّن App عندما يستجيب الخادم فقط: import React from 'react' import ReactDOM from 'react-dom' import App from './App' import axios from 'axios' axios.get('http://localhost:3001/notes').then(response => { const notes = response.data ReactDOM.render( <App notes={notes} />, document.getElementById('root') ) }) يمكن أن نستخدم الطريقة السابقة في ظروف معينة، لكنها سبب للعديد من المشاكل. لنحاول بدلًا من ذلك أن نضع أمر إحضار البيانات داخل المكوِّنApp بدلًا من أن يكون جزءًا من دالة الاستجابة. لكن ما ليس واضحًا تمامًا هو المكان الذي سنضع فيه الأمر داخل المكوِّن. خطافات التأثير تعرفنا سابقًا على خطافات الحالة التي ظهرت مع الإصدار 16.8.0 من React، والتي تمنح دالة المكوِّن إمكانية تحديد حالته الراهنة. كما ظهرت في نفس النسخة خطافات التأثير effect hooks كميزة جديدة، وقد وثّقت كالتالي: إذًا فخطافات التأثير هو ما نحتاجه تمامًا لإحضار البيانات من الخادم. لنحذف أمر إحضار البيانات من ملف index.js، ولن نحتاج أيضًا إلى استخدام الخصائص لتمرير الملاحظات إلى المكوِّن، طالما أننا سنحضرها مباشرة من الخادم. وبالتالي سيبدو الملف index.js كالتالي: ReactDOM.render(<App />, document.getElementById('root')) سيتغير المكوِّن App ليصبح كالتالي: import React, { useState, useEffect } from 'react' import axios from 'axios' import Note from './components/Note' const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) useEffect(() => { console.log('effect') axio.get('http://localhost:3001/notes').then(response => { console.log('promise fulfilled') setNotes(response.data) }) }, []) console.log('render', notes.length, 'notes') // ... } كما أضفنا عدة أوامر للطباعة على الطرفية بغية توضيح مسار العملية، وستظهر عند التنفيذ كالتالي: render 0 notes effect promise fulfilled render 3 notes تنفذ في البداية الشيفرة الموجودة داخل جسم دالة المكوِّن، وتصيَّر النتيجة للمرة الأولى. وعندها ستُطبع العبارة render 0 notes على الطرفية إشارة إلى أن البيانات لم تحضر بعد من الخادم. ستُنفَّذ الدالة أو (التأثير) التالي مباشرة بعد أول عملية تصيير: () => { console.log('effect') axios .get('http://localhost:3001/notes') .then(response => { console.log('promise fulfilled') setNotes(response.data) }) } يؤدي تنفيذ التأثير السابق إلى طباعة العبارة effect على الطرفية، ويهيئ الأمر axios.get لعملية إحضار البيانات من الخادم ويُعرِّف في نفس الوقت معالج الحدث التالي للعملية: response => { console.log('promise fulfilled') setNotes(response.data) }) بمجرد وصول البيانات من الخادم، تستدعي بيئة تشغيل JavaScript معالج الحدث المرتبط بالعملية والذي يطبع بدوره العبارة fulfilled على الطرفية، ويخزّن الملاحظات القادمة من الخادم ضمن حالة المكوِّن باستخدام الدالة (setNote(response.data. وكما هو الحال دائمًا ستسبب تحديث حالة التطبيق إعادة تصيير المكوِّن، وستظهر ثلاث ملاحظات على الطرفية ومن ثم ستصيّر من جديد على الشاشة. لنلقي في النهاية نظرة شاملة على خطاف التأثير الذي استخدمناه: useEffect(() => { console.log('effect') axios .get('http://localhost:3001/notes').then(response => { console.log('promise fulfilled') setNotes(response.data) }) }, []) لنُعِد كتابة الشيفرة بشكل مختلف قليلًا: const hook = () => { console.log('effect') axios .get('http://localhost:3001/notes') .then(response => { console.log('promise fulfilled') setNotes(response.data) }) } useEffect(hook, []) سنرى الآن بوضوح أن الدالة useEffect تأخذ في الواقع مُعاملين اثنين. الأول هو الدالة effect وتمثل التأثير ذاته وفقًا للتوثيق: يتم تنفيذ دالة التأثير افتراضيًا بعد أن يتم تصيير المكوِّن. لكننا نريد في حالتنا هذه أن ينفّذ فقط مع أول تصيير. أما المُعامل الثاني فيستخدم لتحديد كم مرة سيُنفَّذ التأثير الجانبي. فإذا وضعنا مصفوفة فارغة [ ] كقيمة له فإن التأثير سينفذ مرة واحدة عند أول تصيير للمكوِّن. يمكن أن نستخدم خطافات التأثير في أمور عدة بعد إحضار البيانات من الخادم، لكننا سنكتفي حاليًا بهذا الاستخدام. عليك أن تفكر مليًا بتسلسل الأحداث التي ناقشناها. أي جزء من الشيفرة نُفِّذ أولًا، وبأي ترتيب، وكم مرة، لأن فهم ترتيب الأحداث أمر حيوي جدًا. لاحظ أنه كان بالإمكان كتابة شيفرة دالة التأثير على النحو التالي: useEffect(() => { console.log('effect') const eventHandler = response => { console.log('promise fulfilled') setNotes(response.data) } const promise = axios.get('http://localhost:3001/notes') promise.then(eventHandler) }, []) أُسند مرجعٌ لدالة معالج الحدث إلى المتغيّرeventHandler. وخُزّن الوعد الذي يعيده التابع get في المتغيّر promise. يعرّف الاستدعاء (إعادة النداء) بجعل المتغير eventHandler كمُعامل للتابع then العائد إلى الوعد. فمن الضروري إسناد الدوال والوعود إلى متغيرات، ويفضل كتابة الأشياء بشكل مختصر: useEffect(() => { console.log('effect') axios .get('http://localhost:3001/notes') .then(response => { console.log('promise fulfilled') setNotes(response.data) }) }, []) تبقى لدينا مشكلة في التطبيق، فالملاحظات الجديدة التي ننشئها لن تخزن على الخادم. ستجد نسخة عن التطبيق حتى هذه المرحلة على github في الفرع part2-4. بيئة التشغيل الخاصة بالتطوير ستغدو تهيئة التطبيق بشكل كامل أصعب شيئًا فشيئًا. لنراجع إذًا ما الذي يحدث وأين سيحدث. تُنفَّذ شيفرة JavaScript التي توصّف التطبيق على المتصفح. حيث يحضر المتصفح هذه الشيفرة من خادم تطوير React. وهذا الأخير عبارة عن تطبيق يعمل مباشرة بعد تنفيذ الأمر npm start. يحول خادم التطوير dev-server شيفرة JavaScript إلى صيغة يفهمها المتصفح، كما يقوم بعدة أشياء أخرى منها تجميع شيفرة JavaScript من عدة ملفات في ملف واحد. سنطّلع على تفاصيل أكثر حول خادم التطوير في القسم 7. يحضر تطبيق React -الذي يعمل على المتصفح- البيانات بصيغة JSON من خادم JSON الذي يَشغُل المنفذ 3001 من جهازك. يحصل خادم JSON بدوره على البيانات المطلوبة من الملف db.json. تتواجد كل أقسام التطبيق حتى هذه المرحلة من مراحل التطوير ضمن آلة تطوير البرنامج Software Developer 's Machine. والتي تُعرف بالخادم المحلي. سيتغير الوضع حالما تنشر التطبيق على الإنترنت، وهذا ما سنراه في القسم 3. التمارين 2.11 -2.14 2.11 دليل الهاتف: الخطوة 6 خزن المعلومات الأولية للتطبيق في الملف db.json وضعه في المجلد الجذري للمشروع: { "persons":[ { "name": "Arto Hellas", "number": "040-123456", "id": 1 }, { "name": "Ada Lovelace", "number": "39-44-5323523", "id": 2 }, { "name": "Dan Abramov", "number": "12-43-234345", "id": 3 }, { "name": "Mary Poppendieck", "number": "39-23-6423122", "id": 4 } ] } شغل خادم JSON على المنفذ 3001 وتأكد من أن الخادم سيعيد إليك قائمة الأشخاص السابقة عند طلب العنوان http://localhost:3001/persons من المتصفح. إن تلقيت رسالة الخطأ التالية: events.js:182 throw er; // Unhandled 'error' event ^ Error: listen EADDRINUSE 0.0.0.0:3001 at Object._errnoException (util.js:1019:11) at _exceptionWithHostPort (util.js:1041:20) سيعني ذلك بأن المنفذ 3001 محجوز من قبل تطبيقٍ آخر كخادم JSON آخر. أغلق كل التطبيقات الأخرى أو غيّر رقم المنفذ. عدّل التطبيق ليحضر لك بيانات الأشخاص من الخادم باستخدام المكتبة axios. ثم أكمل عملية إحضار البيانات باستخدام خطاف التأثير. 2.12 معلومات عن البلدان: الخطوة 1 * تزودك الواجهة البرمجية https://restcountries.eu بمعلومات عن بلدان مختلفة بصيغة تفهمها آلة التطوير، والتي تدعى REST API. أنشئ تطبيقًا يمكننا من خلاله الاطلاع على معلوماتٍ من بعض الدول. من المفترض أن يحصل تطبيقك على البيانات من نقطة الاتصال all. واجهة المستخدم بسيطة جدًا، فالبحث عن الدولة يكون بكتابة اسمها في حقل البحث. وإن ظهرت العديد من النتائج المطابقة للبحث (أكثر من 10) تظهر رسالة تنبيه للمستخدم أن يحدد بحثه بشكل أدق. وإن ظهرت عدة نتائج للبحث، لكنها أقل من 10 ستظهر جميع النتائج على الشاشة أما إن كانت نتيجة البحث دولة واحدة، ستظهر بعض المعلومات الأولية عنها مع علمها واللغات المحكية فيها. ملاحظة: يكفي أن يعمل التطبيق لأغلبية بلدان العالم. لأن بعض الدول مثل السودان ستربك التطبيق كون كلمة السودان هي أيضًا جزء من اسم دولة أخرى هي جنوب السودان. لا تلق بالًا لهذا الموضوع الآن. تحذير: تنشئ الأداة create-react-app مستودع git محلي يحتوي المشروع، إلا إن كان في المجلد مستودع محلي سابق. من المرجح أنك لا تريد أن يغدو المشروع مستودعًا، لهذا نفذ الأمر التاليrm -rf .git في مسار المشروع. 2.13 معلومات عن البلدان: الخطوة 2 * هناك الكثير من التمارين التي ينبغي إنجازها، لا تبقى طويلًا في هذا التمرين! (معلّم على أنه غير أساسي) حسِّن التطبيق بحيث يعرض زرًا بجانب الدول التي تظهر كنتيجة للبحث، يعطينا بالنقر عليه المعلومات التي ذكرناها سابقًا عن هذه الدولة. يكفي هنا أيضًا أن يعمل التطبيق بالنسبة لأغلبية البلدان، تجاهل الحالة التي أشرنا إليها سابقًا. 2.14 معلومات عن البلدان: الخطوة 3 * هناك الكثير من التمارين التي ينبغي إنجازها لا تبقى طويلًا في هذا التمرين! (معلّم على أنه غير أساسي) أضف إلى المعلومات التي تظهر عندما تكون نتيجة البحث دولة واحدة فقط، معلومات عن حالة الطقس في العاصمة. ستجد الكثير من الواجهات التي تزودك بأحوال الطقس مثل https://weatherstack.com. ملاحظة: ستحتاج إلى مفتاح لأي واجهة برمجية لخدمات الطقس. لا تحتفظ بالمفتاح ضمن عنصر التحكم ولا تبقه كما هو ضمن الشيفرة المصدرية لتطبيقك. استخدم بدلًا من ذلك متغيرات البيئة لحفظ المفتاح. فلو افترضنا أن مفتاح الواجهة هو 0p53cr3t4p1k3yv4lu3، فعندما يُقلع التطبيق بالشكل التالي: REACT_APP_API_KEY='t0p53cr3t4p1k3yv4lu3' npm start // لملف إقلاع لينوكس وماكنتوش ($env:REACT_APP_API_KEY='t0p53cr3t4p1k3yv4lu3') -and (npm start) // powershell ويندوز set REACT_APP_API_KEY='t0p53cr3t4p1k3yv4lu3' && npm start // سطر أوامر ويندوز يمكنك الوصول إلى قيمة المفتاح من خلال الكائن process.env كالتالي: const api_key = process.env.REACT_APP_API_KEY //يمتلك التطبيق الآن قيمة المفتاح الذي وضعناه عند الإقلاع انتبه: إن أنشأت التطبيق باستخدام الأمر npx create-react-app وأردت أن تطلق اسمًا آخر على متغيّر البيئة يجب أن يبدأ الاسم بالعبارة _REACT_APP. كما يمكنك إنشاء ملف لاحقته env. في المجلد الجذري للمشروع وتضع فيه الشيفرة التالية: # .env REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3 بدلًا من تعريف المتغير من خلال سطر الأوامر command line كل مرة. انتبه: عليك إعادة تشغيل الخادم من جديد حتى تُطبّق التغييرات. ترجمة -وبتصرف- للفصل Getting Data from Server](https://fullstackopen.com/en/part2/gettingdatafrom_server) من سلسلة Deep Dive Into Modern Web Development
  22. سنوسّع تطبيقنا وذلك بالسماح للمستخدمين أن يضيفوا ملاحظات جديدة. من الأفضل هنا أن نخزّن الملاحظات داخل حالة التطبيق App، ذلك إن أردنا أن تحدثّ الصفحة محتوياتها عند إضافة الملاحظة الجديدة. لندرج إذًا الدالة useState ونعرّف قطعًا للحالة تأخذ قيمها الابتدائية من القيم الابتدائية لمصفوفة الملاحظات التي يتم تمريرها كخصائص: import React, { useState } from 'react'import Note from './components/Note' const App = (props) => { const [notes, setNotes] = useState(props.notes) return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <Note key={note.id} note={note} /> )} </ul> </div> ) } export default App يستخدم المكوِّن الدالة useState لتهيئة قطع الحالة المخزنة في notes بمصفوفة الملاحظات التي تمرر كخصائص: const App = (props) => { const [notes, setNotes] = useState(props.notes) // ... } علينا استخدام مصفوفة فارغة لتهيئة الملاحظات إن أردنا أن نبدأ التطبيق بقائمة فارغة من الملاحظات. سنحذف أيضًا الخصائص props من تعريف الدالة طالما أنها لن تُستخدم: const App = () => { const [notes, setNotes] = useState([]) // ... } لنركز الآن على القيم الأولية التي تمرر كخصائص للدالة، حيث سنضيف نموذج HTML إلى المكوِّن الذي سيضيف الملاحظات الجديدة: const App = (props) => { const [notes, setNotes] = useState(props.notes) const addNote = (event) => { event.preventDefault() console.log('button clicked', event.target) } return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <Note key={note.id} note={note} /> )} </ul> <form onSubmit={addNote}> <input /> <button type="submit">save</button> </form> </div> ) } لقد أضفنا أيضًا الدالة addNote كمعالج حدث يُستدعى عندما يُسلّم (submit) النموذج عند النقر على زر التسليم. سنستخدم الطريقة التي ناقشناها في [القسم 1]() لتعريف معالج الحدث: const addNote = (event) => { event.preventDefault() console.log('button clicked', event.target) } يؤدي المعامل event دور الحدث الذي يستدعي دالة المعالج. يستدعي معالج الأحداث التابع ()event.preventDefaultr الذي يمنع حدوث التسليم الافتراضي للنموذج، والذي يسبب إعادة تحميل الصفحة إضافة إلى عدة أشياء أخرى. لنطبع الآن وجهة الحدث event.target على الطرفية: إنّ النموذج الذي عرّفناه في المكوًّن هو وجهة الحدث كما هو واضح، لكن كيف سنصل إلى البيانات الموجودة ضمن عنصر الإدخال input (وهو مربع النص في حالتنا) في النموذج؟ طرق كثيرة يمكن أن تفي بالغرض. سنلقي نظرة على أولها وهي استخدام المكوِّنات المقادة (controlled components). لنضف الآن قطعة حالة جديدة تدعى newNote لتخزّن ما يدخله المستخدم في العنصر input ضمن الصفة value: const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState( 'a new note...' ) const addNote = (event) => { event.preventDefault() console.log('button clicked', event.target) } return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <Note key={note.id} note={note} /> )} </ul> <form onSubmit={addNote}> <input value={newNote} /> <button type="submit">save</button> </form> </div> ) } سيُخزّن النص الموجود في مربع النص كقيمة ابتدائية للملاحظة الجديدة، لكن لو حاولنا إضافة أي نص فلن يسمح مربع النص بذلك. ستعرض الطرفية تنبيهًا قد يعطينا دليلًا على الخطأ الذي وقع: طالما أننا أسندنا قطعة من حالة المكوِّن App إلى الصفة value لعنصر الإدخال، فسيتحكم المكوِّن الآن بسلوك هذا العنصر. لذا لابد من تعريف معالج حدث جديد يزامن التغييرات التي تحدث في عنصر الإدخال مع حالة المكوِّن إن أردنا الكتابة ضمنه: const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState( 'a new note...' ) // ... const handleNoteChange = (event) => { console.log(event.target.value) setNewNote(event.target.value) } return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <Note key={note.id} note={note} /> )} </ul> <form onSubmit={addNote}> <input value={newNote} onChange={handleNoteChange} /> <button type="submit">save</button> </form> </div> ) } سنعرف الآن معالج حدث للخاصية onChange لعنصر إدخال النموذج: <input value={newNote} onChange={handleNoteChange} /> سيُستدعى الآن معالج الحدث عند حدوث أية تغييرات في عنصر الإدخال، وستتلقى دالة المعالج كائن الحدث كمعامل: const handleNoteChange = (event) => { console.log(event.target.value) setNewNote(event.target.value) } ترتبط الآن الخاصية target لكائن الحدث بعنصر الإدخال المُقاد (من قِبل المكوِّن)، وستشير القيمة event.target.value إلى محتوى عنصر الإدخال. ملاحظة: لا داعي لاستدعاء التابع ()event.preventDefault كما فعلنا سابقًا، لعدم وجود عمليات افتراضية عند حدوث تغيرات على عنصر الإدخال. يمكنك تتبع ما يجري عند استدعاء المعالج ضمن النافذة console في الطرفية: سترى كيف تتغير الحالة مباشرة داخل نافذة React Devtools، طبعًا إن كنت قد ثبتها سابقًا. ستعكس قطعة الحالة newNote في المكوِّن App التغيرات في قيمة عنصر الإدخال، إذًا يمكننا إكمال الدالة addNote التي تضيف ملاحظة جديدة: const addNote = (event) => { event.preventDefault() const noteObject = { content: newNote, date: new Date().toISOString(), important: Math.random() < 0.5, id: notes.length + 1, } setNotes(notes.concat(noteObject)) setNewNote('') } سننشئ أولًا كائنًا جديدًا يدعى noteObject يتلقى محتواه من قيمة الحالة newState. بينما يتولّد المعرِّف الفريد id وفقًا لعدد الملاحظات. سينجح هذا الأسلوب في تطبيقنا طالما أنه لن يستخدم لحذف الملاحظات. سنعطي الملاحظة احتمالًا بنسبة 50% لأن تعتبر مهمة بالاستفادة من الدالة ()Math.random وهي دالة تستخدم لتوليد أرقام عشوائية. نستخدم التابع concat الذي تعرفنا عليه سابقًا في إضافة الملاحظة الجديدة إلى قائمة الملاحظات: setNotes(notes.concat(noteObject)) لا يغير التابع المصفوفة الأصلية بل ينشئ مصفوفة جديدة وينسخ العناصر إليها ثم يضيف العنصر الجديد إلى نهايتها. وهذا أمر ضروري، إذ لا يجب علينا تغيير الحالة بشكل مباشر في React. سيقوم معالج الحدث أيضًا بتصفير قيمة عنصر الإدخال المقاد باستدعاء الدالة setNewNote المعرفة كالتالي: setNewNote('') ستجد شيفرة التطبيق الذي نعمل عليه في المسار part2-2 ضمن المخزن المخصص على github. انتقاء العناصر التي ستعرض سنضيف مهمة جديدة للتطبيق تقتضي إظهار الملاحظات الهامة فقط. ولنبدأ بإضافة قطعة حالة جديدة للمكوِّن App مهمتها تحديد الملاحظات التي يجب أن تعرض: const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) // ... } سنعدل المكوًن أيضًا لكي يخزن قائمة بكل الملاحظات التي ستعرض ضمن المتغيّر notesDirectShow. يعتمد اختيار عناصر القائمة على حالة المكوِّن: import React, { useState } from 'react' import Note from './components/Note' const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) // ... const notesToShow = showAll ? notes : notes.filter(note => note.important === true) return ( <div> <h1>Notes</h1> <ul> {notesToShow.map(note => <Note key={note.id} note={note} /> )} </ul> // ... </div> ) } يظهر تعريف المتغيّر notesDirectShow مختصرًا بالشكل التالي: const notesToShow = showAll ? notes : notes.filter(note => note.important === true) سترى في التعريف العامل الشرطي الذي ستراه أيضًا في عدة لغات برمجة أخرى. وسنشرح منطق هذا العامل من خلال المثال التالي: const result = condition ? val1 : val2 تعمل هذه الصيغة كالتالي: سيأخذ المتغير result القيمة val1 إذا تحقق الشرط condition وإلا سيأخذ القيمة val2. وبالعودة إلى الصيغة الموجودة في شيفرة التطبيق، سيأخذ المتغيّر notesToShow القيمة notes، وستظهر كل الملاحظات، إذا كان الشرط showAll محققًا. وإلا سيأخذ القيمة الأخرى، وستظهر الملاحظات الهامة فقط. لاحظ أن عملية الانتقاء قد تمت بمساعدة تابع الانتقاء في المصفوفات filter. notes.filter(note => note.important === true) ليس لعامل الموازنة (===) أهمية طالما أن القيمة note.important من النمط المنطقي. إذًا يمكننا ببساطة كتابة السطر البرمجي السابق بالشكل: notes.filter(note => note.important) إن المغزى من كتابة عامل الموازنة بالشكل(===) هو توضيح ناحية هامة بأن العامل (==) لا يؤدي دوره بالشكل المطلوب في كل الحالات ويجب عدم استخدامه في المقارنة حصرًا. اقرأ المزيد حول الموضوع لفهمٍ أعمق. يمكنك اختبار انتقاء الملاحظات بجعل القيمة الأولية false لقطعة الحالة showAll. سنقوم تاليًا بمنح المستخدم إمكانية تغيير قيمة الحالة showAll من واجهة المستخدم. وستظهر الشيفرة بعد التعديل كما يلي: import React, { useState } from 'react' import Note from './components/Note' const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) // ... return ( <div> <h1>Notes</h1> <div> <button onClick={() => setShowAll(!showAll)}> show {showAll ? 'important' : 'all' } </button> </div> <ul> {notesToShow.map(note => <Note key={note.id} note={note} /> )} </ul> // ... </div> ) } سنتحكم بإخفاء أو إظهار الملاحظات باستخدام زر، وسيكون معالج الحدث الخاص به بسيطًا بحيث يمكن تعريفه مباشرة داخل الصفة onClick. يبدّل معالج الحدث قيمة showAll عند كل نقرة زر. () => setShowAll(!showAll) يتغير النص المكتوب على الزر حسب الحالة فمرة سيكون "important" ومرة "all": show {showAll ? 'important' : 'all'} ستجد شيفرة التطبيق الذي نعمل عليه في المسار part2-3 ضمن المخزن المخصص على github. التمارين 2.6-2.10 سنطور التطبيق الذي سنبدأه في التمرين الأول خلال التمارين اللاحقة. يفضل أن تسلم في مثل هذه الحالات النسخة النهائية من التطبيق. ويمكنك الإشارة إلى ذلك إن أردت من خلال التعليقات ضمن شيفرتك. تحذير: تنشئ الأداة create-react-app مستودع git محلي يحتوي المشروع، إلا إن كان في المجلد مستودع محلي سابق. من المرجح أنك لا تريد أن يغدو المشروع مستودعًا، لهذا نفذ الأمر التاليrm -rf .git في مسار المشروع. 2.6 دليل الهاتف: الخطوة 1 صمم دليل هاتف بسيط. سنعمل في هذا القسم على إضافة الأسماء فقط. اكتب الشيفرة المناسبة لإضافة شخص إلى دليلك. يمكنك استخدام الشيفرة التالية كنقطة انطلاق للمكوِّن App الخاص بتطبيقك: import React, { useState } from 'react' const App = () => { const [ persons, setPersons ] = useState([ { name: 'Arto Hellas' } ]) const [ newName, setNewName ] = useState('') return ( <div> <h2>Phonebook</h2> <form> <div> name: <input /> </div> <div> <button type="submit">add</button> </div> </form> <h2>Numbers</h2> ... </div> ) } export default App تستخدم قطعة الحالة newName في قيادة عنصر الإدخال في النموذج. من المفيد أحيانًا تصيير قطع الحالة أو المتغيرات لتظهر قيمها بشكل نصي لأغراض التنقيح. إذ يمكنك إضافة الشيفرة التالية مؤقتًا إلى العنصر الذي تصّيره: <div>debug: {newName}</div> لا تنس الاستفادة مما تعلمته عن تنقيح تطبيقات React في القسم 1، كما سيمنحك الموسِّع React developer tools قدرة كبيرة على تتبع التغيرات التي تحدث في حالة التطبيق. بعد إتمامك العمل ستبدو واجهة التطبيق مشابهة للواجهة التالية: لاحظ في الشكل السابق كيف يُستخدم الموسِّع الذي ذكرناه. تذكر: يمكنك أن تضع اسم الشخص كقيمة للخاصية key. تذكر أن تمنع التسليم الافتراضي للنموذج. 2.7 دليل الهاتف: الخطوة 2 امنع المستخدم من إدخال أسماء موجودة أصلًا في الدليل. استفد من توابع المصفوفات التي تقدمها JavaScript لإتمام المطلوب. وأظهر رسالة تحذير عندما يحاول المستخدم القيام بذلك باستخدام التابع alert. تلميح: يفضل استخدام القالب النصي إذا أردت أن تضع قيمة متغير ما ضمن سلسلة نصية. `${newName} is already added to phonebook` فإن كانت قيمة المتغير newName هي Arto Hellas سيعيد القالب النصي ما يلي: `Arto Hellas is already added to phonebook` كما يمكن استعمال إشارة (+) بكل بساطة: newName + ' is already added to phonebook' طبعًا ستبدو شيفرتك إحترافية أكثر عندما تستخدم القالب النصي. 2.8 دليل الهاتف: الخطوة 3 اجعل المستخدم قادرًا على إضافة رقم الهاتف أيضًا، لذا عليك إضافة عنصر إدخال آخر إلى النموذج، وطبعًا إضافة معالج حدث مناسب: <form> <div>name: <input /></div> <div>number: <input /></div> <div><button type="submit">add</button></div> </form> حتى هذه النقطة ستبدو واجهة التطبيق مشابه للصورة التالية: لاحظ كيف يقوم الموسٍّع بعمله! 2.9 دليل الهاتف: الخطوة 4 * أضف للتطبيق إمكانية البحث عن الأشخاص بالاسم: استخدم لذلك عنصر إدخال جديد وضعه خارج النموذج. توضح الصورة السابقة أنّ منطق الانتقاء غير حساس لحالة الأحرف أي البحث عن arto سيعطيك النتيجة Arto أيضًا إن وجدت. تذكر: أضف دائمًا بعض البيانات الجاهزة في تطبيقك كما فعلنا في الشيفرة التالية: const App = () => { const [persons, setPersons] = useState([ { name: 'Arto Hellas', number: '040-123456' }, { name: 'Ada Lovelace', number: '39-44-5323523' }, { name: 'Dan Abramov', number: '12-43-234345' }, { name: 'Mary Poppendieck', number: '39-23-6423122' } ]) // ... } سيوفر عليك ذلك عناء إدخال البيانات يدويًا كل مرة عند اختبار وظيفة جديدة. 2.10 دليل الهاتف: الخطوة 5 إن صممت تطبيقك ليستخدم مكوِّنًا واحدًا، أعد بناءه بوضع الأجزاء التي تراها مناسبة في مكوِّنات جديدة. ابق على حالة المكوِّن ومعالجات الأحداث ضمن المكوِّن الجذري App. يكفي أن تشكل ثلاثة مكوِّنات جديدة. يمكنك على سبيل المثال عزل عملية البحث في مكوِّن وكذلك النموذج الذي يُستخدم لإضافة الأشخاص وأيضًا عملية تصيير كل الأشخاص في الدليل أو عملية تصيير تفاصيل شخص واحد. يمكن أن يشبه المكوِّن الجذري بعد إعادة البناء الشكل التالي: const App = () => { // ... return ( <div> <h2>Phonebook</h2> <Filter ... /> <h3>Add a new</h3> <PersonForm ... /> <h3>Numbers</h3> <Persons ... /> </div> ) } سيصيّر المكوِّن الجذري عنوان التطبيق فقط، وتتولى المكوِّنات الجديدة بقية المهام. تذكر: قد تواجهك المشاكل في هذا التمرين إن لم تعرف المكوِّنات في المكان الصحيح، ولعلك ستستفيد الآن من مراجعة الفقرة لا تعرف مكوِّنًا داخل مكوِّن آخر من القسم السابق. ترجمة -وبتصرف- للفصل Forms من سلسلة Deep Dive Into Modern Web Development
  23. لنلقي نظرة سريعة على المواضيع التي بدت صعبة في النسخة السابقة من المنهاج قبل أن ننتقل إلى موضوع جديد. التعليمة console.log سيبدو ذلك -رغم المفارقة- صحيحًا على الرغم من أن الهاوي سيحتاج هذه التعليمة أو غيرها من طرق التنقيح أكثر مما يحتاج إليه المحترف. عندما يتوقف تطبيقك عن العمل، لا تحاول أن تخمن مصدر الخطأ فحسب، بل استخدم تعليمة log أو غيرها من وسائل التنقيح. تذكر لا تضم ما تريد إظهاره على الطرفية مستخدمًا إشارة الجمع (+) كأسلوب Java: console.log('props value is' + props) بل افصل الأشياء التي تريد طباعتها بفاصلة (,): console.log('props value is', props) إن استخدمت أسلوب Java فلن تحصل على النتيجة المطلوبة: props value is [Object object] بينما لو استخدمت الفاصلة لتمييز القيم التي تريد إظهارها على الطرفية فستُطبع بشكل قيم نصية منفصلة ومفهومة. لمعرفة المزيد، راجع بعض الأفكار التي تحدثنا عنها بشأن تنقيح تطبيقات React. نصيحة للاحتراف: استخدام مقاطع شيفرة Visual Studio يمكن بسهولة إنشاء مقاطع شيفرة باستخدام Visual studio، حيث تعتبر هذه المقاطع أسطرًا برمجية قابلة لإعادة الاستخدام. وتشابه في عملها أسطر sout في برنامج Netbeans. يمكنك إيجاد المزيد من الإرشادات حول إنشاء واستخدام المقاطع من خلال الإنترنت. كما يمكنك البحث عن مقاطع جاهزة على شكل إضافات لبرنامج Visual Studio مثل الإضافة xabikos.ReactSnippets. يعتبر المقطع الذي ينفذ الأمر ()console.log leg في الشيفرة التالية الأكثر أهمية حيث يوضح الأمرclog: { "console.log": { "prefix": "clog", "body": [ "console.log('$1')", ], "description": "Log output to console" } } مصفوفات JavaScript انطلاقًا من هذه النقطة سنستخدم توابعًا للتعامل مع المصفوفات في JavaScript وفق أسلوب البرمجة بالدوال مثل التوابع find وfilter وmap. تشابه هذه التوابع في مبدأ عملها "المجاري (Streams)" في Java 8 التي اعتُمدت خلال السنوات القليلة الماضية في مقررات قسم علوم الحاسب في جامعة هلسنكي. إن بدا لك غريبًا أسلوب البرمجة بالدوال مع المصفوفات، يمكنك متابعة مقاطع الفيديو الأجنبية التالية: Functional Programming Higher-order functions Map Reduce basics معالجات الأحداث: نظرة ثانية أثبت مفهوم معالجة الأحداث أنه صعب استنادًا إلى تقييمنا لمنهاج العام الفائت. لذا من الأجدى أن تلقي نظرة ثانية على هذا الموضوع الذي ناقشناه في الفصل السابق ضمن فقرة "عودة إلى معالجة الأحداث" إذا أردت إنعاش معلوماتك. كما برزت تساؤلات عدة حول تمرير معالجات الأحداث إلى المكوِّن الابن للمكوِّن الذي أنشأناه سابقًا App، لذلك يفضل مراجعة هذا الموضوع. تصيير مجموعات البيانات سنعمل الآن على الواجهة الأمامية للتطبيقات (frontend) أو ما يعرف بكتابة شيفرة التطبيق من جهة المتصفح. لنبدأ بتطبيق React مشابه للمثال النموذجي الذي رأيناه في مقال أساسيات بناء تطبيقات الويب: import React from 'react' import ReactDOM from 'react-dom' const notes = [ { id: 1, content: 'HTML is easy', date: '2019-05-30T17:30:31.098Z', important: true }, { id: 2, content: 'Browser can execute only Javascript', date: '2019-05-30T18:39:34.091Z', important: false }, { id: 3, content: 'GET and POST are the most important methods of HTTP protocol', date: '2019-05-30T19:20:14.298Z', important: true } ] const App = (props) => { const { notes } = props return ( <div> <h1>Notes</h1> <ul> <li>{notes[0].content}</li> <li>{notes[1].content}</li> <li>{notes[2].content}</li> </ul> </div> ) } ReactDOM.render( <App notes={notes} />, document.getElementById('root') ) تحتوي كل ملاحظة على قيمة نصية وعبارة زمنية وقيمة منطقية (Boolean) تحدد إذا ما كانت الملاحظة هامة أم لا وكذلك قيمة فريدة (unique value) لتمييز الملاحظة تدعى id. يعمل التطبيق السابق على مبدأ وجود ثلاث ملاحظات فقط في المصفوفة، وتصيّر كل ملاحظة باستخدام القيمة id مباشرة: <li>{notes[1].content}</li> لكن من الأفضل أن نظهر المعلومات المستخلصة من المصفوفة على شكل عنصر React باستخدام الدالة map: notes.map(note => <li>{note.content}</li>) ستكون النتيجة مصفوفة من العناصر li: [ <li>HTML is easy</li>, <li>Browser can execute only Javascript</li>, <li>GET and POST are the most important methods of HTTP protocol</li>, ] يمكن إضافتها إلى القائمة ul: const App = (props) => { const { notes } = props return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <li>{note.content}</li>)} </ul> </div> ) } انتبه لضرورة وضع شيفرة JavaScript -الموجودة ضمن قالب JSX- داخل أقواس معقوصة كي يتم تنفيذها. سنجعل الشيفرة أكثر وضوحًا بنشر تصريح الدالة السهمية ليمتد على عدة أسطر: const App = (props) => { const { notes } = props return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <li>{note.content}</li>)} </ul> </div> ) } الصفات المفتاحية key-attribute على الرغم من أن التطبيق سيعمل بشكل طبيعي، إلّا أن تحذيرًا مزعجًا يظهر على الطرفية: تشير التوجيهات في الصفحة المرتبطة برسالة الخطأ، ضرورة امتلاك عناصر القوائم التي أنشأها التابع map قيمًا مفتاحية فريدة وهي صفات تدعى key. لنضف إذا المفاتيح المناسبة: const App = (props) => { const { notes } = props return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <li key={note.id}>{note.content}</li> )} </ul> </div> ) } وهكذا ستختفي رسالة التحذير. تستخدم React الصفات المفتاحية لتقرر الآلية التي تحدّث فيها مظهر التطبيق عندما يعاد تصيير مكوِّن. يمكنك دومًا استخدام وثائق React لمعرفة المزيد. التابع Map إنّ فهمك لآلية عمل التابع map عند التعامل مع المصفوفات أمر حيوي سيلزمك دائمًا. يحتوي التطبيق على مصفوفة تدعى notes: const notes = [ { id: 1, content: 'HTML is easy', date: '2019-05-30T17:30:31.098Z', important: true }, { id: 2, content: 'Browser can execute only Javascript', date: '2019-05-30T18:39:34.091Z', important: false }, { id: 3, content: 'GET and POST are the most important methods of HTTP protocol', date: '2019-05-30T19:20:14.298Z', important: true } ] لنتوقف قليلًا ونرى كيف يعمل map. لنقل أن الشيفرة التالية قد أضيفت إلى نهاية الملف: const result = notes.map(note => note.id) console.log(result) سينشئ التابع map مصفوفة جديدة قيمها [1,2,3] انطلاقًا من عناصر المصفوفة الأصلية. ويستخدم دالة كمُعامل له. ولهذه الدالة الشكل التالي: note => note.id تمثل الشيفرة السابقة الشكل المختصر لدالة سهمية تعطى بشكلها الكامل على النحو: (note) => { return note.id } تقبل الدالة السابقة الكائن note كمُعامل، وتعيد قيمة الحقل id منه. لكن إن غيرنا الشيفرة لتصبح: const result = notes.map(note => note.content) ستكون النتيجة مصفوفة تضم محتوى الملاحظات. هذه الشيفرة قريبة جدًا من شيفرة React التي استخدمناها سابقًا: notes.map(note => <li key={note.id}>{note.content}</li> ) والتي تنشئ عناصر li تضم المحتوى النصي للملاحظات. وطالما أن الدالة مُرّرت كمعامل إلى التابع map لإظهار العناصر، يجب وضع قيم المتغيّرات داخل الأقواس المعقوصة. note => <li key={note.id}>{note.content}</li> حاول أن تعرف ما الذي سيحدث لو أهملت تلك الأقواس. سيزعجك استخدام الأقواس المعقوصة في البداية لكنك ستعتاد على ذلك وستساعدك React بإظهارها مباشرة نتائج شيفرتك. استخدام مصفوفة القرائن كمفاتيح: الأسلوب المعاكس كان بإمكاننا إخفاء رسالة الخطأ السابقة باستخدام مصفوفة القرائن (Array indexes) كمفاتيح، حيث نستخلص هذه القرائن بتمرير معامل آخر إلى دالة الاستدعاء التي استخدمناها كمعامل للتابع map: notes.map((note, i) => ...) عندما نستدعي الدالة بالشكل السابق ستأخذ i قيمة القرينة التي تشير إلى موقع الملاحظة في الكائن note، وبهذا الأسلوب سنعرّف طريقةً لإنشاء المطلوب دون أخطاء: <ul> {notes.map((note, i) => <li key={i}> {note.content} </li> )} </ul> على الرغم من ذلك لا نوصي باتباع هذا الأسلوب نظرًا للمشاكل غير المتوقعة التي يمكن أن تحدث حتى لو بدا أنّ التطبيق يعمل بشكل جيد. يمكنك الاطلاع أكثر بقراءة مقالات تتعلق بالموضوع. إعادة تكوين الوحدات لندعّم الشيفرة التي كتبناها قليلًا. طالما أن اهتمامنا ينحصر في الحقلnotes فقط من خصائص المكوِّن، لنستخلص قيمة الحقل المطلوب مباشرة بطريقة التفكيك: const App = ({ notes }) => { return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <li key={note.id}> {note.content} </li> )} </ul> </div> ) } وإن أردت تذكّر معلوماتك عن موضوع التفكيك راجع فقرة التفكيك في القسم السابق. سنعرف الآن مكوِّنًا جديدًا Note لعرض الملاحظة: const Note = ({ note }) => { return (<li>{note.content}</li> )} const App = ({ notes }) => { return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <Note key={note.id} note={note} /> )} </ul> </div> ) } لاحظ أن الصفة المفتاحية ارتبطت الآن بالمكوِّن Note وليس بالعنصر li. يمكن كتابة تطبيق React بأكمله ضمن ملف واحد. لكن جرت العادة على كتابة كل مكوِّن في ملف خاص على شكل وحدة ES6، على الرغم من أن هذا الأسلوب ليس عمليًا جدًا. لقد استخدمنا الوحدات منذ البداية في شيفرتنا كما فعلنا في أول سطرين من الملف: import React from 'react' import ReactDOM from 'react-dom' فلقد أدرجنا وحدتين لاستخدامهما في الملف. حيث أسندت الوحدة react إلى متغيّر يدعى React وكذلك أسندت الوحدة react-dom إلى المتغيّر ReactDOM. لننقل الآن المكوِّن Note إلى وحدته الخاصة. توضع المكوِّنات عادة في مجلد يدعى components ضمن المجلد src الموجود في المجلد الرئيسي للتطبيق، ويفضل تسمية الملف باسم المكوِّن. دعونا ننفذ ما ذكرناه ونضع المكوِّن Note في ملف جديد يدعى Note.js كالتالي: import React from 'react' const Note = ({ note }) => { return ( <li>{note.content}</li> ) } export default Note لاحظ أننا أدرجنا React في السطر الأول لأن المكوِّن هو مكوِّن React. سيصدّر السطر الأخير الوحدة الجديدة التي عرفناها باسم Note لكي تستخدم في مكان آخر. سندرج الآن الوحدة الجديدة في الملف index.js: import React from 'react' import ReactDOM from 'react-dom' import Note from './components/Note' const App = ({ notes }) => { // ... } وهكذا يمكن استخدام الوحدة الجديدة بإسنادها إلى المتغيّر Note. وتذكر كتابة عنوان الوحدة بطريقة العنوان النسبي عند إدراجها. './components/Note' تشير النقطة (.) في بداية العنوان إلى المسار الحالي وبالتالي فإن الملف Note.js موجود ضمن المجلد الفرعي component والموجود ضمن المسار الحالي (المجلد الذي يحوي التطبيق). ملاحظة: يمكن إغفال كتابة اللاحقة js . لنصرح الآن عن المكوِّن App أيضًا في وحدة خاصة به. وطالما أنه المكوِّن الجذري سنضعه مباشرة في المجلد src: import React from 'react' import Note from './components/Note' const App = ({ notes }) => { return ( <div> <h1>Notes</h1> <ul> {notes.map((note) => <Note key={note.id} note={note} /> )} </ul> </div> ) } export default App ما بقي إذًا في الملف index.js هو التالي: import React from 'react' import ReactDOM from 'react-dom' import App from './App' const notes = [ // ... ] ReactDOM.render( <App notes={notes} />, document.getElementById('root') ) للوحدات استعمالات أخرى عديدة بالإضافة إلى التصريح عن المكوِّنات في ملفاتها الخاصة، سنعود إليها لاحقًا في المنهاج. ستجد شيفرة التطبيق الحالي على موقع GitHub. يحوي المسار الرئيسي للمخزن كما ستلاحظ شيفرات للنسخ التي سنطورها لاحقًا، لكن النسخة الحالية في المسار part2-1. قبل أن تشغّل التطبيق باستعمال الأمر npm start، نفذ أولًا الأمر npm install. عندما ينهار التطبيق سترى في بداياتك البرمجية أو حتى بعد 30 عامًا من كتابة الشيفرات أن التطبيق قد ينهار أحيانًا وبشكل مفاجئ. وسيكون الأمر أسوأ في اللغات التي يمكن كتابة تعليماتها بشكل ديناميكي أثناء التنفيذ مثل JavaScript. فلا يتاح عندها للمتصفح من التحقق مثلًا من نوع البيانات التي يحملها متغيّر أو التي تمرر لدالة. يظهر الشكل التالي مثالًا عن انهيار تطبيق React: سيكون مخرجك الوحيد من المأزق هو الطباعة على الطرفية. إنّ الشيفرة المسؤولة عن الانهيار في حالتنا هي: const Course = ({ course }) => ( <div> <Header course={course} /> </div> ) const App = () => { const course = { // ... } return ( <div> <Course course={course} /> </div> ) } سنقتفي أثر الخطأ بإضافة التعليمة console.log إلى الشيفرة. وطالما أن المكوِّن App سيصيّر أولًا فمن المفيد وضع أول تعليمة طباعة ضمنه: const App = () => { const course = { // ... } console.log('App works...') return ( // .. ) } عليك طبعًا النزول إلى الأسفل مرورًا بقائمة الأخطاء الطويلة حتى ترى نتيجة الطباعة. حالما تتأكد من أن الأمور جيدة حتى نقطة معينة، عليك نقل تعليمة الطباعة إلى نقطة أعمق. وتذكر أن التصريح عن مكوِّن بعبارة واحدة أو عن دالة دون أن تعيد قيمة، سيجعل الطباعة على الطرفية أصعب. const Course = ({ course }) => ( <div> <Header course={course} /> </div> ) لذا غيًر تصريح المكوِّن إلى شكله النموذجي حتى تستطيع الطباعة: const Course = ({ course }) => { console.log(course) return ( <div> <Header course={course} /> </div> ) } قد يكون السبب الأساسي للمشكلة في أحيانٍ كثيرة متعلقًا بالخصائص التي تظهر بأنواع مختلفة عن المتوقع أو التي تستدعى بأسماء مختلفة عن أسمائها الفعلية. وكنتيجة لذلك، ستخفق عملية التفكيك. ويدل على ذلك التخلص من الأخطاء بمجرد إزالة عملية التفكيك، حيث يمكن معرفة المحتوى الفعلي للخصائص. const Course = (props) => { console.log(props) const { course } = props return ( <div> <Header course={course} /> </div> ) } إن لم تُحل المشكلة، لا يمكنك عندها سوى اصطياد الأخطاء بزرع تعليمة الطباعة console.log في أماكن مختلفة من الشيفرة. أضيفت هذه الفقرة لمادة المنهاج بعد أن أخفقت الوحدة التي تمثل حلًا للسؤال التالي بشكل كامل (نظرًا لخطأ في نمط الخصائص) وتوجب علي البحث عن السبب باستخدام تعليمة الطباعة. التمارين 2.1 - 2.5 تسلّم التمارين عبر GitHub ومن ثم يشار إلى إكمالها ضمن منظومة تسليم الملفات. يمكنك أن تضع جميع التمارين في مخزن واحد أو استخدم مخازن مختلفة. إذا وضعت تمارين الأقسام المختلفة في مخزن واحد، احرص على تسمية المجلدات ضمنه بشكل مناسب. تُسلَّم تمارين كل قسم دفعة واحدة، فلا يمكنك تسليم أية تمارين أنجزتها مؤخرًا من قسم ما إذا كنت قد سلمت غيرها من القسم ذاته. يضم هذا القسم تمارين أكثر من القسمين السابقين، لا تسلم أيًا منها حتى تنجزها كلها. تحذير: سيجعل create_react_app المشروع مستودع git إلا إذا أنشأت مشروعك داخل مستودع موجود مسبقًا. لن تريد فعل ذلك على الأغلب، لذا نفذ الأمر rm -rf .git عند جذر مشروعك. 2.1 معلومات عن المنهاج: الخطوة 6 سننهي في هذا التمرين تصيير محتوى المنهاج الذي استعرضناه في التمارين 1.1-1.5. يمكنك البدء من الشيفرة الموجودة في نماذج الأجوبة. ستجد نماذج أجوبة القسم 1 في منظومة تسليم الملفات. انقر على my submissions في الأعلى ثم انقر على show الموجودة ضمن العمود solutions في السطر المخصص للقسم 1. انقر على الملف index.js للاطلاع على حل تمرين "معلومات عن المنهاج course info" تحت عبارة kurssitiedot التي تعني (معلومات عن المنهاج). تنبيه: إذا نسخت مشروعًا من مكان لآخر، سيتوجب عليك حذف المجلد node_modules وإعادة تثبيت ملفات الارتباط (Dependencies) مرة أخرى بتنفيذ الأمر npm install قبل أن تشغل التطبيق. لا يفضل أن تنسخ المحتوى الكامل لتطبيق أو أن تضيف المجلد node_modules إلى منظومة التحكم بالإصدار أو أن تقوم بكلا العملين. لنغيّر الآن المكوِّن App كما يلي: const App = () => { const course = { id: 1, name: 'Half Stack application development', parts: [ { name: 'Fundamentals of React', exercises: 10, id: 1 }, { name: 'Using props to pass data', exercises: 7, id: 2 }, { name: 'State of a component', exercises: 14, id: 3 } ] } return <Course course={course} /> } عّرف مكوِّنًا يدعى Course مسؤولًا عن تنسيق منهاج واحد. يمكن أن يتخذ التطبيق الهيكلية التالية: App Course Header Content Part Part ... سيضم المكوِّن Course كل المكوِّنات التي عرّفناها في القسم السابق والمسؤولة عن تصيير اسم المنهاج وأقسامه. ستبدو الصفحة بعد التصيير على الشكل التالي: ليس عليك جمع عدد التمارين بعد. لكن يجب عليك التأكد من أن التطبيق يعمل بشكل جيد بغض النظر عن عدد الأقسام في المنهاج. أي أنه سيعمل لو أزلت أو أضفت أقسامًا. واحرص على عدم ظهور أخطاء على الطرفية. 2.2 معلومات عن المنهاج: الخطوة 7 أظهر عدد التمارين الكلية في المنهاج. 2.3 معلومات عن المنهاج: الخطوة 8 * إن لم تجد طريقة لحل التمرين السابق، احسب العدد الكلي للتمارين باستخدام التابع reduce الذي يستعمل مع المصفوفات. نصيحة للاحتراف 1: إن بدت شيفرتك بالشكل التالي: const total = parts.reduce((s, p) => someMagicHere) ولم تعمل، يجدر بك استخدام تعليمة الطباعة على الطرفية، والتي تتطلب إعادة كتابة الدالة السهمية بشكلها الكامل: const total = parts.reduce((s, p) => { console.log('what is happening', s, p) return someMagicHere }) نصيحة للاحتراف 2: يمكنك استخدام إضافة للمحرر VS يمكنها تحويل الدالة السهمية المختصرة إلى الكاملة وبالعكس. 2.4: معلومات عن المنهاج: خطوة 9 وسّع التطبيق ليعمل مع أي عدد افتراضي من المناهج: const App = () => { const courses = [ { name: 'Half Stack application development', id: 1, parts: [ { name: 'Fundamentals of React', exercises: 10, id: 1 }, { name: 'Using props to pass data', exercises: 7, id: 2 }, { name: 'State of a component', exercises: 14, id: 3 }, { name: 'Redux', exercises: 11, id: 4 } ] }, { name: 'Node.js', id: 2, parts: [ { name: 'Routing', exercises: 3, id: 1 }, { name: 'Middlewares', exercises: 7, id: 2 } ] } ] return ( <div> // ... </div> ) } يمكن أن يبدو التطبيق بالشكل التالي: 2.5 وحدات منفصلة صرح عن المكوِّن Course في وحدة منفصلة يمكن إدراجها في ملف المكوِّن App. يمكن أن تضع كل المكوِّنات الفرعية للمنهاج ضمن نفس الوحدة. ترجمة -وبتصرف- للفصل Rendering a collection,modules من سلسلة Deep Dive Into Modern Web Development
  24. حالة أكثر تعقيدًا كانت الحالة التي صادفتها في تطبيقنا السابق بسيطة لكونها متعلقة بعدد صحيح واحد. لكن ما العمل إن تطلب أحد التطبيقات التعامل مع حالة أكثر تعقيدًا؟ إنّ أكثر الطرق سهولة في إنجاز ذلك، هو استخدام دالة useState عدة مرات بحيث تتجزأ الحالة الكلية إلى "قطع" صغيرة. سنبني في الشيفرة التالية حالة من قطعتين لتطبيقنا تسمى الأولى "left" والأخرى "right" وستأخذ كل منهما 0 كقيمة ابتدائية. const App = (props) => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) return ( <div> <div> {left} <button onClick={() => setLeft(left + 1)}> left </button> <button onClick={() => setRight(right + 1)}> right </button> {right} </div> </div> ) } سنمنح المكوّن وصولًا إلى الدالتين setLeft وsetRight اللتان ستُستخدمَان لتحديث قطعتي الحالة. يمكننا تحقيق غرض التطبيق بحفظ عدد النقرات التي تحصل على كلا الزرين "left" و"right" ضمن كائن مفرد: { left: 0, right: 0 } وبهذا سيبدو التطبيق كالتالي: const App = (props) => { const [clicks, setClicks] = useState({ left: 0, right: 0 }) const handleLeftClick = () => { const newClicks = { left: clicks.left + 1, right: clicks.right } setClicks(newClicks) } const handleRightClick = () => { const newClicks = { left: clicks.left, right: clicks.right + 1 } setClicks(newClicks) } return ( <div> <div> {clicks.left} <button onClick={handleLeftClick}>left</button> <button onClick={handleRightClick}>right</button> {clicks.right} </div> </div> ) } يمتلك المكوّن الآن قطعة واحدة من الحالة، وستقع على عاتق معالجات الأحداث مهمة تغيير الحالة لكامل التطبيق. يبدو معالج الحدث فوضويًا بعض الشيء. فعندما يُنقر الزر "left" تُستدعى الدالة التالية: const handleLeftClick = () => { const newClicks = { left: clicks.left + 1, right: clicks.right } setClicks(newClicks) } وتُخزّن القيم الجديدة لحالة التطبيق ضمن الكائن التالي: { left: clicks.left + 1, right: clicks.right } ستحمل الخاصية left القيمة left+1، بينما ستبقى قيمة الخاصية right على ما كانت عليه في الحالة السابقة. يمكننا تعريف كائن الحالة الجديد بشكل أكثر أناقة مستخدمين صيغة توسيع الكائن (object spread) التي أضيفت إلى اللغة صيف 2018: const handleLeftClick = () => { const newClicks = { ...clicks, left: clicks.left + 1 } setClicks(newClicks) } const handleRightClick = () => { const newClicks = { ...clicks, right: clicks.right + 1 } setClicks(newClicks) } تبدو الصيغة غريبة بعض الشيء. حيث تنشئ العبارة {clicks... } كائنًا جديدًا يحمل نسخًا عن كل الخصائص التي يمتلكها الكائن clicks. فعندما نحدد خاصية معينة مثل right في العبارة {clicks,right: 1...}، ستكون القيمة الجديدة للخاصية right تساوي 1. لو تأملنا العبارة التالية في المثال السابق: {...clicks, right: clicks.right + 1 } سنجد أنها تنشئ نسخًا للكائن clicks، ستزداد فيها قيم الخاصية right بمقدار واحد. لا حاجة إلى إسناد قيمة الخاصية إلى متغيّر في معالج الحدث وبالتالي سنبسط كتابة الدوال إلى الشكل التالي: const handleLeftClick = () => setClicks({ ...clicks, left: clicks.left + 1 }) const handleRightClick = () => setClicks({ ...clicks, right: clicks.right + 1 }) قد يتساءل بعض القراء لماذا لم نحدّث الحالة مباشرة كما يلي: const handleLeftClick = () => { clicks.left++ setClicks(clicks) } فالتطبيق سيعمل أيضًا، لكن السبب أنّ React تمنع تغيير الحالة مباشرة، لأن ذلك قد يسبب أخطاء جانبية غير متوقعة. فالطريقة المعتمدة هي دائمًا إسناد الحالة إلى كائن جديد. إن لم يتغير الكائن الذي يضم الخصائص، فسُينسخ بكل بساطة إلى كائن جديد عن طريق نسخ خصائصه واعتماد الكائن الجديد ككائن للحالة. إنّ حفظ الحالة بكل قطعها في كائن واحد هو خيار سيئ في هذا التطبيق خصوصًا، فلن يقدّم الأمر أية فائدة وسيغدو التطبيق أكثر تعقيدًا. لذلك يفضل في حالات كهذه تقسيم الحالة إلى قطع أبسط. ستصادف حالات يكون فيها تخزين قطع من حالة التطبيق في بنًى أكثر تعقيدًا أمرًا مفيدًا، يمكنك الاطلاع على معلومات أكثر عن هذا الموضوع من خلال التوثيق الرسمي لمكتبة React. التعامل مع المصفوفات Handling arrays لنضف قطعة من الحالة إلى تطبيقنا. تتذكر المصفوفة allClicks كل نقرة على زر حدثت في التطبيق. const App = (props) => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) const handleLeftClick = () => { setAll(allClicks.concat('L')) setLeft(left + 1) } const handleRightClick = () => { setAll(allClicks.concat('R')) setRight(right + 1) } return ( <div> <div> {left} <button onClick={handleLeftClick}>left</button> <button onClick={handleRightClick}>right</button> {right} <p>{allClicks.join(' ')}</p> </div> </div> ) } ستُخزّن كل نقرة في القطعة allClicks والتي هُيِّئت على شكل مصفوفة فارغة: const [allClicks, setAll] = useState([]) سيُضاف الحرف 'L' إلى المصفوفة allClicks عندما يُنقر الزر "left": const handleLeftClick = () => { setAll(allClicks.concat('L')) setLeft(left + 1) } إذًا فقطعة الحالة التي أنشأناها هي مصفوفة تضم كل عناصر الحالة السابقة بالإضافة إلى الحرف 'L'. وتتم إضافة العناصر الجديدة باستخدام التابع concat الذي لا يعدل في المصفوفة، بل يعيد مصفوفةً جديدةً يضاف إليها العنصر الجديد. وكما ذكرنا سابقًا، يمكننا إضافة العناصر إلى مصفوفة باستخدام التابع push، حيث يمكن دفع العنصر داخل مصفوفة الحالة ثم تحديثها وسيعمل التطبيق: const handleLeftClick = () => { allClicks.push('L') setAll(allClicks) setLeft(left + 1) } لكن، لا تقم بذلك. فكما أشرنا سابقًا، لا يجب تغيير حالة مكوّنات React مثل allClicks مباشرةً. وحتى لو بدا الأمر ممكنًا، فقد يقودك إلى أخطاء من الصعب جدًا تنقيحها. لنلق الآن نظرة أقرب إلى الآلية التي يُصيّر بها التسلسل الزمني للنقرات: const App = (props) => { // ... return ( <div> <div> {left} <button onClick={handleLeftClick}>left</button> <button onClick={handleRightClick}>right</button> {right} <p>{allClicks.join(' ')}</p> </div> </div> ) } استدعت الشيفرة السابقة التابع join الذي يجعل كل عناصر المصفوفة ضمن سلسلة نصية واحدة يفصل بينها فراغ وهو المعامل الذي مُرِّر إلى التابع. التصيير الشرطي Conditional rendering لنعدّل التطبيق بحيث يتولى الكائن الجديد History أمر تصيير تاريخ النقر على الأزرار: const History = (props) => { if (props.allClicks.length === 0) { return ( <div> the app is used by pressing the buttons </div> ) } return ( <div> button press history: {props.allClicks.join(' ')} </div> ) } const App = (props) => { // ... return ( <div> <div> {left} <button onClick={handleLeftClick}>left</button> <button onClick={handleRightClick}>right</button> {right} <History allClicks={allClicks} /> </div> </div> ) } سيعتمد الآن سلوك التطبيق على أحداث النقر على الأزرار. إن لم تُنقر أية أزرار، سيعني ذلك أن مصفوفة الحالة allClicks فارغة. سيُصيّر عندها المكوّن العنصر div وقد ظهرت ضمنه تعليمات للمستخدم: <div>the app is used by pressing the buttons</div> //..يستخدم التطبيق بالنقر على الأزرار بقية الحالات سيصيّر المكوّن تاريخ النقر على الأزرار: <div> button press history: {props.allClicks.join(' ')} </div> يصيّر المكوّن History مكوّنات React مختلفةً كليًا وفقًا لحالة التطبيق. يدعى هذا بالتصيير الشرطي. تقدم React طرقًا عدة للتصيير الشرطي والتي سنلقي عليها نظرةً أقرب في القسم 2 لاحقًا من هذه السلسلة. سنجري تعديلًا أخيرًا على تطبيقنا بإعادة صياغته مستخدمين المكوّن Button الذي عرّفناه سابقًا: const History = (props) => { if (props.allClicks.length === 0) { return ( <div> the app is used by pressing the buttons </div> ) } return ( <div> button press history: {props.allClicks.join(' ')} </div> ) } const Button = ({ onClick, text }) => ( <button onClick={onClick}> {text} </button> ) const App = (props) => { const [left, setLeft] = useState(0) const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) const handleLeftClick = () => { setAll(allClicks.concat('L')) setLeft(left + 1) } const handleRightClick = () => { setAll(allClicks.concat('R')) setRight(right + 1) } return ( <div> <div> {left} <Button onClick={handleLeftClick} text='left' /> <Button onClick={handleRightClick} text='right' /> {right} <History allClicks={allClicks} /> </div> </div> ) } كلمة حول React القديمة نستخدم في هذا المنهاج state hook لإضافة حالة إلى مكوّنات React، حيث تعتبر الخطافات جزءًا من نسخ React الأحدث وتتوفر ابتداء من النسخة 16.8.0. لم تكن هناك طرق لإضافة حالة إلى دالة المكوّن قبل إضافة الخطافات، حيث عُرّفت المكوّنات التي تحتاج إلى حالة كصفوف class باستخدام الصيغة class. لقد قررنا في هذا المنهاج استخدام الخطافات منذ البداية لكي نضمن أننا نتعلم الأسلوب المستقبلي لمكتبة React. وعلى الرغم من أن المكوّنات المبنية على شكل دوال هي مستقبل React، لا يزال تعلم استخدام الأصناف مهمًا. فهناك المليارات من سطور الشيفرة المكتوبة بالأسلوب القديم، وقد ينتهي بك الأمر يومًا وأنت تنقح أحدها، أو قد تتعامل مع توثيق أو أمثلة عن React القديمة على الإنترنت. إذًا سنتعلم أكثر عن الأصناف لكن ليس الآن. تنقيح تطبيقات React يمضي المطورون وقتًا طويلًا في تنقيح وتصيير الشيفرات الموجودة، ويتوجب عليهم بين الفينة والأخرى كتابة أسطر جديدة. لكن بطبيعة الحال سنقضي جُلّ وقتنا باحثين عن سبب خطأ ما أو عن الطريقة التي يعمل بها مكوّن ما. لذلك فاقتناء أدوات للتنقيح والتمكّن من تحري الأخطاء أمر مهم للغاية. ولحسن الحظ تقدم مكتبة React عونًا منقطع النظير للمطورين فيما يتعلق بموضوع التنقيح. قبل أن نتابع، دعونا نتذكر القاعدة الأكثر أهمية في تطوير الويب، حيث تنص القاعدة الأولى في تطوير الويب على مايلي: وعليك أن تبقي المحرر الذي تكتب فيه الشيفرة مفتوحًا في نفس الوقت مع صفحة الويب ودائمًا. وتذكر عندما تُخفق الشيفرة ويمتلئ المتصفح بالأخطاء أن لا تكتب المزيد من الشيفرة بل ابحث عن الخطأ وصححه مباشرةً. لقد مر زمن كان فيه كتابة المزيد من الشيفرة تمثل حلًا سحريًا للأخطاء، لكننا نجزم أن شيئًا كهذا لن يحدث في هذا المنهاج. لا تزال الطريقة القديمة في التنقيح والتي تعتمد على طباعة القيم على الطرفية فكرة جيدة. const Button = ({ onClick, text }) => ( <button onClick={onClick}> {text} </button> ) إن لم يعمل المكوّن كما يفترض، اطبع قيم متغيراته على الطرفية. ولكي تنفذ ذلك بشكل فعّال، عليك تحويل الدوال إلى شكلٍ أقل اختصارًا وأن تستقبل قيم جميع الخصائص دون أن تفككها مباشرةً. const Button = (props) => { console.log(props) const { onClick, text } = props return ( <button onClick={onClick}> {text} </button> ) } سيكشف ذلك مباشرة أنّ اسم إحدى الخصائص مثلًا قد كُتب بطريقة خاطئة عند استخدام المكوّن. ملاحظة: عندما تستخدم التعليمة console.log للتنقيح، لا تستخدم أسلوبًا مشابهًا لشيفرة Java. فلو أردت ضم معلومات مختلفة في الرسالة مثل استخدام إشارة (+). لا تكتب: console.log('props value is ' + props) بل افصل بين الأشياء التي تريد طباعتها بفاصلة ",": console.log('props value is', props) فعندما تستخدم طريقة مشابهة لطريقة Java بضم سلسلة نصية إلى كائن سينتهي الأمر بالحصول على رسالة غير مفهومة: props value is [Object object] بينما ستبقى العناصر التي تفصل بينها باستخدام الفاصلة واضحة على طرفية المتصفح. لا تُعتبر طريقة الولوج إلى الطرفية على أية حال هي الطريقة الوحيدة لتنقيح التطبيقات. إذ يمكنك إيقاف تنفيذ الشيفرة في منقح طرفية التطوير لمتصفح Chrome مثلًا باستخدام الأمر debugger في أي مكان ضمن الشيفرة. حيث سيتوقف التنفيذ عندما تصل إلى النقطة التي وضعت فيها الأمر السابق: انتقل إلى النافذة Console لتتابع الحالة الراهنة للمتغيّرات بسهولة: يمكنك إزالة الأمر debugger وإعادة تحميل الصفحة حالما تكتشف الخطأ في شيفرتك. يمكّنك المنقح أيضًا من تنفيذ الشيفرة سطرًا سطرًا باستخدام عناصر التحكم الخاصة الموجودة على يمين النافذة source في الطرفية. لا حاجة لاستخدام الأمر debugger للوصول إلى المنقح، يمكنك عوضًا عن ذلك استخدام نقاط التوقف (break point) الموجودة في النافذة source، وتتبّع قيم متغيرات المكوّن في القسم Scope: ننصحك بشدة أن تضيف إضافة مُوسِّعة (extension) لمكتبة React على متصفح Chrome تدعى React developer tools، الذي سيضيف النافذة الجديدة React إلى الطرفية: سنستخدم أدوات تطوير React الجديدة في تتبع حالة العناصر المختلفة في التطبيق وخصائصها. لكن النسخة الحالية من هذا الموسِّع تُغفل عرض المطلوب عن حالة المكوّنات التي تنشئها الخطافات: عُرّفت حالة المكوّن بالشكل التالي: const [left, setLeft] = useState(0) const [right, setRight] = useState(0) const [allClicks, setAll] = useState([]) تُظهر أدوات التطوير حالة الخطافات حسب تسلسل تعريفها: قواعد استخدام الخطافات Rules of Hooks هناك قواعد وحدود معينة يجب أن نتقيد بها لنتأكد أن الحالة التي تعتمد على الخطافات في التطبيق ستعمل كما يجب. فلا يجب استدعاء التابع useState (وكذلك التابع useEffect الذي سنتعرف عليه لاحقًا) من داخل الحلقات أو العبارت الشرطية أو من أي موقع لا يمثل دالة لتعريف مكوّن. ذلك لنضمن أن الخطافات ستُستدعى وفق الترتيب ذاته لتعريفها وإلا سيتصرف التطبيق بشكل فوضوي. باختصار استدع الخطافات من داخل جسم الدوال المعرِّفة للمكوّنات: const App = (props) => { // الشيفرة التالية صحيحة const [age, setAge] = useState(0) const [name, setName] = useState('Juha Tauriainen') if ( age > 10 ) { // هذه الشيفرة لن تعمل const [foobar, setFoobar] = useState(null) } for ( let i = 0; i < age; i++ ) { // هذه غير جيدة أيضًا const [rightWay, setRightWay] = useState(false) } const notGood = () => { // هذه غير مشروعة const [x, setX] = useState(-1000) } return ( //... ) } عودة إلى معالجة الأحداث أثبت موضوع معالجة الأحداث صعوبته ضمن الأفكار المتسلسلة التي قدمناها حتى الآن، لذلك سنعود إليه مجددًا. لنفترض أننا نطور هذا التطبيق البسيط: const App = (props) => { const [value, setValue] = useState(10) return ( <div> {value} <button>reset to zero</button> </div> ) } ReactDOM.render( <App />, document.getElementById('root') ) المطلوب هو النقر على الزر لتصفير الحالة المخزّنة ضمن المتغّير value. علينا إضافة معالج حدث للزر لكي يتفاعل مع حدث النقر عليه. تذكر أن معالجات الأحداث يجب أن تكون دائمًا دوال أو مراجع لدوال. لن يعمل الزر إذا أسند معالج الحدث إلى متغيّر من أي نوع آخر. فلو عرفنا معالج الحدث على شكل نص كما يلي: <button onClick={'crap...'}>button</button> ستحذرنا React من ذلك على الطرفية: index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `string` type. in button (at index.js:20) in div (at index.js:18) in App (at index.js:27) وكذلك لن يعمل المعالج التالي: <button onClick={value + 1}>button</button> حيث نعرف معالج الحدث في هذه الحالة بالعبارة value+1 التي ستعيد قيمة العملية، وستحذرنا React أيضًا: index.js:2178 Warning: Expected `onClick` listener to be a function, instead got a value of `number` type. هذه الطريقة لن تنفع أيضًا: <button onClick={value = 0}>button</button> فمعالج الحدث هنا لا يمثل دالة بل عملية إسناد لمتغيّر، وستحذرنا React مجددًا على الطرفية. وتعتبر هذه العملية خاطئة أيضًا من مبدأ عدم تغيير الحالة بشكل مباشر. لكن ماذا لو فعلنا التالي: <button onClick={console.log('clicked the button')}> button </button> ستُطبع العبارة المكتوبة داخل الدالة لمرة واحدة ولن يحدث بعدها شيء عند إعادة نقر الزر. لماذا لم يعمل معالج الحدث إذًا على الرغم من وجود الدالة console.log؟ المشكلة هنا أن معالج الحدث قد عُرّف كاستدعاء لدالة، أي أنه سيعيد قيمة الدالة والتي ستكون "غير محددة" في حالة console.log. ستُنفَّذ هذه الدالة تحديدًا عندما يُعاد تصيير المكوّن، ولهذا لم تظهر العبارة سوى مرة واحدة. ستخفق المحاولة التالية أيضًا: <button onClick={setValue(0)}>button</button> ستجعل معالج الحدث استدعاءً لدالة أيضًا في هذه المحاولة، لن يفلح ذلك. ستسبب هذه المحاولة تحديدًا مشكلة من نوع آخر. فعندما يُصيّر المكوّن، ستُنفَّذ الدالة (setvalue(0 والتي بدورها ستُعيد تصيير المكوّن. إعادة التصيير هذه تستدعي مجددًا (setvalue(0 وهذا ما يسبب حلقة لانهائية من الاستدعاءات. تُستدعى دالة محددة عند النقر على الزر كالتالي: <button onClick={() => console.log('clicked the button')}> button </button> يمثل معالج الحدث الآن دالة معرفة بالطريقة السهمية (console.log('clicked the button <= (). فلن تُستدعى الآن أية دوال عندما يصيّر المكوّن، لأن معالج الحدث قد أسند إلى مرجع لدالة سهمية، ولن تُستدعى هذه الدالة إلا بعد النقر على الزر. يمكننا استخدام نفس الأسلوب لإضافة زر تصفير الحالة في التطبيق الذي بدأناه: <button onClick={() => setValue(0)}>button</button> لقد جعلت الشيفرة السابقة الدالة (setValue(0 <=() معالج الحدث للزر. لا يعتبر تعريف معالج الحدث مباشرة ضمن الخاصية onClick الطريقة الأفضل بالضرورة. حيث نجده أحيانًا معرفًا في مكان آخر. سنعرّف في النسخة التالية من التطبيق الدالة السهمية التي ستلعب دور معالج الحدث ضمن جسم المكوّن ومن ثم سنسندها إلى المتغيّر handleClick: const App = (props) => { const [value, setValue] = useState(10) const handleClick = () => console.log('clicked the button') return ( <div> {value} <button onClick={handleClick}>button</button> </div> ) } يعتبر المتغيّر handleClick مرجعًا للدالة السهمية، وسيُمرَّر هذا المرجع إلى الزر كقيمة للخاصية onClick: <button onClick={handleClick}>button</button> قد تضم دالة معالج الحدث أكثر من أمر، فلا بد عندها من وضع الأوامر بين قوسين معقوصين: const App = (props) => { const [value, setValue] = useState(10) const handleClick = () => { console.log('clicked the button') setValue(0) } return ( <div> {value} <button onClick={handleClick}>button</button> v> ) } الدالة التي تعيد دالة يمكن تعريف معالج حدث بطريقة الدالة التي تعيد دالة. وقد لا تضطر إلى استخدام هذا النوع من الدوال في هذا المنهاج. إن بدا لك الأمر مربكًا بعض الشيء تجاوز هذا المقطع الآن وعد إليه لاحقًا. لنعدل الشيفرة لتصبح كما يلي: const App = (props) => { const [value, setValue] = useState(10) const hello = () => { const handler = () => console.log('hello world') return handler } return ( <div> {value} <button onClick={hello()}>button</button> </div> ) } ستعمل هذه الشيفرة بشكل صحيح على الرغم من مظهرها المعقد. يستدعي معالج الحدث هنا دالة: <button onClick={hello()}>button</button> لكننا قررنا سابقًا أن لا يكون معالج الحدث استدعاء لدالة، بل يجب أن يكون دالة أو مرجعًا لها. لماذا إذًا سيعمل المعالج في هذه الحالة؟ عندما يُصيّر المكوّن، ستُنفّذ الدالة التالية: const hello = () => { const handler = () => console.log('hello world') return handler } إنّ القيمة المعادة من الدالة السهمية الأولى هي دالة سهمية أخرى أسندت إلى المتغيّر handler. فعندما تصيّر React السطر التالي: <button onClick={hello()}>button</button> ستُسنَد القيمة المعادة من ()hello إلى الخاصية onClick، وسيتحول السطر مبدئيًا إلى: <button onClick={() => console.log('hello world')}> button </button> إذًا نجح الأمر لأن الدالة hello قد أعادت دالة وسيكون معالج الحدث دالة أيضًا. لكن ما المغزى من هذا المبدأ؟ لنغير الشيفرة قليلًا: const App = (props) => { const [value, setValue] = useState(10) const hello = (who) => { const handler = () => { console.log('hello', who) } return handler } return ( <div> {value} <button onClick={hello('world')}>button</button> <button onClick={hello('react')}>button</button> <button onClick={hello('function')}>button</button> </div> ) } يضم التطبيق الآن ثلاثة أزرار لكل منها معالج حدث تُعرّفه الدالة hello التي تقبل معاملًا. عُرّف الزر الأول كما يلي: <button onClick={hello('world')}>button</button> يٌنشأ معالج الحدث باستدعاء الدالة hello التي تعيد الدالة التالية: () => { console.log('hello', 'world') } يُعرّف الزر التالي بالشكل: <button onClick={hello('react')}>button</button> سيعيد استدعاء الدالة (hello(react (التي تُنشئ معالج الحدث الخاص بالزر) مايلي: () => { console.log('hello', 'react') } لاحظ أن كلا الزرين حصل على معالج حدث خاص به. فاستخدام الدوال التي تعيد دوال قد يساعد على تعريف الدوال المعمّمة التي يمكن تغييرها حسب الطلب باستخدام المعاملات. حيث تمثل الدالة hello التي أنشأت معالجات الأحداث مصنعًا لإنتاج معالجات أحداث يحددها المستخدم كما يشاء. يبدو تعريف الدالة التالي طويلًا نوعا ما: const hello = (who) => { const handler = () => { console.log('hello', who) } return handler } لنحذف متغيّرات الدالة المساعدة ونعيد التابع الذي أنشأناه: const hello = (who) => { return () => { console.log('hello', who) } } وطالما أن الدالة hello مؤلفة من عبارة برمجية واحدة، سنحذف القوسين المعقوصين أيضًا ونستخدم الشكل المختصر للدالة السهمية: const hello = (who) => () => { console.log('hello', who) } أخيرًا لنكتب كل الأسهم على السطر نفسه: const hello = (who) => () => { console.log('hello', who) } يمكننا استخدام الحيلة ذاتها لتعريف معالجات الأحداث لإسناد قيمة معينة إلى حالة المكوّن، لنغيّر الشيفرة كالتالي: const App = (props) => { const [value, setValue] = useState(10) const setToValue = (newValue) => () => { setValue(newValue) } return ( <div> {value} <button onClick={setToValue(1000)}>thousand</button> <button onClick={setToValue(0)}>reset</button> <button onClick={setToValue(value + 1)}>increment</button> </div> ) } عندما يُصيّر المكوّن، سيُنشأ الزر thousand: <button onClick={setToValue(1000)}>thousand</button> يضبط معالج الحدث ليعيد قيمة العبارة (setValue(1000، والتي تمثلها الدالة التالية: () => { setValue(1000) } يُعرّف زر الزيادة بالطريقة: <button onClick={setToValue(value + 1)}>increment</button> يُنشأ معالج الحدث باستدعاء الدالة (setToValue(value + 1 والتي تستقبل قيمة متغير الحالة value كمعامل لها بعد زيادته بواحد. فلو كانت قيمة المتغير 10 سيكون معالج الحدث الذي سيُنشأ كالتالي: () => { setValue(11) } لبس ضروريًا استخدام (دوال تعيد دوال) لتقديم وظائف كهذه، فلو أعدنا الدالة setToValue المسؤولة عن تحديث الحالة إلى دالة عادية: const App = (props) => { const [value, setValue] = useState(10) const setToValue = (newValue) => { setValue(newValue) } return ( <div> {value} <button onClick={() => setToValue(1000)}> thousand </button> <button onClick={() => setToValue(0)}> reset </button> <button onClick={() => setToValue(value + 1)}> increment </button> </div> ) } يمكننا عندها تعريف معالج الحدث كدالة تستدعي الدالة setToValue باستخدام المعامل المناسب. وسيكون عندها معالج الحدث لتصفير الحالة من الشكل: <button onClick={() => setToValue(0)}>reset</button> وفي النهاية اختيار أي من الطريقتين السابقتين في تعريف معالج الحدث هو أمر شخصي. تمرير معالجات الأحداث إلى المكوّنات الأبناء لنجزِّء مكوّن الزر إلى أقسامه الرئيسية: const Button = (props) => ( <button onClick={props.handleClick}> {props.text} </button> ) يحصل المكوّن على دالة معالج الحدث الخاصة به من الخاصية handleClick ويأخذ النص الذي سيظهر عليه من الخاصية text. إنّ استخدام المكوّن Button أمر سهل، لكن علينا التأكد من اسم الصفة قبل تمرير الخاصية إلى المكوّن. لا تعرّف المكوّنات داخل المكوّنات لنبدأ بإظهار قيمة التطبيق ضمن المكوّن Display الخاص به. سنعدل الشيفرة بتعريف مكوّن جديد داخل المكوّن App: // هذا هو المكان الصحيح لتعريف المكون const Button = (props) => ( <button onClick={props.handleClick}> {props.text} </button> ) const App = props => { const [value, setValue] = useState(10) const setToValue = newValue => { setValue(newValue) } // لا تعرف المكونات داخل المكونات const Display = props => <div>{props.value}</div> return ( <div> <Display value={value} /> <Button handleClick={() => setToValue(1000)} text="thousand" /> <Button handleClick={() => setToValue(0)} text="reset" /> <Button handleClick={() => setToValue(value + 1)} text="increment" /> </div> ) } سيعمل التطبيق كما يبدو، لكن لا تعرّف مكوّنًا ضمن آخر. لن يقدم لك ذلك أية فائدة وسيقود إلى مشاكل عديدة. إذًا دعونا ننقل المكوّن Display إلى مكانه الصحيح خارج دالة المكوّن App: const Display = props => <div>{props.value}</div> const Button = (props) => ( <button onClick={props.handleClick}> {props.text} </button> ) const App = props => { const [value, setValue] = useState(10) const setToValue = newValue => { setValue(newValue) } return ( <div> <Display value={value} /> <Button handleClick={() => setToValue(1000)} text="thousand" /> <Button handleClick={() => setToValue(0)} text="reset" /> <Button handleClick={() => setToValue(value + 1)} text="increment" /> </div> ) } مواد جديرة بالقراءة تمتلئ شبكة الإنترنت بمواد متعلقة بمكتبة React. لكننا في هذا المنهاج سنستخدم الأسلوب الجديد. ستجد غالبية المواد المتوفرة أقدم مما نبغي، لكن ربما ستجد الروابط التالية مفيدة: دروس ومقالات حول React من أكاديمية حسوب توثيقات React الرسمية: تستحق هذه التوثيقات المطالعة في مرحلة ما، حيث يغدو لمعظم محتوياتها أهمية في سياق المنهاج، ماعدا تلك المتعلقة بالمكوّنات المعتمدة على الأصناف. بعض مناهج Egghead.io الأجنبية: مثل Start learning React وتتمتع بقيمة عالية، وقد جرى تحديثها مؤخرًا. ويعتبر كذلك The Beginner's Guide to React جيدًا نوعًا ما. حيث يقدم المنهاجان مبادئ سنستعرضها في منهاجنا لاحقًا. وانتبه إلى أن المنهاج الأول يستخدم مكوّنات الأصناف، بينما يستخدم الثاني طريقة الدوال الجديدة. التمارين 1.6- 1.14 سلّم حلول التمارين برفع الشيفرة إلى GitHub ثم أشر إلى إتمام التمارين على منظومة تسليم التمارين. وتذكر أن تسلم تمارين القسم بالكامل دفعة واحدة , فلو سلمت تمارين قسمٍ ما، لن تكون قادررًا على تسليم غيرها من القسم ذاته. تشكل بعض التمارين جزءًا من التطبيق ذاته، يكفي في هذه الحالة رفع النسخة النهائية من التطبيق. يمكنك إن أردت الإشارة إلى ذلك بتعليق في نهاية كل تمرين، لكنه أمر غير ملزم. تحذير تنشئ الأداة create-react-app مستودع git محلي يحتوي المشروع ، إلا إن كان في المجلد مستودع محلي سابق. من المرجح أنك لا تريد أن يغدو المشروع مستودعًا، لهذا نفذ الأمر التاليrm -rf .git في مسار المشروع. عليك أحيانًا تنفيذ الأمر التالي في نفس المكان: rm -rf node_modules/ && npm i 1.6 unicafe: الخطوة 1 تجمع Unicafe كمعظم الشركات آراء عملائها. ستكون مهمتك إنشاء تطبيق لجمع آراء العملاء. حيث يقدم التطبيق ثلاث خيارات هي جيد وعادي وسيء. يجب أن يُظهر التطبيق العدد الكلي للآراء في كل فئة. يمكن لتطبيقك أن يكون بالشكل التالي: ينبغي لتطبيقك العمل خلال جلسة واحدة للمتصفح، وبالتالي لا بأس إن اختفت المعلومات عند إعادة تحميل الصفحة. يمكن وضع الشيفرة في ملف index.js واحد، كما يمكن الاستفادة من الشيفرة التالية كنقطة للبدء: import React, { useState } from 'react' import ReactDOM from 'react-dom' const App = () => { // save clicks of each button to own state const [good, setGood] = useState(0) const [neutral, setNeutral] = useState(0) const [bad, setBad] = useState(0) return ( <div> code here </div> ) } ReactDOM.render(<App />, document.getElementById('root') ) 1.8 unicafe: الخطوة 2 وسّع تطبيقك ليضم إحصائيات أخرى حول آراء العملاء كالعدد الكلي للآراء و التقييم الوسطي (1 للجيد و 0 للعادي و -1 للسيء) والنسبة المئوية للآراء الإيجابية. 1.9 unicafe: الخطوة 3 أعد صياغة تطبيقك بحيث توضع الإحصائيات في مكوّنها الخاص Statistics. ابق حالة التطبيق داخل المكوّن الجذري App وتذكر ألا تعرّف المكوّنات داخل المكوّنات الأخرى: // a proper place to define a component const Statistics = (props) => { // ... } const App = () => { const [good, setGood] = useState(0) const [neutral, setNeutral] = useState(0) const [bad, setBad] = useState(0) // do not define a component within another component const Statistics = (props) => { // ... } return ( // ... ) } 1.9 unicafe: الخطوة 4 غيّر تطبيقك لتعرض الإحصائيات حالما نحصل على رأي المستخدم. 1.10 unicafe: الخطوة 5 سنكمل إعادة صياغة التطبيق. إفصل المكوّنين التاليين ليقوما بالتالي: المكوّن Button سيُعرِّف الأزرار التي تُستخدم للحصول على الرأي. المكوّن Statistic ليظهر إحصائية واحدة مثل متوسط التقييم. لنوضح الأمر أكثر: يظهر المكوّن Statistic دائمًا إحصائية واحدة، بمعنى أن التطبيق يستخدم مكوّنات متعددة ليصيّر كل الإحصائيات كما يظهر في الشيفرة التالية: const Statistics = (props) => { /// ... return( <div> <Statistic text="good" value ={...} /> <Statistic text="neutral" value ={...} /> <Statistic text="bad" value ={...} /> // ... </div> ) } يجب أن تُبق حالة التطبيق ضمن المكوّن App. 1.11 unicafe: الخطوة 6 * اعرض الإحصائيات في جدول HTML ليبدو تطبيقك مماثلًا بشكل ما للصفحة التالية: تذكر أن تبقي الطرفية مفتوحةً دائمًا، وإن رأيت هذا الخطأ: حاول قدر استطاعتك التخلص من التحذيرات التي ستظهر لك. حاول البحث في Google عن رسائل الخطأ التي تصادفك إن لم تتمكن من إيجاد الحل. فمثلًا، المصدر النموذجي للخطأ: Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist هو موسّع للمتصفح Chrome. ادخل إلى /chrome://extensions ثم الغ تفعيل الموسّعات واحدًا تلو الآخر حتى تقف على الموسّع الذي سبب الخطأ، ثم أعد تحميل الصفحة. من المفترض أن يصحح ذلك الخطأ. احرص من الآن فصاعدًا أن لا ترى أية تحذيرات على متصفحك 1.12 طرائف: خطوة 1 * يمتلئ عالم هندسة البرمجيات بالطرائف التي تختصر الحقائق الخالدة في هذا المجال في سطر واحد قصير. وسّع التطبيق التالي بإضافة زر يعرض بالنقر عليه طرفة مختارة عشوائيًا من مجال هندسة البرمجيات: import React, { useState } from 'react' import ReactDOM from 'react-dom' const App = (props) => { const [selected, setSelected] = useState(0) return ( <div> {props.anecdotes[selected]} </div> ) } const anecdotes = [ 'If it hurts, do it more often', 'Adding manpower to a late software project makes it later!', 'The first 90 percent of the code accounts for the first 90 percent of the development time...The remaining 10 percent of the code accounts for the other 90 percent of the development time.', 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', 'Premature optimization is the root of all evil.', 'Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.' ] ReactDOM.render( <App anecdotes={anecdotes} />, document.getElementById('root') ) ابحث في Google عن طريقة توليد الأرقام العشوائية في JavaScript. ولا تنس أن تجرب توليد الأرقام العشوائية بعرضها مباشرة على طرفيّة المتصفح. سيبدو التطبيق عندما ينتهي قريبًا من الشكل التالي: تحذير تنشئ الأداة create-react-app مستودع git محلي يحتوي المشروع ، إلا إن كان في المجلد مستودع محلي سابق. من المرجح أنك لا تريد أن يغدو المشروع مستودعًا، لهذا نفذ الأمر التاليrm -rf .git في مسار المشروع. 1.13 طرائف: خطوة 2 * وسّع تطبيقك بحيث يمكنك التصويت لصالح الطرفة المعروضة. ملاحظة: خزن نتائج التصويت على كل طرفة في مصفوفة كائنات ضمن مكوّن الحالة. وتذكر أن الطريقة الصحيحة لتحديث حالة التطبيق المخزنة في البنى المعقدة للبيانات كالمصفوفات والكائنات هو إنشاء نسخة للحالة. يمكنك نسخ الكائن كما يلي: const points = { 0: 1, 1: 3, 2: 4, 3: 2 } const copy = { ...points } // زيادة قيمة الخاصية 2 بمقدار 1 copy[2] += 1 أو نسخ مصفوفة على النحو: const points = [1, 4, 6, 3] const copy = [...points] // زيادة القيمة في الموقع 2 من المصفوفة بمقدار 1 copy[2] += 1 استخدم المصفوفة فقد يكون ذلك الخيار الأبسط في هذه الحالة. سيساعدك البحث في Google على إيجاد الكثير من التلميحات عن كيفية إنشاء مصفوفة من الأصفار بالطول الذي تريد. 1.14 طرائف: الخطوة 3* اجعل النسخة النهائية للتطبيق قادرة على عرض الطرفة التي تحقق أعلى عدد من الأصوات: يكفي أن تعرض إحدى الطرائف التي تحقق نفس العدد من الأصوات. لقد وصلنا إلى نهاية تمارين القسم 1 من المنهاج وحان الوقت لتسليم الحلول إلى GitHub. لا تنسى تحديد التمارين التي سلمتها في منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل JavaScript من سلسلة Deep Dive Into Modern Web Development
  25. لنعد إلى العمل مع React، ولنبدأ بمثالٍ جديد: const Hello = (props) => { return ( <div> <p> Hello {props.name}, you are {props.age} years old </p> </div> ) } const App = () => { const name = 'Peter' const age = 10 return ( <div> <h1>Greetings</h1> <Hello name="Maya" age={26 + 10} /> <Hello name={name} age={age} /> </div> ) } الدوال المساعدة لمكون لنوسع المكوّن Hello بحيث يحدد العام الذي ولد فيه الشخص الذي نحيّيه: const Hello = (props) => { const bornYear = () => { const yearNow = new Date().getFullYear() return yearNow - props.age } return ( <div> <p> Hello {props.name}, you are {props.age} years old </p> <p>So you were probably born in {bornYear()}</p> </div> ) } وُضعت الشيفرة التي تحدد عام الولادة ضمن دالة منفصلة تُستدعى حين يُصيَّر المكوّن. لا داعي لتمرير عمر الشخص إلى الدالة كمُعامل لأنها قادرة على الوصول إلى الخصائص (props) مباشرة. لو تفحّصنا الشيفرة المكتوبة عن قرب، لوجدنا أن الدالة المساعدة (helper function) قد عُرّفت داخل دالة أخرى تحدد عمل المكوّن. لا يُستخدَم هذا الأسلوب في Java كونه أمرًا معقدًا ومزعجًا، لكنه مستخدم جدًا في JavaScript. التفكيك Destructuring قبل أن نمضي قدمًا، سنلقي نظرة على ميزة بسيطة لكنها مفيدة أضيفت في النسخة ES6. تسمح هذه الميزة بتفكيك القيم عن الكائنات والمصفوفات عند إسنادها. لقد ربطنا البيانات التي مررناها إلى المكوّن مستخدمين العبارتين props.name وprops.age. كما كررنا أيضا العبارة props.age مرتين في الشيفرة. وطالما أن props هو كائن له الشكل التالي: props = { name: 'Arto Hellas', age: 35, } سنجعل المكوّن أكثر حيوية بإسناد قيمتي خاصيّتيه مباشرة إلى المتغّيرين name وage، ومن ثم نستخدمهما في الشيفرة: const Hello = (props) => { const name = props.name const age = props.age const bornYear = () => new Date().getFullYear() - age return ( <div> <p>Hello {name}, you are {age} years old</p> <p>So you were probably born in {bornYear()}</p> </div> ) } لاحظ كيف استخدمنا العبارة المختصرة للدوال السهمية عندما عرفنا الدالة bornYear. لقد أشرنا إلى هذه النقطة سابقًا، فلا حاجة لوضع جسم الدالة السهمية بين قوسين معقوصين إذا احتوت على عبارة واحدة فقط، حيث تعيد الدالة نتيجة تنفيذ تلك العبارة. وبالتالي فالطريقتان المستخدمتان في تعريف الدالتين التاليتين متطابقتان: const bornYear = () => new Date().getFullYear() - age const bornYear = () => { return new Date().getFullYear() - age } إذًا تُسهّل عملية التفكيك إسناد القيم للمتغيّرات، حيث نستخلص قيم خصائص الكائنات ونضعها في متغيّرات منفصلة: const Hello = (props) => { const { name, age } = props const bornYear = () => new Date().getFullYear() - age return ( <div> <p>Hello {name}, you are {age} years old</p> <p>So you were probably born in {bornYear()}</p> </div> ) } فلو افترضنا أنّ الكائن الذي نفككه يمتلك القيم التالية: props = { name: 'Arto Hellas', age: 35, } ستُسنِد العبارة const { name, age } = props القيمة "Arto Hellas" إلى المتغيّر name والقيمة "35" إلى المتغيّر age. سنتعمق قليلًا في فكرة التفكيك: const Hello = ({ name, age }) => { const bornYear = () => new Date().getFullYear() - age return ( <div> <p> Hello {name}, you are {age} years old </p> <p>So you were probably born in {bornYear()}</p> </div> ) } لقد فككت الشيفرة السابقة الخاصيتين اللتين يمتلكهما المكوّن وأسندتهما مباشرة إلى المتغيّرين name وage. أي باختصار لم نسند الكائن الذي يدعى props إلى متغيّر يدعى props ثم أسندت خاصيتيه إلى المتغيّرين name وage كما يحدث في الشيفرة التالية: const Hello = (props) => { const { name, age } = props بل أسندت قيمتي الخاصيتين مباشرة إلى المتغيّرات بتفكيك الكائن props أثناء تمريره كمُعامل لدالة المكوًن: const Hello = ({ name, age }) => { إعادة تصيير الصفحة Page re-rendering لم تطرأ أية تغييرات على مظهر التطبيقات التي كتبناها حتى الآن. ماذا لو أردنا أن ننشئ عدادًا تزداد قيمته مع الوقت أو عند النقر على زر؟ لنبدأ بالشيفرة التالية: const App = (props) => { const {counter} = props return ( <div>{counter}</div> ) } let counter = 1 ReactDOM.render( <App counter={counter} />, document.getElementById('root') ) تُمرَّر قيمة العدّاد إلى المكوّن App عبر الخاصيّة counter، ثم سيعمل على تصييرها على الشاشة. لكن ما الذي سيحدث لو تغيرت قيمة counter؟ حتى لو زدنا قيمتها تدريجيًا كالتالي: counter += 1 فلن يصيّر المكوّن القيمة الجديدة على الشاشة. لحل المشكلة، علينا استدعاء التابع ReactDOM.render حتى يُعاد تصيير الصفحة مجددًا بالطريقة التالية: const App = (props) => { const { counter } = props return ( <div>{counter}</div> ) } let counter = 1 const refresh = () => { ReactDOM.render(<App counter={counter} />, document.getElementById('root')) } refresh() counter += 1 refresh() counter += 1 refresh() غُلّف أمر إعادة تصيير الصفحة داخل الدالة refresh لتَسهُل كتابة الشيفرة. لاحظ كيف أعيد تصيير المركب ثلاث مرات، الأولى عندما كانت قيمة العدّاد 1 ثم 2 وأخيرًا 3. وطبعًا لن تلاحظ ظهور القيمتين 1 و2 نظرًا لفترة ظهورهما القصيرة جدًا. يمكننا أن نجعل الأداء أفضل قليلًا بتصيير الصفحة وزيادة العدّاد كل ثانية مستخدمين الدالة setInterval التي تنفذ ما بداخلها خلال فترة زمنية محددة بالميلي ثانية: setInterval(() => { refresh() counter += 1 }, 1000) وأخيرًا لا تكرر استدعاء التابع ReactDOM.render، ولا ننصحك بذلك، لأنك ستتعلم لاحقًا طريقةً أفضل. مكوّن متغير الحالات Stateful component تبدو جميع المكوّنات التي تعاملنا معها حتى الآن بسيطة من مبدأ أنها لم تحتوي على حالات قد تتغير خلال فترة عمل المكوّن، لذلك سنضيف الآن حالة إلى المكوّن App بمساعدة أحد خطافات React وهو state hook. فلنقم إذًا بتعديل التطبيق على النحو: import React, { useState } from 'react'import ReactDOM from 'react-dom' const App = () => { const [ counter, setCounter ] = useState(0) setTimeout( () => setCounter(counter + 1), 1000 ) return ( <div>{counter}</div> ) } ReactDOM.render( <App />, document.getElementById('root') ) يُدرِج التطبيق في السطر الأول الدالة useState باستخدام الكلمة import: import React, { useState } from 'react' تبدأ التعليمات داخل دالة المكوّن باستدعاء دالة أخرى كما يلي: const [ counter, setCounter ] = useState(0) تضيف الدالة المستدعاة useState "حالة" وتصيّرها بعد إعطائها قيمة ابتدائية تساوي 0. بعدها تعيد الدالة مصفوفة تضم عنصرين تسندهما إلى المتغيّرين counter وsetCounter مستخدمةً الإسناد بالتفكيك. سيحمل المتغيّر counter القيمة الابتدائية للحالة وهي 0، بينما سيحمل setCounter القيمة التي تعيدها الدالة المغيّرة للحالة. يستدعي التطبيق بعد ذلك الدالة setTimeout ويمرر لها مُعاملان أحدهما دالة لزيادة قيمة العدّاد، والآخر لتحديد وقت الانتهاء (timeout) بثانية واحدة: setTimeout( () => setCounter(counter + 1), 1000 ) تُستدعى الدالة setCounter التي مُررت كمُعامل للدالة setTimeout بعد ثانية من استدعاء الأخيرة. () => setCounter(counter + 1) تعيد React تصييرالمكوّن بعد استدعاء الدالة setCounter والتي تعتبر هنا الدالة المغيّرة للحالة (كونها غيرت قيمة العدّاد)، ويعني هذا إعادة تنفيذ الشيفرة في دالة المكوّن: (props) => { const [ counter, setCounter ] = useState(0) setTimeout( () => setCounter(counter + 1), 1000 ) return ( <div>{counter}</div> ) } عندما تُنفَّذ دالة المكوّن ثانيةً، سيستدعي الدالة useState ثم يعيد القيمة الجديدة للحالة:1. وكذلك الأمر سيُستدعى setTimeout مجددًا والذي سيستدعي بدوره setCounter بعد انقضاء ثانية وسيزيد العدّاد هنا بمقدار 1 ليصبح 2 بعد أن أصبحت قيمة counter تساوي 1. () => setCounter(2) خلال هذه العملية ستظهر على الشاشة القيمة الأقدم للعدّاد. وهكذا سيعاد تصيير الكائن في كل مرة تغيّر فيها الدالة setCounter حالة المكوّن، وستزداد قيمة العدّاد مع تكرار العملية طالما أن التطبيق يعمل. إن لم تحدث عملية إعادة التصيير في الوقت المحدد أو ظننت أنها تحصل في توقيت خاطئ، يمكنك تنقيح (Debug) التطبيق بإظهار قيم المتغيّرات على الطرفية بالطريقة التالية: const App = () => { const [ counter, setCounter ] = useState(0) setTimeout( () => setCounter(counter + 1), 1000 ) console.log('rendering...', counter) return ( <div>{counter}</div> ) } فمن السهل عندها تتبع ومراقبة استدعاءات دالة التصيير: معالجة الأحداث Event handling أشرنا في القسم 0 في مواضع عدة إلى معالجات الأحداث، وهي دوال تُستدعَى عندما يقع حدث ما في زمن التشغيل. فعندما يتعامل المستخدم مع العناصر المختلفة لصفحة الويب، سيحرّض ذلك مجموعة مختلفة من الأحداث. لنعدّل تطبيقنا قليلًا بحيث تزداد قيمة العدّاد عندما ينقر المستخدم على زر، سنستخدم هنا العنصر button. تدعم هذه العناصر ما يسمى بأحداث الفأرة (mouse events)، وأكثر هذه الأحداث شيوعًا هو حدث النقر على الزر(click). يُسجَّل معالج حدث النقر في React كالتالي: const App = () => { const [ counter, setCounter ] = useState(0) const handleClick = () => { console.log('clicked') } return ( <div> <div>{counter}</div> <button onClick={handleClick}> plus </button> </div> ) } نحدد من خلال الصفة onClick للعنصر button أن الدالة handleClick ستتولى معالجة حدث النقر على زر الفأرة. وهكذا ستُستدعَى الدالة handleClick في كل مرة ينقر فيها المستخدم على الزر plus، وستظهر الرسالة "clicked" على طرفية المتصفح. كما يُعرّف معالج الحدث أيضًا باسناده مباشرة إلى الصفة onClick: const App = () => { const [ counter, setCounter ] = useState(0) return ( <div> <div>{counter}</div> <button onClick={() => console.log('clicked')}> plus </button> </div> ) } وبتغيير الشيفرة التي ينفذها معالج الحدث لتصبح على النحو: <button onClick={() => setCounter(counter + 1)}> plus </button> سنحصل على السلوك المطلوب وهو زيادة العدّاد بمقدار 1 وإعادة تصييرالمكوّن. سنضيف الآن زرًا آخر لتصفير العدّاد: const App = () => { const [ counter, setCounter ] = useState(0) return ( <div> <div>{counter}</div> <button onClick={() => setCounter(counter + 1)}> plus </button> <button onClick={() => setCounter(0)}> zero </button> </div> ) } وهكذا سيكون تطبيقنا جاهزًا الآن. معالج الحدث هو دالة لقد عرفنا سابقًا معالج الحدث كقيمة للصفة onClick لعنصر الزر (button): <button onClick={() => setCounter(counter + 1)}> plus </button> لكن ماذا لو عرّفنا معالج الحدث بشكل أبسط كما يلي: <button onClick={setCounter(counter + 1)}> plus </button> سيدمر هذا العمل التطبيق: ماذا حدث؟ يفترض أن يكون معالج الحدث دالة أو مرجعًا إلى دالة، فعندما نعرّفه على النحو: <button onClick={setCounter(counter + 1)}> يمثل معالج الحدث بهذه الطريقة استدعاءً لدالة، وقد يكتب بالشكل السابق، لكن ليس في حالتنا الخاصة هذه. حيث تكون قيمة counter في البداية 0، ثم تُصيّر React التابع للمرة الأولى مستدعيةً الدالة (setCounter(0+1 التي ستُغير الحالة من 0 إلى 1، وفي نفس الوقت تعيد React تصييرالمكوّن مستدعيةً من جديدsetCounter، وستتغير الحالة مجددًا يعقبها إعادة تصيير وهكذا. لنعد الآن إلى تعريف معالج الحدث بالشكل الصحيح: <button onClick={() => setCounter(counter + 1)}> plus </button> تمتلك الآن الصفة onClick التي تحدد ما يجري عند النقر على الزر، القيمة الناتجة عن تنفيذ (setCounter(counter+1)<=() وبذلك تزداد قيمة العدّاد فقط عندما ينقر المستخدم على الزر. لا يعتبر تعريف معالجات الأحداث باستخدام قوالب JSX فكرة جيدة. لا بأس بذلك في حالتنا هذه لأن معالج الحدث بسيط جدًا. لنقم بفصل معالج الحدث إلى عدة دوال: const App = () => { const [ counter, setCounter ] = useState(0) const increaseByOne = () => setCounter(counter + 1) const setToZero = () => setCounter(0) return ( <div> <div>{counter}</div> <button onClick={increaseByOne}> plus </button> <button onClick={setToZero}> zero </button> </div> ) } لقد عُرّف معالج الحدث في الشيفرة السابقة بشكلٍ صحيح، حيث أُسند للصفة onClick متغيّر يتضمن مرجعًا لدالة: <button onClick={increaseByOne}> plus </button> تمرير الحالة إلى المكوّنات الأبناء (child components) يفضل عند كتابة مكونات React أن تكون صغيرة وقابلة للاستخدام المتكرر ضمن التطبيق وحتى عبر المشاريع المختلفة. لنُعِد صياغة تطبيقنا إذًا حتى يضم ثلاث مكونات أصغر، الأول لإظهار العدّاد والآخرين للزرين. لنبدأ بالمكون Display الذي سيكون مسؤولًا عن إظهار قيمة العدّاد على الشاشة. من الأفضل الإبقاء على حالة التطبيق ضمن مكونات المستوى الأعلى (lift the state up)، حيث ينص التوثيق على ما يلي: لذا سنضع حالة التطبيق في المكوّن App، ونمرره نزولًا إلى المكوّن Display عبر الخصائص: const Display = (props) => { return ( <div>{props.counter}</div> ) } سنستخدم المكوّن مباشرة، إذ علينا فقط أن نمرر إليه حالة المتغيّر counter: const App = () => { const [ counter, setCounter ] = useState(0) const increaseByOne = () => setCounter(counter + 1) const setToZero = () => setCounter(0) return ( <div> <Display counter={counter}/> <button onClick={increaseByOne}> plus </button> <button onClick={setToZero}> zero </button> </div> ) } سيجري كل شيء بشكل طبيعي، فحين ننقر الزر سيعاد تصيير المكوّن App وكذلك المكوّن الابنDisplay. سننشئ تاليًا المكوّن Button ليمثل أزرار التطبيق. علينا أن نمرر معالجات الأحداث وعنوان الزر عبر خصائص المكوّن: const Button = (props) => { return ( <button onClick={props.handleClick}> {props.text} </button> ) } سيبدو المكوّن App الآن بهذا الشكل: const App = () => { const [ counter, setCounter ] = useState(0) const increaseByOne = () => setCounter(counter + 1) const decreaseByOne = () => setCounter(counter - 1) const setToZero = () => setCounter(0) return ( <div> <Display counter={counter}/> <Button handleClick={increaseByOne} text='plus' /> <Button handleClick={setToZero} text='zero' /> <Button handleClick={decreaseByOne} text='minus' /> </div> ) } طالما أننا حصلنا على المكوّن Button الذي يمكن استخدامه بسهولة أينما كان، سنزيد من إمكانية التطبيق بإضافة زر جديد ليُنقص قيمة العدّاد. يمرر معالج الحدث إلى المكوّن Button عبر الخاصية handleClick. ليس مهمًا الاسم الذي اطلقناه على الخاصية، لكننا لم نختره عشوائيًا أيضًا، فقد اقترح أسلوب التسمية هذا في الدورة التعليمية الرسمية لمكتبة React. تغّير الحالة يستوجب إعادة التصيير لنستعرض المبدأ الرئيسي لعمل التطبيق مرّة أخرى. عندما يبدأ العمل ستُنفَّذ شيفرة المكوّن App. تستخدم تلك الشيفرة الخطاف useState لبناء حالة التطبيق وتحدد القيمة البدائية للمتغيّر counter. يضم هذا المكوّن مكوّنًا آخر هو Display الذي يعرض القيمة البدائية 0 للعداد، كما يضم ثلاثة مكونات Button، ولكل ٍّ منها معالج حدث يغيّر حالة العدّاد. فعندما يُنقر أي زر سيُنفَّذ معالج الحدث المقابل والذي يستخدم الدالة setCounter لتغيير حالة المكوّن App. ودائمًا استدعاء الدالة التي تغير حالة المكوّن يستوجب إعادة التصيير. فلو نقر المستخدم زر الزيادة (زر عنوانه plus)، سيغيّر معالج الحدث الخاص به قيمة العدّاد إلى 1، وسيعاد تصيير المكوّن App. سيستقبل المكوّن Display أيضًا قيمة العدّاد الجديدة كخاصية، كما سيُزوَّد المكوّن Button بمعالج حدث سيُستخدم لتغيير حالة العدّاد. إعادة تصميم المكوّنات Refactoring the components يظهر المكوِّن الذي يعرض قيمة العدّاد على الصفحة بالشكل التالي: const Display = (props) => { return ( <div>{props.counter}</div> ) } يستخدم المكوّن عمليًا الحقل counter فقط من الخصائص، لهذا يمكن تبسيطه باستخدام التفكيك كما يلي: const Display = ({ counter }) => { return ( <div>{counter}</div> ) } يضم التابع الذي يعرّف المكوّن العبارة التي تعيد نتيجة التنفيذ فقط، لذا يمكننا كتابته بالطريقة المختصرة التي تقدمها الدوال السهمية: const Display = ({ counter }) => <div>{counter}</div> لنبسط أيضًا كتابة المكوّن Button: const Button = (props) => { return ( <button onClick={props.handleClick}> {props.text} </button> ) } وهكذا نرى فائدة التفكيك في استخدام الحقل المطلوب فقط من الخصائص، وسهولة كتابة دوال بشكل مختصر بالاستفادة من الدوال السهمية: const Button = ({ handleClick, text }) => ( <button onClick={handleClick}> {text} </button> ) ترجمة -وبتصرف- للفصل JavaScript من سلسلة Deep Dive Into Modern Web Development
×
×
  • أضف...