文章加密

;

2019年12月20日 星期五

Callback(回調)是什麼? 這裡面還有幾個延伸連結,像mongoDB, fs等等可以讀讀

Callback(回調)是什麼?

中文翻譯字詞會用"回呼""、"回叫"、"回調",都是聽起來很怪異的講法,Callback在英文是"call back"兩個單字的合體,你應該有聽過"Call me back"的英文,實際情況大概是有客戶打來電話給你,可是你正在電話中,客戶會留話說請你等會有空時再"回電"給它,這裡的說法是指在電信公司裡的callback的涵意。而在程式開發上上,callback它的使用情境其實也是有類似的地方。

CPS風格與直接風格

延續傳遞風格(Continuation-passing style, CPS),它的對比是"直接風格(Direct style)",這兩種是程式開發時所使用的風格,CPS早在1970年代就已經被提出來了。CPS用的是明確地移轉控制權到下一個函式中,也就是使用"延續函式"的方式,一般稱它為"回調函式"或"回調(Callback)"。回調是一個可以作為傳入參數的函式,用於在目前的函式呼叫執行最後移交控制權,而不使用函式回傳值的方式。直接風格的控制權移交是不明確的,它是用回傳值的方式,然後進行到下一行程式碼或呼叫接下來其他函式。下面以範例來說明會比較容易。
直接風格的範例如下,其實就是一般函式呼叫的方式,或是用回傳的方式:
//直接風格
function func(x) {
  return x
}
CPS風格就不是這樣,它會用另一個函式作為函式中的傳入參數的樣式來撰寫程式,然後將本來應該要回傳的值(不限定只有一個),傳給下一個延續函式,繼續下個函式的執行:
//CPS風格
function func(x, cb) {
  cb(x)
}
以明確的程式流程的例子來說,假設現在要從資料庫獲取某個會員的資料,然後把裡面的大頭照片輸出。
用直接風格的寫法:
function getAvatar(user){
   //...一些程式碼
   return user
}

function display(avatar){
  console.log(avatar)
}

const avatar = getAvatar('eddy')
display(avatar)
用CPS風格的寫法,像下面這樣:
function getAvatar(user, cb){
  //...一些程式碼
   cb(user)
}

function display(avatar){
  console.log(avatar)
}

getAvatar('eddy', display)
長久以來在程式語言開發界,直接風格的程式碼是最常被使用的,因為它容易被學習與理解,一個步驟接著一個步驟,學校的程式語言課程大部份也是用這種風格來教學,不論在個人電腦上的、在伺服器端的程式語言設計通常也是這樣。主要是因為個人電腦端或是伺服器端,通常使用多執行緒或改進底層運作的執行方式,來解決多工或並行的問題。CPS風格反而很少被使用,並沒有明顯的誘因讓程式設計師一定要用CPS風格,而且也不是所有的程式語言都能使用CPS風格,在過去CPS風格並不算是主流的程式開發寫作風格。
CPS風格相較於直接風格還有一些明顯的缺點:
  • 在愈複雜的應用情況時,程式碼愈不易撰寫與組織,維護性與閱讀性也很低
  • 在錯誤處理上較為困難
JavaScript中會大量使用CPS風格,除了它本身可以使用這種風格外,其實是有原因:
  • 只有單執行緒,在瀏覽器端只有一個使用者,但事件或網路要求(AJAX)要求不能阻塞其他程式的進行,但這也僅限在這些特殊的情況。不過在伺服器端的執行情況都很嚴峻,要能同時讓多人連線使用,必需要達到不能阻塞I/O,才能與以多執行緒執行的伺服器一樣的執行效益。
  • 一開始就是以CPS風格來設計事件異步處理的模型,用於配合異步回調函式的執行使用。
基本上一個程式語言要具有高階函式(High Order Function)的特性才能使用CPS風格,也就是可以把某個函式當作另一函式的傳入參數,也可以回傳函式。除了JavaScript語言外,具有高階函式特性的程式語言常見的有Python、Java、Ruby、Swift等等。

異步回調函式

並非所有的使用callbacks(回調)函式的API都是異步執行的,但CPS的確是一種可以確保異步回調執行流程的風格。在JavaScript中,除了DOM事件處理中的回調函式9成9都是異步執行的,語言內建API中使用的回調函式不一定是異步執行的,也有同步執行的例如Array.forEach要讓開發者自訂的callbacks(回調)的執行轉變為異步,有以下幾種方式:
  • 使用計時器(timer)函式: setTimeoutsetInterval
  • 特殊的函式: nextTicksetImmediate
  • 執行I/O: 監聽網路、資料庫查詢或讀寫外部資源(???)
  • 訂閱事件(???)
針對callbacks(回調)函式來說,異步與同步的執行到底是差在那裡?你可能會產生疑惑。下面用個簡單的例子來說明。
function aFunc(value, callback){
  callback(value)
}

function bFunc(value, callback){
  setTimeout(callback, 0, value)
}

function cb1(value){ console.log(value) }
function cb2(value){ console.log(value) }
function cb3(value){ console.log(value) }
function cb4(value){ console.log(value) }

aFunc(1, cb1)
bFunc(2, cb2)
aFunc(3, cb3)
bFunc(4, cb4)
aFunc是一個簡單的回調結構,callback回調函式被傳入後最後以value作為傳入參數執行。
bFunc函式則是包裹了一個setTimeout內建方法,它可以在一定時間內(第二個參數)執行第一個參數,也就是setTimeout會執行的回調函式,第三個參數是要加入到回調函式的傳入參數值。
aFunc中使用了一般的回調函式,只是傳入到函式中當作參數,然後最後執行而已,這種是同步執行的回調函式,只是用了CPS風格的寫法。
bFunc中使用了計時器APIsetTimeout會把傳入的回調函式進行異步執行,也就是先移到工作佇列中,等執行主執行緒的呼叫堆疊空了,在某個時間回到主執行緒再執行。所以即使它的時間設定為0秒,裡面的回調函式並不是立即執行,而是會暫緩(延時)執行的一種回調函式,一般稱為異步回調函式。
最後的執行結果是1 -> 3 -> 2 -> 4,也就是說,所有的同步回調函式都執行完成了,才會開始依順序執行異步的回調函式。如果你在瀏覽器上測試這個程式,應該會明顯感受到,2與4的輸出時,會有點延遲的現象,這並不是你的瀏覽器或電腦的問題,這是因為不論你設定的setTimeout為0,它要回到主執行緒上執行,仍然需要按照內部事件迴圈所設定的時間差,在某個時間點才會回來執行。
這個程式執行的流程,可以看這個在loupe網站的流程模擬,輸出一樣在瀏覽器的主控台中可以看到。
由這個範例中,可以看到異步回調函式執行比同步回調函式更慢,異步回調函式還有另一個名稱是延時回調(defer callback),是用延時執行特性來命名。這只是一種因應特別情況所採用的函式執行方式,例如需要與外部資源存取(I/O)、DOM事件處理或是計時器的情況。等待的時間則是在Web API中,等有外部資源有回應了(或超時)才會加到佇列中,佇列裡並不會執行函式中的程式碼,只是個準備排隊進入主執行緒的機制,函式一律在主執行緒中執行。
關於函式的異步執行與事件迴圈一些原理的說明,請再參考[異步執行與事件迴圈]的章節裡的內容。


補充:
defer: deferred(延遲的縮寫) 

在<script中加入async 或defer,兩者都是異步執行script,但defer確保DOM已經完整渲染

且在DOMContentLoaded前先去執行。



async:

  1. async操作沒有辦法確保DOM都已經全部渲染
  2. 操作DOM可能等於找死,會QQ饅頭等著吃exception
  3. 所以這種async你比較常會在google analytics上看到,而非UI類庫。
  4. 請求回來的執行是會停止browser parsing喔

reference: https://realdennis.medium.com/html-script-%E4%B8%ADdefer%E8%B7%9Fasync%E6%98%AF%E4%BB%80%E9%BA%BC-1166ee88d18

+

回調函式的複雜性

callback(回調)運用在瀏覽器端似乎並沒有想像中複雜,一個事件的處理範例大概會像下面這樣:
const el = document.getElementById('myButton')

el.addEventListener( 'click', function(){
     console.log('hello!')
}, false)
你也可以把callback寫成另一個函式定義,看起來會更清楚:
function callback(){
     console.log('hello!')
}

const el = document.getElementById('myButton')

el.addEventListener('click', callback, false)
AJAX是另一個常使用的情況,內建的XMLHttpRequest物件的行為類似於事件處理,而且都打包好好的。實際上onreadystatechange這個屬性,就是XMLHttpRequest物件在處理事件用的callback(回調)函式。以下為一個簡單的範例:
var xhr = new XMLHttpRequest();

xhr.onreadystatechange = function() {
    if (xhr.readyState == XMLHttpRequest.DONE ) {
       if (xhr.status == 200) {
           document.getElementById('myDiv').innerHTML = xhr.responseText
       }
       else if (xhr.status == 400) {
          console.log('There was an error 400')
       }
       else {
           console.log('something else other than 200 was returned')
       }
    }
}

xhr.open('GET', 'ajax_info.txt', true)
xhr.send()
AJAX就是定義了
XMLHttpRequest.onreadystatechange = callback;
讓developers可以直接塞入想要的function when the readyState changes.

"匿名函式"、"函式定義"與"函式呼叫"的混合

我會認為回調函式會複雜的原因是主要是來自"匿名函式"、"函式定義"與"函式呼叫"的混合寫法。所以當在看程式碼時,你的腦袋很容易打結。
+

function func(x, cb){
  cb(x)
}

func(123456, function(value){
  console.log(value)
})
這例子很簡單,要分作幾個部份來看:
這是一個完整的"函式呼叫",也就是說它是一個被執行的語句結構:
func(123456, function(value){
  console.log(value)
})
但其中的這一段是什麼,這個是一個"函式定義",而且還是個"匿名函式"定義,它是一個callback(回調)函式的定義,它代表了func函式執行完後要作的下一件事,這個定義是在func函式中的程式碼的最後一句被呼叫執行。:
function(value){
  console.log(value)
}
所以整個語法是代表"在函式呼叫時,要寫出下一個要執行的函式定義",這就是常見回調函式的語法樣式。當然,你可以另外用一個函式來寫得更清楚:
function func(x, cb){
  cb(x)
}

function callback(value){
  console.log(value)
}

func(123456, callback)
不過,你可以發現幾件事情:
  • callback(回調)的函式名稱,可以用匿名函式取代。(實際上callback的名稱在除錯時很有用,可以在錯誤的堆疊上指示出來)
  • callback(回調)因為是函式的定義,所以傳入參數value的名稱叫什麼其實都可以。
  • callback(回調)其實有Closure(閉包)結構的特性,可以獲取到func中的傳入參數,以及裡面的定義的值。(實際上JavaScript中只要函式建立就會有閉包產生)
那麼要說到callback(回調)的最大優點,就是它給了程式開發者很大的彈性,允許開發者可以自訂下一個要執行函式的內容,等於說它可以提高函式的擴充性與重覆使用性

回調地獄(Callback Hell)

複雜的情況是在於CPS風格使用callback(回調)來移往下一個函式執行,當你開始撰寫一個接著一個執行的流程,也就是一個特定工作的函式呼叫後要接下一個特定工作的函式時,就會看到所謂的"回調地獄"的結構,像下面這樣的例子:
step1(x, function(value1){
  //do something...
  step2(y, function(value2){
    //do something...
    step3(z, function(value3){
        //do something...
    })
  })
})
它的執行順序應該是step1 -> step2 -> step3沒錯,這三個都可能是已經寫好要作某件特定工作的函式。所以真正是這樣的流程嗎?你可能忘了匿名函式(callback)也是一個函式,所以執行的步驟是像下面這樣才對:
  1. step1執行後,"value1"已經有值,移往function(value1)執行
  2. function(value1)執行到step2step2執行到最後,"value2"已經有值,移往function(value2)執行
  3. function(value2)執行到step3step3執行到最後,"value3"已經有值,移往function(value3)執行
  4. function(value3)執行完成
寫成流程大概是像下面這樣的順序,一共有6個函式要執行的流程,其中的這三個匿名回調函式的主要工作,是負責準備接續下一個要執行特定工作的函式:
step1 -> function(value1) -> step2 -> function(value2) -> step3 -> function(value3)
那為何為不使用直接風格?而一定要用這麼不易理解的程式流程結構。上面已經有講為什麼JavaScript中會大量的使用CPS的原因:
因為有些I/O或事件類的函式,用直接風格會造成阻塞,所以要寫成異步的回調函式,也就是一定要用CPS
你可能會認為阻塞有這麼容易發生嗎?是的,在JavaScript中要"阻塞"太容易了,它是單執行緖執行的設計,一個比較長時間的程序執行就會造成阻塞,下面的for迴圈就會讓你的按鈕按下去沒反應,而且幾個訊息都要一段時間執行完才會顯示出來:
const el = document.getElementById('myButton')

el.addEventListener( 'click', function(){
     alert('hello!')
}, false)

const aArray = []
for(let i=0; i< 100000000;i++){
  aArray[i] = i+10
}

console.log('aArray done!')

const bArray = []
for(let i=0; i< 100000000;i++){
  bArray[i] = i*10
}

console.log('bArray done!')
或許你會認為在瀏覽器上讓使用者等個幾秒鐘不會怎麼樣,但如果在要求能讓多個使用者同時使用的伺服器上,每個使用者都來阻塞主執行緒幾秒,這個伺服器程式就可以廢了。不過,以異步執行的異步回調函式並不代表就不會阻塞,也有可能從佇列回到主緒行緒後,因為需要CPU密集型的運算,仍然會阻塞到緒行緒的進行。異步回調函式,只是暫時先移到佇列中放著,讓它先不干擾目前的主執行緒的執行而已。這是JavaScript為了在只有單執行緒的情況,用來達成並行(concurrency)模型的設計方式。
如果要配合JavaScript的異步處理流程,也就是非阻塞的I/O處理,只有CPS可以這樣作。
在伺服端的Node.js一開始就使用了CPS作為主要的I/O處理方式,老實說是一個不得已的選擇,當時沒有太多的選擇,而且這原本就是JavaScript中對異步回調函式的設計。Node.js使用error-first(以錯誤為主)的CPS風格,因為考慮到callback(回調)要處理錯誤不容易,所以要優先處理錯誤,它的主要原則如下:
  • callback的第一個參數保留給Error(錯誤),當錯誤發生時,它將會以第一個參數回傳。
  • callback的第2個參數保留給成功回應的資料。當沒有錯誤發生時,error(即第一個參數)會設定為null,然後將成功回應的資料傳入第二個參數。
一個典型的Node.js的回調語法範例如下:
+

var fs = require('fs');

fs.readFile('foo.txt', 'utf8', function(err, data) {
   if(err) {
    console.log('Unknown Error');
    return;
  }
  console.log(data);
});
//  fs means "file system", more details in http://nodejs.cn/api/fs.html#fs_file_system (未看)
而且似乎是node.js的
Node.js使用CPS風格在複雜的流程時,很容易出現回調地獄的問題,這是因為在伺服器端的各種I/O處理會相當頻繁而且複雜。像下面這個資料庫連接與查詢的範例,出自Node.js MongoDB Driver API:
// A simple query using the find method on the collection.

var MongoClient = require('mongodb').MongoClient,
  test = require('assert');
MongoClient.connect('mongodb://localhost:27017/test', function(err, db) {

  // Create a collection we want to drop later
  var collection = db.collection('simple_query');

  // Insert a bunch of documents for the testing
  collection.insertMany([{a:1}, {a:2}, {a:3}], {w:1}, function(err, result) {
    test.equal(null, err);

    // Peform a simple find and return all the documents
    collection.find().toArray(function(err, docs) {
      test.equal(null, err);
      test.equal(3, docs.length);

      db.close();
    });
  });
});
//  關於上面的語法 toArray() and find(), 這裡有詳細解釋https://docs.mongodb.com/manual/reference/method/cursor.toArray/
https://docs.mongodb.com/manual/reference/method/db.collection.find/#db.collection.find
然後發現mongo也是collection, document的DB集合(像 firebase)
而且似乎配合node.js 挺好的
上面這個範例還算簡單,但裡面的回調函式有3個,函式呼叫了11個,再複雜的話就會更不好維護,我會認為這是一種舊時代的程式碼組織方式的弊病,或是當時不得已的解決方案,當可以採用更好的語法結構來取代它時,這種語法未來大概只會出現在教科書中。現在這種寫法都是一般都已經不建議使用。
現在已經有很多協助處理的方式,回調地獄可以用例如Promise、generator、async/await之類的語法結構,或是Asyncco外部函式庫等,來改善或重構原本的程式碼結構,在往後維護程式碼上會比較容易,這些才是你現在應該就要學習的方式。
此外,隨著技術不斷的進步,現在的JavaScript也已經有可以讓它使用其他執行緒的技術,有Web Worker,或是專門給Node.js使用的child_process模組與cluster(叢集)模組。






continue...  這篇裡面很多說到node.js,讓我想看node啊~

reference: https://eyesofkids.gitbooks.io/javascript-start-from-es6/content/part4/callback.html

沒有留言:

張貼留言