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ệnh hứa đã có 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 trường hợp 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 thật tuyệt vời! Đã đến lúc phải làm việc rồi phải không? Bạn đã từng sử dụng những lời Promise này trước đây, nhưng điều đáng lo ngại là tất cả các lượt triển khai đều có 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 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ó chuyện gì vậy?

JavaScript có luồng đơn, có nghĩa là hai bit tập lệnh không thể chạy cùng lúc; chúng phải chạy lần lượt từng bit. 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 việc 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 việc này sẽ làm chậm các hoạt động khác.

Là con người, bạn là người đa 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 một lúc. Chức năng chặn duy nhất mà chúng tôi phải xử lý là hắt hơi, trong đó mọi hoạt động hiện tại phải bị tạm ngưng trong khoảng thời gian hắt hơi. Điều này 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 muốn viết mã hắt hơi.

Bạn có thể đã dùng các sự kiện và lệnh gọi lại để xử lý 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à cử chỉ hắt hơi đâu. 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.

Thật không may, 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 phát hiện được những hình ảnh bị lỗi trước khi chúng tôi có cơ hội lắng nghe chúng; rất tiếc, 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ữ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 phù hợp với những sự kiện 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à điều hứa hẹn sẽ làm được, 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 khá giống trình nghe sự kiện, ngoại trừ:

  • Lời hứa chỉ có thể thành công hoặc thất bại một lần. Thử nghiệm không thể thành công hoặc thất bại hai lần, không thể chuyển từ thành công sang thất bại 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 phải chép States and Fates (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 bị từ chối
  • đã giải quyết – Đã thực hiện đơn hàng 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 có phương thức then. Thuật ngữ này làm tôi nhớ đến cựu huấn luyện viên bóng đá Anh Terry Venables nên tôi sẽ sử dụng từ khoá 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 các hàm này khác nhau và kém hữu ích một chút. Vì vậy, hãy thận trọng. jQuery cũng có loại Promise, nhưng đây chỉ là một tậ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 bạn 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 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ứ đều 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 đối tượng Lỗi là 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" (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 thành phần này trong JavaScript thay vì DOM là rất tuyệt vì chúng sẽ có sẵn trong các ngữ cảnh JS không phải trình duyệt như Node.js (liệu các thành phần này có được sử dụng trong các 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ả 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 hứa hẹn. Đ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 là bạn có thể áp dụng những lời hứa đó theo tiêu chuẩn và nên thực hiện 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) {
  // ...
})

May mắn là đây thường là những gì bạn muốn hoặc ít nhất là cung cấp cho bạn quyề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 đối tượng Lỗi vào trạng thái từ chối.

Dễ dàng tạo mã không đồng bộ phức tạp

Hãy cùng lập trình một vài thứ nhé. 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 cùng 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();
  });
}

Giờ hãy sử dụng:

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

Bây giờ, chúng ta có thể thực hiện 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, bởi vì tôi càng ít phải thấy kiểu viết lạc đà của XMLHttpRequest gây khó chịu 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à 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 hàm getJSON() thực sự 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ẽ mang lại một 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). Khi có then(func1, func2), func1 hoặc func2 sẽ được gọi, không bao giờ được 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ì đâ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!");
})

Luồng trên rất giống với hàm try/catch thông thường của JavaScript, các lỗi xảy ra trong một "thử" 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):

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

Các ngoại lệ và lời hứa đối với JavaScript

Việc từ chối xảy ra khi lời hứa bị từ chối một cách rõ ràng, nhưng cũng ngầm ẩn nếu có 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 mọi 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. Như vậy, các lỗi sẽ tự động bị phát hiện và trở thành thông báo bị 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 tìm nạp story.chapterUrls[0] không thành công (ví dụ: http 500 hoặc người dùng đang ngoại tuyến), thì lệnh gọi lại này sẽ bỏ qua tất cả các lệnh gọi lại thành công sau đây, bao gồm cả lệnh gọi lại trong getJSON() để 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 đó, lệnh này sẽ chuyển sang hàm callback. 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 sẽ 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

Tư duy không đồng bộ không hề dễ dàng. Nếu bạn đang gặp khó khăn để đạt được mục tiêu, hãy thử viết mã như thể 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 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 các thao tác 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 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 không đồng bộ, vì vậy các phân cảnh của chúng ta sẽ xuất hiện theo bất kỳ thứ tự nào tải xuống, về cơ bản là cách viết của Pulp Figma. Đây không phải là phim Pulp Fiction, vì vậy, hãy khắc phục vấn đề này.

Tạo một 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 truyền vào đó một thực thể của Promise, thì thực thể sẽ đơn giản là trả về phiên bản đó (lưu ý: đây là một thay đổi đối với thông số kỹ thuật mà một số phương thức 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 một Promise thực sự thực hiện/từ chối theo cách tương tự. Nếu bạn chuyển vào bất kỳ giá trị nào khác, ví dụ: Promise.resolve('Hello'), nó tạo ra 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ị 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) tạo lời hứa sẽ từ chối bằng 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())

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. " trình tự" là Promise.resolve() ở lần đầu tiên, nhưng đối với phần còn lại của các lệnh gọi "trình tự" 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 trong việc rút gọn một mảng xuống 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. Chúng ta muốn tải tất cả các tệp xuống cùng một lúc, sau đó xử lý các tệp đó 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, quá trình này có thể nhanh hơn vài giây so với việc tải từng nội dung và sẽ ít mã hơn 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ảm nhận được. 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 ra mắt, chúng ta có thể thêm chương hai và chương ba, v.v.

Để thực hiện việc này, chúng tôi tìm nạp JSON cùng lúc cho tất cả các phân cảnh, sau đó tạo một trình tự để thêm các phân cảnh đó 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! Cần 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 nhận được 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 những lời hứa hẹn, mà khi được kết hợp với các tính năng khác của ES6, các tính năng này thậm chí còn dễ dàng hơn nữa.

Phần bổ sung: nhiều chức năng khác

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. Chúng tôi hỗ trợ rộng rãi cả hàm Promise và hàm không đồng bộ 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ề Promisehàm không đồng bộ của MDN.

Rất cảm ơn Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur thiện và Yutaka Hirano. Những người này đã hiệu đính nội dung này và chỉnh sửa/đề xuất.

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