在上一節中我們瞭解了常見的es6語法的一些知識點。這一章節我們將會學習非同步程式設計這一塊內容,鑑於非同步程式設計是js中至關重要的內容,所以我們將會用三個章節來學習非同步程式設計涉及到的重點和難點,同時這一塊內容也是面試常考範圍。
併發(concurrency)和並行(parallelism)的區別
面試題 併發和並行的區別?
非同步和這一小節的知識點其實並不是一個概念,但是這個兩個名詞確實是很多人混淆的知識點,其實混淆的原因可能只是兩個名詞在中文的相似,在英文上來說完全是不同的單詞。
併發是宏觀概念,我分別有任務A和任務B,在一段時間內透過任務間的切換完成了這兩個任務,這種情況就可以成為併發。
並行是微觀概念,假設cpu中存在兩個核心,那麼我就可以同時完成任務A,B。同時完成多個任務的情況就可以稱之為並行。
回撥函式(callback)
面試題: 什麼是回撥函式?回撥函式有什麼缺點?如何解決回撥地獄問題?
回撥函式應該是大家經常使用到的,以下程式碼是回撥函式的例子:
ajax(url,()=>{
//處理邏輯
})
但是回撥函式有個致命的弱點,就是容易寫出回撥地獄,假設多個請求存在依賴性,你可能就會寫出如下程式碼:
ajax(url,()=>{
ajax(url,()=>{})
})
以上程式碼看起來不利於閱讀和維護,當然你可能會說解決這個問題還不簡單,把函式分開來寫不就得了
function firstAjax(){
ajax(url1,()=>{
secondAjax()
})
}
function second(){
ajax(url2,()=>{
})
}
ajax(url,()=>{
firstAjax()
})
以上程式碼看上去有利於閱讀了,但是還是沒有解決根本問題
回撥地獄得根本問題是:
- 巢狀函式存在耦合性,一旦有改動,就會牽一髮而動全身
- 巢狀函式一多就很難處理錯誤
當然,回撥函式還存在著別的缺點,比如不能使用try catch捕獲錯誤,不能直接return。
Generator
面試題:你理解的generator是什麼?
Generator算是es6中難理解的概念之一了,Generator最大的特點就是可以控制函式的執行。在這一小節中我們不會講什麼是Generator,而把重點放在Generator的一些容易困惑的地方。
function *foo(){
let y = 2*(yield(x+1))
let z = yield(y/3)
return (x+y+z)
}
let it = foo(5)
console.log(it.next())
console.log(it.next(12))
console.log(it.next(13))
你也許會疑惑為什麼會產生與你預想不同的值,接下來就讓我為你逐行程式碼分析原因
- 首先 Generator 函式呼叫和普通函式不同,它會返回一個迭代器
- 當執行第一次 next 時,傳參會被忽略,並且函式暫停在 yield (x + 1) 處,所以返回 5 + 1 = 6
- 當執行第二次 next 時,傳入的引數等於上一個 yield 的返回值,如果你不傳參,yield 永遠返回 undefined。此時 let y = 2 12,所以第二個 yield 等於 2 12 / 3 = 8
- 當執行第三次 next 時,傳入的引數會傳遞給 z,所以 z = 13, x = 5, y = 24,相加等於 42
Generator 函式一般見到的不多,其實也於他有點繞有關係,並且一般會配合 co 庫去使用。當然,我們可以透過 Generator 函式解決回撥地獄的問題,可以把之前的回撥地獄例子改寫為如下程式碼:
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()
Promise
翻譯過來就是承諾的意思,這個承諾會在未來有一個確切的答覆,並且該承諾有三種狀態,分別是:
- 等待中(pending)
- 完成了 (resolved)
- 拒絕了(rejected)
這個承諾一旦從等待狀態變成其他狀態就永遠不能更改狀態了,也就是說一旦狀態編為resolved後就不能再次改變
new Promise((resolve, reject) => {
resolve('success')
// 無效
reject('reject')
})
當我們在構造 Promise 的時候,建構函式內部的程式碼是立即執行的
new Promise((resolve, reject) => {
console.log('new Promise')
resolve('success')
})
console.log('finifsh')
// new Promise -> finifsh
Promise 實現了鏈式呼叫,也就是說每次呼叫 then 之後返回的都是一個 Promise,並且是一個全新的 Promise,原因也是因為狀態不可變。如果你在 then 中 使用了 return,那麼 return 的值會被 Promise.resolve() 包裝,參考 前端進階面試題詳細解答
Promise.resolve(1)
.then(res => {
console.log(res) // => 1
return 2 // 包裝成 Promise.resolve(2)
})
.then(res => {
console.log(res) // => 2
})
當然了,Promise 也很好地解決了回撥地獄的問題,可以把之前的回撥地獄例子改寫為如下程式碼:
ajax(url)
.then(res => {
console.log(res)
return ajax(url1)
}).then(res => {
console.log(res)
return ajax(url2)
}).then(res => console.log(res))
前面都是在講述 Promise 的一些優點和特點,其實它也是存在一些缺點的,比如無法取消 Promise,錯誤需要透過回撥函式捕獲。
async 及 await
面試題:async 及 await 的特點,它們的優點和缺點分別是什麼?await 原理是什麼?
一個函式如果加上 async ,那麼該函式就會返回一個 Promise
async function test() {
return "1"
}
console.log(test()) // -> Promise {<resolved>: "1"}
async 就是將函式返回值使用 Promise.resolve() 包裹了下,和 then 中處理返回值一樣,並且 await 只能配套 async 使用
async function test() {
let value = await sleep()
}
async 和 await 可以說是非同步終極解決方案了,相比直接使用 Promise 來說,優勢在於處理 then 的呼叫鏈,能夠更清晰準確的寫出程式碼,畢竟寫一大堆 then 也很噁心,並且也能優雅地解決回撥地獄問題。當然也存在一些缺點,因為 await 將非同步程式碼改造成了同步程式碼,如果多個非同步程式碼沒有依賴性卻使用了 await 會導致效能上的降低。
async function test() {
// 以下程式碼沒有依賴性的話,完全可以使用 Promise.all 的方式
// 如果有依賴性的話,其實就是解決回撥地獄的例子了
await fetch(url)
await fetch(url1)
await fetch(url2)
}
下面來看一個使用 await 的例子:
let a = 0
let b = async () => {
a = a + await 10
console.log('2', a) // -> '2' 10
}
b()
a++
console.log('1', a) // -> '1' 1
對於以上程式碼你可能會有疑惑,讓我來解釋下原因
- 首先b先執行,在執行await 10之前變數a還是0,因為await內部實現了generator,generator會保留堆疊中東西,所以這個時候a = 0被儲存下來
- 因為await是非同步操作,後來的表示式不返回promise的話,就會包裝成Promise.resolve(返回值),然後去執行函式外的同步程式碼
- 同步程式碼執行完畢後開始執行非同步程式碼,將儲存下來的值拿出來使用,這時候 a = 0 + 10
上述解釋中提到了 await 內部實現了 generator,其實 await 就是 generator 加上 Promise 的語法糖,且內部實現了自動執行 generator。如果你熟悉 co 的話,其實自己就可以實現這樣的語法糖。
常用定時器
面試題: setTimeout,setInterval,requestAnimationFrame 各有什麼特點?
非同步程式設計當然少不了定時器,常見的定時器函式有setTimeout,setInterval,requestAnimationFrame。我們先來講講最常用的setTimeout,很多人認為setTimeout是延遲多久,那就應該是多久後執行。
其實這個觀點是錯誤的,因為js是單執行緒執行的,如果前面的程式碼影響了效能,就會導致setTimeout不會按期執行。當然了,我們可以透過程式碼修正setTimeout,從而使定時器相對準確
let period = 60 * 1000 * 60 * 2
let startTime = new Date().getTime()
let count = 0
let end = new Date().getTime() + period
let interval = 1000
let currentInterval = interval
function loop() {
count++
// 程式碼執行所消耗的時間
let offset = new Date().getTime() - (startTime + count * interval);
let diff = end - new Date().getTime()
let h = Math.floor(diff / (60 * 1000 * 60))
let hdiff = diff % (60 * 1000 * 60)
let m = Math.floor(hdiff / (60 * 1000))
let mdiff = hdiff % (60 * 1000)
let s = mdiff / (1000)
let sCeil = Math.ceil(s)
let sFloor = Math.floor(s)
// 得到下一次迴圈所消耗的時間
currentInterval = interval - offset
console.log('時:'+h, '分:'+m, '毫秒:'+s, '秒向上取整:'+sCeil, '程式碼執行時間:'+offset, '下次迴圈間隔'+currentInterval)
setTimeout(loop, currentInterval)
}
setTimeout(loop, currentInterval)
接下來我們來看 setInterval,其實這個函式作用和 setTimeout 基本一致,只是該函式是每隔一段時間執行一次回撥函式。
通常來說不建議使用 setInterval。第一,它和 setTimeout 一樣,不能保證在預期的時間執行任務。第二,它存在執行累積的問題,請看以下虛擬碼
function demo() {
setInterval(function(){
console.log(2)
},1000)
sleep(2000)
}
demo()
以上程式碼在瀏覽器環境中,如果定時器執行過程中出現了耗時操作,多個回撥函式會在耗時操作結束以後同時執行,這樣可能就會帶來效能上的問題。
如果你有迴圈定時器的需求,其實完全可以透過 requestAnimationFrame 來實現
function setInterval(callback, interval) {
let timer
const now = Date.now
let startTime = now()
let endTime = startTime
const loop = () => {
timer = window.requestAnimationFrame(loop)
endTime = now()
if (endTime - startTime >= interval) {
startTime = endTime = now()
callback(timer)
}
}
timer = window.requestAnimationFrame(loop)
return timer
}
let a = 0
setInterval(timer => {
console.log(1)
a++
if (a === 3) cancelAnimationFrame(timer)
}, 1000)
首先 requestAnimationFrame 自帶函式節流功能,基本可以保證在 16.6 毫秒內只執行一次(不掉幀的情況下),並且該函式的延時效果是精確的,沒有其他定時器時間不準的問題,當然你也可以透過該函式來實現 setTimeout。