擼js基礎之非同步

tumars發表於2018-02-26

前言

前端這兩年的新技術鋪天蓋地,各種框架、工具層出不窮眼花繚亂。最近打算好好複習下 js 基礎,夯實的基礎才是學習新技術的基石。本文作為讀書筆記簡單的總結下 js 非同步的基礎知識。

本系列目前已有四篇:

本文首發於個人部落格:(www.ferecord.com/lujs-async.…)。日後還會更新修改,如若轉載請附上原文地址,以便溯源。

目錄

圖例

擼js基礎之非同步

回撥

回撥是編寫和處理 JavaScript 程式非同步邏輯的最常用方式,無論是 setTimeout 還是 ajax,都是以回撥的方式把我們打算做的事情在某一時刻執行。

回撥的一般使用形式

// request(..) 是個支援回撥的請求函式
request('http://my.data', function callback(res) {
    console.log(res)
})

// 或者延時的回撥
setTimeout(function callback() {
    console.log('hi')
}, 1000)

複製程式碼

函式 callback 即為回撥函式,它作為引數傳進請求函式,並將在合適的時候被呼叫執行。

回撥的問題

回撥主要有以下兩點問題。

1. 線性理解能力缺失,回撥地獄

過深的巢狀,導致回撥地獄,難以追蹤回撥的執行順序。

2. 控制反轉信任缺失,錯誤處理無法保證

回撥函式的呼叫邏輯是在請求函式內部,我們無法保證回撥函式一定會被正確呼叫。回撥本身沒有錯誤處理機制,需要額外設計。可能出現的錯誤包括:回撥返回錯誤結果、吞掉可能出現的錯誤與異常、回撥沒有執行、回撥被多次執行、回撥被同步執行等等。


Promise

Promise 的一般使用形式

可以通過構造器 Promise(..) 構造 promise 例項:

var p = new Promise(function(resovle, reject) {
    if(1 > 0){
        resolve() // 通常用於完成
    } esle {
        reject() // 用於拒絕
    }
})

var onFulfilled = function() {} // 用於處理完成
var onRjected = function() {} // 用於處理拒絕

p.then(onFulfilled, onRjected)
複製程式碼

先理解下幾個術語:決議(resolve)、 完成(fulfill)和拒絕(reject)。

fulfill 與 reject 都很好理解,一個完成,一個拒絕。而我上例程式碼中的 resolve() 註釋“通常用於完成”,是由於 resolve 意思是決議,如果給 resolve 傳入一個拒絕值,它會返回拒絕,例如 resolve(Promise.reject())。

Promise 與回撥的區別

假設 request(..) 是一個請求函式:

// 回撥的寫法
request('http://my.data', function onResult(res) {
    if(res.error) {
        // 處理錯誤
    }
    // 處理返回資料
})


// Promise 的寫法
var p = request('http://my.data');

p.then(function onFullfill(res) {
    // 處理返回資料
})
.catch(function onRjected() {
    // 處理錯誤
})
複製程式碼

Promise 不是對回撥的替代。 Promise 在回撥程式碼和將要執行這個任務的非同步程式碼之間提供了一種可靠的中間機制來管理回撥。

使用回撥的話,通知就是任務 request(..) 呼叫的回撥。而使用 Promise 的話,我們把這個關係反轉了過來,偵聽來自 request(..) 的事件,然後在得到通知的時候,根據情況繼續。

你肯定已經注意到 Promise 並沒有完全擺脫回撥。它們只是改變了傳遞迴調的位置。我們並不是把回撥傳遞給 request(..),而是從 request(..) 得到某個東西(外觀上看是一個真正的 Promise),然後把回撥傳給這個東西。

Promise 歸一保證了行為的一致性,Promise 給了確定的值,resolve、reject、pendding。一旦 Promise 決議,它就永遠保持在這個狀態。此時它就成為了不變值(immutable value),可以根據需求多次檢視。

靜態方法

Promise.all()

Promise.all(iterable) 方法返回一個 Promise。引數 iterable 為陣列。當 iterable 引數中所有的 Promise 都返回完成(resolve), 或者當引數不包含 Promise 時,該方法返回完成(resolve),。當有一個 Promise 返回拒絕(reject)時, 該方法返回拒絕(reject)。

對 Promise.all([ .. ]) 來說,只有傳入的所有 promise 都完成,返回 promise 才能完成。如果有任何 promise 被拒絕,返回的主 promise 就立即會被拒絕(拋棄任何其他 promise 的結果)。如果完成的話,你會得到一個陣列,其中包含傳入的所有 promise 的完成值。對於拒絕的情況,你只會得到第一個拒絕 promise 的拒絕理由值。這種模式傳統上被稱為門:所有人都到齊了才開門。

嚴格說來,傳給Promise.all([..])的陣列中的值可以是 Promise、thenable,甚至是立即值。就本質而言,列表中的每個值都會通過 Promise.resolve(..) 過濾,以確保要等待的是一個真正的 Promise,所以立即值會被規範化為為這個值構建的 Promise。如果陣列是空的,主 Promise 就會立即完成。

注意:

若向 Promise.all([ .. ]) 傳入空陣列,它會立即完成,但 Promise.race([ .. ]) 會掛住,且永遠不會決議。

Promise.race()

Promise.race(iterable) 方法返回一個 promise ,並伴隨著 promise物件解決的返回值或拒絕的錯誤原因。引數 iterable 為陣列, 只要 iterable 中有一個 promise 物件"解決(resolve)"或"拒絕(reject)"。

對 Promise.race([ .. ]) 來說,只有第一個決議的 promise(完成或拒絕)取勝,並且其決議結果成為返回 promise 的決議。這種模式傳統上稱為門閂:第一個到達者開啟門閂通過。

注意:

一項競賽需要至少一個“參賽者”。所以,如果你傳入了一個空陣列,主race([..]) Promise 永遠不會決議,而不是立即決議。這很容易搬起石頭砸自己的腳! ES6 應該指定它完成或拒絕,抑或只是丟擲某種同步錯誤。遺憾的是,因為 Promise 庫在時間上早於 ES6 Promise,它們不得已遺留了這個問題,所以,要注意,永遠不要遞送空陣列。

var p1 = Promise.resolve( 42 );
var p2 = Promise.resolve( "Hello World" );
var p3 = Promise.reject( "Oops" );

Promise.race( [p1,p2,p3] )
.then( function(msg){
    console.log( msg ); // 42
} );
Promise.all( [p1,p2,p3] )
.catch( function(err){
    console.error( err ); // "Oops"
} );
Promise.all( [p1,p2] )
.then( function(msgs){
    console.log( msgs ); // [42,"Hello World"]
} );
複製程式碼

all 與 race 的使用示例

Promise.reject()

Promise.reject(reason)方法返回一個用reason拒絕的Promise。

以下兩個 promise 是等價的:

var p1 = new Promise( function(resolve,reject){
    reject( "Oops" );
});
var p2 = Promise.reject( "Oops" );
複製程式碼

Promise.resolve()

Promise.resolve(value)方法返回一個以給定值解析後的 Promise 物件。但如果這個值是個 thenable(即帶有 then 方法),返回的 promise 會“跟隨”這個 thenable 的物件,採用它的最終狀態(指 resolved/rejected/pending/settled);否則以該值為成功狀態返回 promise 物件。

原型方法

Promise.prototype.then()

then(..) 接受一個或兩個引數:第一個用於完成回撥,第二個用於拒絕回撥。如果兩者中的任何一個被省略或者作為非函式值傳入的話,就會替換為相應的預設回撥。預設完成回撥只是把訊息傳遞下去,而預設拒絕回撥則只是重新丟擲(傳播)其接收到的出錯原因。

p.then( fulfilled );
p.then( fulfilled, rejected );
複製程式碼

Promise.prototype.catch()

catch(..) 只接受一個拒絕回撥作為引數,並自動替換預設完成回撥。換句話說,它等價於 then(null,..):

p.catch( rejected ); // 或者p.then( null, rejected )
複製程式碼

then(..) 和 catch(..) 也會建立並返回一個新的 promise,這個 promise 可以用於實現Promise 鏈式流程控制。如果完成或拒絕回撥中丟擲異常,返回的 promise 是被拒絕的。如果任意一個回撥返回非 Promise、非 thenable 的立即值,這個值會被用作返回 promise 的完成值。如果完成處理函式返回一個 promise 或 thenable,那麼這個值會被展開,並作為返回promise 的決議值。

promise 的侷限

1. 順序錯誤處理

Promise 的設計侷限性(具體來說,就是它們連結的方式)造成了一個讓人很容易中招的陷阱,即 Promise 鏈中的錯誤很容易被無意中默默忽略掉。

例如:

// foo(..), STEP2(..)以及STEP3(..)都是支援promise的工具
var p = foo( 42 )
.then( STEP2 )
.then( STEP3 );

p.catch( handleErrors );
複製程式碼

如果鏈中的任何一個步驟事實上進行了自身的錯誤處理(可能以隱藏或抽象的不可見的方式),那你的最後的 catch 就不會得到通知。這可能是你想要的——畢竟這是一個“已處理的拒絕”——但也可能並不是。完全不能得到(對任何“已經處理”的拒絕錯誤的)錯誤通知也是一個缺陷,它限制了某些用例的功能。

2. 單一值

根據定義, Promise 只能有一個完成值或一個拒絕理由。如果希望處理函式接收到多個結果的話只能使用陣列或物件封裝要傳遞的結果。就像這樣:

function foo (a) {
    var  b= a + 1;
    return new Promise(resolve => {
        resolve([a, b])
    })
}

foo(1).then(function(msg) {
    console.log(msg[0], msg[1])  // 1, 2
})
複製程式碼

這個解決方案可以起作用,但要在 Promise 鏈中的每一步都進行封裝和解封,就十分醜陋和笨重了。

在封裝解封單一值的方法上還有以下兩種:

  • [1] 分裂值,即把問題分解為兩個或更多 Promise 的訊號。
function getB(a) {
    return new Promise(resolve => {
        return resolve(a + 1)
    });
}

function foo(a) {
    return [
        Promise.resolve(a),
        getB(a)
    ];
} 

Promise.all(foo(1))
.then(function (msg){
    console.log(msg[0], msg[1])  // 1, 2
});
複製程式碼

恩,這個看起來相對第一種沒什麼改進,反而看起來還更麻煩了。但這種方法更符合 Promise 的設計理念。如果以後需要重構程式碼把對 a 和 b 的計算分開,這種方法就簡單得多。由呼叫程式碼來決定如何安排這兩個 promise,而不是把這種細節放在 foo(..) 內部抽象,這樣更整潔也更靈活。

  • [2] 展開/傳遞引數,使用 apply、或者 es6 的解構,來把單一值解構。
var p = new Promise (resolve => {
    return resolve([1,2])
})

// 使用 apply
p.then(Function.prototype.apply(function(a, b){
    console.log(a, b)  // 1, 2
})

// 使用解構
p.then(function([a, b]) {
    console.log(a, b)  // 1, 2
})
複製程式碼

總結一下,單一值是 Promise 的侷限之一,導致如果我們需要處理有多個引數的結果,只能把結果封裝在物件或陣列這種集合中,再使用各種方法在處理函式中進行解構。

3. 單決議

Promise 最本質的一個特徵是: Promise 只能被決議一次(完成或拒絕)。

所以下面的程式碼就是有問題的:

var p = new Promise(function(resolve) {
    $('.mybtn').click(resolve)
})

p.then(function(e) {
    var btnId = evt.currentTarget.id;
    return fetch('http://myurl.url/?id=' + btnId)
})
.then(function(res) {
    console.log(res)
})
複製程式碼

只有在你的應用只需要響應按鈕點選一次的情況下,這種方式才能工作。如果這個按鈕被點選了第二次的話, promise p 已經決議,因此第二次的 resolve(..) 呼叫就會被忽略。

因此,你可能需要轉化這個範例,為每個事件的發生建立一整個新的 Promise 鏈:

$('#mybtn').click(function(e) {
    var btnId = evt.currentTarget.id;

    fetch('http://myurl.url/?id=' + btnId)
    .then(function(res) {
        console.log(res)
    })
});
複製程式碼

這種方法可以工作,因為針對這個按鈕上的每個 "click" 事件都會啟動一整個新的 Promise 序列。由於需要在事件處理函式中定義整個 Promise 鏈,這很醜陋。除此之外,這個設計在某種程度上破壞了關注點與功能分離(Separation of concerns, SoC, 或稱關注度分離)的思想。你很可能想要把事件處理函式的定義和對事件的響應(那個 Promise 鏈)的定義放在程式碼中的不同位置。如果沒有輔助機制的話,在這種模式下很難這樣實現。

4. 慣性

現存的所有程式碼都還不理解 Promise,你得自己把需要回撥的函式封裝為支援 Promise 的函式。

5. 無法取消的 Promise

一旦建立了一個 Promise 併為其註冊了完成和或拒絕處理函式,如果出現某種情況使得這個任務懸而未決的話,你也沒有辦法從外部停止它的程式。

6. Promise 的效能

把基本的基於回撥的非同步任務鏈與 Promise 鏈中需要移動的部分數量進行比較。很顯然,Promise 進行的動作要多一些,這自然意味著它也會稍慢一些。

生成器

名詞解釋

生成器 (Generator)

生成器是一種返回迭代器的函式,通過 function 關鍵字後的 * 號來表示。

迭代器 (Iterable)

迭代器是一種物件,它具有一些專門為迭代過程設計的專有介面,所有迭代器物件都有一個 next 方法,每次呼叫都返回一個結果物件。結果物件有兩個屬性,一個是 value,表示下一個將要返回的值;另一個是 done,它是一個布林型別的值,當沒有更多可返回資料時返回 true。迭代器還會儲存一個內部指標,用來指向當前集合中值的位置,每呼叫一次 next() 方法,都會返回下一個可用的值。

可迭代物件 (Iterator)

可迭代物件具有 Symbol.iterator 屬性,是一種與迭代器密切相關的物件。Symbol.iterator 通過指定的函式可以返回一個作用於附屬物件的迭代器。 在 ECMCScript 6 中,所有的集合物件(陣列、Set、及 Map 集合)和字串都是可迭代物件,這些物件中都有預設的迭代器。

此外,由於生成器會預設為 Symbol.iterator 屬性賦值,因此所有通過生成器建立的迭代器都是可迭代物件。

for-of 迴圈

for-of 迴圈每執行一次都會呼叫可迭代物件的迭代器介面的 next() 方法,並將迭代器返回的結果物件的 value 屬性儲存在一個變數中,迴圈將持續執行這一過程直到返回物件的屬性值為 true。

生成器的一般使用形式

function *foo() {
    var x = yield 2;
    var y = x * (yield x + 1)
    console.log( x, y );
    return x + y
}

var it = foo();

it.next() // {value: 2, done: false}
it.next(3) // {value: 4, done: false}
it.next(3) // 3 9, {value: 12, done: true}
複製程式碼

yield.. 和 next(..) 這一對組合起來, 在生成器的執行過程中構成了一個雙向訊息傳遞系統。

有幾點需要注意一下:

  • 一般來說,需要的 next(..) 呼叫要比 yield 語句多一個,前面的程式碼片段有兩個 yield 和三個 next(..) 呼叫;
  • 第一個 next(..) 總是啟動一個生成器,並執行到第一個 yield 處;
  • 每個 yield.. 基本上是提出了一個問題:“這裡我應該插入什麼值?”,這個問題由下一個 next(..) 回答。 第二個 next(..) 回答第一個 yield.. 的問題,第三個 next(..) 回答第二個 yield 的問題,以此類推;
  • yield.. 作為一個表示式可以發出訊息響應 next(..) 呼叫, next(..) 也可以向暫停的 yield 表示式傳送值。

使用生成器建立可迭代物件

var obj = {
    [Symbol.iterator]: function *() {
        var result = 1
        while(result < 500) {
            result = result * 2
            yield result
        }
    }
}

for(let value of obj) {
    console.log(value)
}
// 2 4 8 16 32 64 128 256 512
複製程式碼

非同步迭代生成器

來看一下下面這段程式碼,我們在生成器裡 yeild 請求函式(暫停生成器繼續執行,同時並執行請求函式),執行生成器產成可迭代物件後,又在請求函式裡通過 next() 方法獲取到請求結果、將結果傳進生成器並恢復生成器的執行。

function foo() {
     ajax('http://my.data', function(res) {
        if(res.error) {
            // 向*main()丟擲一個錯誤
            it.throw(res.error)
        }

        // 用收到的data恢復*main()
        it.next(res.data)
    })
}

function *main() {
    try {
        var data = yeild foo();
        console.log(data)
    } catch(e) {
        console.error(e)
    }
}

var it = main();

// 這裡啟動!
it.next();

複製程式碼

本例中我們在 *main() 中發起 foo() 請求,之後暫停;又在 foo() 中相應資料恢復 *mian() 繼續執行,並將 foo() 的執行結果通過 next() 傳遞出來。

從本質上而言,我們把非同步作為實現細節抽象了出去,使得我們可以以同步順序的形式追蹤流程控制:“發出一個 Ajax 請求,等它完成之後列印出響應結果。”並且,當然,我們只在這個流程控制中表達了兩個步驟,而這種表達能力是可以無限擴充套件的,以便我們無論需要多少步驟都可以表達。

我們在生成器內部有了看似完全同步的程式碼(除了 yield 關鍵字本身),但隱藏在背後的是,在 foo(..) 內的執行可以完全非同步。並且在非同步程式碼中實現看似同步的錯誤處理(通過 try..catch)在可讀性和合理性方面也都是一個巨大的進步。

生成器 + Promise

Promise 和生成器最大效用的最自然的方法就是 yield 出來一個 Promise,然後通過這個 Promise 來控制生成器的迭代器。

建議看下面這段程式碼然後腦海中反覆思索上面這段話。

function foo() {
    return fetch('http://my.data')
}

function *main() {
    try {
        var data = yeild foo();
        console.log(data)
    } catch(e) {
        console.error(e)
    }
}

var it = main();
var p = it.next().value;   // p 的值是 foo()

// 等待 promise p 決議
p.then(
    function(data) {
        it.next(data);  // 將 data 賦值給 yield
    },
    function(err) {
        it.throw(err);
    }
)
複製程式碼

這樣就實現了 promise + 生成器來管理非同步流程:*mian() 中執行 foo() 發起請求,使用 *mian() 生成的迭代器獲取 foo() 的 promise 決議結果,再根據結果選擇繼續執行迭代器或丟擲錯誤。

我們可以將等待決議、執行 next()這一過程抽象出來,實現自動等待決議並繼續執行,直到結束:

// 定義 run 函式
functiton run(gen) {
    var args = [].slice.call(arguments, 1), it;

    // 在當前上下文中初始化生成器
    it = gen.apply(this, args);

    // 返回一個 promise 用於生成器完成
    return Promise.resolve()
        .then(function handleNext(value) {
            // 對下一個 yield 值出的值執行
            var next = it.next(value);

            return (function handleValue(next){
                // 判斷生成器是否執行完畢
                if(next.done) {
                    return next.value;
                } 
                // 否則繼續執行
                else {
                    return Promise.resolve(next.value)
                        .then(
                            // 成功就恢復非同步迴圈,把決議的值發回生成器
                            handleNext,

                            // 如果 value 是被拒絕的 promise
                            // 就把錯誤傳回生成器進行出錯處理
                            function handleErr(err) {
                                return Promise.resolve(
                                    it.throw(err)
                                )
                            }
                        )
                }
            })(next)
        })
}

function foo(p) {
    return fetch('http://my.data?p=' + p)
}

function *main(p) {
    try {
        var data = yeild foo(p);
        console.log(data)
    } catch(e) {
        console.error(e)
    }
}


// 執行!
run(main, '1')

複製程式碼

建議花費幾分鐘時間學習這段程式碼,以更好地理解生成器 + Promise 協同運作模式。

run() 函式起到的作用跟我們接下來要講的 async/await 函式是一樣的。

Async/Await

async 函式是什麼?一句話,它就是 Generator 函式的語法糖。它在形式上類似我們剛剛寫的 run(..) 函式。

async 函式的一般使用形式

一個 async 函式的基本使用形式如下:

function foo(p) {
    return fetch('http://my.data?p=' + p)
}

async function main(p) {
    try {
        var data = await foo(p);
        return data
    } catch(e) {
        console.error(e)
    }
}

main(1)
.then(data => console.log(data))
複製程式碼

與 Generator 函式的顯著不同是,* 變成了asyncyeild變成了await,同時我們也不用再定義 run(..) 函式來實現 Promise 與 Generator 的結合。async 函式執行的時候,一旦遇到 await 就會先返回,等到非同步操作完成,再接著執行函式體內後面的語句,並且最終返回一個 Promise 物件。

正常情況下,await 命令後面是一個 Promise 物件。如果不是,會被轉成一個立即 resolve 的 Promise 物件。await 命令後面的 Promise 物件如果變為 reject 狀態,則 reject 的引數會被 catch 方法的回撥函式接收到。

async 函式的優點

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

1. 內建執行器。

async 函式內建執行器(類似內部已實現我們剛剛的 run(..) 函式),省去了我們手動迭代生成器的麻煩;

2. 更好的語義。

async 和 await,比起星號和 yield,語義更清楚了。async 表示函式裡有非同步操作,await 表示緊跟在後面的表示式需要等待結果。

3. 更廣的適用性。

co模組約定,yield 命令後面只能是 Thunk 函式或 Promise 物件,而 async 函式的 await 命令後面,可以是 Promise 物件和原始型別的值(數值、字串和布林值,但這時等同於同步操作)。

4. 返回值是 Promis**

async 函式的返回值是 Promise 物件,這比 Generator 函式的返回值是 Iterator 物件方便多了。你可以用 then 方法指定下一步的操作。

async 函式的使用注意點

關於 async 函式的使用有三點需要注意一下:

1. 前面已經說過,await 命令後面的 Promise 物件,執行結果可能是 rejected,所以最好把 await 命令放在 try...catch 程式碼塊中。

2. 多個 await 命令後面的非同步操作,如果不存在繼發關係,最好讓它們同時觸發。

3. await 命令只能用在 async 函式之中,如果用在普通函式,就會報錯。

//getFoo 與 getBar 是兩個互相獨立、互不依賴的非同步操作

// 錯誤寫法,會導致 getBar 在 getFoo 完成後才執行
let foo = await getFoo();
let bar = await getBar();

// 正確寫法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);

// 正確寫法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;
複製程式碼

無繼發關係的非同步操作應當同步觸發

非同步生成器

名稱解釋

非同步生成器 (Async Generator)

就像生成器函式返回一個同步遍歷器物件一樣,非同步生成器函式的作用,是返回一個非同步迭代器物件。

在語法上,非同步 Generator 函式就是 async 函式與 Generator 函式的結合。

非同步迭代器 (Async Iterator)

非同步迭代器與迭代器類似,也是一種物件,也有 next 方法,與迭代器不同的是迭代器的 next 方法每次呼叫返回的是返回的物件的結構是{value, done},其中value表示當前的資料的值,done是一個布林值,表示迭代是否結束。。而非同步迭代器的 next 方法每次返回的是一個 Promise 物件,等到 Promise 物件 resolve 了,再返回一個{value, done}結構的物件。這就是說,非同步迭代器與同步遍歷器最終行為是一致的,只是會先返回 Promise 物件,作為中介。

對於普通迭代器來說,next方法必須是同步的,只要呼叫就必須立刻返回值。也就是說,一旦執行next方法,就必須同步地得到valuedone這兩個屬性。如果我們需要迭代非同步資料,同步迭代器就無法工作。例如在下面的程式碼中,readLinesFromFile() 就無法通過同步迭代器呈現它的非同步資料:

// readLinesFromFile 是一個非同步返回資料的函式
for (const line of readLinesFromFile(fileName)) {
    console.log(line);
}
複製程式碼

ES2018 引入了”非同步迭代器“(Async Iterator),為非同步操作提供原生的迭代器介面,即valuedone這兩個屬性都是非同步產生。

asyncIterator
  .next()
  .then(
    ({ value, done }) => /* ... */
  );
複製程式碼

非同步迭代器的最大的語法特點,就是呼叫迭代器的next方法,返回的是一個 Promise 物件。

下面是一個更具體的非同步迭代器的例子。

// createAsyncIterable(..) 是一個建立可非同步迭代物件的函式,我們稍後解釋它
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 }
});
複製程式碼

由於非同步遍歷器的next方法,返回的是一個 Promise 物件。因此,可以把它放在await命令後面。

async function foo() {
  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 }
}
複製程式碼

可非同步迭代物件 (Async Iterable)

可迭代物件具有 Symbol.asyncIterator 屬性,我們知道,一個物件的同步迭代器的介面,部署在Symbol.iterator屬性上面。同樣地,物件的非同步迭代器介面,部署在Symbol.asyncIterator屬性上面。不管是什麼樣的物件,只要它的Symbol.asyncIterator屬性有值,就表示應該對它進行非同步遍歷。

for-await-of 迴圈

for-of 迴圈用於遍歷同步的 Iterator 介面。新引入的 for-await-of 迴圈,則是用於遍歷非同步的 asyncIterator 介面。

// createAsyncIterable 是一個建立可非同步迭代物件的函式

async function f() {
    for await (const x of createAsyncIterable(['a', 'b'])) {
        console.log(x);
    }
}
// Output:
// a
// b
複製程式碼

如果 next 方法返回的 Promise 物件被 reject,for-await-of 就會報錯,要用 try...catch 捕捉。

function createRejectingIterable() {
    return {
        [Symbol.asyncIterator]() {
            return this;
        },
        next() {
            return Promise.reject(new Error('Problem!'));
        },
    };
}

(async function () { // (A)
    try {
        for await (const x of createRejectingIterable()) {
            console.log(x);
        }
    } catch (e) {
        console.error(e);
            // Error: Problem!
    }
})(); // (B)
複製程式碼

另外 for-await-of 也可用於遍歷同步的可迭代物件。

(async function () {
    for await (const x of ['a', 'b']) {
        console.log(x);
    }
})();
// Output:
// a
// b
複製程式碼

for-await-of 會通過 Promise.resolve() 將每個迭代值都轉換成 Promise。

非同步生成器的一般使用形式

在語法上,非同步 Generator 函式就是 async 函式與 Generator 函式的結合。

async function *createAsyncIterable() {
    var x = yield 2;
    var y = x * (yield x + 1)
    return x + y
}

var it = createAsyncIterable()
function onFulfilled(obj){
    console.log(obj)
}

it.next().then(onFulfilled) // {value: 2, done: false}
it.next(3).then(onFulfilled) // {value: 4, done: false}
it.next(3).then(onFulfilled) // 3 9, {value: 12, done: true}
複製程式碼

通過非同步生成器建立可非同步迭代物件

var obj = {
    [Symbol.asyncIterator]: async function *gen() {
        var result = 1
        while(result < 500) {
            result = result * 2
            yield result
        }
    }
};

(async function foo () {
    for await (const x of obj) {
        console.log(x);
    }
})();

// 2 4 8 16 32 64 128 256 512
複製程式碼

非同步 Generator 函式出現以後,JavaScript 就有了四種函式形式:普通函式、async 函式、Generator 函式和非同步 Generator 函式。請注意區分每種函式的不同之處。基本上,如果是一系列按照順序執行的非同步操作(比如讀取檔案,然後寫入新內容,再存入硬碟),可以使用 async 函式;如果是一系列產生相同資料結構的非同步操作(比如一行一行讀取檔案),可以使用非同步 Generator 函式。

非同步基礎

JS 中的非同步

任何時候,只要把一段程式碼包裝成一個函式,並指定它在響應某個事件(定時器、滑鼠點選、 Ajax 響應等)時執行,你就是在程式碼中建立了一個將來執行的塊,也由此在這個程式中引入了非同步機制。

多個非同步之間可能存在以下三種關係:

  • 非互動
  • 互動
  • 協作

事件迴圈 (event loop)

js 的執行環境都提供了一種機制來處理程式中多個塊的執行,且執行每塊時呼叫 JavaScript 引擎,這種機制被稱為事件迴圈。

主執行緒從"任務佇列"中讀取事件,這個過程是迴圈不斷的,所以整個的這種執行機制又稱為Event Loop(事件迴圈)。

擼js基礎之非同步
JavaScript執行時概念模型

  • 棧(Stack):函式呼叫形成了一個棧幀。
  • 堆(Heap):物件被分配在一個堆中,一個用以表示一個記憶體中大的未被組織的區域。
  • 佇列(Queue):一個JavaScript執行時包含了一個待處理的訊息佇列(又稱“事件佇列”)。每一個訊息都與一個函式(稱為“回撥函式”)相關聯。 當棧為空時,從佇列中取出一個訊息進行處理。這個處理過程包含了呼叫與這個訊息相關聯的函式(以及因而建立了一個初始堆疊幀)。當棧再次為空的時候,也就意味著這個訊息處理結束,接著可以處理下一個訊息了。這就是“事件迴圈”的過程。
參考:https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop

任務佇列 (job queue)

ES6 中介紹了一種叫 “任務佇列(Job Queue)”的新概念。它是事件迴圈佇列之上的一層。遺憾的是,目前為止,這是一個沒有公開 API 的機制,因此要展示清楚有些困難。所以我們目前只從概念上進行描述。

我認為對於任務佇列最好的理解方式就是,它是掛在事件迴圈佇列的每個 tick 之後的一個佇列。在事件迴圈的每個 tick 中,可能出現的非同步動作不會導致一個完整的新事件新增到事件迴圈佇列中,而會在當前 tick 的任務佇列末尾新增一個專案(一個任務)。

這就像是在說:“哦,這裡還有一件事將來要做,但要確保在其他任何事情發生之前就完成它。”

事件迴圈佇列類似於一個遊樂園遊戲:玩過了一個遊戲之後,你需要重新到隊尾排隊才能再玩一次。而任務佇列類似於玩過了遊戲之後,插隊接著繼續玩。

一個任務可能引起更多工被新增到同一個佇列末尾。所以,理論上說, 任務迴圈(job loop)可能無限迴圈(一個任務總是新增另一個任務,以此類推),進而導致程式的餓死,無法轉移到下一個事件迴圈 tick。

任務和 setTimeout(..0) hack 的思路類似,但是其實現方式的定義更加良好,對順序的保證性更強。

Promise 的非同步特性是基於任務的

競態條件、門、門閂(race condition & gate & latch)

來看一段程式碼:

var a = 1;
var b = 2;
function foo() {
    a++;
    b = b * a;
    a = b + 3;
}
function bar() {
    b--;
    a = 8 + b;
    b = a * 2;
}

// ajax(..)是某個庫中提供的某個Ajax函式
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );
複製程式碼

我們無法在程式執行前確定 a 和 b 的最後的值,因為它們的值取決於 foo 和 bar 哪個先執行,這段程式碼裡我們無法確定誰會先執行。

競態條件: 在 JavaScript 的特性中,這種函式順序的不確定性就是通常所說的競態條件(race condition), foo() 和 bar() 相互競爭,看誰先執行。具體來說,因為無法可靠預測 a 和 b的最終結果,所以才是競態條件。

: 它的特性可以描述為“所有都通過後再通過”。形似 if (a && b) 傳統上稱為門,我們雖然不能確定 a 和 b 到達的順序,但是會等到它們兩個都準備好再進一步開啟門。 在經典的程式設計術語中,門( gate)是這樣一種機制要等待兩個或更多並行 / 併發的任務都完成才能繼續。它們的完成順序並不重要,但是必須都要完成,門才能開啟並讓流程控制繼續。

門閂: 它的特性可以描述為“只有第一名取勝”。需要“競爭”到終點,且只有唯一的勝利者。

順序、併發 (sequential & concurrency)

順序和併發是指不相關任務的設計結構。

順序 是指多個任務的執行依次執行。

併發 一個併發程式是指能同時執行通常不相關的各種任務。併發是一段時間內某個系統或單元的各個組成部分通過相互配合來處理大量的任務,強調結構和排程。

舉例,吃飯時同時打電話,這是併發。

序列、並行 (Serial & Parallelism)

序列和並行是指單個任務的執行方式。

序列 指單任務的多個步驟依次執行。

並行 並行是兵分幾路幹同一個事,即單個任務的多個步驟同時執行。

舉例,吃飯時把飯和菜一塊塞嘴裡吃掉,這是並行。

參考:

Concurrency is not parallelism

併發與並行的區別?_zhihu

併發(Concurrency)和並行(Parallelism)的區別_vaikan

還在疑惑併發和並行?_laike9m

也談併發與並行_tonybai

程式、執行緒 (process & thread)

平行計算最常見的工具就是程式執行緒,程式和執行緒獨立執行,並可能同時執行,多個執行緒能夠共享單個程式的記憶體。

程式是具有一定獨立功能的程式、它是系統進行資源分配和排程的一個獨立單位,重點在系統排程和單獨的單位,也就是說程式是可以獨立執行的一段程式。

執行緒是程式的一個實體,是 CPU 排程和分派的基本單位,他是比程式更小的能獨立執行的基本單位,執行緒自己基本上不擁有系統資源。在執行時,只是暫用一些計數器、暫存器和棧。

他們之間的關係:

  1. 一個執行緒只能屬於一個程式,而一個程式可以有多個執行緒,但至少有一個執行緒(通常說的主執行緒)。
  2. 資源分配給程式,同一程式的所有執行緒共享該程式的所有資源。
  3. 執行緒在執行過程中,需要協作同步。不同程式的執行緒間要利用訊息通訊的辦法實現同步。
  4. 處理機分給執行緒,即真正在處理機上執行的是執行緒。
  5. 執行緒是指程式內的一個執行單元,也是程式內的可排程實體。

他們之間的區別:

  1. 排程:執行緒作為排程和分配的基本單位,程式作為擁有資源的基本單位。
  2. 併發性:不僅程式之間可以併發執行,同一個程式的多個執行緒之間也可以併發執行。
  3. 擁有資源:程式是擁有資源的一個獨立單位,執行緒不擁有系統資源,但可以訪問隸屬於程式的資源。
參考:

程式和執行緒有什麼區別?_zhihu

並行執行緒的交替執行和非同步事件的交替排程,其粒度是完全不同的。 事件迴圈把自身的工作分成一個個任務並順序執行,不允許對共享記憶體的並行訪問和修改。通過分立執行緒中彼此合作的事件迴圈,並行和順序執行可以共存。

相關文章