آشنایی با Promise ها
همانطور که در بخش قبل دیدیم، یکی از مسائلی که برنامهنویسان جاوا اسکریپت از گذشته با آن درگیر بودهاند، مسئلهی "جهنم Callback" است. در این بخش قصد داریم به معرفی روشی برای حل این مشکل بپردازیم که در ECMAScript 6 به زبان جاوا اسکریپت اضافه شده است. در ES6 با استفاده از مفهومی به نام پرامیس (Promise) میتوان تا حد زیادی از تشکیل "جهنم Callback" جلوگیری کرد. اما قبل از شروع این مبحث لازم است به چند نکته اشاره شود.
- کلمهی Promise در زبان انگلیسی به معنی "وعده" یا "قول دادن" است. اما در این کتاب از معادل فارسی برای کلمهی Promise استفاده نخواهیم کرد. بلکه از کلمهی "پرامیس" برای این مفهوم استفاده خواهیم کرد.
- "پرامیس" یک مفهوم کلی است که در بسیاری از زبانهای برنامهنویسی امروزی وجود دارد. برای پیادهسازی این مفهوم در جاوا اسکریپت از شیئی به نام Promise استفاده میشود. در این کتاب جهت پیشگیری از بروز ابهام، از کلمهی "Promise" برای اشاره به این شئ خاص استفاده میکنیم. و کلمهی "پرامیس" را برای اشاره به مفهوم کلی آن به کار خواهیم برد.
- پرامیسها در ابتدا کمی گنگ و غیرمفید به نظر میرسند. پس اگر مباحث این بخش را در ابتدا خوب درک نکردید نگران نباشید. زیرا تقریباً تمام برنامهنویسان جاوا اسکریپت، در اولین برخورد با مفهوم پرامیسها دچار سردرگمی میشوند. به همین دلیل لازم است مطالب این بخش را با دقت و تمرکز بالایی مطالعه کنید. حتی در صورت نیاز باید این مبحث را چند بار مرور کنید.
پرامیس چیست؟
با استفاده از پرامیسها میتوان بخشی از کدها را به صورت آسنکرون اجرا کرد. به طوری که پس از پایان اجرای کدهای آسنکرون و مشخص شدن نتیجه، یک تابع Callback مناسب، فراخوانی و اجرا شود. قطعه کد زیر نحوهی ایجاد یک پرامیس را در سادهترین حالت خود نشان میدهد.
const p = new Promise(function(resolve , reject){
// کدهایی که معمولاً به صورت آسنکرون اجرا میشوند
if(success){
resolve(value);
}else{
reject(error);
}
});
همانطور که مشاهده میکنید برای ایجاد یک پرامیس، از تابع سازندهی شئ Promise استفاده شده است. این تابع سازنده، یک تابع را به عنوان آرگومان ورودی دریافت میکند که از این به بعد این تابع را executor مینامیم. در این مثال از یک تابع بینام به عنوان executor استفاده شده است که دو پارامتر ورودی به نامهای resolve و reject دارد. نام این پارامترها اختیاری است و هر نام دلخواهی را میتوان برای این پارامترها در نظر گرفت. اما تقریباً همیشه از همین دو نام (resolve و reject) استفاده میکنیم.
پس از ایجاد یک پرامیس، بلافاصله تابع executor اجرا میشود. این تابع معمولاً حاوی کدهایی است که به صورت آسنکرون اجرا میشوند. و پس از پایان اجرای کدهای آسنکرون، یکی از دو حالت زیر رخ خواهد داد.
- عملیات آسنکرون با موفقیت به پایان میرسد. که به این حالت اصطلاحاً fulfilled یا resolved گفته میشود.
- عملیات آسنکرون با شکست مواجه میشود. که به این حالت اصطلاحاً rejected گفته میشود.
پس از اینکه یکی از حالتهای فوق رخ میدهد، عمر پرامیس یا چرخهی حیات آن به پایان میرسد. در نتیجه یک پرامیس در چرخهی حیات خود میتواند در یکی از ۳ حالت زیر قرار داشته باشد.
- pending (انتظار) : حالتی است که هنوز اجرای کدهای آسنکرون (تابع executor) به پایان نرسیده و نتیجهی آن هنوز مشخص نیست.
- fulfilled (تکمیل) : حالتی است که اجرای کدهای آسنکرون با موفقیت به پایان رسیده است.
- rejected (شکست) : حالتی است که اجرای کدهای آسنکرون با شکست به پایان رسیده است.
برای درک بهتر هر یک از حالتهای فوق، مثال زیر را در نظر بگیرید. توجه کنید که در این مثال از شئ Promise استفاده نشده است. اما مفهوم پرامیس را در این مثال میتوان مشاهده کرد. در این مثال یک درخواست Ajax به صورت آسنکرون ارسال میشود و در هر دو حالت موفقیت و یا شکست، یک پیام مناسب به کاربر نمایش داده میشود.
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1');
xhr.addEventListener('load', function () {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
let id = JSON.parse(xhr.responseText).id;
console.log('The post id is: ' + id);
}else{
console.log('An error occurred');
}
});
xhr.send();
در این مثال پس از اجرای متد send، یک درخواست Ajax ارسال میشود و تا زمانی که پاسخ این درخواست به طور کامل دریافت نشده باشد، در حالت انتظار (pending) قرار داریم. اما پس از دریافت پاسخ دو حالت ممکن است رخ دهد. اگر پاسخ با موفقیت دریافت شده باشد، وضعیت به حالت تکمیل (fulfilled) تغییر میکند. و در غیر این صورت وضعیت به حالت شکست (rejected) تغییر میکند.
البته همانطور که اشاره شد در این مثال از شئ Promise استفاده نشده است و صرفاً برای بیان چرخهی حیات یک پرامیس از این مثال استفاده شده است. حال میخواهیم همین مثال را با استفاده از شئ Promise پیادهسازی کنیم.
قطعه کد زیر نحوهی پیادهسازی مثال فوق را با استفاده از شئ Promise نشان میدهد. به محل استفاده از دو تابع resolve و reject دقت کنید. زیرا عملکرد پرامیسها کاملاً وابسته به نحوهی استفاده از این توابع است. در ادامه نقش این دو تابع در عملکرد پرامیسها را تشریح میکنیم.
const p = new Promise((resolve , reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1');
xhr.addEventListener('load', function () {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
let id = JSON.parse(xhr.responseText).id;
resolve(id)
}else{
reject();
}
});
xhr.send();
});
مشاهده میکنید که کدهای این مثال شباهت زیادی به مثال قبلی دارد. در واقع دو تغییر عمده در این مثال نسبت به مثال قبل رخ داده است. اولاً تمام کدهای مثال قبل داخل تابع executor قرار گرفتهاند. ثانیاً به جای نمایش پیامهای مربوط به موفقیت یا شکست، از توابع resolve و reject استفاده شده است. اگر این مثال را اجرا کنید، هیچ نتیجهای در خروجی نمایش داده نخواهد شد. زیرا توابع resolve و reject به تنهایی هیچ عمل خاصی را انجام نمیدهند.
در واقع فراخوانی توابع resolve و reject فقط نشاندهندهی موفقیت یا شکست در اجرای کدهای آسنکرون است. یعنی باید در جای دیگری تعیین کنیم که در صورت موفقیت و یا شکست، چه عملی باید انجام شود. برای این منظور باید از متد then از شئ Promise استفاده کنیم. نحوهی استفاده از این متد به شکل زیر است.
p.then(onFulfilled , onRejected);
یعنی پس از ایجاد یک پرامیس (مانند p در مثال فوق)، باید با استفاده از متد then مشخص کنیم که در صورت موفقیت و یا شکست چه عملی انجام شود. متد then دو پارامتر ورودی دارد که فقط پارامتر اول اجباری است. هر دو پارامتر onFulfilled و onRejected باید یک تابع باشند. در صورتی که پرامیس کار خود را با موفقیت به پایان برساند، تابع onFulfilled فراخوانی خواهد شد. و در صورتی که پرامیس کار خود را با شکست به پایان برساند، تابع onRejected فراخوانی خواهد شد.
یادآوری میشود که فراخوانی توابع resolve و reject در تابع executor، به ترتیب به معنی موفقیت و یا شکست در اجرای پرامیس است. به همین دلیل تمام آرگومانهایی که به توابع resolve و reject ارسال میشوند، به ترتیب به توابع onFulfilled و onRejected ارسال خواهند شد. یعنی در مثال فوق در صورتی که پرامیس با موفقیت اجرا شود، مقدار متغیر id به تابع onFulfilled ارسال خواهد شد. اما در صورتی که اجرای پرامیس با شکست مواجه شود، هیچ مقداری به عنوان آرگومان به تابع onRejected ارسال نخواهد شد.
حال این مثال را به شکل زیر تکمیل میکنیم. عملکرد این مثال دقیقاً مشابه مثال قبل است که بدون استفاده از شئ Promise پیادهسازی شده بود.
const p = new Promise((resolve , reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1');
xhr.addEventListener('load', function () {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
let id = JSON.parse(xhr.responseText).id;
resolve(id)
}else{
reject();
}
});
xhr.send();
});
p.then(function(value){
console.log('The post id is: ' + value);
},
function(){
console.log('An error occurred');
}
);
این مثال را میتوانید اینجا اجرا کنید. این مثال در حالت عادی با موفقیت اجرا خواهد شد. بنابراین فقط تابع resolve فراخوانی خواهد. در نتیجه پیام موجود در خط 16 در کنسول نمایش داده خواهد شد. اما با قرار دادن یک آدرس نامعتبر در متد open میتوانید حالت شکست را نیز تولید کنید. (مثلاً با قرار دادن 1000 به جای عدد 1 در انتهای آدرس موجود در آرگومان دوم متد open)
نکته : در ابتدای این بخش اشاره شد که هدف از به کار بردن پرامیسها، سادهسازی برنامهنویسی آسنکرون است. اما همانطور که مشاهده میکنید در این مثال استفاده از پرامیسها حجم کدها را افزایش داده و خوانایی کدها را نیز کاهش داده است. البته در این مثال خاص همینطور است. اما به مرور با بررسی مثالهای پیچیدهتر خواهید دید که در کدهای پیچیده، استفاده از پرامیسها هم موجب کاهش حجم کدها، و هم موجب افزایش خوانایی برنامه میشود. به عنوان مثال در بخش بعدی خواهید دید که استفاده از Fetch API (که مبتنی بر پرامیسها است)، تا چه اندازه کار با Ajax را سادهتر میکند.
متد catch
برای افزایش خوانایی کدها میتوان تابع onRejected را به جای ارسال به متد then، به متد catch ارسال کرد. در این صورت میتوان متد catch را به صورت زنجیرهای بعد از متد then قرار داد. قطعه کد زیر نحوهی استفاده از متد catch را برای مثال قبل نشان میدهد.
p.then(function(value){
console.log('The post id is: ' + value);
})
.catch(function(){
console.log('An error occurred');
});
به دلیل خوانایی بیشتر این روش، معمولاً برنامهنویسان استفاده از این روش را ترجیح میدهند.
زنجیرهی پرامیسها
در صورتی که تابع onFulfilled یک شئ از نوع Promise را بازگرداند، میتوان متد then را به صورت زنجیرهای به کار برد. در این صورت هر بخش از کدهای آسنکرون، بعد از پایان بخش قبلی اجرا خواهد شد. به عنوان مثال فرض کنید در یک برنامه ۳ عمل مختلف باید به صورت آسنکرون و با ترتیب مشخص انجام شوند. یعنی هر عمل باید پس از پایان عمل قبلی شروع شود.
همچنین فرض کنید ۳ تابع به نامهای f1 و f2 و f3 داریم که با فراخوانی هر یک از این توابع، اجرای یکی از اعمال ذکر شده به صورت آسنکرون شروع میشود. و خروجی هر ۳ تابع نیز یک شئ از نوع Promise است. در این صورت میتوان از دستور زیر برای اجرای متوالی این اعمال آسنکرون استفاده کرد.
p.then(f1)
.then(f2)
.then(f3)
توجه کنید که فاصلهگذاریها و استفاده از خطوط جدید صرفاً جهت افزایش خوانایی به کار رفته است. یعنی دستور فوق را میتوان در یک خط و به شکل زیر نیز نوشت.
p.then(f1).then(f2).then(f3)
معنی این دستور این است که پس از تکمیل شدن (fulfilled) پرامیس p باید تابع f1 فراخوانی شود. و با توجه به اینکه این تابع یک شئ Promise را بازمیگرداند، پس از تکمیل شدن این پرامیس نیز باید تابع f2 فراخوانی شود. و همینطور پس از تکمیل شدن پرامیس بازگردانده شده از تابع f2، باید تابع f3 فراخوانی شود. همچنین در صورت نیاز میتوان در انتهای زنجیره از متد catch هم به شکل زیر استفاده کرد.
p.then(f1).then(f2).then(f3).catch(onRejected)
حال در صورتی که هر یک از پرامیسهای این زنجیره با شکست مواجه شوند، تابع onRejected فراخوانی خواهد شد.
یک مثال عملی از پرامیسها
حال برای آشنایی با کاربردهای عملی پرامیسها، از مثالی که در بخش قبلی مطرح کردیم استفاده میکنیم. در بخش قبل دیدیم که در صورت استفاده از توابع Callback به صورت تو در تو، خوانایی کدها به شدت کاهش یافته و اصطلاحاً جهنم Callback به وجود میآید. حال میخواهیم با استفاده از پرامیسها همان مثال را بدون اینکه با جهنم Callback مواجه شویم، پیادهسازی کنیم.
برای حل مسئلهی جهنم Callback باید از زنجیرهی پرامیسها (Promises Chaining) استفاده کنیم. برای این منظور ابتدا تابع زیر را تعریف میکنیم. دقت کنید که مقدار بازگشتی از تابع زیر یک شئ از نوع Promise است.
function getData(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.addEventListener('load', function () {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
let id = JSON.parse(xhr.responseText).id;
console.log(id);
resolve(id)
} else {
reject();
}
});
xhr.send();
});
}
این تابع یک آدرس URL را دریافت میکند. سپس یک پرامیس ایجاد میکند و در تابع executor مانند مثالهای قبلی یک درخواست Ajax را ارسال میکند. در انتها نیز پرامیس ایجاد شده از این تابع بازگردانده میشود. حال با استفاده از دستورات زیر میتوان ۴ درخواست Ajax را ارسال کرد. به طوری که هر درخواست پس از تکمیل درخواست قبلی ارسال میشود.
const p = getData('https://jsonplaceholder.typicode.com/todos/1');
p.then((id) => {
console.log(id);
return getData('https://jsonplaceholder.typicode.com/todos/2');
})
.then((id) => {
console.log(id);
return getData('https://jsonplaceholder.typicode.com/todos/3');
})
.then((id) => {
console.log(id);
return getData('https://jsonplaceholder.typicode.com/todos/4');
})
.then((id) => {
console.log(id);
})
.catch(() => {
console.log('An error occurred');
});
مشاهده میکنید که در خط اول با فراخوانی تابع getData و ارسال یک آدرس URL به این تابع، یک شئ از نوع Promise ایجاد شده و در متغیر p ذخیره میشود. حال اگر این پرامیس با موفقیت کار خود را به پایان برساند، تابع تعریف شده در خط 3 اجرا خواهد شد. این تابع ابتدا در خط 4 مقدار آرگومان ورودی خود را در کنسول نمایش میدهد. سپس با استفاده از تابع getData یک پرامیس جدید ایجاد کرده و بازمیگرداند. در نتیجه میتوان متد then را به صورت زنجیرهای در ادامهی این دستور به کار برد. همین کار برای دو آدرس URL دیگر نیز در ادامه انجام میشود. دقت کنید که در آخرین متد then از تابع getData استفاده نشده است. زیرا نیازی به ایجاد یک پرامیس جدید نیست. در انتها نیز از متد catch به صورت زنجیرهای استفاده شده است. در نتیجه در صورت بروز خطا در هر یک از پرامیسهای قبلی، تابع ارسال شده به متد catch اجرا خواهد شد.
این مثال را میتوانید اینجا اجرا کنید. با اجرای این مثال مشاهده خواهید کرد که ترتیب ارسال درخواستهای Ajax همیشه ثابت است. یعنی ارسال هر درخواست پس از دریافت پاسخ درخواست قبلی انجام خواهد شد.
مشاهده میکنید که خوانایی کدهای این مثال به مراتب بیشتر از مثالی است که در بخش قبلی انجام دادیم. زیرا در این مثال به دلیل استفاده از پرامیسها از تشکیل جهنم Callback جلوگیری شده است.
سایر متدهای شئ Promise
علاوه بر متدهای then و catch، شئ Promise دارای تعدادی متد دیگر نیز میباشد که در برخی موارد میتوانند بسیار مفید باشند. در ادامهی این بخش به معرفی ۳ مورد از این متدها میپردازیم.
متد all
متد all چند شئ Promise را به صورت یک آرایه دریافت میکند و یک شئ Promise دیگر را بازمیگرداند. قطعه کد زیر نحوهی استفاده از متد all را نشان میدهد.
const p = Promise.all([p1 , p2 , p3]);
توجه کنید که متد all یک متد استاتیک است. یعنی برای استفاده از این متد نیازی به ایجاد یک شئ از نوع Promise نیست و مانند متدهای شئ Math میتوان در هر نقطهای از برنامه از این متد استفاده کرد. همچنین دو متد allSettled و race نیز از نوع استاتیک هستند. که در ادامه به معرفی این دو متد نیز خواهیم پرداخت.
با اجرای دستور فوق یک پرامیس جدید ایجاد شده و در متغیر p ذخیره میشود. حال اگر با استفاده از متد then یک تابع onFulfilled برای این پرامیس تعریف شود، این تابع زمانی اجرا میشود که هر سه پرامیس p1 و p2 و p3 کار خود را با موفقیت به پایان رسانده باشند. به مثال زیر توجه کنید.
const p1 = new Promise((resolve , reject) => {
setTimeout(() => {
console.log('p1 resolved');
resolve();
} , 300);
});
const p2 = new Promise((resolve , reject) => {
setTimeout(() => {
console.log('p2 resolved');
resolve();
} , 1300);
});
const p3 = new Promise((resolve , reject) => {
setTimeout(() => {
console.log('p3 resolved');
resolve();
} , 800);
});
const p = Promise.all([p1 , p2 , p3]);
p.then(() => console.log('All promises resolved'));
در این مثال ابتدا ۳ پرامیس با نامهای p1 و p2 و p3 تعریف میشوند. برای شبیهسازی کدهای آسنکرون در تابع executor این پرامیسها از تابع setTimeout استفاده شده است تا تابع resolve پس از مدت مشخصی فراخوانی شود. کار هر یک از این پرامیسها به ترتیب بعد از 300، 1300 و 800 میلیثانیه به پایان میرسد. در نتیجه برای اینکه تمام پرامیسها کار خود را با موفقیت به پایان برسانند باید 1300 میلیثانیه سپری شود.
این مثال را میتوانید اینجا اجرا کنید. با اجرای این مثال خروجی زیر را در کنسول مشاهده خواهید کرد. یعنی پرامیس p زمانی کار خود را به پایان میرساند که هر سه پرامیس p1 و p2 و p3 کار خود را با موفقیت به پایان رسانده باشند.
p1 resolved
p3 resolved
p2 resolved
All promises resolved
متد allSettled
این متد نیز دقیقاً مانند متد all تعدادی پرامیس را به صورت یک آرایه دریافت میکند و پس از پایان کار تمام پرامیسها تابع onFulfilled تعریف شده در متد then را اجرا میکند. تنها تفاوت بین متد allSettled و all این است که در متد allSettled فقط پایان یافتن کار پرامیسها مهم است. یعنی موفقیت یا شکست پرامیسها تاثیری در عملکرد متد allSettled ندارد. در صورتی که در متد all باید تمام پرامیسهای ارسال شده به این متد، کار خود را با موفقیت به پایان برسانند. نمونهای از نحوهی استفاده از متد allSettled در قطعه کد زیر نشان داده شده است.
const p1 = new Promise((resolve , reject) => {
setTimeout(() => {
console.log('p1 resolved');
resolve();
} , 300);
});
const p2 = new Promise((resolve , reject) => {
setTimeout(() => {
console.log('p2 rejected');
reject();
} , 1300);
});
const p = Promise.allSettled([p1 , p2]);
p.then(() => console.log('All promises finished'));
این مثال را میتوانید اینجا اجرا کنید. با اجرای این مثال مشاهده خواهید کرد که با وجود شکست در پرامیس p2، باز هم پرامیس p با موفقیت اجرا میشود. اما در صورت استفاده از متد all به جای مثال allSettled این مثال با خطا مواجه خواهد شد.
نکته : متد allSettled فقط در نسخههای اخیر مرورگرهای مدرن قابل استفاده است. (Chrome 76+، Firefox 71+، Edge 79+، Safari 13+ و Opera 63+)
متد race
این متد نیز تعدادی پرامیس را به صورت یک آرایه دریافت میکند و یک پرامیس جدید را بازمیگرداند. پرامیس بازگردانده شده از این متد به محض پایان یافتن یکی از پرامیسهای ارسال شده به متد race به پایان خواهد رسید. نکتهی مهم در رابطه با این متد این است که وضعیت نهایی پرامیس بازگردانده شده از این متد وابسته به وضعیت اولین پرامیس پایان یافته است. یعنی اگر اولین پرامیس با شکست مواجه شود، پرامیس بازگردانده شده از متد race نیز با شکست مواجه میشود. و در صورت موفقیت اولین پرامیس، پرامیس بازگردانده شده از متد race نیز با موفقیت کار خود را پایان میدهد.
قطعه کد زیر نحوهی عملکرد این متد را نشان میدهد.
const p1 = new Promise((resolve , reject) => {
setTimeout(() => {
console.log('p1 resolved');
resolve();
} , 300);
});
const p2 = new Promise((resolve , reject) => {
setTimeout(() => {
console.log('p2 rejected');
reject();
} , 1300);
});
const p = Promise.race([p1 , p2]);
p.then(() => console.log('The promise resolved'))
.catch(() => console.log('The promise rejected'));
در این مثال پرامیس p1 کار خود را با موفقیت به پایان میرساند و پرامیس p2 با شکست مواجه میشود. و با توجه به اینکه p1 از p2 زودتر به کار خود پایان میدهد. در نتیجه پرامیس p نیز کار خود را با موفقیت به پایان خواهد رساند. این مثال را میتوانید اینجا اجرا کنید. با تغییر در زمان پایان یافتن پرامیسها به طوری که p2 زودتر از p1 به پایان برسد، میتوانید کاری کنید که پرامیس p با شکست مواجه شده و تابع ارسال شده به متد catch اجرا شود.
نکته : شئ Promise در مرورگر IE پشتیبانی نمیشود. بنابراین مباحث مطرح شده در این بخش را نمیتوان در مرورگر IE به کار برد.