JavaScript Promises: giới thiệu

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

Jake Archibald
Jake Archibald

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

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

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

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

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 biết chắc chắn những rắc rối đó là gì về. Có thể bạn thậm chí không chắc về "lời hứa" của chúng tôi. 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 về nó, tôi mất rất nhiều thời gian để tìm ra lý do mình nên quan tâm đến việc này nội dung. Bạn nên bắt đầu từ phần đầu.
  • Bạn thật tuyệt vời! Đã đến lúc chưa? Bạn đã từng sử dụng những lời hứa này nhưng điều đáng lo ngại là tất cả các cách 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ì? Có thể bạn muốn bắt đầu với thuật ngữ.
  • Bạn đã biết về điều này và chế giễu những kẻ đang nhảy lên như tin tức đối với họ. Hãy dành chút thời gian để tự tin về ưu thế của bản thâ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.
  • Cạnh: 12.
  • Firefox: 29.
  • Safari: 8.

Nguồn

Để làm cho các trình duyệt thiếu triển khai hứa hẹn đầy đủ theo thông số kỹ thuật tuân thủ 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 (được nén 2k).

Làm gì có vấn đề này nhỉ?

JavaScript là luồng đơn, có nghĩa là hai bit của 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 vị trí. Trong trình duyệt, JavaScript chia sẻ chuỗi với tải nội dung khác khác với trình duyệt trình duyệt. Nhưng thông thường, JavaScript sẽ nằm trong cùng một hàng đợi như tô màu, cập nhật và xử lý các hành động 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 tùy chọn kiểm soát biểu mẫu). Hoạt động ở một trong những điều này sẽ làm chậm trễ 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ể vừa lái xe vừa tổ chức trò chuyện. Chặn duy nhất chúng ta phải giải quyết là chứng hắt hơi, trong đó mọi hoạt động hiện tại đều phải bị tạm ngưng trong khoảng thời gian hắt hơi. Điều đó khá khó chịu, đặc biệt khi bạn đang lái xe và cố gắng thực hiện một cuộc trò chuyệ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à tiếng hắt hơi đâu. Chúng tôi 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.

Không may, trong ví dụ trên, có thể các sự kiện đã xảy ra trước khi bắt đầu lắng nghe họ, vì vậy, chúng tôi cần giải quyết vấn đề đó bằng cách "hoàn chỉnh" thuộc tí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
});

Phương thức này sẽ không phát hiện những hình ảnh có lỗi trước khi chúng tôi có cơ hội nghe them; rất tiếc, DOM không cung cấp cho chúng tôi cách để làm điều đó. 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 khi nào một tập hợp trong số 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 là một cách hay cho những điều có thể xảy ra nhiều lần cùng một lúc đối tượng—keyup, touchstart, v.v. Với những sự kiện đó, bạn không thực sự quan tâm về 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ộ, tốt nhất bạn nên:

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ó "sẵn sàng" đã trả về một lời hứa, chúng ta có thể làm điều 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 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 không thành công hai lần, cũng không thể chuyển từ thành công sang thất bại hoặc ngược lại.
  • Nếu lời hứa đã thành công hoặc không thành công và sau đó bạn thêm thành công/thất bại thì lệnh gọi lại chính xác sẽ được gọi, ngay cả khi sự kiện đã diễn ra vị trí sớm hơn.

Điều này cực kỳ hữu ích trong trường hợp thành công/thất bại không đồng bộ, vì bạn sẽ ít quan tâm đến thời điểm chính xác mà sản phẩm nào đó có hàng và quan tâm hơn trong phản ứng với kết quả.

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

Bằng chứng Domenic Denicola đã đọc bản nháp đầu tiên của bài viết này và đã chấm điểm tôi là "F" cho thuật ngữ. Hắn ta tống tôi đi buộc tôi sao chép Trạng thái và số phận 100 lần và đã viết một bức thư lo lắng gửi cho cha mẹ tôi. Mặc dù vậy, tôi vẫn có rất nhiều thuật ngữ hỗn hợp, nhưng sau đây là những khái niệm cơ bản:

Lời hứa có thể:

  • đã 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 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ữ tính bền vững để mô tả một đối tượng giống như hứa hẹn, ở chỗ có phương thức then. Cụm từ này làm tôi nhớ đến bóng đá cũ Người quản lý Terry Venables để giúp bạn Tôi sẽ sử dụng càng ít càng tốt.

Lời hứa có trong JavaScript!

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

Những điều ở trên và JavaScript hứa hẹn có chung một hành vi chung, được chuẩn hoá có tên là Promises/A+. Nếu bạn là người dùng jQuery, họ có một tên tương tự gọi là Trì hoãn. Tuy nhiên, Nội dung bị trì hoãn không tuân thủ Promise/A+ nên khiến các yêu cầu này khác nhau một chút và kém hữu ích hơn, hãy thận trọng. jQuery cũng có một 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 được chuẩn hoá, nhưng API tổng thể sẽ khác nhau. Các lời hứa JavaScript trong 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 hứa hẹn lấy một đối số, một lệnh gọi lại có 2 tham số giải quyết và từ chối. Thực hiện thao tác nào đó trong lệnh gọi lại (có thể là không đồng bộ), sau đó gọi xử lý nếu mọi thứ đã hoạt động, nếu không thì từ chối cuộc gọi.

Giống như throw trong JavaScript cũ, thông thường, nhưng không bắt buộc phải từ chối bằng đối tượng Lỗi. Lợi ích của đối tượng Lỗi là chúng nắm bắt được 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 2 đối số, một lệnh 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 không bắt buộc, vì vậy bạn 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 "Tương lai", được đổi tên thành "Lời hứa", và cuối cùng được chuyển sang JavaScript. Hiển thị chúng trong JavaScript thay vì DOM rất phù hợp vì chúng sẽ có sẵn trong ngữ cảnh JS không phải trình duyệt như Node.js (việc họ có sử dụng chúng trong các API cốt lõi hay không là một câu hỏi khác).

Mặc dù đây là tính năng JavaScript, DOM không ngại sử dụng chúng. Trong 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. Vấn đề này đã xảy ra với Quản lý hạn mức, Sự kiện tải phông chữ, ServiceWorker! MIDI trên web, Sự kiện phát trực tiếp 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ẽ coi mọi nội dung có phương thức then() là giống như hứa hẹn (hoặc thenable trong lời hứa thở dài), vì vậy, nếu bạn sử dụng thư viện trả về lời hứa Q, không sao cả, thiết bị này sẽ rất thú vị với hứa hẹn về JavaScript.

Mặc dù, như tôi đã đề cập, Trì hoãn của jQuery hơi ... không hữu ích. Rất may là bạn có thể thực hiện những lời hứa tiêu chuẩn và đáng thực hiện sớm nhất có thể:

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

Tại đây, $.ajax của jQuery trả về một Deferred. Vì có phương thức then(), Promise.resolve() có thể biến lời hứa đó thành JavaScript hứa hẹn. Tuy nhiên, đôi khi sẽ trì hoãn việc 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 bỏ qua tất cả trừ những điều đầu tiên:

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

May mắn thay, đây thường là điều bạn muốn hoặc ít nhất là 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 của 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 tôi 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. Thao tác này sẽ cung cấp cho chúng ta tiêu đề và URL của 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 này vào trang
  6. Dừng vòng quay

... mà còn thông báo cho người dùng nếu xảy ra sự cố. Chúng ta 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, chóng mặt và gặp phải 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 câu chuyện, việc phân phát HTML nhanh hơn, nhưng mẫu này khá phổ biến khi xử lý các API: Nhiều dữ liệu tìm nạp, sau đó thực hiện một hành động nào đó khi đã 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:

Xác thực XMLHttpRequest

Các API cũ sẽ được cập nhật để sử dụng các hứa hẹn, nếu có thể thực hiện ngược lại một cách tương thích. XMLHttpRequest là một ứng cử viên hàng đầu, nhưng trong thời điểm đó 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ể 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ì tôi không còn phải thấy XMLHttpRequest tỏ ra giận dữ nữa, thì cuộc sống của tôi càng hạnh phúc.

Xâu chuỗi

then() chưa phải là kết thúc, bạn cò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.

Biến đổi giá trị

Bạn có thể chuyể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 xem một ví dụ thực tế như sau:

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

Phản hồi là JSON nhưng chúng tôi hiện nhận được dưới dạng văn bản thuần tuý. T4 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 ở khu vực 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ị đã biến đổi, 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 hàm tìm nạp url sau đó 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ự.

Điều này rất kỳ diệu khi bạn trả về thông tin nào đó từ lệnh gọi lại then(). 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ả lại nội dung giống như lời hứa thì then() tiếp theo sẽ chờ 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);
})

Ở đây, chúng ta tạo một yêu cầu không đồng bộ đến story.json, cung cấp cho chúng ta một tập hợp URL cần yêu cầu, thì chúng tôi sẽ yêu cầu URL đầu tiên trong số các URL đó. Đây là khi đã hứa thực sự 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 để lấy 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 ngày tiếp theo lần diễn ra getChapter được gọi là chúng ta sử dụng lại lời hứa trong câu chuyện, vì vậy story.json chỉ được tìm nạp một lầ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ố là thành công. khi 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ể 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ỉ đơn giản là thôi then(undefined, func), nhưng dễ đọc hơn. Lưu ý rằng hai mã các ví dụ ở 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. Bỏ qua việc từ chối lời hứa 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ì thì tương đương). Với then(func1, func2), func1 hoặc func2 sẽ là được gọi, không đượ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. Đi tuyế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!");
})

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

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

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

Hành vi 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 tất cả công việc liên quan đến lời hứa của mình trong hứa hẹn lệnh gọi lại hàm khởi tạo, do đó, lỗi sẽ tự động được phát hiện và trở thành sự từ chối.

Điều tương tự cũng xảy ra với các lỗi được tạo 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 chức năng phát hiện lỗi để 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() cố gắng phân tích cú pháp phản hồi dưới dạng JSON và cũng bỏ qua lệnh gọi lại thêm chapter1.html vào trang. Thay vào đó, nó di chuyển vào nội dung bắt được . Do đó, "Không hiển thị được phân cảnh" sẽ được thêm vào trang nếu không thực hiện được bất kỳ hành động nào trước đó.

Giống như kỹ thuật try/catch của JavaScript, lỗi được phát hiện và mã tiếp theo tiếp tục, vì vậy vòng quay luôn bị ẩn. Đây là điều chúng ta muốn. Chiến lược phát hành đĩa đơn ở trên trở thành phiên bản không đồng bộ không tuần tự 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ể chỉ muốn catch() cho mục đích ghi nhật ký mà không khôi phục khỏi lỗi. Để thực hiện việc này, bạn chỉ cần gửi lại lỗi. Chúng tôi có thể thực hiện điều này trong phương thức getJSON() của chúng ta:

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 tôi đã cố gắng tìm nạp được một chương, nhưng chúng tôi muốn có tất cả các chương. Hãy cùng làm điều đó xảy ra.

Song song và sắp xếp trình tự: khai thác tối đa cả hai yếu tố

Tư duy không đồng bộ không hề dễ dàng. Nếu bạn đang gặp khó khăn, 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 rồi! Tuy nhiên, Chrome sẽ đồng bộ hoá và khoá trình duyệt trong khi tải nội dung xuống. Người nhận làm cho công việc này không đồng bộ, chúng ta sử dụng then() để làm cho mọi thứ lần lượt xảy ra.

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 lại các URL của chương và tìm nạp các URL đó theo thứ tự? Chiến dịch này không hoạt động:

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

forEach không nhận biết được 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 mà họ tải xuống, về cơ bản đó chính là cách tiểu thuyết Pulp được viết. Đây không phải Bột giấy, vì vậy, hãy khắc phục nó.

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 tôi thấy Promise.resolve(), một tính năng 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 cho nó. Nếu bạn truyền thực thể của Promise, nó sẽ chỉ trả về nó (lưu ý: đây là một thay đổi đối với thông số kỹ thuật mà một số hoạt động triển khai chưa tuân theo). Nếu bạn truyền nó vào một nội dung giống như lời hứa (có phương thức then()), phương thức này sẽ tạo một Promise thực sự đáp ứng/từ chối theo cách tương tự. Nếu bạn vượt qua trong bất kỳ giá trị nào khác, chẳng hạn như Promise.resolve('Hello'), nó sẽ tạo ra một thực hiện với giá trị đó. Nếu bạn gọi hàm này không có giá trị, như trên, giá trị này đáp ứng giá trị "không xác định".

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

Chúng ta có thể dọn dẹp mã trên bằ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 thực hiện tương tự như ví dụ trước, nhưng không cần đoạn mã riêng biệt "trình tự" biến. Lệnh gọi lại giảm dần được gọi cho từng mục trong mảng. "trình tự" là Promise.resolve() lần đầu tiên, nhưng đối với các lần còn lại gọi "trình tự" là bất cứ giá trị nào chúng ta đã trả về từ cuộc gọi trước đó. array.reduce thực sự hữu ích khi 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 tóm tắt nội dung của buổi hôm nay:

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ó phiên bản đó, một phiên bản phiên bản đồng bộ hoá hoàn toàn không đồng bộ. Nhưng chúng tôi có thể tốt hơn. Hiện tại, trang của chúng tôi đang được tải xuống như sau:

Các trình duyệt khá hiệu quả trong việc tải xuống nhiều nội dung cùng một lúc, vì vậy, chúng tôi đang hiệu suất bằng cách tải lần lượt từng chương xuống. Điều chúng tôi muốn làm là hãy tải xuống tất cả tệp cùng một lúc, sau đó xử lý khi tất cả tệp đều đượ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 nhiều lời hứa và tạo ra lời hứa để thực hiện khi tất cả các bước đó đều hoàn tất thành công. Bạn sẽ nhận được một loạt kết quả (bất kể những lời hứa đã thực hiện) theo cùng thứ tự với những lời hứa bạn đã chuyể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 tải từng ứng dụng một, và ít mã hơn lần thử đầu tiên. Các phân cảnh có thể tải xuống bất cứ lúc 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 1 ra mắt, chúng tôi nên thêm mã đó vào trang. Việc này cho phép người dùng bắt đầu đọc trước phần còn lại của các phân cảnh đã xuất hiện. Khi chương 3 ra mắt, chúng tôi sẽ không thêm nó vào vì người dùng có thể không nhận ra chương 2 bị thiếu. Khi chương hai sắp tới, chúng ta có thể thêm chương hai và ba, v.v.

Để làm việc này, chúng ta 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 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 đã đạt được kết quả tốt 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 nhận được nội dung đầu tiên sớm hơn.

Trong ví dụ nhỏ 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 thông báo sẽ được phóng đại với nhiều hơn, lớn hơn phân cảnh.

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

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

Vì tôi viết bài viết này ngay từ đầu, nên 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 mã dựa trên lời hứa được viết như thể đồng bộ nhưng không chặn luồng chính. Bạn có thể hãy đọc thêm về vấn đề đó trong bài viết về các hàm không đồng bộ của tôi. Có 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ể tìm thấy chi tiết trong MDN Lời hứakhông đồng bộ hàm tham chiếu.

Rất cảm ơn Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Christopher và Yutaka Hirano, những người hiệu đính bài khảo sát này và đưa ra sửa lỗi/đề xuất.

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