JavaScript Promises: giới thiệu

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

Jake Archibald
J Jake Archibald

Các nhà phát triển thân mế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.

[Tiếng trống bắt đầu]

Đã có những lời hứa trong JavaScript!

[Pháo hoa phát nổ, mưa giấy lấp lánh từ trên cao, đám đông trở nên cuồng nhiệt]

Lúc này, bạn thuộc một trong các loại sau:

  • Mọi người đang cổ vũ xung quanh bạn, nhưng bạn không chắc chuyện gì xảy ra. Có thể bạn thậm chí còn không chắc đâu là "lời hứa". Bạn có thể sẽ nhún vai nhưng sức nặng của giấy lấp lánh đang đè lên vai bạn. Nếu vậy, đừng lo lắng về vấn đề này, tôi đã mất nhiều thời gian mới tìm ra lý do mình nên quan tâm đến việc này. Có thể bạn nên bắt đầu từ phần đầu.
  • Bạn quả thật! Đến lúc rồi phải không? Bạn đã từng sử dụng các Promise này nhưng điều đáng lo ngại là tất cả các quá trình 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 với các thuật ngữ.
  • Bạn đã biết điều này rồi chế giễu những người nhảy lên nhảy xuống như thể đó là tin tức đối với họ. Dành chút thời gian để tận hưởng sự vượt trội của 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

  • 32
  • 12
  • 29
  • 8

Nguồn

Để cung cấp cho các trình duyệt không triển khai đầy đủ lời hứa hoàn chỉnh để tuân thủ thông số kỹ thuật hoặc thêm hứa hẹn vào các trình duyệt và Node.js khác, hãy xem polyfill (2k được nén).

Vấn đề là có cái gì thế?

JavaScript là một luồng đơn, nghĩa là hai bit tập lệnh không thể chạy cùng một lúc; các tập lệnh 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. Nhưng JavaScript thường nằm trong cùng một hàng đợi với thao tá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ư đánh dấu văn bản và tương tác với các chế độ điều khiển biểu mẫu). Hoạt động ở 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 có thể đa luồng. Bạn có thể nhập bằng nhiều ngón tay, thúc đẩy và giữ một cuộc trò chuyện cùng lúc. Chức năng chặn duy nhất chúng ta phải xử lý là hắt hơi, khi đó mọi hoạt động hiện tại phải bị tạm ngưng trong suốt thời gian hắt hơi. Điều này khá khó chịu, đặc biệt là khi bạn đang lái xe và cố gắng giữ một cuộc trò chuyện. Bạn không muốn phải viết mã rườm rà.

Có thể bạn đã sử dụng sự kiện và lệnh gọi lại để xử lý vấn đề này. Dưới đâ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ảm giác hắt hơi. Chúng ta nhận được 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.

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, 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 "hoàn chỉnh" 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
});

Tính năng 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 các hình ảnh đó; rất tiếc là DOM không cho chúng ta cách để làm điều đó. Ngoài ra, phương thức này đang tải một hình ảnh. Mọi thứ thậm chí sẽ phức tạp hơn nếu chúng ta muốn biết thời điểm một tập hợp 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

Sự kiện rất 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 sẽ không thực sự quan tâm đến những gì đã xảy ra trước khi đính kèm trình nghe. Tuy nhiên, khi nói đến thành công/thất bại không đồng bộ, lý tưởng nhất là bạn nên có những kết quả 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ì đã hứa hẹn, nhưng cách đặt tên hợp lý hơn. Nếu các phần tử hình ảnh HTML có phương thức "sẵn sàng" trả về hứa hẹn, chúng ta có thể thực hiện việc này:

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ũng giống như 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. Chiến lược không thể thành công hoặc thất bại hai lần, cũng như 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 một lệnh gọi lại thành công/thất bại, 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 đối với các trường hợp thành công/thất bại không đồng bộ, vì bạn ít quan tâm hơn đến thời điểm chính xác có sử dụng tính năng nào đó mà quan tâm nhiều hơn đến việc phản ứng với kết quả.

Thuật ngữ 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ữ. Ông ấy bị giam giữ, buộc tôi sao chép Trạng thái và số định 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 còn nhầm lẫn về nhiều thuật ngữ, nhưng sau đây là những khái niệm 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
  • bị từ chối – 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 bị từ chối
  • đã giải quyết – Đã thực hiện hoặc bị từ chối

Quy cách kỹ thuật cũng sử dụng thuật ngữ thenable (có thể điều chỉnh) để mô tả một đối tượng giống như hứa hẹn, 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, vì vậy, tôi sẽ dùng ít nhất có thể.

Các tính năng hứa hẹn sẽ có trong JavaScript!

Hứa hẹn đã tồn tại một thời gian dưới hình thức thư viện, chẳng hạn như:

Những điều kiện trên và JavaScript hứa hẹn có chung một hành vi phổ biến, được chuẩn hoá gọi là Promises/A+. Nếu bạn là người dùng jQuery, họ sẽ có một hành vi tương tự có tên là Deferreds. Tuy nhiên, Trì hoãn lệnh không tuân thủ Promise/A+, điều này khiến các ứng dụng này khác nhau đáng kể và ít hữu ích hơn. 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 hợp con của Deferred và có cùng một 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 các API tổng thể của chúng lại khác nhau. Các hứa hẹn của JavaScript cũng tương tự như trong API RSVP.js. Đâ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 sẽ nhận một đối số, một lệnh gọi lại có hai tham số là giải quyết và từ chối. Hãy làm gì đó 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 hoặc gọi từ chối.

Giống như throw trong JavaScript cũ, bạn thường nhưng không bắt buộc phải từ chối bằng đối tượng Error (Lỗi). Lợi ích của các đối tượng Error (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.

Dưới đâ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 đối số gọi lại cho trường hợp thành công và một đối số khác cho trường hợp không thành công. Cả hai đều là 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.

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

Mặc dù đây là một tính năng của JavaScript, DOM không ngại sử dụng chúng. Trên thực tế, tất cả các API DOM mới có phương thức 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 tính năng 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

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

Mặc dù, như tôi đã đề cập, Deferreds của jQuery hơi ... không hữu ích. Rất may là bạn có thể chuyển chúng thành các lời hứa tiêu chuẩn. Bạn nên thực hiện điều 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 đó thành một lời hứa JavaScript. Tuy nhiên, đôi khi bị 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 JS hứa hẹn sẽ bỏ qua tất cả trừ những điều đầ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 mục từ chối.

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

Hãy cùng lập trình một vài thứ. Giả sử chúng ta muốn:

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

... mà còn cho người dùng biết nếu có xảy ra sự cố trong quá trình thực hiện. 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 này sẽ tiếp tục quay, hoa mắt và gặp sự cố trong 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 story, việc phân phát dưới dạng HTML nhanh hơn, nhưng mẫu này khá phổ biến khi xử lý các API: Tìm nạp nhiều dữ liệu, sau đó thực hiện một thao tác nào đó khi mọi việc đã xong.

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

Ước tính 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 đề xuất chính, 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();
  });
}

Giờ hãy sử dụng công cụ này:

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 thật tuyệt, vì càng ít phải nhìn thấy cách viết hoa lạc đà của XMLHttpRequest, cuộc sống của tôi càng hạnh phúc hơn.

Xâu chuỗi

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

Giá trị chuyển đổi

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

Hãy quay lại với ví dụ thực tế về:

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

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

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

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 URL rồi phân tích cú pháp phản hồi dưới dạng JSON.

Thêm 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ự.

Việc bạn trả về giá trị nào đó từ lệnh gọi lại then() sẽ có 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 với giá trị đó. Tuy nhiên, nếu bạn trả về nội dung nào đó giống như lời hứa, then() tiếp theo sẽ chờ và chỉ được gọi khi lời hứa đó kết thúc (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);
})

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

Bạn thậm chí có thể tạo một phương thức tắt để thêm 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 là chúng ta sử dụng lại lời hứa câu chuyện, vì vậy story.json chỉ được tìm nạp một lần. Hứa hẹn tuyệt vời!

Xử lý lỗi

Như chúng ta đã thấy, then() nhận hai đối số, một đối số là thành công, một đối số thất bại (hoặc thực hiện và từ chối trong 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ỉ có chữ đường cho then(undefined, func) nhưng sẽ dễ đọc hơn. Xin lưu ý rằng 2 ví dụ về mã ở trên không hoạt động giống nhau, mã 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 rất nhỏ, nhưng cực kỳ hữu ích. Các lệnh từ chối hứa hẹn sẽ chuyển đến then() tiếp theo bằng lệnh gọi lại từ chối (hoặc catch(), vì nó tương đương). Với then(func1, func2), func1 hoặc func2 sẽ được gọi, không bao giờ được gọi cả hai. Tuy nhiên, 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 thực hiện 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 quy trình try/catch của JavaScript thông thường, các lỗi xảy ra trong vòng "thử" sẽ chuyển ngay đến khối catch(). Dưới đây là sơ đồ quy trình ở trên (vì tôi thích lưu đồ):

Theo dõi các đường màu xanh dương đối với các lời hứa sẽ thực hiện hoặc màu đỏ đối với các 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 một lời hứa bị từ chối một cách rõ ràng, nhưng cũng ngầm ẩn nếu xảy ra lỗ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 trong lệnh gọi lại hàm khởi tạo lời hứa, vì vậy, lỗi sẽ tự động được phát hiện và trở thành từ chối.

Điều này cũng áp dụng cho các lỗi được tạo 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ế

Qua câu chuyện và các phân cảnh, chúng ta có thể dùng tính năng này để cho người dùng thấy lỗi:

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 không có kết nối mạng), 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 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 và cũng 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 lệnh gọi lại. Do đó, trạng thái "Không hiển thị được phân cảnh" sẽ được thêm vào trang nếu bất kỳ thao tác 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 tục tiếp tục, vì vậy, vòng quay luôn bị ẩn. Đây là điều chúng ta muốn. Phiên bản ở 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 chỉ muốn catch() cho mục đích ghi nhật ký mà không cần khôi phục lỗi. Để làm việc này, 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 được một chương nhưng chúng ta muốn tìm hết tất cả. Hãy để điều đó xảy ra.

Song song và sắp xếp theo trình tự: tận dụng tối đa cả hai tính năng này

Tư duy không đồng bộ là điều không hề dễ dàng. Nếu bạn đang cố gắng đạt được mục tiêu, hãy thử viết mã như thể nó được đồ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'

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

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 lặp lại các URL của chương và tìm nạp chúng theo thứ tự? Tính năng 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, vì vậy, các phân cảnh của chúng ta sẽ xuất hiện theo thứ tự tải xuống bất kỳ, về cơ bản, đây là cách Pulp Cổ được viết ra. Đây không phải là Pulp tổng quan, vì vậy hãy sửa nó.

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(). Môi trường này tạo ra một hứa hẹn xác định bất kỳ giá trị nào mà bạn đưa ra. Nếu bạn truyền cho thực thể đó một thực thể của Promise, thao tác này sẽ chỉ trả về thực thể đó (lưu ý: đây là 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 cho nó nội dung nào đó giống như hứa hẹn (có phương thức then()), thì thao tác này sẽ tạo một Promise thực sự có thể đá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'), tạo một lời hứa sẽ thực hiện được bằng giá trị đó. Nếu bạn gọi phương thức này mà không có giá trị, như trên, phương thức này sẽ đáp ứng với giá trị "không xác định".

Ngoài ra còn có Promise.reject(val), dùng để tạo một lời hứa sẽ từ chối bằng giá trị bạn đưa ra (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 làm này tương tự như ví dụ trước, nhưng không cần biến "sequence" (trình tự) riêng biệt. Lệnh gọi lại hàm rút gọn của chúng ta được gọi cho từng mặt hàng trong mảng. "sequence" là Promise.resolve() trong lần gọi đầu tiên, nhưng đối với các lệnh gọi còn lại, "sequence" là bất kỳ thứ gì chúng ta trả về từ lệnh gọi trước đó. array.reduce thực sự hữu ích khi chuyển 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 tóm tắt 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à chúng ta đã có nó, một phiên bản hoàn toàn không đồng bộ của phiên bản đồng bộ hoá. Tuy nhiên, chúng ta có thể làm tốt hơn. Hiện tại, trang của chúng tôi đang tải xuống như sau:

Các trình duyệt khá hiệu quả trong việc tải nhiều nội dung xuống cùng một lúc, vì vậy, chúng tôi sẽ giảm hiệu suất do tải lần lượt từng chương xuống. Việc chúng tôi muốn làm là tải xuống tất cả cùng một lúc, sau đó xử lý khi chúng đã đế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 loạt các lời hứa và tạo một lời hứa sẽ thực hiện khi tất cả chúng hoàn tất thành công. Bạn nhận được một loạt kết quả (bất kể điều gì đã hứa hẹn được thực hiện) theo cùng thứ tự với những lời hứa mà bạn đã đưa 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 ứng dụng và sẽ í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 chúng xuất hiện trên màn hình theo đúng thứ tự.

Tuy nhiên, chúng tôi 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 nó vào trang. Điều này cho phép người dùng bắt đầu đọc trước khi các phân cảnh còn lại xuất hiện. Khi chương 3 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 2 bị thiếu. Khi chương hai đến, chúng ta có thể thêm chương hai và ba, v.v.

Để làm điều này, chúng tôi tìm nạp JSON cho tất cả các phân cảnh cùng một lúc, 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à kết quả là tuyệt vời nhất! 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 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 phân cảnh xuất hiện cùng một lúc, nhưng lợi ích của việc hiển thị từng phân cảnh sẽ được phóng đại với nhiều phân cảnh lớn hơn.

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

Vòng thưởng: mở rộng khả năng

Kể từ lần đầu tiên tôi viết bài viết này, khả năng sử dụng Promises đã được 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 lời hứa như thể mã này đồng bộ, nhưng không chặn luồng chính. Bạn có thể đọc thêm về điều đó trong my async functions article. Chúng tôi hỗ trợ rộng rãi cho cả Promise và hàm không đồng bộ trong các trình duyệt chính. Bạn có thể tìm thấy 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 Ashwin, Remy Sharp, Addy Osmani, Arthur5 và Yutaka Hirano, những người đã rà soát vấn đề này và đưa ra nội dung chỉnh sửa/đề xuất.

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