淺析JavaScript非同步

攻城獅發表於2019-05-21

一直以來都知道JavaScript是一門單執行緒語言,在筆試過程中不斷的遇到一些輸出結果的問題,考量的是對非同步程式設計掌握情況。一般被問到非同步的時候腦子裡第一反應就是AjaxsetTimseout...這些東西。在平時做專案過程中,基本大多數操作都是非同步的。JavaScript非同步都是通過回撥形式完成的,開發過程中一直在處理回撥,可能不知不覺中自己就已經處在回撥地獄中。

瀏覽器執行緒

在開始之前簡單的說一下瀏覽器的執行緒,對瀏覽器的作業有個基礎的認識。之前說過JavaScript是單執行緒作業,但是並不代表瀏覽器就是單執行緒的。

JavaScript引擎中負責解析和執行JavaScript程式碼的執行緒只有一個。但是除了這個主程式以外,還有其他很多輔助執行緒。那麼諸如onclick回撥,setTimeoutAjax這些都是怎麼實現的呢?即瀏覽器搞了幾個其他執行緒去輔助JavaScript執行緒的執行。

瀏覽器有很多執行緒,例如:

  1. GUI渲染執行緒 - GUI渲染執行緒處於掛起狀態的,也就是凍結狀態
  2. JavaScript引擎執行緒 - 用於解析JavaScript程式碼
  3. 定時器觸發執行緒 - 瀏覽器定時計數器並不是 js引擎計數
  4. 瀏覽器事件執行緒 - 用於解析BOM渲染等工作
  5. http執行緒 - 主要負責資料請求
  6. EventLoop輪詢處理執行緒 - 事件被觸發時該執行緒會把事件新增到待處理佇列的隊尾
  7. 等等等

從上面來看可以得出,瀏覽器其實也做了很多事情,遠遠的沒有想象中的那麼簡單,上面這些執行緒中GUI渲染執行緒,JavaScript引擎執行緒,瀏覽器事件執行緒是瀏覽器的常駐執行緒。

當瀏覽器開始解析程式碼的時候,會根據程式碼去分配給不同的輔助執行緒去作業。

程式

程式是指在作業系統中正在執行的一個應用程式

執行緒

執行緒是指程式內獨立執行某個任務的一個單元。執行緒自己基本上不擁有系統資源,只擁有一點在執行中必不可少的資源(如程式計數器,一組暫存器和棧)。

程式中包含執行緒,一個程式中可以有N個程式。我們可以在電腦的工作管理員中檢視到正在執行的程式,可以認為一個程式就是在執行一個程式,比如用瀏覽器開啟一個網頁,這就是開啟了一個程式。但是比如開啟3個瀏覽器,那麼就開啟了3個程式。

同步&非同步

既然要了解同步非同步當然要簡單的說一下同步和非同步。說到同步和非同步最有發言權的真的就屬Ajax了,為了讓例子更加明顯沒有使用Ajax舉例。(●ˇ∀ˇ●)

同步

同步會逐行執行程式碼,會對後續程式碼造成阻塞,直至程式碼接收到預期的結果之後,才會繼續向下執行。

console.log(1);
alert("同步");
console.log(2);

//  結果:
//  1
//  同步
//  2

非同步

如果在函式返回的時候,呼叫者還不能夠得到預期結果,而是將來通過一定的手段得到結果(例如回撥函式),這就是非同步。

console.log(1);
setTimeout(() => {
   alert("非同步"); 
},0);
console.log(2);

//  結果:
//  1
//  2
//  非同步

為什麼JavaScript要採用非同步程式設計

一開始就說過,JavaScript是一種單執行緒執行的指令碼語言(這可能是由於歷史原因或為了簡單而採取的設計)。它的單執行緒表現在任何一個函式都要從頭到尾執行完畢之後,才會執行另一個函式,介面的更新、滑鼠事件的處理、計時器(setTimeout、setInterval等)的執行也需要先排隊,後序列執行。假如有一段JavaScript從頭到尾執行時間比較長,那麼在執行期間任何UI更新都會被阻塞,介面事件處理也會停止響應。這種情況下就需要非同步程式設計模式,目的就是把程式碼的執行打散或者讓IO呼叫(例如AJAX)在後臺執行,讓介面更新和事件處理能夠及時地執行。

JavaScript語言的設計者意識到,這時主執行緒完全可以不管IO裝置,掛起處於等待中的任務,先執行排在後面的任務。等到IO裝置返回了結果,再回過頭,把掛起的任務繼續執行下去。

非同步執行機制:

  1. 所有同步任務都在主執行緒上執行,形成一個執行棧。
  2. 主執行緒之外,還存在一個任務佇列。只要非同步任務有了執行結果,就在任務佇列之中放置一個事件。
  3. 一旦執行棧中的所有同步任務執行完畢,系統就會讀取任務佇列,看看裡面有哪些事件。那些對應的非同步任務,於是結束等待狀態,進入執行棧,開始執行。
  4. 主執行緒不斷重複上面的第三步。
<button onclick="updateSync()">同步</button>
<button onclick="updateAsync()">非同步</button>
<div id="output"></div>
<script>
function updateSync() {
  for (var i = 0; i < 1000000; i++) {
    document.getElementById('output').innerHTML = i;
  }
}
function updateAsync() {
  var i = 0;
  function updateLater() {
    document.getElementById('output').innerHTML = (i++);
    if (i < 1000000) {
      setTimeout(updateLater, 0);
    }
  }
  updateLater();
}
</script>

點選同步按鈕會呼叫updateSync的同步函式,邏輯非常簡單,迴圈體內每次更新output結點的內容為i。如果在其他多執行緒模型下的語言,你可能會看到介面上以非常快的速度顯示從0999999後停止。但是在JavaScript中,你會感覺按鈕按下去的時候卡了一下,然後看到一個最終結果999999,而沒有中間過程,這就是因為在updateSync函式執行過程中UI更新被阻塞,只有當它結束退出後才會更新UI。反之,當點選非同步的時候,會明顯的看到Dom在逐步更新的過程。

從上面的例子中可以明顯的看出,非同步程式設計對於JavaScript來說是多麼多麼的重要。

非同步程式設計有什麼好處

從程式設計方式來講當然是同步程式設計的方式更為簡單,但是同步有其侷限性一是假如是單執行緒那麼一旦遇到阻塞呼叫,會造成整個執行緒阻塞,導致cpu無法得到有效利用,而瀏覽器的JavaScript執行和瀏覽器渲染是執行在單執行緒中,一旦遇到阻塞呼叫不僅意味JavaScript的執行被阻塞更意味整個瀏覽器渲染也被阻塞這就導致介面的卡死,若是多執行緒則不可避免的要考慮互斥和同步問題,而互斥和同步帶來複雜度也很大,實際上瀏覽器下因為同時只能執行一段JavaScript程式碼這意味著不存在互斥問題,但是同步問題仍然不可避免,以往回撥風格中非同步的流程控制(其實就是同步問題)也比較複雜。瀏覽器端的程式設計方式也即是GUI程式設計,其本質就是事件驅動的(滑鼠點選,Http請求結束等)非同步程式設計更為自然。

突然有個疑問,既然如此為什麼JavaScript沒有使用多執行緒作業呢?就此就去Google了一下JavaScript多執行緒,在HTML5推出之後是提供了多執行緒只是比較侷限。在使用多執行緒的時候無法使用window物件。若JavaScript使用多執行緒,在A執行緒中正在操作DOM,但是B執行緒中已經把該DOM已經刪除了(只是簡單的小栗子,可能還有很多問題,至於這些歷史問題無從考究了)。會給程式設計作業帶來很大的負擔。就我而言我想這也就說明了為什麼JavaScript沒有使用非同步程式設計的原因吧。

非同步與回撥

回撥到底屬於非同步麼?會想起剛剛開始學習JavaScript的時候常常吧這兩個概念混合在一起。在搞清楚這個問題,首先要明白什麼是回撥函式。

百科:回撥函式;回撥函式是一個函式,它作為引數傳遞給另一個函式,並在父函式完成後執行。回撥的特殊之處在於,出現在“父類”之後的函式可以在回撥執行之前執行。另一件需要知道的重要事情是如何正確地傳遞迴調。這就是我經常忘記正確語法的地方。

通過上面的解釋可以得出,回撥函式本質上其實就是一種設計模式,例如我們熟悉的JQuery也只不過是遵循了這個設計原則而已。在JavaScript中,回撥函式具體的定義為:函式A作為引數(函式引用)傳遞到另一個函式B中,並且這個函式B執行函式A。我們就說函式A叫做回撥函式。如果沒有名稱(函式表示式),就叫做匿名回撥函式。

簡單的舉個小例子:

function test (n,fn){
    console.log(n);
    fn && fn(n);
}
console.log(1);
test(2);
test(3,function(n){
    console.log(n+1)
});
console.log(5)

//  結果
//  1
//  2
//  3
//  4
//  5

通過上面的程式碼輸出的結果可以得出回撥函式不一定屬於非同步,一般同步會阻塞後面的程式碼,通過輸出結果也就得出了這個結論。回撥函式,一般在同步情境下是最後執行的,而在非同步情境下有可能不執行,因為事件沒有被觸發或者條件不滿足。

回撥函式應用場景

  1. 資源載入:動態載入js檔案後執行回撥,載入iframe後執行回撥,ajax操作回撥,圖片載入完成執行回撥,AJAX等等。
  2. DOM事件及Node.js事件基於回撥機制(Node.js回撥可能會出現多層回撥巢狀的問題)。
  3. setTimeout的延遲時間為0,這個hack經常被用到,settimeout呼叫的函式其實就是一個callback的體現
  4. 鏈式呼叫:鏈式呼叫的時候,在賦值器(setter)方法中(或者本身沒有返回值的方法中)很容易實現鏈式呼叫,而取值器(getter)相對來說不好實現鏈式呼叫,因為你需要取值器返回你需要的資料而不是this指標,如果要實現鏈式方法,可以用回撥函式來實現。
  5. setTimeout、setInterval的函式呼叫得到其返回值。由於兩個函式都是非同步的,即:呼叫時序和程式的主流程是相對獨立的,所以沒有辦法在主體裡面等待它們的返回值,它們被開啟的時候程式也不會停下來等待,否則也就失去了setTimeout及setInterval的意義了,所以用return已經沒有意義,只能使用callback。callback的意義在於將timer執行的結果通知給代理函式進行及時處理。

JavaScript中的那些非同步操作

JavaScript既然有很多的輔助執行緒,不可能所有的工作都是通過主執行緒去做,既然分配給輔助執行緒去做事情。

XMLHttpRequest

XMLHttpRequest物件應該不是很陌生的,主要用於瀏覽器的資料請求與資料互動。XMLHttpRequest物件提供兩種請求資料的方式,一種是同步,一種是非同步。可以通過引數進行配置。預設為非同步。

對於XMLHttpRequest這裡就不作太多的贅述了。

var xhr = new XMLHttpRequest();
xhr.open("GET", url, false);    //同步方式請求 
xhr.open("GET", url, true);     //非同步
xhr.send();

同步Ajax請求:

當請求開始傳送時,瀏覽器事件執行緒通知主執行緒,讓Http執行緒傳送資料請求,主執行緒收到請求之後,通知Http執行緒傳送請求,Http執行緒收到主執行緒通知之後就去請求資料,等待伺服器響應,過了N年之後,收到請求回來的資料,返回給主執行緒資料已經請求完成,主執行緒把結果返回給了瀏覽器事件執行緒,去完成後續操作。

非同步Ajax請求:

當請求開始傳送時,瀏覽器事件執行緒通知,瀏覽器事件執行緒通知主執行緒,讓Http執行緒傳送資料請求,主執行緒收到請求之後,通知Http執行緒傳送請求,Http執行緒收到主執行緒通知之後就去請求資料,並通知主執行緒請求已經傳送,主程式通知瀏覽器事件執行緒已經去請求資料,則
瀏覽器事件執行緒,只需要等待結果,並不影響其他工作。

setInterval&setTimeout

setIntervalsetTimeout同屬於非同步方法,其非同步是通過回撥函式方式實現。其兩者的區別則setInterval會連續呼叫回撥函式,則setTimeout會延時呼叫回撥函式只會執行一次。

setInterval(() => {
    alert(1)
},2000)
//  每隔2s彈出一次1
setTimeout(() => {
    alert(2)
},2000)
//  進入頁面後2s彈出2,則不會再次彈出

requestAnimationFarme

requestAnimationFrame字面意思就是去請求動畫幀,在沒有API之前都是基於setInterval,與setInterval相比,requestAnimationFrame最大的優勢是由系統來決定回撥函式的執行時機。具體一點講,如果螢幕重新整理率是60Hz,那麼回撥函式就每16.7ms被執行一次,如果重新整理率是75Hz,那麼這個時間間隔就變成了1000/75=13.3ms,換句話說就是,requestAnimationFrame的步伐跟著系統的重新整理步伐走。它能保證回撥函式在螢幕每一次的重新整理間隔中只被執行一次,這樣就不會引起丟幀現象,也不會導致動畫出現卡頓的問題。

舉個小例子:

var progress = 0;
//回撥函式
function render() {
    progress += 1; //修改影像的位置
    if (progress < 100) {
        //在動畫沒有結束前,遞迴渲染
        window.requestAnimationFrame(render);
    }
}
//第一幀渲染
window.requestAnimationFrame(render);

Object.observe - 觀察者

Object.observe是一個提供資料監視的API,在chrome中已經可以使用。是ECMAScript 7 的一個提案規範,官方建議的是謹慎使用級別,但是個人認為這個API非常有用,例如可以對現在流行的MVVM框架作一些簡化和優化。雖然標準還沒定,但是標準往往是滯後於實現的,只要是有用的東西,肯定會有越來越多的人去使用,越來越多的引擎會支援,最終促使標準的生成。從observe字面意思就可以知道,這玩意兒就是用來做觀察者模式之類。

var obj = {a: 1};
Object.observe(obj, output);
obj.b = 2;
obj.a = 2;
Object.defineProperties(obj, {a: { enumerable: false}}); //修改屬性設定
delete obj.b;
function output(change) {
    console.log(1)
}

Promise

Promise是對非同步程式設計的一種抽象。它是一個代理物件,代表一個必須進行非同步處理的函式返回的值或丟擲的異常。也就是說Promise物件代表了一個非同步操作,可以將非同步物件和回撥函式脫離開來,通過then方法在這個非同步操作上面繫結回撥函式。

在Promise中最直觀的例子就是Promise.all統一去請求,返回結果。

var p1 = Promise.resolve(3);
var p2 = 42;
var p3 = new Promise(function(resolve, reject) {
  setTimeout(resolve, 100, 'foo');
});
Promise.all([p1, p2, p3]).then(function(values) {
  console.log(values);
});
// expected output: Array [3, 42, "foo"]

Generator&Async/Await

ES6Generator卻給非同步操作又提供了新的思路,馬上就有人給出瞭如何用Generator來更加優雅的處理非同步操作。Generator函式是協程在ES6的實現,最大特點就是可以交出函式的執行權(即暫停執行)。整個Generator函式就是一個封裝的非同步任務,或者說是非同步任務的容器。非同步操作需要暫停的地方,都用yield語句註明。Generator函式的執行方法如下。

function * greneratorDome(){
    yield "Hello";
    yield "World";
    return "Ending";
}
let grenDome = greneratorDome();
console.log(grenDome.next());
// {value: "Hello", done: false}
console.log(grenDome.next());
// {value: "World", done: false}
console.log(grenDome.next());
// {value: "Ending", done: true}
console.log(grenDome.next());
// {value: undefined, done: true}

粗略實現Generator

function makeIterator(array) {
  var nextIndex = 0;
  return {
    next: function() {
      return nextIndex < array.length ?
        {value: array[nextIndex++], done: false} :
        {value: undefined, done: true};
    }
  };
}
var it = makeIterator(['a', 'b']);
it.next() // { value: "a", done: false }
it.next() // { value: "b", done: false }
it.next() // { value: undefined, done: true }

Async/AwaitGenerator類似,Async/awaitJavascript編寫非同步程式的新方法。以往的非同步方法無外乎回撥函式和Promise。但是Async/await建立於Promise之上,個人理解是使用了Generator函式做了語法糖。async函式就是隧道盡頭的亮光,很多人認為它是非同步操作的終極解決方案。

function a(){
    return new Promise((resolve,reject) => {
        console.log("a函式")
        resolve("a函式")
    })
}
function b (){
    return new Promise((resolve,reject) => {
        console.log("b函式")
        resolve("b函式")
    })
}
async function dome (){
    let A = await a();
    let B = await b();
    return Promise.resolve([A,B]);
}
dome().then((res) => {
    console.log(res);
});

Node.js非同步I/O

當我們發起IO請求時,呼叫的是各個不同平臺的作業系統內部實現的執行緒池內的執行緒。這裡的IO請求可不僅僅是讀寫磁碟檔案,在*nix中,將計算機抽象了一層,磁碟檔案、硬體、套接字等幾乎所有計算機資源都被抽象為檔案,常說的IO請求就是抽象後的檔案。完成Node整個非同步IO環節的有事件迴圈、觀察者、請求物件。

事件迴圈機制

單執行緒就意味著,所有任務需要排隊,前一個任務結束,才會執行後一個任務。如果前一個任務耗時很長,後一個任務就不得不一直等著。於是就有一個概念,任務佇列。如果排隊是因為計算量大,CPU忙不過來,倒也算了,但是很多時候CPU是閒著的,因為IO裝置(輸入輸出裝置)很慢(比如Ajax操作從網路讀取資料),不得不等著結果出來,再往下執行。

事件迴圈是Node的自身執行模型,正是事件迴圈使得回撥函式得以在Node中大量的使用。在程式啟動時Node會建立一個while(true)死迴圈,這個和Netty也是一樣的,每次執行迴圈體,都會完成一次Tick。每個Tick的過程就是檢視是否有事件等待被處理。如果有,就取出事件及相關的回撥函式,並執行關聯的回撥函式。如果不再有事件處理就退出程式。

淺析JavaScript非同步

執行緒只會做一件事情,就是從事件佇列裡面取事件、執行事件,再取事件、再事件。當訊息佇列為空時,就會等待直到訊息佇列變成非空。而且主執行緒只有在將當前的訊息執行完成後,才會去取下一個訊息。這種機制就叫做事件迴圈機制,取一個訊息並執行的過程叫做一次迴圈。

while(true) {
    var message = queue.get();
    execute(message);
}

我們可以把整個事件迴圈想象成一個事件佇列,在進入事件佇列時開始對事件進行彈出操作,直至事件為0為止。

process.nextTick

process.nextTick()方法可以在當前"執行棧"的尾部-->下一次Event Loop(主執行緒讀取"任務佇列")之前-->觸發process指定的回撥函式。也就是說,它指定的任務總是發生在所有非同步任務之前,當前主執行緒的末尾。(nextTick雖然也會非同步執行,但是不會給其他io事件執行的任何機會);

process.nextTick(function A() {
  console.log(1);
  process.nextTick(function B(){console.log(2);});
});
setTimeout(function C() {
  console.log(3');
}, 0);
// 1
// 2
// 3

非同步過程的構成要素

非同步函式實際上很快就呼叫完成了,但是後面還有工作執行緒執行非同步任務,通知主執行緒,主執行緒呼叫回撥函式等很多步驟。我們把整個過程叫做非同步過程,非同步函式的呼叫在整個非同步過程中只是一小部分。

一個非同步過程的整個過程:主執行緒發一起一個非同步請求,相應的工作執行緒接收請求並告知主執行緒已收到通知(非同步函式返回);主執行緒可以繼續執行後面的程式碼,同時工作執行緒執行非同步任務;工作執行緒完成工作後,通知主執行緒;主執行緒收到通知後,執行一定的動作(呼叫回撥函式)。

它可以叫做非同步過程的發起函式,或者叫做非同步任務註冊函式。args是這個函式需要的引數,callbackFn(回撥函式)也是這個函式的引數,但是它比較特殊所以單獨列出來。所以,從主執行緒的角度看,一個非同步過程包括下面兩個要素:

  1. 發起函式;
  2. 回撥函式callbackFn

它們都是主執行緒上呼叫的,其中註冊函式用來發起非同步過程,回撥函式用來處理結果。

舉個具體的栗子:

setTimeout(function,1000);

其中setTimeout就是非同步過程的發起函式,function是回撥函式。

注:前面說得形式A(args...,callbackFn)只是一種抽象的表示,並不代表回撥函式一定要作為發起函式的引數,例如:

var xhr = new XMLHttpRequest();
xhr.onreadystatechange = xxx;
xhr.open('GET', url);
xhr.send();  

總結

JavaScript的非同步程式設計模式不僅是一種趨勢,而且是一種必要,因此作為HTML5開發者是非常有必要掌握的。採用第三方的非同步程式設計庫和非同步同步化的方法,會讓程式碼結構相對簡潔,便於維護,推薦開發人員掌握一二,提高團隊開發效率。

相關文章