Bản dịch này chưa hoàn thành. Xin hãy giúp dịch bài viết này từ tiếng Anh.

Promise là một đối tượng đại diện cho sự hoàn thành (thành công hoặc thất bại) của một tiến trình bất đồng bộ. Vì đa số chúng ta là người sử dụng Promise được tạo sẵn, bài viết này sẽ hướng dẫn cách sử dụng Promise trước khi hướng dẫn cách tạo chúng.

Về cơ bản, một promise là một đối tượng trả về mà bạn gắn callback vào nó thay vì truyền callback vào trong một hàm.

Giả sử chúng ta có một hàm, createAudioFileAsync(), mà nó sẽ tạo ra một file âm thanh từ config object và hai hàm callback, một hàm sẽ được gọi khi file âm thanh được tạo thành công, và một hàm được gọi khi có lỗi xảy ra.

Sau đây là code ví dụ sử dụng createAudioFileAsync():

function successCallback(result) {
  console.log("Audio file ready at URL: " + result);
}

function failureCallback(error) {
  console.log("Error generating audio file: " + error);
}

createAudioFileAsync(audioSettings, successCallback, failureCallback);

Thay vì như trên, các hàm bất đồng bộ hiện đại sẽ trả về đối tượng promise mà bạn sẽ gắn callback vào nó:

Nếu hàm createAudioFileAsync() được viết lại sử dụng promise, thì việc sử dụng nó sẽ chỉ đơn giản như sau:

createAudioFileAsync(audioSettings).then(successCallback, failureCallback);

Nếu viết dài dòng hơn thì sẽ là:

const promise = createAudioFileAsync(audioSettings); 
promise.then(successCallback, failureCallback);

Chúng ta gọi đây là một lời gọi hàm bất đồng bộ (asynchronous function call). Cách tiếp cận này có nhiều ưu điểm, mà chúng ta sẽ lần lượt xem xét bên dưới.

Sự đảm bảo

Không như cách truyền callback kiểu cũ, một promise có những đảm bảo như sau:

  • Callback sẽ không bao giờ được gọi trước khi hoàn tất lượt chạy của một JavaScript event loop.
  • Callback được thêm vào then() sau khi tiến trình bất đồng bộ đã hoàn thành vẫn được gọi, và theo nguyên tắc ở trên.
  • Nhiều callback có thể được thêm vào bằng cách gọi then() nhiều lần. Mỗi callback sẽ được gọi lần lượt, theo thứ tự mà chúng được thêm vào.

Một trong những đặc tính tuyệt vời của promise chính lài chaining (gọi nối).

Chaining (gọi nối)

Có một nhu cầu phổ biến đó là thực thi hai hay nhiều tiến trình bất đồng độ liên tiến nhau, cái sau bắt đầu ngay khi cái trước hoàn tất, với giá trị truyền vào là kết quả từ bước trước đó. Mục tiêu này có thể đạt được với một chuỗi promise (promise chain).

Sau đây là cách nó hoạt động: hàm then() trả về một promise mới, khác với cái ban đầu:

const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);

hoặc

const promise2 = doSomething().then(successCallback, failureCallback);

Promise thứ hai (promise2) không chỉ đại diện cho việc hoàn thành doSomething() mà còn cho cả successCallback hoặc failureCallback mà bạn đưa vào, mà chúng có thể là những hàm bất đồng bộ khác trả về promise. Trong trường hợp đó, bất kỳ callback nào được thêm vào cho promise2 cũng sẽ được xếp phía sau promise trả về bởi một trong hai successCallback hoặc failureCallback.

Tóm lại, mỗi promise đại diện cho việc hoàn tất của một bước bất đồng bộ trong chuỗi.

Trước khi có promise, kết quả của việc thực hiện một chuỗi các thao tác bất đồng bộ theo cách cũ là một "thảm họa" kim tự tháp callback:

doSomething(function(result) {
  doSomethingElse(result, function(newResult) {
    doThirdThing(newResult, function(finalResult) {
      console.log('Got the final result: ' + finalResult);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

Thay vào đó, với cách tiếp cận hiện đại, chúng ta sẽ gắn các callback vào các promise trả về, tạo thành một chuỗi promise:

doSomething().then(function(result) {
  return doSomethingElse(result);
})
.then(function(newResult) {
  return doThirdThing(newResult);
})
.then(function(finalResult) {
  console.log('Got the final result: ' + finalResult);
})
.catch(failureCallback);

Tham số cho then là không bắt buộc, và catch(failureCallback) là cách viết gọn của then(null, failureCallback). Bạn có thể thấy chuỗi promise dùng với arrow function như sau:

doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
  console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);

Quan trọng: hãy nhớ luôn trả về kết quả, nếu không, callback sẽ không nhận được kết quả từ promise trước đó.

Gọi nối sau hàm catch

Bạn có thể tiếp tục gọi chuỗi then sau một hàm bắt lỗi catch. Điều này cho phép code của bạn luôn thực hiện một thao tác nào đó cho dù đã có lỗi xảy ra ở một bước nào đó trong chuỗi. Hãy xem ví dụ sau:

new Promise((resolve, reject) => {
    console.log('Initial');

    resolve();
})
.then(() => {
    throw new Error('Something failed');
        
    console.log('Do this');
})
.catch(() => {
    console.log('Do that');
})
.then(() => {
    console.log('Do this, no matter what happened before');
});

Đoạn code này sẽ log ra những dòng sau:

Initial
Do that
Do this, no matter what happened before

Ghi chú: Dòng text Do this không hiển thị bởi vì Error Something failed đã xảy ra trước và gây lỗi trong chuỗi promise.

Xử lý lỗi tập trung

Bạn hãy nhớ lại đoạn code kim tự tháp thảm họa ở trên, có đến 3 lần hàm failureCallback được sử dụng. Trong khi đó, bạn chỉ cần khai báo một lần vào cuối chuỗi promise:

doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => console.log(`Got the final result: ${finalResult}`))
.catch(failureCallback);

Về căn bản, một chuỗi promise sẽ dừng lại nếu có lỗi xảy ra, và nó sẽ truy xuống dưới cuối chuỗi để tìm và gọi hàm xử lý lỗi catch. Cách hoạt động này khá giống với try catch của code đồng bộ:

try {
  const result = syncDoSomething();
  const newResult = syncDoSomethingElse(result);
  const finalResult = syncDoThirdThing(newResult);
  console.log(`Got the final result: ${finalResult}`);
} catch(error) {
  failureCallback(error);
}

Và vì lý do trên, try catch cũng được sử dụng để bắt lỗi cho code bất đồng bộ khi viết với cú pháp async/await của ECMAScript 2017.

async function foo() {
  try {
    const result = await doSomething();
    const newResult = await doSomethingElse(result);
    const finalResult = await doThirdThing(newResult);
    console.log(`Got the final result: ${finalResult}`);
  } catch(error) {
    failureCallback(error);
  }
}

Cú pháp trên được xây dựng từ Promise, VD: doSomething() chính là hàm được viết với Promise ở trên. Bạn có thể đọc thêm về cú pháp đó ở đây.

Promise giúp giải quyết một hạn chế cơ bản của kim tự tháp callback, đó là cho phép bắt được tất cả các loại lỗi, từ những lỗi throw Error cho đến lỗi về cú pháp lập trình. Điều này là tối cần thiết cho việc phối hợp các hàm xử lý đồng bộ.

Creating a Promise around an old callback API

A Promise can be created from scratch using its constructor. This should be needed only to wrap old APIs.

In an ideal world, all asynchronous functions would already return promises. Alas, some APIs still expect success and/or failure callbacks to be passed in the old way. The quintessential example is the setTimeout() function:

setTimeout(() => saySomething("10 seconds passed"), 10000);

Mixing old-style callbacks and promises is problematic. If saySomething() fails or contains a programming error, nothing catches it.

Luckily we can wrap it in a promise. Best practice is to wrap problematic functions at the lowest possible level, and then never call them directly again:

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));

wait(10000).then(() => saySomething("10 seconds")).catch(failureCallback);

Basically, the promise constructor takes an executor function that lets us resolve or reject a promise manually. Since setTimeout() doesn't really fail, we left out reject in this case.

Composition

Promise.resolve() and Promise.reject() are shortcuts to manually create an already resolved or rejected promise respectively. This can be useful at times.

Promise.all() and Promise.race() are two composition tools for running asynchronous operations in parallel.

We can start operations in parallel and wait for them all to finish like this:

Promise.all([func1(), func2(), func3()])
.then(([result1, result2, result3]) => { /* use result1, result2 and result3 */ });

Sequential composition is possible using some clever JavaScript:

[func1, func2, func3].reduce((p, f) => p.then(f), Promise.resolve())
.then(result3 => { /* use result3 */ });

Basically, we reduce an array of asynchronous functions down to a promise chain equivalent to: Promise.resolve().then(func1).then(func2).then(func3);

This can be made into a reusable compose function, which is common in functional programming:

const applyAsync = (acc,val) => acc.then(val);
const composeAsync = (...funcs) => x => funcs.reduce(applyAsync, Promise.resolve(x));

The composeAsync() function will accept any number of functions as arguments, and will return a new function that accepts an initial value to be passed through the composition pipeline:

const transformData = composeAsync(func1, func2, func3);
const result3 = transformData(data);

In ECMAScript 2017, sequential composition can be done more simply with async/await:

let result;
for (const f of [func1, func2, func3]) {
  result = await f(result);
}
/* use last result (i.e. result3) */

Timing

To avoid surprises, functions passed to then() will never be called synchronously, even with an already-resolved promise:

Promise.resolve().then(() => console.log(2));
console.log(1); // 1, 2

Instead of running immediately, the passed-in function is put on a microtask queue, which means it runs later when the queue is emptied at the end of the current run of the JavaScript event loop, i.e. pretty soon:

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));

wait().then(() => console.log(4));
Promise.resolve().then(() => console.log(2)).then(() => console.log(3));
console.log(1); // 1, 2, 3, 4

Nesting

Simple promise chains are best kept flat without nesting, as nesting can be a result of careless composition. See common mistakes.

Nesting is a control structure to limit the scope of catch statements. Specifically, a nested catch only catches failures in its scope and below, not errors higher up in the chain outside the nested scope. When used correctly, this gives greater precision in error recovery:

doSomethingCritical()
.then(result => doSomethingOptional()
  .then(optionalResult => doSomethingExtraNice(optionalResult))
  .catch(e => {})) // Ignore if optional stuff fails; proceed.
.then(() => moreCriticalStuff())
.catch(e => console.log("Critical failure: " + e.message));

Note that the optional steps here are nested, not from the indentation, but from the precarious placement of the outer ( and ) around them.

The inner neutralizing catch statement only catches failures from doSomethingOptional() and doSomethingExtraNice(), after which the code resumes with moreCriticalStuff(). Importantly, if doSomethingCritical() fails, its error is caught by the final (outer) catch only.

Common mistakes

Here are some common mistakes to watch out for when composing promise chains. Several of these mistakes manifest in the following example:

// Bad example! Spot 3 mistakes!

doSomething().then(function(result) {
  doSomethingElse(result) // Forgot to return promise from inner chain + unnecessary nesting
  .then(newResult => doThirdThing(newResult));
}).then(() => doFourthThing());
// Forgot to terminate chain with a catch!

The first mistake is to not chain things together properly. This happens when we create a new promise but forget to return it. As a consequence, the chain is broken, or rather, we have two independent chains racing. This means doFourthThing() won't wait for   doSomethingElse() or doThirdThing() to finish, and will run in parallel with them, likely unintended. Separate chains also have separate error handling, leading to uncaught errors.

The second mistake is to nest unnecessarily, enabling the first mistake. Nesting also limits the scope of inner error handlers, which—if unintended—can lead to uncaught errors. A variant of this is the promise constructor anti-pattern, which combines nesting with redundant use of the promise constructor to wrap code that already uses promises.

The third mistake is forgetting to terminate chains with catch. Unterminated promise chains lead to uncaught promise rejections in most browsers.

A good rule-of-thumb is to always either return or terminate promise chains, and as soon as you get a new promise, return it, to flatten things:

doSomething()
.then(function(result) {
  return doSomethingElse(result);
})
.then(newResult => doThirdThing(newResult))
.then(() => doFourthThing())
.catch(error => console.log(error));

Note that () => x is short for () => { return x; }.

Now we have a single deterministic chain with proper error handling.

Using async/await addresses most, if not all of these problems—the tradeoff being that the most common mistake with that syntax is forgetting the await keyword.

See also

Document Tags and Contributors

Những người đóng góp cho trang này: trongthanh
Cập nhật lần cuối bởi: trongthanh,