JavaScript Promises: giới thiệu

Lời hứa giúp đơn giản hoá các phép tính trì hoãn và không đồng bộ. Lời hứa đại diện cho một thao tác chưa hoàn tất.

Jake Archibald
Jake Archibald

Các nhà phát triển, hãy chuẩn bị cho một thời điểm quan trọng trong lịch sử phát triển web.

[Drumroll begins]

Lời hứa đã xuất hiện trong JavaScript!

[Pháo hoa nổ, giấy lấp lánh rơi xuống từ trên cao, đám đông phấn khích]

Tại thời điểm này, bạn thuộc một trong các danh mục sau:

  • Mọi người đang cổ vũ xung quanh bạn, nhưng bạn không chắc chắn điều gì đang diễn ra. Có thể bạn thậm chí không chắc "lời hứa" là gì. Bạn sẽ nhún vai, nhưng trọng lượng của tờ giấy lấp lánh đang đè nặng lên vai bạn. Nếu có, đừng bận tâm, tôi đã mất nhiều thời gian để tìm hiểu lý do tại sao mình nên quan tâm đến nội dung này. Bạn nên bắt đầu từ đầu.
  • Bạn đấm vào không khí! Đã đến lúc phải làm việc rồi phải không? Bạn đã từng sử dụng các tính năng Promise này nhưng điều khiến bạn phiền lòng là tất cả các phương thức triển khai đều có một API hơi khác. API cho phiên bản JavaScript chính thức là gì? Bạn nên bắt đầu bằng thuật ngữ.
  • Bạn đã biết điều này và bạn chế giễu những người đang nhảy lên xuống như thể đó là tin tức mới đối với họ. Hãy dành chút thời gian để tận hưởng sự vượt trội của riêng bạn, sau đó chuyển thẳng đến tài liệu tham khảo API.

Hỗ trợ trình duyệt và polyfill

Hỗ trợ trình duyệt

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

Nguồn

Để đưa các trình duyệt thiếu tính năng triển khai lời hứa hoàn chỉnh lên mức tuân thủ quy cách hoặc thêm lời hứa vào các trình duyệt khác và Node.js, hãy xem polyfill (2k nén bằng gzip).

Có gì to tát đâu?

JavaScript là một luồng đơn, nghĩa là hai bit tập lệnh không thể chạy cùng lúc; các bit này phải chạy lần lượt. Trong trình duyệt, JavaScript chia sẻ một luồng với nhiều nội dung khác nhau tuỳ theo trình duyệt. Tuy nhiên, thường thì JavaScript nằm trong cùng hàng đợi với hoạt động vẽ, cập nhật kiểu và xử lý thao tác của người dùng (chẳng hạn như làm nổi bật văn bản và tương tác với các thành phần điều khiển biểu mẫu). Hoạt động trong một trong những hoạt động này sẽ làm chậm các hoạt động khác.

Là con người, bạn có nhiều luồng. Bạn có thể nhập bằng nhiều ngón tay, bạn có thể lái xe và trò chuyện cùng lúc. Hàm chặn duy nhất mà chúng ta phải xử lý là hắt hơi, trong đó tất cả hoạt động hiện tại phải bị tạm ngưng trong thời gian hắt hơi. Điều đó khá phiền phức, đặc biệt là khi bạn đang lái xe và cố gắng trò chuyện. Bạn không nên viết mã có nhiều lỗi.

Có thể bạn đã sử dụng các sự kiện và lệnh gọi lại để giải quyết vấn đề này. Sau đây là các sự kiện:

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

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

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

Đây không phải là một vấn đề đáng lo ngại. Chúng ta lấy hình ảnh, thêm một vài trình nghe, sau đó JavaScript có thể ngừng thực thi cho đến khi một trong những trình nghe đó được gọi.

Rất tiếc, trong ví dụ trên, có thể các sự kiện đã xảy ra trước khi chúng ta bắt đầu nghe các sự kiện đó, vì vậy, chúng ta cần giải quyết vấn đề đó bằng cách sử dụng thuộc tính "complete" (hoàn tất) của hình ảnh:

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

Điều này không phát hiện được những hình ảnh bị lỗi trước khi chúng ta có cơ hội nghe; rất tiếc là DOM không cho phép chúng ta làm như vậy. Ngoài ra, đây là quá trình tải một hình ảnh. Mọi thứ sẽ trở nên phức tạp hơn nữa nếu chúng ta muốn biết thời điểm một nhóm hình ảnh tải xong.

Sự kiện không phải lúc nào cũng là cách tốt nhất

Sự kiện rất phù hợp với những sự việc có thể xảy ra nhiều lần trên cùng một đối tượng – keyup, touchstart, v.v. Với những sự kiện đó, bạn không thực sự quan tâm đến những gì đã xảy ra trước khi đính kèm trình nghe. Nhưng khi nói đến thành công/thất bại không đồng bộ, tốt nhất bạn nên có nội dung như sau:

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

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

Đây là những gì mà promise thực hiện, nhưng với cách đặt tên tốt hơn. Nếu các phần tử hình ảnh HTML có phương thức "ready" trả về một lời hứa, chúng ta có thể thực hiện như sau:

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

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

Về cơ bản, lời hứa có một chút giống với trình nghe sự kiện, ngoại trừ:

  • Một lời hứa chỉ có thể thành công hoặc không thành công một lần. Hoạt động này không thể thành công hoặc không thành công hai lần, cũng không thể chuyển từ thành công sang không thành công hoặc ngược lại.
  • Nếu một lời hứa đã thành công hoặc không thành công và sau đó bạn thêm lệnh gọi lại thành công/không thành công, thì lệnh gọi lại chính xác sẽ được gọi, mặc dù sự kiện đã diễn ra trước đó.

Điều này cực kỳ hữu ích cho việc thành công/không thành công không đồng bộ, vì bạn ít quan tâm đến thời điểm chính xác mà một nội dung nào đó xuất hiện và quan tâm nhiều hơn đến việc phản ứng với kết quả.

Thuật ngữ về lời hứa

Domenic Denicola đã đọc bản thảo đầu tiên của bài viết này và cho tôi điểm "F" về thuật ngữ. Ông ta cho tôi ngồi tù, buộc tôi chép Tiểu bang và số phận 100 lần và viết thư lo lắng cho cha mẹ tôi. Mặc dù vậy, tôi vẫn còn lẫn lộn nhiều thuật ngữ, nhưng sau đây là những kiến thức cơ bản:

Lời hứa có thể là:

  • đã thực hiện – Hành động liên quan đến lời hứa đã thành công
  • rejected – Không thực hiện được hành động liên quan đến lời hứa
  • đang chờ xử lý – Chưa thực hiện hoặc từ chối
  • đã xử lý – Đã thực hiện hoặc từ chối

Thông số kỹ thuật cũng sử dụng thuật ngữ thenable để mô tả một đối tượng giống như lời hứa, trong đó đối tượng có phương thức then. Thuật ngữ này khiến tôi nhớ đến cựu Huấn luyện viên bóng đá của đội tuyển Anh Terry Venables, vì vậy, tôi sẽ sử dụng thuật ngữ này ít nhất có thể.

Lời hứa đã xuất hiện trong JavaScript!

Lời hứa đã xuất hiện trong một thời gian dưới dạng thư viện, chẳng hạn như:

Lời hứa ở trên và lời hứa JavaScript có chung một hành vi chuẩn hoá được gọi là Promises/A+. Nếu bạn là người dùng jQuery, họ có một tính năng tương tự được gọi là Deferreds. Tuy nhiên, Deferred không tuân thủ Promise/A+, khiến chúng khác biệt một chút và ít hữu ích hơn, vì vậy hãy cẩn thận. jQuery cũng có một loại Promise, nhưng đây chỉ là một tập hợp con của Deferred và có cùng vấn đề.

Mặc dù việc triển khai lời hứa tuân theo một hành vi chuẩn hoá, nhưng API tổng thể của các lời hứa này lại khác nhau. Lời hứa JavaScript tương tự như RSVP.js trong API. Dưới đây là cách tạo một lời hứa:

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

Hàm khởi tạo lời hứa nhận một đối số, một lệnh gọi lại có hai tham số, giải quyết và từ chối. Thực hiện một thao tác nào đó trong lệnh gọi lại, có thể là không đồng bộ, sau đó gọi giải quyết nếu mọi thứ hoạt động, nếu không thì gọi từ chối.

Giống như throw trong JavaScript cũ, bạn có thể từ chối bằng đối tượng Lỗi (không bắt buộc). Lợi ích của các đối tượng Lỗi là chúng ghi lại dấu vết ngăn xếp, giúp các công cụ gỡ lỗi trở nên hữu ích hơn.

Sau đây là cách sử dụng lời hứa đó:

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

then() nhận hai đối số, một lệnh gọi lại cho trường hợp thành công và một lệnh gọi lại khác cho trường hợp không thành công. Cả hai đều không bắt buộc, vì vậy, bạn chỉ có thể thêm lệnh gọi lại cho trường hợp thành công hoặc không thành công.

Lời hứa JavaScript bắt đầu trong DOM dưới dạng "Futures" (Tương lai), được đổi tên thành "Promises" (Lời hứa) và cuối cùng được chuyển sang JavaScript. Việc có các lớp này trong JavaScript thay vì DOM là rất tuyệt vì các lớp này sẽ có trong các ngữ cảnh JS không phải trình duyệt như Node.js (liệu các lớp này có sử dụng các lớp đó trong API cốt lõi hay không là một câu hỏi khác).

Mặc dù là một tính năng của JavaScript, nhưng DOM không ngại sử dụng các tính năng này. Trên thực tế, tất cả API DOM mới có phương thức thành công/không thành công không đồng bộ sẽ sử dụng lời hứa. Điều này đã xảy ra với Quản lý hạn mức, Sự kiện tải phông chữ, ServiceWorker, Web MIDI, Luồng và nhiều tính năng khác.

Khả năng tương thích với các thư viện khác

API lời hứa JavaScript sẽ coi mọi thứ có phương thức then() là lời hứa (hoặc thenable trong lời hứa thở dài), vì vậy, nếu bạn sử dụng một thư viện trả về lời hứa Q, thì không sao, thư viện đó sẽ hoạt động tốt với lời hứa JavaScript mới.

Tuy nhiên, như tôi đã đề cập, các lệnh trì hoãn của jQuery hơi … không hữu ích. Rất may, bạn có thể truyền các lời hứa đó thành lời hứa tiêu chuẩn. Bạn nên làm việc này càng sớm càng tốt:

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

Ở đây, $.ajax của jQuery trả về một Deferred. Vì có phương thức then(), nên Promise.resolve() có thể biến phương thức này thành một lời hứa JavaScript. Tuy nhiên, đôi khi các lệnh trì hoãn sẽ truyền nhiều đối số đến lệnh gọi lại, ví dụ:

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

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

Trong khi đó, các lời hứa của JS bỏ qua tất cả ngoại trừ lời hứa đầu tiên:

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

Rất may, đây thường là điều bạn muốn hoặc ít nhất là cho phép bạn truy cập vào điều bạn muốn. Ngoài ra, hãy lưu ý rằng jQuery không tuân theo quy ước truyền đối tượng Lỗi vào các trường hợp từ chối.

Mã không đồng bộ phức tạp trở nên dễ dàng hơn

Được rồi, hãy lập trình một số thứ. Giả sử chúng ta muốn:

  1. Bắt đầu một vòng quay để cho biết đang tải
  2. Tìm nạp một số JSON cho một câu chuyện, cung cấp cho chúng ta tiêu đề và URL cho từng chương
  3. Thêm tiêu đề vào trang
  4. Tìm nạp từng chương
  5. Thêm câu chuyện vào trang
  6. Dừng vòng quay

… nhưng cũng cho người dùng biết nếu có vấn đề gì xảy ra trong quá trình này. Chúng ta cũng sẽ muốn dừng trình đơn vòng quay tại thời điểm đó, nếu không, trình đơn vòng quay sẽ tiếp tục quay, bị chóng mặt và va vào một số giao diện người dùng khác.

Tất nhiên, bạn sẽ không sử dụng JavaScript để phân phối tin bài, phân phát dưới dạng HTML sẽ nhanh hơn, nhưng mẫu này khá phổ biến khi xử lý các API: Nhiều lần tìm nạp dữ liệu, sau đó thực hiện một thao tác nào đó khi tất cả đều hoàn tất.

Để bắt đầu, hãy xử lý việc tìm nạp dữ liệu từ mạng:

Chuyển đổi XMLHttpRequest thành lời hứa

Các API cũ sẽ được cập nhật để sử dụng các lời hứa, nếu có thể theo cách tương thích ngược. XMLHttpRequest là một ứng cử viên chính, nhưng trong thời gian chờ đợi, hãy viết một hàm đơn giản để tạo yêu cầu 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();
 
});
}

Bây giờ, hãy sử dụng:

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

Giờ đây, chúng ta có thể tạo các yêu cầu HTTP mà không cần nhập XMLHttpRequest theo cách thủ công. Điều này thật tuyệt vời vì càng ít phải thấy kiểu viết hoa camel-casing gây khó chịu của XMLHttpRequest, cuộc sống của tôi càng hạnh phúc hơn.

Xâu chuỗi

then() không phải là kết thúc của câu chuyện, bạn có thể nối các then với nhau để chuyển đổi giá trị hoặc chạy các hành động không đồng bộ bổ sung lần lượt.

Biến đổi giá trị

Bạn có thể biến đổi các giá trị chỉ bằng cách trả về giá trị mới:

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

Để lấy ví dụ thực tế, hãy quay lại:

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

Phản hồi là JSON, nhưng chúng ta hiện đang nhận được phản hồi dưới dạng văn bản thuần tuý. Chúng ta có thể thay đổi hàm get để sử dụng JSON responseType, nhưng chúng ta cũng có thể giải quyết vấn đề này trong vùng đất hứa:

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

JSON.parse() nhận một đối số duy nhất và trả về một giá trị đã chuyển đổi, nên chúng ta có thể tạo một lối tắt:

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

Trên thực tế, chúng ta có thể tạo một hàm getJSON() một cách dễ dàng:

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

getJSON() vẫn trả về một lời hứa, một lời hứa tìm nạp một URL rồi phân tích cú pháp phản hồi dưới dạng JSON.

Đặt các thao tác không đồng bộ vào hàng đợi

Bạn cũng có thể tạo chuỗi then để chạy các thao tác không đồng bộ theo trình tự.

Khi bạn trả về một giá trị từ lệnh gọi lại then(), điều này sẽ có chút kỳ diệu. Nếu bạn trả về một giá trị, then() tiếp theo sẽ được gọi bằng giá trị đó. Tuy nhiên, nếu bạn trả về một giá trị giống như lời hứa, thì then() tiếp theo sẽ chờ giá trị đó và chỉ được gọi khi lời hứa đó được thực hiện (thành công/không thành công). Ví dụ:

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

Tại đây, chúng ta tạo một yêu cầu không đồng bộ đến story.json. Yêu cầu này cung cấp cho chúng ta một tập hợp URL để yêu cầu, sau đó chúng ta yêu cầu URL đầu tiên trong số đó. Đây là lúc lời hứa thực sự bắt đầu nổi bật so với các mẫu lệnh gọi lại đơn giản.

Bạn thậm chí có thể tạo một phương thức lối tắt để lấy các phân cảnh:

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

Chúng ta không tải story.json xuống cho đến khi getChapter được gọi, nhưng(các) lần tiếp theo getChapter được gọi, chúng ta sẽ sử dụng lại lời hứa về câu chuyện, vì vậy story.json chỉ được tìm nạp một lần. Yay Promises!

Xử lý lỗi

Như chúng ta đã thấy trước đó, then() nhận hai đối số, một đối số cho trường hợp thành công và một đối số cho trường hợp không thành công (hoặc thực hiện và từ chối, theo cách nói về lời hứa):

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

Bạn cũng có thể sử dụng catch():

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

catch() không có gì đặc biệt, chỉ là một lớp phủ cho then(undefined, func), nhưng dễ đọc hơn. Lưu ý rằng hai ví dụ về mã ở trên không hoạt động giống nhau, ví dụ sau tương đương với:

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

Sự khác biệt là khó thấy nhưng cực kỳ hữu ích. Lệnh từ chối Promise sẽ chuyển sang then() tiếp theo bằng lệnh gọi lại từ chối (hoặc catch(), vì lệnh này tương đương). Với then(func1, func2), func1 hoặc func2 sẽ được gọi, không bao giờ là cả hai. Nhưng với then(func1).catch(func2), cả hai sẽ được gọi nếu func1 từ chối, vì đây là các bước riêng biệt trong chuỗi. Hãy làm như sau:

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!");
})

Quy trình ở trên rất giống với try/catch JavaScript thông thường, các lỗi xảy ra trong "try" sẽ chuyển ngay đến khối catch(). Dưới đây là sơ đồ quy trình ở trên (vì tôi thích sơ đồ quy trình):

Hãy làm theo các đường màu xanh dương cho những lời hứa thực hiện được hoặc màu đỏ cho những lời hứa từ chối.

Trường hợp ngoại lệ và lời hứa JavaScript

Lỗi từ chối xảy ra khi một lời hứa bị từ chối một cách rõ ràng, nhưng cũng xảy ra ngầm ẩn nếu một lỗi được gửi trong lệnh gọi lại hàm khởi tạo:

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

Điều này có nghĩa là bạn nên thực hiện tất cả công việc liên quan đến lời hứa bên trong lệnh gọi lại hàm khởi tạo lời hứa, để các lỗi được tự động phát hiện và trở thành lỗi từ chối.

Điều này cũng áp dụng cho các lỗi được gửi trong lệnh gọi lại 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);
})

Xử lý lỗi trong thực tế

Với câu chuyện và các chương, chúng ta có thể sử dụng hàm catch để hiển thị lỗi cho người dùng:

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

Nếu không tìm nạp được story.chapterUrls[0] (ví dụ: http 500 hoặc người dùng không có mạng), thì thao tác này sẽ bỏ qua tất cả các lệnh gọi lại thành công sau đó, bao gồm cả lệnh gọi lại trong getJSON() cố gắng phân tích cú pháp phản hồi dưới dạng JSON và cũng bỏ qua lệnh gọi lại thêm chapter1.html vào trang. Thay vào đó, nó sẽ chuyển sang lệnh gọi lại catch. Do đó, thông báo "Không hiển thị được chương" sẽ được thêm vào trang nếu bất kỳ hành động nào trước đó không thành công.

Giống như try/catch của JavaScript, lỗi sẽ được phát hiện và mã tiếp theo sẽ tiếp tục, vì vậy, vòng quay luôn bị ẩn, đó là điều chúng ta muốn. Mã trên trở thành phiên bản không đồng bộ không bị chặn của:

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'

Bạn có thể catch() chỉ cho mục đích ghi nhật ký mà không cần khôi phục lỗi. Để thực hiện việc này, bạn chỉ cần gửi lại lỗi. Chúng ta có thể thực hiện việc này trong phương thức getJSON():

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

Chúng ta đã tìm nạp được một chương, nhưng chúng ta muốn tìm nạp tất cả các chương. Hãy cùng làm điều đó.

Tính song song và trình tự: tận dụng tối đa cả hai

Việc suy nghĩ về tính không đồng bộ không phải là dễ dàng. Nếu bạn đang gặp khó khăn khi bắt đầu, hãy thử viết mã như thể mã đó là đồng bộ. Trong trường hợp này:

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'

Cách này hiệu quả! Tuy nhiên, tính năng này sẽ đồng bộ hoá và khoá trình duyệt trong khi tải nội dung xuống. Để thực hiện việc này một cách không đồng bộ, chúng ta sử dụng then() để thực hiện từng việc một.

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

Nhưng làm cách nào để lặp lại các URL của chương và tìm nạp các URL đó theo thứ tự? Cách này không hoạt động:

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

forEach không nhận biết được chế độ không đồng bộ, vì vậy các chương của chúng ta sẽ xuất hiện theo thứ tự tải xuống bất kỳ, về cơ bản là cách viết Pulp Fiction. Đây không phải là phim Pulp Fiction, vì vậy, hãy khắc phục vấn đề này.

Tạo trình tự

Chúng ta muốn biến mảng chapterUrls thành một chuỗi các lời hứa. Chúng ta có thể thực hiện việc đó bằng cách sử dụng 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);
 
});
})

Đây là lần đầu tiên chúng ta thấy Promise.resolve(), hàm này tạo ra một lời hứa sẽ phân giải thành bất kỳ giá trị nào mà bạn cung cấp. Nếu bạn truyền vào một thực thể của Promise, thì phương thức này sẽ chỉ trả về thực thể đó (lưu ý: đây là một thay đổi đối với thông số kỹ thuật mà một số cách triển khai chưa tuân theo). Nếu bạn truyền vào một đối tượng có tính chất hứa hẹn (có phương thức then()), thì đối tượng này sẽ tạo ra một Promise thực sự thực hiện/từ chối theo cách tương tự. Nếu bạn truyền bất kỳ giá trị nào khác, ví dụ: Promise.resolve('Hello'), hàm này sẽ tạo một lời hứa thực hiện với giá trị đó. Nếu bạn gọi hàm này mà không có giá trị nào, như trên, hàm này sẽ thực hiện với giá trị "undefined" (không xác định).

Ngoài ra, còn có Promise.reject(val), hàm này tạo một lời hứa từ chối với giá trị mà bạn cung cấp (hoặc không xác định).

Chúng ta có thể dọn dẹp mã trên bằng cách sử dụng 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())

Cách này cũng giống như ví dụ trước, nhưng không cần biến "trình tự" riêng biệt. Lệnh gọi lại giảm được gọi cho mỗi mục trong mảng. "sequence" là Promise.resolve() trong lần đầu tiên, nhưng đối với các lệnh gọi còn lại, "sequence" là bất kỳ giá trị nào chúng ta trả về từ lệnh gọi trước đó. array.reduce rất hữu ích khi rút gọn một mảng thành một giá trị duy nhất, trong trường hợp này là một lời hứa.

Hãy cùng tổng hợp lại:

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

Vậy là chúng ta đã có phiên bản không đồng bộ hoàn toàn của phiên bản đồng bộ hoá. Nhưng chúng ta có thể làm tốt hơn. Hiện tại, trang của chúng ta đang tải xuống như sau:

Trình duyệt khá tốt trong việc tải nhiều nội dung cùng một lúc, vì vậy, chúng ta đang mất hiệu suất khi tải các chương xuống lần lượt. Việc chúng ta muốn làm là tải tất cả xuống cùng một lúc, sau đó xử lý khi tất cả đã đến. Rất may, có một API cho việc này:

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

Promise.all lấy một mảng các lời hứa và tạo một lời hứa thực hiện khi tất cả các lời hứa đó hoàn tất thành công. Bạn sẽ nhận được một mảng kết quả (bất kỳ lời hứa nào được thực hiện) theo thứ tự như các lời hứa bạn đã truyền vào.

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

Tuỳ thuộc vào kết nối, cách này có thể nhanh hơn vài giây so với cách tải từng ảnh một, đồng thời cần ít mã hơn so với lần thử đầu tiên. Các phân cảnh có thể tải xuống theo thứ tự bất kỳ, nhưng sẽ xuất hiện trên màn hình theo đúng thứ tự.

Tuy nhiên, chúng ta vẫn có thể cải thiện hiệu suất được cảm nhận. Khi chương 1 đến, chúng ta nên thêm chương đó vào trang. Điều này cho phép người dùng bắt đầu đọc trước khi các chương còn lại đến. Khi chương ba xuất hiện, chúng ta sẽ không thêm chương này vào trang vì người dùng có thể không nhận ra chương hai bị thiếu. Khi chương hai đến, chúng ta có thể thêm chương hai và ba, v.v.

Để thực hiện việc này, chúng ta sẽ tìm nạp JSON cho tất cả các chương cùng một lúc, sau đó tạo một trình tự để thêm các chương đó vào tài liệu:

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

Vậy là xong, bạn đã có được những gì tốt nhất của cả hai! Quá trình phân phối tất cả nội dung mất cùng một khoảng thời gian, nhưng người dùng nhận được phần nội dung đầu tiên sớm hơn.

Trong ví dụ nhỏ này, tất cả các chương đều đến cùng một lúc, nhưng lợi ích của việc hiển thị từng chương sẽ được phóng đại với nhiều chương lớn hơn.

Việc thực hiện các bước trên bằng lệnh gọi lại hoặc sự kiện kiểu Node.js sẽ tăng gấp đôi lượng mã, nhưng quan trọng hơn là không dễ theo dõi. Tuy nhiên, đây chưa phải là kết thúc của câu chuyện về lời hứa, khi kết hợp với các tính năng ES6 khác, lời hứa sẽ trở nên dễ dàng hơn nữa.

Vòng phụ: mở rộng chức năng

Kể từ khi tôi viết bài viết này, khả năng sử dụng Lời hứa đã mở rộng rất nhiều. Kể từ Chrome 55, các hàm không đồng bộ đã cho phép viết mã dựa trên lời hứa như thể mã đó đồng bộ, nhưng không chặn luồng chính. Bạn có thể đọc thêm về vấn đề đó trong bài viết về hàm không đồng bộ của tôi. Các trình duyệt chính hỗ trợ rộng rãi cả Promise và các hàm không đồng bộ. Bạn có thể xem thông tin chi tiết trong tài liệu tham khảo về Promisehàm không đồng bộ của MDN.

Cảm ơn Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans và Yutaka Hirano đã đọc lại bản thảo này và đưa ra các nội dung sửa đổi/đề xuất.

Ngoài ra, cảm ơn Mathias Bynens đã cập nhật nhiều phần của bài viết.