في هذه الصفحة
متطلبات هذا المقال
يُفضل أن يكون لديك خلفية بسيطة عن أساسيات JavaScript مثل: المتغيرات، loops، كيفية عمل الدوال، كيف تعمل الـ”try & catch” وكيف نقوم باستخراج packages حتى تتسنى لك الفرصة لفهم المقال جيدًا 🙂
المقدمة
كان دائمًا المتعارف عليه عندما تعلمنا كتابة أول سطر كود في أي لغة برمجة، أن تنفيذ هذه الأسطر يبدأ دائمًا بالترتيب من الأعلى إلى الأسفل، حيث يقوم المعالج الخاص سواء بحاسوبك أو هاتفك أو ما شابه بتنفيذ الخطوات المرادة لكل سطر على حدة، وعند الانتهاء، نقوم بالانتقال إلى السطر التالي حتى ننتهي بتنفيذ جميع التعليمات كلها. يمكنك ملاحظة ذلك في الكود الآتي:
console.log('Hello, world')
const a = 3, b = 2
const c = a + b
console.log('Final answer:', c)
كما ترون في هذا الكود، نبدأ أولًا بطباعة (Hello, world) في الـconsole ثم نقوم في السطر التالي بتعريف المتغيرين a و b بالقيم 2 و 3، ثم نخزن قيمتهم في المتغير c ومن ثم نطبع في الـconsole هذا الناتج (Final answer: 5)وهذا ما يعرف في البرمجة بإسم (Synchronous programming) أي البرمجة المتتالية، وفيما معناه أن كل سطر ينفذ بشكل متتالي دون أن يُنفذ أي سطر آخر في غير ترتيبه.هذا شيء رائع، وبسيط للغاية. لكن للأسف كل شيء يأتي بثمنه 🙁فلنفترض أنك تريد أن تكتب برنامج يقوم بقراءة اسم من ملف Text وتعرضه للمستخدم، فلنَر كيف يمكننا فعل ذلك:
import { readFileSync } from 'fs';
try {
console.log('Start reading file');
const name = readFileSync('random.txt', 'utf-8');
console.log(name);
console.log('Waiting here');
} catch (err) {
console.error(err);
}
في الكود السابق:
- لاحظ أننا سنقوم بكتابة خطواتنا بداخل try إذا حدثت مشكلة ما عند محاولة قراءة الملف -مثلا كمشكلة عدم وجود الصلاحيات اللازمة لقراءة الملف من نظام التشغيل، أو أن الملف بهذا الإسم غير موجود بالأساس- سنقوم بطباعة هذا الخطأ بداخل الـcatch.
- سنقوم بطباعة (Start reading file) في الـconsole في البداية ثم سيقوم البرنامج بقراءة الملف كله.
عند الانتهاء، سنقوم بطباعة المحتوى بداخل هذا الملف ثم سنقوم بطباعة (Waiting here) في الـconsole.و جرت الأمور كما خُطط لها يا صديقي، ولكن ماذا إذا كان حجم هذا الملف كبير للغاية؟ فلنقل مثلًا 500 ميجا بايت؟ هذا قد يستغرق وقتًا طويلًا للغاية (هذا يعتمد على مدى قوة المعالج الذي سيقوم بتنفيذ هذا البرنامج على اعتبار أن حجم الذاكرة العشوائية يكفي لتحميل جميع محتوى هذا الملف)، ولكن هذه ليست المشكلة فحسب.
لاحظ كلمة “كله” أوًلا 🙁
لذلك، لن تظهر لنا تلك الرسالة (Waiting here) إلا عند الانتهاء من قراءة الملف.
ليست فقط قراءة الملفات الضخمة قد تعطلنا عن تنفيذ العمليات اللاحقة ولكن يوجد أيضًا:
- تحميل محتويات من الخوادم على الإنترنت (تحميل المنشورات في مواقع الأخبار على سبيل المثال)
- العمليات الحسابية التي قد تستغرق وقت كبير للغاية (ناتج حاصل ضرب مليار رقم)
هذه واحدة من العيوب القاتلة لمبدأ الـSynchronous programming (أي البرمجة المتزامنة) وهو أن تتم البرمجة بناءً على ترتيب متفق عليه، ولذلك فقد جاء مبدأ الـAsynchronous programming قالبًا الطاولة رأسًا على عقب 💪
كما هو واضح من اسمها، أنها ستحاول تنفيذ أي خطوات بشكل متوازي بحيث لا يوجد أي كود متوقف على الآخر دون أي داعٍ لذلك. ففي المثال السابق، طباعة (Waiting here) لم يكن لها الضرورة أن تتعطل عن العمل بسبب قراءة ذلك الملف.
إذًا، كيف يمكننا إصلاح ذلك؟
import { readFile } from 'fs';
console.log('Start reading file');
readFile('random.txt', 'utf8', (err, name) => {
if (err) {
console.error(err);
return;
}
console.log(name);
});
console.log('Waiting here');
في هذا الكود، يمكنك رؤية أننا استخدمنا دالة مختلفة بإسم (readFile) وهي تعتبر دالة تعمل بشكل متوازي (Asynchronous function) وتأخذ معطيات كإسم الملف وطريقة تشفيره (decoding) وأيضا دالة أخرى ستنفذ عند انتهاء قراءة الملف و هي تعرف في عالم الـAsynchronous باسم:
Callback functions
وهى تعتبر دالة غاية الأهمية عند التعامل مع كود متوازي (Asynchronous)، فهي تٌستدعى عند الانتهاء من تنفيذ العملية الحسابية التي تقوم بها الدالة الغير المتوازية المصحوبة بها.
ولكن كيف تعمل بالضبط؟
دعني أعرفك أن JavaScript تعتبر لغة ذو Thread واحد فقط، أي يمكنها تنفيذ شيء واحد فقط في آن واحد، مما يعني أن لديها Call Stack واحد فقط (هي تعتبر هيكل بحيث نقوم بوضع فيه الدوال التي ستنفذ حسب ترتيبها في الكود بشكل رأسي. يمكنك تشبيهها بالصحون المرتبة فوق بعضها، حيث أنه لا يمكنك أن تأخذ صحن من وسط الصحون، و أن الطريقة الوحيدة لأخذ هذا الصحن هو إزالة جميع الصحون فوقه)، بمعنى أنه في ذلك الكود أننا نقوم بوضح أول console.log في البداية وبعدها من المفترض وضع تلك الدالة الasynchronous ولكن ذلك لن يحدث لأنها توضع في callstack آخر خاص بالـ”libuv APIs” (إذا كان ذلك الكود تقوم بتشغيله باستخدام Node.js) أو WebAPIs (إذا كان ذلك الكود تستخدمه في المتصفحات) ومن ثم تلك الدوال المتواجدة في الـstack الخاص بها لديها الـCallback function الخاص بها، وعند انتهاء أيًا منهم. نقوم بعد ذلك في هيكل آخر يدعى Task queue (ويعد الـ queue هنا هو هيكل آخر نخزن فيه البيانات بشكل أفقي مثل طابور الدفع في المتاجر بالضبط، حيث أن أول شخص يقف بالطابور هو أول شخص يحاسب صاحب المتجر ويليه من يقف وراءه) وتبدأ تلك الـ callback functions داخل الـqueue بالإنطلاق وتنفذ بداخل الـcallstack الأساسي وينفذ فيها ما يُراد تنفيذه.
ففي الكود السابق:
- ستطبع جملة (Start reading file) في الـconsole أولًا.
- ثم سيأتي الدور على الدالة readFile لتُنفذ، لكنها Asynchronous function! إذن فلتفعل ما ستفعله، و عندما تنتهي هذه الدالة مما تفعله، سوف نأتي إليها لاحقًا ونرى ما جلبته لنا عن طريق هذه الـCallback function
- ستُطبع (Waiting here) في الـConsole
- حسنًا، لقد انتهينا من تنفيذ جميع الـSynchronous code هنا أمامنا. الآن، لنرى ما قد انهته جميع الـAsynchronous functions، وذلك عن طريق الـ Callback functions التي ستعود إلينا من هذه الـ Async functions
- عندئذ، سنرى أنه في حالة قراءة محتويات الملف بنجاح، سنرى أنه سيطبع لنا محتوى الملف في الـconsole.
الـcallback functions ليست الطريقة الوحيدة التي يمكننا أن نكتب بها Asynchronous logic ، فيوجد أيضًا الـPromises
الآن محمد قد استلف 5 جنيهات من أحمد، ومحمد أعطى أحمد وعدًا أن يعيد له الـ5 جنيهات غدًا، لذلك قد يكون لهذا الوعد 3 حالات مختلفة:
- محمد مازال عند وعده ولكن لم يأن المعاد لتنفيذ وعده. (Pending)
- محمد وفى بوعده و أعاد لأحمد الـ5 جنيهات اليوم التالي. (Fulfilled)
- محمد لم يفي بوعده ولم يعطي أحمد الـ5 جنيهات اليوم التالي. (Rejected)
هذا هو ما يحدث بالضبط عند تنفيذ أي async function: عند بدء تنفيذ الـfunction، ستكون حالة ذلك الـpromise عند بدئها هي pending، وبعد ذلك تنتقل لأيًا من الحالات Fulfilled أو Rejected في حالة تنفيذ الـFunction بنجاح أو فشلها لسبب ما.
فلنر كيف يمكننا تحويل الكود السابق بأسلوب الـPromises
import { readFile } from 'fs';
console.log('Start reading');
new Promise((resolve, reject) => {
readFile('random.txt', 'utf8', (err, name) => {
if (err) {
reject(err);
return;
}
resolve(name);
});
})
.then((name) => {
console.log(name);
})
.catch((err) => {
console.error(err);
});
console.log('Waiting here');
في هذا الكود يمكننا أن نرى أننا أحطنا الـFunction التي تقوم بعملية قراءة الملف في Instance من الـPromise class والتي تُأخد كـConstructor لتلك الـCallback function وبها Function باسمي resolve و reject. تعني resolve في هذا الموضع أنه تم تنفيذ هذا الوعد بنجاح، و reject تعني أنه لم يتم ذلك.
فبداخل الـCallback function للـreadFile، إذا فشلت عملية قراءة الملف لسبب ما، إذا ننادي على الـreject function ونمرر لها ذلك الخطأ كـParameter، وإذا نجحت فنعطي محتوي الملف للـresolve function كـParameter.
يمكننا الملاحظة أنه يحدث Chaining أسفل الـPromise instance بـthen و catch، الـthen هنا تنفذ إذا كان تم النداء على الـresolve function بداخل الـPromise، و الـcatch تنفذ عند النداء على الـreject function بداخل الـPromise.
fetch('https://api.isevenapi.xyz/api/iseven/6/')
.then((response) => {
return response.json();
})
.then(({ iseven }) => {
console.log(iseven);
})
.catch((err) => {
console.error(err);
});
وبالمناسبة، يمكننا أن نقوم بربط عدة promises معًا بما أن response.json هنا في هذه الحالة ترجع لنا Promise أيضًا، وإذا فشلت تلك العملية، فسوف نتجه إلى الـcatch block.
بالإضافة إلى ذلك، ظهرت مؤخرًا في ES7 أيضًا الـkeywords الجديدة وهي:async/await
تتيح لك تلك الخاصية الجديدة كتابة Asynchronous code كما لو أنه Synchronous بدلًا من كتابة Promises و بعدها تعريف الـThen و Catch في مكان آخر، فأصبح كل شئ ينفذ بالترتيب. فالـAsynchronous functions تنفذ فقط ما بداخل الـasync functions (في هذا الكود، لقد اعطينا الـmain function تلك الـkeyword الجديدة async وعندئذ، عند تنفيذ أي async function بداخلها، فإننا نُصحبها بالـkeyword الجديدة التي تسمى await.
فلنر كيف يمكننا إعادة كتابة الكود السابق باستخدام الـasync/await
async function readThisFile(filePath) {
try {
const data = await fs.promises.readFile(filePath, 'utf8');
console.log('finish');
return data;
} catch (error) {
console.error('Error reading file: ', error);
throw error;
}
}
async function main() {
try {
const filePath = 'random.txt';
console.log('start reading file');
const fileContent = await readThisFile(filePath);
console.log('File content: ', fileContent);
} catch (error) {
console.error('An error occurred: ', error);
}
}
main();
عند نداء تلك الـreadThisFile function، فإننا لن نقوم بتنفيذ أي كود بعد ذلك إلا عند الانتهاء من تنفيذ تلك الـfunction. وذلك لأن الـawait keyword تجبر الكود على أنه يجب أن يقوم بالانتهاء من الـpromise العائد من تلك الـfunction أيًا كانت حالتها سواء Resolved أو Rejected.
سينفذ ذلك الكود على النحو الآتي:
- سننادي على الـmain function
- سنعرف المتغير المدعو filePath
- سنطبع (Start reading file) في الـConsole
- سنعرف المتغير fileContent والذي بدوره سينتظر الـPromise العائد من readThisFile
- سننادي على الـreadThisFile function
- بداخل تلك الـfunction، سنعرّف المتغير الجديد data والذي بدوره سينتظر نتيجة الـPromise العائد من readFile function المدعومة من الـfs core module في الـNode.js
- إذا قُرأ الملف بنجاح، سنرجع محتوى هذا الملف
- نعود مجددًا إلى الـmain function، ستصبح قيمة fileContent الجديدة هي المحتوى بداخل هذا الملف العائد من الـfunction السابقة
- ثم سنطبع (File Content: x) بداخل الـConsole