JavaScript 異步編程

內容提要:javascript

  • 一、什麼是異步?爲何須要異步?
  • 二、JS異步是如何實現的?
  • 三、JS異步編程方式演化
  • 四、實戰

一、什麼是異步?

同步:必定要等任務執行完了,獲得結果,才執行下一個任務。 異步:不等任務執行完,直接執行下一個任務。html

平常工做的大部分場景,咱們均可以使用同步代碼來實現。告訴編譯器,你應該先作什麼,再作什麼:前端

do('來左邊兒 跟我一塊兒畫個龍'); // 第1步
do('在你右邊兒 畫一道彩虹(走起)'); // 第2步
do('來左邊兒 跟我一塊兒畫彩虹'); // 第3步
do('在你右邊兒 再畫個龍(別停)'); // 第4步
do('在你胸口上比劃一個郭富城'); // 第5步
do('...'') //...
複製代碼

若是函數是同步的,即便調用函數執行的任務比較耗時,也會一直等待直到獲得預期結果。java

可是也會有一些場景,同步並不能知足,這時就須要用到異步的寫法:面試

// 定時器
setTimeout(() => {
    console.log('Hello');
}, 3000)
//讀取文件
fs.readFile('hello.txt', 'utf8', function(err, data) {
    console.log(data);
});
//網絡請求
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = xxx; // 添加回調函數
xhr.open('GET', url);
xhr.send(); // 發起函數
複製代碼

若是函數是異步的,發出調用以後,立刻返回,可是不會立刻返回預期結果。調用者沒必要主動等待,當被調用者獲得結果以後會經過回調函數主動通知調用者。編程

"異步模式"很是重要。在瀏覽器端,耗時很長的操做都應該異步執行,避免瀏覽器失去響應,最好的例子就是Ajax操做。在服務器端,"異步模式"甚至是惟一的模式,由於執行環境是單線程的,若是容許同步執行全部http請求,服務器性能會急劇降低,很快就會失去響應。promise

二、JS異步是如何實現的?

多線程 VS 單線程

在上面介紹異步的過程當中就可能會納悶:既然JavaScript是單線程,怎麼還存在異步,那些耗時操做到底交給誰去執行了?瀏覽器

衆所周知javascript是單線程的,它的設計之初是爲瀏覽器設計的GUI編程語言,GUI編程的特性之一是保證UI線程必定不能阻塞,不然體驗不佳,甚至界面卡死。bash

所謂"單線程",就是指一次只能完成一件任務。若是有多個任務,就必須排隊,前面一個任務完成,再執行後面一個任務,以此類推。服務器

JavaScript其實就是一門語言,說是單線程仍是多線程得結合具體運行環境。JS的運行一般是在瀏覽器中進行的,具體由JS引擎去解析和運行。下面咱們來具體瞭解一下瀏覽器。

瀏覽器

目前最爲流行的瀏覽器爲:Chrome,IE,Safari,FireFox,Opera。瀏覽器的內核是多線程的。 一個瀏覽器一般由如下幾個常駐的線程:

  • 渲染引擎線程:顧名思義,該線程負責頁面的渲染
  • JS引擎線程:負責JS的解析和執行
  • 定時觸發器線程:處理定時事件,好比setTimeout, setInterval
  • 事件觸發線程:處理DOM事件
  • 異步http請求線程:處理http請求

須要注意的是,渲染線程和JS引擎線程是不能同時進行的。渲染線程在執行任務的時候,JS引擎線程會被掛起。由於JS能夠操做DOM,若在渲染中JS處理了DOM,瀏覽器可能就不知所措了。

JS引擎

一般講到瀏覽器的時候,咱們會說到兩個引擎:渲染引擎和JS引擎。渲染引擎就是如何渲染頁面,Chrome/Safari/Opera用的是Webkit引擎,IE用的是Trident引擎,FireFox用的是Gecko引擎。不一樣的引擎對同一個樣式的實現不一致,就致使了常常被人詬病的瀏覽器樣式兼容性問題。這裏咱們不作具體討論。

JS引擎能夠說是JS虛擬機,負責JS代碼的解析和執行。一般包括如下幾個步驟:

  • 詞法分析:將源代碼分解爲有意義的分詞
  • 語法分析:用語法分析器將分詞解析成語法樹
  • 代碼生成:生成機器能運行的代碼
  • 代碼執行

不一樣瀏覽器的JS引擎也各不相同,Chrome用的是V8,FireFox用的是SpiderMonkey,Safari用的是JavaScriptCore,IE用的是Chakra。

之因此說JavaScript是單線程,就是由於瀏覽器在運行時只開啓了一個JS引擎線程來解析和執行JS。那爲何只有一個引擎呢?若是同時有兩個線程去操做DOM,瀏覽器是否是又要不知所措了。

因此,雖然JavaScript是單線程的,但是瀏覽器內部不是單線程的。一些I/O操做、定時器的計時和事件監聽(click, keydown...)等都是由瀏覽器提供的其餘線程來完成的。

消息隊列與事件循環

經過以上了解,能夠知道其實JavaScript也是經過JS引擎線程與瀏覽器中其餘線程交互協做實現異步。可是回調函數具體什麼時候加入到JS引擎線程中執行?執行順序是怎麼樣的?

這一切的解釋就須要繼續瞭解消息隊列和事件循環。

如上圖所示,左邊的棧存儲的是同步任務,就是那些能當即執行、不耗時的任務,如變量和函數的初始化、事件的綁定等等那些不須要回調函數的操做均可歸爲這一類。 右邊的堆用來存儲聲明的變量、對象。下面的隊列就是消息隊列,一旦某個異步任務有了響應就會被推入隊列中。如用戶的點擊事件、瀏覽器收到服務的響應和setTimeout中待執行的事件,每一個異步任務都和回調函數相關聯。

JS引擎線程用來執行棧中的同步任務,當全部同步任務執行完畢後,棧被清空,而後讀取消息隊列中的一個待處理任務,並把相關回調函數壓入棧中,單線程開始執行新的同步任務。

JS引擎線程從消息隊列中讀取任務是不斷循環的,每次棧被清空後,都會在消息隊列中讀取新的任務,若是沒有新的任務,就會等待,直到有新的任務,這就叫事件循環。

上圖以AJAX異步請求爲例,發起異步任務後,由AJAX線程執行耗時的異步操做,而JS引擎線程繼續執行堆中的其餘同步任務,直到堆中的全部異步任務執行完畢。而後,從消息隊列中依次按照順序取出消息做爲一個同步任務在JS引擎線程中執行,那麼AJAX的回調函數就會在某一時刻被調用執行。

實例

引用一篇文章中提到的考察JavaScript異步機制的面試題來具體介紹。

執行下面這段代碼,執行後,在 5s 內點擊兩下,過一段時間(>5s)後,再點擊兩下,整個過程的輸出結果是什麼?

setTimeout(function(){
    for(var i = 0; i < 100000000; i++){}
    console.log('timer a');
}, 0)

for(var j = 0; j < 5; j++){
    console.log(j);
}

setTimeout(function(){
    console.log('timer b');
}, 0)

function waitFiveSeconds(){
    var now = (new Date()).getTime();
    while(((new Date()).getTime() - now) < 5000){}
    console.log('finished waiting');
}

document.addEventListener('click', function(){
    console.log('click');
})

console.log('click begin');
waitFiveSeconds();
複製代碼

要想了解上述代碼的輸出結果,首先介紹下定時器。 setTimeout 的做用是在間隔必定的時間後,將回調函數插入消息隊列中,等棧中的同步任務都執行完畢後,再執行。由於棧中的同步任務也會耗時,因此間隔的時間通常會大於等於指定的時間。 setTimeout(fn, 0) 的意思是,將回調函數fn馬上插入消息隊列,等待執行,而不是當即執行。看一個例子:

setTimeout(function() {
    console.log("a")
}, 0)

for(let i=0; i<10000; i++) {}
console.log("b")

// b a
複製代碼

打印結果代表回調函數並無馬上執行,而是等待棧中的任務執行完畢後才執行的。棧中的任務執行多久,它就得等多久。

理解了定時器的做用,那麼對於輸出結果就容易得出了。

首先,先執行同步任務。其中 waitFiveSeconds 是耗時操做,持續執行長達5s。

0
1
2
3
4
click begin
finished waiting
複製代碼

而後,在JS引擎線程執行的時候,'timer a'對應的定時器產生的回調、 'timer b'對應的定時器產生的回調和兩次 click 對應的回調被前後放入消息隊列。因爲JS引擎線程空閒後,會先查看是否有事件可執行,接着再處理其餘異步任務。所以會產生 下面的輸出順序。 。

click
click
timer a
timer b

複製代碼

最後,5s 後的兩次 click 事件被放入消息隊列,因爲此時JS引擎線程空閒,便被當即執行了。

click
click
複製代碼

理解了JS異步實現的機制後,咱們再看看JS異步編程方式的演化。

三、JS異步編程方式演化

異步發展史能夠簡單概括爲: callback -> promise -> generator + co -> async+await(語法糖)

下面會一步一步展示各類方式。

3.1 回調函數

這是異步編程最基本的用法。

實現1秒後打印消息:

function asyncPrint(value, ms) {
    setTimeout(() => {
        console.log(value);
    },ms)
}
asyncPrint('Hello World', 1000);
複製代碼

回調函數的優勢是簡單、容易理解和部署,缺點是不利於代碼的閱讀和維護,各個部分之間高度耦合(Coupling),流程會很混亂,並且每一個任務只能指定一個回調函數。

3.2 Promise

Promise 是異步編程的一種解決方案,比傳統的解決方案——回調函數和事件——更合理和更強大。它由社區最先提出和實現,ES6 將其寫進了語言標準,統一了用法,原生提供了Promise對象。

Promise 究竟是什麼。當咱們經過new Promise建立 Promise 的時候,你實際建立的只是一個簡單的 JavaScript 對象,這個對象能夠調用兩個方法then和catch。這是關鍵所在,當 Promise 的狀態變爲fulfilled的時候,傳遞給.then的函數將會被調用。若是 Promise 的狀態變爲rejected,傳遞給.catch的函數將會被調用。這就意味着,在你建立 Promise 的時候,要經過.then將你但願異步請求成功時調用的函數傳遞進來,經過.catch將你但願異步請求失敗時調用的函數傳遞進來。

const promise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 異步操做成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});
複製代碼

好比,上面的例子,能夠寫成:

function asyncPrint(value, ms) {
    return new Promise((resolve,reject) => {
        setTimeout(resolve, ms, value);
    })
}

asyncPrint('Hello Wolrd', 1000).then((value) => {
  console.log(value);
});
複製代碼

Promise 不只能夠避免回調地獄,還能夠統一捕獲失敗的緣由。但這種方法的缺點就是編寫和理解,都相對比較難

3.3 Generator(ECMAScript6)

  1. 生成器是一個函數,須要加* ,能夠用來生成迭代器
  2. 生成器函數和普通函數不同,普通函數是一旦調用必定會執行完,可是生成器函 數中間能夠暫停。
  3. 生成器和普通函數不同,調用它並不會當即執行
  4. 它會返回今生成器的迭代器,迭代器是一個對象,每調用一次next就能夠返回一個值對象
function* helloWorldGenerator() {
  yield 'hello';
  yield 'world';
  return 'ending';
}

var hw = helloWorldGenerator();

hw.next()
// { value: 'hello', done: false }

hw.next()
// { value: 'world', done: false }

hw.next()
// { value: 'ending', done: true }

hw.next()
// { value: undefined, done: true }
複製代碼

使用 Generator,上面的例子能夠改寫成:

function *asyncPrint(value, ms) {
    let timer = yield setTimeout((value)=>{console.log(value)}, ms, value);
    return timer;
}

var a = asyncPrint('Hello World', 1000);
a.next();
複製代碼

3.4 co

隨着前端的迅速發展,大神們以爲要像同步代碼同樣寫異步,co問世了,co是 TJ 大神結合了promise 和 生成器 的一個庫,實際上仍是幫助咱們自動執行迭代器

function asyncPrint(value, ms) {
    return new Promise((resolve,rejuect) => {
        setTimeout(resolve, ms, value);
    })
}
function *print() {
  let a = yield asyncPrint('Hello World', 1000);
  return a;
}
function co(gen) {
  let it = gen();//咱們要讓咱們的生成器持續執行
  return new Promise(function (resolve, reject) {
    !function next(lastVal) {
        let {value,done} = it.next(lastVal);
        if(done){
          resolve(value);
        }else{
          value.then(next,reject);
        }
    }()
  });
}
co(print).then(function (data) {
  console.log(data);
});
複製代碼

3.5 async/await

async await是語法糖,內部是generator+promise實現 async函數就是將Generator函數的星號(*)替換成async,將yield替換成await。

function timeout(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

async function asyncPrint(value, ms) {
  await timeout(ms);
  console.log(value);
}

asyncPrint('hello world', 1000);
複製代碼

4. JS異步實戰

有了前面內容的熱身,咱們直接趁熱打鐵,再來看一道比較典型的問題。

紅燈 3s 亮一次,綠燈 1s 亮一次,黃燈 2s 亮一次;如何讓三個燈不斷交替重複亮燈?

請分別用上面的幾種方法實現,具體實現可參考:紅綠燈任務控制

因爲篇幅的緣故,其中沒有提到的宏任務、微任務,以及 Promise、 Generator基礎 等知識點,你們能夠自行百度或Google。

以上就是對 JavaScript 異步編程的所有介紹,若是以爲有收穫,歡迎點贊和留言咯。

參考文章

JavaScript的運行原理

JavaScript異步編程的4種方法

JS 異步發展流程 —— 異步歷史

異步編程的前世此生(異步流程歷史)

100 行代碼實現 Promises/A+ 規範

Javascript異步詳解

相關文章
相關標籤/搜索