前言
餘為前端菜鳥,感姿勢水平匱乏,難觀前端之大局。遂決定循前端知識之脈絡,以興趣為引,輔以幾分堅持,望於己能解惑致知、於同道能助力一二,豈不美哉。
本系列程式碼及文件均在 此處
繼續啃老本...讓人又愛又恨的非同步
開始之前
-
同步和非同步
function sync(){ const doA = '12' const doB = '34' } function async(){ ajax('/api/doC1', (res) => { doC2(res) }) } 複製程式碼
同步很好理解,任務一個個執行,doA以後才能doB。
非同步任務可以理解為分兩個階段,doC的前一階段是發出請求,後一階段是在請求結束後的未來時刻處理。
兩者各有優劣,同步任務會導致阻塞,非同步任務需要由有機制實現前後兩部分的分離,使得主執行緒能夠在這間歇內繼續工作而不浪費時間等待。
以瀏覽器為例大致過程:
主執行緒呼叫web api,通過工作執行緒發起請求,然後主執行緒繼續處理別的任務(這是part1)。工作執行緒執行完了非同步任務以後往事件佇列裡註冊回撥,等待主執行緒空閒後去佇列中取出到主執行緒執行棧中執行(這是part2)。
-
併發和並行
簡單描述:併發是交替做不同事情,並行是同時做不同事情。
我們可以通過多執行緒去處理併發,但說到底CPU只是在快速切換上下文來實現快速的處理。而並行則是利用多核,同時處理多個任務。
-
單執行緒和多執行緒
我們總說js是單執行緒的,node是單執行緒的,其實這樣的說法並不完美。所謂單執行緒指的是js引擎解釋和執行js程式碼的執行緒是一個,也即是我們常說的主執行緒。
又比如對於我們熟悉的node,I/O操作實際上都是通過執行緒池來完成的,js->呼叫c++函式->libuv方法->I/O操作執行->完畢後js執行緒繼續執行後續。
lesson1 Promise
callback
ajax('/a', (res) => {
ajax('/b, (res) => {
// ...
})
})
複製程式碼
醜陋的callback形式,不再多說
你的名字
Promise
誕於社群,初為非同步程式設計之解決方案,後有ES6將其寫入語言標準,終成今人所言之Promise
物件- Promise物件特點有二:狀態不受外界影響、一旦狀態改變後不會再次改變
基本用法
- Promise為建構函式,用於生成Promise例項
// 接收以resolve和reject方法為引數的函式 const pr = new Promise((resolve, reject) => { // do sth resolve(1) // pending -> resolved reject(new Error()) // pending -> rejected }) 複製程式碼
- 使用then方法傳入狀態更改後的回撥函式
pr.then((value) => { // onresolved cb }, (err) => { // onrejected cb }) 複製程式碼
我愚蠢的孩子們
-
Promise.prototype.then
採用鏈式寫法,返回一個新的Promise,上一個回撥的返回作為引數傳遞到下一個回撥
-
Promise.prototype.catch
實際上是
.then(null, rejection)
的別名同樣支援鏈式寫法,最後一個catch可以catch到前面任一個Promise跑丟擲的未catch的error
-
Promise.all
引數需具有Iterator介面,返回為多個Promise例項
var p = Promise.all([p1, p2, p3]); 複製程式碼
p1, p2, p3均resolve後p才resolve,任一個reject則p就reject。
若內部有catch,則外部catch捕獲不到異常。
-
Promise.race
// 若5秒未返回則拋錯 const p = Promise.race([ fetch('/resource-that-may-take-a-while'), new Promise(function (resolve, reject) { setTimeout(() => reject(new Error('request timeout')), 5000) }) ]); p.then(response => console.log(response)); p.catch(error => console.log(error)); 複製程式碼
第一個狀態改變的Promise會引起p狀態改變。
-
Promise.resolve/reject
Promise.resolve('1') Promise.resolve({ then: function() { console.log(123) } }) 複製程式碼
- 不傳引數/傳非thenable物件,生成一個立即resolve的Promise
- 傳thenable物件,立即執行then方法,然後根據狀態更改執行then(普通Promise行為)
-
Promise.prototype.finally
Promise.prototype.finally = function (callback) { let P = this.constructor; return this.then( value => P.resolve(callback()).then(() => value), reason => P.resolve(callback()).then(() => { throw reason }) ); }; 複製程式碼
無論如何都會執行最後的cb
Promise為我們提供了優於callback巢狀的非同步選擇,但實際上還是基於回撥來實現的。
實現
簡單的Promise實現程式碼可以看這裡 github
lesson2 Generator
初探
-
基本概念
function * gen() { const a = yield 1; return 2 } const m = gen() // gen{<suspended>} m.next() // {value: 1, done: false} m.next() // {value: 2, done: true} m.next() // {value: undefined, done: true} m // gen {<closed>} 複製程式碼
- Generator一個遍歷器生成函式,一個狀態機
- 執行返回一個遍歷器,代表Generator函式的內部指標(此時yield後的表示式不會求值)
- 每次呼叫遍歷器的next方法會執行下一個yield前的語句並且返回一個
{ value, done }
物件。 - 其中
value
屬性表示當前的內部狀態的值,是yield表示式後面那個表示式的值,done
屬性是一個布林值,表示是否遍歷結束 - 若沒有yield了,next執行到函式結束,並將return結果作為value返回,若無return則為undefined。
- 這之後呼叫next將返回
{ value: undefined, done: true }
,Generator的內部屬性[[GeneratorStatus]]
變為closed狀態
-
yield
- 呼叫next方法時,將yield後的表示式的值作為value返回,只有下次再呼叫next才會執行這之後的語句,達到了暫停執行的效果,相當於具備了一個惰性求值的功能
- 沒有yield時,Generator函式為一個單純的暫緩執行函式(需要呼叫next執行)
- yield只能用於Generator函式
方法
-
Generator.prototype.next()
通過傳入引數為Generator函式內部注入不同的值來調整函式接下來的行為
// 這裡利用引數實現了重置 function* f() { for(var i = 0; true; i++) { var reset = yield i; if(reset) { i = -1; } } } var g = f(); g.next() // { value: 0, done: false } g.next() // { value: 1, done: false } // 傳遞的引數會被賦值給i(yield後的表示式的值(i)) // 然後執行var reset = i賦值給reset g.next(true) // { value: 0, done: false } 複製程式碼
-
Generator.prototype.throw()
- Generator函式返回的物件都具有throw方法,用於在函式體外丟擲錯誤,在函式體內可以捕獲(只能catch一次)
- 引數可以為Error物件
- 如果函式體內沒有部署try...catch程式碼塊,那麼throw丟擲的錯會被外部try...catch程式碼塊捕獲,如果外部也沒有,則程式報錯,中斷執行
- throw方法被內部catch以後附帶執行一次next
- 函式內部的error可以被外部catch
- 如果Generator執行過程中內部拋錯,且沒被內部catch,則不會再執行下去了,下次呼叫next會視為該Generator已執行結束
-
Generator.prototype.return()
try ... finally
存在時,return會在finally執行完後執行,最後的返回結果是return方法的引數,這之後Generator執行結束,下次訪問會得到{value: undefined, done: true}
try ... finally
不存在時,直接執行return,後續和上一條一致
以上三種方法都是讓Generator恢復執行,並用語句替換yield表示式
yield*
-
在一個Generator內部直接呼叫另一個Generator是沒用的,如果需要在一個Generator內部yield另一個Generator物件的成員,則需要使用
yield*
function* inner() { yield 'a' // yield outer() // 返回一個遍歷器物件 yield* outer() // 返回一個遍歷器物件的內部值 yield 'd' } function* outer() { yield 'b' yield 'c' } let s = inner() for (let i of s) { console.log(i) } // a b c d 複製程式碼
-
yield*
後跟一個遍歷器物件(所有實現了iterator的資料結構實際上都可以被yield*
遍歷) -
被代理的Generator函式如果有return,return的值會被for...of忽略,所以next不會返回,但是實際上可以向外部Generetor內部返回一個值,如下:
function *foo() { yield 2; yield 3; return "foo"; } function *bar() { yield 1; var v = yield *foo(); console.log( "v: " + v ); yield 4; } var it = bar(); it.next() // {value: 1, done: false} it.next() // {value: 2, done: false} it.next() // {value: 3, done: false} it.next(); // "v: foo" // {value: 4, done: false} it.next() // {value: undefined, done: true} 複製程式碼
-
舉個?
// 處理巢狀陣列 function* Tree(tree){ if(Array.isArray(tree)){ for(let i=0;i<tree.length;i++) { yield* Tree(tree[i]) } } else { yield tree } } let ss = [[1,2],[3,4,5],6,[7]] for (let i of Tree(ss)) { console.log(i) } // 1 2 3 4 5 6 7 // 理解for ...of 實際上是一個while迴圈 var it = iterateJobs(jobs); var res = it.next(); while (!res.done){ var result = res.value; // ... res = it.next(); } 複製程式碼
Extra
-
作為物件的屬性的Generator函式
寫法很清奇
let obj = { * sss() { // ... } } let obj = ={ sss: function* () { // ... } } 複製程式碼
-
Generator函式的this
Generator函式返回的是遍歷器物件,會繼承prototype的方法,但是由於返回的不是this,所以會出現:
function* ss () { this.a = 1 } let f = ss() f.a // undefined 複製程式碼
想要在內部的this繫結遍歷器物件?
function * ss() { this.a = 1 yield this.b = 2; yield this.c = 3; } let f = ss.call(ss.prototype) // f.__proto__ === ss.prototype f.next() f.next() f.a // 1 f.b // 2 f.c // 3 複製程式碼
應用
-
舉個?
// 利用暫停狀態的特性 let clock = function* () { while(true) { console.log('tick') yield console.log('tock') yield } } 複製程式碼
-
非同步操作的同步化表達
// Generator函式 function* main() { var result = yield request("http://some.url"); var resp = JSON.parse(result); console.log(resp.value); } // ajax請求函式,回撥函式中要將response傳給next方法 function request(url) { makeAjaxCall(url, function(response){ it.next(response); }); } // 需要第一次執行next方法,返回yield後的表示式,觸發非同步請求,跳到request函式中執行 var it = main(); it.next(); 複製程式碼
-
控制流管理
// 同步steps let steps = [step1Func, step2Func, step3Func]; function *iterateSteps(steps){ for (var i=0; i< steps.length; i++){ var step = steps[i]; yield step(); } } // 非同步後續討論 複製程式碼
實現
TO BE CONTINUED
lesson3 Generator的非同步應用
回到最初提到的非同步:將非同步任務看做兩個階段,第一階段現在執行,第二階段在未來執行,這裡就需要將任務 暫停
。而前面說到的Generator似乎恰好提供了這麼一個當口,暫停
結束後第二階段開啟不就對應下一個next呼叫嘛!
想像我有一個非同步操作,我可以通過Generator的next方法傳入操作需要的引數,第二階段執行完後返回值的value又可以向外輸出,maybe Generator真的可以作為非同步操作的容器?
before it
協程coroutine
協程A執行->協程A暫停,執行權轉交給協程B->一段時間後執行權交還A->A恢復執行
// yield是非同步兩個階段的分割線
function* asyncJob() {
// ...其他程式碼
var f = yield readFile(fileA);
// ...其他程式碼
}
複製程式碼
Thunk函式
-
引數的求值策略
- 傳名呼叫和傳值呼叫之爭
- 後者更簡單,但是可能會有需要大量計算求值卻沒有用到這個引數的情況,造成效能損失
-
js中的Thunk函式
- 傳統的Thunk函式是傳名呼叫的一種實現,即將引數作為一個臨時函式的返回值,在需要用到引數的地方對臨時函式進行求值
- js中的Thunk函式略有不同
js中的Thunk函式是將多引數函式替換為單引數函式(這個引數為回撥函式)
看起來只是換了個樣子,好像並沒有什麼用const Thunk = function(fn) { return function (...args) { return function (callback) { return fn.call(this, ...args, callback); } }; }; 複製程式碼
自執行
Generator看起來很美妙,但是next呼叫方式看起來很麻煩,如何實現自執行呢?
Thunk函式實現Generator函式自動執行
-
Generator函式自動執行
function* gen() { yield a // 表示式a yield 2 } let g = gen() let res = g.next() while(!res.done) { console.log(res.value) res = g.next() // 表示式b } 複製程式碼
但是,這不適合非同步操作。如果必須保證前一步執行完,才能執行後一步,上面的自動執行就不可行。
next方法是同步的,執行時必須立刻返回值,yield後是同步操作當然沒問題,是非同步操作時就不可以了。處理方式就是返回一個Thunk函式或者Promise物件。此時value值為該函式/物件,done值還是按規矩辦事。
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函式中yield 非同步Thunk函式,通過yield將控制權轉交給Thunk函式,然後在Thunk函式的回撥函式中呼叫Generator的next方法,將控制權交回給Generator。此時,非同步操作確保完成,開啟下一個任務。
Generator是一個非同步操作的容器,實現自動執行需要一個機制,這個機制的關鍵是控制權的交替,在非同步操作有了結果以後自動交回控制權,而回撥函式執行正是這麼個時間點。
// Generator函式的執行器 function run(fn) { let gen = fn() // 傳給Thunk函式的回撥函式 function cb(err, data) { // 控制權交給Generator,獲取下一個yield表示式(非同步任務) let result = gen.next(data) // 沒任務了,返回 if (result.done) return // 控制權交給Thunk函式,傳入回撥 result.value(cb) } cb() } // Generator函式 function* g() { let f1 = yield readFileThunk('/a') let f2 = yield readFileThunk('/b') let f3 = yield readFileThunk('/c') } // Thunk函式readFileThunk const Thunk = function(fn) { return function (...args) { return function (callback) { return fn.call(this, ...args, callback); } }; }; var readFileThunk = Thunk(fs.readFile); readFileThunk(fileA)(callback); // 自動執行 run(g) 複製程式碼
-
大名鼎鼎的co
-
說明
- 不用手寫上述的執行器,co模組其實就是將基於Thunk函式和Promise物件的兩種自動Generator執行器包裝成一個模組
- 使用條件:yield後只能為Thunk函式或Promise物件或Promise物件陣列
-
基於Promise的執行器
function run(fn) { let gen = fn() function cb(data) { // 將上一個任務返回的data作為引數傳給next方法,控制權交回到Generator // 這裡將result變數引用{value, done}物件 // 不要和Generator中的`let result = yield xxx`搞混 let result = gen.next(data) if (result.done) return result.value result.value.then(function(data){ // resolved之後會執行cb(data) // 開啟下一次迴圈,實現自動執行 cb(data) }) } cb() } 複製程式碼
-
原始碼分析
其實和上面的實現類似
function co(gen) { var ctx = this; var args = slice.call(arguments, 1) // 除第一個引數外的所有引數 // 返回一個Promise物件 return new Promise(function(resolve, reject) { // 如果是Generator函式,執行獲取遍歷器物件gen if (typeof gen === 'function') gen = gen.apply(ctx, args); if (!gen || typeof gen.next !== 'function') return resolve(gen); // 第一次執行遍歷器物件gen的next方法獲取第一個任務 onFulfilled(); // 每次非同步任務執行完,resolved以後會呼叫,控制權又交還給Generator function onFulfilled(res) { var ret; try { ret = gen.next(res); // 獲取{value,done}物件,控制權在這裡暫時交給非同步任務,執行yield後的非同步任務 } catch (e) { return reject(e); } next(ret); // 進入next方法 } // 同理可得 function onRejected(err) { var ret; try { ret = gen.throw(err); } catch (e) { return reject(e); } next(ret); } // 關鍵 function next(ret) { // 遍歷執行完非同步任務後,置為resolved,並將最後value值返回 if (ret.done) return resolve(ret.value); // 獲取下一個非同步任務,並轉為Promise物件 var value = toPromise.call(ctx, ret.value); // 非同步任務結束後會呼叫onFulfilled方法(在這裡為yield後的非同步任務設定then的回撥引數) if (value && isPromise(value)) return value.then(onFulfilled, onRejected); return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, ' + 'but the following object was passed: "' + String(ret.value) + '"')); } }) } 複製程式碼
其實還是一樣,為Promise物件then方法指定回撥函式,在非同步任務完成後觸發回撥函式,在回撥函式中執行Generator的next方法,進入下一個非同步任務,實現自動執行。
舉個?
'use strict'; const fs = require('fs'); const co =require('co'); function read(filename) { return new Promise(function(resolve, reject) { fs.readFile(filename, 'utf8', function(err, res) { if (err) { return reject(err); } return resolve(res); }); }); } co(function *() { return yield read('./a.js'); }).then(function(res){ console.log(res); }); 複製程式碼
lesson4 async函式
語法糖
-
比較
function* asyncReadFile () { const f1 = yield readFile('/etc/fstab'); const f2 = yield readFile('/etc/shells'); console.log(f1.toString()); console.log(f2.toString()); }; const asyncReadFile = async function () { const f1 = await readFile('/etc/fstab'); const f2 = await readFile('/etc/shells'); console.log(f1.toString()); console.log(f2.toString()); }; 複製程式碼
看起來只是寫法的替換,實際上有這樣的區別
- async函式內建執行器,不需要手動執行next方法,不需要引入co模組
- async適用更廣,co模組對yield後的內容嚴格限制為Thunk函式或Promise物件,而await後可以是Promise物件或原始型別值
- 返回Promise,這點和co比較像
-
用法
- async標識該函式內部有非同步操作
- 由於async函式返回的是Promise,所以可以將async函式作為await命令的引數
- async函式可以使用在函式、方法適用的許多場景
語法
-
返回的Promise
- async函式只有在所有await後的Promise執行完以後才會改變返回的Promise物件的狀態(return或者拋錯除外)即只有在內部操作完成以後才會執行then方法
- async函式內部return的值會作為返回的Promise的then方法回撥函式的引數
- async函式內部丟擲的錯誤會使得返回的Promise變成rejected狀態,同時錯誤會被catch捕獲
-
async命令及其後的Promise
- async命令後如果不是一個Promise物件,則會被轉成一個resolved的Promise
- async命令後的Promise如果拋錯了變成rejected狀態或者直接rejected了,都會使得async函式的執行中斷,錯誤可以被then方法的回撥函式catch到
- 如果希望async的一個await Promise不影響到其他的await Promise,可以將這個await Promise放到一個try...catch程式碼塊中,這樣後面的依然會正常執行,也可以將多個await Promise放在一個try...catch程式碼塊中,此外還可以加上錯誤重試
使用注意
-
相互獨立的非同步任務可以改造下讓其併發執行(Promise.all)
let [foo, bar] = await Promise.all([getFoo(), getBar()]); 複製程式碼
-
await 與 for ... of
應該還在提案階段吧
for await (const item of list) { console.log(item) } 複製程式碼
實現
- 其實就是將執行器和Generator函式封裝在一起,詳見上一課
舉舉?
- 併發請求,順序輸出
async function logInOrder(urls) { // 併發讀取遠端URL const textPromises = urls.map(async url => { const response = await fetch(url); return response.text(); }); // 按次序輸出 for (const textPromise of textPromises) { console.log(await textPromise); } } 複製程式碼
目前瞭解到的非同步解決方案大概就這樣,Promise是主流,Generator作為容器,配合async await語法糖提供了看起來似乎更加優雅的寫法,但實際上因為一切都是Promise,同步任務也會被包裝成非同步任務執行,個人感覺還是有不足之處的。
雖發表於此,卻畢竟為一人之言,又是每日學有所得之筆記,內容未必詳實,看官老爺們還望海涵。