從一道看似簡單的面試題重新理解JS執行機制與定時器


 壹 ❀ 引

最近在看前端進階的系列專欄,碰巧看到了幾篇關於JS事件執行機制的面試文章,因為我在之前一篇 JS執行機制詳解,定時器時間間隔的真正含義 博文中也有記錄JS執行機制,所以正好用於作為測試自己的理解情況,那么本文順着題目來重新理一理思路,說說我對於題目的理解,擴充知識點。

本文站在你對於JS執行機制與定時器已經有所了解的前提下展開,若非如此,建議先了解相關概念會更好,那么本文開始。

 貳 ❀ 一道變化的面試題

 題目一:

說說以上代碼輸出什么?

沒錯,這只是一個非常簡單的for循環,依次輸出0 - 4;我想大家對於for循環一定都非常熟悉,這里通過步驟拆解簡單展示下for循環的執行步驟:

變量 i 從頭到尾就只聲明了一次,然后開始第一次條件判斷,滿足條件執行代碼體,之后 i 自增,繼續條件判斷,如果條件不滿足則跳出循環。

好了,題目升級,我們將for循環內部改成一個定時器,現在會輸出什么呢?

 題目二:

我想稍微有看過類似筆試題的同學,應該都知道,大約在等待一秒鍾后,同時輸出五個5。原因是定時器是異步任務,for循環的每次循環雖然都會創建一個定時器,但並沒有同步執行,而是等到for循環執行完畢后,統一執行了五個定時器,而此時變量 i 早已自增為5。

我們用步驟拆分模擬執行,如下:

由於定時器是異步任務,我們可以理解為最后執行,所以真正的樣子應該是這樣:

此時 i 已經自增為5。那么有同學又要問了,為什么是等待一秒后同時輸出五個5,而不是每隔一秒輸出一個5呢?這就得明白定時器時間真正表示的含義,我們看下面這個例子:

請問上述代碼是每隔三秒輸出一個1呢,還是等待三秒后同時輸出兩個1呢?

答案是后者,如果你覺得答案是前者,那是因為你誤會了定時器的時間含義,3000ms並不是定時器執行前的等待時間,而是將定時器中的回調函數加入任務隊列前的等待時間。

我們這個世界只有一條時間線,也沒發現平時世界,程序也是如此,3秒倒計時后,兩個定時器的回調函數接近同時被加入到了任務隊列,因為回調執行耗時可以忽略不計,所以就像同時輸出了。

我們通過一個例子來驗證這一點,如下:

請問誰先執行?時間間隔又是怎么樣?

答案是大約等待一秒后先輸出2,再等待大約2秒輸出1;原因是1秒過后,第二個定時器回調先加入任務隊列,再過2秒將第一個定時器回調加入任務隊列,然后開始執行。而任務隊列具有FIFO(先進先出)的特性,我們忽略回調執行耗時,也就是1S=>1=>2S=>2這個結果了。

若你對於JS執行機制或者定時器執行這塊有疑慮,可以閱讀博主關於JS執行機制的博客,一定會對你有所幫助:JS執行機制詳解,定時器時間間隔的真正含義

好了,第二題拓展說了稍微有點多,我們將題目二再變形,如下,請說說題目三輸出結果是什么,時間間隔是多少?

 題目三:

結合前面對於for循環的拆分,以及對定時器時間含義重新了解,答案是幾乎無等待的先輸出一個5,之后每隔一秒再輸出四個5。

有同學肯定又要問了,前面你不是說定時器運行時變量 i 已經是5了嗎,照理說不是應該等待五秒之后同時輸出五個5嗎?如果你是這么認為的,那是因為你沒理解定時器的執行規則。

准確來說,定時器異步執行的只是定時器中的回調函數,定時器的時間可不會異步,這里只是將固定的時間換成了一個簡單乘法計算而已,所以時間計算在運行到定時器時就已經同步計算完畢了,我們改寫代碼應該是這樣:

OK,我們對於定時器的理解又更進了一步,那么問題來了,請以題目三為原型做出修改,讓for循環先輸出0之后每隔一秒依次輸出1,2,3,4。做法其實有多種,先自己想想再看答案:

 1.我們可以利用閉包:

上述代碼中我們利用一個自執行函數包裹了定時器,而定時器中的回調函數引用了外層自調函數的形參 i ,所以此時定時器的回調函數是一個閉包。

有趣的事情來了,當創建每個自執行函數時變量 i 都會作為參數立刻傳入到自執行函數體內,此時 i 已經和函數作用域綁定到了一起,而定時器回調函數引用了外層函數的 i ,這樣就達到我們想要的目的了,我們改寫代碼:

沒錯,定時器是異步,它應該最后執行,但JS采用的是靜態作用域,函數在定義時,它的作用域就已經被確定了,不管它在何處被調用,它能訪問的外層作用域就是被創建時所在的作用域,我們再次改寫:

雖然定時器的回調看着是最后執行,但它在執行時由於自己沒有 i ,所以只能從父級作用域找,而它的父級就是創建它的自執行函數。

 2.我們可以利用按值傳遞特性

我們知道JS中基本類型的數值作為函數參數時都是按值傳遞的,什么意思呢,通過一個例子來解釋:

上述代碼中,我聲明了一個基本類型的變量 x 與一個引用類型的數組 y,作為參數傳入到了函數中,分別對x y進行了修改,函數執行完畢之后,x y會發生變化嗎?

直覺告訴我們,x不會變化,而 y 被修改了,這有點類似於深淺拷貝,當基本類型的數據作為函數參數時總是按值傳遞,就像額外拷貝了一份進去,而引用類型的數據傳遞的其實是一個引用地址,任何操作都會修改原有的數據。

懂了這個就好辦了,我們直接聲明一個函數,在for循環中將變量 i 作為調用函數的參數就好了,像這樣:

 

是不是有點把閉包自執行函數移到外面的感覺,原理類似,按值傳遞居然這么好用。

 3.我們可以利用ES6的let

當for循環中使用let去聲明變量 i 時,利用塊級作用域的特性,讓每次循環的 i 成為獨立的一份,直接上代碼:

這里我不太好詳細解釋為何let可以到達目的,如果你對for循環中使用var 和 let聲明變量 i 究竟有何不同,以及為何每次循環 i 都是獨立的一份有興趣,歡迎閱讀博主 for循環中let與var的區別,塊級作用域如何產生與迭代中變量i如何記憶上一步的猜想 這篇文章,順着我的思路,一定給你整的明明白白。

其實當我們使用let 聲明變量 i 時,此時for循環用遞歸來模擬應該是這樣,如果你看不懂以下改寫,還是建議閱讀我上面推薦的文章。

 4.我們可以使用定時器第三參數

不知道有多少人知道定時器其實還有第三參數,如果我們想給定時器回調函數傳遞參數,就可以借助第三參數,直接上代碼:

在創建定時器時,i 同時還作為回調函數的形參傳入了回調,根據按值傳遞的特性,不管定時器何時執行,i 早與創建時的 i 已經綁定在了一起。是不是很棒,原來定時器第三參數還可以這么使用,又學到了一點。

那么到這里,我們居然掌握了四種做法,利用自執行函數創建閉包,利用按值傳值的特性,利用ES6的let,以及利用定時器的第三參數。

好了,既然說到了代碼改寫,那么問題再次升級:

 題目四

定時器的第一參數由一個普通的回調函數變成了一個自執行函數,其它沒什么改變,說說會怎么執行?

答案是無等待的同時輸出0,1,2,3,4。說到這可能有同學就有疑問了,輸出0-4也就算了,為何輸出之間還沒間隔了。難道不是把五個自執行函數壓入任務隊列,然后先輸出0后每隔一秒一次輸出嗎。

我們都知道定時器有兩種寫法,以setTimeout為例:

我們常用的是寫法一,寫法二之所以能正常運行,其實是類似於eval讓字符串運行了,所以並不推薦第二種寫法。而題目四的代碼類似於這樣:

這段代碼的意思是,運行到定時器時,直接將第一參數的函數給執行了,定時器的時間直接不會起作用了。我們可以通過下面的例子來證明這一點:

上述代碼幾乎無等待的輸出1,盡管這是一個周期性定時器,但之后都不會再執行了,因為第一參數不是一個合格的回調函數。

怎么樣,對定時器是不是又加深了一點印象,好了,面試題就到此為止了,說了很多,拓展了很多,我們來做個總結。

 叄 ❀ 總結

通過本文的閱讀,我們知道了以下知識點

1.定時器是一個異步任務,准確來說,最終異步執行的是定時器的回調函數,倒計時以及定時器本身並非異步。

2.我們理解了定時器時間的真正含義,它並非表示過多久之后執行,而是過多久之后將回調函數加入到任務隊列

3.我們知道了任務隊列具有先進先出(FIFO)的特性,不管兩個定時器定義先后如何,先加入隊列的始終先執行(哪個時間設置的小先執行)。

4.我們了解了定時器其實還有第三參數,它可以為回調函數傳遞參數。

5.我們知道了定時器回調函數常用的兩種寫法,以及當回調函數帶括號時會造成什么問題。

6.我們知道了函數參數如果是簡單類型數據時具有按值傳遞的特性,以及JS具備靜態作用域的概念。

7.我們知道了四種讓上方面試題依次輸出0-4的改寫方法。

最后我還知道,如果你在以后的面試中偶遇了類似的題目,你大概能秀的面試官頭皮發麻,那么到這里本文結束。

 肆 ❀ 參考

Excuse me?這個前端面試在搞事!

80% 應聘者都不及格的 JS 面試題


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM