面試必問!一文帶你走進異步編程

19c24ddc-99d7-461e-a782-a93b7de9cc5e.gif

陳晨,微醫雲服務團隊前端工程師,一位「生命在於靜止」的程序員。javascript

異步的由來

JavaScript 是單線程語言,瀏覽器只分配了一個主線程執行任務,意味着若是有多個任務,則必須按照順序執行,前一個任務執行完成以後才能繼續下一個任務。html

這個模式比較清晰,可是當任務耗時較長的時候,好比網絡請求,定時器和事件監聽等,這個時候後續任務繼續等待,效率比較低。咱們常見的頁面無響應,有時候就是由於任務耗時長或者無限循環等形成的。那如今是怎麼解決這個問題呢。。。。前端

首先維護了一個「任務隊列」。JavaScript 雖然是單線程的,但運行的宿主環境(瀏覽器)是多線程的,瀏覽器爲這些耗時任務開闢了另外的線程,主要包括 http 請求線程,瀏覽器定時觸發器,瀏覽器事件觸發線程。這些線程主要把任務回調,放在任務隊列裏,等待主線程執行。
簡單介紹以下圖: 截圖.pngjava

這樣就實現了 JavaScript 的單線程異步,任務被分爲同步任務和異步任務兩種:
同步任務:排隊執行的任務,後一個任務等待前一個任務結束。
異步任務:放入任務隊列的任務,將來纔會觸發執行的事件。程序員

異步執行機制

異步任務分爲宏任務和微任務。es6

宏任務(macroTask)

宏任務,其實就是標準機制下的常規任務,即」任務隊列中「等待被主線程執行的事件,是由瀏覽器宿主發起的任務,例如:編程

  • script (能夠理解爲外層主程序同步代碼)。
  • setTimeout,setInterval,requestAnimationFrame。
  • I/O。
  • 渲染事件(解析 DOM,佈局,繪製等)。
  • 用戶交互事件(鼠標點擊,頁面滾動,放大縮小等)。

宏任務會被放在宏任務隊列裏,先進先出的原則,兩個宏任務中間可能會被插入其餘系統任務,間隔時間不定,效率較低 。數組

微任務(microTask)

因爲宏任務間隔不定,時間顆粒大,對於實時性要求比較高的場景就須要更精確地控制,須要把任務插入到當前宏任務執行,從而產生了微任務的概念。
微任務是 JavaScript 引擎發起的,是須要異步執行的函數。例如:promise

  • Promise:ES6 的異步編程,Promise 的各類 Api 會產生微任務,下面異步實現會作詳細介紹。
  • MutationObserver(瀏覽器):監視 DOM 樹更改,DOM 節點的變化是微任務。

在執行 JavaScript 腳本,建立全局執行上下文的時候,JavaScript 引擎就會建立一個微任務隊列,在執行當前宏任務時,產生的微任務都會保存到微任務隊列裏。在宏任務主函數執行結束以後,宏任務結束以前,清空微任務隊列。
微任務和宏任務是綁定的,每一個宏任務都會建立本身的微任務: image.png瀏覽器

事件循環(Event loop)

主線程運行 JavaScript 代碼時,會生成個執行棧(先進後出),管理主線程上函數調用關係的數據結構。
當執行棧中的全部同步任務執行完畢,系統就會不斷的從"任務隊列"中讀取事件,這個過程是循環不斷的,稱爲 Event Loop(事件循環)。
事件循環機制調度宏任務和微任務,機制以下:

  1. 執行一個宏任務(第一次是最外層同步代碼),執行過程當中若是遇到微任務會加入微任務隊列;
  2. 代碼執行完成後,查看是否有微任務,若是有的執行第 3 步,沒有則執行第 4 步;
  3. 依次執行全部微任務,在執行微任務的過程當中產生的新的微任務也會被事件循環處理,直到隊列清空,宏任務完成,執行第 4 步;
  4. 查看是否有下一個宏任務,有的話則執行第 1 步,沒有則結束。

由於微任務自身能夠入列更多的微任務,且事件循環會持續處理微任務直至隊列爲空,那麼就存在一種使得事件循環無盡處理微任務的真實風險。如何處理遞歸增長微任務是要謹慎而行的。

image.png

異步的實現歷程

回調函數

回調函數是一個函數被當作參數傳遞給另外一個函數,另外一個函數完成以後執行回調。好比 Ajax 請求、IO 操做、定時器的回調等。
下面是 setTimeout 例子:

console.log('setTimeout 調用以前')
setTimeout(() => {console.log('setTimeout 輸出')}, 0);
console.log('setTimeout 調用以後')
// 結果
setTimeout 調用以前
setTimeout 調用以後
setTimeout 輸出
複製代碼

setTimeout 回調放入任務隊列中,當主線程的同步代碼執行完以後,纔會執行任務隊列的回調,因此是如上的輸出結果。

優缺點

優勢:回調函數相對比較簡單、容易理解。
缺點:不利於代碼的閱讀和維護,各個部分之間高度耦合,流程會很混亂,並且每一個任務只能指定一個回調函數,易造成回調函數地獄。以下:

setTimeout(function(){
    let value1 = step1()
    setTimeout(function(){
        let value2 = step2(value1)
        setTimeout(function(){
            step3(value2)
        },0);
    },0);
},0);
複製代碼

Promise

Promise 是 ES6 新增的異步編程的方式,在必定程度上解決了回調地域的問題。簡單說就是一個容器,裏面保存着某個將來纔會結束的事件(一般是一個異步操做)的結果。從語法上說,Promise 是一個對象,從它能夠獲取異步操做的消息。
使用 Promise 首先要明白如下特色:

  1. Promise 有三種狀態 pending、rejected、resolved,狀態一旦肯定就不能改變,且只可以由 pending 狀態變成 rejected 或者 resolved 狀態;
  2. Promise 實例最主要的方法就是 then 的實現,有兩個參數。 Promise 執行成功時,調用 then 方法的第一個回調函數,失敗則調用第二個回調函數,並且 then 方法會返回一個新的 Promise 實例。
  3. 其次經常使用的就是 catch 方法,catch 方法實際是 then 方法第一個參數是 null 的狀況,用於指定發生錯誤時的回調函數。
  4. 還有不少其餘的 finally、all、race、allSettled、any、resolve、reject 等一系列 Api。

下面的例子就是常見的異步操做,主要是使用的 then 和 catch:

new Promise((resolve) => {
    resolve(step1())
}).then(res => {
    return step2(res)
}).catch(err => {
    console.log(err)
})
複製代碼

step1 和 step2 是異步操做,step1 執行完以後的返回值會透傳給 then 回調,當作 step2 的入參,經過 then 一層層的代替回調地域。其中 then 的回調會加入微任務隊列。

Promise 爲何是微任務呢?

當 Promise 入參是同步代碼時:

console.log('start')
new Promise((resolve) => {
    console.log('開始 resolve')
    resolve('resolve 返回值')
}).then(data => {
    console.log(data)
})
console.log('end')

// 原生 promise 輸出結果
start
開始 resolve
end
resolve 返回值

複製代碼

首先看下 Promise 的極簡實現:

class Promise {
    constructor (executor) {
        // 回調值
        this.value = ''
        // 成功的回調
        this.onResolvedCallbacks = []
        executor(this.resolve.bind(this))
    }
    resolve (value) {
        this.value = value
        this.onResolvedCallbacks.forEach(callback => callback())
    }
    then (onResolved, onRejected) {
        this.onResolvedCallbacks.push(() => {
            onResolved(this.value)
        })
    }
}

// 此時上面例子執行結果以下
start
開始 resolve
end

複製代碼

因爲 Promise 是延遲綁定機制(回調在業務代碼的後面),executor 是同步代碼時,在執行到 resolve 的時候,尚未執行 then,因此 onResolvedCallbacks 是空數組。這個時候須要讓 resolve 延後執行,能夠先加一個定時器。以下:

resolve (value) {
    setTimeout(() => {
        this.value = value
        this.onResolvedCallbacks.forEach(callback => callback())
    })
}
複製代碼

輸出結果和預期是一致的,這裏使用 setTimeout 來延遲執行 resolve。可是 setTimeout 是宏任務,效率不高,這裏只是用 setTimeout 代替,在瀏覽器中,JavaScript 引擎會把 Promise 回調映射到微任務,既能夠延遲被調用,又提高了代碼的效率。

優缺點

優勢:

  • 將異步操做以同步操做的流程表達出來,避免了層層嵌套的回調函數。
  • 提供統一的接口,使得控制異步操做更加容易。

缺點:

  • 沒法取消 Promise,一旦新建它就會當即執行,沒法中途取消。
  • 若是不設置回調函數,Promise 內部拋出的錯誤,不會反應到外面。
  • 當處於 pending 狀態時,沒法得知目前進展到哪個階段(剛剛開始仍是即將完成)。

Generator/yield

Generator 是 ES6 提供的異步解決方案,其最大的特色就是能夠控制函數的執行。整個 Generator 函數就是一個封裝的異步任務,或者說是異步任務的容器,異步操做須要暫停的地方,都用 yield 語句註明。
Generator 函數的特徵:

  1. function 關鍵字與函數名之間有一個星號;
  2. 函數體內部使用 yield 表達式,定義不一樣的內部狀態;
  3. 經過 yield 暫停執行;
  4. next 恢復執行,而且返回一個包含 value 和 done 屬性的對象,其中 value 表示 yield 表達式的值,done 表示遍歷器是否完成;
  5. next 方法也能夠接受參數, 做爲上一次 yield 語句的返回值。
function* getData () {
  let value1 = yield 111
  let value2 = yield value1 + 111 // 這裏的 value1 就是下面傳入的 val1.value
  return value2
}
let meth = getData()
let val1 = meth.next() 
console.log(val1) // { value: 111, done: false }
let val2 = meth.next(val1.value)
console.log(val2) // { value: 222, done: false }
let val3 = meth.next(val2.value)
console.log(val3) // { value: 222, done: true }

複製代碼
  1. 調用 getData 函數,會返回一個內部指針 meth(即遍歷器);
  2. 調用指針 meth 的 next 方法,移動內部指針,指向第一個遇到的 yield 語句,輸出返回值爲 {value: 111, done: false}
  3. 再次調用指針 meth 的 next 方法,入參爲 111,賦值給 value1,移動內部指針,指向下一個 yield 語句,輸出表達式的返回值爲 {value: 222, done: false}
  4. 持續調用指針 meth 的 next 方法,入參爲 222,賦值給 value2,遇到 return 結束遍歷器,輸出返回值{ value: 222, done: true }

Generator 是怎麼實現暫停和恢復執行的呢?

Generator 是協程的一種實現方式。
協程:協程是一種比線程更加輕量級的存在,協程處在線程的環境中,一個線程能夠存在多個協程,能夠將協程理解爲線程中的一個個任務。經過應用程序代碼進行控制。
上面的例子中協程具體流程以下:

  1. 經過生成器函數 getData 建立一個協程 meth,建立以後沒有當即執行;
  2. 調用 meth.next() 讓協程執行;
  3. 協程執行時,經過關鍵字 yield 暫停協程;
  4. 協程執行時,遇到 return,JavaScript 引擎結束當前協程,並把結果返回給父協程。

image.png

meth 協程和父協程在主線程上交替執行,經過 next() 和 yield 進行控制,只有用戶態,切換效率高。

優缺點

優勢:Generator 是以一種看似順序、同步的方式實現了異步控制流程,加強了代碼可讀性。
缺點:須要手動 next 執行下一步。

async/await

async/await 將 Generator 函數和自動執行器,封裝在一個函數中,是 Generator 的一種語法糖,簡化了外部執行器的代碼,同時利用 await 替代 yield,async 替代生成器的(*)號。

async 和 Generator 相比改進的地方:

  • 內置執行器,不須要使用 next() 手動執行。
  • await 命令後面能夠是 Promise 對象或原始類型的值,若是是原始值,會 Promise 化。
  • async 返回值是 Promise。返回非 Promise 時,async 函數會把它包裝成 Promise 返回。

下面來看個 sleep 的例子:

function sleep(time) {
    return new Promise((resolve, reject) => {
        time+=1000
        setTimeout(() => {
            resolve(time);
        }, 1000);
    });
}
    
async function test () {
    let time =  0 
    for(let i = 0; i < 4; i++) {
        time = await sleep(time);
        console.log(time);
    }
}

test()

// 輸出結果
1000
2000
3000
複製代碼

執行結果每隔一秒會輸出 time,await 是等待的意思,等待 sleep 執行完畢後經過 resolve 返回,纔會繼續執行,間隔至少一秒。

把 async/await 轉成 Generator 和 Promise 來實現。

function test () {
    let time =  0 
    // stepGenerator 生成器
    function* stepGenerator() {
        for (let i = 0; i < 4; i++) {
            let result = yield sleep(time);
            console.log(result);
        }
    }
    let step = stepGenerator()
    let info
    return new Promise((resolve) => {
        // 自執行 next()
        function stepNext ()  {
            info = step.next(time)
            //  執行結束則返回 value
            if (info.done) {
                resolve(info.value)
            } else {
            // 遍歷沒有結束 ,繼續執行
                return Promise.resolve(info.value).then((res) => {
                    time = res
                    return stepNext()
                })
            }
        }
        stepNext()
    })
}
test()
複製代碼
  1. 首先把 async 包裝成 Promise,async/await 轉換成 stepGenerator 生成器,yield 替換 await;
  2. 執行 stepNext();
  3. stepNext 裏,step 遍歷器會執行 next()。done 爲 false 時,說明遍歷沒有完成,經過 Promise.resolve 等待執行結果,獲取結果以後繼續執行 next(),直到 done 爲 true,async 的 resolve 把最終返回。

優缺點

優勢:是 Generator 更簡化的方式,至關於自動執行 Generator,代碼更清晰,更簡單。
缺點:濫用 await 可能會致使性能問題,由於 await 會阻塞代碼,非依賴代碼失去併發性。

多個異步的執行順序問題

多個異步的執行順序問題是很考驗對異步的理解的。下面咱們把 setTimeout、Promise、async/await 放在一塊兒,看下返回結果和預想的是否一致:

console.log('start')
setTimeout(function() {
    console.log('setTimeout')
}, 0);
async function test () {
    let a = await 'await-result'
    console.log(a)
}
test()
new Promise(function(resolve) {
    console.log('promise-resolve')
    resolve()
}).then(function() {
    console.log('promise-then')
})
console.log('end')

//執行結果
start
promise-resolve
end
await-result
promise-then
setTimeout
複製代碼

上述例子中,外層主程序 和 setTimeout 都是宏任務,Promise 和 async/await 是微任務,因此整個流程以下:

  1. 第一個宏任務(主程序)開始執行 ------ 輸出 start
  2. setTimeout 加入宏任務隊列
  3. 執行 test(),async/await 加入微任務隊列
  4. Promise 初始入參是同步代碼,主程序一塊兒執行 ------ 輸出 promise-resolve
  5. Promise 的 then 回調加入微任務隊列
  6. 繼續執行主程序 ------ 輸出 end
  7. 執行第一個微任務 ------ 輸出 await-result
  8. 執行第二個微任務 ------ 輸出 promise-then
  9. 再執行下一個宏任務(setTimeout) ------ 輸出 setTimeout

總結

前端程序員平常代碼常常會用到異步編程,瞭解異步運行的機制和順序有助於更流暢清晰的實現異步代碼,這裏主要分析了異步的由來和異步代碼實現,可結合不一樣的場景和要求進行選擇。

參考資料

e9a30897-8c14-4881-9e33-5428ee948e53.gif

相關文章
相關標籤/搜索