Lời hứa JavaScript: Giới thiệu

Lời hứa đơ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 web, hãy chuẩn bị cho một thời điểm then chốt trong lịch sử phát triển web.

[Tiếng trống dồn dập]

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 thuộc một trong các danh mục sau:

  • Mọi người xung quanh bạn đang hò reo, nhưng bạn không biết họ đang hò reo vì điều gì. Có thể bạn thậm chí còn không chắc "lời hứa" là gì. Bạn nhún vai, nhưng sức nặng của tờ giấy lấp lánh đang đè nặng lên vai bạn. Nếu vậy, đừng lo lắng, tôi đã mất rất nhiều thời gian để tìm hiểu lý do tại sao tôi nên quan tâm đến vấn đề 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 đã từng sử dụng Promise nhưng bạn cảm thấy phiền vì tất cả các cách triển khai đều có API hơi khác nhau. API cho phiên bản JavaScript chính thức là gì? Bạn nên bắt đầu bằng việc tìm hiểu thuật ngữ.
  • Bạn đã biết điều này và bạn chế giễu những người đang nhảy cẫng lên như thể đây là tin mới đối với họ. Hãy dành chút thời gian để tận hưởng thành quả của mình, 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 triển khai lời hứa hoàn chỉnh vào tuân thủ thông số kỹ thuật 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 gzipped).

Có chuyện gì mà ồn ào thế?

JavaScript là đơn luồng, nghĩa là hai đoạn tập lệnh không thể chạy cùng một lúc; chúng 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, tuỳ theo trình duyệt. Tuy nhiên, JavaScript thường nằm trong cùng một hàng đợi với việc vẽ, cập nhật kiểu và xử lý hành động 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 việc này sẽ trì hoãn những việc khác.

Là con người, bạn có thể làm nhiều việc cùng lúc. Bạn có thể gõ bằng nhiều ngón tay, bạn có thể vừa lái xe vừa trò chuyện. 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, nhấ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ã dễ gây hắt hơ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
});

Cái này không hắt hơi chút nào. 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 các 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, 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
});

Cách 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 lắng nghe; rất tiếc là DOM không cung cấp cho chúng ta cách để làm việc đó. Ngoài ra, đây là đang tải một hình ảnh. Mọi thứ thậm chí cò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 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 cho những việc có thể xảy ra nhiều lần trên cùng một đối tượng, chẳng hạn như 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 một cái gì đó như thế này:

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

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

Đây là chức năng của lời hứa, nhưng có cách đặt tên hay 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ể 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, 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 thất bại một lần. Trạng thái 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 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, ngay cả khi 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 gian chính xác mà một nội dung nào đó trở nên có sẵn, mà 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 hẹn

Domenic Denicola đã đọc bản nháp đầu tiên của bài viết này và chấm điểm "F" cho tôi về thuật ngữ. Thầy phạt tôi ở lại trường, bắt tôi chép lại States and Fates 100 lần, và viết một bức thư lo lắng cho bố mẹ tôi. Dù vậy, tôi vẫn nhầm lẫn nhiều thuật ngữ, nhưng sau đây là những thuật ngữ cơ bản:

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

  • fulfilled (đã thực hiện) – Hành động liên quan đến lời hứa đã thành công
  • rejected – Hành động liên quan đến lời hứa không thành công
  • đang chờ xử lý – Chưa thực hiện hoặc chưa từ chối
  • settled – Đã thực hiện hoặc bị 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 này 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 đá người Anh Terry Venables, nên tôi sẽ hạn chế sử dụng thuật ngữ này.

Lời hứa hẹn đã đến trong JavaScript!

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

Các 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, thì họ có một tính năng tương tự gọi là Deferreds. Tuy nhiên, Deferreds không tuân thủ Promise/A+, 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 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ù các phương thứ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 phương thức này lại khác nhau. JavaScript Promise có API tương tự như RSVP.js. Sau đây là cách tạo 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 số 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 việc đều ổn, nếu không thì gọi reject.

Giống như throw trong JavaScript cũ, việc từ chối bằng đối tượng Error là thông lệ nhưng không bắt buộc. Lợi ích của đối tượng Error 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 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 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", được đổi tên thành "Promises" và cuối cùng được chuyển sang JavaScript. Việc có các API này trong JavaScript thay vì DOM là một đ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ại là một câu hỏi khác).

Mặc dù chúng là một tính năng của JavaScript, nhưng DOM không ngại sử dụng chúng. 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à giống như lời hứa (hoặc thenable trong 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ì 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, Deferreds của jQuery có một chút… không hữu ích. Rất may là bạn có thể truyền chúng đến các lời hứa tiêu chuẩn, điều này đáng làm 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 đối tượng trì hoãn 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 đó, lời hứa JS bỏ qua tất cả trừ lời hứa đầ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 nội dung bạn muốn. Ngoài ra, hãy lưu ý rằng jQuery không tuân theo quy ước chuyển đối tượng Error vào các lệnh từ chối.

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

Được rồi, hãy cùng 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 mỗi 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ó sự cố xảy ra trong quá trình này. Chúng ta cũng sẽ muố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, 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 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ý API: Nhiều dữ liệu tìm nạp, sau đó làm điều 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 lời hứa, 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 để 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 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ì tôi càng ít phải nhìn thấy kiểu viết lạc đà khó chịu của XMLHttpRequest thì cuộc sống của tôi càng hạnh phúc.

Xâu chuỗi

then() không phải là điểm kết thúc của câu chuyện, bạn có thể liên kết các then với nhau để biế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 túy. Chúng ta có thể thay đổi hàm get để sử dụng responseType JSON, nhưng cũng có thể giải quyết vấn đề này trong phạm vi lời hứa:

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

Vì JSON.parse() nhận một đối số duy nhất và trả về một giá trị đã biế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 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 URL rồi phân tích cú pháp phản hồi dưới dạng JSON.

Đưa các hành động không đồng bộ vào hàng đợi

Bạn cũng có thể liên kết then để chạy các hành động 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 có một chút kỳ diệu. Nếu bạn trả về một giá trị, thì 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 tôi tạo một yêu cầu không đồng bộ cho story.json. Yêu cầu này cung cấp cho chúng tôi một tập hợp URL để yêu cầu, sau đó chúng tôi 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 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(những) 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. Lời hứa tuyệt vời!

Xử lý lỗi

Như chúng ta đã thấy, then() nhận hai đối số, một đối số cho trường hợp thành công, 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 của 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);
})

Không có gì đặc biệt về catch(), đây chỉ là cú pháp thay thế cho then(undefined, func), nhưng dễ đọc hơn. Xin 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à rất 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 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ờ gọi 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 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ẽ ngay lập tức chuyển đến khối catch(). Dưới đây là sơ đồ quy trình cho các bước 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 các lời hứa thực hiện hoặc đường màu đỏ cho các lời hứa từ chối.

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

Việc 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 ngầm định 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ệnh 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 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 việc 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ì lệnh gọi lại này sẽ bỏ qua tất 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, đồng thời bỏ qua lệnh gọi lại thêm chapter1.html vào trang. Thay vào đó, hệ thống sẽ chuyển sang lệnh gọi lại catch. Do đó, "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 đượ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, đây là điều chúng ta muốn. Đoạn mã trên trở thành phiên bản không đồng bộ không 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ể muốn catch() chỉ cho mục đích ghi nhật ký mà không cần khôi phục từ lỗi. Để làm 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;
  });
}

Vậy là 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ả. Hãy biến điều đó thành hiện thực.

Xử lý song song và tuần tự: Kết hợp cả hai để đạt hiệu quả cao nhất

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 đây là phương thức đồng bộ và khoá trình duyệt trong khi tải nội dung xuống. Để thực hiện công việc này không đồng bộ, chúng ta sử dụng then() để mọi việ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 để lặp qua các URL của chương và tìm nạp chúng theo thứ tự? 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 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 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, nên hãy khắc phục vấn đề này.

Đang tạo trình tự

Chúng ta muốn biến mảng chapterUrls thành một chuỗi hứa hẹn. 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(), tạo ra một lời hứa sẽ phân giải thành bất kỳ giá trị nào bạn cung cấp cho nó. Nếu bạn truyền một thực thể của Promise, thì thực thể đó 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 cho nó một thứ giống như lời hứa (có phương thức then()), thì nó sẽ tạo ra một Promise thực sự đáp ứng/từ chối theo cùng một cách. Nếu bạn truyền vào bất kỳ giá trị nào khác, ví dụ: Promise.resolve('Hello'), thì 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ị, như trên, thì hàm này sẽ thực hiện với "undefined".

Ngoài ra, còn có Promise.reject(val), tạo ra một lời hứa từ chối với giá trị 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())

Thao tác này cũng 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 kỳ 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 thu 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ó 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:

Trình duyệt khá giỏi trong việc 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 từng chương một. Điều 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ả đã được tải xuống. 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 lời hứa và tạo một lời hứa sẽ 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 cùng thứ tự với 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 tệp một, và ít mã hơn so với lần thử đầu tiên. Các chương 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 được. Khi chương một đế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 được tải xong. Khi chương ba ra mắt, chúng tôi 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 ra mắt, chúng ta có thể thêm chương hai và chương ba, v.v.

Để làm việc này, chúng ta 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à chúng ta đã có cả hai! Mất cùng một khoảng thời gian để phân phối tất cả nội dung, nhưng người dùng sẽ nhận được 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 một lúc, nhưng lợi ích của việc hiển thị từng chương một sẽ được thể hiện rõ hơn khi có nhiều chương lớn hơn.

Việc thực hiện những điều trên bằng các 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 không 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 chúng thậm chí còn dễ dàng hơn.

Vòng thưởng: Các tính năng mở rộ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ã đó là đồ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ả Promise và hàm không đồng bộ đều được hỗ trợ rộng rãi trong các trình duyệt chính. Bạn có thể xem thông tin chi tiết trong tài liệu tham khảo về Promise và hàm không đồ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 đã hiệu đính và đưa ra các nội dung chỉnh sửa/đề xuất.

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