وعده‌های جاوا اسکریپت: مقدمه

«قول‌ها» محاسبات معوق و ناهمگام را ساده می‌کنند. قول نشان‌دهنده عملیاتی است که هنوز تکمیل نشده است.

توسعه‌دهندگان، خود را برای لحظه‌ای محوری در تاریخ توسعه وب آماده کنید.

[صدای طبل شروع می‌شود]

Promises به جاوا اسکریپت اضافه شد!

[آتش‌بازی منفجر می‌شود، کاغذهای زرق‌وبرق‌دار از بالا می‌بارد، جمعیت هیجان‌زده می‌شود]

در این مرحله، در یکی از این دسته‌ها قرار می‌گیرید:

  • مردم اطرافتان درحال هورا کشیدن هستند، اما شما نمی‌دانید این همه هیاهو برای چیست. شاید حتی مطمئن نیستید که «قول» چیست. شانه بالا می‌اندازید، اما سنگینی کاغذ زرق‌وبرق‌دار روی شانه‌هایتان سنگینی می‌کند. اگر این‌طور است، نگران نباشید، مدت‌ها طول کشید تا بفهمم چرا باید به این چیزها اهمیت بدهم. احتمالاً می‌خواهید از ابتدا شروع کنید.
  • مشتتان را در هوا تکان می‌دهید! وقتش رسیده بود، نه؟ قبلاً از این Promiseها استفاده کرده‌اید اما اینکه همه پیاده‌سازی‌ها API کمی متفاوتی دارند شما را آزار می‌دهد. میانای برنامه‌سازی کاربردی برای نسخه رسمی جاوا اسکریپت چیست؟ احتمالاً می‌خواهید با اصطلاحات شروع کنید.
  • شما ازقبل این را می‌دانستید و کسانی را که بالا و پایین می‌پرند و طوری رفتار می‌کنند که انگار این خبر جدیدی برایشان است مسخره می‌کنید. کمی وقت بگذارید و از برتری خود لذت ببرید، سپس مستقیماً به مرجع میانای برنامه‌سازی کاربردی بروید.

پشتیبانی مرورگر و شبیه‌ساز مرورگر

Browser Support

  • Chrome: 32.
  • Edge: 12.
  • Firefox: 29.
  • Safari: 8.

Source

برای اینکه مرورگرهایی را که فاقد پیاده‌سازی کامل promises هستند به سطح مطابقت با مشخصات برسانید، یا promises را به مرورگرهای دیگر و Node.js اضافه کنید، شبیه‌ساز مرورگر (2k gzipped) را بررسی کنید.

این همه هیاهو برای چیست؟

جاوا اسکریپت تک‌رشته‌ای است، یعنی دو بیت اسکریپت نمی‌توانند هم‌زمان اجرا شوند؛ باید یکی پس‌از دیگری اجرا شوند. در مرورگرها، جاوا اسکریپت رشته‌ای را با بار چیزهای دیگر که از مرورگری به مرورگر دیگر متفاوت است به‌اشتراک می‌گذارد. اما معمولاً جاوا اسکریپت در همان صف رنگ‌آمیزی، به‌روزرسانی سبک‌ها، و رسیدگی به کنش‌های کاربر (مثل برجسته کردن نوشتار و تعامل با کنترل‌های فرم) قرار دارد. فعالیت در یکی از این موارد، موارد دیگر را به‌تأخیر می‌اندازد.

به‌عنوان یک انسان، شما چندرشته‌ای هستید. می‌توانید با چند انگشت تایپ کنید، می‌توانید رانندگی کنید و هم‌زمان مکالمه داشته باشید. تنها عملکرد مسدودکننده‌ای که باید با آن کنار بیاییم عطسه کردن است که درطول عطسه کردن، همه فعالیت‌های فعلی باید به‌حالت تعلیق درآید. این موضوع خیلی آزاردهنده است، به‌ویژه وقتی درحال رانندگی هستید و می‌خواهید مکالمه‌ای را ادامه دهید. نمی‌خواهید کدی بنویسید که عطسه‌آور باشد.

احتمالاً از رویدادها و فراخوانی‌های برگشتی برای دور زدن این موضوع استفاده کرده‌اید. رویدادها در اینجا آمده است:

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  // woo yey image loaded
});

img1.addEventListener('error', function() {
  // argh everything's broken
});

این اصلاً عطسه‌آور نیست. تصویر را دریافت می‌کنیم، چند شنونده اضافه می‌کنیم، سپس جاوا اسکریپت می‌تواند تا زمانی که یکی از آن شنونده‌ها فراخوانده شود، اجرا را متوقف کند.

متأسفانه در مثال بالا، ممکن است رویدادها قبل‌از اینکه شروع به گوش دادن به آن‌ها کنیم اتفاق افتاده باشند، بنابراین باید بااستفاده از ویژگی «کامل» تصاویر، این مشکل را برطرف کنیم:

var img1 = document.querySelector('.img-1');

function loaded() {
  // woo yey image loaded
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // argh everything's broken
});

This doesn't catch images that errored before we got a chance to listen for them; unfortunately the DOM doesn't give us a way to do that. همچنین، این یک تصویر را بار می‌کند. اگر بخواهیم بدانیم چه زمانی مجموعه‌ای از تصاویر بار شده است، اوضاع پیچیده‌تر می‌شود.

رویدادها همیشه بهترین راه نیستند

رویدادها برای چیزهایی که می‌توانند چندین بار در یک شیء اتفاق بیفتند عالی هستند—keyup، touchstart و غیره. با این رویدادها، واقعاً برایتان مهم نیست که قبل‌از پیوست کردن شنونده چه اتفاقی افتاده است. اما وقتی صحبت از موفقیت/شکست ناهمگام می‌شود، در حالت ایده‌آل چیزی شبیه به این می‌خواهید:

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

این همان کاری است که وعده‌ها انجام می‌دهند، اما با نام‌گذاری بهتر. اگر عناصر تصویر HTML دارای روش «آماده» بودند که وعده‌ای را برمی‌گرداند، می‌توانستیم این کار را انجام دهیم:

img1.ready()
.then(function() {
  // loaded
}, function() {
  // failed
});

// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
  // all loaded
}, function() {
  // one or more failed
});

در ابتدایی‌ترین حالت، وعده‌ها کمی شبیه شنوندگان رویداد هستند، با این تفاوت که:

  • یک وعده فقط یک‌بار می‌تواند موفق شود یا شکست بخورد. نمی‌تواند دو بار موفق شود یا شکست بخورد، همچنین نمی‌تواند از موفقیت به شکست یا برعکس تغییر کند.
  • اگر یک promise موفق یا ناموفق باشد و بعداً یک callback موفق/ناموفق اضافه کنید، callback صحیح فراخوانی می‌شود، حتی اگر رویداد قبلاً رخ داده باشد.

این امر برای موفقیت/شکست ناهمگام بسیار مفید است، زیرا شما کمتر به زمان دقیق دردسترس قرار گرفتن چیزی علاقه‌مند هستید و بیشتر به واکنش به نتیجه علاقه‌مند هستید.

اصطلاحات Promise

دومنیک دنیکولا پیش‌نویس اولیه این مقاله را غلط‌گیری کرد و برای اصطلاحات به من نمره «F» داد. ‫او مرا تنبیه کرد، مجبورم کرد States and Fates را ۱۰۰ بار رونویسی کنم، و نامه‌ای نگران‌کننده به والدینم نوشت. بااین‌حال، هنوز هم بسیاری از اصطلاحات را با هم اشتباه می‌گیرم، اما اصول اولیه این‌ها هستند:

قول می‌تواند:

  • انجام‌شده - کنش مربوط به وعده موفقیت‌آمیز بود
  • ردشده - کنش مربوط به وعده ناموفق بود
  • درحال انتظار - هنوز انجام یا رد نشده است
  • settled - Has fulfilled or rejected

مشخصات همچنین از اصطلاح thenable برای توصیف شیئی استفاده می‌کند که شبیه promise است، به این معنا که دارای روش then است. این اصطلاح من را یاد تری ونبلز، سرمربی سابق تیم فوتبال انگلیس، می‌اندازد، بنابراین تا جایی که ممکن است کمتر از آن استفاده خواهم کرد.

Promises به جاوا اسکریپت می‌آید!

«قول‌ها» مدتی است که به‌شکل کتابخانه‌هایی وجود دارند، مانند:

موارد بالا و وعده‌های جاوا اسکریپت رفتار مشترک و استانداردی دارند که Promises/A+ نامیده می‌شود. اگر کاربر jQuery هستید، آن‌ها چیز مشابهی به نام Deferreds دارند. بااین‌حال، Deferreds با Promise/A+ سازگار نیست، که باعث می‌شود کمی متفاوت و کمتر مفید باشد، بنابراین مراقب باشید. jQuery همچنین دارای نوع Promise است، اما این فقط یک زیرمجموعه از Deferred است و همان مشکلات را دارد.

اگرچه پیاده‌سازی‌های Promise از رفتار استانداردی پیروی می‌کنند، اما میاناهای برنامه‌سازی کاربردی کلی آن‌ها متفاوت است. قول‌های جاوا اسکریپت در میانای برنامه‌سازی کاربردی شبیه به RSVP.js هستند. نحوه ایجاد قول در اینجا آمده است:

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

سازنده promise یک آرگومان می‌گیرد، یک callback با دو پارامتر، resolve و reject. کاری را در تماس برگشتی انجام دهید، شاید ناهمگام، سپس اگر همه‌چیز درست بود، resolve را فراخوانی کنید، درغیراین‌صورت reject را فراخوانی کنید.

مانند throw در جاوا اسکریپت قدیمی ساده، رد کردن با شیء Error مرسوم است اما الزامی نیست. مزیت اشیاء Error این است که رد پشته را ضبط می‌کنند و ابزارهای اشکال‌زدایی را مفیدتر می‌کنند.

نحوه استفاده از این تعهد:

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

‫then() دو آرگومان می‌گیرد، یک فراخوانی برگشتی برای حالت موفقیت، و دیگری برای حالت شکست. هر دو اختیاری هستند، بنابراین می‌توانید فقط برای حالت موفقیت یا شکست، یک فراخوانی برگشتی اضافه کنید.

JavaScript promises started out in the DOM as "Futures", renamed to "Promises", and finally moved into JavaScript. داشتن آن‌ها در جاوا اسکریپت به‌جای DOM عالی است زیرا در زمینه‌های JS غیرمرورگر مانند Node.js دردسترس خواهند بود (اینکه آیا از آن‌ها در میاناهای برنامه‌سازی کاربردی اصلی خود استفاده می‌کنند یا نه، سؤال دیگری است).

اگرچه این‌ها ویژگی جاوا اسکریپت هستند، DOM از استفاده از آن‌ها ابایی ندارد. درواقع، همه میاناهای برنامه‌سازی کاربردی DOM جدید با روش‌های موفقیت/شکست ناهمگام از وعده‌ها استفاده خواهند کرد. این اتفاق درحال‌حاضر با مدیریت سهمیه، رویدادهای بارگیری قلم، ServiceWorker، Web MIDI، جاری‌سازی‌ها، و موارد دیگر درحال رخ دادن است.

سازگاری با کتابخانه‌های دیگر

The JavaScript promises API will treat anything with a then() method as promise-like (or thenable in promise-speak sigh), so if you use a library that returns a Q promise, that's fine, it'll play nice with the new JavaScript promises.

اگرچه، همانطور که اشاره کردم، Deferreds در jQuery کمی … غیرمفید است. خوشبختانه می‌توانید آن‌ها را به promise استاندارد تبدیل کنید که بهتر است در اسرع وقت انجام شود:

var jsPromise = Promise.resolve($.ajax('/whatever.json'))

در اینجا، $.ajax jQuery یک Deferred برمی‌گرداند. چون روش then() دارد، Promise.resolve() می‌تواند آن را به JavaScript promise تبدیل کند. بااین‌حال، گاهی اوقات deferreds چندین متغیر مستقل را به callbackهای خود ارسال می‌کند، برای مثال:

var jqDeferred = $.ajax('/whatever.json');

jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
})

درحالی‌که JS promises همه را به‌جز اولی نادیده می‌گیرد:

jsPromise.then(function(response) {
  // ...
}, function(xhrObj) {
  // ...
})

خوشبختانه این معمولاً همان چیزی است که می‌خواهید، یا حداقل به شما امکان می‌دهد به آنچه می‌خواهید دسترسی پیدا کنید. همچنین توجه داشته باشید که jQuery از قرارداد ارسال اشیاء Error به ردها پیروی نمی‌کند.

کد ناهمگام پیچیده آسان‌تر شده است

بسیارخوب، بیایید چند چیز را کدنویسی کنیم. فرض کنید می‌خواهیم:

  1. گردونه‌ای را برای نشان دادن بارگیری شروع کنید
  2. برخی JSON را برای داستانی واکشی کنید که عنوان و نشانی‌های وب هر فصل را به ما می‌دهد
  3. افزودن عنوان به صفحه
  4. واکشی هر فصل
  5. افزودن داستان به صفحه
  6. توقف گردونه

… اما اگر مشکلی در این میان پیش آمد، به کاربر اطلاع دهید. We'll want to stop the spinner at that point too, else it'll keep on spinning, get dizzy, and crash into some other UI.

البته، شما از جاوا اسکریپت برای ارائه یک داستان استفاده نمی‌کنید، ارائه به عنوان HTML سریع‌تر است، اما این الگو هنگام کار با APIها بسیار رایج است: چندین واکشی داده، سپس انجام کاری وقتی همه چیز تمام شد.

برای شروع، بیایید به واکشی داده‌ها از شبکه بپردازیم:

Promisifying XMLHttpRequest

میاناهای برنامه‌سازی کاربردی قدیمی به‌روزرسانی خواهند شد تا از وعده‌ها استفاده کنند، اگر این کار به روشی سازگار با نسخه قدیمی امکان‌پذیر باشد. XMLHttpRequest گزینه اصلی است، اما در این میان بیایید تابع ساده‌ای برای ایجاد درخواست GET بنویسیم:

function get(url) {
  // Return a new promise.
  return new Promise(function(resolve, reject) {
    // Do the usual XHR stuff
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      // This is called even on 404 etc
      // so check the status
      if (req.status == 200) {
        // Resolve the promise with the response text
        resolve(req.response);
      }
      else {
        // Otherwise reject with the status text
        // which will hopefully be a meaningful error
        reject(Error(req.statusText));
      }
    };

    // Handle network errors
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Make the request
    req.send();
  });
}

اکنون بیایید از آن استفاده کنیم:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

اکنون می‌توانیم درخواست‌های اچ‌تی‌تی‌پی را بدون تایپ کردن دستی XMLHttpRequest انجام دهیم، که عالی است، زیرا هرچه کمتر مجبور باشم نگارش شتری آزاردهنده XMLHttpRequest را ببینم، زندگی شادتری خواهم داشت.

زنجیر کردن

then() پایان داستان نیست، می‌توانید thenها را به‌هم زنجیر کنید تا مقادیر را تبدیل کنید یا کنش‌های ناهمگام دیگری را یکی پس‌از دیگری اجرا کنید.

درحال تبدیل مقادیر

می‌توانید مقادیر را به‌سادگی با برگرداندن مقدار جدید تبدیل کنید:

var promise = new Promise(function(resolve, reject) {
  resolve(1);
});

promise.then(function(val) {
  console.log(val); // 1
  return val + 2;
}).then(function(val) {
  console.log(val); // 3
})

به‌عنوان مثالی کاربردی، بیایید به این مورد برگردیم:

get('story.json').then(function(response) {
  console.log("Success!", response);
})

پاسخ JSON است، اما درحال‌حاضر آن را به‌صورت نوشتار ساده دریافت می‌کنیم. می‌توانیم تابع get را تغییر دهیم تا از JSON responseType استفاده کند، ولی می‌توانیم آن را در قلمرو promise نیز حل کنیم:

get('story.json').then(function(response) {
  return JSON.parse(response);
}).then(function(response) {
  console.log("Yey JSON!", response);
})

ازآنجایی‌که JSON.parse() یک آرگومان واحد می‌گیرد و مقدار تبدیل‌شده را برمی‌گرداند، می‌توانیم میان‌بری ایجاد کنیم:

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Yey JSON!", response);
})

درواقع، می‌توانیم تابع getJSON() را خیلی راحت بسازیم:

function getJSON(url) {
  return get(url).then(JSON.parse);
}

getJSON() همچنان یک promise برمی‌گرداند، که یک نشانی وب را واکشی می‌کند و سپس پاسخ را به‌عنوان JSON تجزیه می‌کند.

صف‌بندی کنش‌های ناهمزمان

همچنین می‌توانید then را زنجیره‌ای کنید تا کنش‌های ناهمگام را به‌صورت متوالی اجرا کنید.

وقتی چیزی را از بازخوانی then() برمی‌گردانید، کمی جادویی است. اگر مقداری را برگردانید، then() بعدی با آن مقدار فراخوانی می‌شود. بااین‌حال، اگر چیزی شبیه به promise برگردانید، then() بعدی منتظر آن می‌ماند و فقط زمانی فراخوانی می‌شود که آن promise حل‌وفصل شود (موفق/ناموفق). برای مثال:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

در اینجا ما یک درخواست ناهمگام به story.json ارسال می‌کنیم که به ما مجموعه‌ای از نشانی‌های وب را برای درخواست می‌دهد، سپس ما اولین مورد از آن‌ها را درخواست می‌کنیم. اینجاست که وعده‌ها واقعاً از الگوهای فراخوانی ساده متمایز می‌شوند.

حتی می‌توانید یک روش میان‌بر برای دریافت فصل‌ها ایجاد کنید:

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

We don't download story.json until getChapter is called, but the next time(s) getChapter is called we reuse the story promise, so story.json is only fetched once. Yay Promises!

مدیریت کردن خطا

همان‌طور که قبلاً دیدیم، then() دو آرگومان می‌گیرد، یکی برای موفقیت، یکی برای شکست (یا انجام و رد، در زبان وعده‌ها):

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

همچنین می‌توانید از catch() استفاده کنید:

get('story.json').then(function(response) {
  console.log("Success!", response);
}).catch(function(error) {
  console.log("Failed!", error);
})

catch() چیز خاصی نیست، فقط قند برای then(undefined, func) است، اما خواناتر است. توجه داشته باشید که دو کد نمونه بالا رفتار یکسانی ندارند، دومی معادل این است:

get('story.json').then(function(response) {
  console.log("Success!", response);
}).then(undefined, function(error) {
  console.log("Failed!", error);
})

تفاوت ظریف اما بسیار مفید است. رد کردن Promise به then() بعدی با فراخوانی برگشتی رد کردن (یا catch()، چون معادل است) می‌رود. با then(func1, func2)، با func1 یا func2 تماس گرفته می‌شود، نه هر دو. اما با then(func1).catch(func2)، اگر func1 رد کند، هر دو فراخوانی می‌شوند، زیرا مراحل جداگانه‌ای در زنجیره هستند. موارد زیر را مصرف کنید:

asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
})

جریان بالا بسیار شبیه به try/catch معمولی جاوا اسکریپت است، خطاهایی که در «try» اتفاق می‌افتند بلافاصله به بلوک catch() می‌روند. در اینجا موارد بالا به‌صورت روندنما ارائه شده است (چون من عاشق روندنما هستم):

خطوط آبی را برای وعده‌هایی که برآورده می‌شوند یا خطوط قرمز را برای وعده‌هایی که رد می‌شوند دنبال کنید.

استثناها و وعده‌های جاوا اسکریپت

رد شدن زمانی اتفاق می‌افتد که یک وعده به‌طور صریح رد شود، اما همچنین به‌طور ضمنی اگر خطایی در فراخوان سازنده ایجاد شود:

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

این یعنی بهتر است همه کارهای مربوط به promise را در callback سازنده promise انجام دهید تا خطاها به‌طور خودکار شناسایی شوند و به رد شدن تبدیل شوند.

همین امر برای خطاهای ایجادشده در فراخوانی‌های برگشتی then() نیز صادق است.

get('/').then(JSON.parse).then(function() {
  // This never happens, '/' is an HTML page, not JSON
  // so JSON.parse throws
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

مدیریت کردن خطا در عمل

با داستان و فصل‌هایمان، می‌توانیم از catch برای نمایش خطا به کاربر استفاده کنیم:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

اگر واکشی story.chapterUrls[0] ناموفق باشد (مثلاً http 500 یا کاربر آفلاین باشد)، همه فراخوانی‌های موفقیت‌آمیز بعدی را رد می‌کند، ازجمله فراخوانی موجود در getJSON() که سعی می‌کند پاسخ را به‌عنوان JSON تجزیه کند، و همچنین فراخوانی‌ای را که chapter1.html را به صفحه اضافه می‌کند رد می‌کند. درعوض، به callback catch منتقل می‌شود. درنتیجه، اگر هرکدام از کنش‌های قبلی ناموفق باشد، «نمایش فصل ناموفق بود» به صفحه اضافه می‌شود.

مانند try/catch در جاوا اسکریپت، خطا گرفته می‌شود و کد بعدی ادامه می‌یابد، بنابراین نشانگر چرخان همیشه پنهان است، که همان چیزی است که می‌خواهیم. مورد بالا به نسخه ناهمگام غیرمسدودکننده زیر تبدیل می‌شود:

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

ممکن است بخواهید catch() را صرفاً برای اهداف گزارش‌گیری، بدون بازیابی از خطا، انجام دهید. برای انجام این کار، کافی است خطا را دوباره ارسال کنید. می‌توانیم این کار را در روش getJSON() خود انجام دهیم:

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

بنابراین توانستیم یک فصل را واکشی کنیم، اما همه آن‌ها را می‌خواهیم. بیایید آن را عملی کنیم.

موازی‌سازی و توالی: بهره‌مندی از بهترین‌های هر دو

تفکر ناهمگام آسان نیست. اگر برای شروع کار مشکل دارید، سعی کنید کد را طوری بنویسید که گویی همگام است. در این حالت:

try {
  var story = getJSONSync('story.json');
  addHtmlToPage(story.heading);

  story.chapterUrls.forEach(function(chapterUrl) {
    var chapter = getJSONSync(chapterUrl);
    addHtmlToPage(chapter.html);
  });

  addTextToPage("All done");
}
catch (err) {
  addTextToPage("Argh, broken: " + err.message);
}

document.querySelector('.spinner').style.display = 'none'

این کار می‌کند! اما همگام‌سازی است و مرورگر را هنگام بارگیری موارد قفل می‌کند. برای اینکه این کار به‌صورت ناهمگام انجام شود، از then() استفاده می‌کنیم تا کارها یکی پس‌از دیگری انجام شود.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

اما چگونه می‌توانیم نشانی‌های وب فصل را به‌صورت حلقه‌ای تکرار کنیم و آن‌ها را به‌ترتیب واکشی کنیم؟ این کار نمی‌کند:

story.chapterUrls.forEach(function(chapterUrl) {
  // Fetch chapter
  getJSON(chapterUrl).then(function(chapter) {
    // and add it to the page
    addHtmlToPage(chapter.html);
  });
})

‫forEach از ناهمگام‌سازی آگاه نیست، بنابراین فصل‌های ما به هر ترتیبی که بارگیری شوند ظاهر می‌شوند، که اساساً همان‌طور است که «داستان عامه‌پسند» نوشته شده است. این داستان عامه‌پسند نیست، پس بیایید آن را درست کنیم.

درحال ایجاد کردن توالی

می‌خواهیم آرایه chapterUrls را به توالی‌ای از وعده‌ها تبدیل کنیم. می‌توانیم این کار را بااستفاده از then() انجام دهیم:

// Start off with a promise that always resolves
var sequence = Promise.resolve();

// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
  // Add these actions to the end of the sequence
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
})

این اولین‌باری است که Promise.resolve() را می‌بینیم، که قولی ایجاد می‌کند که به هر مقداری که به آن بدهید حل می‌شود. اگر نمونه‌ای از Promise را به آن ارسال کنید، به‌سادگی آن را برمی‌گرداند (توجه: این تغییری در مشخصات است که برخی پیاده‌سازی‌ها هنوز از آن پیروی نمی‌کنند). اگر چیزی شبیه به promise (دارای روش then()) به آن بدهید، Promise واقعی ایجاد می‌کند که به همان روش برآورده/ رد می‌شود. اگر مقدار دیگری را ارسال کنید، مثلاً Promise.resolve('Hello')، یک promise ایجاد می‌کند که با آن مقدار برآورده می‌شود. اگر آن را بدون مقدار فراخوانی کنید، همان‌طور که در بالا نشان داده شده است، با «undefined» (تعریف‌نشده) برآورده می‌شود.

Promise.reject(val) نیز وجود دارد که قولی ایجاد می‌کند که با مقداری که به آن می‌دهید (یا تعریف‌نشده) رد می‌شود.

می‌توانیم کد بالا را بااستفاده از array.reduce مرتب کنیم:

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

این کار همانند مثال قبلی است، اما نیازی به متغیر جداگانه «توالی» ندارد. فراخوانی برگشتی کاهش ما برای هر مورد در آرایه فراخوانی می‌شود. «sequence» در اولین فراخوانی Promise.resolve() است، اما برای بقیه فراخوانی‌ها، «sequence» همان چیزی است که از فراخوانی قبلی برگردانده‌ایم. array.reduce برای خلاصه کردن آرایه به یک مقدار واحد واقعاً مفید است، که در این مورد یک وعده است.

بیایید همه را کنار هم بگذاریم:

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

و این هم از این، نسخه کاملاً ناهمگام از نسخه همگام. اما می‌توانیم بهتر عمل کنیم. درحال‌حاضر صفحه ما به این صورت بارگیری می‌شود:

مرورگرها در بارگیری هم‌زمان چند چیز بسیار خوب عمل می‌کنند، بنابراین با بارگیری فصل‌ها یکی پس‌از دیگری، عملکرد را ازدست می‌دهیم. کاری که می‌خواهیم انجام دهیم این است که همه آن‌ها را هم‌زمان بارگیری کنیم، سپس وقتی همه آن‌ها رسیدند، آن‌ها را پردازش کنیم. خوشبختانه یک API برای این کار وجود دارد:

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

‫Promise.all آرایه‌ای از وعده‌ها را می‌گیرد و وعده‌ای ایجاد می‌کند که وقتی همه آن‌ها باموفقیت تکمیل شوند، برآورده می‌شود. آرایه‌ای از نتایج (هرچه که وعده‌ها به آن عمل کرده‌اند) را به همان ترتیبی که وعده‌ها را ارسال کرده‌اید دریافت می‌کنید.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

بسته به اتصال، این روش می‌تواند چند ثانیه سریع‌تر از بار کردن تک‌تک باشد، و کد کمتری نسبت‌به تلاش اول ما دارد. فصل‌ها می‌توانند با هر ترتیبی بارگیری شوند، اما با ترتیب درست در صفحه نشان داده می‌شوند.

بااین‌حال، همچنان می‌توانیم عملکرد درک‌شده را بهبود بخشیم. وقتی فصل یک رسید باید آن را به صفحه اضافه کنیم. این کار به کاربر اجازه می‌دهد قبل‌از رسیدن بقیه فصل‌ها، خواندن را شروع کند. وقتی فصل سه می‌رسد، آن را به صفحه اضافه نمی‌کنیم چون ممکن است کاربر متوجه نشود که فصل دو وجود ندارد. وقتی فصل دو رسید، می‌توانیم فصل دو و سه و غیره را اضافه کنیم.

برای این کار، JSON را برای همه فصل‌هایمان به‌طور هم‌زمان واکشی می‌کنیم، سپس توالی‌ای برای افزودن آن‌ها به سند ایجاد می‌کنیم:

getJSON('story.json')
.then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence
      .then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

و حالا، بهترین‌های هر دو! ارسال همه محتوا به همان اندازه زمان می‌برد، اما کاربر اولین بخش محتوا را زودتر دریافت می‌کند.

در این مثال ساده، همه فصل‌ها تقریباً هم‌زمان می‌رسند، اما مزیت نمایش یک‌به‌یک فصل‌ها با فصل‌های بیشتر و بزرگ‌تر بیشتر نمایان می‌شود.

انجام موارد بالا با رویدادها یا فراخوانی‌های برگشتی سبک Node.js تقریباً دوبرابر کد است، اما مهم‌تر از آن، دنبال کردن آن آسان نیست. بااین‌حال، این پایان داستان وعده‌ها نیست، وقتی با سایر ویژگی‌های ES6 ترکیب می‌شوند حتی آسان‌تر می‌شوند.

دور اضافی: قابلیت‌های گسترش‌یافته

Since I originally wrote this article, the ability to use Promises has expanded greatly. از Chrome 55، توابع ناهمگام اجازه داده‌اند کد مبتنی بر وعده به‌گونه‌ای نوشته شود که گویی همگام است، اما بدون مسدود کردن رشته اصلی. می‌توانید در مقاله توابع ناهمگام من درباره آن بیشتر بخوانید. پشتیبانی گسترده‌ای از Promises و توابع ناهمگام در مرورگرهای اصلی وجود دارد. جزئیات را می‌توانید در مرجع Promise و تابع ناهمگام MDN پیدا کنید.

Many thanks to Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans, and Yutaka Hirano who proofread this and made corrections/recommendations.

همچنین از ماتیاس بایننز برای به‌روزرسانی بخش‌های مختلف مقاله سپاس‌گزاریم.