js異步編程原理

轉載自:http://www.cnblogs.com/hustskyking/p/javascript-asynchronous-programming.html

衆所周知,JavaScript 的執行環境是單線程的,所謂的單線程就是一次只能完成一個任務,其任務的調度方式就是排隊,這就和火車站洗手間門口的等待一樣,前面的那個人沒有搞定,你就只能站在後面排隊等着。在事件隊列中加一個延時,這樣的問題便可以得到緩解。

A: 嘿,哥們兒,快點!
B: 我要三分鐘,你先等着,完了叫你~
A: 好的,記得叫我啊~ 你(C)也等着吧,完了叫你~
C: 嗯!

告訴後面排隊的人一個準確的時間,這樣後面的人就可以利用這段時間去幹點別的事情,而不是所有的人都排在隊列後抱怨。我寫了一段程序來解決這個問題:

/**
* @author Barret Lee
* @email [email protected]
* @description 事件隊列管理,含延時
*/
var Q = {
    // 保存隊列信息
    a: [],
    // 添加到隊列 queue
    q: function(d){
        // 添加到隊列如果不是函數或者數字則不處理
        if(!/function|number/.test(typeof d)) return;

        Q.a.push(d);
        // 返回對自身的引用
        return Q;
    },
    // 執行隊列 dequeue
    d: function(){
        var s = Q.a.shift();
        // 如果已經到了隊列盡頭則返回
        if(!s) return;

        // 如果是函數,直接執行,然後繼續 dequeue
        if(typeof s === "function") {
            s(), Q.d();
            return;
        }

        // 如果是數字,該數字作爲延遲時間,延遲 dequeue
        setTimeout(function(){
            Q.d();
        }, s);
    }
};

這段程序加了很多註釋,相信有 JS 基礎的童鞋都能夠看懂,利用上面這段代碼測試下:

// 進程記錄函數
function record(s){
    var div = document.createElement("div");
    div.innerHTML = s;
    console.log(s);
    document.body.appendChild(div);
}

Q
.q(function(){
    record("0 <i style='color:blue'>3s 之後搞定,0 把 1 叫進來</i>");
})
.q(3000)  // 延時 3s
.q(function(){
    record("1 <i style='color:blue'>2s 之後搞定,1 把 2 叫進來</i>");
})
.q(2000)  // 延時 2s
.q(function(){
    record("2 <span style='color:red'>後面沒人了,OK,廁所關門~</span>");
})
.d();     // 執行隊列

可以直接運行代碼:

/**
* @author Barret Lee
* @email [email protected]
* @description 事件隊列管理,含延時
*/
var Q = {
    // 保存隊列信息
    a: [],
    // 添加到隊列 queue
    q: function(d){
        // 添加到隊列如果不是函數或者數字則不處理
        if(!/function|number/.test(typeof d)) return;

        Q.a.push(d);
        // 返回對自身的引用
        return Q;
    },
    // 執行隊列 dequeue
    d: function(){
        var s = Q.a.shift();
        // 如果已經到了隊列盡頭則返回
        if(!s) return;

        // 如果是函數,直接執行,然後繼續 dequeue
        if(typeof s === "function") {
            s(), Q.d();
            return;
        }

        // 如果是數字,該數字作爲延遲時間,延遲 dequeue
        setTimeout(function(){
             Q.d();
               }, s);
    }
};

function record(s){
    var div = document.createElement("div");
    div.innerHTML = s;
    console.log(s);
    document.body.appendChild(div);
}
Q
.q(function(){
    record("0 <i style='color:blue'>3s 之後搞定,0 把 1 叫進來</i>");
})
.q(3000)
.q(function(){
    record("1 <i style='color:blue'>2s 之後搞定,1 把 2 叫進來</i>");
})
.q(2000)
.q(function(){
    record("2 <span style='color:red'>後面沒人了,OK,廁所關門~</span>");
})
.d();

一、Javascript 異步編程原理

顯然,上面這種方式和銀行取號等待有些類似,只不過銀行取號我們並不知道上一個人需要多久纔會完成。這是一種非阻塞的方式處理問題。下面來探討下 JavaScript 中的異步編程原理。

  1. setTimeout 函數的弊端

延時處理當然少不了 setTimeout 這個神器,很多人對 setTimeout 函數的理解就是:延時爲 n 的話,函數會在 n 毫秒之後執行。事實上並非如此,這裏存在三個問題,一個是 setTimeout 函數的及時性問題,可以測試下面這串代碼:

var d = new Date, count = 0, f, timer;
timer = setInterval(f = function (){
    if(new Date - d > 1000) 
        clearInterval(timer), console.log(count);
    count++;
}, 0);

可以看出 1s 中運行的次數大概在 200次 左右,有人會說那是因爲 new Date 和 函數作用域的轉換消耗了時間,其實不然,你可以再試試這段代碼:

var d = new Date, count = 0;
while(true) {
    if(new Date - d > 1000) {
        console.log(count);
        break;
    }
    count++;
}

我這裏顯示的是 351813,也就是說 count 累加了 35W+ 次,這說明了什麼呢?setInterval 和 setTimeout 函數運轉的最短週期是 5ms 左右,這個數值在 HTML規範 中也是有提到的:

  1. Let timeout be the second method argument, or zero if the argument was omitted.
    如果 timeout 參數沒有寫,默認爲 0
  2. If nesting level is greater than 5, and timeout is less than 4, then increase timeout to 4.
    如果嵌套的層次大於 5 ,並且 timeout 設置的數值小於 4 則直接取 4.

爲了讓函數可以更快速的相應,部分瀏覽器提供了更加高級的接口(當 timeout 爲 0 的時候,可以使用下面的方式替代,速度更快):

  • requestAnimationFrame 它允許 JavaScript 以 60+幀/s 的速度處理動畫,他適合動畫,使用他可以在 tab 失去焦點或者最小化的時候減緩運動,從而節省 CPU 資源,他的運行間隔確實比 setTimeout 要長。
  • process.nextTick 這個是 NodeJS 中的一個函數,利用他可以幾乎達到上面看到的 while 循環的效率
  • ajax 或者 插入節點 的 readyState 變化
  • MutationObserver 大約 2-3ms
  • setImmediate
  • postMessage 這個相當快

這些東西下次有空再細談。之前研究司徒正美的 avalon 源碼的時候,看到了相關的內容,有興趣的可以看看:

//視瀏覽器情況採用最快的異步回調
var BrowserMutationObserver = window.MutationObserver || window.WebKitMutationObserver
if (BrowserMutationObserver) { //chrome18+, safari6+, firefox14+,ie11+,opera15
    avalon.nextTick = function(callback) { //2-3ms
        var input = DOC.createElement("input")
        var observer = new BrowserMutationObserver(function(mutations) {
            mutations.forEach(function() {
                callback()
            })
        })
        observer.observe(input, {
            attributes: true
        })
        input.setAttribute("value", Math.random())
    }
} else if (window.VBArray) { 
//IE下這個通常只要1ms,而且沒有副作用,不會發現請求,
//setImmediate如果只執行一次,與setTimeout一樣要140ms上下
    avalon.nextTick = function(callback) {
        var node = DOC.createElement("script")
        node.onreadystatechange = function() {
            callback() //在interactive階段就觸發
            node.onreadystatechange = null
            root.removeChild(node)
            node = null
        }
        root.appendChild(node)
    }
} else {
    avalon.nextTick = function(callback) {
        setTimeout(callback, 0)
    }
}

上面說了一堆,目的是想說明, setTimeout 是存在一定時間間隔的,並不是設定 n 毫秒執行,他就是 n 毫秒執行,可能會有一點時間的延遲(2ms左右)。然後說說他的第二個缺點,先看代碼:

var d = new Date;
setTimeout(function(){
    console.log("show me after 1s, but you konw:" + (new Date - d));
}, 1000);
while(1) if(new Date - d > 2000) break;

我們期望 console 在 1s 之後出結果,可事實上他卻是在 2075ms 之後運行的,這就是 JavaScript 單線程給我們帶來的煩惱,while循環阻塞了 setTimeout 函數的執行。接着是他的第三個毛病,try..catch捕捉不到他的錯誤:

try{
    setTimeout(function(){
        throw new Error("我不希望這個錯誤出現!")
    }, 1000);
} catch(e){
    console.log(e.message);
}

可以說 setTimeout 是異步編程不可缺少的角色,但是它本身就存在這麼多的問題,這就要求我們用更加恰當的方式去規避!
2. 什麼樣的函數爲異步的
異步的概念和非阻塞是是息息相關的,我們通過 ajax 請求數據的時候,一般採用的是異步的方式:

var xhr = new XMLHttpRequest();
xhr.open('GET', '/', true);
xhr.send();
xhr.onreadystatechange = function(){
    console.log(xhr.status);
}

在 xhr.open 中我們把第三個參數設置爲 true ,也就是異步加載,當 state 發生改變的時候,xhr 立即響應,觸發相關的函數。有人想過用這樣的方式來處理:

while(1) {
    if(xhr.status === "complete") {
        // dosomething();
        break;
    }
}

而事實上,這裏的判斷已經陷入了死循環,即便是 xhr 的 status 已經發生了改變,這個死循環也跳不出來,那麼這裏的異步是基於事件的。

某個函數會導致將來再運行的另一個函數,後者取自於事件隊列(若後面這個函數是作爲參數傳遞給前者的,則稱其爲回調函數,簡稱爲回調)。—— 摘自《Async Javascript》

由於 JavaScript 的單線程特點,他沒有提供一種機制以阻止函數在其異步操作結束之前返回,事實上,除非函數返回,否則不會觸發任何異步事件。
3. 常見的異步模型
1) 最常見的一種方式是,高階函數(泛函數)

step1(function(res1){
    step2(function(res2){
        step3(function(res3){
            //...
        });
    });
});

解耦程度特別低,如果送入的參數太多會顯得很亂!這是最常見的一種方式,把函數作爲參數送入,然後回調。
2) 事件監聽

E.on("evt", g);
function f(){
    setTimeout(function(){
        E.trigger("evt");
    })
}

JS 和 瀏覽器提供的原生方法基本都是基於事件觸發機制的,耦合度很低,不過事件不能得到流程控制。
3) 發佈/訂閱( Pub/Sub )

E.subscribe("evt", g);
function f(){
    setTimeout(function () {
      // f的任務代碼
      E.publish("evt");
    }, 1000);
}

把事件全部交給 E 這個控制器管理,可以完全掌握事件被訂閱的次數,以及訂閱者的信息,管理起來特別方便。
4) Promise 對象(deferred 對象)
Promise/A+ 規範是對 Promise/A 規範的補充和修改,他出現的目的是爲了統一異步編程中的接口,JS中的異步編程是十分普遍的事情,也出現了很多的異步庫,如果不統一接口,對開發者來說也是一件十分痛苦的事情。

在Promises/A規範中,每個任務都有三種狀態:默認(pending)、完成(fulfilled)、失敗(rejected)。

  • 默認狀態可以單向轉移到完成狀態,這個過程叫resolve,對應的方法是deferred.resolve(promiseOrValue);
  • 默認狀態還可以單向轉移到失敗狀態,這個過程叫reject,對應的方法是deferred.reject(reason);
  • 默認狀態時,還可以通過deferred.notify(update)來宣告任務執行信息,如執行進度;
  • 狀態的轉移是一次性的,一旦任務由初始的pending轉爲其他狀態,就會進入到下一個任務的執行過程中。

二、異步函數中的錯誤處理

前面已經提到了 setTimeout 函數的一些問題,JS 中的 try..catch 機制並不能拿到 setTimeout 函數中出現的錯誤,一個 throw error 的影響範圍有多大呢?我做了一個測試:

<script type="text/javascript">
    throw new Error("error");
    console.log("show me"); // 並沒有打印出來
</script>
<script type="text/javascript">
    console.log("show me"); // 打印出來了
</script>

從上面的測試我們可以看出,throw new Error 的作用範圍就是阻斷一個 script 標籤內的程序運行,但是不會影響下面的 script。這個測試沒什麼作用,只是想告訴大家不要擔心一個 Error 會影響全局的函數執行。所以把代碼分爲兩段,一段可能出錯的,一段確保不會出錯的,這樣不至於讓全局代碼都死掉,當然這樣的處理方式是不可取的。

慶幸的是 window 全局對象上有一個便利的函數,window.error,我們可以利用他捕捉到所有的錯誤,並作出相應的處理,比如:

window.onerror = function(msg, url, line){
    console.log(msg, url, line);
    // 必須返回 true,否則 Error 還是會觸發阻塞程序
    return true;
}

setTimeout(function(){
    throw new Error("error");
    // console:
    //Uncaught Error: error path/to/ie6bug.html 99  
}, 50);

我們可以對錯誤進行封裝處理:

window.onerror = function(msg, url, line){
    // 截斷 "Uncaught Error: error",獲取錯誤類型
    var type = msg.slice(16);
    switch(type){
        case "TooLarge": 
            console.log("The number is too large");
        case "TooSmall": 
            console.log("The number is too Small");
        case "TooUgly": 
            console.log("That's Barret Lee~");
        // 如果不是我們預定義的錯誤類型,則反饋給後臺監控
        default:
            $ && $.post && $.post({
                "msg": msg,
                "url": url,
                "line": line
            })
    }
    // 記得這裏要返回 true,否則錯誤阻斷程序。
    return true;
}

setTimeout(function(){
    if( something )  throw new Error("TooUgly");
    // console:
    //That's Barret Lee~ 
}, 50);

很顯然,報錯已經不可怕了,利用 window 提供的 onerror 函數可以很方便地處理錯誤並作出及時的反應,如果出現了不可知的錯誤,可以把信息 post 到後臺,這也算是一個十分不錯的監控方式。

不過這樣的處理存在一個問題,所有的錯誤我們都給屏蔽了,但有些錯誤本應該阻斷所有程序的運行的。比如我們通過 ajax 獲取數據中出了錯誤,程序誤以爲已經拿到了數據,本應該停下工作報出這個致命的錯誤,但是這個錯誤被 window.onerror 給截獲了,從而進行了錯誤的處理。

window.onerror 算是一種特別暴力的容錯手段,try..catch 也是如此,他們底層的實現就是利用 C/C++ 中的 goto 語句實現,一旦發現錯誤,不管目前的堆棧有多深,不管代碼運行到了何處,直接跑到 頂層 或者 try..catch 捕獲的那一層,這種一腳踢開錯誤的處理方式並不是很好,我覺得。

三、JavaScript 多線程技術介紹

開始說了異步編程和非阻塞這個概念密切相關,而 JavaScript 中的 Worker 對象可以創建一個獨立線程來處理數據,很自然的處理了阻塞問題。我們可以把繁重的計算任務交給 Worker 去倒騰,等他處理完了再把數據 Post 過來。

var worker = new Worker("./outer.js");
worker.addEventListener("message", function(e){
    console.log(e.message);
});
worker.postMessage("data one");
worker.postMessage("data two");

// outer.js
self.addEventListener("message", function(e){
    self.postMessage(e.message);
});

上面是一個簡單的例子,如果我們創建了多個 Worker,在監聽 onmessage 事件的時候還要判斷下 e.target 的值從而得知數據源,當然,我們也可以把數據源封裝在 e.message 中。

Worker 是一個有用的工具,我可以可以在 Worker 中使用 setTimeout,setInterval等函數,也可以拿到 navigator 的相關信息,最重要的是他可以創建 ajax 對象和 WebSocket 對象,也就是說他可以直接向服務器請求數據。不過他不能訪問 DOM 的信息,更不能直接處理 DOM,這個其實很好理解,主線程和 Worker 是兩個獨立的線程,如果兩者都可以修改 DOM,那豈不是得設置一個麻煩的互斥變量?!還有一個值得注意的點是,在 Worker 中我們可以使用 importScript 函數直接加載腳本,不過這個函數是同步的,也就是說他會凍結 Worker 線程,直到 Script 加載完畢。

importScript("a.js", "b.js", "c.js");

他可以添加多個參數,加載的順序就是 參數的順序。一般會使用 Worker 做哪些事情呢?

  • 數據的計算和加密 如計算斐波拉契函數的值,特別費時;再比如文件的 MD5 值比對,一個大文件的 MD5 值計算也是很費時的。
  • 音、視頻流的編解碼工作,這些工作搞微信的技術人員應該沒有少做。有興趣的童鞋可以看看這個技術分享,是杭州的 hehe123 搞的一個WebRTC 分享,內容還不錯。
  • 等等,你覺得費時間的事情都可以交給他做

然後要說的是 SharedWorker,這是 web 通信領域未來的一個趨勢,有些人覺得WebSocket 已經十分不錯了,但是一些基於 WebSocket 的架構,服務器要爲每一個頁面維護一個 WebSocket 代碼,而 SharedWorker 十分給力,他是多頁面通用的。

<input id="inp" /><input type="button" id="btn" value="發送" />
<script type="text/javascript">
    var sw = new SharedWorder("./outer.js");
    // 綁定事件
    sw.port.onmessage = function(e){
        console.log(e.data);
    };
    btn.onclick = function(){
        sw.port.postMessage(inp.value);
        inp.value = "";
    };
    // 創建連接,開始監聽
    sw.port.start();
</script>

// outer.js
var pool = [];
onconnect = function(e) {
    // 把連接的頁面放入連接池
    pool.push(e.ports[0]);
    // 收到信息立即廣播
    e.ports[0].onmessage = function(e){
        for(var i = 0;i < pool.length; i++)
            // 廣播信息
            pool[i].postMessage(e.data);
    };
};

簡單理解 SharedWorker,就是把運行的一個線程作爲 web後臺程序,完全不需要後臺腳本參與,這個對 web通訊,尤其是遊戲開發者,覺得是一個福音!

四、ECMAScript 6 中 Generator 對象搞定異步

異步兩種常見方式是 事件監聽 以及 函數回調。前者沒什麼好說的,事件機制是 JS 的核心,而函數回調這塊,過於深入的嵌套簡直就是一個地獄,可以看看下面的例子:

fs.readdir(source, function(err, files) {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function(filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function(err, values) {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function(width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)
            this.resize(width, height).write(destination + 'w' + width + '_' + filename, function(err) {
              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})

是不是有種想吐的感覺,一層一層的嵌套,雖說這種嵌套十分正常,倘若每段代碼都是這樣的呈現,相信二次開發者一定會累死!關於如何解耦我就不細說了,可以回頭看看上面那篇回調地獄的文章。

ECMAScript 6中有一個 Generator 對象,過段時間會對 ES6 中的新知識進行一一的探討,這裏不多說了,有興趣的同學可以看看 H-Jin 寫的一篇文章使用 (Generator) 生成器解決 JavaScript 回調嵌套問題,使用 yield 關鍵詞和 Generator 把嵌套給“拉直”了,這種方式就像是 chrome 的 DevTool 中使用斷點一般,用起來特別舒服。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章