單執行緒的js是如何工作的

Link-X發表於2019-02-28

JavaScript的任務佇列

js在誕生初期就確定了其單執行緒的定位,也就是說,所有任務需要排隊,前一個行行執行,前面的執行完,才會執行後面的。如果前一個任務耗時很長,後一個任務就不得不一直等著。
在討論單執行緒的js時,我們先來看看為什麼js要是單執行緒的。讓js變成多執行緒的不行麼?~~

為什麼js是單執行緒

  1. js誕生初只是為了實現一些簡單的表單驗證,也就沒必要太強大。也許一個專案也就幾十行js程式碼。那為啥現在js越來越龐大,瀏覽器廠商不給改改呢?讓我們來看第二點。
  2. 作為一個指令碼語言,JavaScript的主要用途是與使用者互動,比如操作DOM,那麼問題來了,如果有JavaScript有兩個執行緒,這裡兩個執行緒同時改變一個div的背景色,那瀏覽器聽誰的,好像兩個執行緒都沒問題...
  3. 雖然js是執行緒的,但瀏覽器是多執行緒

有個叫 Web Worker 的東西,可以讓瀏覽器開出一個Worker 執行緒,通過接受傳送訊息和主執行緒互動,並且和主執行緒不衝突的執行。web worker的定位是負責需要大量計算的程式碼執行執行緒。它限制了我們不能訪問 DOM 物件。

寫了半天怎麼能沒有程式碼呢..

    var a = 0
    while (a < 10000000) {
        a++
    }
    console.log(10000000)
    console.log(123)
    // 123
    // 321
複製程式碼

看上面這個程式碼,先執行while 迴圈,在列印 a,最後123,這很好理解,js就是單執行緒,一行一行解釋執行。就算while 要加一個億,後面的程式碼也得等著

事件監聽

那我們的程式碼是不是就沒辦法非同步執行了呢?當然不是。我們的程式碼需要承擔一個非常重要的任務就是,完成和使用者的互動。比如你點選一個按鈕希望它能給你彈出一個彈窗。

    // 來看個很簡單的例子
    const body = document.querySelector('body')
    console.log(1)
    body.addEventListener('click',function(){
        console.log(body) 
    })
    body.addEventListener('click',function(){
        console.log(body)   
    })
    console.log(2)
    console.log(2)
    console.log(2)
    // 下面可以有無限程式碼
    ...
複製程式碼

這段程式碼。在我們不點選頁面的時候,是不會列印body的。這就是事件監聽通過事件監聽我們可以實現,一個非同步任務。

寫著寫著感覺還是需要簡單的說一個ur從輸入框到載入完成的主要流程。

  1. 使用者abcd輸入一個url,然後瀏覽器發起DNS查詢請求(dns就是把abcd和真實的ip地址1234 對應起來的伺服器)
  2. 找到了1234,瀏覽器就向1234發起建立TCP連線(tcp是一些列協議,其中http是它應用層上的一個,感興趣的同學可以搜尋一下TCP七層模型,也有叫五層的)
  3. 傳送HTTP 請求(http攜帶報文給伺服器,請求行、請求頭、請求體)
  4. 瀏覽器解析http返回的東西(我們就說一個html吧)
  5. 瀏覽器解析html從上到下

這裡每個展開都可以寫N+1盤文章。我們重點了解一下最後一個,瀏覽器解析html的時候會從上到下。在沒有碰到js程式碼之前,瀏覽器會一邊解析css一邊解析dom,最後把他們合併渲染。如果中途遇到了js,瀏覽器會中斷解析dom,去解析js,然後重新解析dom,渲染。

我們再重點瞭解一下瀏覽器對js的解析

遇到script,開啟一個巨集任務吧。解析裡面的js,預解析var和function宣告的東西。值得注意的是JavaScript預解析不只是在一開始。每個執行上下文都會進行一次預解析。

    function fun () {
        console.log(a)
        var a = 1
    }
    fun() // undefined
複製程式碼

為啥會是undefined,而不是因為找不到a報錯呢?原因就是js預解析了一次,實際執行的程式碼我們可以理解為是這樣的

    function fun () {
            var a = undefined
            console.log(a)
            a = 1
    }
    fun() // undefined
複製程式碼

這個預解析也是我們可以在函式定義前執行function的原因。

    fun() // 1
    function fun () {
        var a = 1
        console.log(a) // 1
    }
複製程式碼

思考一下一個需求。讓一些前面的程式碼在最後執行。這時小明說,很簡單。把它放在最後不就行了嗎?。小明你出去?...


console.log('翻天')

var a = 100000
for(let i in [123, 123, 123, 123]) {
}
while (a > 0) {
    a--
}
alert(a)
alert(a)

var ajax=new XMLHttpRequest();
ajax.onreadystatechange=function(){
	console.log('ajax', ajax.responseText);
}
ajax.open("GET","https://search-merger-ms.juejin.im/v1/search?query=ajax&page=0&raw_result=false&src=web",true);
ajax.send();

// 翻天
// alert(0)
// alert(0)
// ajax, '******'
...
複製程式碼

可以看到最先列印翻天,然後在一行一行執行下去了,最後列印 這個非同步ajax返回的結果。

那怎麼辦呢。我們找到了小明,小明把cosnole.log('翻天')放到了程式碼的最後面。執行一下。發現居然還是比ajax先執行...

    // 這樣就行啦
    setTimeout(() => {
        console.log('翻天')
    }, 0)
    ...
    ...
    ...
    // alert(0)
    // alert(0)
    // ajax, '****'
    // 翻天
    
複製程式碼

大家可以在控制檯執行一下。

單執行緒的js是如何工作的
那麼為什麼設定一個setTimeou 0 就可以讓程式碼最後執行呢?這是因為在執行中當js碰到了setTimeout、setInterval、Promise這些東西的時候,瀏覽器為我們開啟了一個非同步的佇列。

瀏覽器的執行緒

  1. js引擎執行緒 (解釋執行js程式碼、使用者輸入、網路請求)

  2. GUI執行緒 (繪製使用者介面,就是我們剛剛說的解析css和dom的,它和js主執行緒是互斥的)

  3. http網路請求執行緒 (處理ajax的)

  4. 定時觸發器執行緒 (setTimeout、setInterval)

  5. 瀏覽器事件處理執行緒 (將click、touch放入事件佇列中)

那為什麼setTimeout 比 ajax還後執行呢?

因為瀏覽器從開始執行遇到script會開啟一個巨集任務。遇到setTimeout也會開啟一個巨集任務。遇到ajax請求開啟的是一個微任務。只有當一個巨集任務所有的同步程式碼和所以微任務全部執行完畢後,瀏覽器才會開始下一個巨集任務。這裡的setTimeout 屬於下一個巨集任務。

難度升級,我們再思考下面這些程式碼

<script>
        console.log(1)
        setTimeout(() => {
            console.log(2)
        }, 0)
        const prom = new Promise(function (ret, rej) {
            console.log(3)
            const ajax = new XMLHttpRequest();
            ajax.onreadystatechange=function(){
            	ret(4)
            }
            ajax.open("GET","https://search-merger-ms.juejin.im/v1/search?query=ajax&page=0&raw_result=false&src=web",true);
            ajax.send();
            console.log(5)
            setTimeout(() => {
                console.log(6)
            }, 0)
        })
        prom.then(res => {
            console.log(res)
            setTimeout(() => {
                console.log(7)
            })
        })
        setTimeout(() => {
            console.log(8)
        })
        console.log(9)
<script/>
複製程式碼

看到這段程式碼有沒有感覺日了狗?。思考一下,會列印啥。

// 1 3 5 9 4 2 6 8 7
複製程式碼

那麼你的答案是對的嗎?如果是,恭喜你很牛逼?...

我們從1到8開始講講為什麼會是這個順序,而不是123456789.依次列印呢?
因為js引擎遇到 setTimeout 會開啟一個巨集任務,new Promise().then() 是一個微任務,這裡的Promise是屬於 script 這個巨集任務的。 執行順序是先進先出。

一個小需求

通過js事件佇列實現這樣一個需求

obj.eat('a') // 馬上列印'a'
obj.stop(3000).eat('a') // 間隔3秒後列印 'a'
複製程式碼

思考一下。


讓我們來看看一個簡單的實現程式碼吧


    class laz {
        constructor(name) {
            this.tasks = []
            setTimeout(() => {
                this.next()
            }, 0)
        }
        next () {
            let task = this.tasks.shift()
            task && task()
        }
        eat (val) {
            const task = (val => () => {
                console.log(val)
                this.next()
            })(val)
            this.tasks.push(task)
            return this
        }
        stop (time) {
            const task = (time => () => {
                setTimeout(() => {
                    console.log(time)
                    this.next()
                }, time)
            })(time)
            this.tasks.push(task)
            return this
        }
    }    
    const obj = new laz()
    obj.eat('a') // a
    obj.stop(3000).eat('a') //  三秒後  3000  a
複製程式碼

這個功能就是利用了js佇列實現了一個簡單的延遲執行.

其實這樣的例子還有很多.

總結:其實一開始只是想寫幾百個字結束戰鬥...結果一寫發現,就現在還是沒寫全。每個知識點都牽扯到一大堆相關聯的知識點。每個展開說都可以說半天!!
我想說的是什麼呢,就是平時我們在學習中,不要死記硬背,東西是背不完的,我們的時間和大腦都是有限的,記住索引即可。

說實話這麼一大片長文字,我自己都不想看
最後送上一句話

吾生也有涯,而知也無涯


相關文章