博客 / 詳情

返回

深入前端-JavaScript異步編程

JavaScript的執行機制在上篇文章中進行了深入的探討,那麼既然是一門單線程語言,如何進行良好體驗的異步編程呢

回調函數Callbacks

當程序跑起來時,一般情況下,應用程序(application program)會時常通過API調用庫裏所預先備好的函數。但是有些庫函數(library function)卻要求應用先傳給它一個函數,好在合適的時候調用,以完成目標任務。這個被傳入的、後又被調用的函數就稱為回調函數(callback function)。

什麼是異步

"調用"在發出之後,這個調用就直接返回了,所以沒有返回結果。換句話説,當一個異步過程調用發出後,調用者不會立刻得到結果。而是在"調用"發出後,"被調用者"通過狀態、通知來通知調用者,或通過回調函數處理這個調用。異步調用發出後,不影響後面代碼的執行。
簡單説就是一個任務分成兩段,先執行第一段,然後轉而執行其他任務,等做好了準備,再回過頭執行第二段。
在異步執行的模式下,每一個異步的任務都有其自己一個或着多個回調函數,這樣當前在執行的異步任務執行完之後,不會馬上執行事件隊列中的下一項任務,而是執行它的回調函數,而下一項任務也不會等當前這個回調函數執行完,因為它也不能確定當前的回調合適執行完畢,只要引它被觸發就會執行,

地獄回調階段

異步最早的解決方案是回調函數,如事件的回調,setInterval/setTimeout中的回調。但是回調函數有一個很常見的問題,就是回調地獄的問題
下面這幾種都屬於回調

  • 事件回調
  • Node API
  • setTimeout/setInterval中的回調函數
  • ajax 請求

異步回調嵌套會導致代碼難以維護,並且不方便統一處理錯誤,不能 try catch會陷入回調地獄

fs.readFile(A, 'utf-8', function(err, data) {
    fs.readFile(B, 'utf-8', function(err, data) {
        fs.readFile(C, 'utf-8', function(err, data) {
            fs.readFile(D, 'utf-8', function(err, data) {
                //....
            });
        });
    });
});

ajax(url, () => {
    // 處理邏輯
    ajax(url1, () => {
        // 處理邏輯
        ajax(url2, () => {
            // 處理邏輯
        })
    })
})

Promise解決地獄回調階段

Promise 一定程度上解決了回調地獄的問題,Promise 最早由社區提出和實現,ES6 將其寫進了語言標準,統一了用法,原生提供了Promise對象。

  • Promise存在三個狀態(state)pending、fulfilled、rejected
  • pending(等待態)為初始態,並可以轉化為fulfilled(成功態)和rejected(失敗態)
  • 成功時,不可轉為其他狀態,且必須有一個不可改變的值(value)
  • 失敗時,不可轉為其他狀態,且必須有一個不可改變的原因(reason)
  • new Promise((resolve, reject)=>{resolve(value)}) resolve為成功,接收參數value,狀態改變為fulfilled,不可再次改變。
  • new Promise((resolve, reject)=>{reject(reason)}) reject為失敗,接收參數reason,狀態改變為rejected,不可再次改變。
  • 若是executor函數報錯 直接執行reject();

Promise 是一個構造函數,new Promise 返回一個 promise對象

const promise = new Promise((resolve, reject) => {
       // 異步處理
       // 處理結束後、調用resolve 或 reject
});

then方法註冊 當resolve(成功)/reject(失敗)的回調函數


// onFulfilled 參數是用來接收promise成功的值,
// onRejected 參數是用來接收promise失敗的原因
//兩個回調返回的都是promise,這樣就可以鏈式調用
promise.then(onFulfilled, onRejected); 
const promise = new Promise((resolve, reject) => {
   resolve('fulfilled'); // 狀態由 pending => fulfilled
});
promise.then(result => { // onFulfilled
    console.log(result); // 'fulfilled' 
}, reason => { // onRejected 不會被調用
    
})

then方法的鏈式調用

Promise對象的then方法返回一個新的Promise對象,因此可以通過鏈式調用then方法。then方法接收兩個函數作為參數,第一個參數是Promise執行成功時的回調,第二個參數是Promise執行失敗時的回調。兩個函數只會有一個被調用,函數的返回值將被用作創建then返回的Promise對象。這兩個參數的返回值可以是以下三種情況中的一種:

  • return了一個值,那麼then返回的 Promise 將會成為接受(resolved)狀態,並且將返回的值作為接受狀態的回調函數的參數值。
  • 沒有返回任何值(,默認返回undefined),那麼then返回的 Promise 將會成為接受(resolved)狀態,並且該接受狀態的回調函數的參數值為undefined
  • 拋出(throw)一個錯誤,那麼then返回的 Promise 將會成為拒絕狀態,並且將拋出的錯誤作為拒絕狀態的回調函數的參數值。
  • return 另一個 Promise,then方法將根據這個Promise的狀態和值創建一個新的Promise對象返回。

    • 返回一個已經是接受狀態的 Promise,那麼then返回的 Promise 也會成為接受狀態,並且將那個 Promise 的接受狀態的回調函數的參數值作為該被返回的Promise的接受狀態回調函數的參數值。
    • 返回一個已經是拒絕狀態的 Promise,那麼then返回的 Promise 也會成為拒絕狀態,並且將那個 Promise 的拒絕狀態的回調函數的參數值作為該被返回的Promise的拒絕狀態回調函數的參數值。
    • 返回一個未定狀態(pending)的 Promise,那麼then返回 Promise 的狀態也是未定的,並且它的終態與那個 Promise 的終態相同;同時,它變為終態時調用的回調函數參數與那個 Promise 變為終態時的回調函數的參數是相同的。

解決層層回調問題

//對應上面第一個node讀取文件的例子
function read(url) {
    return new Promise((resolve, reject) => {
        fs.readFile(url, 'utf8', (err, data) => {
            if(err) reject(err);
            resolve(data);
        });
    });
}
read(A).then(data => {
    return read(B);
}).then(data => {
    return read(C);
}).then(data => {
    return read(D);
}).catch(reason => {
    console.log(reason);
});
//對應第二個ajax請求例子
ajax(url)
  .then(res => {
      console.log(res)
      return ajax(url1)
  }).then(res => {
      console.log(res)
      return ajax(url2)
  }).then(res => console.log(res))

可以看到,Promise在一定程度上其實改善了回調函數的書寫方式,最明顯的一點就是去除了橫向擴展,無論有再多的業務依賴,通過多個then(...)來獲取數據,讓代碼只在縱向進行擴展;另外一點就是邏輯性更明顯了,將異步業務提取成單個函數,整個流程可以看到是一步步向下執行的,依賴層級也很清晰,最後需要的數據是在整個代碼的最後一步獲得。
所以,Promise在一定程度上解決了回調函數的書寫結構問題,但回調函數依然在主流程上存在,只不過都放到了then(...)裏面,和我們大腦順序線性的思維邏輯還是有出入的。

catch方法

catch() 方法返回一個Promise,並且處理拒絕的情況。它的行為與調用Promise.prototype.then(undefined, onRejected) 相同。 (事實上, calling obj.catch(onRejected) 內部calls obj.then(undefined, onRejected)).

p.catch(onRejected);

p.catch(function(reason) {
   // 拒絕
});

onRejected
當Promise 被rejected時,被調用的一個Function。 該函數擁有一個參數:
reason rejection 的原因。
如果 onRejected 拋出一個錯誤或返回一個本身失敗的 Promise , 通過 catch() 返回的Promise 被rejected;否則,它將顯示為成功(resolved)。

Promise缺點

  • 無法取消 Promise
  • 當處於pending狀態時,無法得知目前進展到哪一個階段
  • 錯誤不能被 try catch(try..catch 結構,它只能是同步的,無法用於異步代碼模式)

執行f2(),無法通過try/catch捕獲promise.reject,控制枱拋出Uncaught (in promise)

function f2() {
  try {
    Promise.reject('出錯了');
  } catch(e) {
    console.log(e)
  }
}

改成await/async後,執行f()就能在catch中捕獲到錯誤了,並不會拋出Uncaught (in promise)

async function f() {
  try {
    await Promise.reject('出錯了')
  } catch(e) {
    console.log(e)
  }
}

可以這麼理解,promise中的錯誤發生在未來,所以無法現在捕獲

生成器Generators/ yield

什麼是Generator

Generator 函數是 ES6 提供的一種異步編程解決方案,語法行為與傳統函數完全不同
Generator 函數有多種理解角度。語法上,首先可以把它理解成,Generator 函數是一個狀態機,封裝了多個內部狀態。
執行 Generator 函數會返回一個遍歷器對象,也就是説,Generator 函數除了狀態機,還是一個遍歷器對象生成函數。返回的遍歷器對象,可以依次遍歷 Generator 函數內部的每一個狀態。形式上,Generator 函數是一個普通函數,但是有兩個特徵。

  • 一是,function關鍵字與函數名之間有一個星號;
  • 二是,函數體內部使用yield表達式,定義不同的內部狀態

Generator調用方式

Generator 函數的調用方法與普通函數一樣,也是在函數名後面加上一對圓括號。不同的是,調用 Generator 函數後,該函數並不執行,返回的也不是函數運行結果,而是一個指向內部狀態的指針對象,也就是上一章介紹的遍歷器對象(Iterator Object)。
下一步,必須調用遍歷器對象的next方法,使得指針移向下一個狀態。也就是説,每次調用next方法,內部指針就從函數頭部或上一次停下來的地方開始執行,直到遇到下一個yield表達式(或return語句)為止。換言之,Generator 函數是分段執行的,yield表達式是暫停執行的標記,而next方法可以恢復執行。

function* foo () {  
  var index = 0;
  while (index < 2) {
    yield index++; //暫停函數執行,並執行yield後的操作
  }
}
var bar =  foo(); // 返回的其實是一個迭代器

console.log(bar.next());    // { value: 0, done: false }  
console.log(bar.next());    // { value: 1, done: false }  
console.log(bar.next());    // { value: undefined, done: true }  

瞭解Co

可以看到上個例子當中我們需要一步一步去調用next這樣也會很麻煩,這時我們可以引入co來幫我們控制
Co是一個為Node.js和瀏覽器打造的基於生成器的流程控制工具,藉助於Promise,你可以使用更加優雅的方式編寫非阻塞代碼。
Co 函數庫約定,yield 命令後面只能是 Thunk 函數或 Promise 對象,而 async 函數的 await 命令後面,可以跟 Promise 對象和原始類型的值(數值、字符串和布爾值,但這時等同於同步操作)。
説白了就是幫你自動執行你的Generator不用手動調用next

可以簡單實現下:

function co(it) {
    return new Promise(function (resolve, reject) {
        function step(d) {
            let { value, done } = it.next(d);
            if (!done) {
                value.then(function (data) { // 2,txt
                    step(data)
                }, reject)
            } else {
                resolve(value);
            }
        }
        step();
    });
}

比如我們有個生成器函數是r(),直接扔進co裏自動執行

function* r() {
    let content1 = yield read('1.txt', 'utf8');
    let content2 = yield read(content1, 'utf8');
    return content2;
}
co(r()).then(function (data) {
    console.log(data)
})

解決異步問題

我們可以通過 Generator 函數解決回調地獄的問題,可以把之前的回調地獄例子改寫為如下代碼:

const co = require('co');
co(
function* read() {
    yield readFile(A, 'utf-8');
    yield readFile(B, 'utf-8');
    yield readFile(C, 'utf-8');
    //....
}
).then(data => {
    //code
}).catch(err => {
    //code
});
function *fetch() {
    yield ajax(url, () => {})
    yield ajax(url1, () => {})
    yield ajax(url2, () => {})
}
let it = fetch()
let result1 = it.next()
let result2 = it.next()
let result3 = it.next()

終極解決方案Async/ await

async 函數是Generator 函數的語法糖,是對Generator做了進一步的封裝。

async函數對 Generator 函數的改進

  • 1.內置執行器。Generator 函數的執行必須依靠執行器,而 async 函數自帶執行器,無需手動執行 next() 方法。
  • 2.更好的語義。async和await,比起星號和yield,語義更清楚了。async表示函數裏有異步操作,await表示緊跟在後面的表達式需要等待結果。
  • 3.更廣的適用性。co模塊約定,yield命令後面只能是 Thunk 函數或 Promise 對象,而async函數的await命令後面,可以是 Promise 對象和原始類型的值(數值、字符串和布爾值,但這時會自動轉成立即 resolved 的 Promise 對象)。
  • 4.返回值是 Promise。async 函數返回值是 Promise 對象,比 Generator 函數返回的 Iterator 對象方便,可以直接使用 then() 方法進行調用。

Co+Promise+Generator實現Async

async重點是自帶了執行器,相當於把我們要額外做的(寫執行器/依賴co模塊)都封裝了在內部。比如:

async function fn(args) {
 // ...
}

等同於:

function fn(args) {
 return spawn(function* () {
   // ...
 });
}
function spawn(genF) { //spawn函數就是自動執行器,跟簡單版的思路是一樣的,多了Promise和容錯處理
 return new Promise(function(resolve, reject) {
   const gen = genF();
   function step(nextF) {
     let next;
     try {
       next = nextF();
     } catch(e) {
       return reject(e);
     }
     if(next.done) {
       return resolve(next.value);
     }
     // 沒有結束,遞歸調用step
     Promise.resolve(next.value).then(function(v) {
       step(function() { return gen.next(v); });
     }, function(e) {
       step(function() { return gen.throw(e); });
     });
   }
   step(function() { return gen.next(undefined); });
 });
}

這樣就不難理解為什麼await函數會等到後面跟promise在resolve後才會執行,因為可以看出整個代碼是在Generator裏執行,這樣在resolve裏調用了gen.next(v),才會往下走。
注意一個細節:await下一行的代碼相當註冊在await後面跟的promise的.then回調裏,這裏和事件循環有關後面舉例説明

Async特點

  • 當調用一個 async 函數時,會返回一個 Promise 對象。
    async function async1() {
      return "1"
    }
    console.log(async1()) // -> Promise {<resolved>: "1"}
  • 當這個 async 函數返回一個值時,Promise 的 resolve 方法會負責傳遞這個值;
  • 沒有返回值Promise 的 resolve 方法專遞undefined
  • 當 async 函數拋出異常時,Promise 的 reject 方法也會傳遞這個異常值。
  • async 函數中可能會有 await 表達式,這會使 async 函數暫停執行,等待 Promise 的結果出來,然後恢復async函數的執行並返回解析(resolved)。
  • 內置執行器。 Generator 函數的執行必須靠執行器,所以才有了 co 函數庫,而 async 函數自帶執行器。也就是説,async 函數的執行,與普通函數一模一樣,只要一行。
  • 更廣的適用性。co 模塊約定,yield 命令後面只能是 Thunk 函數或 Promise對象。而 async 函數的 await 命令後面則可以是 Promise 或者 原始類型的值(Number,string,boolean,但這時等同於同步操作)

await特點

  • await 操作符用於等待一個Promise 對象。它只能在異步函數 async function 中使用。
  • [return_value] = await expression;
  • await 表達式會暫停當前 async function 的執行,等待 Promise 處理完成。若 Promise 正常處理(fulfilled),其回調的resolve函數參數作為 await 表達式的值,繼續執行 async function。
  • 另外,如果 await 操作符後的表達式的值不是一個 Promise,則返回該值本身。
  • 若 Promise 處理異常(rejected),await 表達式會把 Promise 的異常原因拋出。可以用catch在外部捕獲

重點:遇到 await 表達式時,會讓 async 函數 暫停執行,等到 await 後面的語句(Promise)狀態發生改變(resolved或者rejected)之後,再恢復 async 函數的執行(再之後 await 下面的語句),並返回解析值(Promise的值)

async await 異常處理

let last;
async function throwError() {  
    await Promise.reject('error');//這裏就是異常    
    last = await '沒有執行'; 
}
throwError().then(success => console.log('成功', success,last))
            .catch(error => console.log('失敗',error,last))

上面函數,執行的到await排除一個錯誤後,就停止往下執行,導致last沒有賦值報錯。
async裏如果有多個await函數的時候,如果其中任一一個拋出異常或者報錯了,都會導致函數停止執行,直接reject;
怎麼處理呢,可以用try/catch,遇到函數的時候,可以將錯誤拋出,並且繼續往下執行。

let last;
async function throwError() {  
    try{  
       await Promise.reject('error');    
       last = await '沒有執行'; 
    }catch(error){
        console.log('has Error stop');
    }
}
throwError().then(success => console.log('成功', last))
            .catch(error => console.log('失敗',last))

Async執行方式

簡單説 , async/awit 就是對上面gennerator自動化流程的封裝 , 讓每一個異步任務都是自動化的執行 , 當第一個異步任務readFile(A)執行完如上一點説明的, async內部自己執行next(),調用第二個任務readFile(B);

這裏引入ES6阮一峯老師的例子
const fs = require('fs');

const readFile = function (fileName) {
  return new Promise(function (resolve, reject) {
    fs.readFile(fileName, function(error, data) {
      if (error) return reject(error);
      resolve(data);
    });
  });
};


async function read() {
    await readFile(A);//執行到這裏停止往下執行,等待readFile內部resolve(data)後,再往下執行
    await readFile(B);
    await readFile(C);
    //code
}

//這裏可用於捕獲錯誤
read().then((data) => {
    //code
}).catch(err => {
    //code
});

注意await下面的代碼執行的時機

async function async1() {
    console.log('async1 start')
    await async2()
    console.log('async1 end')
}
    
async function async2() {
    console.log('async2')
}
    
async1();
    
new Promise((resolve) => {
    console.log(1)
    resolve()
}).then(() => {
    console.log(2)
}).then(() => {
    console.log(3)
}).then(() => {
    console.log(4)
})

執行順序:

async1 start
async2
1
async1 end//註冊在async2()的.then裏,推遲了一個時序
2
3
4

新版V8中執行的時序等價於(激進優化後與老版不同):

function async1(){
    console.log('async1 start');
    const p = async2();
    return Promise.resolve(p)
        .then(() => {
            console.log('async1 end')
        });
}
    
function async2(){
    console.log('async2');
    return Promise.resolve();
}
    
async1();
    
new Promise((resolve) => {
    console.log(1)
    resolve()
}).then(() => {
    console.log(2)
}).then(() => {
    console.log(3)
}).then(() => {
    console.log(4)
})

參考文章

  • http://es6.ruanyifeng.com/
  • https://developer.mozilla.org...
  • https://juejin.im/post/5aa786...
  • https://juejin.im/post/5b83cb...
  • https://juejin.im/post/596e14...
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.