Đây là bài dịch, bài gốc mời các bạn xem ở đây: https://pouchdb.com/2015/05/18/we-have-a-problem-with-promises.html


Xin chào các JavaScripters ! Giờ đã đến lúc phải thừa nhận: Chúng ta có vấn đề với Promises.

Không, không phải với với chính promises. Promises, theo như A+ Spec, vẫn là một tính năng tuyệt vời.

Vấn đề ở đây - như là tôi đã nhận ra sau nhiều năm, trong khi tôi nhìn thấy rất , rất nhiều programmer gặp vấn đề với PouchDB API hay những API khác sử dụng promies - đó là:

Rất nhiều người đang sử dụng promises mà không thực sự hiểu về nó.

OK, hãy xem bài quiz nhỏ mà tôi đã post trên Twitter này:

Điểm khác nhau giữa 4 dòng code sử dụng promises này là gì ?

doSomething().then(function () {
  return doSomethingElse();
});

doSomething().then(function () {
  doSomethingElse();
});

doSomething().then(doSomethingElse());

doSomething().then(doSomethingElse);

Nếu bạn biết câu trả lời, thì xin chúc mừng: Bạn là 1 promises ninja ! Và bạn có thể tắt luôn cái tab đang đọc này đi cũng được.

Với 99.99% còn lại ... Well ... Không một ai trả lời tweet của tôi có thể giải chính xác nó, và bản thân tôi cũng bị bất ngờ bởi đáp án của #3 - Đúng vậy, dù tôi là người tạo cái quiz này !

Câu trả lời sẽ nằm ở cuối bài post này, nhưng trước hết, tôi muốn biết vì sao promises lại phức tạp như vậy, và tại sao rất nhiều người - cả lính mới và cả những expert - đều mắc lỗi khi sử dụng nó.

Hãy bắt đầu với một số giả định phổ biến về promises.

Promises để làm gì ?

Ví dụ phổ biến nhất mà người mới tìm hiểu về Promises sẽ gặp đó là the pyramid of doom, với một đoạn code trông rất tởm - kéo dài liên tục cho tới khi nó chạm tới viền phải màn hình.

Promises - thực tế, giải quyết được vấn đề trên - không đơn giản chỉ là để làm đẹp indentation. Như đã được giải thích trong "Redemtion from callback hell", vấn đề thực sự của callback đó là việc nó lấy đi của ta returnthrow. Thay vào đó, toàn bộ flow chương trình của ta bị phụ thuộc vào side effect : một function sẽ (ngẫu nhiên) gọi tới một function khác.

Toàn bộ mục đích của promises là cho phép chúng ta sử dụng những nguyên tắc cơ bản của ngôn ngữ mà chúng ta không dùng được với async programing : return, throw, và luồng chương trình. Nhưng đó là khi ta thực sự hiểu và biết cách sử dụng đúng lợi thế của promises.

Rookie mistakes

Nhiều người cố gắng giải thích promises như là : "Oh, nó là một thứ mà chúng ta có thể chuyền xung quanh và nó đại diện cho một giá trị bất đồng bộ"

Tôi không đồng ý với cách giải thích đó cho lắm. Với tôi, promises là về cấu trúc của code và luồng chương trình. Vì vậy, tôi nghĩ rằng tốt hơn là mình sẽ chỉ ra một vài lỗi sai phổ biến dưới đây, kèm theo cách fix. Rookie mistake - như cách tôi dùng ở đây - có nghĩa là " Giờ các chú vẫn còn noob lắm :) nhưng cứ làm đi, sau này sẽ thành pro.

Rookie mistakes #1: Promisey pyramid of doom

Ta sử dụng promises để tránh cái gọi là callback hell - pyramid of doom. và nếu không cẩn thận, ta sẽ lại mắc vào một cái bẫy tương tự với promises !

Hãy quan sát cách mọi người sử dụng PouchDB - một API phụ thuộc lớn vào promises - tôi nhìn thấy rất nhiều sai lầm trong cách tiếp cận. Lỗi phổ biến nhất là cái này đây:

remotedb.allDocs({
  include_docs: true,
  attachments: true
}).then(function (result) {
  var docs = result.rows;
  docs.forEach(function(element) {
    localdb.put(element.doc).then(function(response) {
      alert("Pulled doc with id " + element.doc._id + " and added to local db.");
    }).catch(function (err) {
      if (err.name == 'conflict') {
        localdb.get(element.doc._id).then(function (resp) {
          localdb.remove(resp._id, resp._rev).then(function (resp) {
// ........................

Hóa ra ta có thể sử dụng promises giống như sử dụng callback (mặc dù nó giống như sử dụng cái cưa máy chỉ để cắt móng chân vậy).

Nếu bạn nghĩ rằng chỉ có mấy thằng lính mới mới gặp phải lỗi trên, thì bạn sẽ bất ngờ khi nhìn vào đoạn code trên trong blog chính thức của BlackBerry developer ! (P/s: To the developer: xin lỗi vì đã sử dụng code của các anh, nhưng mà nó rõ ràng quá. :) )

Thử chỉnh sửa đoạn code trên xem:

remotedb.allDocs(...).then(function (resultOfAllDocs) {
  return localdb.put(...);
}).then(function (resultOfPut) {
  return localdb.get(...);
}).then(function (resultOfGet) {
  return localdb.put(...);
}).catch(function (err) {
  console.log(err);
});

Đẹp hơn rồi ! và cái trên được gọi là composing promises - Mỗi function sẽ chỉ được gọi khi mà promises trước đó đã được resolve, và được gọi với chính output của promise trước đó.

Tạm gác cái này là đã !

Rookie mistake #2: WTF, làm sao để sử dụng forEach() với Promises ???

Đây là nơi mà hầu hết mọi người bắt đầu hiểu sai về cách dùng promises. Hầu hết mọi người không biết cách sử dụng forEach() (hay for, hay while ...) với promises. Đây là cách mà rất nhiều người viết:

// Tôi muốn remove() tất cả các tài liệu
db.allDocs({include_docs: true}).then(function (result) {
  result.rows.forEach(function (row) {
    db.remove(row.doc);  
  });
}).then(function () {
  // Ngây thơ làm sao - "chắc là các doc đã bị remove() hết rồi !"
});

Vấn đề của đoạn code trên là gì ?

Hóa ra, function đầu tiên sẽ trả về undefined - điều đó có nghĩa là function thứ 2 sẽ không chờ cho db.remove() được gọi xong với tất cả các documents. Trên thực tế, cái function thứ hai chả chờ cái gì cả , và nó sẽ thực thi ngay cả khi chưa có cái doc nào bị xóa hết cả !

Cái bug này rất nguy hiểm, vì ta có thể không nhận ra có gì đó chạy sai, với giả thuyết là PouchDB chạy đủ nhanh và các docs cũng bị xóa đủ nhanh để đồng bộ với thay đổi trên UI. Bug sẽ chỉ hiện ra với một vài điều kiện cá biệt, hoặc viết một vài browser nào đó, và khi đó sẽ vô phương để debug ra lỗi này.

Giải quyết ở đây là gì ?
Hãy nhớ, thay vì sử dụng forEach() /for/while, hãy sử dụng Promise.all():

db.allDocs({include_docs: true}).then(function (result) {
  return Promise.all(result.rows.map(function (row) {
    return db.remove(row.doc);
  }));
}).then(function (arrayOfResults) {
  // Đảm bảo mọi docs đã bị remove() !
});

Promise.all() là hàm nhận một mảng promises làm input, và nó sẽ trả về cho bạn một promises mới mà chỉ resolve một khi tất cả các promise trên đã resolve. Nó tương đương với một vòng lặp for bất đồng bộ.

Promise.all() cũng sẽ truyền một mảng results tới function kế tiếp , cái rất có thể hữu ích trong trường hợp ta muốn get() nhiều thứ từ PouchDB. all() cũng đảm bảo rằng promises trả về sẽ bị reject nếu một trong các promises đầu vào bị reject.

Rookie mistake #3: quên không thêm .catch()

Đây cũng là một lỗi phổ biến. Đừng có chủ quan rằng promises mình viết không bao giờ ném ra error nhé ! Rất nhiều dev quên mất thêm .catch() vào code của mình. Điều này có nghĩa là nếu có error bị bắn ra, nó sẽ biến mất ngay, và thậm chí ta còn không thấy nó trong console.

Để tránh cái lỗi phiền phức này, tôi đã tự hình thành được một thới quen tốt - tự động thêm đoạn code sau vào code promises của mình:

somePromise().then(function () {
  return anotherPromise();
}).then(function () {
  return yetAnotherPromise();
}).catch(console.log.bind(console)); // <-- this is badass

Luôn luôn - kể cả khi bạn không bao giờ nghĩ rằng sẽ có error cả - luôn luôn thêm vào catch(). Nó sẽ khiến bạn dễ thở hơn đấy, nếu như cái suy nghĩ trên của bạn sai.

Rookie mistake #4: sử dụng "deferred"

Đây là cái lỗi mà tôi thấy thường xuyên nhất.

Nói ngắn gọn, vào những ngày đầu tiên của promises, jQuery và Angular sử dụng "deferred" mọi chỗ, thứ mà bây giờ đã bị thay thế bằng Promises trong ES6.

Thế nên, nếu bạn vẫn sử dụng từ này trong code của mình, đó có nghĩa là bạn đang làm gì đó sai sia !

Thay vì đó , hầu hết các thư viện bây giờ cho phép bạn "import" promises từ các thư viện third-party. Ví dụ, Angular có $q cho phép ta wrap các promises sử dụng $q.when(). Angular coder có thể sử dụng promises PouchDB như sau:

$q.when(db.put(doc)).then(/*  xử lý code tiếp theo */)

Một cách khác đó là sử dụng pattern revealing constructor, thứ rất hữu dụng khi muốn wrap các non-promises API. Ví dụ, để wrap một API dạng callback như API sau của Node fs.readFile(), ta có thể làm như sau:

new Promise(function (resolve, reject) {
  fs.readFile('myfile.txt', function (err, file) {
    if (err) {
      return reject(err);
    }
    resolve(file);
  });
}).then(/* ... */)

Rookie mistake #5: sử dụng side effect thay vì return

Có vấn đề gì với code này ?

somePromise().then(function () {
  someOtherPromise();
}).then(function () {
  // Hi vọng rằng cái someOtherPromies() kia đã resolved rồi.
  // ...
  // Spoiler alert: Có cái *beep* !
});

Ok, đây là lúc thích hợp để nói về tất cả những gì mà bạn cần biết về promises đây.

Thật đấy, đây là cái trick mà - khi bạn thực sự nắm được nó - sẽ giúp bạn tránh gặp phải tất cả những cái lỗi mà tôi đã nói ở trên. Sẵn sàng chưa ??

Như tôi đã nói ở trên, magic của promises ở chỗ nó cho phép ta sử dụng returnthrow. Nhưng 2 cái này thì có tác dụng gì trên thực tế ?

Tất các các promises đều cho bạn hàm then() (hoặc catch(), cái mà viết tắt của then(null, ...)). Hãy quan sát 1 hàm then() xem:

somePromise().then(function () {
  // bên trong 1 hàm then() !
});

Ta có thể là gì ở chỗ này nhỉ ? Có 3 hướng như sau:

  1. return một cái promise khác.
  2. return một giá trị đồng bộ (hoặc undefined)
  3. throw một cái error

Vậy đó. Một khi bạn nắm chắc được, bạn sẽ hiểu rõ về promise. Hãy lần lượt xem từng trường hộp một.

trả về 1 cái promise khác

Pattern này được sử dụng phổ biến - như bạn đã thấy trong cái ví dụ về composing promises ở trên:

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // Ở đây tôi đã lấy đợc user account !
});

Chú ý tới cái return trong cái promise thứ 2 => điểm thiết yếu đấy ! Nếu ở đây tôi không gọi return, thì cái getUserAccountById() sẽ trở thành side effect, và function tiếp theo sau đó sẽ nhận được undefined thay vì userAccount !!

trả về 1 giá trị (hoặc undefined)

Trả về undefined thông thường là sai lầm, nhưng trả về một synchronous value là cách để biến một đoạn code đồng bộ trở thành code theo phong cách promises. Ví dụ, coi như chúng ta đang có 1 danh sách users lưu trong bộ nhớ :

getUserByName('nolan').then(function (user) {
  if (inMemoryCache[user.id]) {
    return inMemoryCache[user.id];    // Trả về synchronous value!
  }
  return getUserAccountById(user.id); // Trả về promise!
}).then(function (userAccount) {
  // Có user account ở đây !
});

Tuyệt không ? function thứ 2 không cần quan tâm cái userAccount được lấy về theo kiểu đồng bộ hay bất đồng bộ, và function đầu tiên cũng có thể trả về giá trị theo kiểu nào cũng được.

Tuy nhiên, cần chú ý rằng function không có giá trị trả về trong javascript - thực tế chúng là về undefined => cực kì dễ xảy ra side effect.
Vì lý do trên, tôi lại tự hình thành một thói quen khác khi luôn luôn return hoặc throw bên trong then(), và tôi cũng khuyên các bạn nên làm vậy !

ném ra một cái error

Nói về throw, đây là thứ làm cho promises càng tuyệt vời hơn !

getUserByName('nolan').then(function (user) {
  if (user.isLoggedOut()) {
    throw new Error('user logged out!'); // Ném ra một cái synchronous error!
  }
  if (inMemoryCache[user.id]) {
    return inMemoryCache[user.id];       // Trả về một cái synchronous value!
  }
  return getUserAccountById(user.id);    // returning a promise!
}).then(function (userAccount) {
  // Lấy được user account !
}).catch(function (err) {
  // Bắt được error !
});

catch() của ta sẽ bắt được synchronous error nếu như user đã log out, và bắt được asynchronous error nếu bất kì một promises nào bị reject. Một lần nữa, function không liên quan tới cái error bị bắn ra bằng cách gì.

Tưởng tượng, trong đoạn then() của ta có sử dụng JSON.parse(), nó sẽ bắn ra synchronous error nếu như đoạn JSON đó bị lỗi. Với callback, lỗi đó sẽ không bị bắt lại !

Advanced mistakes

Okay, bạn đã biết được những cái lỗi cơ bản khi dùng promises, hãy cùng tìm hiểu các trường hợp tiếp theo.

Những lỗi dưới đây tôi gọi chúng là advanced, bởi vì tôi chỉ gặp chúng ở những programmer nào đã quen thuộc với promises rồi. Và khi giải quyết xong đống lỗi này, chúng ta sẽ có khả năng giải cái quiz ở đầu bài viết:

Advanced mistake #1: không biết về Promise.resolve()

Như tôi đã nói ở trên, promises rất hữu dụng khi muốn dùng synchronous code như là asynchronous code. Tuy nhiên, nếu bạn thấy code mình sử dụng nhiều:

new Promise(function (resolve, reject) {
  resolve(someSynchronousValue);
}).then(/* ... */);

Bạn có thể viết code trên ngắn hơn như sau:

Promise.resolve(someSynchronousValue).then(/* ... */);

Cách này khá hữu dụng khi muốn bắt synchronous error. Thực tế, tôi cũng đã hình thành thêm 1 thói quen nữa của mình:

function somePromiseAPI() {
  return Promise.resolve().then(function () {
    doSomethingThatMayThrow();
    return 'foo';
  }).then(/* ... */);
}

Bằng việc wrap tất cả mọi thứ trong Promise.resolve(), ta luôn có thể catch() nó sau này, đảm bảo rằng những code nào có khả năng throw ra đều sẽ không bị bỏ sót khi debug.

Tương tự, cũng có Promise.reject() mà ta có thể dùng để trả về 1 promise cần reject:

Promise.reject(new Error('some awful error'));

Advanced mistake #2: catch() không hoàn toàn giống với then(null, ...)

Như tôi đã nói ở trên, catch() nó chỉ như một suger-syntax mà thôi, tức là dù 2 snippet dưới đây là tương đương:

somePromise().catch(function (err) {
  // xử lý lỗi
});

somePromise().then(null, function (err) {
  // xử lý lỗi
});

Nhưng 2 snippet dưới đây thì lại không tương đương:

somePromise().then(function () {
  return someOtherPromise();
}).catch(function (err) {
  // xử lý lỗi
});

somePromise().then(function () {
  return someOtherPromise();
}, function (err) {
  // xử lý lỗi 
});

Nếu bạn thắc mắc tại sao, thì hãy xem điều gì xảy ra khi function đầu tiên throw error:

somePromise().then(function () {
  throw new Error('oh noes');
}).catch(function (err) {
  // Bắt được error ! :)
});

somePromise().then(function () {
  throw new Error('oh noes');
}, function (err) {
  // Không bắt được error !!! :(
});

Hóa ra, khi ta sử dụng then(resolveHandler, rejectHandler), thì rejectHandler không thực sự bắt error nếu như nó được bắn ra bởi resolveHandler !!!

Vì lý do này, bạn nên có thói quen không bao giờ sử dụng argument thứ 2 của then(), thay vào đó nên ưu tiên cho catch(). Chỉ có 1 ngoại lệ, đó là khi tôi viết test bằng Mocha , khi mà tôi cần viết test với trường hợp cần bắn ra error:

it('should throw an error', function () {
  return doSomethingThatThrows().then(function () {
    throw new Error('I expected an error!');
  }, function (err) {
    should.exist(err);
  });
});

Lại nói về cái này: MochaChai là sự lựa chọn rất tuyệt khi muốn test promise API đấy !

Advanced mistake #3: promises vs promise factories

Hãy xét trường hợp ta cần chạy 1 chuỗi các promises, cái này tiếp sau cái khác. Ta sẽ muốn 1 cái gì đó giống giống như Promise.all() - nhưng chạy các promises lần lượt mà không phải là song song nhau.

Bạn có thể nghĩ đoạn code sau sẽ chạy:

function executeSequentially(promises) {
  var result = Promise.resolve();
  promises.forEach(function (promise) {
    result = result.then(promise);
  });
  return result;
}

Không may, đoạn code trên sẽ không chạy như bạn nghĩ đâu ! Các promises mà bạn truyền vào executeSequentially() sẽ vẫn thực hiện song song.

Dựa theo như nguyên lý của promise, ngay khi 1 promise được tạo ra , nó sẽ bắt đầu thực hiện luôn. Vì vậy, cái mà bạn thực sự muốn ở đây là 1 mảng các promise factories:

function executeSequentially(promiseFactories) {
  var result = Promise.resolve();
  promiseFactories.forEach(function (promiseFactory) {
    result = result.then(promiseFactory);
  });
  return result;
}

Tôi biết bạn đang nghĩ gì :) "Cái thằng nào lại đi viết code Java ở đây thế này ?? Và sao hắn lại nói tới factories ??"
Một promise factory thực ra - rất đơn giản - chỉ là 1 function mà trả về 1 promise:

function myPromiseFactory() {
  return somethingThatCreatesAPromise();
}

Tại sao lại vậy ? Đoạn code này chạy được bởi vì promise factory không tạo ra 1 promise cho tới khi được yêu cầu.. Nó có cùng 1 nguyên lý với hàm then() - trên thực tế, nó giống hệt then()!

Hãy nhìn vào function executeSequentially(), sau đó tưởng tượng myPromiseFactory thay thế cho result.then(...) ... !!! Bạn đã thấy mình được khai sáng gì chưa :)

Advanced mistake #4: okay, nếu tôi muốn kết quả của 2 promise ?

Trong nhiều trường hợp, 1 promise sẽ phụ thuộc vào 1 promise khác, nhưng ta lại cần kết quả của cả 2. Ví dụ:

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // đệt ! tôi cần cả "user" ở đây nữa !
});

Để tránh cái pyramid of doom, ta có thể tạo biến user ở scope bên ngoài:

var user;
getUserByName('nolan').then(function (result) {
  user = result;
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // okay, giờ thì xài được cả "user" với "userAccount"
});

Code trên chạy, nhưng mà có vẻ hơi xấu ! Thay vào đó, bạn có thể thử cách này: Cứ thử bỏ qua những cái quy tắc phía trên và thử viết code theo dạng pyramid xem:

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id).then(function (userAccount) {
    // Cũng xài được cả "user""userAccount"
  });
});

... Ít nhất là thế. Nếu bạn bận tâm về indent, thì bạn cũng có thể làm theo cách khác - tách function ra thành các named function:

function onGetUserAndUserAccount(user, userAccount) {
  return doSomething(user, userAccount);
}

function onGetUser(user) {
  return getUserAccountById(user.id).then(function (userAccount) {
    return onGetUserAndUserAccount(user, userAccount);
  });
}

getUserByName('nolan')
  .then(onGetUser)
  .then(function () {
  // lúc này, function doSomething() đã hoàn thành, và ta cũng không gặp vấn đề về indent.
});

Theo thời gian, code của ta càng ngày càng phức tạp, code ta cần tách ra ngày càng nhiều. Điều đó dẫn tới code của ta có thể trông giống như:

putYourRightFootIn()
  .then(putYourRightFootOut)
  .then(putYourRightFootIn)  
  .then(shakeItAllAbout);

Advanced mistake #5: promises không thành công

Cuối cùng, đây là cái lỗi mà tôi ám chỉ khi đề ra cái quiz ở đầu bài. Đây là 1 use case rất dị, có thể nó sẽ không bao giờ xuất hiện trong code của bạn, nhưng nó cũng đã làm tôi ngạc nhiên.

Bạn nghĩ dòng code dưới đây sẽ in ra cái gì ?

Promise.resolve('foo').then(Promise.resolve('bar')).then(function (result) {
  console.log(result);
});

Nếu bạn nghĩ nó in ra bar - Sai rồi !! Nó thực sự sẽ in ra foo !!!

Lý do của việc này đó là khi bạn truyền vào pass() 1 thứ không-phải-là-function (chẳng hạn như promise), nó thực chất được chuyển thành then(null), khi đó nó sẽ trả về result của promise trước đó. Bạn có thể tự mình test:

Promise.resolve('foo').then(null).then(function (result) {
  console.log(result);
});

Thử đi ! Dù bạn có thêm bao nhiêu then(null) đi chăng nữa - kết quả nó code nó vẫn in ra foo.

Quay ngược về cái điều mà tôi đã nói từ trước về promises và promise factories. Ngắn gọn, bạn có thể truyền 1 promise trực tiếp vào then(), nhưng nó sẽ không thực hiện như bạn nghĩ đâu. then() được kì vọng là sẽ nhận 1 function đầu vào, vì thế nên bạn sẽ muốn làm như sau:

Promise.resolve('foo').then(function () {
  return Promise.resolve('bar');
}).then(function (result) {
  console.log(result);
});

Code bây giờ sẽ in ra bar như bạn mong muốn rồi nhé.

Ghi nhớ:

Luôn luôn truyền 1 function vào then() !

Giải câu đố

Giờ đây - khi mà bạn đã học được gần như đầy đủ những gì cần biết về promises - bạn đã có thể giải cái quiz mà tôi đưa ra ở phía đầu.

Tôi sẽ đưa nó ra ở dạng biểu đồ cho dễ nhìn nhé:

Puzzle #1

doSomething().then(function () {
  return doSomethingElse();
}).then(finalHandler);

Đáp án

doSomething
|-----------------|
                   doSomethingElse(undefined)
                   |------------------|
                                       finalHandler(resultOfDoSomethingElse)
                                       |------------------|

Puzzle #2

doSomething().then(function () {
  doSomethingElse();
}).then(finalHandler);

Đáp án

doSomething
|-----------------|
                   doSomethingElse(undefined)
                   |------------------|
                   finalHandler(undefined)
                   |------------------|

Puzzle #3

doSomething().then(doSomethingElse())
  .then(finalHandler);

Đáp án

doSomething
|-----------------|
doSomethingElse(undefined)
|---------------------------------|
                  finalHandler(resultOfDoSomething)
                  |------------------|

Puzzle #4

doSomething().then(doSomethingElse)
  .then(finalHandler);

Đáp án

doSomething
|-----------------|
                  doSomethingElse(resultOfDoSomething)
                  |------------------|
                                     finalHandler(resultOfDoSomethingElse)
                                     |------------------|

Nếu bạn vẫn chưa thể hiểu được đáp án, hãy đọc lại từ đầu 1 lần nữa , hoặc tự mình viết những dòng code và test bằng tay.

Kết luận

Promise are greate ! Nếu bạn vẫn dùng callback, tôi thực lòng khuyến khích bạn đổi sáng promises. Code của bạn sẽ trở nên bé hơn, ngắn gọn và dễ hiểu hơn đấy !