JavaScript Promises: giới thiệu

Promise giúp đơn giản hoá các phép tính bị hoãn lại và không đồng bộ. Promise đạ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]

Các promise đã có trong JavaScript!

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

Tại thời điểm này, bạn sẽ 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 về lý do của tất cả những sự ồn ào này. Có lẽ bạn thậm chí không chắc "lời hứa" là gì. Bạn sẽ nhún vai, nhưng sức nặng của tờ giấy lấp lánh đang đè lên vai bạn. Nếu có, đừng lo lắng, tôi đã mất rất nhiều thời gian để tìm ra lý do tại sao tôi nên quan tâm đến những thứ này. Bạn nên bắt đầu từ đầu.
  • Bạn đấm vào không khí! Đã đến lúc rồi phải không? Bạn đã sử dụng những đối tượng Promise này trước đây nhưng bạn lo ngại rằng tất cả các triển khai đều có một API hơi khác một chút. 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 nhảy xuống như thể đây là tin mới đối với họ. Hãy dành chút thời gian để tận hưởng cảm giác 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

Browser Support

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

Source

Để đưa các trình duyệt thiếu một quy trình triển khai hoàn chỉnh về lời hứa lên đế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 và Node.js khác, hãy xem polyfill (2k nén gzip).

Có chuyện gì mà ồn ào vậy?

JavaScript là đơn luồng, nghĩa là 2 đoạn mã không thể chạy cùng lúc; chúng phải chạy lần lượt. Trong các trình duyệt, JavaScript dùng chung một luồng với nhiều nội dung khác nhau tuỳ theo từng trình duyệt. Nhưng thông thường, JavaScript nằm trong cùng hàng đợi với việc vẽ, cập nhật kiểu và xử lý các 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 chế độ kiểm soát biểu mẫu). Hoạt động trong một trong những thứ này sẽ làm chậm các hoạt động khác.

Là một con người, bạn có khả năng đa nhiệm. Bạn có thể nhập bằng nhiều ngón tay, lái xe và trò chuyện cùng một lúc. Chức năng 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 dừng trong thời gian hắt hơi. Điều đó khá khó chịu, đặc biệt là khi bạn đang lái xe và cố gắng trò chuyện. Bạn không muố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
});

Không hề hắt hơi. Chúng ta sẽ 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 theo dõi chúng, 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" 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 bắt được những hình ảnh bị lỗi trước khi chúng ta có cơ hội lắng nghe chúng; rất tiếc là DOM không cho chúng ta cách thực hiện việc đó. Ngoài ra, thao tác này đang tải một hình ảnh. Mọi thứ sẽ trở nên phức tạp hơn nếu chúng ta muốn biết thời điểm một nhóm hình ảnh đã tải.

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

Các sự kiện rất phù hợp cho những 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 bạn đính kèm trình nghe. Nhưng khi nói đến thành công/thất bại không đồng bộ, lý tưởng nhất là bạn muốn có một thứ 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ì promise thực hiện, nhưng với tên gọi hay hơn. Nếu các phần tử hình ảnh HTML có phương thức "ready" (sẵn sàng) trả về một lời hứa, thì chúng ta có thể làm 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, promise có phần giống với trình nghe sự kiện, ngoại trừ:

  • Một promise chỉ có thể thành công hoặc không thành công một lần. Thao tác này không thể thành công hoặc không thành công hai lần, cũng như 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 promise đã thành công hoặc không thành công và sau đó bạn thêm một 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, ngay cả khi sự kiện diễn ra trước đó.

Điều này cực kỳ hữu ích cho thành công/thất bại không đồng bộ, vì bạn ít quan tâm đến thời điểm chính xác mà một thứ gì đó có sẵ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 nháp đầu tiên của bài viết này và chấm cho tôi điểm "F" về thuật ngữ. Ông ấy phạt tôi ở lại trường, bắt tôi chép States and Fates 100 lần và viết một bức thư lo lắng cho cha mẹ tôi. Mặc dù vậy, tôi vẫn nhầm lẫn rất nhiều thuật ngữ, nhưng đây là những điều cơ bản:

Một promise có thể là:

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

Quy cách cũng sử dụng thuật ngữ thenable để mô tả một đối tượng giống như promise, tức là đố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 Anh Terry Venables nên tôi sẽ hạn chế sử dụng thuật ngữ này.

Các promise đã có trong JavaScript!

Các promise đã xuất hiện một thời gian dưới dạng thư viện, chẳng hạn như:

Các promise ở trên và promise JavaScript có một hành vi chung, được tiêu chuẩn hoá gọi là Promises/A+. Nếu bạn là người dùng jQuery, thì jQuery có một tính năng tương tự gọi là Deferreds. Tuy nhiên, Deferred không tuân thủ Promise/A+, điều này khiến chúng khác biệt một cách tinh tế và ít hữu ích hơn, vì vậy hãy thận trọng. 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 các vấn đề.

Mặc dù các quy trình triển khai promise tuân theo một hành vi chuẩn hoá, nhưng API tổng thể của chúng lại khác nhau. Các promise trong JavaScript có API tương tự như RSVP.js. Sau đây là cách tạo một promise:

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 promise nhận một đối số, một lệnh gọi lại có 2 tham số, resolve và reject. Thực hiện một thao tác trong lệnh gọi lại, có thể là không đồng bộ, sau đó gọi resolve nếu mọi thứ đều hoạt động, nếu không, hãy gọi reject.

Giống như throw trong JavaScript thông thường, bạn có thể từ chối bằng một đối tượng Lỗi, nhưng 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 hữu ích hơn.

Sau đây là cách bạn 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 một lệnh gọi lại cho trường hợp thành công hoặc thất bại.

Các promise trong JavaScript bắt đầu trong DOM dưới dạng "Futures", được đổi tên thành "Promises" và cuối cùng được chuyển vào JavaScript. Việc có các API này trong JavaScript thay vì DOM là điều tuyệt vời vì chúng sẽ có sẵn trong các bối cảnh JS không phải trình duyệt, chẳng hạn như Node.js (việc chúng có sử dụng các API này 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 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ả các API DOM mới có phương thức thành công/thất bại không đồng bộ sẽ sử dụng các promise. Đ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 nội dung 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à giống như lời hứa (hoặc thenable trong ngôn ngữ lời hứa sigh), vì vậy, nếu bạn sử dụng một thư viện trả về lời hứa Q, thì điều đó không sao cả, thư viện đó sẽ hoạt động tốt với lời hứa JavaScript mới.

Mặc dù, như tôi đã đề cập, Deferred của jQuery có phần… không hữu ích. Rất may là bạn có thể truyền các đối tượng này đến các promise 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 sẽ trả về một Deferred. Vì có phương thức then(), Promise.resolve() có thể chuyển đổi thành một promise JavaScript. Tuy nhiên, đôi khi các deferred sẽ truyền nhiều đối số đến các lệnh gọi lại của chúng, ví dụ:

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

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

Trong khi đó, các promise JS sẽ bỏ qua tất cả trừ promise đầu tiên:

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

Rất may là đâ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 những gì bạn muốn. Ngoài ra, hãy lưu ý rằng jQuery không tuân theo quy ước truyền các đối tượng Lỗi vào các lệnh từ chối.

Đơn giản hoá mã không đồng bộ phức tạp

Được rồi, hãy viết một số mã. 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 con quay

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

Tất nhiên, bạn sẽ không dùng JavaScript để phân phối một câu chuyện, 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 việc gì đó khi tất cả đã hoàn tất.

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

Promisifying XMLHttpRequest

Các API cũ sẽ được cập nhật để sử dụng các promise, nếu có thể theo cách tương thích ngược. XMLHttpRequest là một lựa chọn phù hợp, nhưng trong thời gian chờ đợi, hãy viết một hàm đơn giản để thực hiện 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 nó:

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

Giờ đây, chúng ta có thể thực hiện 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 rất tuyệt vì tôi càng ít phải nhìn thấy cách viết hoa chữ cái đầu của từ thứ hai trở đi trong XMLHttpRequest thì cuộc sống của tôi sẽ càng hạnh phúc.

Xâu chuỗi

then() không phải là điểm kết thúc, bạn có thể xâu chuỗ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ể chuyển đổi 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 hiện tại chúng ta đang nhận được phản hồi này 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 cũng có thể giải quyết vấn đề này trong vùng đất của các promise:

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

JSON.parse() chỉ nhận một đối số 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 hàm getJSON() một cách rất 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.

Xếp hàng các thao tác không đồng bộ

Bạn cũng có thể liên kết các then để chạy các thao tác không đồng bộ theo trình tự.

Khi bạn trả về một nội dung nào đó từ lệnh gọi lại then(), đó là một điều 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 thứ gì đó giống như lời hứa, thì then() tiếp theo sẽ đợi lời hứa đó và chỉ được gọi khi lời hứa đó được thực hiện (thành công/thất bại). Ví dụ:

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

Ở đây, chúng ta đưa ra 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 các URL để yêu cầu, sau đó chúng ta yêu cầu URL đầu tiên trong số đó. Đây là lúc các promise 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 rút gọn để lấy các chương:

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ẽ 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 cho thành công, một cho thất bại (hoặc hoàn thành và từ chối, theo ngôn ngữ của promise):

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

Bạn cũng có thể 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à đường cú pháp cho then(undefined, func), nhưng dễ đọc hơn. Xin lưu ý rằng 2 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 này tuy nhỏ nhưng cực kỳ hữu ích. Các lệnh từ chối lời hứa sẽ chuyển tiếp đến then() tiếp theo bằng một 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ờ cả hai. Nhưng với then(func1).catch(func2), cả hai sẽ được gọi nếu func1 từ chối, vì chúng là các bước riêng biệt trong chuỗi. Hãy làm theo các bước 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 quy trình thử/bắt JavaScript thông thường, các lỗi xảy ra trong "thử" sẽ chuyển ngay đến khối catch(). Dưới đây là nội dung trên dưới dạng sơ đồ quy trình (vì tôi rất thích sơ đồ quy trình):

Làm theo các đường màu xanh dương cho những promise thực hiện hoặc màu đỏ cho những promise từ chối.

Ngoại lệ và lời hứa của JavaScript

Lỗi từ chối xảy ra khi một lời hứa bị từ chối rõ ràng, nhưng cũng ngầm nếu một lỗi được đưa ra trong lệnh gọi lại của 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 trong lệnh gọi lại của 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 các lỗi từ chối.

Điều này cũng áp dụng cho các lỗi xảy ra 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 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 quá trình tìm nạp story.chapterUrls[0] không thành công (ví dụ: http 500 hoặc người dùng không có mạng), thì quá trình này sẽ bỏ qua tất cả các lệnh gọi lại thành công tiếp theo, 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), đồng thời 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 phân cảnh" sẽ xuất hiện trên trang nếu có bất kỳ thao tác nào trước đó không thực hiện được.

Giống như try/catch của JavaScript, lỗi này 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. Ở trên trở thành phiên bản không chặn không đồng bộ 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ể muốn catch() chỉ cho mục đích ghi nhật ký mà không cần khôi phục từ lỗi. Để thực hiện việc này, chỉ cần truyền 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;
  });
}

Vì vậy, 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 nhau thực hiện điều đó.

Tính song song và trình tự: khai thác tối đa cả hai

Tư duy không đồng bộ không hề dễ dàng. Nếu bạn gặp khó khăn khi bắt đầu, hãy thử viết mã như thể đó là mã đồ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 đó hiệu quả! Nhưng quá trình 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 thao tác này một cách không đồng bộ, chúng ta sẽ dùng then() để các thao tác diễn ra lần lượ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 để chúng ta có thể lặp qua các URL của chương và tìm nạp chúng theo thứ tự? Cách này không hiệu quả:

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 trạng thái không đồng bộ, vì vậy các chương của chúng ta sẽ xuất hiện theo bất kỳ thứ tự nào mà chúng tải xuống, về cơ bản là cách Pulp Fiction được viết. Đây không phải là Pulp Fiction, vì vậy hãy khắc phục vấn đề này.

Tạo chuỗi

Chúng ta muốn chuyển mảng chapterUrls thành một chuỗi các promise. Chúng ta có thể làm 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 promise (lời hứa) phân giải thành bất kỳ giá trị nào bạn cung cấp. Nếu bạn truyền cho nó một thực thể Promise, nó sẽ chỉ trả về thực thể đó (lưu ý: đây là một thay đổi đối với quy cách mà một số phương thức triển khai chưa tuân theo). Nếu bạn truyền cho nó một đối tượng tương tự như promise (có phương thức then()), thì đối tượng này sẽ tạo ra một Promise thực sự đáp ứng/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'), nó tạo ra một lời hứa sẽ thực hiện với giá trị đó. Nếu bạn gọi hàm này mà không có giá trị, như trên, hàm sẽ thực hiện với giá trị "undefined".

Ngoài ra còn có Promise.reject(val), 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ể sắp xế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())

Thao tác này giống như ví dụ trước, nhưng không cần biến "sequence" riêng biệt. Lệnh gọi lại reduce được gọi cho từng 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 cứ giá trị nào chúng ta trả về từ lệnh gọi trước đó. array.reduce thực sự 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 promise.

Hãy tổng hợp tất cả thông tin:

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ó một phiên bản hoàn toàn không đồng bộ của phiên bản đồng bộ. 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:

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

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

Promise.all nhận một mảng các promise và tạo một promise hoàn thành khi tất cả các promise đó 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 cùng một thứ tự như những lời hứa mà 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 việc tải từng mục một và ít mã hơn so với lần thử đầu tiên của chúng ta. Các phân cảnh có thể tải xuống theo bất kỳ thứ tự nào, nhưng chú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ảm nhận. Khi chương 1 xuất hiệ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 xuất hiện. Khi phần 3 xuất hiện, chúng tôi sẽ không thêm phần này vào trang vì người dùng có thể không nhận ra phần 2 bị thiếu. Khi chương 2 ra mắt, chúng ta có thể thêm chương 2 và 3, 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 chuỗi để 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à thế là xong, bạn có được cả hai! Việc 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 sẽ nhận được phần nội dung đầu tiên sớm hơn.

Trong ví dụ đơn giản này, tất cả các chương đều xuất hiện cùng 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 những thao tác trên bằng lệnh gọi lại hoặc sự kiện theo kiểu Node.js sẽ tốn gấp đôi mã, nhưng quan trọng hơn là không dễ thực hiện. Tuy nhiên, đây không phải là kết thúc của câu chuyện về các promise, khi kết hợp với các tính năng khác của ES6, chúng sẽ trở nên dễ dàng hơn.

Vòng bổ sung: mở rộng các 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 đáng kể. Kể từ Chrome 55, các hàm không đồng bộ đã cho phép viết mã dựa trên promise như thể mã đó là đồng bộ, nhưng không chặn luồng chính. Bạn có thể đọc thêm về vấn đề này trong bài viết về các hàm bất đồng bộ của tôi. Các trình duyệt chính đều hỗ trợ rộng rãi cả Promise và 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 bất đồng bộ của MDN.

Xin chân thành cảm ơn Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans và Yutaka Hirano đã đọc và đưa ra các đề xuất/chỉnh sửa cho tài liệu này.

Ngoài ra, xin cảm ơn Mathias Bynens vì đã cập nhật nhiều phần trong bài viết này.