JS非同步程式設計的幾種方式及區別

猴子的救兵007發表於2019-03-24

前言

眾所周知Javascript是“單執行緒”語言,在實際開發中我們又不得不面臨非同步邏輯的處理,這時候非同步程式設計就變得十分必要。所謂非同步,就是指在執行一件任務,這件任務分A、B兩個階段,執行完A階段後,需要去做另外一個任務得到結果後才能執行B階段。非同步程式設計有以下幾種常用方式:callbackPromiseGeneratorasync

callback函式

callback函式是指通過函式傳參傳遞到其他執行程式碼的,某一塊可執行程式碼的引用,被主函式呼叫後又回到主函式,如下例:

function add(a, b, callback){
    var num = a + b;
    callback(num)
}
add(1, 2, function(num){
    console.log(num); # 3
    # ...
})
複製程式碼

如果是有個任務佇列,裡面包含多個任務的話,那就需要層層巢狀了

var readFile = require('fs-readfile-promise'); # 讀取檔案函式
readFile(fileA, function(data) {
    readFile(fileB, function(data) {
        # ...
    })
})
複製程式碼

如上如果我存在n個任務,那需要層層巢狀n層,這樣程式碼顯得非常冗餘龐雜並且耦合度很高,修改其中某一個函式的話,會影響上下函式程式碼塊的邏輯。這種情況被稱為“回撥地獄”(callback hell)

Promise

Promise是我們常用來解決非同步回撥問題的方法。允許將回撥函式的巢狀,改為鏈式呼叫。以上多個任務的話,可以改造成如下例子:

function add(a, b){
    return new Promise((resolve, reject) => {
        var result = a+b;
        resolve(result);
    })
}
add(10, 20).then(res => {
    return add(res, 20) # res = 30
}).then(res => {
    return add(res, 20) # res = 50
}).then(res => {
    // ...
}).catch(err => {
    // 錯誤處理
})
複製程式碼

add函式執行後會返回一個Promise,它的結果會進入then方法中,第一個引數是Promiseresolve結果,第二個引數(可選)是Promisereject結果。我們可以把回撥後的邏輯在then方法中寫,這樣的鏈式寫法有效的將各個事件的回撥處理分割開來,使得程式碼結構更加清晰。另外我們可以在catch中處理報錯。

如果是我們的非同步請求不是按照順序A->B->C->D這種,而是[A,B,C]->D,先並行執行A、B、C完然後在執行D,我們可以用Promise.all();

# 生成一個Promise物件的陣列
const promises = [2, 3, 5].map(function (id) {
  return getJSON('/post/' + id + ".json"); # getJSON 是返回被Promise包裝的資料請求函式
});

Promise.all(promises).then(function (posts) {
  # promises裡面裝了三個Promise
  # posts返回的是一個陣列,對應三個Promise的返回資料
  # 在這可以執行D任務
}).then(res => {
    //...
}).catch(function(reason){
    //...
});
複製程式碼

但是Promise的程式碼還是有些多餘的程式碼,比如被Promise包裝的函式有一堆new Promisethencatch

Generator函式

Generator函式是ES6提供的一種非同步程式設計解決方案,由每執行一次函式返回的是一個遍歷器物件,返回的物件可以依次遍歷Generator裡面的每個狀態,我們需要用遍歷器物件的next方法來執行函式。

先來個例子:

function* foo() {
    yield 'stepone';
    yield 'steptwo';
    return 'stepthree';
}
var _foo = foo();
_foo.next(); #{value: 'stepone', done: false}
_foo.next(); #{value: 'stepone', done: false}
_foo.next(); #{value: 'stepone', done: false}
複製程式碼

Generator有三個特徵:函式命名時function後面需要加*;函式內部有yield;外部執行需要呼叫next方法。每個yield會將跟在她後面的值包裹成一個物件的返回,返回的物件中包括返回值和函式執行狀態,直到return,返回donetrue

如果每次執行Generator函式我們都需要用next的話,你那就太麻煩了,我們需要一個可以自動執行器。co 模組是著名程式設計師 TJ Holowaychuk 於 2013 年 6 月釋出的一個小工具,用於 Generator 函式的自動執行。 運用co模組時,yield後面只能是 Thunk函式 或者Promise物件,co函式執行完成之後返回的是Promise。如下:

var co = require('co');
var gen = function* () {
  var img1 = yield getImage('/image01');
  var img2 = yield getImage('/image02');
  ...
};
co(gen).then(function (res){
  console.log(res);
}).catch(err){
    # 錯誤處理
};
複製程式碼

co模組的任務的並行處理,等多個任務並行執行完成之後再進行下一步操作:

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

# 物件的寫法
co(function* () {
  var res = yield {
    1: Promise.resolve(1),
    2: Promise.resolve(2),
  };
  console.log(res);
}).then(console.log).catch(onerror);
複製程式碼

Generator函式雖然相比Promise在寫法上更加精簡且邏輯清晰,但是需要額外有個執行co函式去執行,為了解決優化這個問題,async函式出現了。

async函式

async函式Generator函式的語法糖。

var co = require('co');
var gen = function* () {
  var img1 = yield getImage('/image01');
  var img2 = yield getImage('/image02');
  ...
};
co(gen).then(function (res){
  console.log(res);
}).catch(err){
    # 錯誤處理
};
****
#以上Generator函式可以改為
var gen = async function () {
  var img1 = await getImage('/image01');
  var img2 = await getImage('/image02');
  return [img1, img2];
  ...
};
gen().then(res => {
    console.log(res) # [img1, img2]
});
複製程式碼

相比Generator函式,async函式在寫法上的區別就是async替代了*await替代了yield,並且async自帶執行器,只需gen()即可執行函式;擁有比較好的適應性,await後面可以是Promise也可以是原始型別的值;此外async函式返回的是Promise,便於我們更好的處理返回值。

async function gen() {
  return '111';
  # 等同於 return await '111';
};
gen().then(res => {
    console.log(res) # 111
});
複製程式碼

如果是直接return值,這個值會自動成為then方法回撥函式中的值。

async function gen() {
  var a = await getA();
  var b = await getB();
  return a + b;
};
gen().then(res => {
    console.log(res)
});
複製程式碼

async函式返回的Promise,必須等到函式體內所有await後面的Promise物件都執行完畢後,或者return或者拋錯之後才能改變狀態;也就是隻有async裡面的非同步操作全部操作完,才能回到主任務來,並且在then方法裡面繼續執行主任務。

# 錯誤處理1
async function gen() {
    await new Promise((resolve, reject) => {
        throw new Error('出錯了');
    })
};
gen().then(res => {
    console.log(res)
}).catch(err => {
    console.log(err) # 出錯了
});
# 錯誤處理2:如下處理,一個await任務的錯誤不會影響到後面await任務的執行
async function gen() {
    try{
        await new Promise((resolve, reject) => {
            throw new Error('出錯了');
        })
    }catch(e){
        console.log(e); # 出錯了
    }
    return Promise.resolve(1);
};
gen().then(res => {
    console.log(res) # 1
});
複製程式碼

錯誤處理如上。

async function gen() {
    # 寫法一
    let result = await Promise.all([getName(), getAddress()]);
    return result;
    # 寫法二
    let namePromise = getName();
    let addressPromise = getAddress();
    let name = await namePromise;
    let address = await addressPromise;
    return [name, address];
};
gen().then(res => {
    console.log(res); # 一個陣列,分別是getName和getAddress返回值
})
複製程式碼

多個非同步任務互相沒有依賴關係,需要併發時,可按照如上兩種方法書寫。

async與Promise、Generator函式之間的對比

function chainAnimationsPromise(elem, animations) {
  # 變數ret用來儲存上一個動畫的返回值
  let ret = null;
  # 新建一個空的Promise
  let p = Promise.resolve();
  # 使用then方法,新增所有動畫
  for(let anim of animations) {
    p = p.then(function(val) {
      ret = val;
      return anim(elem);
    });
  }
  # 返回一個部署了錯誤捕捉機制的Promise
  return p.catch(function(e) {
    # 錯誤處理
  }).then(function() {
    return ret;
  });

}
複製程式碼

Promise雖然很好的解決了地獄回撥的問題,但是程式碼中有很多與語義無關的thencatch等;

function chainAnimationsGenerator(elem, animations) {
  return co(function*() {
    let ret = null;
    try {
      for(let anim of animations) {
        ret = yield anim(elem);
      }
    } catch(e) {
      # 錯誤處理
    }
    return ret;
  });
}
複製程式碼

Generator函式需要自動執行器來執行函式,且yield後面只能是Promise物件或者Thunk函式。

async function chainAnimationsAsync(elem, animations) {
  let ret = null;
  try {
    for(let anim of animations) {
      ret = await anim(elem);
    }
  } catch(e) {
    # 錯誤處理
  }
  return ret;
}
複製程式碼

async 函式的實現最簡潔,最符合語義,幾乎沒有語義不相關的程式碼。與Generator相比不需要程式設計師再提供一個執行器,async本身自動執行,使用起來方便簡潔。

參考:ECMAScript 6 阮一峰

相關文章