Xử lý bất đồng độ trong JavaScript với Promise và Generator

1. Callback

Ai đã từng sử dụng Javascript chắc đều biết xử lý bất đồng bộ là một đặc trưng của Javascript. Ví dụ, bạn viết hàm request(URL) để gửi yêu cầu đến một đường dẫn rồi ghi log kết quả trả về ra console:

function request(URL) {
  var xhttp = new XMLHttpRequest(); 
  //Bất đồng bộ 
  xhttp.onreadystatechange = function() { 
    if (xhttp.readyState == 4 && xhttp.status == 200) { 
      console.log(xhttp.responseText);
    } 
  }; 
  xhttp.open("GET", URL, true); 
  xhttp.send();
}

Trong đoạn code trên, hàm gắn liền với sự kiện xhttp.onreadystatechange là phần code được xử lý bất đồng bộ. Dù nó được viết trước những lệnh như xhttp.open và xhttp.send nhưng thực ra lại được xử lý sau khi request đã hoàn thành và có kết quả trả về.

Hàm request(URL) như trên được viết không tốt. Lý do là hàm đó đang thực hiện hai chức năng khác nhau: gửi yêu cầu lấy kết quả và xử lý kết quả trả về.  Trong khi chức năng gửi yêu cầu lấy kết quả có thể dùng chung cho bất kì đường dẫn nào thì chức năng xử lý kết quả nên được tách riêng ra để tùy biến cho những trường hợp khác nhau. Do đó, chúng ta có thể sửa lại hàm request(URL) thành request(URL, processResult) với tham số đầu vào processResult là một hàm xử lý bất kì.

function request(URL, processResult) {
  var xhttp = new XMLHttpRequest();
  //Bất đồng bộ
  xhttp.onreadystatechange = function() {
    if (xhttp.readyState == 4 && xhttp.status == 200) {
      //Xử lý kết quả trả về
      processResult(xhttp.responseText);
    }
  };
  xhttp.open("GET", URL, true);
  xhttp.send();
}

Hàm processResult được truyền như một tham số và được thực hiện trong một hàm khác dưới một điều kiện nhất định, theo định nghĩa có tên gọi là callback function. Sau khi tách ra, chúng ta có thể chuyển toàn bộ phần code liên quan đến gửi yêu cầu vào một thư viện, để lần sau dùng, chúng ta chỉ cần định nghĩa hàm xử lý kết quả là đủ:

function logResult(response) {
  console.log(response);
  return 1;
} 
request("www.google.com", logResult);

Việc sử dụng callback function cho xử lý bất đồng bộ đã tồn tại trong suốt lịch sử của Javascript. Khuyết điểm của nó là bạn định nghĩa một hàm số nhưng lời gọi thực hiện nó lại lồng trong và phụ thuộc vào một hàm khác. Điều đó dẫn đến những tình huống trớ trêu, ví dụ khi bạn muốn lấy kết quả từ đường dẫn A gửi cho đường dẫn B, rồi lấy kết quả từ đường dẫn B gửi đến đường dẫn C, ….

request(UrlA, function(response) {
  request(UrlB + response, function(response) {
    request(UrlC + response, function(response) { .... });
  });
});

Những dòng lệnh lồng nhau rối mắt như trên được gọi là callback hell. Nó sẽ còn trở nên phức tạp hơn nữa trong trường hợp bạn cần thêm vào những lệnh xử lý lỗi hay đối ứng với các trường hợp ngoại lệ. Vậy cách giải quyết callback hell như thế nào? Câu trả lời là không có cách nào giải quyết triệt để vấn đề này hết, cho đến khi ES6 giới thiệu về một đối tượng mới: Promise.

ES6: phiên bản thứ 6 của đặc tả ngôn ngữ EMCAScript do tổ chức Ecma International tạo ra. Javascript Engine – dùng để dịch mã Javascript, có thể là một trình thông dịch hay một trình biên dịch động – của các trình duyệt thường được tạo theo bộ đặc tả này

2. Promise

Promise là một đối tượng được sử dụng trong các xử lý bất đồng bộ, đại diện cho một thao tác chưa hoàn thành, đang đợi kết quả trong tương lai.  Một đối tượng Promise có thể có một trong ba trạng thái:

  • Pending: trạng thái ban đầu, chưa rõ kết quả của thao tác là thành công hay thất bại.
  • Fulfilled: thao tác có kết quả thành công
  • Rejected: thao tác có kết quả thất bại

Chúng ta không dùng một callback function trực tiếp trong tham số của hàm request nữa, sửa lời gọi hàm request(URL, processResult) về như ban đầu: request(URL). Tuy nhiên khác với ban đầu, bên trong hàm ta không xử lý trực tiếp kết quả mà trả về một promise:

function request(URL) {
  return new Promise(function(resolve, reject) {
    var xhttp = new XMLHttpRequest();
    //Bất đồng bộ 
    xhttp.onreadystatechange = function() {
      if (xhttp.readyState == 4 && xhttp.status == 200) {
        resolve(xhttp.responseText);
        //Chuyển trạng thái promise về fullfilled
      }
    };
    xhttp.open("GET", URL, true);
    xhttp.send();
  });
}

Hàm request(URL) lúc này chỉ làm một việc là định nghĩa một callback function để khởi tạo cho đối tượng Promise trả về. Callback function này phải có ít nhất hai tham số: resolve và reject. Cả hai đều là callback function được định nghĩa đi kèm với đối tượng Promise. Resolve là callback function để chuyển trạng thái của Promise từ pending sang fullfilled (đánh dấu thao tác thành công), reject là callback function để chuyển trạng thái Promise từ pending sang rejected (đánh dấu thao tác thất bại).

Ở đoạn code kể trên, đối tượng Promise lúc đầu trả về sẽ có trạng thái pending. Tháo tác gửi yêu cầu được thực hiện bên trong đối tượng Promise. Khi có kết quả trả về thành công, hàm resolve được gọi để chuyển trạng thái của đối tượng Promise về fullfilled.

Chúng ta có thể truyền các hàm xử lý kết quả vào cho đối tượng Promise tạo ra ở trên thông qua phương thức then của chính đối tượng Promise đó. Hàm then có thể nhận hai tham số, tham số đầu tiên là một callback function để xử lý kết quả khi Promise có trạng thái fullfilled, tham số thứ hai là một callback function để xử lý kết quả khi Promise có trạng thái rejected.

Ví dụ chúng ta truyền hàm log kết quả ra console vào làm tham số cho hàm then của đối tượng Promise:

request("www.google.com").then(logResult);

Kết quả trả về của hàm then là một Promise mới phụ thuộc vào kết quả của Promise trước và các hàm xử lý kết quả được truyền vào. Trong hàm logResult ở trên,  kết quả trả về là 1 nên nếu Promise tạo ra từ hàm request(“www.google.com”) có trạng thái là fullfilled, thì Promise mới tạo ra từ hàm request(“www.google.com”).then cũng có trạng thái là fullfilled và response là 1.

request("www.google.com").then(logResult).then(function(response) {
  console.log(response); // ==> 1;
});

Vậy sự khác nhau giữa dùng Promise với việc dùng callback function trực tiếp như ban đầu là gì? Là với Promise, bạn đã bẻ mối quan hệ trực tiếp giữa hàm gửi yêu cầu với hàm xử lý kết quả, đưa những xử lý tương tác giữa chúng vào trong đối tượng chung là Promise. Tương tự khi bạn muốn lấy kết quả từ đường dẫn A để gửi cho đường dẫn B, nhờ hàm then cũng trả về một Promise, thay vì các callback function lồng nhau như callback hell, chúng ta sẽ có một chuỗi đối tượng Promise nối tiếp nhau:

request(UrlA).then(function(responseA) {
  return request(UrlB + responseA);
}).then(function(responseB) {
  return request(UrlC + responseB);
}) ;

Rõ ràng với Promise, chúng ta đã có những đoạn code xử lý bất đồng bộ có vẻ dễ nhìn hơn nhiều so với trước. Nhưng một câu hỏi quen thuộc: nó còn có thể tốt hơn được nữa không?

3. Generator

Generator cũng là một đối tượng mới được đề xuất trong ES6. Với một hàm thông thường trong Javascript, khi bạn đã định nghĩa xong nội dung cũng như tham số của hàm, bạn sẽ không thể can thiệp gì khi hàm đó được thực thi. Hàm generator với khai báo có dấu * đằng trước thì khác, bạn có thể thực thi hàm đó từng bước, lấy ra các giá trị trung gian và thay đổi giá trị của các biến trong hàm. Một ví dụ:

function *example(x) {
  var y = 7 * x;
  var k = 7 * (yield y);
  return 7 * (yield k);
}

Ban đầu, ta sẽ tạo ra một đối tượng Generator từ hàm Generator kể trên:

var exampleObj = example(7);

Lúc này hàm example vẫn chưa được thực thi. Chúng ta gọi đến phương thức next của đối tượng exampleObj lần đầu tiên, hàm example sẽ thực thi đến câu lệnh yield thứ nhất. Giá trị biến y trong lệnh yield y sẽ được gán cho thuộc tính value của đối tượng được trả về từ phương thức next.

var res = exampleObj.next(777); // 777 vô nghĩa, không có tác dụng gì
console.log(res.value); // => 49 ( y = 7*7 = 49)

Chúng ta gọi đến phương thức next lần thứ hai. Lúc này giá trị tham số đầu tiên truyền vào phương thức next sẽ thay thế toàn bộ cụm (yield y) có trong hàm generator. Hàm example tiếp tục thực thi đến câu lệnh yield thứ hai. Giá trị biến k sẽ được gán cho thuộc tính value của đối tượng được trả về từ phương thức next.

var res = exampleObj.next(777);
console.log(res.value); // => 5439 ( k = 7*777 = 5439)

Chúng ta gọi đến phương thức next lần thứ ba.  Lần này giá trị tham số đầu tiên truyền vào phương thức next sẽ thay thế toàn bộ cụm (yield k) có trong hàm generator. Vì không có câu lệnh yield phía trước nào nên hàm example sẽ thực thi đến hết. Thuộc tính value của đối tượng được trả về từ phương thức next chính là giá trị trả về của hàm.

var res = exampleObj.next(7);
console.log(res.value); // => 49 ( return 7*7 = 49)

Vì phương thức example đã kết thúc, nên chúng ta có gọi đến phương thức next thêm lần nữa cũng không có ý nghĩa gì.

var res = exampleObj.next(777); // => 777 vô nghĩa, không có tác dụng gì 
console.log(res.value); // => undefined
console.log(res.done); // => true

Chúng ta có thể coi bắt đầu hàm, kết thúc hàm cộng với những câu lệnh yield là các cột mốc, còn câu lệnh next của đối tượng Generator giống như việc di chuyển giữa những cột mộc đó. Với các cột mốc yield, chúng ta vừa có thể lấy giá trị vừa có thể gán lại giá trị vào những cột mốc đó. Số khoảng cách giữa các cột mốt sẽ bằng số cột mốc trừ đi một, nên số câu lệnh next cần thiết để đi từ đầu hàm đến cuối hàm sẽ nhiều hơn một so với số câu lệnh yield trong hàm.

Generator rõ ràng là rất lý thú đúng không. Nhưng nó thì có liên quan gì đến Promise và những phần chúng ta đề cập ở trên nhỉ?

4. Promise & Generator

Generator giúp chúng ta có thể lấy ra và truyền lại các giá trị vào trong hàm. Ý tưởng ở đây là chúng ta sẽ dùng generator để lấy object Promise ra khỏi hàm chính. Đợi đến khi object Promise chuyển trạng thái từ pending sang fullfilled hoặc rejected, chúng ta sẽ đẩy response lấy được từ Promise đó trở lại hàm chính. Như vậy toàn bộ phần xử lý bất đồng bộ sẽ được tách ra khỏi hàm chính!

Chúng ta sẽ thử viết một hàm **runSimple **cơ bản:

function runSimple(main) {
  var it = main();
  var promise = it.next().value; // Dùng generator để lấy promise từ hàm main 
  return Promise.resolve(promise).then(function(response){ 
    // Tại sao lại dùng Promise.resolve(promise)? ==> Giải thích ở dưới 
    var res = it.next(response); // Đẩy lại giá trị response có được sau khi promise chuyển trạng thái vào lại hàm main 
    return res.value; // Giá trị trả về của hàm main; 
  });
}

Tại sao lại dùng Promise.resolve(promise)? Tại ta không thể đảm bảo giá trị promise lấy ra từ hàm generator có phải là một Promise hay không. Promise.resolve() cho phép chúng ta có thể tạo ra một promise từ một giá trị xác định hoặc từ một promise khác. Nếu tham số truyền vào Promise.resolve() là một giá trị xác định thì promise tạo ra sẽ có trạng thái là fullfilled và response thu được chính là giá trị đó. Nếu tham số truyền vào Promise.resolve() là một promise khác, thì promise tạo ra sẽ có trạng thái và response thu được giống với promise được truyền làm tham số.

Chúng ta có thể đưa hàm runSimple ở trên vào thư viện. Sau đó, mỗi lần gửi request đến đường dẫn nào đó, chúng ta chỉ cần viết một hàm như sau:

function *main() {
  var response = yield request("www.google.com"); // Promise từ hàm request có thể được đưa ra ngoài qua đối tượng Generator 
  logResult(response);
} 
runSimple(main);

Như vậy toàn bộ những đoạn code liên quan đến xử lý bất đồng bộ đã được che giấu. Bạn có thể viết code cho một xử lý bất đồng bộ (gửi yêu cầu đến một đường dẫn, lấy kết quả và ghi log ra console) hoàn toàn giống y như những đoạn code đồng bộ thông thường khác.

Tuy nhiên, hàm runSimple hiện tại vẫn còn quá yếu. Nó không cho phép truyền tham số vào hàm main. Nó cũng chỉ có phép trong hàm main có chứa một promise duy nhất. Chúng ta có thể cài đặt hàm run, một phiên bản cái tiến tốt hơn hẳn của hàm runSimple như sau:

function run(generator) { 
  // Tạo Generator với tham số lấy từ các tham số phía sau của hàm run. 
  // Ví dụ gọi hàm run(generator, y), thì biến y sẽ được đưa vào làm tham số của hàm generator. 
  var args = [].slice.call( arguments, 1), it;
  var generatorObj = generator.apply( this, args ); 

  // Promise.resolve() sẽ tạo ra một Promise rỗng. 
  // Tại sao lại tạo ra Promise rỗng mà không lấy luôn ra từ hàm generator như trong ví dụ runSimple? 
  // Vì để đảm bảo chạy được cả các hàm Generator không có Promise bên trong. 
  return Promise.resolve().then(
    // handleNext(value) là hàm đùng để truyền value vào trong hàm generator 
    // Vì Promise.resolve() tạo ra một promise rỗng, 
    // value truyền vào hàm handNext lúc này sẽ là undefined. 
    // Tuy nhiên vì đây là lời gọi next lần đầu tiên, nên giá trị value cũng không có ý nghĩa gì. 
    function handleNext(value){ 
      // Truyền value vào hàm generator, res là giá trị lấy ra được. 
      var res = generatorObj.next( value ); 
      // handResult: hàm xử lý dựa trên giá trị lấy ra đó 
      return (function handleResult(res){ 
        // nếu hàm generator kết thúc, trả về kết quả cuối cùng 
        if (res.done) { 
          return res.value; 
        } else { 
          // Gọi Promise.resolve() để đảm bảo có thể xử lý giá trị lấy ra như là một promise 
          return Promise.resolve( res.value ).then( 
            // Nếu promise lấy ra đã chuyển về trạng thái fullfilled, 
            // dùng hàm handleNext để tiếp tục truyền response của promise đó cho hàm generator handleNext, 
            // Nếu promise lấy ra đã chuyển về trạng thái rejected - thao tác không thanh công, 
            // dùng hàm hanldErr để xử lý lỗi 
            function handleErr(err) { 
              // Throw lỗi gặp phải về cho hàm generator, 
              // việc xử lý lỗi ra sao sẽ do người viết hàm generator quyết định hoàn toàn 
              return Promise.resolve( it.throw( err ) ) .then( handleResult ); 
              // Gọi handleResult để xử lý giá trị trả về từ hàm generator khi gặp lỗi 
            } 
          ); 
        } 
       })(res); 
   } 
  );
}

Với hàm run như trên, đoạn code mô tả callback hell lồng ghép lúc đầu sẽ được viết lại như sau:

function *main() {
  var respA = yield request(urlA);
  var resB = yield request(urlB + resA);
  var resC = yield request(urlC + resB);
  ...
}; 
run(main);

Khác hoàn toàn đúng không?

Có thể viết những dòng code đồng bộ cho các xử lý bất đồng bộ quả là một bước tiến kỳ diệu, nhưng hàm run kể trên có vẻ hơi phức tạp. Dù Javascript Engine của các trình duyệt hiện đại đã hỗ trợ Promise với Generator, song để kết hợp hai đối tượng đó với nhau cũng hơi lằng nhằng. Tin vui là bạn không cần phát minh lại cái bánh xe. Rút cục, mã nguồn mở để làm gì cơ chứ?

5. Thư viện co & co-request

Có không ít thư viện javascript đã hỗ trợ việc kết hợp giữa Promise + Generator. Một trong số đó là co (Tại sao tôi lại giới thiệu thư viện này chứ không phải thư viện khác? – vì sếp tôi bắt dùng). Trong đó hàm co đóng vai trò như hàm run tôi viết ở trên (Nhiều khả năng co của họ được viết tốt hơn). Nếu bạn muốn gửi request đến đường dẫn thì có thể dùng thêm co-request vơí hàm request trong đó cũng có vai trò tạo promise như hàm request tôi viết ở trên (Chắc chắn request của họ được viết tốt hơn)

Sau khi bạn cài hai thư viện vào dự án của mình, bạn có thể code đoạn callback hell mô tả ở đầu như sau:

var co = require("co");
var request = require("co-request");
co(function* () {
  var resA = yield request(urlA);
  var resB = yield request(urlB + resA);
  var resC = yield request(urlC + resB);
  .....
})

Dùng thư viện của người khác rõ ràng là một ý tưởng không tồi. Nhưng nếu vấn đề bạn cần dùng tới thư viện lại là một vấn đề phổ biến đến mức cứ đụng đến Javascript là gặp phải thì sao? Thí dụ, bạn đang trong một dự án X, cả team thống nhất là sử dụng thư viện A cho xử lý bất đồng bộ, đột ngột bạn lại bị chuyển sang dự án Y, lúc này cả team lại đang dùng thư viện B cho xử lý bất đồng bộ. Thư viện A với thư viện B có cú pháp hoàn toàn khác nhau. Kết quả là để xử lý một logic chương trình giống nhau bằng một ngôn ngữ lập trình giống nhau, bạn vẫn phải học lại. Đương nhiên thời gian học của bạn sẽ ngắn hơn thời gian học của những người chưa biết gì, nhưng mất thời gian vẫn là mất thời gian. Chưa kể bạn có thể dính dáng vào những cuộc tranh cãi bất tận mang tính tôn giáo, như thư viện A với thư viện B cái nào tốt hơn?

Để đối phó với những phức tạp như thế, chỉ có một tổ chức chúng ta có thể tin cậy, đó là Ecma International.

6. ES7: Async & await?

Kết hợp generator với promise đã thay đổi hoàn toàn việc lập trình những xử lý bất đồng bộ trong javascript. Nhận thấy được rõ ý nghĩa cũng như sự phổ biến trong tương lai của chúng, nhiều người đã đề xuất đến Ecma International để đưa các cú pháp chuẩn mực cho sự kết hợp này vào trong bộ đặc tả EMCAScript. Ý kiến được đa phần đồng tình là những hàm generator có các xử lý bất đồng bộ ở bên trong sẽ có cú pháp là async function. Và để thể hiện ý nghĩa của các dòng lệnh được tốt hơn, trong async function, cú pháp yield sẽ được thay thế bằng await.

Đoạn code liên quan callback hell khi đó sẽ thành thế này:

async function main() {
  var resA = await request(urlA);
  var resB = await request(urlB + resA); 
  var resC = await request(urlC + resB); 
  ..... 
} 
main();

Và sau khi Javascript Engine của các trình duyệt cài đặt những tính năng này theo EMCAScript, bạn có thể tạo ra những đoạn code xử lý bất đồng bộ một cách đẹp đẽ như trên, không cần đến một thư viện bên ngoài, thậm chí cũng không cần hiểu vì sao nó lại chạy được!

Bản ES7 đã được chính thức công bố vào tháng 6 năm nay. Rất tiếc là async function và await đã không được đưa vào bản đặc tả lần này. Trong khi hi vọng chúng sẽ xuất hiện trong ES8, các bạn có thể tạm thời dùng những thư viện và framework đã có hỗ trợ async function và await như babel chả hạn.

Tham khảo

Bài viết thực ra là sự tóm tắt, chuyền ngữ và thêm thắt từ cuốn You Don’t Know JS: Async & Performance. Bạn nào muốn tìm hiểu chi tiết hơn về Promise và Generator có thể tham khảo thêm trong sách.

Bạn có thể đọc miễn phí cả bộ You Don’t Know JS ở đây: https://github.com/getify/You-Dont-Know-JS

Chi tiết việc cài đặt đối tượng Promise các bạn có thể tham khảo một số thư viện Javascript mã nguồn mở. Ví dụ như đây. Lưu ý là không đam bảo JavaScript Engine của các trình duyệt như V8 JavaScript Engine(được viết bằng C++) cũng cài đặt đối tượng Promise giống như các thư viện trên.