-
المساهمات
164 -
تاريخ الانضمام
-
تاريخ آخر زيارة
نوع المحتوى
ريادة الأعمال
البرمجة
التصميم
DevOps
التسويق والمبيعات
العمل الحر
البرامج والتطبيقات
آخر التحديثات
قصص نجاح
أسئلة وأجوبة
كتب
دورات
كل منشورات العضو ابراهيم الخضور
-
توجد طرق عدة لاختبار تطبيقات 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
-
عرض واجهة تسجيل الدخول في الحالات الملائمة لنعدل التطبيق بحيث لا تظهر واجهة تسجيل الدخول بشكل افتراضي: ستظهر الواجهة عندما ينقر المستخدم على الزر "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
-
لقد انصب اهتمامنا في القسمين السابقين على الواجهة الخلفية بشكل رئيسي، فلم تُدعم الواجهة الأمامية بوظائف إدارة المستخدمين التي أضفناها إلى الواجهة الخلفية في القسم 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
-
على المستخدمين تسجيل دخولهم إلى التطبيق، ومن المفترض عندما يحدث ذلك، أن ترتبط معلوماتهم تلقائيًا بالملاحظات التي سينشئونها. لذا سنضيف آلية تحقق مبنية على الاستيثاق إلى الواجهة الخلفية. يمكن تقديم أساسيات التحقق المبني على الاستيثاق من خلال المخطط التتابعي التالي: يسجل المستخدم دخوله أولًا باستخدام نافذة تسجيل الدخول التي أضيفت إلى تطبيق 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
-
سنحتاج إلى وسيلة للتحقق من المستخدم ومن الصلاحيات الممنوحة له في تطبيقنا. وينبغي أن تُحفظ بيانات المستخدمين في قاعدة البيانات وأن ترتبط بالمستخدم الذي أنشأها. فلن يُسمح بتعديل أو حذف الملاحظة إلا من قبل المستخدم الذي أنشأها. لنبدأ بإضافة معلومات عن المستخدمين إلى قاعدة البيانات. حيث ستظهر علاقة من الشكل "واحد إلى عدة" بين المستخدم 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
-
سنبدأ الآن بكتابة الاختبارات الخاصة بالواجهة الخلفية. وطالما أن منطق شيفرة الواجهة الخلفية ليس بهذا التعقيد، فلن يكون هناك معنى لكتابة اختبارت لأجزاء الشيفرة. لكن الشيء الوحيد الذي يمكن أن نجري عليه اختبار الأجزاء، هو التابع 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
-
سنعود للعمل على نسخة الواجهة الخلفية لتطبيق الملاحظات الذي بدأناه في القسم 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
-
علينا في الواقع، أن نطبق مجموعة من القيود على البيانات التي ستخزن في قاعدة بيانات التطبيق. فلا ينبغي أن نسمح بوجود ملاحظات مفقودة المحتوى أو لا تملك الخاصية 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
-
قبل أن ننتقل إلى الموضوع الرئيسي في هذا الفصل، سنلقي نظرة على بعض الطرق المتبعة في تنقيح تطبيقات 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
-
سنربط في الفقرات التالية تطبيق الواجهة الأمامية الذي أنشأناه في القسم السابق من هذه السلسلة مع تطبيق الواجهة الخلفية. رأينا في القسم السابق، أن الواجهة الأمامية قادرة على الوصول إلى قائمة بكل الملاحظات من خادم 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
-
سنوجّه اهتمامنا في هذا القسم إلى التعامل مع الواجهة الخلفية وآلية تطوير التطبيق بإضافة وظائف جديدة تنفذ هذه المرة من قبل الخادم. سنبني تلك الوظائف باستخدام 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
- 1 تعليق
-
- 2
-
-
يبدو مظهر تطبيقنا الحالي متواضعًا. لقد طلبنا منك سابقًا في التمرين 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
-
من الطبيعي عندما ننشئ ملاحظات جديدة في تطبيقنا، أن نحفظ هذه الملاحظات على الخادم. تَعتبِر حزمة خادم 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
-
انصب تركيزنا حتى هذه اللحظة على كتابة الشيفرة للعمل مع الواجهة الأمامية (العمل من جهة المستخدم أو المتصفح)، وسنبدأ العمل على الواجهة الخلفية (جهة الخادم) عند الوصول إلى الفصل الثالث. مع ذلك سنخطو بداية خطواتنا بتعلم طريقة التواصل بين الشيفرة المكتوبة للمتصفح مع الخادم. سنستعمل عند تطويرنا التطبيقات أداة تدعى 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
-
سنوسّع تطبيقنا وذلك بالسماح للمستخدمين أن يضيفوا ملاحظات جديدة. من الأفضل هنا أن نخزّن الملاحظات داخل حالة التطبيق 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
-
لنلقي نظرة سريعة على المواضيع التي بدت صعبة في النسخة السابقة من المنهاج قبل أن ننتقل إلى موضوع جديد. التعليمة 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
-
حالة أكثر تعقيدًا كانت الحالة التي صادفتها في تطبيقنا السابق بسيطة لكونها متعلقة بعدد صحيح واحد. لكن ما العمل إن تطلب أحد التطبيقات التعامل مع حالة أكثر تعقيدًا؟ إنّ أكثر الطرق سهولة في إنجاز ذلك، هو استخدام دالة 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
-
لنعد إلى العمل مع 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
-
نهدف خلال تقدمنا في المنهاج، إلى تعلم ما يكفي عن JavaScript، فهذا أمر بالغ الأهمية، إضافة إلى تطوير الويب. لقد تطورت JavaScript بسرعة خلال السنوات القليلة الماضية، لذلك سنعتمد هنا الميزات التي توفرها النسخ الجديدة. يطلق على معيار JavaScript رسميًا اسم ECMAScript. وتعتبر النسخة ECMAScript® 2020 هي الأحدث بعد إصدارها في يونيو (حزيران) لعام 2020، كما تُعرف بالرمز ES11. لا تدعم المتصفحات الميزات الأحدث للغة JavaScript حتى الآن. لذلك نقلت الشيفرة (transpiled) التي تنفذها المتصفحات من النسخ الأحدث إلى النسخ الأقدم لتكون أكثر توافقًا. يعتبر استخدام Babel حاليًا أكثر الطرق شعبية لإجراء عملية النقل. وتُهيّأ عملية النقل آليًا في تطبيقات React التي تبنى باستخدام create-react-app. سنلقي نظرة أقرب إلى تهيئة عمليات النقل في القسم 7 لاحقًا. تُعرَّف Node.js بأنها بيئة لتنفيذ شيفرة JavaScript مبنية على محرك JavaScript الذي طورته Google والمعروف باسم chrome V8، وتعمل هذه البيئة في أي مكان من الخوادم إلى الهواتف النقّالة. سنتدرب على كتابة شيفرة JavaScript باستخدام Node، لذلك ينبغي أن تكون نسخة Node.js المثبتة على جهازك 10.18.0 على الأقل. وطالما أنّ أحدث نسخ Node ستتوافق تلقائيًا مع أحدث نسخ JavaScript، لذلك لا حاجة لنقل الشيفرة. تُكتب الشيفرة في ملفات تحمل اللاحقة js. وتنفذ من خلال الأمر node name_of_file.js، كما يمكن أن نكتبها ضمن طرفية Node.js التي نفتحها بكتابة الأمر node في سطر الأوامر، وأيضًا بكتابة الأمر نفسه في طرفية التطوير الخاصة بالمتصفح. تتماشى التنقيحات الأخيرة للمتصفح Chrome جيدًا مع ميزات JavaScript الأحدث دون الحاجة إلى نقل الشيفرة. كما يمكن استخدام أدوات بديلة مثل JS Bin. تُذكِّرنا JavaScript من حيث الاسم والتعابير بلغة Java. لكن من ناحية آلية العمل فهي أبعد ماتكون عنها. فإذا نظرت إليها من منظور مبرمجي Java، سيبدو سلوكها غريبًا قليلًا وخاصة إن لم تكلّف نفسك عناء البحث في ميزاتها. لقد شاع فيما مضى محاكاة ميزات Java في تصميم نماذج JavaScript، لكننا لا نحبّذ هذا الأمر لأن اللغتين والبيئتين اللتين تعملان ضمنهما تختلفان كليًا. المتغيرات Variables تُعرَّف المتغيرات في JavaScript بطرقٍ عدة: const x = 1 let y = 5 console.log(x, y) // 1, 5 y += 10 console.log(x, y) // 1, 15 y = 'sometext' console.log(x, y) // 1, sometext x = 4 // خطا لا تُعرّف الكلمة const متغيرًا، بل تدل على قيمة ثابتة (constant) لايمكن بعد ذلك تغييرها. بالمقابل تُعرّف الكلمة let متغيّرًا عاديًا. يمكن أن نرى بوضوح من خلال المثال السابق أن المتغيّرات قد تأخذ بيانات من أنواع مختلفة أثناء التنفيذ، ففي البداية يخزن المتغير y قيمة صحيحة، ثم سلسة نصية في النهاية. كما تُستخدم الكلمة var في تعريف المتغيّرات، فهذه الكلمة ولفترة طويلة كانت الطريقة الوحيدة لتعريف المتغيّر، ثم أضيفت const وlet مؤخرًا في النسخة ES6. وقد تعمل الكلمة var بطريقة مختلفة لتعريف المتغيرات مقارنة بالطريقة التي تُعرّف بها في معظم اللغات. سنلتزم في منهاجنا باستخدام const وlet عند تعريف المتغيرات. يمكنك الاطلاع على المزيد حول هذا الموضوع عبر YouTube كالفيديو التالي: var, let and const - ES6 JavaScript Features، أومن خلال المقاليين التاليين في أكاديمية حسوب: المتغيرات في JavaScript إفادة var القديمة في JavaScript المصفوفات Arrays تُعرِّف الشيفرة التالية مصفوفة وتقدم مثالين عن كيفية استخدامها: const t = [1, -1, 3] t.push(5) console.log(t.length) // 4 console.log(t[1]) // -1 t.forEach(value => { console.log(value) // 1, -1, 3, 5 }) الأمر الملفت في هذه الشيفرة، هو إمكانية تعديل محتوى المصفوفة بالرغم من كونها ثابتة (const). ذلك أن المصفوفة عبارة عن كائن والمتغيّر سيشير إلى نفس الكائن دومًا حتى لو تغيّر محتواه بإضافة العناصر الجديدة. تستخدم تعليمة forEach كطريقة لتعداد عناصر المصفوفة في الشيفرة السابقة. حيث تمتلك forEach مُعاملًا على شكل دالة سهمية لها الصيغة التالية: value => { console.log(value) } تستدعي forEach الدالة من أجل كل عنصر من عناصر المصفوفة، وتمرر كل عنصر كمُعامل. يمكن للدالة التي تمثل مُعاملًا لتعليمة forEach أن تمتلك مُعاملات أخرى خاصة بها. كما استخدم التابع push لإضافة عنصر جديد إلى المصفوفة في المثال السابق. تستخدم عادة البرمجة بأسلوب الدوال (functional programming) مع React. ولهذا الأسلوب ميزة استخدام بنى المعطيات الصامدة (Immutable). ويفضل عند كتابة شيفرة React استخدام التابع concat، فلا يضيف هذا التابع عنصرًا جديدًا إلى المصفوفة، بل ينشئ مصفوفة جديدة تضم محتويات القديمة بالإضافة إلى العنصر الجديد. const t = [1, -1, 3] const t2 = t.concat(5) console.log(t) // [1, -1, 3] console.log(t2) // [1, -1, 3, 5] لن يضيف الأمر (t.concat(5 عنصرًا جديدًا إلى المصفوفة القديمة، بل سيعيد مصفوفة جديدة تضم العنصر الجديد (5) بالإضافة إلى عناصر المصفوفة القديمة. لقد عُرّفت الكثير من التوابع المفيدة للتعامل مع المصفوفات، فلنلقي نظرة على استخدام التابع map على سبيل المثال: const t = [1, 2, 3] const m1 = t.map(value => value * 2) console.log(m1) // [2, 4, 6] يُنشئ التابع map مصفوفةً جديدةً مبنيةً على أساس القديمة، وتستخدم الدالة التي مُررت كوسيط لإنشاء عناصر المصفوفة الجديدة. لاحظ في مثالنا السابق كيف نتجت العناصر الجديدة عن القديمة بضربها بالعدد 2. ويمكن للتابع map تحويل المصفوفة إلى شيءٍ مختلفٍ تمامًا: const m2 = t.map(value => '<li>' + value + '</li>') console.log(m2) // [ '<li>1</li>', '<li>2</li>', '<li>3</li>' ] لقد تحولّت المصفوفة المكوَّنة من أعداد صحيحة إلى مصفوفة تحوي عبارات HTML باستخدام map. سنستخدم هذا التابع بكثرة في React كما سنرى في القسم 2 لاحقًا. يمكن إسناد قيمة كل عنصر من المصفوفة إلى المتغيّرات بطريقة الإسناد بالتفكيك (destructuring assignment). const t = [1, 2, 3, 4, 5] const [first, second, ...rest] = t console.log(first, second) // 1, 2 console.log(rest) // [3, 4 ,5] "فُكّكت" عناصر المصفوفة t باستخدام الإسناد بالتفكيك، وأسند أول عنصرين منها إلى المتغيّرين first وsecond، ثم جُمّعت بقية عناصر المصفوفة في مصفوفة جديدة أسندت بدورها إلى المتغيّر rest. الكائنات Objects توجد طرق عدة لتعريف الكائنات في JavaScript، وأكثر هذه الطرق شيوعًا هي استخدام الشكل المختصر لتعريف الكائنات (object literals) والتي تُنشأ بوضع خصائصها على شكل قائمة عناصر تفصل بينها الفواصل بين قوسين معقوصين. const object1 = { name: 'Arto Hellas', age: 35, education: 'PhD', } const object2 = { name: 'Full Stack web application development', level: 'intermediate studies', size: 5, } const object3 = { name: { first: 'Dan', last: 'Abramov', }, grades: [2, 3, 5, 3], department: 'Stanford University', } تأخذ الخصائص قيمًا من أي نوع مثل الصحيحة والنصية والمصفوفات والكائنات، وتعرّف خصائص كائن ما باستخدام النقطة "." أو باستخدام الأقواس المعقوفة: console.log(object1.name) // Arto Hellas const fieldName = 'age' console.log(object1[fieldName]) // 35 كما تُضاف الخصائص إلى الكائن مباشرة باستخدام النقطة "." أو الأقواس المعقوفة: object1.address = 'Helsinki' object1['secret number'] = 12341 لقد توجب علينا استخدام الأقواس المعقوفة عند إضافة الخاصية الأخيرة، ذلك أن الاسم (secret number) لا يصلح اسمًا لخاصية نظرًا لوجود فراغ بين الكلمتين. تمتلك الكائنات في JavaScript توابعًا أيضًا، لكننا لن نستخدم في المنهاج كائنات لها توابعها الخاصة، وسنكتفي بمناقشتها بإيجاز خلال تقدمنا. تُعرّف الكائنات أيضًا باستخدام الدوال البانية (constructor functions) التي تشابه في آلية عملها مثيلاتها في لغات برمجة أخرى، كدوال البناء في صفوف Java. مع ذلك، لا تمتلك JavaScript صفوفًا بالمفهوم الذي نراه في البرمجة كائنية التوجه (OOP). أضيفت الصيغة class للغة JavaScript ابتداء من النسخة ES6، والتي تساعد في بعض الحالات على بناء صفوف كائنية التوجه. الدوال Functions تعرّفنا سابقًا على استخدام التوابع السهمية والتي تُكتب بصيغتها الكاملة على النحو: const sum = (p1, p2) => { console.log(p1) console.log(p2) return p1 + p2 } وتُستدعى كما هو متوقع على النحو: const result = sum(1, 5) console.log(result) يمكن تجاهل كتابة الأقواس المحيطة بالمُعاملات إن كان للدالة مُعامل واحد: const square = p => { console.log(p) return p * p } وإن احتوت الدالة على عبارة برمجية واحدة، لا حاجة عندها لاستخدام الأقواس المعقوصة، وستعيد الدالة القيمة الناتجة عن تنفيذ هذه العبارة. لاحظ كيف سيُختصر تعريف الدالة لو أزلنا العبارة التي تطبع النتيجة على الطرفية: const square = p => p * p ولهذا الشكل أهميته التطبيقية عندما نتعامل مع المصفوفات وخاصة باستعمال التابع map: const t = [1, 2, 3] const tSquared = t.map(p => p * p) // tSquared is now [1, 4, 9] أضيفت الدوال السهمية إلى JavaScript مع النسخة ES6 منذ سنتين تقريبًا. وقد عُرّفت الدوال قبل ذلك بالكلمة function فقط. وعمومًا هناك طريقتان لتعريف الدوال، الأولى تسمية الدالة بالتصريح عنها (function declaration): function product(a, b) { return a * b } const result = product(2, 6) // result is now 12 أما الطريقة الثانية فهي استخدام تعبير تعريف دالة (function expression)، وفي هذه الحالة لا داع لإعطاء الدالة اسمًا: const average = function(a, b) { return (a + b) / 2 } const result = average(2, 5) // result is now 3.5 سنستخدم في هذا المنهاج الدوال السهمية دائمًا. التمارين 1.3- 1.5 سنكمل بناء التطبيق الذي بدأناه سابقًا، لهذا يمكنك متابعة كتابة الشيفرة في نفس المشروع طالما أن المهم هو الحالة النهائية للتطبيق الذي ستسلّمه. نصيحة للاحتراف: قد تواجهك عقبات تتعلق ببنية الخصائص التي تمتلكها المكوِّنات. اجعل الأمر أكثر وضوحًا بطباعة الخصائص على الطرفية كالتالي: const Header = (props) => { console.log(props) return <h1>{props.course}</h1> } 1.3 معلومات عن المنهاج: الخطوة 3 سنمضي قدمًا نحو الأمام مستخدمين الكائنات في تطبيقنا. عدّل تعريف المتغيّر في المكوّن App بالشكل التالي وأعد صياغة التطبيق حتى يبقى قابلًا للتنفيذ: const App = () => { const course = 'Half Stack application development' const part1 = { name: 'Fundamentals of React', exercises: 10 } const part2 = { name: 'Using props to pass data', exercises: 7 } const part3 = { name: 'State of a component', exercises: 14 } return ( <div> ... </div> ) } 1.4 معلومات عن المنهاج: الخطوة 4 ضع بعد ذلك الكائنات في مصفوفة. عدّل تعريف المتغيّر في المكوِّن App إلى الشكل التالي ولا تنس تعديل بقية الأجزاء في المكوِّن بما يتوافق معه: const App = () => { const course = 'Half Stack application development' const parts = [ { name: 'Fundamentals of React', exercises: 10 }, { name: 'Using props to pass data', exercises: 7 }, { name: 'State of a component', exercises: 14 } ] return ( <div> ... </div> ) } ملاحظة: اعتبر حتى هذه اللحظة أن هناك ثلاثة عناصر فقط ضمن المصفوفة لذلك لا ضرورة لاستخدام الحلقات عند التنقل بين العناصر. سنعود لاحقًا بتفصيل أعمق إلى موضوع تصيير الكائنات المبنية من عناصر مصفوفة في القسم التالي. لاتُمرِّر كائنات مختلفة على شكل خصائص من المكوِّن App إلى المكوِّنين Content و Total، بل مررها مباشرة كمصفوفة: const App = () => { // const definitions return ( <div> <Header course={course} /> <Content parts={parts} /> <Total parts={parts} /> </div> ) } 1.5 معلومات عن المنهاج: الخطوة 5 لنتقدم خطوة أخرى إلى الأمام. حوّل المنهاج وأقسامه إلى كائن JavaScript واحد، وأصلح كل ما يتضرر: const App = () => { const course = { name: 'Half Stack application development', parts: [ { name: 'Fundamentals of React', exercises: 10 }, { name: 'Using props to pass data', exercises: 7 }, { name: 'State of a component', exercises: 14 } ] } return ( <div> ... </div> ) } توابع الكائنات والمؤشر "this" لن نستخدم كما ذكرنا سابقًا كائنات لها توابعها الخاصة، ذلك أننا سنعتمد على نسخة من React تدعم الخطافات. لن تجد فيما سيأتي لاحقًا في هذا الفصل ما يتعلق بالمنهاج، لكن من الجيد الاطلاع على المعلومات الواردة وفهمها وخاصة عندما تستخدم نسخًا أقدم من React. يختلف سلوك الدوال السهمية عن تلك المعرّفة باستخدام التصريح function فيما يتعلق باستخدام الكلمة this التي تشير إلى الكائن نفسه. من الممكن إسناد التوابع إلى كائن بتعريف بعض خصائصه على شكل دوال: const arto = { name: 'Arto Hellas', age: 35, education: 'PhD', greet: function() { console.log('hello, my name is ' + this.name) },} arto.greet() // "hello, my name is Arto Hellas" وقد تسند التوابع إلى الكائنات حتى بعد إنشاء هذه الكائنات: const arto = { name: 'Arto Hellas', age: 35, education: 'PhD', greet: function() { console.log('hello, my name is ' + this.name) }, } arto.growOlder = function() { this.age += 1} console.log(arto.age) // 35 is printed arto.growOlder() console.log(arto.age) // 36 is printed لنعدّل الكائن كما يلي: const arto = { name: 'Arto Hellas', age: 35, education: 'PhD', greet: function() { console.log('hello, my name is ' + this.name) }, doAddition: function(a, b) { console.log(a + b) },} arto.doAddition(1, 4) // 5 is printed const referenceToAddition = arto.doAddition referenceToAddition(10, 15) // 25 is printed يمتلك الكائن الآن التابع doAddition الذي يجمع الأعداد التي تمرر إليه كوسطاء. يستدعى هذا التابع بالطريقة التقليدية (arto.doAddition(1, 4، أو بإسناد مرجعٍ للتابع إلى متغّير ومن ثم استدعاءه من خلال المتغيّر كما هو الحال في السطرين الأخيرين من الشيفرة السابقة. لكن لو حاولنا استخدام الطريقة ذاتها مع التابع greet سنواجه مشكلة: arto.greet() // "hello, my name is Arto Hellas" const referenceToGreet = arto.greet referenceToGreet() // "hello, my name is undefined" فعندما نستدعي التابع باستخدام مرجع لكائن، سيفقد التابع القدرة على تحديد الكائن الذي تشير إليه الكلمة this. وعلى نقيض بقية اللغات، تُحدّد قيمة this في JavaScript وفقًا للطريقة التي يُستدعى بها التابع. فعندما تُستدعى من خلال مرجع تتحول this إلى كائن عام (global object) ولن تكون النتيجة ما يريده المطوّر في أغلب الأوقات. تقودنا الحالة السابقة لمواجهة العديد من المشاكل وخاصة عندما تحاول React أو Node استدعاء توابع عرفها المطوّر للكائن. لذلك سنتغلب على هذه العقبة بكتابة شيفرة JavaScript لا تستعمل this. لكن هناك حالة خاصة تظهر عندما نحاول ضبط قيمة انتهاء الوقت (timeout) للدالة greet التي تمثل جزءًا من اللكائن arto باستخدام setTimeout. const arto = { name: 'Arto Hellas', greet: function() { console.log('hello, my name is ' + this.name) }, } setTimeout(arto.greet, 1000) تُحدّد قيمة this في JavaScript وفقًا للطريقة التي يُستدعى بها التابع كما أشرنا سابقًا. فعندما نستخدم الدالة setTimeout لاستدعاء تابع، فإن محرك JavaScript هو من يستدعى التابع حقيقةً، وستشير this عندها إلى كائن عام. هناك آليات عديدة للاحتفاظ بمرجع this الأصلي، منها استدعاء الدالة bind كالتالي: setTimeout(arto.greet.bind(arto), 1000) سينشئ الاستدعاء (arto.greet.bind(arto دالة جديدة تشير فيها this إلى Arto بصرف النظر عن الطريقة التي تم بها استدعاء التابع. تُستخدم الدوال السهمية في بعض الأحيان لحل بعض مشاكل this. لكن لا ينبغي استخدام هذه الدوال كتوابع لكائنات، لأن this لن تعمل عندها أبدًا. سنعرّج لاحقًا على توضيح سلوك this مع الدوال السهمية. تمتلئ صفحات الانترنت بمعلومات عن استخدام this في JavaScript، إن أردت فهم ذلك أكثر، نوصيك بشدة متابعة سلسة Understand JavaScript's this Keyword in Depth التي أعدها egghead.io على سبيل المثال. الأصناف Classes لا تمتلك JavaScript كما أشرنا سابقًا صفوفًا تشابه تلك التي تقدمها اللغات كائنية التوجه. لكنها تمتلك ميزات تمكّننا من محاكاة تلك الأصناف. لنلق نظرة سريعة على الصيغة class التي قدمتها النسخة ES6 والتي تبسّط تعريف الأصناف (أو الأغراض الشبيه بالأصناف). سنعرّف في المثال التالي صفًا يسمى Person وكائنين Person: class Person { constructor(name, age) { this.name = name this.age = age } greet() { console.log('hello, my name is ' + this.name) } } const adam = new Person('Adam Ondra', 35) adam.greet() const janja = new Person('Janja Garnbret', 22) janja.greet() تتشابه الأصناف والكائنات الناتجة من الناحية التعبيرية (Syntax) مع تلك التي تقدمها Java، كما تتشابه من ناحية السلوك أيضًا. لكنها تبقى في الصميم كائنات ناتجة عن وراثة نماذج أولية (prototypal inheritance) تقدمها JavaScript. فنمط كلٍّ من الكائنين عمليًا هو Object، لأن JavaScript تعرّف افتراضيًا أنواع البيانات الأساسية التالية: Boolean و Null و Undefined و Number و String و Symbol و Object فقط. على أية حال كان تقديم الصيغة class محط خلاف، يمكنك القراءة أكثر عن ذلك في المقالة Not Awesome: ES6 Classes أو في المقالة ?Is “Class” In ES6 The New “Bad” Part. لقد استخدمت هذه الصيغة مرارًا في النسخ القديمة من React وكذلك في Node.js، لكننا لن نستخدمها في المنهاج كبنية أساسية، طالما أننا سنستخدم ميزة الخطافات الجديدة في React. مواد إضافية لتعلم JavaScript تقدم أكاديمية حسوب توثيقًا عربيًا شاملًا مترجمًا عن الموقع javascript.info وننصح بشدة الاطلاع على كل المقالات الموجودة. بالإضافة إلى ذلك ستجد على شبكة الانترنت الكثير من المواد المتعلقة باللغة منها الجيد ومنها السيء، فيمكنك الاطلاع على المراجع التي تقدمها شركة Mozilla والمتعلقة بميزات JavaScript من خلال الدليل Mozilla's Javascript Guide. كما نوصيك بشدة أن تتطلع مباشرة على الدورة التعليمية A re-introduction to JavaScript JS tutorial على موقع الويب لشركة Mozilla. إن أردت التعمق بهذه اللغة نوصيك بسلسلة كتب مجانية ممتازة على الإنترنت تدعى You-Dont-Know-JS. يعتبر الموقع egghead.io مرجعًا جيدًا حيث يضم شروحات قيّمة عن JavaScript وعن React وغيرها من المواضيع الهامة، لكن ليست جميع المواد الموجودة مجانية. ترجمة -وبتصرف- للفصل JavaScript من سلسلة Deep Dive Into Modern Web Development
-
سنتعرف فيما سيأتي على أهم موضوع في هذا المنهاج، بالتحديد على مكتبة React. لنبدأ إذًا بكتابة تطبيق بسيط يمهد الطريق لفهم المبادئ الأساسية لهذه المكتبة. سنستعمل الأداة create-react-app التي ستسهل علينا كثيرا التعامل مع الملفات الأساسية لمشروع React، فمن الممكن تثبيت هذه الأداة على حاسوبك طالما أن نسخة الأداة npm التي ثُبِّتت مع Node على الأقل 5.3، وبالطبع يبقى موضوع استخدام هذه الأداة اختيارًا شخصيًا. لننشئ الآن تطبيقًا باسم (Part1) ولننتقل إلى المجلد الذي يضم هذا التطبيق من خلال تنفيذ الشيفرة التالية: $ npx create-react-app part1 $ cd part1 سيبدأ كل سطر في هذا التطبيق وفي كل تطبيق بالرمز $ ضمن واجهة الطرفية. لن تضطر إلى كتابة هذا الرمز باستمرار لأنه في الواقع سيمثل مَحثَّ سطر الأوامر. سنشغل التطبيق بكتابة الأمر التالي: $ npm start سيعمل التطبيق بشكل افتراضي على الخادم المحلي (LocalHost) لجهازك عند المنفذ (Port) رقم 3000 وعلى العنوان http://localhost:3000 وسيفتح متصفحك الافتراضي آليًا صفحة الويب. افتح طرفية المتصفح مباشرةً وافتح أيضًا محرر نصوص لتتمكن من عرض الشيفرة وصفحة الويب في نفس الوقت على شاشتك. يحتوي المجلد src على شيفرة التطبيق. سنبسِّط شيفرة التطبيق المكتوبة في الملف index.js التي ستظهر كالتالي: import React from 'react' import ReactDOM from 'react-dom' const App = () => ( <div> <p>Hello world</p> </div> ) ReactDOM.render(<App />, document.getElementById('root')) يمكنك حذف الملفات التالية فلن تحتاجها حاليًا في التطبيق: App.js App.css App.test.js logo.svg setupTests.js service Worker.js المكوِّنات Components يعّرف الملف index.js مكوّنَ React يدعى App، سيصيّره السطر الأخير من الشيفرة إلى عنصرdiv الذي ستجده معرفًا في الملف public/index.html ويحمل القيمة "root" في الخاصية id. ReactDOM.render(<App />, document.getElementById('root')) ستجد ملف html السابق فارغًا بشكل افتراضي، حيث يمكنك إضافة ما تشاء من شيفرة html إليه. لكن طالما أننا نستخدم الآن React، فكل المحتوى الذي سيصيَّر ستجده معرّفًا على شكل مكوِّنات React. لنلق نظرة أقرب على الشيفرة التي تعرّف المكوِّن: const App = () => ( <div> <p>Hello world</p> </div> ) إن كان تخمينك صحيحًا، سيُصيَّر المكوِّن إلى عنصرdiv يضم عنصرp يحوي داخله العبارة "Hello Word". في الواقع عُرّف المكوِّن على شكل دالة JavaScript، فتمثل الشيفرة التالية دالة لا تمتلك أية مُعاملات: () => ( <div> <p>Hello world</p> </div> ) ثم تُسند الدالة بعدها إلى متغيّر ثابت القيمة (Constant Variable) يدعى App: const App = ... تُعرّف الدوال في JavaScript بطرق عدة، سنستخدم هنا الدوال السهمية التي وصِّفت في النسخ الأحدث من JavaScript تحت عنوان ECMAScript 6 والذي يدعى أيضًا ES6. ونظرًا لاحتواء الدالة على عبارة وحيدة، فقد استخدمنا طريقة مختصرة في تمثيلها كما تظهرها الشيفرة التالية: const App = () => { return ( <div> <p>Hello world</p> </div> ) } لاحظ أنّ الدالة قد أعادت قيمة العبارة التي احتوتها. قد تحتوي الدالة التي تعرِّف المكوِّن أية شيفرة مكتوبة بلغة JavaScript. عدِّل الشيفرة لتصبح على النحو التالي وراقب ما الذي سيتغير على شاشة الطرفية: const App = () => { console.log('Hello from component') return ( <div> <p>Hello world</p> </div> ) } من الممكن أيضًا تصيير المحتويات الديناميكية إن وجدت داخل المكوِّن، عدِّل الشيفرة لتصبح كالتالي: const App = () => { const now = new Date() const a = 10 const b = 20 return ( <div> <p>Hello world, it is {now.toString()}</p> <p> {a} plus {b} is {a + b} </p> </div> ) } تُنفَّذ شيفرة JavaScript الموجودة داخل الأقواس المعقوصة (curly braces) وتظهر نتيجتها ضمن أسطر HTML التي يولدها المكوِّن. JSX يبدو للوهلة الأولى أن مكوِّنات React تعيد شيفرة HTML، لكن ليس تمامًا. تُصمم مكوِّنات React غالبًا باستخدام JSX. قد تبدو JSX شبيهة بتنسيق HTML، لكنها في الواقع مجرد طريقة لكتابة JavaScript وستعيد المكوِّنات شيفرة JSX وقد ترجمت إلى JavaScript. سيبدو التطبيق الذي نعمل عليه بالشكل التالي بعد ترجمته: import React from 'react' import ReactDOM from 'react-dom' const App = () => { const now = new Date() const a = 10 const b = 20 return React.createElement( 'div', null, React.createElement( 'p', null, 'Hello world, it is ', now.toString() ), React.createElement( 'p', null, a, ' plus ', b, ' is ', a + b ) ) } ReactDOM.render( React.createElement(App, null), document.getElementById('root') ) تُرجم التطبيق باستخدام Babel. تترجم المشاريع التي أُنشئت باستخدام create-react-app أليًا، وسنطّلع أكثر على هذا الموضوع في القسم 7 من هذا المنهاج. من الممكن أيضًا كتابة تطبيقات React مستخدمين شيفرة JavaScript صرفة دون اعتماد JSX، لكن لن يرغب أحد بفعل ذلك. تتشابه من الناحية العملية كل من JSX و HTML، ويكمن الاختلاف البسيط بينهما في سهولة إدراج شيفرة JavaScript ضمن أقواس معقوصة وتنفيذها ضمن JSX. إن فكرة JSX مشابهة لفكرة العديد من اللغات التي تعتمد على القوالب مثل لغة Thymeleaf التي تستخدم مع Java Spring على الخادم. وهي تشابه XML في أن معرِّفي البداية والنهاية لكل عنصر يجب أن يكونا موجودين. فعلى سبيل المثال يمكن كتابة عنصر السطر الجديد <br> في HTML على النحو: <br> لكن عندما نستخدم JSX، لابد من إغلاق العنصر أي وضع معرِّف النهاية كالتالي: <br /> المكوّنات المتعددة سنعدّل شيفرة التطبيق على النحو التالي: const Hello = () => { return ( <div> <p>Hello world</p> </div> ) } const App = () => { return ( <div> <h1>Greetings</h1> <Hello /> </div> ) } ReactDOM.render(<App />, document.getElementById('root')) انتبه: لم ندرج الملفات الرأسية (الملفات المدرجة باستخدام تعليمة import) في الشيفرة المعروضة سابقًا ولن نفعل في المستقبل، لكن عليك إدراجها دائمًا عندما تحاول تشغيل التطبيق. عرّفنا داخل المكوّن App مكوِّنًا جديدًا يدعى Hello استُخدم أكثر من مرة ويعتبر ذلك أمرًا طبيعيًًا: const App = () => { return ( <div> <h1>Greetings</h1> <Hello /> <Hello /> <Hello /> </div> ) } من السهل إنشاء المكوِّنات في React، وبضم المكوِّنات إلى بعضها ستنتج تطبيقات أكثر تعقيدًا لكنها مع ذلك تبقى قابلة للتنفيذ. إنها فلسفة React التي تعتمد في جوهرها على بناء التطبيقات باستخدام مكوِّنات لها أغراض محددة يمكن إعادة استخدامها في أي وقت. تعتبر فكرة المكوّن الجذري (root component) الذي يمثله هنا App (المكوّن الذي يقع أعلى شجرة المكوِّنات)، نقطة قوة أخرى في React. مع ذلك سنجد في القسم 6 أنّ هذا المكوّن لن يكون جذريًّا تمامًا، لكنه سيُغلَّف ضمن مكوِّن تخديمي (utility component) مناسب. الخصائص (Props): تمرير البيانات إلى المكوِّنات من الممكن تمرير البيانات إلى المكوّنات مستخدمين مايسمى الخصائص (Props). سنعدّل المكوِّن Hello كالتالي: const Hello = (props) => { return ( <div> <p>Hello {props.name}</p> </div> ) } تمتلك الدالة التي تعرّف المكوِّن Hello مُعاملًا يدعى props. يستقبل المُعامل (الذي يلعب دور الوسيط هنا) كائنًا يمتلك حقولًا تمثل كل الخصائص التي عُرِّفت في المكوّن. سنستعرض ذلك من خلال الشيفرة التالية: const App = () => { return ( <div> <h1>Greetings</h1> <Hello name="George" /> <Hello name="Daisy" /> </div> ) } لا يوجد عدد محدد من الخصائص لمكوّن ما، ويمكن أن تحمل هذه الخصائص نصوصًا جاهزةً (hard coded) أو قيمًا ناتجة عن تنفيذ عبارات JavaScript. ينبغي وضع قيم الخصائص الناتجة عن تنفيذ تلك العبارات ضمن أقواس معقوصة. سنعدل شيفرة المكوّن Hello ليمتلك خاصيتين كالتالي: 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> ) } أرسل المكوِّن App الخاصيتين على شكل قيم عائدة لمتغيّرات، كما أرسلهما أيضًا كنتيجة لتنفيذ عبارتي JavaScript، أحداهما عبارة جمع والأخرى قيمة نصية. بعض الملاحظات هُيّئت React لتولّد رسائل خطأ واضحةً تمامًا، وبالرغم من ذلك عليك التقدم بخطوات صغيرة، وأن تتأكد أنّ كل تغيير قد أجريته حقق الغاية منه، اتبع هذا الأسلوب في البداية على الأقل. ابق طرفية التطوير مفتوحةً دومًا. ولا ننصحك بمتابعة العمل إن أبلغك المتصفح بوجود أخطاء، بل عليك أن تتفهم سبب الخطأ قبل كل شيئ، وتذكر أنه يمكنك التراجع دائمًا إلى حالة العمل السابقة. سينفعك أن تكتب التعليمة ()console.log بين الحين والأخر ضمن أسطر شيفرة تطبيقك، حيث تطبع هذه التعليمة سجلات تنفيذ الشيفرة على الطرفية. وتذكر أنّ أسماء مكوِّنات React تبدأ بأحرف كبيرة (Capitals)، فلو حاولت تعريف مكوِّن بالشكل التالي: const footer = () => { return ( <div> greeting app created by <a href="https://github.com/mluukkai">mluukkai</a> </div> ) } ثم استخدمته كما يلي: const App = () => { return ( <div> <h1>Greetings</h1> <Hello name="Maya" age={26 + 10} /> <footer /> </div> ) } لن تعرض الصفحة حينها المحتوى الذي يقدمه المكوِّن Footer، بل ستنشئ عنصرًا فارغًا يحمل اسم footer. لكن إن حولت الحرف الأول من اسم المكوِّن إلى F، ستنشئ React عنصر div المعرّف ضمن المكوّن Footer وسيصيّره على الصفحة. تذكر أن مكوِّنات React تتطلب عادة وجود عنصر جذري (root element) وحيد، فلو حاولنا تعريف المكوِّن App على سبيل المثال دون وجود العنصر div في البداية كالتالي: const App = () => { return ( <h1>Greetings</h1> <Hello name="Maya" age={26 + 10} /> <Footer /> ) } ستكون النتيجة رسالة الخطأ التالية: يمكن أن نستخدم وسيلة بديلة عن العنصر الجذري تتمثل بإنشاء مصفوفة من المكوِّنات كما يلي: const App = () => { return [ <h1>Greetings</h1>, <Hello name="Maya" age={26 + 10} />, <Footer /> ] } لا يعتبر -على الرغم من ذلك- تعريف العنصر الجذري ضمن المكوِّن الجذري لتطبيق ما أمرًا مفضلًا، بل ستظهر الشيفرة بمظهر سيئٍ قليلًا. ذلك أنّ العنصر الجذري المحدد مسبقًا، سيسبب زيادة في عدد عناصرdiv في شجرة DOM. يمكن تلافي هذا الأمر باستخدام الأجزاء (fragments)، أي بتغليف العناصر ليعيدها المكوِّن على شكل عناصر فارغة كالتالي: const App = () => { const name = 'Peter' const age = 10 return ( <> <h1>Greetings</h1> <Hello name="Maya" age={26 + 10} /> <Hello name={name} age={age} /> <Footer /> </> ) } سيُترجم الآن التطبيق بنجاح، ولن تحتوي شيفرة DOM على عناصر div زائدة. التمارين 1.1 - 1.2 تسلّم التمارين عبر GitHub، وتحدَّد التمارين التي نفذت من خلال تطبيق تسليم الملفات submission application. يمكنك وضع كل تمارين المنهاج التي سلمتها في نفس المكان على المنصة أو في عدة أماكن، لكن عليك أن تستخدم تسميات منطقية للمجلدات إن كنت ستضع تمارين كل الأقسام في نفس المكان. يمكنك اتباع المخطط التالي لتنظيم المجلدات والتمارين المسلمة: part0 part1 courseinfo unicafe anecdotes part2 phonebook countries ألق نظرة على طريقة تنظيم الملفات. فلكل قسم مجلد خاص به يتفرع إلى مجلدات أخرى تضم تمارين مثل "unicafe" الموجود في القسم 1. يفضل تسليم سلسلة التمارين التي تشكل تطبيقًا معًا، ماعدا المجلد node_modules. يتوجب تسليم تمارين كل قسم على حدى، إذ لا يمكنك تسليم أية تمارين جديدة تعود إلى قسم ما إذا كنت قد سلمت غيرها من ذات القسم. انتبه يضم هذا القسم تمارين أخرى غير التمارين التي سنستعرضها الآن، لا تسلم التمارين التي ستنجزها في هذا الفصل بل انتظر حتى تنهي جميع تمارين القسم. 1.1- معلومات عن المنهاج course information، الخطوة 1 سنطور التطبيق الذي ننفذه انطلاقًا من هذا التمرين خلال التمارين القليلة القادمة. يكفي أن تسلم التطبيق في حالته النهائية دون تسليمه تمرينًا تمرينًا، وينطبق هذا الكلام على مجموعات التمارين المشابهة التي ستواجهها لاحقًا. بالطبع يمكنك ترك ملاحظة تشير إلى ذلك ضمن كل تمرين من السلسلة، لكنه أمر اختياري بحت. استخدم create-react-app لإنشاء تطبيق جديد وعدل الملف index.js ليظهر كالتالي: import React from 'react' import ReactDOM from 'react-dom' const App = () => { const course = 'Half Stack application development' const part1 = 'Fundamentals of React' const exercises1 = 10 const part2 = 'Using props to pass data' const exercises2 = 7 const part3 = 'State of a component' const exercises3 = 14 return ( <div> <h1>{course}</h1> <p> {part1} {exercises1} </p> <p> {part2} {exercises2} </p> <p> {part3} {exercises3} </p> <p>Number of exercises {exercises1 + exercises2 + exercises3}</p> </div> ) } ReactDOM.render(<App />, document.getElementById('root')) احذف الملفات الزائدة وهي: App.js وApp.css وApp.test.js وlogo.svg وsetupTests.js وserviceWorker.js. لسوء الحظ فالتطبيق ككل محتوًى في نفس المكوِّن، أعد صياغة التطبيق ليضم ثلاثة مكوِّنات جديدة: Header وContent وTotal. عدل الشيفرة التي تمرر البيانات في التطبيق App عبر الخصائص واجعل المكوِّن Header يتولى أمر البيانات المتعلقة باسم المنهاج، واجعل المكوِّن Content مسؤولًا عن تصيير البيانات المتعلقة بالأقسام وعدد التمارين في كل منها، وأخيرًا اجعل المكوِّن Total مسؤولًا عن تصيير البيانات المتعلقة بالعدد الكلي للتمارين. سيظهر جسم التطبيق الجديد تقريبًا كما يلي: const App = () => { // const-definitions return ( <div> <Header course={course} /> <Content ... /> <Total ... /> </div> ) } تحذير تنشئ الأداة create-react-app مستودع git محلي يحتوي المشروع ، إلا إن كان في المجلد مستودع محلي سابق. من المرجح أنك لا تريد أن يغدو المشروع مستودعًا، لهذا نفذ الأمر التالي rm -rf .git في مسار المشروع. 1.2- معلومات عن المنهاج، الخطوة 2 أعد صياغة المكوِّن Content بحيث لا يصيّر بنفسه أسماء الأقسام أو عدد التمارين التي تضمها، بل يصير ثلاثة مكوِّنات تدعى Part، ثم يُصيّر كل مكوّن منها اسم قسم واحد وعدد التمارين التي يضمها. const Content = ... { return ( <div> <Part .../> <Part .../> <Part .../> </div> ) } يمرر تطبيقنا حاليًا المعلومات بطريقة بدائية لاعتماده على متغيرات مفردة. سيتحسن الأمر قريبًا. اقترح تعديلًا على مضمون الفصل الأصلي. ترجمة -وبتصرف- للفصل Introduction to React من سلسلة Deep Dive Into Modern Web Development
-
قبل أن نبدأ البرمجة، سنستعرض بعض المبادئ المعتمدة في تطوير الويب من خلال الاطلاع على هذا التطبيق النموذجي عبر الرابط: studies.cs.helsinki.fi/exampleapp. صممت هذه التمارين لتوضيح بعض المفاهيم الأساسية في المنهاج، وليس لتقديم الطريقة التي ينبغي أن تبنى بها تطبيقات الويب. فهي على العكس تمامًا، ستشرح بعض التقنيات القديمة في تطوير الويب والتي يمكن أن تعتبر في أيامنا هذه عادات برمجية سيئة. مع هذا ستتعلم كتابة الشيفرة بالأسلوب الأنسب في القسم 1. استعمل متصفح Chrome الآن ودائمًا خلال رحلتك في هذا المنهاج. ثم استخدمه في فتح التطبيق النموذجي، قد يستغرق ذلك وقتًا. القاعدة الأولى في تطوير الويب: ابق طرفية التطوير (Development Console) مفتوحةً دومًا في متصفحك. إن كنت تعمل على نظام التشغيل Windows اضغط زر F12 أو أزرار ctrl-shift-i معًا لفتحه. أما في نظام التشغيل MacOS فاضغط زر F12 أو الأزرار Option-cmd-i معًا. قبل المتابعة، اكتشف طريقة فتح طرفية التطوير على حاسوبك، وتذكر أن تبقيها مفتوحةً دومًا عندما تعمل على تطوير تطبيقات الويب. ستظهر طرفية التطوير بالشكل التالي: تأكد أن نافذة (Network) مفتوحة، ثم ألغ تفعيل خيار (Disable Cache). إن حفظ سجل العمل (log) قد يكون مفيدًا، إذ سيحفظ سجلات تنفيذ التطبيق عند إعادة تحميل الصفحة. لاحظ جيدًا: إن أكثر النوافذ أهميةً في الطرفية هي (Network)، و سنستخدمها بكثرة في البداية. الطلبية من النوع HTTP GET يتواصل الخادم (Server) مع المتصفح باستخدام بروتوكول HTTP، وتعرض نافذة (Network) تفاصيل هذا التواصل. فعندما نطلب إعادة تحميل الصفحة ( الزر F5 أو الرمز ↺ على المتصفح)، ستُظهر الطرفية أن حدثين قد وقعا: جلب المتصفح محتوى صفحة التطبيق من الخادم. نزّل المتصفح الصورة Kuva.png. ] عليك تكبير شاشة الطرفية لترى ذلك إن كانت شاشة العرض صغيرة. ستظهر لك معلومات أكثر عند النقر على الحدث الأول: يظهر القسم العلوي (General)، أن المتصفح قدم طلبًا إلى العنوان: https://fullstack-exampleapp.herokuapp.com مستخدما أسلوب GET، وأن الطلب كان ناجحًا لأن الخادم استجاب معيدًا رمز حالة (Status Code) قيمته 200. توجد العديد من الترويسات (Headers) للطلب والاستجابة، يُظهر الشكل التالي بعضها: تعطينا ترويسات الاستجابة (Response headers) معلومات عدة، مثل حجم الاستجابة مقدرة بالبايت والوقت الدقيق لها. كذلك الأمر تخبرنا ترويسة نوع المحتوى (Content_Type) أن الاستجابة كانت على شكل ملف نصي بصيغة utf-8، وتحدد المحتوى الذي سيظهر بتنسيق HTML. وبهذا يعلم المتصفح أن الاستجابة ستكون على شكل صفحة HTML قياسية، ليعرضها كصفحة ويب. تُظهر نافذة (Response) بيانات الاستجابة على شكل صفحة HTML. حيث يحدد القسم Body هيكل الصفحة التي ستُعرض على الشاشة. وتحوي صفحة HTML أيضًا العنصر Div الذي يضم عناصر أخرى مثل معرّف العنوان h1 والرابط a إلى صفحة الملاحظات ومعّرف الصورimg وأخيرًا معرّف الفقرة p الذي يُظهر في مثالنا عدد الملاحظات التي تم إنشاؤها. ونظرًا لوجود معرّف الصور img، أرسل المتصفح طلب HTTP جديد إلى الخادم لإحضار الصورة التي عنوانها kuva.png. يُظهر الشكل التالي تفاصيل الطلب: أُرسل طلب HTTP-GET إلى العنوان: https://fullstack-exampleapp.herokuapp.com/kuva.png وتخبرنا ترويسات الاستجابة أن حجم الاستجابة يعادل 89350 بايت، ومحتواها صورة بصيغة png. يستخدم المتصفح هذه المعلومات لإظهار الصورة بشكل صحيح على الشاشة. يمثل المخطط التتابعي التالي، سلسلة الأحداث التي بدأت بفتح الصفحة https://fullstack-exampleapp.herokuapp.com: أرسل المتصفح في البداية، طلبًا إلى الخادم من نوع HTTP-GET للحصول على شيفرة HTML للصفحة. يخبر معرّف الصور img الموجود في شيفرة HTML المتصفح أن يحضر صورة بعنوان kuva.png. ثم يعالج المتصفح شيفرة HTML والصورة ويظهرهما على الشاشة. مع ذلك يصعب ملاحظة أن معالجة صفحة HTML بدأت قبل أن يحضر المتصفح الصورة من الخادم. تطبيقات الويب التقليدية يماثل عمل صفحة التطبيق النموذجي السابق عمل تطبيقات الويب التقليدية. فعند الدخول إلى الصفحة، يحضر المتصفح من الخادم ملف HTML الذي يُنسِّق طريقة عرض الصفحة ومحتواها النصي. لقد بنى الخادم هذا المستند بطريقة ما. فقد يكون المستند عبارة عن مستند نصي ذي محتوًى ثابت محفوظ على الخادم. كما يمكن للخادم أيضًا أن يبني مستند HTML ديناميكيًا وفقًا للتعليمات الموجودة في شيفرة التطبيق مستخدمًا على سبيل المثال، بيانات موجودة في قاعدة بيانات محددة. فشيفرة ملف HTML في التطبيق النموذجي السابق مثلًا، كُتبت ديناميكيًا من قبل الخادم، كونه احتوى معلومات تدل على عدد الملاحظات التي دونت في الصفحة. تظهر شيفرة HTML لصفحة التطبيق النموذجي بالشكل التالي: const getFrontPageHtml = (noteCount) => { return(` <!DOCTYPE html> <html> <head> </head> <body> <div class='container'> <h1>Full stack example app</h1> <p>number of notes created ${noteCount}</p> <a href='/notes'>notes</a> <img src='kuva.png' width='200' /> </div> </body> </html> `) } app.get('/', (req, res) => { const page = getFrontPageHtml(notes.length) res.send(page) }) بالطبع ليس عليك استيعاب الشيفرة المكتوبة حاليًا. يُحفظ محتوى صفحة HTML على شكل قالب نصي (Template String) أو على شكل سلسلة نصية تسمح على سبيل المثال، بتحديد قيمة المتغيرات الموجودة ضمنها. فالقسم الديناميكي (القابل للتغيير) في صفحة التطبيق النموذجي السابق هو القسم الذي يشير إلى عدد الملاحظات المدونة و يدعى notecount. يُستبدَل هذا القسم بالعدد الفعلي للملاحظات notes.length ضمن القالب النصي للصفحة. لاحظ أن وجود شيفرة HTML ضمن الشيفرة السابقة هو أمر غير مُحبَّذ، لكن مبرمجي مدرسة PHP التقليدية يعتبرون ذلك شيئًا طبيعيًا. يُعتبَر المتصفح في تطبيقات الويب التقليدية "محايدًا". فوظيفته فقط إحضار بيانات HTML من الخادم الذي يقع على عاتقه القيام بكل العمليات المنطقية التي يحتاجها التطبيق. يمكن بناء الخادم باستعمال لغة Java Spring كالخادم الذي استخدم في منهاج Web-palvelinohjelmointi لجامعة هلسنكي، أو استعمال لغة Python Flask كمنهاج tietokantasovellus ، أو باستعمال Ruby and Rails. سيستعمل التطبيق السابق Express من Node.j.s، وسيستخدم هذا المنهاج التقنيتين السابقتين لبناء الخادم. تشغيل التطبيق من المتصفح ابق طرفية التطوير مفتوحةً، واحذف ما فيها بالنقر على ?. عندما تنقر الآن على رابط notes، سيرسل المتصفح أربع طلبات HTTP: لهذه الطلبات أنواع مختلفة. ويمثل النوع (مستند document)، شيفرة HTML للصفحة ويظهر بالشكل التالي: الآن، عندما نقارن الصفحة الظاهرة على المتصفح مع شيفرة HTML التي يعيدها الخادم، سنلاحظ أن الشيفرة لا تحتوي على قائمة الملاحظات. تحتوي ترويسة القسم html من الشيفرة على العنصر script الذي يطلب من المتصفح إحضار ملف JavaScript يدعى main.js. تظهر شيفرة JavaScript كالتالي: var xhttp = new XMLHttpRequest() xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { const data = JSON.parse(this.responseText) console.log(data) var ul = document.createElement('ul') ul.setAttribute('class', 'notes') data.forEach(function(note) { var li = document.createElement('li') ul.appendChild(li) li.appendChild(document.createTextNode(note.content)) }) document.getElementById('notes').appendChild(ul) } } xhttp.open('GET', '/data.json', true) xhttp.send() لا تهمنا حاليًا التفاصيل الواردة في الشيفرة السابقة، لكن جزء منها قد كتب لتجميل مظهر الصور والنصوص (من المرجح أن نبدأ كتابة شيفرات في القسم 1). ونلفت انتباهك أن كتابة الشيفرة السابقة لا تتناسب إطلاقًا وتقنيات كتابة الشيفرات التي ستتعلمها في هذا المنهاج. قد يتساءل البعض عن مغزى استخدام كائن xhttp بدلًا من الأسلوب الحديث لجلب البيانات. إن السبب الرئيسي هو أننا لانريد الآن إظهار ما وعدنا به، و أيضًا للدور الثانوي الذي تلعبه هذه الشيفرة في هذا القسم من المنهاج. لكننا سنطبق في القسم الثاني الطرق الأحدث في إرسال الطلبات إلى الخادم. بعد إحضار ماطلبه عنصرscript مباشرةً، يبدأ المتصفح بتنفيذ الشيفرة. حيث يشير السطرين الأخيرين إلى أن المتصفح سيرسل طلبا من نوع HTTP-GET إلى العنوان التالي data.json/ على الخادم: xhttp.open('GET', '/data.json', true) xhttp.send() سنكون بذلك قد وصلنا إلى آخر طلب سيظهر ضمن النافذة (Network). يمكننا بطبيعة الحال الوصول إلى هذا العنوان مباشرة من نافذة المتصفح كما في الشكل التالي: سنحصل عند فتح العنوان السابق على الملاحظات مكتوبة بصيغة بيانات غير معالجة (raw data) كالتي يستخدمها JSON. لا يمكن للمتصفح في الحالة الطبيعية إظهار هذه البيانات بشكل واضح، لذلك يمكن تثبيب إضافة (Plugin) للتعامل مع هذه الصيغة من البيانات. ثبت على سبيل المثال JSONView على متصفح Chrome ثم أعد تحميل الصفحة. ستظهر البيانات الآن بشكل مفهوم. إذًا، تُحمِّل شيفرة HTML في صفحة الملاحظات Notes بيانات JSON تحتوي على الملاحظات، وترتبها على شكل قائمة نقاط. ويتم ذلك بتنفيذ الشيفرة التالية: const data = JSON.parse(this.responseText) console.log(data) var ul = document.createElement('ul') ul.setAttribute('class', 'notes') data.forEach(function(note) { var li = document.createElement('li') ul.appendChild(li) li.appendChild(document.createTextNode(note.content)) }) document.getElementById('notes').appendChild(ul) تُنشأ الشيفرة بداية قائمة غير مرتبة ul كما يلي: var ul = document.createElement('ul') ul.setAttribute('class', 'notes') ثم تضيف العنصرli (الذي يمثل عنصرًا من قائمة نقاط) لكل ملاحظة، وتضع بداخله محتويات الحقل content فقط من الملاحظة. أما القيم الزمنية الموجودة في الحقل date، فلن تستخدم هنا. data.forEach(function(note) { var li = document.createElement('li') ul.appendChild(li) li.appendChild(document.createTextNode(note.content)) }) افتح الآن النافذة Console ضمن طرفية التطوير كالتالي: سيظهر النص كاملًا عند النقر على المثلث الصغير في بداية السطر: نتج النص السابق عن تعليمة console.log الموجودة في الشيفرة التالية: const data = JSON.parse(this.responseText) console.log(data) وبالتالي ستطبع الشيفرة تلك البيانات في الطرفية بعد استقبالها من الخادم. ستعتاد التعامل مع نافذة Console ومع تعليمة Console.log خلال مسيرتك في هذا المنهاج. معالج الأحداث (Event Handler) و دوال رد النداء (Callback functions) ستبدو لك بنية هذه الشيفرة غريبةً نوعا ما: var xhttp = new XMLHttpRequest() xhttp.onreadystatechange = function() { // الشيفرة التي ستعالج استجابة الخادم } xhttp.open('GET', '/data.json', true) xhttp.send() لقد أُرسِل الطلب إلى الخادم في آخر سطر من الشيفرة، لكن التعليمات التي تعالج الاستجابة أتت في البداية. ما الذي يحدث؟ في هذا السطر: xhttp.onreadystatechange = function () { تم تحديد معالج (handler) للحدث onreadystatechange، الخاص بالكائن xhttp، أثناء إرسال الطلب إلى الخادم. فعندما تتغير حالة هذا الكائن، يستدعي المتصفح الدالة التي تمثل معالج هذا الحدث، ثم تتحق الدالة من أن قيمة الخاصة 'readystate' تساوي 4 (والتي تشير إلى أن العملية قد اكتملت)، وأن قيمة رمز الحالة (status code) لبروتوكول HTTP عند الاستجابة تساوي 200. xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { // الشيفرة التي ستعالج استجابة الخادم } } إن هذا الأسلوب في استدعاء معالجات الأحداث شائع جدًا في لغة JavaScript، وتسمى دالة معالج الأحداث (نداء callback). لا تستدعي شيفرة التطبيق الدوال بنفسها، بل يستدعي المتصفح الذي يعتبر (بيئة التشغيل) في هذه الحالة، الدوال في الوقت المناسب وذلك عندما يقع الحدث. نموذج كائن المستند (Document Object Model DOM) يمكنك التفكير بصفحة HTML على أنها بنًى شجرية متداخلة. html head link script body div h1 div ul li li li form input input يمكن مشاهدة بنًى كهذه ضمن الطرفية /نافذة Elements: تعتمد آلية عمل المتصفح على فكرة تمثيل عناصر HTML على شكل شجرة. وبما أن نموذج كائن المستند DOM هو واجهة تطبيقات برمجية API، قادرة على تعديل العناصر الشجرية لصفحة الويب برمجيًا، فقد استخدمت شيفرة JavaScript التي رأيناها سابقًا الواجهة DOM-API لإضافة لائحة بالملاحظات المدونة في الصفحة. تضيف الشيفرة التالية عقدة جديدة للمتغيِّر ul، ثم تضيف عقدًا ضمنه (عقد أبناء): var ul = document.createElement('ul') data.forEach(function(note) { var li = document.createElement('li') ul.appendChild(li) li.appendChild(document.createTextNode(note.content)) }) وأخيرًا، بعد اكتمال فرع الشجرة الذي يمثله المتحول ul، سنضمه إلى شجرة HTML الممثلة لكامل صفحة الويب في مكانه المناسب. document.getElementById('notes').appendChild(ul) إجراء التعديلات على DOM من الطرفية يمكننا القيام بالعديد من العمليات على صفحة الويب باستخدام واجهة DOM-API. تدعى العقدة الأعلى في شجرة ملف HTML، كائن document. وللوصول إلى كائن مستند صفحة الويب من الطرفية اكتب كلمة document في النافذة Console. لنضف ملاحظة جديدة إلى الصفحة باستخدام الطرفية. دعونا نستعرض أولًا قائمة الملاحظات الموجودة في أول عنصر ul تحتويه الصفحة: list = document.getElementsByTagName('ul')[0] بعد ذلك سننشئ عنصر li جديد ونكتب نصًا ما داخله: newElement = document.createElement('li') newElement.textContent = 'Page manipulation from console is easy' وأخيرًا سنضيف العنصر الجديد إلى القائمة: list.appendChild(newElement) على الرغم من أن محتوى الصفحة قد تغير على متصفحك، إلا أنَّ هذا التغيير مؤقت. فلو حاولت إعادة تحميل الصفحة ستختفي التغيرات التي أجريتها، ذلك أن التغيرات لم ترفع إلى الخادم. إذًا، ستنشئ شيفرة JavaScript التي أحضرها المتصفح، قائمة بالملاحظات الموجودة فقط في العنوان https://fullstack-exampleapp.herokuapp.com/data.json صفحات الأنماط الانسيابية Cascading Style Sheets CSS يحتوي عنصر head في ملف HTML المرتبط بالصفحة Notes، عنصر ارتباط Link، يخبر المتصفح ضرورة إحضار ملف CSS من العنوان main.css. وتعتبر ملفات CSS لغة وصفية (Markup) تستخدم بكثرة في التحكم بمظهر صفحات الويب. يظهر ملف CSS الذي سيحضره المتصفح كالتالي: .container { padding: 10px; border: 1px solid; } .notes { color: blue; } يعرّف الملف محددي صنف (Class Selectors). يستخدم محدد الصنف لاختيار أجزاء من صفحة الويب، يطبق عليها قواعد محددة للتحكم بمظهرها. يبدأ سطر تعريف محدد الصنف بنقطة (.)، يليها اسم لهذا الصنف. وتعتبر هذه الأصناف (خاصيات attributes) يمكن إضافتها إلى عناصر HTML للتحكم بمظهر هذه العناصر. يمكن تفحص خاصيات CSS ضمن نافذة Elements في الطرفية: ] لاحظ أن العنصر div يمتلك الصنف container، بينما يمتلك العنصر ul الصنف notes. وبالعودة إلى ملف CSS، فإن أي عنصر يمتلك الصنف container سيُحاط بمربع مستمر سماكته 1 بكسل وسيُضبط بعد العنصر عن محيطه (padding) بمقدار 10 بكسل، وأي عنصر يمتلك الصنف notes سيكون النص فيه أزرق اللون. يمكن لعناصر HTML امتلاك خاصيات أخرى غير classes. فالعنصر div الذي يحتوي الملاحظات notes، يمتلك خاصية تدعىid. تستخدم JavaScript هذه الخاصية لإيجاد العنصر. يمكن استخدام النافذة Elements في الطرفية لتغيير مظهر العناصر. وكما هي الحال لن تكون التغييرات التي نجريها دائمة. إن أردت تغييرات دائمة، عليك أن تحفظها في مستند CSS الموجود على الخادم. تحميل صفحة تحتوي على شيفرة JavaScript (نظرة ثانية) دعونا نعيد النظر إلى ما يجري عندما نفتح الصفحة https://fullstack-exampleapp.herokuapp.com/notes من خلال المتصفح. يحضر المتصفح شيفرة HTML التي تحدد هيكل ومحتوى الصفحة من الخادم عبر طلب HTTP-GET. تطلب العناصر link في شيفرة HTML من المتصفح إحضار ملف CSS عنوانه main.css وكذلك ملف JavaScript عنوانه main.js من الخادم. ينفذ المتصفح شيفرة JavaScript. ترسل الشيفرة طلب HTTP-GET إلى العنوان https://fullstackexampleapp.herokuapp.com/data.json الذي سيستجيب بإرسال الملاحظات على شكل بيانات JSON. عندما تصل البيانات، سينفذ المتصفح الشيفرة الموجودة ضمن معالج حدث، وهذا الأخير سيظهر الملاحظات على صفحة الويب مستخدمًا الواجهة DOM-API. الاستمارات Forms وطلبات HTTP-POST دعونا نتفحص الآن كيف أُضيفت ملاحظة جديدة. تحتوي الصفحة Notes على عنصر استمارة Form، عندما ننقر على الزر الموجود في الاستمارة، سيرسل المتصفح المعلومات التي أدخلها المستخدم إلى الخادم. لنفتح النافذة Network ولنرى كيف يتم إرسال الاستمارة: نتج عن إرسال الاستمارة خمس طلبات HTTP. يمثل الأول حدث إرسال الاستمارة (Form Submit Event)، وهو طلب HTTP-POST إلى العنوان new_note الموجود على الخادم. سيستجيب الخادم برمز حالة رقمه 302، ويعني هذا أن عملية تحويل عنوان (URL-redirect) قد تمت. في هذه العملية يطلب الخادم من المتصفح إن يرفع طلب HTTP-GET جديد إلى العنوان notes الذي نجده ضمن الموقع Location الذي يمثل جزءًا من ترويسة الاستجابة التي أرسلها الخادم. سيعيد الخادم إذًا، تحميل صفحة الملاحظات. وتسبب تلك العملية ثلاثة طلبات HTTP: إحضار ملف CSS باسم main.css إحضار ملف JavaScript باسم main.js إحضار ملف البيانات data.json يمكن أن نشاهد البيانات التي أرسلت أيضًا في نافذة Network: يمتلك معرّف الاستمارةForm خاصيتي action وmethod اللتان تشيرا إلى أن إرسال الاستمارة سيكون على شكل طلب HTTP-POST إلى العنوان new_notes. تعتبر الشيفرة الموجودة على الخادم والمسؤولة عن الاستجابة إلى طلب المتصفح بسيطة (لاحظ: هذه الشيفرة موجودة على الخادم، وليست جزءًا من ملف JavaScript الذي يحضره المتصفح): app.post('/new_note', (req, res) => { notes.push({ content: req.body.note, date: new Date(), }) return res.redirect('/notes') }) ترسل البيانات ضمن جسم (Body) طلب الإرسال (Post-Request) حيث يتمكن الخادم عندها من الوصول إلى البيانات الموجودة في جسم كائن من النوع Request يدعى req باستعمال الطريقة req.body. ومن ثم ينشأ الخادم كائنًا جديدًا من النوع note، ويضيفه إلى مصفوفة تدعى notes: notes.push({ content: req.body.note, date: new Date(), }) يضم الكائن note حقلين، يدعى الأول content يحوي نص الملاحظة، ويدعى الآخرdate ويحوي معلومات عن تاريخ ووقت إنشاء الملاحظة. طبعًا، لم يحفظ الخادم هذه الملاحظة ضمن قاعدة بيانات، وبالتالي ستختفي الملاحظة الجديدة بانتهاء جلسة العمل. تقنية AJAX اعتمدت صفحة Notes في التطبيق السابق أسلوبًا قديمًا في تطوير الويب، بالإضافة إلى استخدامه تقنية AJAX التي تصدرت قائمة تقنيات الويب بداية العقد الماضي. تأتي التسمية AJAX من (Asynchronous JavaScript and XML). قدم هذا المصطلح في فبراير (شباط) عام 2005، على خلفية التطورات في تقنية المتصفح، لتقدم مقاربةً ثوريةً جديدةً تمكن المتصفح من إحضار محتوى صفحات الويب باستخدام شيفرة JavaScript مدمجة ضمن HTML، دون الحاجة لإعادة تكوين (Rerender) الصفحة من جديد. استخدمت جميع تطبيقات الويب أسلوب التطبيق النموذجي الذي استعرضناه سابقًا قبل ظهور Ajax. فجميع البيانات التي تعرض في الصفحة، يتم إحضارها عند تنفيذ شيفرة HTML التي أنشأها الخادم. استخدمت صفحة Note تقنية AJAX لإحضار البيانات، بينما استخدمت الطريقة التقليدية في إرسال الاستمارة. وتعكس العناوين التي استخدمها التطبيق أسلوب الزمن القديم اللامبالي، حيث أحضرت بيانات JSON من العنوان: https://fullstack-exampleapp.herokuapp.com/data.json وأرسلت الملاحظات الجديدة إلى العنوان: https://fullstack-exampleapp.herokuapp.com/new_note. لاتعتبر هذه العناوين مقبولة حاليًا، كونها لا تتقيد بالتفاهمات العامة التي جرى الاتفاق عليها بخصوص واجهة التطبيقات RESTful التي سنراها في القسم 3. شاع مصطلح AJAX كثيرًا لدرجة أنه اعتبر من المسلمات، ثم تلاشى في عالم النسان ولم يعد يسمع به أحد من الأجيال الجديدة. تطبيق من صفحة واحدة تسلك الصفحة الرئيسية في المثال النموذجي الذي عملنا عليه سلوك صفحة الويب التقليدية. حيث تجري العمليات المنطقية للتطبيق على الخادم، بينما يكّون المتصفح ملف HTML بمقتضى الشيفرة المكتوبة. أما صفحة Notes، فتلقي على المتصفح بعض المسؤولية في توليد شيفرة HTML لعرض الملاحظات الموجودة. إذ ينفذ شيفرة JavaScript التي أحضرها من الخادم، وستحضر هذه الشيفرة بدورها الملاحظات على شكل بيانات JSON، ثم يضيف إليها المتصفح عناصر HTML لعرض الملاحظات على الصفحة مستخدمًا DOM-API. سادت فكرة تصميم تطبيق من صفحة واحدة (Single-page application (SPA، في السنوات الأخيرة. فمواقع الويب التي تستخدم هذا الأسلوب، لاتحضر صفحاتها من الخادم بشكل منفصل كما فعل التطبيق النموذجي السابق، بل تضم جميع الصفحات في صفحة HTML واحدة وتحضرها، ويتم التعامل معها لاحقًا بواسطة شيفرة JavaScript التي تُنفَّذ ضمن المتصفح. تحمل صفحة Notes في تطبيقنا السابق شبهًا بتطبيقات الويب ذات الصفحة الواحدة، لكنها لم تبلغ هذا المستوى بعد. فبالرغم من أن منطق تكوين الملاحظات ينفذه المتصفح، لاتزال الصفحة تستخدم الطريقة التقليدية في إضافة الملاحظات. حيث ترسل الاستمارة البيانات إلى الخادم، الذي يوجه المتصفح لإعادة تحميل الصفحة مع إعادة توجيه نحو عنوان جديد. يحوي العنوان التالي https://fullstack-exampleapp.herokuapp.com/spa نسخة الصفحة الواحدة من تطبيقنا النموذجي. حيث نلاحظ للوهلة الأولى تطابق الطريقتين. فللطريقتين شيفرة HTML نفسها، لكن ملف JavaScript مختلف ويدعى (spa.js)، وكذلك حصل تغيير بسيط في كيفية استخدام معرّف الاستمارة Form، فلا تمتلك الاستمارة الآن خاصيات method و action لتحدد كيف وأين سترسل البيانات المدخلة ضمنها. افتح نافذة Network، وامسح محتواها بالنقر على الرمز ?. ستلاحظ أن طلبًا واحدًا فقط أرسل إلى الخادم عند إنشاء ملاحظة جديدة. يتضمن طلب الإرسال POST إلى العنوان new_note_spa ملاحظة جديدة بصيغة بيانات JSON، تضم محتوى الملاحظة content وتوقيتها date: { content: "single page app does not reload the whole page", date: "2019-05-25T15:15:59.905Z" } يفهم الخادم أن البيانات الواردة إليه مكتوبة بصيغة JSON، عن طريق الخاصية Content-Type من ترويسة الطلب. ومن غير هذه الترويسة، لن يعرف المتصفح كيف يفسر البيانات بشكل صحيح. يستجيب الخادم معلنًا نجاح عملية إنشاء الملاحظة الجديدة معيدًا رمز الحالة 201. لن يطلب من المتصفح هذه المرة إعادة توجيه الصفحة إلى عنوان آخر، بل يبقى المتصفح على نفس الصفحة دون أن يرسل أية طلبات HTTP أخرى. لاترسل نسخة SPA من التطبيق بيانات الاستمارة بالطريقة التقليدية، بل تستخدم شيفرة JavaScript التي تحضرها من الخادم. سنلقي نظرة سريعة على هذه الشيفرة، لكن ليس عليك الآن فهم تفاصيلها. var form = document.getElementById('notes_form') form.onsubmit = function(e) { e.preventDefault() var note = { content: e.target.elements[0].value, date: new Date(), } notes.push(note) e.target.elements[0].value = '' redrawNotes() sendToServer(note) } تطلب التعليمة ('document.getElementById('notes_form إحضار العنصر Form من الصفحة، وربطه بمعالج أحداث ليتعامل مع حدث إرسال الاستمارة. يستدعي معالج الأحداث مباشرة التابع ()e.preventDefault لإيقاف الاستجابة الافتراضية لحدث الإرسال. هذه الاستجابة التي لانريد حصولها، هي من ترسل البيانات إلى الخادم من خلال طلب GET جديد. بعدها ينشأ معالج الأحداث ملاحظة جديدة ويضيفها إلى قائمة الملاحظات باستعمال التعليمة (notes.push(note ثم يعيد تكوين هذه القائمة على الصفحة ويرسل الملاحظة إلى الخادم. تمثل الشيفرة التالية تعليمات إرسال الملاحظة إلى الخادم: var sendToServer = function(note) { var xhttpForPost = new XMLHttpRequest() // ... xhttpForPost.open('POST', '/new_note_spa', true) xhttpForPost.setRequestHeader( 'Content-type', 'application/json' ) xhttpForPost.send(JSON.stringify(note)) } تحدد التعليمات السابقة طريقة إرسال البيانات على شكل طلب HTTP-POST وأنها بصيغة JSON، ومن ثم ترسل البيانات على شكل سلسلة JSON النصية. تتوفر شيفرة التطبيق على العنوان https://github.com/mluukkai/example_app. وعليك أن تتذكر أن التطبيق قد صمم لإبراز مفاهيم المنهاج فقط، وأنه اعتمد أسلوبًا ضعيفًا نسبيًا لايجب عليك اتباعه عند كتابة تطبيقاتك الخاصة. وينطبق كلامنا أيضًا على طريقة استخدام العناوين التي لا تتماشى مع معايير البرمجة الأفضل المعتمدة حاليًا. مكتبات JavaScript صمم التطبيق السابق بما يسمى vanilla Javascript أي باستعمال جافاسكربت فقط، التي تستخدم DOM-API و JavaScript في تغيير بنية الصفحة. وبدلًا من استخدام التقنيتين السابقتين فقط، تستخدم مكتبات مختلفة تحتوي على أدوات يسهل التعامل معها مقارنة بتقنية DOM-API. وتعتبر JQuery أحد أشهر هذه المكتبات.طورت المكتبة JQuery في الوقت الذي استخدمت فيه تطبيقات الويب الأسلوب التقليدي المتمثل بإنشاء الخادم لصفحة HTML، وقد حسَّن ظهورها فعالية المتصفح باستخدام JavaScript المكتوبة اعتمادًا على هذه المكتبة. يعتبر أحد أسباب نجاح JQuery توافقها مع المتصفحات المختلفة (cross-browser). حيث تعمل المكتبة أيًّا كان المتصفح أو الشركة التي أنتجته، وبالتالي انتفت الحاجة إلى حلول خاصة بكل متصفح. لا تُستخدَم JQuery اليوم بالشكل المتوقع، على الرغم من تطور أسلوب Vanilla JS -أي كما ذكرنا الاعتماد على جافاسكربت الصرفة- والدعم الجيد الذي تقدمه أشهر المتصفحات لوظائفها الأساسية. دفع انتشار التطبيق وحيد الصفحة بتقنيات أحدث من jQuery إلى الواجهة، حيث جذبت BackboneJS الموجة الأولى من المطورين. لكن سرعان ما أصبحت AngularJS التي قدمتها غوغل، المعيار الفعلي لتطوير تطبيقات الويب الحديثة، بعد إطلاقها عام 2012. لكن شعبيتها انخفضت بعد أن صرّح فريق العمل في أكتوبر (تشرين الأول) لعام 2014، أن دعم النسخة الأولى سينتهي، وأن النسخة الثانية لن تكون متوافقة معها. لذلك لم تلق Angular بنسختيها ترحيبًا حارًا جدًا. تعتبر حاليًا مكتبة React التي أنتجها Facebook، الأكثر شعبية في مشاركة المتصفح عند تنفيذ منطق تطبيقات الويب. وسنتعلم خلال مسيرتنا في هذا المنهاج التعامل معها وكذلك التعامل مع مكتبة Redux التي سنستخدمها مع React بشكل متكرر. تبدو React حاليًا في موقع قوة، لكن عالم JavaScript دائم التغير. فالقادم الجديد لهذه العائلة VueJS يلقى اهتماما متزايدًا الآن (يمكنك الاطلاع على سلسلة "مقدمة إلى Vue.js" للتعرف على هذه المكتبة الرائعة). التطوير الشامل لتطبيق الويب ما الذي يعنيه مصطلح «التطوير الشامل لتطبيق الويب» (Full stack web development)؟. هذا المصطلح الذي يردده الجميع دون معرفة فعلية بمضمونه أو على الأقل ليس هناك اتفاق على تعريف محدد له. تمتلك جميع تطبيقات الويب من الناحية العملية مستويين: الأول مستوى العمل مع المتصفح وهو الأقرب إلى المستخدم النهائي (End-User) ويعتبر المستوى الأعلى. والثاني هو مستوى الخادم وهو المستوى الأدنى. وتوضع أحيانًا قواعد البيانات في مستوًى أخفض من مستوى الخادم. وبالتالي يمكننا التفكير بهندسة تطبيقات الويب على أنها مستويات متراكمة. يتم الحديث أحيانًا عن واجهة أمامية (frontend) وعن واجهة خلفية (backend). سيمثل المتصفح وشيفرة JavaScript التي ينفذها الواجهة الأمامية، بينما يمثل الخادم الواجهة الخلفية. يُفهم من مصطلح التطوير الشامل في سياق هذا المنهاج، أن العمل سيكون على كل المستويات سواء الواجهة الأمامية أو الخلفية، وكذلك على قواعد البيانات. وقد تعتبر البرمجيات الخاصة بالخادم ونظام تشغيله جزءًا من مستويات التطوير، لكننا لن ندخل في هذا الموضوع. سنكتب شيفرة الواجهة الخلفية بلغة JavaScript مستخدمين بيئة التشغيل Node.js. وسيمنح استخدام نفس لغة البرمجة على مستويات التطوير جميعها بعدًا جديدًا لعملية التطوير الشامل، علمًا أن هذا الأمر ليس ملزمًا. اعتاد المطورون على التخصص بأحد مستويي التطوير، ذلك أن التقنيات المستخدمة في المستويين مختلفة تمامًا. لكن في مضمار التطوير الشامل، لابد من وجود مطورين أكفاء في جميع المستويات بما فيها قواعد البيانات. كما ينبغي على المطورين في بعض الأحيان، امتلاك مهارات في التهيئة والإدارة لتشغيل تطبيقاتهم على منصات مختلفة كالمنصات السحابية (Cloud). جافاسكربت المتعبة يحمل التطوير الشامل للتطبيقات تحديات مختلفة. فأمور كثيرة تحدث معًا في أماكن مختلفة، وتنقيح المشاكل أصعب قليلًا مقارنة بتطبيقات سطح المكتب. فلا تتصرف JavaScript كما هو متوقع منها دائمًا (مقارنة بغيرها من اللغات)، كما أن الطريقة غير المتزامنة التي تعمل بها بيئة التشغيل ستبرز كل أنواع التحديات. المعرفة ببروتوكولHTTP ضروري جدًا لفهم الاتصال في الشبكة، وكذلك معالجة الأمور المتعلقة بقواعد البيانات وإدارة وتهيئة الخادم. لابد أيضا من امتلاك معرفة جيدة بتنسيق CSS لتقديم التطبيق بشكل لائق على الأقل. يتغير عالم JavaScript بسرعة، ولهذا الأمر حصته من التحديات. فكل الأدوات والمكتبات حتى اللغة نفسها عرضة للتطوير المستمر، حتى أن البعض سئم من التغيير المستمر، فأطلق مصطلح جافاسكربت المتعبة (أو Javascript fatigue). ستعاني من هذا التعب أنت أيضًا خلال مسيرتك في المنهاج، ولحسن حظنا، هناك طرق عدة لتسهيل الأمور، منها أننا سنبدأ بكتابة الشيفرة بدلًا من الغوص في عمليات التهيئة، والتي لا يمكننا تفاديها تمامًا، لكن سنحاول أن نتقدم خلال الأسابيع القليلة القادمة متجاوزين الأسوء في هذا الكابوس. التمارين 0.1 - 0.6 ارفع حلول التمارين على منصة GitHub، وينبغي عليك الإشارة إلى إتمام عملية التحميل ضمن منظومة تسليم الملفات submission system. يمكنك تحميل جميع التمارين في نفس المكان، أو استخدم أماكن مختلفة. اعط أسماء مناسبة للمجلدات إن رفعت التمارين التي تعود لأقسام مختلفة من المنهاج إلى نفس المكان، وأضف اسم mluukkai إلى قائمة المتعاونين، إن حمَّلت ملفاتك إلى مجلدات خاصة. تعتبر الطريقة التالية جيدة في تسمية المجلدات: part0 part1 Courseinfo unicafe anecdotes part2 phonebook countries لاحظ أن لكل قسم مجلده الخاص الذي يضم مجلدات فرعية لكل تمرين (مثل تمرين unicafe في القسم1). وانتبه جيدًا لرفع كل التمارين العائدة لقسم محدد معًا، فلو رفعت تمرينًا واحدًا فقط، لن تتمكن من رفع أية تمارين أخرى تعود لنفس القسم. 1- التمرين 0.1 (HTML) راجع أساسيات HTML بقراءة هذه الدورة التعليمية من Mozilla. لاترفع هذا التمرين إلى GitHub، يكفي أن تطلع على الدورة. 2- التمرين 0.2 (CSS) راجع أساسيات CSS بقراءة هذه الدورة التعليمية من Mozilla. لاترفع هذا التمرين إلى GitHub، يكفي أن تطلع على الدورة. 3- التمرين 0.3 (HTML forms) تعلم أساسيات استخدام استمارات HTML بقراءة هذه الدورة التعليمية من Mozilla. لاترفع هذا التمرين إلى GitHub، يكفي أن تطلع على الدورة. 4- التمرين 0.4 (ملاحظة جديدة new note) في فقرة تحميل صفحة تحتوي على شيفرة JavaScript (نظرة ثانية)، عرضت سلسلة الأحداث الناتجة عن فتح هذه الصفحة على شكل مخطط تتابعي أُنجز باستخدام خدمة websequencediagrams كالتالي: browser-`server: HTTP GET https://fullstack-exampleapp.herokuapp.com/notes server--`browser: HTML-code browser-`server: HTTP GET https://fullstack-exampleapp.herokuapp.com/main.css server--`browser: main.css browser-`server: HTTP GET https://fullstack-exampleapp.herokuapp.com/main.js server--`browser: main.js note over browser: browser starts executing js-code that requests JSON data from server end note browser-`server: HTTP GET https://fullstack-exampleapp.herokuapp.com/data.json server--`browser: [{ content: "HTML is easy"، date: "2019-05-23" }، ...] note over browser: browser executes the event handler that renders notes to display end note أنشئ مخططًا مماثلًا لتوضيح الحالة التي ينشأ فيها المستخدم ملاحظة جديدة في الصفحة https://fullstack-exampleapp.herokuapp.com/notes، وذلك بكتابة شيء ما في حقل النص ونقر زر إرسال. يمكنك إظهار العمليات على المخدم أو المتصفح كتعليقات على المخطط، إن وجدت هذا ضروريًا. ولا حاجة أن يكون المخطط تتابعيًّا، فلا بأس بأية طريقة مفهومة لعرض الموضوع. ستجد كل المعلومات المتعلقة بحل هذا التمرين مع التمرينين القادمين في نص هذا القسم. فكرة هذه التمارين قراءة النص مرة أخرى والتفكر مليًا بما يحدث. ليس من الضروري قراءة شيفرة التطبيق على الرغم من أن ذلك ممكن. 5- تمرن 0.5 (تطبيق من صفحة واحدة SPA) أنشئ مخططًا لتوضيح الحالة التي ينتقل فيها المستخدم إلى صفحة نسخة تطبيق من صفحة واحدة من تطبيق الملاحظات على الرابط https://fullstack-exampleapp.herokuapp.com/spa. 6- تمرين 0.6 (ملاحظة جديدة new note) أنشئ مخططًا لتوضيح الحالة التي ينشئ فيها المستخدم ملاحظة جديدة مستخدمًا نسخة التطبيق ذو الصفحة الواحدة. بهذا التمرين نصل إلى نهاية القسم. لقد حان الوقت لترفع إجاباتك على GitHub. لا تنسى أن تشير إلى إتمام عملية التحميل ضمن منظومة تسليم الملفات submission system. يمكنك اقتراح تعديلات على القسم أيضًا إن أحببت في المقال الأصلي. ترجمة -وبتصرف- للفصل Fundamentals of web apps من سلسلة Deep Dive Into Modern Web Development