Promise là một đối tượng thể hiện cho sự hoàn thành 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 ra 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 cách bất đồng bộ, 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 là 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 hàm 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 đó (với arrow functions () => x được rút gọn từ () => { return x; })

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 cách thức hoạt động của 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 rất cần thiết cho việc phối hợp các hàm xử lý bất đồng bộ.

Tạo Promise từ API có kiểu callback cũ

Một Promise có thể được tạo mới từ đầu bằng cách sử dụng hàm constructor. Tuy nhiên cách này thường chỉ dùng để bọc lại API kiểu cũ.

Trong môi trường lý tưởng, tất cả các hàm bất đồng bộ đều trả về promise. Tuy nhiên vẫn còn nhiều API yêu cầu hàm callback được truyền vào theo kiểu cũ. Ví dụ điển hình nhất chính là hàm setTimeout():

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

Trộn lẫn callback và promise có nhiều vấn đề tiềm ẩn. Nếu hàm saySomething() xảy ra lỗi bên trong nó, sẽ không có gì bắt được lỗi này. setTimeout là để đổ lỗi cho điều này.

May mắn là chúng ta có thể bọc setTimeout lại với promise. Cách làm tốt nhất là bọc hàm có vấn đề ở cấp thấp nhất, để rồi sau đó chúng ta không phải gọi nó trực tiếp nữa:

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

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

Về căn bản, constructor của Promise nhận vào một hàm thực thi với hai tham số hàm resolve và reject để chúng ta có thể giải quyết hoặc từ chối promise một cách thủ công. Bởi vì hàm setTimeout() không bao giờ gây ra lỗi, chúng ta bỏ qua reject trong trường hợp này.

Phối hợp các Promise

Promise.resolve()Promise.reject() là những phương thức để lấy nhanh một promise đã được giải quyết hoặc từ chối sẵn. Những phương thức này có thể hữu dụng trong một số trường hợp.

Promise.all()Promise.race() là hai hàm tiện ích dùng để phối hợp các thao tác bất đồng bộ chạy song song.

Chúng ta có thể cho các tiến trình bất đồng bộ bắt đầu song song và chờ cho đến khi tất cả đều hoàn tất như sau:

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

Việc phối hợp các tiến trình bất đồng bộ diễn ra tuần tự không có sẵn tiện ích nhưng có thể viết mẹo với reduce như sau:

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

Về cơ bản, chúng ta đang rút gọn (reduce, tạm dịch) một mảng các hàm bất đồng bộ thành một chuỗi promise: Promise.resolve().then(func1).then(func2).then(func3);

Thao tác trên có thể được viết thành một hàm dùng lại được, như cách chúng ta hay làm trong functional programming:

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

Hàm composeAsync() sẽ nhận vào tham số là các hàm xử lý bất đồng bộ với số lượng bất kỳ, và trả và một hàm mới mà khi gọi, nó nhận vào một giá trị ban đầu mà giá trị này sẽ được truyền vào tuần tự qua các hàm xử lý bất đồng bộ:

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

Trong ECMAScript 2017, việc phối hợp tuần tự các promise có thể thực hiện đơn giản hơn với async/await:

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

Thời điểm thực thi

Để nhất quán và tránh những bất ngờ, các hàm truyền vào cho then() sẽ không bao giờ được thực thi đồng bộ, ngay với  cả những promíe đã được giải quyết:

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

Thay vì chạy ngay lập tức, promise callback được đặt vào hàng đợi microtask, điều này có nghĩa là nó sẽ chỉ được thực thi khi hàng đợi được làm rỗng ( các promise đều được giải quy) cuối event loop hiện tại của JavaScript:

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

Lồng cấp

Chuỗi Promise đơn giản chỉ nên có một cấp và không lồng vào nhau, vì lồng cấp sẽ dẫn đến những tổ hợp phức tạp dễ gây ra lỗi. Xem các sai lầm thường gặp.

Lồng nhau là một cấu trúc kiểm soát để giới hạn phạm vi của các câu lệnh catch. Cụ thể, một câu lệnh catch lồng bên trong chỉ có thể bắt được những lỗi trong phạm vi của nó và bên dưới, không phải là lỗi phía bên trên của chuỗi bên ngoài phạm vi lồng nhau. Khi được sử dụng một cách hợp lý, nó mang lại độ chính xác cao hơn trong việc khôi phục lỗi:

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));

Lưu ý rằng các bước tuỳ chọn ở đây được lồng vào trong, không phải từ việc thụt đầu dòng, mà từ vị trí đặt dấu ( và ) xung quanh chúng.

Câu lệnh catch bên trong chỉ bắt lỗi từ doSomethingOptional() và doSomethingExtraNice(), sau đó nó sẽ tiếp tục với moreCriticalStuff(). Điều quan trọng là nếu doSomethingCritical() thất bại, lỗi của nó chỉ bị bắt bởi catch cuối cùng (bên ngoài).

Một số sai lầm phổ biến

Dưới đây là một số lỗi phổ biến cần chú ý khi sử dụng chuỗi promise. Một số trong những sai lầm này biểu hiện trong ví dụ sau:

// Một ví dụ có đến 3 sai lầm!
doSomething().then(function(result) {
  doSomethingElse(result) // Quên trả về promise từ chuỗi lồng bên trong + lồng nhau không cần thiết
  .then(newResult => doThirdThing(newResult));
}).then(() => doFourthThing());
// Quên kết thúc chuỗi bằng một hàm catch!

Sai lầm đầu tiên là không kết nối mọi thứ với nhau đúng cách. Điều này xảy ra khi chúng ta tạo ra một promise mới nhưng quên trả lại. Hậu quả là chuỗi bị hỏng, hay đúng hơn, chúng ta có hai chuỗi độc lập cùng chạy. Có nghĩa là doFourthThing() sẽ không đợi doSomethingElse() hoặc doThirdThing() kết thúc và sẽ chạy song song với chúng, có khả năng ngoài ý muốn. Các chuỗi riêng biệt cũng sẽ xử lý lỗi riêng biệt, dẫn đến khả năng lỗi không được xử lý.

Sai lầm thứ hai là làm lồng nhau không cần thiết, cho phép sai lầm đầu tiên. Lồng nhau cũng giới hạn phạm vi của các trình xử lý lỗi bên trong, điều mà nếu không cố ý thì có thể dẫn đến các lỗi chưa được xử lý. Một biến thể của điều này là​ ​​​​​​promise constructor anti-pattern, kết hợp lồng với việc sử dụng dự phòng của promise constructor để bọc mã đã sử dụng promise.

Sai lầm thứ ba là quên chấm dứt chuỗi với catch. Chuỗi promise không kết thúc bằng catch, khi bị reject sẽ gây ra lỗi "uncaught promise rejection" trong hầu hết các trình duyệt.

Một nguyên tắc tốt là luôn luôn trả lại hoặc chấm dứt chuỗi promise, và ngay khi bạn nhận được một promise mới, hãy trả lại ngay lập tức:

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

Lưu ý rằng () => x được rút gọn từ () => { return x; }.

Bây giờ chúng ta có một chuỗi xác định duy nhất với xử lý lỗi thích hợp.

Sử dụng async/await sẽ khắc phục hầu hết, nếu không muốn nói là tất cả các vấn đề trên đây — với một hạn chế cũng là một lỗi thường gặp với cú pháp này đó là việc quên từ khoá await.

Xem thêm

Document Tags and Contributors

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