async,await與forEach引發的血案

我叫蝸牛發表於2019-04-16

本文僅是技術驗證,記錄,交流,不針對任何人。有冒犯的地方,請諒解。
該文首發於https://vsnail.cn/static/doc/blog/asyncForEach.html

偶然間看到一篇文章,提及awaitforEach中不生效,asyncawait這個ES6中的語法,對於我來說應該也不陌生了,早在一兩年前就用過了,知道這是幹什麼用的,怎麼用的。在看完這篇文章後,“第七感”覺著本身這個標題似乎有所不妥,內容到是感覺沒啥問題,但是看到總結和餘下的評論,總覺的這裡面應該是有誤區了。因此想要扒一下“它的外套”,看看是啥牌子(嘿嘿嘿嘿。。。真的只是看牌子)。在看了幾篇詳細介紹asyncawait後,才發現寫決定寫這篇文章,是個錯誤。因為它太深了,牽扯太多了,感覺就像無極中的饅頭一樣,能牽出一堆故事;也像是一個有實力,有背景的女一號,處處都是戲。

這世上的一切你都可以得到,只要你夠壞,而你,你還不夠壞 --《無極》

好了,話不多說,進入正題。讓我們一起來一件件的扒,看看到底是什麼?

asyncawait

ES2017 標準引入了 async 函式,使得非同步操作變得更加方便。OK,看看如何操作的。


async function getBookInfo(name){
    const baseInfo = await requestBookBaseInfo(name); //requestBookBaseInfo 方法傳送一個請求,向後臺請求資料。這是一個非同步方法
    const bookPrice = await requestBookPrice(baseInfo.id); //requestBookPrice方法傳送一個請求,向後臺請求資料。這是一個非同步方法
    return {..baseInfo,bookPrice};
}

複製程式碼

getBookInfo方法中,有兩個非同步函式,並且第二個非同步函式用到了第一個非同步函式的結果。如果getBookInfo能夠達到我們的目的,那麼用你的小指頭想想就會有一個直接的結論。

原來async函式內部使用await後,可以將await後面跟的非同步函式變為同步。

姑且認為這個結論是正確的,那麼async函式又是如何實現的,才能有如此神奇的效果?async函式返回的是函式裡面的return的值嗎?await只能跟非同步函式嗎?

好的,帶著這些疑問,我們繼續向下扒,看看到底是A,還是B還是C、D、E、F、G。。。

阮大神在《ECMAScript 6 入門》中的async函式一篇,提到這麼一句話 “async 函式是什麼?一句話,它就是 Generator 函式的語法糖。

什麼?asyncGenerator的語法糖?OK,那我們再去扒下今天的女二號,Generator

Generator

Generator 函式是 ES6 提供的一種非同步程式設計解決方案,語法行為與傳統函式完全不同。---這也是阮大神說的。

按我個人的理解,Generator英語直譯“生成”,那麼Generator函式其實就是一個生成器,生成什麼呢,生成的就是一個Iterator。等等又出現個Iterator,這又是什麼?好吧,我們姑且將她放置一邊,畢竟都女三號了,沒這麼快入場。如果不瞭解女三號,那麼我們也可以將Generator理解為狀態管理機。畢竟偉大詩人曾說過“橫看成嶺側成峰”,我們現在也只是轉個角度欣賞女二號而已。

在形式上,Generator只是一個普通的函式而已,只不過有兩個比較明顯的特徵。一個是在關鍵字function和函式名之間有個*;二,在函式內部使用yield表示式,定義不同的狀態(注意這裡,這就是為什麼又稱之為狀態管理機的由來)。


function* childEatProcess() {
  yield 'use toilet';
  yield 'wash hands';
  yield 'sit down';
  return 'eat'
}

var ch = childEatProcess();

ch.next();//{value:'use toilet',done:false}
ch.next();//{value:'wash hands',done:false}
ch.next();//{value:'sit down',done:false}
ch.next();//{value:'eat',done:true}
ch.next();//{value:'undefined',done:true}
複製程式碼

上面的程式碼定義了一個Generator函式,他的內部有三個yield,也就是說該函式有四個狀態(use toiletwash handssit down以及returneat)。childEatProcess和其他函式一樣,直接呼叫即可。但是他的返回(一定要注意這裡)不是return的值,而是一個物件,一個指向內部狀態的指標物件,也就是Iterator物件

並且Generator函式類似我們家小朋友吃飯前的準備工作一樣,你不去觸發他,他是不會自己執行的。當ch呼叫next方法後,函式內部才開始執行,執行到yield關鍵字後,執行yield後面的表示式,然後就停下來了。等著你再去觸發他(next方法的呼叫)

yield表示式

由於 Generator 函式返回的遍歷器物件,只有呼叫next方法才會遍歷下一個內部狀態,所以其實提供了一種可以暫停執行的函式。yield表示式就是暫停標誌。

遍歷器物件的next方法的執行邏輯如下。

(1)遇到yield表示式,就暫停執行後面的操作,並將緊跟在yield後面的那個表示式的值,作為返回的物件的value屬性值。

(2)下一次呼叫next方法時,再繼續往下執行,直到遇到下一個yield表示式。

(3)如果沒有再遇到新的yield表示式,就一直執行到函式結束,直到return語句為止,並將return語句後面的表示式的值,作為返回的物件的value屬性值。

(4)如果該函式沒有return語句,則返回的物件的value屬性值為undefined

需要注意的是,yield表示式後面的表示式,只有當呼叫next方法、內部指標指向該語句時才會執行,因此等於為 JavaScript 提供了手動的“惰性求值”(Lazy Evaluation)的語法功能。

yield* 表示式

和普通的yield表示式相比,yield*表示式多了一個星號。yield*表示式,用於將後面Generator表示式執行。這個還真不好表達,來看看下面的程式碼,直觀感受下。


function* generator_1(){
    yield "b";
    yield "c";
}

function* generator_2(){
    yield "a";
    yield generator_1();
    yield "d";
}

function* generator_3(){
    yield "a";
    yield* generator_1();
    yield "d";
}
let g2 = generator_2();
g2.next();//{value:"a",done:false}
g2.next();//{value:Iterator,done:false}
g2.next();//{value:"d",done:true}
g2.next();//{value:undefined,done:true}

let g3 = generator_3();
g3.next();//{value:"a",done:false}
g3.next();//{value:"b",done:false}
g3.next();//{value:"c",done:false}
g3.next();//{value:"d",done:false}

複製程式碼

從上面的列子,可以看出yield只是執行了generator函式而已,也就是獲取到generator函式生成的iterator而已。而yield*,確是執行了generator函式的內部指標。

那麼也可以將程式碼


function* generator_1(){
    yield "b";
    yield "c";
}

function* generator_3(){
    yield "a";
    yield* generator_1();
    yield "d";
}

//上面的程式碼等價於

function* generator_4(){
    yield "a";
    yield "b";
    yield "c";
    yield "d";
}


複製程式碼

next的引數

yield表示式本身沒有返回值,或者說總是返回undefined。next方法可以帶一個引數,該引數就會被當作上一個yield表示式的返回值。。注意,這句話非常重要,是理解後面的根本。重要的事情說三遍,yield表示式本身沒有返回值yield表示式本身沒有返回值yield表示式本身沒有返回值

Iterator

作為本文的女三號,Iterator,我們就簡單的扒一下吧。畢竟她不是這篇文章的小主。但是千萬別小看她,這位也絕對是位重量級的女主,物件遍歷,陣列遍歷,偽陣列遍歷,解構賦值,擴充套件符運算,所有能遍歷的一切都離不開她的石榴裙。只是今天戲份略少而已。

Iterator就是遍歷器,它是一種介面,為各種不同的資料結構提供統一的訪問機制。任何資料結構只要部署 Iterator 介面,就可以完成遍歷操作(即依次處理該資料結構的所有成員)。

Iterator 的遍歷過程是這樣的。

(1)建立一個指標物件,指向當前資料結構的起始位置。也就是說,遍歷器物件本質上,就是一個指標物件。

(2)第一次呼叫指標物件的next方法,可以將指標指向資料結構的第一個成員。

(3)第二次呼叫指標物件的next方法,指標就指向資料結構的第二個成員。

(4)不斷呼叫指標物件的next方法,直到它指向資料結構的結束位置。

每一次呼叫next方法,都會返回資料結構的當前成員的資訊。具體來說,就是返回一個包含valuedone兩個屬性的物件。其中,value屬性是當前成員的值,done屬性是一個布林值,表示遍歷是否結束。

再扒Generator

看了女三號(Iterator)的個人簡歷。應該清楚Generator函式執行後返回的物件就是一個內部指標的遍歷器物件即Iterator物件了吧。Iterator物件再呼叫next方法,遍歷Generator中所有yield定義的狀態。

之前描述女一號說,asyncgenerator的語法糖,但是還是沒有看出來generatorasync的關係呀。不急,我們慢慢來。反過來先假如async是generator的語法糖這句話是正確的,那麼我們肯定可以用generator函式來寫出async的效果。

async拆解後,可以發現其實就兩點:

  1. 內部的非同步函式在async中變為了同步,即await後的非同步表示式執行完後,才繼續向下執行。
  2. 對於generator來說,async是自動執行的,而generator返回的是iterator,必須要呼叫next,才能執行。

OK,那我們就按照這兩點一個個的實現:

第一點,其實很簡單,那麼就是用回撥函式,promise等等都可以實現順序執行。

有麻煩的是,要讓Generator函式自動執行,而不是我們手動呼叫next

自動執行Generator

Thunk 函式是自動執行 Generator 函式的一種方法。

很早很早以前,有一個爭論的焦點就是"求值策略",即函式的引數到底應該何時求值。有人覺的應該在使用的時候表示式才求值,這樣避免不必要的計算,相當於傳名呼叫。有人認為應該在使用前就將表示式計算好,相當於傳值呼叫

Thunk則是傳名呼叫的實現,是將引數放到一個臨時函式之中,再將這個臨時函式傳入函式體。

JavaScript 語言是傳值呼叫,它的 Thunk 函式含義有所不同。在 JavaScript 語言中,Thunk 函式替換的不是表示式,而是多引數函式,將其替換成一個只接受回撥函式作為引數的單引數函式。似乎和函式柯理化一個概念了。


function readSome(a,b,callBack){
    setTimeout(function(){
        callBack && callBack(a+b);
    },200)
}

let thunkFun = function(fn){
    return function(...args){
        return function(callBack){
           return  fn.call(this,...args,callBack);
        }
    }
}

let thunk_rs = thunkFun(readSome);

thubk_rs('Hi','girl')(function(str){
    console.log(str);
})

複製程式碼

你可能會問, Thunk 函式有什麼用?和Generator自執行有什麼關係。。慢慢來,衣服是一件件扒,一件件穿的。


function* gen() {
  // ...
}

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

while(!res.done){
  console.log(res.value);
  res = g.next();
}


複製程式碼

上面程式碼中,Generator 函式gen會自動執行完所有步驟。 但是,這不適合非同步操作。如果必須保證前一步執行完,才能執行後一步,上面的自動執行就不可行。這時,Thunk 函式就能派上用處。


function readSome(a,b,callBack){
    setTimeout(function(){
        callBack && callBack(a+b);
    },200)
}

let thunkFun = function(fn){
    return function(...args){
        return function(callBack){
           return  fn.call(this,...args,callBack);
        }
    }
}

let thunk_rs = thunkFun(readSome);


var gen = function* (){
  var r1 = yield thunk_rs('Hi','girl');
  console.log(r1.toString());
  var r2 = yield readFileThunk('you are ','beautiful');
  console.log(r2.toString());
};

function run(fn){
    var gen = fn();
    function next(err,data){
        let rs = gen.next(data);
        if(rs.done) return ;
        rs.value(next)
    }
    next();
}

run(gen)


複製程式碼

似乎這就完美的完成了自動執行。當然自動執行並不僅僅這一種方式。

async原理

通過之前的瞭解,我們知道async的原理其實就是Generator函式和自執行器包裝在一個函式裡。所以才有asyncGenerator的語法糖的說法。真相大白,原來女一號就是穿了個馬甲的女二號,只不過這個馬甲賦予了女一號一些特別的能力。就像超人要穿他的戰服才叫超人,才有超能力。

再扒async

穿了馬甲自然有些地方不一樣啦,雖然內部資料都一樣。那麼我們來看看穿上馬甲後,有什麼不同了。

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

(1)內建執行器。就是我們所謂的自執行。

(2)更好的語義。

(3)更廣的適用性。

(4)返回值promise

最重要的是第一和第四點。第一點,地球人都知道,不說了。第四點,返回promise物件,而generator返回的iterator物件。這是非常重要的差異點。

實際上async函式執行的時候,一旦遇到await就會先返回(返回一個promise物件),等到非同步操作完成,再接著執行函式體內後面的語句。async函式內部return語句返回的值,會成為then方法回撥函式的引數。

解析那個饅頭

為了一個饅頭引發了一場血案,為了一篇文章引發了今天的扒衣行動。那我們回過頭來再來看看這篇文章《為啥await在forEach中不生效》。

文章中有這麼一段程式碼:


function test() {
  let arr = [3, 2, 1]
  arr.forEach(async item => {
    const res = await fetch(item)
    console.log(res)
  })
  console.log('end')
}

function fetch(x) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(x)
    }, 500 * x)
  })
}

test()

複製程式碼

看了文章,大概瞭解這段程式碼其實是想做一個事情,雖然非同步了,但是想按照陣列排序順序顯示陣列中的元素。使用forEach遍歷,沒有實現這個需求。所以才有了文章的標題。但是女一號表示這個鍋,我不背。不是我不行,而是你沒把我安排到好的劇本中。

來,我們換個劇本,依然在forEach裡面,但是呢,在裡面的回撥函式中做點文章。


function test() {
    let arr = ["a", "b", "c"]
    arr.forEach(async (item,index) => {
      console.log('迴圈第'+index+'次')
      const res = await fetch(item)
      console.log('res',res)
      const res1 = await fetch1(res);
      console.log('res1',res1)
    })
    console.log('end')
  }
  
  function fetch(x,index) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(x+"經過fetch處理")
      }, 500)
    })
  }
  
  function fetch1(x) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve(x+" 經過fetch1處理")
      }, 100)
    })
  }
  
  test()

複製程式碼

這個劇本,async函式裡面對兩個非同步表示式設定了await。並且都是後一個await的非同步表示式使用了前一個await非同步表示式的返回值作為引數。也就是說如果asyncforEach中有作用,那麼後一個非同步表示式肯定會用前一個非同步表示式的返回值做引數。也就是說我們期望的輸出效果應該是:

迴圈第0次
迴圈第1次
迴圈第2次
end
undefined
res a經過fetch處理
res b經過fetch處理
res c經過fetch處理
res1 a經過fetch處理 經過fetch1處理
res1 b經過fetch處理 經過fetch1處理
res1 c經過fetch處理 經過fetch1處理

親們,你們可以試試,是不是這樣子的輸出。嘿嘿,我已經試了,確實是這樣子輸出的。

我們來看看為什麼劇本一達不到預期的目的,而劇本二達到了預期的目的?很簡單,async函式返回的是什麼,返回的是promise,是一個非同步物件。而forEach是一個個的回撥函式,也就是說這些回撥函式會立即執行,當執行到一個await關鍵字附近的時候,就會返回一個promise物件,async函式內部被凍結,等待await後面的非同步表示式執行完後,再執行async函式內部的剩餘程式碼。因此劇本一forEach時得到的是一堆的promise物件,而不是async函式內部的執行結果。async函式保證的是函式內部的await的順序執行。那麼也就能說明asyncforEach中是有作用的,只是場景不對罷了。

總結

其實無論async還是generator都還有很多點沒有扒到。asyncgenerator的出現對於非同步函式的處理真的是一個質的飛躍,較於原來的回撥函式的金字塔,promise的非語義化來說,async完全可以勝任女一號的。

參考文獻

1、《重學 JS:為啥 await 在 forEach 中不生效》juejin.im/post/5cb1d5…

2、《ECMAScript 6 入門》 es6.ruanyifeng.com/#docs/async

相關文章