كتابة المهام غير المتزامنة في JavaScript الحديث

نشرت: 2022-03-10
ملخص سريع ↬ في هذه المقالة ، سنستكشف تطور JavaScript حول التنفيذ غير المتزامن في العصر الماضي وكيف غيّر الطريقة التي نكتب بها الكود ونقرأه. سنبدأ ببدايات تطوير الويب ، وننتقل إلى أمثلة الأنماط الحديثة غير المتزامنة.

تحتوي JavaScript على خاصيتين رئيسيتين كلغة برمجة ، وكلاهما مهم لفهم كيفية عمل الكود الخاص بنا. الأول هو طبيعته المتزامنة ، مما يعني أن الكود سيعمل سطرًا بعد سطر ، كما تقرأه تقريبًا ، وثانيًا أنه مترابط واحد ، يتم تنفيذ أمر واحد فقط في أي وقت.

مع تطور اللغة ، ظهرت قطع أثرية جديدة في المشهد للسماح بالتنفيذ غير المتزامن ؛ جرب المطورون طرقًا مختلفة أثناء حل الخوارزميات الأكثر تعقيدًا وتدفقات البيانات ، مما أدى إلى ظهور واجهات وأنماط جديدة من حولهم.

التنفيذ المتزامن ونمط المراقب

كما هو مذكور في المقدمة ، يقوم JavaScript بتشغيل الكود الذي تكتبه سطراً بسطر ، في معظم الأحيان. حتى في سنواتها الأولى ، كان للغة استثناءات لهذه القاعدة ، على الرغم من أنها كانت قليلة وربما تعرفها بالفعل: طلبات HTTP وأحداث DOM والفواصل الزمنية.

 const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })

إذا أضفنا مستمعًا لحدث ، على سبيل المثال نقرة عنصر ما وقام المستخدم بتشغيل هذا التفاعل ، فإن محرك JavaScript سيصطف مهمة لاستدعاء مستمع الحدث ولكنه سيستمر في تنفيذ ما هو موجود في مكدسها الحالي. بعد الانتهاء من المكالمات الموجودة هناك ، سيقوم الآن بتشغيل رد اتصال المستمع.

يشبه هذا السلوك ما يحدث لطلبات الشبكة وأجهزة ضبط الوقت ، والتي كانت أول الأدوات التي تمكّن مطوري الويب من الوصول إلى التنفيذ غير المتزامن.

على الرغم من أن هذه كانت استثناءات للتنفيذ المتزامن الشائع في JavaScript ، إلا أنه من الضروري أن نفهم أن اللغة لا تزال ذات سلسلة واحدة وعلى الرغم من أنها يمكن أن تصطف في قائمة taks وتشغيلها بشكل غير متزامن ثم العودة إلى السلسلة الرئيسية ، إلا أنها تستطيع تنفيذ جزء واحد فقط من التعليمات البرمجية في الوقت.

المزيد بعد القفز! أكمل القراءة أدناه ↓

على سبيل المثال ، دعنا نتحقق من طلب الشبكة.

 var request = new XMLHttpRequest(); request.open('GET', '//some.api.at/server', true); // observe for server response request.onreadystatechange = function() { if (request.readyState === 4 && request.status === 200) { console.log(request.responseText); } } request.send();

عندما يعود الخادم ، يتم وضع مهمة للطريقة المخصصة onreadystatechange في القراءة في قائمة الانتظار (يستمر تنفيذ الكود في السلسلة الرئيسية).

ملاحظة : إن شرح كيفية قيام محركات JavaScript بوضع مهام قائمة الانتظار ومعالجة سلاسل التنفيذ هو موضوع معقد يجب تغطيته وربما يستحق مقالة خاصة به. ومع ذلك ، أوصي بمشاهدة فيلم "What The Heck Is The Event Loop Anyway؟" بقلم فيليب روبرتس لمساعدتك على فهم أفضل.

في كل حالة مذكورة ، نحن نستجيب لحدث خارجي. تم الوصول إلى فترة زمنية معينة ، أو إجراء مستخدم أو استجابة الخادم. لم نتمكن من إنشاء مهمة غير متزامنة في حد ذاتها ، فقد لاحظنا دائمًا حدوث أحداث خارج نطاق وصولنا.

هذا هو السبب في أن الكود الذي تم تشكيله بهذه الطريقة يسمى نمط المراقب ، والذي يتم تمثيله بشكل أفضل بواسطة واجهة addEventListener في هذه الحالة. سرعان ما ازدهرت مكتبات أو أطر عمل بواعث الأحداث التي تعرض هذا النمط.

Node.js وبواعث الأحداث

وخير مثال على ذلك هو Node.js الذي تصف الصفحة نفسها بأنها "وقت تشغيل JavaScript غير متزامن يحركه الحدث" ، لذا فإن بواعث الأحداث ومعاودة الاتصال كانوا مواطنين من الدرجة الأولى. حتى أنه تم بالفعل تنفيذ مُنشئ EventEmitter .

 const EventEmitter = require('events'); const emitter = new EventEmitter(); // respond to events emitter.on('greeting', (message) => console.log(message)); // send events emitter.emit('greeting', 'Hi there!');

لم يكن هذا هو النهج الجاهز للتنفيذ غير المتزامن فحسب ، بل كان أيضًا نمطًا أساسيًا واتفاقية لنظامه البيئي. افتتح Node.js حقبة جديدة من كتابة JavaScript في بيئة مختلفة - حتى خارج الويب. نتيجة لذلك ، كانت المواقف الأخرى غير المتزامنة ممكنة ، مثل إنشاء أدلة جديدة أو كتابة ملفات.

 const { mkdir, writeFile } = require('fs'); const styles = 'body { background: #ffdead; }'; mkdir('./assets/', (error) => { if (!error) { writeFile('assets/main.css', styles, 'utf-8', (error) => { if (!error) console.log('stylesheet created'); }) } })

قد تلاحظ أن عمليات الاسترجاعات تتلقى error كوسيطة أولى ، إذا كانت بيانات الاستجابة متوقعة ، فستكون بمثابة وسيطة ثانية. كان هذا يسمى نمط رد الخطأ الأول ، والذي أصبح اصطلاحًا اعتمده المؤلفون والمساهمون للحزم والمكتبات الخاصة بهم.

الوعود وسلسلة رد الاتصال التي لا نهاية لها

نظرًا لأن تطوير الويب واجه مشاكل أكثر تعقيدًا لحلها ، ظهرت الحاجة إلى أدوات غير متزامنة أفضل. إذا نظرنا إلى مقتطف الشفرة الأخير ، يمكننا أن نرى تسلسلًا متكررًا لمعاودة الاتصال لا يتسع نطاقه مع زيادة المهام العددية.

على سبيل المثال ، دعنا نضيف خطوتين أخريين فقط ، قراءة الملف والمعالجة المسبقة للأنماط.

 const { mkdir, writeFile, readFile } = require('fs'); const less = require('less') readFile('./main.less', 'utf-8', (error, data) => { if (error) throw error less.render(data, (lessError, output) => { if (lessError) throw lessError mkdir('./assets/', (dirError) => { if (dirError) throw dirError writeFile('assets/main.css', output.css, 'utf-8', (writeError) => { if (writeError) throw writeError console.log('stylesheet created'); }) }) }) })

يمكننا أن نرى كيف يصبح البرنامج الذي نكتبه أكثر تعقيدًا ، يصبح من الصعب متابعة الكود للعين البشرية بسبب تسلسل رد الاتصال المتعدد ومعالجة الأخطاء المتكررة.

وعود وأغلفة وأنماط سلسلة

لم تحظ Promises باهتمام كبير عندما تم الإعلان عنها لأول مرة كإضافة جديدة للغة JavaScript ، فهي ليست مفهومًا جديدًا لأن اللغات الأخرى كان لها تطبيقات مماثلة قبل عقود. الحقيقة هي أنهم قاموا بتغيير الكثير من دلالات وبنية معظم المشاريع التي عملت عليها منذ ظهورها.

لم تقدم Promises حلاً مضمنًا للمطورين لكتابة تعليمات برمجية غير متزامنة فحسب ، بل فتحت أيضًا مرحلة جديدة في تطوير الويب تعمل كقاعدة بناء لميزات جديدة لاحقة لمواصفات الويب مثل fetch .

أصبح ترحيل طريقة من نهج رد الاتصال إلى أسلوب قائم على الوعد أكثر شيوعًا في المشاريع (مثل المكتبات والمتصفحات) ، وحتى Node.js بدأ الترحيل ببطء إليها.

دعنا ، على سبيل المثال ، التفاف طريقة readFile في Node:

 const { readFile } = require('fs'); const asyncReadFile = (path, options) => { return new Promise((resolve, reject) => { readFile(path, options, (error, data) => { if (error) reject(error); else resolve(data); }) }); }

نقوم هنا بإخفاء رد الاتصال من خلال التنفيذ داخل مُنشئ الوعد ، واستدعاء resolve عندما تكون نتيجة الطريقة ناجحة ، reject عند تحديد كائن الخطأ.

عندما تقوم إحدى الطرق بإرجاع كائن Promise ، يمكننا اتباع حلها الناجح عن طريق تمرير دالة إلى then ، فإن الوسيطة الخاصة بها هي القيمة التي تم حل الوعد بها ، في هذه الحالة ، data .

إذا تم إلقاء خطأ أثناء الطريقة ، فسيتم استدعاء وظيفة catch ، إذا كانت موجودة.

ملاحظة : إذا كنت بحاجة إلى فهم كيفية عمل Promises بمزيد من التفصيل ، فإنني أوصي بمقالة Jake Archibald بعنوان "JavaScript Promises: An Introduction" والتي كتبها على مدونة تطوير الويب في Google.

الآن يمكننا استخدام هذه الأساليب الجديدة وتجنب سلاسل رد الاتصال.

 asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))

إن امتلاك طريقة أصلية لإنشاء مهام غير متزامنة وواجهة واضحة لمتابعة نتائجها المحتملة مكّن الصناعة من الخروج من نمط المراقب. يبدو أن تلك القائمة على الوعد تحل الشفرة غير المقروءة والمعرضة للخطأ.

نظرًا لأن تمييز بناء الجملة الأفضل أو رسائل الخطأ الأكثر وضوحًا يساعدان أثناء الترميز ، فإن الكود الذي يسهل تفسيره يصبح أكثر قابلية للتنبؤ به بالنسبة للمطور الذي يقرأه ، مع صورة أفضل لمسار التنفيذ يكون من الأسهل اكتشاف الوقوع في مأزق محتمل.

كان تبني Promises عالميًا جدًا في المجتمع لدرجة أن Node.js أطلقت بسرعة إصدارات مضمنة من طرق الإدخال / الإخراج الخاصة بها لإرجاع كائنات الوعد مثل استيراد عمليات الملفات من fs.promises .

حتى أنها قدمت promisify لاستخدام التفاف أي وظيفة اتبعت نمط رد الاتصال بالخطأ أولاً وتحويلها إلى وظيفة قائمة على الوعد.

لكن هل تساعد الوعود في كل الأحوال؟

دعونا نعيد تخيل أسلوب المعالجة المسبقة الخاص بنا والمكتوب مع Promises.

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => writeFile('assets/main.css', result.css, 'utf-8')) ) .catch(error => console.error(error))

هناك انخفاض واضح في التكرار في الكود ، خاصةً فيما يتعلق بمعالجة الأخطاء لأننا نعتمد الآن على catch ، لكن الوعود فشلت بطريقة ما في تقديم مسافة بادئة واضحة للشفرة تتعلق مباشرة بتسلسل الإجراءات.

يتم تحقيق ذلك بالفعل في أول تعليمة then بعد readFile . ما يحدث بعد هذه السطور هو الحاجة إلى إنشاء نطاق جديد حيث يمكننا أولاً إنشاء الدليل ، لكتابة النتيجة لاحقًا في ملف. يؤدي هذا إلى كسر في إيقاع المسافة البادئة ، مما يجعل من السهل تحديد تسلسل التعليمات للوهلة الأولى.

تتمثل إحدى طرق حل هذه المشكلة في إعداد طريقة مخصصة مسبقًا تتعامل مع هذا وتسمح بالتسلسل الصحيح للطريقة ، لكننا سنقدم مزيدًا من التعقيد في الكود الذي يبدو أنه يحتوي بالفعل على ما يحتاجه لتحقيق المهمة نحن نريد.

ملاحظة : ضع في الاعتبار أن هذا مثال على البرنامج ، ونحن نتحكم في بعض الأساليب وكلها تتبع اتفاقية صناعية ، ولكن هذا ليس هو الحال دائمًا. مع تسلسل أكثر تعقيدًا أو إدخال مكتبة ذات شكل مختلف ، يمكن كسر نمط الكود الخاص بنا بسهولة.

لحسن الحظ ، تعلم مجتمع JavaScript مرة أخرى من بناء جمل لغة أخرى وأضاف تدوينًا يساعد كثيرًا في هذه الحالات حيث لا يكون تسلسل المهام غير المتزامنة ممتعًا أو مباشرًا للقراءة مثل الكود المتزامن.

عدم التزامن والانتظار

يتم تعريف Promise على أنه قيمة لم يتم حلها في وقت التنفيذ ، وإنشاء مثيل من Promise هو استدعاء صريح لهذه الأداة.

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => { writeFile('assets/main.css', result.css, 'utf-8') })) .catch(error => console.error(error))

داخل طريقة غير متزامنة ، يمكننا استخدام الكلمة المحجوزة في await لتحديد حل Promise قبل مواصلة تنفيذه.

دعنا نعيد الزيارة أو مقتطف الشفرة باستخدام بناء الجملة هذا.

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') async function processLess() { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } processLess()

ملاحظة : لاحظ أننا بحاجة إلى نقل كل التعليمات البرمجية الخاصة بنا إلى طريقة لأننا لا نستطيع استخدام await خارج نطاق الدالة غير المتزامنة اليوم.

في كل مرة تعثر طريقة غير متزامنة على عبارة await ، ستتوقف عن التنفيذ حتى يتم حل قيمة الإجراء أو الوعد.

هناك نتيجة واضحة لاستخدام تدوين غير متزامن / انتظار ، على الرغم من تنفيذه غير المتزامن ، يبدو الرمز كما لو كان متزامنًا ، وهو شيء اعتدنا على المطورين رؤيته والتفكير حوله.

ماذا عن معالجة الأخطاء؟ لذلك ، نستخدم العبارات التي كانت موجودة لفترة طويلة في اللغة ، try catch .

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } catch(e) { console.error(e) } } processLess()

نحن مطمئنون إلى أن أي خطأ يتم إلقاؤه في العملية سيتم التعامل معه بواسطة الكود الموجود داخل بيان catch . لدينا مكان مركزي يهتم بمعالجة الأخطاء ، ولكن لدينا الآن رمز يسهل قراءته ومتابعته.

لا يلزم تخزين الإجراءات اللاحقة التي تُرجع القيمة في متغيرات مثل mkdir التي لا تكسر إيقاع الكود ؛ ليست هناك حاجة أيضًا لإنشاء نطاق جديد للوصول إلى قيمة result في خطوة لاحقة.

من الآمن أن نقول إن Promises كانت أداة أساسية تم تقديمها في اللغة ، وهي ضرورية لتمكين تدوين غير متزامن / انتظار في JavaScript ، والذي يمكنك استخدامه على كل من المتصفحات الحديثة وأحدث إصدارات Node.js.

ملاحظة : في الآونة الأخيرة في JSConf ، أعرب رايان داهل ، المنشئ والمساهم الأول في Node ، عن أسفه لعدم الالتزام بـ Promises على تطويره المبكر في الغالب لأن الهدف من Node هو إنشاء خوادم مدفوعة بالأحداث وإدارة الملفات التي يخدمها نمط Observer بشكل أفضل.

خاتمة

جاء إدخال Promises في عالم تطوير الويب لتغيير الطريقة التي نصطف بها الإجراءات في التعليمات البرمجية الخاصة بنا وغيرت طريقة تفكيرنا في تنفيذ الكود الخاص بنا وكيف نؤلف المكتبات والحزم.

لكن الابتعاد عن سلاسل رد الاتصال يصعب حله ، وأعتقد أن الاضطرار إلى تمرير طريقة ما في then لم يساعدنا على الابتعاد عن سلسلة الأفكار بعد سنوات من التعود على نمط المراقب والأساليب التي اعتمدها كبار البائعين في المجتمع مثل Node.js.

كما يقول نولان لوسون في مقالته الممتازة حول الاستخدامات الخاطئة في تسلسل الوعد ، فإن عادات رد الاتصال القديمة لا تموت بسهولة ! يشرح لاحقًا كيفية الهروب من بعض هذه المزالق.

أعتقد أن الوعود كانت مطلوبة كخطوة وسط للسماح بطريقة طبيعية لإنشاء مهام غير متزامنة ولكن لم تساعدنا كثيرًا في المضي قدمًا في أنماط تعليمات برمجية أفضل ، وفي بعض الأحيان تحتاج في الواقع إلى بناء جملة لغة أكثر قابلية للتكيف وتحسين.

بينما نحاول حل الألغاز الأكثر تعقيدًا باستخدام JavaScript ، نرى الحاجة إلى لغة أكثر نضجًا ونجرب البنى والأنماط التي لم نعتد على رؤيتها على الويب من قبل.

"

ما زلنا لا نعرف كيف ستبدو مواصفات ECMAScript في السنوات لأننا نوسع دائمًا حوكمة JavaScript خارج الويب ونحاول حل الألغاز الأكثر تعقيدًا.

من الصعب الآن تحديد ما سنحتاجه بالضبط من اللغة حتى تتحول بعض هذه الألغاز إلى برامج أبسط ، لكنني سعيد بكيفية تحريك الويب وجافا سكريبت نفسها للأشياء ، في محاولة للتكيف مع التحديات والبيئات الجديدة. أشعر الآن أن JavaScript هو مكان ودود غير متزامن أكثر مما كان عليه عندما بدأت في كتابة التعليمات البرمجية في متصفح منذ أكثر من عقد.

قراءة متعمقة

  • "وعود جافا سكريبت: مقدمة" ، جيك أرشيبالد
  • "Promise Anti-Patterns" ، مكتبة وثائق بلوبيرد
  • "لدينا مشكلة مع الوعود" ، نولان لوسون