大家認真看這個圖片,然後思考一個問題:當setTimeout執行後,什麼時候開始計時的呢?由於單線程的原因,不可能在setTimeout後就開始執行,因為一個線程同一時間只能做一件事情。執行後續代碼的同時就不可能又去計時。那麼只可能是在所有代碼執行完後才開始計時,然後5秒後執行隊列中的回調函數,是這樣嗎?我們用一段代碼來驗證下:
這段代碼在之前的基礎上加了一個循環,循環次數為50萬次,然後每次輸出i的值。這段循環是比較耗時的,從實際運行來看,大概需要14秒左右(具體時間可自行測算)。這個時間已經遠遠大於setTimeout的等待時間。按照之前的說法,應該先把所有同步的代碼執行完,然後再執行非同步的回調方法,結果應該是:
從結果可以看到setTimeout的計時應該是早就開始了,但是JS是單線程運行,那誰在計時呢?要解釋這個問題,大家一定要先搞明白一件事。JS的單線程並不是指整個JS引擎只有1個線程。它是指運行代碼只有1個線程,但是它還有其他線程來執行其他任務。比如時間函數的計時、AJAX技術中的和後臺交互等操作。所以,實際情況應該是:JS引擎中執行代碼的線程開始運行代碼,當執行到非同步方法時,把非同步的回調方法放入到隊列中,然後由專門計時的線程開始計時。代碼線程繼續運行。如果計時的時間已到,那麼它會通知代碼線程來執行隊列中對應的回調函數。當然,前提是代碼線程已經把同步代碼執行完後。否則需要繼續等待,就像這個例子中一樣。
在調用setTimeout函數時我們傳遞了一個函數進去,這個函數並沒有立即被調用,而是在5秒後被調用。這種函數也被稱為回調函數(關於回調函數請參看前面的內容)。由於JS中的函數是一等公民,它和其他數據類型一樣,可以作為參數傳遞也可以作為返回值返回,所以經常能夠看到回調函數使用。
在非同步實現中,回調函數的使用是不可避免的。之前我不是講過嗎,JS的非同步是單線程非阻塞式的。它將一個非同步動作分為兩步,第一步執行非同步方法,然後代碼接著往下執行。然後在後面的某個時刻調用第二步的回調函數,完成後續動作。
有的時候,我們希望在非同步操作中加入同步的行為。比如,我想列印4句話,但是每句話都在前一句話的基礎上延遲2秒輸出。代碼如下:這段代碼能夠實現想要的功能,但是總覺得哪裡不對。如果輸出的內容越來越多,嵌套的代碼也會增多。那無論是編寫還是閱讀起來都會很恐怖。造成這種情況的罪魁禍首就是回調函數。因為你想在前面的非同步操作完成後再進行接下來的動作,那隻能在它的回調函數中進行,這樣就會越套越多,代碼越來越來複雜,俗稱「回調地獄」。
首先需要創建一個Promise對象,該對象的構造函數中接收一個回調函數,回調函數中可以接收兩個參數,resolve和reject。注意,這個回調函數是在Promise創建後就會調用。它實際上就是非同步操作的第一步。那第二步操作再在哪裡做呢?Promise把兩個步驟分開了,第二步通過Promise對象的then方法實現。
實際上Promise實現非同步的原理和之前純用回調函數的原理是一樣的。只是Promise的做法是顯示的將兩個步驟分開來寫。then方法的回調函數同樣會先放入隊列中,等待所有的同步方法執行完後,同時Promise中的resolve也被調用後,該回調函數才會執行。
調用resolve時還可以把數據傳遞給then的回調函數。
let pm = new Promise(function(resolve,reject){
resolve("this is data");
});
console.log("go on");
pm.then(function(data){
console.log("非同步完成",data);
});
reject是出現錯誤時調用的方法。它觸發的不是then中的回調函數,而是catch中的回調函數。比如:
let err = false;
let pm = new Promise(function(resolve,reject){
if(!err){
resolve("this is data");
}else{
reject("fail");
}
});
console.log("go on");
pm.then(function(data){
console.log("非同步完成",data);
});
pm.catch(function(err){
console.log("出現錯誤",err);
});
下面,我把剛才時間函數的非同步操作用Promise實現一次。當然,其中setTimeout還是需要使用,只是在它外面包裹一個Promise對象。
let pm = new Promise(function(resolve,reject){
setTimeout(function(){
resolve();
},2000);
});
console.log("go on");
pm.then(function(){
console.log("非同步完成");
});
效果和之前一樣,但是代碼複雜了不少,感覺有點多此一舉。接下來做做同步效果。
let timeout = function(time){
return new Promise(function(resolve,reject){
setTimeout(function(){
resolve();
},time);
});
}
console.log("go on");
timeout(2000).then(function(){
console.log("first");
return timeout(2000);
}).then(function(){
console.log("second");
return timeout(2000);
}).then(function(){
console.log("third");
return timeout(2000);
}).then(function(){
console.log("fourth");
return timeout(2000);
});
由於需要多次創建Promise對象,所以用了timeout函數將它封裝起來,每次調用它都會返回一個新的Promise對象。當then方法調用後,其內部的回調函數默認會將當前的Promise對象返回。當然也可以手動返回一個新的Promise對象。我們這裡就手動返回了一個新的計時對象,因為需要重新開始計時。後面繼續用then方法來觸發非同步完成的回調函數。這樣就可以做到同步的效果,從而避免了過多的回調嵌套帶來的「回調地獄」問題。
實際上Promise的應用還是比較多,比如前面講到的fetch,它就利用了Promise來實現AJAX的非同步操作:
let pm = fetch("/users"); // 獲取Promise對象
pm.then((response) => response.text()).then(text => {
test.innerText = text; // 將獲取到的文本寫入到頁面上
})
.catch(error => console.log("出錯了"));
注意:response.text()返回的不是文本,而是Promise對象。所以後面又跟了一個then,然後從新的Promise對象中獲取文本內容。
Promise作為ES6提供的一種新的非同步編程解決方案,但是它也有問題。比如,代碼並沒有因為新方法的出現而減少,反而變得更加複雜,同時理解難度也加大。因此它並不是非同步實現的最終形態,後續我們還會繼續介紹其他的非同步實現方法。
迭代器和生成器
Promise來實現非同步也會存在一些問題,比如代碼量增多,不易理解。接下來看看另外一種非同步操作的方法—-生成器。這是ES6新增的方法,在講它之前,咱們還得理解另外一個東西:迭代器。
迭代器(Iterator)
迭代器是一種介面,也可以說是一種規範。它提供了一種統一的遍曆數據的方法。我們都知道數組、集合、對象都有自己的循環遍歷方法。比如數組的循環:
let ary = [1,2,3,4,5,6,7,8,9,10];
//for循環
for(let i = 0;i < ary.length;i++){
console.log(ary[i]);
}
//forEach循環
ary.forEach(function(ele){
console.log(ele);
});
//for-in循環
for(let i in ary){
console.log(ary[i]);
}
//for-of循環
for(let ele of ary){
console.log(ele);
}
集合的循環:
let list = new Set([1,2,3,4,5,6,7,8,9,10]);
for(let ele of list){
console.log(ele);
}
對象的循環:
let obj = {
name : tom,
age : 25,
gender : 男,
intro : function(){
console.log(my name is +this.name);
}
}
for(let attr in obj){
console.log(attr);
}
從以上的代碼可以看到,數組可以用for、forEach、for-in以及for-of來遍歷。集合能用for-of。對象能用for-in。也就是說,以上數據類型的遍歷方式都各有不同,那麼有沒有統一的方式遍歷這些數據呢?這就是迭代器存在的意義。它可以提供統一的遍曆數據的方式,只要在想要遍歷的數據結構中添加一個支持迭代器的屬性即可。這個屬性寫法是這樣的:
const obj = {
[Symbol.iterator]:function(){}
}
[Symbol.iterator]屬性名是固定的寫法,只要擁有了該屬性的對象,就能夠用迭代器的方式進行遍歷。迭代器的遍歷方法是首先獲得一個迭代器的指針,初始時該指針指向第一條數據之前。接著通過調用next方法,改變指針的指向,讓其指向下一條數據。每一次的next都會返回一個對象,該對象有兩個屬性。其中value代表想要獲取的數據,done是個布爾值,false表示當前指針指向的數據有值。true表示遍歷已經結束。
let ary = [1,2,3];
let it = ary[Symbol.iterator](); // 獲取數組中的迭代器
console.log(it.next()); // { value: 1, done: false }
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: 3, done: false }
console.log(it.next()); // { value: undefined, done: true }
數組是支持迭代器遍歷的,所以可以直接獲取其中的迭代器。集合也是一樣。
let list = new Set([1,2,3]);
let it = list.entries(); // 獲取set集合中的迭代器
console.log(it.next()); // { value: [ 1, 1 ], done: false }
console.log(it.next()); // { value: [ 2, 2 ], done: false }
console.log(it.next()); // { value: [ 3, 3 ], done: false }
console.log(it.next()); // { value: undefined, done: true }
set集合中每次遍歷出來的值是一個數組,裡面的第一和第二個元素都是一樣的。
由於數組和集合都支持迭代器,所以它們都可以用同一種方式來遍歷。es6中提供了一種新的循環方法叫做for-of。它實際上就是使用迭代器來進行遍歷,換句話說只有支持了迭代器的數據結構才能使用for-of循環。在JS中,默認支持迭代器的結構有:
Array
Map
Set
String
TypedArray
函數的 arguments 對象
NodeList 對象
這裡面並沒有包含自定義的對象,所以當我們創建一個自定義對象後,是無法通過for-of來循環遍歷它。除非將iterator介面加入到該對象中:
let obj = {
name : tom,
age : 25,
gender : 男,
intro : function(){
console.log(my name is +this.name);
},
[Symbol.iterator]:function(){
let i = 0;
let keys = Object.keys(this); // 獲取當前對象的所有屬性並形成一個數組
return {
next: function(){
return {
value:keys[i++], // 外部每次執行next都能得到數組中的第i個元素
done:i > keys.length // 如果數組的數據已經遍歷完則返回true
}
}
}
}
}
for(let attr of obj){
console.log(attr);
}
通過自定義迭代器就能讓自定義對象使用for-of循環。
迭代器的概念及使用方法我們清楚了,接下來就是生成器。
生成器(Generator)
生成器也是ES6新增加的一種特性。它的寫法和函數非常相似,只是在聲明時多了一個」*」號。
function* say(){}
const say = function*(){}
注意:這個」*」只能寫在function關鍵字的後面。
生成器函數和普通函數並不只是一個「*」號的區別。普通函數在調用後,必然開始執行該函數,直到函數執行完或遇到return為止。中途是不可能暫停的。但是生成器函數則不一樣,它可以通過yield關鍵字將函數的執行掛起,或者理解成暫停。它的外部在通過調用next方法,讓函數繼續執行,直到遇到下一個yield,或函數執行完畢。
function* say(){
yield "開始";
yield "執行中";
yield "結束";
}
let it = say(); // 調用say方法,得到一個迭代器
console.log(it.next()); // { value: 開始, done: false }
console.log(it.next()); // { value: 執行中, done: false }
console.log(it.next()); // { value: 結束, done: false }
console.log(it.next()); // { value: undefined, done: true }
調用say函數,這句和普通函數的調用沒什麼區別。但是此時say函數並沒有執行,而是返回了一個該生成器的迭代器對象。接下來就和之前一樣,執行next方法,say函數執行,當遇到yield時,函數被掛起,並返回一個對象。對象中包含value屬性,它的值是yield後面跟著的數據。並且done的值為false。再次執行next,函數又被激活,並繼續往下執行,直到遇到下一個yield。當所有的yield都執行完了,再次調用next時得到的value就是undefined,done的值為true。
如果你能理解剛才講的迭代器,那麼此時的生成器也就很好理解了。它的yield,其實就是next方法執行後掛起的地方,並得到你返回的數據。那麼這個生成器有什麼用呢?它的yield關鍵字可以將執行的代碼掛起,外部通過next方法讓它繼續運行。這和非同步操作的原理非常類似,把一個操作分為兩部分,先執行一部分,然後再執行另外一部分。所以生成器可以處理和非同步相關的操作。我們知道,非同步操作主要是依靠回調函數實現。但是純回調函數的方式去處理同步效果會帶來「回調地域「的問題.Promise可以解決這個問題。但是Promise寫起來代碼比較複雜,不易理解。而生成器又提供了一種解決方案。看下面這個例子:function* delay(){
yield new Promise((resolve,reject) => {setTimeout(()=>{resolve()},2000)})
console.log("go on");
}
let it = delay();
it.next().value.then(()=>{
it.next();
});
這個例子實現了等待2秒鐘後,列印字元串」go on」。下面我們來分析下這段代碼。在delay這個生成器中,yield後面跟了一個Promise對象。這樣,當外部調用next時就能得到這個Promise對象。然後調用它的then函數,等待2秒鐘後Promise中會調用resolve方法,接著then中的回調函數被調用。也就是說,此時指定的等待時間已到。然後在then的回調函數中繼續調用生成器的next方法,那麼生成器中的代碼就會繼續往下執行,最後輸出字元串」go on」。
例子中時間函數外面為什麼要包裹一個Promise對象呢?這是因為時間函數本身就是一個非同步方法,給它包裹一個Promise對象後,外部就可以通過then方法來處理非同步操作完成後的動作。這樣,在生成器中,就可以像寫同步代碼一樣來實現非同步操作。比如,利用fetch來獲取遠程伺服器的數據(為了測試方便,我將用MockJS來攔截請求)。Mock.mock(/.json/,{
stuents|5-10 : [{
id|+1 : 1,
name : @cname,
gender : /[男女]/, //在正則表達式匹配的範圍內隨機
age|15-30 : 1, //年齡在15-30之間生成,值1隻是用來確定數據類型
phone : /1d{10}/,
addr : @county(true), //隨機生成中國的一個省、市、縣數據
date : "@date(yyyy-MM-dd)"
}]
});
function* getUsers(){
let data = yield new Promise((resolve,reject) => {
$.ajax({
type:"get",
url:"/users.json",
success:function(data){
resolve(data)
}
});
});
console.log("data",data);
}
let it = getUsers();
it.next().value.then((data) => {
it.next(data);
});
在Promise中調用JQuery的AJAX方法,當數據返回後調用resolve,觸發外部then方法的回調函數,將數據返回給外部。外部的then方法接收到data數據後,再次調用next,移動生成器的指針,並將data數據傳遞給生成器。所以,在生成器中你可以看到,我聲明瞭一個data變數來接收非同步操作返回的數據,這裡的代碼就像同步操作一樣,但實際上它是個非同步操作。當非同步的數據返回後,才會執行後面的列印操作。這裡的關鍵代碼就是yield後面一定是一個Promise對象,因為只有這樣外部才能調用then方法來等待非同步處理的結果,然後再繼續做接下來的操作。
之前我們還講過一個替代AJAX的方法fetch,它本身就是用Promise的方法來實現非同步,所以代碼寫起來會更簡單:
function* getUsers(){
let response = yield fetch("/users");
let data = yield response.json();
console.log("data",data);
}
let it = getUsers();
it.next().value.then((response) => {
it.next(response).value.then((data) => {
it.next(data);
});
});
由於mock無法攔截fetch請求,所以我用nodejs+express搭建了一個mock-server伺服器。
這裡的生成器我用了兩次yield,這是因為fetch是一個非同步操作,獲得了響應信息後再次調用json方法來得到其中返回的JSON數據。這個方法也是個非同步操作。
從以上幾個例子可以看出,如果單看生成器的代碼,非同步操作可以完全做的像同步代碼一樣,比起之前的回調和Promise都要簡單許多。但是,生成器的外部還是需要做很多事情,比如需要頻繁調用next,如果要做同步效果依然需要嵌套回調函數,代碼依然很複雜。市面也有很多的插件可以輔助我們來執行生成器,比如比較常見的co模塊。它的使用很簡單:
co(getUsers);
引入co模塊後,將生成器傳入它的方法中,這樣它就能自動執行生成器了。關於co模塊這裡我就不再多講,有興趣的話可以參考這篇文章:http:// es6.ruanyifeng.com/# docs/generator-async
async和await
生成器這種方式需要編寫外部的執行器,而執行器的代碼寫起來一點也不簡單。當然也可以使用一些插件,比如co模塊來簡化執行器的編寫。
在ES7中,加入了async函數來處理非同步。它實際上只是生成器的一種語法糖而已,簡化了外部執行器的代碼,同時利用await替代yield,async替代生成器的(*)號。下面還是來看個例子:async function delay(){
await new Promise((resolve) => {setTimeout(()=>{resolve()},2000)});
console.log("go on);
}
delay();
這個例子我們之前用生成器也寫過,其中把生成器的(*)號被換成了async。async關鍵字必須寫在function的前面。如果是箭頭函數,則寫在參數的前面:
const delay = async () => {}
在函數中,第一句用了await。它替代了之前的yield。後面同樣需要跟上一個Promise對象。接下來的列印語句會在上面的非同步操作完成後執行。外部調用時就和正常的函數調用一樣,但它的實現原理和生成器是類似的。因為有了async關鍵字,所以它的外部一定會有相應的執行器來執行它,並在非同步操作完成後執行回調函數。只不過這一切都被隱藏起來了,由JS引擎幫助我們完成。我們需要做的就是加上關鍵字,在函數中使用await來執行非同步操作。這樣,可以大大的簡化非同步操作。同時,能夠像同步方法一樣去處理它們。
接下來我們再來看看更細節的一些問題。await後面必須是一個Promise對象,這個很好理解。因為該Promise對象會返回給外部的執行器,並在非同步動作完成後執行resolve,這樣外部就可以通過回調函數處理它,並將結果傳遞給生成器。
如圖:
那如果await後面跟的不是Promise對象又會發生什麼呢?
const delay = async () => {
let data = await "hello";
console.log(data);
}
這樣的代碼是允許的,不過await會自動將hello字元串包裝一個Promise對象。就像這樣:
let data = await new Promise((resolve,reject) => resolve("hello"));
創建了Promise對象後,立即執行resolve,並將字元串hello傳遞給外部的執行器。外部執行器的回調函數再將這個hello傳遞迴來,並賦值給data變數。所以,執行該代碼後,馬上就會輸出字元串hello。雖然代碼能夠這樣寫,但是await在這裡的意義並不大,所以await還是應該用來處理非同步方法,同時該非同步方法應該使用Promise對象。
async函數裡面除了有await關鍵字外,感覺和其他函數沒什麼區別,那它能有返回值嗎?答案是肯定的,const delay = async () => {
await new Promise((resolve) => {setTimeout(()=>{resolve()},2000)});
return "finish";
}
let result = delay();
console.log(result);
在delay函數中先執行等待2秒的非同步操作,然後返回字元串finish。外部調用時我用一個變數接收它的返回值。最後輸出的結果是:
// 沒有任何等待立即輸出
Promise { <pending> }
// 2秒後程序結束
我們可以看到,沒有任何等待立即輸出了一個Promise對象。而整個程序是在2秒鐘後才結束的。由此看出,獲取async函數的返回結果實際上是return出來的一個Promise對象。假如return後面跟著的本來就是一個Promise對象,那麼它會直接返回。但如果不是,則會像await一樣包裹一個Promise對象返回。所以,想要得到返回的具體內容應該這樣:
const delay = async () => {
await new Promise((resolve) => {setTimeout(()=>{resolve()},2000)});
return "finish";
}
let result = delay();
console.log(result);
result.then(function(data){
console.log("data:",data);
});
執行的結果:
// 沒有任何等待立即輸出
Promise { <pending> }
//等待2秒後輸出
data: finish
那如果函數沒有任何返回值,得到的又是什麼呢?我將上面代碼中取掉return,再次運行:
// 沒有任何等待立即輸出
Promise { <pending> }
//等待2秒後輸出
data: undefined
可以看到,仍然可以得到Promise對象,但由於函數沒有返回值,所以就不會有任何數據傳遞出來,那麼列印的結果就是undefined。
async的基本原理我們清楚了,下面我們把之前的AJAX例子用async重寫下:Mock.mock(/.json/,{
stuents|5-10 : [{
id|+1 : 1,
name : @cname,
gender : /[男女]/, //在正則表達式匹配的範圍內隨機
age|15-30 : 1, //年齡在15-30之間生成,值1隻是用來確定數據類型
phone : /1d{10}/,
addr : @county(true), //隨機生成中國的一個省、市、縣數據
date : "@date(yyyy-MM-dd)"
}]
});
async function getUsers(){
let data = await new Promise((resolve,reject) => {
$.ajax({
type:"get",
url:"/users.json",
success:function(data){
resolve(data)
}
});
});
console.log("data",data);
}
getUsers();
這是用JQuery的AJAX方法實現。
async function getUsers(){
let response = await fetch("/users");
let data = await response.json();
console.log("data",data);
}
getUsers();
這是fetch方法的實現。
從這兩個例子可以看出,async和生成器兩種方式都很類似,但async可以不藉助任何的第三方模塊,也更易於理解,async表示該函數要做非同步處理。await表示後面的代碼是一個非同步操作,等待該非同步操作完成後再執行後面的動作。如果非同步操作有返回的數據,則在左邊用一個變數來接收它。我們知道,await可以讓非同步操作變為同步的效果。但是,有的時候為了提高效率,我們需要讓多個非同步操作同時進行怎麼辦呢?方法就是執行非同步方法時不加await,這樣它們就可以同時進行,然後在獲取結果時用await。比如:function time(ms){
return new Promise((resolve,reject) => {
setTimeout(()=>{resolve()},ms);
});
}
const delay = async () => {
let t1 = time(2000);
let t2 = time(2000);
await t1;
console.log("t1 finish");
await t2;
console.log("t2 finish");
}
delay();
我先把時間函數的非同步操作封裝成了函數,並返回Promise對象。在delay函數中調用了兩次time方法,但沒有用await。也就是說這兩個時間函數的執行是「同時」(其實還是有先後順序)進行的。然後將它們的Promise對象分別用t1和t2表示。先用await t1。表示等待t1的非同步處理完成,然後輸出t1 finish。接著再用await t2,等待t2的非同步處理完成,最後輸出t2 finish。由於這兩個時間函數是同時執行,而且它們的等待時間也是一樣的。所以,當2秒過後,它們都會執行相應的回調函數。運行的結果就是:等待2秒後,先輸出t1 finish,緊接著立即輸出 t2 finish。
const delay = async () => {
await time(2000);
console.log("t1 finish");
await time(2000);;
console.log("t2 finish");
}
如果是這樣寫,那麼執行的結果會是等待2秒後輸出t1 finish。再等待2秒後輸出t2 finish。
async確實是一個既好用、又簡單的非同步處理方法。但是它的問題就是不兼容老的瀏覽器,只有支持了ES7的瀏覽器才能使用它。最後,還需要注意一個問題:await關鍵字必須寫在async定義的函數中。 推薦閱讀: