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 cần chuẩn bị sẵn sàng cho một thời khắc quan trọng trong lịch sử 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 tất cả những điều ồn ào đó là gì. Có thể bạn thậm chí không biết "lời hứa" là gì. Bạn có thể nhún vai nhưng trọng lượng của tờ giấy lấp lánh đang đè lên vai bạn. Nếu có, đừng lo lắng, tôi đã mất rất nhiều thời gian để tìm ra lý do mình nên quan tâm đến việc này. 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 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 với các thuật ngữ.
  • Bạn đã biết về điều này và chế giễu những người đang nhảy múa như thể họ vừa nhận được tin tức. Hãy dành chút thời gian để tự tin vượt trội, 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 đủ hứa hẹn 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 tệp polyfill (tệp 2k được nén).

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

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, khác nhau giữa các trình duyệt. Tuy nhiên, thông thường JavaScript nằm trong cùng một hàng đợi với việc tô màu, cập nhật kiểu và xử lý các thao tác của người dùng (chẳng hạn như làm nổi bật văn bản và tương tác với các chế độ điều khiển 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ể lái xe và giữ cuộc trò chuyện cùng 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á khó chịu, đặc biệt là khi bạn đang lái xe và cố gắng tổ chức một cuộc 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à tiếng hắt hơi đâu. 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 chúng, vì vậy, chúng ta cần xử lý 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ứ 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 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. Tuy nhiên, khi nói đến thành công/thất bại không đồng bộ, lý tưởng là bạn nên có những định dạng 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 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 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, 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 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 tình trạng thành công/thất bại không đồng bộ, vì bạn không mấy quan tâm đến thời điểm chính xác có mặt hàng mà quan tâm hơn đến việc 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" về thuật ngữ. Anh ta bắt tôi giam giữ, buộc tôi sao chép Tiểu bang và số phận 100 lần, đồng thời 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òn hiểu lầm 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ể:

  • đã 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ữ có thể điều chỉnh để 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 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 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ư:

Các lời hứa JavaScript ở trên và JavaScript có chung một hành vi được chuẩn hoá, phổ biến tên là Promises/A+. Nếu bạn là người dùng jQuery, thì họ có một hành vi tương tự gọi là Deferred (Hoãn). 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 lưu ý. 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 được chuẩn hoá, nhưng các API tổng thể của chúng lại 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 sẽ lấy một đối số, một lệnh gọi lại có hai tham số, phân giải và từ chối. Hãy 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 giải quyết nếu mọi thứ đều hoạt động, nếu không thì từ chối lệnh 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 Error (Lỗi). 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 đố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 chỉ có thể thêm lệnh gọi lại trong 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ó chúng trong JavaScript thay vì DOM là một điều tuyệt vời vì chúng sẽ có sẵn trong ngữ cảnh JS không phải của trình duyệt, chẳng hạn như Node.js (việc 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à tính năng 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/thất bại không đồng bộ sẽ sử dụng hứa hẹn. Việc này vốn đã diễn 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 rằng API sẽ coi mọi thứ có phương thức then() là giống như hứa hẹn (hoặc thenable trong âm thanh hứa hẹn ), 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 cả, thư viện đó sẽ hoạt động tốt với các lời hứa JavaScript mới.

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ể á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'))

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 phương thức này thành lời hứa JavaScript. Tuy nhiên, đôi khi sẽ trì hoãn việc truyền nhiều đối số đến các 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 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 Error (Lỗi) vào trường hợp 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 cũng 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 câu chuyện, việc 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: Tìm nạp nhiều dữ liệu, sau đó thực hiện việc gì đó 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ể 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 để gửi 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() chưa phải là kết thúc, 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.

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ý. Chúng ta có thể thay đổi hàm get để sử dụng JSON responseType, nhưng cũng có thể giải quyết hàm này trong vùng 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, 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 hứa hẹn, 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ả về một lời hứa tương tự, then() tiếp theo sẽ chờ thực hiện 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 thực hiện 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 các URL để yêu cầu, sau đó chúng ta yêu cầu các URL đầu tiên. Đây là thời điểm các 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 để 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 sẽ không tải story.json xuống cho đến khi getChapter được gọi, nhưng vào(các) lần tiếp theo getChapter được gọi, chúng ta sẽ 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 trước đó, then() nhận 2 đối số, một là thành công và một là thất bại (hoặc thực hiện và từ chối trong phần nói hứa hẹn):

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

Không có gì đặc biệt về catch(), chỉ là đường đơn giản đối với then(undefined, func) nhưng dễ đọc hơn. Xin lưu ý rằng hai mã 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. Việc từ chối lời hứa sẽ chuyển tiếp đến then() tiếp theo bằng một lệnh gọi lại từ chối (hoặc catch(), vì 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. Tuy nhiên, với then(func1).catch(func2), cả hai sẽ được gọi nếu func1 từ chối, vì đây là hai 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 phương thức 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(). Sau đâ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 đối với những lời hứa đã thực hiện hoặc màu đỏ đối với những lời hứa bị 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 để tự động phát hiện lỗi và trở thành thông báo bị 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() để 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 hàm callback. Do đó, thông báo "Không hiện được chương" sẽ được thêm vào trang nếu bạn không thực hiện được bất kỳ thao tác nào trước đó.

Giống như cú pháp try/catch của JavaScript, lỗi sẽ được phát hiện và mã tiếp theo vẫn tiếp tục, vì vậy vòng quay luôn bị ẩn. Đây là điều chúng ta cần làm. Phầ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 khi gặp 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;
  });
}

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 để đ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 để đạ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 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. Để làm cho công việc này không đồng bộ, chúng ta sử dụng then() để lần lượt diễn ra công việc.

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ự? 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 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à Pulp rằng chúng ta hãy cùng sửa lỗi nhé.

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(). 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 cho nó. 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 nội dung nào đó giống như lời hứa (có phương thức then()), thì phương thức này sẽ tạo một Promise chính xác đáp ứng/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 phương thức không có giá trị, như trên, phương thức này sẽ thực hiện bằng giá trị "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 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 thực hiện này tương tự như ví dụ trước, nhưng không cần biến "sequence" riêng biệt. 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 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 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ể làm 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 nhiều nội dung xuống cùng một lúc, vì vậy, chúng tôi sẽ làm mất hiệu suất khi tải lần lượt các chương xuống. Điều chúng ta cần làm là tải tất cả tệp xuống cùng một lúc, sau đó xử lý khi tất cả tệp đều đến nơi. 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 loạt các lời hứa và tạo ra lời hứa sẽ thực hiện khi tất cả các lời hứa đó thực hiện thành công. Bạn nhận được một loạt kết quả (bất kể đã thực hiện những lời hứa nào) theo cùng thứ tự như những lời hứa mà 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 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 bất kỳ thứ tự nào, nhưng chúng 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 này vào trang. Nhờ đó, người dùng có thể bắt đầu đọc trước khi các phân cảnh còn lại. Khi chương 3 xuất hiện, chúng tôi sẽ không thêm chương trình này vào trang vì người dùng có thể không nhận ra rằng chương 2 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à chúng ta đã đạt được kết quả tốt nhất! Cần cùng một lượ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 phân cảnh sẽ bị phóng đại khi có nhiều phân cảnh hơn và lớn hơn.

Thực hiện những việc trên với lệnh gọi lại hoặc sự kiện kiểu Node.js sẽ có mã tăng gấp đôi, nhưng quan trọng hơn là không dễ thực hiện theo. 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

Vì tôi là người đầu tiên viết bài viết này 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 viết mã dựa trên hứa hẹn 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 my async functions article. 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ể tìm thấy thông tin chi tiết trong tệp tham chiếu 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 này.