صفا الفليج
الأعضاء-
المساهمات
47 -
تاريخ الانضمام
-
تاريخ آخر زيارة
نوع المحتوى
ريادة الأعمال
البرمجة
التصميم
DevOps
التسويق والمبيعات
العمل الحر
البرامج والتطبيقات
آخر التحديثات
قصص نجاح
أسئلة وأجوبة
كتب
دورات
كل منشورات العضو صفا الفليج
-
تُتيح لنا المُكرّرات غير المتزامنة المرور على البيانات التي تأتينا على نحوٍ غير متزامن متى لزم، مثل حين نُنزّل شيئًا كتلةً كتلةً عبر الشبكة. المولّدات غير المتزامنة تجعل من ذلك أسهل فأسهل. لنرى مثالًا أولًا لنفهم الصياغة، بعدها نرى مثالًا من الحياة العملية. المكررات غير المتزامنة تتشابه المُكرّرات غير المتزامنة مع تلك العادية، بفروق بسيطة في الصياغة. فكائن المُكرّر العادي (كما رأينا في الفصل الكائنات المكرَّرة (Iterables) في جافاسكربت) يكون هكذا: let range = { from: 1, to: 5, // تستدعي حلقة for..of هذه الدالّة مرة واحدة في البداية [Symbol.iterator]() { // ...ستعيد كائن مُكرُر: // لاحقًا ستعمل حلقة for..of فقط مع ذلك الكائن. // وتطلب منه القيم التالية باستخدام دالة next() return { current: this.from, last: this.to, // تُستدعى next() في كلّ تكرار من خلال الحلقة for..of next() { // (2) // يجب أن تعيد القيم ككائن {done:.., value :...} if (this.current <= this.last) { return { done: false, value: this.current++ }; } else { return { done: true }; } } }; } }; for(let value of range) { alert(value); // 1 then 2, then 3, then 4, then 5 } راجِع … لتعرف تفاصيل المُكرّرات العادية، لو لزم. ولنجعل الكائن مُتكرّرًا غير متزامنًا: علينا استعمال Symbol.asyncIterator بدل Symbol.iterator. على next() إعادة وعد. علينا استعمال حلقة for await (let item of iterable) لتكرار كائن معين. فلنصنع كائن range مُتكرّرًا (كما أعلاه) ولكن يُعيد القيم بنحوٍ غير متزامن، قيمةً واحدةً كلّ ثانية: let range = { from: 1, to: 5, // تستدعي حلقة for..of هذه الدالّة مرة واحدة في البداية [Symbol.asyncIterator]() { // (1) // ...ستعيد كائن مُكرُر: // لاحقًا ستعمل حلقة for..of فقط مع ذلك الكائن. // وتطلب منه القيم التالية باستخدام دالة next() return { current: this.from, last: this.to, // تُستدعى next() في كلّ تكرار من خلال الحلقة for..of async next() { // (2) // يجب أن تعيد القيم ككائن {done:.., value :...} // وستُغلّف تلقائيًا في وعد غير متزامن // يمكننا استخدام await لتنفيذ أشياء غير متزامنة: await new Promise(resolve => setTimeout(resolve, 1000)); // (3) if (this.current <= this.last) { return { done: false, value: this.current++ }; } else { return { done: true }; } } }; } }; (async () => { for await (let value of range) { // (4) alert(value); // 1,2,3,4,5 } })() كما ترى فالبنية تشبه المُكرّرات العادية: ليكون الكائن مُتكرّرًا على نحوٍ غير متزامن، يجب أن يحتوي التابِع Symbol.asyncIterator (لاحِظ (1)). على التابِع إعادة الكائن وهو يحتوي التابِع next() الذي يُعيد وعدًا (لاحِظ (2)). ليس على التابِع next() أن يكون غير متزامن async. فيمكن أن يكون تابِعًا عاديًا يُعيد وعدًا، إلّا أنّ async تُتيح لنا استعمال await وهذا أفضل لنا. هنا نؤخّر التنفيذ ثانيةً واحدةً فقط (لاحِظ (3)). ليحدث التكرار نستعمل for await(let value of range) (لاحِظ (4))، أي نُضيف await قبل الحلقة for. هكذا تستدعي الحلقة range[Symbol.asyncIterator]() مرةً واحدة، وبعدها تابِع next() للقيم التالية. إليك ورقة صغيرة تغشّ منها: المُكرّرات المُكرّرات غير المتزامنة تابِع الكائن الذي يُقدّم المُكرّر Symbol.iterator Symbol.asyncIterator قيمة next() المُعادة هي أيّ قيمة وعد Promise to loop, use for..of for await..of تحذير: "لا يعمل معامل البقية ... بطريقة غير متزامنة" الميزات التي تتطلب تكرارات منتظمة ومتزامنة، لا تعمل مع تلك المتزامنة. على سبيل المثال، لن يعمل معامل البقية هنا: alert( [...range] ); // Error, no Symbol.iterator هذا أمر طبيعي، لأنه يتوقع العثور على Symbol.iterator، مثل for..of بدون await. ولكن ليس Symbol.asyncIterator. المولدات غير المتزامنة كما نعلم فلغة جافاسكربت تدعم المولّدات أيضًا، وهذه المولّدات مُتكرّرة. فلنتذكّر مولّد السلاسل من الفصل المولِّداتكان يولّد سلسلة من القيم من متغير البداية start إلى متغير النهاية end: function* generateSequence(start, end) { for (let i = start; i <= end; i++) { yield i; } } for(let value of generateSequence(1, 5)) { alert(value); // 1, then 2, then 3, then 4, then 5 } لا يمكننا في الدوال العادية استعمال await، فعلى كلّ القيم أن تأتي بنحوٍ متزامن وليس ممكنًا وضع أيّ تأخير في حلقة for..of، ولكن، ماذا لو أردنا استعمال await في المولّد؟ مثلًا لنُرسل الطلبات عبر الشبكة؟ لا مشكلة، نضع قبله async هكذا: async function* generateSequence(start, end) { for (let i = start; i <= end; i++) { // yay, can use await! await new Promise(resolve => setTimeout(resolve, 1000)); yield i; } } (async () => { let generator = generateSequence(1, 5); for await (let value of generator) { alert(value); // 1, then 2, then 3, then 4, then 5 } })(); الآن لدينا مولّد غير متزامن، وقابل للتكرار مع for await...of. إنها حقًا بسيطة للغاية. نضيف الكلمة المفتاحية async، ويستطيع المولّد الآن استخدام await بداخله، ويعتمد على الوعود والدوالّ غير متزامنة الأخرى. وتقنيًا، الفرق الآخر للمولّد غير المتزامن هو أنّ تابِع generator.next() صار غير متزامنًا أيضًا ويُعيد الوعود. في المولّدات العادية نستعمل result = generator.next() لنأخذ القيم، بينما في المولّدات غير المتزامنة نُضيف await هكذا: result = await generator.next(); // result = {value: ..., done: true/false} المكررات غير المتزامنة كما نعلم ليكون الكائن مُتكرّرًا علينا إضافة رمز Symbol.iterator إليه. let range = { from: 1, to: 5, [Symbol.iterator]() { return <object with next to make range iterable> } } هناك أسلوب شائع هو إعادة Symbol.iterator لمولّد بدل كائن صِرف يحمل التابِع next كما المثال أعلاه. لنستذكر معًا المثال من الفصل المولِّدات: let range = { from: 1, to: 5, *[Symbol.iterator]() { // اختصارًا لـِ [Symbol.iterator]: function*() for(let value = this.from; value <= this.to; value++) { yield value; } } }; for(let value of range) { alert(value); // 1, then 2, then 3, then 4, then 5 } نرى الكائن المخصّص range هنا مُتكرّرًا، والمولّد *[Symbol.iterator] يؤدّي المنطق اللازم لسرد القيم. لو أردنا إضافة أيّ إجراءات غير متزامنة للمولّدة، فعلينا استبدال الرمز Symbol.iterator بالرمز Symbol.asyncIterator غير المتزامن. let range = { from: 1, to: 5, async *[Symbol.asyncIterator]() { // مشابه لـِ [Symbol.asyncIterator]: async function*() for(let value = this.from; value <= this.to; value++) { // توقف بين القيم لانتظار شيء ما await new Promise(resolve => setTimeout(resolve, 1000)); yield value; } } }; (async () => { for await (let value of range) { alert(value); // 1, then 2, then 3, then 4, then 5 } })(); الآن تأتينا القيم متأخرة عن بعضها البعض ثانيةً واحدة. مثال من الحياة العملية حتى اللحظة كانت الأمثلة كلّها بسيطة، لنفهم القصة فقط. الآن حان الوقت لنُطالع مثالًا وحالةً من الواقع. نرى على الإنترنت خدمات كثيرة تقدّم لنا البيانات على صفحات (paginated). فمثلًا حين نطلب قائمة من المستخدمين يُعيد الطلب عددًا تحدّد مسبقًا (مثلًا 100 مستخدم)، أي ”صفحة واحدة“، ويعطينا أيضًا عنوان الصفحة التالية. هذا النمط شائع جدًا بين المطوّرين، وليس مستخدمًا للمستخدمين فقط بل لكلّ شيء. فمثلًا غِتهَب تتيح لك جلب الإيداعات بنفس الطريقة باستعمال الصفحات: عليك إرسال طلب إلى المسار https://api.github.com/repos//commits. يستجيب الخادوم بكائن JSON فيه 30 إيداعًا، ويُعطيك رابطًا يوصلك إلى الصفحة التالية في ترويسة Link. بعدها نستعمل ذلك الرابط للطلبات التالية لنجلب إيداعات أكثر، وهكذا. ولكن ما نريد هو واجهة برمجية أبسط قليلًا: أي كائنًا مُتكرّرًا فيه الإيداعات كي نمرّ عليها بهذا النحو: let repo = 'javascript-tutorial/en.javascript.info'; // المستودع الذي فيه الإيداعات التي نريد على غِتهَب for await (let commit of fetchCommits(repo)) { // نُعالج كلّ إيداع commit } ونريد كتابة الدالة fetchCommits(repo) لتجلب تلك الإيداعات لنا وتؤدّي الطلبات اللازمة متى… لزم. كما ونترك في عهدتها مهمة الصفحات كاملةً. ما سنفعله من جهتنا هو أمر بسيط، for await..of فقط. باستعمال المولّدات غير المتزامنة فهذه المهمّة تصير سهلة: async function* fetchCommits(repo) { let url = `https://api.github.com/repos/${repo}/commits`; while (url) { const response = await fetch(url, { // (1) headers: {'User-Agent': 'Our script'}, }); const body = await response.json(); // (2) // (3) let nextPage = response.headers.get('Link').match(/<(.*?)>; rel="next"/); nextPage = nextPage && nextPage[1]; url = nextPage; for(let commit of body) { // (4) yield commit; } } } نستعمل تابِع fetch من المتصفّح لتنزيل العنوان البعيد. يُتيح لنا التابِع تقديم تصاريح الاستيثاق وغيرها من ترويسات مطلوبة. غِتهَب يطلب User-Agent. نحلّل نتيجة الجلب على أنّها كائن JSON، وهذا تابِع آخر خاصّ بالتابِع fetch. نستلم هكذا عنوان الصفحة التالية من ترويسة Link داخل الردّ. لهذا العنوان تنسيق خاص فنستعمل تعبيرًا نمطيًا لتحليله. يظهر عنوان الصفحة التالية على هذا الشكل: https://api.github.com/repositories/93253246/commits?page=2، وموقع غِتهَب هو من يولّده بنفسه. بعدها نُنتِج كلّ الإيداعات التي استلمناها، ومتى انتهت مثال على طريقة الاستعمال (تعرض من كتبَ الإيداعات في الطرفيّة): (async () => { let count = 0; for await (const commit of fetchCommits('javascript-tutorial/en.javascript.info')) { console.log(commit.author.login); if (++count == 100) { // لنتوقف عند 100 إيداع break; } } })(); كما نريد تمامًا. الطريقة التي تعمل بها الطلبات بهذا النوع لا تظهر للشيفرات الخارجية وتبقى تفاصيل داخلية. المهم لنا هي استعمال مولّد غير متزامن يُعيد الإيداعات. خلاصة تعمل المُكرّرات والمولّدات العادية كما نريد لو لم تأخذ البيانات التي نحتاج وقتًا حتّى تتولّد. وحين نتوقّع بأنّ البيانات ستأتي بنحوٍ غير متزامن بينها انقطاعات، فيمكن استعمال البدائل غير المتزامنة مثل for await..of بدل for..of. الفرق في الصياغة بين المُكرّرات العادية وغير المتزامنة: المتكرر المتكرر غير المتزامن التابِع الذي يُقدّم المُكرّر Symbol.iterator Symbol.asyncIterator قيمة next() المُعادة هي {value:…, done: true/false} Promise والّتي تعوض لتصبح {value:…, done: true/false} الفرق في الصياغة بين المولّدات العادية وغير المتزامنة: المولدات المولدات غير المتزامنة التعريف function* async function* next() القيمة المُعادة هي {value:…, done: true/false} Promise والّتي تعوض لتصبح {value:…, done: true/false} غالبًا ونحن نعمل على تطوير الوِب نواجه سيولًا كبيرة من البيانات، سيولًا تأتينا قطعًا قطعًا (مثل تنزيل أو رفع الملفات الكبيرة). يمكننا هنا استعمال المولّدات غير المتزامنة لمُعالجة هذه البيانات. كما يجدر بنا الملاحظة كيف أنّ لبعض البيئات (مثل المتصفّحات) واجهات برمجة أخرى باسم السيول (Stream) وهي تقدّم لنا واجهات خاصّة للعمل مع السيول هذه، ذلك لتحويل شكل البيانات ونقلها من سيل إلى آخر (مثل التنزيل من مكان وإرسال ذلك التنزيل إلى مكان آخر مباشرةً). ترجمة -وبتصرف- للفصل Async iterators and generators من كتاب The JavaScript language 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; }
-
تُعيد الدوالّ العادية قيمة واحدة فقط لا غير (أو لا تُعيد شيئًا). بينما يمكن للمولّدات إعادة (أو إنتاج yeild) أكثر من قيمة واحدةً بعد الأخرى حسب الطلب. تعمل المولّدات عملًا جميلًا جدًا مع الكائنات المكرَّرة (Iterables) في جافاسكربت وتتيح لنا إنشاء سيول البيانات بسهولة بالغة. الدوال المولدة لإنشاء مولّد علينا استعمال صياغة مميّزة: function* أو ما يسمّونه ”الدالة المولِّدة“. هذا شكلها: function* generateSequence() { yield 1; yield 2; return 3; } يختلف سلوك الدوال المولِّدة عن تلك العادية، فحين تُستدعى الدالة لا تُشغّل الشيفرة فيها، بل تُعيد كائنًا مميزًا نسمّيه ”كائن المولّد“ ليُدير عملية التنفيذ. خُذ نظرة: function* generateSequence() { yield 1; yield 2; return 3; } // تنشئ الدالة المولِّدة كائن مولّد let generator = generateSequence(); alert(generator); // [object Generator] الشيفرة الموجودة بداخل الدالّة لم تُنفذ بعد: التابِع الأساسي للمولّد هو next(). متى استدعيناه بدأ عملية التنفيذ حتّى يصل أقرب جملة yield <value> (يمكن ألّا نكتب value وستصير القيمة undefined). بعدها يتوقّف تنفيذ الدالة مؤقّتًا وتُعاد القيمة value إلى الشيفرة الخارجية. ناتج التابِع next() لا يكون إلّا كائنًا له خاصيتين: value: القيمة التي أنتجها المولّد. done: القيمة true لو اكتملت شيفرة الدالة، وإلّا false. فمثلًا هنا نُنشِئ مولّدًا ونأخذ أوّل قيمة أنتجها: function* generateSequence() { yield 1; yield 2; return 3; } let generator = generateSequence(); let one = generator.next(); // هنا alert(JSON.stringify(one)); // {value: 1, done: false} حاليًا أخذنا القيمة الأولى فقط، وسير تنفيذ الدالة موجود في السطر الثاني: فلنستدعِ generator.next() ثانيةً الآن. سنراه واصل تنفيذ الشيفرة وأعاد القيمة المُنتَجة yield التالية: let two = generator.next(); alert(JSON.stringify(two)); // {value: 2, done: false} والآن إن شغّلناه مرّة ثالثة سيصل سير التنفيذ إلى عبارة return ويُنهي الدالة: let three = generator.next(); alert(JSON.stringify(three)); // {value: 3, done: true} // لاحِظ قيمة done الآن اكتمل المولّد بقيمة value:3 ويمكننا الآن معالجتها. عرفنا ذلك من done:true. الاستدعاءات اللاحقة على generator.next() لن تكون منطقية الآن. ولو حصلت فستُعيد الدالة الكائن نفسه: {done: true}. ملاحظة: الصياغة function* f(…) أم function *f(…)؟ في الحقيقة كِلاهما صحيح. ولكن عادةً تكون الصياغة الأولى مفضلة أكثر من الثانية. وتشير النجمة * على أنها دالة مولّد، إذ تصف النوع وليس الاسم، لذلك يجب أن ندمج الكلمة المفتاحية function بالنجمة. المولدات قابلة للتكرار نفترض أنّك توقّعت ذلك حين رأيت التابِع next()، إذ أن المولدات قابلة للتكرار iterable، فيمكننا المرور على عناصره عبر for..of: function* generateSequence() { yield 1; yield 2; return 3; } let generator = generateSequence(); for(let value of generator) { alert(value); // 1 ثمّ 2 } هذا أجمل من استدعاء .next().value، أم لا؟ ولكن… لاحِظ: يعرض المثال أعلاه 1 ثمّ 2 وفقط. لن يعرض 3 مطلقًا! هذا لأنّ عملية التكرار لـِ for..of تتجاهل قيمة value الأخيرة حين تكون done: true. لذا لو أردنا أن تظهر النتائج كلّها لعملية تكرار for..of، فعلينا إعادتها باستعمال yield: function* generateSequence() { yield 1; yield 2; yield 3; // هكذا } let generator = generateSequence(); for(let value of generator) { alert(value); // 1 ثمّ 2 ثمّ 3 } نظرًا من كون المولّدات قابلة للتكرار، يمكننا استدعاء جميع الدوالّ المتعلّقة بذلك، مثل: معامل «البقية» ...: function* generateSequence() { yield 1; yield 2; yield 3; } let sequence = [0, ...generateSequence()]; alert(sequence); // 0, 1, 2, 3 يحول التابع ...generateSequence() في الشيفرة أعلاه كائن المولد القابل للتكرار إلى مصفوفة من العناصر (لمزيد من المعلومات أحيلك إلى هذا المقال "المُعاملات «البقية» ومُعامل التوزيع"). استعمال المولدات على أنها مكرّرات سابقًا في فصل "المُعاملات «البقية» ومُعامل التوزيع" أنشأنا كائن range يمكن تكراره والذي يعيد القيم بين قيميتين from..to. لنتذكّر الشيفرة معًا: let range = { from: 1, to: 5, // تستدعي حلقة for..of هذه الدالّة مرة واحدة في البداية [Symbol.iterator]() { // ...ستعيد كائن مُكرُر // لاحقًا ستعمل حلقة for..of مع ذلك الكائن وتطلب منه القيم التالية return { current: this.from, last: this.to, // تستدعى next() في كلّ تكرار من خلال الحلقة for..of next() { // يجب أن تعيد القيم ككائن {done:.., value :...} if (this.current <= this.last) { return { done: false, value: this.current++ }; } else { return { done: true }; } } }; } }; // عملية التكرار تمرُّ عبر range من range.from إلى range.to alert([...range]); // 1,2,3,4,5 يمكننا استعمال دالة المولِّدة كمكرّرات من خلال Symbol.iterator. إليك نفس الكائن range, ولكن بطريقة أكثر إيجازًا: let range = { from: 1, to: 5, *[Symbol.iterator]() { // اختصارًا لـِ [Symbol.iterator]: function*() for(let value = this.from; value <= this.to; value++) { yield value; } } }; alert( [...range] ); // 1,2,3,4,5 الشيفرة تعمل إذ يُعيد range[Symbol.iterator]() الآن مولّدًا، وما تتوقّعه for..of هي توابِع تلك المولّدات بعينها: إذ لها التابِع .next() وتُعيد القيم على النحو الآتي {value: ..., done: true/false} بالطبع هذه ليست مصادفة أضيفت المولّدات إلى لغة جافاسكربت مع الأخذ بعين الاعتبار للمكرّرات لتنفيذهم بسهولة. إن التنوع في المولدات أعطى شيفرة موجزة أكثر الشيفرة الأصلية لكائن range، وجميعهم لديهم نفس الخصائص الوظيفية. ملاحظة: المولّدات يمكن أن تولد قيمًا للأبد في الأمثلة أعلاه أنشأنا متتالية منتهية من القيم، ولكن باستخدام المولد نستطيع أن ننتج قيمًا إلى الأبد. على سبيل المثال لننشئ متتالية غير منتهية من الأرقام العشوائية الزائفة. ومن المؤكد أن هذا المولّد سيحتاجُ إلى طريقة لإيقافه مثل: break (أو return) في حلقة for..of. وإلا فإن الحلقة ستستمر إلى الأبد. تراكب المولّدات تراكب المولّدات (Generator composition) هي ميزة خاصّة للمولّدات تتيح لها ”تضمين“ المولّدات الأخرى فيها دون عناء. فمثلًا لدينا هذه الدالة التي تُولّد سلسلة من الأعداد: function* generateSequence(start, end) { for (let i = start; i <= end; i++) yield i; } نريد الآن إعادة استعمالها لتوليد سلسلة أعداد معقّدة أكثر من هذه أعلاه: أولًا الأرقام 0..9 (ورموز المحارف اليونيكوديّة هو من 48 إلى 57) ثمّ الأحرف الأبجدية الإنجليزية بالحالة الكبيرة A..Z (ورموزها من 65 إلى 90) ثمّ الأحرف الأبجدية الإنجليزية بالحالة الصغيرة a..z (ورموزها من 97 إلى 122) يمكننا استعمال هذه السلسلة لإنشاء كلمات السرّ باختيار المحارف منها مثلًا (ويمكننا إضافة المحارف الخاصّة أيضًا). ولكن لذلك علينا توليدها أولًا. علينا في الدوالّ العادية (لتضمين النواتج من دوالّ أخرى) استدعاء تلك الدوالّ وتخزين نواتجها ومن ثمّ ربطها في نهاية الدالة الأمّ. وفي المولّدات نستعمل الصياغة المميّزة yield* لتضمين (أو تركيب) مولّدين داخل بعض. المولّد المركّب: function* generateSequence(start, end) { for (let i = start; i <= end; i++) yield i; } function* generatePasswordCodes() { // 0..9 yield* generateSequence(48, 57); // A..Z yield* generateSequence(65, 90); // a..z yield* generateSequence(97, 122); } let str = ''; for(let code of generatePasswordCodes()) { str += String.fromCharCode(code); } alert(str); // 0..9A..Za..z توجه yield* التنفيذ إلى مولّد آخر. مما يعني أن yield* gen ستكرر عبر المولّد gen وستُعيد نتائجه للخارج. أي كما او أنتجت هذه القيم بمولّد خارجي. النتيجة نفسها كما لو أننا ضمنَا الشيفرة من المولّدات المتداخلة: function* generateSequence(start, end) { for (let i = start; i <= end; i++) yield i; } function* generateAlphaNum() { // yield* generateSequence(48, 57); for (let i = 48; i <= 57; i++) yield i; // yield* generateSequence(65, 90); for (let i = 65; i <= 90; i++) yield i; // yield* generateSequence(97, 122); for (let i = 97; i <= 122; i++) yield i; } let str = ''; for(let code of generateAlphaNum()) { str += String.fromCharCode(code); } alert(str); // 0..9A..Za..z تراكب المولّدات هو فعلًا طريقة بديهيّة لإدخال أكثر من مولّد في مولّد واحد، ولا تستعمل ذاكرة إضافية لتخزين النواتج البينيّة. عبارة ”yield“ باتجاهين اثنين إلى هنا نرى المولّدات تشبه كائنات المكرّرات كثيرًا، فقط أنّ لها صياغة مميّزة لتوليد القيم. ولكن وعلى أرض الواقع، المولّدات أكثر مرونة وفاعليّة. إذ أنّ yield تعمل بالمجيء وبالإياب: فلا تُعيد الناتج إلى الخارج فحسب بل أيضًا يمكنها تمرير القيمة إلى داخل المولّد. لذلك علينا استدعاء generator.next(arg) بتمرير وسيط واحد. هذا الوسيط سيكون ناتج yield. الأفضل لو نرى مثالًا: function* gen() { // نمرّر سؤالًا إلى الشيفرة الخارجية وننتظر إجابةً عليه let result = yield "2 + 2 = ?"; // (*) alert(result); } let generator = gen(); let question = generator.next().value; // <-- تُعيد yield القيمة generator.next(4); // --> نمرّر القيمة إلى المولّد يكون الاستدعاء الأوّل للتابِع generator.next() دومًا دون تمرير أيّ وسيط. يبدأ الاستدعاء التنفيذَ ويُعيد ناتج أوّل yield "2+2=?". هنا يُوقف المولّد التنفيذ مؤقّتًا (على ذلك السطر). ثمّ (كما نرى في الصورة) يُوضع ناتج yield في متغير السؤال question في الشيفرة التي استدعت المولّد. وعند generator.next(4) يُواصل المولّد عمله ويستلم 4 ناتجًا: let result = 4. لاحِظ أنّه ليس على الشيفرة الخارجية استدعاء next(4) مباشرةً وفي الحال، بل يمكن أن تأخذ الوقت الذي تريد. سيبقى المولّد منتظرًا ولن تكون مشكلة. مثال: // نُواصل عمل المولّد بعد زمن معيّن setTimeout(() => generator.next(4), 1000); كما نرى فعلى العكس تمامًا من الدوال العادية، يمكن للمولّد ولشيفرة الاستدعاء تبادل النتائج بتمرير القيم إلى next/yield. ليتوضّح هذا أكثر سنرى مثالًا آخر فيه استدعاءات أكثر: function* gen() { let ask1 = yield "2 + 2 = ?"; alert(ask1); // 4 let ask2 = yield "3 * 3 = ?" alert(ask2); // 9 } let generator = gen(); alert( generator.next().value ); // "2 + 2 = ?" alert( generator.next(4).value ); // "3 * 3 = ?" alert( generator.next(9).done ); // true إليك صورة سير التنفيذ: استدعاء .next() الأوّل يبدأ التنفيذ، ويصل إلى أوّل عبارة yield. يُعاد الناتج إلى الشيفرة الخارجية. يمرّر استدعاء .next(4) الثاني القيمة 4 إلى المولّد ثانيةً على أنّها ناتج أوّل مُنتَج yield، ويُواصل التنفيذ. يصل التنفيذ إلى عبارة yield الثانية، وتصير هي ناتج الاستدعاء. يُمرّر next(9) الثالث القيمة 9 إلى المولّد على أنّها ناتج ثاني مُنتَج yield ويُواصل التنفيذ حتّى يصل نهاية الدالة، بذلك تكون done: true. تشبه هذه لعبة تنس الطاولة، حيث يمرّر كلّ تابِع next(value) (باستثناء الأوّل طبعًا) القيمة إلى المولّد فتصير ناتج المُنتَج yield الحالي، ومن ثمّ generator.throw يمكن للشيفرات الخارجية تمرير القيم إلى المولّدات على أنّها نواتج yield (كما لاحظنا من الأمثلة أعلاه). ويمكنها أيضًا بدء (أو رمي) خطأ أيضًا. هذا طبيعي إذ الأخطاء هي نواتج، نوعًا ما. علينا استدعاء التابِع generator.throw(err) لتمرير الأخطاء إلى عبارة yield. في هذه الحال يُرمى الخطأ err عند السطر الذي فيه yield. فمثلًا تؤدّي عبارة "2 + 2 = ?" هنا إلى خطأ: function* gen() { try { let result = yield "2 + 2 = ?"; // (1) alert("The execution does not reach here, because the exception is thrown above"); } catch(e) { alert(e); // أظهر الخطأ } } let generator = gen(); let question = generator.next().value; generator.throw(new Error("The answer is not found in my database")); // (2) الخطأ الذي رميَ في المولد عند السطر (2) يقودنا إلى الخطأ في السطر (1) مع yield. في المثال أعلاه التقطت try..catch الخطأ وثمّ عرضته. لو لم نلتقطه سيكون مآله (مآل أيّ استثناء آخر غيره) أن ”يسقط“ من المولّد إلى الشيفرة التي استدعت المولّد. هل يمكننا التقاط الخطأ في سطر شيفرة الاستدعاء الّتي تحتوي على generator.throw، (المشار إليه (2))، هكذا؟ function* generate() { let result = yield "2 + 2 = ?"; // خطأ في هذا السطر } let generator = generate(); let question = generator.next().value; try { generator.throw(new Error("The answer is not found in my database")); } catch(e) { alert(e); // عرض الخطأ } إن لم نلتقط الخطأ هنا فعندئذ وكما هي العادة ستنهار الشيفرة الخارجية (إن كانت موجودة) وإذا لم يكتشف الخطأ أيضًا عندها سينهار السكربت بالكامل. خلاصة تُنشِئ الدوال المولِّدة function* f(…) {…} المولّدات. يوجد المُعامل yield داخل المولدات (فقط). تتبادل الشيفرة الخارجية مع المولدات النتائج من خلال استدعاءات next/yield. نادرًا ما تستخدم المولّدات في الإصدار الحديث من جافاسكربت. لكن في بعض الأحيان نستخدمها لتبادل البيانات بين الشيفرة المستدعاة أثناء تنفيذ شيفرة وحيدة. وبالتأكيد إنها رائعة لتوليد أشياء قابلة للتكرار. في الفصل التالي، سنتعرف على مولدات غير متزامنة، والّتي تُستخدم لقراءة تدفقات البيانات غير المتزامنة (على سبيل المثال ، نرى على الإنترنت خدمات كثيرة تقدّم لنا البيانات على صفحات [paginated]) في حلقات for await ... of. في برمجة الوِب، غالبًا ما نعمل مع البيانات المتدفقة، لذا فهذه حالة استخدام أخرى مهمة جدًا. تمارين مولد أرقام شبه عشوائية نواجه كثيرًا من الأحيان حاجة ماسّة إلى بيانات عشوائية. إحدى هذه الأحيان هي إجراء الاختبارات، فنحتاج إلى بيانات عشوائية كانت نصوص أو أعداد أو أيّ شيء آخر لاختبار الشيفرات والبنى البرمجية. يمكننا في جافاسكربت استعمال Math.random()، ولكن لو حدث خطب ما فنودّ إعادة إجراء الاختبار باستعمال نفس البيانات بالضبط (كي نختبر هذه البيانات). لهذا الغرض نستعمل ما يسمّى ”بمولّدات الأعداد شبه العشوائية المزروعة“. تأخذ هذه المولّدات "البذرة“ والقيمة الأولى ومن ثمّ تولّد القيم اللاحقة باستعمال معادلة رياضية بحيث أنّ كلّ بذرة تُنتج نفس السلسلة دائمًا، وهكذا يمكننا ببساطة استنساخ التدفق بالكامل من خلال بذورها فقط. إليك مثال عن هذه المعادلة التي تولّد قيمًا موزّعة توزيعًا next = previous * 16807 % 2147483647 لو استعملنا 1 …، فستكون القيم كالآتي: 16807 282475249 1622650073 …وهكذا… مهمّة هذا التمرين هو إنشاء الدالة المولِّدة pseudoRandom(seed) فتأخذ البذرة seed وتُنشِئ مولّدًا بالمعادلة أعلاه. طريقة الاستعمال: let generator = pseudoRandom(1); alert(generator.next().value); // 16807 alert(generator.next().value); // 282475249 alert(generator.next().value); // 1622650073 الحل function* pseudoRandom(seed) { let value = seed; while(true) { value = value * 16807 % 2147483647 yield value; } }; let generator = pseudoRandom(1); alert(generator.next().value); // 16807 alert(generator.next().value); // 282475249 alert(generator.next().value); // 1622650073 لاحِظ أننا نستطيع تأدية ذات الأمر بالدوال العادية هكذا: function pseudoRandom(seed) { let value = seed; return function() { value = value * 16807 % 2147483647; return value; } } let generator = pseudoRandom(1); alert(generator()); // 16807 alert(generator()); // 282475249 alert(generator()); // 1622650073 ستعمل هذه أيضًا. ولكن بعد ذلك سنفقدُ قابلية التكرار باستخدام for..of واستخدام المولّد المركب، وتلك ممكن أن تكون مفيدة في مكان آخر. ترجمة -وبتصرف- للفصل Generators من كتاب The JavaScript language
-
توجد صياغة مميّزة للعمل مع الوعود بنحوٍ أكثر سهولة تُدعى async/await. فهمها أسهل من شرب الماء واستعمالها الدوال غير المتزامنة فلنبدأ أولًا بكلمة async المفتاحية. يمكننا وضعها قبل الدوال هكذا: async function f() { return 1; } وجود الكلمة ”async“ قبل (اختصار ”غير متزامنة“ بالإنجليزية) يعني أمرًا واحدًا: تُعيد الدالة وعدًا دومًا. فمثلًا تُعيد هذه الدالة وعدًا مُنجز فيه ناتج 1. فلنرى: async function f() { return 1; } f().then(alert); // 1 كما يمكننا أيضًا إعادة وعد صراحةً: async function f() { return Promise.resolve(1); } f().then(alert); // 1 هكذا تضمن لنا async بأنّ الدالة ستُعيد وعدًا وستُغلّف الأشياء التي ليست بوعود وعودًا. بسيطة صح؟ ليس هذا فحسب، بل هناك أيضًا الكلمة المفتاحية await التي تعمل فقط في الدوال غير المتزامنة async، وهي الأخرى جميلة. Await الصياغة: // لا تعمل إلّا في الدوال غير المتزامنة let value = await promise; الكلمة المفتاحية تجعل لغة جافاسكربت تنتظر حتى يُنجز الوعد ويعيد نتيجة. إليك مثالًا لوعد نُفذّ خلال ثانية واحدة: async function f() { let promise = new Promise((resolve, reject) => { setTimeout(() => resolve("done!"), 1000) // تم! }); let result = await promise; // ننتظر ... الوعد (*) alert(result); // "done!" تم! } f(); يتوقّف تنفيذ الدالة ”قليلًا“ عند السطر (*) ويتواصل متى ما أُنجز الوعد وصار result ناتجه. الشيفرة أعلاه تعرض ”تم!“ بعد ثانية واحدة. لنوضح أمرًا مهمًا: تجعل await لغة جافاسكربت تنتظر حتى يُنجز الوعد، وبعدها تذهب مع النتيجة. هذا لن يكلفنا أي موارد من المعالج لأن المحرك مُنشغل بمهام أخرى في الوقت نفسه، مثل: تنفيذ سكربتات أُخرى، التعامل مع الأحداث ..إلخ. هي مجرد صياغة أنيقة أكثر من صياغة promise.then للحصول على ناتج الوعد. كما أنها أسهل للقراءة والكتابة.. تحذير: لا يمكننا استخدام await في الدوال العادية إذا حاولنا استخدام الكلمة المفتاحية await في الدوال العادية فسيظهر خطأ في في الصياغة: function f() { let promise = Promise.resolve(1); let result = await promise; // Syntax error } سنحصل على هذا الخطأ إذا لم نضع async قبل الدالّة، كما قلنا await تعمل فقط في الدوالّ غير المتزامنة. لنأخذ مثال showAvatar() من الفصل سَلسلة الوعود ونُعيد كتابته باستعمال async/await: علينا استبدال استدعاءات .then ووضع await. علينا أيضًا تحويل الدالة لتكون غير متزامنة async كي تعمل await. async function showAvatar() { // إقرأ ملفات JSON let response = await fetch('/article/promise-chaining/user.json'); let user = await response.json(); // إقرأ مستخدم github let githubResponse = await fetch(`https://api.github.com/users/${user.name}`); let githubUser = await githubResponse.json(); // أظهرالصورة الرمزية avatar let img = document.createElement('img'); img.src = githubUser.avatar_url; img.className = "promise-avatar-example"; document.body.append(img); // انتظر 3 ثواني await new Promise((resolve, reject) => setTimeout(resolve, 3000)); img.remove(); return githubUser; } showAvatar(); شيفرة نظيفة وسهلة القراءة! أفضل من السابقة بأميال. ملاحظة: لن تعمل await في الشيفرة ذات المستوى الأعلى يميل المبتدئين إلى نسيان أن await لن تعمل في الشيفرة البرمجية ذات هرمية أعلى(ذات المستوى الأعلى). فمثلًا لن يعمل هذا: // خطأ صياغيّ في الشيفرة عالية المستوى let response = await fetch('/article/promise-chaining/user.json'); let user = await response.json(); يمكننا تغليفها بداخل دالّة متزامنة مجهولة، هكذا: (async () => { let response = await fetch('/article/promise-chaining/user.json'); let user = await response.json(); ... })(); ملاحظة: إن await تقبل "thenables" تسمح await باستخدام كائنات thenable (تلك التي تستخدم دالة then القابلة للاستدعاء) تمامًا مثل promise.then. الفكرة أنه يمكن ألا يكون الكائن الخارجي وعدًا ولكنه متوافق مع الوعد: إن كان يدعم .then، وهذا يكفي لاستخدامه مع await. هنا مثال لاستخدام صنف Thenable والكلمة المفتاحية await أدناه ستقبلُ حالاتها: class Thenable { constructor(num) { this.num = num; } then(resolve, reject) { alert(resolve); // نفذ عملية this.num*2 بعد ثانية واحدة setTimeout(() => resolve(this.num * 2), 1000); // (*) } }; async function f() { // انتظر لثانية واحدة. ثم النتيجة ستصبح 2 let result = await new Thenable(1); alert(result); } f(); إذا حصَلت await على كائن ليس وعدًا مع .then فإنه يستدعي الدوالّ المضمنة في اللغة مثل: resolve و reject كوسطاء (كما يفعل المنفذّ للوعد العادي تمامًا). وثم تنتظر await حتى يستدعى أحدهم (في المثال أعلاه في السطر (*)) ثم يواصل مع النتيجة. ملاحظة: لتعريف دالّة صنف غير متزامنة إضفها مع الكلمة async: class Waiter { async wait() { return await Promise.resolve(1); } } new Waiter() .wait() .then(alert); // 1 لاحظ أن المعنى هو نفسه: فهو يضمن أن القيمة المرتجعة هي وعد وawait مفعّلة أيضًا. التعامل مع الأخطاء لو نُفذّ الوعد بنجاح فسيُعيد await promise الناتج، ولكن لو حصلت حالة رفض فسترمي الخطأ كما لو كانت إفادة throw مكتوبة. إليك الشيفرة: async function f() { await Promise.reject(new Error("Whoops!")); // لاحظ } و… هي ذاتها هذه: async function f() { throw new Error("Whoops!"); // هنا } في الواقع تأخذ الوعود وقتًا قبل أن تُرفض. في تلك لحالة فستكون هناك مهلة قبل أن يرمي await الخطأ. يمكننا التقاط ذاك الخطأ باستعمال try..catch كما استعملنا throw: async function f() { try { let response = await fetch('http://no-such-url'); } catch(err) { alert(err); // TypeError: failed to fetch خطأ في النوع: فشل الجلب } } f(); لو حدث خطأ فينتقل سير التنفيذ إلى كتلة catch. يمكننا أيضًا وضع أكثر من سطر واحد: async function f() { try { let response = await fetch('/no-user-here'); let user = await response.json(); } catch(err) { // تلتقط أخطاء fetch وresponse.json معًا alert(err); } } f(); لو لم نضع try..catch فستكون حالة الوعد الذي نفّذه استدعاء الدالة غير المتزامنة f() - يكون بحالة رفض. يمكننا هنا استعمال .catch للتعامل معه: async function f() { let response = await fetch('http://no-such-url'); } // يصير استدعاء f() وعدًا مرفوضًا f().catch(alert); // TypeError: failed to fetch خطأ في النوع: فشل الجلب (*) لو نسينا هنا إضافة .catch فسنتلقّى خطأ وعود لم نتعامل معه (يمكن أن نراه من الطرفية). يمكننا أيضًا استلام هذه الأخطاء باستعمال دالة مُعاملة الأحداث العمومية كما وضّحنا في الفصل …. ملاحظة: "async/await وpromise.then/catch" عندما نستخدم async/await نادرًا ما نحتاج إلى .then وذلك لأن await تعالج عملية الانتظار. ويمكننا استخدام try..catch بدلًا من .catch. وهذه عادةً (ليس دائمًا) ما تكون أكثر ملاءمة. ولكن في الشيفرات ذات هرمية أعلى (مستوى أعلى)، عندما نكون خارج أي دالّة غير متزامنة، يتعذر علينا استخدام await لذا من المعتاد إضافة .then/catch لمعالجة النتيجة النهائية أو الأخطاء المتساقطة. كما هو الحال في السطر (*) من المثال أعلاه. ملاحظة: "تعمل async/await مع Promise.all" عندما نحتاج لانتظار عدة وعود يمكننا أن نغلفها في تابع Promise.all وبعده await: // انتظر مصفوفة النتائج let results = await Promise.all([ fetch(url1), fetch(url2), ... ]); وإن حدث خطأ ما، فإنه سيُنقل من الوعد نفسه إلى التابع Promise.all، وبعدها يصبح استثناءً يمكننا التقاطه باستخدام try..catch حول الاستدعاء. خلاصة لكلمة async المفتاحية قبل الدوال تأثيرين اثنين: تحوّلها لتُعيد وعدًا دومًا. تتيح استعمال await فيها. حين يرى محرّك جافاسكربت الكلمة المفتاحية await قبل الوعود، ينتظر حتّى يُنجز الوعد ومن ثمّ: لو كان خطأ فسيُولِّد الاستثناء كما لو استعملنا throw error. وإلّا أعاد الناتج. تقدّم لنا هتين الكلمتين معًا إطار عمل رائع نكتب به شيفرات غير متزامنة تسهل علينا قراءتها كما وكتابتها. نادرًا ما نستعمل promise.then/catch بوجود async/await، ولكن علينا ألّا ننسى بأنّ الأخيرتين مبنيّتين على الوعود إذ نضطر أحيانًا (خارج الدوال مثلًا) استعمال promise.then/catch. كما وأنّ Promise.all جميل جدًا لننتظر أكثر من مهمّة في وقت واحد. تمارين إعادة الكتابة باستعمال async/await أعِد كتابة الشيفرة في المثال من الفصل سلسلة الوعود باستعمال async/await بدل .then/catch: function loadJson(url) { return fetch(url) .then(response => { if (response.status == 200) { return response.json(); } else { throw new Error(response.status); } }) } loadJson('no-such-user.json') // (3) .catch(alert); // Error: 404 الحل ترى الملاحظات أسفل الشيفرة: async function loadJson(url) { // (1) let response = await fetch(url); // (2) if (response.status == 200) { let json = await response.json(); // (3) return json; } throw new Error(response.status); } loadJson('no-such-user.json') .catch(alert); // Error: 404 (4) ملاحظات: الدالّة loadJson تصبح async. جميع المحتوى في .then يستبدل بـِ await. يمكننا إعادة return response.json() بدلًا من انتظارها. هكذا: if (response.status == 200) { return response.json(); // (3) } ثم الشيفرة الخارجية ستنتظر await لينفذ الوعد في حالتنا الأمر غير مهم. سيرمى الخطأ من التابع loadJson المعالج من قبل .catch. لن نستطيع استخدام await loadJson(…) هنا، وذلك لأننا لسنا في دالّة غير متزامنة. أعِد كتابة rethrow باستعمال async/await في الشيفرة أدناه مثلًا عن إعادة الرمي من فصل سلسلة الوعود. أعد كتابته باستخدام async/await بدلًا من .then/catch. وتخلص من العودية لصالح الحلقة في demoGithubUser: مع استخدام async/await سيسهلُ الأمر. class HttpError extends Error { constructor(response) { super(`${response.status} for ${response.url}`); this.name = 'HttpError'; this.response = response; } } function loadJson(url) { return fetch(url) .then(response => { if (response.status == 200) { return response.json(); } else { throw new HttpError(response); } }) } // اطلب اسم المستخدم إلى أن يعيد لك github مستخدم صحيح function demoGithubUser() { let name = prompt("Enter a name?", "iliakan"); return loadJson(`https://api.github.com/users/${name}`) .then(user => { alert(`Full name: ${user.name}.`); return user; }) .catch(err => { if (err instanceof HttpError && err.response.status == 404) { alert("No such user, please reenter."); return demoGithubUser(); } else { throw err; } }); } demoGithubUser(); الحل لا يوجد أي صعوبة هنا، إذ كل ما عليك فعله هو استبدال try...catch بدلًا من .catch بداخل تابع demoGithubUser وأضف async/await عند الحاجة: class HttpError extends Error { constructor(response) { super(`${response.status} for ${response.url}`); this.name = 'HttpError'; this.response = response; } } async function loadJson(url) { let response = await fetch(url); if (response.status == 200) { return response.json(); } else { throw new HttpError(response); } } // اطلب اسم المستخدم إلى أن يعيد لك github مستخدم صحيح async function demoGithubUser() { let user; while(true) { let name = prompt("Enter a name?", "iliakan"); try { user = await loadJson(`https://api.github.com/users/${name}`); break; // لا يوجد خطأ اخرج من الحلقة } catch(err) { if (err instanceof HttpError && err.response.status == 404) { // تستمر الحلقة بعد alert alert("No such user, please reenter."); } else { // خطأ غير معروف , أعد رميه rethrow throw err; } } } alert(`Full name: ${user.name}.`); return user; } demoGithubUser(); استدعاء async من دالة غير متزامنة لدينا دالة ”عادية“، ونريد استدعاء async منها واستعمال ناتجها، كيف؟ async function wait() { await new Promise(resolve => setTimeout(resolve, 1000)); return 10; } function f() { // ...ماذا نكتب هنا؟ // علينا استدعاء async wait() والانتظار حتّى تأتي 10 // لا تنسَ، لا يمكن استعمال ”await“ هنا } ملاحظة: هذه المهمّة (تقنيًا) بسيطة جدًا، ولكنّ السؤال شائع بين عموم المطوّرين الجدد على async/await. الحل هنا تأتي فائدة معرفة طريقة عمل هذه الأمور. ليس عليك إلّا معاملة استدعاء async وكأنّه وعد ووضع تابِع .then: async function wait() { await new Promise(resolve => setTimeout(resolve, 1000)); return 10; } function f() { // يعرض 10 بعد ثانية واحدة wait().then(result => alert(result)); } f(); ترجمة -وبتصرف- للفصل Async/await من كتاب The JavaScript language
-
دوال المُعاملة للوعود .then و .catch و.finally هي دوال غير متزامنة، دومًا. فحتّى لو سويَ الوعد مباشرةً (أي سواءً أنُجز أو رُفض) فالشيفرة أسفل .then و.catch و.finally ستُنفّذ حتّى قبل دوال المعاملة. لاحِظ: let promise = Promise.resolve(); promise.then(() => alert("promise done!")) // اكتمل الوعد alert("code finished"); // نرى هذا النص أولًا (انتهت الشيفرة) لو شغّلته فسترى أولًا code finished وبعدها ترى promise done!. هذا… غريب إذ أن الوعد أنجز قطعًا في البداية. لماذا شُغّلت .then بعدئذ؟ ما الّذي يحدث؟ طابور المهام السريعة تطلب الدوال غير المتزامنة عملية إدارة مضبوطة. ولهذا تحدّد مواصفة اللغة طابورًا داخليًا باسم PromiseJobs (غالبًا ما نسمّيه ”بطابور المهام السريعة“ Microtask Queue حسب مصطلح محرّك V8). تقول المواصفة: الطابور بمبدأ ”أوّل من يدخل هو أوّل من يخرج“: المهام التي تُضاف أولًا تُنفّذ أولًا. لا يبدأ تنفيذ المهمة إلّا لو لم يكن هناك شيء آخر يعمل. وبعبارة أبسط، فمتى جهز الوعد تُضاف دوال المعاملة .then/catch/finally إلى الطابور، وتبقى هناك بلا تنفيذ. متى وجد محرّك جافاسكربت نفسه قد فرغ من الشيفرة الحالية، يأخذ مهمة من الطابور وينفّذها. لهذا السبب نرى ”اكتملت الشيفرة“ في المثال أعلاه أولًا. دوال معاملة الوعود تمرّ من الطابور الداخلي هذا دومًا. لو كانت في الشيفرة سلسلة من .then/catch/finally فستُنفّذ كلّ واحدة منها تنفيذًا غير متزامن. أي أنّ الأولى تُضاف إلى الطابور وتُنفّذ متى اكتمل تنفيذ الشيفرة الحالية وانتهت دوال المُعاملة الّتي أُضيفت إلى الطابور مسبقًا. ولكن ماذا لو كان الترتيب يهمّنا؟ كيف نشغّل code finished بعد promise done؟ بسيطة، نضعها في الطابور باستعمال .then: Promise.resolve() .then(() => alert("promise done!")) .then(() => alert("code finished")); هكذا صار الترتيب كما نريد. الرفض غير المعالج تذكر حدث unhandledrejection من فصل التعامل مع الأخطاء في الوعود. سنرى الآن كيف تعرف محرّكات جافاسكربت ما إن وُجدت حالة رفض لم يُتعامل معها، أم لا. تحدث ”حالة الرفض لم يُتعامل معها“ حين لا يتعامل شيء مع خطأ أنتجه وعد في آخر طابور المهام السريعة. عادةً لو كنّا نتوقّع حدوث خطأ نُضيف التابِع .catch إلى سلسلة الوعود للتعامل معه: let promise = Promise.reject(new Error("Promise Failed!")); promise.catch(err => alert('caught')); // هكذا // لا يعمل هذا السطر إذ تعاملنا مع الخطأ window.addEventListener('unhandledrejection', event => alert(event.reason)); ولكن… لو نسينا وضع .catch سيُشغّل المحرّك هذا الحدث متى فرغ طابور المهام السريعة: let promise = Promise.reject(new Error("Promise Failed!")); // Promise Failed! window.addEventListener('unhandledrejection', event => alert(event.reason)); وماذا لو تعاملنا مع الخطأ لاحقًا؟ هكذا مثلًا: let promise = Promise.reject(new Error("Promise Failed!")); setTimeout(() => promise.catch(err => alert('caught')), 1000); // لاحِظ // Error: Promise Failed! window.addEventListener('unhandledrejection', event => alert(event.reason)); إذا شغلناه الآن سنرى Promise Failed! أولًا ثم caught. لو بقينا نجهل طريقة عمل طابور المهام السريعة فسنتساءل: ”لماذا عملت دالة المُعاملة unhandledrejection؟ الخطأ والتقطناه!“. أمّا الآن فنعرف أنّه لا يُولّد unhandledrejection إلّا حين انتهاء طابور المهام السريعة: فيفحص المحرّك الوعود وإن وجد حالة ”رفض“ في واحدة، يشغّل الحدث. في المثال أعلاه، أضيفت .catch وشغّلت من قِبل setTimeout متأخرةً بعد حدوث unhandledrejection لذا فإن ذلك لم يغيّر أي شيء. خلاصة التعامل مع الوعود دومًا يكون غير متزامن، إذ تمرّ إجراءات الوعود في طابور داخلي ”لمهام الوعود“ أو ما نسمّيه ”بطابور المهام السريعة“ (مصطلح المحرّك V8). بهذا لا تُستدعى دوال المُعاملة .then/catch/finally إلّا بعد اكتمال الشيفرة الحالية. ولو أردنا أن نضمن تشغيل هذه الأسطر بعينها بعد .then/catch/finally فيمكننا إضافتها إلى استدعاء .then في السلسلة. في معظم محركات جافاسكربت، بما في ذلك المتصفحات و Node.js، يرتبط مفهوم المهام السريعة ارتباطًا وثيقًا بـ "حلقة الأحداث" والمهام الكبيرة "macrotasks". نظرًا لأنها لا تملك علاقة مباشرة بالوعود، لذا فإننا شرحناها في جزء آخر من السلسلة التعليمية، في الفصل Event loop: microtasks and macrotasks. ترجمة -وبتصرف- للفصل Microtasks من كتاب The JavaScript language
-
تحويل الدوال إلى وعود (Promisification) هي عملية تغليف الدالة التي تستلم ردّ نداء لتصبح دالة تُعيد وعدًا. وفي الحياة العملية فهذا النوع من التحويل مطلوب جدًا إذ تعتمد العديد من الدوال والمكتبات على ردود النداء. ولكن… الوعود أسهل وأفضل لذا من المنطقي تحويل تلك الدوال. لنأخذ مثلًا دالة loadScript(src, callback) من الفصل مقدمة إلى ردود النداء callback: function loadScript(src, callback) { let script = document.createElement('script'); script.src = src; script.onload = () => callback(null, script); script.onerror = () => callback(new Error(`Script load error for ${src}`)); document.head.append(script); } // الاستعمال // loadScript('path/script.js', (err, script) => {...}) هيا نحوّلها. على الدالة الجديدة loadScriptPromise(src) القيام بنفس ما تقوم به تلك، ولكن لا تقبل إلّا src وسيطًا (بدون callback) وتُعيد وعدًا. let loadScriptPromise = function(src) { return new Promise((resolve, reject) => { loadScript(src, (err, script) => { if (err) reject(err) else resolve(script); }); }) } // الاستعمال // loadScriptPromise('path/script.js').then(...) الآن يمكننا استعمال loadScriptPromise بسهولة بالغة في الشيفرات التي تعتمد الوعود. وكما نرى فالدالة تكلّف الدالة الأصلية loadScript بكلّ العمل اللازم، وما تفعله هو تقديم ردّ نداء من عندها تحوّله إلى وعد resolve/reject. نحتاج عمليًا إلى تحويل دوال عديدة وكثيرة لتعتمد الوعود، لذا من المنطقي استعمال دالة مساعِدة. لنسمّها promisify(f) وستقبل دالة الأصل f اللازم تحويلها، وتُعيد دالة غالِفة. يؤدّي الغلاف نفس عمل الشيفرة أعلاه: يُعيد وعدًا ويمرّر النداء إلى الدالة الأصلية f متتّبعًا الناتج في ردّ نداء يصنعه بنفسه: function promisify(f) { return function (...args) { // يعيد التابع المغلّف return new Promise((resolve, reject) => { function callback(err, result) { // رد النداء خاصتنا لـِ f if (err) { return reject(err); } else { resolve(result); } } args.push(callback); // نضيف رد النداء خاصتنا إلى نهاية واسطاء f f.call(this, ...args); // استدعي التابع الأصلي }); }; }; // طريقة الاستخدام: let loadScriptPromise = promisify(loadScript); loadScriptPromise(...).then(...); نفترض هنا بأنّ الدالة الأصلية تتوقّع استلام ردّ نداء له وسيطين (err, result)، وهذا ما نواجهه أغلب الوقت لهذا كتبنا ردّ النداء المخصّص بهذا التنسيق، وتعمل دالة promisify على أكمل وجه… لهذه الحالات. ولكن ماذا لو كانت تتوقّع f ردّ نداء له وسطاء أكثر callback(err, res1, res2, ...)؟ إليك نسخة promisify ذكية محسّنة: لو استدعيناها هكذا promisify(f, true) فسيكون ناتج الوعد مصفوفة من نواتج ردود النداء [res1, res2, ...]: // التابع promisify(f, true) لجب مصفوفة النتائج function promisify(f, manyArgs = false) { return function (...args) { return new Promise((resolve, reject) => { function callback(err, ...results) { // رد النداء خاصتنا لـِ f if (err) { return reject(err); } else { // إجلب جميع ردود النداء وإذا حدّد أكثر من وسيط resolve(manyArgs ? results : results[0]); } } args.push(callback); f.call(this, ...args); }); }; }; // طريقة الاستخدام: f = promisify(f, true); f(...).then(arrayOfResults => ..., err => ...) أمّا لتنسيقات ردود النداء الشاذّة (مثل التي بدون وسيط err أصلًا callback(result)) فيمكننا تحويلها يدويًا بدون استعمال الدالة المساعِدة. كما وهناك وحدات لها دوال تحويل مرنة أكثر في التعامل مثل es6-promisify. وفي Node.js نرى الدالة المضمّنة util.promisify لهذا الغرض. ملاحظة: يعدّ تحويل الدوال إلى وعود نهجًا رائعًا، خاصةً عند استخدام async/await (الّتي سنراها في الفصل التالي)، ولكن ليس بديلًا كليًا لردود النداء. تذكر أن الوعد له نتيجة واحدة فقط، ولكن تقنيًا ممكن أن تُستدعى ردود النداء عدة مرات. لذا فإن تحويل الدوال إلى وعود مخصصة للدوال التي تستدعي ردود النداء لمرة واحدة. وستُتجاهل جميع الاستدعاءات اللاحقة. ترجمة -وبتصرف- للفصل Promisification من كتاب The JavaScript language
-
ثمّة 5 توابِع ثابتة (static) في صنف الوعود Promise. سنشرح الآن عن استعمالاتها سريعًا. Promise.all لنقل بأنّك تريد تنفيذ أكثر من وعد واحد في وقت واحد، والانتظار حتّى تجهز جميعها. مثلًا أن تُنزّل أكثر من عنوان URL في آن واحد وتُعالج المحتوى ما إن تُنزّل كلها. وهذا الغرض من وجود Promise.all. إليك صياغته: let promise = Promise.all([...promises...]); يأخذ التابِع Promise.all مصفوفة من الوعود (تقنيًا يمكن أن تكون أيّ … ولكنّها في العادة مصفوفة) ويُعيد وعدًا جديدًا. لا يُحلّ الوعد الجديد إلّا حين تستقرّ الوعود في المصفوفة، وتصير تلك المصفوفة التي تحمل نواتج الوعود - تصير ناتج الوعد الجديد. مثال على ذلك تابِع Promise.all أسفله إذ يستقرّ بعد 3 ثوان ويستلم نواتجه في مصفوفة [1, 2, 3]: Promise.all([ new Promise(resolve => setTimeout(() => resolve(1), 3000)), // 1 new Promise(resolve => setTimeout(() => resolve(2), 2000)), // 2 new Promise(resolve => setTimeout(() => resolve(3), 1000)) // 3 ]).then(alert); // 1,2,3 ما إن تجهز الوعود يُعطي كلّ واحد عنصرًا في المصفوفة لاحِظ كيف أنّ ترتيب عناصر المصفوفة الناتجة يتطابق مع ترتيب الوعود، فحتّى لو أخذ الوعد الأوّل وقتًا أطول من غيره حتّى يُحلّ، فسيظلّ العنصر الأول في مصفوفة النواتج. نستعمل عادةً حيلة صغيرة أن نصنع مصفوفة جديدة بخارطة تأخذ القديمة وتحوّلها إلى وعود، ثمّ نمرّر ذلك كلّه إلى Promise.all. فمثلًا لو لدينا مصفوفة من العناوين، يمكننا جلبها كلّها هكذا: let urls = [ 'https://api.github.com/users/iliakan', 'https://api.github.com/users/remy', 'https://api.github.com/users/jeresig' ]; // نحوّل كلّ عنوان إلى وعد التابِع fetch let requests = urls.map(url => fetch(url)); // ينتظر Promise.all حتّى تُحلّ كلّ المهام Promise.all(requests) .then(responses => responses.forEach( response => alert(`${response.url}: ${response.status}`) )); من أكبر الأمثلة على جلب المعلومات. هي جلب المعلومات لمجموعة من مستخدمي موقع GitHub من خلال اسمائهم (يمكننا جلب مجموعة من السلع بحسب رقم المعرف الخاص بها، منطق الحل متشابه في كِلا الحالتين): let names = ['iliakan', 'remy', 'jeresig']; let requests = names.map(name => fetch(`https://api.github.com/users/${name}`)); Promise.all(requests) .then(responses => { // استدعيت جميع الردود بنجاح for(let response of responses) { alert(`${response.url}: ${response.status}`); // أظهر 200 من أجل كلّ عنوان url } return responses; }) // اربط مصفوفة الردود مع مصفوفة response.json() لقراءة المحتوى .then(responses => Promise.all(responses.map(r => r.json()))) // تحلل جميع الإجابات : وتكوّن مصفوفة "users" منهم .then(users => users.forEach(user => alert(user.name))); لو رُفض أيّ وعد من الوعود، سيرفض الوعد الذي يُعيده Promise.all مباشرةً بذلك الخطأ. مثال: Promise.all([ new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)), // هنا new Promise((resolve, reject) => setTimeout(() => reject(new Error("Whoops!")), 2000)), new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)) ]).catch(alert); // Error: Whoops! نرى هنا أنّ الوعد الثاني يُرفض بعد ثانيتين، ويؤدّي ذلك إلى رفض Promise.all كاملًا، وهكذا يُنفّذ التابِع .catch ويصير الخطأ ناتج Promise.all كلّه. تحذير: في حالة حدوث خطأ ما، تُتجاهل الوعود الأُخرى لو أن وعدًا ما رُفضَ، ستُرفض جميع الوعود من خلال تابع Promise.all مباشرةً، متناسيًا بذلك الوعود الأخرى في القائمة. وعندها ستُتجاهل نتائجها أيضًا. على سبيل المثال، إن كان العديد من استدعاءات fetch كما هو موضح في المثال أعلاه، وفشِل أحدها، فسيستمر تنفيذ الاستدعاءات الأخرى. ولكن لن يشاهدها تابع Promise.all بعد الآن. ربما ستُنجز بقية الوعود، ولكن نتائجها ستُتجاهل في نهاية المطاف. لن يُلغي التابع Promise.all الوعود الأخرى، إذ لا يوجد مثل هذا الفعل في الوعود، سنغطي في فصل آخر طريقة المتبعة في إلغاء الوعود وسيساعدنا التابع AbortController في ذلك، ولكنه ليس جزءًا من واجهة الوعود البرمجية. ملاحظة: يسمح التابع Promise.all(iterable) لغير الوعود (القيم النظامية) في iterable. يقبل التابع Promise.all(...) وعودًا قابلة للتكرار (مصفوفات في معظم الوقت). ولكن لو لم تكُ تلك الكائنات وعودًا. فستُمرّر النتائج كما هي. فمثلًا، النتائج هنا [1, 2, 3]: Promise.all([ new Promise((resolve, reject) => { setTimeout(() => resolve(1), 1000) }), 2, 3 ]).then(alert); // 1, 2, 3 يمكننا تمرير القيم الجاهزة لتابِع Promise.all عندما يكون ذلك مناسبًا. Promise.allSettled إضافة حديثة هذه إضافة حديثة للغة، وقد يحتاج إلى تعويض نقص الدعم في المتصفحات القديمة. لو رُفض أحد الوعود في Promise.all فسيُرفض كلّه مجتمعًا. هذا يفيدنا لو أردنا استراتيجية ”إمّا كل شيء أو لا شيء“، أي حين نطلب وجود النتائج كلها كي نواصل: Promise.all([ fetch('/template.html'), fetch('/style.css'), fetch('/data.json') ]).then(render); // تابِع التصيير يطلب نواتج كلّ توابِع الجلب بينما ينتظر Promise.allSettled استقرار كلّ الوعود. للمصفوفة الناتجة هذه العناصر: {status:"fulfilled", value:result} من أجل الاستجابات الناجحة. {status:"rejected", reason:error} من أجل الأخطاء. مثال على ذلك هو لو أردنا جلب معلومات أكثر من مستخدم واحد. فلو فشل أحد الطلبات ما زلنا نريد معرفة معلومات البقية. فلنستعمل Promise.allSettled: let urls = [ 'https://api.github.com/users/iliakan', 'https://api.github.com/users/remy', 'https://no-such-url' ]; Promise.allSettled(urls.map(url => fetch(url))) .then(results => { // (*) results.forEach((result, num) => { if (result.status == "fulfilled") { alert(`${urls[num]}: ${result.value.status}`); } if (result.status == "rejected") { alert(`${urls[num]}: ${result.reason}`); } }); }); ستكون قيمة results في السطر (*) أعلاه كالآتي: [ {status: 'fulfilled', value: ...ردّ...}, {status: 'fulfilled', value: ...ردّ...}, {status: 'rejected', reason: ...كائن خطأ...} ] هكذا نستلم لكلّ وعد حالته وقيمته أو الخطأ value/error. تعويض نقص الدعم لو لم يكن المتصفّح يدعم Promise.allSettled فمن السهل تعويض ذلك: if(!Promise.allSettled) { Promise.allSettled = function(promises) { return Promise.all(promises.map(p => Promise.resolve(p).then(value => ({ state: 'fulfilled', value }), reason => ({ state: 'rejected', reason })))); }; } في هذه الشيفرة يأخذ التابِع promises.map قيم الإدخال ويحوّلها إلى وعود (في حال وصله شيء ليس بالوعد) ذلك باستعمال p => Promise.resolve(p)، بعدها يُضيف دالة المُعاملة .then لكلّ وعد. هذه الدالة تحوّل النواتج الناجحة v إلى {state:'fulfilled', value:v}، والأخطاء r إلى {state:'rejected', reason:r}. هذا التنسيق الذي يستعمله Promise.allSettled بالضبط. يمكننا لآن استعمال Promise.allSettled لجلب نتائج كلّ الوعود الممرّرة حتّى لو رفضت بعضها. Promise.race يشبه التابِع Promise.all إلّا أنّه ينتظر استقرار وعد واحد فقط ويأخذ ناتجه (أو الخطأ). صياغته هي: let promise = Promise.race(iterable); فمثلًا سيكون الناتج هنا 1: Promise.race([ new Promise((resolve, reject) => setTimeout(() => resolve(1), 1000)), new Promise((resolve, reject) => setTimeout(() => reject(new Error("Whoops!")), 2000)), new Promise((resolve, reject) => setTimeout(() => resolve(3), 3000)) ]).then(alert); // 1 أوّل الوعود هنا كان أسرعها وهكذا صار هو الناتج. ومتى ”فاز أوّل وعد مستقرّ بالسباق“، تُهمل بقية النواتج/الأخطاء. Promise.resolve/reject نادرًا ما نستعمل Promise.resolve و Promise.reject في الشيفرات الحديثة إذ صياغة async/await (نتكلم عنها في مقال لاحق) نشرحها هنا ليكون شرحًا كاملًا، وأيضًا لمن لا يستطيع استعمال async/await لسبب أو لآخر. Promise.resolve(value) يُنشئ وعد مُنجز بالناتجvalue. ويشبه: let promise = new Promise(resolve => resolve(value)); يستخدم هذا التابع للتوافقية، عندما يُتوقع من تابع ما أن يُعيد وعدًا. فمثلًا، يجلب التابِع أدناه loadCached عنوان URL ويخزن المحتوى في الذاكرة المؤقتة. ومن أجل الاستدعاءات اللاحقة لنفس عنوان URL سيحصل على المحتوى فورًا من الذاكرة المؤقتة، ولكنه يستخدم التابع Promise.resolve لتقديم وعدًا بهذا الأمر. لتكون القيمة المرتجعة دائمًا وعدًا: let cache = new Map(); function loadCached(url) { if (cache.has(url)) { return Promise.resolve(cache.get(url)); // (*) } return fetch(url) .then(response => response.text()) .then(text => { cache.set(url,text); return text; }); } يمكننا كتابة loadCached(url).then(…) لأننا نضمن أن التابِع سيُعيد وعدًا. كما يمكننا دائمًا استخدام .then بعد تابِع loadCached. وهذا هو الغرض من Promise.resolve في السطر (*). Promise.reject Promise.reject(error) ينشئ وعد مرفوض مع خطأ. ويشبه: let promise = new Promise((resolve, reject) => reject(error)); عمليًا لا نستعمل هذا التابِع أبدًا. خلاصة لصنف الوعود Promise خمس توابِع ثابتة: Promise.all(promises) -- ينتظر جميع الوعود لتعمل ويعيد مصفوفة بنتائجهم. وإذا رُفض أي وعدٍ منهم، سيرجع Promise.all خطأً، وسيتجاهلُ جميع النتائج الأخرى. Promise.allSettled(promises) -- (التابع مضاف حديثًا) ينتظر جميع الوعود لتُنجز ليُعيد نتائجها كمصفوفة من الكائنات تحتوي على: -state: الحالة وتكون إما "fulfilled" أو "rejected". -value: القيمة (إذا تحقق الوعد) أو reason السبب (إذا رُفض). Promise.race(promises) -- ينتظر الوعد الأول ليُنجز وتكون نتيجته أو الخطأ الذي سيرميه خرج هذا التابِع. Promise.resolve(value) -- ينشئ وعدًا منجزًا مع القيمة الممرّرة. Promise.reject(error) -- ينشئ وعدًا مرفوضًا مع الخطأ المُمرّر. ومن بينها Promise.all هي الأكثر استعمالًا عمليًا. ترجمة -وبتصرف- للفصل Promise API من كتاب The JavaScript language
-
تُعدّ سلاسل الوعود ممتازة في التعامل مع الأخطاء، فمتى رُفض الوعد ينتقل سير التحكّم إلى أقرب دالة تتعامل مع حالة الرفض، وهذا عمليًا يسهّل الأمور كثيرًا. فمثلًا نرى في الشيفرة أسفله أنّ العنوان المرّر إلى fetch خطأ (ما من موقع بهذا العنوان) ويتعامل التابِع .catch مع الخطأ: fetch('https://no-such-server.blabla') // هذا الموقع من وحي الخيال العلمي .then(response => response.json()) .catch(err => alert(err)) // TypeError: failed to fetch (يمكن أن يتغيّر النصّ بتغيّر الخطأ) وكما ترى فليس ضروريًا أن يكون التابِع .catch مباشرةً في البداية، بل يمكن أن يظهر بعد تابِع .then واحد أو أكثر حتّى. أو قد يكون الموقع سليمًا ولكنّ الردّ ليس كائن JSON صالح. الطريقة الأسهل في هذه الحالة لالتقاط كلّ الأخطاء هي بإضافة تابِع .catch إلى نهاية السلسلة: fetch('/article/promise-chaining/user.json') .then(response => response.json()) .then(user => fetch(`https://api.github.com/users/${user.name}`)) .then(response => response.json()) .then(githubUser => new Promise((resolve, reject) => { let img = document.createElement('img'); img.src = githubUser.avatar_url; img.className = "promise-avatar-example"; document.body.append(img); setTimeout(() => { img.remove(); resolve(githubUser); }, 3000); })) .catch(error => alert(error.message)); // هنا الطبيعي هو ألّا يعمل تابِع .catch مطلقًا، ولكن لو رُفض أحد الوعود أعلاه (بسبب مشكلة في الشبكة أو كائن غير صالح أو أيّ شيء آخر)، فسيستلم التابِع الخطأ. صياغة try..catch الضمنية تُحيط بشيفرتي مُنفّذ الوعود ودوال مُعاملة الوعود عبارة try..catch مخفية إن صحّ القول، فإن حدث استثناء يُلتقط ويتعامل معه المحرّك على أنّه حالة رفض. خُذ مثلًا هذه الشيفرة: new Promise((resolve, reject) => { throw new Error("Whoops!"); // لاحِظ }).catch(alert); // Error: Whoops! … وهل تعمل تمامًا مثل عمل هذه: new Promise((resolve, reject) => { reject(new Error("Whoops!")); // لاحِظ }).catch(alert); // Error: Whoops! تتلقّى عبارة try..catch المخفي المُحيطة بالمُنفّذ الأخطاءَ تلقائيًا وتحوّلها إلى وعود مرفوضة. ولا يحدث هذا في دالة المُنفّذ فحسب بل أيضًا في دوال المُعاملة. فلو رمينا شيئًا في دالة المُعاملة للتابِع throw فيعني ذلك بأنّ الوعد رُفض وينتقل سير التحكّم إلى أقرب دالة تتعامل مع الأخطاء. إليك مثالًا: new Promise((resolve, reject) => { resolve("ok"); }).then((result) => { throw new Error("Whoops!"); // نرفض الوعد }).catch(alert); // Error: Whoops! وهذا يحدث مع الأخطاء كافّة وليس فقط لتلك التي رُميت بعبارة throw. حتّى أخطاء المبرمجين، انظر: new Promise((resolve, reject) => { resolve("ok"); }).then((result) => { blabla(); // ما من دالة كهذه }).catch(alert); // ReferenceError: blabla is not defined تابِع .catch الأخير لا يستلم حالات الرفض الصريحة فحسب، بل تلك التي تحدث أحيانًا في دوال المُعاملة أعلاه أيضًا. إعادة الرمي كما رأينا فوجود تابِع .catch نهاية السلسلة شبيه بعبارة try..catch. يمكن أن نكتب ما نريد من دوال مُعاملة .then وثمّ استعمال تابِع .catch واحد في النهاية للتعامل مع أخطائها. في عبارات try..catch العاديّة نحلّل الخطأ ونُعيد رميه لو لم نستطع التعامل معه. ذات الأمر مع الوعود. فإن رمينا شيئًا داخل .catch، ينتقل سير التحكّم إلى أقرب دالة تتعامل مع الأخطاء. وإن تعاملنا مع الخطأ كما يجب فيتواصل إلى أقرب دالة .then. في المثال أسفله يتعامل التابِع .catch مع الخطأ كما ينبغي: // سلسلة التنفيذ: catch -> then new Promise((resolve, reject) => { throw new Error("Whoops!"); }).catch(function(error) { alert("The error is handled, continue normally"); }).then(() => alert("Next successful handler runs")); لا تنتهي هنا كتلة .catch البرمجية بأيّ أخطاء، بذلك تُستدعى دالة المُعاملة في تابِع .then التالي. نرى في المثال أسفله الحالة الثانية للتابِع .catch. تلتقط دالة المُعاملة عند (*) الخطأ ولكن لا تعرف التعامل معه (مثلًا لا تعرف إلّا أخطاء URIError) فترميه ثانيةً: // سلسلة التنفيذ: catch → catch → then new Promise((resolve, reject) => { throw new Error("Whoops!"); }).catch(function(error) { // (*) if (error instanceof URIError) { // handle it } else { alert("Can't handle such error"); *!* throw error; // رمي هذا الخطأ أو أي خطأ آخر سينقُلنا إلى catch التالية */!* } }).then(function() { /* لن يعمل هنا */ }).catch(error => { // (**) alert(`The unknown error has occurred: ${error}`); // لن يعيد أي شيء=>عملية التنفيذ حدثت بطريقة عادية }); ينتقل سير التنفيذ من تابِع .catch الأول عند (*) إلى التالي عند (**) في السلسلة. حالات رفض ماذا يحدث لو لم نتعامل مع الخطأ؟ فنقل مثلًا نسينا إضافة تابِع .catch في نهاية السلسلة هكذا: new Promise(function() { noSuchFunction(); // خطأ (لا يوجد هذا التابع) }) .then(() => { // معالجات الوعد الناجح سواءً واحدة أو أكثر }); // بدون .catch في النهاية! لو حدث خطأ يُرفض الوعد وعلى سير التنفيذ الانتقال إلى أقرب دالة تتعامل مع حالات الرفض. ولكن ما من دالة كهذه و”يعلق“ الخطأ إن صحّ التعبير إذ ما من شيفرة تتعامل معه. عمليًا يتشابه هذا مع الأخطاء التي لم نتعامل معها في الشيفرات، أي أنّ شيئًا مريعًا قد حدث. تتذكّر ما يحدث لو حدث خطأ عادي ولم تلتقطه عبارة try..catch؟ ”يموت“ النص البرمجي ويترك رسالةً في الطرفية. ذات الأمر يحدث مع حالات رفض الوعود التي لم يجري التعامل معها. يتعقّب محرّك جافاسكربت هذه الحالات ويولّد خطأً عموميًا في هذه الحالة. يمكنك أن تراه في الطرفية إن شغلت المثال أعلاه. يمكننا في المتصفّحات التقاط هذه الأخطاء باستعمال الحدث unhandledrejection: *!* window.addEventListener('unhandledrejection', function(event) { // لدى كائن الحدث خاصيتين مميزتين: alert(event.promise); // [object Promise] - الوعد الذي يولد الخطأ alert(event.reason); // Error: Whoops! - كائن الخطأ غير المعالج }); */!* new Promise(function() { throw new Error("Whoops!"); }); // لا يوجد catch لمعالجة الخطأ إنّما الحدث هو جزء من معيار HTML. لو حدث خطأ ولم يكن هناك تابِع .catch فيُشغّل معالج unhandledrejection ويحصل على كائن الحدث مع معلومات حول الخطأ حتى نتمكن من فعل شيء ما. مثل هذه الأخطاء عادةً ما تكون غير قابلة للاسترداد، لذلك أفضل طريقة للخروج هي إعلام المستخدم بالخطأ أو حتى إبلاغ الخادم بالخطأ. ثمّة في البيئات الأخرى غير المتصفّحات (مثل Node.js) طرائقَ أخرى لتعقّب الأخطاء التي لم نتعامل معها. خلاصة يتعامل .catch مع الأخطاء في الوعود أيًا كانت: أكانت من استدعاءات reject() أو من رمي الأخطاء في دوال المُعاملة. يجب أن نضع .catch في الأماكن التي نريد أن نعالج الخطأ فيها ومعرفة كيفية التعامل معها. يجب على المعالج تحليل الأخطاء (الأصناف المخصصة تساعدنا بذلك) وإعادة رمي الأخطاء غير المعروفة (ربما تكون أخطاء برمجية). لا بأس بعدم استخدام .catch مطلقًا، إن لم يكُ هنالك طريقة للاسترداد من الخطأ. على أية حال، يجب أن يكون لدينا معالج الأحداث unhandledrejection (للمتصفحات وللبيئات الأخرى) وذلك لتتبع الأخطاء غير المُعالجة وإعلام المستخدم (وربما إعلام الخادم) عنها. حتى لا يموت تطبيقنا مطلقًا. تمارين خطأ في تابِع setTimeout ما رأيك هل ستعمل .catch؟ وضح إجابتك. new Promise(function(resolve, reject) { setTimeout(() => { throw new Error("Whoops!"); }, 1000); }).catch(alert); الجواب: لا لن تعمل: new Promise(function(resolve, reject) { setTimeout(() => { throw new Error("Whoops!"); }, 1000); }).catch(alert); كما ذكرنا في هذا الفصل لدينا "صياغة try..catch ضمنية" حول تابع معيّن. لذلك تُعالج جميع الأخطاء المتزامنة. ولكن هنا الخطأ لا يُنشأ عندما يعمل المُنفذّ وإنما في وقت لاحق. لذا فإن الوعد لن يستطيع معالجته. ترجمة -وبتصرف- للفصل Error handling with promises من كتاب The JavaScript language
-
طرحناها في الفصل "مقدمة إلى ردود النداء callbacks" مشكلةً ألا وهي أنّ لدينا تسلسلًا من المهام غير المتزامنة ويجب أن تُجرى واحدةً بعد الأخرى، مثلًا تحميل السكربتات. كيف نكتب شيفرة … لهذه المشكلة؟ تقدّم لنا الوعود طرائق مختلفة لهذا الغرض. وفي هذا الفصل سنتكلّم عن سَلسلة الوعود فقط. هكذا تكون: new Promise(function(resolve, reject) { setTimeout(() => resolve(1), 1000); // (*) }).then(function(result) { // (**) alert(result); // 1 return result * 2; }).then(function(result) { // (***) alert(result); // 2 return result * 2; }).then(function(result) { alert(result); // 4 return result * 2; }); الفكرة وما فيها هي تمرير الناتج في سلسلة توابِع .then تابعًا تابعًا. هكذا تكون: يبدأ الوعد الأوّل ويُنجز خلال ثانية واحدة (*). بعدها يُستدعى معالج .then (**). النتيجة التي ستعود ستمرر إلى معالج .then التالي (***). وهكذا… . نظرًا لتمرير النتيجة على طول سلسلة المعالجات، يمكننا رؤية سلسلة من استدعاءات alert هكذا: 1 ← 2 ← 4. ويعود سبب هذا كلّه إلى أنّ استدعاء promise.then يُعيد وعدًا هو الآخر، بذلك يمكننا استدعاء التابِع .then التالي على ذلك الوعد، وهكذا. حين تُعيد دالة المُعاملة قيمةً ما، تصير القيمة ناتج ذلك الوعد، بذلك يمكن استدعاء .then عليه. خطأ شائع بين المبتدئين: تقنيًا يمكننا إضافة أكثر من تابِع .then إلى وعد واحد. لا يُعدّ هذا سَلسلة وعود. مثلًا: let promise = new Promise(function(resolve, reject) { setTimeout(() => resolve(1), 1000); }); promise.then(function(result) { alert(result); // 1 return result * 2; }); promise.then(function(result) { alert(result); // 1 return result * 2; }); promise.then(function(result) { alert(result); // 1 return result * 2; }); هنا كتبنا أكثر من دالة مُعاملة لوعد واحد، وهذه التوابِع لا تمرّر القيمة إلى بعضها البعض، بل كلّ تعالجها على حدة. إليك الصورة (ووازِن بينها وبين السلسلة أعلاه): تتلقّى كلّ توابِع .then في نفس الوعد ذات الناتج (أي ناتج الوعد) بذلك تعرض الشيفرة أعلاه نتائج alert متطابقة: 1. أمّا عمليًا فنادرًا ما نستعمل أكثر من دالة مُعاملة واحدة لكلّ وعد، على عكس السَلسلة التي يشيع استعمالها. إعادة الوعود يمكن لدالة المُعاملة (المستعملة في .then(handler)) إنشاء وعد وإعادته. هنا تنتظر دوال المُعاملة الأخرى حتّى يكتمل الوعد وتستلم ناتجه. مثال على هذا: new Promise(function(resolve, reject) { setTimeout(() => resolve(1), 1000); }).then(function(result) { alert(result); // 1 // لاحِظ return new Promise((resolve, reject) => { // (*) setTimeout(() => resolve(result * 2), 1000); }); }).then(function(result) { // (**) alert(result); // 2 return new Promise((resolve, reject) => { setTimeout(() => resolve(result * 2), 1000); }); }).then(function(result) { alert(result); // 4 }); هنا يعرض أوّل تابِع .then القيمة 1 ويُعيد new Promise(…) في السطر (*). بعد ثانية واحدة، … الوعد ويُمرّر ناتجه (أي وسيط التابِع resolve، في حالتنا هو result * 2) إلى دالة المُعاملة التالية في تابِع .then التالي. نرى كيف أنّ الدالة في السطر (**) تعرض 2 وتؤدّي ما أدّته دالة المُعاملة السابقة. بذلك نحصل على ما حصلنا عليه في المثال السابق: 1 ثمّ 2 ثمّ 4، الفرق هو التأخير لمدّة ثانية بين كلّ استدعاء من استدعاءات alert. بإعادة الوعود يمكننا بناء سلسلة من الإجراءات غير المتزامنة. مثال: loadScript لنستعمل هذه الميزة مع دالة loadScript (التي كتبناها في الفصل السابق) لنُحمّل النصوص البرمجية واحدًا تلو الآخر: loadScript("/article/promise-chaining/one.js") .then(function(script) { return loadScript("/article/promise-chaining/two.js"); }) .then(function(script) { return loadScript("/article/promise-chaining/three.js"); }) .then(function(script) { // نستعمل الدوال المعرّف عنها في النصوص البرمجية // ونتأكّد تمامًا بأنّها حُمّلت one(); two(); three(); }); يمكننا أيضًا تقصير الشيفرة قليلًا باستعمال الدوال السهميّة: loadScript("/article/promise-chaining/one.js") .then(script => loadScript("/article/promise-chaining/two.js")) .then(script => loadScript("/article/promise-chaining/three.js")) .then(script => { // اكتمل تحميل النصوص، يمكننا استعمال الدوال فيها الآن one(); two(); three(); }); نرى هنا أنّ كلّ استدعاء من استدعاءات loadScript يُعيد وعدًا، ويعمل تابِع .then التالي في السلسلة متى … الوعد. بعدها تبدأ الدالة بتحميل النص البرمجي التالي، وهكذا تُحمّل كلّ النصوص واحدًا بعد آخر. ويمكننا إضافة ما نريد من إجراءات غير متزامنة إلى السلسلة، ولن يزيد طول الشيفرة إلى اليمين، بل إلى أسفل، ولن نُقابل وجه … ثانيةً. يمكننا تقنيًا إضافة تابِع .then داخل دوال loadScript مباشرةً هكذا: loadScript("/article/promise-chaining/one.js").then(script1 => { loadScript("/article/promise-chaining/two.js").then(script2 => { loadScript("/article/promise-chaining/three.js").then(script3 => { // يمكن أن تصل هذه الدالة إلى المتغيّرات script1 وscript2 وscript3 one(); two(); three(); }); }); }); وتؤدّي الشيفرة نفس العمل: تُحمّل 3 نصوص برمجية بالترتيب. المشكلة هي أنّ طولها يزيد نحو اليمين وهي نفس مشكلة ردود النداء. عادةً ما يجهل المبرمجون الجدد الذين يستعملون الوعود ميزة السَلسلة، فيكتبون الشيفرات هكذا. لكنّ سَلسلة الوعود هي الأمثل وغالبًا الأفضل. ولكنّ استعمال .then مباشرةً أحيانًا لا يكون بالمشكلة الكبيرة، إذ يمكن للدوال المتداخلة الوصول إلى … الخارجي. في المثال أعلاه مثلًا يمكن لآخر ردّ نداء متداخل الوصول إلى كلّ المتغيّرات script1 وscript2 وscript3، إلّا أنّ هذا استثناء عن القاعدة وليس قاعدة بحدّ ذاتها. ملاحظة: كائنات Thenables على وجه الدقة، لا تعيد المعالجات وعودًا وإنما تعيد كائن thenable - وهو كائن عشوائي له نفس توابع .then. ويتعامل معه بنفس طريقة التعامل مع الوعد. الفكرة أن مكتبات الخارجية تنفذ كائنات خاصة بها "متوافقة مع الوعد". ويمكن أن يملكوا مجموعة توابع موسّعة. ولكن يجب أن يتوافقوا مع الوعود الأصيلة، لأنهم ينفذون .then. وإليك مثالًا على كائن thenable: class Thenable { constructor(num) { this.num = num; } then(resolve, reject) { alert(resolve); // function() { native code } // إنجاز الوعد وتحقيقه مع this.num*2 بعد ثانية setTimeout(() => resolve(this.num * 2), 1000); // (**) } } new Promise(resolve => resolve(1)) .then(result => { return new Thenable(result); // (*) }) .then(alert); // shows 2 after 1000ms تتحقق جافاسكربت من الكائن المُعاد من معالج .then في السطر (*): إن لديه تابع قابل للاستدعاء يدعى then عندها سيستدعي ذاك التابع مزودًا بذلك بالتوابع الأصيلة مثل: resolve و reject كوسطاء (مشابه للمنفذ) وينتظر حتى يستدعى واحدًا منهم. في المثال أعلاه تستدعى resolve(2) بعد ثانية انظر (**). بعدها تمرر النتيجة إلى أسفل السلسلة. تتيح لنا هذه المميزات دمج الكائنات المخصصة مع سلاسل الوعود دون الحاجة إلى الوراثة من الوعد Promise. مثال أضخم: fetch عادةً ما نستعمل الوعود في برمجة الواجهات الرسومية لطلبات الشبكة. لنرى الآن مثالًا أوسع مجالًا قليلًا. سنستعمل التابِع fetch لتحميل بعض المعلومات التي تخصّ المستخدم من الخادوم البعيد. لهذا التابِع معاملات كثيرة اختيارية كتبنا عنا في فصول مختلفة، إلّا أنّ صياغته الأساسية بسيطة إلى حدّ ما: let promise = fetch(url); هكذا نُرسل طلبًا شبكيًا إلى العنوان url ونستلم وعدًا يُحل مع قيمة الكائن response ما إن يردّ الخادم البعيد بترويسات الطلب، ولكن قبل تنزيل الردّ كاملًا. علينا استدعاء التابِع response.text() لقراءة الردّ كاملًا، وهو يُعيد وعدًا يُحل resolved متى نُزّل النص الكامل من الخادوم البعيد، وناتجه يكون ذلك النص. تُرسل الشيفرة أسفله طلبًا إلى user.json وتحمّل نصّه من الخادوم: fetch('/article/promise-chaining/user.json') // إن .then تعمل عندما يستجيب الخادم البعيد .then(function(response) { // إن التابع response.text() يُعيد وعدًا جديدًا والذي يعاد مع كامل نص الاستجابة // عندما يُحمّل return response.text(); }) .then(function(text) { // ...وهنا سيكون محتوى الملف البعيد alert(text); // {"name": "iliakan", isAdmin: true} }); كما أنّ هناك التابِع response.json() والذي يقرأ البيانات المستلمة البعيدة ويحلّلها على أنّها JSON. في حالتنا هذا أفضل وأسهل فهيًا نستعمله. كما وسنستعمل الدوال السهميّة للاختصار قليلًا: // مشابه للمثال أعلاه ولكن التابع response.json() يحلل المحتوى البعيد كملف JSON fetch('/article/promise-chaining/user.json') .then(response => response.json()) .then(user => alert(user.name)); // iliakan, got user name الآن لنصنع شيئًا بهذا المستخدم الذي حمّلناه. يمكننا مثلًا إجراء طلبات أكثر من غِتهَب وتحميل ملف المستخدم الشخصي وعرض صورته: // أنشئ طلب لـِuser.json fetch('/article/promise-chaining/user.json') // حمله وكأنه ملف json .then(response => response.json()) // أنشئ طلب لـِ GitHub .then(user => fetch(`https://api.github.com/users/${user.name}`)) // حمّل الرد كملف json .then(response => response.json()) // أظهر الصورة الرمزية (avatar) من (githubUser.avatar_url) لمدة 3 ثواني .then(githubUser => { let img = document.createElement('img'); img.src = githubUser.avatar_url; img.className = "promise-avatar-example"; document.body.append(img); setTimeout(() => img.remove(), 3000); // (*) }); الشيفرة تعمل على أكمل وجه (طالِع التعليقات لتعرف التفاصيل). ولكن هناك مشكلة فيه قد تحدث، وهي خطأ شائع يقع فيه من يستعمل الوعود أوّل مرّة. طالِع السطر (*): كيف يمكن أن نفعل مهمّة معينة متى اكتمل عرض الصورة وأُزيلت؟ فلنقل مثلًا سنعرض استمارة لتحرير ذلك المستخدم أو أيّ شيء آخر. حاليًا… ذلك مستحيل. لنقدر على مواصلة السلسلة علينا إعادة وعد المُنجز متى اكتمل عرض الصورة. هكذا: fetch('/article/promise-chaining/user.json') .then(response => response.json()) .then(user => fetch(`https://api.github.com/users/${user.name}`)) .then(response => response.json()) // هنا .then(githubUser => new Promise(function(resolve, reject) { // (*) let img = document.createElement('img'); img.src = githubUser.avatar_url; img.className = "promise-avatar-example"; document.body.append(img); setTimeout(() => { img.remove(); resolve(githubUser); // (**) }, 3000); })) // يحدث بعد 3 ثوانٍ .then(githubUser => alert(`Finished showing ${githubUser.name}`)); هكذا صارت تُعيد دالة المُعاملة في .then عند السطر (*) كائنَ new Promise لا … إلّا بعد استدعاء resolve(githubUser) في setTimeout عند (**). وسينتظر تابِع .then التالي في السلسلة اكتمال ذلك. تُعد إعادة الإجراءات غير المتزامنة للوعود دومًا إحدى الممارسات الصحيحة في البرمجة. هكذا يسهّل علينا التخطيط للإجراءات التي ستصير بعد هذا، فحتّى لو لم نريد توسعة السلسلة الآن لربّما احتجنا إلى ذلك لاحقًا. وأخيرًا، يمكننا أيضًا تقسيم الشيفرة إلى دوال يمكن إعادة استعمالها: function loadJson(url) { return fetch(url) .then(response => response.json()); } function loadGithubUser(name) { return fetch(`https://api.github.com/users/${name}`) .then(response => response.json()); } function showAvatar(githubUser) { return new Promise(function(resolve, reject) { let img = document.createElement('img'); img.src = githubUser.avatar_url; img.className = "promise-avatar-example"; document.body.append(img); setTimeout(() => { img.remove(); resolve(githubUser); }, 3000); }); } // نستعملها الآن: loadJson('/article/promise-chaining/user.json') .then(user => loadGithubUser(user.name)) .then(showAvatar) .then(githubUser => alert(`Finished showing ${githubUser.name}`)); // اكتمل عرض كذا // ... خلاصة إن أعادت دالة مُعاملة .then (أو catch/finally، لا يهمّ حقًا) وعدًا، فتنتظر بقية السلسلة حتّى تُنجز متى حدث ذلك يُمرّر الناتج (أو الخطأ) إلى التي بعدها. إليك الصورة الكاملة: تمارين الوعد: then وcatch هل تؤدّي هاتين الشيفرتين نفس الغرض؟ أي هل يتطابق سلوكهما في الحالات المختلفة، وأيّما كانت دوال المُعاملة؟ promise.then(f1).catch(f2); مقابل: promise.then(f1, f2); الحل الجواب المختصر: لا ليسا متساويين: الفرق أنه إن حدث خطأ في f1 فستعالجها .catch هنا: promise .then(f1) .catch(f2); …لكن ليس هنا: promise .then(f1, f2); وذلك بسبب تمرير الخطأ لأسفل السلسلة، وفي الجزء الثاني من الشيفرة لا يوجد سلسلة أقل من f1. بمعنى آخر يمرر .then النتيجة أو الخطأ إلى .then/catch التالية. لذا في المثال الأول يوجد catch بينما في المثال الثاني لا يوجد. ولذلك لم يعالج الخطأ. ترجمة -وبتصرف- للفصل Promises chaining من كتاب The JavaScript language
-
لنقل بأنّك أنت هو عبد الحليم حافظ، ولنفترض بأنّ مُعجبوك من المحيط إلى الخليج يسألونك ليلًا نهارًا عن الأغنية الشاعرية التالية. وكي تُريح بالك تعدهم بإرسالها إليهم ما إن تُنشر. فتُعطي مُعجبيك قائمة يملؤون فيها عناوين بريدهم. ومتى ما نشرت الأغنية يستلمها كلّ من في تلك القائمة. ولو حصل مكروه (لا سمح الله) مثل أن شبّت النار والتهمت الأستديو ولم تقدر على نشر الأغنية - لو حصل ذلك فسيعلمون به أيضًا. وعاش الجميع بسعادة وهناء: أنت إذ لا يُزعجك الجميع بالتهديدات والتوعّدات، ومُعجبيك إذ لن تفوتهم أيّة رائعة من روائعك الفنية. إليك ما يشبه الأمور التي نفعلها في الحياة الواقعية - في الحياة البرمجية: ”شيفرة مُنتِجة“ تُنفّذ شيئًا وتأخذ الوقت. مثل الشيفرات التي تُحمّل البيانات عبر الشبكة. هذا أنت، ”المغنّي“. ”شيفرة مُستهلِكة“ تطلب ناتج ”الشيفرة المُنتِجة“ ما إن يجهز. وهناك عديد من الدوال تحتاج إلى هذا الناتج. هذه ”مُعجبوك“. الوعد (Promise) هو كائن فريد في جافاسكربت يربط بين ”الشيفرة المُنتِجة“ و”الشيفرة المُستهلِكة“. في الحياة العملية، الوعد هو ”قائمة الاشتراك“. يمكن أن تأخذ ”الشيفرة المُنتِجة“ ما تلزم من وقت لتقدّم لنا النتيجة التي وعدتنا بها، وسيُجهّزها لنا ”الوعد“ لأيّة شيفرة طلبتها متى جهزت. إن هذه المقاربة ليست دقيقة جدًا على الرغم من أنها جيدة كبداية ولكن وعود جافاسكربت أكثر تعقيدًا من قائمة اشتراك بسيطة بل لديها ميزات وقيود إضافية. هذه صياغة الباني لكائنات الوعد: let promise = new Promise(function(resolve, reject) { // المُنفِّذ (الشيفرة المُنتجة، مثل ”المغنّي“) }); تُدعى الدالة الممرّرة إلى new Promise ”بالمُنفِّذ“. متى صُنع الوعد new Promise عملت الدالة تلقائيًا. يحتوي هذا المُنفِّذ الشيفرة المُنتجِة، ويمكن أن تقدّم لنا في النهاية ناتجًا. في مثالنا أعلاه، فالمُنفِّذ هذا هو ”المغنّي“. تقدّم جافاسكربت الوسيطين resolve و reject وهما ردود نداء. كما ولا نضع الشيفرة التي نريد تنفيذها إلا داخل المُنفِّذ. لا يهمّنا متى سيعرف المُنفِّذ الناتجَ (آجلًا كان ذلك أم عاجلًا)، بل أنّ عليه نداء واحدًا من ردود النداء هذه: resolve(value): لو اكتملت المهمّة بنجاح. القيمة تسجّل في value. reject(error): لو حدث خطأ. error هو كائن الخطأ. إذًا نُلخّص: يعمل المُنفِّذ تلقائيًا وعليه مهمّة استدعاء resolve أو reject. لكائن الوعد promise الذي أعاده الباني new Promise خاصيتين داخليتين: الحالة state: تبدأ بالقيمة "pending" وبعدها تنتقل إلى "fulfilled" متى استُدعت resolve، أو إلى "rejected" متى استُدعت reject. الناتج result: يبدأ أولًا غير معرّف undefined، وبعدها يتغيّر إلى value متى استُدعت resolve(value) أو يتغيّر إلى error متى استُدعت reject(error). وفي النهاية ينقل المُنفِّذ الوعدَ promise ليصير بإحدى الحالات الآتية: سنرى لاحقًا كيف سيشترك ”مُعجبونا“ بهذه التغييرات. إليك مثالًا عن بانيًا للوعود ودالة مُنفِّذ بسيطة فيها ”شيفرة مُنتجِة“ تأخذ بعض الوقت (باستعمال setTimeout? let promise = new Promise(function(resolve, reject) { // تُنفّ الدالة مباشرةً ما إن يُصنع الوعد // وبعد ثانية واحدة نبعث بإشارة بأنّ المهمة انتهت والنتيجة هي ”تمت“ (done) setTimeout(() => resolve("done"), 1000); }); بتشغيل الشيفرة أعلاه، نرى أمرين اثنين: يُستدعى المُنفِّذ تلقائيًا ومباشرةً (عند استعمال new Promise). يستلم المُنفِّذ وسيطين: دالة الحلّ resolve ودالة الرفض reject، وهي دوال معرّفة مسبقًا في محرّك جافاسكربت، ولا داعٍ بأن نصنعها نحن، بل استدعاء واحدة ما إن تجهز النتيجة. بعد سنة من عملية ”المعالجة“ يستدعي المُنفِّذ الدالةَ resolve("done") لتُنتج الناتج. هكذا تتغيّر حالة كائن promise: كان هذا مثالًا عن مهمّة اكتملت بنجاح، أو ”وعد تحقّق“. والآن سنرى مثالًا عن مُنفِّذ يرفض الوعد مُعيدًا خطأً: let promise = new Promise(function(resolve, reject) { // بعد ثانية واحدة نبعث بإشارة بأنّ المهمة انتهت ونُعيد خطأً setTimeout(() => reject(new Error("Whoops!")), 1000); }); باستدعاء reject(...) ننقل حالة كائن الوعد إلى حالة الرفض "rejected": ملخّص القول هو أنّ على المُنفِّذ تنفيذ المهمة (أي ما يأخذ بعض الوقت ليكتمل) وثمّ يستدعي واحدةً من الدالتين resolve أو reject لتغيير حالة كائن الوعد المرتبط بالمُنفِّذ. يُسمّى الوعد الذي تحقّق أو نُكث الوعد المنُجز، على العكس من الوعد المعلّق. ملاحظة: إما أن تظهر نتيجة واحدة أو خطأ، يجب على المنفّذ أن يستدعي إما resolve أو reject. أي تغيير في الحالة يعدّ تغييرًا نهائيًا. وسيُتجاهل جميع الاستدعاءات اللاحقة سواءً أكانت resolve أو reject: let promise = new Promise(function(resolve, reject) { resolve("done"); reject(new Error("…")); // ستتجاهل setTimeout(() => resolve("…")); // ستتجاهل }); الفكرة هنا أن خرج عمل المنفذّ سيعرض إما نتيجة معينة أو خطأ. وتتوقع التعليمتين resolve/reject وسيطًا واحدًا مُررًا (أو بدون وسطاء نهائيًا) وأي وسطاء إضافية ستُتجاهل. ملاحظة: الرفض مع كائن Error في حال حدوث خطأ ما، يجب على المنفذّ أن يستدعي تعليمة reject. ويمكن تمرير أي نوع من الوسطاء (تمامًا مثل: resolve). ولكن يوصى باستخدام كائنات Error (أو أي كائنات ترث من Error). وقريبًا سنعرف بوضوح سبب ذلك. ملاحظة: استدعاء resolve/reject الفوري عمليًا عادة ينجز المنفذّ عمله بشكل متزامن ويستدعي resolve/reject بعد مرور بعض الوقت، ولكن الأمر ليس إلزاميًا، يمكننا استدعاء resolve أو reject فورًا، هكذا: let promise = new Promise(function(resolve, reject) { // يمكننا القيام بالمهمة مباشرة resolve(123); // أظهر مباشرة النتيجة: 123 }); على سبيل المثال من الممكن أن يحدث ذلك في حال البدء بمهمة معينة ولكن تكتشف بأن كلّ شيء أنجز وخزّن في الذاكرة المؤقتة. هذا جيد، فعندها يجب أن ننجز الوعد فورًا. ملاحظة: الحالة state و النتيجة result الداخليتين تكون خصائص الحالة state و النتيجة result لكائن الوعد داخلية. ولا يمكننا الوصول إليهم مباشرة. يمكننا استخدام التوابِع .then/.catch/.finally لذلك والتي سنشرحُها أدناه. الاستهلاك: عبارات then وcatch وfinally كائن الوعد هو كالوصلة بين المُنفِّذ (أي ”الشيفرة المُنتِجة“ أو ”المغنّي“) والدوال المُستهلكة (أي ”المُعجبون“) التي ستسلم الناتج أو الخطأ. يمكن تسجيل دوال الاستهلاك (أو أن تشترك، كما في المثال العملي ذاك) باستعمال التوابِع .then و.catch و.finally. then يُعدّ .then أهمّها وعِماد القصة كلها. صياغته هي: promise.then( function(result) { /* نتعامل مع الناتج الصحيح */ }, function(error) { /* نتعامل مع الخطأ */ } ); الوسيط الأوّل من التابِع .then يُعدّ دالة تُشغّل إن تحقّق الوعد، ويكون الوسيطُ الناتج. بينما الوسيط الثاني يُعدّ دالةً تُشغّل إن رُفض الوعد، ويكون الوسيطُ الخطأ. إليك مثال نتعامل فيه مع وعد تحقّق بنجاح: let promise = new Promise(function(resolve, reject) { setTimeout(() => resolve("done!"), 1000); }); // تُنفِّذ resolve أول دالة في .then promise.then( result => alert(result), // إظهار "done!" بعد ثانية error => alert(error) // هذا لا يعمل ); هكذا نرى الدالة الأولى هي التي نُفّذت. وإليك المثال في حالة الرفض: let promise = new Promise(function(resolve, reject) { setTimeout(() => reject(new Error("Whoops!")), 1000); }); // تُنفِّذ reject ثاني دالة في .then promise.then( result => alert(result), // لا تعمل error => alert(error) // إظهار "Error: Whoops!" بعد ثانية ); لو لم نُرِد إلّا حالات الانتهاء الناجحة، فيمكن أن نقدّم دالةً واحدة وسيطًا إلى .then فقط: let promise = new Promise(resolve => { setTimeout(() => resolve("done!"), 1000); }); promise.then(alert); // إظهار "done!" بعد ثانية catch لو لم نكن نهتمّ إلّا بالأخطاء، فعلينا استعمال null وسيطًا أولًا: .then(null, errorHandlingFunction)، أو نستعمل .catch(errorHandlingFunction) وهو يؤدّي ذات المبدأ تمامًا ولا فرق إلّا قصر الثانية مقارنة بالأولى: let promise = new Promise((resolve, reject) => { setTimeout(() => reject(new Error("Whoops!")), 1000); }); // .catch(f) is the same as promise.then(null, f) promise.catch(alert); // إظهار "Error: Whoops!" بعد ثانية finally كما المُنغلِقة finally في عبارات try {...} catch {...} العادية، فهناك مثلها في الوعود. استدعاء .finally(f) يشبه استدعاء .then(f, f)، ووجه الشبه هو أنّ الدالة f تعمل دومًا متى استقر الوعد أو أنجز سواءً كان قد تحقّق أو نُكث. استعمال finally مفيد جدًا لتنظيف ما تبقّى من أمور مهمًا كان ناتج الوعد، مثل إيقاف أيقونات التحميل (فلم نعد نحتاجها). هكذا مثلًا: new Promise((resolve, reject) => { // افعل شيئًا يستغرق وقتًا ثم استدع resolve/reject lre }) // runs when the promise is settled, doesn't matter successfully or not .finally(() => stop loading indicator) .then(result => show result, err => show error) ولكنها ليست متطابقة تمامًا مع then(f,f)، فهناك فروقات مهمّة: أولًا، ليس لدالة المُعالجة finally أيّ وسطاء. أي لسنا نعلم في finally أكان الوعد تحقّق أو نُكث، وهذه ليست مشكلة إذ ما نريده عادةً هو تنفيذ بعض الأمور ”العامّة“ لنُنهي ما بدأنا به. ثانيًا، يمرُّ مُعالج finally على النتائج والأخطاء وبعدها إلى المعالج التالي. مثال على ذلك هو الناتج الذي تمرّر من finally إلى then هنا: new Promise((resolve, reject) => { setTimeout(() => resolve("result"), 2000) }) .finally(() => alert("Promise ready")) .then(result => alert(result)); // <-- .then ستعالج الناتج كما ترى القيمة value المعادة من الوعد تُمرر عبر finally إلى then، وهذا السلوك مفيد جدًا إذ لا يفترض بأن تتعامل finally مع ناتج الوعد، بل تمرّره إلى من يتعامل معه، وهي مخصصة إلى إجراء عمليات ختامية معينة (مثل التنظيف) بغض النظر عن الناتج. وهنا واجه الوعد خطأً، وتمرّر من finally إلى catch: new Promise((resolve, reject) => { throw new Error("error"); }) .finally(() => alert("Promise ready")) .catch(err => alert(err)); // <-- .catch ستعالج كائن الخطأ error object ثالثًا، معالج finally لا يجب أن يعيد شيئًا، وإن فعل، فسيجري تجاهل القيمة المعادة، والاستثناء الوحيد هنا هو عندما يرمي معالج finally خطأً ويُمرر آنذاك الخطأ إلى المعالج التالي بغض النظر عما سبقه من مخرجات. الخلاصة، المعالج finally: لا يأخذ أي مخرجات من المعالج السابق، فليس لديه معاملات، بل تتخطاه المخرجات إلى المعالج التالي المناسب إن وُجد. لا يجب أن يعيد شيئًا وإن أعاد فلن يحصل شيء وتُتجاهل القيمة. إن رمى خطأً، فسيستلمه أقرب معالج أخطاء. ملاحظة: في الوعود المنجزة المُعالجات تعمل مباشرة إن كان الوعد مُعلقًا لسببٍ ما، فإن معالجات .then/catch/finally ستنتظره. عدا ذلك (إن كان الوعد مُنجزًا) فإن المعالجات ستنفذّ مباشرةً: // يصبح الوعد منجزًا ومتحققًا بعد الإنشاء مباشرةً let promise = new Promise(resolve => resolve("done!")); promise.then(alert); // done! (تظهر الآن) الآن لنرى أمثلة عملية على فائدة الوعود في كتابة الشيفرات غير المتزامنة. تحميل السكربتات: الدالة loadScript أمامنا من الفصل الماضي الدالة loadScript لتحميل السكربتات. إليك الدالة بطريقة ردود النداء، لنتذكّرها لا أكثر ولا أقل: function loadScript(src, callback) { let script = document.createElement('script'); script.src = src; script.onload = () => callback(null, script); // خطأ في تحميل السكربت كذا script.onerror = () => callback(new Error(`Script load error for ${src}`)); document.head.append(script); } هيًا نُعد كتابتها باستعمال الوعود. لن تطلب دالة loadScript الجديدة أيّ ردود نداء، بل ستصنع كائن وعد يتحقّق متى اكتمل التحميل، وتُعيده. يمكن للشيفرة الخارجية إضافة الدوال المُعالجة (أي دوال الاشتراك) إليها باستعمال .then: function loadScript(src) { return new Promise(function(resolve, reject) { let script = document.createElement('script'); script.src = src; script.onload = () => resolve(script); script.onerror = () => reject(new Error(`Script load error for ${src}`)); document.head.append(script); }); } الاستعمال: let promise = loadScript("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.11/lodash.js"); promise.then( script => alert(`${script.src} is loaded!`), error => alert(`Error: ${error.message}`) ); promise.then(script => alert('Another handler...')); بنظرة خاطفة يمكن أن نرى فوائد هذه الطريقة موازنةً بطريقة ردود النداء: الوعود ردود النداء تتيح لنا الوعود تنفيذ الأمور بترتيبها الطبيعي أولًا نشغّل loadScript(script) ومن بعدها .then نكتب ما نريد فعله بالنتيجة. يجب أن يكون تابِع callback تحت تصرفنا عند استدعاء loadScript(script, callback). بعبارة أخرى يجب أن نعرف ما سنفعله بالنتيجة قبل استدعاء loadScript. يمكننا استدعاء .then في الوعد عدة مرات كما نريد. في كلّ مرة نضيف معجب جديدة "fan"، هنالك تابع سيضيف مشتركين جُدد إلى قائمة المشتركين. سنرى المزيد حول هذا الأمر في الفصل القادم يمكن أن يكون هنالك ردّ واحد فقط. 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; } إذًا، فالوعود تقدّم لنا تحكمًا مرنًا بالشيفرة وسير تنفيذها، وما زالت هنالك الكثير من الأمور الرائعة التي سنتعرف عليها الفصل القادم. تمارين إعادة … الوعد؟ ما ناتج الشيفرة أدناه؟ let promise = new Promise(function(resolve, reject) { resolve(1); setTimeout(() => resolve(2), 1000); }); promise.then(alert); الحل الناتج هو: 1. يُهمل استدعاء resolve الثاني إذ لا يتهمّ المحرّك إلّا بأول استدعاء من reject/resolve، والباقي كلّه يُهمل. التأخير باستعمال الوعود تستعمل الدالة المضمّنة في اللغة setTimeout ردودَ النداء. اصنع واحدة تستعمل الوعود. على الدالة delay(ms) إعادة وعد ويجب أن … هذا الوعد خلال ms مليثانية، ونُضيف تابِع .then إليه هكذا: function delay(ms) { // شيفرتك هنا } delay(3000).then(() => alert('runs after 3 seconds')); الحل function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } delay(3000).then(() => alert('runs after 3 seconds')); لاحظ أنّنا في هذا التمرين استدعينا resolve بلا وسطاء، ولم نُعد أيّ قيمة من delay بل … فقط صورة دائرة متحركة مع وعد أعِد كتابة الدالة showCircle في حلّ التمرين السابق لتُعيد وعدًا بدل أن تستلم ردّ نداء. ويكون استعمالها الجديد هكذا: showCircle(150, 150, 100).then(div => { div.classList.add('message-ball'); div.append("Hello, world!"); }); ليكن الحلّ في التمرين المذكور أساس المسألة الآن. الحل يمكنك مشاهدة الحل عبر المثال الحي. ترجمة -وبتصرف- للفصل Promise من كتاب The JavaScript language
-
ملاحظة ابتدائية: لتوضيح طريقة استخدام ردود النداء callbacks والوعود promises والمفاهيم المجردة سنستخدم بعض توابِع المتصفح، تحديدًا سكربتات التحميل loading scripts وأدوات التلاعب بالمستندات البسيطة. إن لم تكُ على دراية بهذه الطرق، وكان استخدامها في الأمثلة مربكًا نوعًا ما، أو حتى إن رغبتَ فقط في فهمها فهمًا أفضل، فيمكنك قراءة بعض الفصول من الجزء التالي من الدورة التعليمية. وبالرغم من ذلك سنحاول توضيح الأمور بكل الأحوال ولن يكون هنالك شيء معقد في المتصفح. أغلب الإجراءات في جافاسكربت هي إجراءات غير متزامنة، أي أنّنا نشغّلها الآن ولكنّها تنتهي في وقت لاحق. مثال على ذلك هو حين نُجدول تلك الإجراءات باستعمال setTimeout. يُعدّ تحميل السكربتات والوحدات (سنشرحها في الفصول القادمة) أمثلة فعلية عن الإجراءات غير المتزامنة. لاحظ مثلًا الدالة loadScript(src) أسفله، إذ تُحمّل سكربتًا من العنوان src الممرّر: function loadScript(src) { // أنشئ وسم <script> وأضفه للصفحة // فسيؤدي ذلك إلى بدء تحميل السكربت ذي الخاصية src ثم تنفيذه عند الاكتمال let script = document.createElement('script'); script.src = src; document.head.append(script); } تُضيف الدالة إلى المستند الوسم <script src="…"> الجديد الذي وُلّد ديناميكيًا. حين يعمل المتصفّح ينفّذ الدالة. يمكننا استعمال هذه الدالة هكذا: // حمّل ونفّذ السكربت في هذا المكان loadScript('/my/script.js'); يُنفّذ هذا السكربت ”بلا تزامن“ إذ تحميله يبدأ الآن أمّا تشغيله فيكون لاحقًا متى انتهى الدالة. ولو كانت هناك شيفرة أسفل الدالة loadScript(…) فلن ننتظر انتهاء تحميل السكربت. loadScript('/my/script.js'); // الشيفرة أسفل loadScript // لا تنتظر انتهاء تحميل السكربت // ... فنقل بأنّنا نريد استعمال السكربت متى انتهى تحميله (فهو مثلًا يُصرّح عن دوال جديدة ونريد تشغيلها). ولكن لو حدث ذلك مباشرةً بعد استدعاء loadScript(…)، فلن تعمل الشيفرة: // في السكربت الدالة "function newFunction() {…}" loadScript('/my/script.js'); newFunction(); // ما من دالة بهذا الاسم! الطبيعي هو أنّ ليس على المتصفّح انتظار مدّة من الزمن حتّى ينتهي تحميل السكربت. كما نرى فحاليًا لا تقدّم لنا الدالة loadScript أيّ طريقة لنعرف وقت اكتمال التحميل، بل يُحمّل السكربت ويُشغّل متى اكتمل، وفقط. ولكن ما نريده هو معرفة وقت ذلك كي نستعمل الدوال والمتغيرات الجديدة فيه. لنُضيف دالة ردّ نداء callback لتكون الوسيط الثاني لدالة loadScript، كي تُنفّذ متى انتهى تحميل السكربت: function loadScript(src, callback) { let script = document.createElement('script'); script.src = src; script.onload = () => callback(script); document.head.append(script); } الآن متى أردنا استدعاء الدوال الجديدة في السكربت، نكتبها في دالة ردّ النداء تلك: loadScript('/my/script.js', function() { // يعمل ردّ النداء بعد تحميل السكربت newFunction(); // الآن تعمل ... }); هذه الفكرة تمامًا: يُعدّ الوسيط الثاني دالةً (عادةً ما تكون مجهولة) تعمل متى اكتمل الإجراء. وإليك مثالًا حقيقيًا قابل للتشغيل: function loadScript(src, callback) { let script = document.createElement('script'); script.src = src; script.onload = () => callback(script); document.head.append(script); } *!* loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => { alert(`Cool, the script ${script.src} is loaded`); alert( _ ); // التابع معرف في السكربت المُحمّل }); */!* يُسمّى هذا الأسلوب في البرمجة غير المتزامنة ”الأسلوب المبني على ردود النداء“ (callback-based)، إذ على الدوال التي تنفّذ أمور غير متزامنة تقديمَ وسيط ردّ نداء callback نضع فيه الدالة التي ستُشغّل متى اكتملت تلك الأمور. ولقد فعلناها في loadScript ولكن بكلّ الأحوال هذا نهج عام. رد النداء داخل رد النداء وكيف نُحمّل سكربتين واحدًا تلو الآخر: يعمل الأول وبعدها حين ينتهي يعمل الثاني؟ الحلّ الطبيعي الذي سيفكّر به الجميع هو وضع استدعاء loadScript الثاني داخل ردّ النداء، هكذا: loadScript('/my/script.js', function(script) { // جميل، اكتمل تحميل كذا، هيًا نُحمّل الآخر alert(`Cool, the ${script.src} is loaded, let's load one more`); loadScript('/my/script2.js', function(script) { // جميل، اكتمل تحميل السكربت الثاني أيضًا alert(`Cool, the second script is loaded`); }); }); وبعدما تكتمل الدالة الخارجية loadScript، يُشغّل ردّ النداء الدالة الداخلية. ولكن ماذا لو أردنا سكربتًا آخر، أيضًا…؟ loadScript('/my/script.js', function(script) { loadScript('/my/script2.js', function(script) { loadScript('/my/script3.js', function(script) { // ...نواصل متى اكتملت كلّ السكربتات }); }) }); هكذا نضع كلّ إجراء جديد داخل ردّ نداء. لو كانت الإجراءات قليلة فليست مشكلة، ولكن لو زادت فستصير كارثية. سنرى ذلك قريبًا. التعامل مع الأخطاء لم نضع الأخطاء في الحسبان في هذه الأمثلة. ماذا لو فشل تحميل السكربت؟ يفترض أن تتفاعل دالة ردّ النداء بناءً على الخطأ. إليك نسخة محسّنة من الدالة loadScript تتعقّب أخطاء التحميل: function loadScript(src, callback) { let script = document.createElement('script'); script.src = src; // هنا script.onload = () => callback(null, script); // خطأ في تحميل السكربت كذا script.onerror = () => callback(new Error(`Script load error for ${src}`)); document.head.append(script); } هنا نستدعي callback(null, script) لو اكتمل التحميل، ونستدعي callback(error) لو لم يكتمل. طريقة الاستعمال: loadScript('/my/script.js', function(error, script) { if (error) { // نتعامل مع الخطأ } else { // تحمّل السكربت بنجاح } }); نُعيد، ما استعملناه هنا للدالة loadScript هي طريقة مشهورة جدًا، تُدعى بأسلوب ”ردّ نداء الأخطاء أولًا“. إليك ما اصطُلح عليه: يُحجز الوسيط الأوّل من دالة callback للخطأ إن حدث، ويُستدعى callback(err). يكون الوسيط الثاني (وغيرها إن دعت الحاجة) للنتيجة الصحيحة، هكذا نستعمل الدالة callback فقط للأمرين: الإبلاغ عن الأخطاء وتمرير النتيجة. هرم العذابات (Pyramid of Doom) يمكن أن نرى أنّ البرمجة بنمط اللاتزامن مفيد جدًا، وهذا جليّ من النظرة الأولى. فحين نكون أمام استدعاء أو استدعاءين فقط، فأمورنا ممتازة. ولكن لو تتابعت الإجراءات غير المتزامنة واحدة تلو الأخرى، سنرى هذا: loadScript('1.js', function(error, script) { if (error) { handleError(error); } else { // ... loadScript('2.js', function(error, script) { if (error) { handleError(error); } else { // ... loadScript('3.js', function(error, script) { if (error) { handleError(error); } else { // ...نُواصل بعد اكتمال تحميل كل السكربتات (*) } }); } }) } }); إليك ما في الشيفرة أعلاه: نُحمّل 1.js ونرى لو لم تحدث أخطاء. نُحمّل 2.js ونرى لو لم تحدث أخطاء. نُحمّل 3.js، ولو لم تحدث أخطاء نفّذنا شيئًا (*). فكلّما تداخل الاستدعاءات أكثر أصبحت الشيفرة متشعّبة جدًا وأصعب في الإدارة كثيرًا، هذا خصوصًا لو كانت هناك شيفرة فعلية لا ... (مثل الحلقات والعبارات الشرطية وغيرها). يُسمّون هذا أحيانًا ”بجحيم ردود النداء“ (callback hell) أو ”هرم العذابات“. بزيادة الإجراءات غير المتزامنة واحدًا بعد آخر، يتشعّب ”هرم“ الاستدعاءات المتداخلة إلى اليمين أكثر فأكثر، ولن يمضي من الوقت الكثير حتى دخلتْ في دوّامة حلزونية محال تنظيمها. بهذا تُعدّ طريقة البرمجة هذه سيّئة. يمكننا في محاولة يائسة لتقليل وقع المشكلة تحويل كلّ إجراء إلى دالة منفردة، هكذا: loadScript('1.js', step1); function step1(error, script) { if (error) { handleError(error); } else { // ... loadScript('2.js', step2); } } function step2(error, script) { if (error) { handleError(error); } else { // ... loadScript('3.js', step3); } } function step3(error, script) { if (error) { handleError(error); } else { // ...نواصل بعد اكتمال تحميل كل السكربتات (*) } }; رأيت الفكرة؟ مبدأ الشيفرة واحد وليس هناك تداخلات متشعّبة إذ حوّلنا كلّ إجراء إلى دالة لا مشكلة في الشيفرة إذ هي تعمل، ولكنّها فعليًا مقطّعة إربًا إربًا، ويصعُب على الشخص قراءتها وغالبًا تنتقل بنظرك بين أجزاء الشيفرة وأنت تقرأها. ليس هذا بالأمر الجيد خصوصًا لو لم يكن قارئ الشيفرة يعلم بما تفعله أو إلى أين ينقل نظره. كما وأنّ الدوال بأسماء الخطوات step* هي لاستعمال واحد ولم نصنعها إلّا لتفادي ”هرم العذابات“. لن يأتي أحد لاحقًا ويُعيد استعمالها في أيّ شيء عدا سلسلة الإجراءات هذه. بهذا ”نلوّث“ فضاء الأسماء، إن جاز التعبير. نريد بالطبع ما هو أفضل من هذا الحل. ولحسن حظنا (كالعادة)، فهناك طرق أخرى نتجنّب بها الأهرام هذه، أفضلها هي استعمال ”الوعود“ وسنشرحها في الفصل القادم. تمارين دائرة تتحرك ولها رد نداء نرى في التمرين Animated circle دائرة يكبُر مقاسها في حركة جميلة. لنقل بأنّا لا نريد الدائرة فقط، بل أيضًا عرض رسالة فيها. يجب أن تظهر الرسالة بعدما ينتهي التحريك (أي تكون الدائرة بمقاسها الكبير الكامل)، وإلّا بدا النص قبيح المظهر. في حلّ ذاك التمرين، نرى الدالة showCircle(cx, cy, radius) ترسم الدائرة ولكن لا تُقدّم لنا أيّ طريقة نعرف بها انتهاء الرسم. أضِف وسيط ردّ نداء showCircle(cx, cy, radius, callback) يُستدعى متى اكتمل التحريك. على المُعامل callback استلام كائن <div> للدائرة وسيطًا له. إليك مثالًا: showCircle(150, 150, 100, div => { div.classList.add('message-ball'); div.append("Hello, world!"); }); تجربة حية: يمكنك اعتماد حل هذا التمرين منطلقًا للحل. الحل يمكنك معاينة الحل من خلال المثال الحي. ترجمة -وبتصرف- للفصل Introduction: callbacks من كتاب The JavaScript language
-
متى نكون نطوّر البرامج نحتاج إلى أصناف أخطاء خاصة بنا لتوضّح تمامًا ما قد يحدث خطأً في المهام التي نقوم بها. فمثلًا لأخطاء الشبكة نستعمل HttpError، ولعمليات قواعد البيانات DbError ولعمليات البحث NotFoundError وهكذا. وعلى هذه الأخطاء أن تدعم الخاصيات الأساسية مثل الرسالة message والاسم name والمَكدس stack (يفضّل ذلك)، ولكن يمكن أن تحتوي على خصائص أخرى خاصة بها مثل خاصية statusCode لكائنات HttpError وتحمل قيمة من قيم رموز الحالة 404 أو 403 أو 500. تتيح لنا جافاسكربت استعمال throw بتمرير أيّ وسيط، لذا فأصناف الخطأ الخاصة بنا يمكن ألّا ترث (تقنيًا) من كائن الخطأ Error، ولكن لو ورثنا منها فيمكنن للجميع استعمال obj instanceof Error لاحقًا لتتعرّف على كائنات الخطأ، بذلك يكون أفضل لو ورثناها. وكلّما كبر التطبيق شكّلت الأخطاء التي خصّصناها شجرة، فمثلًا سيرث الصنف HttpTimeoutError الصنفَ HttpError، وهكذا دواليك. توسيع Error لنأخذ مثالًا دالةَ readUser(json) تقرأ كائن JSON في بيانات المستخدم. وهذا مثال عن كائن json صالح: let json = `{ "name": "John", "age": 30 }`; سنستعمل في الشيفرة التابِع JSON.parse، وإن استلم كائن json معطوب رمى خطأ SyntaxError. ولكن، حتّى لو كان الكائن صحيحًا صياغيًا، فلا يعني هذا أنّ المستخدم صالحًا أيضًا، أم لا؟ لربّما لا يحتوي بعض البيانات مثل خاصيتي الاسم json والعمر name الضروريتين للمستخدمين. بهذا لن تقرأ الدالة readUser(json) كائن JSON فحسب، بل ستفحص (”تتحقّق من“) البيانات، فلو لم تكن الحقول المطلوبة موجودة، أو كان تنسيق الكائن مغلوطًا، فهنا نكون أمام خطأ… وهذا الخطأ ليس خطأً صياغيًا SyntaxError إذ أنّ البيانات صحيحة صياغيًا، بل نوع آخر من الأخطاء. سنسمّي هذا النوع ValidationError ونصنع صنف له. على هذا النوع من الأخطاء احتواء ما يلزم من معلومات تخصّ الحقل المخالف. يفترض علينا وراثة الصنف المضمّن في اللغة Error لصنفنا ValidationError. إليك شيء عن شيفرة الصنف المضمّن لنعرف ما نحاول توسعته: // شيفرة مبسّطة لصنف الخطأ Error المضمّن في لغة جافاسكربت نفسها class Error { constructor(message) { this.message = message; this.name = "Error"; // (تختلف الأسماء باختلاف أصناف الأخطاء المضمّنة) this.stack = <call stack>; // ليست قياسية، إلّا أنّ أغلب البيئات تدعمها } } الآن صار وقت أن يرث الصنف ValidationError منها: class ValidationError extends Error { constructor(message) { super(message); // (1) this.name = "ValidationError"; // خطأ في التحقّق (2) } } function test() { throw new ValidationError("Whoops!"); // آخ } try { test(); } catch(err) { alert(err.message); // آخ! alert(err.name); // خطأ في التحقّق alert(err.stack); // قائمة من الاستدعاءات المتداخلة في كلّ منها رقم السطر } لاحظ كيف أنّنا في السطر (1) استدعينا الباني الأب، إذ تطلب منا لغة جافاسكربت استدعاء super في البانيات الابنة، أي أنّه أمر إلزامي. يضبط الباني الأب خاصية الرسالة message. كما يضبط أيضًا خاصية الاسم name لتكون "Error"، ولذلك نُعدّلها إلى القيمة الصحيحة في السطر (2). فلنحاول الآن استعماله في readUser(json): class ValidationError extends Error { constructor(message) { super(message); this.name = "ValidationError"; // خطأ في التحقّق } } // الاستعمال function readUser(json) { let user = JSON.parse(json); if (!user.age) { throw new ValidationError("No field: age"); // حقل غير موجود: العمر } if (!user.name) { throw new ValidationError("No field: name"); // حقل غير موجود: الاسم } return user; } // هنا نجرّب باستعمال try..catch try { let user = readUser('{ "age": 25 }'); } catch (err) { if (err instanceof ValidationError) { alert("Invalid data: " + err.message); // البيانات غير صالحة: حقل غير موجود: الاسم } else if (err instanceof SyntaxError) { // (*) alert("JSON Syntax Error: " + err.message); // خطأ صياغي لكائن JSON } else { throw err; // خطأ لا نعرفه، علينا إعادة رميه (**) } } تتعامل كتلة try..catch في الشيفرة أعلاه النوعين من الأخطاء: ValidationError والخطأ المضمّن SyntaxError الذي يرميه التابِع JSON.parse. لاحِظ أيضًا كيف استعملنا instanceof لفحص نوع الخطأ في السطر (*). يمكننا أيضًا مطالعة err.name هكذا: // ... // بدل (err instanceof SyntaxError) } else if (err.name == "SyntaxError") { // (*) // ... ولكنّ استعمال instanceof أفضل بكثير إذ يحدث ونوسّع مستقبلًا الصنف ValidationError بأصناف فرعية منه مثل PropertyRequiredError، والفحص عبر instanceof سيظلّ يعمل للأصناف الموروثة منه، كما من المهمّ أن تُعيد كتلة catch رمي الأخطاء التي لا تفهمها، كما في السطر (**). ليس على هذه الكتلة إلّا التعامل مع أخطاء التحقّق والصياغة، أمّا باقي الأخطاء (والتي قد تحدث بسبب الأخطاء المطبعية في الشيفرة أو غيرها من أمور غريبة عجيبة) فيجب أن تسقط أرضًا. تعميق الوراثة صنف الخطأ ValidationError عامٌ جدًا جدًا، إذ يمكن أن تحصل أمور كثيرة خطأً في خطأ. لربّما كانت الخاصية غير موجودة أو كان نسقها خطأ (مثل تقديم سلسلة نصية قيمةً للعمر age). لنصنع الصنف …. PropertyRequiredError ونستعمله فقط للخاصيات غير الموجودة، وسيحتوي على أيّة معلومات إضافية عن الخاصية الناقصة. class ValidationError extends Error { constructor(message) { super(message); this.name = "ValidationError"; // خطأ في التحقّق } } // هذا class PropertyRequiredError extends ValidationError { constructor(property) { super("No property: " + property); // خاصية غير موجودة this.name = "PropertyRequiredError"; // خطأ إذ الخاصية مطلوبة this.property = property; } } // الاستعمال function readUser(json) { let user = JSON.parse(json); if (!user.age) { throw new PropertyRequiredError("age"); // العمر } if (!user.name) { throw new PropertyRequiredError("name"); // الاسم } return user; } // هنا نجرّب باستعمال try..catch try { let user = readUser('{ "age": 25 }'); } catch (err) { if (err instanceof ValidationError) { alert("Invalid data: " + err.message); // البيانات غير صالحة: خاصية غير موجودة: الاسم alert(err.name); // PropertyRequiredError alert(err.property); // name } else if (err instanceof SyntaxError) { alert("JSON Syntax Error: " + err.message); // خطأ صياغي لكائن JSON } else { throw err; // خطأ لا نعرفه، علينا إعادة رميه } } يسهُل علينا استعمال الصنف الجديد PropertyRequiredError، فكلّ ما علينا تمريره هو اسم الخاصية: new PropertyRequiredError(property)، وسيُولّد الباني الرسالةَ message التي نفهمها نحن البشر. لاحظ كيف أنّنا أسندنا الخاصية this.name في باني PropertyRequiredError يدويًا، مرّة ثانية. قد ترى هذا الأمر مُتعبًا حيث ستُسند قيمة this.name = <class name> في كلّ صنف خطأ تصنعه. يمكن تجنّب هذا العناء وإسناد قيمة مناسبة لصنف ”الخطأ الأساس“ this.name = this.constructor.name، وبعدها نرث من هذا الصنف كلّ أصناف الأخطاء المخصّصة. لنسمّه مثلًا MyError. إليك شيفرة MyError وغيرها من أصناف أخطاء مخصّصة، ولكن مبسّطة: class MyError extends Error { constructor(message) { super(message); this.name = this.constructor.name; // هنا } } class ValidationError extends MyError { } class PropertyRequiredError extends ValidationError { constructor(property) { super("No property: " + property); // خاصية غير موجودة this.property = property; } } // الاسم صحيح alert( new PropertyRequiredError("field").name ); // PropertyRequiredError الآن صارت شيفرات الأخطاء المخصّصة أقصر بكثير (خاصّة ValidationError) إذ حذفنا السطر "this.name = ..." في الباني. تغليف الاستثناءات هدف الدالة readUser في الشيفرة أعلاه هو ”قراءة بيانات المستخدم“، ويمكن أن تحدث مختلف الأخطاء أثناء تنفيذ ذلك. حاليًا نرى SyntaxError وValidationError فقط، ولكن في المستقبل العاجل ستصير الدالة readUser أكبر وأكبر وقد تُولّد لنا أنواع أخرى من الأخطاء. ومَن يتعامل مع هذه الأخطاء؟ الشيفرة التي تستدعي readUser! حاليًا لا تستعمل إلا بضعة تعابير شرطية if في كُتل catch (تفحص الصنف وتتعامل مع الأخطاء وتُعيد رمي ما لا تفهم)، وسيكون المخطط هكذا: try { ... readUser() // الخطأ الأساسي هنا ... } catch (err) { if (err instanceof ValidationError) { // معالحة أخطاء ValidationError } else if (err instanceof SyntaxError) { // معالجة الأخطاء الصياغية SyntaxError } else { throw err; // خطأ مجهول فلنُعد رميه من جديد } } نرى في الشيفرة البرمجية أعلاه نوعين من الأخطاء ولكن ممكن أن يكون أكثر من ذلك. ولكن متى ولّدت الدالة readUser أنواع أخرى من الأخطاء، فعلينا طرح السؤال: هل علينا حقًا فحص كلّ نوع من أنواع الأخطاء واحدًا واحدًا في كلّ شيفرة تستدعي readUser؟ عادةً ما يكون الجواب هو ”لا“، فالشيفرة الخارجية تفضّل أن تكون ”على مستوًى أعلى من ذلك المستوى“، أي أن تستلم ما يشبه ”خطأ في قراءة البيانات“، أمّا عن سبب حدوثه فلا علاقة لها به (طالما رسالة الخطأ تصف نفسها). أو ربّما (وهذا أفضل) تكون هناك طريقة لتحصل فيها على بيانات الخطأ، لو أرادت الشيفرة ذلك. تدعى الطريقة التي وصفناها بتغليف الاستثناءات. لنصنع الآن صنف ”خطأ في القراءة“ ReadError لنمثّل هذه الأخطاء. متى حدثت أخطاء داخل الدالة readUser، سنلتقطها فيها ونُولّد خطأ ReadError، بدلًا من ValidationError و SyntaxError. الكائن ReadError سيُبقي أيضًا إشارة إلى الخطأ الأصلي في خاصية السبب cause. لذا و هكذا لن يكون للشيفرة الخارجية (التي ستستدعي readUser) إلّا فحص ReadError. وليس كلّ نوع من أخطاء قراءة البيانات. وإن كان يحتاج لمزيد من التفاصيل عن الخطأ يمكنه العودة إلى الخاصية cause والتحقق منها. إليك الشيفرة التي نُعرّف فيها الخطأ ReadError ونمثّل طريقة استعماله في الدالة readUser وفي try..catch: class ReadError extends Error { constructor(message, cause) { super(message); this.cause = cause; this.name = 'ReadError'; // خطأ في القراءة } } class ValidationError extends Error { /*...*/ } class PropertyRequiredError extends ValidationError { /* ... */ } function validateUser(user) { if (!user.age) { throw new PropertyRequiredError("age"); // العمر } if (!user.name) { throw new PropertyRequiredError("name"); // الاسم } } function readUser(json) { let user; try { user = JSON.parse(json); } catch (err) { // هنا if (err instanceof SyntaxError) { throw new ReadError("Syntax Error", err); // خطأ صياغي } else { throw err; } } try { validateUser(user); } catch (err) { // وهنا if (err instanceof ValidationError) { throw new ReadError("Validation Error", err); // خطأ في التحقّق } else { throw err; } } } try { readUser('{bad json}'); } catch (e) { if (e instanceof ReadError) { // هنا alert(e); // Original error: SyntaxError: Unexpected token b in JSON at position 1 alert("Original error: " + e.cause); // الخطأ الأصل } else { throw e; } } في الشيفرة أعلاه تعمل الدالة readUser تمامًا كم وصفها، تلتقط أخطاء الصياغة والتحقّق وترمي أخطاء قراءة ReadError بدلها (كما وتُعيد رمي الأخطاء المجهولة أيضًا). هكذا ليس على الشيفرة الخارجية إلّا فحص instanceof ReadError فقط، لا داعٍ للتحقّق من كلّ خطأ يمكن أن يحصل في هذه المجرّة. يُسمّى هذا النهج ”بتغليف الاستثناءات“ حيث نستلم نحن ”الاستثناءات في المستوى المنخفض من البرمجة“ (low level) و”نُغلّفها“ داخل خطأ ReadError يكون أكثر بساطة وأسهل استعمالًا للشيفرات التي تنادي على الدوال. هذا النهج مستعمل بكثرة في البرمجة كائنية التوجّه. خلاصة يمكننا وراثة صنف الخطأ Error وغيرها من أخطاء مضمّنة كما الوراثة العادية. المهم أن ننتبه من خاصية الاسم name ولا ننسى استدعاء super. يمكننا استعمال instanceof لفحص ما نريد من أخطاء بدقّة، كما ويعمل المُعامل مع الوراثة. أحيانًا نستلم كائن خطأ من مكتبة طرف ثالث وما من طريقة سهلة لنعرف اسم صنفها. هنا يمكن استعمال خاصية الاسم name لإجراء هذا الفحص. أسلوب تغليف الاستثناءات هو أسلوب منتشر الاستعمال، فيه تتعامل الدالة مع الاستثناءات في المستوى المنخفض من البرمجة، وتصنع أخطاء مستواها عالٍ بدل تلك المنخفضة. وأحيانًا تصير الاستثناءات المنخفضة خصائص لكائن الخطأ (تمامًا مثل err.cause في الأمثلة أعلاه)، ولكنّ هذا ليس إلزاميًا أبدًا. تمارين الوراثة من SyntaxError الأهمية: 5 اصنع الصنف FormatError ليرث من الصنف المضمّن SyntaxError. يجب أن يدعم الصنف خصائص الاسم name والرسالة message والمَكدس stack. طريقة الاستعمال: let err = new FormatError("formatting error"); // خطأ في التنسيق alert( err.message ); // خطأ في التنسيق alert( err.name ); // FormatError alert( err.stack ); // المَكدس alert( err instanceof FormatError ); // true alert( err instanceof SyntaxError ); // true (إذ يرث الصنف الصنفَ SyntaxError) الحل class FormatError extends SyntaxError { constructor(message) { super(message); this.name = this.constructor.name; } } let err = new FormatError("formatting error"); alert( err.message ); // خطأ في التنسيق alert( err.name ); // خطأ في التنسيق alert( err.stack ); // stack(المَكدس) alert( err instanceof SyntaxError ); // true ترجمة -وبتصرف- للفصل Custom errors, extending Error من كتاب The JavaScript language
-
مهما كنّا عباقرة نحن معشر المبرمجين، فلا بدّ أن تكون في السكربتات مشكلة ما. تحدث هذه المشاكل إمّا بسببنا، أو بسبب شيء أدخله المستخدم لم نتوقّعه، أو بسبب ردّ فيه خطأ من الخادوم، أو بمليار سبب آخر. في العادة ”يموت“ السكربت (أي يتوقّف مباشرة) لو حدث خطأ، ويطبع ذلك في الطرفية، لكن في كل الأحوال يمكن التعامل مع هذا الخطأ بعدة طرق. قبل التطرق إلى كيفية التقاط الأخطاء البرمجية في لغة جافاسكربت وكيفية التعامل معها، ندعوكم للاطلاع على الفيديو الآتي الذي يشرح الأخطاء البرمجية عامةً، كيفية التعامل معها، لنعود ونفصل في الصياغات المخصصصة لجافاسكربت في التعامل مع هذه الأخطاء. تتيح لنا الصياغة try..catch إمكانية ”التقاط“ هذه الأخطاء والقيام بما هو مفيد بدل أن يموت السكربت. صياغة try..catch للتعبير try..catch كتلتين برمجيتين أساسيتين: التجربة try والالتقاط catch بعدها: try { // الشيفرة... } catch (err) { // التعامل مع الأخطاء } يعمل التعبير هكذا: أولًا، يجري تنفيذ الشيفرة في try {...}. لم نجح التنفيذ دون أيّ أخطاء، تُهمل الكتلة catch(err) وتصل عملية التنفيذ إلى نهاية try وتواصل عملها بعد تجاهل catch. إن حدث أيّ خطأ يتوقّف التنفيذ داخل كتلة try وينتقل سير العملية إلى بداية الكتلة catch(err). سيحتوي المتغير err (يمكننا استبداله بأيّ اسم آخر) كائن خطأ فيه تفاصيل عمّا حدث. بهذا لا تقتل الأخطاء داخل كتلة try {…} السكربت، فما زال ممكنًا أن نتعامل معها في catch. وقت الأمثلة. إليك مثالًا ليس فيه أخطاء: يعرض alert السطرين (1) و (2): try { alert('Start of try runs'); // (1) <-- بداية التشغيلات // ...ما من أخطاء هنا alert('End of try runs'); // (2) <-- نهاية التشغيلات } catch(err) { // (3) تُهمل catch إذ ليست هناك أخطاء alert('Catch is ignored, because there are no errors'); } مثال فيه خطأ: يعرض السطرين (1) و(3): try { alert('Start of try runs'); // (1) <-- بداية التشغيلات lalala; // error, variable is not defined! خطأ: المتغير غير معرّف! alert('End of try (never reached)'); // (2) نهاية كتلة try (لا نصل إليها أبدًا) } catch(err) { alert(`Error has occurred!`); // (3) <-- حدث خطأ! } تحذير: لا يعمل تعبير try..catch إلّا مع الأخطاء أثناء التشغيل يجب أن تكون الشيفرة البرمجية صياغتها صحيحة لكي تعمل try..catch بعبارة أخرى، يجب أن تكون الشيفرة البرمجية خالية من أخطاء الصياغة. لن تعمل في هذه الحالة لأن هنالك أقواسًا مفتوحة بدون غُلاقاتها. try { {{{{{{{{{{{{ } catch(e) { alert("لا يمكن للمحرك فهم هذه الشيفرة فهي غير صحيحة"); } أولًا يقرأ محرك جافاسكربت الشيفرة البرمجية، ومن ثمّ يشغّلها. تدعى الأخطاء التي تحدث في مرحلة القراءة أخطاء التحليل (parse-time) ولا يمكن إصلاحها (من داخل الشيفرة البرمجية نفسها). وذلك لأن محرك لغة جافاسكربت لا يستطيع فهم الشيفرة البرمجية من الأساس. تحذير:تعمل الصياغة try..catch بشكل متزامن. إذا حدث خطأ ما في شيفرة برمجية مجدولة مثلما في setTimeout فلن تستطيع الصياغة try..catch أن تلتقطه: try { setTimeout(function() { noSuchVariable; // السكربت سيموت هنا }, 1000); } catch (e) { alert( "won't work" ); } وذلك لأن التابِع سيُنفذ لاحقًا، بينما يكون محرك جافاسكربت غادر باني try..catch. للتقاط خطأ بداخل تابِع مُجدوَل يجب أن تكون الصياغة try..catch في داخل هذا التابِع. setTimeout(function() { try { // صياغة try..catch تُعالح الخطأ noSuchVariable; // try..catch handles the error! } catch { alert( "error is caught here!" ); } }, 1000); كائن الخطأ Error تولّد جافاسكربت -متى حدث الخطأ- كائنًا يحوي تفاصيل الخطأ كاملةً، بعدها تُمرّرها وسيطًا إلى catch: try { // ... } catch(err) { // هذا ”كائن الخطأ“، ويمكننا استعمال أيّ اسم نريد بدل err // ... } لكائن الخطأ (في حالة الأخطاء المضمّنة في اللغة) خاصيتين اثنتين: name: اسم الخطأ. فمثلًا لو كان المتغير غير معرّفًا فسيكون الاسم "ReferenceError". message: الرسالة النصية بتفاصيل الخطأ. كما هناك خاصيات أخرى غير قياسية في أغلب بيئات جافاسكربت. أكثرها استعمالًا ودعمًا هي: stack: مَكدس الاستدعاء الحالي: سلسلة نصية فيها معلومات عن سلسلة الاستدعاءات المتداخلة التي تسبّبت بالخطأ. نستعمل الخاصية للتنقيح فقط. مثال: try { *!* lalala; // خطأ المتغيّر غير معرّف */!* } catch(err) { alert(err.name); // ReferenceError(خطأ في الإشارة) alert(err.message); // المتغيّر lalala غير معرف alert(err.stack); // ReferenceError:(خطأ في الإشارة) المتغيّر lalala غير معرّف في (...call stack) //يمكننا أيضًا عرض الخطأ بالكامل //تحول الخطأ إلى سلسلة نصية هكذا :"name: message" alert(err); // ReferenceError:(خطأ في الإشارة) المتغيّر lalala غير معرّف } إسناد "catch" الاختياري تحذير:هذه إضافة حديثة للغة من الممكن أن تحتاج لترقيع الخطأ في المتصفحات القديمة لأن هذه الميّزة جديدة. لو لم تريد تفاصيل الخطأ فيمكنك إزالتها من catch: try { // ... } catch { // <-- بدون المتغير (err) // ... } استعمال "try..catch" هيًا نرى معًا مثالًا واقعيًا عن try..catch: كما نعلم فجافاسكربت تدعم التابِع JSON.parse(str) ليقرأ القيم المرمّزة بِـ JSON. عادةً ما نستعمله لفكّ ترميز البيانات المستلمة من الشبكة، كانت آتية من خادوم أو من أيّ مصدر غيره. نستلم البيانات ونستدعي JSON.parse هكذا: let json = '{"name":"John", "age": 30}'; // بيانات من الخادوم // لاحظ let user = JSON.parse(json); // نحوّل التمثيل النصي إلى كائن جافاسكربت // الآن صار user كائنًا فيه خاصيات أتت من السلسلة النصية alert( user.name ); // John alert( user.age ); // 30 يمكنك أن ترى تفاصيل أكثر عن JSON في درس JSON methods, toJSON. لو كانت سلسلة json معطوبة، فسيُولّد JSON.parse خطأً و”ويموت“ السكربت. هل نقبل بالأمر الواقع المرير؟ لا وألف لا! في هذا الحال لن يعرف الزائر ما يحدث لو حدث للبيانات أمر (ما لم يفتح طرفية المطوّرين). وعادةً ما لا يحب الناس ما ”يموت“ أمامهم دون أن يُعطيهم رسالة خطأ. فلنستعمل try..catch للتعامل مع هذه المعضلة: let json = "{ bad json }"; try { let user = JSON.parse(json); // <-- لو حدث خطأ... alert( user.name ); // فلن يعمل هذا } catch (e) { // ...تنتقل عملية التنفيذ إلى هنا alert( "Our apologies, the data has errors, we'll try to request it one more time." ); // المعذرة، في البيانات أخطاء. سنحاول طلبها مرة ثانية. alert( e.name ); alert( e.message ); } استعملنا هنا الكتلة catch لعرض رسالة لا أكثر، ولكن يمكن أن نستغلّها أفضل من هذا: مثل إرسال طلب إلى الشبكة، أو اقتراح عملية بديلة على الزائر أو إرسال معلومات الخطأ إلى … تسجيل، وغيرها… هذا كلّه أفضل من الموت والموت فقط. رمي أخطائنا نحن ماذا لو كان كائن json صحيح صياغيًا إلّا أنّ الخاصية المطلوبة name ليست فيه؟ هكذا مثلًا: let json = '{ "age": 30 }'; // البيانات ناقصة try { let user = JSON.parse(json); // <-- لا أخطاء alert( user.name ); // ما من اسم! } catch (e) { alert( "doesn't execute" ); // لا يتنفّذ السطر } هنا سيعمل التابِع JSON.parse كما يجب، لكن عدم وجود الخاصية name هي المشكلة والخطأ في هذه الحالة. لتوحيد طريقة معالجة الأخطاء، سنستخدم مُعامل throw. مُعامل "الرمي" throw يُولّد لنا مُعامل الرمي throw خطأً. صياغته هي: throw <error object> يمكننا تقنيًا استعمال ما نريد ليكون كائن خطأ، مثل الأنواع الأولية كالأعداد والسلاسل النصية. ولكن من الأفضل استعمال الكائنات ومن الأفضل أكثر أن تملك خاصيتي الاسم name والرسالة message (لتكون متوافقة إلى حدٍّ ما مع الأخطاء المضمّنة). في جافاسكربت مختلف البانيات المضمّنة للأخطاء القياسية: الخطأ Error والخطأ الصياغي SyntaxError والخطأ في الإشارة ReferenceError والخطأ في النوع TypeError وغيرها. يمكننا استعمال هذه البانيات أيضًا لإنشاء كائنات الخطأ. صياغتها هي: let error = new Error(message); // أو let error = new SyntaxError(message); let error = new ReferenceError(message); // ... في الأخطاء المضمّنة في اللغة (ولا أعني الكائنات أيًا كانت، الأخطاء بعينها) تكون قيمة الخاصية name هي ذاتها اسم الباني، وتُؤخذ الرسالة message من الوسيط. مثال: let error = new Error("Things happen o_O"); // غرائب وعجائب ة_ه alert(error.name); // خطأ alert(error.message); // غرائب وعجائب ة_ه لنرى نوع الخطأ الذي يُولّده التابِع JSON.parse: try { JSON.parse("{ bad json o_O }"); // جيسون شقي ة_ه } catch(e) { alert(e.name); // SyntaxError خطأ صياغي alert(e.message); // Unexpected token o in JSON at position 2 } كما رأينا فهو خطأ صياغي SyntaxError. وفي حالتنا نحن فعدم وجود الخاصية name هي خطأ، إذ لا بدّ أن يكون للمستخدم اسمًا. هيًا نرمِ الخطأ: let json = '{ "age": 30 }'; // البيانات ناقصة try { let user = JSON.parse(json); // <-- لا أخطاء if (!user.name) { throw new SyntaxError("Incomplete data: no name"); // (*) البيانات ناقصة: ليس هنالك اسم } alert( user.name ); } catch(e) { alert( "JSON Error: " + e.message ); // خطأ JSON: البيانات ناقصة: ليس هنالك اسم } يُولّد مُعامل throw (في السطر (*)) خطأً صياغيًا SyntaxError له الرسالة الممرّرة message، تمامًا كما تُولّد جافاسكربت نفسها الخطأ. بهذا يتوقّف التنفيذ داخل try وينتقل سير العملية إلى catch. الآن صارت الكتلة catch هي المكان الذي نتعامل فيه مع الأخطاء فحسب، أكانت أخطاء JSON.parse أم أخطاء أخرى. إعادة الرمي يمكننا في المثال أعلاه استعمال try..catch للتعامل مع البيانات الخاطئة. ولكن هل يمكن أن يحدث خطأ آخر غير متوقّع في كتلة try {...}؟ مثلًا لو كان خطأ المبرمج (لم يعرّف المتغير) أو شيئًا آخر ليس أنّ ”البيانات خاطئة“؟ مثال: let json = '{ "age": 30 }'; // البيانات ناقصة try { user = JSON.parse(json); // نسينا ”let“ قبل user // ... } catch(err) { alert("JSON Error: " + err); // خطأ JSON: خطأ في الإشارة: user غير معرّف (في الواقع، ليس خطأ JSON) } كلّ شيء ممكن كما تعلم! فالمبرمجون يخطؤون. حتّى في الأدوات مفتوحة المصدر والتي يستعملها ملايين البشر لسنين تمضي، لو اكتُشفت علة فجأةً ستؤدّي إلى اختراقات مأساوية. في حالتنا هذه على try..catch التقاط أخطاء ”البيانات الخاطئة“ فقط، ولكنّ طبيعة catch هي أن تلتقط كلّ الأخطاء من try. فهنا مثلًا التقطت خطأً غير متوقع ولكنها ما زالت تصرّ على رسالة "JSON Error". هذا ليس صحيحًا ويصعّب من تنقيح الشيفرة كثيرًا. لحسن الحظ فيمكننا معرفة الخطأ الذي التقطناه، من اسمه name مثلًا: try { user = { /*...*/ }; } catch(e) { alert(e.name); // ”خطأ في الإشارة” محاولة الوصول لمتغيّر غير معرّف } القاعدة ليست بالمعقّدة: على catch التعامل مع الأخطاء التي تعرفها فقط، و”إعادة رمي“ ما دونها. يمكننا توضيح فكرة ”إعادة الرمي“ بهذه الخطوات: تلتقط catch كلّ الأخطاء. نحلّل كائن الخطأ err في كتلة catch(err) {...}. لو لم نعرف طريقة التعامل معه، رميناه throw err. في الشيفرة أسفله استعملنا إعادة الرمي لتتعامل الكتلة catch مع أخطاء الصياغة فقط SyntaxError: let json = '{ "age": 30 }'; // البيانات ناقصة try { let user = JSON.parse(json); if (!user.name) { throw new SyntaxError("Incomplete data: no name"); // البيانات ناقصة: لا اسم } blabla(); // خطأ غير متوقع alert( user.name ); } catch(e) { if (e.name == "SyntaxError") { alert( "JSON Error: " + e.message ); // خطأ JSON: كذا كذا } else { throw e; // نُعيد رميه (*) } } الخطأ المرمي في السطر (*) داخل كتلة catch ”يسقط خارج أرض“ try..catch ويمكننا إمّا التقاطه باستعمال تعبير try..catch خارجي (لو كتبناه) أو سيقتل الخطأ السكربت. هكذا لا تتعامل الكتلة catch إلا مع ما تعرف مع أخطاء وتتخطّى (إن صحّ القول) الباقي. يوضّح المثال أسفله كيفية التقاط هذه الأخطاء بمستويات أخرى من try..catch: function readData() { let json = '{ "age": 30 }'; try { // ... blabla(); // خطأ! } catch (e) { // ... if (e.name != 'SyntaxError') { throw e; // نُعيد رميه (لا ندري طريقة التعامل معه) } } } try { readData(); } catch (e) { alert( "External catch got: " + e ); // التقطناه أخيرًا! } هكذا لا تعرف الدالة readData إلّا طريقة التعامل مع أخطاء الصياغة SyntaxError، بينما تتصرّف تعابير try..catch الخارجية بغيرها من أخطاء (إن عرفتها). try..catch..finally لحظة… لم ننتهي بعد. يمكن أيضًا أن يحتوي تعبير try..catch على مُنغلقة أخرى: finally. لو كتبناها فستعمل في كلّ الحالات الممكنة: بعد try لو لم تحدث أخطاء وبعد catch لو حدثت أخطاء. هكذا هي الصياغة ”الموسّعة“: try { ... نحاول تنفيذ الشيفرة ... } catch(e) { ... نتعامل مع الأخطاء ... } finally { ... ننفّذه مهما كان الحال ... } جرّب تشغيل هذه الشيفرة: try { alert( 'try' ); if (confirm('Make an error?')) BAD_CODE(); // أتريد ارتكاب خطأ؟ } catch (e) { alert( 'catch' ); // أمسكناه } finally { alert( 'finally' ); // بعد ذلك } يمكن أن تعمل الشيفرة بطريقتين اثنتين: لو كانت الإجابة على ”أتريد ارتكاب خطأ؟“ ”نعم“، فستعمل هكذا try -> catch -> finally. لو رفضت ذلك، فستعمل هكذا try -> finally. نستعمل عادةً مُنغلقة finally متى ما شرعنا في شيء وأردنا إنهائه مهما كانت نتيجة الشيفرة. فمثلًا نريد قياس الوقت الذي تأخذه دالة أعداد فيبوناتشي fib(n). الطبيعي أن نبدأ القياس قبل التشغيل ونُنهيه بعده، ولكن ماذا لو حدث خطأ أثناء استدعاء الدالة؟ مثلًا خُذ شيفرة الدالة fib(n) أسفله وسترى أنّها تُعيد خطأً لو كانت الأعداد سالبة أو لم تكن أعدادًا صحيحة (not integer). مُنغلقة finally هي المكان الأمثل لإنهاء هذا القياس مهما كانت النتيجة، فتضمن لنا بأنّ الوقت سيُقاس كما ينبغي في الحالتين معًا، نجح تنفيذ أم لم ينجح وأدّى لخطأ: let num = +prompt("Enter a positive integer number?", 35) let diff, result; function fib(n) { if (n < 0 || Math.trunc(n) != n) { throw new Error("Must not be negative, and also an integer."); } return n <= 1 ? n : fib(n - 1) + fib(n - 2); } let start = Date.now(); try { result = fib(num); } catch (e) { result = 0; *!* } finally { diff = Date.now() - start; } */!* alert(result || "error occurred"); alert( `execution took ${diff}ms` ); يمكنك التأكّد بتشغيل الشيفرة وإدخال 35 في prompt، وستعمل كما يجب، أي تكون finally بعد try. وبعد ذلك جرّب إدخال -1 وسترى خطأً مباشرةً وسيأخذ تنفيذ الشيفرة مدّة 0ms، وكِلا الزمنين المقيسين صحيحين. أي أنّه يمكن للدالة أن بعبارة return أو throw، لا مشكلة إذ ستُنفّذ مُنغلقة finally في أيّ من الحالتين. ملاحظة:المتغيّرات في صياغة try..catch..finally محلية. لاحظ أن المتغيرات result و diff في الشيفرة البرمجية أعلاه معرّفة قبل الصياغة try..catch. وبذلك إذا عرفنا let في الصياغة try فستبقى مرئية بداخلها فقط. ملاحظة: بخصوص finally و return فإن مُنغلقة finally تعمل مع أي خروج من صياغة try..catch والتي تتضمن خروج واضح من خلال تعليمة return. في المثال أدناه هنالك تعليمة return في try في هذه الحالة ستنفذّ finally قبل عودة عنصر التحكم إلى الشيفرة البرمجية الخارجية مباشرة. function func() { try { *!* return 1; */!* } catch (e) { /* ... */ } finally { *!* alert( 'finally' ); */!* } } alert( func() ); // أولًا ستعمل تعليمة alert من المنغلقة finally ومن ثمّ هذه ملاحظة: إن باني try..finally بدون منغلقة catch مفيد أيضًا إذ يمكننا استخدامه عندما لا نريد معالجة الأخطاء وإنما نريد التأكد من أن العمليات التي بدأناها انتهت فعلًا. function func() { // نبدأ بشيء يحتاج لإكمال (مثل عملية القياس) try { // ... } finally { // نكمله هنا حتى وإن كلّ السكربت مات } } في الشيفرة البرمجية أعلاه، هنالك خطأ دائمًا يحدث في try لأنه لا يوجد catch. ومع ذلك ستعمل finally قبل الخروج من التابع. الالتقاط العمومي تحذير: مخصص لبيئة العمل إن المعلومات في هذه الفقرة ليست جزءًا من لغة جافاسكربت لنقل مثلًا أنْ حصل خطأ فادح خارج تعبير try..catch، ومات السكربت (مثلًا كان خطأ المبرمج أو أيّ شيء آخر مريب). أهناك طريقة يمكننا التصرّف فيها في هكذا حالات؟ لربّما أردنا تسجيل الخطأ أو عرض شيء معيّن على المستخدم (عادةً لا يرى المستخدمون رسائل الأخطاء) أو غيرها. لا نجد في المواصفة أيّ شيء عن هذا، إلّا أنّ بيئات اللغة تقدّم هذه الميزة لأهميتها البالغة. فمثلًا تقدّم Node.js الحدث process.on("uncaughtException"). ولو كنّا نُطوّر للمتصفّح فيمكننا إسناد دالة إلى خاصية window.onerror الفريدة (إذ تعمل متى حدث خطأ لم يُلتقط). الصياغة هي: window.onerror = function(message, url, line, col, error) { // ... }; message: رسالة الخطأ. url: عنوان URL للسكربت أين حدث الخطأ. line وcol: رقم السطر والعمود أين حدث الخطأ. error: كائن الخطأ. مثال: <script> window.onerror = function(message, url, line, col, error) { alert(`${message}\n At ${line}:${col} of ${url}`); }; function readData() { badFunc(); // Whoops, something went wrong! } readData(); </script> عادةً، لا يكون الهدف من …. العمومي window.onerror استعادة تنفيذ السكربت (إذ هو أمر مستحيل لو كانت أخطاء من المبرمج) بل يكون لإرسال هذه الرسائل إلى المبرمج. كما أنّ هناك خدمات على الوِب تقدّم مزايا لتسجيل الأخطاء في مثل هذه الحالات، أمثال https://errorception.com وhttp://www.muscula.com. وهكذا تعمل: نُسجّل في الخدمة ونأخذ منهم شيفرة جافاسكربت صغيرة (أو عنوانًا للسكربت) لنضعها في صفحاتنا. يضبط هذا السكربت دالة window.onerror مخصّصة. ومتى حدث خطأ أرسلَ طلب شبكة بالخطأ إلى الخدمة. ويمكننا متى أردنا الولوج إلى واجهة الوِب للخدمة ومطالعة تلك الأخطاء. خلاصة يتيح لنا تعبير try..catch التعامل مع الأخطاء أثناء تنفيذ الشيفرات. كما يدلّ اسمها فهي تسمح ”بتجربة“ تنفيذ الشيفرة و”والتقاط“ الأخطاء التي قد تحدث فيها. صياغتها هي: try { // شغّل الشيفرة } catch(err) { // انتقل إلى هنا لو حدث خطأ // err هو كائن الخطأ } finally { // مهما كان الذي حدث، نفّذ هذا } يمكننا إزالة قسم catch أو قسم finally، واستعمال الصيغ الأقصر try..catch وtry..finally. لكائنات الأخطاء الخاصيات الآتية: message: رسالة الخطأ التي نفهمها نحن البشر. name: السلسلة النصية التي فيها اسم الخطأ (اسم باني الخطأ). stack (ليست قياسية ولكنّها مدعومة دعمًا ممتازًا): المَكدس في لحظة إنشاء الخطأ. لو لم تُرد كائن الخطأ فيمكنك إزالتها باستعمال catch { بدل catch(err) {. يمكننا أيضًا توليد الأخطاء التي نريد باستعمال مُعامل throw. تقنيًا يمكن أن يكون وسيط throw ما تريد ولكن من الأفضل لو كان كائن خطأ يرث صنف الأخطاء Error المضمّن في اللغة. سنعرف المزيد عن توسعة الأخطاء في القسم التالي. كما يُعدّ نمط ”إعادة الرمي“ البرمجي نمطًا مهمًا عند التعامل مع الأخطاء، فلا تتوقّع كتلة catch إلّا أنواع الأخطاء التي تفهمها وتعرف طريقة التعامل معها، والباقي عليها إعادة رميه لو لم تفهمه. حتّى لو لم نستعمل تعبير try..catch فأغلب البيئات تتيح لنا إعداد … أخطاء ”عمومي“ لالتقاط الأخطاء التي ”تسقط أرضًا“. هذا … في المتصفّحات اسمه window.onerror. تمارين أخيرًا أم الشيفرة؟ الأهمية: 5 وازِن بين الشيفرتين هتين. تستعمل الأولى finally لتنفيذ الشيفرة بعد try..catch: try { عمل عمل عمل } catch (e) { نتعامل مع الأخطاء } finally { ننظّف مكان العمل // هنا } الثانية تضع عملية التنظيف بعد try..catch: try { عمل عمل عمل } catch (e) { نتعامل مع الأخطاء } ننظّف مكان العمل // هنا سنحتاج بكل بتأكيد للتنظيف بعد العمل. بغض النظر إن وجدَنا خطأً ما أم لا. هل هنالك ميزة إضافية لاستخدام finally؟ أم أن الشيفرتين السابقتين متساويتين؟ إن كان هنالك ميزّة اكتب مثلًا يوضحها. الحل عندما ننظر إلى الشيفرة الموجودة بداخل التابع يصبح الفرق جليًا. يختلف السلوك إذا كان هناك "قفزة" من "try..catch". على سبيل المثال، عندما يكون هناك تعليمة return بداخل صياغة try..catch. تعمل منغلقة finally في حالة وجود أي خروج من صياغة try..catch، حتى عبر تعليمة return: مباشرة بعد الانتهاء من try..catch ، ولكن قبل أن تحصل شيفرة الاستدعاء على التحكم. function f() { try { alert('start'); return "result"; } catch (e) { /// ... } finally { alert('cleanup!'); } } f(); // cleanup! … أو عندما يكون هناك throw، مثل هنا: function f() { try { alert('start'); throw new Error("an error"); } catch (e) { // ... if("can't handle the error") { throw e; } } finally { alert('cleanup!') } } f(); // cleanup! تضمنُ finally عملية التنظيف هنا. إذا وضعنا الشيفرة البرمجية في نهاية f ، فلن تشغّل في هذا السيناريو. ترجمة -وبتصرف- للفصل Error handling, "try..catch" من كتاب The JavaScript language.
-
لا يمكننا في جافاسكربت إلّا أن نرث كائنًا واحدًا فقط. وليس هناك إلّا كائن [[Prototype]] واحد لأيّ كائن. ولا يمكن للصنف توسعة أكثر من صنف واحد. وأحيانًا يكون ذلك مُقيّدًا نوعًا ما. فنقل بأنّ لدينا صنف ”كنّاسة شوارع“ StreetSweeper وصنف ”دراجة هوائية“ Bicycle، وأردنا صنع شيء يجمعهما: ”كنّاسة شوارع بدراجة هوائية“ StreetSweepingBicycle. أو ربّما لدينا صنف المستخدم User وصنف ”مُطلق الأحدث“ EventEmitter حيث فيه خاصية توليد الأحداث، ونريد إضافة ميزة EventEmitter إلى User كي يُطلق المستخدمون الأحداث أيضًا. هناك مفهوم في اللغة يساعد في حلّ هذه وهو ”المخاليط“ (Mixins). كما تقول ويكيبيديا، المخلوط هو صنف يحتوي على توابِع يمكن للأصناف الأخرى استعمالها دون أن ترث ذاك الصنف. أي بعبارة أخرى، يُقدّم المخلوط توابِع فيها وظائف وسلوك معيّن، ولكنّا لا نستعمله لوحده بل نُضيف تلك الوظيفة أو ذاك السلوك إلى الأصناف الأخرى. مثال عن المخاليط أبسط طريقة لكتابة تنفيذ مخلوط في جافاسكربت هو صناعة كائن فيه توابِع تُفيدنا في أمور معيّنة كي ندمجها بسهولة داخل كائن prototype لأيّ صنف كان. لنقل مثلًا لدينا المخلوط sayHiMixin ليُضيف بعض الكلام للمستخدم أو الصنف User: // مخلوط let sayHiMixin = { sayHi() { alert(`Hello ${this.name}`); // مرحبًا يا فلان }, sayBye() { alert(`Bye ${this.name}`); // وداعًا يا فلان } }; // الاستعمال: class User { constructor(name) { this.name = name; } } // ننسخ التوابِع Object.assign(User.prototype, sayHiMixin); // الآن يمكن أن يرحّب المستخدم بغيره new User("Dude").sayHi(); // Hello Dude! مرحبًا يا Dude! ما من وراثة هنا، بل نسخ بسيط للتوابِع. هكذا يمكن أن يرث صنف المستخدم من أيّ صنف آخر وأن يُضيف هذا المخلوط ليدمج فيه توابِع أخرى، هكذا: class User extends Person { // ... } Object.assign(User.prototype, sayHiMixin); يمكن أن تستفيد المخاليط من الوراثة بينها ذاتها المخاليط. فمثلًا، نرى هنا كيف يرث sayHiMixin المخلوطَ sayMixin: let sayMixin = { say(phrase) { alert(phrase); } }; let sayHiMixin = { __proto__: sayMixin, // (أو نستعمل Object.create لضبط كائن prototype هنا) sayHi() { // نستدعي التابِع الأب super.say(`Hello ${this.name}`); // (*) }, sayBye() { super.say(`Bye ${this.name}`); // (*) } }; class User { constructor(name) { this.name = name; } } // ننسخ التوابِع Object.assign(User.prototype, sayHiMixin); // الآن يمكن أن يرحّب المستخدم بالناس new User("Dude").sayHi(); // مرحبًا يا Dude! لاحظ كيف يبحث استدعاء التابِع الأب super.say() في sayHiMixin (في الأسطر عند (*)) - كيف يبحث عن التابِع داخل كائن prototype المخلوطَ وليس داخل الصنف. إليك صورة (طالع الجانب الأيمن فيها): يعزو ذلك إلى أنّ التابِعين sayHi وsayBye أُنشآ في البداية داخل sayHiMixin. فحتّى حين ننسخهما تُشير خاصية [[HomeObject]] الداخلية فيهما إلى sayHiMixin كما نرى في الصورة. وطالما يبحث super عن التوابِع الأب في [[HomeObject]].[[Prototype]] يكون البحث داخل sayHiMixin.[[Prototype]] وليس داخل User.[[Prototype]]. مخلوط الأحداث EventMixin الآن حان وقت استعمال المخاليط في الحياة العملية. يُعدّ توليد الأحداث ميزة مهمّة جدًا للكائنات بشكل عام، وكائنات المتصفّحات بشكل خاص. وتُعدّ هذه الأحداث الطريقة المُثلى ”لنشر المعلومات“ لمن يريدها. لذا هيًا نصنع مخلوطًا يُتيح لنا إضافة دوال أحداث إلى أيّ صنف أو كائن. سيقدّم المخلوط تابِعًا باسم .trigger(name, [...data]) ”يُولّد حدثًا“ متى حدث شيء مهم. وسيط الاسم name هو… اسم الحدث، وبعده تأتي بيانات الحدث إن احتجناها. كما والتابِع .on(name, handler) الذي سيُضيف دالة ”معاملة“ handler لتستمع إلى الأحداث حسب الاسم الذي مرّرناه. ستُستدعى الدالة متى تفعّل الحدث بالاسم name وسيُمرّر لها الوسطاء في استدعاء .trigger. وأيضًا… التابِع .off(name, handler) الذي سيُزيل المستمع handler. بعدما نُضيف المخلوط سيقدر كائن المستخدم user على توليد حدث ولوج "login" متى ولج الزائر إلى الموقع. ويمكن لكائن آخر (لنقل التقويم calendar) الاستماع إلى هذا الحدث للقيام بمهمة ما (مثلًا تحميل تقويم المستخدم الذي ولج). أو يمكن أن تُولّد القائمة menu حدثَ الاختيار "select" متى اختار المستخدم عنصرًا منها، ويمكن للكائنات الأخرى إسناد ما تحتاج من معالجات للاستماع إلى ذلك الحدث. وهكذا. إليك الشيفرة: let eventMixin = { /** *طريقة الانضمام إلى الحدث * menu.on('select', function(item) { ... } */ on(eventName, handler) { if (!this._eventHandlers) this._eventHandlers = {}; if (!this._eventHandlers[eventName]) { this._eventHandlers[eventName] = []; } this._eventHandlers[eventName].push(handler); }, /** * طريقة إلغاء الانضمام إلى الحدث * menu.off('select', handler) */ off(eventName, handler) { let handlers = this._eventHandlers && this._eventHandlers[eventName]; if (!handlers) return; for (let i = 0; i < handlers.length; i++) { if (handlers[i] === handler) { handlers.splice(i--, 1); } } }, /** * توليد حدث من خلال الاسم والبيانات المعطاة * this.trigger('select', data1, data2); */ trigger(eventName, ...args) { if (!this._eventHandlers || !this._eventHandlers[eventName]) { return; // no handlers for that event name } // call the handlers this._eventHandlers[eventName].forEach(handler => handler.apply(this, args)); } }; .on(eventName, handler) -- يضبط الدالة handler لتُشغّل متى ما حدث الحدث بهذا الاسم. تقنيًا تُخزّن خاصية _eventHandlerslrm مصفوفة من دوال المعاملة لكلّ حدث، وتُضيف الدالة إلى القائمة. .off(eventName, handler) -- يحذف الدالّة handler من القائمة. .trigger(eventName, ...args) -- يولّد حدث: كلّ المعاملات تُستدعى من _eventHandlers[eventName] مع قائمة الوسطاء ...args. الاستعمال: // أنشئ صنف class Menu { choose(value) { this.trigger("select", value); } } // أضف مخلوط مع التوابع المرتبط به Object.assign(Menu.prototype, eventMixin); let menu = new Menu(); // أضف معالج حدث من أجل أن يستدعى عند الإختيار *!* menu.on("select", value => alert(`Value selected: ${value}`)); */!* // ينطلق الحدث=> يُشغّل المعالج مباشرةً ويعرُض // القيمة المختارة:123 menu.choose("123"); يمكننا الآن أن نحوّل أيّ شيفرة لتتفاعل متى ما اخترنا شيئًا من القائمة بالاستماع إلى الحدث عبر menu.on(...). كما ويُسهّل المخلوط eventMixin من إضافة هذا السلوك (أو ما يشابهه) إلى أيّ صنف نريد دون أن نتدخّل في عمل سلسلة الوراثة. خلاصة مصطلح المخلوط في البرمجة كائنية التوجّه: صنف يحتوي على توابِع نستعملها في أصناف أخرى. بعض اللغات تتيح لنا الوراثة من أكثر من صنف، أمّا جافاسكربت فلا تتيح ذلك، ولكن يمكننا التحايل على الأمر وذلك بنسخ التوابع إلى النموذج الأولي prototype. يمكننا استعمال المخاليط بطريقة لتوسيع الصنف وذلك بإضافة سلوكيات متعددة إليه، مثل: معالج الأحداث كما رأينا في المثال السابق. من الممكن أن تصبح المخاليط نقطة تضارب إذا قاموا بدون قصد بإعادة تعريف توابع الأصناف. لذا عمومًا يجب التفكير مليًا بأسماء المخاليط لتقليل احتمالية حدوث ذلك. ترجمة -وبتصرف- للفصل Mixins من كتاب The JavaScript language
-
يُتيح لنا المُعامل instanceof (أهو سيرورة من) فحص هل الكائن ينتمي إلى الصنف الفلاني؟ كما يأخذ الوراثة في الحسبان عند الفحص. توجد حالات عديدة يكون فيها هذا الفحص ضروريًا. **سنستعمله هنا لصناعة دالة **، أي دالة تغيّر تعاملها حسب نوع الوسطاء الممرّرة لها. معامل instanceof صياغته هي: obj instanceof Class يُعيد المُعامل قيمة true لو كان الكائن obj ينتمي إلى الصنف Class أو أيّ صنف يرثه. مثال: class Rabbit {} let rabbit = new Rabbit(); // هل هو كائن من الصنف Rabbit؟ alert( rabbit instanceof Rabbit ); // true نعم كما يعمل مع الدوال البانية: // بدل استعمال class function Rabbit() {} alert( new Rabbit() instanceof Rabbit ); // true نعم كما والأصناف المضمّنة مثل المصفوفات Array: let arr = [1, 2, 3]; alert( arr instanceof Array ); // true نعم alert( arr instanceof Object ); // true نعم لاحظ كيف أنّ الكائن arr ينتمي إلى صنف الكائنات Object أيضًا، إذ ترث المصفوفات -عبر prototype- الكائناتَ. عادةً ما يتحقّق المُعامل instanceof من سلسلة prototype عند الفحص. كما يمكننا وضع المنطق الذي نريد لكلّ صنف في التابِع الثابت Symbol.hasInstance. تعمل خوارزمية obj instanceof Class بهذه الطريقة تقريبًا: لو وجدت التابِع الثابت Symbol.hasInstance تستدعيه وينتهي الأمر: Class[Symbol.hasInstance](obj). يُعيد التابِع إمّا true وإمّا false. هكذا نخصّص سلوك المُعامل instanceof. مثال: // ضبط instanceOf للتحقق من الافتراض القائل // بأن كل شيء يملك الخاصية canEat هو حيوان class Animal { static [Symbol.hasInstance](obj) { if (obj.canEat) return true; } } let obj = { canEat: true }; alert(obj instanceof Animal); // true // تستدعى Animal[Symbol.hasInstance](obj) ليس لأغلب الأصناف التابِع Symbol.hasInstance. في هذه الحالة تستعمل المنطق العادي: يفحص obj instanceOf Class لو كان كائن Class.prototype مساويًا لأحد كائنات prototype في سلسلة كائنات prototype للكائن obj. وبعبارة أخرى ، وازن بينهم واحدًا تلو الآخر: obj.__proto__ === Class.prototype? obj.__proto__.__proto__ === Class.prototype? obj.__proto__.__proto__.__proto__ === Class.prototype? ... // لو كانت إجابة أيًا منها true، فتُعيد true // وإلّا متى وصلت نهاية السلسلة أعادت false في المثال أعلاه نرى rabbit.__proto__ === Rabbit.prototype، بذلك تُعطينا الجواب مباشرةً. أمّا لو كنّا في حالة وراثة، فستتوقّف عملية المطابقة عند الخطوة الثانية: class Animal {} class Rabbit extends Animal {} let rabbit = new Rabbit(); alert(rabbit instanceof Animal); // (هنا) نعم // rabbit.__proto__ === Rabbit.prototype // rabbit.__proto__.__proto__ === Animal.prototype (تطابق!) إليك صورة توضّح طريقة موازنة rabbit instanceof Animal مع Animal.prototype: كما وهناك أيضًا التابِع objA.isPrototypeOf(objB)، وهو يُعيد true لو كان الكائن objA في مكان داخل سلسلة prototype للكائن objB. يعني أنّنا نستطيع كتابة الفحص هذا obj instanceof Class هكذا: Class.prototype.isPrototypeOf(obj). الأمر مضحك إذ أنّ باني الصنف Class نفسه ليس لديه أيّ كلمة عن هذا الفحص! المهم هو سلسلة prototype وكائن Class.prototype فقط. يمكن أن يؤدّي هذا إلى عواقب مثيرة متى تغيّرت خاصية prototype للكائن بعد إنشائه. طالع: function Rabbit() {} let rabbit = new Rabbit(); // غيّرنا كائن prototype Rabbit.prototype = {}; // ...لم يعد أرنبًا بعد الآن! alert( rabbit instanceof Rabbit ); // false لا التابع Object.prototype.toString للأنواع نعلم بأنّ الكائنات العادية حين تتحوّل إلى سلاسل نصية تصير [object Object]: let obj = {}; alert(obj); // [object Object] alert(obj.toString()); // كما أعلاه يعتمد هذا على طريقة توفيرهم لتنفيذ التابِع toString. ولكن هناك ميزة مخفيّة تجعل هذا التابِع أكثر فائدة بكثير ممّا هو عليه، أن نستعمله على أنّه مُعامل typeof موسّع المزايا، أو بديلًا عن التابِع toString. تبدو غريبة؟ أليس كذلك؟ لنُزل الغموض. حسب المواصفة، فيمكننا استخراج التابِع toString المضمّن من الكائن وتنفيذه في سياق أيّ قيمة أخرى نريد، وسيكون ناتجه حسب تلك القيمة. لو كان عددًا، فسيكون [object Number] لو كان قيمة منطقية، فسيكون [object Boolean] لو كان null: [object Null] لو كان undefined: [object Undefined] لو كانت مصفوفة: [object Array] …إلى آخره (ويمكننا تخصيص ذلك). هيًا نوضّح: // ننسخ التابِع toString إلى متغير ليسهل عملنا let objectToString = Object.prototype.toString; // ما هذا النوع؟ let arr = []; alert( objectToString.call(arr) ); // [object Array] مصفوفة! استعملنا هنا call كما وضّحنا في درس ”المزخرفات والتمرير“ لتنفيذ الدالة objectToString بسياق this=arr. تفحص خوارزمية toString داخليًا قيمة this وتُعيد الناتج الموافق لها. إليك أمثلة أخرى: let s = Object.prototype.toString; alert( s.call(123) ); // [object Number] alert( s.call(null) ); // [object Null] alert( s.call(alert) ); // [object Function] Symbol.toStringTag يمكننا تخصيص سلوك التابِع toString للكائنات باستعمال خاصية الكائنات Symbol.toStringTag الفريدة. مثال: let user = { [Symbol.toStringTag]: "User" // مستخدم }; alert( {}.toString.call(user) ); // [object User] // لاحظ لأغلب الكائنات الخاصّة بالبيئات خاصية مثل هذه. إليك بعض الأمثلة من المتصفّحات مثلًا: // تابِع toStringTag للكائنات والأصناف الخاصّة بالمتصفّحات: alert( window[Symbol.toStringTag]); // window alert( XMLHttpRequest.prototype[Symbol.toStringTag] ); // XMLHttpRequest alert( {}.toString.call(window) ); // [object Window] alert( {}.toString.call(new XMLHttpRequest()) ); // [object XMLHttpRequest] كما نرى فالناتج هو تمامًا ما يقوله Symbol.toStringTag (لو وُجد) بغلاف [object ...]. في النهاية نجد أن لدينا "نوع من المنشطات" لا تعمل فقط على الأنواع الأولية للبيانات بل وحتى الكائنات المضمنة في اللغة ويمكننا تخصيصها أيضًا. يمكننا استخدام {}.toString.call بدلًا من instanceof للكائنات المضمنة في اللغة عندما نريد الحصول على نوع البيانات كسلسلة بدلًا من التحقق منها. خلاصة لنلخّص ما نعرف عن التوابِع التي تفحص الأنواع: يعمل على يُعيد typeof الأنواع الأولية سلسلة نصية {}.toString الأنواع الأولية والكائنات المضمّنة والكائنات التي لها Symbol.toStringTag سلسلة نصية instanceof الكائنات true/false كما نرى فعبارة {}.toString هي تقنيًا typeof ولكن ”متقدّمة أكثر“. بل إن التابع instanceof يؤدي دور مميّز عندما نتعامل مع تسلسل هرمي للأصناف ونريد التحقق من صنفٍ ما مع مراعاة الوراثة. تمارين instnaceof غريب عجيب الأهمية: 5 في الشيفرة أسفله، لماذا يُعيد instanceof القيمة true. يتّضح جليًا بأنّ B() لم يُنشِئ a. function A() {} function B() {} A.prototype = B.prototype = {}; let a = new A(); // هنا alert( a instanceof B ); // true الحل أجل، غريب عجيب حقًا. ولكن كما نعرف فلا يكترث المُعامل instanceof بالدالة، بل بكائن prototype لها حيث تُطابقه مع غيره في سلسلة prototype. وهنا نجد a.__proto__ == B.prototype، بذلك يُعيد instanceof القيمة true. إذًا فحسب منطق instanceof، كائن prototype هو الذي يُعرّف النوع وليس الدالة البانية. ترجمة -وبتصرف- للفصل Class checking: "instanceof" من كتاب The JavaScript language 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; }
-
يمكننا توسيع الأصناف المضمّنة مثل المصفوفات والخرائط وغيرها. فمثلًا يرث صنف PowerArray من المصفوفة Array الأصيلة: // نُضيف تابِعًا آخر إليها (أو أكثر لو أردنا) class PowerArray extends Array { isEmpty() { return this.length === 0; } } let arr = new PowerArray(1, 2, 5, 10, 50); alert(arr.isEmpty()); // false let filteredArr = arr.filter(item => item >= 10); alert(filteredArr); // 10, 50 alert(filteredArr.isEmpty()); // false من الجدير بالملاحظة أن التوابع المضمّنة في اللغة مثل: filter و map وغيرهم، تعيد كائنات جديدة من نفس النوع الموروث بالضبط PowerArray. وذلك لأن التطبيق الداخلي للتوابع يستخدم الباني المخصص لتلك الكائنات. في المثال أعلاه: arr.constructor === PowerArray متى استدعينا arr.filter() أنشأ التابِع داخليًا مصفوفةً جديدة من النتائج باستعمال arr.constructor ذاتها، وليس المصفوفة العادية Array. هذا أمر رائع جدًا إذ يمكننا استعمال توابِع PowerArray على النتائج أيضًا. يمكننا حتّى تخصيص هذا السلوك كما نرغب. فنُضيف جالِبًا ثابتًا Symbol.species إلى الصنف. لو كان موجودًا فسيُعيد الباني الذي ستستعمله جافاسكربت داخليًا لإنشاء المدخلات الجديدة في التوابِع map و filter وغيرها. لو أردنا من التوابِع المضمّنة مثل map و filter - لو أردنا أن تُعيد المصفوفات الطبيعية، فعلينا إعادة صنف Array في رمز Symbol.species هكذا: class PowerArray extends Array { isEmpty() { return this.length === 0; } // ستستعمل التوابِع المضمّنة هذا الصنف ليكون بانيًا static get [Symbol.species]() { return Array; } } let arr = new PowerArray(1, 2, 5, 10, 50); alert(arr.isEmpty()); // false // يصنع التابِع filter مصفوفةً جديدة باستعمال arr.constructor[Symbol.species] بانيًا let filteredArr = arr.filter(item => item >= 10); // نوع filteredArr ليس PowerArray بل Array alert(filteredArr.isEmpty()); // Error: filteredArr.isEmpty is not a function كما نرى فالآن يُعيد التابِع .filter مصفوفةً Array. بذلك تلك الميزة الموسّعة لن تُمرّر أكثر وأكثر. ملاحظة: هنالك مجموعات أخرى تعمل بنفس الطريقة مثل: Map أو Set ويستخدمون أيضًا Symbol.species. الأنواع المضمّنة لا ترث الثوابت للكائنات المضمّنة أيضًا توابِع ثابتة مثل Object.keys و Array.isArray وغيرها. وكما نعلم فالأصناف الأصيلة تُوسّع نفسها. فمثلًا تُوسّع المصفوفات Array الكائناتَ Object. وعادةً متى وسّع الصنف صنفًا آخر ورث التوابِع الثابتة وغير الثابتة. شرحنا هذا بالتفصيل في المقال ”الخاصيات والتوابع الثابتة“. ولكن الأصناف المضمّنة في اللغة استثناء لهذا، ولا ترث الحقول الثابتة من بعضها البعض. مثال: ترث المصفوفات والتواريخ الكائناتَ، بذلك نرى لسيروراتها توابِع أتت من Object.prototype. ولكنّ كائن Array.[[Prototype]] لا يُشير إلى Array، بذلك لا نرى توابِع ثابتة مثل Array.keys() أوArray.keys() مثلًا. تغني الصورة عن ألف كلمة، إليك واحدة توضّح بنية التواريخ Date والكائنات Object: كما ترى فليس هناك رابط بين التواريخ والكائنات، فهي مستقلة بذاتها. كائن Date.prototype فقط هو من يرث Object.prototype. هذا فرق مهمّ للوراثة بين الكائنات المضمّنة في اللغة وتلك التي نحصل عليها باستعمال extends. ترجمة -وبتصرف- للفصل Extending built-in classes من كتاب The JavaScript language
-
في البرمجة كائنية التوجّه، يُعدّ أكثر المبادئ أهمية هو فصل الواجهة الداخلية عن الخارجية. هذا المبدأ ”إلزامي“ متى ما طوّرنا تطبيقًا يفوق تطبيقات ”أهلًا يا عالم“ تعقيدًا. سنضع البرمجة ”على جنب“ ونرى الواقع؛ لنفهم هذا المبدأ. الأجهزة الّتي نستعملها يوميًا عادةً ما تكون معقّدة، إلّا أنّ فصل واجهتها الداخلية عن تلك الخارجية يُتيح لنا استعمالها دون مشاكل تُذكر. مثال من الحياة العملية لنقل مثلًا آلة صنع القهوة، من الخارج بسيطة: زرّ تشغيل وشاشة عرض وبعض الفتحات وطبعًا لا ننسى الناتج… القهوة المميّزة الطعم! ولكن من الداخل نرى… (هذه الصورة من كتيّب التصليح) نرى تفاصيل وتفاصيل ولا شيء إلّا التفاصيل، ولكنّنا نستعملها دون أيّ فكرة عن تلك التفاصيل. يمكننا أن نثق بجودة آلات صنع القهوة، ألا توافقني الرأي؟ فنحن نستعملها لسنوات وسنوات وإن ظهر أيّ عُطل، أخذناها للتصليح. يكمن سرّ هذه الثقة وهذه البساطة لآلات صنع القهوة في أنّ التفاصيل كلّها مضبوطة كما يجب ومخفية داخلها. إن أزلنا غطاء الحماية من الآلة وحاولنا استعمالها لكان الأمر أكثر تعقيدًا (أيّ زرّ نضغط؟) وخطرًا (الصدمات الكهربية). كما سنرى أسفله، فالكائنات في البرمجة تشبه تمامًا آلات صنع القهوة. ولكن لنُخفي التفاصيل الداخلية لن نستعمل غطاء حماية بل صياغة خاصّة في اللغة، كما وما هو متّفق عليه بين الناس. الواجهتان الداخلية والخارجية في البرمجة كائنية التوجّه، تنقسم الخاصيات والتوابِع إلى قسمين: واجهة داخلية (Internal interface): فيها التوابِع والخاصيات الّتي يمكن أن تصل إليها توابِع هذا الصنف، ولا يمكن أن يصل إليها ما خارج هذا الصنف. واجهة خارجية (External interface): فيها التوابِع والخاصيات الّتي يمكن أن يصل إليها ما خارج هذا الصنف أيضًا. لو واصلنا الشرح على آلة القهوة تلك، فهذا بعض ممّا هو مخفي: أنبوب غلي، عنصر التسخين، وغيرهما من العناصر الداخلية التي تشكل الواجهة الداخلية الخلفية الخفية حيث نستعمل الواجهة الداخلية ليعمل هذا الشيء (أيًا كان)، إذ ترى تفاصيله هذه تلك. ولكن من الخارج نجد أن آلة القهوة لها غلاف محكم الإغلاق فلا يمكن لأحد أن يصل إلى تلك التفاصيل، فتكون مخفيّة ولا يمكن استعمال ميزات الآلة إلا عبر الواجهة الخارجية. إذًا، ما نريده لنستعمل الكائنات هو معرفة واجهته الخارجية. أمّا معرفة طريقة عمله بالتفاصيل الدقيقة ليس أمرًا مهمًا أبدًا، وهذا في الواقع تسهيل عظيم. شرحنا الآن الفكرة بشكلٍ عام. أمّا في جافاسكربت فهناك نوعين من حقول الكائنات (الخاصيات والتوابِع): عامّة: يمكن أن نصل إليها من أيّ مكان، وهي تصنع تلك الواجهة الخارجية. حتّى اللحظة في هذا الكتاب كنّا نستعمل الخاصيات والتوابِع العموميّة فقط. خاصّة: يمكن أن نصل إليها من داخل الصنف فقط، وهي تصنع الواجهة الداخلية. كما هناك في اللغات الأخرى حقول ”محميّة“: أي نصل إليها من داخل الصنف ومن أيّ صنف آخر يوسّعه (تعمل مثل الخاصّة، علاوةً على الوصول إليها من الأصناف الموروثة). هذه الحقول مفيدة جدًا للواجهة الداخلية، وهي منطقيًا أكثر استعمالًا من الخاصّة إذ حين نرث صنفًا نريدُ أن نصل إلى تلك الحقول الّتي فيه عادةً. ليس هناك تعريف لتنفيذ هذه الحقول في جافاسكربت على مستوى اللغة، ولكنّها عمليًا تُسهّل الأمور كثيرًا، بذلك نحاكي عملها. الآن حان وقت صناعة آلة قهوة بجافاسكربت مستعملين تلك الأنواع من الخاصيات. طبعًا لآلة القهوة تفاصيل عديدة ولن نضعها كلها في صنفنا لتسهيل الأمور (ولكن ما من مانع لتفعل أنت ذلك). حماية ”مقدار الماء“ لنصنع أولًا صنفًا بسيطًا لآلة قهوة: class CoffeeMachine { waterAmount = 0; // مقدار الماء في الآلة constructor(power) { this.power = power; alert( `Created a coffee-machine, power: ${power}` ); // صنعنا آلة قهوة بقوّة كذا } } // نصنع آلة قهوة واحدة let coffeeMachine = new CoffeeMachine(100); // نُضيف إليها الماء coffeeMachine.waterAmount = 200; حاليًا فخاصيتي مقدار الماء waterAmount والقوّة power عامّتين، ويمكننا بسهولة جلبهما أو ضبطهما من خارج الصنف لأيّ قيمة نريد. لنغيّر خاصية waterAmount فتصير محميّة، بذلك يكون في يدنا التحكّم. لنقل مثلًا بأنّه يُمنع ضبط القيمة إلى ما دون الصفر. عادةً ما نضع شَرطة سفلية _ قبل أسماء الخاصيات المحميّة. لا تفرض اللغة هذا الأمر ولكنّه اتّفاق بين معشر المطوّرين بأنّه ممنوع الوصول إلى هذه الخاصيات والتوابِع من خارج الأصناف. إذًا، ستكون الخاصية باسم _waterAmount: class CoffeeMachine { _waterAmount = 0; set waterAmount(value) { if (value < 0) throw new Error("Negative water"); // قيمة الماء بالسالب this._waterAmount = value; } get waterAmount() { return this._waterAmount; } constructor(power) { this._power = power; } } // نصنع آلة القهوة المميّزة let coffeeMachine = new CoffeeMachine(100); // نُضيف الماء coffeeMachine.waterAmount = -10; // خطأ: قيمة الماء بالسالب الآن صرنا نتحكّم بالوصول إلى الخاصية، ومحاولة ضبط قيمة الماء إلى ما دون الصفر ستفشل. ”القوّة“ للقراءة فقط أمّا عن خاصية القوّة power فسنجعلها للقراءة فقط. نواجه أحيانًا مواقف حيث تُضبط الخاصية أثناء إنشاء الصنف فقط، ويُمنع تعديلها قطعيًا بعد ذلك. هذه هي حالة آلة القهوة هنا، إذ قوّة الآلة لا تتغيّر أبدًا. لذلك سنصنع جالبًا فقط دون ضابِط: class CoffeeMachine { // ... constructor(power) { this._power = power; } get power() { return this._power; } } // نصنع آلة القهوة let coffeeMachine = new CoffeeMachine(100); alert(`Power is: ${coffeeMachine.power}W`); // القوّة هي: 100 واط coffeeMachine.power = 25; // خطأ (لا ضابِط) ملاحظة:استخدامنا هنا صياغة الجوالِب/الضوابِط (Getter/setter) الإفتراضية ولكن دوالّ get.../set... مفضلة في معظم الأحيان. هكذا: class CoffeeMachine { _waterAmount = 0; setWaterAmount(value) { if (value < 0) throw new Error("Negative water"); this._waterAmount = value; } getWaterAmount() { return this._waterAmount; } } new CoffeeMachine().setWaterAmount(100); تبدو هذه الصياغة أطول قليلًا ولكن الدوالّ هنا أكثر مرونة. إذ يمكننا تمرير عدة وسطاء لهم (حتى ولو لم نحتاج لهذا الأمر في هذا المثال). من ناحية أخرى إن صياغة الإفتراضية أقصر لذا لا توجد قاعدة صارمة للأمر، ولك مطلق الحرية في الاختيار. ملاحظة: الخاصيات المحميّة موروثة. إذا ورثنا class MegaMachine extends CoffeeMachine، فلا شيء سيمنعنا من الوصول إلى this._waterAmount أو this._power من توابع الصنف الجديد. لذا فالوضع الطبيعي للخاصيات المحمية أن تكون قابلة للتوريث. على عكس الخاصيات الخاصة والّتي سنراها أدناه. ”#حدّ الماء“ خاصّة انتبه: إضافة حديثة انتبه رجاءً إلى أن هذه الميزة أضيفت حديثًا إلى اللغة، لذا هي ليست مدعومة على محركات جافاسكربت بعد ولا حتى جزئيًا وتحتاج إلى ترقيع يسد نقص الدعم هذا (polyfilling). هناك مُقترح للغة جافاسكربت (كاد أن يصل إلى المعيار) يُقدّم لنا دعمًا على مستوى اللغة للخاصيات والتوابِع الخاصة. تبدأ الخاصيات والتوابِع بعلامة #، ولا يمكن الوصول إليها إلّا من داخل الصنف. فمثلًا إليك خاصية #waterLimit الخاصّة وتابِع فحص مستوى الماء #checkWater الخاصّ: class CoffeeMachine { #waterLimit = 200; // هنا // هذا #checkWater(value) { if (value < 0) throw new Error("Negative water"); // قيمة الماء بالسالب if (value > this.#waterLimit) throw new Error("Too much water"); // قيمة الماء فوق اللزوم } } let coffeeMachine = new CoffeeMachine(); // محال الوصول إلى الخاصيات والتوابِع الخاصّة من خارج الصنف coffeeMachine.#checkWater(); // Error coffeeMachine.#waterLimit = 1000; // Error علامة # على مستوى اللغة هي علامة خاصّة تقول بأنّ هذا الحقل حقلٌ خاصّ، وتمنع أيّ شيء من الوصول إليه من الخارج أو من الأصناف الموروثة. كما وأنّ الحقول الخاصّة لا تتضارب مع تلك العامّة: يمكن أن نضع حقلين خاصًا #waterAmount وعامًا waterAmount في آنٍ واحد. فمثلًا ليكن waterAmount خاصية وصول للخاصية #waterAmount: class CoffeeMachine { #waterAmount = 0; get waterAmount() { return this.#waterAmount; } set waterAmount(value) { if (value < 0) throw new Error("Negative water"); // قيمة الماء بالسالب this.#waterAmount = value; } } let machine = new CoffeeMachine(); machine.waterAmount = 100; alert(machine.#waterAmount); // خطأ وعلى عكس الحقول المحميّة، فتلك الخاصّة تفرضها اللغة نفسها، وهذا أمر طيّب. ولكن لو ورثنا شيئًا من CoffeeMachine فلن يمكننا الوصول إلى #waterAmount مباشرةً، بل الاعتماد على جالِب وضابِط waterAmount: class MegaCoffeeMachine extends CoffeeMachine { method() { alert( this.#waterAmount ); // خطأ: يمكنك أن تصل إليه من داخل CoffeeMachine فقط } } هناك حالات عديدة يكون فيها هذا القيد صارم كثيرًا. حين نُوسّع CoffeeMachine يكون لدينا أسباب منطقية لنصل إلى خوارزمياته الداخلية. لهذا السبب فالحقول المحميّة أكثر فائدة بكثير حتّى لو لم تدعمها صياغة اللغة. تحذير: لا يمكنك الوصول إلى الحقول الخاصّة هكذا: this[name] إذ عادةً ما يمكننا الوصول إلى الحقول العادية هكذا : this[name] class User { ... sayHi() { let fieldName = "name"; alert(`Hello, ${this[fieldName]}`); } } ولكن مع الحقول الخاصة من المحال الوصول إليها this['#name'] فإنها لن تعمل. وذلك بسبب القيد الموجود على صياغة استدعاء الحقول الخاصة لضمان خصوصية الحقل. خلاصة لو استعرنا تسميات البرمجة كائنية التوجّه، فعملية فصل الواجهة الداخلية عن الخارجية تُسمّى تغليف (Encapsulation)، وهي تقدّم لنا فوائد منها: حماية المستخدمين كي لا يضروا أنفسهم: تخيّل فريق مطوّرين يستعمل آلةً للقهوة صنعتها شركة ”Best CoffeeMachine“، وهي تعمل كما يجب لكنّها دون غطاء حماية، أيّ أنّ الواجهة الداخلية مكشوفة للعيان. المطوّرون متحضّرون فيستعملون الآلة كما يفترض استعمالها، ولكنّ أحدهم (واسمه John) قال بأنّه الأذكى بين الجميع وعدّل على ميكانيكا الآلة الداخلية قليلًا. بعد يومين، لم تعد تعمل. هذه ليست غلطة John طبعًا، بل الشخص الّذي أزال غطاء الحماية وسمح لأشخاص (مثل John) أن يقوموا بهذه التعديلات. الأمر ذاته في البرمجة. فلو غيّر أحد مستخدمي الصنف أشياء لا يُفترض تغييرها من خارج الكائن، يكون التكهّن بعواقب ذلك مستحيلًا. تحظى بالدعم: تختلف البرمجة عن آلة القهوة الحقيقية إذ الأولى معقّدة أكثر لأنّنا لا نشتريها مرّة واحدة فقط، بل تمرّ الشيفرة في مراحل تطوير وتحسين عديدة لا تنتهي أحيانًا. إن أجبرنا فصل الواجهة الداخلية عن الخارجية، فيمكن للمطوّر تغيير خاصيات الأصناف وتوابِعها الداخلية كما يشاء دون أن يُبلغ مستخدميها حتّى. لو كنت تطوّر صنفًا مثل هذا، ففكرة أنّ تغيير اسم التوابِع الخاصة أو تغيير مُعاملتها أو حتّى إزالتها - فكرة رائعة إذ ليست هناك أيّ شيفرة خارجية تعتمد عليها. أمّا للمستخدمين، فلو صدرت نسخة جديدة (وقد تكون كتابة كاملةً داخليًا) سيكون سهلًا جدًا الترقية إليها لو بقيت الواجهة الخارجية كما هي. إخفاء التعقيد: يعشق الناس استعمال ما هو بسيط، على الأقلّ من الخارج. الداخل لا يهمّ. وليس المبرمج استثناءً دومًا ما يكون أفضل لو كانت تفاصيل التنفيذ مخفية، وكانت هناك واجهة خارجية بسيطة موثّقة كما يجب. لنُخفي الواجهات الداخلية، نستعمل إمّا الخاصيات المحميّة أو الخاصّة: تبدأ الحقول المحميّة بعلامة الشرطة السفلية _. هذا اتّفاق بين معشر المبرمجين وليس فرضًا من اللغة نفسها. على المبرمجين الوصول إلى الحقول الّتي تبدأ بهذه العلامة داخلَ الصنف والأصناف الموروثة فقط لا غير. تبدأ الحقول الخاصّة بعلامة #. تضمن لنا لغة جافاسكربت بأن لا يقدر أحد على الوصول إليها إلّا من داخل الصنف. حاليًا فالحقول الخاصّة ليست مدعومة تمامًا في المتصفّحات، ولكن يمكننا تعويض نقص الدعم هذا. ترجمة -وبتصرف- للفصل Private and protected properties and methods من كتاب The JavaScript language
-
يمكننا أيضًا إسناد التوابِع إلى دالة الصنف ذاتها وليس إلى كائن "prototype" لها. نسمّي هذه التوابِع بالتوابِع ”الثابتة“ (static). في الأصناف نضع بعدها كلمة static هكذا: class User { static staticMethod() { // لاحظ alert(this === User); } } User.staticMethod(); // true في الواقع، لا يفرق هذا عن إسنادها على أنّها خاصية بشيء: class User() { } User.staticMethod = function() { alert(this === User); }; User.staticMethod(); // true وتكون قيمة this في الاستدعاء User.staticMethod() هي باني الصنف User ذاته (تذكّر قاعدة ”الكائن قبل النقطة“), عادةً ما نستعمل التوابِع الثابتة لكتابة دوال تعود إلى الصنف نفسه وليس إلى أيّ كائن من ذلك الصنف. مثال للتوضيح: لدينا كائنات مقالات Article ونريد دالة لموازنتها. الحل الطبيعي هو إضافة تابِع Article.compare هكذا: class Article { constructor(title, date) { this.title = title; this.date = date; } // هنا static compare(articleA, articleB) { return articleA.date - articleB.date; } } // الاستعمال let articles = [ new Article("HTML", new Date(2019, 1, 1)), new Article("CSS", new Date(2019, 0, 1)), new Article("JavaScript", new Date(2019, 11, 1)) ]; articles.sort(Article.compare); // لاحظ alert( articles[0].title ); // CSS نرى هنا التابِع Article.compare ”فوق“ المقالات فيتيح لنا طريقة لموازنتها. ليس التابِع تابِعًا للمقالة ذاتها، بل لصنف المقالات نفسه. توابِع ”المصانع“ (factory) تنفعها أيضًا التوابِع الثابتة هذه. لنقل بأنّا نريد إنشاء المقالات بطرائق عدّة: بتمرير المُعاملات (العنوان title والتاريخ date وغيرها). إنشاء مقالة فارغة تحمل تاريخ اليوم. أو… كما تريد أنت. يمكننا تنفيذ الطريقة الأولى باستعمال بانٍ، وللثانية صناعة تابِع ثابت للصنف. مثل التابِع Article.createTodays() هنا: class Article { constructor(title, date) { this.title = title; this.date = date; } static createTodays() { // لا تنسَ: this = Article return new this("Today's digest", new Date()); // موجز أحداث اليوم } } let article = Article.createTodays(); alert( article.title ); // موجز أحداث اليوم الآن متى أردنا صناعة موجز عن أحداث اليوم، استدعينا Article.createTodays(). أُعيد، ليس هذا تابِعًا للمقالة نفسها بل تابِعًا للصنف كله. كما نستعمل التوابِع الثابتة في أصناف قواعد البيانات للبحث عن المُدخلات وحفظها وإزالتها، هكذا: // بفرض أنّ Article هو صنف مخصّص لإدارة المقالات // نستعمل تابِعًا ثابتًا لإزالة المقالة: Article.remove({id: 12345}); الخاصيات الثابتة إضافة حديثة انتبه رجاءً إلى أنه هذه الميزة مضافة حديثًا إلى اللغة، لذا لن تعمل الأمثلة إلى في الإصدارات الحديث من المتصفح كروم. كما يمكننا أيضًا استعمال الخاصيات الثابتة. ظاهرها فهي خاصيات للأصناف، ولكن نضع قبلها عبارة static: class Article { static publisher = "Ilya Kantor"; } alert( Article.publisher ); // Ilya Kantor لا يفرق هذا عن الإسناد المباشر إلى صنف Article: Article.publisher = "Ilya Kantor"; وراثة الخاصيات والتوابِع الثابتة هذه الأنواع من الخاصيات والتوابِع فمثلًا التابِع Animal.compare والخاصية Animal.planet في الشيفرة أسفله موروثين بالأسماء Rabbit.compare و Rabbit.planet: class Animal { static planet = "Earth"; // الأرض constructor(name, speed) { this.speed = speed; this.name = name; } run(speed = 0) { this.speed += speed; alert(`${this.name} runs with speed ${this.speed}.`); } static compare(animalA, animalB) { return animalA.speed - animalB.speed; } } // نرث من الصنف Animal class Rabbit extends Animal { hide() { alert(`${this.name} hides!`); } } let rabbits = [ new Rabbit("White Rabbit", 10), new Rabbit("Black Rabbit", 5) ]; rabbits.sort(Rabbit.compare); // لاحظ rabbits[0].run(); // Black Rabbit runs with speed 5. alert(Rabbit.planet); // الأرض الآن متى استدعينا Rabbit.compare، استدعى المحرّك التابِع Animal.compare الموروث. ولكن كيف يعمل هذا الشيء؟ كالعادة، بكائنات prototype: تُقدّم extends للصنف Rabbit إشارة [[Prototype]] إلى الصنف Animal. لذا فعبارة Rabbit extends Animal تصنع إشارتي [[Prototype]]: دالة Rabbit موروثة عبر prototype من دالة Animal. كائن Rabbit.prototype موروث عبر prototype من كائن Animal.prototype. بهذا تعمل الوراثة للتوابِع العادية والثابتة معًا. تشكّ؟ انظر للشيفرة: class Animal {} class Rabbit extends Animal {} // لكلّ ما هو ثابت alert(Rabbit.__proto__ === Animal); // true // للتوابِع العادية alert(Rabbit.prototype.__proto__ === Animal.prototype); // true خلاصة نستعمل التوابِع الثابتة لأيّة وظائف تخصّ الصنف ”كلّه على بعضه“، ولا يخصّ سيرورة معيّنة من الصنف. مثل تابع الموازنة Article.compare(article1, article2) أو تابع المصنع Article.createTodays(). ويصنفون كثوابت (static) في تعريف الصنف. نستعمل الخاصيات الثابتة متى أردنا تخزين البيانات على مستوى الصنف لا على مستوى السيرورات. صياغتها هي: class MyClass { static property = ...; static method() { ... } } تقنيًا فالتصريح الثابت (static declaration) لا يفرق عن الإسناد إلى الصنف مباشرةً: MyClass.property = ... MyClass.method = ... الخاصيات والدوال الثابتة الموروثة. من أجل العبارة class B extends A إن prototype للصنف B يشير إلى A:B.[[Prototype]] = A. لذا إن تعذر العثور على خاصية ما في الصنف B فسيستمر البحث في الصنف A. ترجمة -وبتصرف- للفصل Static properties and methods من كتاب The JavaScript language
-
تُعدّ وراثة الأصناف واحدةً من الطرائق لتوسعة أحد الأصناف لديك، أي أن نقدّم وظائف جديدة لأحد الأصناف علاوةً على ما لديه. عبارة التوسعة extends لنقل بأنّ لدينا صنف الحيوان Animal: class Animal { constructor(name) { this.speed = 0; this.name = name; } run(speed) { this.speed += speed; alert(`${this.name} runs with speed ${this.speed}.`); // يركض حيوان كذا بالسرعة كذا } stop() { this.speed = 0; alert(`${this.name} stands still.`); // يقف حيوان كذا في مكانه } } let animal = new Animal("My animal"); هكذا نصف كائن الحيوان animal وصنف الحيوان Animal في صورة: ولنقل بأنّنا نريد إضافة صنف آخر… ليكن أرنبًا class Rabbit. وطبعًا، فالأرانب حيوانات أيضًا، وعلى صنف الأرانب Rabbit أن يكون أساسه الحيوان Animal ليصل إلى توابِع الحيوانات فتقوم الأرانب بما تقوم به الحيوانات ”العادية“ (generic). صياغة توسعة الصنف إلى صنف آخر هي: class Child extends Parent (صنف الابن توسعة من صنف الأبّ). لنصنع صنف الأرنب class Rabbit ليرث الحيوان Animal: class Rabbit extends Animal { // لاحظ hide() { alert(`${this.name} hides!`); // اختفى هكذا! } } let rabbit = new Rabbit("White Rabbit"); // الأرنب الأبيض rabbit.run(5); // White Rabbit runs with speed 5. // يركض حيوان الأرنب بسرعة 5 rabbit.hide(); // White Rabbit hides! // اختفى الأرنب الأبيض! يمكن لكائن Rabbit الوصول إلى توابِع Rabbit (مثل rabbit.hide()) كما توابِع Animal (مثل rabbit.run()). داخليًا فعبارة extends تعمل كما تعمل ميكانيكية كائنات prototype المعهودة، فضتبط Rabbit.prototype.[[Prototype]] ليكون Animal.prototype. بهذا لو لم يوجد التابِع في كائن Rabbit.prototype، يأخذه المحرّك من Animal.prototype. فمثلًا لنجد التابِع rabbit.run يبحث المحرّك (من أسفل إلى أعلى، كما الصورة): كائن الأرنب rabbit (ليس فيه run). كائن prototype له، أي Rabbit.prototype (فيه hide وليس فيه run). كائن prototype له، أي (بسبب extends) Animal.prototype، بهذا يجد تابِع run أخيرًا. كما نذكر من فصل ”“، فمحرّك جافاسكربت نفسه يستعمل التوارث عبر prototype لكائناته المضمّنة في اللغة. فمثلًا كائن Date.prototype.[[Prototype]] هو الكائن Object.prototype. لهذا يمكن للتواريخ الوصول إلى توابِع الكائنات العادية. ملاحظة: تسمح صياغة الصنف ليس بتحديد الصنف فقط وإنما إضافة أي تعبير بعد عبارة extends. فمثلًا استدعاء التابع التاي سيبني صنف الأب. function f(phrase) { return class { sayHi() { alert(phrase) } } } class User extends f("Hello") {} new User().sayHi(); // Hello سيرثُ الصنف class User من النتيجة للصنف f("Hello"). وهذه الميزة مفيدة لكتابة الأنماط البرمجية المتقدمة عندما نستخدم تابع لينشئ صنف صنف اعتمادًا على عدة شروط والتي يمكن أن ترثها. إعادة تعريف دالة الآن نتحرّك ونعيد تعريف أحد التوابِع. مبدئيًا فكلّ التوابِع غير المحدّدة في صنف الأرانب تُؤخذ مباشرةً ”كما هي“ من صنف الحيوانات. ولكن لو حدّدنا تابِعًا معيّنًا في في Rabbit (وليكن stop()) فسيُستعمل بدله: class Rabbit extends Animal { stop() { // الآن سنسخدمها من أجل rabbit.stop() // بدلًا من استخدام stop() من الصنف مباشرة } } عادةً لا نرغب باستبدال كامل ما في التابِع الأب، بل البناء فوقه أو تعديله أو توسعة وظائفه، أي ننفّذ شيئًا في التابِع ثمّ نستدعي التابِع الأبّ قبل أو بعد ذلك. ولحسن الحظ فالأصناف تقدّم لنا عبارة "super". نستعمل super.method(...) لنستدعي تابِعًا أبًا. ونستدعي super(...) لنستدعي الباني الأب (هذا فقط لو كنّا في باني هذه الدالة). فمثلًا، ليختبئ هذا الأرنب النبه ما إن يتوقّف: class Animal { constructor(name) { this.speed = 0; this.name = name; } run(speed) { this.speed += speed; alert(`${this.name} runs with speed ${this.speed}.`); } stop() { this.speed = 0; alert(`${this.name} stands still.`); } } class Rabbit extends Animal { hide() { alert(`${this.name} hides!`); } // هنا stop() { super.stop(); // نستدعي stop في الأب this.hide(); // ثمّ نستدعي hide } } let rabbit = new Rabbit("White Rabbit"); rabbit.run(5); // سيركض "White Rabbit" بسرعة 5 rabbit.stop(); // توقف الأرنب .وهو الآن مختبئ الآن صار داخل الأرنب التابِع Rabbit الذي يستدعي التابِع stop من أباه كما شرحنا في درس "الدوال السهمية" لا يمكننا استخدام الكلمة المفتاحية super على الدوالّ السهمية. ولو استطعنا الوصول إليها من خلال الكلمة المفتاحية super فستكون مأخوذة من الدالّة الخارجية. هكذا: class Rabbit extends Animal { stop() { setTimeout(() => super.stop(), 1000); // استدعاء الأب سيتوقف بعد ثانية واحدة } } إن عمل الدلّة stop() مشابه تمامًا لعمل الكلمة المفتاحية super مع الدوال السهمية. لذا فإنها تعمل مثلما نريد. ولو حُددت كدالّة عادية فسيظهر لدينا خطأ: // Unexpected super setTimeout(function() { super.stop() }, 1000); إعادة تعريف الباني متى تعاملنا مع البانيات، صار الأمر لم يكن لصنف الأرنب Rabbit (حتّى اللحظة) أيّ بانٍ له. حسبما تقول المواصفة فلو وسّع أحد الأصناف صنفًا آخر ليس له بانيًا، فسيُولّد المحرّك هذا الباني ”الفارغ“: class Rabbit extends Animal { // يُولّد للأصناف التي تُوسِّع أخرى وليس فيها بانيات constructor(...args) { super(...args); } } كما نرى فهي تستدعي الباني constructor الأبّ بتمرير كلّ الوُسطاء إليه، فقط. لا يحدث هذا إلّا لو لم نكتب بانيًا في الصنف الابن. لنُضف الآن بانيًا من عندنا إلى الأرنب Rabbit. سيضبط هذا الباني الخاصية earLength علاوةً على name: class Animal { constructor(name) { this.speed = 0; this.name = name; } // ... } class Rabbit extends Animal { // هنا constructor(name, earLength) { this.speed = 0; this.name = name; this.earLength = earLength; } // ... } // لا تعمل! let rabbit = new Rabbit("White Rabbit", 10); // Error: this is not defined. ماذا؟! هناك خطأ! انتهى الأمر، ”صناعة الأرانب“ لم تعد ممكنة بعد الآن. تراه ما المشكلة؟ باختصار: على البانيات في الأصناف الموروثة استدعاء super(...) هذا أولًا، وثانيًا استدعاءه قبل استعمال this. ولكن لحظة… لماذا؟ ما الذي يجري؟ هذا المطلب غريب حقًا. بالطبع لا شيء بدون توضيح وشرح، لذا فلنتعمّق داخل التفاصيل ونفهم ما يجري ”حبّة حبّة“. تضع لغة جافاسكربت خطًا فاصلًا بين الدالة البانية للصنف الموروث (أي ”الباني المشتقّ“) وغيرها من دوال. لهذا الباني خاصية داخلية فريدة اسمها [[ConstructorKind]]:"derived"، وهي علامة يضعها المحرّك عليه داخليًا خلف الكواليس. تؤثّر هذه ”العلامة“ على سلوك الباني حين نستعمله مع new. حين نُنفّذ الدوال العادية باستعمال new، تُنشِئ لنا كائنًا فارغًا وتضبطه ليكون this. ولكن حين يعمل الباني المشتق، فلا يفعل ذلك، بل يتوقّع من الباني الأبّ القيام بهذه المهمة الصعبة. لذا على الباني المشتق استدعاء super ليُنفّذ باني أباه (غير المشتق) وإلّا فلن يُنشأ أيّ كائن يكون this، بهذا تكون الشيفرة خطأ. على باني الصنف Rabbit استدعاء super() قبل this ليعمل، هكذا تمامًا: class Animal { constructor(name) { this.speed = 0; this.name = name; } // ... } class Rabbit extends Animal { constructor(name, earLength) { super(name); // هنا this.earLength = earLength; } // ... } // الآن كلّ شيء كما يجب أن يكون let rabbit = new Rabbit("White Rabbit", 10); alert(rabbit.name); // White Rabbit alert(rabbit.earLength); // 10 عبارة super: أمور داخلية و[[HomeObject]] تحذير: سترى معلومات متقدّمة لو كنت تقرأ هذا الدرس أوّل مرّة فيمكنك تخطّي هذا الجزء، إذ يتكلّم عن الميكانيكا الداخلية التي يستعملاها التوارث وعبارة super. هيًا ننزل إلى الأعماق ونرى ما خلف كواليس super. في هذه الرحلة سنرى أمور جميلة أيضًا ”إكسترا سوبر“. لنوضّحها من البداية: لو أخذت كلّ ما تعلّمناه حتّى الحظة، فما من طريقة لتعمل فيها عبارة super في أيّ حال من الأحوال! تمامًا، كما فكّرت الآن، لنطرح السؤال: كيف تعمل هذه العبارة تقنيًا أساسًا؟ متى ما عمل أحد توابِع الكائنات، جلب الكائن الحالي على أنّه this. فلو استدعينا super.method() فكلّ ما على المحرّك فعله هو جلب التابِع method من كائن prototype للكائن الحالي، صحيح؟ أجل ولكن كيف ذلك؟ ربّما ترى المهمة سهلة ولكنّها ليست كذلك البتة. يمكن القول أنّ المحرّك يعلم بالكائن الحالي this، فيمكنه أن يأخذ تابِع method في الأب باستعمال this.__proto__.method. ولكن للأسف فهذا الحلّ ”البسيط“ لن يعمل أبدًا. لنوضّح المشكلة أولًا، باستعاضة الأصناف لتكون كائنات عادية لتسهيل الفهم. (لو لم تريد معرفة التفاصيل فتخطّى هذا القسم وانتقل إلى الجزء [[HomeObject]]، لا مشكلة. أو واصِل معنا في هذه الرحلة الموحشة في أعماق غابة لغة جافاسكربت.) في المثال أسفله، نرى rabbit.__proto__ = animal. لنجرّب الآن هذا الأمر: داخل rabbit.eat() نستدعي animal.eat() باستعمال this.__proto__: let animal = { name: "Animal", eat() { alert(`${this.name} eats.`); } }; let rabbit = { __proto__: animal, name: "Rabbit", eat() { // هذه إحدى طرق عمل super.eat() this.__proto__.eat.call(this); // (*) } }; rabbit.eat(); // Rabbit eats. أخذنا في السطر (*) التابِعَ eat من كائن prototype (animal) واستدعيناه على أنّ السياق هو الكائن الحالي. لاحظ أهمية .call(this) إذ لو كتبنا this.__proto__.eat() فقط فسيُنفّذ التابِع eat الأب داخل سياق كائنَ prototype، وليس في الكائن الحالي. وفي الجزء الأول من الشيفرة نرى كلّ شيء يعمل: عمل التابِع alert كما ينبغي عليه. حان وقت إضافة كائن آخر إلى السلسلة، وكسر هذه السلسلة إربًا: let animal = { name: "Animal", eat() { alert(`${this.name} eats.`); } }; let rabbit = { __proto__: animal, eat() { // ...هنا يأكل الأرنب كما تأكل الأرانب، بعدها نستدعي التابِع الأبّ (animal) this.__proto__.eat.call(this); // (*) } }; let longEar = { __proto__: rabbit, eat() { // ...الأرنب ذو الأذن الطويلة يلهو ويلعب، ثمّ نستدعي التابِع الأبّ (rabbit) this.__proto__.eat.call(this); // (**) } }; // هنا!longEar.eat(); // Error: Maximum call stack size exceeded لم تعد الشيفرة تعمل الآن! نرى خطأً عند استدعاء longEar.eat(). قد لا يبدو الأمر جليًا من أوّل نظرة، ولكن لو تعقّبنا استدعاء longEar.eat() فسنرى الأمر بوضوح، ففي السطرين () و (*) تكون قيمة this هي الكائن الحالي (longEar). هذا ضمن الأساسيات، فعلى توابِع الكائنات جلب الكائن الحالي فهو this، وليس كائنَ prototype أو ما شابهه. بذلك في السطرين معًا (*) و (**) تكون قيمة this.__proto__ واحدة: الكائن rabbit، وكلاهما يستدعيان التابِع rabbit.eat دون أن يرتقيا بالسلسلة، فيدوران في حلقة لا نهاية لها. إليك عمّا يحدث في صورة: نرى في التابِع longEar.eat() عند السطر (**) استدعاء rabbit.eat بتمرير this=longEar. // نرى داخل longEar.eat() قيمة this = longEar this.__proto__.eat.call(this) // (**) // يصير longEar.__proto__.eat.call(this) // وهو فعليًا rabbit.eat.call(this); وبعدها في السطر (*) داخل rabbit.eat، نحاول تمرير الاستدعاء إلى مستوًى أعلى داخل السلسلة، ولكن قيمة this=longEar، بهذا تصير قيمة this.__proto__.eat هي rabbit.eat ثانيةً. // نرى داخل rabbit.eat() قيمة this = longEar this.__proto__.eat.call(this) // (*) // becomes longEar.__proto__.eat.call(this) // or (again) rabbit.eat.call(this); بهذا… يستدعي rabbit.eat نفسه في حلقة لانهاية لها لأنّها يعجز عن الارتقاء في السلسلة. ما من طريقة لحلّ هذه المشكلة باستعمال this فقط. [[HomeObject]] أضافت لغة جافاسكربت -لحلّ هذه المعضلة- خاصية داخلية (أخرى) للدوال، وهي ”الكائن المنزل“ [[HomeObject]]. متى ما حُدّدت الدالة لتكون صنفًا أو تابِعًا لكائن، أصبحت خاصية [[HomeObject]] للدالة ذلك الصنف أو الكائن. تستعمل super هذا الكائن لحلّ كائن prototype الأبّ هو وتوابِعه. لنرى كيف يعمل هذا الشيء، بالكائنات العادية أولًا: let animal = { name: "Animal", eat() { // animal.eat.[[HomeObject]] == animal alert(`${this.name} eats.`); } }; let rabbit = { __proto__: animal, name: "Rabbit", eat() { // rabbit.eat.[[HomeObject]] == rabbit super.eat(); } }; let longEar = { __proto__: rabbit, name: "Long Ear", eat() { // longEar.eat.[[HomeObject]] == longEar super.eat(); } }; // يعمل التابِع كما نريد longEar.eat(); // Long Ear eats. عملت الشيفرة كما المفترض ذلك بسبب آلية عمل [[HomeObject]]. تعرف التوابِع (مثل longEar.eat) خاصيةَ [[HomeObject]] لها وتأخذ التابِع الأبّ من كائن prototype لذاك الكائن، ودون استعمال this أبدًا. التوابِع ليست ”حرّة“ كما نعلم فالتوابع -بنحوٍ عام- ”حرّة“ وليست مروبطة بأيّ كائن. فيمكننا نسخها بين الكائنات واستعمالها بتمرير قيمة this أخرى. ولكن وجود [[HomeObject]] يخلّ بهذا المبدأ إذ تتذكّر التوابِع كائناتها الأصلية هكذا. يبقى هذا الارتباط وثيقًا للأبد إذ لا يمكننا تغيير [[HomeObject]]. ولكن المكان الوحيد الذي نستعمل فيه خاصية [[HomeObject]] (في لغة جافاسكربت) هو super. يعني أنّه لو لم يستعمل التابِع super فيمكننا عدّه حرًا ونمضي بنسخه وتوزيعه على الكائنات. ولكن متى استعملت super، ساءت الأمور. إليك مثالًا كاملًا عن نتيجة خطأ بعد النسخ بسبب super: let animal = { sayHi() { console.log(`I'm an animal`); // أنا حيوان } }; // يرث صنف الأرنب صنفَ الحيوان let rabbit = { __proto__: animal, sayHi() { super.sayHi(); } }; let plant = { sayHi() { console.log("I'm a plant"); // أنا نبات } }; // يرث صنف الشجرة صنفَ النبات let tree = { __proto__: plant, sayHi: rabbit.sayHi // (*) }; tree.sayHi(); // أنا حيوان (؟!؟) باستدعاء tree.sayHi() نرى الشجرة تقول ”أنا حيوان“. لا، لا! سبب ذلك بسيط جدًا: في السطر (*) نسخنا التابِع tree.sayHi من rabbit. من يدري، ربّما لنقلّل من تكرار الشيفرات؟ وخاصية [[HomeObject]] لها هي الصنف rabbit، إذ صنعنا التابِع داخل rabbit، وما من طريقة لتغيير [[HomeObject]]. نرى في شيفرة التابِع tree.sayHi() الاستدعاءَ super.sayHi()، وهو ينتقل إلى أعلى عند rabbit ويأخذ التابِع من animal. إليك صورة توضّح ما يحدث: توابِع لا صفات داليّة تُعرّف اللغة عن خاصيات [[HomeObject]] للتوابِع في الأصناف وفي الكائنات العادية. ولكن في حالة الكائنات فيجب تعريف التوابِع هكذا تمامًا method() وليس هكذا "method: function()". قد لا نرى فرقًا جوهريًا في الطريقتين، لكنّ محرّكات جافاسكربت تراه كذلك. استعملنا في المثال أسفله صياغة ليست بتابِع للموازنة. بهذا لم تُضبط خاصية [[HomeObject]] ولن تعمل الوراثة: let animal = { eat: function() { // نكتبها هكذا بدل eat() عمدًا {... // ... } }; let rabbit = { __proto__: animal, eat: function() { super.eat(); } }; rabbit.eat(); // Error calling super (إذ [[HomeObject]] غير موجودة) خلاصة نستعمل class Child extends Parent لتوسعة الأصناف. أي أنّ خاصية Child.prototype.__proto__ ستكون Parent.prototype، فتصير التوابِع موروثة. عندما نعيد تعريف الباني: علينا استدعاء الباني الأبّ باستعمال super() في الباني ”الابن“ ذلك قبل استعمال this. عندما نعيد تعريف أي تابع آخر: يمكننا استعمال super.method() في التابِع ”الابن“ لاستدعاء التابِع ”الأبّ“. أمور داخلية: تتذكّر التوابِع صنفها/كائنها وتحفظه في خاصية [[HomeObject]] الداخلية، هكذا يحلّ super التوابِع الأب. بذلك يكون ليس من الآمن نسخ تابِع لديه super من كائن ووضعه في آخر. كما وأنّ: ليس للدوال السهمية لا this ولا super تمارين خطأ في إنشاء سيرورة الأهمية: 5 إليك الشيفرة التي نُوسّع فيها صنف Rabbit من صنف Animal. للأسف فلا يمكننا صناعة كائنات الأرانب. ما المشكلة؟ أصلِحها في طريقك. class Animal { constructor(name) { this.name = name; } } class Rabbit extends Animal { constructor(name) { this.name = name; this.created = Date.now(); } } // هنا let rabbit = new Rabbit("White Rabbit"); // Error: this is not defined alert(rabbit.name); الحل هذا لأنّ على الباني الابن استدعاء super(). إليك الشيفرة الصحيحة: class Animal { constructor(name) { this.name = name; } } class Rabbit extends Animal { constructor(name) { super(name); // هنا this.created = Date.now(); } } let rabbit = new Rabbit("White Rabbit"); // الآن تمام alert(rabbit.name); // White Rabbit توسعة ساعة الأهمية: 5_ لدينا صنف ساعة Clock، وهو حاليًا يطبع الوقت في كلّ ثانية. class Clock { constructor({ template }) { this.template = template; } render() { let date = new Date(); let hours = date.getHours(); if (hours < 10) hours = '0' + hours; let mins = date.getMinutes(); if (mins < 10) mins = '0' + mins; let secs = date.getSeconds(); if (secs < 10) secs = '0' + secs; let output = this.template .replace('h', hours) .replace('m', mins) .replace('s', secs); console.log(output); } stop() { clearInterval(this.timer); } start() { this.render(); this.timer = setInterval(() => this.render(), 1000); } } أنشِئ الصنف الجديد ExtendedClock ليرث من Clock وأضِف المُعامل precision، وهو عدد الملّي ثانية ms بين كلّ ”تَكّة“. يجب أن يكون مبدئيًا 1000 (أي ثانية كاملة). ضع شيفرتك في الملف extended-clock.js. تعديل ملف clock.js الأصلي ممنوع. وسّع الصنف. يمكن الاعتماد على هذه البيئة التجريبية لحل التمرين. الحل class ExtendedClock extends Clock { constructor(options) { super(options); let { precision = 1000 } = options; this.precision = precision; } start() { this.render(); this.timer = setInterval(() => this.render(), this.precision); } }; مشاهدة الحل في بيئة تجريبية. الأصناف تُوسّع الكائنات؟ الأهمية: 5 كما نعلم فالكائنات كلها ترث Object.prototype وتقدر على الوصول إلى توابِع الكائنات ”العادية“ مثل hasOwnProperty وغيرها. مثال سريع: class Rabbit { constructor(name) { this.name = name; } } let rabbit = new Rabbit("Rab"); // التابِع hasOwnProperty مأخوذ من Object.prototype alert( rabbit.hasOwnProperty('name') ); // true ولكن لو كتبنا ذلك جهارةً هكذا "class Rabbit extends Object" فالناتج يختلف عن "class Rabbit" فقط! غريب. تُراه ما الفرق؟ إليك مثالًا عمّا أقصد (الشيفرة لا تعمل، لماذا؟ أصلِحها!): class Rabbit extends Object { constructor(name) { this.name = name; } } let rabbit = new Rabbit("Rab"); alert( rabbit.hasOwnProperty('name') ); // true الحل أولًا نرى ما مشكلة الشيفرة تلك. متى شغّلنا الشيفرة بان سبب المشكلة: على باني الأصناف الموروثة استدعاء super() وإلّا تكون قيمة "this" ”غير معرّفة“. لذا سنصلحها: class Rabbit extends Object { constructor(name) { super(); // علينا استدعاء الباني الأب حين نرث الصنف this.name = name; } } let rabbit = new Rabbit("Rab"); alert( rabbit.hasOwnProperty('name') ); // true ولكن… لم ننتهِ بعد. حتّى مع هذا … فهناك فرق مهم جوهري بين "class Rabbit extends Object" وclass Rabbit. كما نعلم فصياغة extends تضبط كائنا prototype: بين توابِع الباني لـِ"prototype" (بالنسبة للتوابع العادية). بين توابِع الباني نفسها (بالنسبة للتوابع الثابتة). في حالتنا إن class Rabbit extends Object تعني: class Rabbit extends Object {} alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true alert( Rabbit.__proto__ === Object ); // (2) true لذا يوفر Rabbit إمكانية الوصول إلى الدوال الثابتة للكائن Object. هكذا: class Rabbit extends Object {} *!* // عادةً نستدعي Object.getOwnPropertyNames alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // a,b */!* ولكن إذا لم يكن لدينا extends Object فلن تُسند Rabbit.__proto__ للصنف Object. إليك المثال: class Rabbit {} alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true alert( Rabbit.__proto__ === Object ); // (2) false (!) alert( Rabbit.__proto__ === Function.prototype ); // as any function by default *!* // error, no such function in Rabbit alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // Error */!* في هذه الحالة إن Rabbit لن يزودنا بطريقة للوصول إلى التوابع الثابتة في Object. بالمناسبة يملك النموذج الأولي للتوابع Function.prototype دوال مُعمَّمة مثل :call و bind ..إلخ. وهي متاحة دائمًا في كِلا الحالتين، لأن باني Object المضمن في اللغة هو Object.__proto__ === Function.prototype. لذلك وباختصار هناك اختلافان وهما: class Rabbit class Rabbit extends Object -- يحتاج لاستدعاءsuper() في الباني Rabbit.__proto__ === Function.prototype Rabbit.__proto__ === Object 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; } ترجمة -وبتصرف- للفصل Class inheritance من كتاب The JavaScript language
-
بعيدًا عن الكلام النظري، ففي الواقع نريد عادةً إنشاء أكثر من كائن تحمل نفس النوع، مثل المستخدمين والبضائع وغيرها. كما علمنا في الفصل "الباني والعامل "new" في جافاسكربت"، يمكن للتعليمة new function القيام بهذه المهمة. ولكن، في لغة جافاسكربت الحديثة هناك بوانيٍ أكثر تقدّمًا للأصناف إذ تُتيح لنا مزايا جديدة مميّزة تفيدنا حين نُبرمج على طريقة البرمجة كائنية التوجه. صياغة الأصناف class إليك الصياغة الأساسية: class MyClass { // توابِع الصنف constructor() { ... } method1() { ... } method2() { ... } method3() { ... } ... } بعدها استعمل new MyClass() لتُنشِئ كائنًا جديدًا فيه التوابِع تلك. يُنادي الباني constructor() العبارة new تلقائيًا ليهيئ الكائن في المكان المطلوب. إليك مثالًا: class User { constructor(name) { this.name = name; } sayHi() { alert(this.name); } } // الاستعمال: let user = new User("John"); user.sayHi(); متى استدعينا new User("John"): نكون أنشأنا كائنًا جديدًا. نكون شغّلنا (خلف الكواليس) الباني وممرًا له الوسطاء المناسبة، وإسناد هذه الوسطاء للمتغيرات المناسبة. هنا أُسندت الوسيط "John" إلى المتغير name عبر this.name. …وبعد ذلك، ننادي توابِع الكائن مثل user.sayHi(). لا يوجد فاصلة بين التوابع. من الشائع بين المطورين المبتدئين وضع فاصلة بين توابع الصنف والّذي سيرمي خطأ في الصياغة، وهذه نقطة مهمة لعدم الخلط بينها والكائنات المجردة. إذًا داخل الصنف الفاصلة غير مطلوبة. ما الصنف أصلًا؟ هذا جميل، ولكن ما هو الصنف class أساسًا؟ لو ظننته كيانًا جديدًا كليًا في اللغة، فأنت مخطئ. هيًا معًا نكشف الأسرار ونعرف ماهيّة الصنف حقًا، بذلك نفهم أمور كثيرة معقّدة، بسهولة. الصنف -في جافاسكربت- مشابه للدالة نوعًا ما. انظر بنفسك: class User { constructor(name) { this.name = name; } sayHi() { alert(this.name); } } // الإثبات: الصنف User هو دالّة alert(typeof User); // function إليك ما يفعله الباني class User {...} حقًا: يُنشِئ دالّة باسم User تصير هي ناتج التصريح عن الصنف، وتُؤخذ شيفرة الدالّة من الباني constructor (يُعامل الباني الصنف على أنّه فارغ إن لم تكن كتبت دالّة تغيّر ذلك). يُخزّن توابِع الصنف (مثل sayHi) في النموذج الأولي للكائن User.prototype. متى ما أُنشأ كائن جديد (new User)، واستدعينا تابِعًا منه يُؤخذ التابِع من كائن النموذج الأولي (prototype) كما وضّحنا في الفصل ”الوراثة النموذجية - 2 -“. هكذا يكون للكائن تصريح الوصول إلى توابِع الصنف. يمكن أن نوضّح ناتج التصريح class User في هذه الصورة: إليك الشيفرة …: class User { constructor(name) { this.name = name; } sayHi() { alert(this.name); } } // الصنف ما هو إلّا دالةً alert(typeof User); // function // ...أو للدّقة هو تابِع الباني alert(User === User.prototype.constructor); // true // التوابِع موجودة في كائن النموذج الأولي User.prototype، مثال: alert(User.prototype.sayHi); // alert(this.name); // في كائن النموذج الأولي prototype تابِعين بالضبط alert(Object.getOwnPropertyNames(User.prototype)); // constructor, sayHi ليست مجرد تجميل لغوي يقول البعض بأنّ الأصناف class هي مجرد "تجميل لغوي" (Syntactic sugar)، أي أنّها صياغة أساس تصميمها هو تسهيل القراءة دون تقديم ما هو جديد، إذ يمكننا ببساطة التصريح عن ذات الشيء بدون تلك الكلمة المفتاحية class: // نُعيد كتابة صنف المستخدم User باستعمال الدوال فقط // 1. نُنشِئ دالّة الباني function User(name) { this.name = name; } //عادةً إن أي نموذج أولي لتابِعٍ معين لديه باني // لذلك لسنا بحاجة لإنشائه // 2. نضيف التابع إلى النموذج الأولي User.prototype.sayHi = function() { alert(this.name); }; // طريقة الاستخدام: let user = new User("John"); user.sayHi(); ناتج هذا التصريح أعلاه يشبه كثيرًا… لا بل يتطابق مع تصريح الصنف. لهذا ففكرة أنّ الأصناف هي حقًا تجميل لغوي لتعريف البواني مع توابِع كائن النموذج الأولي prototype لها - هي فكرة منطقية حقًا. مع ذلك، فهناك فوارق. أولًا، تُوضع على الدوال الّتي تُنشؤها عبارة class كخاصيّةً داخلية فريدة لهذا الصنف هكذا [[FunctionKind]]:"classConstructor". لذا فالطريقتين (هذه وإنشائها يدويًا) ليستا تمامًا الشيء نفسه. وعلى عكس الدوالّ العادية، يلزم عليك استدعاء باني الصنف باستعمال new: class User { constructor() {} } alert(typeof User); // function User(); // Error: Class constructor User cannot be invoked without 'new' كما وأنّ تمثيل أغلب محرّكات جافاسكربت النصّيّ لباني الصنف يبدأ بالعبارة ”class…“ class User { constructor() {} } alert(User); // class User { ... } توابِع الأصناف غير قابلة للإحصاء. يضبط التصريح عن الصنف راية enumerable على القيمة false لكلّ التوابِع في كائن النموذج الأولي للصنف "prototype". هذا جميل إذ لا نريد ظهور توابِع الصنف حين نستعرضهن باستعمال حلقة for..in. تستعمل الأصناف الوضع الصارم دومًا. كلّ الشيفرة البرمجية في الباني تكون بالوضع الصارم (سبق وأن تحدثنا في مقال سابق عن الوضع الصارم في لغة جافاسكربت). كما أنّ صياغة الأصناف تُفيدنا بكثير من المزايا نشرحها لاحقًا. تعابير الأصناف كما الدوال فيمكن التعريف عن الأصناف داخل التعابير الأخرى وتمريرها وإعادتها وإسنادها وغيره. إليك مثال عن تعبير صنف: let User = class { sayHi() { alert("Hello"); } }; وكما تعابير الدوال المسمّاة (NFE) وهي اختصارًا لِـ (Named Function Expressions)، يمكن أن نضع اسمًا لتعابير الأصناف. ولو كان لتعبير الصنف اسمًا فسيكون ظاهرًا داخل الصنف فقط لا غير: // ”تعبير أصناف مسمّى“ (NCE) // (ليس في المواصفات القياسية للغة هذا الاسم، لكنّها شبيهة بتعابير الدوال المسمّاة(NFE) ) let User = class MyClass { sayHi() { alert(MyClass); // لا يظهر اسم الصنف MyClass إلّا داخل الصنف } }; new User().sayHi(); // يعمل ويعرض تعريف MyClass alert(MyClass); // خطأ اسم تعبير الصنف MyClass غير مرئي خارج الصنف يمكننا حتى جعل الأصناف جاهزة عند الطلب، هكذا: function makeClass(phrase) { // declare a class and return it return class { sayHi() { alert(phrase); }; }; } // إنشاء صنف جديد let User = makeClass("Hello"); new User().sayHi(); // Hello الجوالِب والضوابِط والاختصارات الأخرى كما الكائنات المجردة فيمكن أن يكون في الأصناف ضوابِط وجوالِب (Gettes/Setters) وأسماءً محسوبةً للخاصيات (computed properties name) وغيرها. إليك مثالًا عن خاصية user.name كتبنا تنفيذها عبر ضابِط وجالِب: class User { constructor(name) { // يشغّل الضابِط this.name = name; } // هنا get name() { return this._name; } // وهنا set name(value) { if (value.length < 4) { alert("Name is too short."); // الاسم قصير جدًا return; } this._name = value; } } let user = new User("John"); alert(user.name); // John user = new User(""); // الاسم قصير جدًا ينشئ التعريف عن الصنف جوالِب وضوابِط في كائن User.prototype هكذا: Object.defineProperties(User.prototype, { name: { get() { return this._name }, set(name) { // ... } } }); وهذا مثال عن استخدام أسماءً محسوبةً للخاصيّة (computed property name) داخل أقواس [...]: class User { // هنا ['say' + 'Hi']() { alert("Hello"); } } new User().sayHi(); خاصيات الأصناف تحذير: قد يكون عليك تعويض النقص في المتصفّحات القديمة الخاصيات على مستوى الأصناف هي إضافة حديثة على اللغة. نرى في المثال أعلاه بأنّ للصنف User توابِع فقط. هيًا نُضف الخاصية name للصنف class User: class User { name = "Anonymous"; // هكذا sayHi() { alert(`Hello, ${this.name}!`); } } new User().sayHi(); alert(User.prototype.sayHi); // موجود في كائن User.prototype alert(User.prototype.name); // undefined، غير موجودة في كائن User.prototype الأمر الجدير بالذكر أن خاصيات الصنف تعيّن على أنها كائنات فردية وليست ضمن النموذج الأولي للصنف User.prototype. من الناحية العملية تُعالجُ هذه الخاصيات بعد أن يُنهي الباني عمله. إنشاء توابع مرتبطة بخاصيات الصنف كما تحدثنا في فصلٍ سابق من هذه السلسلة ربط الدوالّ، إن دلالات الكلمة المفتاحية this في الدوالّ ديناميكية إذ تعتمد إعتمادً أساسيًا على سياق الاستدعاء. لذا فإن مررها تابِعٌ من كائنٍ معين للكلمة المفتاحية this، واستدعيت مرة أخرى في مكان آخر بغير السياق السابق، فلن تشير إلى نفس الكائن السابق بسبب تغير سياق الاستدعاء. على سبيل المثال الشيفرة البرمجية التالية ستظهر undefined. class Button { constructor(value) { this.value = value; } click() { alert(this.value); } } let button = new Button("hello"); setTimeout(button.click, 1000); // undefined تدعى هذه المشكلة "ضياع قيمة this". وهنالك طريقتين لإصلاح هذه المشكلة كما ذكرنا في فصل "ربط الدوال"ّ تمرير دالّة مغلّفة (مثل: setTimeout(() => button.click(), 1000)). ربط الدالّة بصنف كما في الباني التالي: class Button { constructor(value) { this.value = value; this.click = this.click.bind(this); } click() { alert(this.value); } } let button = new Button("hello"); setTimeout(button.click, 1000); // hello تزودنا خاصيات الصنف بصياغة مناسبة للحل الّذي سنستخدمه: class Button { constructor(value) { this.value = value; } click = () => { alert(this.value); } } let button = new Button("hello"); setTimeout(button.click, 1000); // hello تنشئ الخاصية click= () => {...} دالّة مستقلة لكل كائن من Button، والكلمة المفتاحية this تشير إلى الكائن نفسه. وبإمكاننا بعدها تمرير button.click في أي مكان ومع احتفاظها بالقيمة الصحيحة للكلمة المفتاحية this. إن استخدام هذه الطريقة مفيد جدًا في بيئة المتصفحات وخصيصًا عند احتياجنا لإعداد دالّة مستمع الحدث (event listener). خلاصة الصياغة الأساسية للأصناف هي كالآتي: class MyClass { prop = value; // خاصية constructor(...) { // الباني // ... } method(...) {} // تابِع get something(...) {} // تابِع جلب set something(...) {} // تابِع ضبط [Symbol.iterator]() {} // تابِع اسمه محسوب (نضع رمزًا هنا) // ... } تقنيًا، فالصنف MyClass ما هو إلّا دالّة (تلك الّتي نكتبها لتكون الباني constructor)، بينما التوابِع والضوابِط والجوالِب تُكتب في كائن MyClass.prototype. سنتعرّف أكثر في الفصول اللاحقة عن الأصناف والوراثة والميزات الأخرى. تمارين أعِد كتابتها لتكون صنفًا الأهمية: 5 كُتب صنف الساعة Clock وكأنّه دالّة. أعِد كتابته ليكون بصياغة الأصناف. ملاحظة: تدقّ عقارب الساعة في الطرفية، افتحها واحترس من العقارب اللادغة. اطلع على تجربة حية للتمرين. الحل class Clock { constructor({ template }) { this.template = template; } render() { let date = new Date(); let hours = date.getHours(); if (hours < 10) hours = '0' + hours; let mins = date.getMinutes(); if (mins < 10) mins = '0' + mins; let secs = date.getSeconds(); if (secs < 10) secs = '0' + secs; let output = this.template .replace('h', hours) .replace('m', mins) .replace('s', secs); console.log(output); } stop() { clearInterval(this.timer); } start() { this.render(); this.timer = setInterval(() => this.render(), 1000); } } let clock = new Clock({template: 'h:m:s'}); clock.start(); ويمكن الاطلاع على تجربة حية للحل. ترجمة -وبتصرف- للفصل Class basic syntax من كتاب The JavaScript Language
-
في أوّل فصل من هذا القسم قُلنا بأنّ هناك طرائق حديثة لكتابة الخاصية [[prototype]]. يُعدّ التابِع __proto__ قديمًا وربّما نقول أيضًا لم يعد مستخدمًا (تحديدًا من جهة المتصفح في معايير جافاسكربت) النسخ الحديثة هي: Object.create(proto[, descriptors]) -- ينشئ كائنًا فارغًا بضبط proto الممرّر ليكون كائن [[Prototype]] مع واصِفات الخاصيات الاختيارية لو مُرّرت. Object.getPrototypeOf(obj) -- يُعيد كائن [[Prototype]] للكائن obj. Object.setPrototypeOf(obj, proto) -- يضبط الخاصية [[Prototype]] للكائن obj لتكون proto. هذه التعليمات يجب عليك استعمالها بدلًا من __proto__. مثال: let animal = { eats: true }; // نصنع كائنًا جديدًا يكون الكائن animal ككائنَ نموذج أولي له let rabbit = Object.create(animal); alert(rabbit.eats); // true alert(Object.getPrototypeOf(rabbit) === animal); // true Object.setPrototypeOf(rabbit, {}); // نغيّر كائن النموذج الأولي للكائن rabbit إلى {} للتابِع Object.create وسيط ثانٍ اختياري وهو: واصِفات الخاصيات، إذ يمكننا تقديم خاصيات إضافية إلى الكائن الجديد مباشرةً هكذا: let animal = { eats: true }; let rabbit = Object.create(animal, { jumps: { value: true } }); alert(rabbit.jumps); // true تنسيق الواصِفات هو نفس التنسيق الذي شرحناه في درس رايات الخاصيات وواصفاتها. يمكننا أيضًا استعمال التابِع Object.create لنسخ الكائنات بتحكّم أكبر من نسخ الخاصيات في حلقة for..in: // إنشاء نسخة مماثلة للكائن obj let clone = Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj)); يصنع هذا الاستدعاء نسخة حقيقية مطابقة عن obj بما فيها الخاصيات: قابلية العدّ و منع قابلية العدّ وخاصيات البيانات و الضوابط/الجوالب - ينسخ كلّ شيء مع خاصية [[Prototype]]. لمحة تاريخية لو عددنا الآن كلّ الطرائق التي نُدير فيها خاصية [[Prototype]] لوجدناها لا تُحصى! الكثير من الطرق لمهمة واحدة. ولكن لماذا؟ طبعًا وكالعادة، لأسباب تاريخية. بدأت الخاصية "prototype" للبواني (constructor) منذ زمن بعيد جدًا. ولاحقًا في 2012، ظهر التابِع Object.create في معيار اللغة. قدّم بذلك إمكانية صناعة الكائنات لها كائن نموذج أولي (prototype)، ولكن لم تقدّم أيّ طريقة لجلبه أو ضبطه. لهذا صنعت المتصفّحات تابع غير قياسي __proto__ ليصل إلى كائن النموذج الأولي ويُتيح للمستخدم جلبه وضبطه متى أراد. بعدها في 2015 أُضيف التابِعين Object.setPrototypeOf و Object.getPrototypeOf إلى المعيار فيقوموا بنفس مقام التابع __proto__. ولكن بحكم الأمر الواقع، فقد كان __proto__ موجودًا في كلّ مكان، وهكذا صار غير مستحسن استخدامه ونزل في المعيار إلى المُلحق باء (Annex B)، أي صار ”اختياريًا للبيئات التي ليست متصفّحات“. والآن صرنا نملك هذه الطرائق الثلاث نتنعّم بها. ولكن لماذا استبدلوا __proto__ بالدوال getPrototypeOf/setPrototypeOf؟ سؤال مهم وعليه سنعرف ما السوء الكبير للتابِع __proto__. واصِل القراءة لمعرفة ذلك. ملاحظة:إن كانت السرعة عنصر مهم في مشروعك فلا تغيّر قيمة الخاصية [[Prototype]] للكائنات الحالية. عادة ما نُسند هذه الخاصية [[Prototype]] مرة واحدة فقط وذلك عند إنشاء الكائن ولا نعدلها بعد ذلك. ولكن بالطبع يمكننا استخدام الضابِط/الجالِب للخاصية [[Prototype]] في أي وقت نريده. الكائن rabbit يرث من الكائن animal ولن يتغير هذا الأمر لاحقًا. محرك لغة جافاسكربت محسّن على النحو الأمثل لهذا الغرض. إن عملية تغيير النموذج الأولي "على عجالة" باستخدام التابِع Object.setPrototypeOf أو التابِع obj.__proto__= تعدّ عملية بطيئة جدًا لأنها تكسر التحسينات الداخلية لعمليات الوصول إلى الخاصية لهذا الكائن. لذا فإن كانت السرعة تهمك أو كنت تعرف ما عواقب ما تُقدِم عليه فغيره، وإلا فلا. الكائنات ”البسيطة جدًا“ كما نعلم فيمكننا استعمال الكائنات على أنّها مصفوفات مترابطة لتخزين أزواج المفاتيح/القيم. ولكن… لو أردنا تخزين المفاتيح التي يُعطينا إيّاها المستخدم (مثل قاموس يكتبه المستخدم) فسنرى مشكلة ظريفة: كلّ المفاتيح تعمل عدا "__proto__". انظر هذا المثال: let obj = {}; let key = prompt("What's the key?", "__proto__"); // ما المفتاح؟ obj[key] = "some value"; alert(obj[key]); // [object Object]، وليس ”some value“! لو كتب هنا المستخدم __proto__، فستُهمل عملية الإسناد! لا، هذا ليس مفاجئًا، إذ نعرف بأنّ __proto__ مميّزة عن غيرها: فإمّا تكون كائنًا أو null. ممنوع أن تصير السلسلة النصية (String) كائنَ نموذج أولي. ولكن لم تكن النية أساسًا لتنفيذ هذا السلوك، صح؟ ما نريده هو تخزين أزواج المفاتيح والقيم، وهناك مفتاح اسمه "__proto__" لم يُحفظ كما المفترض، والّذي أنشأ مشكلة! هنا قد لا تكون العواقب وخيمة جدًا، ولكنها في حالات أخرى تكون، مثل لو كنّا نُسند قيم الكائنات، وبعدها تغيرت قيم النماذج الأولية لسبب ما لهذه الكائنات، عندها سيكون ناتج التنفيذ خاطئ وبطريقة غير متوقعة نهائيًا. الأنكى من هذا هو أنّ المطوّرين -عادةً- لا يفكّرون حتّى بإمكانية حدوث هذا، ما يصنع علل (Bugs) محال ملاحظتها، أو حتّى علل تصير ثغرات أمنية خصوصًا حين نستعمل جافاسكربت من جهة الخوادم. كما تحدث أيضًا غرائب وعجائب ما إن أسندتَ شيئًا إلى التابِع toString (وهو دالة بالوضع الإفتراضي) وغيره من توابِع أخرى مضمنّة في أصل اللغة. كيف لنا يا تُرى تجنّب هذا؟ أوّلا، نترك ما نستعمل وننتقل إلى الخارطة Map، وهكذا نحلّ كلّ شيء. ولكن الكائنات نفسها Object تكفي في هذه الحال إذ أنّ من صنع لغة جافاسكربت فكّر بهذه المشكلة قبل أن نُولد حتّى. ليست __proto__ خاصية للكائن، بل خاصية وصول إلى Object.prototype: لذا لو قرأنا obj.__proto__ أو ضبطناها، فسيُستدعى الجالِب (أو الضابِط) من كائن النموذج الأولي (prototype) لها وتجلب/تضبط كائن [[Prototype]]. فكما قلنا في بداية هذا القسم من الكتاب: ليست __proto__ إلّا طريقة للوصول إلى الخاصية [[Prototype]] وليست الكائن نفسه. الآن بعد هذا كلّه، لو أردنا استعمال الكائن مصفوفةً مترابطة فيمكننا ذلك بخدعة صغيرة: let obj = Object.create(null); // هذه let key = prompt("What's the key?", "__proto__"); // ما المفتاح؟ obj[key] = "some value"; alert(obj[key]); // "some value" ينشئ التابِع Object.create(null) كائنًا فارغًا ليس له نموذج أولي (prototype) (أي أنّ [[Prototype]] يساوي null): يعني أنْ ليس هناك أيّ جالِب أو ضابِط موروثان للخاصية __proto__، فهي الآن خاصية بيانات عادية وسيعمل مثالنا أعلاه كما نريد. نسمّي هذه الكائنات ”بالبسيطة جدًا“ (very plain) أو ”كائنات ليست إلّا قواميس“ إذ أنّها أبسط حتّى من الكائن البسيط (العادي) {...}. العيب هنا هو أنّ ليس فيها أيّ توابِع كائنات مضمّنة مثل toString: let obj = Object.create(null); alert(obj); // Error (no toString) ولكن ربّما ذلك يكون كافيًا للمصفوفات المترابطة لاحظ كيف أنّ أغلب التوابِع المتعلّقة بالكائنات هي بالشكل Object.something(...) (مثل Object.keys(obj)) وليست موجودة في كائن النموذج الأولي (prototype)، وستعمل كما ينبغي لهذه الكائنات البسيطة: let arabicDictionary = Object.create(null); chineseDictionary.hello = "hello"; chineseDictionary.bye = "bye"; alert(Object.keys(arabicDictionary)); // hello,bye خلاصة التوابِع الحديثة لضبط كائنات النماذج الأولية (prototype) والوصول إليها مباشرةً هي: Object.create(proto[, descriptors]) -- ينشئ كائنًا فارغًا بضبط proto الممرّر ليكون كائن [[Prototype]] (يمكن أن يحمل القيمة null) مع واصِفات الخاصيات الاختيارية لو مُرّرت. Object.getPrototypeOf(obj) -- يُعيد كائن [[Prototype]] للكائن obj (مشابه لعمل الجالِب للخاصية __proto__). Object.setPrototypeOf(obj, proto) -- يضبط الخاصية [[Prototype]] للكائن obj لتكون proto (مشابه لعمل الضابِط للخاصية __proto__). ليس من الآمن استعمال الجالِب/الضابِط __proto__ المضمّن في اللغة إن أردنا وضع مفاتيح صنعها المستخدم في الكائن. ليس ذلك آمنًا إذ يمكن أن يُدخل المستخدم "__proto__" مفتاحًا وسنواجه خطأً له عواقب محالة التنبّؤ نأمل أن يُحمد عقباها. لذا يمكننا إمّا استعمال التابِع Object.create(null) لإنشاء كائن ”بسيط جدًا“ بدون استعمال __proto__ أو يمكننا استغلال كائنات الخرائط Map وهو الأفضل. كما يمكننا أيضًا نسخ الكائنات مع جميع واصفاتِها من خلال التابع Object.create. let clone = Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj)); كما أنّا وضّحنا كيف أنّ __proto__ ليس إلّا جالِبًا/ضابِطًا للخاصية [[Prototype]] وهو موجود تحت ظلّ Object.prototype مثل غيره من التوابِع. يمكننا إنشاء كائن ليس له نموذج أولي (prototype) باستعمال Object.create(null)، وهكذا نستعملها على أنّها ”خرائط خام (Map)“، والجميل أنّها لا تُمانع قيمة مثل "__proto__" لتكون مفتاحًا فيها. توابِع أخرى: Object.keys(obj) / Object.values(obj) / Object.entries(obj) -- تعيد مصفوفة فيها جميع السلاسل (مفعلٌ بها خاصية قابلية العدّ) على شكل أسماء/قيم/أزواج مفاتيح-قيم. Object.getOwnPropertySymbols(obj) -- تُعيد مصفوفةً فيها جميع الخاصيات الرمزية (symbol properties) الموجودة مباشرةً في الكائن المعطي. Object.getOwnPropertyNames(obj) -- تُعيد مصفوفةً فيها جميع الخاصيات التابعة مباشرةً للكائن المعطي، بما في ذلك الخاصيات غير القابلة للإحصاء (non-enumerable) لكن باستثناء خاصيات الرموز Symbol. Reflect.ownKeys(obj) -- تعيد مصفوفة من جميع المفاتيح الخاصة بها. obj.hasOwnProperty(key): تعيد true لو كان للكائن obj خاصيةً ما مباشرةً (أي أنها لم يرثها). تُعيد كلّ التوابِع التي تُعيد خاصيات الكائن (مثل Object.keys وغيرها) خاصياتها ”هي“. لو أردنا تلك الموروثة فعلينا استعمال حلقة for..in. تمارين إضافة toString إلى dictionary الأهمية: 5 لديك الكائن dictionary حيث أنشأناه باستعمال Object.create(null) ليخزّن أزواج key/value أيًا كانت. أضِف التابِع dictionary.toString() فيه ليُعيد قائمة الخاصيات مفصولة بفواصل. يجب ألا يظهر التابِع toString في حلقة for..in عند مرورها على الكائن. إليك طريقة عمل التابِع: let dictionary = Object.create(null); // اكتب هنا الشيفرة التي تُضيف تابِع dictionary.toString // نُضيف بعض البيانات dictionary.apple = "Apple"; dictionary.__proto__ = "test"; // ليس __proto__ هنا إلّا خاصية لا أكثر // في الحلقة apple و __proto__ فقط for(let key in dictionary) { alert(key); // "apple"، ثمّ "__proto__" } // حان دور التابِع toString الذي كتبته alert(dictionary); // "apple,__proto__" الحل يمكن أن يأخذ التابِع كلّ الخاصيات (keys) القابلة للإحصاء باستعمال Object.keys ويطبع قائمة بها. لنمنع التابِع toString من قابلية الإحصاء لنُعرّفه باستعمال واصِف خاصيات. تُتيح لنا صياغة التابِع Object.create تقديم الكائن مع واصِفات الخاصيات كوسيط ثاني يمرر للتابِع. // الشيفرة let dictionary = Object.create(null, { toString: { // نعرّف الخاصية toString value() { // قيمة الخاصية دالة return Object.keys(this).join(); } } }); dictionary.apple = "Apple"; dictionary.__proto__ = "test"; // في الحلقة apple و __proto__ for(let key in dictionary) { alert(key); // "apple"، ثمّ "__proto__" } // قائمة من الخاصيات طبعها toString مفصولة بفواصل alert(dictionary); // "apple,__proto__" متى أنشأنا الخاصية باستعمال واصِف فستكون راياتها بقيمة false مبدئيًا. إذًا في الشيفرة أعلاه التابِع dictionary.toString ليس قابلًا للإحصاء. ألقِ نظرة على درس "رايات الخاصيات وواصِفاتها" لتُنعش ذاكرتك. الفرق بين الاستدعاءات الأهمية: 5 لنُنشئ ثانيةً كائن rabbit جديد: function Rabbit(name) { this.name = name; } Rabbit.prototype.sayHi = function() { alert(this.name); }; let rabbit = new Rabbit("Rabbit"); هل تؤدّي هذه الاستدعاءات نفس المهمة أم لا؟ rabbit.sayHi(); Rabbit.prototype.sayHi(); Object.getPrototypeOf(rabbit).sayHi(); rabbit.__proto__.sayHi(); الحل في الاستدعاء الأول يكون this == rabbit، بينما في البقية يكون this مساويًا إلى Rabbit.prototype إذ أنّ الكائن الفعلي يكون قبل النقطة. إذًا فالاستدعاء الأول هو الوحيد الذي يعرض Rabbit، بينما البقية تعرض undefined: function Rabbit(name) { this.name = name; } Rabbit.prototype.sayHi = function() { alert( this.name ); } let rabbit = new Rabbit("Rabbit"); rabbit.sayHi(); // Rabbit Rabbit.prototype.sayHi(); // undefined Object.getPrototypeOf(rabbit).sayHi(); // undefined rabbit.__proto__.sayHi(); // undefined ترجمة -وبتصرف- للفصل Prototype methods, objects without __proto__ من كتاب The JavaScript language
-
كثيرٌ من الكائنات تستعمل الخاصية "prototype"، حتّى في محرّك جافاسكربت، إذ تستعملها كلّ البواني المضمّنة في اللغة. لنرى أولًا تفاصيلها وبعدها كيفية استعمالها لإضافة مزايا جديدة إلى الكائنات المضمّنة. Object.prototype لنقل بأنّا طبعنا كائنًا فارغًا: let obj = {}; alert( obj ); // "[object Object]" ? ما هذه الشيفرة الّتي ولّدت النصّ "[object Object]"؟ هذه أفعال تابِع toString المضمّن في اللغة، ولكن أين هذا التابِع فكائن obj فارغ! ولكن لو فكّرنا لحظة… فالاختصار هذا obj = {} هو كأنما كتبنا obj = new Object()، وهنا Object هو الباني المضمّن في اللغة يُشير الخاصية prototype في الكائن إلى كائن آخر ضخم فيه التابِع toString وغيره من توابِع. هذا ما يحدث: متى استدعينا new Object() (أو أنشأنا كائن مجرّد {...})، ضُبطت الخاصية [[Prototype]] لذاك الكائن إلى Object.prototype طبقًا للقاعدة الّتي تحدّثنا عنها في الفصل السابق: لذا متى حدث استدعاء إلى obj.toString() أخذت لغة جافاسكربت التابِعَ من Object.prototype. يمكننا التأكّد من هذا هكذا: let obj = {}; alert(obj.__proto__ === Object.prototype); // true // obj.toString === obj.__proto__.toString == Object.prototype.toString لاحظ أنّ لم تعد هناك كائنات [[Prototype]] في السلسلة فوق Object.prototype: alert(Object.prototype.__proto__); // null كائنات النماذج الأولية الأخرى المضمّنة في اللغة الكائنات الأخرى مثل المصفوفات Array والتواريخ Date والدوال Function تضع هي الأخرى توابِعها في كائنات النماذج الأولية prototype. فمثلًا، حين نُنشئ المصفوفة [1, 2, 3] تستدعي لغة جافاسكربت داخليًا الباني new Array() بنفسها، بذلك يصير كائن Array.prototype كائنَ النموذج الأولي (prototype) ويقدّم له التوابِع اللازمة. هذا الأمر يزيد من كفاءة الذاكرة. بحسب المواصفات القياسية للغة، أنّ لكلّ كائنات النماذج الأولية (prototype) المضمّنة كائنَ Object.prototype آخر فوقها، ولهذا يقول الناس بأنّ ”كلّ شيء يرث الكائنات (Objects)“. إليك صورة تصف هذا كله لنرى أمر كائنات النماذج الأولية prototype يدويًا: js runlet arr = [1, 2, 3]; // هل ترث Array.prototype؟ alert( arr.__proto__ === Array.prototype ); // true // ثمّ من Object.prototype؟ alert( arr.__proto__.__proto__ === Object.prototype ); // true // وفوق هذا كلّه null. alert( arr.__proto__.__proto__.__proto__ ); // null أحيانًا تتداخل التوابِع في كائنات النماذج الأولية (prototype) مع بعضها. فمثلًا للكائن Array.prototype تابِعًا خاصًا فيه toString يعرض العناصر بينها فاصلة: let arr = [1, 2, 3] alert(arr); // 1,2,3 <-- ناتج Array.prototype.toString كما رأينا سابقًا فللكائن Object.prototype تابِع toString أيضًا، ولكنّ Array.prototype أقرب في سلسلة وراثة النموذج الأولي prototype وبذلك تستعمل لغة جافاسكربت تابِع المصفوفة لا الكائن. كما أنّ الأدوات في المتصفّحات (مثل طرفية كروم للمطوّرين) تعرض الوراثة (إن التعليمة console.dir ربّما سنحتاجها للكائنات المضمنة في اللغة). كما أنّ الكائنات الأخرى المضمّنة في اللغة تعمل بنفس الطريقة. حتى الدوالّ هم كائنات مبنية من خلال البواني المخصصة للدوالّ والمضمّنة في اللغة والدوالّ الخاصة بها (مثل الاستدعاء(call)/التطبيق(apply) وغيرهم من الدوالّ) مأخوذة من النموذج الأولي للدوالّ Function.prototype. ولديهم دالّة toString أيضًا. function f() {} alert(f.__proto__ == Function.prototype); // true alert(f.__proto__.__proto__ == Object.prototype); // true، إذ ترث الكائنات الأنواع الأولية حتّى هنا لا تعقيد، إلّا حين نتعامل مع السلاسل النصية والأعداد والقيم المنطقية، عندها نرى التعقيدات تبدأ. كما نتذكّر من فصول سابقة، فهذه الأنواع ليست كائنات، ولكن لو حاولنا الوصول إلى خاصياتها فسنرى كائنات تغليف أُنشئت مؤقتًا باستعمال البواني المضمّنة في اللغة String و Number و Boolean، وهذه الكائنات تقدم ما نريد من توابِع وتختفي. نرى هذه الكائنات مؤقّتًا إذ تُصنع سريعًا دون معرفتنا إن القيم null و undefined لا تملك أي كائنٍ مغلّف لها، وليس لديها أي دوال ولا خاصيات ولا حتى نماذج أولية. تغيير كائنات النماذج الأولية الأصيلة يمكن تعديل كائنات النماذج الأولية الأصيلة. فمثلًا يمكننا إضافة تابِع إلى String.prototype فيصبح متاحًا لكلّ السلاسل النصية: String.prototype.show = function() { alert(this); }; "BOOM!".show(); // BOOM! يمكن أن يراود المرء (وهو يطوّر) أفكار جديدة لتكون توابِع مضمّنة، ويريد إضافتها، بل نريد ونتوق إلى إضافتها إلى كائنات النماذج الأولية الأصيلة، إلّا أنّ هذه (وبصفة عامة) فكرة سيّئة جدًا. تحذير بما أنّ النماذج الأولية نماذج عامة فمن السهل أن يحدث تضارب. إذ تأتي مكتبتين تُضيفان التابِع String.prototype.show وتكتب واحدة على تابِع الأُخرى دون قصد. لهذا تعدّ عملية تعديل كائنات النماذج الأولية الأصيلة على أنّها فكرة سيئة. أمّا في البرمجة الحديثة، فما من طريقة مقبولة لتعديل كائنات النماذج الأولية الأصيلة إلا طريقة واحدة وهي: ترقيع نقص الدعم. ترقيع نقص الدعم (أو Polyfilling) هو صناعة بديل عن تابِع توضّحه مواصفة جافاسكربت ولكنّه ليس مدعومًا في محرّك جافاسكربت الهدف. لهذا السبب نكتب التنفيذ يدويًا ونضعه في كائن النموذج الأولي المضمّن له. مثال: if (!String.prototype.repeat) { // لو لم يكن هناك مثل هذا التابِع // نُضيفه إلى كائن prototype String.prototype.repeat = function(n) { // نكرّر السلسلة النصية n مرّة // في الواقع فالشيفرة الممتازة هي أكثر تعقيدًا من هذه // (تجد خوارزميتها الكاملة في المواصفة) // ولكن حتى التعويض الناقص يكون كافيًا أحيانًا كثيرة return new Array(n + 1).join(this); }; } alert( "La".repeat(3) ); // LaLaLa الاستعارة من كائنات النماذج الأولية تحدّثنا في الفصل "المُزخرِفات والتمرير، التابِعان call وapply"، عن استعارة التوابِع، أي حين نأخذ تابِعًا من كائن وننسخه إلى كائن غيره. في أحيان كثيرة نستعير بعض توابِع كائنات النماذج الأولية الأصيلة. مثال على ذلك حين نصنع كائنًا يشبه المصفوفات ونريد نسخ بعض توابِع المصفوفات Array إليه. انظر: let obj = { 0: "Hello", 1: "world!", length: 2, }; obj.join = Array.prototype.join; // هنا alert( obj.join(',') ); // Hello,world! تعمل الشيفرة أعلاه إذ أنّ الخوارزمية الداخلية لتابِع join المضمّن لا يهمهّا إلا الفهارس الصحيحة وخاصية الطول length، ولا ترى الكائن أهو حقًا مصفوفة أم لا. تتصرّف توابِع أخرى مضمّنة مثل تصرّف هذا التابِع. يمكننا أيضًا الوراثة بضبط obj.__proto__ على Array.prototype فتصير توابِع المصفوفات Array مُتاحة للكائن obj تلقائيًا. ولكن ما إن يرث obj من أيّ كائن آخر (غير كائن Array.prototype) يصير هذا مستحيلًا. لا تنسَ بأنّا لا نستطيع الوراثة إلا من كائن واحد فقط لا غير. تُعدّ هذه الميزة (ميزة استعارة التوابِع) ميزةً مرنة إذ تتيح لنا دمج مختلف مزايا الكائنات إن احتجناها. خلاصة تتبع كافة الكائنات المضمّنة في اللغة هذا النمط: التوابِع مخزّنة داخل كائن النموذج الأولي (مثل Array.prototype و Object.prototype وDate.prototype وغيرها) لا يخزّن الكائن إلّا بياناته (مثل عناصر المصفوفة وخصائص الكائن والتاريخ) تخزّن الأنواع الأولية أيضًا توابِعها في كائنات النماذج الأولية لكائنات تغليف: Number.prototype و String.prototype و Boolean.prototype. فقط undefined و null ليس لهما كائنات تغليف. يمكنك تعديل كائنات النماذج الأولية المضمّنة في اللغة أو إضافة توابِع جديدة لها، ولكنّ تغييرها ليس أمرًا مستحسنًا. ربما تكون إضافة المعايير الجديدة (والّتي لا يدعمها محرّك جافاسكربت بعد) هي الحالة الوحيدة المسموح بها. تمارين إضافة التابع f.defer(ms) إلى الدوال الأهمية: 5 أضِف إلى كائن النموذج الأولي المخصص للدوال التابِعَ defer(ms)، ووظيفته تشغيل الدالة بعد ms مِلّي ثانية. بعدما تنتهي، يفترض أن تعمل هذه الشيفرة: function f() { alert("Hello!"); } f.defer(1000); // تعرض ”Hello!“ بعد ثانية واحدة الحل Function.prototype.defer = function(ms) { setTimeout(this, ms); }; function f() { alert("Hello!"); } f.defer(1000); // تعرض ”Hello!“ بعد ثانية واحدة إضافة المُزخرِف defer() إلى الدوال الأهمية: 4 أضِف إلى كائن النموذج الأولي المخصص للدوالّ التابِعَ defer(ms)، ووظيفته إعادة غلاف يُؤخّر الاستدعاء ms مِلّي ثانية. إليك مثالًا عن طريقة عمله: function f(a, b) { alert( a + b ); } f.defer(1000)(1, 2); // يعرض ”3“ بعد ثانية واحدة لاحِظ أنّ عليك تمرير الوُسطاء إلى الدالة الأصل. الحل Function.prototype.defer = function(ms) { let f = this; return function(...args) { setTimeout(() => f.apply(this, args), ms); } }; // نتأكّد function f(a, b) { alert( a + b ); } f.defer(1000)(1, 2); // يعرض ”3“ بعد ثانية واحدة لاحظ بأنّا استعملنا this في التابِع f.apply لجعل المزخرف يعمل لكائن الدوالّ لذا فإن استدعيَ تابع التغليف كدالّة كائن عندها ستمرر this إلى الدالة الأصلية f. Function.prototype.defer = function(ms) { let f = this; return function(...args) { setTimeout(() => f.apply(this, args), ms); } }; let user = { name: "John", sayHi() { alert(this.name); } } user.sayHi = user.sayHi.defer(1000); user.sayHi(); ترجمة -وبتصرف- للفصل Native prototypes من كتاب The JavaScript language
-
سنكمل في هذا الدرس الحديث عن موضوع الوراثة النموذجية الذي بدأناه في الدرس السابق. لا تنسَ بأنّك يمكنك إنشاء كائنات جديدة من خلال دالّة الباني (مثل new F() ). لو كان F.prototype كائن جافاسكربت، فإن المعامِل new سيضبط الخاصية [[Prototype]] لهذا الكائن الجديد. من بداية تضمين لغة جافاسكربت للوراثة النموذجية جعلتها من المميزات الأساسية في اللغة. ولكن في الماضي لم يكن هنالك القدرة للوصول المباشر للوراثة النموذجية والطريقة الوحيدة الّتي حلّت محلها هي خاصية "prototype" في دالّة الباني. سنشرح في هذا الدرس كيفية استخدامها لأنه مازال العديد من الشيفرات البرمجية القديمة تستخدمها. لاحظ بأنّ F.prototype هنا تعني وجود خاصية عادية باسم "prototype" للكائن F. ربما تفكّر وكأنها النموذج الأولي لهذا الكائن، ولكن لا… فهنا نعني حرفيًا أنها خاصية عادية لها هذا الاسم. إليك مثالًا: let animal = { eats: true }; function Rabbit(name) { this.name = name; } Rabbit.prototype = animal; // هنا let rabbit = new Rabbit("White Rabbit"); // rabbit.__proto__ == animal alert( rabbit.eats ); // true تعني التعليمة Rabbit.prototype = animal حرفيًا الآتي: "ما إن يُنشأ كائن new Rabbit، أسنِد خاصية [[Prototype]] له لتكون للكائن animal". إليك الصورة الناتجة: في الصورة نرى "prototype" في سهم أفقي (أي أنّها خاصية عادية) بينما [[Prototype]] في سهم رأسي (أي أنّها توضّح وراثة كائن rabbit للكائن animal). إن الخاصية F.prototype تستخدم عند الإنشاء فقط أي عندما تستدعى تعليمة new F وتُسند للكائن القيمة المناسبة للخاصية [[Prototype]]. في حال تغيرت الخاصية F.prototype مثلًا (F.prototype = <another object>)، عندها ستحصل الكائنات المنشأة بعد هذا التغيير على القيمة الجديدة للخاصية [[Prototype]] (أي الكائن الجديد)، ولكن الكائنات القديمة مازالت تحتفظ بالقيمة القديمة. القيمة الإفتراضية للخاصية prototype في الباني لكلّ دالة خاصية "prototype" حتّى لو لم نقدّمها نحن. إن القيمة الإفتراضية للخاصية "prototype" تُشير إلى نفس الدالّة. هكذا تمامًا: function Rabbit() {} /* كائن prototype Rabbit.prototype = { constructor: Rabbit }; */ يمكننا فحص ذلك أيضًا: function Rabbit() {} // مبدئيًا: // Rabbit.prototype = { constructor: Rabbit } alert( Rabbit.prototype.constructor == Rabbit ); // true طبيعيًا، إن لم نعدل أي شيء، ستكون خاصية constructor مُتاحة لكلّ كائنات rabbit من خلال كائن [[Prototype]]: function Rabbit() {} // مبدئيًا: // Rabbit.prototype = { constructor: Rabbit } let rabbit = new Rabbit(); // ترث من {constructor: Rabbit} alert(rabbit.constructor == Rabbit); // true من prototype يمكننا استعمال الخاصية constructor لإنشاء كائن جديد باستعمال نفس الباني الّذي أنشأ الكائن الموجود حاليًا. هكذا: function Rabbit(name) { this.name = name; alert(name); } let rabbit = new Rabbit("White Rabbit"); // انظر let rabbit2 = new rabbit.constructor("Black Rabbit"); يُفيدنا هذا حين نكون أمام كائن ولكن لا نعرف الباني الحقيقي الّذي بناه (ربما أتى من مكتبة خارجية)، وأردنا إنشاء كائن آخر مثله. ولكن الأمر الأهم الّذي يتعلّق بِـ "constructor" هو أنّ لغة جافاسكربت نفسها لا تتأكّد من صحّة قيمة خاصية "constructor". نعم كما قرأت، الخاصية موجودة في "prototype" للدوالّ، وهذا كلّ ما في الأمر. إذ ستعتمد لغة جافاسكربت علينا فيما سيحدث لاحقًا. فمثلًا لو أردنا استبدال القيمة الإفتراضية للخاصية prototype، فلن يملك الكائن أيّ خاصية "constructor". مثال: function Rabbit() {} Rabbit.prototype = { jumps: true }; let rabbit = new Rabbit(); // لاحظ alert(rabbit.constructor === Rabbit); // false ولهذا لنُبقي على خاصية "constructor" الصحيحة يمكننا إضافة الخاصيات وإزالتها من كائن "prototype" الإفتراضي بدل الطريقة السابقة. هكذا: function Rabbit() {} // بدل الكتابة على كلّ Rabbit.prototype // نُضيف ما نريد إليه Rabbit.prototype.jumps = true // هكذا تبقى خاصية Rabbit.prototype.constructor الإفتراضية محفوظة أو يمكننا (لو أردنا) إعادة إنشاء الخاصية constructor يدويًا: Rabbit.prototype = { jumps: true, constructor: Rabbit // هنا }; // الآن سيكون المُنشِئ صحيحًا إذ أنّا من أضفناه خلاصة شرحنا في هذا الفصل سريعًا طريقة ضبط كائن [[Prototype]] للكائنات الّتي أنشأتها بدالّة الباني. سنرى لاحقًا أنماط متقدّمة في البرمجة تعتمد على هذا الطريقة. ما أخذناه بسيط، ولكن بعض الأمور نوضّحها ثانيةً للتأكّد: تضبط الخاصية F.prototype (لا تظنّها كائن [[Prototype]]) لكائنٍ ما الخاصية [[Prototype]] لكلّ الكائنات الجديدة متى استدعيت new F(). يجب أن تكون قيمة F.prototype إمّا كائنًا أو null، ولن تعمل أيّة قيم أخرى. هذا التأثير للخاصية "prototype" موجود فقط حين يُضبط في دالة الباني وحين يُنفّذ بتعليمة new. في الكائنات العادية ليست بخاصية خاصة جدًا: let user = { name: "John", prototype: "Bla-bla" // نزعنا السحر }; لكلّ الدوالّ مبدئيًا F.prototype = { constructor: F }، فيمكننا أن نأخذ باني معين من كائن ما بالدخول إلى الخاصية "constructor" الخاصة به. تمارين تغيير الخاصية ”prototype“ الأهمية: 5 أنشأنا في الشيفرة أدناه كائنًا جديدًا new Rabbit وحاولنا بعدها تعديل الخاصية prototype لهذا الكائن. بادئ ذي بدء، كانت الشيفرة: function Rabbit() {} Rabbit.prototype = { eats: true }; let rabbit = new Rabbit(); alert( rabbit.eats ); // true وأضفنا سلسلة نصية أخرى (عليها علامة). ماذا سيعرض التابِع alert؟ function Rabbit() {} Rabbit.prototype = { eats: true }; let rabbit = new Rabbit(); Rabbit.prototype = {}; // (*) alert( rabbit.eats ); // ? وماذا لو… كانت الشيفرة كهذه (استبدلنا سطرًا فيها)؟ function Rabbit() {} Rabbit.prototype = { eats: true }; let rabbit = new Rabbit(); Rabbit.prototype.eats = false; // (*) alert( rabbit.eats ); // ? وماذا عن هذه (استبدلنا سطرًا أيضًا)؟ function Rabbit() {} Rabbit.prototype = { eats: true }; let rabbit = new Rabbit(); delete rabbit.eats; // (*) alert( rabbit.eats ); // ? وهذه… أيضًا: function Rabbit() {} Rabbit.prototype = { eats: true }; let rabbit = new Rabbit(); delete Rabbit.prototype.eats; // (*) alert( rabbit.eats ); // ? الحل الإجابات: true. عملية الإسناد على Rabbit.prototype تضع الخاصية [[Prototype]] للكائنات الجديدة، ولكنّها لا تعدّل على الكائنات الموجودة مسبقًا. false. عملية الإسناد تكون من خلال الخاصية Rabbit.prototype، إن الخاصية المشار إليها هنا Rabbit.prototype ليست مكررًا، وإنما بقيت يُشار إليها من خلال Rabbit.prototype و الخاصية [[Prototype]] للكائن rabbit. لذا حين نغيّر المحتوى في الطريقة الأولى سنرى النتائج في الطريقة الثانية. true. كلّ عمليات الحذف تطبق مباشرة على الكائن. تحاول هذه التعليمة delete rabbit.eats حذف الخاصية المخصصة للكائن rabbit ولكنها ليست لها. لذا العملية لن يكون لها أي تأثير. undefined. حُذفت الخاصية eats من كائن prototype وما عادت موجودة بعد الآن. إنشاء كائن جديد من خلال نفس باني لكائنٍ آخر الأهمية: 5 تخيّل بأنّ لدينا الكائن الفريد obj وأنشأته بدالة الباني، ولكننا… لا نعرف أيّ دالة هذه، ولكن مع ذلك نريد استعمال نفس الباني لإنشاء كائن جديد آخر. أيمكن لهذه الشيفرة إنجاز المهمة؟ let obj2 = new obj.constructor(); اكتب مثالين باستخدام بانيين للكائن obj، واحدًا يعمل مع الشيفرة أعلاه، وواحدًا لا يعمل له. الحل يمكن أن نستعمل هذه الطريقة لو كنّا متأكدين مئة بالمئة بأنّ خاصية "constructor" تحمل القيمة الصحيحة. فمثلًا لو لم نعدّل على "prototype" المبدئية فستعمل هذه الشيفرة بلا ريب: function User(name) { this.name = name; } let user = new User('John'); let user2 = new user.constructor('Pete'); alert( user2.name ); // Pete (عملت!) نفذت الشيفرة تنفيذًا صحيحًا إذ أنّ User.prototype.constructor == User. ولكن… لو أتى أحدهم مثلًا وكتب على User.prototype ونسي إعادة إنشاء constructor لتُشير إلى كائن المستخدم User، فلن تعمل الشيفرة. مثال: function User(name) { this.name = name; } User.prototype = {}; // (*) let user = new User('John'); let user2 = new user.constructor('Pete'); alert( user2.name ); // undefined لمَ قيمة user2.name هي undefined؟ إليك طريقة عمل تعليمة new user.constructor('Pete'): أولًا، تبحث عن المُنشِئ constructor داخل user، ولا تجده. ثمّ تتبع سلسلة prototype وتجد prototype الكائن user هو User.prototype، وأيضًا لا تجده. قيمة User.prototype ما هي إلّا كائنًا فارغًا {}، و قيمة الخاصية prototype لهذا الكائن هي Object.prototype، وهنا وجدنا Object.prototype.constructor == Object بذلك استعملناه. وفي نهاية الأمر، لدينا التعليمة let user2 = new Object('Pete') إذ أنّ الباني الخاص بالكائن Object يتجاهل الوسطاء وينشىء دائمًا كائنًا فارغًا. بطريقة مشابهة جدًا للتعليمة let user2 = {} والّتي أنشأت لنا الكائن user2 في نهاية الأمر. ترجمة -وبتصرف- للفصل F.prototype من كتاب The JavaScript language
-
أثناء البرمجة، نرى دائما مواقف حيث تريد أخذ شيء وتوسعته أكثر. فمثلًا لدينا كائن مستخدم user له خاصيات وتوابِع، وأردنا إنشاء نسخ عنه (مدراء admin وضيوف guest) لكن معدّلة قليلًا. سيكون رائعًا لو أعدنا استعمال الموجود في كائن المستخدم بدل نسخه أو إعادة كتابة توابِعه، سيكون رائعًا لو صنعنا كائنًا جديدًا فوق كائن user. الوراثة النموذجية (تدعى أيضًا الوراثة عبر كائن النموذج الأولي prototype)* هي الميزة الّتي تساعدنا في تحقيق هذا الأمر. الخاصية [[Prototype]] لكائنات جافاسكربت خاصية مخفية أخرى باسم [[Prototype]] (هذا اسمها في المواصفات القياسية للغة جافاسكربت)، وهي إمّا أن تكون null أو أن تشير إلى كائن آخر. نسمّي هذا الكائن بِـ”prototype“ (نموذج أولي). إن كائن النموذج الأولي ”سحريٌ“ إن صحّ القول، فحين نريد قراءة خاصية من كائن object ولا يجدها محرّك جافاسكربت، يأخذها تلقائيًا من كائن النموذج الأولي لذاك الكائن. يُسمّى هذا في علم البرمجة ”بالوراثة النموذجية“ (Prototypal inheritance)، وهناك العديد من المزايا الرائعة في اللغة وفي التقنيات البرمجية مبنية عليها. الخاصية [[Prototype]] هي خاصية داخلية ومخفية، إلّا أنّ هناك طُرق عديدة لنراها. إحداها استعمال __proto__ هكذا: let animal = { eats: true }; let rabbit = { jumps: true }; rabbit.__proto__ = animal; // هنا __proto__ هو الجالب والضابط القديم للخاصية [[Prototype]] كانت تستخدم قديمًا، ولكن في اللغة الحديثة استبدلت بالدالتين Object.getPrototypeOf/Object.setPrototypeOf وهي أيضًا تعمل عمل الجالب والضابط للنموذج الأولي (سندرس هذه الدوالّ لاحقًا في هذا الدرس). إن المتصفحات هي الوحيدة الّتي تدعم __proto__ وفقًا للمواصفات القياسية للغة، ولكن في الواقع جميع البيئات تدعمها حتى بيئات الخادم وذلك لأنها سهلة وواضحة. وهي الّتي سنستخدمها في الأمثلة. فمثلاً لو بحثنا الآن عن خاصية ما في كائن rabbit ولم تكُ موجودة، ستأخذها لغة جافاسكربت تلقائيًا من كائن animal. مثال على ذلك: let animal = { eats: true }; let rabbit = { jumps: true }; rabbit.__proto__ = animal; // (*) // الآن كلتا الخاصيتين في الأرنب: alert( rabbit.eats ); // true (**) alert( rabbit.jumps ); // true هنا نضبط (في السطر (*)) كائن animal ليكون النموذج الأولي (Prototype) للكائن rabbit. بعدها متى ما حاولت التعليمة alert قراءة الخاصية rabbit.eats (انظر (**))، ولم يجدها في كائن rabbit ستتبع لغة جافاسكربت الخاصية [[Prototype]] لمعرفة ما هو كائن النموذج الأولي لكائن rabbit، وسيجده كائن animal (البحث من أسفل إلى أعلى): يمكن أن نقول هنا بأنّ الكائن animal هو النموذج الأولي للكائن rabbit، أو كائن rabbit هو نسخة نموذجية من الكائن animal. وبهذا لو كان للكائن animal خاصيات وتوابِع كثيرة مفيدة، تصير مباشرةً موجودة عند كائن rabbit. نسمّي هذه الخاصيات بأنّها ”موروثة“. لو كان للكائن animal تابِعًا فيمكننا استدعائه في كائن rabbit: let animal = { eats: true, walk() { alert("Animal walk"); } }; let rabbit = { jumps: true, __proto__: animal }; // نأخذ walk من كائن النموذج الأولي rabbit.walk(); // Animal walk يُؤخذ التابِع تلقائيًا من كائن النموذج الأولي، هكذا: يمكن أيضًا أن تكون سلسلة الوراثة النموذجية (النموذج الأولي) أطول: let animal = { eats: true, walk() { alert("Animal walk"); } }; let rabbit = { jumps: true, __proto__: animal // (*) }; let longEar = { earLength: 10, __proto__: rabbit // (*) }; // نأخذ الدالّة walk من سلسلة الوراثة النموذجية longEar.walk(); // Animal walk alert(longEar.jumps); // true (من rabbit) ولكن، هناك مُحددان للوراثة النموذجية وهما: لا يمكن أن تكون سلسلة الوراثة النموذجية دائرية (على شكل حلقة). ما إن تُسند __proto__ بطريقة دائرية فسترمي لغة جافاسكربت خطأً. يمكن أن تكون قيمة __proto__ إمّا كائنًا أو null، وتتجاهل لغة جافاسكربت الأنواع الأخرى. ومن الواضح جليًا أيضًا أي كائن سيرث كائن [[Prototype]] واحد وواحد فقط، لا يمكن للكائن وراثة كائنين. كائن النموذج الأولي للقراءة فقط لا يمكننا تعديل أو حذف خصائص أو دوالّ من كائن النموذج الأولي وإنما هو للقراءة فقط. وأيّة عمليات كتابة أو حذف تكون مباشرةً على الكائن نفسه وليس على كائن النموذج الأولي. في المثال أسفله نُسند التابِع walk إلى الكائن rabbit: let animal = { eats: true, walk() { /* لن يستعمل الكائن `rabbit` هذا التابِع */ } }; let rabbit = { __proto__: animal }; rabbit.walk = function() { alert("Rabbit! Bounce-bounce!"); }; rabbit.walk(); // Rabbit! Bounce-bounce! من الآن فصاعدًا فستجد استدعاء التابع rabbit.walk() سيكون من داخل كائن rabbit مباشرةً وتُنفّذه دون استعمال كائن النموذج الأولي: ولكن خاصيات الوصول استثناء للقاعدة، إذ يجري الإسناد على يد دالة الضابِط، أي أنّك بالكتابة في هذه الخاصية في الكائن الجديد ولكنّك استدعيت دالة الضابط الخاصة بكائن النموذج الأولي لإسناد هذه القيمة. لهذا السبب نرى الخاصية admin.fullName في الشيفرة أسفله تعمل كما ينبغي لها: let user = { name: "John", surname: "Smith", set fullName(value) { [this.name, this.surname] = value.split(" "); }, get fullName() { return `${this.name} ${this.surname}`; } }; let admin = { __proto__: user, isAdmin: true }; alert(admin.fullName); // John Smith (*) // عمل الضابِط! admin.fullName = "Alice Cooper"; // (**) هنا في السطر (*) نرى أن admin.fullName استدعت الجالِب داخل الكائن user، ولهذا استُدعيت الخاصية. وفي السطر (**) نرى عملية إسناد للخاصية admin.fullName ولهذا استدعيَ الضابِط داخل الكائن user. ماذا عن "this"؟ بعدما تتمعّن في المثال أعلاه، يمكن أن تتساءل ما قيمة this داخل set fullName(value)؟ أين كُتبت القيم الجديدة this.name و this.surname؟ داخل الكائن user أم داخل الكائن admin؟ جواب هذا السؤال المحيّر بسيط: لا تؤثّر كائنات النموذج الأولي على قيمة this. أينما كان التابِع موجودًا أكان في الكائن أو في كائن النموذج الأولي، سيكون تأثير this على الكائن الّذي قبل النقطة (الكائن المستدعى من خلاله هذه الخاصية) دائمًا وأبدًا. لهذا فالضابِط الّذي يستدعي admin.fullName= يستعمل كائن admin عوضًا عن this وليس الكائن user. في الواقع فهذا أمر مهما جدًا جدًا إذ أنّ لديك ربما كائنًا ضخمًا فيه توابِع كثيرة جدًا، وهناك كائنات أخرى ترثه، وما إن تشغّل تلك الكائنات الموروثة التوابِعَ الموروثة، ستعدّل حالتها هي -أي الكائنات- وليس حالة الكائن الضخم ذاك. فمثلًا هنا، يمثّل كائن animal ”مخزّنَ توابِع“ وكائن rabbit يستغلّ هذا المخزن. فاستدعاء rabbit.sleep() يضبط this.isSleeping على كائن rabbit: // للحيوان توابِع let animal = { walk() { if (!this.isSleeping) { alert(`I walk`); } }, sleep() { this.isSleeping = true; } }; let rabbit = { name: "White Rabbit", __proto__: animal }; // يعدّل rabbit.isSleeping rabbit.sleep(); alert(rabbit.isSleeping); // true alert(animal.isSleeping); // غير معرّف (لا يوجد خاصية معرفة في كائن النموذج الأولي بهذا الأسم) الصورة الناتجة: لو كانت هناك كائنات أخرى (مثل الطيور bird والأفاعي snake وغيرها) ترث الكائنanimal، فسيمكنها الوصول إلى توابِع الكائن animal، إلّا أنّ قيمة this في كلّ استدعاء للتوابِع سيكون على الكائن الّذي استُدعيت منه، وستعرِفه لغة جافاسكربت أثناء الاستدعاء (أي سيكون الكائن الّذي قبل النقطة) ولن يكون animal. لذا متى كتبنا البيانات من خلال this، فستُخزّن في تلك الكائنات الّتي استدعيت عليها this. وبهذا نخلص إلى أنّ التوابِع مشتركة، ولكن حالة الكائن ليست مشتركة. حلقة for..in كما أنّ حلقة for..in تَمرُّ على الخاصيات الموروثة هي الأخرى. مثال: let animal = { eats: true }; let rabbit = { jumps: true, __proto__: animal }; // يُعيد التابع Object.keys خصائص الكائن نفسه فقط alert(Object.keys(rabbit)); // jumps *!* // تدور حلقة for..in على خصائص الكائن نفسه والخصائص الموروثة معًا for(let prop in rabbit) alert(prop); // jumps ثمّ eats */!* لو لم تكن هذه النتيجة ما نريد (أي نريد استثناء الخاصيات الموروثة)، فيمكن استعمال التابِع obj.hasOwnProperty(key) المضمّن في اللغة: إذ يُعيد true لو كان للكائن obj نفسه (وليس للموروث منه) خاصية بالاسم key. بهذا يمكننا ترشيح الخاصيات الموروثة (ونتعامل معها على حدة): let animal = { eats: true }; let rabbit = { jumps: true, __proto__: animal }; for(let prop in rabbit) { let isOwn = rabbit.hasOwnProperty(prop); if (isOwn) { alert(`Our: ${prop}`); // تخصّنا: jumps } else { alert(`Inherited: ${prop}`); // ورثناها: eats } } هنا نرى سلسلة الوراثة الآتية: يرث كائن rabbit كائنَ animal، والّذي يرثه هكذا Object.prototype (إذ أنّه كائن مجرّد {...}، وهذا السلوك المبدئي)، وبعدها يرث null: ملاحظة لطيفة في هذا السياق وهي: من أين أتى التابِع rabbit.hasOwnProperty؟ لم نعرّفه يدويًا! لو تتبّعناه في السلسلة لرأينا بأنّ كائن النموذج الأولي Object.prototype.hasOwnProperty هو من قدّم التابِع، أي بعبارة أخرى، ورث كائن rabbit هذا التابِع من كائن النموذج الأولي. ولكن لحظة… لماذا لم يظهر تابع hasOwnProperty في حلقة for..in كما ظهرت eats و jumps طالما تُظهر حلقات for..in الخاصيات الموروثة؟ الإجابة هنا بسيطة أيضًا: لإنه مُنع من قابلية العدّ (من خلال إسناده لقيمة الراية enumerable:false). في النهاية هي مِثل غيرها من الخاصيات في Object.prototype- تملك الراية enumerable:false، وحلقة for..in لا تمرّ إلّا على الخاصيات القابلة للعدّ. لهذا السبب لم نراها لا هي ولا خاصيات Object.prototype الأخرى. كلّ التوابِع الّتي تجلب المفتاح/القيمة تُهمل الخاصيات الموروثة، تقريبًا مثل تابِع Object.keys أو تابِع Object.values وما شابههم. إذ إنهم يتعاملون مع خصائص الكائن نفسه ولا يأخذون بعين الاعتبار الخصائص الموروثة خلاصة لكلّ كائنات جافاسكربت خاصية [[Prototype]] مخفية قيمتها إمّا أحد الكائنات أو null. يمكننا استعمال obj.__proto__ للوصول إلى هذه الخاصية (وهي خاصية جالِب/ضابِطة). هناك طرق أخرى سنراها لاحقًا. الكائن الّذي تُشير إليه الخاصية [[Prototype]] يسمّى كائن النموذج الأولي. لو أردنا قراءة خاصية داخل كائن ما obj أو استدعاء تابِع، ولم تكن موجودة/يكن موجودًا، فسيحاول محرّك جافاسكربت البحث عنه/عنها في كائن النموذج الأولي. عمليات الكتابة والحذف تتطبّق مباشرة على الكائن المُستدعي ولا تستعمل كائن النموذج الأولي (إذ يعدّ أنّها خاصية بيانات وليست ضابِطًا). لو استدعينا التابِع obj.method() وأخذ المحرّك التابِع method من كائن النموذج الأولي، فلن تتغير إشارة this وسيُشير إلى obj، أي أنّ التوابِع تعمل على الكائن الحالي حتّى لو كانت التوابِع نفسها موروثة. تمرّ حلقة for..in على خاصيات الكائن والخاصيات الموروثة، بينما لا تعمل توابِع جلب المفاتيح/القيم إلّا على الكائن نفسه. تمارين العمل مع prototype الأهمية: 5 إليك شيفرة تُنشئ كائنين وتعدّلها. ما القيم الّتي ستظهر في هذه العملية؟ let animal = { jumps: null }; let rabbit = { __proto__: animal, jumps: true }; alert( rabbit.jumps ); // ? (1) delete rabbit.jumps; alert( rabbit.jumps ); // ? (2) delete animal.jumps; alert( rabbit.jumps ); // ? (3) يجب أن هنالك ثلاث إجابات. الحل true، تأتي من rabbit. null، تأتي من animal. undefined، إذ ليس هناك خاصية بهذا الاسم بعد الآن. خوارزمية بحث الأهمية: 5 ينقسم هذا التمرين إلى قسمين. لديك الكائنات التالية: let head = { glasses: 1 }; let table = { pen: 3 }; let bed = { sheet: 1, pillow: 2 }; let pockets = { money: 2000 }; استعمل __proto__ لإسناد كائنات النموذج الأولي بحيث يكون البحث عن الخاصيات بهذه الطريقة: pockets ثمّ bed ثمّ table ثمّ head (من الأسفل إلى الأعلى على التتالي). فمثلًا، قيمة pockets.pen تكون 3 (من table)، وقيمة bed.glasses تكون 1 (من head). أجِب عن هذا السؤال: ما الأسرع، أن نجلب glasses هكذا pockets.glasses أم هكذا head.glasses؟ قِس أداء كلّ واحدة لو لزم. الحل لنُضيف خاصيات __proto__: let head = { glasses: 1 }; let table = { pen: 3, __proto__: head }; let bed = { sheet: 1, pillow: 2, __proto__: table }; let pockets = { money: 2000, __proto__: bed }; alert( pockets.pen ); // 3 alert( bed.glasses ); // 1 alert( table.money ); // undefined حين نتكلّم عن المحرّكات الحديثة، فليس هناك فرق (من ناحية الأداء) لو أخذنا الخاصية من الكائن أو من النموذج الأولي، فهي تتذكّر مكان الخاصية وتُعيد استعمالها عند طلبها ثانيةً. فمثلًا ستتذكّر التعليمة pockets.glasses بأنّها وجدت glasses في كائن head، وفي المرة التالية ستبحث هناك مباشرة. كما أنّها ذكية لتُحدّث ذاكرتها الداخلية ما إن يتغيّر شيء ما لذا فإن الأداء الأمثل في أمان. أين سيحدث التعديل؟ الأهمية: 5 لدينا الكائن rabbit يرث من الكائن animal. لو استدعينا rabbit.eat() فأيّ الكائنين ستُعدل به الخاصية full، الكائن animal أم الكائن rabbit؟ let animal = { eat() { this.full = true; } }; let rabbit = { __proto__: animal }; rabbit.eat(); الحل الإجابة هي: الكائن rabbit. لأنّ قيمة this هي الكائن قبل النقطة، بذلك يُعدّل rabbit.eat(). عملية البحث عن الخاصيات تختلف تمامًا عن عملية تنفيذ تلك الخاصيات. نجد التابِع rabbit.eat سيُستدعى أولًا من كائن النموذج الأولي، وبعدها نُنفّذه على أنّ this=rabbit. لماذا أصابت التخمة كِلا الهامسترين؟ الأهمية: 5 لدينا هامسترين، واحد سريع speedy وآخر كسول lazy، والاثنين يرثان كائن الهامستر العمومي hamster. حين نُعطي أحدهما الطعام، نجد الآخر أُتخم أيضًا. لماذا ذلك؟ كيف نُصلح المشكلة؟ let hamster = { stomach: [], eat(food) { this.stomach.push(food); } }; let speedy = { __proto__: hamster }; let lazy = { __proto__: hamster }; // وجد هذا الهامستر الطعامَ قبل الآخر speedy.eat("apple"); alert( speedy.stomach ); // apple // هذا أيضًا وجده. لماذا؟ أصلِح الشيفرة. alert( lazy.stomach ); // apple الحل لنرى ما يحدث داخل الاستدعاء speedy.eat("apple") بدقّة. نجد التابِع speedy.eat في كائن النموذج الأولي الهامستر (=hamster)، وبعدها ننفّذه بقيمة this=speedy (الكائن قبل النقطة). بعدها تأتي مهمة البحث للتابِع this.stomach.push() ليجد خاصية المعدة stomach ويستدعي عليها push. يبدأ البحث عن stomach في this (أي في speedy)، ولكنّه لا يجد شيئًا. بعدها يتبع سلسلة الوراثة ويجد المعدة stomach في hamster. ثمّ يستدعي push عليها ويذهب الطعام في معدة النموذج الأولي. بهذا تتشارك الهامسترات كلها معدةً واحدة! أكان lazy.stomach.push(...) أم speedy.stomach.push()، لا نجد خاصية المعدة stomach إلّا في كائن النموذج الأولي (إذ ليست موجودة في الكائن نفسه)، بذلك ندفع البيانات الجديدة إلى كائن النموذج الأولي. لاحظ كيف أنّ هذا لا يحدث لو استعملنا طريقة الإسناد البسيط this.stomach=: let hamster = { stomach: [], eat(food) { // نُسند إلى this.stomach بدلًا من this.stomach.push this.stomach = [food]; } }; let speedy = { __proto__: hamster }; let lazy = { __proto__: hamster }; // وجد الهامستر السريع الطعام speedy.eat("apple"); alert( speedy.stomach ); // apple // معدة ذاك الكسول فارغة alert( lazy.stomach ); // <لا شيء> الآن يعمل كلّ شيء كما يجب، إذ لا تبحث عملية الإسناد this.stomach= عن خاصية stomach، بل تكتبها مباشرةً في كائن الهامستر الّذي وجد الطعام (المستدعى قبل النقطة). ويمكننا تجنّب هذه المشكلة من الأساس بتخصيص معدة لكلّ هامستر (كما الطبيعي): let hamster = { stomach: [], eat(food) { this.stomach.push(food); } }; let speedy = { __proto__: hamster, stomach: [] // هنا }; let lazy = { __proto__: hamster, stomach: [] // هنا }; // وجد الهامستر السريع الطعام speedy.eat("apple"); alert( speedy.stomach ); // apple // معدة ذاك الكسول فارغة alert( lazy.stomach ); // <لا شيء> يكون الحلّ العام هو أن تُكتب الخاصيات كلّها الّتي تصف حالة الكائن المحدّد ذاته (مثل stomach أعلاه) - أن تُكتب في الكائن ذاته، وبهذا نتجنّب مشاكل تشارك المعدة. ترجمة -وبتصرف- للفصل Prototypal inheritance من كتاب The JavaScript language
-
يوجد نوعين من الخاصيات. الأوّل هو خاصيات البيانات (Data Properties). نعرف جيدًا كيف نعمل مع هذا النوع إذ كلّ ما استعملناه من البداية إلى حدّ الساعة هي خاصيات بيانات. النوع الثاني هو الجديد، وهو خاصيات الوصول (Accessor Properties). هي في الأساس دوال تجلب القيم وتضبطها، ولكن في الشيفرة تظهرُ لنا وكأنها خاصيات عادية. الجالبات والضابطات خاصيات الوصول هذه هي توابِع ”جلب“ (getter) و”ضبط“ (setter). let obj = { get propName() { // جالب، يُستعمَل جلب قيمة الخاصية obj.propName }, set propName(value) { // ضابط يُستعمَل لضبط قيمة الخاصية obj.propName إلى value } }; يعمل الجالب متى ما طلبت قراءة الخاصية obj.propName، والضابط… متى ما أردت إسناد قيمة obj.propName = value. لاحظ مثلًا كائن user له خاصيتين: اسم name ولقب surname: let user = { name: "John", surname: "Smith" }; الآن نريد إضافة خاصية الاسم الكامل fullName، وهي "John Smith". طبعًا لا نريد نسخ المعلومات ولصقها، لذا سنُنفذها باستخدام خاصية الوصول (ِget): let user = { name: "John", surname: "Smith", // لاحظ get fullName() { return `${this.name} ${this.surname}`; } }; alert(user.fullName); // John Smith خارج الكائن لا تبدو خاصية الوصول إلا خاصية عادية، وهذا بالضبط الغرض من هذه الخاصيات، فلسنا نريد استدعاء user.fullName على أنّها دالة، بل قراءتها فحسب، ونترك الجالب يقوم بعمله خلف الكواليس. الآن ليس للخاصية fullName إلا جالبًا، لو حاولنا إسناد قيمة لها user.fullName= فسنرى خطأً: let user = { get fullName() { return `...`; } }; user.fullName = "Test"; // خطأ (للخاصية جالب فقط) هيًا نُصلح الخطأ ونُضيف ضابطًا للخاصية user.fullName: let user = { name: "John", surname: "Smith", get fullName() { return `${this.name} ${this.surname}`; }, // هنا set fullName(value) { [this.name, this.surname] = value.split(" "); } }; // نضبط fullName كما النية بتمرير القيمة. user.fullName = "Alice Cooper"; alert(user.name); // Alice alert(user.surname); // Cooper وهكذا صار لدينا الخاصية ”الوهمية“ fullName. يمكننا قراءتها والكتابة عليها، ولكنها في واقع الأمر، غير موجودة. واصفات الوصول (Accessor Descriptors) واصِفات خاصيات الوصول (Accessor Properties) تختلف عن واصِفات خاصيات البيانات (Data Properties). فليس لخاصيات الوصول قيمة value أو راية writable، بل هناك دالة get ودالة set. أي يمكن لواصِف الوصول أن يملك مايلي: get -- دالة ليس لها وُسطاء تعمل متى ما قُرئت الخاصية. set -- دالة لها وسيط واحد تُستدعى متى ما ضُبطت الخاصية. enumerable -- خاصية قابلية الإحصاء وهي مشابهة لخاصيّات البيانات. configurable -- خاصية قابلية إعادة الضبط وهي مشابهة لخاصيّات البيانات. فمثلًا لننشئ خاصية الوصول fullName باستعمال التابِع defineProperty، يمكننا تمرير واصِفًا فيه دالة get ودالة set: let user = { name: "John", surname: "Smith" }; // هنا Object.defineProperty(user, 'fullName', { get() { return `${this.name} ${this.surname}`; }, set(value) { [this.name, this.surname] = value.split(" "); } }); alert(user.fullName); // John Smith for(let key in user) alert(key); // name, surname أُعيد بأنّ الخاصية إمّا تكون خاصية وصول (لها توابِع get/set) أو خاصية بيانات (لها قيمة value)، ولا تكون الاثنين معًا. فلو حاولنا تقديم get مع value في نفس الواصِف، فسنرى خطأً: // خطأ: واصِف الخاصية غير صالح Object.defineProperty({}, 'prop', { get() { return 1 }, value: 2 }); الجوالب والضوابط الذكية يمكننا استعمال الجوالب والضوابط كأغلفة للخاصيات ”الفعلية“، فتكون في يدنا السيطرة الكاملة على العمليات التي تؤثّر عليها. فمثلًا لو أردنا منع الأسماء القصيرة للاسم user فيمكن كتابة الضابِط name وترك القيمة في خاصية منفصلة باسم _name: let user = { get name() { return this._name; }, set name(value) { if (value.length < 4) { alert("Name is too short, need at least 4 characters"); // الاسم قصير جدًا. أقلّ طول هو 4 محارف return; } this._name = value; } }; user.name = "Pete"; alert(user.name); // Pete user.name = ""; // الاسم قصير جدًا... هكذا نخزّن الاسم في الخاصية _name والوصول يكون عبر الجالب والضابط. عمليًا يمكن للشيفرة الخارجية الوصول إلى الاسم مباشرةً باستعمال user._name، ولكن هناك مفهوم شائع بين المطوّرين هو أنّ الخاصيات التي تبدأ بشرطة سفلية "_" هي خاصيات داخلية وممنوع التعديل عليها من خارج الكائن. استعمالها لغرض التوافقية إحدى استعمالات خاصيات الوصول هذه هي إتاحة الفرصة للتحكّم بخاصية بيانات ”عادية“ متى أردنا واستبدالها بدالتي جلب وضبط وتعديل سلوكها. لنقل مثلًا بأنّا بدأنا المشروع حيث كانت كائنات المستخدمين تستعمل خاصيات البيانات: الاسم name والعمر age: function User(name, age) { this.name = name; this.age = age; } let john = new User("John", 25); alert( john.age ); // 25 ولكن الأمور لن تبقى على حالها وإنما ستتغير، عاجلًا أم آجلًا. فبدل العمر age نقول بأنّا نريد تخزين تاريخ الميلاد birthday إذ هو أكثر دقّة وسهولة في الاستعمال: function User(name, birthday) { this.name = name; this.birthday = birthday; } let john = new User("John", new Date(1992, 6, 1)); ولكن… كيف سنتعامل مع الشيفرة القديمة الّتي مازالت تستعمل خاصية age؟ يمكن أن نبحث في كلّ أمكان استخدام الخاصية age وتغييرها بخاصية جديدة مناسبة، ولكنّ ذلك يأخذ وقتًا ويمكن أن يكون صعبًا لو عدة مبرمجين يعملون على هذه الشيفرة، كما وأنّ وجود عمر المستخدم كخاصية age أمر جيّد، أليس كذلك؟ إذًا لنُبقي الخاصية كما هي، ونُضيف جالبًا للخاصية تحلّ لنا المشكلة: function User(name, birthday) { this.name = name; this.birthday = birthday; // العمر هو الفرق بين التاريخ اليوم وتاريخ الميلاد Object.defineProperty(this, "age", { get() { let todayYear = new Date().getFullYear(); return todayYear - this.birthday.getFullYear(); } }); } let john = new User("John", new Date(1992, 6, 1)); alert( john.birthday ); // تاريخ الميلاد موجود alert( john.age ); // ...وعمر المستخدم أيضًا هكذا بقيت الشيفرة القديمة تعمل كما نريد، وأضفنا الخاصية الإضافية. ترجمة -وبتصرف- للفصل Property getters and setters من كتاب The JavaScript language
-
كما نعلم فالكائنات تُخزّن داخلها خاصيات (properties) تصفها. وحتى الآن لم تكن الخاصية إلا زوجًا من مفاتيح وقيم، ولكن خاصية الكائن يمكن أن تكون أكثر قوّة ومرونة من هذا. سنتعلم في هذا المقال بعض خصائص الضبط الأخرى، وفي الفصل الّذي يليه سنرى كيف نحوّلها إلى دوال جلب/ضبط (Setters/Getters) أيضًا. رايات الخاصيات لخصائص الكائنات (إضافةً إلى القيمة الفعلية لها) ثلاث سمات أخرى مميزة (أو ”رايات“ flags): قابلية التعديل -- لو كانت بقيمة true فيمكننا تغيير القيمة وتعديلها، ولو لم تكن فالقيمة للقراءة فقط. قابلية الإحصاء -- لو كانت بقيمة true، فستقدر الحلقات على المرور على عناصرها، وإلا فلن تقدر. قابلية إعادة الضبط -- لو كانت بقيمة true فيمكن حذف الخاصية وتعديل هذه السمات، وإلا فلا يمكن. لم نتطرّق إلى هذه الرايات قبلًا إذ لا تظهر عادةً في الشيفرات، فحين ننشئ خاصية "بالطريقة العادية" فكلّ هذه السمات بقيمة true، ولكن يمكننا طبعًا تغييرها متى أردنا. أولًا لنعرف كيف سنرى هذه الرايات. يتيح لنا التابِع Object.getOwnPropertyDescriptor الاستعلامَ عن المعلومات الكاملة الخاصة بأيّ خاصية. وهذه صياغته: let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName); obj: الكائن الّذي سنجلب معلوماته. propertyName: اسم الخاصية الّتي نريد. نسمّي القيمة المُعادة بكائن ”واصِف الخاصيات“ (Property Descriptor)، وهو يحتوي على القيمة وجميع الرايات الّتي سبق لنا شرحها. إليك مثالًا: let user = { name: "John" }; let descriptor = Object.getOwnPropertyDescriptor(user, 'name'); alert( JSON.stringify(descriptor, null, 2 ) ); /* واصف الخاصية: { "value": "John", "writable": true, "enumerable": true, "configurable": true } */ يمكننا استعمال التابِع Object.defineProperty لتغيير الرايات. إليك صياغته: Object.defineProperty(obj, propertyName, descriptor) obj و propertyName: الكائن الّذي سنطبّق عليه الواصِف، واسم الخاصية. descriptor: واصِف الخاصيات الّذي سنطبّقه على الكائن. لو كانت الخاصية موجودة فسيُحدّث التابع defineProperty راياتها. وإلّا فسيُنشئ الخاصية بهذه القيمة الممرّرة والرايات كذلك، وفي هذه الحالة لو لم يجد قيمة لأحد الرايات، فسيعدّه بقيمة false. مثلًا هنا نُنشئ الخاصية name حيث تكون راياتها كلّها بقيمة false: let user = {}; Object.defineProperty(user, "name", { value: "John" }); let descriptor = Object.getOwnPropertyDescriptor(user, 'name'); alert( JSON.stringify(descriptor, null, 2 ) ); /* { "value": "John", // لاحظ هنا "writable": false, "enumerable": false, "configurable": false } */ وازِن هذه الخاصية بتلك الّتي صنعناها أعلاه user.name (كالعادة): وأصبحت قيمة جميع الرايات false. لو لم يكن هذا ما تريده فربّما الأفضل ضبطها على true في كائن descriptor. لنرى الآن تأثيرات هذه الرايات في هذا المثال. منع قابلية التعديل لنمنع قابلية التعديل على الخاصية user.name (أي استحالة إسناد قيمة لها) وذلك بتغيير راية writable: let user = { name: "John" }; Object.defineProperty(user, "name", { writable: false // هنا }); user.name = "Pete"; // خطأ: لا يمكن إسناد القيم إلى الخاصية `name` إذ هي للقراءة فقط الآن يستحيل على أيّ شخص تعديل اسم هذا المستخدم إلّا لو طبّقوا تابِع defineProperty من طرفهم ليُلغي ما فعلناه نحن. لا تظهر الأخطاء إلّا في الوضع الصارم لا تظهر أي أخطاء عند تعديل قيمة خاصية في وضع غير الصارم إلّا أنها لن تتغير قيمتها بطبيعة الحال، وذلك لأن خطأ خرق الراية لن يظهر إلا في الوضع الصارم. إليك نفس المثال ولكن دون إنشاء الخاصية من الصفر: let user = { }; Object.defineProperty(user, "name", { value: "John", // لو كانت الخاصيات جديدة فعلينا إسناد قيمها إسنادًا صريحًا enumerable: true, configurable: true }); alert(user.name); // John user.name = "Pete"; // Error منع قابلية الإحصاء الآن لنُضيف تابِع toString مخصّص على كائن user. عادةً لا يمكننا استخدام التابع toString على الكائنات، وذلك لأنها غير قابلة للإحصاء، ولذلك فلا يمكن تمريرها على حلقة for..in. ولكن إن أردنا تغيير ذلك يدويًا (كما في المثال التالي) عندها يمكننا تمريرها إلى حلقة for..in. let user = { name: "John", toString() { return this.name; } }; // مبدئيًا، ستعرض الشيفرة الخاصيتين معًا: for (let key in user) alert(key); // name, toString لو لم نرد ذلك فيمكن ضبط enumerable:false حينها لن نستطع أن نمرر الكائن في حلقات for..in كما في السلوك المبدئي: let user = { name: "John", toString() { return this.name; } }; Object.defineProperty(user, "toString", { enumerable: false // هنا }); // الآن اختفى تابِع toString: for (let key in user) alert(key); // name كما أنّ التابِع Object.keys يستثني الخاصيات غير القابلة للإحصاء: alert(Object.keys(user)); // name منع قابلية إعادة الضبط أحيانًا ما نرى راية ”قابلية إعادة الضبط“ ممنوعة (أي configurable:false) في بعض الكائنات والخاصيات المضمّنة في اللغة. لا يمكن حذف هذه الخاصية لو كانت ممنوعة (أي configurable:false). فمثلًا المتغيّر المضمّن في اللغة Math.PI يمنع قابلية التعديل والإحصاء وإعادة الضبط عليه: let descriptor = Object.getOwnPropertyDescriptor(Math, 'PI'); alert( JSON.stringify(descriptor, null, 2 ) ); /* { "value": 3.141592653589793, "writable": false, "enumerable": false, "configurable": false } */ هكذا لا يقدر المبرمج على تغيير قيمة Math.PI ولا الكتابة عليها. Math.PI = 3; // خطأ // delete Math.PI لن تعمل أيضًا إن تفعيل خاصيّة منع قابلية إعادة الضبط هو قرار لا عودة فيه، فلا يمكننا تغيير الراية (إتاحة قابلية إعادة الضبط) باستعمال التابِع defineProperty. وللدقّة فهذا المنع يضع تقييدات أخرى على defineProperty: منع تغيير راية قابلية إعادة الضبط configurable. منع تغيير راية قابلية الإحصاء enumerable. منع تغيير راية قابلية التعديل writable: false إلى القيمة true (ولكن العكس ممكن). منع تغيير ضابط وجالب واصف الوصول get/set (ولكن يمكن إسناد قيم إليه). هنا سنحدّد الخاصية user.name لتكون ثابتة للأبد : let user = { }; Object.defineProperty(user, "name", { value: "John", writable: false, configurable: false }); Object.defineProperty(user, "name", {writable: true}); // خطأ نلاحظ عدم إمكانية تغيير الخاصيّة user.name ولا حتى راياتها ولن نستطيع تطبيق هذه العمليات عليها: الكتابة عليها user.name = "Pete". حذفها delete user.name. تغيير قيمتها باستخدام التابع defineProperty هكذا: defineProperty(user, "name", { value: "Pete" }). ”إن منع قابلية إعادة الضبط“ ليس ”منعًا لقابلية التعديل“ إن فكرة منع قابلية إعادة الضبط هي في الحقيقة لمنع تغيير رايات هذه الخاصية أو حذفها، وليس تغيير قيمة الخاصية بحد ذاتها. ملاحظة: في المثال السابق جعلنا قابلية التعديل ممنوعة يدويًا. التابع Object.defineProperties هناك أيضًا التابِع Object.defineProperties إذ يُتيح تعريف أكثر من خاصية في وقت واحد. صياغته هي: Object.defineProperties(obj, { prop1: descriptor1, prop2: descriptor2 // ... }); مثال عليه: Object.defineProperties(user, { name: { value: "John", writable: false }, surname: { value: "Smith", writable: false }, // ... }); أي أنّنا نقدر على ضبط أكثر من خاصية معًا. التابع Object.getOwnPropertyDescriptors يمكننا استعمال التابِع Object.getOwnPropertyDescriptors(obj) لجلب كلّ واصفات الخاصيات معًا. ويمكن استعماله بدمجه مع Object.defineProperties لنسخ الكائنات ”ونحن على علمٍ براياتها“: let clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(obj)); فعادةً حين ننسخ كائنًا نستعمل الإسناد لنسخ الخاصيات، هكذا: for (let key in user) { clone[key] = user[key] } ولكن… هذا لا ينسخ معه الرايات. لذا يفضّل استعمال Object.defineProperties لو أردنا نُسخةً ”أفضل“ عن الكائن. الفرق الثاني هو أنّ حلقة for..in تتجاهل الخاصيات الرمزية (Symbolic Properties)، ولكنّ التابِع Object.getOwnPropertyDescriptors يُعيد كلّ واصِفات الخاصيات بما فيها الرمزية. إغلاق الكائنات على المستوى العام تعمل واصِفات الخاصيات على مستوى الخاصيات منفردةً. هناك أيضًا توابِع تقصر الوصول إلى الكائن كلّه: Object.preventExtensions(obj) يمنع إضافة خاصيات جديدة إلى الكائن. Object.seal(obj) يمنع إضافة الخاصيات وإزالتها، فهو يمنع قابلية إعادة الضبط configurable: false على كلّ الخاصيات الموجودة. Object.freeze(obj) يمنع إضافة الخاصيات أو إزالتها أو تغييرها، إذ يمنع قابلية التعديل writable: false وقابلية إعادة الضبط configurable: false على كلّ الخاصيات الموجودة. كما أنّ هناك توابِع أخرى تفحص تلك المزايا: Object.isExtensible(obj) يُعيد false لو كان ممنوعًا إضافة الخاصيات، وإلا true. Object.isSealed(obj) يُعيد true لو كان ممنوعًا إضافة الخاصيات أو إزالتها، وكانت كلّ خاصيات الكائن الموجودة ممنوعة من قابلية إعادة الضبط configurable: false. Object.isFrozen(obj) يُعيد true لو كان ممنوعًا إضافة الخاصيات أو إزالتها أو تغييرها، وكانت كلّ خاصيات الكائن الموجودة ممنوعة أيضًا من قابلية التعديل writable: false أو إعادة الضبط configurable: false. أمّا على أرض الواقع، فنادرًا ما نستعمل هذه التوابِع. ترجمة -وبتصرف- للفصل Property flags and descriptors من كتاب The JavaScript language