Cách sử dụng Promise để code bất đồng bộ dễ dàng hơn (Phần 1)

JavaScript là một ngôn ngữ lập trình phía client, giúp chúng ta có những ứng dụng web đẹp hơn, thao tác dễ hơn, hiệu ứng cool hơn. Tuy nhiên, cách thức hoạt động của JavaScript hơi đặc thù một chút. Rất nhiều hoạt động của nó đều ở dạng bất đồng bộ (asynchronous).

Vì vậy, việc kiểm soát code để nó có thể hoạt động trơn tru cũng không phải là việc đơn giản. Trong bài viết này, chúng ta sẽ tìm hiểu những phương thức mới được giới thiệu từ ECMAScript 2015 trở đi, giúp chúng ta code JavaScript bất đồng bộ được dễ dàng hơn.

Cách truyền thống: dùng callback

Callback là tên mà chúng ta dùng để gọi các hàm JavaScript trong một trường hợp đặc biệt. Rất khó để định nghĩa chúng nhưng có thể rất dễ hiểu thông qua ví dụ dưới đây. Callback chỉ là tên được cộng động dùng, nó không có gì đặc biệt trong ngôn ngữ này cả.

Thuật ngữ bất đồng bộ (asynchronous, hoặc gọi ngắn là async) có thể hiểu rằng “sẽ mất một chút thời gian”, “sẽ hoàn thành trong tương lai, không phải bây giờ”. Callback là phương án được sử dụng phổ biến trong những hoạt động bất đồng bộ này.

Hoạt động bất đồng bộ của JavaScript diễn ra rất thường xuyên. Là một lập trình viên web, chắc hẳn bạn đã rất quen thuộc với những truy vấn kiểu ajax. Chúng ta có thể xem xét một ví dụ thực tế như sau:

  function loadScript(src) {
      const script = document.createElement('script');      script.src = src;      document.head.append(script);  
} 

Mục đích của hàm trên là để load một file JavaScript bằng JavaScript. Sau khi chạy hàm này, nó sẽ chèn thêm một thẻ <script src="${ src }"> </script> vào trong head, sau đó trình duyệt sẽ tải file này về và thực thi.

Cách sử dụng nó rất đơn giản:

 
//loads and executes the script  loadScript('/my_script.js'); 

Hàm này hoạt động một cách bất đồng bộ, bởi vì việc tải file script sẽ mất một chút thời gian. Việc gọi hàm sẽ bất đầu việc load script, việc load này sẽ được trình duyệt thực hiện “ngầm” bởi một tiến trình khác. Những code phía dưới hàm này sẽ tiếp tục được thực thi mà không cần đợi script được load. Thâm chí, nó có thể kết thúc trước cả việc script được load xong.

  loadScript('/my_script.js'); 
//Code dưới này sẽ được thực thi ngay là không chờ script load xong 

Việc hoạt động bất đồng bộ này không phải là vấn đề, chúng ta hoàn toàn không cần quan tâm. Tuy nhiên, có một vài trường hợp, khi load script mới, nó định nghĩa một số hàm và biến, và chúng ta cần sử dụng lại những thứ này. Điều này thường gặp khi chúng ta sử dụng các thư viện, như jQuery chẳng hạn:

  loadScript('//code.jquery.com/jquery-3.3.1.min.js');  $("#test").hide(); 

Rất tự nhiên, trình duyệt sẽ cần thời gian để tải thư viện jQuery về. Tuy nhiên, nó lại không chờ cho script được tải về mà sẽ ngay lập tức thực hiện lệnh tiếp theo.

Vì vậy, những code tiếp theo sẽ không thực thi được mà chúng ta sẽ gặp lỗi:

  Uncaught ReferenceError: $ is not defined 

Với cách làm như trên, chúng ta chưa có cách nào theo dõi trạng thái của việc load script. Nhưng nếu chúng ta muốn sử dụng những hàm và biến được định nghĩa trong script, chúng ta cần sử dụng một phương thức khác. Truyền callback là một cách phổ thông nhất.

  function loadScript(src, callback) {
      const script = document.createElement('script');      script.src = src;      script.onload = callback;      document.head.append(script);  
} 

Bây giờ, muốn sử dụng những gì được định nghĩa trong script, chúng ta có thể cho vào callback:

  loadScript('//code.jquery.com/jquery-3.3.1.min.js', () => {
   
//callback được gọi sau khi script load xong    $("#test").hide();    ...  
}); 

Ý tưởng của việc này rất đơn giản, chúng ta truyền một hàm làm tham số của hàm khác, hàm này gọi là callback. Và hàm đó sẽ được gọi khi sau khi thực hiện xong một số đoạn code cần thiết. Đó cũng là phương thức xưa nay vẫn thường được sử dụng. Bất cứ một hàm nào hoạt động bất đồng bộ cũng cần cung cấp một tham số dành riêng cho việc truyền callback.

Callback lồng nhau

Việc sử dụng callback như trên rất tốt. Nhưng mọi việc sẽ phức tạp hơn khi chúng ta cần load nhiều hơn một script.

  loadScript('//code.jquery.com/jquery-3.3.1.min.js', () => {
      console.log('jQuery loaded');      loadScript('//cdn.jsdelivr.net/npm/[email protected]/lodash.min.js', () => {
          console.log('lodash 2 loaded');          ...      
})  
}) 

Với cách gọi callback lồng nhau như trên, sau khi script thứ nhất load xong, callback sẽ gọi việc load script thứ hai.

Code như trên vẫn còn trông rất đẹp, nhưng nếu chúng ta có nhiều script hơn nữa thì sao:

  loadScript('script1.js', () => {
      loadScript('script2.js', () => {
          loadScript('script3.js', () => {
              loadScript('script4.js', () => {
                  ...              
})          
})      
})  
}) 

Việc sử dụng callback lồng nhau vẫn ổn nếu chúng ta lồng nhau ít cấp. Nhưng khi mức độ lồng nhau tăng lên, rõ ràng là không thể dùng cách này được. Mọi việc sẽ còn phức tạp hơn nữa khi các hoạt động bất đồng bộ này không phải lúc nào cũng thành công.

Xử lý khi gặp lỗi

Trong những ví dụ ở trên, chúng ta hoàn toàn không quan tâm đến trường hợp bị lỗi. Chúng ta nên nâng cấp code một chút để nó có thể xử lý thêm trường hợp này

  function loadScript(src, callback) {
      const script = document.createElement('script');      script.src = src;      script.onload = () => callback(null, script);      script.onerror = () => callback(new Error('script not loaded'));      document.head.append(script);  
} 

Việc sử dụng rất đơn giản, hàm được truyền làm callback cần có hai tham số, tham số thứ nhất là lỗi (nếu không có lỗi thì truyền vào null) và tham số thứ hai là script được load.

  loadScript('my_script.js', (error, script) => {
      if (error) {
         
//Có lỗi xảy ra khi load script        
} else {
         
//Script đã load xong      
}  
}); 

Việc định nghĩa callback như trên là theo phong cách error-first callback. Convention rất đơn giản: tham số đầu tiên dùng để truyền lỗi khi nó xảy ra. Những tham số tiếp theo dùng để truyền kết quả cho trường hợp bình thường (khi đó, tham số đầu tiên sẽ là null). Bằng cách này, chúng ta chỉ cần định nghĩa một callback cho cả trường hợp có lỗi và không.

Callback hell

Những trường hợp ở trên, chúng ta đã xem xét cách sử dụng callback cho các hoạt động bất đồng bộ. Và trong trường hợp cần thiết, chúng ta cần phải sử dụng callback trong callback, thậm chí lồng nhau vài lớp. Nhưng càng lồng nhau nhiều, nguy cơ mất kiểm soát code sẽ càng tăng lên.

  loadScript('script1.js', (error, script) => {
      if (error) {
          handleError(error);      
} else {
          loadScript('script2.js', (error, script) => {
              if (error) {
                  handleError(error);              
} else {
                  loadScript('script3.js', (error, script) => {
                      if (error) {
                          handleError(error);                      
} else {
                          loadScript('script4', (error, script) => {
                              if (error) {
                                  handleError(error);                              
} else {
                                 
//Code sau khi tất cả các hoạt động bất đồng                                 
//bộ hoàn thành.                              
}                          
})                      
}                  
})              
}          
})      
}  
}) 

Vâng, phải nói là trông code rất đẹp. Mọi việc rất đơn giản theo flow như sau:

  • Load script1.js, nếu không có lỗi thì tiếp tục.
  • Load script2.js, nếu không có lỗi thì tiếp tục.
  • Load script3.js, nếu không có lỗi thì tiếp tục.
  • Load script4.js, nếu không có lỗi thì bắt đầu xử lý logic chúng ta cần.

Với cách làm như thế này thì code có thể tiếp tục mở rộng thêm nữa mà không gặp vấn đề gì. Nhưng khi mọi thứ trở nên phức tạp hơn, việc lồng nhau mức độ cao hơn, đặc biệt, khi chúng ta có những code với vòng lặp, các câu lệnh điều kiện, rẽ nhánh, v.v… việc kiểm soát code sẽ trở nên cực kỳ khó khăn.

Vấn đề này trong lập trình nói chung được gọi là pyramid of doom (do code trông như xây kim tự tháp). Riêng trong JavaScript nó còn được gọi với tên gọi là khác callback hell.

Nguyên nhân của callback hell là khi chúng ta cố gắng viết code JavaScript theo kiểu tuần tự như những ngôn ngữ khác. Nhưng vì đặc thù của hoạt động bất đồng bộ, nên việc tuần tự này không thể thực hiện được. Callback hell thường xảy ra ở những lập trình viên còn ít kinh nghiệm, tuy nhiên, kể cả người đã đi làm nhiều năm vẫn có thể gặp phải, bởi cấu trúc code lồng nhau thật quá phức tạp.

Ví dụ với code ở trên thì mọi thứ vẫn chạy tốt, nhưng chỉ cần đóng mở ngoặc sai một ly thôi là đi luôn một dặm. Trang web này có đưa ra một số phương án để phòng tránh callback hell cũng khá hay, có thể áp dụng được. Tuy nhiên, trong bài viết này, chúng ta sẽ tìm hiểu một phương án còn hay hơn nữa.

Một cách đơn giản để trông code có vẻ đơn giản hơn, tránh code trông như kim tự tháp kia là định nghĩa các hàm và gọi chúng như sau:

  loadScript('script1.js', callbackAfterScript1);    callbackAfterScript1 = (error, script) => {
      if (error) {
          handleError(error);      
} else {
          loadScript('script2.js', callbackAfterScript2);      
}  
}    callbackAfterScript2 = (error, script) => {
      if (error) {
          handleError(error);      
} else {
          loadScript('script3.js', callbackAfterScript3);      
}  
}    callbackAfterScript3 = (error, script) => {
      if (error) {
          handleError(error);      
} else {
          loadScript('script4.js', callbackAfterScript4);      
}  
}    callbackAfterScript4 = (error, script) => {
      if (error) {
          handleError(error);      
} else {
         
//Code sau khi tất cả các hoạt động bất đồng         
//bộ hoàn thành.      
}  
} 

Bằng cách làm như trên, dù code không thay đổi về bản chất, nhưng kim tự tháp của chúng ta đã thấp đi đáng kể, bằng cách đó, callback hell sẽ khó xảy ra hơn. Mặc dù vậy, code này lại trở nên khó đọc hơn, để hiểu được hoạt động của nó, chúng ta phải do từ hàm này đến hàm khác. Nếu mức độ lồng nhau nhiều, thì việc làm này cũng tốn không ít thời gian.

May mắn cho chúng ta, từ khi ECMAScript 2015 (ES 6) ra đời, chúng ta đã có phương án tốt hơn rất nhiều để giải quyết.

Promise

Promise được giới thiệu kể từ ECMAScript 2015. Đây là một điểm sáng giúp chúng ta giải quyết các logic bất đồng bộ một cách tốt hơn.

Promise (lời hứa) có thể hiểu thế này: bạn hứa với mọi người sẽ làm việc XYZ và sẽ cho họ xem kết quả khi làm xong, nhưng bạn không biết chính xác khi nào thì sẽ xong. Họ cứ làm việc của họ trong lúc chờ đợi, khi công việc hoàn thành, bạn báo cho họ kết quả. Nếu chẳng may đại sự bất thành, bạn cũng thông báo cho họ không phải chờ nữa.

Như vậy, lời hứa được đảm bảo, ai nấy đều vui vẻ cả. Promise cũng được thiết kế với ý tưởng tương tự như vậy.

Một vài hoạt động bất đồng bộ, nó cần thời gian để hoàn thành, như ví dụ, đó là load một script khác. Rất nhiều code khác đang chờ công việc đó hoàn thành, promise là lời hứa mà loadScript đưa cho họ. Khi nào loadScript hoàn thành, những ai đang chờ sẽ được thông báo, kể cả load thất bại thì việc thất bại đó cũng được thông báo luôn.

Tạo promise

Promise được tạo ra như sau:

  let promise = new Promise((resolve, reject) => {
     
//code thực hiện logic  
}); 

Hàm được truyền vào để khởi tạo Promise được gọi là “executor”. Hàm này sẽ được thực thi khi promise được tạo ra. Khi executor kết thúc, nó phải gọi một trong số hai hàm resolvereject.

  • Gọi resolve khi code chạy thành công, công việc kết thúc mà không có lỗi gì. Khi đó, state của đối tượng promise sẽ là fulfilled (trạng thái khởi tạo là pending), đồng thời result của đối tượng promise sẽ là giá trị của tham số được truyền cho resolve.
  • Gọi reject nếu có lỗi xảy ra, khi đó state của đối tượng promise sẽ là rejected, đồng thời result cũng sẽ là tham số được truyền vào cho reject.

Quay lại trường hợp loadScript ở trên, chúng ta sẽ chuyển sang dùng promise như sau, không cần phải truyền callback vào nữa.

  function loadScript(src) {
      return new Promise((resolve, reject) => {
          const script = document.createElement('script');          script.src = src;          script.onload = () => resolve(script);          script.onerror = () => reject(new Error('script not loaded'));          document.head.append(script);      
});  
} 

Giờ hàm này vẫn gọi như bình thường, còn callback đi đâu chúng ta sẽ tìm hiểu tiếp ở những phần tiếp theo.

  loadScript('//code.jquery.com/jquery-3.3.1.min.js'); 

Việc thực thi sẽ diễn ra như sau:

  • Executor sẽ được thực thi khi khởi tạo promise mới.
  • Executor có hai tham số là resolvereject, đây chính là hai hàm cần phải được gọi khi hoàn thành. Đây là hàm được cung cấp sẵn, chúng ta không cần quan tâm đến nó, chỉ cần sử dụng là được.
  • Executor hoạt động và tuỳ vào kết quả nó sẽ gọi đến một trong hai hàm trên.

Một lưu ý rằng, resolve hoặc reject chỉ có thể được gọi một lần. Dù chúng ta có gọi nhiều lần thì chỉ lần gọi đầu tiên có tác dụng:

  new Promise((resolve, reject) => {
      resolve('first resolve');      reject('error');      resolve('second resolve')  
}) 
//trả về Promise {

<resolved>: "first resolve"
} 
//gọi resolve, reject tiếp theo cũng không có tác dụng 

Ngoài ra, các hàm resolvereject có thể nhận số lượng tham số tuỳ ý, nhưng sẽ chỉ có tham số đầu tiên được sử dụng làm result cho đối tượng promise, những tham số tiếp theo sẽ bị bỏ qua. Hành động resolve hoạt reject promise này được gọi với thuật ngữ settle promise đó.

Vậy là chúng ta đã tạo ra đối tượng promise, công việc bây giờ mà chúng ta cần là tìm cách gọi những code tiếp theo cần thực thi khi công việc bất đồng bộ này thành công. Đó cũng chính là nội dung của phần tiếp theo

Sử dụng thencatch

Promise cho phép chúng ta liên kết các hoạt động bất đồng bộ với những code cần thực thi (những code cần đến kết quả của hoạt động kia) sau đó rất dễ dàng. Những điều đó có thể thực hiện thông qua .then như sau:

  promise.then(      result => {
         
//Code trong trường hợp thành công      
},      error => {
         
//Code trong trường hợp lỗi      
}  ); 

Tham số đầu tiên được thực thi khi promise được resolve và thành công, còn tham số thứ hai được gọi khi promise bị reject trong trường hợp lỗi.

Với ví dụ loadScript ở trên, chúng ta có thể thực hiện đơn giản thế này:

  loadScript('//code.jquery.com/jquery-3.3.1.min.js')          .then(              () => {
                  $("#test").hide();              
},              error => {
                  console.log(error);              
}          ); 

Nếu chúng ta chỉ cần quan tâm đến trường hợp thành công, còn lỗi thì bỏ qua, chúng ta chỉ cần dùng 1 tham số cho then là đủ:

  loadScript('//code.jquery.com/jquery-3.3.1.min.js').then(() => {
      $("#test").hide();  
}); 

Nếu chúng ta chỉ quan tâm đến lỗi, chúng ta có thể dùng then(null, errorCallback). Và trong trường hợp này, catch cho chúng ta cú pháp đẹp hơn:

  loadScript('//code.jquery.com/jquery-3.3.1.min.js').catch(error => {
      console.log(error);  
}) 

Việc gọi catchthen(null, function) hoàn toàn giống nhau, chúng ta có thể dùng loại nào mình cảm thấy thích. Những hàm được truyền vào then hoặc catch luôn luôn được đảm bảo rằng, chúng chỉ được thực thi khi nào promise được resolve hoặc reject mà thôi. Vì vậy, mọi việc hoạt động bất đồng bộ vẫn luôn được đảm bảo mà không cần lo về lỗi khi code được thực thi khi đang chờ code khác.

Một lưu ý nữa là các đối tượng promise luôn đóng gói stateresult, chúng ta không thể truy cập nó từ bên ngoài, mọi thao tác với promise đều cần phải sử dụng những API được cung cấp, then, catch là một trong số chúng.

Cơ chế try...catch ngầm

thencatch của promise có một cơ chế rất hay: Nếu có exception, cho dù promise không bị reject, callback vẫn ngầm hiểu rằng promise này trạng thái là rejected. Ví dụ:

  new Promise(() => {
      throw new Error();  
}).then(      result => console.log(result),      () => console.log('Error occured')  ) 
//Error occured 

Nó hoạt động hoàn toàn giống với:

  new Promise((resolve, reject) => {
      reject(new Error());  
}).then(      result => console.log(result),      () => console.log('Error occured')  ) 

 

Leave a Reply

avatar
  Subscribe  
Notify of