-
المساهمات
164 -
تاريخ الانضمام
-
تاريخ آخر زيارة
نوع المحتوى
ريادة الأعمال
البرمجة
التصميم
DevOps
التسويق والمبيعات
العمل الحر
البرامج والتطبيقات
آخر التحديثات
قصص نجاح
أسئلة وأجوبة
كتب
دورات
كل منشورات العضو ابراهيم الخضور
-
تستخدم منحنيات بيزيه Bezier curves في رسوميات الحاسوب لإنشاء الأشكال والرسوم المتحركة المعتمدة على أوراق التنسيق المتتالية CSS وغيرها. هذه المنحنيات بسيطة وتستحق الدراسة، وبعدها ستكون مرتاحًا عند التعامل مع الرسوميات الشعاعية vector graphics والرسوم المتحركة المتقدمة. نقاط التحكم يحدد منحني بيزيه بواسطة مجموعة من النقاط الحاكمة، وقد يكون عددها 2 أو 3 أو 4 أو أكثر، لاحظ مثلًا منحنيًا من نقطتين: والمنحني التالي محدد بثلاث نقاط: وهذا بأربع نقاط: لو تمعنت في هذه المنحنيات، فيمكنك أن تلاحظ التالي: لا تنتمي النقاط بالضرورة إلى المنحني وهذا أمر طبيعي، وستفهم ذلك عندما سنشرح طريقة بنائه لاحقًا. درجة المنحني curve order تساوي عدد النقاط ناقصًا واحدًا، فالمنحني المؤلف من نقطتين هو خط مستقيم من الدرجة الأولى، والمنحني المؤلف من ثلاث نقاط هو منحن تربيعي، أي قطع مكافئ، وهكذا. ينحصر المنحني ضمن المضلع المحدّب convex hull الذي تشكله النقاط الحاكمة: سنتمكن من أَمثَلة اختبارات التداخل في رسوميات الحاسوب اعتمادًا على الخاصية الأخيرة، فلن تتداخل المنحنيات إن لم تتداخل المضلعات المحدبة، وبالتالي ستعطي دراسة تداخل المضلعات المحدبة جوابًا سريعًا لتداخل المنحنيات، بالإضافة إلى أنّ دراسة تداخل المضلعات أسهل لأنّ أشكالها مثل المثلث والمربع، تُعَد مفهومة موازنةً بالمنحنيات. تكمن الفائدة الرئيسية لاستخدام منحني بيزيه في تغيير شكل المنحني بمجرد تحريك النقاط الحاكمة. لاحظ المنحني الذي تولده الشيفرة التالية، واستخدم الفأرة لتحريك النقاط الحاكمة وراقب تغير المنحني: ستتمكن بعد التمرن قليلًا من رسم المنحني المطلوب بمجرد تحريك النقاط الحاكمة، ويمكنك عمليًا الحصول على أي شكل تريده بوصل عدة منحنيات، إليك بعض الأمثلة: خوارزمية دي كاستلجو De Casteljau يمكننا استخدام صيغة رياضية لرسم منحنيات بيزيه، وهذا ما سنشرحه لاحقًا، لأنّ خوارزمية دي كاستلجو De Casteljau’s algorithm التي نسبت لمخترعها، تتطابق مع التعريف الرياضي، وتساعدنا على تمييز طريقة إنشاء المنحنيات بصريًا. سنرى أولًا مثالًا لمنحن من ثلاث نقاط حاكمة، لاحظ أنه يمكنك تحريك النقاط 1 و 2 و 3 بالفأرة، ثم انقر زر التشغيل الأخضر. خوازمية دي كاستلجو لبناء منحني بيزيه من ثلاث نقاط ارسم النقاط الثلاث، والتي تمثلها النقاط 1 و2 و3 في المثال السابق. صل بين النقاط السابقة باتجاه واحد 3 → 2 → 1، وهي الخطوط البنية في المثال السابق. يأخذ المعامل t قيمه بين "0" و"1"، وقد استخدمنا في مثالنا النموذجي السابق خطوةً مقدارها "0.05"، أي تتحرك حلقة التنفيذ وفق الآتي: 0 ثم 0.05 ثم 0.1 ثم 0.15 وهكذا. لكل قيمة من قيم t: نأخذ على كل خط بني بين نقطتين متتاليتين نقطةً تبعد عن بدايته مسافةً تتناسب مع t، وطالما أن هناك خطان، فسنحصل على نقطتين، وستكون كلتا النقطتين في بداية الخط عندما يكون t=0 مثلًا، كما ستبعد النقطة الأولى عن بداية الخط مسافةً تعادل 25% من طوله عندما تكون t=0.25، وهكذا. نصل بين النقطتين المتشكلتين، والخط الواصل في المثال التالي باللون الأزرق. 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; } p iframe.code-result__iframe { border: 1px solid #e7e5e3 !important; } t=0.25 t=0.5 خذ على الخط الأزرق السابق نقطةً تبعد عن طرفه مقدارًا يتناسب مع t، ستكون النقطة في نهاية الربع الأخير من الخط من أجل t=0.25، وستكون في منتصف الخط من أجل t=0.5، وهي النقطة الحمراء في الشكل السابق. عندما تتحول t بين 0 و1 ستضيف كل قيمة لهذا المعامل نقطةً من نقاط المنحني، وتمثل هذه المجموعة من النقاط منحني بيزيه. نتبع الأسلوب ذاته بالنسبة لمنحنى مكون من أربع نقاط حاكمة. الخوارزمية المتبعة لأربع نقاط هي: صل بين النقاط الحاكمة 1 إلى 2، و2 إلى 3، و3 إلى 4، وستحصل على ثلاثة خطوط بنية. لكل قيمة للمعامل t بين 0 و1: نأخذ نقاطًا على الخطوط الثلاثة تتناسب مع t كما فعلنا سابقًا، وبوصل هذه النقاط الثلاثة المتشكلة سنحصل على خطين جديدين، باللون الأخضر مثلًا. نأخذ على الخطين الأخضرين نقاطًا متناسبةً مع t، وعندها سنصل بين النقطتين المتشكلتين فنحصل على خط واحد، وليكن باللون الأزرق. نأخذ على الخط الأزرق نقطةً تبعد عن أحد طرفي الخط مسافةً متناسبةً مع t، فنحصل على نقطة حمراء واحدة تمثل أحد نقاط منحني بيزيه. تمثل النقاط الحمراء كلها المنحني الكامل. إن الخوارزمية السابقية تعاودية، أي قادرة على إعادة نفسها، لذلك يمكن تعميمها إلى N نقطة حاكمة: نصل بين النقاط فنحصل على N-1 خطًا. لكل قيمة للمعامل t بين 0 و1 نأخذ على كل خط نقطةً تبعد عن طرف الخط مسافةً متناسبةً مع t، ونصل بين هذه النقاط فنحصل على N-2 خطًا. نكرر الخطوة 2 حتى نحصل على نقطة واحدة تمثل إحدى نقاط منحني بيزيه. تشكل جميع النقاط الناتجة في الخطوة 3 منحني بيزيه. شغّل الرسوميات التالية ولاحظ شكل المنحني: منحن يبدو مثل الخط البياني للدالة y=1/t. نقاط حاكمة بشكل Zig-zag تبدو جيدةً أيضًا: يمكن رسم حلقة: يمكن رسم منحني بيزيه غير أملس أيضًا: تابع الأمثلة الحية السابقة لترى كيف يُبنى المنحني، إذا وجدت شيئًا مبهمًا في وصف الخوارزمية. يمكن استخدام العدد الذي نريده من النقاط الحاكمة لرسم منحني بيزيه من أي درجة، ما دامت الخوارزمية تعاوديةً، لكن يفضل عمليًا استخدام عدد قليل من النقاط -عادةً 2 أو 3 نقاط-، ثم نربط بين عدة منحنيات بسيطة إذا أردنا شكلًا أكثر تعقيدًا. الصيغ الرياضية يمكن توصيف منحني بيزيه باستخدام صيغة رياضية، وكما رأينا فلا حاجة لمعرفة هذه الصيغة. يرسم المستخدمون المنحني بمجرد تحريك النقاط بالفأرة، لكن إذا أردت استخدام الرياضيات فلك هذا. لنفترض أنّ إحداثيي نقطة حاكمة هو Pi. ستكون النقطة الأولى (P1 = (x1, y1، والثانية (P2 = (x2, y2، وهكذا حتى آخر نقطة (Pi = (xi, yi، ويمكن أن نصف منحني بيزيه بمعادلة متغيرها t يأخذ قيمه بين 0 و1. صيغة منحن يمر بنقطتين هي: صيغة منحن يمر من 3 نقاط هي: صيغة منحن يمر من 4 نقاط هي: إنّ المعادلات السابقة هي معادلات شعاعية، أي يمكننا وضع الإحداثيين x وy بدلًا من P، فمن أجل المنحني المار من ثلاث نقاط مثلًا ستصبح الصيغة بدلالة x وy هي: ينبغي تعويض القيم x1, y1, x2, y2, x3, y3 في المعادلات وكتابة الصيغة بدلالة المتغير t، فلو فرضنا أنّ إحداثيات النقاط الحاكمة الثلاثة هي: (0,0) و(0.5, 1) و(1, 0)،فستصبح قيمة إحداثيي النقطة المقابلة من منحني بيزيه: سنحصل على إحداثيي نقطة جديدة (x,y) من نقاط منحني بيزيه كلما تغيرت قيمة t بين 0 و1. خلاصة تُحدَّد منحنيات بيزيه بثلاث نقاط حاكمة، ويمكن رسمها بأسلوبين كما رأينا: باستخدام عملية رسومية: أي خوارزمية دي كاستلجو. باستخدام صيغة رياضية. من الصفات الجيدة لمنحنيات بيزيه: إمكانية رسم منحنيات ملساء عبر تحريك النقاط الحاكمة باستخدام الفأرة. إمكانية رسم أشكال أكثر تعقيدًا بوصل عدة منحنيات بيزيه معًا. استخدامات منحنيات بيزيه: في رسوميات الحاسوب والنمذجة ومحررات الرسوميات الشعاعية، كما توصف بها أنواع خطوط الكتابة. في تطوير تطبيقات الويب، من خلال الرسم ضمن لوحات وفي الملفات بتنسيق SVG، وقد كتبت الأمثلة النموذجية السابقة بتنسيق SVG، وهي عمليًا مستند SVG مفرد أُعطي نقاطًا مختلفةً مثل معاملات، ويمكن فتح هذه النماذج في نوافذ منفصلة والاطلاع على الشيفرة المصدرية: demo.svg. في الرسوميات المتحركة لوصف مسارها وسرعتها. ترجمة -وبتصرف- للفصل Bezier curve من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: كائنا localStorage وsessionStorage لتخزين بيانات الويب في جافاسكربت أساسيات منحنى بيزيه Bezier Curve في سكريبوس تأثيرات الانتقال والحركة في CSS
-
يسمح هذان الكائنان بتخزين الأزواج "مفتاح/قيمة" في المتصفح، لكن الميزة الهامة لهما هي بقاء البيانات المخزنة في الكائن sessionStorage بعد تحديث الصفحة وبقاء المعلومات المخزنة في localStorage بعد إعادة تشغيل المتصفح، لكن السؤال الذي يلفت النظر هو: ما دام لدينا ملفات تعريف الارتباط cookies، فلماذا سنستخدم كائنات إضافيةً؟ والجواب: لا يُرسَل كائنا تخزين بيانات الويب هذان إلى الخادم مع كل طلب، وذلك خلافًا لملفات تعريف الارتباط، وبالتالي سنتمكن من تخزين بيانات أكثر، حيث تتيح أغلب المتصفحات حوالي 2 ميغابايت من البيانات -وأكثر-، ولها إعدادات لتهيئة حجم التخزين. لا يمكن للخادم التحكم بهذين الكائنين عبر ترويسات HTTP، وسيُنجز كل شيء باستخدام JavaScript، خلافًا لملفات تعريف الارتباط. ترتبط ذاكرة التخزين بالأصل الذي ولّدها (نطاق/بروتوكول/منفذ)، مما يعني أن البروتوكولات أو النطاقات الفرعية المختلفة ستدل على كائنات تخزين مختلفة، ولا يمكن أن تصل إلى بيانات بعضها البعض. لكائني التخزين التوابع والخصائص نفسها، وهي: (setItem(key, value: يخزّن الأزواج "مفتاح/قيمة". (getItem(key: يحصل على القيمة عن طريق المفتاح. (removeItem(key: يزيل المفتاح مع قيمته. ()clear: يحذف كل شيء. (key(index: يعيد مفتاحًا ذا موقع محدد. length: يعطي عدد العناصر المخزّنة. تشبه هذه التوابع ما يقوم به الترابط Map، لكنه يسمح أيضًا بالوصول إلى المفاتيح من خلال مواقعها (key(index. مثال نموذجي عن الكائن localStorage ميزات هذا الكائن الرئيسية هي: مشترك بين كل النوافذ التي تشترك بالأصل ذاته. ليس للبيانات فترة صلاحية، إذ تبقى بعد إعادة تشغيل المتصفح أو نظام التشغيل. فلو شغّلنا الشيفرة التالية مثلًا: localStorage.setItem('test', 1); إذا أغلقنا المتصفح ثم أعدنا تشغيله، أو فتحنا الصفحة نفسها في أكثر من نافذة، فسنحصل على القيمة التي خزناها بالشكل التالي: alert( localStorage.getItem('test') ); // 1 علينا فقط أن نكون ضمن صفحات تنتمي إلى الأصل ذاته (نطاق/منفذ/بروتوكول)، على الرغم من إمكانية اختلاف المسار، لأن الكائن localStorage مشترك بين كل النوافذ التي تنتمي إلى الأصل ذاته. الوصول بأسلوب الكائنات يمكن استخدام أسلوب الكائن البسيط للحصول على المفاتيح أو تغيير قيمها بالشكل التالي: // ضبط المفتاح localStorage.test = 2; // الحصول على قيمة المفتاح alert( localStorage.test ); // 2 // إزالة المفتاح delete localStorage.test; يُسمح بهذه الطريقة -التي ستعمل غالبًا- لأسباب تاريخية، لكن لا يُفضّل استعمالها للأسباب التالية: لو ولّد المستخدم المفتاح، فقد يكون أي شيء مثل length أو toString، أي قد يتشابه مع توابع محجوزة تستخدَم مع localStorage، في هذه الحالة ستعمل التوابع getItem/setItem، لكن سيخفق الوصول بأسلوب الكائنات . let key = 'length'; localStorage[key] = 5; // Error, can't assign length الحدث storage الذي يقع عند تعديل البيانات، وليس عند تطبيق أسلوب الكائنات، وسنرى ذلك لاحقًا في هذا المقال. التنقل بين المفاتيح ضمن حلقات تؤمّن التوابع السابقة وظائف الحصول على قيم المفاتيح وضبطها وحذفها، لكن كيف سنخزّن جميع القيم أو المفاتيح؟ لسوء الحظ لا تقبل كائنات تخزين البيانات التكرار، لكن إحدى الطرق المتبعة هي التنقل في حلقة كما لو أننا نتعامل مع مصفوفة: for(let i=0; i<localStorage.length; i++) { let key = localStorage.key(i); alert(`${key}: ${localStorage.getItem(key)}`); } يمكن استخدام الحلقة for key in localStorage كما نفعل مع الكائنات النظامية، حيث تتكرر تعليمات الحلقة وفقًا للمفاتيح المخزّنة، لكنها ستعطي حقولًا مدمجةً غير مطلوبة: // محاولة فاشلة for(let key in localStorage) { alert(key); // وغيرها من الحقول المدمجة getItem, setItem ستظهر } فإمّا أن نرشح الحقول التي ستُعرض بالتحقق من الخاصية hasOwnProperty: for(let key in localStorage) { if (!localStorage.hasOwnProperty(key)) { continue; // "setItem", "getItem" تتجاهل مفاتيح مثل } alert(`${key}: ${localStorage.getItem(key)}`); } أو نحصل على المفاتيح الخاصة بالكائن باستخدام الأمر Object.keys، ثم نطبق الحلقة عليها: let keys = Object.keys(localStorage); for(let key of keys) { alert(`${key}: ${localStorage.getItem(key)}`); } ستنجح الطريقة الأخيرة لأنّ التابع Object.keys سيعيد المفاتيح التي ترتبط بالكائن فقط، ويتجاهل النموذج الأولي prototype. قيم نصية فقط يجب الانتباه إلى أنّ المفاتيح وقيمها كائنات نصية، وستحوّل أي أنواع أخرى - مثل الأرقام- إلى قيم نصية تلقائيًا: sessionStorage.user = {name: "John"}; alert(sessionStorage.user); // [object Object] يمكن استخدام JSON لتخزين الكائنات أيضًا: sessionStorage.user = JSON.stringify({name: "John"}); // لاحقًا let user = JSON.parse( sessionStorage.user ); alert( user.name ); // John كما يمكن تحويل كائن التخزين بالكامل إلى نص، لأغراض التنقيح مثلًا: // لتبدو النتيجة أفضل JSON.stringify يمكن إضافة خيارات تنسيق إلى alert( JSON.stringify(localStorage, null, 2) ); الكائن sessionStorage يُستخدم في حالات أقل من الكائن localStorage، وله نفس التوابع والخصائص، لكنها أكثر محدوديةً. يتواجد الكائن فقط ضمن النافذة الحالية المفتوحة ضمن المتصفح. سيكون لنافذة أخرى مفتوحة ضمن المتصفح كائن آخر خاص بها. تتشارك النوافذ الضمنية الموجودة في نافذة نفس الكائن، بفرض أنها مشتركة بالأصل. تبقى البيانات المخزنة بعد تحديث الصفحة، لكنها تُحذف عند إغلاق النافذة وإعادة فتحها. شغّل هذه الشيفرة لترى آلية عمل الكائن: sessionStorage.setItem('test', 1); ثم حدّث الصفحة. عندها ستلاحظ أن البيانات مازالت موجودة: alert( sessionStorage.getItem('test') ); // after refresh: 1 لكن لو فتحت الصفحة نفسها في نافذة أخرى، وحاولت تنفيذ الشيفرة السابقة مجددًا، فستعيد القيمة "null" أي أنها لم تجد شيئًا، لأنّ الكائن sessionStorage لا يتعلق فقط بالأصل المشترك بل بالنافذة المفتوحة في المتصفح، لذا يندر استخدامه. أحداث التخزين عندما تُحدّث البيانات ضمن كائني التخزين فستقع أحداث التخزين التالية: key: المفتاح الذي تغيّر، وسيعيد null إذا استدعي التابع ()clear. oldValue: القيمة القديمة، وستكون null إذا أضيف المفتاح حديثًا. newValue: القيمة الجديدة، وستكون null إذا حُذف المفتاح. url: عنوان الصفحة التي حدث فيها التغيير. storageArea أو أحد الكائنين localStorage أو sessionStorage حيث حدث التغيير. أمّا الأمر الهام فهو أنّ هذه الأحداث ستقع في كل الكائنات window التي يمكن فيها الوصول إلى كائن تخزين البيانات، عدا تلك التي سببت وقوع الحدث. تخيل وجود نافذتين في المتصفح تعرضان الصفحة نفسها، عندئذ ستتشارك النافذتان الكائن localStorage نفسه، وقد يكون عرض الصفحة في نافذتين مختلفتين مناسبًا لاختبار الشيفرة التي سنعرضها تاليًا، إذا استمعت كلتا النافذتين إلى الحدث window.onstorage، فستتفاعلان مع التحديثات التي تجري في كلٍّ منهما. // يقع عند تحديث كائن التخزين من قبل صفحة أخرى window.onstorage = event => { // same as window.addEventListener('storage', event => { if (event.key != 'now') return; alert(event.key + ':' + event.newValue + " at " + event.url); }; localStorage.setItem('now', Date.now()); لاحظ أنّ الحدث سيتضمن أيضًا event.url، وهو عنوان الصفحة التي حصل فيها التغيير، كما يتضمن الحدث كائن التخزين (سيبقى الحدث نفسه للكائنين sessionStorage وlocalStorage)، لذا سيشير الحدث event.storageArea إلى الكائن الذي جرى تعديله منهما، وبما أننا نريد أن نعيد ضبط قيمة ما استجابةً للتغيير، فسيسمح ذلك للنوافذ ذات الأصل المشترك بتبادل الرسائل. تدعم المتصفحات الحديثة الواجهة البرمجية لقناة البث Broadcast channel API، وهي واجهة خاصة بتبادل الرسائل بين النوافذ التي لها أصل مشترك. لهذه الواجهة ميزات متكاملة أكثر لكنها أقل دعمًا، ومع ذلك فستجد الكثير من المكتبات التي توائم هذه الواجهة مع المتصفحات بالاستفادة من الكائن localStorage مما يجعلها متاحةً في أي مكان. خلاصة يسمح الكائنان localStorage وsessionStorage بتخزين الأزواج (مفتاح/قيمة) في المتصفح. المفتاح key والقيمة value من النوع النصي. الحد الأقصى للتخزين بحدود 5 ميغابايت وذلك تبعًا للمتصفح. ليس لها فترة صلاحية. ترتبط البيانات بأصل الصفحة (نطاق/ منفذ/بروتوكول). 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; } localStorage sessionStorage مشترك بين النوافذ التي لها نفس الأصل تُرى ضمن نافذة واحدة في المتصفح بما فيها النوافذ الضمنية التي لها نفس الأصل تبقى بعد إعادة تشغيل المتصفح تبقى بعد تحديث الصفحة لكنها تحذف عند إغلاق النافذة الواجهة البرمجية: (setItem(key, value: يخزّن أزواج (مفتاح/قيمة). (getItem(key: يحصل على القيمة عن طريق المفتاح. (removeItem(key: يزيل المفتاح مع قيمته. ()clear: يحذف كل شيء. (key(index: يعيد مفتاحًا ذا موقع محدد. length: يعطي عدد العناصر المخزّنة. Object.keys: للحصول على جميع المفاتيح. يمكن الوصول إلى المفاتيح عبر خصائص الكائن، لكن لن يقع الحدث storage في هذه الحالة. أحداث التخزين: تقع نتيجةً للاستدعاءات التالية setItem أو removeItem أو clear. تحتوي على كل البيانات المتعلقة بالعملية key/oldValue/newValue، وبالصفحة url، وبكائن التخزين storageArea. تقع في جميع النوافذ التي يمكنها الوصول إلى كائن التخزين، عدا تلك التي ولّدته، ضمن نافذة واحدة بالنسبة للكائن sessionStorage، ولكل النوافذ بالنسبة للكائن localStorage. مهمات لإنجازها الحفظ التلقائي لحقل من حقول نموذج أنشئ حقلًا نصيًا textarea يحفظ تلقائيًا القيمة التي يحتويها بعد كل تغيير فيها، وبالتالي عندما يُغلق المستخدم الصفحة عن طريق الخطأ، ثم يفتحها مجددًا، فسيجد النص الذي لم يكمله بعد في مكانه. افتح المثال في بيئة تجريبية. وإن أردت الحل، فهو في هذه البيئة التجريبية. ترجمة -وبتصرف- للفصل localStorage, sessionStorage من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: العمل مع قاعدة البيانات IndexedDB في جافاسكربت ملفات تعريف الارتباط وضبطها في JavaScript المدخل الشامل لتعلم علوم الحاسوب
-
- كائنات التخزين
- localstorage
-
(و 1 أكثر)
موسوم في:
-
IndexedDB هي قاعدة بيانات مدمجة مع المتصفح، ولها ميزات أقوى بكثير من الكائن localStorage، أهمها: تخزن أي نوع من القيم تقريبًا من خلال المفاتيح ذات الأنواع المختلفة. تدعم الإجرائيات المترابطة transactions لوثوقية أعلى. تدعم الاستعلامات عن المفاتيح ضمن مجالات، كما تدعم الوصول إلى المفتاح بالفهرس index. يمكن أن تخزّن بيانات أحجام أكبر بكثير مما يخزنه الكائن localStorage. إنّ القدرة التي تؤمّنها قاعدة البيانات هذه تفوق المطلوب في تطبيقات (خادم-عميل) التقليدية، فهي مصممة للتطبيقات التي تعمل دون اتصال offline، وذلك لتشترك مع تقنية عمال الخدمات ServiceWorkers وغيرها من التقنيات. تشرح توصيفات قاعدة البيانات IndexedDB الواجهة الأصلية للتعامل مع القاعدة، وتتميز بأنها مقادة بالأحداث، كما يمكن أيضًا استخدام آلية async/await بمساعدة مُغلّف wrapper يعتمد على الوعود promise مثل المُغلّف idb، وعلى الرغم من أن هذه آلية مريحة، إلا أنها ليست مثاليةً، إذ لن يتمكن المغلف من استبدال الأحداث في كل الحالات، لذلك سنبدأ أولًا بالتعرف على الأحداث، ثم نتفهم قاعدة البيانات IndexedDb، ثم سنعود لاستخدام المُغلِّف. الاتصال مع قاعدة البيانات نحتاج أولًا إلى تأسيس اتصال مع قاعدة البيانات IndexedDB باستعمال التابع open قبل البدء بالعمل معها، ولهذا التابع الصيغة التالية: let openRequest = indexedDB.open(name, version); name: قيمة نصية تشير إلى اسم قاعدة البيانات. version: قيمة صحيحة موجبة لنسخة قاعدة البيانات، وهي 1 افتراضيًا. قد توجد قواعد بيانات عديدة بأسماء مختلفة، لكنها تعود جميعها إلى نفس الأصل (نطاق/بروتوكول/منفذ)، ولا يمكن لمواقع الويب المختلفة الوصول إلى قواعد بيانات المواقع الأخرى. يعيد الاستدعاء الكائن openRequest، وينبغي علينا الاستماع إلى الأحداث المتعلقة به، وهي: success: يقع عندما تكون قاعدة البيانات جاهزة، أو لما يتواجد كائن قاعدة بيانات ضمن openRequest.result، والذي علينا استخدامه في الاستدعاءات اللاحقة. error: يقع عند الإخفاق في إنشاء الاتصال مع قاعدة البيانات upgradeneeded: قاعدة البيانات جاهزة، لكن نسختها قديمة. تتميز IndexedDB بوجود آلية مدمجة فيها لتحديد نسخة تخطيطها schema versioning، والتي لا نراها في قواعد البيانات الموجودة في جهة الخادم، فهي قاعدة بيانات تعمل من جهة العميل وتخزّن بياناتها ضمن المتصفح، كما لا يستطيع المطورون الوصول إليها في أي وقت، لذا عندما يزور المستخدم موقع الويب بعد إطلاق نسخة جديدة من التطبيق، فلا بدّ من تحديث قاعدة البيانات، فإذا كانت نسخة قاعدة البيانات أقل من تلك التي يحملها الأمر open، فسيقع الحدث upgradeneeded الذي يوازن بين النسختين ويُحدِّث هياكل البيانات بما يناسب، كما يقع هذا الحدث أيضًا عندما تكون قاعدة البيانات غير موجودة (أي تكون نسختها -تقنيًا- "0")، وهذا ما يجعلنا قادرين على إجراء عملية التهيئة. لنفترض أننا قد أصدرنا النسخة الأولى من تطبيقنا، حيث يمكننا عندها تأسيس الاتصال مع قاعدة بيانات نسختها "1"، وتهيئتها بالاستفادة من معالج الحدث upgradeneeded بالشكل التالي: let openRequest = indexedDB.open("store", 1); openRequest.onupgradeneeded = function() { // يقع عندما لا يمتلك العميل قاعدة بيانات // ...إنجاز التهيئة... }; openRequest.onerror = function() { console.error("Error", openRequest.error); }; openRequest.onsuccess = function() { let db = openRequest.result; // متابعة العمل مع قاعدة البيانات }; ثم أصدرنا لاحقًا النسخة الثانية، عندها سنتمكن من تأسيس الاتصال مع النسخة "2" والتحديث بالشكل التالي: let openRequest = indexedDB.open("store", 2); openRequest.onupgradeneeded = function(event) { // قاعدة البيانات أقل من النسخة 2 أو غير موجودة let db = openRequest.result; switch(event.oldVersion) { // النسخة الموجودة من القاعدة case 0: // لا قاعدة بيانات // تهيئة case 1: // يمتلك المستخدم النسخة 1 من القاعدة // تحديث } }; لاحظ أنه عندما تكون نسختنا الحالية هي "2"، فسيتضمن معالج الحدث onupgradeneeded شيفرةً تعالج حالة النسخة "0"، وهذا الأمر ملائم للمستخدمين الذين يزورون الصفحة للمرة الأولى ولا يمتلكون قاعدة بيانات، كما يتضمن شيفرةً تعالج وجود النسخة "1" لتحديثها. سيقع الحدث openRequest.onsuccess عندما ينتهي معالج الحدث onupgradeneeded بنجاح، وينجح تأسيس الاتصال مع قاعدة البيانات. لحذف قاعدة البيانات: let deleteRequest = indexedDB.deleteDatabase(name) //العملية deleteRequest.onsuccess/onerror يتتبع الحدثان لا يمكن تأسيس اتصال مع قاعدة بيانات بنسخة أقدم إذا كانت النسخة الحالية لقاعدة بيانات المستخدم أعلى من النسخة التي نمررها للاستدعاء، أي نسخة القاعدة "3" ونحاول تأسيس اتصال مع النسخة "2" مثلًا، فسيتولد خطأ وسيقع الحدث openRequest.onerror. وعلى الرغم من ندرة حدوث هذا الأمر، إلا أنه قد يحصل عندما يحاول الزائر تحميل شيفرة JavaScript قديمة، مثل أن تكون من الذاكرة المؤقتة لخادم وكيل مثلًا، حيث ستكون الشيفرة قديمةً والقاعدة حديثة. ولا بدّ من التحقق من نسخة قاعدة البيانات db.version، واقتراح إعادة تحميل الصفحة إذا أردنا الحماية من الأخطاء، كما نستخدم ترويسة HTTP ملائمةً للتعامل مع الذاكرة المؤقتة لتفادي تحميل شيفرة قديمة، وبالتالي لن نواجه مشاكل. مشكلة التحديث المتوازي Parallel update ما دمنا نتكلم عن تحديد نسخة التطبيق، فسنتطرق إلى مشكلة صغيرة مرتبطة بذلك، لنتأمل الحالة التالية: فتح مستخدم موقعنا في نافذة متصفح، وكانت نسخة قاعدة البيانات هي "1". ثم حدّثنا الصفحة وأصبحت الشيفرة أحدث. ثم فتح المستخدم نفسه موقعنا في نافذة أخرى. أي ستكون هناك نافذة متصلة بقاعدة بيانات نسختها "1"، بينما تحاول النافذة الأخرى تحديثها إلى النسخة "2" عبر معالج الحدث upgradeneeded. تتلخص المشكلة بأن قاعدة البيانات مشتركة بين نافذتين، لأنهما تعودان لنفس الموقع ولهما الأصل ذاته، ولا يمكن أن تكونا من النسختين "1" و"2" في نفس الوقت، ولتنفيذ عملية الانتقال إلى النسخة "2"، ينبغي إغلاق كل قنوات الاتصال مع النسخة "1" بما فيها قناة اتصال النافذة الأولى، ولتنظيم ذلك سيقع الحدث versionchange ضمن كائن قاعدة البيانات "المنتهية الصلاحية"، لذا يفترض الاستماع لهذا الحدث، وإغلاق اتصال قاعدة البيانات القديمة. يمكن اقتراح إعادة تحميل الصفحة للحصول على الشيفرة الأحدث، فإذا لم نستمع إلى الحدث versionchange ولم نغلق قناة الاتصال، فلن يُنفَّذ الاتصال الثاني، وسيعطي الكائن openRequest الحدث blocked بدلًا من success ولن تعمل النافذة الثانية. إليك الشيفرة التي تتعامل بشكل صحيح مع التحديث المتوازي، إذ تثبّت معالج الحدث onversionchange الذي يقع عندما يصبح الاتصال الحالي مع قاعدة البيانات منتهي الصلاحية، أي عندما تُحدَّث نسخة قاعدة البيانات في مكان آخر، ويغلق الاتصال. let openRequest = indexedDB.open("store", 2); openRequest.onupgradeneeded = ...; openRequest.onerror = ...; openRequest.onsuccess = function() { let db = openRequest.result; db.onversionchange = function() { db.close(); alert("Database is outdated, please reload the page.") }; // ...قاعدة البيانات جاهزة! استخدمها... }; openRequest.onblocked = function() { }; إن ما نفعله هنا بعبارة أخرى هو: يُعلمنا المستمِع إلى الحدث db.onversionchange عن محاولة التحديث المتوازي، عندما تصبح النسخة الحالية لقاعدة البيانات منتهية الصلاحية. يُعلمنا المستمِع إلى الحدث openRequest.onblocked عن الحالة المعاكسة، وهي وجود اتصال لم يُغلَق بعد مع نسخة منتهية الصلاحية في نافذة ما، وبالتالي لن يعمل الاتصال الجديد. يمكن أن نتعامل مع الموضوع بطريقة ألطف عند استخدام الحدث db.onversionchange الذي يبلغ المستخدم بوجوب حفظ بياناته قبل قطع الاتصال، يمكن أيضًا اعتماد مقاربة أخرى لا تتعلق بإغلاق الاتصال مع قاعدة البيانات في معالج الحدث db.onversionchange، بل باستخدام معالج الحدث onblocked -في نافذة متصفح أخرى- لتنبيه المستخدم بأن النسخة الجديدة لن تُحمَّل قبل إغلاق النافذة الأخرى. لا يحدث التعارض في التحديث إلا نادرًا، ومع ذلك لا بدّ من توقعه والتعامل معه، على الأقل باستخدام معالج الحدث onblocked لمنع انهيار السكربت. مخزن الكائن نحتاج إلى مخزن الكائنات Object Store لتخزين أي شيء في قاعدة البيانات IndexedDB، وهو مفهوم جوهري فيها، ويقابل مفهوم "الجداول tables" أو "المجموعات collections" في قواعد البيانات الأخرى، كما تُخزّن فيه البيانات. وقد تتكون القاعدة من عدة مخازن، يكون الأول فيها للمستخدمين، والثاني للبضائع مثلًا وغيرها. على الرغم من اسم "مخزن الكائن" إلا أنه يمكن تخزين القيم الأولية فيه بالإضافة إلى الكائنات، إذ يمكن تخزين أي قيمة بما فيها الكائنات المعقدة، وتستخدم قاعدة البيانات خوارزمية التفكيك المعيارية standard serialization algorithm لنسخ وتخزين الكائن، ويشابه ذلك استخدام الأمر JSON.stringify لكن مع إمكانيات أكثر، وقدرة على تخزين أنواع أكثر من البيانات. ومن الكائنات التي لا يمكن تخزينها في هذه المخازن، نجد الكائن ذو المراجع الحلقية التي تشكل حلقةً يدل آخرها على أولها، والتي لا يمكن أن تُفكك، وسيفشل الأمر JSON.stringify معها. يجب أن يكون لكل قيمة ستُخزّن في قاعدة البيانات مفتاح فريد key، كما يجب أن تكون هذه المفاتيح من أحد الأنواع التالية: رقم أو تاريخ أو نص أو قيمة ثنائية أو مصفوفة، وسنتمكن من البحث عن القيم أو إزالتها أو تحديثها بواسطة هذه المفاتيح الفريدة. سنرى قريبًا كيف سنحصل على مفتاح عند إضافة قيمة جديدة إلى المخزن، كما يحدث عند استخدام الكائن localStorage، لكن ستسمح لنا قاعدة البيانات IndexedDB عند تخزين كائن؛ بضبط وإعداد خصائصه مثل مفتاح، وهذه مقاربة أفضل بكثير، كما يمكننا توليد المفاتيح تلقائيًا، لكننا سنحتاج إلى إنشاء مخزن للكائن أولًا. إليك الصيغة المستخدمة في إنشاء مخزن لكائن: db.createObjectStore(name[, keyOptions]); name: وتمثل اسم المخزن. keyOptions: وتمثل كائنًا اختياريًا له خاصيتان، هما: keyPath: المسار إلى خاصية الكائن التي سنستخدمها مفتاحًا، مثل id. autoIncrement: إذا كانت قيمته true فسيتولد تلقائيًا مفتاح للكائن الجديد، مثل رقم يتزايد باستمرار. إذا لم نستخدم keyOptions، فلا بدّ حينها من التصريح عن المفتاح لاحقًا عند تخزين الكائن. يستخدم مخزن الكائن التالي الخاصية id مفتاحًا، وبشكل صريح: db.createObjectStore('books', {keyPath: 'id'}); لا يمكن إنشاء أو تعديل مخزن كائن عند تحديث نسخة قاعدة البيانات إلا ضمن معالج الحدث upgradneeded، ويشكل هذا الأمر محدوديةً تقنيةً، إذ يمكن إضافة أو إزالة أو تحديث البيانات خارج المعالج، بينما لا يمكننا إنشاء أو حذف أو تغيير مخزن الكائن إلا خلال تحديث نسخة قاعدة البيانات. وتوجد مقاربتان لتنفيذ عملية تحديث نسخة قاعدة البيانات: يمكن كتابة دوال خاصة لتحديث كل نسخة ممكنة إلى النسخة الجديدة، من "1" إلى "2"، ومن "2" إلى "3" وهكذا، ثم يمكن موازنة النسخ ضمن جسم دالة معالج الحدث upgradeneeded من القديمة 2 إلى الحديثة 4 مثلًا، وتنفيذ الدالة المناسبة لتنفيذ التحديث خطوةً بخطوة، أي من 2 إلى 3، ثم من 3 إلى 4. فحص قاعدة البيانات والحصول على قائمة بمخازن الكائنات الموجودة باستخدام التابع db.objectStoreNames، ويمثل الكائن المُعاد قائمةً من النوع DOMStringList، والذي يزودنا بالتابع (contains(name الذي يتحقق من وجود مخزن باسم محدد، ثم سنتمكن من إجراء التحديث اعتمادًا على ما هو موجود وما هو غير موجود، وهذه المقاربة أبسط لقواعد البيانات الصغيرة، وإليك مثالًا عن استخدامها: let openRequest = indexedDB.open("db", 2); // تحديث وإضافة قواعد البيانات دون تحقق openRequest.onupgradeneeded = function() { let db = openRequest.result; if (!db.objectStoreNames.contains('books')) { // "books" إن لم يكن هناك مخزن باسم db.createObjectStore('books', {keyPath: 'id'}); // أنشئه } }; ولحذف مخزن كائن: db.deleteObjectStore('books') الإجرائيات المترابطة Transactions يُعَد مصطلح "إجرائيات مترابطة" مصطلحًا عامًا، ويُستخدم في قواعد بيانات عديدة، كما يشير إلى مجموعة من العمليات التي ينبغي أن تُنفَّذ مترابطةً، بحيث تنجح معًا أو تخفق معًا، فعندما يشتري شخص شيئًا ما مثلًا، فعلينا: سحب المبلغ من حساب المشتري. إضافة المشتريات إلى سلة مشترياته. سيبدو الأمر سيئًا بالطبع لو أُنجزت الخطوة الأولى بنجاح وفشلت الخطوة الثانية لسبب أو لآخر، فإما أن تنجح الخطوتان -أي يكتمل الشراء- أو تخفق الخطوتان، فعندها لن يخسر المشتري ماله ويستطيع إعادة المحاولة مجددًا. ينبغي أن تُنفَّذ جميع العمليات على البيانات ضمن إجرائيات مترابطة في القاعدة IndexedDB، وللبدء بإجرائية مترابطة يجب تنفيذ الأمر: db.transaction(store[, type]); store: اسم المخزن الذي ستُنفَّّذ عليه إجرائية مترابطة، المخزن "books" مثلًا، ويمكن أن يكون مصفوفةً من أسماء المخازن إذا كنا نريد الوصول إلى عدة مخازن. type: نوع الإجرائية المترابطة، وقد يكون: readonly: يُنفِّذ عمليات قراءة فقط، وهذا هو الخيار الافتراضي. readwrite: يُنفِّذ عمليات قراءة وكتابة فقط، ولا يستطيع إنشاء أو حذف أو تبديل الكائن. كما يمكن تنفيذ إجرائيات مترابطة من النوع versionchange، ويستطيع هذا النوع تنفيذ أي شيء، لكن لا يمكن إنشاؤه يدويًا، إذ تنشئ قاعدة البيانات الإجرائيات المترابطة من النوع versionchange عند تأسيس الاتصال مع قاعدة البيانات لتنفيذ معالج الحدث updateneeded، لذا فهو المكان الوحيد الذي نستطيع فيه تحديث هيكلية قاعدة البيانات أو إنشاء وحذف كائن المخزن. يمكن إضافة العناصر إلى المخزن بعد إنشاء الإجرائية المترابطة بالشكل التالي: let transaction = db.transaction("books", "readwrite"); // (1) // الحصول على كائن المخزن للتعامل معه let books = transaction.objectStore("books"); // (2) let book = { id: 'js', price: 10, created: new Date() }; let request = books.add(book); // (3) request.onsuccess = function() { // (4) console.log("Book added to the store", request.result); }; request.onerror = function() { console.log("Error", request.error); }; تُنفَّذ العملية مبدئيًا وفق خطوات أربع هي: إنشاء إجرائية مترابطة تشير إلى كل المخازن الذي سنصل إليها. الحصول على كائن المخزن باستخدام الأمر (transaction.objectStore(name. تنفيذ العمليات على كائن المخزن (books.add(book. التعامل مع حالة نجاح أو فشل الإجرائية المترابطة على كائن المخزن (books.add(book. تدعم كائنات المخازن تابعين لتخزين القيم، هما: ([put(value,[key: حيث يضيف القيمة value إلى المخزن، ويزوَّد التابع بالمعامل key فقط في الحالة التي لا يمتلك فيها المخزن أحد الخيارين keyPath أو autoIncrement، فإن وجدت قيمة لها نفس المفتاح فستُستبدَل. ([add(value,[key: يشابه التابع السابق، لكن إن وجدت قيمة لها نفس المفتاح فسيخفق الطلب، وسيولِّد خطأً باسم "ConstraintError". يمكن إرسال الطلب (books.add(book مثلًا بما يتناسب مع تأسيس اتصال مع قاعدة بيانات، ومن ثم ننتظر وقوع أحد الحدثين success/error. سيكون مفتاح الكائن الجديد هو نتيجة الطلب request.result للتابع add. فإن وقع خطأ ما فسنجده في الكائن request.error. اكتمال الإجراءات المترابطة بدأنا الإجرائية المترابطة في المثال السابق بتنفيذ الطلب add، لكن -وكما أشرنا سابقًا- قد تتألف الإجرائية المترابطة من عدة طلبات ينبغي أن تنجح معًا أو تخفق معًا، فكيف سنميِّز إذًا انتهاء الإجرائية ولا توجد طلبات أخرى قيد التنفيذ؟ الجواب باختصار هو أننا لا نستطيع، ولا بدّ من وجود طريقة يدوية لإنهاء الإجرائيات المترابطة في النسخة التالية 3.0 من التوصيفات، لكن حاليًا في النسخة 2.0 لا يوجد شيء مشابه لهذا. وستكتمل الإجرائية تلقائيًا عندما تنتهي جميع الطلبات المتعلقة بإجرائية مترابطة، وسيصبح صف المهام المتناهية الصغر microtasks queue فارغًا. توصف الإجرائية عادةً بأنها مكتملة عندما تكتمل كل طلباتها، وينتهي تنفيذ الشيفرة المتعلقة بها، لذا فلا حاجة في الشيفرة السابقة مثلًا إلى استدعاء خاص لإنهاء الإجرائية المترابطة. ويترافق مبدأ الاكتمال التلقائي للإجرائيات المترابطة بتأثير جانبي مهم، فلا يمكن تنفيذ عملية غير متزامنة مثل fetch أو setTimeout أثناء تنفيذ الإجرائية، كما لن تبقي قاعدة البيانات IndexedDB الإجرائية المترابطة في حالة انتظار حتى تُنجز هذه العملية. سيُخفق الطلب request2 في السطر(*) من الشيفرة التالية لأن الإجرائية قد اكتملت بالفعل، ولن نتمكن من تنفيذ طلبات أخرى: let request1 = books.add(book); request1.onsuccess = function() { fetch('/').then(response => { let request2 = books.add(anotherBook); // (*) request2.onerror = function() { console.log(request2.error.name); // TransactionInactiveError }; }); }; لأنّ fetch عملية غير متزامنة وتمثل مهمةً مستقلةً macrotask، وستُغلَق الإجرائية المترابطة قبل أن يبدأ المتصفح بتنفيذ مهمات مستقلة، كما سيرى محررو توصيفات قاعدة البيانات IndexedDB أنّ مدة إنجاز الإجرائيات المترابطة لا بدّ أن تكون قصيرة، وذلك لأسباب تتعلق بالأداء في الغالب. تقفِل الإجرائيات المترابطة من النوع readwrite المخزن عند الكتابة فيه، لذا إذا حاول جزء آخر من التطبيق تنفيذ عملية مشابهة على نفس المخزن، فعليه الانتظار. ستعلَّق الإجرائية المترابطة الجديدة حتى تنتهي الأولى، وهذا ما يسبب تأخيرًا غريبًا إذا استغرقت إجرائية مترابطة ما وقتًا طويلًا، فما العمل إذًا؟ بإمكاننا في المثال السابق تنفيذ إجرائية مترابطة جديدة db.transaction تمامًا قبل الطلب الجديد في السطر "(*)"، لكن يفضَّل -إن أردنا إبقاء العمليات معًا في إجرائية مترابطة واحدة- أن نفصل الإجرائية المترابطة في قاعدة البيانات IndexedDB عن الأمور الأخرى غير المتزامنة. نفِّذ العملية fetch أولًا، ثم حضر البيانات إن تطلب الأمر ذلك، ثم أنشئ إجرائيةً مترابطةً، ونفّذ كل الطلبات وسينجح الأمر. استمع إلى الحدث transaction.oncomplete لمعرفة اللحظة التي تكتمل فيها الإجرائية المترابطة بنجاح. let transaction = db.transaction("books", "readwrite"); // ...perform operations... transaction.oncomplete = function() { console.log("Transaction is complete"); }; تضمن الخاصية complete فقط اكتمال وحفظ الإجرائية بالكامل، وقد تنجح طلبات بمفردها، لكن قد تفشل بالمقابل عملية الكتابة النهائية، بسبب خطأ في منظومة الدخل/خرج مثلًا. استدعي التابع التالي لإيقاف الإجرائية المترابطة يدويًا: transaction.abort(); سيلغي هذا الاستدعاء كل التغييرات التي نفذتها الطلبات، ويتسبب بوقوع الحدث transaction.onabort. معالجة الأخطاء قد تُخفق طلبات الكتابة، ولا بدّ من توقع هذا الأمر -لا نتيجةً للأخطاء المحتملة التي قد نرتكبها فقط- بل لأسباب تتعلق بالإجرائيات المترابطة بحد ذاتها، فقد يحدث تجاوز حجم المخزن المحدد على سبيل المثال، لذا لا بدّ أن نكون مستعدين للتعامل مع حالات كهذه. يوقف إخفاق الطلب الإجرائية المترابطة تلقائيًا ويلغي كل التغييرات التي حدثت، لكننا قد نحتاج إلى التعامل مع حالة إخفاق الطلب، لتجريب طلب آخر مثلًا، دون إلغاء التغييرات التي حدثت، ومن ثم متابعة الإجرائية المترابطة، وهذا أمر ممكن، إذ يمكن لمعالج الحدث request.onerror أن يمنع إلغاء الإجرائية المترابطة عن طريق استدعاء التابع ()event.preventDefault. سنرى في المثال التالي كيف يُضاف كتاب جديد بمفتاح id موجود مسبقًا، وعندها سيولِّد التابع store.add الخطأ "ConstraintError"، الذي نتعامل معه دون إلغاء الإجرائية المترابطة: let transaction = db.transaction("books", "readwrite"); let book = { id: 'js', price: 10 }; let request = transaction.objectStore("books").add(book); request.onerror = function(event) { //عند إضافة قيمة بمفتاح موجود مسبقًا ConstraintError يقع الخطأ if (request.error.name == "ConstraintError") { console.log("Book with such id already exists"); // التعامل مع الخطأ event.preventDefault(); // لا تلغ الإجرائية المترابطة // استخدم مفتاح جديد للكتاب؟ } else { // خطأ غير متوقع لايمكن التعامل معه // ستلغى الإجرائية المترابطة } }; transaction.onabort = function() { console.log("Error", transaction.error); }; تفويض الأحداث هل نحتاج إلى الحدثين onsuccess/onerror عند كل طلب؟ والجواب هو لا، ليس في كل مرة، إذ يمكننا أن نستعمل تفويضًا للحدث event delegation بدلًا من ذلك. تجري عملية انسياب Bubbling الحدث في قاعدة بيانات بالشكل التالي: request → transaction → database والتي تقتضي التقاط العنصر الداخلي ضمن شجرة DOM للحدث، ثم تستمع إليه الأحداث الخارجية بالتتالي. قد تنساب الأحداث في الشجرة DOM للخارج bubbling أو للداخل capturing، لكن يُستخدم عادةً الانسياب نحو الخارج، ويمكن حينها التقاط الأخطاء عن طريق معالج الحدث db.onerror لإظهارها أو لأي أسباب أخرى. db.onerror = function(event) { let request = event.target; // الطلب الذي ولّد الخطأ console.log("Error", request.error); }; لكن ماذا لو تمكنا من التعامل مع الخطأ كاملًا؟ عندها لن نحتاج لإظهار أي شيء، يمكننا إيقاف الانسياب الخارجي للأحداث، وبالتالي إيقاف الحدث db.onerror، باستخدام الأمر ()event.stopPropagation ضمن دالة معالجة الحدث request.onerror. request.onerror = function(event) { if (request.error.name == "ConstraintError") { console.log("Book with such id already exists"); // معالجة الخطأ event.preventDefault(); // لا توقف الإجرائية المترابطة event.stopPropagation(); // لا تجعل الأحداث تنساب للخارج } else { // لا تفعل شيئًا // إيقاف الإجرائية المترابطة // transaction.onabort يمكن التعامل مع الخطأ ضمن } }; عمليات البحث يوجد نوعان أساسيان للبحث في مخزن الكائن: بقيمة المفتاح أو مجال المفتاح، ففي مثالنا عن المخزن "books"، سنتمكن من البحث عن قيمة أو مجال من القيم للمفتاح book.id. باستخدام حقل آخر من حقول الكائن، مثل البحث في مثالنا السابق اعتمادًا على الحقل book.price، ويتطلب هذا البحث هيكليةً إضافيةً للبيانات تُدعى الفهرس index. البحث بالمفتاح لنتعرف أولًا على النوع الأول، وهو البحث بالمفتاح، وتدعم طرق البحث حالة القيمة الدقيقة للمفتاح أو ما يسمى "مجالًا من القيم"، ويمثلها الكائن IDBKeyRange، وهي كائنات تحدد مجالًا مقبولًا من قيم المفاتيح، وتتولد الكائنات IDBKeyRange نتيجةً لاستخدام الاستدعاءات التالية: ([IDBKeyRange.lowerBound(lower, [open: وتعني أن تكون القيم أكبر أو تساوي الحد الأدنى lower، أو أكبر تمامًا إذا كانت قيمة open هي "true". ([IDBKeyRange.upperBound(upper, [open: تعني أن تكون القيم أصغر أو تساوي الحد الأعلى upper، أو أصغر تمامًا إذا كانت قيمة open هي "true". ([IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen: تعني أن تكون القيمة محصورةً بين الحد الأدنى lower والأعلى upper، ولن يتضمن المجال قيمتي الحدين الأعلى والأدنى إذا ضبطنا open على القيمة "true". (IDBKeyRange.only(key: وتمثل مجالًا يتكون من مفتاح واحد، وهو نادر الاستخدام. سنرى التطبيق العملي لهذه الاستدعاءات السابقة قريبًا. ولتنفيذ بحث حقيقي ستجد التوابع التالية التي تقبل الوسيط query، وقد يكون هذا الوسيط قيمةً دقيقةً للمفتاح أو مجالًا: (store.get(query: يبحث عن أول قيمة من خلال مفتاح أو مجال. ([store.getAll([query], [count: يبحث عن جميع القيم، ويكون عدد القيم محدودًا إذا أعطينا قيمةً للوسيط count. (store.getKey(query: يبحث عن أول مفتاح يحقق الاستعلام، وعادةً يكون مجالًا. ([store.getAllKeys([query], [count: يبحث عن كل المفاتيح التي تحقق الاستعلام، ويكون عدد النتائج محدودًا إذا أعطينا count قيمةً. [(store.count([query: يعيد العدد الكلي للمفاتيح التي تحقق الاستعلام، وعادةً يكون مجالًا. قد يكون لدينا على سبيل المثال الكثير من الكتب في مخزننا، وما دام الحقل id هو المفتاح، فستتمكن تلك التوابع من البحث باستعماله، مثل: // الحصول على كتاب واحد books.get('js') // 'css' <= id <= 'html' الحصول على كتاب يكون books.getAll(IDBKeyRange.bound('css', 'html')) // id < 'html' الحصول على كتاب بحيث books.getAll(IDBKeyRange.upperBound('html', true)) // الحصول على كل الكتب books.getAll() // id > 'js' الحصول على كل الكتب التي تحقق books.getAllKeys(IDBKeyRange.lowerBound('js', true)) البحث بالحقول نحتاج إلى إنشاء هيكلية إضافية للبيانات تُدعى الفهرس index، لتنفيذ استعلام باستخدام حقل آخر من حقول الكائن، ويمثِّل الفهرس إضافةً add-on إلى المخزن لتتبع حقل محدد من كائن، ويُخزّن الفهرس -ومن أجل كل قيمة للحقل المحدد- قائمةً من مفاتيح الكائنات التي تمتلك تلك القيمة، وسنوضح ذلك لاحقًا بالتفصيل. إليك صيغة إنشاء الفهرس: objectStore.createIndex(name, keyPath, [options]); name: اسم الفهرس. keyPath: المسار إلى حقل الكائن الذي سيتتبعه الفهرس، وسنبحث اعتمادًا على ذلك الحقل. option: كائن اختياري له الخصائص التالية: unique: إذا كانت قيمته "true"، فيجب أن يوجد كائن واحد في المخزن له القيمة المعطاة في المسار المحدد، وسيجبر الفهرس تنفيذ هذا الأمر بتوليد خطأ إذا حاولنا إضافة نسخة مكررة. multiEntry: ويستخدَم فقط إذا كانت القيمة في المسار المحدد keyPath مصفوفةً، وسيعامل الفهرس في هذه الحالة المصفوفة بأكملها مثل مفتاح افتراضيًا، لكن إذا كانت قيمة multiEntry هي "true"، فسيحتفظ الفهرس بقائمة من كائنات المخزن لكل قيمة في تلك المصفوفة، وهكذا ستصبح عناصر المصفوفة مفاتيح فهرسة. نخزّن في المثال التالي الكتب باستعمال المفتاح id، ولنقل أننا نريد البحث باستعمال الحقل price، سنحتاج أولًا إلى إنشاء فهرس، ويجب أن ننجز ذلك ضمن معالج الحدث upgradeneeded تمامًا مثل مخزن الكائن: openRequest.onupgradeneeded = function() { //لابد من إنشاء الفهرس هنا، ضمن إجرائية تغيير النسخة let books = db.createObjectStore('books', {keyPath: 'id'}); let index = books.createIndex('price_idx', 'price'); }; سيتعقب الفهرس الحقل price. لن يكون السعر حقلًا بقيم فريدة، إذ يمكن وجود عدة كتب لها السعر نفسه، وبالتالي لن نضبط الخيار unique. السعر قيمة مفردة وليس مصفوفةً، لذا لن نطبّق الخيار multiEntry. لنتخيل الآن وجود 4 كتب في مخزننا inventory، إليك الصورة التي تظهر طبيعة الفهرس index: كما قلنا سابقًا، سيحفَظ الفهرس العائد لكل قيمة من قيم price -الوسيط الثاني- قائمةً بالمفاتيح التي ترتبط بهذا السعر، ويُحدَّث الفهرس تلقائيًا، فلا حاجة للاهتمام بهذا الأمر. سنطبِّق ببساطة التوابع السابقة نفسها على الفهرس عندما نريد أن نبحث عن سعر محدد: let transaction = db.transaction("books"); // قراءة فقط let books = transaction.objectStore("books"); let priceIndex = books.index("price_idx"); let request = priceIndex.getAll(10); request.onsuccess = function() { if (request.result !== undefined) { console.log("Books", request.result); // مصفوفة من الكتبالتي سعرها=10 } else { console.log("No such books"); } }; يمكن أن نستخدم IDBKeyRange أيضًا لإنشاء مجال محدد، والبحث عن الكتب الرخيصة أو باهظة الثمن: // جد الكتب التي سعرها يساوي أو أقل من 5 let request = priceIndex.getAll(IDBKeyRange.upperBound(5)); تُصنَّف الفهارس داخليًا وفق الحقل الذي تتعقبه، وهو السعر price في حالتنا، لذا سترتَّب النتائج حسب السعر عندما ننفذ البحث وفق حقل السعر. الحذف من مخزن يبحث التابع delete عن القيم التي يُطلب حذفها عبر استعلام، وله صيغة شبيهة بالتابع getAll. (delete(query: يحذف القيم المطابقة للاستعلام، إليك مثالًا: // id='js' احذف الكتاب الذي يحقق books.delete('js'); إذا أردنا حذف الكتب بناءً على سعرها أو بناءً على أي حقل آخر، فعلينا أولًا إيجاد المفتاح ضمن الفهرس، ثم استدعاء التابع delete: // ايجاد المفتاح في حالة السعر=5 let request = priceIndex.getKey(5); request.onsuccess = function() { let id = request.result; let deleteRequest = books.delete(id); }; ولحذف كل شيء: books.clear(); // افرغ المخزن المؤشرات Cursors تعيد التوابع -مثل getAll/getAllKeys- مصفوفةً من المفاتيح والقيم، وقد يكون كائن التخزين ضخمًا وأكبر من أن تتسع له الذاكرة المتاحة، وبالتالي سيخفق التابع getAll في الحصول على كل سجلات المصفوفة، فما العمل في حالة مثل هذه؟ ستساعدنا المؤشرات على الالتفاف على هذه المشكلة. المؤشرات هي كائنات خاصة تتجاوز كائن التخزين عند تنفيذ استعلام، وتعيد زوجًا واحدًا (مفتاح/قيمة) في كل مرة، وبالتالي ستساعدنا في توفير الذاكرة. ما دام مخزن الكائن سيُصنَّف وفقًا للمفتاح، فسينتقل المؤشر عبر المخزن وفق ترتيب المفتاح تصاعديًا (افتراضيًا). إليك صيغة استخدام المؤشر: // لكن مع مؤشر getAll مثل let request = store.openCursor(query, [direction]); // store.openKeyCursor للحصول على المفاتيح لا القيم query: مفتاح أو مجال لمفتاح، ويشابه في عمله getAll. direction: وهو وسيط اختياري، ويفيد في ترتيب تنقل المؤشر بين السجلات: next: وهي القيمة الافتراضية، حيث يتحرك المؤشر من المفتاح ذي القيمة الأدنى إلى الأعلى. prev: يتحرك المؤشر من القيمة العليا للمفتاح إلى الدنيا. nextunique وprevunique: تشابهان الخيارين السابقين، لكنهما تتجاوزان المفتاح المكرر، وتعملان فقط مع المؤشرات المبنية على فهارس، فعند وجود عدة قيم لن تُعاد إلا قيمة أول سعر يحقق معيار البحث. إن الاختلاف الرئيسي في عمل المؤشرات هو أنها تسبب وقوع الحدث request.onsuccess عدة مرات، مرةً عند كل نتيجة. إليك مثالًا عن استخدام المؤشر: let transaction = db.transaction("books"); let books = transaction.objectStore("books"); let request = books.openCursor(); // يُستدعى من أجل كل كتاب وجده المؤشر request.onsuccess = function() { let cursor = request.result; if (cursor) { let key = cursor.key; // (id) مفتاح الكتاب let value = cursor.value; // كائن الكتاب console.log(key, value); cursor.continue(); } else { console.log("No more books"); } }; للمؤشر التوابع التالية: (advance(count: يدفع المؤشر إلى الأمام بمقدار الوسيط count ويتجاوز القيم. ([continue([key]): يدفع المؤشر إلى القيمة التالية في المجال المطابق للبحث، أو إلى ما بعد مفتاح معين مباشرةً إذا حددنا القيمة key. يُستدعى معالج الحدث onsuccess، سواءً تعددت القيم التي تطابق معيار البحث أو لا، ثم سنتمكن من الحصول على المؤشر الذي يدل على السجل التالي ضمن النتيجة result التي حصلنا عليها، أو أن لا يؤشر إلى شيء unidefined. تدريب أنشئ المؤشر في المثال السابق لمخزن الكائن. يمكن أيضًا أن ننشئ المؤشر اعتمادًا على الفهارس، إذ تسمح الفهارس كما نتذكر بالبحث وفق أي حقل من حقول الكائن، وتتشابه المؤشرات المبنية على الفهارس مع تلك المبنية على أساس مخازن الكائنات، فهي توفر الذاكرة بإعادة قيمة واحدة في كل مرة. سيكون cursor.key مفتاح الفهرسة بالنسبة للمؤشرات المبنية على الفهارس، وينبغي أن نستخدم الخاصية cursor.primaryKey إذا أردنا الحصول على مفتاح الكائن. let request = priceIdx.openCursor(IDBKeyRange.upperBound(5)); // يُستدعى لكل سجل request.onsuccess = function() { let cursor = request.result; if (cursor) { let primaryKey = cursor.primaryKey; // مفتاح مخزن الكائن التالي (id field) let value = cursor.value; // مفتاح مخزن الكائن التالي (book object) let key = cursor.key; //مفتاح الفهرسة التالي (price) console.log(key, value); cursor.continue(); } else { console.log("No more books"); } }; مغلف الوعود Promise wrapper إنّ إضافة الحدثين onsuccess/onerror إلى كل طلب أمر مرهق، ويمكن أحيانًا تحسين الوضع باستخدام التفويض delegation، مثل إعداد معالجات أحداث للإجرائية المترابطة بأكملها، لكن الصيغة async/await أكثر ملائمةً. لنستخدم مُغلَّف الحدث البسيط idb في هذا المقال، حيث ننشئ كائن idb عامًا مزوّدًا بتوابع مبنية على الوعود promisified لقاعدة البيانات IndexedDB، وسنكتب الشيفرة التالية بدلًا من استخدام onsuccess/onerror: let db = await idb.openDB('store', 1, db => { if (db.oldVersion == 0) { // نفذ عملية التهيئة db.createObjectStore('books', {keyPath: 'id'}); } }); let transaction = db.transaction('books', 'readwrite'); let books = transaction.objectStore('books'); try { await books.add(...); await books.add(...); await transaction.complete; console.log('jsbook saved'); } catch(err) { console.log('error', err.message); } وهكذا سنرى الشيفرة المحببة للجميع، "شيفرة غير متزامنة " وكتلة "try…catch". معالجة الأخطاء إذا لم نلتقط الأخطاء، فستقع إلى أن تعترضها أول كتلة try..catch. ستتحول الأخطاء التي لا نعترضها إلى حدث "عملية رفض وعد غير معالجة" ضمن الكائن window، لكن يمكن التعامل مع هذا الخطأ بالشكل التالي: window.addEventListener('unhandledrejection', event => { let request = event.target; // IndexedDB كائن طلب أصلي للقاعدة let error = event.reason; //request.error خطأ غير مُعتَرض للكائن ...report about the error... }); إجرائية مترابطة غير فعالة تكتمل الإجرائية المترابطة -كما رأينا سابقًا- عندما ينتهي المتصفح من تنفيذ شيفرتها ومهامها المتناهية الصغر، فلو وضعنا مهمةً مستقلةً مثل fetch وسط إجرائية مترابطة، فلن تنتظر عندها الإجرائية انتهاء هذه المهمة المستقلة، بل ستكتمل الإجرائية ببساطة وسيخفق الطلب التالي. يبقى الأمر نفسه بالنسبة إلى مُغلَّف الوعود والصيغة async/await، إليك مثالًا عن العملية fetch داخل إجرائية مترابطة: let transaction = db.transaction("inventory", "readwrite"); let inventory = transaction.objectStore("inventory"); await inventory.add({ id: 'js', price: 10, created: new Date() }); await fetch(...); // (*) await inventory.add({ id: 'js', price: 10, created: new Date() }); // Error سيخفق الأمر inventory.add بعد العملية fetch في السطر "(*)" وسيقع الخطأ “inactive transaction” أي "إجرائية مترابطة غير فعالة"، لأنّ الإجرائية المترابطة قد اكتملت بالفعل وأغلقت، وسنلتف على هذه المشكلة كما فعلنا سابقًا، فإما أن ننشئ إجرائيةً مرتبطةً جديدةً، أو أن نفصل الأمور عن بعضها. حضّر البيانات ثم أحضر كل ما يلزم عبر fetch أولًا. احفظ البيانات داخل قاعدة البيانات. الحصول على كائنات أصيلة يُنفِّذ المُغلَّف طلب IndexedDB أصلي داخليًا، ويضيف معالجي الحدثين onerror/onsuccess إليه، ثم يعيد وعدًا يُرفَض أو يُنفَّذ مع نتيجة الطلب. يعمل هذا الأمر جيّدًا في معظم الأوقات، وستجد مثالًا في مستودع GitHub الخاص بمُغلًّف الحدث idb. وفي حالات قليلة نادرة، وعندما نحتاج إلى الكائن request الأصلي، يمكن الوصول إليه باستخدام الخاصية promise.request العائدة للوعد. let promise = books.add(book); // الحصول على الوعد دون انتظار النتيجة let request = promise.request; // كائن الطلب الأصلي let transaction = request.transaction; // كائن الإجرائية المترابطة الأصلي // ...do some native IndexedDB voodoo... let result = await promise; // إن كنا بحاجة لذلك الخلاصة يمكن تشبيه قاعدة البيانات بالكائن "localStorage" لكنها مدعَّمة، وهي قاعدة بيانات (مفتاح-قيمة) لها إمكانيات كبيرة كافية للتطبيقات التي تعمل دون اتصال، كما أنها سهلة الاستخدام. تُمثِّل التوصيفات الخاصة بقاعدة البيانات أفضل دليل لاستخدامها، ونسختها الحالية هي 2.0، كما ستجد بعض التوابع من النسخة 3.0 التي لن تختلف كثيرًا، وهي مدعومة جزئيًا. يمكن تلخيص طريقة استخدامها الأساسية بالشكل التالي: أولًا، الحصول على مُغلِّف وعود مثل idb. ثانيًا، فتح قاعدة البيانات. idb.openDb(name, version, onupgradeneeded) إنشاء مخزن للكائن وفهارس ضمن معالج الحدث onupgradeneeded، أو تنفيذ تحديث للنسخة عند الحاجة. ثالثًا، لتنفيذ الطلبات عليك باتباع الآتي: أنشئ إجرائيةً مترابطةً ('db.transaction('books، مع إمكانية القراءة والكتابة عند الحاجة. احصل على مخزن كائن بالشكل التالي ('transaction.objectStore('books. رابعًا، يمكنك البحث بالمفتاح أو استدعاء التوابع المتعلقة بكائن المخزن مباشرةً. أنشئ فهرسًا للبحث باستخدام حقل آخر من حقول الكائن. خامسًا، إذا لم تتسع الذاكرة للبيانات، فاستخدم مؤشرًا cursor. إليك هذا المثال النموذجي: <!doctype html> <script src="https://cdn.jsdelivr.net/npm/idb@3.0.2/build/idb.min.js"></script> <button onclick="addBook()">Add a book</button> <button onclick="clearBooks()">Clear books</button> <p>Books list:</p> <ul id="listElem"></ul> <script> let db; init(); async function init() { db = await idb.openDb('booksDb', 1, db => { db.createObjectStore('books', {keyPath: 'name'}); }); list(); } async function list() { let tx = db.transaction('books'); let bookStore = tx.objectStore('books'); let books = await bookStore.getAll(); if (books.length) { listElem.innerHTML = books.map(book => `<li> name: ${book.name}, price: ${book.price} </li>`).join(''); } else { listElem.innerHTML = '<li>No books yet. Please add books.</li>' } } async function clearBooks() { let tx = db.transaction('books', 'readwrite'); await tx.objectStore('books').clear(); await list(); } async function addBook() { let name = prompt("Book name?"); let price = +prompt("Book price?"); let tx = db.transaction('books', 'readwrite'); try { await tx.objectStore('books').add({name, price}); await list(); } catch(err) { if (err.name == 'ConstraintError') { alert("Such book exists already"); await addBook(); } else { throw err; } } } window.addEventListener('unhandledrejection', event => { alert("Error: " + event.reason.message); }); </script> وستكون النتيجة كالتالي: ترجمة -وبتصرف- للفصل indexeddb من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: ملفات تعريف الارتباط وضبطها في JavaScript تعرّف على IndexedDB مفهوم Service Worker وتأثيره في أداء وبنية مواقع وتطبيقات الويب
-
ستتعرف من خلال سلسلة المقالات هذه على تفاصيل جهاز راسبيري باي؛ الحاسوب الجديد الذي لا يتعدى حجمه حجم بطاقتك الإئتمانية، وستكتشف الكمّ الكبير من المكونات التي يدعمها، وما الذي ستقدمه لك هذه المكونات. يُعَد راسبيري باي Raspberry Pi جهازًا مميزًا، فهو حاسوبٌ متكامل وظيفيًا ضمن علبةٍ صغيرة رخيصة الثمن، فلو كانت غايتك هي جهازٌ تستخدمه لتصفح الإنترنت أو للألعاب، أو كنت مهتمًا بتطوير برامجك الخاصة أو تصميم دوائرك الإلكترونية أو تجهيزاتٍ خاصة بك، فجهاز راسبيري باي ومجتمع هواته ومحترفيه الرائعين سيشكلان السند الحقيقي لك في كل خطوةٍ تخطوها. تُعرف أجهزة راسبيري باي بأنها حواسيبٌ مجمّعةٌ على لوحة إلكترونية مفردة single board؛ فهي عبارة عن حاسوب مثل الحواسيب المكتبية أو المحمولة أو الهواتف الذكية، لكنه جُمِّع على لوحةٍ واحدةٍ طُبعت عليها جميع الدوائر الإكترونية المُكوِّنة له. يتمتع راسبيري باي مثل غيره من الحواسيب وحيدة اللوحة بميزةٍ مهمة، وهي أنه صغير الحجم، حيث لا يتعدى حجمه حجم البطاقة الائتمانية، ولا يعني ذلك إطلاقًا أنّ قدرته أضعف بل يستطيع تنفيذ ما يستطيع تنفيذه أي حاسوبٍ أكبر وأكثر استهلاكًا للطاقة، لكن ليس بالسرعة نفسها بالضرورة. ظهرت عائلات راسبيري باي نتيجةً للرغبة في تشجيع طرقٍ أكثر تقدّمًا في تعليم الحوسبة حول العالم، وقد انطلقت من فكرةٍ بسيطةٍ تبناها مخترعي راسبيري، الذين أنشؤوا لاحقًا مؤسسة راسبيري باي غير الربحية والتي أثبتت شعبيتها الكبيرة، فقد بيعت بضعة آلافٍ من القطع التي جُمِّعت عام 2012 لاختبار أنواع المياه، وتبعها مباشرةً شحنٌ لملايين الأجهزة إلى كل أصقاع الأرض في السنوات التالية. وجدت هذ التجهيزات طريقها إلى المنازل وصفوف التعليم والمكاتب ومراكز البيانات والمعامل، وحتى إلى الزوارق المقادة ذاتيًا والبالونات التي تُطلق إلى الفضاء. أُصدرت نماذجٌ متعددة من راسبيري باي تباعًا بعد إصدار النموذج الأصلي B، وحمل كلٌ منها مواصفاتٍ محسنة أو ميزاتٍ مخصصة لاستخداماتٍ محددة، حيث تتميز العائلة راسبيري باي زيرو Raspberry Pi Zero مثلًا، بأنها نسخةٌ مصغرةٌ عن أجهزة راسبيري باي ذات الأبعاد الكاملة، لكنها تفتقر إلى ميزاتٍ عدة وخصوصًا منافذ USB المتعددة ومنفذ الاتصال مع الشبكات السلكية حتى يكون مخططها أصغر وأقل استهلاكًا للطاقة الكهربائية. تشترك جميع نماذج راسبيري باي بميزةٍ هامةٍ، وهي التوافق Compatibility؛ فالبرنامج الذي تكتبه لنموذجٍ ما، سيعمل على كل النماذج الأخرى، كما أنك قادرٌ على نقل أحدث نسخةٍ من نظام تشغيل راسبيري باي وتشغيله على النموذج الأولي الأصلي (نموذج B)، حيث سيعمل ببطء بالتأكيد، لكنه سيعمل. سنتعرف خلال سلسلة المقالات هذه على راسبيري باي 4 نموذج B، وهو النموذج الأحدث والأقوى من أجهزة راسبيري باي، ومع ذلك من السهل تطبيق ما تعلمته على أية نماذجٍ أخرى من عائلة راسبيري باي، حيث لا داعي للقلق مما إذا كان نموذجك مختلفًا. تُظهر أجهزة راسبيري باي جميع مكوّناتها ومنافذ اتصالها وجميع ميزاتها، على خلاف الحواسيب التقليدية التي تخفي تفاصيلها في حاويتها cases، ويمكنك طبعًا شراء حاويةٍ مستقلةٍ لراسبيري باي لحماية الجهاز إن أردت. سيتيح لك ذلك فرصةً رائعةً لتعلم كيفية عمل الأجزاء المختلفة للحاسب، وسيسهل عليك معرفة ماذا يحدث وأين، وذلك عندما نصل إلى المرحلة التي نتعلم فيها طريقة توصيل أجهزة إضافيةٍ إلى لوحتك، أو ما يُعرف بالطرفيات peripherals. شكل 1-1 راسبيري باي 4 نموذج B يُظهر الشكل السابق جهاز راسبيري باي 4 نموذج B كما يبدو من الأعلى. حاول أن تُبقي لوحة الجهاز في الوضعية التي نعرضها في الصورة عندما تستخدم هذا الكتاب لتعلم راسبير باي، فقد تختلط عليك الأمور في بعض مراحل العمل، مثل ترتيب الأرجل في واجهة الدخل والخرج العمومية GPIO، والذي سنتحدث عنه بالتفصيل في الفصل السادس. وعلى الرغم من وجود الكثير من الأشياء المُدمجة في لوحة راسبيري باي الصغيرة، لكن فهم هذا الجهاز يُعَد أمرًا يسيرًا. وسنبدأ بمكوّناته، وهي الدوائر التي تجعل كل شيءٍ يعمل. مكونات راسبيري باي يتألف الجهاز مثل بقية الحواسيب التقليدية من مكوّناتٍ مختلفة لكلٍ دورها في المنظومة، حيث ستجد المكوّن الأكثر أهميةً قريبًا من مركز اللوحة على الوجه العلوي مغطّىً بغطاءٍ معدني، وهو رقاقة النظام المدمجة System-On-Chip -أو اختصارًا SoC-، كما هو موضحٌ في الشكل التالي. شكل 2-1 رقاقة النظام المدمجة يوحي اسم هذا المكوّن بما ستجده إذا نزعت الغطاء المعدني، وهو رقاقةٌ سيليكونيةٌ معروفةٌ باسم الدارة المتكاملة integrated circuit، وتضم كامل منظومة راسبيري باي بما فيها وحدة المعالج المركزية Central Processing Unit -أو اختصارًا CPU-، التي تمثل من الناحية الوظيفية دماغ الحاسب، ووحدة معالجة الرسوميات Graphics Processing Unit -أو اختصارًا GPU-، التي تتعامل مع نواحي الإظهار البصري. وطالما أن الدماغ سيغدو بلا فائدةٍ دون ذاكرة، فستجد إلى جانب رقاقة النظام رقاقةً أخرى تبدو مثل مربع بلاستيكي أسود كما هو موضحٌ في الشكل الآتي، وهي ذاكرة الوصول العشوائي Random Access Memory -أواختصارًا RAM-. ستكون ذواكر الوصول العشوائي مسرحًا لتنفيذ كل أعمالك التي لن تنتقل إلى الذاكرة الدائمة المتمثّلة ببطاقة ذاكرة من النوع microSD إلا عندما تخزِّن عملك. يُعرف نوعا الذاكرة السابقين بالذواكر المتطايرة والذواكر الدائمة غير المتطايرة؛ حيث تفقد ذواكر الوصول العشوائي المتطايرة RAM محتواها عند انقطاع التغذية الكهربائية، بينما تحتفظ الذاكرة غير المتطايرة الموجودة على بطاقة microSD بمحتواها. شكل 3-1 ذاكرة الوصول العشوائي لجهاز راسبيري باي ستجد على الزاوية اليمينية العليا من اللوحة غطاءً معدنيًا آخر يغطي منظومة الراديو التي تمنح جهاز راسبيري باي القدرة على الاتصال اللاسلكي كما هو موضحٌ في الشكل التالي، وتضم المنظومة وظيفيًا مكوّنين، أحدهما مكوّن الاتصال اللاسلكي بتقنية WiFi للاتصال مع شبكات الحاسب، والآخر مكوّن الاتصال اللاسلكي بتقنية بلوتوث Bluetooth للاتصال مع الطرفيات مثل الفأرة، أو نقل البيانات من وإلى الأجهزة الذكية المجاورة مثل الهواتف والحساسات. شكل 4-1 وحدة الاتصال الراديوي تُشاهد على الحافة السفلية للوحة رقاقةً سوداء أخرى مغلفةً بالبلاستيك خلف مجموعة منافذ الناقل التسلسلي العالمي Universal serial Bus -أو اختصارًا USB-؛ وهي رقاقةٌ التحكم بمنافذ USB والمسؤولة عن تشغيلها، كما ستجد إلى جانبها رقاقةً التحكم بالشبكة السلكية؛ وهي رقاقةٌ سوداء أصغر حجمًا تدير منفذ الاتصال السلكي المحلي Ethernet. ستجد في الناحية اليسارية العليا وإلى الأعلى قليلًا من مأخذ التغذية الكهربائية للّوحة عبر منفذ USB-C، رقاقةً سوداء أصغر حجمًا من بقية الرقاقات تُعرف باسم الدارة المتكاملة لإدارة الطاقة Power Management integrated circuit -أو اختصارًا PMIC-، كما هو موضحٌ في الشكل التالي؛ حيث تدير هذه الدارة الطاقة الكهربائية التي تصل عبر منفذ USB-C لتغطي حاجة جهاز راسبيري باي. شكل 5-1 الدارة المتكاملة لإدارة الطاقة PMIC لا تقلق إن بدا لك الأمر صعبًا، فلست مضطرًا إلى معرفة موضع كل مكوّن أو دوره لتستخدم راسبيري باي. منافذ الاتصال في راسبيري باي يضم جهاز راسبيري باي مجموعةً من المنافذ التي سنستعرضها خلال السطور القليلة التالية. منافذ الناقل التسلسلي العالمي USB ستجد هذه المنافذ في وسط الحافة السفلية للوحة وإلى يمينها، وتمنحك القدرة على الاتصال مع الطرفيات المتوافقة مع الناقل USB، مثل الفأرة ولوحة المفاتيح وآلات التصوير الرقمية والذواكر المتنقلة flash drives. يوجد نوعين لمنافذ USB من الناحية التقنية هما على النحو الأتي: USB 2.0: صُمم وفقًا للنسخة الثانية من معيار الناقل التسلسلي العالمي، وبالإمكان تمييزه من خلال القطعة البلاستيكية السوداء داخله. USB 3.0: وهو المنفذ الأسرع بينهما، وصُمم وفقًا للنسخة الثالثة الأحدث من المعيار السابق، وتستطيع تمييزه من خلال القطعة البلاستيكية الزرقاء داخله. الشكل 6-1 منافذ USB لجهاز راسبيري باي منفذ الاتصال السلكي المحلي Ethernet يتواجد على يمين منافذ USB -كما هو موضحٌ في الشكل التالي-، ويُعرف أيضًا بمنفذ الشبكة network port، ويُستخدم للاتصال مع شبكات الحاسوب سلكيًا عبر كابلٍ ينتهي بمقبسٍ من النوع RJ45 شبيهٍ بمقبس سلك الهاتف لكنه أوسع. ستجد مؤشرين ضوئيين LEDs أسفل المنفذ يمثلان مؤشري الحالة؛ يعطيانك فكرةً عن نجاح الاتصال من عدمه. شكل 7-1 منفذ الاتصال السلكي لجهاز راسبيري باي مخرج صوتي-بصري AV يتواجد على الحافة اليسارية للوحة راسبيري باي فوق منفذ الاتصال السلكي، ويُعرف أيضًا باسم مخرج سماعات الرأس، وهو بقطر 3.5 ميليمتر، كما هو موضحٌ بالشكل التالي. يُستخدم هذا المخرج لوصل الجهاز مع سماعات الرأس، كما يمكن وصله مع مكبر صوت Speaker للحصول على جودةٍ صوتيةٍ أعلى. لهذا المخرج ميزةٌ خفية، إذ بإمكانه نقل إشارة الفيديو، وبالتالي وصله إلى الشاشات أو أجهزة الإسقاط وغيرها من أجهزة العرض التي تدعم إشارة الفيديو المركبة التي تنتقل عبر كابلٍ ينتهي بطرفية الغلاف مدبب الرأس ثنائي الحلقة tip-ring-ring-sleeve -أواختصارًا TRRS-، وذلك تمامًا مثل المقبس التقليدي لسماعات الرأس التي تتصل بالهواتف المحمولة. شكل 8-1 مخرج صوتي-بصري بقطر 3.5 ميليمتر مدخل كاميرا التصوير يتوضع فوق مخرج AV مباشرةً، ويأتي على شكل وصلةٍ غريبة المظهر مزودةٍ بغطاء بلاستيكي قابلٍ للسحب، ويُعرف أيضًا باسم واجهة الكاميرا التسلسلية Camera Serial Interface -أواختصارًا CSI-. يوضح الشكل التالي هذا المنفذ الذي يسمح بالاتصال مع الكاميرا المُصممة خصيصًا لجهاز راسبيري باي، والتي سنتعلم التعامل معها في الفصل الثامن. شكل 9-1 وصلة كاميرا راسبيري باي منفذ واجهة الوسائط المتعددة عالية الدقة micro-HDMI يتوضع على الحافة اليسارية للوحة الجهاز وفوق وصلة الكاميرا مباشرةً، وهو منفذٌ من النموذج micro الذي يأتي أصغر حجمًا من منافذ HDMI المعيارية التي تراها في وحدات الألعاب أو أجهزة التلفاز، كما هو موضحٌ في الشكل التالي. تنقل هذه المنافذ إشارات الصوت والفيديو بجودةٍ عالية، ويمكنك استخدامها لوصل راسبيري باي بجهازٍ أو جهازي عرض، مثل شاشات الحاسوب أو أجهزة التلفاز أو أجهزة الإسقاط. شكل 10-1 منفذ HDMI منفذ USB-C للتغذية بالطاقة الكهربائية يأتي مباشرةً فوق منفذ HDMI، ويُستخدم لإيصال التغذية الكهربائية إلى لوحة راسبيري باي كما هو موضحٌ في الشكل التالي. من المؤكد أنك رأيت منفذ USB-C في الهواتف الذكية والأجهزة اللوحية وغيرها من الأجهزة المحمولة. ولا يُنصح بالطبع استخدام شواحن الهواتف المحمولة في تغذية راسبيري باي على الرغم من إمكانية ذلك، كما يفضّل استخدام مصدر التغذية المعتمد من قبل راسبيري باي. شكل 11-1 منفذ USB-C كمدخل تغذية بالطاقة الكهربائية واجهة شاشة الإظهار وهي وصلةٌ شبيهةٌ بوصلة الكاميرا، حيث يوضح الشكل التالي موصل الشاشة display connector، أو واجهة شاشة الإظهار Display Serial Interface -أو اختصارًا DSI-، والتي تقع على الحافة العليا للوحة الجهاز، حيث تُستخدم لتوصيل شاشة اللمس الخاصة براسبيري باي الموضحة بالشكل الثاني. شكل 12-1 وصلة DSI الخاصة براسبيري باي شكل 13-1 شاشة لمس راسبيري باي منصة GPIO تكون منصة أرجل الدخل والخرج للأغراض العامة general-purpose input/output أو اختصارًا GPIO، متواجدةً على الحافة اليمينية للوحة راسبيري باي، وتتكون من 40 رِجلًا معدنيةً pins منتظمةً في صفين يضم كلٌ منهما 20 رجلًا كما هو موضحٌ في الشكل التالي. تستخدم راسبيري باي هذه الأرجل للتخاطب مع عناصر الوسط الخارجي، مثل المؤشرات الضوئية LEDs والأزرار وغيرها، بما في ذلك الحساسات الإلكترونية، مثل حسّاسات درجة الحرارة ومقابض الألعاب joysticks، وشاشات مراقبة معدلات النبض. سنتعرف على عمل هذه الأرجل في الفصل السادس المتمحور حول الحوسبة الفيزيائية وبرمجة المكوّنات باستخدام بايثون Python وسكراتش Scratch. شكل 14-1 منصة GPIO منصة أرجل التغذية عبر شبكة الاتصال المحلية PoE منصة PoE هي اختصارٌ للمصطلح Power Over Ethernet، وهي تقنيةٌ لنقل الطاقة الكهربائية عبر سلكين ضمن كابل الاتصال، وتُستخدم هذه المنصة التي تقع أسفل ويسار منصة GPIO، والمؤلفة من أربعة أرجل أصغر حجمًا، مثل إضافةٍ لتغذية جهاز راسبيري باي عبر شبكة الاتصال السلكية المحلية بدلًا من منفذ USB-C. موصل بطاقة الذاكرة من نوع microSD وهو المنفذ الأخير الذي نستعرضه، ويقع على الوجه السفلي للّوحة مقابل مأخذ شاشة الإظهار DSI، كما هو موضحٌ في الشكل التالي. تمثل بطاقة الذاكرة microSD أداة التخزين الدائمة لنظام راسبيري باي، حيث توضع هذه البطاقة داخل الحاضنة المخصصة لها لتخزِّن كل الملفات التي تحتاجها، وكل البرامج التي تثبتها بالإضافة إلى نظام التشغيل الذي يقود الجهاز. شكل 15-1 حاضنة بطاقة الذاكرة من نوع microSD طرفيات راسبيري باي لن يقدم لك جهاز راسبيري باي الكثير بمفرده، فحاله حال حاضنة الحاسوب المكتبي بمفردها، ومن أجل استغلال إمكانيات الجهاز لا بدّ من تأمين الطرفيات المناسبة، حيث ستحتاج بالحد الأدنى إلى بطاقة ذاكرة من نوع microSD لتخزين البيانات، وشاشة أو تلفاز لترى ما تفعل، إلى جانب لوحة مفاتيح وفأرة لإيصال تعليماتك، كما ستحتاج إلى مصدر تغذية بالطاقة الكهربائية يعطي الجهاز جهدًا ثابتًا مقداره 5 فولت وتيار شدته 3 أمبير أو أفضل. وهكذا ستحصل على حاسوبٍ قادرٍ على العمل تمامًا، وسنتعلم كيفية ربط هذه الطرفيات مع جهاز راسبيري باي في الفصل الثاني. لا تمثل الطرفيات التي أشرنا إليها كل ما يمكنك استخدامه مع راسبيري باي، حيث تضم قائمة الطرفيات الرسمية التي تنتجها راسبيري باي مايلي: حاضنة للوحة الجهاز Raspberry case، والتي تؤمن حمايةً للّوحة دون أن تعيق وصولك إلى المنافذ المختلفة للجهاز. تجهيزة الكاميرا Camera Module: ستجد تفاصيلها في الفصل الثامن. شاشة لمس راسبيري باي متصلة مع منفذ شاشة الإظهار وتؤمن عرضًا بصريًا وواجهة لمس على هيئة جدول. الطرفية Sense HAT متعددة الوظائف والموضحة في الشكل التالي، وهي طرفيةٌ ذكيةٌ ترتبط بالجهاز Hardware Attached Top، وتنفذ العديد من الوظائف التي سنتعرف عليها في الفصل السابع المتمحور حول الحوسبة الفيزيائية باستخدام Sense HAT. تجهيزات متنوعة من أطراف أخرى، حيث ستجد في الأسواق الكثير من الأدوات والتجهيزات تبدأ بتلك التي تحول راسبيري باي إلى حاسوبٍ محمول أو إضافاتٍ تمنح جهازك القدرة على فهم الكلام، وحتى الرد عليك. شكل 16-1 الطرفية Sense HAT تذكر أنك بحاجةٍ إلى تعلم الكثير عن راسبيري باي قبل شراء تلك الطرفيات. ترجمة -وبتصرف- للفصل الأول Get to Know your Raspberry Pi من كتاب The Official Raspberry Pi] Beginner's Guide. اقرأ أيضًا المدخل الشامل لتعلم علوم الحاسوب أنظمة التشغيل للمبرمجين
-
ملفات تعربف الارتباط هي بيانات نصية تُخزَّن مباشرةً في ذاكرة المتصفح، وهي في الواقع جزء من بروتوكول HTTP تُعرِّفها المواصفات RFC 6265، ويضبط الخادم عادةً هذه الملفات مستخدمًا ترويسة الاستجابة Set-Cookie، ثم يضيفها المتصفح إلى كل طلب -تقريبًا- يُرسل إلى النطاق ذاته مستخدمًا الترويسة Cookie. يُعَد الاستيثاق authentication أكثر الحالات شيوعًا لاستعمال ملفات تعريف الارتبابط، وذلك بسبب ما يلي: يستخدم الخادم الترويسة Set-Cookie في الاستجابة أثناء تسجيل الدخول لإعداد ملف تعريف ارتباط له مُعرِّف جلسة عمل session فريد. عند إرسال الطلب في المرة القادمة إلى نفس النطاق، سيُرسل المتصفح ملف تعريف الارتباط عبر الشبكة باستخدام الترويسة Cookie. وبالتالي سيعلم الخادم الجهة التي أرسلت الطلب. يمكن الوصول إلى ملفات تعريف الارتباط من المتصفح باستخدام الخاصية document.cookie، وستواجهك الكثير من النقاط المربكة عند استخدام ملفات تعريف الارتباط والخيارات المتعلقة بها، وسنغطي هذه النقاط بالتفصيل في هذا المقال. القراءة من الخاصية document.cookie لتعرف إذا ما كان متصفحك يُخزِّن أي ملفات تعريف ارتباط عائدة لموقع ما، استخدم الأمر: alert( document.cookie ); // cookie1=value1; cookie2=value2;... تتكون قيمة الخاصية document.cookie من أزواج name=value تفصل بينها فاصلة منقوطة ";"، حيث يمثل كل منها ملف تعريف ارتباط منفصل. لإيجاد ملف تعريف ارتباط معين يمكن فصل قيمة الخاصية document.cookie بالاستفادة من الفاصلة المنقوطة ";"، ثم إيجاد الاسم المطلوب، وننفذ ذلك باستخدام تعابير برمجية نظامية أو مصفوفة دوال، وسنترك ذلك تمرينًا للقارئ، كما ستجد في نهاية المقال دوالًا مساعدةً للتعامل مع ملفات تعريف الارتباط. الكتابة إلى الخاصية document.cookie يمكن أن نكتب قيمًا ضمن الخاصية document.cookie، وهي ليست خاصية بيانات، وإنما هي أشبه بدالة وصول accessor (ضابط setter/ جالب getter)، لذا لا بدّ من التعامل معها بخصوصية. يضبط الاستدعاء التالي مثلًا ملف تعريف ارتباط بالاسم user على القيمة John: document.cookie = "user=John"; alert(document.cookie); ستشاهد عند تنفيذ الشيفرة السابقة ملفات ارتباط عدةً غالبًا، لأن الأمر = document.cookie يغيِّر قيمة ملف الارتباط الذي يحمل الاسم user فقط. يمكن أن يتألف الاسم أو القيمة تقنيًا من أي محارف، لكن للإبقاء على تنسيق قابل للقراءة، ينبغي ترميز المحارف الخاصة باستخدام الدالة المدمجة encodeURIComponent: // ينبغي ترميز المحارف الخاصة let name = "my name"; let value = "John Smith" // encodes the cookie as my%20name=John%20Smith document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value); alert(document.cookie); // ...; my%20name=John%20Smith لملفات الارتباط خيارات عدة، وينبغي ضبط بعضها لأهميته، حيث توضع هذه الخيارات بعد الأزواج key=value، وتفصل بينها فواصل منقوطة ";": document.cookie = "user=John; path=/; expires=Tue, 19 Jan 2038 03:14:07 GMT" الخيار path path=/mypath: ينبغي أن يكون مسار العنوان مُطلقًا absolute، بحيث يمكن الوصول إلى ملفات الارتباط ضمن جميع الصفحات التي تقع تحت هذا المسار، أما القيمة الافتراضية فهي "المسار الحالي"، فلو كان المسار path=/admin، فستكون الملفات مرئيةً في الصفحة الحالية admin وفي أي صفحة تحتها admin/something/، لكن ليس في صفحات مثل home/ أو adminpage/، ويُضبط هذا الخيار عادةً على القيمة /=path لتتمكن كل صفحات الموقع من الوصول إلى ملفات الارتباط. الخيار domain domain=site.com: يحدد هذا الخيار المكان الذي يمكن الوصول منه إلى ملفات الارتباط، لكن لن نتمكن عمليًا من تحديد أي نطاقات نريد، إذ لا يمكن الوصول إلى ملفات الارتباط افتراضيًا سوى من النطاق الذي أعدّها، فلو أعد الموقع hsoub.com ملفات الارتباط، فلن يتمكن الموقع site.com من الوصول إليها، لكن الأمر المربك فعلًا، هو أننا لن نصل إلى ملفات الارتباط ضمن النطاقات الفرعية مثل academy.hsoub.com!. // hsoub.com في الموقع document.cookie = "user=John" // academy.hsoub.com في الموقع alert(document.cookie); // no user لا توجد طريقة تُمكِّن نطاقًا من المستوى الثاني من الوصول إلى ملفات الارتباط التي يُعدّها نطاق آخر، إذ تمنعنا أمور أمنية من تخزين بيانات حساسة لا يجب أن تظهر سوى في الموقع داخل ملفات الارتباط، لكن يمكن -إن أردنا- السماح للنطاق الفرعي academy.hsoub.com بالوصول إلى ملفات الارتباط، وذلك بضبط الخيار domain عند إعداد ملف ارتباط في النطاق hsoub.com، على قيمة النطاق الجذر domain=hsoub.com: // at hsoub.com // الوصول لملف الارتباط من النطاقات الفرعية // make the cookie accessible on any subdomain *.hsoub.com: document.cookie = "user=John; domain=hsoub.com" // لاحقًا // academy.hsoub.com في alert(document.cookie); // has cookie user=John يعمل الأمر domain=.hsoub.com -لاحظ النقطة قبل اسم النطاق- بالطريقة ذاتها لأسباب تاريخية، حيث سيسمح بالوصول إلى ملفات الارتباط من النطاقات الفرعية، ولا بدّ من استخدام هذا الأسلوب القديم إذا أردنا دعم المتصفحات القديمة. إذًا سيسمح لنا الخيار domain بجعل النطاقات الفرعية قادرةً على الوصول إلى ملفات الارتباط. الخياران expire و max-age إذا لم يتضمن ملف الارتباط أيًا من هذين الخيارين، فسيختفي الملف افتراضيًا مع إغلاق المتصفح، ويُدعى هذا النوع من ملفات الارتباط "ملفات ارتباط جلسة العمل"، ولنحافظ على ملف الارتباط بعد إغلاق المتصفح فلابدّ من ضبط أحد الخيارين expires أو max-age، وإليك مثالًا عن ذلك: expires=Tue, 19 Jan 2038 03:14:07 GMT يحدد تاريخ انتهاء الصلاحية الوقت الذي سيحذف فيه المتصفح ملف الارتباط، وينبغي أن يكون التاريخ بالتنسيق السابق تمامًا ووفق توقيت GMT، ويمكن أن نستخدم الدالة date.toUTCString للحصول على التاريخ بهذا التنسيق، حيث يمكن على سبيل المثال ضبط فترة انتهاء الصلاحية بعد يوم كامل: //بعد يوم من الآن let date = new Date(Date.now() + 86400e3); date = date.toUTCString(); document.cookie = "user=John; expires=" + date; سيُحذف الملف إذا ضبطنا قيمة الخيار expires على قيمة في الماضي. max-age=3600: يمثل الخيار max-age بديلًا عن expires، ويعبّر عن زمن انتهاء صلاحية ملف الارتباط بالثواني ابتداءً من اللحظة الحالية، وسيُحذف ملف الارتباط إذا ضُبط على القيمة صفر أو أي قيمة سالبة. // سيحذف الملف بعد ساعة document.cookie = "user=John; max-age=3600"; // سيحذف الملف مباشرة document.cookie = "user=John; max-age=0"; الخيار secure secure: يجب نقل ملفات الارتباط من خلال بروتوكول HTTPS فقط. لو أعددنا ملف ارتباط ضمن الموقع http://hsoub.com، فسيظهر افتراضيًا أيضًا ضمن الموقع https://hsoub.com، والعكس بالعكس، فملفات الارتباط متعلقة بالنطاقات ولا تميّز بين البروتوكولات، لكن يمكن تفادي ذلك باستخدام الخيار secure، إذ لن تظهر ملفات الارتباط التي يُعدها النطاق https://hsoub.com ضمن النطاق http://hsoub.com، لأن استخدام هذا الخيار مهم في منع نقل البيانات الحساسة عبر بروتوكول HTTP غير المشفر. // https:// بافتراض أننا حاليًا في // ضبط ملف الارتباط ليكون آمنًا document.cookie = "user=John; secure"; الخيار samesite وهو خيار من خيارات الأمان، وقد صُمم للحماية ضد الهجوم المعروف باسم "تزوير الطلبات ما بين الصفحات cross-site request forgery" أو اختصارًا XSFR، ولفهم الآلية التي يعمل بها هذا الخيار، ومتى يكون مفيدًا لنلق نظرةً على الهجوم XSFR. الهجوم XSFR لنفترض أنك سجلت دخولك إلى الموقع bank.com، وبالتالي ستحصل على ملف ارتباط استيثاق من هذا الموقع، وسيرسل متصفحك هذا الملف إلى الموقع مع كل طلب حتى يميّزك وينفذ لك عمليات ماليةً حساسةً مثلًا، ولنتخيل الآن أنك تتصفح الشابكة في نافذة أخرى ووصل بك الأمر إلى الموقع المشبوه evil.com، الذي يمتلك شيفرة JavaScript. ترسل إلى الموقع bank.com النموذج <form action="https://bank.com/pay"> الذي يحتوي حقولًا تهيئ لعملية تحويل مالي إلى حساب المخترق، وسيرسل المتصفح ملف الارتباط في كل مرة تزور فيها الموقع bank.com، حتى لو أُرسل النموذج من الموقع evil.com، وهكذا سيميُّزك البنك وينفذ عملية الدفع؛ هذا هو الهجوم XSFR. طبعًا تحمي البنوك الحقيقية نفسها من هذا الهجوم، فجميع النماذج التي يولّدها الموقع bank.com تحتوي على حقل مميز يدعى مفتاح الحماية المشفّر protection token من هجوم XSRF، والتي لا يمكن للصفحات المشبوهة توليدها أو استخلاصها من صفحة أخرى، وبالتالي ستستطيع هذه الصفحات إرسال النماذج، لكن لا يمكن أن تحصل على بيانات من المواقع المستهدفة، وسيتحقق الموقع bank.com من وجود مفتاح الحماية المشفر في كل نموذج يتلقاه. تستغرق طريقة الحماية هذه وقتًا لإنجازها، إذ يجب التحقق من وجود المفتاح في كل نموذج، كما ينبغي التحقق من كل طلب. استخدام الخيار samsite يؤمّن هذا الخيار طريقةً للحماية من الهجوم السابق، ولا يتطلب -نظريًا- استخدام مفتاح الحماية المشفر، ولهذا الخيار قيمتان، هما: samesite=strict: وهو مشابه لاستخدام الخيار بلا قيمة، حيث لن يُرسل ملف الارتباط -عند استخدام هذه القيمة- إذا لم يكن المستخدم ضمن النطاق نفسه. وبعبارة أخرى، لن تُرسل ملفات الارتباط سواءً تبع المستخدم رابطًا ضمن بريده الإلكتروني أم أرسل نموذجًا من موقع مشبوه أو نفّذ عمليةً خارج النطاق. عندما يكون لملف ارتباط الاستيثاق الخيار samesite، فلن تكون هناك فرصة لتحقيق هجوم XSRF، لأن النموذج الذي يرسله موقع مشبوه evil.com سيأتي دون ملفات ارتباط، وبالتالي لن يميز الموقع bank.com المستخدم ولن يتابع عملية الدفع، كما لن ترسل ملفات الارتباط فعليًا في هذه الحالة. يمكن الاعتماد على أسلوب الحماية هذا، لكن مع ذلك يمكن أن نقلق، فعندما يتتبع المستخدم رابطًا مشروعًا إلى الموقع bank.com -مثل أن يكون ضمن الملاحظات التي يوّلدها الموقع-؛ سيتفاجأ المستخدم أن الموقع لن يميّزه، ويمكن أن نتخطى هذا الأمر باستخدام نوعين من ملفات الارتباط، حيث يُستخدم أحدهما لتمييز المستخدم لأغراض عامة مثل أن تلقي التحية عليه "مرحبًا أحمد"، أما النوع الآخر فيُستخدم لعمليات تغيير البيانات وسيُزود بالخيار samesite=strict، بهذا سيرى المستخدم الذي يصل إلى الموقع bank.com من موقع آخر رسالة ترحيب، لكن العمليات المالية لن تُنفَّذ إلا من الموقع نفسه، بعد إرسال ملف الارتباط الثاني. samesite=lax: وهو خيار أقل تشددًا، يحمي من هجوم XSRF ولا يهدد تجربة المستخدم، حيث يمنع المتصفح من إرسال ملفات الارتباط التي تأتي من خارج الموقع، لكنه يضيف بعض الاستثناءات. تُرسل ملفات الارتباط ذات الخيار samesite=lax إذا تحقق الشرطان التاليان: نوع طلب HTTP آمن (GET مثلًا وليس POST)، وستجد قائمةً كاملةً بطلبات HTTP الآمنة في التوصيفات RFC7231 specification، لكن مبدئيًا، ستستخدم هذه الطلبات في قراءة البيانات وليس في كتابتها، فلا ينبغي لها تنفيذ عمليات لتغيير البيانات، ويُتبع الرابط دائمًا بطلب آمن من النوع GET. أن تُنفِّذ العملية انتقالًا إلى عنوان عالي المستوى (تُغيِّر العنوان في شريط عناوين المتصفح)، وهو أمر محقق عادةً، لكن إذا جرى الانتقال إلى عنوان من خلال نافذة ضمنية <iframe> فلن يكون انتقالًا عالي المستوى، ولا تنفذ طلبات JavaScript عبر الشبكة أي تنقلات كونها لن تتناسب مع هذه العملية. لذلك نرى أن كل ما يفعله الخيار samesite=lax هو السماح بامتلاك عمليات التنقل الأساسية ملفات ارتباط، مثل عمليات الانتقال من ملاحظة يولدها موقع إلى الموقع ذاته، فهي تحقق الشرطين السابقين، لكن ستفقد العمليات الأكثر تعقيدًا -مثل طلبات الشبكة بين موقعين مختلفين- ملفات الارتباط، لذا استخدم هذا الخيار إن كان الأمر مناسبًا لك، إذ لن يؤثر ذلك غالبًا على تجربة المستخدم وسيؤمن لك الحماية. وعلى الرغم من أن استخدام الخيار samesite أمر جيد، لكنك ستواجه العائق المهم الذي يتمحور حول تجاهل المتصفحات القديمة -2017 وما قبلها- لهذا الخيار، بحيث ستخترَق المتصفحات القديمة إذا اعتمدت على هذه الطريقة في الحماية فقط. لكن يمكننا استخدام samesite مع معايير حماية أخرى، مثل مفاتيح الحماية المشفرة من هجمات xsrf، وبذلك سنضيف طبقة حماية جديدة، وبالتالي سنتمكن غالبًا من تفادي هجمات xsrf حين يتوقف دعم المتصفحات القديمة مستقبلًا. الخيار httpOnly ليس لهذا الخيار علاقة بلغة JavaScript، لكن لا بدّ من ذكره حرصًا على اكتمال الصورة. يُستخدم خادم الويب الترويسة Set-Cookie لإعداد ملف الارتباط، كما يمكن للخادم استخدام الخيار httpOnly الذي يمنع أي شيفرة JavaScript من الوصول إلى ملف الارتباط، إذ لا يمكن عندها رؤية ملف الارتباط أو التعامل معه باستعمال الأمر document.cookie، ويستعمل هذا الخيار عاملًا احترازيًا لحماية المستخدم من هجمات معينة، يدفع فيها المخترق شيفرة JavaScript إلى صفحة ويب وينتظر الضحية ليزورها، وبالطبع لا ينبغي أن يتمكن المخترقون من دفع شيفراتهم إلى موقعنا، لكن قد نترك وراءنا ثغرات تمكنهم من ذلك. وإذا حدث شيء كهذا وزار المستخدم صفحة ويب تحتوي على شيفرة لمخترقين، فستُنفَّذ هذه الشيفرة وسيصل المخترق إلى document.cookie، وبالتالي إلى ملف الارتباط التي يحتوي على معلومات الاستيثاق. لكن إذا استُخدم الخيار httpOnly فلن تتمكن الخاصية document.cookie من رؤية ملف الارتباط، وسيبقى المستخدم محميًا. الدوال التي تتعامل مع ملفات الارتباط سنبين مجموعةً صغيرةً من الدوال التي تتعامل مع ملفات الارتباط، وهي ملاءمة أكثر من التعديلات اليدوية على الخاصية document.cookie، وستجد العديد من المكتبات التي ستساعدك في إنجاز ذلك، لكننا سنستعرض هذه الدوال على سبيل التجربة. الدالة (getCookie(name إنّ الطريقة الأقصر للوصول إلى ملف الارتباط ستكون باستخدام التعابير النظامية. حيث تعيد الدالة (getCookie(name ملف ارتباط باسم محدد name. // تعيد الدالة ملف ارتباط باسم محدد // أو تعيد كائنًا غير محدد إن لم يوجد الملف function getCookie(name) { let matches = document.cookie.match(new RegExp( "(?:^|; )" + name.replace(/([\.$?*|{}\(\)\[\]\\\/\+^])/g, '\\$1') + "=([^;]*)" )); return matches ? decodeURIComponent(matches[1]) : undefined; } يوّلد الكائن new RegExp ديناميكيًا ليُطابق اسم ملف الارتباط <name=<value. لاحظ أن قيمة ملف الارتباط مرمّزة، لذلك تستخدم الدالة getCookie الدالة decodeURIComponent لفك الترميز. الدالة (setCookie(name, value, options تجعل هذه الدالة القيمة value اسمًا لملف الارتباط name، وتضبط خيار المسار افتراضيًا على القيمة =/path، ويمكن تعديلها لإضافة قيم افتراضية لخيارات أخرى: function setCookie(name, value, options = {}) { options = { path: '/', // إضافة قيم افتراضية أخرى ...options }; if (options.expires instanceof Date) { options.expires = options.expires.toUTCString(); } let updatedCookie = encodeURIComponent(name) + "=" + encodeURIComponent(value); for (let optionKey in options) { updatedCookie += "; " + optionKey; let optionValue = options[optionKey]; if (optionValue !== true) { updatedCookie += "=" + optionValue; } } document.cookie = updatedCookie; } // :مثال الاستخدام setCookie('user', 'John', {secure: true, 'max-age': 3600}); الدالة (deleteCookie(name تُستدعى هذه الدالة بقيم سالبة لخيار فترة الصلاحية max-age: function deleteCookie(name) { setCookie(name, "", { 'max-age': -1 }) } ملفات ارتباط الطرف الثالث Third-party cookie يُعَد ملف الارتباط من "طرف ثالث" إذا أعدّه نطاق مختلف عن الصفحة التي تزورها، فمثلًا: تُحمِّل صفحة من الموقع hsoub.com شريطًا دعائيًا من موقع آخر <img src="https://ads.com/banner.png">. يمكن أن يهيئ الخادم الذي يستضيف الموقع ads.com الترويسة Set-Cookie متضمنةً ملف الارتباط id=1234 مثلًا، وسيكون هذا الملف -الذي يعود أصلًا إلى النطاق ads.com- مرئيًا فقط لصفحات هذا النطاق. عندما ننتقل إلى ads.com في المرة القادمة، سيحصل الخادم على مُعرِّف id ملف الارتباط وسيميِّز المستخدم. سيظهر الأمر الأكثر أهميةً عند انتقال المستخدم من الموقع hsoub.com إلى موقع آخر يحوي شريطًا دعائيًا، إذ سيحصل النطاق ads.com على ملف الارتباط لأنه ينتمي إليه، وبالتالي سيميّز المستخدم وسيتعقبه عند التنقل بين المواقع. استُخدمت ملفات ارتباط الطرف الثالث تقليديًا لخدمات الدعاية وتعقب المستخدمين نظرًا لطبيعتها، إذ ترتبط هذه الملفات بالنطاقات التي أعدَّتها، حيث يمكن للنطاق ads.com أن يتعقب المستخدم ذاته عبر مواقع مختلفة إذا أمكن لهذه المواقع الوصول إلى هذا النطاق. تسمح المتصفحات بتعطيل عمل هذا النوع من ملفات الارتباط لأن المستخدم لا يحب أن يُتتبع عادةً، كما تتبع بعض المتصفحات الحديثة سياسات خاصةً بملفات الارتباط هذه، وهي: لا يسمح المتصفح Safari بملفات ارتباط الطرف الثالث. يأتي المتصفح Firefox بقائمة سوداء تضم مجموعة نطاقات تحجب ملفات الارتباط التي تعود لها. التشريع الأوروبي GDPR لا يتعلق الموضوع باللغة إطلاقًا، لكن انتبه إليه عندما تعمل على إعداد ملفات الارتباط، حيث يجبر تشريع أوروبي يُدعى "GDPR" صفحات الويب على احترام مجموعة من القواعد التي تضمن خصوصية المستخدم، وتتطلب إحدى هذه القواعد إذنًا صريحًا لتتبع ملفات الارتباط العائدة لمستخدم، وتذكر أنه يخص ملفات ارتباط التتبع أو التعريف أو الاستيثاق، فإذا أردنا إعداد ملف ارتباط لحفظ بعض المعلومات -لا لتتبع أو تحديد مستخدم- فلنا كامل الحرية، ولابدّ من إذن صريح من المستخدم في حال تتبعه أو التعرف عليه. تطبق مواقع الويب هذا التشريع بأسلوبين، ولا بدّ أنك لاحظتهما أثناء تصفحك للإنترنت: عندما تحاول صفحة الويب تتبع مستخدمين مستَوثَقين، فلا بدّ عندها أن يحتوي نموذج التسجيل على صندوق تحقق checkbox يحمل عنوانًا مثل "قبول سياسة الخصوصية"، والتي تصف كيفية استخدام ملفات الارتباط، ولا بدّ أن يختاره المستخدم حتى يتابع، عندها ستستخدم الصفحة ملفات ارتباط الاستيثاق بحرية. عندما تحاول صفحة الويب تتبع كل مستخدم فستعرض الصفحة -إن أرادت تنفيذ ذلك بصورة قانونية- شاشة بداية splash screen للقادمين الجدد، وتطلب منهم الموافقة على استخدام ملفات الارتباط. عندها ستتمكن الصفحة من إنشاء ملفات ارتباط، ثم ستسمح للمستخدم بمتابعة محتوى الصفحة. قد يكون الأمر مربكًا للمستخدم، فلا أحد يحب النقر الإجباري على شاشة بداية قبل متابعة المحتوى، لكن التشريع GDPR يتطلب موافقةً صريحةً. لا يتعلق التشريع GDPR بملفات الارتباط، بل بأمور أخرى تتعلق بالخصوصية أيضًا، لكنها خارج إطار موضوعنا. خلاصة توفِّر الخاصية document.cookie وصولًا إلى ملفات الارتباط cookies، مع ملاحظة ما يلي: لا تعدل عمليات الكتابة سوى ملفات الارتباط التي تذكرها الخاصية. لا بدّ من ترميز الزوج "اسم/قيمة" name/value. لا يتجاوز حجم ملف الارتباط عتبة 4 كيلوبايت، ولا يتجاوز عدد ملفات الارتباط 20 ملفًا في الموقع، وفقًا للمتصفح المستخدم. خيارات ملف الارتباط: /=path: قيمته الافتراضية هي المسار الحالي، ويجعل ملف الارتباط مرئيًا فقط تحت المسار المحدد. domain=site.com: يكون ملف الارتباط مرئيًا ضمن النطاق المحدد فقط افتراضيًا، لكن ستكون ملفات الارتباط مرئيةً ضمن النطاقات الفرعية أيضًا إذا صُرّح عن النطاق. expires أو max-age: تحدد زمن انتهاء صلاحية ملف الارتباط، وبدونها يُحذف ملف الارتباط عند إغلاق المتصفح. secure: لا بدّ من استخدام بروتوكول HTTPS حتى تُرى ملفات الارتباط. samesite: يمنع المتصفح من إرسال ملفات الارتباط مع الطلبات الواردة من خارج نطاق الموقع، وسيساعد ذلك في إيقاف هجمات XSRF. إضافةً إلى ذلك: يمكن أن يمنع المتصفح استخدام ملفات الارتباط القادمة من طرف ثالث افتراضيًا مثل المتصفح Safari. ينبغي التقيد بالتشريع GDPR عند إعداد ملفات ارتباط لتتبع مستخدمين من الاتحاد الأوروبي. ترجمة -وبتصرف- للفصل cookies, document.cookies من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: آليات الاتصال المستمر مع الخادم في جافاسكربت الجلسات وملفات تعريف الارتباط ومكتبة cURL في PHP الواجهة البرمجية Fetch API في جافاسكريبت احم موقعك من الاختراق
-
سنشرح في هذا المقال آليات الاتصال الدائم المستمر مع الخادم وهو الاتصال الذي يُنشَأ فيه اتصال مستمر بين الخادم والعميل (غالبًا متصفح الويب) يجري فيه تبادل الطلبيات والردود آنيًا، وتنحصر آلية تنفيذ الاتصال الدائم تلك في ثلاثة أنواع: آلية الاستطلاع المفتوح Long polling وآلية مقابس الويب عبر البروتوكول WebSocket وآلية الأحداث المرسلة EventSource، سنناقش كل واحدة بالتفصيل على حدة. آلية الاستطلاع آلية الجس والاستطلاع polling هي الطريقة الأبسط للمحافظة على اتصال ثابت مع الخادم، ولا تتطلب أي بروتوكولات خاصة مثل WebSocket، أو أي أحداث من طرف الخادم، كما أنها سهلة الإنجاز وكافية في الكثير من الحالات. الاستطلاع الدوري Regular polling الطريقة الأبسط للحصول على معلومات من الخادم هي الاستطلاع الدوري، بإرسال طلبات إلى الخادم لسؤاله عن أي معلومات جديدة على فترات زمنية منتظمة (مثلًا: كل 10 ثوان)، وعند الاستجابة ينتبه الخادم إلى اتصال العميل به، ثم يرسل حزمةً من الرسائل التي تلقاها حتى اللحظة، سيعمل الأمر كما هو متوقع، لكن مع بعض الجوانب السلبية، منها: تمرَّر الرسائل بتأخير زمني قد يصل إلى 10 ثوان (الزمن الفاصل بين الطلبات). سيُغرَق الخادم بالطلبات كل 10 ثوان حتى لو غادر المستخدم الموقع أو استغرق في النوم، وهذا حمل كبير عليه من منظور الأداء. لهذا قد تكون هذه المقاربة جيدةً عندما تكون الخدمة بسيطةً، لكنها وبشكل عام تحتاج إلى التحسين. الاستطلاع المفتوح Long polling وهي طريقة أفضل للاستطلاع أو الجس من حالة الخادم، وتتميز بسهولة تنفيذها، وقدرتها على تسليم الرسائل دون تأخير. حيث: يُرسَل الطلب إلى الخادم. لا يغلق الخادم الاتصال حتى تكون لديه رسالة لتسليمها. عندما تظهر الرسالة، يستجيب الخادم للطلب بهذه الرسالة. يرسل المتصفح طلبًا جديدًا مباشرة. إن أساس هذه الطريقة هو إرسال المتصفح للطلب وإبقاء الاتصال مع الخادم قيد الانتظار، ولا يهيّئ المتصفح الاتصال من جديد حتى يتسلم الاستجابة، فإن فُقد الاتصال نتيجة خطأ ما، فسيرسل المتصفح طلبًا آخر مباشرةً. تمثل الشيفرة التالية الدالة subscribe التي ترسل الطلبات المفتوحة من جهة العميل: async function subscribe() { let response = await fetch("/subscribe"); if (response.status == 502) { // 502 خطأ تجاوز الوقت المسموح للاتصال // يحدث عند بقاء الاتصال مفتوحًا لفترة طويلة جدًا // ويغلقه الخادم لذا يجب إعادة الاتصال await subscribe(); } else if (response.status != 200) { // إظهار الخطأ showMessage(response.statusText); // إعادة الاتصال خلال ثانية await new Promise(resolve => setTimeout(resolve, 1000)); await subscribe(); } else { // الحصول على الرسالة وإظهارها let message = await response.text(); showMessage(message); // استدعاء التابع من جديد await subscribe(); } } subscribe(); يرسل التابع subscribe طلب إحضار، وينتظر الاستجابة ليعالجها عندما تصل، ثم يستدعي نفسه مجددًا. مثال اختباري لنظام محادثة بسيط إليك تطبيق محادثة اختباريًا يمكنك تنزيله وتشغيله محليًا إذا كنت على دراية باستخدام Node.JS وتثبيت الوحدات البرمجية اللازمة: ملف شيفرة العميل "browser.js": // POSTإرسال رسالة، طلب function PublishForm(form, url) { function sendMessage(message) { fetch(url, { method: 'POST', body: message }); } form.onsubmit = function() { let message = form.message.value; if (message) { form.message.value = ''; sendMessage(message); } return false; }; } // تلقي الرسائل باستخدام الاستطلاع المفتوح function SubscribePane(elem, url) { function showMessage(message) { let messageElem = document.createElement('div'); messageElem.append(message); elem.append(messageElem); } async function subscribe() { let response = await fetch(url); if (response.status == 502) { // تجاوز الوقت المسموح // يحدث عندما يبقى الاتصال مفتوحًا لفترة طويلة // لا بد من إعادة الاتصال await subscribe(); } else if (response.status != 200) { // Show Error showMessage(response.statusText); // إعادة الاتصال خلال ثانية await new Promise(resolve => setTimeout(resolve, 1000)); await subscribe(); } else { // الحصول على الرسالة let message = await response.text(); showMessage(message); await subscribe(); } } subscribe(); } ملف شيفرة الواجهة الخلفية "server.js": let http = require('http'); let url = require('url'); let querystring = require('querystring'); let static = require('node-static'); let fileServer = new static.Server('.'); let subscribers = Object.create(null); function onSubscribe(req, res) { let id = Math.random(); res.setHeader('Content-Type', 'text/plain;charset=utf-8'); res.setHeader("Cache-Control", "no-cache, must-revalidate"); subscribers[id] = res; req.on('close', function() { delete subscribers[id]; }); } function publish(message) { for (let id in subscribers) { let res = subscribers[id]; res.end(message); } subscribers = Object.create(null); } function accept(req, res) { let urlParsed = url.parse(req.url, true); // مستخدم جديد يريد النتائج if (urlParsed.pathname == '/subscribe') { onSubscribe(req, res); return; } // إرسال رسالة if (urlParsed.pathname == '/publish' && req.method == 'POST') { // POSTقبول req.setEncoding('utf8'); let message = ''; req.on('data', function(chunk) { message += chunk; }).on('end', function() { publish(message); // النشر للجميع res.end("ok"); }); return; } // البقية ثابتة fileServer.serve(req, res); } function close() { for (let id in subscribers) { let res = subscribers[id]; res.end(); } } // ----------------------------------- if (!module.parent) { http.createServer(accept).listen(8080); console.log('Server running on port 8080'); } else { exports.accept = accept; if (process.send) { process.on('message', (msg) => { if (msg === 'shutdown') { close(); } }); } process.on('SIGINT', close); } الملف الأساسي "index.html": <!DOCTYPE html> <script src="browser.js"></script> All visitors of this page will see messages of each other. <form name="publish"> <input type="text" name="message" /> <input type="submit" value="Send" /> </form> <div id="subscribe"> </div> <script> new PublishForm(document.forms.publish, 'publish'); // random url parameter to avoid any caching issues new SubscribePane(document.getElementById('subscribe'), 'subscribe?random=' + Math.random()); </script> وستظهر النتيجة بالشكل التالي: مجال الاستخدام تعمل طريقة الاستطلاع المفتوح بصورة ممتازة عندما تكون الرسائل نادرةً، لكن عندما تتكرر الرسائل بكثرة، فسيكون منحني (طلب-استجابة) المرسوم في الأعلى على هيئة أسنان المنشار، ولأن لكل رسالة طلبًا مستقلًا له ترويسات وآلية استيثاق خاصة، فمن الأفضل استخدام طرق أخرى مثل: Websocket أوServer Sent Events، التي سنشرحها في الفقرات التالية. استخدام البروتوكول WebSocket يزوّدنا هذا البروتوكول الموصوف في المعيار RFC 6455 بطريقة لتبادل البيانات بين الخادم والمتصفح عبر اتصال ثابت، حيث تمرَّر البيانات إلى كلا الطرفين على شكل حزم دون قطع الاتصال أو استخدام طلبات HTTP إضافية، وتظهر الفائدة الكبيرة لاستخدامه عند وجود خدمات تتطلب التبادل المستمر للبيانات، مثل ألعاب الشبكة وأنظمة التجارة العاملة في الزمن الحقيقي وغيرها. مثال بسيط سنحتاج إلى كائن مقبس اتصال جديد new WebSocket لفتح اتصال websocket باستخدام بروتوكول نقل خاص هو ws في العنوان: let socket = new WebSocket("ws://javascript.info"); كما يمكن استخدام البروتوكول المشفّر //:wss، فهو مشابه للبروتوكول HTTPS لكن مع websockets. ينبغي الاستماع إلى الأحداث التي تطرأ على مقبس الاتصال Socket بعد تأسيسه مباشرةً، وهي أربعة أحداث: open: عند تأسيس الاتصال. message: عند استقبال البيانات. error: عند وقوع خطأ يتعلق بالبروتوكول websocket. close: عند إغلاق الاتصال. ولإرسال أي بيانات يكفي استخدام الصيغة (socket.send(data، وإليك مثالًا: let socket = new WebSocket("wss://javascript.info/article/websocket/demo/hello"); socket.onopen = function(e) { alert("[open] Connection established"); alert("Sending to server"); socket.send("My name is John"); }; socket.onmessage = function(event) { alert(`[message] Data received from server: ${event.data}`); }; socket.onclose = function(event) { if (event.wasClean) { alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`); } else { // كأن تلغى العملية أو فشل في الشبكة // 1006 قيمته في هذه الحالة عادة event.code alert('[close] Connection died'); } }; socket.onerror = function(error) { alert(`[error] ${error.message}`); }; لقد كتبنا شيفرة خادم تجريبي باستخدام Node.JS في الملف server.js لتشغيل الشيفرة في المثال السابق، وسيرد الخادم بالرسالة "Hello from server, John"، ثم ينتظر 5 ثوان ويقطع الاتصال. سترى إذًا تسلسل الأحداث open → message → close، والأمر بهذه البساطة فعليًا، لأنك تستخدم الآن البروتوكول WebSocket، لنتحدث الآن في أمور أكثر عمقًا. فتح اتصال مقبس ويب WebSocket يُنشئ الأمر (new WebSocket(url كائنًا جديدًا ويبدأ عملية الاتصال مباشرةً، سيسأل المتصفح الخادم خلال تأسيس الاتصال -عبر الترويسات- إذا كان يدعم البروتوكول Websocket، فإن كان الجواب نعم فسيستمر العمل به، وهو يختلف عن HTTP كليًا. وإليك مثالًا عن ترويسات المتصفح للطلب الذي يرسله الأمر (new WebSocket("wss://javascript.info/chat. GET /chat Host: javascript.info Origin: https://javascript.info Connection: Upgrade Upgrade: websocket Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q== Sec-WebSocket-Version: 13 Origin: أصل الصفحة العميلة (https://javascript.info مثلًا)، فالكائنات WebSocket هي كائنات من أصول مختلطة بطبيعتها، حيث لا توجد أي قيود لاستخدامها، وطالما أنّ الخوادم القديمة لا تدعم هذا البروتوكول، فلن نواجه مشكلة توافق. تظهر أهمية الترويسة Origin في أنها تسمح للخادم بالاختيار إن كان سيتواصل مع الصفحة العميلة وفق هذا البروتوكول أم لا. Connection: Upgrade: يشير إلى رغبة العميل في تغيير البروتوكول. Upgrade: websocket: تدل أنّ البروتوكول المطلوب هو websocket. Sec-WebSocket-Key: مفتاح أمان عشوائي يولده المتصفح. Sec-WebSocket-Version: نسخة بروتوكول websocket، وهي حاليًا النسخة 13. ينبغي على الخادم أن يعيد الرمز 101 عندما يوافق على التحوّل إلى البروتوكول WebSocket: 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g= ستكون الترويسة Sec-WebSocket-Accept هي نفسها Sec-WebSocket-Key مسجلةً باستخدام خوارزمية خاصة، وسيستخدمها المتصفح للتأكد من توافق الاستجابة مع الطلب. بعد ذلك ستُنقل البيانات باستخدام بروتوكول WebSocket، وسنطلع على بنيتها (إطاراتها) قريبًا. الموسعات والبروتوكولات الفرعية هنالك أيضًا الترويستان Sec-WebSocket-Extensions التي تصف الموسّعات، وSec-WebSocket-Protocol التي تصف البروتوكولات الفرعية. فمثلًا: Sec-WebSocket-Extensions: deflate-frame: يعني أن المتصفح سيدعم ضغط البيانات، فالموسّع هو شيء يتعلق بنقل البيانات ووظيفة توسّع عمل البروتوكول، يرسل المتصفح الترويسة Sec-WebSocket-Extensions تلقائيًا مزودةً بقائمة الموسّعات المدعومة كلها. Sec-WebSocket-Protocol: soap, wamp: يعني أننا سننقل البيانات باستخدام أحد البروتوكولين الفرعيين SOAP أو WAMP (وهو اختصار للعبارة " WebSocket Application Messaging Protocol" أي "بروتوكول نقل الرسائل لتطبيق websocket"). تُسجل البروتوكولات الفرعية في IANA catalogue، أي أن هذه الترويسة الاختيارية تحدد صيغ البيانات التي سنستخدمها، وتُضبَط باستخدام المعامل الثاني للدالة البانية new WebSocket، فلو أردنا استخدام SOAP أو WAMP مثلًا، فسنضعهما في مصفوفة بالشكل التالي: let socket = new WebSocket("wss://javascript.info/chat", ["soap", "wamp"]); سيستجيب الخادم بقائمة من البروتوكولات والموسّعات التي يوافق على استخدامها، وإليك مثالًا: GET /chat Host: javascript.info Upgrade: websocket Connection: Upgrade Origin: https://javascript.info Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q== Sec-WebSocket-Version: 13 Sec-WebSocket-Extensions: deflate-frame Sec-WebSocket-Protocol: soap, wamp وستكون الاستجابة: 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g= Sec-WebSocket-Extensions: deflate-frame Sec-WebSocket-Protocol: soap أي أن الخادم سيدعم الموسّع "deflate-frame" والبروتوكول الفرعي SOAP فقط. نقل البيانات يتكون الاتصال من إطارات "frames"، وهي أجزاء من البيانات يمكن إرسالها من كلا الطرفين وقد تكون من الأنواع التالية: "text": يحتوي بيانات نصيةً ترسلها الأطراف إلى بعضها. "binary data": يحتوي بيانات ثنائيةً تُرسلها الأطراف إلى بعضها. "ping/pong": تستخدَم للتحقق من الاتصال، ويرسلها الخادم إلى المتصفح الذي يرد عليها تلقائيًا. ستجد أيضًا "connection close" وبعض الإطارات الأخرى. ونعمل في بيئة المتصفح مع الإطارات النصية والثنائية. يمكن للتابع ()send. إرسال بيانات نصية أو ثنائية.، ويسمح استدعاء التابع (socket.send(body بأن تكون قيمة جسم الطلبbody بالتنسيق النصي أو الثنائي، بما في ذلك الكائن Blob وArrayBuffer وغيرهما، ولا حاجة لإجراء ضبط خاص بل تُرسَل البيانات كما هي. تأتي البيانات النصية على شكل "string" عندما نستقبل البيانات، ومع ذلك يمكننا الاختيار بين BlobوArrayBuffer للبيانات الثنائية.** وكذا ضبط ذلك باستخدام الخاصية socket.binaryType، التي تأخذ القيمة "blob" افتراضيًا، لذلك تأتي البيانات الثنائية ضمن الكائن Blob، الذي يمثل كائن بيانات ثنائية عالي المستوى ويتكامل مباشرةً مع المعرّفين <a>و<img> وغيرهما، ويمكن الانتقال إلى استخدام الكائن ArrayBuffer عند معالجة البيانات الثنائية والعمل مع البايتات بشكل منفصل. socket.binaryType = "arraybuffer"; socket.onmessage = (event) => { //أما أن يكون نصًا أو بيانات ثنائيةً event.data }; محدودية معدل النقل Rate limiting لنتخيل أن تطبيقنا قادر على توليد كمية كبيرة من البيانات، لكن المستخدم يعاني من اتصال بطيء بالانترنت. يمكن استدعاء التابع (socket.send(data مرات عدةً، لكن البيانات ستُخزّن مؤقتًا في الذاكرة وستُرسَل بالسرعة التي تسمح بها الشبكة فقط، عندها يمكن استخدام الخاصية socket.bufferedAmount التي تُخزّن حجم البيانات الموجودة في المخزن المؤقت في هذه اللحظة وتنتظر الظروف المناسبة لنقلها، ويمكن فحص الحجم المتبقي من البيانات للتأكد من أن المقبس Socket لا يزال متاحًا لمتابعة نقل البيانات. // افحص المقبس كل 100 ثانية وحاول إرسال البيانات // عندما يكون حجم البيانات المخزّنة التي لم تصل صفرًا setInterval(() => { if (socket.bufferedAmount == 0) { socket.send(moreData()); } }, 100); إغلاق الاتصال عندما يحاول أحد الأطراف إغلاق الاتصال (للمخدم والمتصفح الحق نفسه في ذلك)، يرسَل عادةً إطار إغلاق الاتصال "connection close" الذي يحتوي قيمةً عدديةً وسببًا نصيًا للإغلاق. إليك الصيغة اللازمة: socket.close([code], [reason]); code: رمز خاص لإغلاق اتصال WebSocket، وهو اختياري. reason: نص يصف سبب الإغلاق، وهو اختياري. يستقبل معالج الحدث close في الطرف الآخر الرمز والسبب: // الطرف الذي سيغلق الاتصال: socket.close(1000, "Work complete"); // الطرف الآخر socket.onclose = event => { // event.code === 1000 // event.reason === "Work complete" // event.wasClean === true (clean close) }; إليك القيم الأكثر شيوعًا للرموز: 1000: وهي القيمة الافتراضية، وتستخدم للإغلاق العادي (تستخدم إن لم يكن هناك رمز code). 1006: لا يمكن ضبط هذا الرمز يدويًا ويشير إلى فقد الاتصال. 1001: عند مغادرة أحد الأطراف، مثل: إغلاق الخادم أو مغادرة المتصفح للصفحة. 1009: الرسائل أضخم من أن تُعالج. 1011: خطأ في الخادم. يمكن الاطلاع على القائمة الكاملة ضمن المعيار RFC6455, §7.4.1. إنّ رموز WebSocket مشابهة نوعًا ما لرموز HTTP وتختلف في بعض النواحي، فأي رمز أقل من 1000 سيكون محجوزًا، وسيتولد خطأ عند استخدامه. // في حال فشل الاتصال socket.onclose = event => { // event.code === 1006 // event.reason === "" // event.wasClean === false (no closing frame) }; حالة الاتصال تتيح الخاصية socket.readyState إمكانية معرفة الوضع الحالي للاتصال وفقًا للقيم التي تأخذها: 0 متصل "CONNECTING": أي أن الاتصال يجري، ولم يُؤسَّس بعد. 1 مفتوح "OPEN": التواصل قائم. 2 يُغلق "CLOSING": يجري إغلاق الاتصال. 3 مُغلق "CLOSED": الاتصال مغلق. مثال تطبيق محادثة آنية لنراجع مثال تطبيق المحادثة باستخدام الواجهة البرمجية WebSocket API والوحدة البرمجية Node.js WebSocket module، سنركز على طرف العميل، وسيكون الخادم بسيطًا أيضًا. نحتاج إلى نموذج <form> لإرسال الرسائل ومعرِّف <div> لاستقبالها. <!-- message form --> <form name="publish"> <input type="text" name="message"> <input type="submit" value="Send"> </form> <!-- div with messages --> <div id="messages"></div> كما سنستخدم في جافاسكربت: فتح الاتصال. إرسال رسالة عن طريق النموذج (socket.send(message. وضع الرسالة المستقبلة ضمن div#messages. وإليك الشيفرة اللازمة: let socket = new WebSocket("wss://javascript.info/article/websocket/chat/ws"); // إرسال رسالة إلى النموذج document.forms.publish.onsubmit = function() { let outgoingMessage = this.message.value; socket.send(outgoingMessage); return false; }; // div#messagesإظهار الرسالة بعد وصولها في socket.onmessage = function(event) { let message = event.data; let messageElem = document.createElement('div'); messageElem.textContent = message; document.getElementById('messages').prepend(messageElem); } لن نتعمق بشيفرة الواجهة الخلفية، فهي خارج نطاق مقالتنا، لكننا سنستخدم هنا Node.js، ومع ذلك فالأمر ليس إلزاميًا، إذ أن للعديد من المنصات طرقها للتعامل مع WebSocket. تمثل الخطوات التالية خوارزمية عمل الواجهة الخلفية: إنشاء مجموعة من مقابس الاتصال ()clients = new Set. إضافة كل مقبس مقبول إلى المجموعة clients.add(socket)، وتهيئة مستمع message للحدث للحصول على الرسائل المتعلقة بالمقبس. عندما استقبال رسالة كرر عملية إرسالها إلى كل عميل. احذف مقابس العملاء عند قطع الاتصال (clients.delete(socket. const ws = new require('ws'); const wss = new ws.Server({noServer: true}); const clients = new Set(); http.createServer((req, res) => { // websocket سنعالج فقط اتصالات // websocket في مشروعنا الحقيقي ستوجد هنا شيفرة تعالج الطلبات بطرق أخرى غير wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect); }); function onSocketConnect(ws) { clients.add(ws); ws.on('message', function(message) { message = message.slice(0, 50); // 50سيكون الحجم الأكبر للرسالة هو for(let client of clients) { client.send(message); } }); ws.on('close', function() { clients.delete(ws); }); } إليك النتيجة: يمكن تنزيل التطبيق وتجربته محليًا على جهازك، ولا تنس تثبيت Node.js، ثم تثبيت WebSocket باستخدام الأمر npm install ws قبل تشغيل التطبيق. الأحداث المرسلة من قبل الخادم تشرح التوصيفات Server-Sent Events صنفًا مدمجًا هو EventSource، حيث يحافظ على الاتصال مع الخادم ويسمح باستقبال أحداث منه، وهو اتصال ثابت مثل WebSocket تمامًا لكن مع بعض الاختلافات بينهما: 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; } WebSocket EventSource ثنائي الاتجاه: إذ يمكن أن يتبادل الخادم والعميل الرسائل وحيد الاتجاه: فالخادم هو من يرسل الرسائل إلى العميل نصوص وبيانات ثنائية نصوص فقط بروتوكول websocket بروتوكول HTTP النظامي. يمثل EventSource طريقةً أقل قدرةً للاتصال مع الخادم بالموازنة مع WebSocket، إذًا لماذا نستخدمها؟ يعود السبب الرئيسي لاستخدامه في كونه أبسط، إذ لا نحتاج إلى القدرة الكبيرة لبروتوكول WebSocket في بعض التطبيقات، فتلقي بيانات من الخادم مثل رسائل محادثة أو لوائح أسعار أو أي شيء مشابه هو مجال EventSource، كما أنه يدعم إعادة الاتصال، وهو أمر لا بد من إنجازه يدويًا في WebSocket، ويستخدم كذلك بروتوكول HTTP وليس أي بروتوكول جديد. الحصول على الرسائل لا بد من إنشاء كائن جديد من الصنف EventSource للبدء باستقبال الرسائل، وذلك بتنفيذ الأمر (new EventSource(url، حيث سيتصل المتصفح بالعنوان url ويبقي الاتصال مفتوحًا ومنتظرًا الأحداث، وهنا ينبغي أن يستجيب الخادم برمز الحالة 200 وبالترويسة Content-Type: text/event-stream، وسيبقي الخادم بعدها قناة الاتصال مفتوحةً، وسيرسل الرسائل ضمنها بتنسيق خاص بالشكل التالي: data: Message 1 data: Message 2 data: Message 3 data: of two lines تأتي الرسالة بعد الكلمة :data بينما يعَد الفراغ الموجود بعد النقطتين اختياريًا. يفصَل بين الرسائل برمز السطر الجديد مضاعفًا n\n\ يمكننا عند الحاجة إلى إرسال رمز سطر جديد n\ أن نرسل مباشرةً الكلمة :data (كما في الرسالة الثالثة في المثال السابق)، لكن عمليًاP تًرسل الرسائل المركّبة مرمزةً بتنسيق JSON، وترمّز محارف الانتقال إلى سطر جديد بالشكل n\ ضمنها، فلا حاجة عندها للرسائل :data متعددة السطر. فمثلًا: data: {"user":"John","message":"First line\n Second line"} وهكذا يمكن أن نعد أن العبارة data: ستليها رسالة واحدة تمامًا. يولَّد الحدث message لكل من تلك الرسائل بالشكل التالي: let eventSource = new EventSource("/events/subscribe"); eventSource.onmessage = function(event) { console.log("New message", event.data); // سيطبع ثلاث رسائل في المثال السابق }; // or eventSource.addEventListener('message', ...) الطلبات ذات الأصل المختلط يدعم الصنف EventSource الطلبات ذات الأصل المختلط cross-origin requests كما يفعل fetch، إذ يمكننا استخدام أي عنوان URL: let source = new EventSource("https://another-site.com/events"); سيستقبل الخادم البعيد الترويسة Origin وينبغي عليه الإجابة بالترويسة Access-Control-Allow-Origin للمتابعة، ولتمرير الثبوتيات credentials لا بد من ضبط خيار إضافي هو withCredentials بالشكل التالي: let source = new EventSource("https://another-site.com/events", { withCredentials: true }); راجع فصل استخدام Fetch مع الطلبات ذات الأصول المختلطة للاطلاع على تفاصيل أكثر حول ترويسات الأصول المختلطة. إعادة الاتصال بعد إنشاء الكائن EventSource فسيتصل بالخادم مباشرةً ويعيد الاتصال في حال انقطاعه، وبالتالي لا حاجة لإجراء تدابير خاصة بهذا الأمر، وبطبيعة الحال يوجد تأخير زمني صغير لثوان معدودة افتراضيًا بين كل محاولتين لإعادة الاتصال، وهنا يمكن للخادم أن يضبط التأخير اللازم باستخدام الخاصية :retry في الاستجابة (مقدرًا بالميلي ثانية). retry: 15000 data: Hello, I set the reconnection delay to 15 seconds يمكن أن تأتي :retry مصحوبةً ببيانات أخرى أو برسالة وحيدة، وهكذا سيكون على الخادم انتظار الفترة المحددة قبل إعادة الاتصال، وقد ينتظر فترة أطول، فإذا علم المتصفح -عن طريق نظام التشغيل- أن الشبكة غير متصلة حاليًا مثلًا، فسينتظر حتى يعود الاتصال ثم يحاول ثانيةً. إذا أراد الخادم من المتصفح التوقف عن إعادة الاتصال، فعليه أن يرد برمز الحالة 204 (رمز HTTP). إذا أراد المتصفح إغلاق الاتصال، فعليه استدعاء التابع eventSource.close. let eventSource = new EventSource(...); eventSource.close(); إذا احتوت الاستجابة على ترويسة Content-Type خاطئةً فلن يعاد الاتصال، وكذلك إذا اختلف رمز حالة HTTP عن القيم 301 أو 307 أو 200 أو 204، فعندها سيُولد الحدث "error" ولن يعيد المتصفح الاتصال. معرف الرسالة الفريد Message id عندما ينقطع الاتصال جراء خلل في الشبكة، tلن يتأكد كلا الطرفين من كون الرسائل استلمَت أم لا، وحتى يُستأنَف الاتصال بشكل صحيح، فينبغي أن تحمل كل رسالة مُعرِّفًا مميزًا id مثل التالي: data: Message 1 id: 1 data: Message 2 id: 2 data: Message 3 data: of two lines id: 3 عندما يتلقى المتصفح الرسالة مع معرّفها :id فإنه: يضبط قيمة الخاصية eventSource.lastEventId على قيمة المعرِّف. يعيد الترويسة Last-Event-ID أثناء إعادة الاتصال مع قيمة المعرّف id، ليتمكن الخادم من إرسال الرسائل التالية إن وجدت. حالة الاتصال: الخاصية ReadyState يمتلك الكائن EventSource الخاصية readyState التي تحمل إحدى القيم الثلاث التالية: EventSource.CONNECTING = 0; // يتصل أو يعيد الاتصال EventSource.OPEN = 1; // متصل EventSource.CLOSED = 2; // الاتصال مغلق تكون قيمة هذه الخاصية عند إنشاء كائن جديد أو انقطاع الاتصال هي EventSource.CONNECTING دومًا (وهي تعادل 0)، ويمكن الاستعلام عن هذه الخاصية لمعرفة حالة الكائن EventSource. أنواع الأحداث يوّلِّد الكائن EventSource افتراضيًا ثلاثة أحداث: message: عند استلام رسالة، وتكون متاحةً باستخدام event.data. open: عندما يكون الاتصال مفتوحًا. error: عند عدم إمكانية تأسيس الاتصال، كأن يعيد الخادم رمز الحالة 500. فمثلًا: event: join data: Bob data: Hello event: leave data: Bob لمعالجة الأحداث السابقة لابد من استخدام مستمع الحدث addEventListener وليس onmessage: eventSource.addEventListener('join', event => { alert(`Joined ${event.data}`); }); eventSource.addEventListener('message', event => { alert(`Said: ${event.data}`); }); eventSource.addEventListener('leave', event => { alert(`Left ${event.data}`); }); مثال نموذجي كامل يرسل الخادم في هذا المثال الرسائل 1 و2 و3 ثم bye ويقطع الاتصال، بعدها يعيد المتصفح الاتصال تلقائيًا. شيفرة الخادم "server.js": let http = require('http'); let url = require('url'); let querystring = require('querystring'); let static = require('node-static'); let fileServer = new static.Server('.'); function onDigits(req, res) { res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache' }); let i = 0; let timer = setInterval(write, 1000); write(); function write() { i++; if (i == 4) { res.write('event: bye\ndata: bye-bye\n\n'); clearInterval(timer); res.end(); return; } res.write('data: ' + i + '\n\n'); } } function accept(req, res) { if (req.url == '/digits') { onDigits(req, res); return; } fileServer.serve(req, res); } if (!module.parent) { http.createServer(accept).listen(8080); } else { exports.accept = accept; } شيفرة الملف "index.html": <!DOCTYPE html> <script> let eventSource; function start() { // "Start" عند ضغط الزر if (!window.EventSource) { // متصفح إنترنت إكسبلورر أو أي متصفح قديم alert("The browser doesn't support EventSource."); return; } eventSource = new EventSource('digits'); eventSource.onopen = function(e) { log("Event: open"); }; eventSource.onerror = function(e) { log("Event: error"); if (this.readyState == EventSource.CONNECTING) { log(`Reconnecting (readyState=${this.readyState})...`); } else { log("Error has occurred."); } }; eventSource.addEventListener('bye', function(e) { log("Event: bye, data: " + e.data); }); eventSource.onmessage = function(e) { log("Event: message, data: " + e.data); }; } function stop() { // when "Stop" button pressed eventSource.close(); log("eventSource.close()"); } function log(msg) { logElem.innerHTML += msg + "<br>"; document.documentElement.scrollTop = 99999999; } </script> <button onclick="start()">Start</button> Press the "Start" to begin. <div id="logElem" style="margin: 6px 0"></div> <button onclick="stop()">Stop</button> "Stop" to finish. وستظهر النتيجة بالشكل التالي: خلاصة تعرفنا في هذا الفصل على آليات إنشاء إتصال مستمر مع الخادم، فبدأ أولًا بعرض آلية الاستطلاع والجس وهي الطريقة الأبسط للمحافظة على اتصال ثابت مع الخادم، ولا تتطلب أي بروتوكولات أو أي أحداث خاصة من طرف الخادم، وهي طريقة سهلة الإنجاز وكافية في الكثير من الحالات. انتقلنا بعدها إلى شرح بروتوكول WebSocket والذي يُقدم طريقةً عصريةً لتأسيس اتصال ثابت بين المتصفح والخادم، له ميزات منها: لا توجد تقييدات للأصول المختلطة. مدعوم جيدًا من قبل المتصفحات. يمكنه إرسال نصوص وبيانات ثنائية واستقبالها. واجهته البرمجية بسيطة. التوابع المستعملة في هذا البروتوكول: socket.send(data) socket.close([ code ], [reason]) أما الأحداث: open message error close لا تقدّم WebSocket بذاتها تقنيات لإعادة الاتصال أو الاستيثاق وغيرها من الآليات عالية المستوى، لذلك ستجد مكتبات للعميل والخادم تهتم بهذه النقاط، ويمكنك أيضًا تنفيذ شيفرتها يدويًا بنفسك. ولكي تتكامل WebSocket مع مشروع جاهز، جرت العادة أن يشغل المستخدم خادم WebSocket بالتوازي مع خادم HTTP الرئيسي ليتشاركا بقاعدة بيانات واحدة، حيث تُرسل طلبات WebSocket إلى النطاق الفرعي wss://ws.site.com الذي يقود إلى خادم WebSocket، بينما يقود العنوان https://site.com إلى خادم HTTP الرئيسي، كما توجد طرق أخرى للتكامل. عرضنا أخيرًا الآلية الثالثة للاتصال وهي عبر الكائن EventSource الذي يؤسس اتصالًا ثابتًا مع الخادم ويسمح له بإرسال الرسائل عبر قناة الاتصال، ويقدم: إعادة الاتصال التلقائي مع قيمة زمنية retry لإعادة الاتصال بعدها. معرّفات فريدة للرسائل "ids" لاستئناف الأحداث من آخر معرّف أرسِل مع الترويسة Last-Event-ID أثناء إعادة الاتصال. حالة الاتصال من خلال الخاصية readyState. تجعل هذه المزايا الكائن EventSource بديًلا قيمًا للكائن WebSocket، الذي يعمل في مستوىً أدنى ويفتقر إلى الميزات السابقة، والتي يمكن إنجازها يدويًا بالطبع. يعَد الكائن EventSource في الكثير من التطبيقات الحقيقية قادرًا كفاية على إنجاز المطلوب، وتدعمه جميع المتصفحات الحديثة، بينما لا يدعمه IE، وإليك صيغة استخدامه: let source = new EventSource(url, [credentials]); للوسيط الثاني خيار وحيد ممكن هو: { withCredentials: true }، ويسمح بإرسال ثبوتيات طلبات الأصول المختلطة. إن عوامل الأمان المتعلقة باستخدام طلبات الأصول المختلطة للكائن EventSourceمشابهة للاتصال fetch وغيره من طرق الاتصال عبر الشبكات. خاصيات الكائن EventSource: readyState: حالة الاتصال القائم EventSource.CONNECTING (=0) EventSource.OPEN (=1) EventSource.CLOSED (=2) lastEventId: آخر معرّف استقبله المتصفح، وسيرسله أثناء إعادة الاتصال مع الترويسة Last-Event-ID. وتوابع EventSource فتتمثل بالتابع: ()close: يغلق الاتصال. أما أحداث EventSource فهي: message: عند استلام رسالة، وتكون متاحةً باستخدام event.data. open: عندما يكون الاتصال مفتوحًا. error: في حال وقع خطأ، بما في ذلك فقدان الاتصال أو الأخطاء القاتلة، يمكننا التحقق من الخاصية readyState للتأكد من وجود محاولات لإعادة الاتصال. يمكن للخادم اختيار اسم مخصص لحدث ما من خلال :event، وينبغي معالجة هذه الأحداث من خلال مستمع حدث addEventListener وليس باستخدام <on<event. رأينا من أجل تنسيق استجابة الخادم أن الخادم يرسل الرسائل مفصولةً عن بعضها باستخدامn\n\، ويمكن أن تحتوي الرسالة الحقول التالية: :data ويمثل جسم الرسالة، وتُفسَّر سلسلة من الحقول data كرسالة واحدة يفصل بين أجزائها الكتلة n\. :id تُجدِّد قيمة lastEventId وتُرسَل ضمن الترويسة Last-Event-ID عند إعادة الاتصال. :retry تحدد فترة التأخير الزمني بين كل محاولتين لإعادة الاتصال بالميلي ثانية، ولا يمكن ضبطها باستخدام JavaScript. :event اسم الحدث وينبغي أن تسبق الحقل data. يمكن أن تحتوي الرسالة على أحد الحقول السابقة أو أكثر وبأي ترتيب، لكن الحقل :id يأتي في النهاية عادةً. ترجمة -وبتصرف- للفصول Long polling وWebSocket وServer sent events من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: استئناف رفع الملفات في جافاسكريبت تطويع البيانات في جافاسكربت
-
من السهل رفع ملف باستخدام fetch، لكن كيف سنستأنف عملية الرفع بعد فقدان الاتصال؟ لا توجد خيارات مدمجة لتنفيذ هذا الأمر، لكن لدينا كل الأجزاء التي تمكننا من تنفيذ ذلك. ينبغي أن تأتي عمليات الرفع القابلة للاستئناف مع مؤشرات على تقدم العملية، خاصةً عندما يكون الملف ضخمًا -إن أردنا استئناف الرفع-، وطالما لن تسمح fetch بتعقب عملية الرفع؛ فسنحتاج إلى الكائن XMLHttpRequest. حدث تتبع غير مفيد كفاية لا بدّ من معرفة الكمية التي جرى رفعها قبل انقطاع الاتصال لاستئناف رفع الملف، حيث يمتلك الكائن xhr الحدث xhr.upload.onprogress لتتبع تقدم عملية الرفع، لكنه لن يفيدنا في استئناف الرفع -لسوء الحظ-، لأنه سيقع عند انتهاء إرسال البيانات، لكن لا يمكن للمتصفح تحديد هل استقبلها الخادم فعلًا أم لا، فمن الممكن أن تكون هذه البيانات قد خُزّنت مؤقتًا من قبل شبكة محلية وكيلة، وربما تكون العملية انتهت على الخادم البعيد قبل أن يتمكن من معالجة البيانات، أو أنها ببساطة قد فُقدت في مرحلة ما ولم تصل إلى المستقبل النهائي، لذلك لن تتعدى فائدة هذا الحدث إظهار شريط تقدم جميل على الصفحة. إذًا لا بدّ من معرفة عدد البايتات التي استقبلها الخادم بدقة حتى نتمكن من استئناف الرفع، والخادم فقط هو من يمتلك هذه المعلومة، لذا فلا بدّ من إرسال طلب إضافي. الخوارزمية ننشئ في البداية معرّفًا مميزًا id للملف الذي سنرفعه، بالشكل التالي: let fileId = file.name + '-' + file.size + '-' + file.lastModified; سنحتاج إليه لاستئناف الرفع، إذ لا بدّ من إبلاغ الخادم عن الملف الذي سنستأنف رفعه، حيث إذا تغير اسم أو حجم أو تاريخ آخر تعديل للبيانات، فسيتغيّر معرّف الملف fileId. ثم نرسل إلى الخادم طلبًا نسأله عن حجم البيانات التي وصلت إليه (مقدرًا بالبايت)، بالشكل التالي: let response = await fetch('status', { headers: { 'X-File-Id': fileId } }); // تلقى الخادم هذا العدد من البايتات let startByte = +await response.text(); هذا يفترض قدرة الخادم على تتبع تقدم عملية رفع الملف باستخدام الترويسة X-File-Id، وينبغي إنجاز ذلك في الواجهة الخلفية (من جانب الخادم)، فإذا لم يكن الملف موجودًا بعد على الخادم، فيستجيب الخادم بالرمز 0. يمكننا استخدام التابع slice العائد لكائن البيانات الثنائية Blob لإرسال الملف ابتداءً من البايت الذي سنتابع بعده startByte، بالشكل التالي: xhr.open("POST", "upload", true); // معرّف الملف ليعلم الخادم اسم الملف الذي يُرفع xhr.setRequestHeader('X-File-Id', fileId); // البايت الذي سنستأنف منه الرفع xhr.setRequestHeader('X-Start-Byte', startByte); xhr.upload.onprogress = (e) => { console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`); }; //أو أي مصدر input.files[0] قد يكون مصدر الملف من xhr.send(file.slice(startByte)); وهكذا سنرسل إلى الخادم معرّف الملف عبر الترويسة X-File-Id ليعرف الملف الذي نرفعه، كما سنرسل بايت البداية عبر الترويسة X-Start-Byte ليعرف الخادم بأننا لا نرفع الملف من البداية بل نستأنف عملية رفع سابقة، ولا بدّ أن يتحقق الخادم من سجلاته. فإذا جرت عملية رفع سابقة لهذا الملف وكان حجمه الحالي مساويًا تمامًا لقيمة X-Start-Byte؛ فسيُلحِق البيانات التي يستقبلها تاليًا به. إليك تطبيقًا نموذجيًا يضم شيفرتي الخادم والعميل وقد كتبت باستخدام Node.js، ويمكنك تنزيله وتشغيله محليًا لترى آلية عمله. شيفرة الخادم "server.js": let http = require('http'); let static = require('node-static'); let fileServer = new static.Server('.'); let path = require('path'); let fs = require('fs'); let debug = require('debug')('example:resume-upload'); let uploads = Object.create(null); function onUpload(req, res) { let fileId = req.headers['x-file-id']; let startByte = +req.headers['x-start-byte']; if (!fileId) { res.writeHead(400, "No file id"); res.end(); } // we'll files "nowhere" let filePath = '/dev/null'; // يمكن استخدام مسار حقيقي عوضًا عنه، مثل: // let filePath = path.join('/tmp', fileId); debug("onUpload fileId: ", fileId); // تهيئة عملية رفع جديدة if (!uploads[fileId]) uploads[fileId] = {}; let upload = uploads[fileId]; debug("bytesReceived:" + upload.bytesReceived + " startByte:" + startByte) let fileStream; // صفرًا أو غير مهيئ فسننشئ ملفًا جديدًا، وإلا فسيختبر الحجم ويضمه إلى الملف الموجود startByte إذا كان if (!startByte) { upload.bytesReceived = 0; fileStream = fs.createWriteStream(filePath, { flags: 'w' }); debug("New file created: " + filePath); } else { // we can check on-disk file size as well to be sure if (upload.bytesReceived != startByte) { res.writeHead(400, "Wrong start byte"); res.end(upload.bytesReceived); return; } // ضمه إلى الملف الموجود fileStream = fs.createWriteStream(filePath, { flags: 'a' }); debug("File reopened: " + filePath); } req.on('data', function(data) { debug("bytes received", upload.bytesReceived); upload.bytesReceived += data.length; }); // إرسال جسم الطلب إلى الملف req.pipe(fileStream); // عندما ينتهي الطلب وتكتَب المعلومات كلها fileStream.on('close', function() { if (upload.bytesReceived == req.headers['x-file-size']) { debug("Upload finished"); delete uploads[fileId]; // يمكننا فعل شيء آخر بالملف المرفوع res.end("Success " + upload.bytesReceived); } else { // فقد الاتصال، نترك الملف غير المكتمل debug("File unfinished, stopped at " + upload.bytesReceived); res.end(); } }); // I/O أنهِ الطلب في حال خطأ fileStream.on('error', function(err) { debug("fileStream error"); res.writeHead(500, "File error"); res.end(); }); } function onStatus(req, res) { let fileId = req.headers['x-file-id']; let upload = uploads[fileId]; debug("onStatus fileId:", fileId, " upload:", upload); if (!upload) { res.end("0") } else { res.end(String(upload.bytesReceived)); } } function accept(req, res) { if (req.url == '/status') { onStatus(req, res); } else if (req.url == '/upload' && req.method == 'POST') { onUpload(req, res); } else { fileServer.serve(req, res); } } // ----------------------------------- if (!module.parent) { http.createServer(accept).listen(8080); console.log('Server listening at port 8080'); } else { exports.accept = accept; } شيفرة العميل "uploader.js": class Uploader { constructor({file, onProgress}) { this.file = file; this.onProgress = onProgress; // أنشئ معرّف الملف // يمكننا إضافة معرّف لجلسة المستخدم (عند وجوده ) لجعله فريدًا أكثر this.fileId = file.name + '-' + file.size + '-' + file.lastModified; } async getUploadedBytes() { let response = await fetch('status', { headers: { 'X-File-Id': this.fileId } }); if (response.status != 200) { throw new Error("Can't get uploaded bytes: " + response.statusText); } let text = await response.text(); return +text; } async upload() { this.startByte = await this.getUploadedBytes(); let xhr = this.xhr = new XMLHttpRequest(); xhr.open("POST", "upload", true); // send file id, so that the server knows which file to resume xhr.setRequestHeader('X-File-Id', this.fileId); // send the byte we're resuming from, so the server knows we're resuming xhr.setRequestHeader('X-Start-Byte', this.startByte); xhr.upload.onprogress = (e) => { this.onProgress(this.startByte + e.loaded, this.startByte + e.total); }; console.log("send the file, starting from", this.startByte); xhr.send(this.file.slice(this.startByte)); // return // true if upload was successful, // false if aborted // throw in case of an error return await new Promise((resolve, reject) => { xhr.onload = xhr.onerror = () => { console.log("upload end status:" + xhr.status + " text:" + xhr.statusText); if (xhr.status == 200) { resolve(true); } else { reject(new Error("Upload failed: " + xhr.statusText)); } }; // xhr.abort() فقط عندما يُستدعى onabort يقع xhr.onabort = () => resolve(false); }); } stop() { if (this.xhr) { this.xhr.abort(); } } } الملف "index.js": <!DOCTYPE HTML> <script src="uploader.js"></script> <form name="upload" method="POST" enctype="multipart/form-data" action="/upload"> <input type="file" name="myfile"> <input type="submit" name="submit" value="Upload (Resumes automatically)"> </form> <button onclick="uploader.stop()">Stop upload</button> <div id="log">Progress indication</div> <script> function log(html) { document.getElementById('log').innerHTML = html; console.log(html); } function onProgress(loaded, total) { log("progress " + loaded + ' / ' + total); } let uploader; document.forms.upload.onsubmit = async function(e) { e.preventDefault(); let file = this.elements.myfile.files[0]; if (!file) return; uploader = new Uploader({file, onProgress}); try { let uploaded = await uploader.upload(); if (uploaded) { log('success'); } else { log('stopped'); } } catch(err) { console.error(err); log('error'); } }; </script> وستكون النتيجة بالشكل التالي: تُعَد الأساليب الجديدة لإرسال الطلبات عبر الشبكة أقرب في إمكانياتها إلى إدارة الملفات كما رأينا، مثل التحكم بالترويسات ومؤشرات تقدم العمليات وإرسال الملفات وغير ذلك، وهذا ما مكّننا من تنفيذ شيفرات لاستئناف رفع الملفات وغيرها الكثير. ترجمة -وبتصرف- للفصل Resumable file upload من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا هياكل البيانات: الكائنات والمصفوفات في جافاسكريبت الحياة السرية للكائنات في جافاسكريبت ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت
-
هو كائن مدمَج في المتصفح، يسمح بإرسال طلبات HTTP في جافاسكريبت JavaScript، ويمكنه العمل مع أي نوع من البيانات، وليس فقط بيانات "XML" على الرغم من وجود الكلمة "XML" في تسميته. يساعدنا هذا الكائن في عمليات رفع وتنزيل الملفات وتتبع تقدمها وغير ذلك الكثير، ولا بدّ من الإشارة إلى وجود أسلوب أكثر حداثةً منه وهو استخدام fetch التي ألغت استخدامه بشكل أو بآخر. يُستخدم XMLHttpRequest في تقنيات تطوير الويب العصرية لثلاثة أسباب، هي الآتية: أسباب تاريخية: عند الحاجة إلى دعم سكربت موجود يستخدم XMLHttpRequest. الحاجة إلى دعم المتصفحات القديمة دون استخدام شيفرة "polyfill". إنجاز شيء لا يمكن تنفيذه باستخدام fetch مثل تتبع تقدم رفع ملف. إن كنت تحتاج ما ذكرناه تابع قراءة المقال وإلا فتوجه إلى فصل استخدام Fetch. الأساسيات يعمل الكائن XMLHttpRequest وفق النمطين المتزامن وغير المتزامن، سنتحدث بدايةً عن الوضع غير المتزامن، لأنه يستخدم في أغلب الحالات. يُنجز طلب HTTP وفق 4 خطوات: الخطوة الأولى، إنشاء الكائن XMLHttpRequest: let xhr = new XMLHttpRequest(); ليس للدالة البانية أي وسطاء. الخطوة الثانية، تهيئة الكائن، وتكون بعد إنشائه عادةً: xhr.open(method, URL, [async, user, password]) يؤمن التابع open المعاملات الرئيسية للطلب، وهي: method: تحدد نوع طلب HTTP، عادةً يكون GET أو POST. URL: عنوان الموقع الذي نرسل الطلب إليه، ويمكن استخدام الكائن URL. async: تكون العملية متزامنةً إذا أسندت لها القيمة false صراحةً، وسنغطي ذلك لاحقًا. user وpassword: اسم المستخدم وكلمة المرور للاستيثاق، عند الحاجة. لا يفتح التابع open الاتصال على الرغم من اسمه، بل يهيئ الطلب فقط، ولا يبدأ الاتصال عبر الشبكة إلا باستدعاء send. الخطوة الثالثة، إرسال الطلب: xhr.send([body]) يفتح هذا التابع الاتصال ويرسل الطلب إلى الخادم، حيث يحتوي المعامل body على جسم الطلب.ولا تمتلك الطلبات مثل GET جسماً، وتستخدم طلبات أخرى مثل POST الجسم لإرسال البيانات إلى الخادم، وسنرى أمثلةً عن ذلك لاحقًا. الخطوة الرابعة، الاستماع إلى أحداث الكائن xhr لتلقي الاستجابة، وإليك أكثر الأحداث استخدامًا: load: عندما يكتمل الطلب ونحصل على الاستجابة كاملةً، حتى لو أعاد الخادم رمز الحالة 500 أو 400 مثلًا. error: عندما يتعذر تنفيذ الطلب، كأن تكون الشبكة معطلةً أو العنوان خاطئًا. progress: ويقع دوريًا أثناء تنزيل الاستجابة، ويوضح الكمية التي نُزِّلت. xhr.onload = function() { alert(`Loaded: ${xhr.status} ${xhr.response}`); }; xhr.onerror = function() { // عندما لا تحدث الاستجابة إطلاقًا alert(`Network Error`); }; xhr.onprogress = function(event) { // يقع دوريًا // event.loaded - الحجم الذي نزل بالبايت // event.lengthComputable = true إن أرسل الخادم ترويسة طول المحتوى // event.total - العدد الكلي للبايتات alert(`Received ${event.loaded} of ${event.total}`); }; إليك مثالًا كاملًا، حيث تحمّل الشيفرة الموالية العنوان الآتي article/xmlhttprequest/example/load/ من الخادم، وتطبع تقدم العملية: // 1. إنشاء الكائن let xhr = new XMLHttpRequest(); // 2. URL /article/.../load تهيئته مع العنوان xhr.open('GET', '/article/xmlhttprequest/example/load'); // 3. إرسال الطلب عبر الشبكة xhr.send(); // 4. سيستدعى هذا الجزء عند تلقي الاستجابة xhr.onload = function() { if (xhr.status != 200) { // analyze HTTP status of the response alert(`Error ${xhr.status}: ${xhr.statusText}`); // e.g. 404: Not Found } else { // show the result alert(`Done, got ${xhr.response.length} bytes`); // response is the server response } }; xhr.onprogress = function(event) { if (event.lengthComputable) { alert(`Received ${event.loaded} of ${event.total} bytes`); } else { alert(`Received ${event.loaded} bytes`); // no Content-Length } }; xhr.onerror = function() { alert("Request failed"); }; نستقبل نتيجة الطلب باستخدام خصائص الكائن xhr التالية حالما يستجيب الخادم: status: رمز حالة HTTP، عدد، مثل 200 و404 و403 وهكذا، كما يمكن أن يكون 0 عند حدوث خطأ لا علاقة له بالنقل وفق بروتوكول HTTP. statusText: رسالة حالة HTTP، نص، مثل OK لرمز الحالة 200 وNot Found لرمز الحالة 404، وForbidden لرمز الحالة 403. response: قد تستخدم السكربتات القديمة responseText، وتمثل جسم الاستجابة التي يرسلها الخادم. يمكن أن نحدد أيضًا زمن انتهاء timeout مستخدمين الخاصية الموافقة: xhr.timeout = 10000; // زمن الانتهاء بالميلي ثانية إن لم ينجح الطلب خلال الفترة الزمنية المحددة فسيُلغى وسيقع الحدث timeout. إضافة معاملات بحث إلى العنوان URL let url = new URL('https://google.com/search'); url.searchParams.set('q', 'test me!'); // 'q' ترميز المعامل xhr.open('GET', url); // https://google.com/search?q=test+me%21 نوع الاستجابة من الممكن استخدام الخاصية ResponseType لضبط تنسيق الاستجابة: "": القيمة الافتراضية، الحصول على الاستجابة كنص. "text": الحصول على الاستجابة كنص. "arraybuffer": الحصول على الاستجابة على شكل كائن ArrayBuffer، للحصول على بيانات ثنائية. "blob": الحصول على الاستجابة على شكل كائن Blob، للحصول على بيانات ثنائية. "document": الحصول على مستند XML، باستخدام اللغة XPath أو غيرها. "json": الحصول على الاستجابة بصيغة JSON، تفسر الاستجابة تلقائيًا. لنستقبل الاستجابة بصيغة JSON مثلًا: let xhr = new XMLHttpRequest(); xhr.open('GET', '/article/xmlhttprequest/example/json'); xhr.responseType = 'json'; xhr.send(); // {"message": "Hello, world!"} الاستجابة هي xhr.onload = function() { let responseObj = xhr.response; alert(responseObj.message); // Hello, world! }; حالات الجاهزية Ready States يمر الكائن بعدة حالات عند تقدم تنفيذ الطلب، ويمكن الوصول إلى الحالة من خلال xhr.readyState، وإليك الحالات جميعها كما وردت في التوصيفات: UNSENT = 0; // الحالة الأساسية OPENED = 1; //open استدعاء التابع HEADERS_RECEIVED = 2; // استقبال ترويسة الاستجابة LOADING = 3; // تلقي جسم الاستجابة DONE = 4; // اكتمال الطلب ينتقل الكائن XMLHttpRequest بين الحالات السابقة بالترتيب 0 ثم 1 ثم 2 ثم 3 ثم 4، وتتكرر الحالة 3 في كل مرة تُستقبل فيها حزمة بيانات عبر الشبكة، ويمكننا تتبع هذه الحالات باستخدام الحدث readystatechange. xhr.onreadystatechange = function() { if (xhr.readyState == 3) { // تحميل } if (xhr.readyState == 4) { // انتهاء الطلب } }; قد تجد مستمعات للأحداث readystatechange حتى في الشيفرات القديمة، وذلك لأنّ أحداثًا مثل load وغيرها، لم تكن موجودةً في فترة ما، لكن معالجات الأحداث load/error/progress قد ألغت استخدامها. طلب الإلغاء Aborting request يمكن إلغاء الطلب في أي لحظة، وذلك باستدعاء التابع ()xhr.abort : xhr.abort(); // إلغاء الطلب يحرّض استدعاء التابع الحدث abort وتأخذ الخاصية xhr.status القيمة 0. الطلبات المتزامنة عند إسناد القيمة false إلى المعامل الثالث async للتابع open، فسيُنفَّذ الطلب بتزامن Synchronously. وبعبارة أخرى، ستتوقف JavaScript مؤقتًا عن التنفيذ عند إرسال الطلب ()send ثم تتابع عند تلقي الاستجابة، وهذا يشابه تعليمتي alert وprompt. إليك المثال السابق وقد أعيدت كتابته مع وجود المعامل الثالث للتابع open: let xhr = new XMLHttpRequest(); xhr.open('GET', '/article/xmlhttprequest/hello.txt', false); try { xhr.send(); if (xhr.status != 200) { alert(`Error ${xhr.status}: ${xhr.statusText}`); } else { alert(xhr.response); } } catch(err) { // onerror بدلًا من alert("Request failed"); } قد يبدو الأمر جيدًا لكنها طريقة يندر استخدامها، لأنها تعيق تنفيذ شيفرة JavaScript حتى يكتمل التحميل، وقد يصبح من المستحيل تمرير الصفحة لعرض بقية المحتويات أثناء تنفيذها في بعض المتصفحات، وقد يقترح المتصفح أيضًا إغلاق الصفحة العالقة إذا تأخر تنفيذ الطلب المتزامن كثيرًا. ولا تتاح العديد من الإمكانيات المتقدمة للكائن XMLHttpRequest، مثل الطلب من نطاق آخر أو تحديد زمن الانتهاء من الطلب، عند إرسال الطلبات بتزامن، ولا مؤشرات أيضًا على تقدم العملية، لذلك لا تُنفّذ الطلبات المتزامنة إلا نادرًا. ترويسات HTTP يتيح الكائن XMLHttpRequest إرسال ترويسات مخصصة وقراءة ترويسات الاستجابة، وهنالك ثلاثة توابع للتعامل مع الترويسات: (setRequestHeader(name, value: يعطي اسمًا name وقيمةً value لترويسة الطلب، وإليك مثالًا عن ذلك: xhr.setRequestHeader('Content-Type', 'application/json') فيما يلي مثال لتوضيح ذلك: xhr.setRequestHeader('X-Auth', '123'); xhr.setRequestHeader('X-Auth', '456'); // ستكون الترويسة: // X-Auth: 123, 456 (getResponseHeader(name: تعيد ترويسة الاستجابة ذات الاسم المحدد name، عدا Set-Cookie وSet-Cookie2، إليك مثالًا: xhr.getResponseHeader('Content-Type') ()getAllResponseHeaders: تعيد كل ترويسات الاستجابة، عدا Set-Cookie وSet-Cookie2، وتُعاد القيم ضمن سطر مفرد، حيث سيكون الفاصل هو "\r\n" (لا يتعلق بنظام تشغيل محدد) بين كل ترويستين، وبالتالي يمكن فصلها إلى ترويسات مفردة، وإليك مثالًا: Cache-Control: max-age=31536000 Content-Length: 4260 Content-Type: image/png Date: Sat, 08 Sep 2012 16:53:16 GMT تفصل بين اسم الترويسة وقيمتها النقطتان المتعامدتان، يليها فراغ " :"، وهذا أمر ثابت في التوصيفات، فلو أردنا مثلًا الحصول على كائن له اسم وقيمةP فلا بد من تنفيذ الأمر بالشكل التالي، مفترضين أنه عند وجود ترويستين بنفس الاسم، وعندها ستحل الأخيرة منهما مكان الأولى: let headers = xhr .getAllResponseHeaders() .split('\r\n') .reduce((result, current) => { let [name, value] = current.split(': '); result[name] = value; return result; }, {}); // headers['Content-Type'] = 'image/png' الطلب POST والكائن FormData يمكن استخدام الكائن FormData لإرسال طلب HTTP-POST: إليك الشيفرة اللازمة: let formData = new FormData([form]); //<form> إنشاء كائن يُملأ اختيارًا من formData.append(name, value); // ربط الحقل تنشئ الشيفرة السابقة الكائن FormData وتملؤه بقيم من نموذج form اختياريًا، وتضيف append حقولًا أخرى إن اقتضى الأمر، ومن ثم: نستخدم الأمر (...,'xhr.open('POST: لنؤسس الطلب POST. نستخدم الأمر (xhr.send(formData: لإرسال النموذج إلى الخادم. إليك المثال التالي: <form name="person"> <input name="name" value="John"> <input name="surname" value="Smith"> </form> <script> // form ملؤها من let formData = new FormData(document.forms.person); // إضافة حقل جديد formData.append("middle", "Lee"); // إرساله let xhr = new XMLHttpRequest(); xhr.open("POST", "/article/xmlhttprequest/post/user"); xhr.send(formData); xhr.onload = () => alert(xhr.response); </script> يُرسَل النموذج بترميز multipart/form-data، فإذا أردنا استخدام JSON فلا بدّ من تنفيذ الأمر JSON.stringify ثم إرساله في هيئة نص، إلى جانب ضبط الترويسة Content-Type على القيمة application/json، وتفكِّك العديد من إطارات العمل مع الخادم محتوى JSON تلقائيًا بهذه الطريقة. let xhr = new XMLHttpRequest(); let json = JSON.stringify({ name: "John", surname: "Smith" }); xhr.open("POST", '/submit') xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); xhr.send(json); ويمكن للتابع أن يُرسل أي جسم للطلب بما في ذلك كائنات Blob وBufferSource. تقدم عمليات رفع البيانات يقع الحدث progress في مرحلة التنزيل فقط، فلو نشرنا شيئًا ما باستخدام الطلب POST، فسيرفع الكائن XMLHttpRequest البيانات -جسم الطلب- أولًا ومن ثم ينزل الاستجابة. وسيكون تتبع تقدم عملية رفع البيانات خاصةً إن كانت ضخمة؛ أمرًا هامًا، لكن لن نستفيد من الحدث xhr.onprogress في حالتنا، يوجد كائن آخر لا يمتلك توابعًا، وهو مخصص حصرًا لتتبع أحداث رفع البيانات وهو xhr.upload، الذي يولِّد أحداثًا كما يفعلxhr، لكنها تقع فقط عند رفع البيانات: loadstart: يقع عندما يبدأ رفع البيانات. progress: يقع دوريًا مع تقدم الرفع. abort: يقع عند إلغاء الرفع. error: يقع عند وقوع خطأ لا يتعلق بالبروتوكول HTTP. load: يقع عند نجاح عملية الرفع. timeout: يقع عند انتهاء الوقت المخصص لرفع البيانات، إذا ضُبطت الخاصية timeout. loadend: يقع عند انتهاء الرفع بنجاح أو بإخفاق. إليك أمثلةً عن معالجات هذه الأحداث: xhr.upload.onprogress = function(event) { alert(`Uploaded ${event.loaded} of ${event.total} bytes`); }; xhr.upload.onload = function() { alert(`Upload finished successfully.`); }; xhr.upload.onerror = function() { alert(`Error during the upload: ${xhr.status}`); }; إليك أيضًا مثالًا واقعيًا عن رفع ملف مع مؤشرات على تقدم العملية: <input type="file" onchange="upload(this.files[0])"> <script> function upload(file) { let xhr = new XMLHttpRequest(); // تعقب تقدم عملية الرفع xhr.upload.onprogress = function(event) { console.log(`Uploaded ${event.loaded} of ${event.total}`); }; // تعقب الانتهاء، بنجاح أو إخفاق xhr.onloadend = function() { if (xhr.status == 200) { console.log("success"); } else { console.log("error " + this.status); } }; xhr.open("POST", "/article/xmlhttprequest/post/upload"); xhr.send(file); } </script> الطلبات ذات الأصول المختلطة يمكن للكائن XMLHttpRequest تنفيذ طلبات الأصل المختلط مستخدمًا سياسة CORS، تمامًا كما يفعل fetch، ولن يُرسل ملفات تعريف الارتباط cookies أو معلومات استيثاق إلى مواقع ذات أصل مختلف افتراضيًا. ولتمكين ذلك لا بدّ من ضبط الخاصية xhr.withCredentials على القيمة true. let xhr = new XMLHttpRequest(); xhr.withCredentials = true; xhr.open('POST', 'http://anywhere.com/request'); ... اطلع على فصل "استخدام Fetch مع الطلبات ذات الأصل المختلط" لمعلومات أكثر. خلاصة تمثل الشيفرة التالية، الشيفرة النموذجية لطلب GET باستخدام XMLHttpRequest let xhr = new XMLHttpRequest(); xhr.open('GET', '/my/url'); xhr.send(); xhr.onload = function() { if (xhr.status != 200) { // HTTP error? // معالجة الخطأ alert( 'Error: ' + xhr.status); return; } // xhr.response الحصول على الاستجابة من }; xhr.onprogress = function(event) { // إعطاء تقرير عن التقدم alert(`Loaded ${event.loaded} of ${event.total}`); }; xhr.onerror = function() { // handle non-HTTP error (e.g. network down) }; هنالك أحداث أكثر في التوصيفات الحديثة، والتي وُضِعت في قائمة مرتبة وفق دورة حياة كل حدث: loadstart: عندما يبدأ الطلب. progress: عند وصول حزمة بيانات، وتكون الاستجابة الكاملة في هذه اللحظة ضمن الاستجابة response. ()abort: عند إلغاء الطلب باستدعاء التابع. error: عند وقوع خطأ في الاتصال، مثل اسم نطاق خاطئ ولم يحدث نتيجة خطأ HTTP مثل 404. load: عند انتهاء الطلب بنجاح. timeout: عند إلغاء الطلب نتيجة تجاوز الوقت المخصص، ويحدث عندما تُضبَط هذه الخاصية. loadend: ويقع بعد الأحداث load أو error أو timeout أو abort، وهذه الأحداث متنافية فيما بينها، أي لا يمكن وقوع سوى حدث واحد منها. إن أكثر الأحداث استخدامًا هما حدث إكمال التحميل load وحدث إخفاق التحميل error، كما يمكن استعمال معالج الحدث loadend والتحقق من خصائص الكائن xhr للتأكد من طبيعة الحدث الذي وقع. لقد تعرفنا في هذا المقال أيضًا على الحدث readystatechange، والذي ظهر منذ زمن بعيد قبل استقرار التوصيفات، ولا حاجة حاليًا لاستخدامه، حيث يُستبدل بالأحداث الأكثر عصريةً. لكن مع ذلك قد نصادفه في بعض السكربتات القديمة. إذا أردنا تعقب تقدم رفع البيانات، فلا بدّ من الاستماع إلى نفس أحداث التنزيل لكن باستخدام الكائن xhr.upload. ترجمة -وبتصرف- للفصل XMLHttpRequest من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: كائنات URL في جافاسكريبت ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت فحص الأصناف عبر instanceof في جافاسكربت هياكل البيانات: الكائنات والمصفوفات في جافاسكريبت الحياة السرية للكائنات في جافاسكريبت
-
يقدم الصنف URL المدمج واجهةً ملائمةً لإنشاء عناوين الموارد وروابط URL وتفسيرها، لا تحتاج الطلبات عبر الشبكة إلى هذا الكائن بالتحديد، فالقيم النصية التي يمكن أن تعبّر عن العناوين كافية، وبالتالي لن نحتاج إليه تقنيًا، لكننا سنجد أن استخدامه مفيد في مناسبات عدة. إنشاء رابط URL إليك الصيغة البرمجية التي تُنشئ كائن URL جديدًا: new URL(url, [base]) url: عنوان URL الكامل، أو جزء منه عند إسناد قيمة إلى base. base: أساس اختياري للعنوان، فإذا أسندت قيمة لهذا الوسيط وكانت قيمة الوسيط الآخر url هي مسار فقط، فسيُولّد الكائن URL منسوبًا إلى القاعدة base. إليك مثالًا: let url = new URL('https://javascript.info/profile/admin'); لاحظ أن كائني URL التاليين متطابقين تمامًا: let url1 = new URL('https://javascript.info/profile/admin'); let url2 = new URL('/profile/admin', 'https://javascript.info'); alert(url1); // https://javascript.info/profile/admin alert(url2); // https://javascript.info/profile/admin يمكن بالطبع إنشاء كائن URL جديد مبني على مسار نسبي أساسه كائن URL موجود مسبقًا: let url = new URL('https://javascript.info/profile/admin'); let newUrl = new URL('tester', url); alert(newUrl); // https://javascript.info/profile/tester نستطيع الوصول إلى مكوّنات الكائن URL مباشرةً، وبالتالي سيقدم طريقةً أنيقةً لتفسير العناوين: let url = new URL('https://javascript.info/url'); alert(url.protocol); // https: alert(url.host); // javascript.info alert(url.pathname); // /url إليك لائحةً بمكوّنات URL: href: ويعيد العنوان كاملًا، تمامًا كما يفعل التابع ()url.toString. protocol: جزء من العنوان ينتهي بالنقطتين ":". search: سلسلة نصية من المعاملات يبدأ بإشارة الاستفهام "؟". hash: ويبدأ بالعلامة "#". كما يمكنك أن تجد الخاصيتين user وpassword عند استخدام استيثاق HTTP، مثل http://login:password@site.com، لكنه نادر الاستخدام. معامل البحث "?" لنفترض أننا سننشئ عنوان url له معاملات بحث محددة، مثل https://google.com/search?query=JavaScript، كما يمكننا وضع المعاملات عند إنشاء كائن URL: new URL('https://google.com/search?query=JavaScript') ويجب ترميز المعاملات إذا احتوت على فراغات أو أحرف ليست لاتينيةً وما شابه (ستجد المزيد عن ذلك في الفقرات التالية)، لذلك توجد خاصية تتولى ذلك هي url.searchParams، وهي كائن من النوع URLSearchParams، وتؤمن مجموعةً من التوابع التي تتعامل مع معاملات البحث: (append(name, value: يضيف المعامل المحدد بالاسم name. (delete(name: يحذف المعامل المحدد بالاسم name. (get(name: يحضر المعامل المحدد بالاسم name. (getAll(name: يحضر كل المعاملات التي لها نفس الاسم name، وهو أمر ممكن، مثل ?user=John&user=Pete. (has(name: التحقق من وجود معامل بالاسم name. (set(name, value: لضبط معامل أو تغييره. ()sort: فرز المعاملات بالاسم، وتُستخدّم نادرًا، وهي قابلة للمرور عليها Iterable بصورة مشابهة للترابط Map. let url = new URL('https://google.com/search'); url.searchParams.set('q', 'test me!'); //! يضيف معاملًا ضمنه فراغ وإشارة alert(url); // https://google.com/search?q=test+me%21 url.searchParams.set('tbs', 'qdr:y'); // ":" يضيف معاملًا يحوي العلامة // تُرمز المعاملات تلقائيًا alert(url); // https://google.com/search?q=test+me%21&tbs=qdr%3Ay //(المرور على المعاملات (فك ترميز for(let [name, value] of url.searchParams) { alert(`${name}=${value}`); // q=test me!, then tbs=qdr:y } الترميز Encoding يحدد المعيار RFC3986 المحارف المسموحة في العناوين، والمحارف التي لا يُسمح باستخدامها. وينبغي ترميز المحارف التي لا يسمح بها، مثل الأحرف غير اللاتينية والفراغات، باستبدالها بمقابلاتها في ترميز UTF-8 مسبوقًا بالمحرف "%"، كأن نكتب 20%. ويمكن ترميز الفراغ بالمحرف "+" لأسباب تاريخية -وهذه حالة استثنائية-، لكن الجيد بالأمر هو أنّ الكائن URL ينجز كل ذلك تلقائيًا، وكل ما علينا فعله هو تزويده بالمعاملات دون ترميز، وسيحول العنوان إلى نص: // using some cyrillic characters for this example let url = new URL('https://ru.wikipedia.org/wiki/Тест'); url.searchParams.set('key', 'ъ'); alert(url); //https://ru.wikipedia.org/wiki/%D0%A2%D0%B5%D1%81%D1%82?key=%D1%8A لاحظ أن القيمتين Тест في مسار العنوان وъ في المعامل قد رُمِّزا، سيغدو العنوان أطول لأن كل محرف سيُمثّل ببايتين في UTF-8، وبالتالي ستكون هناك كتلتان من الشكل ..% لكل محرف. ترميز القيم النصية استخدم المبرمجون -في السابق وقبل ظهور الكائن URL- القيم النصية لتمثيل العناوين، لكن استخدام الكائن URL حاليًا أكثر ملاءمةً، ومع ذلك لا يزال استخدام القيم النصية شائعًا، فهو يجعل العنوان أقصر في الكثير من الأحيان، ولا بدّ عند استخدام القيم النصية من ترميز أو فك ترميز المحارف الخاصة يدويًا، باستخدام دوال مدمجة هي: encodeURI: يُرمِّز العنوان بالكامل. decodeURI: يفك ترميز النص المرمَّز. encodeURIComponent: يرمّز مكوّنًا من مكونات العنوان، مثل معاملات البحث أو المسار. decodeURIComponent: يفك ترميز الجزء المُرمَز. لكن السؤال الطبيعي سيكون: "ما هو الفرق بين encodeURIComponent وencodeURI؟ ومتى سنستخدم كلًا منهما؟" من السهل استيعاب الفكرة عند النظر إلى الصورة السابقة التي تفصل العنوان التالي إلى مكوناته: https://site.com:8080/path/page?p1=v1&p2=v2#hash إذ يُسمح باستخدام المحارف التالية : و? و= و& و# في عنوان URL، ومن جهة أخرى إذا نظرنا إلى أي مكوّن من مكوّنات العنوان بمفرده، مثل: المعاملات، فلا بدّ من ترميز هذه المحارف حتى لا تخِلّ بتنسيق العنوان. وهنا سنرى استخدام الدالتين السابقتين: encodeURI: تُرمز المحارف المرفوضة كليًا في العناوين فقط. encodeURIComponent: ترمز نفس المحارف التي ترمزها الدالة السابقة بالإضافة إلى المحارف التالية: # و$ و& و+ و, و/ و: و; و= و? و@. لذلك يمكن استخدام الدالة encodeURI لترميز العنوان كاملًا: // using cyrillic characters in url path let url = encodeURI('http://site.com/привет'); alert(url); // http://site.com/%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82 بينما ستستخدم الدالة encodeURIComponent في ترميز معاملات العنوان: let music = encodeURIComponent('Rock&Roll'); let url = `https://google.com/search?q=${music}`; alert(url); // https://google.com/search?q=Rock%26Roll لاحظ الفرق عند استخدام encodeURI: let music = encodeURI('Rock&Roll'); let url = `https://google.com/search?q=${music}`; alert(url); // https://google.com/search?q=Rock&Roll حيث لا تُرمّز الدالة encodeURI المحرف & لأنه محرف مقبول في عنوان URL الكامل، لكن لا بدّ من ترميزه عندما يكون ضمن معامل البحث، وإلا ستكون نتيجة q=Rock&Roll هي q=Rock، بالإضافة إلى معامل غامض يمثله Roll، وهي ليست كما قصدنا. لذا لا بدّ من استخدام encodeURIComponent فقط عند تشفير كل معامل من معاملات البحث، لكي نضعه بشكله الصحيح في نص العنوان، وتبقى طريقة ترميز الاسم والعنوان معًا هي الأكثر أمانًا؛ إلا عندما نثق تمامًا بعدم احتواء أي منهما على محارف ممنوعة الاستخدام. // valid url with IPv6 address let url = 'http://[2607:f8b0:4005:802::1007]/'; alert(encodeURI(url)); // http://%5B2607:f8b0:4005:802::1007%5D/ alert(new URL(url)); // http://[2607:f8b0:4005:802::1007]/ ترجمة -وبتصرف- للفصل URL Objects من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: الواجهة البرمجية Fetch API ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت
-
لقد أخذنا فكرةً لا بأس بها عن fetch في المقالات السابقة من هذه السلسلة (بدءًا من مقال إرسال البيانات واستلامها عبر الشبكة وحتى مقال استخدام Fetch مع الطلبات ذات الأصل المختلط Cross-Origin)، والآن لنلق نظرةً على بقية مكوّنات الواجهة البرمجية لنغطي كل إمكاناتها. إليك قائمةً كاملةً بكل خيارت fetch الممكنة مع قيمها الافتراضية (وضعنا البدائل في تعليقات): let promise = fetch(url, { method: "GET", // POST, PUT, DELETE, etc. headers: { // the content type header value is usually auto-set // depending on the request body "Content-Type": "text/plain;charset=UTF-8" }, body: undefined // string, FormData, Blob, BufferSource, or URLSearchParams referrer: "about:client", // or "" to send no Referer header, // or an url from the current origin referrerPolicy: "no-referrer-when-downgrade", // no-referrer, origin, same-origin... mode: "cors", // same-origin, no-cors credentials: "same-origin", // omit, include cache: "default", // no-store, reload, no-cache, force-cache, or only-if-cached redirect: "follow", // manual, error integrity: "", // a hash, like "sha256-abcdef1234567890" keepalive: false, // true signal: undefined, // AbortController to abort request window: window // null }); لقد غطينا المفاهيم method وheaders وbody في مقال استخدام Fetch، كما غطينا signal في مقال إيقاف تنفيذ Fetch، وسنتعرف الآن على بقية الإمكانات. خيارا المحيل referrer وسياسة المحيل referrerPolicy يتحكم هذان الخياران بكيفية ضبط fetch للترويسة Referrer، وهي إحدى ترويسات HTTP، وتُضبط تلقائيًا لتحتوي على عنوان الصفحة التي ولّدت الطلب، ولا تعَد هامةً في معظم الأحيان، وقد يكون من المنطقي أحيانًا إزالتها أو تقصيرها لأسباب تتعلق بالأمان. يسمح الخيار referrer بتسمية أي مُحيل، وهو الصفحة أو الرابط الذي أحالك إلى الصفحة الحالية التي تعمل عليها، أو إزالته على أن يشترك بالأصل مع الصفحة الحالية، وإذا لم ترغب بإرسال أي محيل فأسند إليه نصًا فارغًا: fetch('/page', { referrer: "" // لا توجد توريسة محيل }); ولوضع عنوان مورد آخر من الأصل ذاته: fetch('/page', { // https://javascript.info بفرض أننا في // نستطيع ضبط أي ترويسة محيل، لكن ضمن الأصل الحالي referrer: "https://javascript.info/anotherpage" }); يضبط الخيار referrerPolicy بعض القواعد العامة للمُحيل Referer. تنقسم الطلبات إلى ثلاث مجموعات، هي الآتية: الطلب إلى مورد من الأصل ذاته. الطلب إلى مورد من أصل مختلف. الطلب من بروتوكول HTTPS إلى بروتوكول HTTP: أي من بروتوكول النقل الآمن إلى غير الآمن. يدل الخيار referrerPolicy المتصفح على القواعد الخاصة باستخدام المُحيل في كل مجموعة من الطلبات، ولا يسمح بضبط القيمة الدقيقة للمحيل. ستجد جميع القيم الممكنة في توصيف سياسة المحيل: no-referrer-when-downgrade: قيمتها الافتراضية "full"، حيث تُرسل قيمة المحُيل دومًا عدا الحالة التي يُرسَل فيها الطلب من HTTPS إلى HTTP (إلى بروتوكول أقل أمانًا). no-referrer: لا يُرسَل المُحيل. origin: يُرسل الأصل فقط ضمن المُحيل وليس عنوان الصفحة المُحيلة الكامل، أي يُرسل العنوان على الشكل http://site.com وليس على الشكل http://site.com/path. origin-when-cross-origin: يُرسل العنوان الكامل للمحيل إلى المواقع ذات الأصل المشترك، بينما يُرسَل الأصل فقط إلى المواقع ذات الأصل المختلط. same-origin: يُرسل المُحيل كاملًا إلى المواقع التي تنتمي إلى نفس الأصل، ولايرُسل أبدًا إلى المواقع ذات الأصول المختلطة. strict-origin: يُرسل الأصل فقط وليس المُحيل كاملًا في الطلبات من HTTPS إلى HTTP. strict-origin-when-cross-origin: يُرسل المُحيل كاملًا إلى المواقع التي تنتمي إلى نفس الأصل، ويُرسل الأصل فقط إلى المواقع ذات الأصول المختلطة، عدا الحالة التي يُرسَل فيها الطلب من HTTPS إلى HTTP، فلا يُرسل شيء. unsafe-url: يُرسل عنوان المُحيل كاملًا، حتى في الحالة التي يُرسَل فيها الطلب من HTTPS إلى HTTP. يوضح الجدول التالي جميع الخيارات: 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; } القيمة إلى نفس الأصل إلى أصل مختلف HTTPS→HTTP "no-referrer" - - - no-referrer-when-downgrade أو "" وهي القيمة الافتراضية كاملًا كاملًا - "origin" الأصل الأصل الأصل "origin-when-cross-origin" كاملًا الأصل الأصل "same-origin" كاملًا - - "strict-origin" الأصل الأصل - "strict-origin-when-cross-origin" كاملًا الأصل - "unsafe-url" كاملًا كاملًا كاملًا لنفترض وجود صفحة بصلاحيات مدير، ولا ينبغي كشف عنوانها خارج نطاق الموقع، لذا فعند إرسال fetch، فسترسَل الترويسة Referer افتراضيًا مع عنوان صفحتنا كاملًا، عدا الحالة التي يُرسَل فيها الطلب من HTTPS إلى HTTP. حيث لا توجد أي ترويسة Referer، فإذا كان العنوان هو Referer: https://javascript.info/admin/secret/paths مثلًا، وأردنا إرسال الأصل فقط وليس العنوان الكامل، فيمكن أن نرسل الخيار التالي: fetch('https://another.com/page', { // ... referrerPolicy: "origin-when-cross-origin" // Referer: https://javascript.info }); يمكن وضع الخيار السابق لكل استدعاءات fetch، كما يمكن أيضًا دمجه في مكتبة JavaScript التي نستخدمها في مشروعنا، والتي تنفّذ كل الطلبات التي تستخدم fetch، ويقتصر الفرق الوحيد بينه وبين الخيار الافتراضي في أنه يرسِل الجزء الأصلي من عنوان الموقع المُحيل، مثلًا: https://javascript.info ولا يُرسل المسار الكلي، وسنحصل على العنوان الكامل في الطلبات المُرسَلة إلى مواقع من نفس الأصل، فربما تكون مفيدةً لأغراض التنقيح. الخيار mode ويمثل هذا الخيار الحارس الذي يمنع الطلبات ذات الأصل المختلط التي تحدث فجأةً. cors: وهي القيمة الافتراضية، وتسمح بالطلبات ذات الأصل المختلط كما ورد في فصل استخدام Fetch في الطلبات ذات الأصل المختلط. same-origin: يمنع استخدام الطلبات ذات الأصل المختلط. no-cors: يسمح فقط لطلبات الأصل المختلط الآمنة. قد تظهر أهمية هذا الخيار عندما يأتي العنوان القادم مع fetch من طرف ثالث، ونريد آليةً للحد من الإمكانات المسموحة للأصول المختلطة. الخيار credentials ويحدد ما إذا كان على fetch إرسال ملفات تعريف الارتباط cookies، وترويسات استيثاق HTTP مع الطلب. same-origin: وهي القيمة الافتراضية، لا تُرسل الثبوتيات مع الطلبات ذات الأصول المختلطة. include: تُرسل الثبوتيات دومًا، ونحتاج إلى الترويسة Access-Control-Allow-Credentials من الخادم ذي الأصل المختلط لتتمكن جافا سكربت من الوصول إلى الاستجابة، وقد شرحنا ذلك في فصل استخدام Fetch في الطلبات ذات الأصل المختلط. omit: لا تُرسل الثبوتيات أبدًا، حتى للطلبات من الأصل نفسه. الخيار cache تستخدم طلبات fetch افتراضيًا ذاكرة HTTP المؤقتة المعيارية HTTP-cache، فهي تحترم الترويستين Expires وCache-Control، وترسل الترويسة If-Modified-Since تمامًا كما تفعله طلبات HTTP النظامية. يسمح الخيار cache بتجاهل "HTTP-cache" أو يضبط استخدامه: default: تستخدم fetch ترويسات وقواعد "HTTP-cache" المعيارية. no-store: يتجاهل الطلب قواعد "HTTP-cache" كليًا، وتصبح هذه القيمة افتراضيةً عند إرسال إحدى الترويسات التالية: If-Modified-Since أو If-None-Match أو If-Unmodified-Since أو If-Match أو If-Range. reload: لا يأخذ النتيجة من "HTTP-cache" -إن وجدت-، بل ينشر محتويات الذاكرة المؤقتة مع الاستجابة، إذا سمحت ترويسات الاستجابة بذلك. no-cache: يُنشئ طلبًا شرطيًا عند وجود استجابة مخزنة في الذاكرة المؤقتة، وطلبًا عاديًا في غير تلك الحالة، وينشر "HTTP-cache" مع الاستجابة. force-cache: يستخدم الاستجابة الموجودة في "HTTP-cache" حتى لو كانت قديمة، وسينشئ طلب HTTP نظاميًا إذا لم تحتوي على استجابة، كما سيسلك الطلب السلوك الطبيعي. only-if-cached: يستخدم الاستجابة الموجودة في "HTTP-cache" حتى لو كانت قديمةً، وسيرمي خطأً إذا لم تحتوي على استجابة، وتعمل فقط مع القيمة same-origin للخيار mode. الخيار redirect تخضع fetch بكل شفافية لإعادة التوجيه "HTTP-redirect" مثل الحالتين 301 (النقل النهائي لمحتوى) و302 (موجود ولكن يفضل الانتقال إلى العنوان الجديد). follow: وهي القيمة الافتراضية، ويخضع الطلب عندها لحالات إعادة التوجيه. error: يرمي خطأً عند محاولة إعادة توجيه الطلب. manual: يسمح بالتعامل مع إعادة توجيه الطلب يدويًا، وسنحصل عندها على كائن استجابة خاص من النوع "response.type="opaqueredirect، وتكون قيمة خاصية الحالة response.status صفرًا، وكذلك قيم أغلب خصائصه. الخيار integrity يسمح هذا الخيار بالتحقق من مطابقة الاستجابة للقيم الاختبارية Checksum المحددة مسبقًا، كما هو محدد في التوصيفات. وتُدعم دوال "hash" التالية: SHA-256 وSHA-384 وSHA-512، كما قد تتطلب بعض الاعتماديات وفقًا للمتصفح، فإذا كنا بصدد تنزيل ملف مثلًا، ونعلم أنّ القيمة الاختبارية له وفق SHA-256 هي "abcdef"، والتي ستكون أطول في الواقع، فيمكننا وضعها قيمةً للخيار integrity بالشكل التالي: fetch('http://site.com/file', { integrity: 'sha256-abcdef' }); ستحسب fetch قيمة SHA-256 بنفسها وتوازنها مع القيمة التي وضعناها، وسترمي خطأً عند عدم تطابق القيمتين. الخيار keepalive يسمح هذا الخيار ببقاء الطلب فعالًا خارج الصفحة التي أنشأتها. لنفترض مثلًا أننا نجمع إحصائيات عن سلوك المستخدم الحالي لصفحتنا (عدد نقرات الفأرة وأجزاء الصفحة التي زارها) لتحليل تجربة المستخدم وتطويرها، ونرغب بحفظ البيانات على الخادم عندما يغادر هذا المستخدم الصفحة، حيث يمكن أن ننفذ ذلك باستخدام الحدث window.onunload بالشكل التالي: window.onunload = function() { fetch('/analytics', { method: 'POST', body: "statistics", keepalive: true }); }; لكن ستُغلق كل طلبات الشبكة المتعلقة بالمستند عند إزالته، وهنا تظهر أهمية الخيار keepalive الذي يخبر المتصفح بإبقاء الطلبات حيةً في الخلفية حتى بعد أن يغادر الزائر الصفحة، لأن هذا الخيار أساسي لاستمرار الطلب ونجاحه. لكن بالطبع هناك بعض التقييدات في استخدامه، والمتمثلة في الآتي: لا يمكن إرسال أحجام بالميجابايت: لأن الحد الأعلى لحجم جسم الطلب مع خيار keepalive هو 64 كيلوبابت. إذا أردنا جمع إحصائيات كثيرةً عن الزائر، فلا بدّ من إرسالها بانتظام ضمن حزم متتالية، لكي لا تبقى الكثير من المعلومات التي لم ترسل بعد عند تنفيذ الطلب الأخير مع الحدث onunload. تطبق هذه التقييدات على كل الطلبات التي تحمل الخيار keepalive معًا، أي يمكن تنفيذ عدة طلبات من هذا النوع في الوقت نفسه، لكن يجب ألا يتجاوز مجموع أحجام أجسام هذه الطلبات حد 64 كيلوبايت. لا يمكن التعامل مع استجابة الخادم عند إزالة المستند، لذا سينجح استخدام fetch في مثالنا بوجود keepalive، لكن بالطبع لن تُنفَّذ الدوال اللاحقة. لن تظهر المشاكل في أغلب الأحيان عند إرسال بيانات مثل الإحصائيات، لأنّ الخادم سيقبل هذه البيانات وسيعيد غالبًا استجابةً فارغةً لطلبات مثل هذه. ترجمة -وبتصرف- للفصل Fetch: API من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: استخدام Fetch مع الطلبات ذات الأصل المختلط في جافاسكريبت
-
من المحتمل أن يخفق الطلب fetch المرسَل إلى موقع ويب آخر، مثلًا: ستخفق محاولة الحصول على http://example.com: try { await fetch('http://example.com'); } catch(err) { alert(err); // Failed to fetch } لا بدّ من الإشارة أولًا إلى المفاهيم البنيوية لموضوعنا: الأصل origin: وهو الثلاثية (نطاق ومنفذ وبروتوكول). الطلبات ذات الأصل المختلط cross-origin requests: وهي الطلبات المرسَلة إلى نطاق (أو نطاق فرعي) آخر أو عبر منفذ آخر أو باستخدام بروتوكول آخر، وتتطلب ترويسات خاصةً من الجانب البعيد. تدعى هذه السياسة "CROS" وهو اختصار للعبارة "Cross-Origin Resource Sharing"، وتعني مشاركة الموارد ذات الأصول المختلطة. لماذا نحتاج إلى CROS؟ لمحة تاريخية موجزة وُجِدت هذه السياسة لحماية الإنترنت من المخترقين، فلسنوات عديدة لم يكن مسموحًا لسكربت من موقع ما أن يصل إلى محتوى موقع آخر، حيث لا يمكن لسكربت مشبوه من الموقع hacker.com مثلًا الوصول إلى صندوق البريد الإلكتروني لمستخدم على الموقع gmail.com، مما أشعَر مستخدمي الإنترنت في ذلك الوقت بالأمان لعدم امتلاك جافاسكريبت JavaScript، أي توابع خاصة لتنفيذ طلبات عبر الشبكة، فقد كانت عبارةً عن لغة للتسلية وتزيين صفحات الويب، إلا أن مطوري الويب احتاجوا سريعًا إلى قوة أكبر للتحكم بالصفحات، فاخترعوا أساليب متنوعةً للالتفاف على محدودية اللغة وإرسال الطلبات إلى مواقع أخرى. استخدام النماذج لقد كانت إحدى طرق التواصل مع خادم آخر هي إرسال نموذج <form> إليه، وذلك باستخدام الإطارات <iframe> لإبقاء الزوار ضمن الصفحة نفسها: <!-- form target --> <iframe name="iframe"></iframe> <!-- a form could be dynamically generated and submited by JavaScript --> <form target="iframe" method="POST" action="http://another.com/…"> ... </form> وهكذا تمكن الناس من إرسال طلبات GET/POST إلى مواقع أخرى دون وجود توابع لتنفيذ ذلك، لأنّ النماذج قادرة على إرسال البيانات إلى أي مكان، لكن لم يكن بالإمكان الحصول على الاستجابة لأنّ الوصول إلى محتويات الإطار <iframe> غير مسموح، ولنكون دقيقين؛ وُجِدت بعض الحيل للالتفاف على ذلك أيضًا، لكنها تطلبت سكربتًا خاصًا يوضع ضمن الإطار والصفحة، أي صار التواصل بينهما ممكنًا من الناحية التقنية. استخدام السكربتات اعتمدت إحدى الحيل المستخدَمة أيضًا على المعرّف <script>، إذ يمكن أن تكون قيمة الخاصية src لسكربت هي اسم أي نطاق أو موقع مثل: <script src="http://another.com/…"> وبالتالي يمكن تنفيذ سكربت أيًا كان مصدره، فإذا أراد موقع ما مثل another.com إتاحة أمكانية الوصول لبياناته، فسيستخدم البروتوكول "JSON with padding" واختصاره JSNOP، وإليك آلية عمله: لنفترض أننا نريد الوصول إلى بيانات الطقس على الموقع http://another.com إنطلاقًا من موقعنا: نعرّف في البداية دالةً عامةً لاستقبال البيانات ولتكن gotWeather. // صرح عن الدالة التي ستعالج البيانات المطلوبة function gotWeather({ temperature, humidity }) { alert(`temperature: ${temperature}, humidity: ${humidity}`); } ننشئ <script>، وتكون قيمة الخاصية src فيه هي src="http://another.com/weather.json?callback=gotWeather" وللمستخدمين اسم الدالة العامة كقيمة للمعامل callback الخاص بالعنوان URL. let script = document.createElement('script'); script.src = `http://another.com/weather.json?callback=gotWeather`; document.body.append(script); يوّلد الخادم البعيد another.com ديناميكيًا سكربتًا يستدعي الدالة ()gotWeatherبالبيانات التي يريدنا أن نحصل عليها. // ستبدو الاستجابة التي نتوقعها من الخادم كالتالي gotWeather({ temperature: 25, humidity: 78 }); عندما يُحمَّل السكربت الذي يولده الخادم ويُنفَّذ، ستُنفَّذ الدالة gotWeather ونحصل على البيانات. سيعمل الأسلوب السابق ولن يشكل خرقًا لأمن الموقع البعيد بسبب اتفاق كلا الطرفين على تبادل المعلومات بهذا الشكل، ولهذا لن تُعَدَّ العملية عندها اختراقًا، ولا زالت بعض الخدمات تتبع نفس الأسلوب في الوصول إلى البيانات البعيدة وتعمل حتى على المتصفحات القديمة جدًا. ظهرت بعد فترة من الزمن ضمن لغة JavaScript توابع الطلبات عبر الشبكة والتي ينفذها المتصفح، وقد رفضت الطلبات ذات الأصول المختلطة في البداية، إلا أنه سُمح باستخدامها نتيجة نقاشات طويلة، بشرط الحصول على سماحيات صريحة من الخادم لتنفيذ أي متطلبات، ويُعبَّر عنها من خلال ترويسات خاصة. الطلبات الآمنة هنالك نوعان من الطلبات ذات الأصل المختلط: الطلبات الآمنة safe requests. بقية الأنواع. من السهل إنشاء الطلبات الآمنة لذلك سنبدأ بها، إذ يُعَد الطلب آمنًا إذا حقق الشرطين التاليين: يستخدم نوعًا آمنًا مثل GET أو POST أو HEAD. يستخدم ترويسات آمنةً. ويسمح بالترويسات المخصصة التالية فقط: Accept. Accept-Language. Content-Language. Content-Type: بحيث تحمل إحدى القيم التالية application/x-www-form-urlencoded أو multipart/form-data أو text/plain. وتُعَد بقية الطلبات "غير آمنة"، حيث لا تطابق الطلبات باستخدام PUT أو باستخدام الترويسة API-Key معايير الأمان السابقة مثلًا. ويكمن الفرق الجوهري في إمكانية تنفيذ الطلبات الآمنة باستخدام معرِّف النموذج <form> أو معرّف السكربت <script> دون الحاجة لأي توابع خاصة، وبالتالي ستكون أقدم الخوادم قادرةً على استقبالها. لا يمكن في المقابل استخدام الطلبات التي لا تمتلك ترويسات معياريةً مثل DELETE بهذه الطريقة، ولفترة طويلة لم تكن JavaScript قادرةً على استخدام هذا النوع من الطلبات، وهكذا سيفترض الخادم القديم أن هذه الطلبات قادمة من مصدر مخوّل بذلك، لأنه يتوقع أنّ صفحة الويب غير قادرة على إرسال هذه الطلبات. عندما نرسل طلبًا غير آمن، فسيرسل المتصفح طلبًا تمهيديَا preflight، سيسأل الخادم فيه إن كان سيوافق على طلبات ذات أصول مختلطةً أم لا، فإن لم يؤكد الخادم ذلك صراحةً من خلال الترويسات، فلن يُرسَل الطلب غير الآمن. سياسة CROS للطلبات غير الآمنة سيضيف المتصفح دائمًا الترويسة Origin إلى الطلب من الأصول المختلطة، فإذا طلبنا المورد https://anywhere.com/request من الموقع https://javascript.info/page مثلًا؛ فستبدو الترويسات بالشكل التالي: GET /request Host: anywhere.com Origin: https://javascript.info ... تحتوي الترويسة origin كما نرى الأصل (نطاق وبروتوكول ومنفذ) كما هو لكن دون مسار، ويمكن للخادم أن يتحقق من الترويسة Origin، فإن وافق على قبول هذا الطلب، فسيضيف ترويسةً خاصةً هي Access-Control-Allow-Origin إلى الاستجابة، وينبغي أن تحتوي الترويسة على الأصل المقبول (https://javascript.info في حالتنا) أو رمز النجمة (*)، عندها سيكون الطلب ناجحًا وإلا فسيُعَد خاطئًا. يلعب المتصفح دور الوسيط الموثوق حيث: يضمن إرسال الأصل الصحيح في الطلب ذي الأصول المختلطة. يتحقق من وجود السماحية Access-Control-Allow-Origin في الاستجابة، فإذا وُجدَت فسيسمح لشيفرة JavaScript بالوصول إلى الاستجابة وإلا ستخفق العملية وسيحدث خطأ. 200 OK Content-Type:text/html; charset=UTF-8 Access-Control-Allow-Origin: https://javascript.info ترويسات الاستجابة افتراضيًا قد لا تتمكن JavaScript من الوصول إلا إلى الترويسات الآمنة للاستجابة عند إرسال طلبات ذات أصل مختلط، وهذه الترويسات هي: Cache-Control. Content-Language. Content-Type. Expires. Last-Modified. Pragma. ويسبب الدخول إلى أي ترويسات أخرى خطأً. لمنح إمكانية الوصول إلى ترويسة الاستجابة، ينبغي أن يُرسل الخادم الترويسة Access-Control-Expose-Headers، والتي تتضمن قائمةً بأسماء الترويسات غير الآمنة التي يُفترض جعلها قابلةً للوصول، وتفصل بينها فاصلة، كالمثال التالي: 200 OK Content-Type:text/html; charset=UTF-8 Content-Length: 12345 API-Key: 2c9de507f2c54aa1 Access-Control-Allow-Origin: https://javascript.info Access-Control-Expose-Headers: Content-Length,API-Key وبوجود ترويسة مثل Access-Control-Expose-Headers سيُسمح للسكربت بقراءة ترويستي الاستجابة Content-Length وAPI-Key. الطلبات غير الآمنة يمكن استخدام جميع طلبات HTTP مثل PATCH وDELETE وغيرها، وليس فقط GET/POST، ولم يتخيل أحد في السابق إمكانية تنفيذ صفحات الويب لهذه الطلبات، لذلك قد تجد بعض خدمات الويب التي تعامل الطلبات غير المعيارية مثل إشارة "بأنها طلبات مصدرها ليس المتصفح"، ويمكنها أن تأخذ هذا الأمر في الحسبان عندما تتحقق من حقوق الوصول، ولتفادي سوء الفهم، لن ينفِّذ المتصفح أي طلبات غير آمنة كانت قابلة للتنفيذ مباشرةً فيما مضى، وسيرسل طلبًا تمهيديًا preflight إلى الخادم لطلب الإذن، ويستخدم الطلب التمهيدي التابع OPTIONS دون جسم للطلب، وبوجود ترويستين: الترويسة Access-Control-Request-Method: وتؤمن تابعًا للطلب غير الآمن. الترويسة Access-Control-Request-Headers: وتؤمن قائمةً بترويسات غير آمنة تفصل بينها فاصلة. إذا وافق الخادم على تنفيذ الطلبات، فسيرسل استجابةً بجسم فارغ ورمز الحالة 200، بالإضافة إلى الترويسات التالية: Access-Control-Allow-Origin: ويجب أن تحمل القيمة (*) أو أصل الموقع الذي أرسل الطلب، مثل "https://javascript.info"، ليُسمح له بالوصول. Access-Control-Allow-Methods: ويجب أن تحتوي التوابع المسموحة. Access-Control-Allow-Headers: ويحب أن تضم قائمةً بالترويسات المسموحة. Access-Control-Max-Age: وهي ترويسة إضافية يمكنها تحديد الفترة الزمنية (ثوانٍ) للاحتفاظ بالإذن، لذا لن يكون على المتصفح إرسال طلبات تمهيدية للطلبات اللاحقة التي تحقق السماحيات الممنوحة سابقًا. لنلق نظرةً على آلية العمل خطوةً خطوةً، بمثال عن طلب PATCH ذي أصول مختلطة (والذي يُستخدَم غالبًا لتحديث البيانات): let response = await fetch('https://site.com/service.json', { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'API-Key': 'secret' } }); لدينا ثلاثة أسباب لعدّ هذا الطلب غير آمن (ويكفي أحدها بالطبع): الطلب هو PATCH. لاتحمل الترويسة Content-Type إحدى القيم: application/x-www-form-urlencoded أو multipart/form-data أو text/plain. وجود الترويسة API-Key غير الآمنة. الخطوة 1: الطلب التمهيدي preflight يرسل المتصفح بنفسه -قبل إرسال طلب غير آمنٍ كهذا- طلبًا تمهيديًا له الشكل التالي: OPTIONS /service.json Host: site.com Origin: https://javascript.info Access-Control-Request-Method: PATCH Access-Control-Request-Headers: Content-Type,API-Key OPTIONS: تابع الطلب التمهيدي. /service.json: المسار، ويطابق مسار الطلب الرئيسي تمامًا. الترويسات الخاصة بالطلب ذي الأصل المختلط: Origin: أصل مُرسل الطلب. Access-Control-Request-Method: نوع الطلب Access-Control-Request-Headers:قائمة بترويسات غير آمنة تفصل بينها فاصلة. الخطوة 2: الاستجابة للطلب التمهيدي ينبغي أن يستجيب الخادم برمز الحالة 200 والترويسات التالية: Access-Control-Allow-Origin: https://javascript.info Access-Control-Allow-Methods: PATCH Access-Control-Allow-Headers: Content-Type,API-Key. تسمح هذه الاستجابة بالتواصل المستقبلي مع الخادم وإلا فسيقع خطأ. إذا كنت سترسل طلبات أو ترويسات من أنواع أخرى مستقبلًا، فمن المنطقي أن تطلب الإذن مسبقًا، وذلك بإضافتهم إلى القائمة أثناء الطلب التمهيدي، يوضح المثال التالي استجابةً يُسمح فيها باستخدام PUT وDELETE بالإضافة إلى ترويسات أخرى: 200 OK Access-Control-Allow-Origin: https://javascript.info Access-Control-Allow-Methods: PUT,PATCH,DELETE Access-Control-Allow-Headers: API-Key,Content-Type,If-Modified-Since,Cache-Control Access-Control-Max-Age: 86400 سيرى المتصفح الآن الطلب PATCH في قائمة الطلبات المسموحة Access-Control-Allow-Methods، كما سيرى الترويسة Content-Type,API-Key في قائمة الترويسات المسموحة، لذا لن يتردد بإرسال الطلب الرئيسي المبني عليهما. إذا رأى المتصفح الترويسة Access-Control-Max-Age وقد أسندت إليها قيمة بالثواني، فسيحتفظ بالسماحيات التي مُنحت للطلب التمهيدي خلال هذه المدة الزمنية، أي سيُحتفظ بالسماحيات في مثالنا السابق فترة 86400 ثانية (أي يوم كامل)، ولن تحتاج الطلبات اللاحقة إلى نفس الخادم طلبات تمهيديةً أخرى، بفرض أنها تتلاءم مع السماحيات الممنوحة، وستُرسل مباشرةً. الخطوة 3: الطلب الفعلي يرسِل المتصفح الطلب الفعلي عندما ينجح الطلب التمهيدي، وتُنفَّذ العملية يطريقة مماثلة لإرسال طلب آمن. سيتضمن الطلب الرئيسي الترويسة Origin (لأنه طلب ذو أصل مختلط): PATCH /service.json Host: site.com Content-Type: application/json API-Key: secret Origin: https://javascript.info الخطوة 4: الاستجابة الفعلية لا بدّ للخادم من إضافة الترويسة Access-Control-Allow-Origin إلى الاستجابة الرئيسية، ولن يُعفيه الطلب التمهيدي الناجح من هذه المهمة: Access-Control-Allow-Origin: https://javascript.info تستطيع بعد ذلك قراءة استجابة الخادم الفعلية. يمكن لشيفرة JavaScript الآن قراءة الاستجابة على الطلب الفعلي. الثبوتيات Credentials لا تحضر الطلبات ذات الأصل المختلط التي تنتج عن شيفرة JavaScript أية ثبوتيات (ملفات تعريف الارتباط cookies أو استيثاق HTTP)، وهذا ليس أمرًا شائعًا في طلبات HTTP، فعند إرسال طلب HTTP إلى الموقع http://site.com مثلَا، فسيحمل الطلب جميع ملفات تعريف الارتباط الموجودة في نطاق المُرسِل، لكن الطلبات ذات الأصل المختلط الناتجة عن JavaScript تُمثّل استثناءً، حيث لن يُرسل الأمر (fetch(http://another.com أي ملفات تعريف ارتباط حتى تلك التي تنتمي إلى النطاق another.com. لكن لماذا؟ لأنّ الطلبات التي تُزوَّد بثبوتيات أقوى بكثير، إذ يمكن لشيفرة JavaScript -إن سُمح لها- أن تعمل بكامل إمكانياتها بالنيابة عن المستخدم، وأن تصل إلى معلومات حساسة بالاستفادة من هذه الثبوتيات. لكن هل يثق الخادم بسكربت ما إلى هذا الحد؟ إن كان الأمر كذلك، فلا بدّ من السماح صراحةً بالطلبات التي تحمل ثبوتيات من خلال ترويسة إضافية، حيث سنحتاج إلى إضافة الخيار credentials: "include" عند إرسال الثبوتيات مع fetchبالشكل التالي: fetch('http://another.com', { credentials: "include" }); يمكن الآن إرسال ملفات تعريف الارتباط التي تنتمي إلى another.com عبر الطلب fetch إلى الموقع الهدف، وينبغي على الخادم إذا وافق على قبول الثبوتيات، إضافة الترويسة Access-Control-Allow-Credentials: true إلى استجابته بالإضافة إلى الترويسة Access-Control-Allow-Origin، مثلًا: 200 OK Access-Control-Allow-Origin: https://javascript.info Access-Control-Allow-Credentials: true لاحظ أنه يُمنع استخدام النجمة (*) كقيمة للترويسة Access-Control-Allow-Origin في الطلبات التي تحمل ثبوتيات، إذ لا بدّ -كما نرى في المثال السابق- من تحديد الأصل بدقة، وهذا معيار إضافي للتأكد من أنّ الخادم يعرف تمامًا الجهة التي يثق بها لتنفيذ طلبات مثل هذه. خلاصة هنالك نوعان من الطلبات ذات الأصول المختلطة من وجهة نظر المتصفح: آمنة وغير آمنة. لا بدّ للطلبات الآمنة من تحقيق الشرطين التاليين: أن تستخدم نوعًا آمنًا مثل: GET أو POST أو HEAD. أن تستخدم الترويسات الآمنة التالية: Accept Accept-Language Content-Language Content-Type: وتحمل إحدى القيم application/x-www-form-urlencoded أو multipart/form-data أو text/plain. الفرق الجوهري هو أن الطلبات الآمنة ومنذ وقت طويل، تُنفَّذ باستخدام معرِّف النموذج <form> أو معرِّف السكربت <script>، بينما مُنعت المتصفحات من تنفيذ الطلبات غير الآمنة لفترة طويلة. يظهر هذا الفرق عمليًا في إمكانية إرسال الطلبات الآمنة مع الترويسة Origin مباشرةً، بينما يحتاج المتصفح إلى إرسال طلب تمهيدي preflight عند إرسال الطلبات غير الآمنة، يطلب فيها إذنًا من الخادم. للطلبات الآمنة: يرسل المتصفح الترويسة مع الأصل Origin. بالنسبة للطلبات التي لا تحمل ثبوتيات (لا ترسَل الثبوتيات بشكل افتراضي) لا بدّ أن يضبط الخادم ما يلي: Access-Control-Allow-Origin: على القيمة (*) أو نفس قيمة الأصل Origin. بالنسبة للطلبات التي تحمل ثبوتيات لا بدّ أن يضبط الخادم: Access-Control-Allow-Origin: على نفس قيمة الأصل Origin. Access-Control-Allow-Credentials: على القيمة "true". ولمنح JavaScript الوصول إلى ترويسات الاستجابة عدا Cache-Control و Content-Language وContent-Type وExpires وLast-Modified وPragma، فلا بدّ أن يضع الخادم الترويسة التي يُسمح بالوصول إليها ضمن الترويسة Access-Control-Expose-Headers. يُرسل المتصفح طلبًا تمهيديًا قبل الطلب الفعلي عند إرسال طلبات غير آمنة: يرسل المتصفح الطلب OPTIONS إلى نفس العنوان الذي سيرسِل إليه الطلب الفعلي مزوّدًا بالترويسات التالية: Access-Control-Request-Method: ويحمل نوع الطلب. Access-Control-Request-Headers: ويحمل قائمةً بترويسات غير آمنة يُطلب الإذن باستخدامها. يستجيب الخادم برمز الحالة 200، وبالترويسات التالية: Access-Control-Allow-Methods: تضم قائمةً بأنواع الطلبات المسموحة. Access-Control-Allow-Headers: تضم قائمةً بالترويسات المسموحة. Access-Control-Max-Age: وتحتوي على قيمة تمثل الفترة الزمنية (مقدرةً بالثواني) التي يُحتفظ فيها بالسماحيات. يُرسَل الطلب الفعلي بعد ذلك، وتُطبق خطوات إرسال الطلب الآمن. ترجمة -وبتصرف- للفصل Fetch: Cross-origin Requests من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: تتبع تقدم عملية التنزيل باستخدام Fetch وإلغاء العملية Fetch
-
تتيح الدالة fetch تتبع عملية التنزيل download. لاحظ أنه لا توجد حاليًا طريقة تسمح للدالة fetch بتتبع عملية الرفع upload، نستخدم لهذه الغاية الكائن XMLHttpRequest الذي سنغطيه لاحقًا. تُستخدم الخاصية response.body لتتبع تقدم التنزيل، وتمثل هذه الخاصية كائن ReadableStream، وهو كائن خاص يزودنا عند وصوله بجسم الطلب كتلةً بكتلة chunk-by-chunk، ستجد وصفًا لمجاري التدفق القابلة للقراءة Readable streams في توصيف الواجهة Streams API، وتمنح الخاصية response.body تحكمًا كاملًا بعملية القراءة على خلاف التابعين ()response.text و()response.json وغيرهما. كما تمنح إمكانية تقدير الوقت المستغرق في أية لحظة. إليك مثالُا عن شيفرة تقرأ الاستجابة من response.body: // والطرق الأخرى response.json() بدلا من const reader = response.body.getReader(); // حلقة لا نهائية حتي يكتمل التنزيل while(true) { // عند آخر جزء true القيمة done ستحمل //لبايتات كل جزء Unit8Array هو value const {done, value} = await reader.read(); if (done) { break; } console.log(`Received ${value.length} bytes`) } ستكون نتيجة الاستدعاء ()await reader.read كائنًا له الخاصيتان التاليتان: done : تأخذ القيمة true عندم اكتمال عملية القراءة، وإلا فستكون قيمتها false. value : مصفوفة من النوع Uint8Array. //واحصل على قارئ للبيانات Fetch الخطوة1: إبدأ تنفيذ الدالة let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits?per_page=100'); const reader = response.body.getReader(); // الخطوة2: احصل على الحجم الكلي const contentLength = +response.headers.get('Content-Length'); // الخطوة3 : إقرأ البيانات let receivedLength = 0; // حجم البايتات المستقبلة حتى اللحظة let chunks = []; // مصفوفة الأجزاء المستلمة التي تمثل جسم الاستجابة while(true) { const {done, value} = await reader.read(); if (done) { break; } chunks.push(value); receivedLength += value.length; console.log(`Received ${receivedLength} of ${contentLength}`) } // الخطوة 4: ضم الأجزاء في مصفوفة واحدة let chunksAll = new Uint8Array(receivedLength); // (4.1) let position = 0; for(let chunk of chunks) { chunksAll.set(chunk, position); // (4.2) position += chunk.length; } // الخطوة5: الترميز في سلسلة نصية let result = new TextDecoder("utf-8").decode(chunksAll); // النهاية let commits = JSON.parse(result); alert(commits[0].author.login); المخرجات: "Received 258566 of 0" "Received 444982 of 0" لنشرح الشيفرة السابقة: لقد نفّذنا الدالة ftech، لكننا استخلصنا مجرى التدفق ()reader response.body.getReader بدلًا من استدعاء التابع ()response.json، ولايمكن استخدام الطريقتين معًا لقراءة الاستجابة، استخدم إحداهما للحصول على النتيجة. يمكننا قبل الشروع في قراءة الاستجابة تحديد الحجم الكلي لها عن طريق الترويسة Content-Length، وقد لا تكون الترويسة موجودةً في الطلبات ذات الأصل المختلط Cross-origin، لكن لن يُعدَّها الخادم عمليًا، وستبقى في مكانها. نستدعي التابع ()await reader.read حتى ينهي عمله، ونُجمِّع أجزاء الاستجابة في المصفوفة chunks، وهذا الأمر ضروري لأن الاستجابة ستختفي ولن نتمكن من إعادة قراءتها باستخدام ()response.json ولا بأي طريقة أخرى، وستحصل على خطأ إذا حاولت ذلك. سنحصل في النهاية على chunks وهي مصفوفة من الأجزاء لها النوع Uint8Array، وعلينا تجميعها ضمن نتيجة واحدة، ولسوء الحظ لا يوجد تابع لضمها، لهذا علينا كتابة الشيفرة التي ستنجز العملية: إنشاء المصفوفة (chunksAll = new Uint8Array(receivedLength، وهي مصفوفة من النوع Uint8Array لها حجم جميع الأجزاء. استخدام التابع (set(chunk, position. لنسخ كل جزء بدوره إليها. سنحصل على النتيجة ضمن المصفوفة chunksAll، وهي مصفوفة من البايتات وليست نصًا، ولتحويلها إلى نص لا بد من تفسير هذه البيانات عن طريق الكائن TextDecoder، ثم استخدام JSON.parse إن استدعت الحاجة. لكن ماذا لو احتجنا إلى محتوىً ثنائي بدل النص؟ سيكون الأمر أبسط، علينا فقط استبدال الخطوتين 4 و5 بسطر وحيد يُنشئ كائن بيانات ثنائية Blob يضم كل الأجزاء. let blob = new Blob(chunks); وهكذا سنحصل على النتيجة بالصيغة التي نريد مع إمكانية تتبع تقدم العملية، مرةً أخرى يجب الانتباه إلى أن العملية غير صالحة لتتبع تقدم عملية الرفع، أي لا يمكن استخدام Fetch، بل فقط للتنزيل. ولا بد من التحقق من حجم البيانات الواصلة receivedLength في كل لحظة ضمن الحلقة وإنهائها بمجرد وصولها إلى حد معين، إذا لم يكن حجم البيانات التي سنستقبلها معروفًا، وبالتالي لن تستهلك المصفوفة chunks الذاكرة. الكائن AbortController: مقاطعة العمليات غير المتزامنة تعيد fetch كما نعرف وعدًا promise، ولكننا نعلم أنّ JavaScript لا تقبل إلغاء الوعود عمومًا، فكيف سنلغي عملية fetch أثناء تنفيذها؟ هنالك كائن خاص مدمج لهذا الغرض هو AbortController، يمكن استخدامه لإلغاء fetch وغيرها من المهام غير المتزامنة، ويطبّق مباشرةً. لننشئ متحكمًا بالشكل التالي: let controller = new AbortController(); والمتحكم هو كائن شديد البساطة، له: تابع وحيد هو ()abort. وخاصية واحدة هي signal تسمح بإعداد مستمع حدث event listener له. أما عند استدعاء التابع ()abort فسيحدث الآتي: تحرّض الخاصية controller.signal وقوع الحدث abort. تأخذ الخاصية controller.signal.aborted القيمة true. تكون للعملية في العادة مرحلتان: المرحلة التي تُنفِّذ عمليةً قابلة للإلغاء، وتهيئ مستمع حدث للخاصية controller.signal. المرحلة التي تلغي: وتُنفَّذ باستدعاء التابع ()controller.abort عندما يتطلب الأمر. إليك مثالًا كاملًا دون Fetch: let controller = new AbortController(); let signal = controller.signal; // القيام بعملية قابلة للإلغاء // "signal" الحصول على الكائن // controller.abort() ضبط إطلاق المستمع عند استدعاء signal.addEventListener('abort', () => alert("abort!")); // القيام بالإلغاء controller.abort(); // abort! // true القيمة signal.aborted إطلاق الحدث ويصبح لــ alert(signal.aborted); // true لاحظ أنّ الكائن AbortController هو مجرد أداة لتمرير الحدث abort عند استدعاء التابع ()abort، ومن الواضح أنه بالإمكان تنفيذ مستمع حدث كهذا باستخدام شيفرتنا الخاصة دون الحاجة إلى AbortController، لكن أهميته ستظهر عندما نعلم أن fetch تعرف تمامًا كيفية التعامل معه، فهو متكامل معها. مقاطعة العملية Fetch لإلغاء العملية fetch علينا تمرير قيمة الخاصية signal العائدة للكائن AbortController مثل خيار لها: let controller = new AbortController(); fetch(url, { signal: controller.signal }); تعلم fetch تمامًا كيفية التعامل مع AbortController، وستستمع إلى الحدث abort الذي تحرّض الخاصية signal وقوعه. لإلغاء العملية سنستدعي التابع: controller.abort(); وهكذا تلغى العملية، حيث تحصل fetch على الحدث abort من الخاصية signal وتلغي الطلب، عند إلغاء fetch سيُرفض الوعد الذي تعيده وسيُرمى الخطأ AbortError، وينبغي التعامل معه من خلال حلقة try..catch مثلًا. إليك مثالًا كاملًا مع استخدام fetch، حيث تلغى العملية بعد ثانية واحدة: // الإلغاء خلال ثانية واحدة let controller = new AbortController(); setTimeout(() => controller.abort(), 1000); try { let response = await fetch('/article/fetch-abort/demo/hang', { signal: controller.signal }); } catch(err) { if (err.name == 'AbortError') { // handle abort() alert("Aborted!"); } else { throw err; } } كائن قابل للتوسع يسمح الكائنAbortController بإلغاء عدة عمليات معًا، إليك الشيفرة التمثيلية التالية التي تحضر عدة موارد على التوازي، ثم تستخدم كائن متحكم وحيدًا لإلغائها جميعًا: let urls = [...]; //قائمة بالموارد التي ينبغي إحضارها let controller = new AbortController(); // fetch مصفوفة من الوعود التي ستعيدها عمليات let fetchJobs = urls.map(url => fetch(url, { signal: controller.signal })); let results = await Promise.all(fetchJobs); // بمجرد استدعاء حدث الإلغاء ستلغى جميع عمليات الإحضار وسنتمكن أيضًا من إلغاء أي عمليات أخرى غير متزامنة مع عمليات fetch باستخدام كائن AbortController وحيد، بمجرد الاستماع إلى الحدث abort: let urls = [...]; let controller = new AbortController(); let ourJob = new Promise((resolve, reject) => { // المهمة المطلوب إلغاءها ... controller.signal.addEventListener('abort', reject); }); let fetchJobs = urls.map(url => fetch(url, { // عمليات الإحضار signal: controller.signal })); // انتظار إنجاز جميع العمليات let results = await Promise.all([...fetchJobs, ourJob]); // بمجرد استدعاء حدث الإلغاء ستلغى جميع عمليات الإحضار // بالإضافة إلى بقية المهام خلاصة بهذا نكون قد تعرفنا على كيفية تتبع عملية التنزيل باستخدام Fetch، وذلك بالاعتماد على عدة خاصيات، كما تعرفنا على كيفية مقاطعة العملية Fetch، وذلك بالاعتماد على الكائنات الآتية: AbortController: هو كائن بسيط يولّد الحدث abort على الخاصية signal عند استدعاء التابع ()abort، الذي يعطي الخاصية signal.aborted القيمة "true" أيضًا. تتكامل fetch مع هذا الكائن، حيث تُمرر الخاصية signal كخيار لتستمع إليه، وبالتالي يصبح إلغاؤها ممكنًا. يمكن استخدام AbortController في شيفرتنا، حيث يستمع التابع ()abort إلى الحدث abort بعملية بسيطة تطبق في أي مكان، كما يمكن استخدامها دون استخدام fetch. ترجمة -وبتصرف- للفصلين Fetch: Download Progress وFetch: Abort من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت
-
يمكن للغة جافاسكربت JavaScript إرسال طلبات شبكة إلى الخادم وتحميل معلومات جديدة عندما يتطلب الأمر ذلك، إذ يمكننا على سبيل المثال استخدام طلبات الشبكة في الحالات التالية: إرسال طلب. بتحميل معلومات مستخدم. الحصول على آخر التحديثات من الخادم. ويجري كل ذلك دون إعادة تحميل الصفحة. تنضوي طلبات الشبكة التي تنفذها لغة JavaScript تحت المظلة AJAX، وهي اختصار للعبارة Asynchronous JavaScript And XML، ورغم ذلك لا نحتاج إلى استخدام XML، فقد وضعت العبارة السابقة منذ فترة طويلة لذلك وجدت هذه الكلمة ضمنها، وقد تكون سمعت بهذه العبارة الآن أيضًا. هنالك طرق عديدة لإرسال طلبات عبر الشبكة والحصول على معلومات من الخادم، وسنبدأ بالطريقة الأحدث ()fetch، علمًا أنه لا تدعم المتصفحات القديمة هذه الدالة (ويمكن الاستعاضة عنها بشيفرة بديلة)، لكنها مدعومة جيدًا في المتصفحات الحديثة، وإليك صيغتها: let promise = fetch(url, [options]) حيث: url: عنوان المورد الذي ستصل إليه الدالة. options: المعاملات الاختيارية من توابع وترويسات وغيرها. تتحول الدالة إلى طلب GET بسيط لتنزيل محتوى العنوان url إن لم تكن هناك معاملات اختيارية options، ويبدأ المتصفح الطلب مباشرةً ويعيد وعدًا promise ستستخدمه الشيفرة التي تستدعي الطلب للحصول على النتيجة، وتكون الاستجابة عادةً عمليةً بمرحلتين: الأولى: يُحلَّل الوعد الذي تعيده fetch عبر كائن من الصنف Respo-nse حالما يستجيب الخادم بالترويسات المناسبة، ويمكن التحقق من نجاح الطلب أو عدم نجاحه، والتحقق أيضًا من الترويسات، لكن لن يصل جسم الطلب في هذه المرحلة، ويُرفَض الوعد إن لم تكن fetch قادرةً على إنجاز طلب HTTP لمشاكل في الشبكة مثلًا، أو لعدم وجود موقع على العنوان المُعطى، ولن تسبب حالات HTTP غير العادية مثل 404 أو 500 أخطاءً. يمكن معرفة حالة طلب من خصائص الاستجابة: status: رمز الحالة status code لطلب HTTP مثل الرمز 200. ok: قيمة منطقية "true" عندما يكون رمز الحالة بين 200 و299. إليك المثال التالي: let response = await fetch(url); if (response.ok) { // إن كان رمز الحالة بين 200-299 // الحصول على جسم الطلب let json = await response.json(); } else { alert("HTTP-Error: " + response.status); } الثانية: استخدام استدعاء إضافي للحصول على جسم الطلب، ويؤمن الكائن Response عدة توابع مبنية على الوعد للوصول إلى جسم الطلب وبتنسيقات مختلفة: ()response.text: لقراءة الاستجابة وإعادة نص. ()response.json: يفسِّر النص وفق تنسيق JSON. ()response.formData: يعيد الاستجابة على شكل كائن FormData سنشرحه في الفقرة التالية. ()response.blob: يعيد الاستجابة على شكل كائن البيانات الثنائية Blob. ()response.arrayBuffer: يعيد الاستجابة على شكل كائن ArrayBuffer وهو تمثيل منخفض المستوى للبيانات الثنائية. الكائن response.body وهو كائن من الصنف ReadableStream يسمح بقراءة جسم الطلب كتلةً كتلةً، وسنعرض مثالًا عن ذلك لاحقًا. لنحاول على سبيل المثال الحصول على كائن JSON من آخر نسخة معتمدة لموقع الدورة التعليمية هذه على GitHub: let url = 'https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits'; let response = await fetch(url); let commits = await response.json(); // Json قراءة الاستجابة على شكل شيفرة alert(commits[0].author.login); See the Pen JS-P3-Fetch-ex02 by Hsoub (@Hsoub) on CodePen. كما يمكن فعل ذلك من خلال الوعود الصرفة دون استخدام await: fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits') .then(response => response.json()) .then(commits => alert(commits[0].author.login)); See the Pen JS-P3-Fetch-ex03 by Hsoub (@Hsoub) on CodePen. استخدم ()await response.text للحصول على نص الطلب بدلًا من ()json.: let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits'); let text = await response.text(); // قراءة جسم الاستجابة على شكل نص alert(text.slice(0, 80) + '...'); See the Pen JS-P3-Fetch-ex04 by Hsoub (@Hsoub) on CodePen. لنستعرض مثالًا عن قراءة بيانات بالصيغة الثنائية، ونحضر صورةً ما ونظهرها: let response = await fetch('https://javascript.info/article/fetch/logo-fetch.svg'); let blob = await response.blob(); // Blob تنزيل على شكل // <img> إنشاء عنصر let img = document.createElement('img'); img.style = 'position:fixed;top:10px;left:10px;width:100px'; document.body.append(img); // إظهاره img.src = URL.createObjectURL(blob); setTimeout(() => { // إخفاءه بعد ثلاث ثوان img.remove(); URL.revokeObjectURL(img.src); }, 3000); See the Pen JS-P3-Fetch-ex05 by Hsoub (@Hsoub) on CodePen. let text = await response.text(); // انتهاء معالجة جسم الطلب let parsed = await response.json(); // سيخفق، فقد جرت المعالجة وانتهت ترويسات الاستجابة يمكن الحصول على ترويسات الاستجابة على شكل كائن ترويسات شبيه بالترابط Map من خلال الأمر response.headers، ولا يُعَد الكائن ترابطًا تمامًا، لكنه يمتلك توابع مماثلةً للحصول على ترويسات من خلال اسمها أو بالمرور عليها: let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits'); // الحصول على ترويسة واحدة alert(response.headers.get('Content-Type')); // application/json; charset=utf-8 // المرور على الترويسات كلها for (let [key, value] of response.headers) { alert(`${key} = ${value}`); } See the Pen JS-P3-Fetch-ex06 by Hsoub (@Hsoub) on CodePen. ترويسات الطلب يمكن استخدام خيار الترويسات headers لإعداد ترويسة الطلب في الدالة featch، إذ تمتلك كائنًا يضم الترويسات المُرسَلة كالتالي: let response = fetch(protectedUrl, { headers: { Authentication: 'secret' } }); لكن هناك قائمة من ترويسات HTTP المنوعة التي لا يمكن ضبطها: Accept-Charset وAccept-Encoding Access-Control-Request-Headers Access-Control-Request-Method Connection Content-Length Cookie وCookie2 Date DNT Expect Host Keep-Alive Origin Referer TE Trailer Transfer-Encoding Upgrade Via Proxy-* Sec-* تضمن هذه الترويسات ملاءمة طلبات HTTP وأمانها، لذلك يتحكم فيها المتصفح حصرًا. طلبات الكتابة POST لإرسال طلب POST أو طلب من أي نوع لا بدّ من استخدام خيارات fetch: method: نوع طلب HTTP مثل HTTP-POST. body: ويمثل جسم الطلب وقد يكون: نصًا: بتنسيق JSON مثلًا. كائن FormData لإرسال بيانات على شكل form/multipart. كائن Blob أو BufferSourceلإرسال بيانات ثنائية. URLSearchParams لإرسال البيانات بتشفير x-www-form-urlencoded، وهو نادر الاستخدام. يُستخدم تنسيق JSON غالبًا، حيث تُرسل الشيفرة التالية الكائن user وفق تنسيق JSON مثلًا: let user = { name: 'John', surname: 'Smith' }; let response = await fetch('https://javascript.info/article/fetch/post/user', { method: 'POST', headers: { 'Content-Type': 'application/json;charset=utf-8' }, body: JSON.stringify(user) }); let result = await response.json(); alert(result.message); لاحظ ضبط الترويسة Content-Type افتراضيًا على القيمتين text/plain;charset=UTF-8 إذا كان جسم الطلب على شكل نص، لكن طالما أننا سنرسل البيانات بصيغة JSON، فسنستخدم الخيار headers لإرسال الترويسة application/json بدلًا عن text/plain كونها تمثل المحتوى الصحيح للبيانات. إرسال صورة يمكن إرسال بيانات ثنائية عبر الدالة fetch باستخدام الكائنات Blob أو BufferSource، سنجد في المثال التالي معرّف لوحة رسم <canvas> التي يمكننا الرسم ضمنها بتحريك الفأرة، ومن ثم إرسال الصورة الناتجة إلى الخادم عند النقر على الزر "submit": <body style="margin:0"> <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas> <input type="button" value="Submit" onclick="submit()"> <script> canvasElem.onmousemove = function(e) { let ctx = canvasElem.getContext('2d'); ctx.lineTo(e.clientX, e.clientY); ctx.stroke(); }; async function submit() { let blob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png')); let response = await fetch('/article/fetch/post/image', { method: 'POST', body: blob }); // يستجيب الخادم بتأكيد وصول البيانات وبحجم الصورة let result = await response.json(); alert(result.message); } </script> </body> ستظهر النتيجة كالتالي: See the Pen JS-P3-Fetch-ex07 by Hsoub (@Hsoub) on CodePen. لاحظ أننا لم نضبط هنا قيمة الترويسة Content-Type يدويًا، لأنّ الكائن Blob له نوع مضمَّن (هو image/png في حالتنا، كما ولّده التابع toBlob)، وسيمثّل هذا النوع قيمة الترويسة Content-Type في كائنات Blob. يمكن كتابة الدالة ()submit دون استخدام الصيغة async/await كالتالي: function submit() { canvasElem.toBlob(function(blob) { fetch('https://javascript.info/article/fetch/post/image', { method: 'POST', body: blob }) .then(response => response.json()) .then(result => alert(JSON.stringify(result, null, 2))) }, 'image/png'); } استخدام الكائن FormData لإرسال النماذج يمكننا الاستفادة من الكائن FormData لإرسال نماذج HTML مع ملفات أو بدونها، بالإضافة إلى حقول إضافية. وكما قد تخمِّن؛ يمثل هذا الكائن بيانات نماذج HTML، وإليك صيغة الدالة البانية له: let formData = new FormData([form]); سيتحكم الكائن FormData تلقائيًا بحقول العنصر form إذا استُخدم في مستند HTML، وما يميز الكائن FormData هو أنّ توابع إرسال الطلبات واستقبالها عبر الشبكة مثل Fetch ستقبله مثل جسم للطلب، إذ يُشفَّر ويُرسَل بترويسة قيمتها Content-Type: multipart/form-data، وتبدو العملية بالنسبة إلى الخادم مثل إرسال عادي لنموذج. إرسال نموذج بسيط لنرسل أولًا نموذجًا بسيطًا، وسيظهر في مثالنا هذا في نموذج من سطر واحد: <form id="formElem"> <input type="text" name="name" value="John"> <input type="text" name="surname" value="Smith"> <input type="submit"> </form> <script> formElem.onsubmit = async (e) => { e.preventDefault(); let response = await fetch('https://javascript.info/article/formdata/post/user', { method: 'POST', body: new FormData(formElem) }); let result = await response.json(); alert(result.message); }; </script> وستكون النتيجة كالتالي: See the Pen JS-P3-FormData-ex01 by Hsoub (@Hsoub) on CodePen. لا توجد شيفرة خاصة بالخادم في هذا المثال، لأنها خارج نطاق هذه الدورة التعليمية، حيث سيقبل الخادم الطلب HTTP-POST ويستجيب بالرسالة "User saved" أي "خُزّن المستخدم". توابع الكائن نستخدم عددًا من التوابع لتعديل الحقول في الكائن FormData: (formData.append(name, value: يُضيف حقلًا بالاسم name قيمته هي value. (formData.append(name, blob, fileName: يضيف حقلًا كما لو أنه العنصر <"input type="file>، حيث يحدد الوسيط الثالث للتابع fileName اسم الملف -وليس اسم الحقل- كما لو أنه اسم لملف في منظومة ملفات الجهاز. (formData.delete(name: يزيل حقلًا محددًا بالاسم name. (formData.get(name: يعطي قيمة الحقل المحدد بالاسمname. (formData.has(name: إذا وجد حقل بالاسم name فسيعيد القيمة true وإلا false. يمكن أن يحوي النموذج العديد من الحقول التي لها نفس الاسم، لذلك سينتج عن الاستدعاءات المختلفة لضم append الحقول حقولًا لها نفس الاسم. وسنجد التابع set الذي له صيغة append نفسها، لكنه يزيل جميع الحقول التي لها اسم محدد name، ثم يضيف الحقل الجديد، وبالتالي سنضمن وجود حقل وحيد بالاسم name، تشبه باقي التفاصيل التابع append. (formData.set(name, value (formData.set(name, blob, fileName. يمكن أيضًا إجراء تعداد على عناصر الكائن FormData باستخدام الحلقة for..of: let formData = new FormData(); formData.append('key1', 'value1'); formData.append('key2', 'value2'); // قائمة من الأزواج مفتاح/قيمة for(let [name, value] of formData) { alert(`${name} = ${value}`); // key1 = value1, then key2 = value2 } See the Pen JS-P3-FormData-ex02 by Hsoub (@Hsoub) on CodePen. إرسال نموذج مع ملف يُرسَل النموذج دائمًا بحيث تكون ترويسة المحتوى مثل التالي Content-Type: multipart/form-data، وتسمح هذه الطريقة في الترميز بإرسال الملفات، أي ستُرسَل الملفات التي يحددها العنصر <"input type="file> أيضًا بشكل مشابه للإرسال الاعتيادي للنماذج، إليك مثالًا عن ذلك: <form id="formElem"> <input type="text" name="firstName" value="John"> Picture: <input type="file" name="picture" accept="image/*"> <input type="submit"> </form> <script> formElem.onsubmit = async (e) => { e.preventDefault(); let response = await fetch('https://javascript.info/article/formdata/post/user-avatar', { method: 'POST', body: new FormData(formElem) }); let result = await response.json(); alert(result.message); }; </script> ستظهر النتيجة كالتالي: See the Pen JS-P3-FormData-ex03 by Hsoub (@Hsoub) on CodePen. إرسال ملف يحتوي على كائن بيانات ثنائية يمكن أن نرسل بيانات ثنائيةً مولّدةً تلقائيًا، مثل الصور على شكل كائن بيانات Blob، وبالتالي يمكن تمريره مباشرةً مثل المعامل body للدالة Fetch كما رأينا في الفقرة السابقة، ومن الأنسب عمليًا إرسال صورة لتكون جزءًا من نموذج له حقول وبيانات وصفية Metadata وليس بشكل منفصل، إذ يُلائم الخوادم عادةً استقبال نماذج مشفرة مكونة من أجزاء متعددة أكثر من بيانات ثنائية خام. يُرسل المثال التالي صورةً مرسومةً ضمن العنصر <canvas>، بالإضافة إلى بعض الحقول على شكل نموذج باستخدام الكائن FormData: <body style="margin:0"> <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas> <input type="button" value="Submit" onclick="submit()"> <script> canvasElem.onmousemove = function(e) { let ctx = canvasElem.getContext('2d'); ctx.lineTo(e.clientX, e.clientY); ctx.stroke(); }; async function submit() { let imageBlob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png')); let formData = new FormData(); formData.append("firstName", "John"); formData.append("image", imageBlob, "image.png"); let response = await fetch('https://javascript.info/article/formdata/post/image-form', { method: 'POST', body: formData }); let result = await response.json(); alert(result.message); } </script> </body> وتظهر النتيجة كالتالي: See the Pen JS-P3-FormData-ex04 by Hsoub (@Hsoub) on CodePen. لاحظ كيف يُضاف الكائن Blob الذي يمثل الصورة: formData.append("image", imageBlob, "image.png"); وهذا الأسلوب مشابه لاستخدام العنصر ضمن نموذج، حيث يرسل الزائر الملف الذي يحمل الاسم "image.png" (الوسيط الثالث) والذي يحمل البيانات التي يحددها imageBlob (الوسيط الثاني) انطلاقًا من منظومة الملفات، ويقرأ الخادم بيانات النموذج وكذلك الملف كما لو أنها عملية إرسال نموذج اعتيادية. خلاصة يتكون طلب إحضار بيانات تقليدي من استدعاءين باستخدام الصيغة await: let response = await fetch(url, options); // يُنفَّذ مع ترويسة الاستجابة headers let result = await response.json(); // JSON قراءة جسم الطلب بتنسيق أو دون الصيغة await: fetch(url, options) .then(response => response.json()) .then(result => /* process result */) وتتمثل خصائص الاستجابة في الآتي: response.status: رمز حالة HTTP للاستجابة. response.ok: يأخذ القيمة "true" إذا كانت قيمة رمز الحالة بين 200-299. response.headers: تعيد كائنًا شبيهًا بالترابط Map يضم ترويسات HTTP. توابع الحصول على جسم الاستجابة: ()response.text: لقراءة الاستجابة وإعادة نص. ()response.json: يفسر النص وفق تنسيق JSON. ()response.formData: يعيد الاستجابة على شكل كائن FormData. ()response.blob: تعيد الاستجابة على شكل كائن بيانات ثنائية Blob. ()response.arrayBuffer: يعيد الاستجابة على شكل كائن ArrayBuffer وهو تمثيل منخفض المستوى للبيانات الثنائية. خيارات Fetch التي تعرفنا عليها حتى الآن: method: نوع طلب HTTP. headers: كائن يضم ترويسات الطلب، ويجب الانتباه إلى الترويسات التي يُمنع استخدامها. body: البيانات التي ستُرسل (جسم الطلب) على شكل string أو FormData أو BufferSource أو Blob أو UrlSearchParams. وسنتعرف على خيارات أخرى في الفصل التالي. تُستخدم الكائنات FormData للتحكم بنماذج وإرسالها باستخدام fetch أو أي دوال لإرسال الطلبات عبر الشبكة، ويمكن إنشاؤها بالأمر (new FormData(form انطلاقًا من نموذج HTML موجود، أو إنشاؤها دون نموذج ثم نضيف إليه الحقول باستخدام التوابع: (formData.append(name, value (formData.append(name, blob, fileName (formData.set(name, value (formData.set(name, blob, fileName لاحظ هاتين الميزتين: يزيل التابع set الحقول التي لها نفس الاسم، بينما لا يفعل ذلك التابع append، وهذا هو الاختلاف الوحيد بينهما. لا بدّ من استخدام صيغة تضم ثلاثة وسطاء لإرسال الملف، آخرها اسم الملف والذي يؤخذ عادةً من منظومة ملفات المستخدم من خلال العنصر <"input type="file>. من التوابع الأخرى: (formData.delete(name (formData.get(name (formData.has(name تمارين إحضار بيانات مستخدمين من GitHub أنشئ دالةً غير متزامنة "async" باسم (getUsers(names للحصول على مصفوفة من سجلات الدخول Logins على GitHub، وتحضر المستخدمين أيضًا، ثم تعيد مصفوفةً بأسماء المستخدمين على GitHub. سيكون العنوان الذي يحوي معلومات مستخدم معين له اسم مستخدم محدد USERNAME هو: api.github.com/users/USERNAME. ستجد مثالًا تجريبيًا وضعناه في نمط الحماية sandbox يوضح ذلك. تفاصيل مهمة: ينبغي أن يكون هناك طلب إحضار واحد لكل مستخدم. لا ينبغي أن ينتظر أي طلب انتهاء طلب آخر، لكي تصل البيانات بالسرعة الممكنة. في حال إخفاق أي طلب، أو عدم وجود مستخدم بالاسم المُعطى، فينبغي أن تعيد الدالة القيمة "null" في المصفوفة الناتجة. افتح التمرين في بيئة تجريبية الحل إليك حل التمرين: نفِّذ التعليمة التالية لإحضار مستخدم: fetch('https://api.github.com/users/USERNAME') استدع التابع ()json. لقراءة الكائن JS، إن كان رمز الحالة المرافق لاستجابة الخادم هو 200. في الحالة التي تخفق فيها تعليمة الإحضار fetch أو لم يكن رمز الحالة 200، أعد القيمة null في المصفوفة الناتجة. إليك الشيفرة: async function getUsers(names) { let jobs = []; for(let name of names) { let job = fetch(`https://api.github.com/users/${name}`).then( successResponse => { if (successResponse.status != 200) { return null; } else { return successResponse.json(); } }, failResponse => { return null; } ); jobs.push(job); } let results = await Promise.all(jobs); return results; } ملاحظة: يرتبط استدعاء التابع then. بمباشرة بالدالة fetch، وبالتالي لا تنتظر عمليات إحضار أخرى لتنتهي عندما تصلك الاستجابة على أحدها بل إبدأ بقراءة الاستجابة مستخدمًا ()json.. إن استخدمت الشيفرة التالية : await Promise.all(names.map(name => fetch(...))) ثم استدعيت ()json. لقراءة النتائج، فقد يكون عليك الانتظار لتنتهي جميع عمليات الإحضار. ستضمن قراءة نتيجة كل عملية إحضار بمفردها إن استخدمت مباشرة ()json. مع fetch. فما عرضناه كان مثالًا عن فائدة واجهة الوعود البرمجية منخفضة المستوى low-level Promise API حتى لو استخدمنا async/await. إليك الحل في بيئة تجريبية مع الاختبارات ترجمة -وبتصرف- للفصلين popups and window methods و FormData من سلسلة The Modern JavaScript Tutorial
-
يمثل ArrayBuffer جزءًا من معيار "ECMA"، وهو جزء من جافاسكريبت JavaScript، لكن توجد كائنات عالية المستوى ضمن المتصفح وُصِفت في الواجهة البرمجية الخاصة بالملفات File API وبالتحديد الكائن Blob، والذي يتألف من نص افتراضي هو type (من النوع متعدد الوسائط MIME عادةً)، بالإضافة إلى الوسيط blobParts وهو سلسلة من كائنات Blob أخرى ونصوص ومصدر للبيانات الثنائية BufferSource. تأخذ الدالة البانية الصيغة التالية: new Blob(blobParts, options); حيث: blobParts: هو مصفوفة قيمها كائنات Blob وBufferSource وString. options: ويتضمن كائنات اختياريةً هي: type: يمثل نوع الكائن Blob، وهو عادةً من النوع متعدد الوسائط MIME مثل: "image/png". endings: ويحدد إن كنا سنحوّل محرف نهاية السطر للكائن Blob بما يناسب نظام التشغيل الحالي (n\ أو \r\n\)، وسيأخذ افتراضيًا القيمة "transparent" أي لا تفعل شيئًا، وقد يأخذ القيمة "native" أي أَجرِ التحويل. إليك المثال التالي: // (blob) إنشاء كائن بيانات ثنائية من نص let blob = new Blob(["<html>…</html>"], {type: 'text/html'}); // لاحظ أن الوسيط الأول هو مصفوفة // إنشاء كائن بيانات ثنائية من نص ومصفوفة let hello = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" بالصيغة الثنائية let blob = new Blob([hello, ' ', 'world'], {type: 'text/plain'}); يمكن استخراج الشرائح المكونة للكائن Blob كالتالي: blob.slice([byteStart], [byteEnd], [contentType]); حيث: byteStart: بايت البداية وافتراضيًا هو البايت 0. byteEnd: البايت الأخير (ضمنًا، وافتراضيًا حتى آخر المخزن). contentType: نوع كائن blob الجديد، وسيكون افتراضيًا نفس نوع مصدر البيانات. حيث تشابه هذه الوسائط مقابلاتها في التابع array.slice، ويُسمح باستخدام القيم السالبة. الكائن Blob كعنوان لمورد URL يمكن استخدام الكائن Blob مثل عناوين للرابط التشعبي <a> ولمعرّف الصورة <img> لإظهار محتوياتهما بفضل الوسيط type، كما يمكن رفع أو تنزيل الكائنات Blob، وسيتحول النوع type بالطبع إلى Content-Type في طلبات الشبكة network requests. لنبدأ بالمثال البسيط التالي، فبالنقر على الرابط سينزل كائن Blob مولَّد آليًا يحتوي على النص "Hello world" ضمن ملف: <!-- download attribute forces the browser to download instead of navigating --> <a download="hello.txt" href='#' id="link">Download</a> <script> let blob = new Blob(["Hello, world!"], {type: 'text/plain'}); link.href = URL.createObjectURL(blob); </script> See the Pen JS-P3-01-Blob-ex1 by Hsoub (@Hsoub) on CodePen. بالإمكان أيضًا إنشاء رابط آليًا في JavaScript، ومن ثم محاكاة عملية النقر بزر الفأرة ()link.click وسيبدأ بعدها التنزيل آليًا، إليك الشيفرة التالية التي تسمح للمستخدم بتنزيل كائن Blob المولَّد آليًا دون أي شيفرة HTML: let link = document.createElement('a'); link.download = 'hello.txt'; let blob = new Blob(['Hello, world!'], {type: 'text/plain'}); link.href = URL.createObjectURL(blob); link.click(); URL.revokeObjectURL(link.href); See the Pen JS-P3-01-Blob-ex2 by Hsoub (@Hsoub) on CodePen. يأخذ التابع URL.createObjectURL الكائن Blob مثل وسيط وينشئ عنوانًا له على الشكل <blob:<origin>/<uuid. ستبدو قيمة الخاصية link.href كالتالي: blob:https://javascript.info/1e67e00e-860d-40a5-89ae-6ab0cbee6273 يُخزِّن المتصفح كل عنوان يولّده التابع URL.createObjectURL على شكل ارتباط map داخلي من الشكل "URL>Blob"، لذا ستكون هذه العناوين قصيرةً، لكنها تسمح بالوصول إلى الكائن Blob، وسيكون العنوان المولَّد -وبالتالي الرابط المتعلق به- صالحًا ضمن المستند الحالي طالما كان مفتوحًا، كما سيسمح بتحديد مرجع للكائن في كل من <img> و<a> وغيرهما من الكائنات التي تحتاج إلى عنوان URL، ومع ذلك هنالك أثر جانبي، فعلى الرغم من ارتباط Blob بالعنوان فهذا الكائن مقيم في الذاكرة، ولا يمكن للمتصفح تحرير الذاكرة المتعلقة به، وسيُزال الارتباط كليًا عند إنهاء المستند، وبالتالي ستتحرر الذاكرة المرتبطة بالكائنات Blob، لكن إن استمر التطبيق لفترة طويلة فلن تحدث هذه العملية خلال فترة وجيزة. إذا أنشأنا عنوانًا فسيبقى الكائن Blob مقيمًا في الذاكرة حتى لو لم تَعُد هناك حاجة له. يزيل التابع (URL.revokeObjectURL(url المرجع من علاقة الارتباط الداخلي بين الكائن Blob والعنوان متيحًا المجال لإزالته (إن لم يرتبط بمراجع أخرى) وتحرير الذاكرة، وقد حرصنا في المثال الأخير على استخدام Blob مرةً واحدةً للتنزيل الفوري، لذلك سنستدعي مباشرةً الآتي: URL.revokeObjectURL(link.href) لم نستدع التابع (URL.revokeObjectURL(link.href في مثالنا السابق الذي تضمن رابط HTML قابلًا للنقر، مما سيجعل الكائن Blob غير صالح، ولن يعمل العنوان بعد إلغاء الارتباط المرجعي. التحويل بين Blob ونص مشفر بطريقة base64 يمكن أن نحوّل الكائن Blob إلى سلسلة نصية بتشفير base64 لتكون يمثابة وسيلة بديلة لاستخدام التابع URL.createObjectURL، يمثل التشفير السابق البيانات الثنائية مثل نص يتكون من محارف ASCII من 0 حتى 64 قابلة للقراءة، ويتمتع بحيز أمان كبير جدًا، والأهم من ذلك إمكانية استخدام هذا التشفير مع عناوين موارد البيانات data urls، والتي لها الشكل: <data:[<mediatype>][;base64],<data يمكن استخدام هذه العناوين في أي مكان مثل العناوين النظامية. إليك طريقة تمثيل بيانات تعطي وجهًا تعبيريًا Smiley: <img src="data:image/png;base64,R0lGODlhDAAMAKIFAF5LAP/zxAAAANyuAP/gaP///wAAAAAAACH5BAEAAAUALAAAAAAMAAwAAAMlWLPcGjDKFYi9lxKBOaGcF35DhWHamZUW0K4mAbiwWtuf0uxFAgA7"> حيث سيفكك المتصفح شيفرة البيانات ويعطي كنتيجة الوجه التعبيري التالي: نستخدم الكائن FileReader المدمج لتحويل Blob إلى base64، حيث يستطيع قراءة البيانات من Blob بصيغ مختلفة، وسنتعمق في هذا الموضوع أكثر في الفصل القادم. إليك نموذجًا عن تنزيل Blob من خلال التشفير base64 هذه المرة: let link = document.createElement('a'); link.download = 'hello.txt'; let blob = new Blob(['Hello, world!'], {type: 'text/plain'}); let reader = new FileReader(); reader.readAsDataURL(blob); // استدعاء التحويل reader.onload = function() { link.href = reader.result; // عنوان بيانات link.click(); }; See the Pen JS-P3-01-Blob-ex3 by Hsoub (@Hsoub) on CodePen. يمكن استخدام إحدى الطريقتين السابقتين لتحويل كائن Blob إلى عنوان، لكن تكون عادةً الطريقة (URL.createObjectURL(blob أبسط وأسرع. 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; } Blob to data url (URL.createObjectURL(blob لا حاجة لإزالة أي شيء لابد من إزالتها إذا كنا نهتم بمقدار الذاكرة المحجوزة خسارة في الأداء والذاكرة لكائنات Blob الضخمة عند الترميز وصول مباشر إلى الكائن Blob، لا حاجة للترميز وفك الترميز تحويل الصورة إلى كائن Blob يمكن تحويل صورة أو جزء من صورة أو لقطة شاشة إلى كائن بيانات ثنائية Blob، وهذا مفيد عند تحميل هذه الصور إلى مكان ما، وتُنفّذ العمليات على الصور باستخدام العنصر <canvas>: ارسم صورةً أو جزءًا منها ضمن لوحة الرسم Canvas باستخدام التابع canvas.drawImage. استدع التابع (toBlob(callback, format, quality الذي يُنشئ وينفّذ استدعاءً محددًا عندما يكتمل. ستجد في المثال التالي صورةً نُسخت للتو، لكن يمكن اقتطاع جزء منها أو نقلها إلى لوحة رسم قبل إنشاء Blob: // خذ أية صورة let img = document.querySelector('img'); // شكّل لوحة رسم بنفس الحجم let canvas = document.createElement('canvas'); canvas.width = img.clientWidth; canvas.height = img.clientHeight; let context = canvas.getContext('2d'); // انسخ الصورة إلى اللوحة context.drawImage(img, 0, 0); // يمكن أن نعدل اللوحة كما نشاء // عملية التحويل إلى كائن ثنائي غير متزامنة canvas.toBlob(function(blob) { //الكائن جاهز سننزله let link = document.createElement('a'); link.download = 'example.png'; link.href = URL.createObjectURL(blob); link.click(); // امسح المرجع الداخلي للكائن حتى يتمكن Canvas المتصفح من إزالته URL.revokeObjectURL(link.href); }, 'image/png'); See the Pen JS-P3-01-The-clickjacking-attack-ex7 by Hsoub (@Hsoub) on CodePen. يمكن استخدام الصيغة async/await بدلًا من دوال الاستدعاء: let blob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png')); يمكن استخدام مكتبات خاصة لالتقاط صورة للشاشة، وما عليك إلا الانتقال ضمن الصفحة ورسمها ضمن لوحة <canvas>، ثم يمكن نقلها بعد ذلك إلى Blob بنفس الأسلوب السابق. التحويل من الكائن Blob إلى الكائن arrayBuffer تسمح الدالة البانية للكائن Blob بإنشاء هذا الكائن من أي شيء تقريبًا، بما في ذلك أية كائنات BufferSource، لكن لو أردنا إنجاز عملية معالجة منخفضة المستوى، فيمكننا الحصول على كائن ArrayBuffer ذو مستوى أدنى مستخدمين FileReader: // get arrayBuffer from blob let fileReader = new FileReader(); fileReader.readAsArrayBuffer(blob); fileReader.onload = function(event) { let arrayBuffer = fileReader.result; }; خلاصة تمثل الكائنات ArrayBuffer وUint8Array وغيرها من الكائنات التي تنضوي تحت المصطلح BufferSource بيانات ثنائيةً، بينما يمثل Blob بيانات ثنائيةً لها نوع، مما يجعل الكائن Blob مناسبًا لعمليات الرفع والتنزيل التي تستخدم بكثرة من المتصفح. يمكن للتوابع التي تُنفِّذ طلبات الويب web-requests مثل: XMLHttpRequest وfetch وغيرها؛ أن تعمل مع Blob كما تعمل مع غيره من أنواع البيانات الثنائية. يمكن التحويل بسهولة بين Blob وكائنات البيانات الثنائية منخفضة المستوى: يمكن التحويل بين Blob ومصفوفة النوع باستخدام الدالة البانية (...)new Blob يمكن الحصول على الكائن ArrayBuffer من الكائن Blob باستخدام التعليمة FileReader، ثم إنشاء كائن استعراض بناءً على هذا الأخير، وذلك لمعالجة البيانات الثنائية في مستويات عمل منخفضة. ترجمة -وبتصرف- للفصل Blob من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: ترميز النصوص والتعامل مع كائنات الملفات في جافاسكريبت
-
ماذا لو كانت البيانات الثنائية معلومات نصيةً؟ أي ماذا لو تلقينا ملفًا يحتوي على نص؟ سيسمح لنا الكائن TextDecoder المدمج ضمن JavaScript بقراءة البيانات وتحويلها إلى نص فعلي، بعد تزويده بالمخزن المؤقت buffer الذي يحتوي البيانات الثنائية وطريقة فك الترميز، حيث علينا أوّلًا إنشاء مفكك الترميز Decoder: let decoder = new TextDecoder([label], [options]); حيث: label: يمثل طريقة الترميز، والتي ستكون utf-8 افتراضيًا، كما تدعم كلًا من الطريقتين big5 وwindows-1251 وغيرها. options: وتضم كائنات اختياريةً هي: fatal: قيمة منطقية boolean، حيث عندما تأخذ القيمة "true"، فسترمي استثناءً عند وجود محارف غير صالحة أي لا يمكن فك ترميزها، وإلا -وهي الحالة الافتراضية- فستستبدلها بالمحرف "uFFFD\". ignoreBOM: وهي قيمة منطقية ستتجاهل BOM (وهي علامة Unicode خاصة بترتيب البايتات) التي تُستخدم نادرًا، ويحدث ذلك عندما تأخذ القيمة "true". ومن ثم عملية فك الترميز Decoding: let str = decoder.decode([input], [options]); input: ويمثل كائن BufferSource الذي يحتوي البيانات الثنائية. options: وتضم كائنًا اختياريًا هو: stream: وتأخذ القيمة "true" عندما نريد فك ترميز مجرى تدفق دخل، وذلك عند استدعاء مفكك الترميز باستمرار عن طريق مجموعات البيانات القادمة، وفي هذه الحالة قد يوضع محرف من عدة بايتات multi-byte charecter ليفصل بين هذه المجموعات، ويخبر هذا الخيار مفكك الترميز بتذكر المحارف غير المرمزة، وأن يفك ترميزها عندما تصل المجموعة الجديدة من البيانات. فمثلًا: let uint8Array = new Uint8Array([72, 101, 108, 108, 111]); alert( new TextDecoder().decode(uint8Array) ); // Hello let uint8Array = new Uint8Array([228, 189, 160, 229, 165, 189]); alert( new TextDecoder().decode(uint8Array) ); // 你好 يمكن فك ترميز جزء من المخزن المؤقت بإنشاء مصفوفة ثانوية subarray بالطريقة التالية: let uint8Array = new Uint8Array([0, 72, 101, 108, 108, 111, 0]); // النص في الوسط // أنشئ تمثيلًا جديدًا دون نسخ أي شيء let binaryString = uint8Array.subarray(1, -1); alert( new TextDecoder().decode(binaryString) ); // Hello مرمز النصوص TextEncoder مهمته معاكسة لمهمة مفكك الترميز، إذ يحوّل المُرمِّز TextEncoder النص إلى بايتات. ويُستخدم كالتالي: let encoder = new TextEncoder(); كما يدعم طريقة الترميز "utf-8" فقط. وله تابعان هما: (encode(str: ويعيد كائنًا من النوع Uint8Array انطلاقًا من نص. (encodeInto(str, destination: يُرمز str إلى destination والتي ينبغي أن تكون من النوع Uint8Array. let encoder = new TextEncoder(); let uint8Array = encoder.encode("Hello"); alert(uint8Array); // 72,101,108,108,111 الكائنان File وFileReader يرث الكائن File الكائن Blob ويُوسَّع بإمكانيات تتعلق بنظام الملفات، وتوجد طريقتان لإنشائه: باستخدام الدالة البانية بشكل مشابه للكائن Blob: new File(fileParts, fileName, [options]) حيث: fileparts: يمثل مصفوفةً قد تكون قيمها نصية أو blob أو BufferSource. fileName: اسم الملف. options: وتضم الكائن الاختياري التالي: lastModified: تاريخ آخر تعديل (تاريخ من النوع الصحيح integer). الحصول على ملف باستخدام <"input type="file> أو أسلوب الجر والإفلات، أو غيرها من الواجهات التي يؤمنها المتصفح، حيث يأخذ الملف في هذه الحالات المعلومات السابقة التي استخدمَت بمثابة وسطاء من نظام التشغيل. بما أن الكائن File يرث الكائن Blob، فله خصائصه نفسها، بالإضافة إلى الآتي: name: اسم الملف. lastModified: تاريخ آخر تعديل. إليك مثالًا يصف الحصول على الكائن File باستخدام <"input type="file>: <input type="file" onchange="showFile(this)"> <script> function showFile(input) { let file = input.files[0]; alert(`File name: ${file.name}`); // e.g my.png alert(`Last modified: ${file.lastModified}`); // e.g 1552830408824 } </script> See the Pen JS-P3-02-File-and-FileReader-ex1 by Hsoub (@Hsoub) on CodePen. الكائن FileReader يمثل FileReader كائنًا يخدم غرضًا وحيدًا هو قراءة البيانات من الكائن Blob (وبالتالي من الكائن File أيضًا)، وينقل البيانات مستخدمًا الأحداث، لأن القراءة من القرص قد تستغرق وقتًا، وصيغة الدالة البانية له هي بالشكل: let reader = new FileReader(); // لا وسطاء لهذا الكائن عدة توابع، أهمها: (readAsArrayBuffer(blob: يقرأ البيانات بالصيغة الثنائية على شكل ArrayBuffer. ([readAsText(blob, [encoding: يقرأ البيانات مثل نص مستعمل للتشفير المحدد، وهو utf-8 افتراضيًا. (readAsDataURL(blob: يقرأ البيانات الثنائية ويحولها إلى عنوان بيانات data url مشفر بطريقة base64. ()abort: يلغي العملية. نحدد التابع الذي سيستعمَل في القراءة وفقًا لصيغة البيانات التي نفضلها، وكيف سنستخدمها. readAsArrayBuffer: يُستخدم لقراءة الملفات الثنائية وتنفيذ عمليات ثنائية منخفضة المستوى، بينما نستدعي الكائن File مباشرةً دون قراءة عند تنفيذ عمليات عالية المستوى (ليست ثنائيةً) مثل اقتطاع شرائح من البيانات slicing. readAsText: يُستخدم عند قراءة ملفات نصية والحصول على قيم نصية. readAsDataURL: يُستخدم عندما نريد استخدام البيانات المقروءة في الخاصية src للمعرف img وغيره من المعرّفات، كما توجد طريقة أخرى لقراءة الملف تعرفنا عليها في مقال "كائن البيانات الثنائية Blob"، وهي استخدام التابع (URL.createObjectURL(file يمكن الاستفادة من عدة أحداث تقع أثناء عملية القراءة: loadstart: يقع عند بداية التحميل. progress: يقع خلال القراءة. load: يقع عند انتهاء القراءة دون أخطاء. abort: يقع عند استدعاء التابع ()abort. error: يقع عند حدوث خطأ. loadend: يقع عند انتهاء القراءة بنجاح أو بفشل. يمكن الوصول إلى النتيجة عند انتهاء القراءة بالشكل التالي: reader.result: للحصول على النتيجة عند نجاح العملية. reader.error: للحصول على الخطأ عند إخفاق القراءة. وأكثر الأحداث استخدامًا هما الحدثان load وerror. إليك مثالًا عن قراءة ملف: <input type="file" onchange="readFile(this)"> <script> function readFile(input) { let file = input.files[0]; let reader = new FileReader(); reader.readAsText(file); reader.onload = function() { console.log(reader.result); }; reader.onerror = function() { console.log(reader.error); }; } </script> See the Pen JS-P3-02-File-and-FileReader-ex2 by Hsoub (@Hsoub) on CodePen. خلاصة تعرفنا في هذا المقال على كيفية ترميز النصوص وفك ترميزها بجافاسكريبت، مركزين على مرمز النصوص TextEncoder، بعدها انتقلنا للحديث عن كيفية التعامل مع كائنات الملفات، حيث توصلنا إلى الآتي: يرث الكائن File والكائن Blob. للكائن File الخاصيتين name وlastModified بالإضافة إلى خصائص الكائن Blob وتوابعه، مع إمكانية القراءة من نظام الملفات المرتبط بنظام التشغيل. يمكن الحصول على الكائنات File عن طريق مُدخلات المستخدم مثل <input> أو أحداث الجر والإفلات مثل ondragend. يمكن لكائن FileReader القراءة من ملف أو Blob بإحدى التنسيقات التالية: نص باستخدام التابع readAsText. ArrayBuffer باستخدام التابع readAsArrayBuffer. عنوان بيانات بتشفير base64 باستخدام التابع readAsDataURL. لا نحتاج في بعض الحالات إلى قراءة محتويات ملف، لذا وكما فعلنا مع الكائن blob سننشئ عنوانًا قصيرًا باستخدام الأمر (URL.createObjectURL(file ونسنده إلى المعرف <a> أو <img>، وبهذا يمكن تنزيل الملف أو عرضه كصورة أو كجزء من لوحة رسم canvas. سيكون الأمر بسيطًا إذا كنا سنرسل ملفًا عبر الشبكة، إذ يقبل الكائنان XMLHttpRequest و fetch الكائن File مباشرةً. ترجمة -وبتصرف- للفصلين text decoder and text encoder وFile and FileReader من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: مصفوفة المخزن المؤقت ArrayBuffer والمصفوفات الثنائية binary arrays هياكل البيانات: الكائنات والمصفوفات في جافاسكريبت تطويع البيانات في جافاسكربت
-
- كائنات الملفات
- ترميز
-
(و 1 أكثر)
موسوم في:
-
نتعامل في تطوير الويب مع البيانات بالصيغة الثنائية لما نتعامل مع الملفات، سواءً عند إنشائها أو رفعها أو تنزيلها، كما يمكن استخدامها في عمليات معالجة الصور، ويمكن إنجاز كل ما سبق في JavaScript التي تتميز العمليات الثنائية فيها بأنها عالية الأداء على الرغم من وجود بعض الالتباسات، نظرًا لوجود العديد من الأصناف، منها ArrayBuffer وUint8Array وDataView وBlob وFile، وغيرها. لا توجد طريقة معيارية للتعامل مع البيانات الثنائية في JavaScript موازنةً بغيرها من اللغات، لكن عندما نصنف الأمور فسيبدو كل شيء بسيطًا نوعًا ما، إذ يمثل الكائن الثنائي الأساسي ArrayBuffer مثلًا مرجعًا إلى مساحة ذاكرة متجاورة وذات حجم محدد، ويمكن إنشاؤه كالتالي: let buffer = new ArrayBuffer(16); // إنشاء مخزن مؤقت حجمه 16 alert(buffer.byteLength); // 16 حيث تحجز الشيفرة السابقة حجمًا من الذاكرة مقداره 16 بايت، ويُملأ بالأصفار. فالكائن ArrayBuffer هو مساحة من الذاكرة التي لا يمكن معرفة ما يُخزّن فيها، بل هي سلسلة خام من البايتات، وللتعامل مع ArrayBuffer، سنحتاج إلى كائن عرض لا يخزن أي بيانات بمفرده، بل تنحصر مهمته في تفسير البايتات المخزنة في ArrayBuffer، ومن بين هذه الكائنات الآتي: "Uint8Array": يعامِل كل بايت من ArrayBuffer مثل عدد مستقل يأخذ قيمةً بين 0 و255، وتُمثِّل عددًا صحيحًا بلا إشارة مؤلفًا من 8-بت. "Uint16Array": يعامِل كل بايتين مثل عدد صحيح يأخذ قيمةً بين 0 و65535، وتمثِّل عددًا صحيحًا بلا إشارة مؤلفًا من 16-بت. "Uint32Array": يعامِل كل 4 بايتات مثل عدد صحيح يأخذ قيمةً بين 0 و4294967295، وتمثل عددًا صحيحًا بلا إشارة مؤلفًا من 16-بت. "Float64Array": يعامل كل 8 بايتات مثل عدد عشري ذي فاصلة عائمة floating-point، ويأخذ قيمةً بين 5.0x 10^-324 و1.8x 10^308. وبالتالي سيفسَّر الكائن المؤلف من 16 بايت -وفق الكائنات السابقة- إلى 16 عدد صغير، أو 8 أرقام أكبر (بايتان لكل عدد)، أو 4 أعداد أضخم (4 بايتات لكل عدد)، أو عددين بالفاصلة العائمة عاليي الدقة (8 بايتات لكل منهما). فالكائن ArrayBuffer هو الكائن البنيوي وأساس البيانات الخام. ولا بدّ من استخدام كائن عرض عند محاولة الكتابة ضمنه أو المرور على قيمه وكذلك لأي عملية بالأساس، مثلًا: let buffer = new ArrayBuffer(16); // إنشاء مخزن مؤقت من 16 بايت let view = new Uint32Array(buffer); // تعامل مع المخزن كعدد صحيح من 32-بت alert(Uint32Array.BYTES_PER_ELEMENT); // أربعة بايت لكل عدد alert(view.length); // 4 alert(view.byteLength); // 16 // كتابة قيمة view[0] = 123456; // المرور على قيمه for(let num of view) { alert(num); // 123456, then 0, 0, 0 (أربعة قيم ككل) } الكائن TypedArray إنّ المصطلح الرئيسي لكائنات العرض السابقة هو مصفوفات النوع TypedArray، والتي تشترك جميعها بمجموعة التوابع والخصائص، مع ملاحظة عدم وجود دالة بانية اسمها TypedArray، فهو مجرد مصطلح يمثِّل كائنات العرض السابقة التي تطبق على ArrayBuffer، وهي Int8Array وUint8Array، وغيرها من الكائنات، وسنتابع بقية القائمة لاحقًا. عندما نرى أمرًا وفق الصيغة new TypedArray، فقد يعني ذلك أيًا من new Int8Array أو new Uint8Array وغيرها، إذ تسلك مصفوفات النوع سلوك بقية المصفوفات، فلها فهارس indexes ويمكن المرور على عناصرها، كما يختلف سلوكها باختلاف نوع الوسائط التي تقبلها، وهناك 5 حالات ممكنة للوسائط: new TypedArray(buffer, [byteOffset], [length]); new TypedArray(object); new TypedArray(typedArray); new TypedArray(length); new TypedArray(); إذا أضيف وسيط من النوع ArrayBuffer فسيتشكل كائن عرض موافق له، وقد استخدمنا هذه الصيغة سابقًا. يمكن أن نختار إزاحةً byteOffset لنبدأ منها (ولها القيمة 0 افتراضيًا)، وأن نحدد حجمًا محددًا length من المخزن (وسيكون حجم المخزن الكلي هو القيمة الافتراضية)، وبهذا سنغطي جزءًا من المخزن المؤقت buffer فقط. عندما نضع وسيطًا على شكل مصفوفة Array أو أي كائن شبيه بها، فستُنشِئ مصفوفةَ نوع لها نفس الحجم وتنسخ المحتويات إليها، ويمكن استخدام ذلك لملء المصفوفة مسبقًا بالبيانات: let arr = new Uint8Array([0, 1, 2, 3]); alert( arr.length ); // 4, ينشئ مصفوفة ثنائية لها الحجم نفسه alert( arr[1] ); // 1,لها القيم نفسها (unsigned 8-bit integers) يملؤها بـ 4 بايتات من النوع لو استخدمنا مصفوفة نوع TypedArray من نوع مختلف، فستُنشَأ مصفوفة نوع لها نفس الحجم وتنسخ المحتويات إليها، وستحوَّل القيم إلى النوع الجديد خلال العملية عند الحاجة لذلك، مثلًا: let arr16 = new Uint16Array([1, 1000]); let arr8 = new Uint8Array(arr16); alert( arr8[0] ); // 1 alert( arr8[1] ); // 232, tried to copy 1000, but can't fit 1000 into 8 bits (explanations below) يُنشئ الوسيط العددي length مصفوفة نوع تحتوي عدد عناصر مساويًا لقيمته، أما طولها بالبايت فسيكون مساويًا للقيمة length مضروبةً بعدد البايتات اللازم لتمثيل أحد عناصرها TypedArray.BYTES_PER_ELEMENT، مثلًا: let arr = new Uint16Array(4); // integer إنشاء مصفوفة نوع تحوي 4 أعداد من النوع alert( Uint16Array.BYTES_PER_ELEMENT ); // بايتان لكل عدد alert( arr.byteLength ); // حجمها 8 بايت عندما لا نستخدم أي وسائط، فستتشكل مصفوفة نوع طولها 0. يمكن إنشاء مصفوفة نوع مباشرةً دون الإشارة إلى الكائن ArrayBuffer، لكن كائن العرض لا يتشكل دون وجود الكائن الأساسي ArrayBuffer، وبالتالي سيتشكل كائن التخزين المؤقت أوتوماتيكيًا في الحالات السابقة كلها عدا الحالة الأولى (عندما ننشئه نحن). سنستخدم الخصائص التالية للوصول إلى الكائن ArrayBuffer: arr.buffer: يعيد مرجعًا إلى الكائن ArrayBuffer. arr.byteLength: يعيد طول الكائن ArrayBuffer. وبالتالي يمكننا الانتقال من كائن عرض إلى آخر، مثلًا: let arr8 = new Uint8Array([0, 1, 2, 3]); // كائن عرض آخر للمعطيات نفسها let arr16 = new Uint16Array(arr8.buffer); إليك قائمةً بمصفوفات النوع: "‘Uint32Array" و"Uint16Array" و"Uint8Array": تستخدم للأعداد الصحيحة 8 و16 و32 بت. "Uint8ClampedArray": لتمثيل الأعداد الصحيحة ذات 8-بت، وتبقي القيمة 0 أو 255 إن كانت خارج المجال. "‘Int32Array" و"Int16Array" و"Int8Array": لتمثيل الأعداد الصحيحة السالبة أو الموجبة. "Float32Array" و"Float64Array": لتمثيل الأعداد العشرية ذات 32 و64 بت والتي تحمل إشارةً بطريقة الفاصلة العائمة. سلوك المصفوفات عند خروج القيم عن حدودها عندما نحاول كتابة قيمة خارج حدود مصفوفة النوع فلن يظهر لنا خطأ، لكن ستُحذف البتات الزائدة. لنحاول مثلًا وضع القيمة 256 ضمن Uint8Array، فإذا حولنا 256 إلى الصيغة الثنائية، فستكون بالشكل "100000000" (9 بتات)، لكن النوع الذي استخدمناه يحوي 8 بتات فقط، أي قيمًا بين 0 و255، وفي حال أدخلنا رقمًا أكبر من النطاق السابق فستُخزن الخانات اليمينية الثمان (الأقل قيمة) فقط ويُحذف الباقي، وبالتالي سنحصل على القيمة 0 في هذا المثال. لنأخذ العدد 257 مثالًا آخر لذلك، أينما سنحصل ثنائيًا على تسع بتات "100000001"، ستُخزّن منها الخانات اليمينية الثمان الأولى، وسنحصل بالنتيجة على القيمة 1. let uint8array = new Uint8Array(16); let num = 256; alert(num.toString(2)); // 100000000 (التمثيل الثنائي) uint8array[0] = 256; uint8array[1] = 257; alert(uint8array[0]); // 0 alert(uint8array[1]); // 1 للنوع Uint8ClampedArray ميزة خاصة، فهو يعطي القيمة 255 لأي عدد أكبر من 255، والقيمة 0 لأي عدد سالب، وهذا مفيد في معالجة الصور. توابع الصنف TypedArray لهذا الصنف نفس توابع المصفوفات مع بعض الاستثناءات الواضحة، حيث يمكننا مسح عناصره، وتنفيذ التوابع map وslice وfind وreduce؛ إلا أنه لا يمكن تنفيذ عدة أشياء، منها الآتي: لا يمكن استخدام التابع splice: لا يمكن "حذف" قيمة ما، لأنّ مصفوفات النوع هي كائنات تعرض مساحات ثابتةً متجاورةً من الذاكرة مؤقتًا، وكل ما يمكننا فعله هو إسناد القيمة 0 لها. لا يمكن استخدام التابع concat. كما يوجد تابعان إضافيان: ([arr.set(fromArr, [offset: ينقل عناصر المصفوفة fromArr إلى arr ابتداءً من الموقع offset، والذي يأخذ القيمة 0 افتراضيًا. ([arr.subarray([begin, end: يُنشئ كائن عرض جديد من نفس النوع بين القيمتين begin وend ضمنًا، وهذا يشبه التابع slice المدعوم أيضًا، لكنه لا ينسخ أي قيم، بل ينشئ كائن عرض جديد للتعامل مع جزء محدد من البيانات. تتيح لنا التوابع السابقة نسخ مصفوفات النوع ودمجها وإنشاء مصفوفات جديدة من مصفوفات موجودة مسبقًا وغيرها. الكائن DataView يمثل الكائن DataView كائن عرض عالي المرونة بلا نوع untyped للكائن ArrayBuffer، حيث يسمح بالوصول إلى البيانات ابتداءً من أي موضع وبأي تنسيق. تحدِّد الدالة البانية تنسيق البيانات في مصفوفات النوع، ويُفترض أن تكون الدالة منتظمةً ككل، وأن نحصل على العنصر الذي ترتيبه "i" باستخدام التعليمة [arr[i. يمكن الوصول إلى البيانات عند استخدام DataView من خلال توابع مثل (getUint8(i. أو (getUint16(i.، ونختار التنسيق عند استدعاء التابع وليس عند إنشائه. نستخدم DataView كالتالي: new DataView(buffer, [byteOffset], [byteLength]) buffer: ويمثل الكائن ArrayBuffer الأساسي، ولا تنشئ DataView مخزنًا مؤقتًا خاصًا بها على عكس مصفوفات النوع، وإنما ينبغي أن يكون موجودًا مسبقًا. byteOffset: ويمثل بايت البداية لكائن العرض (القيمة الافتراضية 0). byteLength: عدد بايتات كائن العرض (إلى نهاية المخزن افتراضيًا). في المثال التالي نستخرج أعدادًا بتنسيقات مختلفة من المخزن ذاته: // مصفوفة ثنائية مكونة من 4 بايتات، وستكون أكبر قيمة 255 let buffer = new Uint8Array([255, 255, 255, 255]).buffer; let dataView = new DataView(buffer); // الحصول على رقم 8 بت عند الإزاحة 0 alert( dataView.getUint8(0) ); // 255 // الحصول على رقم 16 بت عند الإزاحة 0 alert( dataView.getUint16(0) ); // 65535 (biggest 16-bit unsigned int) // الحصول على رقم 32 بت عند الإزاحة 0 alert( dataView.getUint32(0) ); // 4294967295 (biggest 32-bit unsigned int) dataView.setUint32(0, 0); // إعطاء القيمة 0 لرقم من 4 بايت، وبالتالي تصفير المخزن تَظهر قدرة DataView عند تخزين أرقام ذات تنسيق مختلف في المخزن المؤقت نفسه، فلو خزّنّا مثلًا سلسلةً من البيانات مؤلفةً من الأزواج (عدد صحيح 16 بت، عدد عشري عائم 32 بت)، فسيسمح لنا DataView بالوصول إليها بكل سهولة. الخلاصة يمثل الكائن الأساسي ArrayBuffer مرجعًا إلى حجم ثابت ومتجاور في الذاكرة. نحتاج إلى كائن عرض لتنفيذ أي عملية على ArrayBuffer، ومن هذه الكائنات: مصفوفة النوع، والتي يمكن أن تكون: Uint8Array وUint16Array وUint32Array: وهي أعداد بلا إشارة من 8 أو 16 أو 32 بت. Uint8ClampedArray: أعداد صحيحة من 8 بت، مع اجتزاء البتات التي تزيد عن 8. Int8Array وInt16Array وInt32Array: أعداد صحيحة ذات إشارة. Float32Array وFloat64Array: أعداد بفاصلة عائمة من 32 أو 64 بت، وذات إشارة. الكائن DataView: ويستخدم التوابع لتحديد تنسيق الأعداد في مخزن مؤقت للبيانات، مثل (getUint8(offset. في معظم الأحيان يمكن العمل مباشرةً على مصفوفات النوع محتفظين بالكائن ArrayBuffer خلف الستار، ويمكن الوصول إليه باستخدام الأمر buffer.، كما يمكن إنشاء كائن عرض جديد عند الحاجة. يوجد مصطلحان إضافيان يستخدَمان في وصف التوابع التي تتعامل مع البيانات الثنائية: ArrayBufferView: وهو مصطلح يغطي كل أنواع كائنات العرض. BufferSource: مصطلح يغطي كلًا من ArrayBuffer أو ArrayBufferView، وسنرى هذه المصطلحات في الفصول القادمة، BufferSource من المصطلحات الشائعة، ويعني "أي نوع من البيانات الثنائية" سواءً كانت الكائن ArrayBuffer أو أي كائن عرض له. مهمات للإنجاز ضم مصفوفات النوع Concatenate typed arrays لنفترض وجود مصفوفة من النوع Uint8Array، يمكن استخدام الدالة (concat(arrays لضمّ عدة مصفوفات في مصفوفة واحدة. الحل إليك الحل المتمثل بالشيفرة التالية: function concat(arrays) { // مجموع أطوال كل مصفوفة let totalLength = arrays.reduce((acc, value) => acc + value.length, 0); if (!arrays.length) return null; let result = new Uint8Array(totalLength); // انسخ محتوى كل مصفوفة إلى الناتج result // يُنسخ محتوى المصفوفة اللاحقة على الجانب الأيمن للمصفوفة السابقة let length = 0; for(let array of arrays) { result.set(array, length); length += array.length; } return result; } افتح الحل في تجربة حية ترجمة -وبتصرف- للفصل Array buffer, Binary data من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: هجمات الاختطاف بالنقر Clickjacking في جافاسكريبت هياكل البيانات: الكائنات والمصفوفات في جافاسكريبت معالجة المصفوفات Arrays في جافا المصفوفات ثنائية البعد Two-dimensional Arrays في جافا البحث والترتيب في المصفوفات Array في جافا
-
الآن وقد انتهينا من إعداد بيئة التطوير، سنتعرف على أساسيات React Native وسنبدأ بتطوير تطبيقنا. سنتعلم في هذا الفصل كيف سنبني واجهة مستخدم بالاستعانة بمكوّنات React Native البنيوية، وكيف سننسق مظهر هذه المكونات البنيوية، وكيف سننتقل من واجهة عرض إلى أخرى، كما سنتعلم كيف سندير حالة النماذج بفعالية. المكونات البنيوية تعلمنا في الأقسام السابقة طريقة استخدام React في تعريف المكوّنات كدوال تتلقى الخصائص كوسطاء وتعيد شجرة من عناصر React. تُمثَّل هذه الشجرة عادةً باستخدام شيفرة JSX. كما رأينا كيفية استخدام المكتبة ReactDOM في بيئة المتصفح من أجل تحويل المكوّنات إلى شجرة DOM قابلة للتصيير ضمن المتصفح. لاحظ الشيفرة التالية التي تمثل مكوّنًا بسيطًا: import React from 'react'; const HelloWorld = props => { return <div>Hello world!</div>; }; يعيد المكوّن HelloWorld عنصر <div> أُنشئ باستخدام عبارة JSX. وتذكر أن عبارة JSX ستُصرّف إلى استدعاءات للتابع React.createElement كالتالي: React.createElement('div', null, 'Hello world!'); سيُنشئ سطر الشيفرة السابق عنصر <div> دون أية خصائص props وله عنصر ابن وحيد هو النص "Hello World". وعندما يُصيَّر هذا المكوّن إلى عنصر DOM جذري بتطبيق التابع ReactDOM.render، سيُصيَّر العنصر <div> إلى عنصر DOM المقابل. وهكذا نرى أن React لا ترتبط ببيئة محددة كبيئة المتصفح، لكن وبالاستعانة بمكتبات مثل ReactDOM ستتمكن من تصيير مجموعة من المكوّنات المعرّفة مسبقًا كعناصر DOM في بيئة محددة. ندعو هذه المكوّنات المعرّفة مسبقًا في React Native بالمكوّنات البنيوية core components. فالمكوّنات البنيوية هي مجموعة مكوّنات تؤمنها React Native قادرة على استخدام المكونات الأصلية لمنصة خلف الستار. لننفذ المثال السابق باستخدام React Native: import React from 'react'; import { Text } from 'react-native'; const HelloWorld = props => { return <Text>Hello world!</Text>;}; لاحظ كيف أدرجنا المكوّن Text من React Native، واستبدلنا العنصر<div> بالعنصر <text>. ستجد الكثير من عناصر DOM المقابلة لعناصر React Native، وسنستعرض بعض الأمثلة التي استقيناها من توثيق المكونات البنيوية: المكوّن Text: ويعتبر المكون الوحيد من مكونات React Native والذي يمتلك أبناء على شكل قيم نصية، ويشابه العناصر <h1> و<strong>. المكوّن View: ويمثل وحدات البناء الرئيسية لواجهة المستخدم، ويشابه العنصر <div>. المكوّن TextInput: وهو مكوّن لحقل نصي، ويشابه العنصر <input>. المكوّن TouchableWithoutFeedback: وغيره من المكونات القابلة للمس Touchable ويستخدم لالتقاط مختلف حوادث الضغط والنقر، ويشابه العنصر <button>. هنالك بعض الاختلافات الواضحة بين المكوّنات البنيوية وعناصر DOM. يظهر الاختلاف الأول بأن المكوّن Text هو المكوّن الوحيد الذي يمتلك أبناء على شكل قيم نصية في React Native. وبمعنًى آخر، لن تستطيع استبدال هذا المكوّن بالمكوّن View مثلًا في المثال الذي أوردناه سابقًا. يتعلق الاختلاف الثاني بمعالجات الأحداث. فقد اعتدنا عند العمل مع عناصر DOM على إضافة معالج أحداث مثل onClick إلى أية عناصر مثل <div> و<btton>. لكن عليك قراءة توثيق الواجهة البرمجية في React Native لمعرفة أية معالجات وأية خصائص أخرى سيقبلها المكوّن الذي تستخدمه. إذ تؤمن -على سبيل المثال- عائلة مكوّنات اللمس Touchable components القدرة على التقاط الحركات التي تُرسم على الشاشة، كما يمكنها إظهار استجابات محددة عند تمييز هذه الحركات. ومن هذه المكوّنات أيضًا TouchableWithoutFeedback الذي يقبل الخاصية onPress: import React from 'react'; import { Text, TouchableWithoutFeedback, Alert } from 'react-native'; const TouchableText = props => { return ( <TouchableWithoutFeedback onPress={() => Alert.alert('You pressed the text!')} > <Text>You can press me</Text> </TouchableWithoutFeedback> ); }; سنبدأ الآن في هيكلة مشروعنا بعد أن اطلعنا على أساسيات المكونات البنيوية. لننشئ مجلدًا باسم "src" في المجلد الجذري للمشروع، ولننشئ ضمنه مجلدًا آخر باسم "components". أنشئ ملفًا باسم "Main.js" وضعه في المجلد "components"، على أن يحتوي هذا الملف الشيفرة التالية: import React from 'react'; import Constants from 'expo-constants'; import { Text, StyleSheet, View } from 'react-native'; const styles = StyleSheet.create({ container: { marginTop: Constants.statusBarHeight, flexGrow: 1, flexShrink: 1, }, }); const Main = () => { return ( <View style={styles.container}> <Text>Rate Repository Application</Text> </View> ); }; export default Main; لنستخدم الآن المكوّن Main ضمن المكوّن App الموجود في الملف "App.js" والذي يقع بدوره في المجلد الجذري للمشروع. استبدل محتويات الملف السابق بالشيفرة التالية: import React from 'react'; import Main from './src/components/Main'; const App = () => { return <Main />; }; export default App; إعادة تحميل التطبيق يدويا رأينا كيف يعيد Expo تحميل التطبيق تلقائيًا عندما نجري تغييرات على الشيفرة. لكن قد تفشل عملية إعادة التحميل تلقائيًا في حالات معينة، وسنضطر إلى إعادة تحميل التطبيق يدويًا. تُنجز هذه العملية من خلال قائمة المطوّر التي يمكن الوصول إليها بهزّ جهازك أو باختيار الرابط "Shake Gesture" ضمن قائمة العتاد الصلب في محاكي iOS. كما يمكن أن تستخدم أيضًا الاختصار "D⌘" عندما يعمل تطبيقك على محاكي iOS، أو الاختصار "M⌘" عندما يعمل التطبيق على مقلّد Android في نظام macOS، والاختصار "Ctrl+M" لمقلد Android في نظامي Linux وWindows. اضغط على "Reload" عندما تفتح قائمة المطوّر لإعادة تحميل التطبيق. لن تحتاج بعد ذلك إلى إعادة تحميل التطبيق، بل ستحدث العملية تلقائيًا. التمرين 10.3 قائمة المستودعات المقيمة سننجز في هذه التمارين النسخة الأولى من تطبيق قائمة المستودعات المُقيَّمة. ينبغي أن تتضمن القائمة الاسم الكامل للمستودع ووصفه ولغته وعدد التشعبات وعدد النجمات ومعدل التقييم وعدد التقييمات. ولحسن الحظ، تؤمن مكوّنًا مفيدًا لعرض قائمة من البيانات وهو FlatList. انجز المكوّنين RepositoryList وRepositoryItem في الملفين "RepositoryList.jsx" و"RepositoryItem.jsx" وضعهما في المجلد "componenets". ينبغي أن يصيّر المكوّن RepositoryList المكوّن البنيوي FlatList وأن يصيّر المكوّن RepositoryItem عنصرًا واحدًا من القائمة استخدم ذلك كأساس للملف "RepositoryItem.jsx": import React from 'react'; import { FlatList, View, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ separator: { height: 10, }, }); const repositories = [ { id: 'jaredpalmer.formik', fullName: 'jaredpalmer/formik', description: 'Build forms in React, without the tears', language: 'TypeScript', forksCount: 1589, stargazersCount: 21553, ratingAverage: 88, reviewCount: 4, ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/4060187?v=4', }, { id: 'rails.rails', fullName: 'rails/rails', description: 'Ruby on Rails', language: 'Ruby', forksCount: 18349, stargazersCount: 45377, ratingAverage: 100, reviewCount: 2, ownerAvatarUrl: 'https://avatars1.githubusercontent.com/u/4223?v=4', }, { id: 'django.django', fullName: 'django/django', description: 'The Web framework for perfectionists with deadlines.', language: 'Python', forksCount: 21015, stargazersCount: 48496, ratingAverage: 73, reviewCount: 5, ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/27804?v=4', }, { id: 'reduxjs.redux', fullName: 'reduxjs/redux', description: 'Predictable state container for JavaScript apps', language: 'TypeScript', forksCount: 13902, stargazersCount: 52869, ratingAverage: 0, reviewCount: 0, ownerAvatarUrl: 'https://avatars3.githubusercontent.com/u/13142323?v=4', }, ]; const ItemSeparator = () => <View style={styles.separator} />; const RepositoryList = () => { return ( <FlatList data={repositories} ItemSeparatorComponent={ItemSeparator} // other props /> ); }; export default RepositoryList; لا تبدّل محتويات المتغيّر repositories، فمن المفترض أن يحتوي على كل ما تحتاج إليه لإكمال التمرين. صيّر المكوّن ReositoryList ضمن المكوّن Main الذي أضفناه سابقّا إلى الملف "Main.jsx". ستبدو قائمة الكتب المُقَّيمة مشابهةً للتالي: تنسيق التطبيق بعد أن تعرفنا على آلية عمل المكوّنات، وكيفية استخدامها في بناء واجهة مستخدم بسيطة، لا بد من الالتفات إلى طرق تنسيق مظهر هذه التطبيقات. لقد رأينا في القسم 2 أنه من الممكن في بيئة المتصفح تعريف خصائص لتنسيق المكوّنات باستخدام CSS. كما رأينا أن هناك طريقتان لإنجاز ذلك: إما التنسيق ضمن السياق inline باستخدام الخاصية style، أو عن طريق محددات التنسيق المناسبة selectors المكتوبة في ملف CSS منفصل. ستجد تشابهًا واضحًا في الطريقة التي ترتبط بها خصائص التنسيق بمكونات React Native البنيوية والطريقة التي ترتبط بها هذه الخصائص بعناصر DOM. فمعظم مكوّنات React Native البنيوية تقبل الخاصية style، وتقبل هذه الخاصية بدورها كائنًا يحمل خصائص تنسيقٍ مع قيمها. وتتطابق في أغلب الأحيان خصائص التنسيق هذه مع مقابلاتها في CSS، مع اختلاف بسيط وهو كتابة أسماء هذه الصفات بطريقة سنام الجمل camelCase. أي ستكتب خصائص مثل "padding-top" على الشكل "paddingTop". وسيوضح المثال التالي طريقة استخدام الخاصية style: import React from 'react'; import { Text, View } from 'react-native'; const BigBlueText = () => { return ( <View style={{ padding: 20 }}> <Text style={{ color: 'blue', fontSize: 24, fontWeight: '700' }}> Big blue text </Text> </View> ); }; بالإضافة إلى أسماء الخصائص، ستلاحظ اختلافًا آخر في المثال السابق. إذ ترتبط بخصائص CSS التي تقبل قيمًا عددية واحدات مثل px أو % أو em أو rem. بينما لا تمتلك الخصائص التي تتعلق بالأبعاد مثل width وheight وpadding وكذلك font-size في React Native أية واحدات. تمثل هذه القيم العددية بيكسلات مستقلة عن الكثافة density-independent pixels. ولو تساءلت عن خصائص التنسيق المتاحة لمكوّن بنيوي محدد، ستجد الجواب في أوراق التنسيق الزائفة Styling Cheat Sheet لإطار عمل React Native. وبشكل عام، لا يعتبر تنسيق المكوًنات باستخدام الخاصية style مباشرة فكرة جيدة، لأنها ستجعل شيفرة المكونات غير واضحة. ويفضّل بدلًا عن ذلك، تعريف التنسيق خارج دالة المكوّن باستخدام التابع StyleSheet.create. يقبل هذا التابع وسيطًا واحدًا على شكل كائن يتألف بحد ذاته من كائنات تنسيق مسماة ومحددة القيم، كما ينشئ مرجعًا للمكوّن البنيوي StyleSheet من هذا الكائن. سنستعرض فيما يلي طريقة إعادة كتابة المثال السابق باستخدام التابع StyleSheet.create: import React from 'react'; import { Text, View, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ container: { padding: 20, }, text: { color: 'blue', fontSize: 24, fontWeight: '700', },}); const BigBlueText = () => { return ( <View style={styles.container}> <Text style={styles.text}> Big blue text <Text> </View> ); }; أنشأنا في الشيفرة السابقة كائني تنسيق مسمّيين هما: styles.container وstyles.text. يمكننا الوصول ضمن مكوّن إلى كائن تنسيق محدد بنفس الطريقة التي نصل بها إلى كائن صرف. تقبل الخاصية style مصفوفة من الكائنات بالإضافة إلى الكائنات المفردة. وتتالى كائنات التنسيق في المصفوفة من اليسار إلى اليمين، وبالتالي سيكون للتنسيقات التي تظهر لاحقًا أولوية التنفيذ. كما يمكن تطبيق ذلك بشكل عودي recursive، إذ يمكن على سبيل المثال، أن تحتوي مصفوفة على مصفوفة أخرى لكائنات التنسيق وهكذا. إن كانت إحدى قيم المصفوفة غير صالحة أو كانت "null" أو "undefined"، سيتم تجاهل هذه القيم. وهكذا يصبح من السهل تعريف تنسيقات شرطية كتلك التي تُبنى على أساس قيمة خاصية مثلًا، وإليكم مثالًا عن ذلك: import React from 'react'; import { Text, View, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ text: { color: 'grey', fontSize: 14, }, blueText: { color: 'blue', }, bigText: { fontSize: 24, fontWeight: '700', }, }); const FancyText = ({ isBlue, isBig, children }) => { const textStyles = [ styles.text, isBlue && styles.blueText, isBig && styles.bigText, ]; return <Text style={textStyles}>{children}</Text>; }; const Main = () => { return ( <> <FancyText>Simple text</FancyText> <FancyText isBlue>Blue text</FancyText> <FancyText isBig>Big text</FancyText> <FancyText isBig isBlue> Big blue text </FancyText> </> ); }; استخدمنا في الشيفرة السابقة العامل && في العبارة condition && exprIfTrue. سيعطي تنفيذ هذه العبارة قيمة "experIfTrue" إن كانت قيمة "condition" هي "true"، وإلا سيعطي القيمة "condition" والتي ستأخذ في هذه الحالة القيمة "false". هذه الطريقة المختصرة شائعة جدًا ومفيدة جدًا. كما يمكنك استخدام العامل الشرطي الثلاثي ":?" كالتالي: condition ? exprIfTrue : exprIfFalse سمات بواجهة مستخدم متناسقة لنبقى في مجال تنسيق التطبيقات لكن من منظورٍ أوسع قليلًا. لقد استخدم معظمنا تطبيقات متنوعة، وربما سنتفق أن ما بجعل واجهة المستخدم جيدة هو تناسق العناصر. فمظهر مكوّنات واجهة المستخدم كحجم الخط وعائلته ولونه ستتبع أسلوبًا متناسقًا. ولإنجاز ذلك لابد من ربط قيم خصائص التنسيق المختلفة بمعاملات، وهذا ما يعرف ببناء السمات theming. ربما يكون مصطلح "بناء السمات" مألوفًا لمستخدمي مكتبات بناء واجهات المستخدم المشهورة مثل Bootstrap وMaterial UI. وعلى الرغم من اختلاف طرق بناء السمات، إلا أنّ الفكرة الرئيسية ستبقى دائمًا استخدام المتغيرات مثل colors.primary بدلًا من الأرقام السحرية مثل 0366dd# عند تعريف التنسيق، وهذا ما سيقود إلى زيادة في الاتساق والمرونة. دعونا نلقي نظرة على طريقة التنفيذ العملي لبناء السمات في تطبيقنا. سنستخدم في عملنا العديد من النصوص وبتنسيقات مختلفة مثل حجم الخط ولونه. وطالما أنّ React Native لا تدعم التنسيق العام، لا بد من إنشاء مكوّنات Text خاصة بنا لنبقي المحتوى النصي متسقًا. لنبدأ إذًا بإضافة شيفرة كائن "تهيئة التنسيق" التالية إلى الملف "theme.js" الموجود في المجلد "src": const theme = { colors: { textPrimary: '#24292e', textSecondary: '#586069', primary: '#0366d6', }, fontSizes: { body: 14, subheading: 16, }, fonts: { main: 'System', }, fontWeights: { normal: '400', bold: '700', }, }; export default theme; علينا الآن إنشاء الكائن Text الفعلي الذي يستخدم قواعد تهيئة التنسيق. لننشئ إذًا الملف "Text.jsx" في المجلد "components" الذي يحتوي ملفات بقية المكوّنات، ولنضع فيه الشيفرة التالية: import React from 'react'; import { Text as NativeText, StyleSheet } from 'react-native'; import theme from '../theme'; const styles = StyleSheet.create({ text: { color: theme.colors.textPrimary, fontSize: theme.fontSizes.body, fontFamily: theme.fonts.main, fontWeight: theme.fontWeights.normal, }, colorTextSecondary: { color: theme.colors.textSecondary, }, colorPrimary: { color: theme.colors.primary, }, fontSizeSubheading: { fontSize: theme.fontSizes.subheading, }, fontWeightBold: { fontWeight: theme.fontWeights.bold, }, }); const Text = ({ color, fontSize, fontWeight, style, ...props }) => { const textStyle = [ styles.text, color === 'textSecondary' && styles.colorTextSecondary, color === 'primary' && styles.colorPrimary, fontSize === 'subheading' && styles.fontSizeSubheading, fontWeight === 'bold' && styles.fontWeightBold, style, ]; return <NativeText style={textStyle} {...props} />; }; export default Text; وهكذا نكون قد أنجزنا مكوّن النص الخاص بنا من حيث اللون وحجم الخط وكثافته لنستخدمه في أي مكان من تطبيقنا. يمكننا الحصول على تغييرات مختلفة في هذا المكوّن باستخدام خصائص مختلفة كالتالي: import React from 'react'; import Text from './Text'; const Main = () => { return ( <> <Text>Simple text</Text> <Text style={{ paddingBottom: 10 }}>Text with custom style</Text> <Text fontWeight="bold" fontSize="subheading"> Bold subheading </Text> <Text color="textSecondary">Text with secondary color</Text> </> ); }; export default Main; لك كامل الحرية في توسيع أو تعديل هذا المكوّن إن أردت. وقد يكون إنشاء مكوّنات نصية قابلة لإعادة الاستخدام تستخدم المكوّن Text مثل Subheading فكرة جيدة. حاول أيضًا أن توسع أو تعدل قواعد تهيئة السمة كلما تقدمنا في بناء تطبيقنا. استخدام flexbox في تصميم واجهة التطبيق سنعرّج أخيرًا في عرضنا لمواضيع تنسيق التطبيقات على تصميم الواجهات باستخدام flexbox. يعرف المطورون الذين ألفوا اللغة CSS أنّ flexbox لا يتعلق فقط بإطار العمل React Native، فقد يستعمل في حالات عدة لتطوير صفحات الويب أيضًا. وعمليًا لن يتعلم المطورون الذين يعرفون آلية عمل flexbox الكثير من هذه الفقرة، لكن دعونا على الأقل نراجع أو نتعلم أساسيات flexbox. flexbox هو كيان لتصميم الواجهات يتألف من مكونين منفصلين هما: حاوية flex وضمنها مجموعة من عناصر flex. تمتلك حاوية flex مجموعة من الخصائص التي تتحكم بتوزيع العناصر. ولجعل مكوّن ما حاوية flex، لا بد من أن يمتلك خاصية التنسيق display وقد أسندت لها القيمة "flex" وهي القيمة الافتراضية لهذه الخاصية. تظهر الشيفرة التالية طريقة تعريف حاوية flex: import React from 'react'; import { View, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ flexContainer: { flexDirection: 'row', }, }); const FlexboxExample = () => { return <View style={styles.flexContainer}>{/* ... */}</View>; }; ربما تكون الخصائص التالية أهم خصائص حاوية flex: الخاصية flexDirection: وتتحكم بالاتجاه الذي ستأخذه عناصر flex عند ترتيبها ضمن الحاوية. وتأخذ هذه الخاصية القيم row وrow-reverse وcoulmn وcolumn-reverse. سيرتب row عناصر flex من اليسار إلى اليمين، بينما سيرتبها column من الأعلى للأسفل (القيمة الافتراضية). وستعكس القيم ذات اللاحقة reverse- اتجاه ترتيب العناصر. الخاصية justifyContent: وتتحكم بمحاذاة عناصر flex على طول المحور الرئيسي للترتيب الذي تحدده الخاصية السابقة. تأخذ هذه الخاصية القيم flex-start (القيمة الافتراضية) وflex-end وcenter وspace-between وspace-around وspace-evenly. الخاصية alignItems: وتقوم بوظيفة الخاصية السابقة لكن على المحور الآخر لترتيب العناصر. تأخذ هذه الخاصية القيم flex-start وflex-end وcenter وbaseline و stretch (القيمة الافتراضية). لننتقل الآن إلى عناصر flex والتي أشرنا سابقًا على أنها محتواة ضمن حاوية flex. تمتلك هذه العناصر خصائص تتحكم بتنسيق وسلوك هذه العناصر بالنسبة لبعضها في نفس الحاوية. ولكي يكون المكوّن عنصر flex يكفي أن نجعله ابنا مباشرًا لحاوية flex: import React from 'react'; import { View, Text, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ flexContainer: { display: 'flex', }, flexItemA: { flexGrow: 0, backgroundColor: 'green', }, flexItemB: { flexGrow: 1, backgroundColor: 'blue', }, }); const FlexboxExample = () => { return ( <View style={styles.flexContainer}> <View style={styles.flexItemA}> <Text>Flex item A</Text> </View> <View style={styles.flexItemB}> <Text>Flex item B</Text> </View> </View> ); }; إنّ أكثر خصائص العناصر استخدامًا هي flexGrow. حيث تقبل هذه الخاصية قيمة تحدد قدرة العنصر على النمو إن دعت الحاجة. فلو كانت قيمة هذه الخاصية لكل عناصر flex في حاوية هي 1، ستتقاسم هذه العناصر المساحة المتاحة للحاوية بالتساوي. بينما لو كانت قيمتها 0 لعنصر ما، فسيأخذ المساحة التي يتطلبها محتواه فقط، وسيترك المساحة الباقية لبقية العناصر. ننصحك بدراسة المثال النموذجي Flexbox example، والذي يشكّل مثالًا رئيسًا أكثر تفاعلية لاستخدام flexbox في تنفيذ بطاقة بسيطة لها حاشية علوية وجسم وحاشية سفلية. إقرأ تاليًا المقالة تعرف على CSS Flexbox وأساسيات استعماله لهيكلة صفحات الويب التي تحتوي على أمثلة مصوّرة شاملة عن استخدام flex. ومن الجيد أيضًا أن تجرب خصائص flexbox ضمن أرضية العمل Flexbox Playground لترى كيفية تأثير الخصائص على تصميم الواجهة. وتذكر أنّ أسماء الخصائص في React Native هي نفسها في CSS ما عدا نقطة التسمية بأسلوب سنام الجمل، إلا أن قيم هذه الخاصيات مثل flex-start وflex-end ستبقى نفسها. التمارين 10.4 - 10.5 10.4 شريط تنقل للتطبيق سنحتاج قريبًا إلى التنقل بين الواجهات المختلفة للتطبيق، لذلك سنحتاج إلى شريط تنقل بين هذه الواجهات. أنشئ الملف "AppBar.jsx" وضعه في المجلد "components" ثم ضع الشيفرة التالية ضمنه: import React from 'react'; import { View, StyleSheet } from 'react-native'; import Constants from 'expo-constants'; const styles = StyleSheet.create({ container: { paddingTop: Constants.statusBarHeight, // ... }, // ... }); const AppBar = () => { return <View style={styles.container}>{/* ... */}</View>; }; export default AppBar; وبما أنّ المكوّن AppBar سيمنع شريط الحالة من تغطية المحتوى، يمكن إزالة خاصية التنسيق marginTop التي وضعناها للمكوّنMain في الملف "Main.jsx". ينبغي أن يضم المكوّن AppBar نافذة عنوانها "Repositories". اجعل النافذة مستجيبة للمس touchable باستخدام المكوّن TouchableWithoutFeedback، لكن لا حاجة الآن لتعالج الحدث onPress. أضف المكوّن AppBar إلى المكوّن Main ، بحيث يبقى المكوّن الأعلى في الواجهة. من المفترض أن يبدو المكوّن AppBar شبيهًا للتالي: إن لون خلفية التطبيق كما تظهره الصورة السابقة هو 24292e#، لكن يمكنك استخدام اللون الذي تشاء. يمكنك أيضًا تهيئة لون خلفية التطبيق ضمن قواعد تهيئة السمة، وسيصبح تغيير اللون أمرًا سهلًا إن احتجت لذلك. ومن الجيد أيضًا فصل شيفرة النافذة الموجودة في المكوّن AppBar لتصبح ضمن مكوّن مستقل يمكن أن تسميه AppBarTab، سيسهّل ذلك إضافة نوافذ أخرى مستقبلًا. 10.5 قائمة محسنة المظهر للمستودعات المقيمة تبدو النسخة الحالية من تطبيق قائمة المستودعات المقيَّمة كئيبة المظهر. عدّل المكوّن RepositoryListItem ليعرض أيضًا الصورة التمثيلية لمؤلف المستودع. ويمكنك إنجاز ذلك باستخدام المكوّن Image. ينبغي إظهار القيم العددية كعدد النجمات وعدد التشعبات التي تزيد عن 1000 برقم مرتبة الآلاف وبدقة فاصلة عشرية واحدة فقط تليها اللاحقة "k". فلو كان مثلًا عدد التشعبات هو 8439، ستُعرض على الشاشة القيمة "8.4k". حسِّن أيضًا المظهر الكلي للمكوّن بحيث تبدو قائمة المستودعات مشابهة للتالي: إنّ لون الخلفية للمكوّن Main في الصورة السابقة هو e1e4e8# بينما ستظهر خلفية للمكوّن RepositoryListItem باللون الأبيض. كما يأخذ لون خلفية محدد اللغة القيمة 0366d6 #، وهي نفسها قيمة المتغيّر colors.primary في إعدادات تهيئة السمة. لا تنس الاستفادة من المكوّن Text الذي أنجزناه سابقًا. افصل المكوّن RepositoryListItem -إن دعت الحاجة- إلى مكوّنات أصغر. مسارات التنقل في React Native سرعان ما سنحتاج إلى آلية للانتقال بين مختلف واجهات العرض في التطبيق كواجهة المستودعات وواجهة تسجيل الدخول، وذلك عندما نبدأ بتوسيعه. وكنا قد تعلمنا في القسم 7 استخدام المكتبة React router لتنفيذ مهمة التنقل والتوجه في تطبيقات الويب. تختلف آلية التنقل في React Native قليلًا عن التنقل في تطبيقات الويب. ويظهر الاختلاف الرئيسي في عدم القدرة على الإشارة المرجعية للصفحات من خلال العناوين "URL" التي نكتبها في شريط تنقل المتصفح، ولا يمكننا كذلك التنقل بين الصفحات التالية والسابقة اعتمادًا على الواجهة البرمجية لتاريخ تنقلات المستخدم history API في المتصفح. لكن تبقى هذه الإشكالية مسألة تتعلق بواجهة التنقل التي نستخدمها. يمكننا في ReactNative استخدام متحكمات المسار البنيوية React Router's core بالكامل كالخطافات والمكوّنات. لكن علينا استبدال BrowserRouter الذي يستخدم في بيئة المتصفح بمقابله NativeRouter الملائم لإطار عمل React Native والذي تؤمنه المكتبة react-router-native. لنثبّت إذًا هذه المكتبة: npm install react-router-native سيؤدي استخدام المكتبة react-router-native إلى توقف عرض التطبيق ضمن متصفح ويب المنصة Expo، بينما ستعمل بقية طرق عرض التطبيق بشكل جيد. يمكن إصلاح هذه المشكلة بتوسيع إعدادات تهيئة المجمع webpack للمنصة Expo بحيث ينقل transpile الشيفرة المصدرية للمكتبة باستخدام Babel. ولتوسيع إعدادات التهيئة، لابدّ من تثبيت المكتبة expo/webpack-config@: npm install @expo/webpack-config --save-dev سننشئ بعد ذلك الملف "webpack.config.js" في المجلد الجذري للمشروع وضمنه الشيفرة التالية: const path = require('path'); const createExpoWebpackConfigAsync = require('@expo/webpack-config'); module.exports = async function(env, argv) { const config = await createExpoWebpackConfigAsync(env, argv); config.module.rules.push({ test: /\.js$/, loader: 'babel-loader', include: [path.join(__dirname, 'node_modules/react-router-native')], }); return config; }; أعد تشغيل أدوات تطوير لكي تُطبق إعدادات تهيئة webpack الجديدة، وستجد أن المشكلة قد حُلّت. افتح الآن الملف "App.js" وأضف المكوّن NativeRouter إلى المكوّن App: import React from 'react'; import { NativeRouter } from 'react-router-native'; import Main from './src/components/Main'; const App = () => { return ( <NativeRouter> <Main /> </NativeRouter> ); }; export default App; بعد أن أنشأنا متحكمًا بالمسار، سنضيف أول مسار تنقل (وجهة- Route) في المكوّن Main، وذلك ضمن الملف "Main.jsx": import React from 'react'; import { StyleSheet, View } from 'react-native'; import { Route, Switch, Redirect } from 'react-router-native'; import RepositoryList from './RepositoryList'; import AppBar from './AppBar'; import theme from '../theme'; const styles = StyleSheet.create({ container: { backgroundColor: theme.colors.mainBackground, flexGrow: 1, flexShrink: 1, }, }); const Main = () => { return ( <View style={styles.container}> <AppBar /> <Switch> <Route path="/" exact> <RepositoryList /> </Route> <Redirect to="/" /> </Switch> </View> ); }; export default Main; التمرينان 10.6 - 10.7 10.6 واجهة عرض تسجيل الدخول سننفذ قريبًا نموذجًا لكي يصبح المستخدم قادرًا على تسجيل دخوله إلى التطبيق. لكن علينا قبل ذلك أن ننشئ واجهة عرض يمكن الوصول إليها من شريط التطبيق. أنشئ الملف "Signin.jsx" في المجلد "components"، ثم ضع الشيفرة التالية ضمنه: import React from 'react'; import Text from './Text'; const SignIn = () => { return <Text>The sign in view</Text>; }; export default SignIn; هيئ مسارًا وجهته المكوّن Signin وضعه في المكوًن Main. أضف بعد ذلك نافذة عنوانها النص "Sign in" في أعلى شريط التطبيق إلى جوار النافذة "Repositories". ينبغي أن يكون المستخدم قادرًا على التنقل بين النافذتين السابقتين بالضغط على عنوان كل نافذة 10.7 شريط تطبيق قابل للتمرير scrollable طالما أننا سنضيف نوافذ جديدة إلى شريط التطبيق، فمن الجيد أن يكون قابلًا للتمرير أفقيًا عندما لا تتسع الواجهة لكل النوافذ. سنستخدم في ذلك المكوّن الأنسب لأداء المهمة وهو ScrollView. ضع النوافذ الموجودة في المكوّن AppBar ضمن المكوّن ScrollView: const AppBar = () => { return ( <View style={styles.container}> <ScrollView horizontal>{/* ... */}</ScrollView> </View> ); }; عندما تُسند القيمة "true" إلى الخاصية horizontal العائدة للمكون ScrollView، سيجعله قابلًا للتمرير أفقيًا عندما لا تتسع الشاشة للنوافذ التي يحتويها شريط التطبيق. وانتبه إلى ضرورة إضافة خصائص تنسيق ملائمة للمكوّن ScrollView، بحيث تُرتَّب النوافذ على شكل صف "row" ضمن حاوية flex. للتأكد من إنجاز المطلوب أضف نوافذ جديدة إلى الشريط حتى لا تتسع الشاشة لها جميعًا، ولا تنس إزالة هذه النوافذ التجريبية بعد التحقق من إنجاز المهمة. إدارة حالة النموذج بعد أن حصلنا على حاضنة مناسبة لواجهة عرض تسجيل الدخول، ستكون المهمة التالية إنجاز نموذج لتسجيل الدخول. لكن قبل الشروع في ذلك، سنتحدث قليلًا عن النماذج وبمنظور أوسع. يعتمد تنفيذ النماذج بشدة على إدارة الحالة. فقد يقدم لنا الخطاف useState حلًا جيدًا للنماذج الصغيرة، لكن سيكون التعامل مع الحالة بنفس الأسلوب مرهقًا عندما يغدو النموذج أكثر تعقيدًا. ولحسن الحظ سنجد مكتبات تتعايش مع بيئة React لتسهيل عملية إدارة حالة النماذج ومنها المكتبة Formik. تعتمد المكتبة Formik على مفهومي سياق العمل context والحقل field. يؤمن سياق العمل المكوّن Formik الذي يحتوي على حالة النموذج. وتتكون الحالة من معلومات عن حقول النموذج، وتتضمن هذه المعلومات مثلًا قيم الحقول والأخطاء الناتجة عن تقييم كلٍ منها. يمكن الإشارة إلى حالة الحقول باستخدام اسم الحقل عن طريق الخطاف useField أو المكوّن Field. لنرى كيف سيعمل الأمر من خلال إنشاء نموذج لحساب "مؤشر كتلة الجسم" عند البشر: import React from 'react'; import { Text, TextInput, TouchableWithoutFeedback, View } from 'react-native'; import { Formik, useField } from 'formik'; const initialValues = { mass: '', height: '', }; const getBodyMassIndex = (mass, height) => { return Math.round(mass / Math.pow(height, 2)); }; const BodyMassIndexForm = ({ onSubmit }) => { const [massField, massMeta, massHelpers] = useField('mass'); const [heightField, heightMeta, heightHelpers] = useField('height'); return ( <View> <TextInput placeholder="Weight (kg)" value={massField.value} onChangeText={text => massHelpers.setValue(text)} /> <TextInput placeholder="Height (m)" value={heightField.value} onChangeText={text => heightHelpers.setValue(text)} /> <TouchableWithoutFeedback onPress={onSubmit}> <Text>Calculate</Text> </TouchableWithoutFeedback> </View> ); }; const BodyMassIndexCalculator = () => { const onSubmit = values => { const mass = parseFloat(values.mass); const height = parseFloat(values.height); if (!isNaN(mass) && !isNaN(height) && height !== 0) { console.log(`Your body mass index is: ${getBodyMassIndex(mass, height)}`); } }; return ( <Formik initialValues={initialValues} onSubmit={onSubmit}> {({ handleSubmit }) => <BodyMassIndexForm onSubmit={handleSubmit} />} </Formik> ); }; لا يعتبر هذا المثال جزءًا من تطبيقنا، لذا لا حاجة لإضافة شيفرته إلى التطبيق. لكن يمكنك تجريب الشيفرة في Expo Snack مثلًا، وهو محرر لشيفرة React Native على شبكة الإنترنت على غرار JSFiddle وCodePen، ويمثل منصة مفيدة لتجريب الشيفرات بسرعة. كما يمكنك مشاركة Expo Snacks مع الآخرين بتحديد رابط إلى عملك أو تضمين الشيفرة على شكل مشغّل Snack ضمن أي صفحة ويب. ولربما قد صادفت مسبقًا مشغل Snack في مادة منهاجنا أو أثناء اطلاعك على توثيق React Native. عرّفنا في المثال السابق سياق عمل Formik ضمن المكوّن BodyMassIndexCalculator، وزودناه بالقيم الأولية وباستدعاء لإرسال محتويات النموذج submit callback. كما استخدمنا الخاصية initialValues لتزويد السياق بالقيم الأولية على شكل كائنات مفاتيحها أسماء الحقول وقيمها هي القيم الأولية المقابلة. كما تزودنا الصفة onSubmit باستدعاء إرسال محتويات النموذج، حيث ينفَّذ هذا الاستدعاء عندما تُستدعى الدالة handleSubmit شريطة أن لا تظهر أية أخطاء تقييم لمحتويات الحقول. تُستدعى الدالة التي تمثل ابنا مباشرًا للمكوّن Formik من خلال الخصائص props التي تحتوي معلومات متعلقة بالحالة، كما تحتوي أفعالًا كالدالة handleSubmit. يحتوي المكوّن BodyMassIndexForm ارتباطات الحالة بين السياق وعناصر الإدخال النصيّة. ويُستخدم الخطاف useField للحصول على قيمة حقل أو تعديل قيمته. يقبل useField وسيطًا واحدًا هو اسم الحقل ويعيد مصفوفة من ثلاث كائنات هي [الحقل field, البيانات الوصفية meta، المُساعدات helpers]. يحتوي الكائن field على قيمة الحقل، بينما يحتوي الكائن meta على المعلومات الوصفية للحقل كرسائل الخطأ التي قد يرميها. أما الكائن الأخير helpers، فيحتوي على الأفعال التي تُستخدم لتغيير حالة الحقل كالدالة setValue. وتجدر الإشارة إلى أنّ المكوّن الذي يستخدم الخطاف لابدّ أن يكون ضمن سياق Formik. أي يجب أن يكون هذا المكوّن من أبناء المكوّن Formik. يمكنك الاطلاع على نسخة تفاعلية عن المثال السابق باسم Formik example على موقع Expo Snack. لقد سبب استخدام الخطاف useField مع المكوّن TextInput شيفرة مكررة. لنعزل هذه الشيفرة ونضعها في مكوّن جديد باسم FormikTextInput، ولننشئ مكوّنًا مخصصًا باسم TextInput لإضافة مظهر مرئي أفضل لعنصر الإدخال النصي. لنثبّت أولًا Formik: npm install formik سننشئ الآن الملف "TextInput.jsx" في المجلد "components" ونضع فيه الشيفرة التالية: import React from 'react'; import { TextInput as NativeTextInput, StyleSheet } from 'react-native'; const styles = StyleSheet.create({}); const TextInput = ({ style, error, ...props }) => { const textInputStyle = [style]; return <NativeTextInput style={textInputStyle} {...props} />; }; export default TextInput; لننتقل إلى المكوّن FormikTextInput الذي ينشئ رابطًا لحالة Formik بالمكوّن TextInput. سننشئ الآن الملف "FormikTextInput.jsx" في المجلد "components" ونضع فيه الشيفرة التالية: import React from 'react'; import { StyleSheet } from 'react-native'; import { useField } from 'formik'; import TextInput from './TextInput'; import Text from './Text'; const styles = StyleSheet.create({ errorText: { marginTop: 5, }, }); const FormikTextInput = ({ name, ...props }) => { const [field, meta, helpers] = useField(name); const showError = meta.touched && meta.error; return ( <> <TextInput onChangeText={value => helpers.setValue(value)} onBlur={() => helpers.setTouched(true)} value={field.value} error={showError} {...props} /> {showError && <Text style={styles.errorText}>{meta.error}</Text>} </> ); }; export default FormikTextInput; يمكننا بعد استخدام المكوّن FormikTextInput أن نعيد كتابة المكوّن BodyMassIndexForm في المثال السابق كالتالي: const BodyMassIndexForm = ({ onSubmit }) => { return ( <View> <FormikTextInput name="mass" placeholder="Weight (kg)" /> <FormikTextInput name="height" placeholder="Height (m)" /> <TouchableWithoutFeedback onPress={onSubmit}> <Text>Calculate</Text> </TouchableWithoutFeedback> </View> ); }; وكما نرى سيقلص إنشاء المكوّن FormikTextInput الذي يعالج ارتباطات حالة Formik بالمكوّن TextInput الشيفرة اللازمة. ومن الجيد أن تكرر نفس العملية إن استخدَمَت نماذج Formik التي لديك مكونات إدخال بيانات. التمرين 10.8 10.8 نموذج تسجيل الدخول أنجز نموذجًا لتسجيل الدخوّل يرتبط بالمكوّن SignIn الذي أضفناه سابقًا في الملف"SignIn.jsx". يجب أن يحتوي النموذج مربعي إدخال نصيين، أحدهما لاسم المستخدم والآخر لكلمة السر. كما يجب أن يحتوي زرًا لتسليم بيانات النموذج. لا حاجة لكتابة دالة الاستدعاء onSubmit، بل يكفي إظهار قيم الحقول باستخدام الأمر console.log عند تسليم البيانات: const onSubmit = (values) => { console.log(values); }; تذكّر أن تستخدم المكوّن FormikTextInput الذي أنشأناه سابقّا. كما يمكنك استخدام الخاصية secureTextEntry في المكوّن TextInput لحجب كلمة السر عند كتابتها. سيبدو نموذج تسجيل الدخول مشابهًا للتالي: تقييم النموذج تقدم Formik مقاربتين لتقييم النماذج: دالة تقييم أو تخطيط تقييم. فدالة التقييم: هي دالة تُمرر للمكون Formik كقيمة للخاصية validate. حيث تستقبل الدالة قيم حقول النموذج كوسطاء وتعيد كائنًا يحتوى الأخطاء المحتملة الخاصة بكل حقل. أما تخطيط التقييم فيمرر إلى المكوّن Formik كقيمة للخاصية validationSchema. ويمكن إنشاء هذا التخطيط باستخدام مكتبة تقييم تُدعى Yup. لنثبّت هذه المكتبة إذًا: npm install yup كمثال على ما أوردنا، سننشئ تاليًا تخطيط تقييم لنموذج "مؤشر كتلة الجسم" الذي أنجزناه سابقًا. نريد أن نتحقق أنّ قيمتي كتلة الجسم والطول موجودتان، وأنهما قيم عددية. كذلك نريد أن نتحقق أنّ كتلة الجسم أكبر أو تساوي1، وأنّ الطول أكبر أو يساوي 0.5. تمثل الشيفرة التالية طريقة تنفيذ التخطيط: import React from 'react'; import * as yup from 'yup'; // ... const validationSchema = yup.object().shape({ mass: yup .number() .min(1, 'Weight must be greater or equal to 1') .required('Weight is required'), height: yup .number() .min(0.5, 'Height must be greater or equal to 0.5') .required('Height is required'),}); const BodyMassIndexCalculator = () => { // ... return ( <Formik initialValues={initialValues} onSubmit={onSubmit} validationSchema={validationSchema} > {({ handleSubmit }) => <BodyMassIndexForm onSubmit={handleSubmit} />} </Formik> ); }; تجري عملية التحقق افتراضيًا عند حدوث أي تغيير في قيم حقول النموذج، وكذلك عند استدعاء الدالة handleSubmit. فإن أخفق التحقق، لن تُستدعى الدالة التي تمرر إلى الكائن Formik عبر الخاصية onSubmit يعرض المكوًن FormikTextInput الذي أنشأناه سابقًا رسائل الخطأ المتعلقة بالحقل إن وقع الخطأ الموافق لها ولُمس الحقل. أي في الحالة التي يتلقى فيها الحقل تركيز الدخل ثم يفقده: const FormikTextInput = ({ name, ...props }) => { const [field, meta, helpers] = useField(name); // Check if the field is touched and the error message is present const showError = meta.touched && meta.error; return ( <> <TextInput onChangeText={(value) => helpers.setValue(value)} onBlur={() => helpers.setTouched(true)} value={field.value} error={showError} {...props} /> {/* Show the error message if the value of showError variable is true */} {showError && <Text style={styles.errorText}>{meta.error}</Text>} </> ); }; التمرين 10.9 10.9 تقييم نموذج تسجيل الدخول تحقق أنّ حقلي كلمة السر واسم المستخدم في نموذج تسجيل الدخول إجباريين. وتذكر أن لا تستدعي الدالة onSubmit التي أنجزناها في التمرين السابق إن أخفق تقييم النموذج. سيعرض المكوّن FormikTextInput حاليًا رسالة خطأ إن حدث خطأ في الحقل الذي تم لمسه، عزز مظهر هذه الرسالة بجعل نصها أحمر اللون. بالإضافة إلى رسالة الخطأ، إجعل إطار حقل الإدخال أحمر اللون ليشير ذلك إلى وقوع خطأ ضمنه. وتذكر أن المكوّن FormikTextInput سيعطي القيمة "true" للخاصية error العائدة للمكوّن TextInput عند حدوث أية أخطاء ضمن الحقل. يمكنك استعمال قيمة هذه الخاصية لإضافة تنسيق شرطي للمكوّن TextInput. سيبدو شكل نموذج تسجيل الدخول عند حدوث خطأ في أحد الحقول قريبًا من الشكل التالي: الشيفرة الخاصة بمنصة من أعظم ميزات استخدام React Native هي أننا لن نهتم بالمنصة التي سيعمل عليها التطبيق سواء Android أو iOS. لكن، قد تصادفنا حالات نضطر فيها إلى تنفيذ شيفرة خاصة بمنصة محددة. من هذه الحالات مثلًا، كتابة مكوّنات بطرق مختلفة لتلائم منصات مختلفة. يمكن الوصول إلى المنصة التي يستخدمها المستخدم من خلال الثابت Platform.OS: import { React } from 'react'; import { Platform, Text, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ text: { color: Platform.OS === 'android' ? 'green' : 'blue', }, }); const WhatIsMyPlatform = () => { return <Text style={styles.text}>Your platform is: {Platform.OS}</Text>; }; يأخذ هذا الثابت إحدى القيمتين: android أو ios. يمكن كتابة شيفرة مخصصة لمنصة أيضًا مستفيدين من التابع Platform.select. إذ يمرر للتابع كائن قد تأخذ مفاتيحه إحدى القيم التالية: ios أو android أو native أو default، ويعيد القيمة الأكثر ملائمة للمنصة التي يعمل عليها المستخدم. يمكننا إعادة كتابة المتغير styles في المثال السابق باستعمال التابع Platform.select كالتالي: const styles = StyleSheet.create({ text: { color: Platform.select({ android: 'green', ios: 'blue', default: 'black', }), }, }); وبالإمكان أيضًا استخدام التابع Platform.select لطلب مكوّن خاص بمنصة محددة: const MyComponent = Platform.select({ ios: () => require('./MyIOSComponent'), android: () => require('./MyAndroidComponent'), })(); <MyComponent />; إنّ الطريقة الأكثر تقدّمًا لكتابة وإدراج مكوّنات خاصة بمنصة محددة أو أية أجزاء من الشيفرة، هي استخدام ملفات لها إحدى اللاحقتين "ios.jsx." أو "android.jsx.". وانتبه إلى إمكانية استخدام أية لاحقة يمكن للمجمع أن يميزها مثل "js.". إذ يمكن مثلًا أن ننشئ ملفات باسم "Button.ios.jsx" أو باسم "Button.android.jsx" بحيث نستطيع إدراجها في شيفرتنا كالتالي: import React from 'react'; import Button from './Button'; const PlatformSpecificButton = () => { return <Button />; }; وهكذا ستضم حزمة Android للتطبيق المكوّن المعرّف في الملف "Button.android.jsx"، بينما تضم حزمة iOS المكوّن المعرّف في الملف "Button.ios.jsx". التمرين 10.10 10.10 خط خاص بمنصة محددة حُددت عائلة الخط الذي نستخدمه حاليًا في تطبيقنا بأنها "System" ضمن إعدادات تهيئة السمة الموجودة في الملف "theme.js". استخدم بدلًا من الخط "system" خطًا من خطوط العائلة Sans-serif ليكون خطًا خاصًا بكل منصة. استخدم الخط "Roboto" في منصة Android والخط Arial في منصة iOS. يمكن أن تُبقي "System" مثل خط افتراضي. هذا هو التمرين الأخير في هذا الفصل، وقد حان الوقت لتسليم التمارين إلى GitHub والإشارة إلى أنك أكملتها في منظومة تسليم التمارين. انتبه إلى وضع الحلول في القسم 2 ضمن المنظومة. ترجمة -وبتصرف- للفصل React Native basics من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: مدخل إلى React Native مدخل إلى التحريك في React Native
-
تتطلب كتابة تطبيقات أصليّة Native لنظامي التشغيل iOS وAndroid تقليديًا استخدام لغات برمجة وبيئات تطوير خاصة بكل منصة. فقد استخدمت لغات مثل Objective C وSwift لتطوير تطبيقات iOS، ولغات مبنية على JVM مثل Java وScala وKotlin لتطوير تطبيقات موجّهة لنظام Android. وبالتالي وجب من الناحية التقنية كتابة تطبيقين منفصلين وبلغتي برمجة مختلفتين من أجل العمل على المنصتين السابقتين، وهذا ما تطلب الكثير من الموارد. لقد كان استخدام المتصفح كمحرك لتصيير التطبيقات إحدى المقاربات الأكثر شعبية في توحيد تطوير التطبيقات للمنصات المختلفة. وكانت المنصة Cordova من أشهر المنصات التي استخدمت في تطوير تطبيقات تعمل على منصات مختلفة. إذ تسمح هذه المنصة بتطوير تطبيقات قادرة على العمل على منصات مختلفة اعتمادًا على تقنيات الويب المعيارية مثل HTML5 وCSS3 وJavaScript. تعمل تطبيقات Cordova ضمن المتصفح المدمج في جهاز المستخدم، وبالتالي لن تحقق الأداء ولا المظهر الذي يمنحه استخدام التطبيقات الأصلية التي تستخدم مكوّنات واجهة المستخدم الأصلية. تقدم React Native إطار عمل لتطوير تطبيقات أصلية لمنصتي iOS وAndroid باستخدام JavaScript وReact. حيث تؤمن مجموعة من المكوّنات التي تعمل على منصات مختلفة وتستخدم خلف الستار المكوّنات الأصلية بالمنصة التي يعمل عليها التطبيق. وباستخدام React Native يمكننا استحضار كل الميزات المألوفة التي تقدمها React مثل JSX والمكوّنات components والخصائص props والحالة state والخطافات hooks، لتطوير تطبيقات أصلية لمنصة. وفوق كل ذلك، سنتمكن من استخدام مكتبات مألوفة بالنسبة لنا ضمن بيئة عمل React مثل react-redux وreact-apollo وreact-router وغيرها الكثير. تعتبر سرعة التطوير ومنحى التعلم المتصاعد للمطورين الذين ألفوا React، من أبرز الفوائد التي تمنحها React Native. وإليكم اقتباسًا تشجيعيًا ورد في مقال لمدونة Coinbase بعنوان Onboarding thousands of users with React Native، أو بالعربية "ضم آلاف المستخدمين إلى React Native"، حول فوائد React Native. حول هذا القسم سنتعلم خلال هذا القسم بناء تطبيقات React Native فعلية من البداية إلى النهاية. وسنتعلم مفاهيم مثل مكونات React Native البنيوية core components، وكيفية بناء واجهات مستخدم جميلة، وكيف سنتصل مع الخادم، كما سنتعلم كيفية اختبار تطبيقات React Native. سنعمل على تطوير تطبيق لتقييم مستودعات GitHub. وسيمتلك التطبيق ميزاتٍ كفرز وانتقاء المستودعات التي نقيّمها، وكذلك ميزة تسجيل مستخدم جديد، وتسجيل دخول مستخدم، وإنشاء مذكرة تقييم جديدة. سنستخدم واجهة خلفية جاهزة لكي ينصب تركيزنا على تطوير التطبيق باستخدام React Native فقط. ينبغي أن تسلم كل تمارين هذا القسم إلى مستودع GitHub واحد، سيضم مع الوقت الشيفرة الكلية للتطبيق. ستجد أيضًا حلًا نموذجيًا لكل مقال من هذا الجزء يمكنك استخدامه لإكمال التمارين الناقصة عند تسليمها. تعتمد هيكلية القسم على فكرة تطوير التطبيق تدريجيًا مع تقدمك في قراءة مادة القسم العلمية. فلا تنتظر حتى تصل إلى التمارين لتبدأ بتطوير تطبيقك، بل طوّره بما يتناسب مع المرحلة التي وصلت إليها في قراءة مادة القسم. سيعتمد هذا القسم بشدة على مفاهيم ناقشناها في أقسام سابقة. لذا وقبل أن تبدأ رحلتك هنا، عليك امتلاك بعض المعارف الأساسية في JavaScript وReact وGraphQL. لن تحتاج إلى معرفة عميقة في تطوير التطبيقات من ناحية الخادم، فكل الشيفرة التي تحتاجها للواجهة الخلفية محضرة مسبقًا. مع ذلك، سننفذ طلبات اتصال شبكية من خلال تطبيقات React Native مثل استعلامات GraphQL. ننصحك بإكمال الأقسام Part 1 وPart 2 وPart 5 وPart 7 وPart 8 قبل البدء بهذا القسم. تسليم التمرينات والحصول على الساعات المعتمدة ستُسلّم التمارين إلى منظومة استلام التمارين كما هو الحال في الأقسام السابقة. لكن انتبه إلى أنّ تمارين هذا المقال ستُسلّم إلى نسخةً أخرى من المنهاج تختلف عن النسخة التي سلمنا فيها تمارين الأقسام السابقة. فالأقسام من 1 إلى 4 في منظومة التسليم في هذه النسخة تشير إلى الفصول من a إلى d من هذا القسم. ويعني هذا أن عليك تسليم تمارين كل مقال على حدى ابتداءً من هذا المقال "مدخل إلى React Native" والذي سيحمل الاسم "Part 1" في منظومة تسليم التمارين. ستحصل في هذا القسم على ساعات معتمدة بناء على عدد التمارين التي تُكملها. فستحصل على ساعة معتمدة عند إكمالك 19 تمرينًا على الأقل، وعلى ساعتين معتمدتين عند إكمالك 26 تمرينُا على الأقل. وإن أردت الحصول على الساعات عند إكمالك للتمارين، أعلمنا من خلال منظومة التسليم بأنك أكملت المنهاج. وتشير الملاحظة "exam done in Moodle" إلى امتحان منهاج التطوير الشامل لتطبيقات الويب، والذي عليك اجتيازه حتى تحصل على ساعاتك المعتمدة المخصصة لهذا القسم. يمكنك الحصول على شهادة إكمال هذا القسم بالنقر على إحدى أيقونات "العلم". حيث ترتبط أيقونات الأعلام باللغة التي نُظِّمت بها الشهادة. وانتبه إلى ضرورة إكمال ما يعادل ساعة معتمدة من تمارين هذا القسم لتحصل على الشهادة. تهيئة التطبيق علينا تهيئة بيئة التطوير حتى نبدأ بالعمل مع تطبيقنا. وكنا قد تعاملنا في الأقسام السابقة مع أدوات مفيدة في ضبط وتهيئة تطبيقات React بسرعة مثل برنامج "create react app". تمتلك React Native لحسن الحظ أدواتٍ مشابهة أيضًا. سنستخدم لتطوير تطبيقنا منصة Expo، وهي منصة تبسط إعداد وتطوير وبناء ونشر تطبيقات React Native. لنبدأ إذًا بتثبيت واجهة سطر الأوامر الخاصة بهذه المنصة: npm install --global expo-cli يمكننا الآن تهيئة مشروعنا داخل المجلد "rate-repository-app" بتنفيذ الأمر التالي: expo init rate-repository-app --template expo-template-blank@sdk-38 يضبط الوسيط "sdk-38@" نسخة "Expo SDK" على 38 وهي النسخة التي تدعم النسخة 0.62 من React Native. وقد يتسبب لك استخدام نسخة مختلفة من "Expo SDK" مشاكل عند تتبع المادة التعليمية في هذا القسم. وتجدر الإشارة إلى محدودية Expo في بعض النقاط موازنةً بواجهة اللغة المشتركة CLI التي تستخدمها React Native، لكن لن تؤثر هذه المحدودية على التطبيق الذي سننفذه في هذا القسم. افتح المجلد "rate-repository-app" الذي أنشأناه عند تهيئة التطبيق باستخدام محرر شيفرة مثل Visual Studio Code. قد ترى بعض الملفات والمجلدات المألوفة ضمنه مثل "package.json" و"node_modules"، كما سترى أيضًا الملفات الأكثر ارتباطًا بالتطبيق مثل "app.json" الذي يحتوي على أوامر التهيئة الخاصة بالمنصة Expo، والملف "App.js" الذي يمثل المكوِّن الجذري لتطبيقنا. لا تغير اسم الملف "App.js" ولا تنقله إلى مجلد آخر، لأن Expo سيدرجه لتسجيل المكوّن الجذري. لنلق نظرة على قسم السكربتات في الملف "package.json": { // ... "scripts": { "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", "eject": "expo eject" }, // ... } سيشغّل تنفيذ الأمر npm start المجمّع Metro وهو مُجمّع JavaScript أي JavaScript bundler يستخدم مع React Native، ويمكن أن نعده مشابهًا لبرنامج Webpack لكنه خاص ببيئة React Native. كما ينبغي أن تُشغَّل أدوات تطوير Expo على العنوان http://localhost:19002 من خلال المتصفح. وهذه الأدوات مفيدةً جدًا في عرض سجلات تنفيذ التطبيق، بالإضافة إلى تشغيل التطبيق من خلال المقلّد emulator، أو من خلال تطبيق جوّال Expo. سنعود إلى هذين الأخيرين لاحقًا، إذا علينا أولًا تشغيل التطبيق في المتصفح بالنقر على الزر "Run": ستجد بعد النقر على الرابط أن النص المكتوب في الملف "App.js" سيظهر على نافذة المتصفح. افتح هذا الملف باستخدام أي محرر مناسب واجر تغييرًا بسيطًا على النص الموجود في المكوّن Text. يجب أن تظهر التعديلات التي أجريتها مباشرة على شاشة المتصفح بعد أن تحفظ التغيرات. إعداد بيئة التطوير لقد ألقينا نظرة أولى على تطبيقنا من خلال متصفح Expo. وعلى الرغم من فائدة هذا المتصفح، إلا أنه يقدم محاكاة بدائية للبيئة الأصلية. سنتعرف تاليًا على بعض البدائل المتوفرة لدينا بما يتعلق ببيئة التطوير. يمكن تقليد أجهزة iOS وAndroid كالأجهزة اللوحية والهواتف عن طريق الحاسوب وباستخدام مقلّدات Emulators خاصة. وهذا أمر مفيد جدًا عند تطوير التطبيقات باستخدام React Native. يمكن لمستخدمي نظام التشغيل macOS استخدام مقلدات iOS وAndroid على حواسيبهم، أما مستخدمي أنظمة التشغيل الأخرى مثل Linux وWindows، فليس بوسعهم سوى العمل مع مقلدات Android. اتبع التعليمات التالية حول إعداد المقلّد، بناء على نظام التشغيل الذي يستخدمه حاسوبك: Set up Android emulator with Android Studio لأي نظام تشغيل Set up iOS simulator with Xcode لنظام التشغيل macOS بعد تثبيت المقلّد واختباره، شغّل أدوات تطوير Expo كما فعلنا سابقًا عن طريق الأمر npm start. وبناء على المقلد الذي تستخدمه، إنقر الزر "Run" على مقلد أجهزة Android، أو "Run" على رابط محاكي iOS. ينبغي أن يتصل Expo بالمقلد بعد النقر على الرابط، وسترى التطبيق عليه. كن صبورًا، فقد يستغرق الأمر وقتًا. بالإضافة إلى المقلدات، ستجد أنّ هناك طريقة غاية في الأهمية لتطوير تطبيقات React Native باستخدام Expo، وهي تطبيق جوّال Expo. فباستخدام هذا التطبيق، ستتمكن من استعراض تطبيقك عبر جهازك النقّال الفعلي، وهذا ما يمنحك خبرة أكثر موازنةً بالمقلدات. ثبّت أولًا تطبيق جوّال Expo باتباع التعليمات الموجودة في توثيق Expo. وانتبه إلى أنّ تطبيق جوّال Expo لن يعمل إن لم يكن جهازك النقًال متصلًا بنفس الشبكة المحلية (نفس الشبكة اللاسلكية على سبيل المثال) التي يتصل بها حاسوب التطوير الذي تعمل عليه. شغل تطبيق جوّال Expo عندما تنتهي من تثبيته. إن لم تكن قد شغّلت أدوات تطوير Expo بعد، فافعل ذلك مستخدمًا الأمر npm start. سترى في الزاوية اليسارية السفلى من نافذة أدوات التطوير شيفرة QR. امسح هذه الشيفرة باستخدام تطبيق جوّال Expo عن طريق الضغط على الرابط "Scan QR Code". ينبغي أن يبدأ تطبيق Expo ببناء مجمّع JavaScript، ومن المفترض أن ترى تطبيقك وهو يعمل بعد انتهاء عملية التجميع. وهكذا ستتمكن في كل مرة تفتح فيها تطبيقك باستخدام تطبيق جوّال Expo من الوصول إليه دون الحاجة إلى مسح شيفرة QR مجددًا، وذلك بالضغط عليه في قائمة التطبيقات التي شُغّلت مؤخرًا، والتي تظهر في نافذة العرض "Project". التمرين 10.1 10.1 تهيئة التطبيق استخدم واجهة سطر أوامر Expo لتهيئة تطبيقك، وجهّز بيئة التطوير باستخدام المقلد أو تطبيق جوّال Expo. ننصحك بتجريب الأسلوبين السابقين لتحدد البيئة الأنسب بالنسبة لك. لا تهتم كثيرًا لاسم التطبيق، فيمكنك أن تستخدم الاسم "rate-repository-app". عليك إنشاء مستودع GitHub جديد لتسليم هذا التمرين وغيره من التمارين اللاحقة، ويمكنك تسمية هذا المستودع باسم التطبيق الذي قمت بتهيئته عند تنفيذك الأمر expo init. إن قررت إنشاء مستودع خاص، أضف المستخدم Kaltsoon كمشارك لك collaborator في هذا المستودع، وذلك للتحقق من التمارين التي أنجزتها وسلمتها. نفذ بعد إنشائك المستودع الأمر git init ضمن المجلد الجذري لمشروعك للتأكد من تهيئة المستودع كمستودع Git. ولإضافة المستودع الجديد إلى قائمة العمل عن بعد نفذ الأمر التالي: git remote add origin git@github.com:<YOUR\*GITHUB\*USERNAME>/<NAME\*OF\*YOUR_REPOSITORY>.git وتذكر أن تستبدل القيم داخل الأقواس بما يناسب عندك كتابتك لسطر الأوامر. ادفع أخيرًا بالتغييرات التي أجريتها إلى المستودع وسيكون كل شيء على ما يرام. المدقق ESLint الآن، وقد ألفنا بيئة التطوير سنعمل على تحسين مهاراتنا في التطوير وذلك بتهيئة مدقق للشيفرة. سنستخدم المدقق ESLint الذي تعاملنا معه سابقًا، وكالعادة سنبدأ بتثبيت الاعتماديات اللازمة: npm install --save-dev eslint babel-eslint eslint-plugin-react سنضع القواعد التالية للمدقق داخل الملف ذو اللاحقة "eslintrc." والموجود في المجلد "rate-repository-app": { "plugins": ["react"], "settings": { "react": { "version": "detect" } }, "extends": ["eslint:recommended", "plugin:react/recommended"], "parser": "babel-eslint", "env": { "browser": true }, "rules": { "react/prop-types": "off", "semi": "error" } } ومن ثم سنضيف السكربت "lint" إلى محتويات الملف "package.json"، وذلك للتحقق من تطبيق قواعد المدقق في الملفات المحددة: { // ... "scripts": { "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", "eject": "expo eject", "lint": "eslint ./src/**/*.{js,jsx} App.js --no-error-on-unmatched-pattern" }, // ... } لاحظ أننا استخدمنا الفواصل لإنهاء سطر الشيفرة على نقيض ما فعلنا في الأقسام 1-8، لذلك أضفنا القاعدة semi للتأكد من ذلك. نستطيع الآن التأكد من خضوع ملفات JavaScript الموجودة في المجلد "src" وكذلك الملف "App.js" لقواعد المدقق، وذلك بتنفيذ الأمر npm run lint. سنضع ملفات الشيفرة لاحقًا في المجلد "src"، لكن طالما أننا لم نضف أية ملفات ضمنه حتى اللحظة، سنضطر إلى تفعيل الراية "no-error-on-unmatched-pattern". حاول إن استطعت أيضًا دمج المدقق ESlint مع محرر الشيفرة الذي تعمل عليه. فإن كنت تعمل على Visual Studio Code، يمكنك فعل ذلك بتفقد قسم الموّسعات extensions والتأكد من تثبيت وتفعيل موسِّع ESlint. تمثل قواعد تهيئة المدقق السابقة، القواعد الأساسية للتهيئة. يمكنك أن تعدّل بحرية على هذه القواعد وأن تضيف ملحقات جديدة plugins إن شعرت بالحاجة إلى ذلك. التمرين 10.2 10.2 إعداد وضبط المدقق ESLint هيئ المدقق في مشروعك حتى تتمكن من تدقيق ملفات الشيفرة عند تنفيذك الأمر. ويفضّل عند استخدامك المدقق أن تدمجه مع محررك لكي تستفيد من كامل إمكانياته. هذا هو التمرين الأخير في هذا المقال، وقد حان الوقت لتسليم التمارين إلى GitHub والإشارة إلى أنك أكملتها في منظومة تسليم التمارين. انتبه إلى وضع الحلول في القسم 1 ضمن المنظومة. عرض سجلات العمل يمكن الاستفادة من بيئة تطوير Expo في عرض سجلات العمل للتطبيق، كما يمكن متابعة رسائل الخطأ والتحذير ضمن المقلّد أو ضمن واجهة تطبيق الجوّال. ستظهر رسالة الخطأ باللون الأحمر فوق مكان الخطأ، بينما يمكن عرض رسائل التحذير بالضغط على رسالة التحذير في أسفل الشاشة. كما يمكنك استخدام الأمر console.log لطباعة رسائل محددة، وذلك لغايات التنقيح وكشف الأخطاء. لتجريب ذلك عمليًا، شغّل أدوات تطوير Expo بتنفيذ الأمر npm start، ثم شغّل التطبيق باستخدام المقلّد أو تطبيق الجوّال. ستتمكن عندها من رؤية الأجهزة المتصلة بحاسوبك تحت العنوان "Metro Bundler" في الزاوية العليا اليسارية من نافذة أدوات التطوير: انقر على الجهاز لفتح سجلات العمل عليه. افتح بعد ذلك الملف "App.js" وأضف رسالةً ما باستخدام الأمر console.log إلى المكوِّن App. سترى رسالتك ضمن السجلات بمجرد أن تحفظ التغييرات التي أجريتها على الملف. استخدام منقح الأخطاء Debugger قد يكون فحص الرسائل التي كُتبت باستخدام الأسلوب console.log مفيدًا، لكن سيتطلب الأمر فهمًا أوسع إن كنت تحاول أن تجد ثغرةً ما أو أن تفهم سير عمل التطبيق. فربما سنهتم -على سبيل المثال- بخصائص أو بحالة مكوِّن محدد، أو بالاستجابة لطلبٍ محدد على الشبكة، لذلك استخدمنا أدوات المطوّر التي يؤمنها المتصفح لإنجاز هذا العمل سابقًا. بالمقابل سنجد أنّ المنقّح React Native Debugger سيزوّدك بنفس ميزات التنقيح، لكن لتطبيقات React Native. لنبدأ العمل بتثبيت منقِّح React Native بمساعدة تعليمات التثبيت. شغّل المنقّح عند انتهاء التثبيت، وافتح نافذة تنقيح جديدة (يمكنك استخدام الاختصارات التالية: command+T على macOS وCtrl+T على Windows/Linux)، ثم اضبط رقم منفذ محزِّم React Native على 19001. علينا الآن تشغيل تطبيقنا وربطه مع المنقّح. لننفذ إذًا الأمر npm start أولًا، ثم سنفتح التطبيق ضمن المقلّد أو تطبيق جوّال Expo. افتح قائمة المطوّرين داخل المقلّد أو التطبيق باتباعك الإرشادات الموجودة في توثيق Expo. اختر من قائمة المطوّرين العنصر "Debug remote JS" لتتصل مع المنقّح. إن جرى كل شيء على مايرام سترى شجرة مكونات التطبيق ضمن المنقّح. يمكنك استخدام المنقّح للتحقق من حالة المكوّنات وخصائصها وتغييرها إن أردت. حاول أن تعثر مثلًا على المكوّن Text الذي يصيره المكوّن App باستخدام خيار البحث أو عبر شجرة المكوّنات. انقر المكوّن text وغّير قيمة الخاصية children. من المفترض أن يَظهر التغيير تلقائيًا في نافذة عرض التطبيق. للاطلاع على أدوات تنقيح أكثر فائدة لتطبيقات Expo، اطلع على توثيق عملية التنقيح في Expo. ترجمة -وبتصرف- للفصل Introduction to React Native من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: استخدام TypeScript والأنواع التي توفرها في تطبيقات React. مدخل إلى التحريك في React Native.
-
يسمح "هجوم الاختطاف بالنقر clickjacking" للصفحات المشبوهة بأن تنقر ضمن الصفحة المستهدفة نيابةً عن الزائر. وقد اختُرقت الكثير من الصفحات بهذه الطريقة، بما في ذلك تويتر وفيسبوك وبيبال وغيرها، وبالطبع أصلحت الثغرات التي سببت الهجوم على تلك المواقع. فكرة الهجوم إن فكرة الهجوم بسيطة جدًا، فقد كانت طريقة الهجوم على فيسبوك مثلًا كما يلي: جُذب المستخدم إلى الصفحة المشبوهة بطريقة ما. احتوت الصفحة رابطًا لا يبدو خطرًا بعناوين، مثل: "طريقك إلى الثروة"، أو "انقر هنا، أمر مضحك جدًا". وضعت الصفحة المشبوهة فوق هذا الرابط نافذةً ضمنيةً <iframe> شفافة ترتبط خاصية src فيه بالموقع "facebook.com"، بحيث ظهر زر "أعجبني" فوق الرابط مباشرةً، وعادة يُنفَّذ ذلك باستخدام الخاصية z-index. وبالتالي نقر الزائر على الزر عندما حاول النقر على الرابط. مثال توضيحي ستبدو الصفحة المشبوهة كالتالي، مع الانتباه إلى أنّ النافذة الضمنية <iframe> هنا نصف شفافة،أما في الصفحات المشبوهة فستكون النافذة الضمنية شفافةً تمامًا: <style> iframe { /* النافذة الضمنية من الصفحة الضحية */ width: 400px; height: 100px; position: absolute; top:0; left:-20px; opacity: 0.5; /* الشفافية 0 في الصفحة المشبوهة */ z-index: 1; } </style> <div>Click to get rich now:</div> <!-- العنوان على الصفحة الضحية --> <iframe src="/clickjacking/facebook.html"></iframe> <button>Click here!</button> <div>...And you're cool (I'm a cool hacker actually)!</div> See the Pen JS-P3-01-The-clickjacking-attack-ex1 by Hsoub (@Hsoub) on CodePen. يحوي الملف "facebook.html" الشيفرة التالية: <!DOCTYPE HTML> <html> <body style="margin:10px;padding:10px"> <input type="button" onclick="alert('Like pressed on facebook.html!')" value="I LIKE IT !"> </body> </html> ويحوي الملف "index.html" الشيفرة التالية: <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <style> iframe { width: 400px; height: 100px; position: absolute; top: 5px; left: -14px; opacity: 0.5; z-index: 1; } </style> <div>Click to get rich now:</div> <!-- The url from the victim site --> <iframe src="facebook.html"></iframe> <button>Click here!</button> <div>...And you're cool (I'm a cool hacker actually)!</div> </body> </html> ستظهر النتيجة كالتالي: تظهر النافذة الضمنية <"iframe src="facebook.html> في المثال السابق نصف شفافة، بحيث يمكن رؤيتها عند تحريك المؤشر فوق الزر، وعند النقر على الزر فإننا ننقر في الواقع على النافذة الضمنية التي ستكون مخفيّةً كونها شفافةً تمامًا. فإن أمكن للمستخدم الوصول إلى حسابه مباشرةً دون الحاجة لتسجيل الدخول (عندما تكون ميزة "تذكرني remember me" مفعلة)، فسيضيف النقر على النافذة الضمنية إعجابًا Like، بينما سينقر في تويتر على زر المتابعة Follow. إليك نتيجة المثال السابق لكن بواقعية أكثر عندما نسند القيمة "0" إلى الخاصية opacity: كل ما يتطلبه شن الهجوم هو وضع النافذة الضمنية iframe في الصفحة المشبوهة بطريقة تجعل الزر فوق الرابط مباشرةً، وبالتالي سيضغط الزائر الزر بدلًا من الرابط، ويُنجز ذلك عادة باستخدام لغة الأنماط الانسيابية CSS. الأسلوب الدفاعي للمدرسة التقليدية يعود جزء من الآلية الدفاعية القديمة إلى JavaScript التي تمنع فتح الصفحة في نافذة ضمنية، وهو ما يُعرف بكسر الإطار framebusting، ويبدو الأمر كالتالي: if (top != window) { top.location = window.location; } بهذه الشيفرة تتحقق النافذة من كونها الأعلى ترتيبًا بين العناصر، فإن لم تكن كذلك فستجعل نفسها الأعلى تلقائيًا، وهذا أسلوب ضعيف في الدفاع يمكن الالتفاف عليه بسهولة، وسنطلع تاليًا على عدة أساليب أخرى. منع الانتقال إلى الأعلى يُمكن منع الانتقال الناتج عن تغيير قيمة الخاصية top.location في معالج الحدث beforeunload. حيث ستحدد الصفحة الموجودة في الأعلى (مرفقة بالصفحة التي تعود للمتسلل) معالجًا يمنع الانتقال إليها كالتالي: window.onbeforeunload = function() { return false; }; عندما تحاول النافذة الضمنية تغيير قيمة top.location، فستظهر رسالة للزائر تسأله إن كان يريد مغادرة الصفحة، وفي معظم الحالات سيرفض الزائر ذلك لأنه يجهل تمامًا وجود هذه النافذة، وكل ما يراه هي الصفحة العليا ولا سبب لمغادرتها، لذا لن تتغير عندها قيمة top.location خلال العملية. سنرى في المثال التالي تنفيذ الفكرة عمليًا، حيث يحتوي الملف "iframe.html" الشيفرة التالية: <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <div>Changes top.location to javascript.info</div> <script> top.location = 'https://javascript.info'; </script> </body> </html> ويحتوي الملف "index.html" الشيفرة التالية: <!doctype html> <html> <head> <meta charset="UTF-8"> <style> iframe { width: 400px; height: 100px; position: absolute; top: 0; left: -20px; opacity: 0; z-index: 1; } </style> <script> function attack() { window.onbeforeunload = function() { window.onbeforeunload = null; return "Want to leave without learning all the secrets (he-he)?"; }; document.body.insertAdjacentHTML('beforeend', '<iframe src="iframe.html">'); } </script> </head> <body> <p>After a click on the button the visitor gets a "strange" question about whether they want to leave.</p> <p>Probably they would respond "No", and the iframe protection is hacked.</p> <button onclick="attack()">Add a "protected" iframe</button> </body> </html> وستكون النتيجة كما يلي: الخاصية Sandbox تقيِّد الخاصية sandbox عدة أشياء منها عملية التنقل، حيث لا يمكن للنافذة الضمنية المقيّدة تغيير قيمة top.location، ولذلك يمكننا استخدام هذه الخاصية لتخفيف القيود بالشكل sandbox="allow-scripts allow-forms"، وهكذا سنسمح باستخدام النماذج والسكربتات، لكن في الوقت نفسه لا نسمح بالتنقل عندما لا نضيف الخيار allow-top-navigation، وبالتالي سنمنع تغيير القيمة top.location. والشيفرة المستخدمة في ذلك هي: <iframe sandbox="allow-scripts allow-forms" src="facebook.html"></iframe> ويمكن طبعًا الالتفاف على أسلوب الحماية البسيط هذا. خيارات X-Frame يمكن للترويسة X-Frame-Options المتعلقة بالواجهة الخلفية أن تسمح بعرض صفحة ضمن نافذة ضمنية أو تمنعه، كما ينبغي أن تُرسَل على شكل ترويسة HTTP حصرًا، إذ سيتجاهلها المتصفح إن وجدها ضمن العنصر meta، أي ستكون الشيفرة التالية بلا فائدة: <meta http-equiv="X-Frame-Options"> يمكن أن تأخذ الترويسة إحدى القيم التالية: DENY: لن تظهر الصفحة ضمن نافذة ضمنية أبدًا. SAMEORIGIN: يمكن أن تُعرَض الصفحة ضمن نافذة ضمنية إن كان للصفحة الأم وللنافذة الضمنية الأصل نفسه. ALLOW-FROM domain: يمكن أن تُعرَض الصفحة داخل نافذة ضمنية إن انتمت الصفحة الأم إلى نطاق محدد سلفًا. مثلًا: يستخدم موقع تويتر الخيار X-Frame-Options: SAMEORIGIN بحيث تكون النتيجة نافذةً ضمنيةً فارغةـ وتكون النتيجة: <iframe src="https://twitter.com"></iframe> ستكون النتيجة حسب المتصفح المستخدَم، فقد تكون نافذةً ضمنيةً فارغةً، أو تنبيهًا للمستخدم بأنّ المتصفح لم يسمح للصفحة أن تُعرض بهذه الطريقة. إظهار النافذة بوظيفة معطلة للترويسة X-Frame-Options تأثير جانبي، حيث لن تتمكن الصفحات الأخرى من عرض صفحتنا ضمن نافذة ضمنية حتى لو كان ذلك لسبب وجيه، لذلك سنجد حلولًا أخرى مثل تغطية الصفحة بعنصر <div> له التنسيق: height: 100%; width: 100%;، وعندها سيعترض كل نقرات الفأرة، ثم يُزال هذا العنصر عندما يتحقق الشرط window == top، أو عندما لا نجد مبررًا لحماية الصفحة. سيظهر الأمر كالتالي: <style> #protector { height: 100%; width: 100%; position: absolute; left: 0; top: 0; z-index: 99999999; } </style> <div id="protector"> <a href="/" target="_blank">Go to the site</a> </div> <script> // there will be an error if top window is from the different origin // سيحدث خطأ إذا كانت النافذة العليا من مصدر مختلف، لكن هنا لا مشكلة // but that's ok here if (top.document.domain == document.domain) { protector.remove(); } </script> يوضح المثال التالي ما فعلناه، حيث يحتوي الملف "iframe.html" على الشيفرة التالية: <!doctype html> <html> <head> <meta charset="UTF-8"> <style> #protector { height: 100%; width: 100%; position: absolute; left: 0; top: 0; z-index: 99999999; } </style> </head> <body> <div id="protector"> <a href="/" target="_blank">Go to the site</a> </div> <script> if (top.document.domain == document.domain) { protector.remove(); } </script> This text is always visible. But if the page was open inside a document from another domain, the div over it would prevent any actions. <button onclick="alert(1)">Click wouldn't work in that case</button> </body> </html> وسيحتوي الملف "index.html" على هذه الشيفرة: <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <iframe src="iframe.html"></iframe> </body> </html> وستظهر النتيجة كالتالي: الخاصية samesite لملف تعريف الارتباط يمكن للخاصية samesite أن تمنع هجوم الاختطاف بالنقر، حيث لن يُرسَل ملف تعريف الارتباط cookie الذي يحملها إلى أي موقع إلا إذا فُتح مباشرةً، وليس عن طريق نافذة ضمنية أو أي شيء مشابه. يمكن الاطلاع على معلومات أكثر ضمن مقال "ملفات تعريف الارتباط وضبطها في مستندات JavaScript". فلو امتلك موقع مثل فيسبوك الخاصية samesite ضمن ملف تعريف الارتباط على الشكل التالي: Set-Cookie: authorization=secret; samesite فلن يُرسل ملف تعريف الارتباط عندما يُفتح فيسبوك في نافذة ضمنية عائدة لموقع آخر، وبالتالي سيخفق الهجوم. لن يكون للخاصية samesite فائدة إن لم تُستعمل ملفات تعريف الارتباط، وقد يسمح ذلك لمواقع أخرى بعرض صفحاتنا العامة غير المستوثقة في النوافذ الضمنية، مما سيسمح بحدوث هجمات الاختطاف بالنقر لكن في حالات محدودة، إذ ستبقى المواقع التي تدعم عمليات التصويت مثلًا، مجهولة المصدر وتمنع تكرار التصويت لنفس عنوان IP عرضة لهذه الهجمات، لأنها لا تستوثق المستخدمين من خلال ملفات تعريف الارتباط. الخلاصة يمثل الاختطاف بالنقر طريقةً للاحتيال على المستخدمين للنقر على صفحات مستهدفة دون علمهم، وتأتي خطورتها عند وجود أفعال مهمة تُنفَّذ عند النقر، حيث يمكن للمخترق أن ينشر رابطًا ضمن صفحته المشبوهة ضمن رسالة، أو أن يجذب الزوار إلى صفحته بطرق أخرى. لا يعَد الهجوم "عميقًا" من ناحية أولية، فكل ما يفعله المخترق هو اعتراض نقرة الفأرة، لكن من ناحية أخرى إذا علم المخترق بظهور عملية أخرى بعد النقر، فربما يستخدم رسائل ماكرةً تجبر المستخدم على النقر عليها أيضًا، وهنا سيكون الهجوم خطرًا، إذ لا نخطط لاعتراض هذه الأفعال عند تصميم واجهة المستخدم UI، وقد تظهر نقاط الضعف في أماكن لا يمكن توقعها أبدًا. لهذايُنصح باستخدام الأمر X-Frame-Options: SAMEORIGIN ضمن الصفحات أو المواقع التي لا نريد عرضها ضمن نوافذ ضمنية، واستخدام العنصر <div> لتغطية الصفحة عند عرضها ضمن نوافذ ضمنية، ومع ذلك يجب توخي الحذر. ترجمة -وبتصرف- للفصل clickjacking attack من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال السابق: التوابع المتعلقة بالنوافذ والنوافذ المنبثقة Popups في جافاسكريبت احم موقعك من الاختراق ممارسات الأمن والحماية في تطبيقات PHP تنظيم شيفرات SQL وتأمينها
-
تقيِّد سياسة الأصل المشترك same origin أو سياسة الموقع المشترك same site من إمكانية وصول النوافذ أو الإطارات إلى بعضها إن لم تكن ضمن الموقع نفسه، فلو فتح مستخدم صفحتين، بحيث الأولى مصدرها الموقع academy.hsoub.com، والأخرى مصدرها الموقع gmail.com؛ فلا نريد هنا بالطبع لأية شيفرة من الصفحة academy.hsoub.com أن تقرأ بريده الإلكتروني الذي تعرضه الصفحة gmail.com، فالغاية إذًا من سياسة الأصل المشترك هي حماية المستخدم من لصوص المعلومات. الأصل المشترك نقول أنّ لعنواني URL أصلًا مشتركًا إن كان لهما نفس البروتوكول، والنطاق domain، والمنفذ. لذا يمكن القول أنّ للعناوين التالية أصلًا مشتركًا: http://site.com http://site.com/ http://site.com/my/page.html بينما لا تشترك العناوين التالية مع السابقة بالأصل: http://www.site.com (النطاق مختلف لوجود www هنا). http://site.org (النطاق مختلف لوجود org بدلًا من com). https://site.com (البروتوكول مختلف هنا https). http://site.com:8080 (المنفذ مختلف 8080). وتنص سياسة الأصل المشترك على مايلي: إذا وُجِد مرجع إلى نافذة أخرى، مثل: النوافذ المنبثقة المُنشأة باستخدام الأمر window.open، أو الموجودة داخل وسم النافذة الضمنية <iframe>، وكان للنافذتين الأصل نفسه، فسيمنحنا ذلك إمكانية الوصول الكامل إلى تلك النافذة. إذا لم يكن للنافذتين الأصل نفسه، فلا يمكن لإحداهما الوصول إلى محتوى الأخرى، بما في ذلك المتغيرات والمستندات، ويُستثنى من ذلك موضع النافذة location، إذ يمكننا تغييره (وبالتالي إعادة توجيه المستخدم)، دون القدرة على قراءة موضع النافذة (وبالتالي لا يمكننا معرفة مكان المستخدم الآن، وبالتالي لن تتسرب المعلومات). العمل مع النوافذ الضمنية iframe يستضيف الوسم iframe نافذةً ضمنيةً منفصلةً عن النافذة الأصلية، ولها كائنان، هما document وwindow مستقلان، ويمكن الوصول إليهما بالشكل التالي: iframe.contentWindow للحصول على الكائن window الموجودة ضمن الإطار iframe. iframe.contentDocument للحصول على الكائنdocument ضمن الإطار iframe، وهو اختصار للأمر iframe.contentWindow.document. سيتحقق المتصفح أنّ للإطار والنافذة الأصل نفسه عندما نَلِج إلى شيء ما داخل نافذة مضمّنة، وسيرفض الوصول إن لم يتحقق ذلك (تُستنثنى هنا عملية تغيير الخاصية location فلا تزال مسموحة). لنحاول مثلًا القراءة والكتابة إلى الإطار iframe من مورد لا يشترك معه بالأصل: <iframe src="https://example.com" id="iframe"></iframe> <script> iframe.onload = function() { // يمكننا الحصول على مرجع إلى النافذة الداخلية let iframeWindow = iframe.contentWindow; // صحيح try { // ...لكن ليس للمستند الموجود ضمنه let doc = iframe.contentDocument; // خطأ } catch(e) { alert(e); // (خطأ أمان (أصل مختلف } // لايمكننا أيضًا قراءة العنوان ضمن الإطار try { // Location لا يمكن قراءة عنوان الموقع من كائن الموضع let href = iframe.contentWindow.location.href; // خطأ } catch(e) { alert(e); // Security Error } // يمكن الكتابة ضمن خاصية الموضع وبالتالي تحميل شيء آخر ضمن الإطار iframe.contentWindow.location = '/'; // صحيح iframe.onload = null; //امسح معالج الحدث، لا تنفذه بعد تغيير الموضع }; </script> See the Pen JS-P3-01-cross-window-communications-ex1 by Hsoub (@Hsoub) on CodePen. ستعطي الشيفرة السابقة أخطاءً عند تنفيذها عدا حالتي: الحصول على مرجع إلى النافذة الداخلية iframe.contentWindow فهو أمر مسموح. تغيير الموضع location. وبالمقابل، إن كان للإطار iframe نفس أصل النافذة الداخلية، فيمكننا أن نفعل فيها ما نشاء: <!-- iframe from the same site --> <iframe src="/" id="iframe"></iframe> <script> iframe.onload = function() { // افعل ما تشاء iframe.contentDocument.body.prepend("Hello, world!"); }; </script> See the Pen JS-P3-01-cross-window-communications-ex2 by Hsoub (@Hsoub) on CodePen. الموازنة بين الحدثين iframe.onload وiframe.contentWindow.onload يؤدي الحدث iframe.onload على الوسم iframe مبدئيًا، نفس ما يؤديه الحدث iframe.contentWindow.onload على كائن النافذة المضمنة، إذ يقع الحدث عندما تُحمّل النافذة المضمنة بكل مواردها، لكن لا يمكن الوصول إلى الحدث iframe.contentWindow.onload لإطار من آخر ذي أصل مختلف، وبالتالي سنستخدم عندها iframe.onload. النوافذ ضمن النطاقات الفرعية والخاصية document.domain وجدنا من التعريف أنّ العناوين المختلفة تقود إلى موارد غير مشتركة بالأصل، لكن لو تشاركت النوافذ بالنطاق الفرعي أو النطاق من المستوى الثاني -مثل النطاقين الفرعيين التاليين: john.site.com وpeter.site.com (المشتركان بالنطاق الفرعي site.com)-، فيمكن حينها أن نطلب من المتصفح أن يتجاهل الفرق، وبالتالي سيعدهما من أصل مشترك، وذلك لأغراض التخاطب بين النوافذ. ولكي ننفذ ذلك لا بدّ من وجود الشيفرة التالية في كل نافذة: document.domain = 'site.com'; ستتخاطب النوافذ مع بعضها الآن دون معوقات، ولا بد أن نذكر أن هذا ممكن فقط في الصفحات التي تشترك بنطاق من المستوى الثاني. مشكلة كائن "document" خاطئ في النوافذ الضمنية عندما نتعامل مع نافذة ضمنية لها الأصل نفسه، يمكننا الوصول إلى الكائن document الخاص بها، وعندها ستواجهنا مشكلة لا تتعلق بالأصل المشترك -لكن لا بدّ من الإشارة إليها-، وهي أن النافذة الضمنية سيكون لها كائن document مباشرةً بعد إنشائها، لكنه سيختلف عن الكائن الذي سيُحمَّل ضمنها، وبالتالي قد يضيع الأمر الذي ننفِّذه على الكائن document مباشرةً. لنتأمل الشيفرة التالية: <iframe src="/" id="iframe"></iframe> <script> let oldDoc = iframe.contentDocument; iframe.onload = function() { let newDoc = iframe.contentDocument; // كائن المستند الذي حُمِّل مختلف عن الكائن الأساسي alert(oldDoc == newDoc); // false النتيجة }; </script> See the Pen JS-P3-01-cross-window-communications-ex3 by Hsoub (@Hsoub) on CodePen. لا ينبغي التعامل مع الكائن document لأي إطار قبل التحميل الكامل لمحتوياته لتجنب التعامل مع الكائن الخاطئ، إذ سيتجاهل المتصفح أية معالجات أحداث قد نستخدمها ضمن الكائن. لكن كيف نحدد اللحظة التي يجهز فيها الكائن document؟ سيظهر المستند بالتأكيد عند وقوع الحدث iframe.onload، والذي لا يطلق إلا عندما تُحمَّل موارد الإطار كلها. كما يمكن التقاط اللحظة المناسبة بالتحقق المستمر خلال فترات زمنية محددة عبر التعليمة setInterval: <iframe src="/" id="iframe"></iframe> <script> let oldDoc = iframe.contentDocument; // تحقق أن المستند الجديد جاهز كل 100 ميلي ثانية let timer = setInterval(() => { let newDoc = iframe.contentDocument; if (newDoc == oldDoc) return; alert("New document is here!"); clearInterval(timer); // فلن تحتاجها الآن setInterval ألغ }, 100); </script> See the Pen JS-P3-01-cross-window-communications-ex4 by Hsoub (@Hsoub) on CodePen. استخدام المجموعة window.frames يمكن استخدام طريقة بديلة للوصول إلى الكائن window الخاص بالنافذة الضمنية iframe عبر المجموعة window.frames: باستخدام الرقم: إذ سيعطي الأمر [window.frames[0 كائن window الخاص بأول إطار في المستند. باستخدام الاسم: إذ يعطي الأمر window.frames.iframeName كائن window الخاص بالإطار الذي يحمل الاسم "name="iframeName. مثلًا: <iframe src="/" style="height:80px" name="win" id="iframe"></iframe> <script> alert(iframe.contentWindow == frames[0]); // true alert(iframe.contentWindow == frames.win); // true </script> See the Pen JS-P3-01-cross-window-communications-ex5 by Hsoub (@Hsoub) on CodePen. قد تحوي نافذة ضمنية ما نوافذ ضمنيةً أخرى، وعندها ستكون الكائنات window الناتجة عن الانتقال الشجري بين الإطارات هي: window.frames: وتمثل مجموعة النوافذ الأبناء للإطارات المتداخلة nested. window.parent: المرجع إلى النافذة الأم المباشرة (الخارجية). window.top: المرجع إلى النافذة الأم الأعلى ترتيبًا. مثلًا: window.frames[0].parent === window; // محقق يمكن استخدام الخاصية top للتحقق من أنّ المستند الحالي مفتوح ضمن إطار أم لا: if (window == top) { // current window == window.top? alert('The script is in the topmost window, not in a frame'); } else { alert('The script runs in a frame!'); } See the Pen JS-P3-01-cross-window-communications-ex6 by Hsoub (@Hsoub) on CodePen. الخاصية sandbox المتعلقة بالنافذة الضمنية iframe تسمح الخاصية sandbox باستبعاد تنفيذ أفعال معينة داخل النافذة الضمنية <iframe>، وذلك لمنع تنفيذ الشيفرة غير الموثوقة، إذ تقيَّد الخاصية sandbox للإطار كونه آتيًا من أصل مختلف مع/أو بالإضافة إلى قيود أخرى. تتوفر عدة تقييدات افتراضية ضمن الأمر <"..."=iframe sandbox src>، ويفضل أن نترك فراغًا بين عناصر قائمة القيود التي لا نريد تطبيقها كالتالي: <iframe sandbox="allow-forms allow-popups"> ستطبّق أكثر التقييدات صرامةً عندما تكون الخاصية sandbox فارغة "دون قائمة قيود" ، ويمكننا رفع ما نشاء منها بذكرها ضمن قائمة يفصل بين عناصرها فراغات. وإليك قائمة الخيارات المتعلقة بالتقييدات: allow-same-origin: تجبر الخاصية sandbox المتصفح على احتساب النافذة الضمنية من أصل مختلف تلقائيًا، حتى لو دلت الصفة src على الأصل نفسه، لكن هذا الخيار يزيل التطبيق الافتراضي لهذه الميزة. allow-top-navigation: يسمح هذا الخيار للنافذة الضمنية بتغيير قيمة parent.location. allow-forms: يسمح هذا الخيار بإرسال نماذج forms انطلاقًا من الإطار. allow-scripts: يسمح هذا الخيار بتشغيل سكربتات انطلاقًا من الإطار. allow-popups: يسمح بفتح نوافذ منبثقة باستخدام الأمر window.open انطلاقًا من الإطار. اطلع على تعليمات التنفيذ إن أردت التعرف على المزيد. يُظهر المثال التالي إطارًا مقيَّدًا بمجموعة القيود الافتراضية <"..."=iframe sandbox src>، ويتضمن المثال شيفرةً ونموذجًا وملفين، وستلاحظ أنّ الشيفرة لن تعمل نظرًا لصرامة القيود الافتراضية. الشيفرة الموجودة في الملف "index.html": <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <div>The iframe below has the <code>sandbox</code> attribute.</div> <iframe sandbox src="sandboxed.html" style="height:60px;width:90%"></iframe> </body> </html> وفي الملف "sandbox.html": <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <button onclick="alert(123)">Click to run a script (doesn't work)</button> <form action="http://google.com"> <input type="text"> <input type="submit" value="Submit (doesn't work)"> </form> </body> </html> وستكون النتيجة: ملاحظة: إنّ الغاية من وجود الصفة sandbox هي إضافة المزيد من القيود فقط، إذ لا يمكنها إزالة هذه القيود، لكنها تخفف بعض القيود المتعلقة بالأصل المشترك إن لم يكن للإطار الأصل نفسه. تبادل الرسائل بين النوافذ تسمح واجهة التخاطب postMessage للنوافذ بالتخاطب مع بعضها أيًا كان أصلها، فهي إذًا أسلوب للالتفاف على سياسة "الأصل المشترك"، إذ يمكن لنافذة من النطاق academy.hsoub.comأن تتخاطب مع النافذةgmail.com` وتتبادل المعلومات معها، لكن بعد موافقة كلتا النافذتين ووجود دوال JavaScript الملائمة فيهما، وهذا بالطبع أكثر أمانًا للمستخدم. تتكون الواجهة من قسمين رئيسيين: القسم المتعلق بالتابع postMessage تستدعي النافذة التي تريد إرسال رسالةٍ التابع postMessage العائد للنافذة الهدف، أي إن أردنا إرسال رسالة إلى win، فعلينا تنفيذ الأمر (win.postMessage(data, targetOrigin، وإليك توضيح وسطاء هذا التابع: data: وهي البيانات المراد إرسالها، والتي قد تكون أي كائن. حيث تُنسخ البيانات باستخدام خوارزمية التفكيك البنيوي "structured serialization". لا يدعم المتصفح Internet Explorer سوى النصوص، لذلك لا بدّ من استخدام التابع JSON.stringify مع الكائنات المركبة التي سنرسلها لدعم هذا المتصفح. targetOrigin: يُحدد أصل النافذة الهدف، وبالتالي ستصل الرسالة إلى نافذة من أصل محدد فقط، ويعَدّ هذا مقياسًا للأمان، فعندما تأتي النافذة الهدف من أصل مختلف، فلا يمكن للنافذة المرسِلة قراءة موقعها location، ولن نعرف بالتأكيد الموقع الذي سيُفتح حاليًا في النافذة المحددة، وقد ينجرف المستخدم بعيدًا دون علم النافذة المرسِلة. إذًا سيضمن تحديد الوسيط targetOrigin أن النافذة الهدف ستستقبل البيانات ما دامت في الموقع الصحيح، وهذا ضروري عندما تكون البيانات على درجة من الحساسية. في هذا المثال ستستقبل النافذة الرسالة إن كان مستندها (الكائن document الخاص بها) منتميًا إلى الموقع "http://example.com": <iframe src="http://example.com" name="example"> <script> let win = window.frames.example; win.postMessage("message", "http://example.com"); </script> يمكن إلغاء التحقق من الأصل بإسناد القيمة "*" إلى الوسيط targetOrigin: <iframe src="http://example.com" name="example"> <script> let win = window.frames.example; win.postMessage("message", "*"); </script> القسم المتعلق بالحدث onmessage ينبغي أن تمتلك النافذة التي ستستقبل الرسالة، معالجًا للحدث message، والذي يقع عند استدعاء التابع postMessage (ويجري التحقق بنجاح من قيمة الوسيط targetOrigin). لكائن الحدث خصائص مميزة، هي: data: وهي البيانات التي يحضرها التابع postMessage. origin: أصل المرسل، مثلًا: http://javascript.info. source: المرجع إلى النافذة المرسِلة، إذ يمكننا الرد مباشرة بالشكل التالي (...)source.postMessage إن أردنا. ولتحديد معالج الحدث السابق لا بدّ من استخدام addEventListener، إذ لن يعمل الأمر دونه، فمثلًا: window.addEventListener("message", function(event) { if (event.origin != 'http://javascript.info') { // كائن من نطاق مختلف، سنتجاهله return; } alert( "received: " + event.data ); // يمكن إعادة الإرسال باستخدام event.source.postMessage(...) }); يتكون المثال من الملف "iframe.html": <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> Receiving iframe. <script> window.addEventListener('message', function(event) { alert(`Received ${event.data} from ${event.origin}`); }); </script> </body> </html> والملف "index.html": <!doctype html> <html> <head> <meta charset="UTF-8"> </head> <body> <form id="form"> <input type="text" placeholder="Enter message" name="message"> <input type="submit" value="Click to send"> </form> <iframe src="iframe.html" id="iframe" style="display:block;height:60px"></iframe> <script> form.onsubmit = function() { iframe.contentWindow.postMessage(this.message.value, '*'); return false; }; </script> </body> </html> وستكون النتيجة كالتالي: الخلاصة لاستدعاء التوابع التي تسمح بالوصول إلى نوافذ أخرى، لا بدّ من وجود مرجع إليها، حيث نحصل على هذا المرجع بالنسبة للنوافذ المنبثقة باستخدام: window.open: يُنفَّذ ضمن النافذة الأساسية، وسيفتح نافذة جديدة ويعيد مرجعًا إليها. window.opener: يُنفّذ ضمن النافذة المنبثقة، ويعطي مرجعًا إلى النافذة التي أنشأتها. يمكن الوصول إلى النوافذ الآباء والأبناء ضمن النوافذ الضمنية <iframe> بالشكل التالي: window.frames: تمثل مجموعة من الكائنات window المتداخلة. window.parent وwindow.top: يمثلان مرجعين إلى النافذة الأم المباشرة والنافذة الأم العليا. iframe.contentWindow: تمثل النافذة الموجودة داخل وسم النافذة الضمنية iframe. إذا كان لنافذتين الأصل نفسه (البرتوكول والمنفذ والنطاق)، فيمكنهما التحكم ببعضهما كليًا أما إن لم يكن لهما أصل مشترك، فيمكن إجراء مايلي: تغيير موقع location نافذة أخرى (سماحية للكتابة فقط). إرسال رسالة إليها. لكن توجد بعض الاستثناءات: إذا كان لنافذتين النطاق الفرعي (نطاق من المستوى الثاني) ذاته، مثلًا: a.site.com وb.site.com، فيمكن أن ندفع المتصفح لأن يعدّهما من الأصل ذاته بإضافة الأمر 'document.domain='site.com' في كلتيهما. إن امتلك الإطار الخاصية sandbox، فسيُعَدّ قسرًا من أصل مختلف، إلا في الحالة التي نستخدم فيها القيمة allow-same-origin لهذه الخاصية، ويستخدَم ذلك لتشغيل شيفرة غير موثوقة ضمن نافذة ضمنية على الموقع نفسه. تسمح الواجهة postMessage لنافذتين -ليس لهما بالضرورة الأصل ذاته- بالتخاطب مع بعضهما، حيث: تستدعي النافذة المرسِلة التابع (targetWin.postMessage(data, targetOrigin إن لم يكن للوسيط targetOrigin القيمة "*"، فسيتحقق المتصفح من أن للنافذة الهدف نفس الأصل المحدد كقيمة له. إن كان للنافذتين نفس الأصل أو لم يأخذ الوسيط targetOrigin القيمة "*"، فسيقع الحدث message الذي يمتلك الخصائص التالية: origin: نفس أصل النافذة المرسِلة مثل "http://my.site.com". source: وهو المرجع إلى النافذة المرسِلة. data: البيانات المرسَلة، ويمكن أن تكون كائنًا من أي نوع، عدا في المتصفح Internet Explorer الذي لا يقبل سوى الكائنات النصية. ولا بدّ لنا أيضًا من استخدام addEventListener لضبط معالج هذا الحدث داخل النافذة الهدف. ترجمة -وبتصرف- للفصل cross-window communication من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا الأحداث المتعلقة بدورة حياة صفحة HTML وكيفية التحكم بها عبر جافاسكربت أساسيات بناء تطبيقات الويب 10 أخطاء شائعة عند استخدام إطار العمل Bootstrap
-
تستخدم الأساليب القديمة في JavaScript النوافذ المنبثقة لإظهار صفحات أو مستندات أخرى للمستخدم، إذ يمكن فتح نافذة جديدة تعرض المورد المرتبط بالعنوان المحدَّد بسهولة عن طريق تنفيذ الأمر التالي: window.open('https://javascript.info/') تعرض معظم المتصفحات الحديثة محتويات الموارد الموجودة على عنوان محدد في نوافذ متجاورة، بدلًا من فتح نافذة جديدة لكل منها. إنّ فكرة النوافذ المنبثقة قديمة، وقد استخدمت لعرض محتوىً مختلف للمستخدم دون إغلاق النافذة الرئيسية، لكن حاليًا تستخدَم أساليب أخرى، حيث يمكن تحميل المحتوى ديناميكيًا من خلال التابع fetch ثم إظهاره ضمن معرَّف <div> يُولَّد ديناميكيًا، لذا لم يعد استخدام النوافذ المنبثقة شائعًا. كما أن استخدامها في الأجهزة النقالة التي لا تعرض عدة نوافذ معًا مربك أحيانًا. مع ذلك يبقى للنوافذ المنبثقة استخداماتها في إنجاز بعض المهام كالاستيثاق "OAuth"، المستخدم لتسجيل الدخول على Google أو Facebook وغيرها، لأنّ: النوافذ المنبثقة هي نوافذ منفصلة لها بيئة JavaScript مستقلة، وبالتالي سيكون فتح نافذة منبثقة مصدرها طرف ثالث أو موقع غير موثوق آمنًا. من السهل جدًا فتح نافذة منبثقة. يمكن للنوافذ المنبثقة الانتقال إلى عنوان آخر، وإرسال رسالة إلى النافذة الأصلية. منع ظهور النوافذ المنبثقة أساءت المواقع المشبوهة استخدام النوافذ المنبثقة سابقًا، حيث أمكنها فتح آلاف النوافذ المنبثقة التي تحمل إعلانات أو منشورات دعائية، لذلك تحاول معظم المتصفحات الحديثة حاليًا منع ظهور هذه النوافذ لحماية المستخدم. تحجب معظم المتصفحات النوافذ المنبثقة إذا استُدعيت خارج شيفرة معالجات الأحداث event handler التي تستجيب لأفعال المستخدِم مثل onClick: // تُحجب النافذة المنبثقة window.open('https://javascript.info'); // يُسمح بظهور النافذة المنبثقة button.onclick = () => { window.open('https://javascript.info'); }; وهكذا ستتمكن المتصفحات من حماية المستخدم نوعًا ما، لكن هذا الأسلوب لن يعطل وظيفة النوافذ المنبثقة كاملةً. لكن ماذا لو فتحنا نافذة منبثقة ضمن شيفرة onClcik، لكن بعد تحديد زمن ظهورها باستخدام التعليمة setTimeOut؟ سيبدو الأمر مربكًا لاختلاف السلوك على متصفحات مختلفة. جرّب الشيفرة التالية: // ستظهر النافذة لثلاث ثوان setTimeout(() => window.open('http://google.com'), 3000); See the Pen JS-P3-01-Popups-and-window-methods-ex1 by Hsoub (@Hsoub) on CodePen. تظهر النافذة المنبثقة على Chrome وسيحجبها Firefox، لكن لو قللنا زمن ظهورها فستظهر أيضًا على Firefox: // ستظهر النافذة لثانية setTimeout(() => window.open('http://google.com'), 1000); إن سبب الاختلاف السابق هو أنّ Firefox سيقبل أية قيمة لزمن الظهور التي تساوي ثانيتين أو أقل، لكنه سيرفع ثقته عن النوافذ المنبثقة معتبرًا أنها ظهرت رغم إرادة المستخدم إن زاد زمن الظهور، لذلك فقد حُجبت في الحالة الأولى وظهرت في الثانية. التابع window.open تُستخدم العبارة البرمجية التالية لفتح نافذة منبثقة: window.open(url, name, params) حيث: url: عنوان المورد الذي سيُحمّل ضمن الصفحة. name: اسم النافذة التي ستُفتح، فلكل نافذة اسم window.name، وبذلك يمكننا تحديد النافذة التي سنستخدمها لإظهار المحتويات المنبثقة، فإذا وجدت نافذة بهذا الاسم فستُحمّل محتويات المورد ضمنها، وإلا ستُفتح نافذة جديدة بالاسم المحدد. params: سلسلة نصية تمثل تعليمات تهيئة النافذة الجديدة، وتحتوي على إعدادات تفصل بينها فاصلة، ولا ينبغي وجود فراغات بين الإعدادات، مثل السلسلة width=200,height=100، والمعاملات params التي يمكن ضبطها هي: الموضع position: left/top: قيمة عددية تمثل إحداثيات الزاوية العليا اليسرى للنافذة على شاشة العرض، لكن لا يمكن وضع النافذة خارج شاشة العرض. width/height: تحدد عرض النافذة الجديدة وارتفاعها، وهناك طبعًا حد أدنى لقيمة كل منهما، فلا يمكن إنشاء نافذة غير مرئية. ميزات النافذة Window features: menubar: تأخذ إحدى القيمتين yes/no، ومهمتها إظهار قائمة المتصفح في النافذة الجديدة أو إخفاؤها. toolbar: تأخذ إحدى القيمتين yes/no، ومهمتها إظهار شريط التنقل navigation bar في النافذة الجديدة أو إخفاؤه. location: تأخذ إحدى القيمتين yes/no، ومهمتها إظهار شريط العنوان في النافذة الجديدة أو إخفاؤه، ويجب الانتباه إلى أن المتصفحين Firefox وInternet Explorer لا يسمحان بذلك افتراضيًا. status: تأخذ إحدى القيمتين yes/no، ومهمتها إظهار شريط الحالة status bar أو إخفاؤه، وتفرض معظم المتصفحات إظهاره في النوافذ الجديدة. resizable: تأخذ إحدى القيمتين yes/no، تمنع تغيير حجم النافذة الجديدة، ولا يُنصح بتمكين هذه الميزة. scrollbars: تأخذ إحدى القيمتين yes/no، تمنع ظهور أشرطة التمرير في النافذة الجديدة، ولا يُنصح بتمكين هذه الميزة. توجد أيضًا مجموعة من الميزات الأقل دعمًا كونها متعلقة بمتصفحات محددة ولا تستخدم عادةً، ويمكن الاطلاع عليها عن طريق البحث عن التابع window.open ضمن شبكة مطوري Mozilla مثال نموذجي لنافذة بأقل ميزات لنفتح نافذةً بأقل مجموعة ممكنة من الميزات، وذلك لمعرفة تلك التي يسمح المتصفح بتعطيلها: let params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no, width=0,height=0,left=-1000,top=-1000`; open('/', 'test', params); See the Pen JS-P3-01-Popups-and-window-methods-ex2 by Hsoub (@Hsoub) on CodePen. عطلنا في الشيفرة السابقة معظم ميزات النافذة ووضعناها خارج مجال عرض الشاشة، إذا نفّذنا الشيفرة السابقة وشاهدنا ما يحدث، فسنجد أن معظم المتصفحات ستعالج تلقائيًا القيم الخاطئة -كإعطاء الطول أو العرض القيمة 0 أو القيمة التي تجعل من الزاوية العليا اليسرى للنافذة خارج مجال العرض- فمثلًا: سيفتح Chrome هذه النوافذ بأعلى قيم ممكنة للطول والعرض لتملأ الشاشة. لنضع الآن النافذة في مكان مناسب ونعطي قيمًا مقبولة للإحداثيات width وheight وleft وtop: let params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no, width=600,height=300,left=100,top=100`; open('/', 'test', params); See the Pen JS-P3-01-Popups-and-window-methods-ex3 by Hsoub (@Hsoub) on CodePen. ستُظهر معظم المتصفحات النافذة بالشكل المطلوب. إليك مجموعة من القواعد عند تجاهل بعض الإعدادات: إذا لم تضع قيمة للوسيط الثالث params عند استدعاء التابع open، فستُستخدم المعاملات الافتراضية للنافذة. إذا وضعت السلسلة النصية التي تصف المعاملات params، لكنك أغفلت بعض الميزات التي تأخذ إحدى القيمتين "yes/no"، فستأخذ هذه الميزات القيمة "no"، وبالتالي إن أردت معاملات محددة، فانتبه إلى التصريح علنًا عن الميزات المطلوبة وإسناد القيمة "yes" لها. إذا لم تحدَّد الخاصية left/top في المعاملات params، فسيحاول المتصفح فتح النافذة الجديدة بجانب آخر نافذة قد فتحتها. إن لم تحدّد الخاصية width/height في المعاملات params، فسيكون حجم النافذة الجديدة مساويًا لحجم آخر نافذة فتحتها. الوصول إلى النافذة المنبثقة من النافذة الأصل يعيد استدعاء التابع open مرجعًا إلى النافذة الجديدة، يمكن استخدامه للتحكم بخصائص تلك النافذة، وتغيير موضعها وأشياء أخرى. سنوّلد في هذا المثال نافذةً منبثقةً ذات محتوىً معين باستخدام JavaScript: let newWin = window.open("about:blank", "hello", "width=200,height=200"); newWin.document.write("Hello, world!"); سنغيّر الآن محتوى النافذة بعد انتهاء تحميله: let newWindow = open('/', 'example', 'width=300,height=300') newWindow.focus(); alert(newWindow.location.href); // (*) about:blank, لم يبدأ التحميل بعد newWindow.onload = function() { let html = `<div style="font-size:30px">Welcome!</div>`; newWindow.document.body.insertAdjacentHTML('afterbegin', html); }; See the Pen JS-P3-01-Popups-and-window-methods-ex4 by Hsoub (@Hsoub) on CodePen. لن يكون محتوى النافذة الجديدة قد حُمِّل بعد استدعاء window.open مباشرةً، وقد وضحنا ذلك باستخدام التنبيه alert في السطر "(*)"، لذا عليك الانتظار حتى يعدل التابع المطلوب، كما يمكننا استخدام معالج الحدث DOMContentLoaded مع الخاصية newWin.document. الوصول إلى نافذة من نافذة منبثقة يمكن أن تصل النافذة المنبثقة إلى النافذة التي أنتجتها باستخدام المرجع الذي يعيده التابع window.opener، والذي يحمل القيمة "null" لجميع النوافذ عدا النوافذ المنبثقة. لاستبدال محتوى النافذة التي أنتجت النافذة المنبثقة بالكلمة "Test" نستخدم الشيفرة التالية : let newWin = window.open("about:blank", "hello", "width=200,height=200"); newWin.document.write( "<script>window.opener.document.body.innerHTML = 'Test'<\/script>" ); See the Pen JS-P3-01-Popups-and-window-methods-ex5 by Hsoub (@Hsoub) on CodePen. إذًا فالعلاقة بين النوافذ الأصلية والمنبثقة ثنائية الاتجاه، لأن كلّا منهما تحمل مرجعًا إلى الأخرى. إغلاق نافذة منبثقة نفذ الأمر win.close لإغلاق نافذة. نفذ الأمر win.closed للتحقق من إغلاق النافذة. يمكن عمليًا لأي نافذة استخدام التابع ()close، لكن معظم المتصفحات ستتجاهله عندما لا تُنشأ النافذة عن طريق التابع ()window.open، وبالتالي سيعمل على النوافذ المنبثقة فقط، وستكون قيمة الخاصية closed هي "true" إن أُغلقت النافذة، ولهذه الخاصية فائدتها في التحقق من إغلاق النوافذ والنوافذ المنبثقة، لذلك ينبغي على الشيفرة أن تأخذ في الحسبان احتمال إغلاق المستخدم للنافذة في أي لحظة. ستحمِّل الشيفرة التالية نافذة ثم ستغلقها: let newWindow = open('/', 'example', 'width=300,height=300'); newWindow.onload = function() { newWindow.close(); alert(newWindow.closed); // true }; See the Pen JS-P3-01-Popups-and-window-methods-ex6 by Hsoub (@Hsoub) on CodePen. تحريك النافذة وتغيير حجمها توجد عدة خيارات لتحريك نافذة أو تغيير حجمها، هي: (win.moveBy(x,y: يحرّك النافذة "x" بيكسل نحو اليمين و"y" بيكسل نحو الأسفل انطلاقًا من الموقع الحالي، ويمكن استخدام قيمة سالبة للتحرك نحو الأعلى واليسار. (win.moveTo(x,y: لنقل النافذة إلى الموقع المحدد بالإحداثيتين (x,y) ضمن الشاشة. (win.resizeBy(width,heigh: تغيير حجم النافذة بتغيير قيمتي الارتفاع والعرض بالنسبة إلى القيمتين الحاليتين، ويُسمح باستخدام قيم سالبة. (win.resizeTo(width,height: تغيير حجم النافذة إلى الأبعاد المحددة. يمكن الاستفادة أيضًا من الحدث window.onresize عندما يتغير حجم النافذة. تمرير محتويات نافذة Scrolling a window تحدثنا سابقًا عن الموضوع في فصل "أحجام النوافذ وقابلية التمرير"، والتوابع التي تنفذ المهمة هي: (win.scrollBy(x,y: يمرر محتويات النافذة "x" بكسل إلى اليمين و"y" بكسل إلى الأسفل، ويسمح التابع باستخدام القيم السالبة. (win.scrollTo(x,y: يمرر محتويات النافذة إلى الموقع الذي إحداثياته (x,y). (elem.scrollIntoView(top = true: يمرر محتويات النافذة ليظهر العنصر elem في أعلى الصفحة، وهي الحالة الافتراضية، أو أسفل الصفحة عندما تكون top=false. كما يمكن الاستفادة من الحدث window.onscroll. نقل تركيز الدخل إلى نافذة وتغشيتها focus/blur يمكن نظريًا استخدام التابعين()window.blur و()window.focus للتركيز على نافذة أو رفع التركيز عنها، كما يمكن استخدام الحدث focus/blur الذي يسمح بالتقاط اللحظة التي ينقل فيها المستخدم التركيز إلى نافذة محددة أو ينتقل إلى أخرى. لكن لو انتقلنا إلى التطبيق العملي فسنجد محدوديةً كبيرةً في تطبيقهما، لأنّ الصفحات المشبوهة أساءت استخدامهما. لنتأمل الشيفرة التالية مثلًا: window.onblur = () => window.focus(); فعندما يحاول المستخدم تبديل النافذة والانتقال إلى أخرى (window.onblur)، ستعيد الشيفرة السابقة التركيز إلى نفس النافذة. لذلك وضعت المتصفحات قيودًا لمنع استخدام الشيفرة بالطريقة السابقة، ولحماية المستخدم من النوافذ الدعائية والصفحات المشبوهة. مثلًا: يتجاهل متصفح الهواتف المحمولة التابع ()window.focus بشكل كامل، كما لا يمكن التركيز على نافذة منبثقة ستظهر في صفحة جديدة ضمن النافذة نفسها بدلًا من ظهورها في نافذة جديدة. ومع ذلك يمكننا الاستفادة من التابعين السابقين في بعض الحالات، فمثلًا: من الجيد تنفيذ الأمر ()newWindow.focusعند فتح نافذة منبثقة جديدة، لضمان وصول المستخدم إلى النافذة الجديدة في بعض المتصفحات العاملة على بعض أنظمة التشغيل. يمكن تعقب تنفيذ الحدثين window.onfocus/onblur إذا أردنا التأكد من أنّ زائري الموقع يستخدمون تطبيقنا أم لا، مما سيسمح بإيقاف أو إعادة استخدام بعض محتويات التطبيق كالرسوم المتحركة وغيرها، مع وجوب الانتباه إلى أنّ الحدث blur سيشير إلى مغادرة الزائر للنافذة المحددة، ومع إمكانية ملاحظة الزائر للنافذة إذا بقيت في خلفية الشاشة. خلاصة لا تستخدم النوافذ المنبثقة إلا نادرًا، لوجود بدائل عنها، مثل: تحميل وإظهار المعلومات ضمن الصفحة أو استخدام إطارات iframe. ويعتبر إبلاغ المستخدم بفتح نافذة منبثقة أسلوبًا جيدًا، إذ يسمح وجود أيقونة "فتح نافذة" بجوار أي رابط أو زر أن يتوقع المستخدم انتقال التركيز من النافذة التي يعمل عليها إلى أخرى، وبالتالي سينتبه إلى كلتيهما. يمكن فتح نافذة منبثقة باستدعاء التابع ( open(url, name, params الذي يعيد مرجعًا إلى النافذة الجديدة. تمنع المتصفحات استدعاء التابع من خلال شيفرة خارج نطاق أفعال المستخدم (شيفرة الاستجابة لأحداث)، كما تُظهر المتصفحات عادة تنبيهًا للمستخدم بهذا الأمر، ويكون الخيار له بالسماح بتنفيذ الشيفرة أم لا. تفتح المتصفحات صفحات جديدة ضمن النافذة نفسها بشكل افتراضي، لكن عندما يُحدد حجم للنافذة ستظهر على شكل نافذة منبثقة. يمكن للنافذة الجديدة الوصول إلى الأصلية باستخدام الخاصية window.opener. يمكن للنافذتين الجديدة والأصلية قراءة وتعديل محتويات الأخرى إذا كان لهما الأصل ذاته، ويمكنهما فقط تغيير موقع بعضهما وتبادل الرسائل إذا لم يكن لهما الأصل نفسه . يستخدم التابع ()close لإغلاق نافذة منبثقة، كما يمكن إغلاقها بالطريقة التقليدية، وفي كلا الحالتين ستكون قيمة الخاصية window.closed هي "true". يستخدم التابعان ()foucs و()blur لإعطاء التركيز إلى نافذة او تحويله عنها، لكنهما لا يعملان كما هو مطلوب دائمًا. يستخدم الحدثان focus وblur لتتبع عدد مرات دخول النافذة أو الخروج منها، مع الانتباه إلى أنّ أي نافذة يمكن أن تبقى مرئية في الخلفية بعد تنفيذ الأمر blur. ترجمة -وبتصرف- للفصل popups and window methods من سلسلة The Modern JavaScript Tutorial. اقرأ أيضًا المقال التالي: التخاطب بين النوافذ في جافاسكريبت الأحداث المتعلقة بدورة حياة صفحة HTML وكيفية التحكم بها عبر جافاسكربت الدوال العليا في جافاسكريبت
-
علينا أولًا وقبل أن ندخل في موضوع استخدام TypeScript مع React تحديد ما يلزمنا وما الذي نهدف لتحقيقه. فعندما يعمل كل شيء كما ينبغي، ستساعدنا TS في التقاط الأخطاء التالية: محاولة تمرير خاصيات Props زائدة أو غير مطلوبة لمكوِّن نسيان تمرير خاصية يحتاجها المكوَّن تمرير خاصية من النوع الخاطئ إلى مكوِّن ستساعدنا TS على التقاط أيًا من الأخطاء السابقة ضمن المحرر مباشرة. وفي حال لم نستخدم TS، سنضطر إلى التقاط هذه الأخطاء لاحقًا عن طريق الاختبارات، وربما سنقضي وقتًا مرهقًا في إيجاد مسبباتها. هذه الأسباب كافية حتى الآن لنبدأ إذًا! إنشاء تطبيق React باستخدام TypeScript يمكننا استخدام create-react-app لإنشاء تطبيق React باستخدام TS بإضافة الوسيط template إلى سكربت التهيئة الأولية. لذا نفّذ الأمر التالي لإنشاء تطبيق create-react-app باستخدام TS: npx create-react-app my-app --template typescript ينبغي بعد تنفيذ الأمر، أن تحصل على تطبيق React مكتمل يستخدم TS. يمكنك أن تشغل التطبيق باستخدام الأمر npm start في جذر المشروع. لو ألقينا نظرة على الملفات والمجلدات، ستجد أن التطبيق لا يختلف كثيرًا عن التطبيقات التي تستخدم JavaScript صرفة. إذ تقتصر الاختلافات على تحول الملفات التي تحمل إحدى اللاحقتين js. و jsx. إلى ملفات باللاحقتين ts.وtsx. وستحتوي هذه الملفات على مسجلات للأنواع، كما سيحتوي المجلد الجذري على الملف tsconfig.json. لنلق نظرة الآن على الملف tsconfig.json الذي أُنشئ نيابة عنا. ينبغي أن تكون قواعد التهيئة ضمنه مناسبة إلى حد ما، إلا أنّ هذه القواعد ستسمح بتصريف ملفات JavaScript، لأن القاعدة allowJs تأخذ القيمة "true". لا بأس بذلك إن كنت ستمزج بين TS و JavaScript (في الحالة التي تعمل فيها مثلًا على تحويل شيفرة JS إلى TS أو ما شابه)، لكننا نريد هنا إنشاء تطبيق TS صرف، لذا سنغير قيمة القاعدة allowJs إلى false. استخدمنا في المشروع السابق المدقق لمساعدتنا في فرض أسلوب برمجة معين، سنفعل ذلك أيضًا في هذا التطبيق. لا حاجة لتثبيت أية اعتماديات لأن create-react-app قد اهتم بكل الترتيبات. يحمل الملف ذو اللاحقة "eslintrc." قواعد eslint التالية: { "env": { "browser": true, "es6": true, "jest": true }, "extends": [ "eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended" ], "plugins": ["react", "@typescript-eslint"], "settings": { "react": { "pragma": "React", "version": "detect" } }, "rules": { "@typescript-eslint/explicit-function-return-type": 0 } } طالما أنّ النوع الذي تعيده جميع مكوّنات React هي عناصر JSX أو القيمة null، سنغير قواعد المدقق قليلًا بإلغاء تفعيل القاعدة explicit-function-return-type. وهكذا لن نحتاج إلى التصريح عن نوع القيمة التي تعيدها الدالة في كل مكان. علينا أيضًا أن نجعل المدقق قادرًا على فهم ملفات "tsx."، وهي المقابل في TS لملفات "jsx." في React. سننفذ ذلك بتغيير السكربت lint في الملف package.json على النحو التالي: { // ... "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "lint": "eslint './src/**/*.{ts,tsx}'" }, // ... } قد تحتاج إلى استخدام علامة التنصيص المزدوجة عند كتابة مسار المدقق: {lint": "eslint './src/**/*.{ts,tsx} وذلك إن كنت تعمل على نظام Windows. لو نفذنا الآن الأمر npm run lint سنحصل على رسالة خطأ من المدقق eslint مجددًا: لماذا يحدث ذلك؟ تخبرنا رسالة الخطأ أنّ الملف serviceWorker.ts لا يتقيد بقواعد المدقق. والسبب في ذلك، أنّ الدالة register ستستخدم دوال أخرى عُرِّفت لاحقًا في الملف نفسه وهذا لا يتوافق مع القاعدةtypescript-eslint/no-use-before-define@ . ولإصلاح المشكلة لا بدّ من نقل الدالة register إلى آخر الملف. لا ينبغي أن تظهر أية أخطاء بعد الآن. لا يشكل الخطأ السابق عائقًا في واقع الأمر، لأننا لن نحتاج إلى الملف serviceWorker.ts. لذا من الأفضل حذفه. مكونات React مع TypeScript لنتأمل المثال التالي لتطبيق React كُتب باستخدام JavaScript: import React from "react"; import ReactDOM from 'react-dom'; import PropTypes from "prop-types"; const Welcome = props => { return <h1>Hello, {props.name}</h1>; }; Welcome.propTypes = { name: PropTypes.string }; const element = <Welcome name="Sara" />; ReactDOM.render(element, document.getElementById("root")); لدينا في هذا المثال المكوًن Welcome الذي نمرر إليه اسمًا كخاصية ليقوم بتصييره على الشاشة. ينبغي أن يكون الاسم من النوع string وقد استخدمنا الحزمة prop-types التي تعرفنا عليها في القسم 5، لكي نحصل على تلميحات حول الأنواع المطلوبة لخصائص المكوّنات وتحذيرات عند استخدام خصائص من النوع الخاطئ. لن نحتاج إلى الحزمة prop-types أبدًا عند استخدام TS. إذ يمكننا تعريف الأنواع بمساعدة TS، وذلك باستخدام واجهة النوع "FunctionComponent" أو باستخدام اسمها المستعار FC. عندما نستخدم TS مع المكوّنات، ستبدو مسجلات الأنواع مختلفة قليلًا عن شيفرات TS الأخرى. حيث نضيف النوع إلى المتغيًر الذي يُسند إليه المكوّن بدلًا من الدالة وخصائصها. يدعى النوع الناتج عن "FunctionComponent" بالنوع المُعمَّم generic. بحيث يمكن أن نمرر لهذه الواجهة نوعًا كمعامل، ثم تستخدمه على أنه النوع الخاص بها. تبدو تصريحات React.FC وReact.FunctionComponent على النحو التالي: type FC<P = {}> = FunctionComponent<P>; interface FunctionComponent<P = {}> { (props: PropsWithChildren<P>, context?: any): ReactElement | null; propTypes?: WeakValidationMap<P>; contextTypes?: ValidationMap<any>; defaultProps?: Partial<P>; displayName?: string; } سترى أولًا أن FC ببساطة هو اسم مستعار لواجهة النوع FunctionComponent، وكلاهما من النوع المعمم الذي يمكن تمييزه بسهولة من قوسي الزاوية "<>" بعد اسم النوع. سترى داخل قوسي الزاوية هذه الشيفرة {}=P. وتعني أنه بالإمكان تمرير نوع كمعامل. سيُعرَف النوع الذي سيُمرَّر بالاسم P، وهو افتراضيًا كائن فارغ {}. لنلق نظرة على السطر الأول من شيفرة FunctionComponent: (props: PropsWithChildren<P>, context?: any): ReactElement | null; تحمل الخصائص النوع PropsWithChildren، وهو أيضًا نوع معمم يُمرَّر إليه النوع P. يمثل النوع "PropsWithChildren" بدوره تقاطعًا intersection بين النوعين بالشكل التالي: type PropsWithChildren<P> = P | { children?: ReactNode }; كل ما نحتاج إليه الآن من هذا الشرح الذي قد يبدو معقدًا بأنه من الممكن تعريف النوع وتمريره إلى واجهة النوع FunctionComponent، حيث ستحمل خصائصها بعد ذلك النوع الذي عرًفناه بالإضافة إلى المكوّنات الأبناء. لنعد إلى مثالنا ونحاول أن نعرًف نوعًا لخصائص المكوًن Welcome باستخدام TS: interface WelcomeProps { name: string; } const Welcome: React.FC<WelcomeProps> = (props) => { return <h1>Hello, {props.name}</h1>; }; const element = <Welcome name="Sara" />; ReactDOM.render(element, document.getElementById("root")); عرًفنا في الشيفرة السابقة واجهة النوع WelcomeProps ومررناها إلى المكوّن Welcome عندما صرّحنا عن نوعه: const Welcome: React.FC<WelcomeProps>; يمكننا كتابة الشيفرة بأسلوب أقصر: const Welcome: React.FC<{ name: string }> = ({ name }) => ( <h1>Hello, {name}</h1> ); سيعرف المحرر الآن أنّ الخاصية من النوع string. لكن المدقق لن يقتنع تمامًا بما فعلنا، وسيعترض بأن الخاصية name غير موجودة عند تقييم الخصائص. ويحدث هذا لأنّ قاعدة المدقق ستتوقع منا أن نعرِّف أنواعًا لجميع الخصائص. فلن يدرك المدقق أننا نستخدم TS في تعريف الأنواع لخصائصنا. لإصلاح الخلل، لا بدّ من إضافة قاعدة تدقيق جديدة إلى الملف "eslintrc.": { // ... "rules": { "react/prop-types": 0, }, // ... } التمرين 9.14 9.14 أنشئ تطبيق create-react-app مستخدمًا TS وهيئ المدقق لمشروعك بالطريقة التي تعلمناها. يشبه هذا التمرين تمرينًا نفَّذناه في القسم 1 من المنهاج لكن باستخدام TS هذه المرة بالإضافة إلى بعض التعديلات. إبدأ بتعديل محتويات الملف "index.tsx" لتصبح على النحو التالي: import React from "react"; import ReactDOM from "react-dom"; const App: React.FC = () => { const courseName = "Half Stack application development"; const courseParts = [ { name: "Fundamentals", exerciseCount: 10 }, { name: "Using props to pass data", exerciseCount: 7 }, { name: "Deeper type usage", exerciseCount: 14 } ]; return ( <div> <h1>{courseName}</h1> <p> {courseParts[0].name} {courseParts[0].exerciseCount} </p> <p> {courseParts[1].name} {courseParts[1].exerciseCount} </p> <p> {courseParts[2].name} {courseParts[2].exerciseCount} </p> <p> Number of exercises{" "} {courseParts.reduce((carry, part) => carry + part.exerciseCount, 0)} </p> </div> ); }; ReactDOM.render(<App />, document.getElementById("root")); واحذف الملفات غير الضرورية. إنّ التطبيق ككل موجود في مكوّن واحد، وهذا ما لا نريده. أعد كتابة الشيفرة لتتوزع على ثلاثة مكوّنات: Header وContent وTotal. ابق على جميع البيانات ضمن المكوّن App الذي سيمرر كل البيانات اللازمة لمكوّن كخصائص. واحرص على تعريف نوع لكل خاصية من خصائص المكوُنات. سيتحمل المكوًن Header مسؤولية تصيير اسم المنهاج، وسيصيّر المكوّن Content أسماء الأقسام المختلفة وعدد التمارين في كل قسم، أما المكوّن Total فسيصيّر مجموع التمارين في كل الأقسام. سيبدو المكوًن App بالشكل التالي تقريبًا: const App = () => { // const-declarations return ( <div> <Header name={courseName} /> <Content ... /> <Total ... /> </div> ) }; استخدام أدق للأنواع يتضمن المثال السابق منهاجًا من ثلاثة أقسام، ويمتلك كل قسم نفس الصفتين name وexcerciseCount. لكن ماذا لو احتجنا إلى صفات أخرى، وتطلّب كل قسم صفات مختلفة عن الآخر؟ كيف ستبدو شيفرة التطبيق؟ لنتأمل المثال التالي: const courseParts = [ { name: "Fundamentals", exerciseCount: 10, description: "This is an awesome course part" }, { name: "Using props to pass data", exerciseCount: 7, groupProjectCount: 3 }, { name: "Deeper type usage", exerciseCount: 14, description: "Confusing description", exerciseSubmissionLink: "https://fake-exercise-submit.made-up-url.dev" } ]; أضفنا في الشيفرة السابقة صفات أخرى إلى كل قسم. يمتلك الآن كل قسم الصفتين name وexerciseCount، ويمتلك القسمان الأول والثالث الصفة description، كما يمتلك القسمان الثاني والثالث بعض الصفات الإضافية الخاصة. لنتخيل أنّ تطبيقنا سيستمر في النمو، وأننا سنضطر إلى تمرير الأقسام المختلفة ضمن الشيفرة. وقد تّضاف أيضًا خصائص أخرى أو أقسام أخرى. كيف سنضمن أن الشيفرة قادرة على التعامل مع كل الأنواع المختلفة للبيانات بشكل صحيح، وأننا لن ننس تصيير أحد أقسام المنهاج على صفحة ما؟ هنا ستظهر فائدة اللغة TS! لنبدأ بتعريف أنواع لأقسام المنهاج المختلفة: interface CoursePartOne { name: "Fundamentals"; exerciseCount: number; description: string; } interface CoursePartTwo { name: "Using props to pass data"; exerciseCount: number; groupProjectCount: number; } interface CoursePartThree { name: "Deeper type usage"; exerciseCount: number; description: string; exerciseSubmissionLink: string; } ستُنشئ الشيفرة التالية نوعًا موّحَدًا من كل الأنواع. وبالتالي يمكننا استخدامه من أجل مصفوفتنا التي يُفترض بها أن تقبل أي نوع من الأنواع التي تحملها أقسام المنهاج: type CoursePart = CoursePartOne | CoursePartTwo | CoursePartThree; يمكننا الأن تحديد نوع المتغيّر Coursepart، وسيحذرنا المحرر تلقائيًا إن استخدمنا النوع الخاطئ لإحدى الصفات، أو استخدمنا صفة زائدة، أو نسينا أن نحدد صفة متوقعة. اختبر ذلك بوضع علامة تعليق قبل أي صفة لأي قسم. وبفضل القيمة النصية الحرفية التي تحملها السمة name، يمكن أن تحدد TS أي قسم سيحتاج إلى أية صفات إضافية، حتى لو عرفنا المتغير على أنه من النوع الموّحد. لم نصل درجة القناعة بتطبيقنا بعد، فلا زال هناك تكرار كثير للأنواع، ونريد أن نتجنب ذلك. لهذا سنبدأ بتعريف الصفات المشتركة بين جميع الأقسام، ثم سنعرِّف نوعًا أساسيًا (Base Type) يحتويها. سنوسع بعد ذلك النوع الأساسي لإنشاء الأنواع الخاصة بكل قسم: interface CoursePartBase { name: string; exerciseCount: number; } interface CoursePartOne extends CoursePartBase { name: "Fundamentals"; description: string; } interface CoursePartTwo extends CoursePartBase { name: "Using props to pass data"; groupProjectCount: number; } interface CoursePartThree extends CoursePartBase { name: "Deeper type usage"; description: string; exerciseSubmissionLink: string; } كيف سنستخدم هذه الأنواع الآن في مكوًناتنا؟ من الطرق المفيدة في استخدام هذه الأنواع هي في عبارة switch case. فعندما تصرُح علنًا عن نوع المتغير أنه من النوع الموّحد أو استدلت TS على ذلك واحتوى كل نوع موجود ضمن النوع الموًحد صفة معينة، يمكن استخدام ذلك كمعرّفات للأنواع. يمكن بعد ذلك استخدام البنية switch case للتنقل بين الصفات وستحدد TS الصفات الموجودة في كل حالة من حالات البنية switch. ستميّز TS في المثال السابق أن المتغير coursePart من النوع CoursePart. ويمكنها عندها أن تستدل أن المتغير part من أحد الأنواع التالية CoursePartOne أو CoursePartTwo أو CoursePartThree. أما الصفة name فهي مختلفة ومميزة لكل نوع، لذلك من الممكن استخدامها لتمييز الأنواع وستكون TS قادرة على تحديد الصفات الموجودة في كل حالة case من حالات البنية switch case. وهكذا ستعطي خطأً إن حاولت على سبيل المثال أن تستخدم الصفة part.descriptionضمن كتلة الحالة Using props to pass data. كيف سنضيف نوعًا جديدًا؟ من الجيد أن نعرف إن كنا قد أنجزنا مسبقًا آليةً للتعامل مع هذا النوع في شيفرتنا، عند إضافة قسم جديد للمنهاج. فإضافة أي نوع جديد في المثال السابق ستجعله موجودًا في الكتلة default من البنية switch case وبالتالي لن يطبع شيء عن هذا النوع. وقد يكون هذا الأمر مقبولًا في بعض الحالات، كالحالة التي نريد فيها التعامل مع نوع واحد محدد من النوع الموّحد، لكن في أغلب الحالات عليك التعامل مع كل الحالات وبشكل منفصل. يمكن في TS أن نستخدم طريقة تدعى "التحقق الشامل من الأنواع". ومبدأ هذه الطريقة: أنه في حال واجهتنا قيمة غير معروفة النوع، نستدعي دالة تقبل قيمة من النوع never وتعيد قيمة من النوع نفسه. تمثل الشيفرة التالية تطبيقًا مباشرًا لهذا المبدأ: /** * Helper function for exhaustive type checking */ const assertNever = (value: never): never => { throw new Error( `Unhandled discriminated union member: ${JSON.stringify(value)}` ); }; لكن لو أردنا استبدال محتويات الكتلة كالتالي: default: return assertNever(part); ووضع علامة تعليق قبل الكتلة Deeper type usage case block سنرى الخطأ التالي: تنص الرسالة أن معاملًا من النوع CoursePartThree لم يُسند إلى معامل من النوع never. أي أننا نستخدم متغيرًا في مكان ما يفترض به أن يكون من النوع never. وهذا ما يدلنا على وجود مشكلة. لكن بمجرد أن نزيل علامة التعليق التي وضعناها على الكتلة Deeper type usage case block سيختفي الخطأ. التمرين 9.15 9.15 أضف في البداية النوع information إلى الملف index.tsx واستبدل المتغير courseParts بالمتغير الموجود في المثال التالي: // new types interface CoursePartBase { name: string; exerciseCount: number; } interface CoursePartOne extends CoursePartBase { name: "Fundamentals"; description: string; } interface CoursePartTwo extends CoursePartBase { name: "Using props to pass data"; groupProjectCount: number; } interface CoursePartThree extends CoursePartBase { name: "Deeper type usage"; description: string; exerciseSubmissionLink: string; } type CoursePart = CoursePartOne | CoursePartTwo | CoursePartThree; // this is the new coursePart variable const courseParts: CoursePart[] = [ { name: "Fundamentals", exerciseCount: 10, description: "This is an awesome course part" }, { name: "Using props to pass data", exerciseCount: 7, groupProjectCount: 3 }, { name: "Deeper type usage", exerciseCount: 14, description: "Confusing description", exerciseSubmissionLink: "https://fake-exercise-submit.made-up-url.dev" } ]; نعلم الآن أن واجهتي النوع CoursePartThree وCoursePartOne يتقاسمان صفة تدعى description بالإضافة إلى الصفات الأساسية (الموجودة في واجهة النوع الأساسية)، وهذه الصفة من النوع string في كلتا الواجهتين. تقتضي مهمتك الأولى أن تصرِّح عن واجهة نوع جديد تتضمن الصفة description وتوسِّع واجهة النوع CoursePartThree. عدّل الشيفرة بعد ذلك بحيث تصبح قادرًا على إزالة الصفة description من الواجهتين دون حدوث أية أخطاء. أنشئ بعد ذلك المكوّن Part الذي يصيّر كل الصفات من كل نوع ضمن أقسام المنهاج. استخدم آلية تحقق شاملة من الأنواع معتمدًا على بنية switch case. استخدم المكوِّن الجديد ضمن المكوّن Content. أضف في النهاية واجهة نوع لقسم جديد يحوي على الأقل الصفات التالية: name وexerciseCount وdescription. ثم أضف واجهة النوع هذه إلى النوع الموّحد CoursePart وأضف البيانات المتعلقة بالقسم إلى المتغير CourseParts. إن لم تكن قد عدّلت المكوّن Content بشكل صحيح، ستحصل على رسالة خطأ، لأنك لم تضف ما يدعم النوع الخاص بالقسم الرابع. أجر التعديلات المناسبة على المكوّن Content لكي تُصيّر كل صفات القسم الجديد دون أخطاء. ملاحظة حول تعريف أنواع للكائنات لقد استخدمنا واجهات النوع لتعريف أنواع للكائنات مثل DiaryEntry من الفقرة السابقة: interface DiaryEntry { id: number; date: string; weather: Weather; visibility: Visibility; comment?: string; } وCoursePart من هذه الفقرة. interface CoursePartBase { name: string; exerciseCount: number; } لقد كان بمقدورنا تنفيذ ذلك باستخدام نوع بديل type alias. type DiaryEntry = { id: number; date: string; weather: Weather; visibility: Visibility; comment?: string; } يمكنك استخدام الأسلوبين Interface وType لإنشاء نوع في معظم الحالات. لكن هنالك بعض النقاط التي ينبغي الانتباه لها. فلو عرَّفت عدة واجهات نوع لها نفس الاسم ستحصل على واجهة نوع مختلطة، بينما لو عرّفت عدة أنواع لها الاسم ذاته ستحصل على رسالة خطأ مفادها أنّ نوعًا يحمل نفس الاسم قد جرى التصريح عنه مسبقًا. ينصحك توثيق TS باستخدام الواجهات في معظم الحالات. العمل مع شيفرة جاهزة من الأفضل عندما تبدأ العمل على شيفرة جاهزة للمرة الأولى أن تلقي نظرة شاملة على هيكلية المشروع وأسلوب العمل. يمكنك البدء بقراءة الملف README.md الموجود في جذر المستودع. يحتوي هذا الملف عادة على وصف موجز للتطبيق ومتطلباته، وكيف سنجعله يعمل لبدء عملية التطوير. إن لم يكن الملف README متاحًا أو أنّ أحدهم فضّل اختصار الوقت ووضعه كمرجع احتياطي، سيكون من الجيد الاطلاع على الملف package.json. ومن الجيد دائمًا تشغيل التطبيق وتجريبه والتحقق من وظائفه. يمكنك أيضًا تصفح هيكيلة مجلد المشروع لتطلع على وظائفه أو/والمعمارية المستخدمة. لكن هذا الأسلوب لن يفيدك دائمًا، فلربما اختار المطوّر أسلوبًا لم تعهده. ركّزنا في تنظيم المشروع التدريبي الذي سنستخدمه في ما تبقى من هذا القسم على الميزات التي يقدمها. حيث يمكنك الاطلاع على الصفحات التي يعرضها التطبيق، وبعض المكوّنات العامة كالوحدات وحالة التطبيق، وتذكر أن للميزات مجالات مختلفة. فالوحدات هي مكونات مرئية على مستوى واجهة المستخدم بينما تحافظ حالة التطبيق على جميع البيانات تحت الستار لكي تستخدمها بقية المكوّنات. تزوّدك TS بالأنواع التي تخبرك عن شكل بنى البيانات والدوال والمكوّنات والحالة التي ستحصل عليها. يمكنك إلقاء نظرة على الملف types.ts أو أي ملف مشابه كبداية. كما سيساعدك VSCode كثيرًا فمجرد توضيحه للمتغيرات والمعاملات سيمنحك رؤية أفضل للشيفرة. ويعتمد هذا كله بالطبع على طريقة استخدام الأنواع في هذا المشروع. إن احتوى المشروع اختبارات أجزاء أو اختبارت تكامل فسيكون الاطلاع عليها مفيدًا بالتأكيد. فالاختبارات أداة مفيدة جدًا عندما تعيد كتابة الشيفرة أو عند كتابة ميزات جديدة للتطبيق. وتأكد من عدم تخريب أية ميزة موجودة عندما تبدأ بالتلاعب بالشيفرة. سترشدك TS أيضًا بما يتعلق بالمعاملات والقيم المعادة عندما تتغير الشيفرة. إن قراءة الشيفرة مهارة بحد ذاتها. لا تقلق إن لم تستطع فهم الشيفرة في البداية، فقد تحتوي الشيفرات على بعض الحالات غير الواضحة أو قد تحتوي أجزاءً أضيفت هنا وهناك خلال عملية التطوير. فلا يمكننا تصور المشاكل التي عانى منها المطوّر السابق لهذه الشيفرة. ويتطلب فهم تفاصيل الشيفرة بشكل كامل الغوص إلى أعماقها وفهم متطلبات المجال الذي تعمل عليه. فكلما قرأت شيفرات أكثر ستصبح أفضل في فهمها واستخدامها. إقرأ شيفرات أكثر مما تكتبه. الواجهة الأمامية لتطبيق إدارة المرضى لقد حان الوقت لإكمال الواجهة الأمامية للواجهة الخلفية التي بنيناها في التمارين السابقة وقبل أن نتعمق في كتابة الشيفرة، سنشغل الواجهتين معًا. إن سار كل شيء على ما يرام، سترى صفحة ويب تضم قائمة بالمرضى. تحضر الصفحة قائمة المرضى من الواجهة الخلفية وتصيّرها على الشاشة ضمن جدول بسيط. ستجد أيضًا زرًا لإضافة مريض جديد إلى الواجهة الخلفية. وطالما أننا نستخدم بيانات وهمية بدلًا من قواعد البيانات، فلن تخزّن البيانات التي أضفناها عند إغلاق الواجهة الخلفية. وطبعًا تقييم تصميم واجهة المستخدم UI ليس في صالح المصمم، لذلك سنهمل موضوع واجهة المستخدم حاليًا. بعد التأكد من كل الأمور، يمكننا الشروع في دراسة الشيفرة. سنجد معظم النقاط المهمة في المجلد /src. ولمساعدتك هنالك أيضًا ملف جاهز يصف الأنواع الرئيسية التي يستخدمها التطبيق، والتي علينا ان نوسِّعها أو نعيد كتابتها خلال التمارين. يمكننا من حيث المبدأ استخدام الأنواع نفسها في الواجهتين الأمامية والخلفية، لكن الواجهة الأمامية ستحتوي على بنى بيانات مختلفة وستستخدمها في حالات مختلفة، مما يجعل الأنواع ضمنها مختلفة. فللواجهة الأمامية على سبيل المثال حالة للتطبيق، ولربما أردت تخزين البيانات ضمن كائنات، بينما تستخدم الواجهة الخلفية مصفوفة. وقد لا تحتاج الواجهة الأمامية إلى كل حقول كائن البيانات المخزّن في الواجهة الخلفية، كما قد تضيف حقولًا جديدة لاستخدامها في التصيير. ستبدو هيكلية المجلد على النحو التالي تحتوي الواجهة الخلفية حاليًا مكوّنين هما: AddPatientModal وPatientListPage. يحتوي المجلد state على الشيفرة التي تتعامل مع حالة الواجهة الأمامية. وتقتصر وظيفة الشيفرة في هذا المجلد على إبقاء البيانات في مكان واحد وتزويد الواجهة بأفعال بسيطة لتبديل حالة التطبيق. التعامل مع حالة التطبيق لندرس الطرق المتبعة في التعامل مع حالة التطبيق. إذ يبدو أنّ الكثير من الأشياء تجري خلف الستار، وهي مختلفة قليلًا عن الطرق التي استخدمت سابقًا في المنهاج. بُني أسلوب إدارة الحالة باستخدام الخطافين useContext وuseReducer للمكتبة React. وهذا قرار صائب لأنّ التطبيق صغير نوعًا ما ولن نحتاج إلى Redux أو إلى أية مكتبات أخرى لإدارة الحالة. ستجد على الانترنت مواد تعليمة مفيدة عن استخدام هذه المقاربة. تستخدم هذه المقاربة أيضًا الواجهة البرمجية context العائدة للمكتبة React، إذ ينص توثيق الواجهة أنها: فالبيانات المشتركة في حالتنا هي حالة التطبيق ودالة الإيفاد التي تستخدم لإجراء التعديلات على البيانات. ستعمل شيفرتنا بأسلوب يشابه كثيرًا أسلوب Redux في إدارة الحالة والذي خبرناه في القسم 6، لكنها شيفرة أسرع لأنها لا تحتاج أية مكتبات خارجية. يفترض هذا القسم أنك مطلع على طريقة عمل Redux على الأقل، وبعبارة أخرى من المفترض أنك اطلعت على الفصل الأول من القسم 6. تتضمن الواجهة البرمجية context قناة تضم حالة التطبيق ودالة إيفاد لتغيير الحالة. وقد حُدِّد نوع للحالة على النحو التالي: export type State = { patients: { [id: string]: Patient }; }; فالحالة كما تُظهر الشيفرة هي كائن بمفتاح واحد يدعى Patients يمتلك قاموسًا، أو بعبارة أخرى، يقبل كائنًا له مفاتيح من النوع "string" مع كائن Patient كقيمة له. يمكن لقرينة الفهرسة أن تكون من أحد النوعين "string" أو"number" إذ يمكننا الوصول إلى قيم الكائن باستخدام هذين النوعين. وبهذا نجبر الحالة أن تتقيد بالصيغة التي نريد، وتمنع المطورين من استخدامها بشكل غير صحيح. لكن انتبه إلى نقطة مهمة! عندما يُصرّح عن نوع بالطريقة التي اتبعناها مع patients، فلن تمتلك TS أية طريقة لمعرفة إن كان المفتاح الذي تحاول الوصول إليه موجودًا أم لا. فلو حاولنا الوصول إلى بيانات مريض بمعرِّف غير موجود، سيعتقد المصرّف أن القيمة المعادة من النوع Patient، ولن تلق أية أخطاء عند محاولة الوصول إلى خصائص هذا الكائن: const myPatient = state.patients['non-existing-id']; console.log(myPatient.name); // لا أخطاء إذ يعتقد المصرّف أن القيمة المعادة //patient من النوع لإصلاح الخلل، يمكننا أن نعرّف نوع القيم التي تحمل بيانات المريض على أنها من نوعٍ موحّد بين Patient وundefined كالتالي: export type State = { patients: { [id: string]: Patient | undefined }; }; وجراء هذا الحل، سيحذرنا المصرّف بالرسالة التالية: const myPatient = state.patients['non-existing-id']; console.log(myPatient.name); // error, Object is possibly 'undefined' من الجيد دائمًا إنشاء هذا النوع مع بعض الإضافات الأمنية عندما تستخدم مثلًا بيانات من مصادر خارجية أو قيمة أدخلها المستخدم للوصول إلى بيانات ضمن شيفرتك. لكن إن كنت متأكدًا من أن شيفرتك قادرة على التعامل مع البيانات غير الموجودة، فلا مانع أبدًا من استخدام أول حلٍ قدمناه. وعلى الرغم من عدم استخدام هذا الأسلوب في هذا القسم، ينبغي الإشارة إلى أن استخدام كائنات Map سيؤمن لك طريقة أكثر تشددًا في استخدام الأنواع. حيث يمكنك أن تُصرح عن نوع لكلٍ من المفتاح ومحتواه. تعيد دالة الوصول ()getإلى كائنات Map نوعًا موحّدًا يجمع بين النوع المصرّح عنه وundefined، وبالتالي ستطلب TS تلقائيُا إجراء تحقق من البيانات المستخلصة من كائن Map: interface State { patients: Map<string, Patient>; } ... const myPatient = state.patients.get('non-existing-id'); // type for myPatient is now Patient | undefined console.log(myPatient.name); // error, Object is possibly 'undefined' console.log(myPatient?.name); // valid code, but will log 'undefined' تُنفذ التعديلات على الحالة باستخدام دوال الاختزال، تمامًا كما في Redux. عُرِّفت هذه الدوال في الملف reducer.ts بالإضافة إلى النوع Action الذي يبدو على النحو التالي: export type Action = | { type: "SET_PATIENT_LIST"; payload: Patient[]; } | { type: "ADD_PATIENT"; payload: Patient; }; تبدو دالة الاختزال مشابه تمامًا للدوال التي أنشأناها في القسم 6. حيث تغيّر حالة كل نوع من الأفعال: export const reducer = (state: State, action: Action): State => { switch (action.type) { case "SET_PATIENT_LIST": return { ...state, patients: { ...action.payload.reduce( (memo, patient) => ({ ...memo, [patient.id]: patient }), {} ), ...state.patients } }; case "ADD_PATIENT": return { ...state, patients: { ...state.patients, [action.payload.id]: action.payload } }; default: return state; } }; ينحصر الفرق في أنّ الحالة الآن على شكل قاموس (أو كائن) بدلًا من المصفوفة التي استخدمناها في القسم 6. تجري الكثير من الأمور في الملف state.ts والتي تهيئ سياق العمل. ويعتبر الخطاف useReducer الذي يستخدم لإنشاء الحالة، ودالة الإيفاد المكونان الرئيسيان لإنجاز التغييرات على حالة التطبيق، حيث يمرران إلى التابع context.povider: export const StateProvider: React.FC<StateProviderProps> = ({ reducer, children }: StateProviderProps) => { const [state, dispatch] = useReducer(reducer, initialState); return ( <StateContext.Provider value={[state, dispatch]}> {children} </StateContext.Provider> ); }; يستخدم التابع السابق في تزويد كل المكوّنات بالحالة ودالة الإيفاد، وذلك بفضل الإعدادات الموجودة في الملف index.ts: import { reducer, StateProvider } from "./state"; ReactDOM.render( <StateProvider reducer={reducer}> <App /> </StateProvider>, document.getElementById('root') ); كما يُعرّف أيضًا الخطاف useStateValue: export const useStateValue = () => useContext(StateContext); وتستعمله أيضًا المكوّنات التي تحتاج إلى الحالة أو دالة الإيفاد لتخزينهما: import { useStateValue } from "../state"; // ... const PatientListPage: React.FC = () => { const [{ patients }, dispatch] = useStateValue(); // ... } لا تقلق إن بدا الأمر مربكًا قليلًا، فبالطبع سيبقى كذلك حتى تدرس توثيق context وطريقة استخدامها في إدارة الحالة. لكن ليس عليك فهم كل ذلك بشكل كامل حتى تحل التمارين. من الشائع جدًا أن لا تفهم بشكل كامل ما تفعله الشيفرة خلف الستار، عندما تبدأ العمل على مشروع جاهز. فإن كان المشروع مبني بهيكلية جيدة يمكنك أن تثق أن أية تعديلات مدروسة جيدًا لن تؤثر على عمل التطبيق، على الرغم من عدم إدراكك لكامل آليات العمل التي تجري داخله. مع الوقت ستفهم الأجزاء التي لم تكن مألوفة بالنسبة لك، لكن ذلك لن يحدث بين يوم وليلة وخاصة عندما تعمل على مشروع ذو شيفرة ضخمة. صفحة قائمة المرضى لنتوجه إلى الملف index.ts الموجود في المجلد PateintListPage لنأخذ بعض الأفكار التي تساعدنا على إحضار البيانات من الواجهة الخلفية وتحديث حالة التطبيق. تستخدم الصفحة خطافًا أنشئ خصيصًا لوضع البيانات في الحالة ودالة الإيفاد لتحديث محتوياتها. فعندما ننشئ قائمة بالمرضى لا نحتاج سوى تفكيك خصائص الكائن patients الموجود في حالة التطبيق: import { useStateValue } from "../state"; const PatientListPage: React.FC = () => { const [{ patients }, dispatch] = useStateValue(); // ... } ونستخدم أيضًا الحالة app التي أنشئت باستخدام الخطاف useState لإدارة حالات إظهار الوحدة أو إخفائها، وكذلك معالجة الأخطاء: const [modalOpen, setModalOpen] = React.useState<boolean>(false); const [error, setError] = React.useState<string | undefined>(); نمرر للخطاف useState نوعًا كمعامل، ثم يُطبَّق على الحالة الفعلية. فالمعامل modalOpen من النوع Boolean والمعامل error من النوع string | undefined. وكلا دالتي الضبط اللتان يعيدهما الخطاف useState سيقبلان معاملات من نوع يماثل النوع الذي يمرر إليها من خلال معامل (يمتلك نوعًا). فالنوع الفعلي للدالة setModalOpen هو ما يلي: <<React.Dispatch<React.SetStateAction<boolean. كما نستخدم الدالتين المساعدتين closeModal وopenModal لقراءة البيانات بشكل أفضل وأكثر ملاءمة: const openModal = (): void => setModalOpen(true); const closeModal = (): void => { setModalOpen(false); setError(undefined); }; تتعلق الأنواع في الواجهة الأمامية بما قمت بتطويره بإنشائه عند تطوير الواجهة الخلفية في الفصل السابق. سيحضر المكوّن App عندما يُثبَّت المرضى مستخدمًا المكتبة axios. ويجدر الانتباه إلى أننا مررنا نوعًا على شكل معامل إلى الدالة axios.get لكي نحدد نوع البيانات التي سنحصل عليها من الاستجابة: React.useEffect(() => { axios.get<void>(`${apiBaseUrl}/ping`); const fetchPatientList = async () => { try { const { data: patients } = await axios.get<Patient[]>( `${apiBaseUrl}/patients` ); dispatch({ type: "SET_PATIENT_LIST", payload: patients }); } catch (e) { console.error(e); } }; fetchPatientList(); }, [dispatch]); تحذير! لن تُقيَّم البيانات عندما نمرر النوع كمعامل إلى المكتبة axios. وهو خطر تمامًا وخاصة إن كنت تستخدم واجهة برمجية خارجية. لتفادي ذلك يمكنك إنشاء دوال تقييم مخصصة تتحمل عبء التقييم وتعيد النوع الصحيح، أو يمكنك استخدام "حاميات النوع". ستجد أيضًا عدة مكتبات تزوّدك بأساليب لتقييم البيانات ضمن أنواع مختلفة من التخطيطات، مثل المكتبة io-ts. لكننا وتوخيًا للبساطة سنثق أننا سنحصل على البيانات بشكلها الصحيح من الواجهة الخلفية. وطالما أن تطبيقنا صغير، سنحدِّث الحالة باستدعاء دالة الإيفاد التي يؤمنها لنا الخطاف useStateValue. وسيساعدنا المصرّف بالتأكد من أننا أوفدنا الأفعال بما يوافق النوع Action الذي يضم قيمة محددة مسبقًا من النوع string وحمولة من البيانات payload. dispatch({ type: "SET_PATIENT_LIST", payload: patients }); التمارين 9.16 - 9.18 سنضيف قريبًا النوع Entry إلى التطبيق والذي يمثّل مُدخلًا على شكل ملخّص بسيط عن المريض. يتكون الملخص من نص وصفي، وتاريخ الإنشاء، ومعلومات تتعلق بالاختصاصي الذي أنشأ الملخص ورمز التشخيص المحتمل. ترتبط شيفرة التشخيص بالرموز ICD-10 التي تُعيدها وصلة التخديم api/diagnosis/. سيكون ما ننفذه بسيطًا، إذ يعطي لكل مريض مصفوفة من المُدخلات. سنُجري بعض التحضيرات، قبل الشروع في العمل. 9.16 تطبيق إدارة المرضى: الخطوة 1 أنشئ وصلة تخديم على العنوان api/patients/:id/ تعيد كل المعلومات عن مريض، بما فيها مصفوفة المُدخلات الخاصة به والتي ستبقى فارغة لجميع المرضى. وسّع حاليًا الأنواع في الواجهة الخلفية على النحو التالي: // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface Entry { } export interface Patient { id: string; name: string; ssn: string; occupation: string; gender: Gender; dateOfBirth: string; entries: Entry[]} export type PublicPatient = Omit<Patient, 'ssn' | 'entries' > ينبغي أن تبدو الاستجابة كالتالي: 9.17 تطبيق إدارة المرضى: الخطوة 2. أنشئ صفحة لإظهار المعلومات الكاملة عن مريض ضمن الواجهة الأمامية. ينبغي أن يكون المستخدم قادرًا على الوصول إلى معلومات المريض بالنقر على اسم المريض مثلًا. أحضر البيانات من وصلة التخديم التي أنشأتها في التمرين السابق. أضف المعلومات التي ستحضرها عن المريض إلى حالة التطبيق. لا تحضر المعلومات إن كانت موجودة مسبقًا في حالة التطبيق، أي في الحالة التي يعرض فيها المستخدم معلومات مريض أكثر من مرة. طالما أننا سنستخدم حالة التطبيق ضمن سياق العمل، عليك أن تُعرّف فعلًا جديدًا لتحديث بيانات مريض محدد. يستخدم التطبيق المكتبة Semantic UI React لإضافة التنسيقات إليه. وهي مكتبة مشابهة كثيرًا للمكتبتين React Bootstrap وMaterialUI اللتان تعاملنا معهما في القسم 7. يمكنك استخدامهما لتنسيق المكوّن الجديد، وهذا أمر يعود إليك، فاهتمامنا ينصب الآن على TS. ويستخدم التطبيق أيضًا المكتبة react router للتحكم بإظهار واجهات العرض على الشاشة. عليك إلقاء نظرة على القسم 7 إن لم تكن متمكنًا من فهم آلية عمل الموجّهات routers. ستبدو النتيجة كالتالي: يظهر جنس المريض باستخدام المكون Icon من المكتبة Semantic UI React. ملاحظة: لتصل إلى المُعرِّف بكتابة عنوان المورد، عليك إعطاء الخطاف useParams معامل من نوع مناسب: const { id } = useParams<{ id: string }>(); 9.18 تطبيق إدارة المرضى: الخطوة 3. سننشئ حاليًا كائنات أفعال action في أي مكان نوفد إليه هذه الأفعال. إذ يمتلك المكوّن App مثلًا دالة الإيفاد التالية: dispatch({ type: "SET_PATIENT_LIST", payload: patientListFromApi }); أعد كتابة الشيفرة مستخدمًا دوال توليد الأفعال المُعرّفة ضمن الملف "reducer.tsx". سيتغير المكوِّن App مثلًا على النحو التالي: import { useStateValue, setPatientList } from "./state"; // ... dispatch(setPatientList(patientListFromApi)); مدخلات كاملة أنجزنا في التمرين 9.12 وصلة تخديم لإحضار تشخيص مريض، لكننا لم نستخدمها بعد. من الجيد توسيع بياناتنا قليلُا بعد أن أصبح لدينا صفحة لعرض معلومات المرضى. لنضف إذًا حقلًا من النوع Entry إلى بيانات المريض، تتضمن مدخلاته الدوائية مع التشخيص المحتمل. لنفصل بنية بيانات المرضى السابقة عن الواجهة الخلفية ونستخدم الشكل الجديد الموسَّع. ملاحظة: إن تنسيق البيانات هذه المرة بالصيغة "ts." وليس بالصيغة ".json". كما أن النوعين Gender وPatient جاهزين مسبقًا، لذلك كل ما عليك الآن هو تصحيح مسار إدراجهما إن اقتضت الحاجة. لننشئ النوع Entry بشكل ملائم بناء على البيانات المتوفرة. لو ألقينا الآن نظرة أقرب على البيانات المتوفرة، سنجد أن المُدخلات مختلفة عن بعضها قليلًا. لاحظ الاختلافات بين أول مدخلين على سبيل المثال: { id: 'd811e46d-70b3-4d90-b090-4535c7cf8fb1', date: '2015-01-02', type: 'Hospital', specialist: 'MD House', diagnosisCodes: ['S62.5'], description: "Healing time appr. 2 weeks. patient doesn't remember how he got the injury.", discharge: { date: '2015-01-16', criteria: 'Thumb has healed.', } } ... { id: 'fcd59fa6-c4b4-4fec-ac4d-df4fe1f85f62', date: '2019-08-05', type: 'OccupationalHealthcare', specialist: 'MD House', employerName: 'HyPD', diagnosisCodes: ['Z57.1', 'Z74.3', 'M51.2'], description: 'Patient mistakenly found himself in a nuclear plant waste site without protection gear. Very minor radiation poisoning. ', sickLeave: { startDate: '2019-08-05', endDate: '2019-08-28' } } سنرى أن بعض الحقول متطابقة لكن المُدخل الأول يمتلك الحقل discharge والثاني يمتلك الحقلين employerName وsickLeave. وبشكل عام تحتوي جميع المدخلات حقول مشتركة وحقول خاصة. وبالنظر إلى الحقل type، سنجد ثلاثة أشكال للمدخلات تشير إلى حاجتنا إلى ثلاثة أنواع منفصلة: OccupationalHealthcare Hospital HealthCheck. تشترك المدخلات بعدة حقول، وبالتالي من المناسب إنشاء واجهة نوع أساسي تدعى "Entry" قابلة للتوسّع بإضافة حقول خاصة لكل نوع. تُعَدّ الحقول الآتية مشتركةً بين كل المدخلات: id description date specialist ويبدو أن الحقل diagnosesCodes موجود فقط في المدخلات من النوعين OccupationalHealthcare وHospital. وطالما أنه لا يستخدم دائمًا حتى في هذين النوعين، فسنفترض أنه حقل اختياري. ويمكن إضافته إلى النوع HealthCheck أيضًا، إذ ليس من الضرورة استخدامه في أي من المدخلات الثلاث. ستبدو واجهة النوع الأساسي كالتالي: interface BaseEntry { id: string; description: string; date: string; specialist: string; diagnosisCodes?: string[]; } لو أردنا تهيئة الواجهة بشكل أفضل، ولعلمنا أن النوع Diagnoses قد عُرِّف مسبقًا في الواجهة الخلفية، من الممكن إذًا الإشارة مباشرة إلى حقل الشيفرة للنوع Diagnoses في حال تغيّر نوعه لسبب ما. سننفذ ذلك على النحو التالي: interface BaseEntry { id: string; description: string; date: string; specialist: string; diagnosisCodes?: Array<Diagnosis['code']>; } وتذكّر أن <Array<Type هي صيغة بديلة للتعليمة []Type. ومن الأفضل والأوضح في حالات كهذه أن نستخدم المصفوفة، لأن استخدام الخيار الآخر سيدفعنا إلى تعريف النوع بالعبارة DiagnosisCode والتي تبدو غريبة بعض الشيء. يمكننا الآن وبعد تعريف النوع الأساسي Entry، أن ننشئ الأنواع Entry الموسّعة التي سنستخدمها فعليًا. وسنبدأ بإنشاء النوع HealthCheckEntry. تحتوي المدخلات من النوع HealthCheck على الحقل HealthCheckRating الذي يأخذ قيمًا صحيحة بين 0 و 3. تعني القيمة 0 أن المريض بصحة جيدة، أما القيمة 3 فتعني أن المريض بحالة حرجة. وهذا ما يجعل استخدام التعداد مثاليًا. وبناء على المعطيات السابقة يمكن تعريف النوع HealthCheckEntry على النحو التالي: export enum HealthCheckRating { "Healthy" = 0, "LowRisk" = 1, "HighRisk" = 2, "CriticalRisk" = 3 } interface HealthCheckEntry extends BaseEntry { type: "HealthCheck"; healthCheckRating: HealthCheckRating; } يبقى علينا الآن إنشاء النوعين OccupationalHealthcareEntry وHospitalEntry ومن ثم نضم الأنواع الثلاثة ونصدّرها كنوع موحّد Entry كالتالي: export type Entry = | HospitalEntry | OccupationalHealthcareEntry | HealthCheckEntry; التمارين 9.19 - 9.22 9.19 تطبيق إدارة المرضى: الخطوة 4 عّرف النوعين بما يتلائم مع البيانات الموجودة. تأكد أن الواجهة الخلفية ستعيد المُدخل المناسب عندما تتوجه لإحضار معلومات مريض محدد. استخدم الأنواع بشكل صحيح في الواجهة الخلفية. لا حاجة الآن لتقييم بيانات كل الحقول في الواجهة الخلفية، ويكفي التحقق أن الحقل type سيحتوي قيمة من النوع الصحيح. 9.20 تطبيق إدارة المرضى: الخطوة 5 وسّع صفحة المريض في الواجهة الأمامية لإظهار قائمة تضم تاريخ ووصف ورمز التشخيص لكل مدخلات المريض. يمكنك استخدام نفس النوع Entry الذي عرّفناه الآن ضمن الواجهة الأمامية. ويكفي في هذه التمارين أن ننقل تعريف الأنواع كما هو من الواجهة الخلفية إلى الأمامية (نسخ/لصق). قد يبدو حلك قريبًا من التالي: 9.21 تطبيق إدارة المرضى: الخطوة 6 أحضر التشخيص ثم أضفه إلى حالة التطبيق من وصلة التخديم api/diagnosis/. استخدم بيانات التشخيص الجديدة لإظهار توصيف رموز التشخيصات للمريض. 9.22 تطبيق إدارة المرضى: الخطوة 7 وسّع قائمة المدخلات في صفحة المريض لتتضمن تفاصيل أكثر باستخدام مكوّن جديد يُظهِر بقية المعلومات الموجودة ضمن مدخلات المريض ومميزًا الأنواع عن بعضها. يمكنك أن تستخدمعلى سبيل المثال المكوّن Icon أو أي مكوًن آخر من مكوّنات المكتبة SemanticUI للحصول على مظهر مناسب لقائمتك. ينبغي تنفيذ عملية تصيير شرطية باستخدام البنية switch case وآلية التحقق الشاملة من الأنواع لكي لا تنس أية حالة. تأمل الشكل التالي: ستبدو قائمة المدخلات قريبة من الشكل التالي: نموذج لإضافة مريض قد يكون التعامل مع النماذج مزعجًا أحيانًا في ، لذلك قررنا استخدام الحزمة Formik لإنشاء نموذج لإضافة مريض في تطبيقنا. فيما سيأتي تقديم بسيط اجتزأناه من توثيق : ستنظم Formik الأشياء بتجميع العمليات السابقة في مكان واحد، وستغدو الاختبارات و إعادة كتابة الشيفرة وتقييم النماذج أمرًا يسيرًا. يمكنك إيجاد شيفرة النموذج في الملف src/AddPatientModal/AddPatientForm.tsx ،كما يمكنك إيجاد الدوال المساعدة لهذا النموذج في الملف src/AddPatientModal/FormField.tsx. لو نظرت إلى الملف AddPatientForm.tsx لوجدت أننا أنشأنا نوعًا لقيم النموذج يدعى PatientFormValues. يمثل هذا النوع نسخة معدلة عن النوع Patient بعد حذف الخاصيتين id و entries. فلا نريد أن يرسل المستخدم المعلومات الموجودة ضمن هاتين الخاصيتين عند إنشاء مريض جديد. فالمعرِّف id ستنشئه الواجهة الخلفية والمدخلات entries لا يمكن إضافتها إلا لمريض موجود مسبقًا. export type PatientFormValues = Omit<Patient, "id" | "entries">; سنصرّح تاليًا عن خصائص المكوّن الخاص بالنموذج: interface Props { onSubmit: (values: PatientFormValues) => void; onCancel: () => void; } يتطلب المكوّن وجود خاصيتين: onSubmit وonCancel. وكلاهما دالة استدعاء لا تعيد قيمًا. ينبغي أن تتلقى الدالة كائنًا من النمط "PatientFormValues" كمعامل لكي تتمكن من معالجة قيم النموذج. بالنظر إلى مكوّن الدالة AddPatientForm، ستجد أننا ربطنا به خصائصه، ومن ثم فككنا onSubmit وonCancel من هذه الخصائص. export const AddPatientForm: React.FC<Props> = ({ onSubmit, onCancel }) => { // ... } قبل أن نكمل، لنلق نظرة على الدوال المساعدة في نموذجنا والموجودة في الملف FormField.tsx. لو تحققت من الأشياء التي يصدرها الملف، ستجد النوع GenderOption ومكوني الدوال SelectField وTextField. لنلق نظرة على مكوِّن الدالة SelectField والأنواع المتعلقة به. أنشأنا أولًا نوعًا معممًا لكل كائن خيارات يتضمن قيمة وتسمية. هذه هي أنواع كائنات الخيارات التي سنسمح بها في نموذجنا ضمن حقل الخيارات. وطالما أن الخيار الوحيد الذي سنسمح به هو جنس المريض سنجعل القيمة value من النوع Gender. export type GenderOption = { value: Gender; label: string; }; أعطينا المتغير genderOptions في الملف AddPatientForm.tsx النوع GenderOption وصرحنا أنه مصفوفة تحتوي على كائنات من النوع GenderOption. const genderOptions: GenderOption[] = [ { value: Gender.Male, label: "Male" }, { value: Gender.Female, label: "Female" }, { value: Gender.Other, label: "Other" } ]; لاحظ إيضًا النوع SelectFieldProps. إذ يقوم بتعريف نوع لخصائص مكوِّن الدالة SelectField. سترى أن الخيارات عبارة عن مصفوفة من النوع GenderOption. type SelectFieldProps = { name: string; label: string; options: GenderOption[]; }; إن عمل مكوِّن الدالة SelectField واضح تمامًا. فهو يصيّر التسمية ويختار عنصرًا، ومن ثم يعطي كل الخيارات المتاحة لهذا العنصر ( قيم هذه الخيارات وتسمياتها). export const SelectField: React.FC<SelectFieldProps> = ({ name, label, options }: SelectFieldProps) => ( <Form.Field> <label>{label}</label> <Field as="select" name={name} className="ui dropdown"> {options.map(option => ( <option key={option.value} value={option.value}> {option.label || option.value} </option> ))} </Field> </Form.Field> ); لننتقل الآن إلى مكوِّن الدالة TextField. يُصيِّر هذا المكوّن الكائن Form.Field من المكتبة SemanticUI إلى تسمية ومكوّن Field من المكتبة Formik. يتلقى هذا المكوًن القيمة name والقيمة placeholder كخصائص. interface TextProps extends FieldProps { label: string; placeholder: string; } export const TextField: React.FC<TextProps> = ({ field, label, placeholder }) => ( <Form.Field> <label>{label}</label> <Field placeholder={placeholder} {...field} /> <div style={{ color:'red' }}> <ErrorMessage name={field.name} /> </div> </Form.Field> ); لاحظ أننا استخدمنا المكوّن ErrorMessage من المكتبة Formik لتصيير رسالة خطأ تتعلق بالقيمة المدخلة إن اقتضى الأمر. ينفذ المكوّن كل ما يلزم خلف الستار، فلا حاجة لكتابة شيفرة خاصة لذلك. من الممكن أيضًا الاحتفاظ برسالة الخطأ ضمن المكوّن باستخدام الخاصيّة form. export const TextField: React.FC<TextProps> = ({ field, label, placeholder, form }) => { console.log(form.errors); // ... } سنعود الآن إلى مكوّن النموذج الفعلي AddPatientForm.tsx. سيصيّر مكون الدالة AddPatientForm مكوّن Formik. سيغلّف هذا المكون بقية المكوّنات ويتطلب خاصيتين initialValues وonSubmit. شيفرة دالة الخصائص واضحة تمامًا. سيتعقب مكوِّن Formik حالة النموذج وسيظهر هذه الحالة بالإضافة إلى عدة توابع قابلة لإعادة الاستخدام ومعالجات الأحداث ضمن النموذج عن طريق الخصائص. نستخدم أيضًا الخاصية الاختيارية valiate التي تقبل دالة تقييم وتعيد كائنًا يتضمن الأخطاء المحتملة. نتحقق في نموذجنا أن الحقول النصية ليست فارغة، لكن بالإمكان أن نضيف بعض المعايير الأخرى للتقييم كالتأكد من صحة رقم الضمان الاجتماعي على سبيل المثال. يمكن لرسائل الخطأ التي تحددها دالة التقييم أن تُعرض ضمن مكوّن ErrorMessage الخاص بكل حقل. لنلق نظرة أولًا على المكوّن بشكله الكامل. ثم سنناقش بعدها أقسامه المختلفة بشيء من التفصيل. interface Props { onSubmit: (values: PatientFormValues) => void; onCancel: () => void; } export const AddPatientForm: React.FC<Props> = ({ onSubmit, onCancel }) => { return ( <Formik initialValues={{ name: "", ssn: "", dateOfBirth: "", occupation: "", gender: Gender.Other }} onSubmit={onSubmit} validate={values => { const requiredError = "Field is required"; const errors: { [field: string]: string } = {}; if (!values.name) { errors.name = requiredError; } if (!values.ssn) { errors.ssn = requiredError; } if (!values.dateOfBirth) { errors.dateOfBirth = requiredError; } if (!values.occupation) { errors.occupation = requiredError; } return errors; }} > {({ isValid, dirty }) => { return ( <Form className="form ui"> <Field label="Name" placeholder="Name" name="name" component={TextField} /> <Field label="Social Security Number" placeholder="SSN" name="ssn" component={TextField} /> <Field label="Date Of Birth" placeholder="YYYY-MM-DD" name="dateOfBirth" component={TextField} /> <Field label="Occupation" placeholder="Occupation" name="occupation" component={TextField} /> <SelectField label="Gender" name="gender" options={genderOptions} /> <Grid> <Grid.Column floated="left" width={5}> <Button type="button" onClick={onCancel} color="red"> Cancel </Button> </Grid.Column> <Grid.Column floated="right" width={5}> <Button type="submit" floated="right" color="green" disabled={!dirty || !isValid} > Add </Button> </Grid.Column> </Grid> </Form> ); }} </Formik> ); }; export default AddPatientForm; يمتلك مكوّن التغليف دالة تعيد محتويات النموذج كمكوِّن ابن child component. ونستخدم العنصر Form من المكتبة Formik لتصيير العناصر الفعلية للنموذج. سنضع داخل هذا العنصر مكونات الدوال TextField وSelectField التي أنشأناها في الملف FormField.tsx. سننشئ في النهاية زرين: الأول لإلغاء إرسال معلومات النموذج، وآخر لإرسالها. يستدعي الزر الأول الدالة onCancel مباشرة، ويحرّض الزر الثاني الحدث onSubmitt للمكتبة Formik الذي يستدعي بدوره الدالة onSubmitt من خصائص المكوّن. سيُفعَّل زر الإرسال إن كان النموذج صالحًا (يعيد القيمة valid) و مستعملًا (يعيد القيمة dirty). أي عندما يضيف المستخدم معلومات ضمن بعض الحقول. نعالج عملية إرسال بيانات النموذج باستخدام المكتبة لأنها تسمح لنا باستدعاء دالة التقييم قبل الشروع فعليًا بإرسال البيانات. فإن أعادة دالة التقييم أية أخطاء، سيلغى الإرسال. وُضع الزرين داخل العنصر Grid من المكتبة Formik لضبطهما متجاورين بطريقة سهلة. <Grid> <Grid.Column floated="left" width={5}> <Button type="button" onClick={onCancel} color="red"> Cancel </Button> </Grid.Column> <Grid.Column floated="right" width={5}> <Button type="submit" floated="right" color="green"> Add </Button> </Grid.Column> </Grid> يُمرر الاستدعاء onSubmit من صفحة قائمة المرضى نزولًا إلى نموذج إضافة مريض، ليرسل طلب HTTP-POST إلى الواجهة الخلفية، ويضيف المريض الذي تعيده الواجهة الخلفية إلى حالة التطبيق ويغلق النموذج. إن أعادت الواجهة الخلفية خطأً، سيُعرض الخطأ على النموذج. تمثل الشيفرة التالية دالة الاستدعاء: const submitNewPatient = async (values: FormValues) => { try { const { data: newPatient } = await axios.post<Patient>( `${apiBaseUrl}/patients`, values ); dispatch({ type: "ADD_PATIENT", payload: newPatient }); closeModal(); } catch (e) { console.error(e.response.data); setError(e.response.data.error); } }; ينبغي أن تكون قادرًا على إكمال بقية التمارين في هذا القسم بناء على المعلومات التي قدمناها. وإن راودتك أية شكوك، أعد قراءة الشيفرة الموجودة، فقد تعطيك تلميحًا لكيفية المتابعة. التمارين 9.23 - 9.27 9.23 تطبيق إدارة المرضى: الخطوة 8 صممنا التطبيق بحيث يمكن أن تجد عدة مدخلات لنفس المريض. لا توجد حتى الآن آلية في تطبيقنا لإضافة مُدخلات. عليك الآن أن تضيف وصلة تخديم على العنوان api/patients/:id/entries/ في الواجهة الخلفية، لتتمكن من إضافة مُدخل لمريض. تذكر أن هناك أنواع مختلفة من المدخلات في تطبيقنا، ويجب أن تدعم الواجهة الخلفية كل هذه الأنواع، وتحقق من وجود كل الحقول المطلوبة لكل نوع. 9.24 تطبيق إدارة المرضى: الخطوة 9 ستدعم الواجهة الخلفية الآن إضافة المُدخلات، ونريد أن ننفذ الوظيفة نفسها في الواجهة الأمامية. عليك إذًا إنشاء نموذج لإضافة مُدخل إلى سجل مريض. وتعتبر صفحة المريض مكانًا مناسبًا للوصول إلى هذا النموذج. يكفي في هذا التمرين أن تدعم نوعًا واحدًا من المدخلات، وليس مطلوبًا معالجة كافة الأخطاء الآن. إذ يكفي أن تنشئ بنجاح مدخلًا جديدًا عندما تُملأ حقول النموذج ببيانات صحيحة. ينبغي أن يُضاف مُدخل جديد إلى سجل المريض المحدد بعد نجاح عملية إرسال بيانات النموذج. كما ينبغي تحديث صفحة المريض ليظهر المُدخل الجديد. يمكنك إن أردت استخدام جزء من شيفرة نموذج إضافة مريض، لكن ذلك غير ضروري. انتبه إلى وجود المكوّن الجاهز GiagnosesSelection في الملف FormField.tsx حيث يمكنك استخدامه لضبط الحقل diagnoses بالشكل التالي: const AddEntryForm: React.FC<Props> = ({ onSubmit, onCancel }) => { const [{ diagnoses }] = useStateValue() return ( <Formik initialValues={{ /// ... }} onSubmit={onSubmit} validate={values => { /// ... }} > {({ isValid, dirty, setFieldValue, setFieldTouched }) => { return ( <Form className="form ui"> // ... <DiagnosisSelection setFieldValue={setFieldValue} setFieldTouched={setFieldTouched} diagnoses={Object.values(diagnoses)} /> // ... </Form> ); }} </Formik> ); }; كما يوجد أيضًا مكوّن جاهز يدعى NumberField للقيم العددية ضمن مجال محدد: <Field label="healthCheckRating" name="healthCheckRating" component={NumberField} min={0} max={3} /> 9.25 تطبيق إدارة المرضى: الخطوة 10 وسّع التطبيق ليُظهر رسالة خطأ إن كانت إحدى القيم المطلوبة مفقودة أو بتنسيق غير صحيح. 9.26 تطبيق إدارة المرضى: الخطوة 11 وسّع تطبيقك ليدعم نوعين من المُدخلات، ويُظهر رسالة خطأ إن كانت إحدى القيم المطلوبة مفقودة أو بتنسيق غير صحيح. لا تهتم بالأخطاء المحتملة التي قد تحملها استجابة الخادم. تقتضي الطريقة الأسهل -وبالطبع ليست الأكثر أناقة- لتنفيذ التمرين أن تنشئ نموذجًا منفصلًا لكل مدخل، فقد تواجهك بعض الصعوبات في التعامل مع الأنواع إن استخدمت نمودجًا واحدًا. 9.27 تطبيق إدارة المرضى: الخطوة 12 وسّع التطبيق ليدعم كل أنواع المُدخلات، ويُظهر رسالة خطأ إن كانت إحدى القيم المطلوبة مفقودة أو بتنسيق غير صحيح. لا تهتم بالأخطاء المحتملة التي قد تحملها استجابة الخادم. وهكذا نكون قد وصلنا إلى التمرين الأخير في هذا الفصل وحان الوقت لتسليم الحلول إلى GitHub، والإشارة إلى التمارين التي أنجزتها في منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل React with Types من سلسلة Deep Dive Into Modern Web Development
-
لقد امتلكنا الآن بعض المفاهيم الأساسية عن عمل TypeScript وعن كيفية استخدامها في إنشاء المشاريع الصغيرة، وأصبحنا قادرين على تنفيذ أشياء مفيدة فعلًا. سنبدأ إذًا بإنشاء مشروع جديد ذو استخدامات أكثر واقعية. سيكون التغيير الرئيسي في هذا القسم عدم استخدام ts-node. وعلى الرغم من أنها أداة مفيدة وتساعدك في الانطلاق، إلا أن استخدام المصرِّف الرسمي للغة هو الخيار المفضل، وخاصة على المدى الطويل لأعمالك. يأتي هذا المصرِّف مع الحزمة typescript، حيث يولّد ويحزم ملفات JavaScript انطلاقًا من ملفات "ts." وبذلك لن تحتوي نسخة الإنتاج بعد ذلك شيفرات TypeScript. وهذا ما نحتاجه فعلًا لأن TypeScript غير قابلة للتنفيذ على المتصفح أو على Node. إعداد المشروع سننشئ مشروعًا من أجل Ilari الذي يحب وضع خطط لرحلات جوية قصيرة، لكنه يعاني من ذكريات مزعجة متعلقة بهذه الرحلات. سيكون هو نفسه من سيكتب شيفرة التطبيق فلن يحتاج إلى واجهة مستخدم، لكنه يرغب باستعمال طلبات HTTP ويبقى خياراته مفتوحة في إضافة واجهة مستخدم مبنية على تقنيات الويب لاحقًا. لنبدأ بإنشاء أول مشروع واقعي لنا بعنوان Ilari flight diaries. وسنثبت كالعادة حزمة typescript باستخدام الأمر npm init ومن ثم npm install typescript. يساعدنا مصرِّف TypeScript الأصلي tsc في تهيئة المشروع من خلال الأمر tsc --init. لكن علينا أولًا أن نضيف الأمر tsc إلى قائمة السكربتات القابلة للتنفيذ داخل الملف package.json (إلا إن كنت قد ثبتَّ TypeScript لكافة المشاريع). وحتى لو ثبتّها لكافة المشاريع لابد من إدراجها كاعتمادية تطوير في مشروعك. سيكون سكربت npm الذي سينفذ الأمر tsc كالتالي: { // .. "scripts": { "tsc": "tsc", }, // .. } يضاف الأمر tsc غالبًا لاستخدامه في سكربتات تنفذ سكربتات أخرى، وبالتالي من الشائع رؤية إعداداته ضمن المشروع بهذا الشكل. سنهيئ الآن إعدادات الملف tsconfig.json بتنفيذ الأمر: npm run tsc -- --init لاحظ وجود المحرفين "--" قبل الوسيط الفعلي init. يُفسَّر الوسيط الذي يقع قبل "--" كأوامر npm والذي يقع بعدها كأوامر تُنفَّذ من قبل السكربت. ينتج عن تنفيذ الأمر السابق الملف tsconfig.ts الذي يضم قائمة طويلة بكل أوامر التهيئة التي يمكن ضبطها. وسترى أن قلة منها فقط لم تُسبق بعلامة التعليق. ستفيدك دراسة هذا الملف في إيجاد بعض تعليمات التهيئة التي قد تحتاجها. يمكنك أيضًا ترك الأسطر التي تبدأ بعلامة التعليق في الملف، فلربما قررت لاحقًا توسيع إعدادات التهيئة الخاصة بمشروعك. إن كل ما نحتاج إليه حاليًا من إعدادات هي: { "compilerOptions": { "target": "ES6", "outDir": "./build/", "module": "commonjs", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true } } لنعرّج على الإعدادات واحدًا تلو الآخر: target: سيخبر المصرِّف عن نسخة ECMAScript التي سيستعملها لتوليد ملفات JavaScript. ولاحظ أن القيمة التي يأخذها هي ES6 التي تدعمها معظم المتصفحات، وهي خيار جيد وآمن. outDir: يحدد المجلد الذي سيحوي الشيفرة المصرِّفة. modules: يخبر المصرِّف أننا سنستخدم وحدات commonjs في الشيفرة المصرِّفة. وهذا يعني أننا نستطيع استخدام require بدلًا من import وهذا الأمر غير مدعوم في النسخ الأقدم من Node مثل النسخة 10. strict: وهو في الواقع اختصار لعدة خيارات منفصلة توجه استخدام ميزات TypeScript بطريقة أكثر تشددًا (يمكنك أن تجد تفاصيل بقية الخيارات في توثيق tsconfig، كما ينصح التوثيق باستخدام strict دائمًا) هي: noImplicitAny: يكون الخيار المألوف بالنسبة لنا هو الأكثر أهمية. ويمنع هذا الخيار تضمين النوع any (إعطاء قيمة ما النوع any)، والذي قد يحدث إن لم تحدد نوعًا لمعاملات دالة على سبيل المثال. noImplicitThis alwayesStrict strictBindCallApply strictNullChecks strictFunctionTypes strictpropertyIntialization noUnUsedLocals: يمنع وجود متغيرات محلية غير مستعملة. nofallThroughCasesInSwitch: ترمي خطأً إن احتوت الدالة على معامل لم يستعمل. esModuleInterop: تتأكد من وجود إحدى التعليمتين return أو break في نهاية الكتلة case، عند استخدام البنية switch case. ModuleInterop: تسمح بالتصريف بين وحدات commonjs و ES. يمكنك الاطلاع أكثر على هذا الإعداد في توثيق tsconfig وبما أننا أنهينا ضبط إعدادات التهيئة التي نريد، سنتابع العمل بتثبيت المكتبة express وتثبيت تعريفات الأنواع الموجودة ضمنها مستخدمين "types/express@". وبما أن المشروع واقعي، أي أنه سينمو مع الوقت، سنستخدم المدقق eslint من البداية: npm install express npm install --save-dev eslint @types/express @typescript-eslint/eslint-plugin @typescript-eslint/parser ستبدو محتويات الملف package.json مشابهة للتالي: { "name": "ilaris-flight-diaries", "version": "1.0.0", "description": "", "main": "index.ts", "scripts": { "tsc": "tsc", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC", "dependencies": { "express": "^4.17.1" }, "devDependencies": { "@types/express": "^4.17.2", "@typescript-eslint/eslint-plugin": "^2.17.0", "@typescript-eslint/parser": "^2.17.0", "eslint": "^6.8.0", "typescript": "^3.7.5" } } كما سننشئ ملف تهيئة المدقق ذو اللاحقة eslintrc ونزوده بالمحتوى التالي: { "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking" ], "plugins": ["@typescript-eslint"], "env": { "browser": true, "es6": true }, "rules": { "@typescript-eslint/semi": ["error"], "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/no-unused-vars": [ "error", { "argsIgnorePattern": "^_" } ], "@typescript-eslint/no-explicit-any": 1, "no-case-declarations": 0 }, "parser": "@typescript-eslint/parser", "parserOptions": { "project": "./tsconfig.json" } } سنتابع الآن العمل بإعداد بيئة التطوير، وسنصبح بعد ذلك جاهزين لكتابة الشيفرة الفعلية. هناك خيارات عدة لإجراء ذلك. فقد نستخدم المكتبة nodemon مع ts-node، لكن وكما رأينا سابقًا فالمكتبة ts-node-dev تؤمن الوظائف نفسها، لذا سنستمر في استخدامها: npm install --save-dev ts-node-dev سنعرّف بعض سكربتات npm: { // ... "scripts": { "tsc": "tsc", "dev": "ts-node-dev index.ts", "lint": "eslint --ext .ts ." }, // ... } هنالك الكثير لتفعله قبل البدء بكتابة الشيفرة. فعندما تعمل على مشروع حقيقي، سيفيدك الإعداد المتأني لبيئة التطوير إلى حد كبير. خذ وقتك في ضبط إعدادات بيئة العمل لك ولفريقك، وسترى أن كل شيء سيسير بسلاسة على المدى الطويل. لنبدأ بكتابة الشيفرة سنبدأ كتابة الشيفرة بإنشاء وصلة تخديم endpoint لتفقد الخادم، لكي نضمن أن كل شيء يعمل بالشكل الصحيح. فيما يلي محتوى الملف index.ts: import express from 'express'; const app = express(); app.use(express.json()); const PORT = 3000; app.get('/ping', (_req, res) => { console.log('someone pinged here'); res.send('pong'); }); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); لو نفذنا الآن الأمر npm run dev سنرى أن الطلب إلى العنوان http://localhost:3000/ping، سيعيد الاستجابة pong، وبالتالي فتهيئة المشروع قد أنجزت بالشكل الصحيح. سيعمل التطبيق بعد تنفيذ الأمر السابق في وضع التطوير. وهذا الوضع لن يكون مناسبًا أبدًا عند الانتقال لاحقًا إلى وضع الإنتاج. لنحاول إنجاز نسخة إنتاج بتشغيل مصرِّف TypeScript. وبما أننا حددنا قيمةً للإعداد outDir في الملف package.json، فلا شيء بقي لنفعله سوى تنفيذ السكربت باستخدام الأمر npm run tsc. ستكون النتيجة نسخة إنتاج مكتوبة بلغة JavaScript صرفة، لواجهة express الخلفية ضمن المجلد build. سيفسر المدقق eslint حاليًًا الملفات ويضعها ضمن المجلد build نفسه. ولا نرغب بالطبع أن يحدث هذا، فمن المفترض أن تكون الشيفرة الموجودة في هذا المجلد ناتجة عن المصرِّف فقط. يمكن منع ذلك بإنشاء ملف تجاهل لاحقته "eslintignore." يضم قائمة بالمحتوى الذي نريد من المدقق أن يتجاهله، كما هي الحال مع git وgitignore. لنضف سكربت npm لتشغيل التطبيق في وضع الإنتاج: { // ... "scripts": { "tsc": "tsc", "dev": "ts-node-dev index.ts", "lint": "eslint --ext .ts .", "start": "node build/index.js" }, // ... } وبتشغيل التطبيق باستخدام الأمر npm start، نتأكد أن نسخة الانتاج ستعمل أيضًا بشكل صحيح. لدينا الآن بيئة تطوير تعمل بالحد الأدنى. وبمساعدة المصرِّف والمدقق eslint سنضمن أن تبقى الشيفرة بمستوًى جيد. وبناء على ما سبق سنتمكن من إنشاء تطبيق قابل للنشر لاحقًا كنسخ إنتاج. التمرينان 9.8 - 9.9 قبل أن تبدأ بالحل ستطوّر خلال مجموعة التمارين هذه واجهة خلفية لمشروع جاهز يدعى Patientor. والمشروع عبارة عن تطبيق سجلات طبية بسيط يستخدمه الأطباء الذين يشخصون الأمراض ويسجلون المعلومات الصحية لمرضاهم. بُنيت الواجهة الأمامية مسبقًا من قبل خبراء خارجيين، ومهمتك أن تطور واجهة خلفية تدعم الشيفرة الموجودة. 9.8 الواجهة الخلفية لتطبيق إدارة المرضى: الخطوة 1 هيئ المشروع الذي ستستخدمه الواجهة الأمامية. ثم هيئ المدقق eslint والملف tsconfig بنفس إعدادات التهيئة التي استخدمناها سابقًا. عرِّف وصلة تخديم تستجيب لطلبات HTTP-GET المرسلة إلى الوجهة ping/. ينبغي أن يعمل المشروع باستخدام سكربتات npm في وضع التطوير، وكشيفرة مصرّفة في وضع الإنتاج. 9.9 الواجهة الخلفية لتطبيق إدارة المرضى: الخطوة 2 انسخ المشروع patientor. شغل بعد ذلك التطبيق مستعينًا بالملف README.md. يجب أن تعمل الواجهة الأمامية دون حاجة لوجود واجهة خلفية وظيفية. تأكد من استجابة الواجهة الخلفية إلى طلب التحقق من الخادم ping الذي ترسله الواجهة الأمامية. وتحقق من أداة التطوير للتأكد من سلامة عملها. من الجيد أن تلقي نظرة على النافذة console في طرفية التطوير. وإن أخفق شيء ما، استعن بمعلومات القسم الثالث المتعلقة بحل هذه المشكلة. كتابة شيفرة وظائف الواجهة الخلفية لنبدأ بالأساسيات. يريد Ilari أن يكون قادرًا على مراجعة ما اختبره خلال رحلاته الجوية. إذ يريد تخزين مدخلات في مذكراته تتضمن المعلومات التالية: تاريخ المُدخل حالة الطقس (جيد، رياح، ماطر، عاصف) مدى الرؤية (جيد، ضعيف) نص يفصِّل تجربته ضمن الرحلة. حصلنا على عينة من المعلومات التي سنستخدمها كأساس نبني عليه. خُزّنت المعلومات بصيغة JSON ويمكن الحصول عليها من GitHub. تبدو البيانات على النحو التالي: [ { "id": 1, "date": "2017-01-01", "weather": "rainy", "visibility": "poor", "comment": "Pretty scary flight, I'm glad I'm alive" }, { "id": 2, "date": "2017-04-01", "weather": "sunny", "visibility": "good", "comment": "Everything went better than expected, I'm learning much" }, // ... ] لننشئ وصلة تخديم تعيد كل مدخلات مذكرة الرحلات. علينا في البداية إتخاذ بعض القرارات المتعلقة بطريقة هيكلة الشيفرة المصدرية. فمن الأفضل في حالتنا وضع الشيفرة بأكملها في مجلد واحد باسم src، وهكذا لن تختلط ملفات الشيفرة مع ملفات التهيئة. إذًا سننقل الملف إلى هذا المجلد ونجري التعديلات اللازمة على سكربت npm. سنضع أيضًا وحدات المتحكمات بالمسار routers المسؤولة عن التعامل مع موارد محددة كمدخلات المذكرات، ضمن المجلد src/routes. يختلف ما فعلناه قليلًا عن المقاربة التي اتبعناها في القسم 4 حيث وضعنا المتحكمات في المجلد src/controllers. ستجد شيفرة المتحكم الذي يتعامل مع جميع وصلات تخديم المرتبطة بالمذكّرات في الملف "src/routes/diaries.ts" الذي يحتوي الشيفرة التالية: import express from 'express'; const router = express.Router(); router.get('/', (_req, res) => { res.send('Fetching all diaries!'); }) router.post('/', (_req, res) => { res.send('Saving a diary!'); }) export default router; سنوجّه كل الطلبات التي تبدأ بالعنوان api/diaries/ إلى متحكم المسار المحدد في الملف index.ts import express from 'express'; import diaryRouter from './routes/diaries'; const app = express(); app.use(express.json()); const PORT = 3000; app.get('/ping', (_req, res) => { console.log('someone pinged here'); res.send('pong'); }); app.use('/api/diaries', diaryRouter); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); وهكذا لو أرسلنا طلبًا إلى العنوان http://localhost:3000/api/diaries فمن المفترض أن نرى الرسالة Fetching all diaries. علينا تاليًا البدء بتقديم البيانات الأساسية (عينة المذكّرات) انطلاقًا من التطبيق. إذ سنحضر البيانات ونخزنها في الملف data/diaries.json. لن نكتب الشيفرة التي تغير البيانات الفعلية في ملف المتحكم بالمسار، بل سننشئ خدمة تهتم بهذا الأمر. ويعتبر فصل "منطق العمل" عن شيفرة متحكمات المسار ضمن وحدات منفصلة أمرًا شائعًا، وتدعى في أغلب الأحيان "خدمات". ويعود أصل هذه التسمية إلى مفهوم التصميم المقاد بالمجال Domain driven design، ومن ثم جعله إطار العمل Spring أكثر شعبية. لننشئ مجلدًا للخدمات يدعى src/services، ونضع الملف diaryService.ts فيه. يحتوي الملف على دالتين لإحضار وتخزين مُدخلات المذكرة: import diaryData from '../../data/diaries.json' const getEntries = () => { return diaryData; }; const addEntry = () => { return null; }; export default { getEntries, addEntry }; لكن سنلاحظ خللًا ما: يخبرنا المحرر أننا قد نحتاج إلى استعمال الإعداد resolveJsonModule في ملف التهيئة tsconfig. سنضيفه إذًا إلى قائمة الإعدادات: { "compilerOptions": { "target": "ES6", "outDir": "./build/", "module": "commonjs", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "resolveJsonModule": true } } وهكذا ستختفي المشكلة: وجدنا في وقت سابق كيف يمكن للمصرِّف أن يقرر نوع المتغير بناء على القيمة التي أسندت إليه. وبالمثل يمكن للمصرِّف تفسير مجموعات كبيرة من البيانات تتضمن كائنات ومصفوفات. وبناء على ذلك يمكن للمصرِّف أن يحذرنا عندما نحاول أن نفعل شيئًا قد يعتبره مريبًا باستخدام بيانات JSON التي نعالجها. فلو كنا نتعامل على سبيل المثال مع مصفوفة من كائنات محددة النوع، وحاولنا إضافة كائن لا يمتلك كل حقول كائنات المصفوفة أو خللًا في نوع الحقل (number بدل string مثلًا)، سيحذرنا المصرِّف مباشرة. وعلى الرغم من فائدة المصرِّف في التحقق من أننا لم نفعل شيئًا خاطئًا، فتحديد أنواع البيانات بأنفسنا هو الخيار الأكثر أمانًا. ما لدينا حاليًا، هو تطبيق express باستخدام TypeScript يعمل بشكل محدود، لكن لا وجود لأية أنواع في شيفرته. وطالما أننا على دراية بأنواع البيانات التي سنستقبلها في حقلي weather، وvisibility، فلا سبب سيدفعنا لإدراج نوعيهما في الشيفرة. لننشئ ملفًا للأنواع يدعى types.ts، وسنعرّف ضمنه كل الأنواع التي سنستخدمها في المشروع. أولًا سنعرف نوعين لقيم weather، وvisibility باستخدام نوع موّحد union type للقيم النصية التي يمكن أن يأخذها الحقلين: export type Weather = 'sunny' | 'rainy' | 'cloudy' | 'windy' | 'stormy'; export type Visibility = 'great' | 'good' | 'ok' | 'poor'; سنتابع بإنشاء واجهة نوع interface باسم DiaryEntry: export interface DiaryEntry { id: number; date: string; weather: Weather; visibility: Visibility; comment: string; } وسنحاول تحديد نوع لبيانات JSON المُدرجة: import diaryData from '../../data/diaries.json'; import { DiaryEntry } from '../types'; const diaries: Array<DiaryEntry> = diaryData; const getEntries = (): Array<DiaryEntry> => { return diaries;}; const addEntry = () => { return null; }; export default { getEntries, addEntry }; ولأننا صرحنا مسبقا عن القيم، فإسناد نوع لها سيوّلد خطأً: تُظهر نهاية رسالة الخطأ المشكلة: الحقل غير ملائم. فقد حددنا نوعه في واجهة النوع على أنه DiaryEntry، لكن المصرِّف استدل من القيمة المسندة إليه أنه من النوع string. يمكن حل المشكلة بتأكيد النوع type assertion. ولا ينبغي فعل ذلك إلا عندما نعلم قطعًا مالذي نفعله. فلو أكدنا أن نوع المتغير diaryData هو DiaryEntry باستخدام التعليمة as سيعمل كل شيء على مايرام. import diaryData from '../../data/entries.json' import { Weather, Visibility, DiaryEntry } from '../types' const diaries: Array<DiaryEntry> = diaryData as Array<DiaryEntry>; const getEntries = (): Array<DiaryEntry> => { return diaries; } const addEntry = () => { return null } export default { getEntries, addEntry }; لا تستخدم أسلوب تأكيد النوع إلا في غياب أية طرق أخرى للمتابعة. فتأكيد نوع غير مناسب سيسبب أخطاءً مزعجة في زمن التشغيل. على الرغم من أن المصرِّف سيثق بك عندما تستخدم as، لكن بعملك هذا لن تكون قد سخرت الإمكانيات الكاملة للغة TypeScript بل اعتمدت على المبرمج لضمان عمل الشيفرة. من الممكن في حالتنا تغيير الطريقة التي نصدّر بها البيانات، وذلك بإعطائها نوعًا. وبما أننا لا نستطيع استخدام الأنواع داخل ملف JSON لابد من تحويله إلى ملف TS الذي يصدِّر البيانات على النحو التالي: import { DiaryEntry } from "../src/types"; const diaryEntries: Array<DiaryEntry> = [ { "id": 1, "date": "2017-01-01", "weather": "rainy", "visibility": "poor", "comment": "Pretty scary flight, I'm glad I'm alive" }, // ... ]; export default diaryEntries; وهكذا سيفسر المصرِّف المصفوفة عند إدراجها بشكل صحيح، وسيفهم نوع الحقلين weather وvisibility: import diaries from '../../data/diaries'; import { DiaryEntry } from '../types'; const getEntries = (): Array<DiaryEntry> => { return diaries; } const addEntry = () => { return null; } export default { getEntries, addEntry }; لو أردت أن تُخزّن مُدخلًا ما باستثناء حقل محدد، يمكنك ضبط نوع هذا الحقل كحقل اختياري بإضافة "؟" إلى تعريف النوع: export interface DiaryEntry { id: number; date: string; weather: Weather; visibility: Visibility; comment?: string; } وحدات Node وJSON من المهم أن تنتبه إلى مشكلة قد تظهر عند استخدام الخيار resolveJsonModule في الملف tsconfig: { "compilerOptions": { // ... "resolveJsonModule": true } } فبناء على توثيق node المتعلق بملفات الوحدات، ستحاول node أن تنفّذ الوحدات وفق ترتيب اللاحقات كالتالي: ["js", "json", "node"] وبالإضافة إلى ذلك ستوسِّع المكتبتان ts-node وts-node-dev قائمة اللاحقات لتصبح كالتالي: ["js", "json", "node", "ts", "tsx"] لنتأمل الهيكيلة التالية لمجلد يحتوي على ملفات: ├── myModule.json └── myModule.ts إن ضبطنا الخيار resolveJsonModule على القيمة true في TypeScript، سيصبح الملف myModule.json وحدة node صالحة للاستخدام. لنتخيل الآن السيناريو الذي نرغب فيه باستخدام الملف myModule.ts: import myModule from "./myModule"; بالنظر إلى ترتيب لاحقات الوحدات في node: ["js", "json", "node", "ts", "tsx"] سنلاحظ أن الأولوية ستكون للملف ذو اللاحقة "json." على الملف ذو اللاحقة "ts."، وبالتالي سيُدرج الملف myModule.json بدلًا من الملف myModule.ts. ولكي نتجنب هذه الثغرة التي ستضيع الكثير من الوقت، يفضل أن نسمي الملفات التي تحمل لاحقة تفهمها node بأسماء مختلفة. الأنواع الخدمية قد نحتاج أحيانًا إلى إجراء تعديلات محددة على نوع. فلنفترض مثلًا وجود صفحة لعرض قائمة من البيانات التي يعتبر بعضها بيانات حساسة وأخرى غير حساسة. ونريد أن نضمن عدم عرض البيانات الحساسة. يمكننا اختيار الحقول التي نريدها من نوع محدد، عن طريق استخدام أحد الأنواع الخدمية Utility type ويدعى Pick. ينبغي علينا في مشروعنا أن نفترض أن Ilari يريد إظهار قائمة بالمذكرات دون أن يُظهر حقل التعليقات، فقد يكتب شيئًا لايريد إظهاره لأحد، وخاصة في الرحلات التي أفزعته بشدة. يسمح النوع الخدمي Pick أن نختار ما نريد استخدامه من حقول نوع محدد. ويستخدم هذا النوع لإنشاء نوع جديد تمامًا أو لإعلام الدالة ما يجب أن تعيده في زمن التشغيل. فالأنواع الخدمية هي صنف خاص من أدوات إنشاء الأنواع، لكن يمكن استخدامها كما نستخدم أي نوع آخر. ولكي ننشئ نسخة خاضعة للرقابة من النوع DiaryEntry، سنستخدم Pick عند تعريف الدالة: const getNonSensitiveEntries = (): Array<Pick<DiaryEntry, 'id' | 'date' | 'weather' | 'visibility'>> => { // ... } سيتوقع المصرِّف أن تعيد الدالة مصفوفة قيم من النوع DiaryEntry المعدّل والذي يتضمن الحقول الأربعة المختارة فقط. وطالما أن النوع Pick سيتطلب أن يكون النوع الذي سيعدّله معطًى كنوع متغير، كما هو حال المصفوفة، سيكون لدينا نوعين متغيرين ومتداخلين، وستبدو العبارة غريبة بعض الشيء. يمكن تحسين القدرة على قراءة الشيفرة باستخدام العبارة البديلة للمصفوفة: const getNonSensitiveEntries = (): Pick<DiaryEntry, 'id' | 'date' | 'weather' | 'visibility'>[] => { // ... } سنحتاج في حالتنا إلى استثناء حقل واحد، لذلك من الأفضل أن نستخدم النوع الخدمي Omit والذي يحدد الحقول التي يجب استبعادها من نوع: const getNonSensitiveEntries = (): Omit<DiaryEntry, 'comment'>[] => { // ... } وكطريقة أخرى، يمكن التصريح عن نوع جديد NonSensitiveDiaryEntry كالتالي: export type NonSensitiveDiaryEntry = Omit<DiaryEntry, 'comment'>; ستصبح الشيفرة الآن على النحو: import diaries from '../../data/diaries'; import { NonSensitiveDiaryEntry, DiaryEntry } from '../types'; const getEntries = (): DiaryEntry[] => { return diaries; }; const getNonSensitiveEntries = (): NonSensitiveDiaryEntry[] => { return diaries; }; const addEntry = () => { return null; }; export default { getEntries, addEntry, getNonSensitiveEntries}; هنالك شيء واحد سيسبب القلق في تطبيقنا. ستعيد الدالة getNonSensitiveEntries كامل حقول المُدخلات عند تنفيذها، ولن يعطينا المصرِّف أية أخطاء على الرغم من تحديد نوع المعطيات المعادة. يحدث هذا لسبب بسيط هو أن TypeScript ستتحقق فقط من وجود كل الحقول المطلوبة، ولن تمنع وجود حقول زائدة. فلن تمنع في حالتنا إعادة كائن من النوع [ ]DiaryEntry، لكن لو حاولنا الوصول إلى الحقل comment فلن نتمكن من ذلك، لأن TypeScript لا تراه على الرغم من وجوده. سيقود ذلك إلى سلوك غير مرغوب إن لم تكن مدركًا ما تفعله. فلو كنا سنعيد كل المُدخلات عند تنفيذ الدالة getNonSensitiveEntries إلى الواجهة الأمامية، ستكون النتيجة وصول قيمٍ لحقول لانريدها إلى المتصفح الذي أرسل الطلب، وهذا مخالف لتعريف نوع القيم المعادة. علينا إذًا استثناء تلك الحقول بأنفسنا، طالما أن TypeScript لا تعدّل البيانات الفعلية المعادة بل نوعها فقط: import diaries from '../../data/entries.js' import { NonSensitiveDiaryEntry, DiaryEntry } from '../types' const getEntries = () : DiaryEntry[] => { return diaries } const getNonSensitiveEntries = (): NonSensitiveDiaryEntry [] => { return diaries.map(({ id, date, weather, visibility }) => ({ id, date, weather, visibility, }));}; const addDiary = () => { return [] } export default { getEntries, getNonSensitiveEntries, addDiary } فلو أردنا الآن إعادة هذه البيانات على أنها من النوع "DiaryEntry" كالتالي: const getNonSensitiveEntries = () : DiaryEntry[] => { سنحصل على الخطأ التالي: سيقدم لنا السطر الأخير من الرسالة هذه المرة أيضًا فكرة الحل. لنتراجع عن هذا التعديل. تتضمن الأنواع الخدمية العديد من الوسائل المفيدة، وتستحق أن نقف عندها لبعض الوقت ونطلع على توثيقها. وأخيرًا يمكننا إكمال المسار الذي يعيد كل المُدخلات: import express from 'express'; import diaryService from '../services/diaryService'; const router = express.Router(); router.get('/', (_req, res) => { res.send(diaryService.getNonSensitiveEntries());}); router.post('/', (_req, res) => { res.send('Saving a diary!'); }); export default router; وستكون الاستجابة كما نريدها: التمرينان 9.10 -9.11 لن نستخدم في هذه التمارين قاعدة بيانات حقيقية في تطبيقنا، بل بيانات محضرة مسبقًا موجودة في الملفين diagnoses.json وpatients.json. نزِّل الملفين وخزنهما في مجلد يدعى data في مشروعك. ستجري التعديلات على البيانات في ذاكرة التشغيل فقط، فلا حاجة للكتابة على الملفين في هذا القسم. 9.10 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 3 أنشئ النوع daiagnoses واستخدمه لإنشاء وصلة التخديم لإحضار كل تشخيصات المريض من خلال الطلب HTTP -GET. اختر هيكيلة مناسبة لشيفرتك عبر اختيار أسماء ملائمة للملفات والمجلدات. ملاحظة: قد يحتوي النوع daiagnoses على الحقل latin وقد لا يحتويه. ربما ستحتاج إلى استخدام الخصائص الاختيارية عند تعريف النوع. 9.11 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 4 أنشئ نوعًا للبيانات باسم Patient ثم أنشئ وصلة تخديم لطلبات GET على العنوان api/patients/ لتعيد بيانات كل المرضى إلى الواجهة الأمامية لكن دون الحقل ssn. استخدم الأنواع الخدمية للتأكد من أنك أعدت الحقول المطلوبة فقط. يمكنك في هذا التمرين افتراض نوع الحقل gender على أنه string. جرب وصلة التخديم من خلال المتصفح، وتأكد أن الحقل ssn غير موجود في بيانات الاستجابة: تأكد من عرض الواجهة الأمامية لقائمة المرضى بعد إنشائك لوصلة التخديم: منع الحصول على نتيجة غير محددة عرضيا سنوسع الواجهة الخلفية لدعم وظيفة الحصول على مُدخل محدد من خلال الطلبGET إلى العنوان api/diaries/:id. سنوسّع الخدمة DiaryService بإنشاء الدالة findById: // ... const findById = (id: number): DiaryEntry => { const entry = diaries.find(d => d.id === id); return entry;}; export default { getEntries, getNonSensitiveEntries, addDiary, findById} مرة أخرى سيواجهنا الخطأ التالي: تظهر المشكلة لأن وجود مُدخل بمعرِّف id محدد غير مضمون. من الجيد أننا أدركنا هذه المشكلة في مرحلة التصريف. فلو لم نستخدم TypeScript لما جرى تحذيرنا من قبل المصرِّف، وقد نعيد في السيناريو الأسوء كائنًا غير محدد بدلًا من تنبيه المستخدم أنه لاتوجد مُدخلات بهذا المعرِّف. نحتاج قبل كل شيء في حالات كهذه إلى اتخاذ قرار بشأن القيمة المعادة إن لم نجد الكائن المطلوب، وكيفية معالجة هذه الحالة. يعيد تابع المصفوفات find كائنًا غير محدد إن لم يجد الكائن المطلوب، ولا مشكلة في ذلك. إذ يمكن حل مشكلتنا بتحدبد نوع للقيمة المعادة على النحو التالي: const findById = (id: number): DiaryEntry | undefined => { const entry = diaries.find(d => d.id === id); return entry; } وسنستخدم معالج المسار التالي: import express from 'express'; import diaryService from '../services/diaryService' router.get('/:id', (req, res) => { const diary = diaryService.findById(Number(req.params.id)); if (diary) { res.send(diary); } else { res.sendStatus(404); } }) // ... export default router; إضافة مذكرة جديدة لنبدأ بإنشاء وصلة تخديم لطلبات HTTP-POST وذلك لإضافة مّدخلات جديدة إلى المذكرات. ينبغي أن تحمل المدخلات النوع ذاته للبيانات المطلوبة. تعالج الشيفرة التالية بيانات الاستجابة: router.post('/', (req, res) => { const { date, weather, visibility, comment } = req.body; const newDiaryEntry = diaryService.addDiary( date, weather, visibility, comment, ); res.json(newDiaryEntry); }); وسيكون التابع الموافق في شيفرة الخدمة DiaryService كالتالي: import { NonSensitiveDiaryEntry, DiaryEntry, Visibility, Weather} from '../types'; const addDiary = ( date: string, weather: Weather, visibility: Visibility, comment: string ): DiaryEntry => { const newDiaryEntry = { id: Math.max(...diaries.map(d => d.id)) + 1, date, weather, visibility, comment, } diaries.push(newDiaryEntry); return newDiaryEntry; }; وكما ترى، سيغدو من الصعب قراءة الدالة addDiary نظرًا لكتابة كل الحقول كمعاملات منفصلة. وقد يكون من الأفضل لو أرسلنا البيانات ضمن كائن واحد إلى الدالة: router.post('/', (req, res) => { const { date, weather, visibility, comment } = req.body; const newDiaryEntry = diaryService.addDiary({ date, weather, visibility, comment, }); res.json(newDiaryEntry); }) لكن ما نوع هذا الكائن؟ فهو ليس من النوع DiaryEntry تمامًا كونه يفتقد الحقل id. من الأفضل هنا لو أنشأنا نوعًا جديدًا باسم NewDiaryEntry للمُدخلات التي لم تُخزَّن بعد. لنعرّف هذا النوع في الملف types.ts مستخدمين النوع DiaryEntry مع النوع الخدمي Omit. export type NewDiaryEntry = Omit<DiaryEntry, 'id'>; يمكننا الآن استخدام النوع الجديد في الخدمة DiaryService، بحيث يُفكَّك الكائن الجديد عند إنشاء مُدخل جديد من أجل تخزينه: import { NewDiaryEntry, NonSensitiveDiaryEntry, DiaryEntry } from '../types'; // ... const addDiary = ( entry: NewDiaryEntry ): DiaryEntry => { const newDiaryEntry = { id: Math.max(...diaries.map(d => d.id)) + 1, ...entry }; diaries.push(newDiaryEntry); return newDiaryEntry; }; ستبدو الشيفرة أوضح الآن! ولتفسير البيانات المستقبلة، علينا تهيئة أداة JSON الوسطية: import express from 'express'; import diaryRouter from './routes/diaries'; const app = express(); app.use(express.json()); const PORT = 3000; app.use('/api/diaries', diaryRouter); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); أصبح التطبيق الآن جاهزًا لاستقبال طلبات HTTP-POST لإنشاء مُدخلات جديدة بالنوع الصحيح. التأكد من الطلب هناك الكثير من الأمور التي قد تسير بشكل خاطئ عند استقبال بيانات من مصدر خارجي. فلا يمكن أن تعمل التطبيقات بنفسها إلا نادرًا، وعلينا أن نقبل حقيقة أن البيانات التي نحصل عليها من خارج منظومة التطبيق غير موثوقة بشكل كامل. فلا يمكن أن تكون البيانات عند استقبالها من مصدر خارجي محددة النوع مسبقًا. وعلينا أن نقرر الأسلوب الذي سنتعامل به مع ضبابية هذه الحالة. تعالج express الموضوع بتأكيد النوع any لكل حقول جسم الطلب. لن يكون هذا السلوك واضحًا للمصرِّف في حالتنا، لكن لو حاولنا إلقاء نظرة أقرب إلى المتغيرات، ومررنا مؤشر الفأرة فوق كل منها، سنجد أن نوع كل منها هو بالفعل any. وبالتالي لن يعترض المحرر أبدًا عندما نمرر هذه البيانات إلى الدالة addDiary كمعاملات. يمكن إسناد القيم من النوع any إلى متغيرات من أي نوع، فلربما تكون هي القيمة المطلوبة. وهذا الأسلوب يفتقر بالتأكيد إلى عامل الأمان، لذا علينا التحقق من القيم المستقبلة سواءً استخدمنا TypeScript أو لا. بالإمكان إضافة آليتي تحقق بسيطتين exist و is_value_valid ضمن الدالة التي تعرّف الوجهة route، لكن من الأفضل كتابة منطق تفسير البيانات وتقييمها في ملف منفصل "utils.ts". لابد من تعريف الدالة toNewDiaryEntry التي تستقبل جسم الطلب كمعامل وتعيد كائن NewDiaryEntry بنوع مناسب. يستخدم تعريف الوجهة هذه الدالة على النحو التالي: import toNewDiaryEntry from '../utils'; // ... router.post('/', (req, res) => { try { const newDiaryEntry = toNewDiaryEntry(req.body); const addedEntry = diaryService.addDiary(newDiaryEntry); res.json(addedEntry); } catch (e) { res.status(400).send(e.message); } }) طالما أننا نحاول الآن كتابة شيفرة آمنة، وأن نتأكد من تلقي البيانات التي نريدها تمامًا من الطلبات، علينا أن نتحقق من ترجمة وتقييم بيانات كل حقل نتوقع أن نستقبله. سيبدو هيكل الدالة toNewDiaryEntry على النحو التالي: import { NewDiaryEntry } from './types'; const toNewDiaryEntry = (object): NewDiaryEntry => { const newEntry: NewDiaryEntry = { // ... } return newEntry; } export default toNewDiaryEntry; على الدالة ترجمة كل حقل والتأكد أن نوع كل حقل من حقول القيمة المعادة هو NewDiaryEntry. ويعني هذا التحقق من كل حقل على حدى. ستواجهنا أيضًا في هذه المرحلة مشاكل تتعلق بالأنواع: ماهو نوع الكائن المعاد، طالما أنه جسم الطلب في الحقيقة من النوع any؟ إن فكرة هذه الدالة، هي الربط بين الحقول مجهولة النوع بحقول من النوع الصحيح والتحقق من أنها معرّفة كحقول متوقعة أولا. وربما تكون هذه الحالة هي الحالة النادرة التي سنسمح فيها باستخدام النوع any. سيعترض المدقق eslint على استخدام النوع any: وسبب ذلك، هو تفعيل القاعدة no-explicit-any التي تمنع التصريح علنًا عن النوع any. وهي في الواقع قاعدة جيدة لكنها غير مطلوبة في هذا الجزء من الملف. يمككنا أن نتجاوز هذه القاعدة بإلغاء تفعيلها عن طريق وضع السطر التالي في ملف الشيفرة: /* eslint-disable @typescript-eslint/no-explicit-any */ سنبدأ الآن بإنشاء المفسّرات لكل حقل من حقول الكائن المُعاد. لتقييم الحقل comment لابد من التحقق أنه موجود وأنه يحمل النوع string. ستبدو الدالة على نحو مماثل لما يلي: const parseComment = (comment: any): string => { if (!comment || !isString(comment)) { throw new Error('Incorrect or missing comment: ' + comment); } return comment; } تتلقى الدالة معاملُا من النوع any وتعيده من النوع string إن كان موجودًا ومن النوع الصحيح. ستبدو دالة تقييم النوع string كالتالي: const isString = (text: any): text is string => { return typeof text === 'string' || text instanceof String; }; تدعى هذه الدالة أيضًا بالدالة حامية النوع type guard. وتعرَّف بأنها دالة تعيد قيمة منطقية ونوعًا إسناديُا (type predicate -يسند نوعا صريحًا لقيمة معادة-). سيكون شكل النوع الإسنادي في حالتنا هو التالي: text is string إن الشكل العام للنوع الإسنادي هو parameterName is Type حيث يمثل parameterName معامل الدالة ويمثل "Type" النوع الذي سيُعاد. فإن أعادت الدالة حامية النوع القيمة true، سيعرف مصرِّف TypeScript أن للمتغير المُختَبَر النوع ذاته الذي حدده النوع الإسنادي. قبل أن تُستدعى الدالة حامية للنوع سيكون النوع الفعلي للمتغير comment مجهولًا. لكن بعد استدعائها سيعلم المصرِّف -في حال أعادت حامية النوع القيمة true، أن المتغير من النوع string. لماذا نرى شرطين في حامية النوع string؟ const isString = (text: any): text is string => { return typeof text === 'string' || text instanceof String;} أليس كافيًا أن نكتب شيفرة الحامية على الشكل: const isString = (text: any): text is string => { return typeof text === 'string'; } إن الشكل الأبسط في أغلب الأحيان كافٍ للتطبيق العملي. لكن إن أردنا أن نكون متأكدين تمامًا لابد من وجود الشرطين. فهنالك طريقتان لإنشاء كائن من النوع string في JavaScript وكلاهما يعمل بطريقة تختلف قليلًا عن الآخر بما يتعلق باستخدام العاملين typeof و instanceof. const a = "I'm a string primitive"; const b = new String("I'm a String Object"); typeof a; --> returns 'string' typeof b; --> returns 'object' a instanceof String; --> returns false b instanceof String; --> returns true لن يفكر في الغالب أحد باستخدام الدالة البانية لإنشاء قيمة نصية، وبالتالي ستكون النسخة الأبسط للدالة الحامية مناسبة. لنتأمل الآن الحقل date. سيُترجم ويُقيَّم هذا الحقل بطريقة مشابه كثيرًا لطريقة تقييم الحقل comment. وطالما أن TypeScript لا تعرف نوعًا للتاريخ، سنضطر للتعامل معه كنص string. ولابد كذلك من التحقق على مستوى JavaScript أن تنسيق التاريخ مقبول. سنضيف الدوال التالية: const isDate = (date: string): boolean => { return Boolean(Date.parse(date)); }; const parseDate = (date: any): string => { if (!date || !isString(date) || !isDate(date)) { throw new Error('Incorrect or missing date: ' + date); } return date; }; لا يوجد شيء مميز في هذه الشيفرة، لكن يجدر الإشارة أنه لا يمكن استعمال الدالة حامية النوع لأن التاريخ هنا سيعتبر من النوع string ليس إلا. ولاحظ على الرغم من أن الدالة parseDate تقبل المتغير date على أنه من النوع any، فسيتغير نوعه إلى string بعد التحقق من نوعه باستخدام isString. وهذا مايفسر الحاجة لقيمة نصية صحيحة التنسيق، عند تمرير المتغير إلى الدالة isDate. سننتقل الآن إلى النوعين الأخيرين Weather و Visibility. نريد أن تجري عمليتي الترجمة والتقييم كالتالي: const parseWeather = (weather: any): Weather => { if (!weather || !isString(weather) || !isWeather(weather)) { throw new Error('Incorrect or missing weather: ' + weather) } return weather; }; لكن كيف يمكننا تقييم نص ما على أنه من تنسيق أو شكل محدد؟ تقتضي أحدى الطرق المتبعة، كتابة دالة حامية للنوع بالشكل التالي: const isWeather = (str: string): str is Weather => { return ['sunny', 'rainy', 'cloudy', 'stormy' ].includes(str); }; سيعمل الحل السابق بشكل جيد، لكن المشكلة أن قائمة الأحوال الجوية المحتملة قد لاتبقى متزامنة مع تعريفات النوع إن حدث أي تبديل فيها (خطأ في الكتابة مثلًا). وهذا بالطبع ليس جيدًا، لأننا نرغب بوجود مصدر وحيد لكل أنواع الطقس الممكنة. من الأفضل في حالتنا أن نحسَّن تعريف أنواع الطقس. فبدلًا من النوع البديل لابد من استخدام تعداد TypeScript الذي يسمح لنا باستخدام القيمة الفعلية في الشيفرة في زمن التشغيل، وليس فقط في مرحلة التصريف. لنعد تعريف النوع Weather ليصبح كالتالي: export enum Weather { Sunny = 'sunny', Rainy = 'rainy', Cloudy = 'cloudy', Stormy = 'stormy', Windy = 'windy', } يمكننا التحقق الآن أن القيمة النصية المستقبلة هي إحدى أنواع الطقس الممكنة، وستكتب الدالة الحامية للنوع كالتالي: const isWeather = (param: any): param is Weather => { return Object.values(Weather).includes(param); }; لاحظ كيف غيرنا نوع المعامل إلى any فلو كان من النوع string، لن يُترجَم التابع includes. وهذا منطقي وخاصة إذا أخذنا في الحسبان إعادة استخدام الدالة لاحقًا. بهذه الطريقة سنكون متأكدين تمامًا أنه أيًا كانت القيمة التي ستمرر إلى الدالة، فستخبرنا إن كانت القيمة مناسبة للطقس أو لا. يمكن تبسيط الدالة قليلًا كما يلي: const parseWeather = (weather: any): Weather => { if (!weather || !isWeather(weather)) { throw new Error('Incorrect or missing weather: ' + weather); } return weather; }; ستظهر مشكلة بعد هذه التغييرات. لن تلائم البيانات الأنواع التي عرّفناه بعد الآن. والسبب أننا لا نستطيع ببساطة افتراض أن القيمة string على أنها تعداد enum. يمكننا حل المشكلة بربط عناصر البيانات الأولية (المحضرة مسبقًا) بالنوع DiaryEntry مستخدمين الدالة toNewDiaryEntry: import { DiaryEntry } from "../src/types"; import toNewDiaryEntry from "../src/utils"; const data = [ { "id": 1, "date": "2017-01-01", "weather": "rainy", "visibility": "poor", "comment": "Pretty scary flight, I'm glad I'm alive" }, // ... ] const diaryEntries: DiaryEntry [] = data.map(obj => { const object = toNewDiaryEntry(obj) as DiaryEntry object.id = obj.id return object }) export default diaryEntries وطالما أن الدالة ستعيد كائنًا من النوع NewDiaryEntry، فعلينا التأكيد على أنه من النوع DiaryEntry باستخدام العامل as. يستخدم التعداد عادة عندما نكون أمام مجموعة من القيم المحددة مسبقًا ولا نتوقع تغييرها في المستقبل، خاصة القيم التي يصعب تغييرها مثل أيام الأسبوع وأسماء الأشهر وغيرها. لكن طالما أنها تقدم أسلوبًا جيدًا في تقييم القيم الواردة فمن المفيد استخدامها في حالتنا. وكذلك الأمر لابد من التعامل مع النوع visibility بنفس الأسلوب. سيبدو التعداد الخاص به كالتالي: export enum Visibility { Great = 'great', Good = 'good', Ok = 'ok', Poor = 'poor', } وستمثل الشيفرة التالية الدالة الحامية للنوع: const isVisibility = (param: any): param is Visibility => { return Object.values(Visibility).includes(param); }; const parseVisibility = (visibility: any): Visibility => { if (!visibility || !isVisibility(visibility)) { throw new Error('Incorrect or missing visibility: ' + visibility); } return visibility; }; وهكذا نكون قد انتهينا من إنشاء الدالة toNewDiaryEntry التي تتحمل مسؤولية تقييم وتفسير حقول بيانات الطلب: const toNewDiaryEntry = (object: any): NewDiaryEntry => { return { date: parseDate(object.date), comment: parseComment(object.comment), weather: parseWeather(object.weather), visibility: parseVisibility(object.visibility) }; }; وبهذا ننهي النسخة الأولى من تطبيق مذكرات الرحلات الجوية! وفي حال أردنا إدخال مذكرة جديدة بحقول مفقودة أو غير صالحة سنحصل على رسالة الخطأ التالية: التمرينان 9.12 - 9.13 9.12 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 5 أنشأ وصلة تخديم للطلب POST على العنوان api/patients/ بغرض إضافة مرضى جدد. وتأكد من إمكانية إضافة مرضى عبر الواجهة الأمامية أيضًا. 9.13 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 6 جد آلية أمنة لتفسير وتقييم وحماية الأنواع لطلبات HTTP-POST إلى العنوان api/patients/ أعد كتابة الحقل Gender مستخدمًا التعداد (enum). تصريف -وبتصرف- للفصل Typing the express app من سلسلة Deep Dive Into Modern Web Development
-
بعد المقدمة الموجزة التي تحدثنا فيها عن أساسيات اللغة TypeScript في المقال السابق، سنكون جاهزين لبدء رحلتنا لنصبح مطورين شاملين لتطبيقات الويب باستخدام TypeScript. سنركز في هذا القسم على المشاكل التي قد تنشأ عن استخدام اللغة لتطوير تطبيقات express للواجهة الخلفية وReact للواجهة الأمامية، بدلًا من تقديم مدخل موّسع عن كل جوانب اللغة. وسنركز أيضًا على استخدام أدوات التطوير المتاحة بالإضافة إلى الميزات التي تقدمها اللغة. تجهيز كل شيء لبدء العمل ثَبّت ما تحتاجه لاستخدام TypeScript على المحرر الذي تختاره، ستحتاج إلى تثبيت الموسِّع typescript hero إن كنت ستعمل على Visual Studio Code. لا يمكن تنفيذ شيفرة TypeScript مباشرة كما ذكرنا سابقًا، بل علينا تصريفها إلى شيفرة JavaScript قابلة للتنفيذ. عندما تصرّف شيفرة اللغة إلى JavaScript ستصبح عرضًة لإزالة الأنواع. أي ستزال مسجلات النوع والاستدلالات والأسماء المستعارة للأنواع وكل معطيات نظام تحديد النوع الأخرى، وستكون النتيجة شيفرة JavaScript صرفة جاهزة للتنفيذ. نقصد أحيانًا بالحاجة إلى التصريف في بيئات الإنتاج، أن نوجد "خطوة لبناء التطبيق". حيث تُصرّف كل شيفرة TypeScript خلال خطوة بناء التطبيق إلى شيفرة JavaScript ضمن مجلد منفصل، ومن ثم تُنفِّذ بيئة الإنتاج الشيفرة الموجودة ضمن هذا المجلد. يُفضَّل في بيئات الإنتاج أن نستخدم التصريف في الزمن الحقيقي، وإعادة التحميل التلقائي لنتمكن من متابعة النتائج بشكل أسرع. لنبدأ بكتابة أول تطبيق لنا باستخدام TypeScript. ولتبسيط الأمر، سنستخدم حزمة npm تُدعى ts-node. حيث تصرّف هذه المكتبة ملف TypeScript وتنفذه مباشرة. وبالتالي لا حاجة لخطوة تصريف مستقلة. يمكن تثبيت المكتبة ts-node والحزمة الرسمية للغة TypeScript لكافة المشاريع globally كالتالي: npm install -g ts-node typescript إن لم تستطع أو لم ترد تثبيت حزم شاملة، يمكنك إنشاء مشروع npm وتثبت فيه الاعتماديات اللازمة فقط ومن ثم تُنفِّذ شيفرتك ضمنه. كما سنستخدم المقاربة التي اتبعناها في القسم 3، حيث نهيئ مشروع npm بتنفيذ الأمر npm init في مجلد فارغ، ونثبّت بعدها الاعتماديات بتنفيذ الأمر: npm install --save-dev ts-node typescript كما يجب أن نهيئ تعليمة scripts ضمن الملف package.json كالتالي: { // .. "scripts": { "ts-node": "ts-node" }, // .. } يمكنك استخدام ts-node ضمن المجلد بتنفيذ الأمر npm run ts-node. وتجدر الملاحظة أن استخدام ts-node من خلال الملف package.json يقتضي أن تبدأ كل أسطر الأوامر بالرمز "--". لذا سيكون عليك تنفيذ الأمر التالي إن أردت مثلًا تنفيذ الملف file.ts باستخدام ts-node: npm run ts-node -- file.ts نشير أيضًا إلى وجود أرضية عمل خاصة باللغة TypeScript على الإنترنت، حيث يمكنك تجريب شيفرتك والحصول على شيفرة JavaScript الناتجة وأخطاء التصريف المحتملة بسرعة. ملاحظة: يمكن أن تتضمن أرضية العمل قواعد تهيئة مختلفة (وهذا ما سنوضحه لاحقًا) عما هو موجود في بيئة التطوير الخاصة بك، لهذا قد تجد تحذيرات مختلفة. يمكن تعديل قواعد التهيئة لأرضية العمل هذه من خلال القائمة المنسدلة config. ملاحظة عن أسلوب كتابة الشيفرة إن لغة JavaScript بحد ذاتها مريحة الاستخدام، ويمكنك أن تنفذ ما تريده بطرق مختلفة في أغلب الأحيان. فعلى سبيل المثال يمكنك استخدام الدوال named، أو anynamous، وقد تستخدم const، أو var، أو let للتصريح عن متغير، وكذلك الاستخدامات المختلفة للفاصلة المنقوطة. سيختلف هذا القسم عن البقية بطريقة استخدام الفاصلة المنقوطة. ولا يمثل هذا الاختلاف طريقة خاصة باللغة لكنه أكثر ما يكون أسلوبًا لكتابة شيفرة أي نوع من JavaScript. واستخدام هذا التنسيق أو عدمه سيعود كليًا للمبرمج. لكن من المتوقع أن يغير أسلوبه في كتابة الشيفرة لتتماشى مع الأسلوب المتبع في هذا القسم، إذ يجب إنجاز تمارين القسم مستخدمًا الفواصل المنقوطة ومراعيًا أسلوب كتابة الشيفرة المستخدم فيه. كما ستجد اختلافًا في تنسيق الشيفرة في هذا القسم بالمقارنة مع التنسيق الذي اتبعناه في بقية أقسام المنهاج كتسمية المجلدات مثلًا. لنبدأ بإنشاء دالة بسيطة لتنفيذ عملية الضرب. ستبدو تمامًا كما لو أنها مكتوبة بلغة JavaScript: const multiplicator = (a, b, printText) => { console.log(printText, a * b); } multiplicator(2, 4, 'Multiplied numbers 2 and 4, the result is:'); وكما نرى، فالدالة مكتوبة بشيفرة JavaScript عادية دون استخدام ميزات TS. ستصرّف وتنفذ الشيفرة بلا مشاكل باستخدام الأمر npm run ts-node -- multiplier. لكن ما الذي سيحدث إن مررنا نوعًا خاطئًا من المعاملات إلى الدالة؟ لنجرب ذلك! const multiplicator = (a, b, printText) => { console.log(printText, a * b); } multiplicator('how about a string?', 4, 'Multiplied a string and 4, the result is:'); عند تنفيذ الشيفرة، سيكون الخرج: "Multiplied a string and 4, the result is: NaN". أليس من الجيد أن تمنعنا اللغة من ارتكاب هذا الخطأ؟ سترينا هذه المشكلة أولى مزايا TS. لنعرف أنواعًا للمعاملات ونرى ما الذي سيتغير. تدعم اللغة TS أنواعًا مختلفة، مثل: number، وstring، وArray. يمكنك إيجاد قائمة بالأنواع في توثيق TS على الانترنت. كما يمكن إنشاء أنواع مخصصة أكثر تعقيدًا. إنّ أول معاملين لدالة الضرب من النوع number والثالث من النوع string. const multiplicator = (a: number, b: number, printText: string) => { console.log(printText, a * b); } multiplicator('how about a string?', 4, 'Multiplied a string and 4, the result is:'); لم تعد الشيفرة السابقة شيفرة JavaScript صالحة للتنفيذ. فلو حاولنا تنفيذها، لن تُصرَّف: إنّ إحدى أفضل ميزات محرر TS، أنك لن تحتاج إلى تنفيذ الشيفرة حتى ترى المشكلة. فالإضافة VSCode فعالة جدًا، حيث ستبلغك مباشرة إن حاولت استخدام النوع الخاطئ. إنشاء أول نوع خاص بك لنطور دالة الضرب إلى آلة حاسبة تدعم أيضًا الجمع والقسمة. ينبغي أن تقبل الآلة الحاسبة ثلاث معاملات: عددين وعملية قد تكون الضرب multiply، أو الجمع add، أو القسمة divide. تحتاج الشيفرة في JavaScript إلى تقييم إضافي للمعامل الثالث لتتأكد من كونه قيمة نصية. بينما تقدم TS طريقة لتعريف أنواع محددة من الدخل، بحيث تصف تمامًا الأنواع المقبولة. وعلاوة على ذلك، ستظهر لك TS معلومات عن القيم الصالحة للاستخدام ضمن المحرر. يمكن إنشاء نوع باستخدام التعليمة الداخلية type في TS. سنصف فيما سيأتي النوع Operation الذي عرًفناه: type Operation = 'multiply' | 'add' | 'divide'; يقبل النوع Op ثلاثة أشكال من الدخل وهي تمامًا القيم النصية الثلاثة التي نريد. يمكننا باستخدام العامل OR ('|') تعريف متغير يقبل قيمًا متعددة وذلك بإنشاء نوع موحَّد union type.لقد استخدمنا في الحالة السابقة القيمة النصية بحرفيتها (وهذا ما يدعى التعريف المختصر للنوع النصي string literal types)، لكن باستخدام الأنواع الموحَّدة، ستجعل المصرِّف قادرًا على قبول قيمة نصية أو عددية: string | number. تُعرّف التعليمة type اسمًا جديدًا للنوع أو ما يسمى اسمًا مستعارًا للنوع. وطالما أن النوع المُعرَّف هو اتحاد بين ثلاث قيم مقبولة، فمن المفيد إعطاء النوع اسمًا مستعارًا يصفه بشكل جيد. لنلق نظرة على الآلة الحاسبة: type Operation = 'multiply' | 'add' | 'divide'; const calculator = (a: number, b: number, op : Operation) => { if (op === 'multiply') { return a * b; } else if (op === 'add') { return a + b; } else if (op === 'divide') { if (b === 0) return 'can\'t divide by 0!'; return a / b; } } ستلاحظ الآن أنك لو مررت الفأرة فوق النوع Operation في المحرر، ستظهر لك مباشرة اقتراحات عن طريقة استخدامه: ولو أردنا استخدام قيمة غير موجودة ضمن النوع Operation، ستظهر لك مباشرة إشارة التحذير الحمراء ومعلومات إضافية ضمن المحرر: لقد أبلينا جيدًا حتى اللحظة، لكننا لم نطبع بعد النتيجة التي تعيدها دالة الآلة الحاسبة. غالبًا ما نريد معرفة النتيجة التي تعيدها الدالة، ومن الأفضل أن نضمن أن الدالة ستعيد القيمة التي يفترض أن تعيدها. لنضف إذًا القيمة المعادة number إلى الدالة: type Operation = 'multiply' | 'add' | 'divide'; const calculator = (a: number, b: number, op: Operation): number => { if (op === 'multiply') { return a * b; } else if (op === 'add') { return a + b; } else if (op === 'divide') { if (b === 0) return 'this cannot be done'; return a / b; } } سيعترض المصرِّف مباشرة على فعلتنا، ذلك أن الدالة ستعيد في إحدى الحالات (القسمة على صفر) قيمة نصية. هناك عدة طرق لحل المشكلة. إذ يمكن إعادة تعريف النوع المعاد ليتضمن القيم النصية string على النحو التالي: const calculator = (a: number, b: number, op: Operation): number | string => { // ... } أو قد ننشئ نوعًا جديدًا للقيم المعادة يتضمن النوعين اللذين تعيدهما الدالة تمامًا مثل النوع Operation. type Result = string | number const calculator = (a: number, b: number, op: Operation): Result => { // ... } لكن السؤال الذي يطرح نفسه هو: هل من الطبيعي لدالة أن تعيد قيمة نصة؟ عندما تصل أثناء كتابة الشيفرة إلى حالة تضطر فيها إلى التقسيم على صفر، فإن خطأً جسيمًا قد وقع في مرحلة ما، وينبغي أن يلقي التطبيق خطأً ثم يعالج عند استدعاء الدالة. عندما تحاول أن تعيد قيمًا لم تكن تتوقعها أصلًا، ستمنعك التحذيرات التي تراها في محرر TSمن اتخاذ قرارات متسرعة، وتضمن لك عمل شيفرتك كما أردتها. وعليك أن تدرك أمرًا آخر، فحتى لو عرّفنا أنواعًا لمعاملاتنا، فإن شيفرة JavaScript المتولدة عند التصريف لن تكون قادرة على التحقق من هذه الأنواع أثناء التنفيذ. وإن كانت قيمة المعامل الذي يحدد العملية قادمةً من مكتبة خارجية على سبيل المثال، فلن تضمن أنها واحدة من القيم المسموحة. لذلك من الأفضل أن تنشئ معالجات للأخطاء، وأن تكون منتبهًا لحدوث ما لا تتوقعه. وفي الحالة التي تصادفك فيها عدة قيم مقبولة بينما ينبغي رفض بقية القيم وإلقاء خطأ، ستجد أن كتلة switch…case أكثر ملائمة من كتلة "if….else". ستبدو شيفرة الآلة الحاسبة التي طورناها على النحو التالي: type Operation = 'multiply' | 'add' | 'divide'; type Result = number; const calculator = (a: number, b: number, op : Operation) : Result => { switch(op) { case 'multiply': return a * b; case 'divide': if( b === 0) throw new Error('Can\'t divide by 0!'); return a / b; case 'add': return a + b; default: throw new Error('Operation is not multiply, add or divide!'); } } try { console.log(calculator(1, 5 , 'divide')) } catch (e) { console.log('Something went wrong, error message: ', e.message); } لا بأس بما فعلنا حتى الآن، لكن من الأفضل لو استخدمنا سطر الأوامر لإدخال القيم بدلًا من تغييرها ضمن الشيفرة في كل مرة. سنجرب ذلك كما لو كنا ننفذ تطبيق Node نظامي، باستخدام process.argv. لكن مشكلة ستقع: الحصول على الأنواع في حزم npm من خلال Type@ لنعد إلى الفكرة الأساسية للغة TS. تتوقع اللغة أن تكون كل الشيفرة المستخدمة قادرة على تمييز الأنواع، كما تتوقع ذلك من شيفرة مشروعك الخاص الذي هيِّئ بشكل مناسب. ولا تتضمن المكتبة TS بذاتها سوى الأنواع الموجودة في الحزمة TS. من الممكن كتابة أنواع خاصة بك لمكتبة، لكن ذلك غير ضروري إطلاقًا! فمجتمع TS قد فعل ذلك بالنيابة عنّا. وكما هو حال npm، يرحب عالم TS بالشيفرة مفتوحة المصدر. ومجتمع TS مجتمع نشط ويتفاعل دومًا مع آخر التحديثات والتغييرات في حزم npm المستخدمة أكثر. ستجد غالبًا كل ماتحتاجه من أنواع حزم npm، فلا حاجة لإنشاء هذه الأنواع بنفسك لكل اعتمادية قد تستخدمها. عادة ما نحصل على الأنواع المعرّفة في الحزم الموجودة من خلال منظمة Types@ وباستخدام npm. إذ يمكنك إضافة الأنواع الموجودة في حزمة إلى مشروعك بتثبيت حزمة npm لها نفس اسم حزمتك لكنه مسبوق بالبادئة Types@. فعلى سبيل المثال ستُثبَّت الأنواع التي توفرها المكتبة express بتنفيذ الأمر npm install --save-dev @types/express. تجري صيانة وتطوير Types@ من قبل Definitely typed، وهو مشروع مجتمعي يهدف إلى صيانة أنواع كل ما هو موجود في مكان واحد. قد تحتوي حزمة npm في بعض الأحيان الأنواع الخاصة بها ضمن الشيفرة، فلا حاجة في هذه الحالة إلى تثبيت تلك الأنواع بالطريقة التي ذكرنا سابقًا. طالما أن المتغير العام process قد عُرِّف من قبل Node، سنحصل على الأنواع التي يؤمنها بتثبيت الحزمة types/node@: npm install --save-dev @types/node لن يعترض المصرِّف على المتغير process بعد تثبيت الأنواع. ولاحظ أنه لا حاجة لطلب الأنواع ضمن الشيفرة، إذ يكفي تثبيت الحزمة التي تُعرِّفها. تحسينات على المشروع سنضيف تاليًا سيكربت npm لتشغيل كلا من برنامج دالة الضرب، وبرنامج الآلة الحاسبة: { "name": "part1", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "ts-node": "ts-node", "multiply": "ts-node multiplier.ts", "calculate": "ts-node calculator.ts" }, "author": "", "license": "ISC", "devDependencies": { "ts-node": "^8.6.2", "typescript": "^3.8.2" } } سنجعل دالة الضرب تعمل من خلال سطر الأوامر عند إجراء التعديلات التالية: const multiplicator = (a: number, b: number, printText: string) => { console.log(printText, a * b); } const a: number = Number(process.argv[2]) const b: number = Number(process.argv[3]) multiplicator(a, b, `Multiplied ${a} and ${b}, the result is:`); وسننفذ البرنامج كالتالي: npm run multiply 5 2 لو نفذنا البرنامج بمعاملات ليست من النوع الصحيح، كما تُظهر الشيفرة التالية: npm run multiply 5 lol سيعمل البرنامج أيضًا معطيًا الخرج التالي: Multiplied 5 and NaN, the result is: NaN والسبب في ذلك أن تنفيذ الأمر ('Number('lol سيعيد القيمة NaN وهذه القيمة من النوع number. لهذا لا يمكن أن تنقذنا TS من مأزق كهذا. لا بدّ في هذه الحالات من تقييم البيانات التي نحصل عليها من سطر الأوامر لكي نمنع سلوكًا كهذا. ستبدو النسخة المحسّنة من "دالة الضرب" كالتالي: interface MultiplyValues { value1: number; value2: number; } const parseArguments = (args: Array<string>): MultiplyValues => { if (args.length < 4) throw new Error('Not enough arguments'); if (args.length > 4) throw new Error('Too many arguments'); if (!isNaN(Number(args[2])) && !isNaN(Number(args[3]))) { return { value1: Number(args[2]), value2: Number(args[3]) } } else { throw new Error('Provided values were not numbers!'); } } const multiplicator = (a: number, b: number, printText: string) => { console.log(printText, a * b); } try { const { value1, value2 } = parseArguments(process.argv); multiplicator(value1, value2, `Multiplied ${value1} and ${value2}, the result is:`); } catch (e) { console.log('Error, something bad happened, message: ', e.message); } عندما ننفذ البرنامج الآن: npm run multiply 1 lol سنحصل على رسالة خطأ مناسبة للوضع: Error, something bad happened, message: Provided values were not numbers! يحتوي تعرف الدالة parseArguments بعض الأشياء الملفتة: const parseArguments = (args: Array<string>): MultiplyValues => { // ... } سنجد أولًا أنّ المعامل arg هو مصفوفة من النوع "string"، وأنّ القيم المعادة من النوع "MultiplayValues" الذي عُرِّف بالشكل التالي: interface MultiplyValues { value1: number; value2: number; } لقد استخدمنا في تعريف هذا النوع التعليمة Interface للغة TS، وهي إحدى الطرق المتبعة لتحديد شكل الكائن. إذ ينبغي كما هو واضح في حالتنا، أن تكون القيمة المعادة كائنًا له خاصيتين value1 وvalue2 من النوع "number". التمارين 9.1 - 9.3 التثبيت سننجز التمارين من 9.1 إلى 9.7 ضمن مشروع Node واحد. أنشئ المشروع في مجلد فارغ مستخدمًا الأمر npm init وثبِّت حزمتي ts-node وtypescript. أنشئ أيضًا ملفًا باسم tsconfig.json ضمن المجلد بحيث يحتوي الشيفرة التالية: { "compilerOptions": { "noImplicitAny": true, } } يستخدم هذا الملف لتحديد الطريقة التي سيفسر بها مصرِّف TS الشيفرة المكتوبة، وما هو مقدار التشدد الذي سيفرضه المصرِّف، وما هي الملفات التي ينبغي مراقبتها أو تجاهلها، والكثير الكثير من الأشياء. سنستخدم حاليًا خيار المصرِّف noImplicitAny الذي يشترط أن تحمل جميع المتغيرات أنواعًا محددة. 9.1 مؤشر كتلة الجسم ضع شيفرة هذا التمرين في الملف bmiCalculator.ts. اكتب دالة باسم calculateBmi تحسب مؤشر كتلة الجسم BMI بناء على طول محدد (سنتيمتر) ووزن محدد (كيلو غرام)، ثم أعد رسالة مناسبة تحمل النتيجة. استدعي الدالة من شيفرتك ومرر إليها معاملات جاهزة ثم اطبع النتيجة. ينبغي أن تطبع الشيفرة التالية: console.log(calculateBmi(180, 74)) النتيجة التالية: Normal (healthy weight) أنشئ سكربت npm لكي تنفذ البرنامج باستخدام الأمر npm run calculateBmi 9.2 آلة حاسبة لساعات التمرين ضع شيفرة هذا التمرين في الملف exerciseCalculatore.ts. اكتب دالة باسم calculateExercise تحسب متوسط ساعات التمرين اليومية وتقارنها بعدد الساعات التي ينبغي الوصول إليها يوميًا، ثم تعيد كائنًا يتضمن القيم التالية: عدد الأيام عدد أيام التمرين القيمة المستهدفة أساسًا متوسط الوقت المحسوب قيمة منطقية تحدد إن تم تحقيق الهدف أم لا. تقييمًا بين 1-3 يصف حسن التمرين خلال ساعات التمرين. قرر أسلوب التقييم كما تشاء. قيمة نصية تصف التقييم تمرر ساعات التمرين اليومية إلى الدالة مثلل مصفوفة تحتوي على عدد ساعات التمرين كل يوم خلال فترة التمرين. فلو فرضنا أن ساعات التمرين خلال أسبوع موزعة كالتالي: يوم الاثنين 3، يوم الثلاثاء 0، يوم الأربعاء 2، يوم الخميس 4.5 وهكذا، ستكون المصفوفة مشابهة للمصفوفة التالية: [3, 0, 2, 4.5, 0, 3, 1] عليك أن تنشئ واجهة interface من أجل توصيف الكائن الذي سيحمل النتيجة. لو استدعيت الدالة وقد مررت لها المصفوفة [3, 0, 2, 4.5, 0, 3, 1] والقيمة 2 للمعامل الآخر ستكون النتيجة على النحو: { periodLength: 7, trainingDays: 5, success: false, rating: 2, ratingDescription: 'not too bad but could be better', target: 2, average: 1.9285714285714286 } أنشئ سكربت npm لينفذ الأمر npm run calculateExercise الذي يستدعي الدالة بمعاملات قيمها موجودة مسبقًا في الشيفرة. 9.3 سطر الأوامر عدّل التمرينين السابقين بحيث يمكنك تمرير قيم معاملات الدالتين calculateBmi وcalculateExercise من خلال سطر الأوامر. يمكن أن تنفذ برنامجك على سبيل المثال على النحو التالي: $ npm run calculateBmi 180 91 Overweight أو على النحو: $ npm run calculateExercises 2 1 0 2 4.5 0 3 1 0 4 { periodLength: 9, trainingDays: 6, success: false, rating: 2, ratingDescription: 'not too bad but could be better', target: 2, average: 1.7222222222222223 } وانتبه إلى أنّ المعامل الأول للدالة السابقة هو القيمة المستهدفة. تعامل مع الاستثناءات والأخطاء بطريقة مناسبة. وانتبه إلى أنّ الدالة exerciseCalculator ستقبل قيمًا لمعاملاتها بأطوال مختلفة. قرر بنفسك كيف ستجمّع كل البيانات المطلوبة. المزيد حول قواعد تهيئة TS لقد استخدمنا في التمارين السابقة قاعدة تهيئة واحدة هي noImplicitAny. وهي بالفعل نقطة انطلاق جيدة، لكن يجب أن نطلع بشيء من التفصيل على الملف "config". يحتوي الملف tsconfig.json كل التفاصيل الجوهرية التي تحدد الطريقة التي ستنفذ بها TS مشروعك. فيمكنك أن تحدد مقدار التشدد في تفحص الشيفرة، وأن تحدد الملفات التي ستدرجها في المشروع والتي ستستثنيها (يستثنى الملف "node_modules" افتراضيًا)، وأين ستخزّن الملفات المصرِّفة (سنتحدث أكثر عن هذا الموضوع لاحقًا). لنضع القواعد التالية في الملف tsconfig.json: { "compilerOptions": { "target": "ES2020", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true } } لا تقلق بخصوص القواعد في الجزء compilerOptions من الملف، فسنمر عليها بشيئ من التفصيل في القسم 2. يمكنك أن تجد شروحات عن كل قاعدة تهيئة من خلال توثيق TS، أو من خلال صفحة الويب tsconfig page، أو من خلال تعريف تخطيط الملف tsconfig والذي صيغ لسوء الحظ بطريقة أقل وضوحًا من الخيارين السابقين. إضافة المكتبة express إلى الخلطة وصلنا إلى مرحلة لا بأس بها. فمشروعنا قد ثّبت، ويحتوي على نسخة قابلة للتنفيذ من الآلة الحاسبة. لكن طالما أنا نهتم بالتطوير الشامل لتطبيقات الويب، فقد حان الوقت لنعمل مع بعض طلبات HTTP. لنبدأ بتثبيت المكتبة express: npm install express ثم سنضيف سكربت start إلى الملف package.json { // .. "scripts": { "ts-node": "ts-node", "multiply": "ts-node multiplier.ts", "calculate": "ts-node calculator.ts", "start": "ts-node index.ts" }, // .. } سنتمكن الآن من إنشاء الملف index.ts، ثم سنكتب ضمنه طلب HTTP-GET للتحقق من الاتصال بالخادم: const express = require('express'); const app = express(); app.get('/ping', (req, res) => { res.send('pong'); }); const PORT = 3003; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); يبدو أن كل شيء يعمل على ما يرام، لكن كما هو متوقع، لا بدّ من تحديد نوع كل من المعاملين req وres للدالة app.get (لأننا وضعنا قاعدة تهيئة تفرض أن يكون لكل متغير نوع). لو نظرت جيدًا، ستجد أن VSCode سيعترض على إدراج express. ستجد خطًا منقطا أصفر اللون تحت التعليمة require. وعندما نمرر الفأرة فوق الخطأ ستظهر الرسالة التالية: إنّ سبب الاعتراض هو أن require يمكن أن تُحوَّل إلى import. لننفذ النصيحة ونستخدم import كالتالي: import express from 'express'; ملاحظة: تقدم لك VSCode ميزة إصلاح المشاكل تلقائيًا بالنقر على زر "…Quick fix". انتبه إلى تلك الاصلاحات التلقائية، وحاول أن تتابع النصائح التي يقدمها محرر الشيفرة، لأن ذلك سيحسّن من شيفرتك ويجعلها أسهل قراءة. كما يمكن أن يكون الإصلاح التلقائي للأخطاء عاملًا رئيسيًا في توفير الوقت. سنواجه الآن مشكلة جديدة. سيعترض المصرِّف على عبارة import. وكالعادة يمثل المحرر المكان الأفضل لإيجاد حلول للمشاكل: لم نثبّت أنواع express كما يشير المحرر، لنثبتها إذًا: npm install --save-dev @types/express وهكذا سيعمل البرنامج بلا أخطاء. فلو مررنا الفأرة على عبارة require سنرى أن المصرِّف قد فسّر كل ما يتعلق بالمكتبة express على أنه من النوع "any". لكن عند استخدام imports فسيعرف المصرِّف الأنواع الفعلية للمتغيرات: يعتمد استخدام عبارة الإدراج على أسلوب التصدير الذي تعتمده الحزمة التي ندرجها. وكقاعدة أساسية: حاول أن تدرج وحدات الشيفرة باستخدام التعليمة import أولًا. سنستخدم دائمًا هذا الأسلوب عند كتابة شيفرة الواجهة الأمامية. فإن لم تنجح التعليمة import، جرّب طريقة مختلطة بتنفيذ الأمر ('...')import...= require. كما نوصيك بشدة أن تطلع أكثر على وحدات TS. لا تزال هنالك مشكلة عالقة: يحدث هذا لأننا منعنا وجود معاملات غير مستخدمة عند كتابة قواعد التهيئة في الملف tsconfig.json. { "compilerOptions": { "target": "ES2020", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true } } ستسبب لك التهيئة مشاكلًا إن استخدمت دوال معرّفة مسبقُا لمكتبة وتتطلب تصريحًا عن متغير حتى لو لم يستخدم مطلقًا كالحالة التي تواجهنا. ولحسن الحظ فقد حُلًت هذه المشكلة على مستوى قواعد التهيئة. وبمجرد تمرير مؤشر الفأرة فوق الشيفرة التي سببت المشكلة سيعطينا فكرة الحل. وسننجز الحل هذه المرة بالنقر على زر الإصلاح السريع. إن كان من المستحيل التخلص من المتغيرات غير المستخدمة، يمكنك أن تضيف إليها البادئة (_) لإبلاغ المصرِّف بأنك قد فكرت بحل ولم تصل إلى نتيجة! لنعدّل اسم المتغير req ليصبح req_. وأخيرًا سنكون مستعدين لتشغيل البرنامج. ويبدو أن كل شيء على مايرام. ولتبسيط عملية التطوير، ينبغي علينا تمكين ميزة إعادة التحميل التلقائي، وذلك لتحسين انسيابية العمل. لقد استخدمنا سابقًا المكتبة nodemon التي توفر هذه الميزة، لكن البديل عنها في ts-node هي المكتبة ts-node-dev. وقد جرى تصميم هذه الأخيرة لتُستخدم فقط في بيئة التطوير، حيث تتولى أمور إعادة التصريف عند كل تغيير، وبالتالي لا حاجة لإعادة تشغيل التطبيق. لنثبّت المكتبة ts-node-dev كاعتمادية تطوير: npm install --save-dev ts-node-dev ثم علينا إضافة سكربت خاص بها ضمن الملف package.json. { // ... "scripts": { // ... "dev": "ts-node-dev index.ts", }, // ... } وهكذا ستحصل على بيئة تطوير تدعم إعادة التحميل التلقائي للمشروع بتنفيذ الأمر npm run dev. التمرينان 9.4 - 9.5 9.4 استخدام المكتبة Express أضف المكتبة express إلى اعتماديات التطبيق، ثم أنشئ وصلة تخديم endpoint لطلبات HTTP-GET تدعى hello تجيب على الطلب بالعبارة 'Hello FullStack'. ينبغي أن تُشغّل التطبيق باستخدام الأمر npm start وذلك في بيئة الإنتاج وبالأمر npm run dev في بيئة التطوير التي من المفترض أن تستخدم المكتبة ts-node-dev لتشغيل التطبيق. استبدل محتوى الملف tsconfig.json بالمحتوى التالي: { "compilerOptions": { "noImplicitAny": true, "noImplicitReturns": true, "strictNullChecks": true, "strictPropertyInitialization": true, "strictBindCallApply": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitThis": true, "alwaysStrict": true, "esModuleInterop": true, "declaration": true, } } تأكد من عدم وجود أية أخطاء! 9.5 تطبيق ويب لقياس مؤشر الكتلة BMI أضف وصلة تخديم لحاسبة مؤشر الكتلة يمكن استخدامها بإرسال طلبات HTTP-GET إلى وصلة التخديم bmi، بحيث يكون الدخل على شكل معاملات استعلام نصية (query string parameters). فلو أردت مثلًا الحصول على مؤشر كتلة شخص طوله 180 ووزنه 72، ستحصل عليه بطلب العنوان http://localhost:3002/bmi?height=180&weight=72. ستكون الاستجابة بيانات json لها الشكل التالي: { weight: 72, height: 180, bmi: "Normal (healthy weight)" } اطلع على توثيق express لتعرف آلية الوصول إلى بارامترات الاستعلام. إن كانت معاملات الطلب من نوع خاطئ أو مفقودة، أجب برمز حالة مناسب ورسالة الخطأ التالية: { error: "malformatted parameters" } لا تنسخ شيفرة الحاسبة إلى الملف index.ts، بل اجعلها وحدة TS مستقلة يمكن إدراجها ضمن هذا الملف. كوابيس استخدام النوع any قد نلاحظ بعد أن أكملنا تنفيذ أول وصلة تخديم أننا بالكاد استخدمنا TS في هذا الأمثلة الصغيرة. وقد نلاحظ أيضًا بعض النقاط الخطرة عند تفحص الشيفرة بعناية. لنضف وصلة تخديم طلبات HTTP-GET تدعى calculate إلى تطبيقنا: import { calculator } from './calculator' // ... app.get('/calculate', (req, res) => { const { value1, value2, op } = req.query const result = calculator(value1, value2, op) res.send(result); }); عندما نمرر مؤشر الفأرة فوق الدالة calculate، ستجد أنها تحمل نوعًا على الرغم من أن الشيفرة ذاتها لا تتضمن أية أنواع. وبتمرير مؤشر الفأرة فوق القيم المستخلصة من الطلب، ستظهر مشكلة جديدة: ستحمل جميع المتغيرات النوع any. لن نتفاجأ كثيرًا بما حدث، لأننا في الواقع لم نحدد نوعًا لأي قيمة منها. تُحل المشكلة بطرق عدة، لكن علينا أولًا أن نفهم سبب حدوث هذا السلوك وما الذي جعل القيم من النوع any. كل متغير في TS لا يملك نوعًا ولا يمكن الاستدلال على نوعه، سيعتبر من النوع any ضمنًا. وهذا النوع هو بمثابة نوع "بديل" ويعني حرفيًا "أيًا كان النوع". ويحدث هذا الأمر كثيرًا عندما ينسى المبرمج أن يحدد نوعًا للقيم التي تعيدها الدالة ولمعاملاتها. كما يمكن أن نصُرّح بأن نوع المتغيّر هو any. والفرق الوحيد بين التصريح عن هذا النوع أو تضمينه هو في مظهر الشيفرة المكتوبة، فلن يكترث المصرِّف لأي فرق. لكن سينظر المبرمجون إلى هذا الموضوع بشكل مختلف. فتضمين النوع any يوحي بالمشاكل، لأنه ينتج عادة عن نسيان تحديد نوع المتغير أو المعامل، كما يعني أنك لم تستخدم TS بالطريقة الملائمة. وهذا هو السبب في وجود قاعدة التهيئة noImplicitAny على مستوى المصرِّف. ومن الأفضل أن تبقيها دائمًا مفعّلة. في الحالات النادرة التي لا تعرف فيها فعلًا نوع المتغيًر، عليك التصريح على أنه من النوع any. const a : any = /* افعل هذا إن كنت لا تعلم حقًا نوع المتغير */ لقد فعلنا القاعدة في مثالنا، لماذا إذًا لم يعترض المصرِّف على القيم التي تحمل النوع any ضمنًا؟ يعود السبب إلى الحقل query من كائن الطلب العائد للمكتبة express، فهو يمتلك النوع any صراحة. وكذلك الأمر بالنسبة للحقل request.body الذي يُستخدم لإرسال المعلومات إلى التطبيق. هل يمكننا منع المطور من استخدام any نهائيًا؟ لدينا لحسن الحظ طرق أخرى غير استخدام tsconfig.ts لإجبار المطور على اتباع أسلوب محدد في كتابة الشيفرة. إذا يمكننا استخدام المدقق eslint لإدارة أسلوب كتابة الشيفرة. لنثبت eslint إذًا: npm install --save-dev eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser سنُهيئ المدقق بحيث يمنع التصريح بالنوع "any". اكتب القواعد التالية في الملف ذو اللاحقة "eslintrc.": { "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 11, "sourceType": "module" }, "plugins": ["@typescript-eslint"], "rules": { "@typescript-eslint/no-explicit-any": 2 } } لنكتب أيضًا السكربت lint داخل الملف package.json ليتحقق من وجود ملفات لاحقتها "ts.": { // ... "scripts": { "start": "ts-node index.ts", "dev": "ts-node-dev index.ts", "lint": "eslint --ext .ts ." // ... }, // ... } سيعترض المدقق لو حاولنا أن نصرّح عن متغير من النوع any. ستجد في @typescript-eslint الكثير من قواعد المدقق eslint المتعلقة باللغة TS، كما يمكنك استخدام القواعد الأساسية للمدقق في مشاريع هذه اللغة. من الأفضل حاليًا استخدام الإعدادات الموصى بها، ثم يمكننا لاحقًا تعديل القواعد عندما نحتاج إلى ذلك. وعلينا الانتباه إلى التوصيات المتعلقة بأسلوب كتابة الشيفرة بما يتوافق مع الأسلوب الذي يتطلبه هذا القسم وكذلك وضع فاصلة منقوطة في نهاية كل سطر من أسطر الشيفرة. سنستخدم حاليًا ملف قواعد المدقق الذي يتضمن ما يلي: { "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking" ], "plugins": ["@typescript-eslint"], "env": { "node": true, "es6": true }, "rules": { "@typescript-eslint/semi": ["error"], "@typescript-eslint/no-explicit-any": 2, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "no-case-declarations": 0 }, "parser": "@typescript-eslint/parser", "parserOptions": { "project": "./tsconfig.json" } } هناك بعض الفواصل المنقوطة المفقودة، لكن إضافتها أمر سهل. والآن علينا إصلاح كل ما يحتاج إلى إصلاح. التمرينان 9.6 - 9.7 9.6 المدقق ESlint هيئ مشروعك ليستخدم الإعدادات السابقة وأصلح كل المشاكل. 9.7 تطبيق ويب لحساب ساعات التمرين أضف وصلة تخديم إلى تطبيقك الذي يحسب ساعات التمرين اليومية. ينبغي أن تستخدم الوصلة لإرسال طلب HTTP-POST إلى وصلة التخديم المقابلة exercises متضمنة معلومات الدخل في جسم الطلب. { "daily_exercises": [1, 0, 2, 0, 3, 0, 2.5], "target": 2.5 } سيكون الجواب بصيغة json على النحو التالي: { "periodLength": 7, "trainingDays": 4, "success": false, "rating": 1, "ratingDescription": "bad", "target": 2.5, "average": 1.2142857142857142 } إن لم يكن جسم الطلب بالتنسيق الصحيح، أعد رمز الحالة المناسب مع رسالة الخطأ هذه: { error: "parameters missing" } أو هذه: { error: "malformatted parameters" } بناء على الخطأ. ويحدث الخطأ الثاني إن لم تمتلك القيم المدخلة النوع المناسب، كأن لا تكون أرقامًا أو لا يمكن تحويلها إلى أرقام. يمكن أن تجد أن التصريح عن متغير بأنه من النوع any مفيدًا في هذا التمرين عندما تتعامل مع البيانات الموجودة ضمن جسم الطلب. ستمنعك طبعًا قواعد تهيئة المدقق، لكن يمكنك أن تلغي تفعيل هذه القاعدة من أجل سطر محدد من الشيفرة بوضع هذا التعليق قبل السطر المطلوب. // eslint-disable-next-line @typescript-eslint/no-explicit-any انتبه بأنك ستحتاج إلى تهيئة صحيحة لتتمكن من الاحتفاظ ببيانات جسم الطلب. راجع القسم 3. ترجمة -وبتصرف- للفصل First steps with TypeScript من سلسلة Deep Dive Into Modern Web Development
-
TypeScript هي لغة برمجة مصممة لتنفيذ مشاريع JavaScript ضخمة، صممتها Microsoft. فقد بُنيت على سبيل المثال برنامج مثل Azure Management Portal الذي يبلغ عدد أسطر شيفرته 1.2 مليون، وبرنامج Visiual Studio Code الذي يبلغ عدد أسطر شيفرته 300 ألف باستخدام TypeScript. تقدم TypeScript ميزات عديدة لدعم مشاريع JavaScript الضخمة مثل أدوات تطوير أفضل وتحليل شيفرة ساكنة والتحقق من الأنواع عند الترجمة والتوثيق على مستوى الشيفرة. المبدأ الرئيسي تمثل اللغة TypeScript مجموعة مطوّرة من تعليمات JavaScript قادرة على تمييز الأنواع وتترجم عادة إلى شيفرة JavaScript صرفة. ويمكن للمطور أن يحدد إصدار الشيفرة الناتجة طالما أن إصدار ECMAScript هو الإصدار 3 أو أحدث. ويعني أنّ هذه اللغة مجموعة مطوّرة من تعليمات JavaScript أنها تتضمن كل ميزات JavaScript بالإضافة إلى ميزات خاصة بها. وتعتبر كل شيفرات JavaScript شيفرات TypeScript صحيحة. تتألف TypeScript من ثلاثة أقسام منفصلة لكنها متكاملة مع بعضها: اللغة المصرِّف خدمة اللغة تتألف اللغة من القواعد والكلمات المحجوزة (تعليمات) ومسجلات الأنواع Type annotations وتتشابه قواعدها مع مثيلاتها في JavaScript لكنها ليست متماثلة. وأكثر ما يتعامل معه المبرمجون من تلك الأقسام هي اللغة. تنحصر مهمة المصرِّف في محو المعلومات المتعلقة بالنوع بالإضافة إلى التحويلات على الشيفرة. إذ تمكن عملية تحويل الشيفرة من نقل شيفرة TypeScript إلى شيفرة JavaScript قابلة للتنفيذ. يزيل المصرِّف كل ما يتعلق بالأنواع أثناء الترجمة، وبالتالي لا تميز هذه اللغة الأنواع بشكل فعلي قبل الترجمة. تعني عملية التصريف بالمعنى المتعارف عليه، تحويل الشيفرة من الشكل الذي يمكن للإنسان قراءته وفهمه إلى الشكل الذي تفهمه الآلة. أما عملية الترجمة في TypeScript فهي عملية تحويل الشيفرة من شكل يفهمه الإنسان إلى شكل آخر يفهمه الإنسان أيضًا، لذا من الأنسب أن تسمى هذه العملية بالنقل Transpilling. لكن مصطلح الصريف هو الأشيع في هذا المضمار وسنستخدمه نحن بدورنا. ينفذ المصرِّف أيضًا عملية تحليل الشيفرة ما قبل التنفيذ. إذ يمكنه إظهار رسائل التحذير والخطأ إن كان هناك سبب لذلك، كما يمكن أن يُهيَّئ لتنفيذ مهام إضافية كضم الشيفرة الناتجة في ملف واحد. تجمع خدمة اللغة معلومات عن الأنواع الموجودة في الشيفرة المصدرية. وبالتالي ستتمكن أدوات التطوير من استخدامها في تقديم ميزة إكمال الشيفرة في بيئات التطوير وطباعة التلميحات وتقديم اقتراحات لإعادة كتابة أجزاء من الشيفرة. الميزات الرئيسية للغة TypeScript سنقدم تاليًا وصفًا لبعض ميزات اللغة TypeScript. والغاية من ذلك تزويدك بالأساسيات التي تقدمها اللغة وميزاتها، وذلك للتعامل مع الأفكار التي سنراها تباعًا في المنهاج. مسجلات الأنواع وهي طريقة خفيفة في TypeScript لتسجيل الأنواع التي نريد أن تمرر أو تعاد من الدوال أو أنواع المتغيرات. فلقد عرفنا في المثال التالي على سبيل المثال الدالة birthdayGreeter التي تقبل معاملين أحدهما من النوع string، والآخر من النوع number، وستعيد قيمة من النوع string. const birthdayGreeter = (name: string, age: number): string => { return `Happy birthday ${name}, you are now ${age} years old!`; }; const birthdayHero = "Jane User"; const age = 22; console.log(birthdayGreeter(birthdayHero, 22)); نظام الخصائص المعرفة للأنواع تستخدم اللغة TypeScript الخصائص المعرفة للأنواع structural typing. ويعتبر عنصران في هذا النظام متوافقان إن كان لكل ميزة في النوع الأول ميزة تماثلها تمامًا في النوع الثاني. ويعتبر النوعان متطابقان إذا كانا متوافقان مع بعضهما. الاستدلال على النوع يحاول المصرِّف أن يستدل على نوع المعلومة إن لم يحدد لها نوع. إذ يمكن الاستدلال على نوع المتغير بناء على القيمة التي أسندت له. تحدث هذه العملية عند تهيئة المتغيرات والأعضاء، وعند إسناد القيم الافتراضية للمعاملات، وعند تحديد القيمة التي تعيدها دالة. لنتأمل على سبيل المثال الدالة Add: const add = (a: number, b: number) => { /* تستخدم القيمة المعادة لتحديد نوع القيمة التي تعيدها الدالة*/ return a + b; } يستدل المصرِّف على نوع القيمة المعادة للدالة بتعقب الشيفرة حتى الوصول إلى عبارة return. تعيد هذه العبارة مجموع المعاملين a وb. وكما نرى فكلا المعاملين من النوع number وهكذا سيستدل المصرِّف على أن القيمة التي تعيدها الدالة من النوع number. وكمثال أكثر تعقيدًا، لنتأمل الشيفرة التالية (قد يكون المثال صعب الفهم قليلًا إن لم تكن قد استخدمت TypeScript مسبقًا. يمكنك تخطي هذا المثال حاليًا.) type CallsFunction = (callback: (result: string) => any) => void; const func: CallsFunction = (cb) => { cb('done'); cb(1); } func((result) => { return result; }); بداية نجد تصريحًا لاسم نوع مستعار type alias يدعى CallsFunction. يُمثل نوع دالة تقبل معاملًا واحدًا callback يمثِّل بدوره دالة تتلقى معاملًا من النوع "string" وتعيد قيمة من النوع any. وكما سنرى لاحقًا فالنوع any هو شكل من أشكال الحروف البديلة wildcards والتي يمكن أن تحل محل أي نوع. بعد ذلك نعرّف الدالة func من النوع CallsFunction. يمكننا أن نستدل من نوع الدالة بأن معاملها الدالة cb ستقبل فقط معاملًا من النوع string.ولتوضيح ذلك سنورد مثالًا آخر تُستدعى فيه دالة كمعامل لكن بقيمة من النوع number، وسيسبب هذا الاستدعاء خطأً في TypeScript. وأخيرًا نستدعي الدالة func بعد أن نمرر إليها الدالة التالية كمعامل: (result) => { return result; } وعلى الرغم من عدم تحديد أنواع معاملات الدالة، يمكننا الاستدلال من سياق الاستدعاء أن المعامل result من النوع String. إزالة الأنواع تزيل TypeScript جميع الأنواع التي يبنيها نظام تحديد الأنواع أثناء الترجمة: فلو كانت الشيفرة قبل الترجمة كالتالي: let x: SomeType; ستكون بعد الترجمة: let x; ويعني هذا عدم وجود أية معلومات عن الانواع أثناء التنفيذ، فلا يشير أي شيء على أن متغيرًا X على سبيل المثال قد عُرِّف أنه من نوع ما. سيكون النقص في المعلومات عن الأنواع في زمن التنفيذ مفاجئًا للمبرمجين الذين اعتادوا على استخدام تعليمات التحقق Reflection وغيرها من أنظمة إظهار البيانات الوصفية. هل علينا فعلا استعمال TypeScript؟ إن كنت من متابعي المنتديات البرمجية على الانترنت، ستجد تضاربًا في الآراء الموافقة والمعارضة لاستخدام هذه اللغة. والحقيقة أنّ مدى حاجتك لاستخدام الدوال التي تقدمها TypeScript هي من يحدد وجهة نظرك. سنعرض على أية حال بعض الأسباب التي قد تجعل استخدام هذه اللغة مفيدًا. أولًا: ستقدم ميزة التحقق من الأنواع وميزة التحليل الساكن للشيفرة (قبل الترجمة والتنفيذ). كما يمكننا أن نطلب قيمًا من نوع محدد وأن نتلقى تحذيرات من المصرِّف عندما نستخدم هذه القيم بشكل خاطئ. سيقلل هذا الأمر الأخطاء التي تحصل وقت التنفيذ، كما يمكن أن يقلل من عدد اختبارات الأجزاء unit tests التي يحتاجها التطبيق وخاصة ما يتعلق باختبارات الأنواع الصرفة pure types. ولا تحذرك عملية التحليل الساكن للشيفرة من الاستخدام الخاطئ للأنواع وحسب، بل تشير إلى الأخطاء الأخرى ككتابة اسم المتغير أو الدالة بشكل خاطئ أو استخدام المتغير خارج المجال المعرّف ضمنه. ثانيًا: يمكن لميزة تسجيل الأنواع أن تعمل كشكل من أشكال التوثيق على مستوى الشيفرة. إذ يمكنك أن تتحقق من خلال بصمة الدالة signature من الأنواع التي تتلقاها الدالة والأنواع التي ستعيدها. سيبقى هذا الشكل من التوثيق محدّثًا أثناء تقدم العمل، وسيسهّل ذلك التعامل مع مشاريع جاهزة وخاصة للمبرمجين الجدد. كما ستساعد كثيرًا عند العودة إلى مشاريع قديمة. يمكن إعادة استخدام الأنواع في أي مكان من الشيفرة، وستنتقل أية تغييرات على تعريف النوع إلى أي مكان استخدم فيه هذا النوع. وقد يجادل البعض بأن استخدام JSDoc سينجز التوثيق على مستوى الشيفرة بشكل مماثل، لكنه لا يرتبط بالشيفرة بشكل وثيق كما تفعل أنواع TypeScript، وقد يسبب هذا خللًا في تزامن عرض المعلومات، بالإضافة إلى أنها أطول. ثالثًا: ستقدم بيئة عمل اللغة ميزة إكمال الشيفرة بشكل أفضل لأنها تعلم تمامًا نوع البيانات التي تعالجها. وتعتبر الميزات الثلاث السابقة مفيدة للغاية عند إعادة كتابة الشيفرة. فسيحذرك التحليل الساكن للشيفرة من الأخطاء في شيفرتك، وسيرشدك معالج إكمال الشيفرة إلى الخصائص التي يقدمها المتغير أو الكائن أو حتى خيارات لإعادة كتابة الشيفرة بشكل أنسب، وسيساعدك التوثيق على مستوى الشيفرة في فهم الشيفرة المكتوبة. وبهذه المساعدة التي تقدمها لك بيئة عمل هذه اللغة، سيكون من السهل استخدام الميزات الأحدث من لغة JavaScript وفي أية مرحلة تقريبًا بمجرد تغيير إعدادات التهيئة. ما الأمور التي لا يمكن للغة TypeScript أن توفرها لك؟ لقد ذكرنا سابقًا أن تسجيل الأنواع والتحقق منها فقط في مراحل ما قبل التنفيذ. فحتى لو لم يشر المصرِّف إلى أخطاء فقط تحدث الأخطاء أثناء التنفيذ. تحدث أخطاء زمن التنفيذ بشكل متكرر عند التعامل مع مصدر دخل خارجي كالبيانات المستقبلة كطلب عبر شبكة. وضعنا أخيرًا قائمة بالمشاكل التي قد تواجهنا عند استخدام TypeScript، ويجب الانتباه إليها: الأنواع غير المكتملة أو الخاطئة أو المفقودة التي مصدرها مكتبات خارجية قد تجد في بعض المكتبات الخارجية أنواعًا غير معرّفة أو ذات تعريف خاطئ بشكل أو بآخر. والسبب المرجح أنّ المكتبة لم تُكتَب بلغة Typescript، وأنّ المبرمج الذي صرّح عن النوع يدويًا قد ارتكب خطأً. عليك في هذه الحال كتابة التصريح عن النوع يدويًا بنفسك. لكن هناك فرصة كبيرة أن يكون أحدهم قد فعل ذلك مسبقًا عوضًا عنك، تحقق من موقع DefinitelyTyped أو صفحة GitHub الخاصة بهذا الموقع أولًا. وهذان المصدران هما الأكثر شعبية لإيجاد ملفات تعريف الأنواع. في حال لم يحالفك الحظ، لابدّ أن تبدأ من الصفر بقراءة توثيق TypeScript بما يخص تعريف الأنواع. قد يحتاج الاستدلال على النوع إلى بعض المساعدة إن الاستدلال على النوع في هذه اللغة جيد جدًا لكنه ليس مثاليًا. قد تعتقد أحيانًا أنك صرحت عن نوع بشكل مثالي، لكن المصرِّف يحذرك بشكل مستمر من أن خاصية محددة غير موجودة أو أنّ التصريح بهذه الطريقة غير مسموح. عليك في حالات كهذه مساعدة المصرِّف بتنفيذ عملية تحقق إضافية من النوع الذي صرحت عنه، وخاصة فيما يتعلق بالتحويل بين الأنواع type casting أو حاميات الأنواع type gaurds. عند التحويل بين الأنواع أو عند استخدام الحاميات فأنت بذلك تضمن للمصرِّف أن القيمة التي وضعتها هي بالتأكيد من النوع المصرح عنه وهذا مصدر للأخطاء. ربما عليك الاطلاع على التوثيق فيما يتعلق بحاميات الأنواع أو مؤكدات الأنواع Type Assertions. أخطاء الأنواع الغامضة من الصعب أحيانًا فهم طبيعة الخطأ الذي يعطيه نظام تحديد الأنواع، وخاصة إن استخدمت أنواع معقدة. وكقاعدة أساسية، ستحمل لك رسائل خطأ TypeScript المعلومات الأكثر أهمية في نهاية الرسالة. فعندما تظهر لك رسالة طويلة مزعجة، ابدأ بالقراءة من آخر الرسالة. ترجمة -وبتصرف- للفصل Background and introduction من سلسلة Deep Dive Into Modern Web Development