JavaScript異步編程的終極演變

寫在前面

有一個有趣的問題:node

爲何Node.js約定回調函數的第一個參數必須是錯誤對象err(若是沒有錯誤,該參數就是null)?git

緣由是執行回調函數對應的異步操做,它的執行分紅兩段,這兩段之間拋出的錯誤程序沒法捕獲,因此只能做爲參數傳入第二段。你們知道,JavaScript只有一個線程,若是沒有異步編輯,複雜的程序基本無法使用。在ES6誕生之前,異步編程的方式大概有下面四種:github

  • 回調函數編程

  • 事件監聽json

  • 發佈/訂閱api

  • Promise對象promise

ES6將JavaScript異步編程帶入了一個全新的階段,ES7中的async函數更是給出了異步編程的終極解決方案。下面將具體講解異步編程的原理和值得注意的地方,待我細細道來~babel

異步編程的演變

基本理解

所謂異步,簡單地說就是一個任務分紅兩段,先執行第一段,而後轉而執行其餘任務,等作好準備再回過頭執行第二段。併發

舉個例子
讀取一個文件進行處理,任務的第一段是向操做系統發出請求,要求讀取文件。而後,程序執行其餘任務,等到操做系統返回文件,再接着執行任務的第二段(處理文件)。這種不連續的執行,就叫作異步。異步

相應地,連續的執行就叫做同步。因爲是連續執行,不能插入其餘任務,因此操做系統從硬盤讀取文件的這段時間,程序只能乾等着。

回調函數

所謂回調函數,就是把任務的第二段單獨寫在一個函數中,等到從新執行該任務時直接調用這個函數。其英文名字 callback 直譯過來就是 "從新調用"的意思。

拿上面的例子講,讀取文件操做是這樣的:

fs.readFile(fileA, (err, data) => {
    if (err) throw err;
    console.log(data)
})

fs.readFile(fileB, (err, data) => {
    if (err) throw err;
    console.log(data)
})

注意:上面兩段代碼彼此是異步的,雖然開始執行的順序是從上到下,可是第二段並不會等到第一段結束才執行,而是併發執行。

那麼問題來了,若是想fileB等到fileA讀取成功後再開始執行應該怎麼處理呢?最簡單的辦法是經過 回調嵌套

fs.readFile(fileA, (err, data) => {
    if (err) throw err;
    console.log(data)
    
    fs.readFile(fileB, (_err, _data) => { 
        if (_err) throw err;
        console.log(_data)
    })
})

這種方式我只能容忍個位數字的嵌套,並且它使得代碼橫向發展,實在是醜的一筆,次數多了根本是無法看。試想萬一要同步執行100個異步操做呢?瘋掉算了吧!有沒有更好的辦法呢?

使用Promise

要澄清一點,Promise的概念並非ES6新出的,而是ES6整合了一套新的寫法。一樣繼續上面的例子,使用Promise代碼就變成這樣了:

var readFile = require('fs-readfile-promise');

readFile(fileA)
.then((data)=>{console.log(data)})
.then(()=>{return readFile(fileB)})
.then((data)=>{console.log(data)})
// ... 讀取n次
.catch((err)=>{console.log(err)})

注意:上面代碼使用了Node封裝好的Promise版本的readFile函數,它的原理其實就是返回一個Promise對象,咱也簡單地寫一個:

var fs = require('fs');

var readFile = function(path) {
    return new Promise((resolve, reject) => {
        fs.readFile(path, (err, data) => {
            if (err) reject(err)
            resolve(data)
        })
    })
}

module.export = readFile

可是,Promise的寫法只是回調函數的改進,使用then()以後,異步任務的兩段執行看得更清楚,除此以外並沒有新意。撇開優勢,Promise的最大問題就是代碼冗餘,原來的任務被Promise包裝一下,無論什麼操做,一眼看上去都是一堆then(),本來的語意變得很不清楚。

把酒問蒼天,MD還有更好的辦法嗎?

使用Generator

在引入generator以前,先介紹一下什麼叫 協程

"攜程在手,說走就走"。哈哈,別混淆了, "協程" 非 "攜程"

協程

所謂 "協程" ,就是多個線程相互協做,完成異步任務。協程有點像函數,又有點像線程。其運行流程大體以下:

  • 第一步: 協程A開始執行

  • 第二步:協程A執行到一半,暫停,執行權轉移到協程B

  • 第三步:一段時間後,協程B交還執行權

  • 第四步:協程A恢復執行

function asyncJob() {
    // ... 其餘代碼
    var f = yield readFile(fileA);
    // ... 其餘代碼 
}

上面的asyncJob()就是一個協程,它的奧妙就在於其中的yield命令。它表示執行到此處執行權交給其餘協程,換而言之,yield就是異步兩個階段的分界線。

協程遇到yield命令就暫停,等到執行權返回,再從暫停的地方繼續日後執行。它的最大優勢就是代碼的寫法很是像同步操做,若是除去 yield命令,簡直如出一轍。

Generator函數

Generator函數是協程在ES6中的實現,最大的特色就是能夠交出函數的執行權(即暫停執行)。整個Generator函數就是一個封裝的異步任務,或者說就是異步任務的容器。

function* gen(x) {
    var y = yield x + 2;
    return y;
} 

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面的代碼中,調用Generator函數,會返回一個內部指針(即遍歷器)g,這是Generator函數不一樣於普通函數的另外一個地方,即執行它不會返回結果,返回的是指針對象。調用指針g的next()方法,會移動內部指針(即執行異步任務的第一段),指向第一個遇到的yield語句。

換而言之,next()方法的做用是分階段執行Generator函數。每次調用next()方法,會返回一個對象,表示當前階段的信息(value屬性和done屬性)。value屬性是yield語句後面表達式的值,表示當前階段的值;done屬性是一個布爾值,表示Generator函數是否執行完畢,便是否還有一個階段。

Generator函數的數據交換和錯誤處理

Generator函數能夠暫停執行和恢復執行,這是它封裝異步任務的根本緣由。除此以外,它還有兩個特性,使它能夠做爲異步編程的解決方案:函數體內外的數據交換和錯誤處理機制。

next()方法返回值的value屬性,是Generator函數向外輸出的數據;next()方法還能夠接受參數,向Generator函數體內輸入數據。

function* gen(x) {
    var y = yield x + 2;
    return y;
} 

var g = gen(1);
g.next()      // { value: 3, done: false }
g.next(2)     // { value: 2, done: true }

上面的代碼中,第一個next()方法的value屬性,返回表達式x+2的值(3)。第二個next()方法帶有參數2,這個參數能夠傳入Generator函數,做爲上個階段異步任務的返回結果,被函數體內的變量y接收,所以這一步的value屬性返回的就是2(變量y的值)。

Generator函數內部還能夠部署錯誤處理代碼,捕獲函數體外拋出的錯誤。

function* gen(x) {
    try {
        var y = yield x + 2
    } catch(e) {
        console.log(e)
    }
    return y
}

var g = gen(1);
g.next();
g.throw('出錯了');

上面代碼的最後一行,Generator函數體外,使用指針對象的throw方法拋出的錯誤,能夠被函數體內的try...catch 代碼塊捕獲。這意味着,出錯的代碼與處理錯誤的代碼,實現了時間和空間上的分離,這對於異步編程無疑是很重要的。

異步任務的封裝

下面看看如何使用Generator函數,執行一個真實的異步任務。

var fetch = require('node-fetch')

function* gen() {
    var url = 'https://api.github.com/usrs/github';
    var result = yield fetch(url);
    console.log(result.bio);
}

上面代碼中,Generator函數封裝了一個異步操做,該操做先讀取一個遠程接口,而後從JSON格式的數據解析信息。就像前面說過的,這段代碼很是像同步操做。除了加上yield命令。

執行這段代碼的方法以下:

var g = gen();
var result = g.next();

result.value.then(function(data) {
    return data.json()
}).then(function(data) {
    g.next(data)
});

上面代碼中,首先執行Generator函數,獲取遍歷器對象。而後使用next()方法,執行異步任務的第一階段。因爲Fetch模塊返回的是一個Promise對象,所以須要用then()方法調用下一個next()方法。

能夠看到,雖然Generator函數將異步操做表示得很簡潔,可是流程管理卻不方便(即合適執行第一階段,什麼時候執行第二階段)

大Boss登場之 async函數

所謂async函數,實際上是Generator函數的語法糖。

繼續咱們異步讀取文件的例子,使用Generator實現

var fs = require('fs');

var readFile = (path) => {
    return new Promise((resolve, reject) => {
        fs.readFile(path, (err, data) => {
            if (err) reject(err)
            resolve(data)
        })
    })
}

var gen = function* () {
    var f1 = yield readFile(fileA);
    var f2 = yield readFile(fileB);
    console.log(f1.toString());
    console.log(f2.toString());
}

寫成async函數,就是下面這樣:

var asyncReadFile = async function() {
    var f1 = await readFile(fileA);
    var f2 = await readFile(fileB);
    console.log(f1.toString())
    console.log(f2.toString())
}

發現了吧,async函數就是將Generator函數的*替換成了async,將yield替換成await,除此以外,還對 Generator作了如下四點改進:

(1)內置執行器。Generator函數的執行好比靠執行器,因此纔有了co模塊等異步執行器,而async函數是自帶執行器的。也就是說:async函數的執行,與普通函數如出一轍,只要一行:

var result = asyncReadFile();

(2)上面的代碼調用了asyncReadFile(),就會自動執行,輸出最後結果。這徹底不像Generator函數,須要調用next()方法,或者使用co模塊,才能獲得真正執行,從而獲得最終結果。

(3)更好的語義。asyncawait比起星號和yield,語義更清楚。async表示函數裏有異步操做,await表示緊跟在後面的表達式須要等待結果。

(4)更廣的適用性。async函數的await命令後面能夠是Promise對象和原始類型的值(數值、字符串和布爾值,而這是等同於同步操做)。

(5)返回值是Promise,這比Generator函數返回的是Iterator對象方便多了。你能夠用then()指定下一步操做。

進一步說,async函數徹底能夠看做由多個異步操做包裝成的一個Promise對象,而await命令就是內部then()命令的語法糖。

實現原理

async函數的實現就是將Generator函數和自動執行器包裝在一個函數中。以下代碼:

async function fn(args) {
    // ...
}

// 等同於 
function fn(args) {
  return spawn(function*() {
    // ...
  })
}
// 自動執行器
function spawn(genF) {
  return new Promise(function(resolve, reject) {
    var gen = genF();
    function step(nextF) {
      try {
        var next = nextF()
      } catch(e) {
        return reject(e)
      }
      if (next.done) {
        return resolve(next.value)
      }
      Promise.resolve(next.value).then(function(v) {
        step(function() { return gen.next(v) })
      },function(e) {
        step(function() { return gen.throw(e) })
      })
    }
    step(function() { return gen.next(undefined) })
  })
}

async函數用法

(1)async函數返回一個Promise對象,能夠是then()方法添加回調函數。
(2)當函數執行時,一旦遇到await()就會先返回,等到觸發的異步操做完成,再接着執行函數體內後面的語句。

下面是一個延遲輸出結果的例子:

function timeout(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}

async function asyncPrint(value, ms) {
  await timeout(ms)
  console.log(value)
}

// 延遲500ms後輸出 "Hello World!"
asyncPrint('Hello World!', 500)

注意事項

(1)await命令後面的Promise對象,運行結果多是reject,因此最好把await命令放在try...catch代碼塊中。

(2)await命令只能用在async函數中,用在普通函數中會報錯。

(3)ES6await增長爲保留字。若是使用這個詞做爲標識符,在ES5中是合法的,可是ES6會拋出 SyntaxError(語法錯誤)。

終極一戰

"倚天不出誰與爭鋒",上面介紹了一大堆,最後仍是讓咱們經過一個例子來看看 async 函數和PromiseGenerator到底誰纔是真正的老大吧!

需求:假定某個DOM元素上部署了一系列的動畫,前一個動畫結束才能開始後一個。若是當中又一個動畫出錯就再也不往下執行,返回上一個成功執行動畫的返回值。

Promise實現

function chainAnimationsPromise(ele, animations) {

  // 變量ret用來保存上一個動畫的返回值 
  var ret = null;
  
  // 新建一個空的Promise 
  var p = Promise.resolve();

  // 使用then方法添加全部動畫 
  for (var anim in animations) {
    p = p.then(function(val) {
      ret = val;
      return anim(ele);
    })
  }
  
  // 返回一個部署了錯誤捕獲機制的Promise 
  return p.catch(function(e) {
    /* 忽略錯誤,繼續執行 */
  }).then(function() {
    return ret;
  })
}

雖然Promise的寫法比起回調函數的寫法有很大的改進,可是操做自己的語義卻變得不太明朗。

Generator實現

function chainAnimationsGenerator(ele, animations) {
  return spawn(function*() {
    var ret = null;
    try {
      for(var anim of animations) {
        ret = yield anim(ele)
      }
    } catch(e) {
      /* 忽略錯誤,繼續執行 */
    }
    return ret;
  })
}

使用Generator雖然語義比Promise寫法清晰很多,可是用戶定義的操做所有出如今spawn函數的內部。這個寫法的問題在於,必須有一個任務運行器自動執行Generator函數,它返回一個Promise對象,並且保證yield語句後的表達式返回的是一個Promise。上面的spawn就扮演了這一角色。它的實現以下:

function spawn(genF) {
  return new Promise(function(resolve, reject) {
    var gen = genF();
    function step(nextF) {
      try {
        var next = nextF()
      } catch(e) {
        return reject(e)
      }
      if (next.done) {
        return resolve(next.value)
      }
      Promise.resolve(next.value).then(function(v) {
        step(function() { return gen.next(v) })
      },function(e) {
        step(function() { return gen.throw(e) })
      })
    }
    step(function() { return gen.next(undefined) })
  })
}

使用async實現

async function chainAnimationAsync(ele, animations) {
  var ret = null;
  try {
    for(var anim of animations) {
      ret = await anim(ele)
    } 
  } catch(e) {
    /* 忽略錯誤,繼續執行 */
  }
  return ret;
}

好了,光從代碼量上就看出優點了吧!簡潔又符合語義,幾乎沒有不相關代碼。完勝!

注意一點:async屬於ES7的提案,使用時請經過babel或者regenerator進行轉碼。

參考

阮一峯 《ES6標準入門》


@歡迎關注個人 github我的博客 -Jafeney

相關文章
相關標籤/搜索