1. 程式人生 > >ES6學習15(非同步操作和Async函式)

ES6學習15(非同步操作和Async函式)

非同步程式設計對JavaScript語言太重要。Javascript語言的執行環境是“單執行緒”的,如果沒有非同步程式設計,根本沒法用,非卡死不可。
ES6誕生以前,非同步程式設計的方法,大概有下面四種。

  • 回撥函式
  • 事件監聽
  • 釋出/訂閱
  • Promise 物件

ES6將JavaScript非同步程式設計帶入了一個全新的階段,ES7的Async函式更是提出了非同步程式設計的終極解決方案。

基本概念

所謂”非同步”,簡單說就是一個任務分成兩段,先執行第一段,然後轉而執行其他任務,等做好了準備,再回過頭執行第二段。

比如,有一個任務是讀取檔案進行處理,任務的第一段是向作業系統發出請求,要求讀取檔案。然後,程式執行其他任務,等到作業系統返回檔案,再接著執行任務的第二段(處理檔案)。這種不連續的執行,就叫做非同步。

回撥函式

讀取檔案進行處理,是這樣寫的:

fs.readFile('/etc/passwd', function (err, data) {
  if (err) throw err;
  console.log(data);
});

為什麼Node.js約定,回撥函式的第一個引數,必須是錯誤物件err(如果沒有錯誤,該引數就是null)?原因是執行分成兩段,在這兩段之間丟擲的錯誤,程式無法捕捉,只能當作引數,傳入第二段。

Promise

回撥函式本身並沒有問題,它的問題出現在多個回撥函式巢狀。這個是個老問題了。
Promise就是為了解決這個問題而提出的。它不是新的語法功能,而是一種新的寫法,允許將回調函式的巢狀,改成鏈式呼叫。採用Promise,連續讀取多個檔案,寫法如下。

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

readFile(fileA)
.then(function(data){
  console.log(data.toString());
})
.then(function(){
  return readFile(fileB);
})
.then(function(data){
  console.log(data.toString());
})
.catch(function(err) {
  console.log(err);
});

Promise 的寫法只是回撥函式的改進,使用then方法以後,非同步任務的兩段執行看得更清楚了,除此以外,並無新意。

Promise 的最大問題是程式碼冗餘,原來的任務被Promise 包裝了一下,不管什麼操作,一眼看去都是一堆 then,原來的語義變得很不清楚。

Generator

協程

協程有點像函式,又有點像執行緒。它的執行流程大致如下。

  • 第一步,協程A開始執行
  • 第二步,協程A執行到一半,進入暫停,執行權轉移到協程B
  • 第三步,(一段時間後)協程B交還執行權
  • 第四步,協程A恢復執行。

Generator就是ES6中對協程的實現,比如讀取檔案:

function *asyncJob() {
  // ...其他程式碼
  //首次呼叫next執行到這裡,開始執行readFile函式,這個函式暫停執行
  //在readFile中,完成讀取後再呼叫這個Generator的next並將返回的結果傳入
  //這個結果就會作為yield的語句的返回值賦給f,然後繼續執行到下一個yield
  var f = yield readFile(fileA);
  // ...其他程式碼
}

非同步任務的封裝

var fetch = require('node-fetch');
function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}
var g = gen();
var result = g.next();
result.value.then(function(data){
  return data.json();
}).then(function(data){
  g.next(data);
});

Thunk函式

在JavaScript語言中,Thunk函式替換多引數函式,將其替換成單引數的版本,且只接受回撥函式作為引數。
任何函式,只要引數有回撥函式,就能寫成Thunk函式的形式。下面是一個簡單的Thunk函式轉換器。

var fs = require('fs');
var Thunk = function(fn) {
  return function (...args) {
    return function (callback) {
      return fn.call(this, ...args, callback);
    }
  };
};
var readFileThunk = Thunk(fs.readFile);
readFileThunk("./import.js")(function(err,data){
    console.log(data);
});

有一個Thunkify模組,考慮的更加周到,基本思想是一樣的:

function thunkify(fn){
  return function(){
    var args = new Array(arguments.length);
    var ctx = this;
    for(var i = 0; i < args.length; ++i) {
      args[i] = arguments[i];
    }
    return function(done){
      var called;
      args.push(function(){
        //這裡利用閉包儲存了一個called變數,來標誌一個回撥函式是否已經被執行過
        if (called) return;
        called = true;
        done.apply(null, arguments);
      });
      try {
        fn.apply(ctx, args);
      } catch (err) {
        done(err);
      }
    }
  }
};
var read = thunkify(fs.readFile);
read('./import.js')(function(err, str){
  console.log(str);
});

這個called變數可以保證callback只執行一次,如果在非同步過程中多次呼叫callback也只執行一次:

function f(a, b, callback){
  var sum = a + b;
  callback(sum);
  callback(sum);
}
var ft = thunkify(f);
var print = console.log.bind(console);
ft(1, 2)(print);
// 3

Generator 函式的流程管理

你可能會問, Thunk函式有什麼用?回答是以前確實沒什麼用,但是ES6有了Generator函式,Thunk函式現在可以用於Generator函式的自動流程管理。
假設我們要依次讀取兩個檔案,我們的Generator可以這樣寫:

var fs = require('fs');
var readFile = thunkify(fs.readFile);
var gen = function* (){
  var r1 = yield readFile('./import.js');
  console.log(r1.toString());
  var r2 = yield readFile('./import.js');
  console.log(r2.toString());
};

手動執行時:

var g = gen();
var r1 = g.next();
r1.value(function(err, data){
  if (err) throw err;
  var r2 = g.next(data);
  r2.value(function(err, data){
    if (err) throw err;
    g.next(data);
  });
});

Thunk函式的自動流程管理

Generator函式的執行過程,其實是將同一個回撥函式,反覆傳入next方法的value屬性。這使得我們可以用遞迴來自動完成這個過程。

function run(fn) {
  //執行傳入的Generator,獲得遍歷器物件
  var gen = fn();
  //要傳入Trunk函式的回撥函式
  //在這裡就相當於是readFile('./import.js')(next)
  //首先要手動執行一下next(),此時呼叫gen.next(data)時無論data是什麼值Generator都會忽略的
  //這時result/value獲得到的就是一個readFile('./import.js'),也就是一個trunk函式
  //再把next傳進去,就可以開始遞迴執行了
  function next(err, data) {
    var result = gen.next(data);
    if (result.done) return;
    result.value(next);
  }
  next();
}
run(gen);

基於Promise的自動流程管理

var fs = require('fs');
//先把一個非同步操作封裝為一個Promise物件
var readFile = function (fileName){
  return new Promise(function (resolve, reject){
    fs.readFile(fileName, function(error, data){
      if (error) return reject(error);
      resolve(data);
    });
  });
};
var gen = function* (){
  //每次執行next返回的就是一個Promise物件
  var f1 = yield readFile('./import.js');
  console.log(f1.toString());
  var f2 = yield readFile('./import.js');
  console.log(f2.toString());
};

手動執行:

var g = gen();
g.next().value.then(function(data){
  g.next(data).value.then(function(data){
    g.next(data);
  });
});

手動執行其實就是用then方法,層層添加回調函式。理解了這一點,就可以寫出一個自動執行器,基本原理和Trunk函式的相同:

var run = function(gen) {
  var g = gen();
  function next(data){
    var result = g.next(data);
    if (result.done) return result.value;
    result.value.then(function(data){
      next(data);
    });
  }
  next();
}
run(gen);

co模組

co模組是著名程式設計師TJ Holowaychuk於2013年6月釋出的一個小工具,用於Generator函式的自動執行。
對於一個Generator,不需要再編寫Generator的執行器:

co(gen);

co函式返回一個Promise物件,因此可以用then方法添加回調函式。

co(gen).then(function (){
  console.log('Generator 函式執行完成');
});

使用co的前提條件是,Generator函式的yield命令後面,只能是Thunk函式或Promise物件。
co支援併發的非同步操作,即允許某些操作同時進行,等到它們全部完成,才進行下一步。
這時,要把併發的操作都放在陣列或物件裡面,跟在yield語句後面。

// 陣列的寫法
co(function* () {
  var res = yield [
    Promise.resolve(1),
    Promise.resolve(2)
  ];
  console.log(res);
}).catch(onerror);

async函式

ES7提供了async函式,使得非同步操作變得更加方便。async函式是什麼?一句話,async函式就是Generator函式的語法糖。
定義:

var asyncReadFile = async function (){
  var f1 = await readFile('/etc/fstab');
  var f2 = await readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

執行:

var result = asyncReadFile();

async函式對 Generator 函式的改進,體現在以下四點。

  1. 內建執行器。Generator函式的執行必須靠執行器,所以才有了co模組,而async函式自帶執行器。也就是說,async函式的執行,與普通函式一模一樣,只要一行,然後它就會自動執行,輸出最後結果。這完全不像Generator函式,需要呼叫next方法,或者用co模組,才能得到真正執行,得到最後結果。
  2. 更好的語義。async和await,比起星號和yield,語義更清楚了。async表示函式裡有非同步操作,await表示緊跟在後面的表示式需要等待結果。
  3. 更廣的適用性。 co模組約定,yield命令後面只能是Thunk函式或Promise物件,而async函式的await命令後面,可以是Promise物件和原始型別的值(數值、字串和布林值,但這時等同於同步操作)。
  4. 返回值是Promise。async函式的返回值是Promise物件,這比Generator函式的返回值是Iterator物件方便多了。你可以用then方法指定下一步的操作。

語法

async函式返回一個Promise物件。
async函式內部return語句返回的值,會成為then方法回撥函式的引數。
async函式內部丟擲錯誤,會導致返回的Promise物件變為reject狀態。丟擲的錯誤物件會被catch方法回撥函式接收到。

async function f() {
  throw new Error('出錯了');
  return 'hello world';
}

f().then(
  v => console.log(v),
  e => console.log(e)
)
// Error: 出錯了

async函式返回的Promise物件,必須等到內部所有await命令的Promise物件執行完,才會發生狀態改變。只有async函式內部的非同步操作執行完,才會執行then方法指定的回撥函式。
正常情況下,await命令後面是一個Promise物件。如果不是,會被轉成一個立即resolve的Promise物件。await命令後面的Promise物件如果變為reject狀態,則reject的引數會被catch方法的回撥函式接收到。
只要一個await語句後面的Promise變為reject,那麼整個async函式都會中斷執行。
如果await後面的非同步操作出錯,那麼等同於async函式返回的Promise物件被reject。

async 函式的用法

async函式返回一個Promise物件,可以使用then方法添加回調函式。當函式執行的時候,一旦遇到await就會先返回,等到觸發的非同步操作完成,再接著執行函式體內後面的語句。

function timeout(data, ms) {
  return new Promise((resolve) => {
    setTimeout(function(){
        resolve(data);
    }, ms);
  });
}
async function asyncPrint(value, ms) {
  //timeout會返回一個promise物件
  //await會等待這個物件中的resolve方法執行
  //並用其引數當做自己的返回值
  //值得注意的是await命令後面的Promise物件
  //執行結果可能是rejected
  //所以最好把await命令放在try...catch程式碼塊中
  //或者使用catch方法
  var a = await timeout(value,ms)
  .catch(function (err) {
    console.log(err);
  });
  console.log('a:'+a);
  return 'async over'
}
asyncPrint('hello world', 5000).then(v => console.log(v));
console.log('after async');
//after async
//a:hello world
//async over

注意事項

//多個await命令後面的非同步操作,如果不存在繼發關係,最好讓它們同時觸發
//這樣就不好,一個執行完再執行一個
//假設getFoo()和getBar()會返回Promise物件
let foo = await getFoo();
let bar = await getBar();
//可以這樣
let [foo, bar] = await Promise.all([getFoo(), getBar()]);
//或者這樣
//由於在Promise生成時裡面的非同步程式碼立刻開始執行
//所以前兩行執行完兩個非同步操作就都已經開始了
//接下來在用await來等待這兩個非同步完成操作
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;

非同步遍歷器

目前,有一個提案,為非同步操作提供原生的遍歷器介面,即value和done這兩個屬性都是非同步產生,這稱為”非同步遍歷器“(Async Iterator)。

非同步遍歷的介面

非同步遍歷器的最大的語法特點,就是呼叫遍歷器的next方法,返回的是一個Promise物件。
物件的非同步遍歷器介面,部署在Symbol.asyncIterator屬性上面。

const asyncIterable = createAsyncIterable(['a', 'b']);
const asyncIterator = asyncIterable[Symbol.asyncIterator]();

asyncIterator.next()
.then(iterResult1 => {
  console.log(iterResult1); // { value: 'a', done: false }
  return asyncIterator.next();
}).then(iterResult2 => {
  console.log(iterResult2); // { value: 'b', done: false }
  return asyncIterator.next();
}).then(iterResult3 => {
  console.log(iterResult3); // { value: undefined, done: true }
});

上面程式碼中,非同步遍歷器其實返回了兩次值。第一次呼叫的時候,返回一個Promise物件;等到Promise物件resolve了,再返回一個表示當前資料成員資訊的物件。這就是說,非同步遍歷器與同步遍歷器最終行為是一致的,只是會先返回Promise物件,作為中介。
由於非同步遍歷器的next方法,返回的是一個Promise物件。因此,可以把它放在await命令後面,await會等待這個Promise物件執行完,取它傳到resolve函式裡的值,就不用上面的then方法了。

async function f() {
  const asyncIterable = createAsyncIterable(['a', 'b']);
  const asyncIterator = asyncIterable[Symbol.asyncIterator]();
  console.log(await asyncIterator.next());
  // { value: 'a', done: false }
  console.log(await asyncIterator.next());
  // { value: 'b', done: false }
  console.log(await asyncIterator.next());
  // { value: undefined, done: true }
}

注意,非同步遍歷器的next方法是可以連續呼叫的,不必等到上一步產生的Promise物件resolve以後再呼叫。這種情況下,next方法會累積起來,自動按照每一步的順序執行下去。下面是一個例子,把所有的next方法放在Promise.all方法裡面。

const asyncGenObj = createAsyncIterable(['a', 'b']);
const [{value: v1}, {value: v2}] = await Promise.all([
  asyncGenObj.next(), asyncGenObj.next()
]);

console.log(v1, v2); // a b

for await…of

async function () {
  try {
    for await (const x of createAsyncIterable(['a', 'b'])) {
      console.log(x);
    }
  } catch (e) {
    console.error(e);
  }
}

上面程式碼中,createAsyncIterable()返回一個非同步遍歷器,for…of迴圈自動呼叫這個遍歷器的next方法,會得到一個Promise物件。await用來處理這個Promise物件,一旦resolve,就把得到的值(x)傳入for…of的迴圈體。
如果next方法返回的Promise物件被reject,那麼就要用try…catch捕捉。

非同步Generator函式

非同步Generator函式的作用,是返回一個非同步遍歷器物件。
在語法上,非同步Generator函式就是async函式與Generator函式的結合。

async function* readLines(path) {
  let file = await fileOpen(path);
  try {
    while (!file.EOF) {
      yield await file.readLine();
    }
  } finally {
    await file.close();
  }
}
for await (const line of readLines(filePath)) {
  console.log(line);
}

上面程式碼中,非同步操作前面使用await關鍵字標明,即await後面的操作,應該返回Promise物件。凡是使用yield關鍵字的地方,就是next方法的停下來的地方,它後面的表示式的值(即await file.readLine()的值),會作為next()返回物件的value屬性,這一點是於同步Generator函式一致的。
yield命令依然是立刻返回的,但是返回的是一個Promise物件。

async function* asyncGenerator() {
  console.log('Start');
  const result = await doSomethingAsync(); // (A)
  yield 'Result: '+ result; // (B)
  console.log('Done');
}

上面程式碼中,呼叫next方法以後,會在B處暫停執行,yield命令立刻返回一個Promise物件。這個Promise物件不同於A處await命令後面的那個Promise物件。主要有兩點不同,一是A處的Promise物件resolve以後產生的值,會放入result變數;二是B處的Promise物件resolve以後產生的值,是表示式’Result: ’ + result的值;三是A處的Promise物件一定先於B處的Promise物件resolve。
如果非同步Generator函式丟擲錯誤,會被Promise物件reject,然後丟擲的錯誤被catch方法捕獲。

async function* asyncGenerator() {
  throw new Error('Problem!');
}

asyncGenerator()
.next()
.catch(err => console.log(err)); // Error: Problem!

注意,普通的async函式返回的是一個Promise物件,而非同步Generator函式返回的是一個非同步Iterator物件。async函式和非同步Generator函式,是封裝非同步操作的兩種方法,都用來達到同一種目的。區別在於,前者自帶執行器,後者通過for await…of執行,或者自己編寫執行器。
下面就是一個非同步Generator函式的執行器。

async function takeAsync(asyncIterable, count=Infinity) {
  const result = [];
  const iterator = asyncIterable[Symbol.asyncIterator]();
  while (result.length < count) {
    const {value,done} = await iterator.next();
    if (done) break;
    result.push(value);
  }
  return result;
}

yield* 語句

yield*語句也可以跟一個非同步遍歷器。

async function* gen1() {
  yield 'a';
  yield 'b';
  return 2;
}

async function* gen2() {
  const result = yield* gen1();
}

上面程式碼中,gen2函式裡面的result變數,最後的值是2。
與同步Generator函式一樣,for await…of迴圈會展開yield*。

(async function () {
  for await (const x of gen2()) {
    console.log(x);
  }
})();
// a
// b