經典面試題解析
- 2019 年 11 月 8 日
- 筆記
本篇博客專門用於收集各類經典面試題,並給出相關的解題思路和原理。
1.考點:塊級作用域和閉包
先看一道很經典的面試題
var a=[]; for(var i=0;i<10;i++){ a[i] = function(){ console.log(i); } } console.log(a[6]);
如果你認為輸出的是6,那麼恭喜你答錯了。正確答案是10。首先分析一下這段代碼的具體執行過程。
var a=[]; var i=0; /* 用var聲明的變量要麼在函數作用域中,要麼在全局作用域中,很明顯這裡是在全局作用域中, 因此認為i是全局變量,直接放在全局變量中。*/ a[0]=function(){ console.log(i); /* 關鍵!!這裡之所以i為i而不是0;是因為我們只是定義了該函數,並未調用它,所以沒有進入 該函數執行環境,i當然不會沿着作用域鏈向上搜索找到自由變量i的值。*/ } // 由於不具備塊級作用域,所以該函數暴露在全局作用域中。 var i=1; //第二次循環,這時var i=1;覆蓋了前面的var i=0;即現在全局變量i為1; a[1]=function(){ console.log(i); //解釋同a[0]函數。 } var i=2; // 第三次循環,這時var i=2;覆蓋了前面的var i=1;即現在全局變量i為2; a[2]=function(){ console.log(i); } ......第四次循環 此時i=3 這個以及下面的i不斷的覆蓋前面的i,因為都在全局作用域中 ......第五次循環 此時i=4 ......第六次循環 此時i=5 ......第七次循環 此時i=6 ......第八次循環 此時i=7 ......第九次循環 此時i=8 var i=9; a[9]=function(){ console.log(i); } var i=10;// 這時i為10,因為不滿足循環條件,所以停止循環。 緊接着在全局環境中繼續向下執行。 a[6](); /* 這時調用a[6]函數,所以隨即進入a[6]函數的執行上下文環境中,即 function(){console.log(i)}中,此時執行函數中的代碼console.log(i), 因為在當前的函數執行上下文中不存在變量i,所以i為自由變量,此時會 沿着作用域鏈向上尋找,進而進入了全局作用域中尋找變量i,而全局作用域 中的i在循環跑完後已經變成了10,所以a[6]的值就是10了。*/
那麼,如果我們想要輸出6,應該怎麼修改代碼呢?兩種方法。 1.使用let形成塊級作用域,配合閉包使用
var a=[]; { //進入第一次循環 let i=0; /*注意:因為使用let使得for循環為塊級作用域,此次let i=0 在這個塊級作用域中,而不是在全局作用域中。*/ a[0]=function(){ console.log(i); }; /* 注意:由於是用let聲明的i,所以使整個塊成為塊級作用域,又由於a[0]這個函數 引用到了上一級作用域中的自由變量,所以a[0]就成了一個閉包。*/ } /*聲明:這裡用{}表達並不符合語法,只是希望通過它來說明let存在時,這個for循環塊 是塊級作用域,而不是全局作用域。*/ 講道理,上面這是一個塊級作用域,就像函數作用域一樣,執行完畢,其中的變量會被銷毀, 但是因為這個塊級作用域中存在一個閉包,且該閉包維持着對自由變量i的引用,所以在閉包 被調用之前也就是後續為了測試而console.log出a[..]之前,此次循環的自由變量i即0不會 被銷毀. { //進入第二次循環 let i=1; /*注意:進入第二次循環即進入第二個代碼塊,此時處於激活狀態的是let i=1。 它位於與let i=0不同的塊級作用域中,所以兩者不會相互影響。*/ a[1]=function(){ console.log(i); }; //同樣,這個a[i]也是一個閉包 } ......進入第三次循環,此時其中let i=2; ......進入第四次循環,此時其中let i=3; ......進入第五次循環,此時其中let i=4; ......進入第六次循環,此時其中let i=5; ......進入第七次循環,此時其中let i=6; ......進入第八次循環,此時其中let i=7; ......進入第九次循環,此時其中let i=8; {//進入第十次循環 let i=9; a[i]=function(){ console.log(i); };//同樣,這個a[i]也是一個閉包 } { let i=10; /*不符合條件,不再向下執行,導致此次的塊級作用域中不存在閉包,導致let i=10 未像前面的i一樣等待被閉包引用,故此次的i沒有必要繼續存在,隨即被銷毀。*/ } a[6](); /*調用a[6]()函數,這時執行環境隨即進入下面這個代碼塊中的執行環境: funcion(){console.log(i)};*/ 即進入: { let i=6; a[6]=function(){ console.log(i); }; //同樣,這個a[i]也是一個閉包 } a[6]函數(閉包)這個執行環境中,它會首先尋找該執行環境中是否存在 i,沒有找到, 就沿着作用域鏈繼續向上到了函數所在的塊級作用域,找到了自由變量i=6,於是輸出了6, 即a[6]()的結果為6。閉包既已被調用,所以整個代碼塊中的變量i和函數a[6]()被銷毀。
2.利用自執行函數 說來慚愧,本來如果明白這道題的原理,應該自然想到可以利用自執行函數達到相同的目的,但是最後還是在群里朋友的點撥下才明白的。 實際很簡單,前面我們說過一句很關鍵的話:
這裡之所以 i 為 i 而不是 0;是因為我們只是定義了該函數,並未調用它,所以沒有進入該函數執行環境,i 當然不會沿着作用域鏈向上搜索找到自由變量 i 的值
那麼反過來想一想,假如我們在定義了函數之後即刻對其進行了調用,是否此時將會在環境中尋找 i 的值並馬上替換掉 console.log(i) 中的 i 呢?是的。要立刻調用函數,用自執行函數就可以,代碼如下:
var a=[]; for(var i=0;i<10;i++){ a[i] = (function(){ console.log(i); })() }
需要注意的是,這裡每一次的循環實際上是對當前函數進行一次立即調用,所以在循環的同時對應的值就已經打印出來了,並且這些函數的返回值依次賦值給數組元素。在沒有顯式指定函數返回值時,默認返回 undefined,因此後續再訪問數組元素時只能得到 undefined。
2.考點:連等、解析和引用類型
這是某大廠一道知名的面試題,表面簡單但是坑很多。
var a = {n:1}; var b = a; a.x = a ={n:2}; console.log(a.x); // undefined console.log(b.x); // {n:2}
我們來分析一下這段代碼到底是怎麼執行的,就會明白為什麼結果與我們預想的完全不同,甚至可以說很怪異。
var a = {n:1}; var b = a;
首先,這兩句令a和b同時引用了{n:2}對象,接着的a.x = a = {n:2}
是關鍵。儘管賦值是從右到左的沒錯,但是.的優先級比=要高,所以這裡首先執行a.x,相當於為a(或者b)所指向的{n:1}
對象新增了一個屬性x,即此時對象將變為{n:1;x:undefined}
。之後按正常情況,從右到左進行賦值,此時執行a ={n:2}
的時候,a重定向,指向了新對象{n:2}
,而b依然指向的是舊對象,這點是不變的。接着的關鍵來了:執行a.x = {n:2}
的時候,並不會重新解析一遍a,而是沿用最初解析a.x時候的a,也即舊對象,故此時舊對象的x的值為{n:2}
,舊對象為 {n:1;x:{n:2}}
,它被b引用着。 後面輸出a.x的時候,又要解析a了,此時的a當然是重定向後的指向新對象的a,而這個新對象是沒有x屬性的,故得到undefined;而輸出b.x的時候,將輸出舊對象的x屬性的值,即{n:2}
。
3.考點:異步、作用域、閉包
如果無法深入到內部,從原理層面上理解代碼的運行機制,那麼知識只是浮在表面、淺嘗輒止。「同步優先,異步靠邊,回調墊底」的口訣可以幫助我們迅速判斷,但是我希望用自己剛學習的事件循環機制來解釋這道題。 實際上這也是比較普遍的一道面試題:
for (var i = 0; i < 3; i++) { setTimeout(function() { console.log(i); }, 0); console.log(i); } 代碼最後輸出什麼?
如果不熟悉異步,很可能直截了當地回答是:0 0 1 1 2 2
。 正確答案應該是 0 1 2 3 3 3
根據事件循環的機制,跑循環和輸出i的值都是主線程上的同步任務,既然是同步任務,當然是按照順序執行,所以0 1 2
是容易理解的。那麼setTimeout怎麼辦呢?setTimeout是異步任務,並不在主線程上,而是在宏任務隊列里,它必須等待主線程的執行棧清空,才有自己的「一席之地」,才能去執行,所以這裡我們直接忽略setTimeout,將前三次循環的setTimeout都掛在任務隊列里。之後,循環跑完了,主線程的同步任務結束。此時i變成了3。 輪到任務隊列了——> 我們回過頭調用setTimeout里的回調函數,進行i的輸出。當然,由於i只有一個,即全局變量,所以此時輸出的都是3,三次setTimeout即三次3。
如果我們要輸出 0 1 2 0 1 2
呢? 其實這裡就和第一個考點很像了。這裡有三種方法,
1.將var改為let 改為 let 後會形成多個獨立的塊級作用域,這樣,每個setTimeout里的回調函數的i都將對應每一次循環的i(因為是塊級作用域)。接着,由於輸出和循環依然是同步任務,所以輸出 0 1 2
;之後輪到任務隊列,也是輸出0 1 2
。
2.利用自執行函數 讓函數在定義之後就即刻執行,那麼函數中的 i 就會指向當前循環的 i,這個 i 的值為多少在那時就已經確定了,而不再是隨着跑循環而動態變化。這裡又有兩種自執行的方法:
for (var i = 0; i < 3; i++) { setTimeout((function(i) { return function() { console.log(i); }; })(i), 0); console.log(i); }
或者
for (var i = 0; i < 3; i++){ (function (i) { setTimeout(function () { console.log(i); }, 0) })(i); console.log(i); }
一個是將回調函數作為自執行函數,一個是將setTimeout函數作為自執行函數,效果是一樣的。
3.利用bind()
for (var i = 0; i < 3; i++) { setTimeout(function(i) { console.log(i); }.bind(null,i), 0); console.log(i); }
bind()
的第一個參數是 thisArg,用來綁定 this,這裡我們不管,直接傳參 null,重點在於第二個參數,這個參數也就是回調函數的參數。這裡要理解循環做了什麼:每一次循環,實際上執行的是 setTimeout()
方法,執行完之後把每次的回調函數掛載在隊列里,後續等主任務清空之後,再一一執行。這裡添加了 bind()
方法後,每次循環除了掛載回調函數,其實還完成了硬綁定,這時候對應的 i 值已經存在於回調函數的詞法作用域里了。所以,後面執行回調函數的時候,每個函數都能在詞法作用域中找到自己對應的 i 值。
4.考點:作用域、NFE的函數名只讀性
let b = 10; (function b(){ b=20; console.log(b); })(); console.log(b); // 代碼最後輸出什麼?
如果沒有認識到NFE函數的函數名只讀性,這道題就會做錯。正確答案應該是:
f { b=20; console.log(b); } 10
要理解這道題,先來看另一段代碼
var c=function b(){ console.log("234"); console.log(b); } console.log(b) // b is no defined
首先,這是一個具名函數表達式,即NFE。而NFE的函數名只能在函數內部訪問,所以我們將該函數的引用賦給變量c之後,就只能通過c()調用該函數,而不能通過b()調用,更不能訪問b。並且還要注意,函數名在函數內部類似於一個const常量,只能訪問而不能對它進行修改。
理解這一點之後再來看最開始的代碼,這是一段IIFE—–立即執行函數表達式(因為括號是操作符,所以認為括號里的是表達式而不是聲明),它同樣也是具名函數表達式,自然也有上面的性質。函數自調用,遇到b=20
語句時開始在函數作用域中查找b是在哪裡聲明的,結果發現就是函數b,然後試圖對函數名進行修改,因為這種修改相當於是修改一個常量,所以是無效的(非嚴格模式下靜默失敗,嚴格模式下拋出Type錯誤)。忽略了這段語句後,等於是只輸出b,也就是輸出函數本身。之後,我們在全局下輸出b,根據上面的說法,我們無法在NFE函數外部訪問NFE的函數名,所以這裡的b代表的不是函數,而是用let聲明的那個變量b。
let b = 10; (function b(){ var b=20; console.log(b); })(); // 20
當然,如果在函數內部用var或者let重新聲明一個同名變量b並賦值,則是允許的,此時的b變量與函數b沒有任何關係,僅僅是同名而已。 PS:NFE 函數名為什麼是只讀的?規範有說嗎?還真有,看下面:
The production FunctionExpression : function Identifier ( FormalParameterListopt ) { FunctionBody } is evaluated as follows: 1.Let funcEnv be the result of calling NewDeclarativeEnvironment passing the running execution context』s Lexical Environment as the argument 2.Let envRec be funcEnv』s environment record. 3.Call the CreateImmutableBinding concrete method of envRec passing the String value of Identifier as the argument. 4.Let closure be the result of creating a new Function object as specified in 13.2 with parameters specified by FormalParameterListopt and body specified by FunctionBody. Pass in funcEnv as the Scope. Pass in true as the Strict flag if the FunctionExpression is contained in strict code or if its FunctionBody is strict code. 5.Call the InitializeImmutableBinding concrete method of envRec passing the String value of Identifier and closure as the arguments. 6.Return closure.
NOTE The Identifier in a FunctionExpression can be referenced from inside the FunctionExpression』s FunctionBody to allow the function to call itself recursively. However, unlike in a FunctionDeclaration, the Identifier in a FunctionExpression cannot be referenced from and does not affect the scope enclosing the FunctionExpression.
重點就在第三和第五的 ImmutableBinding,注意這是一個不可變的綁定。 關於這道題的詳細解釋,移步: https://segmentfault.com/q/1010000002810093
5. this 綁定
某不知來源的面試題一道:
"use strict"; const a=[1,2,30]; const b=[4,5,60]; const c=[7,8,90]; a.forEach((function (){ console.log(this); }).bind(globalThis),b); // 輸出什麼?
正確答案是:
window window window
這道題的難點在於,forEach()
的 thisArg
指定了回調的 this,而回調本身也有一個 bind()
方法指定 this,那麼應該以哪個為準呢?在這篇文章中曾經討論過 this 綁定的問題,但是 forEach()
的 this 綁定好像並不符合文章裏面的情況。不妨看一下 forEach()
的 polyfill 代碼:
A polyfill is a piece of code (usually JavaScript on the Web) used to provide modern functionality on older browsers that do not natively support it.
也就是說,forEach()
綁定 this 實際上也是通過 call()
實現的。 接下來再來看一下 bind()
的 polyfill 代碼:
bind()
實際上也是通過 apply()
實現的 —— 原理就是返回一個包裝函數,這個函數在內部對初始函數完成了 this binding。之後不管怎麼調用這個包裝函數,this 都是使用 bind()
的thisArg。也就是說,即使是:
func.bind(obj1).bind(obj2);
func 中的 this 最後也是指向 obj1 而不是 obj2,原因在於 func.bind(obj1)
是一個返回的包裝函數,內部的 this 是沒有暴露出來的,看上去就像是一個沒有 this 的函數,因此後面的 bind(obj2)
對其不生效。這也是為什麼說 bind()
是 tight binding 的原因,一旦綁定就很難再改變。 理解這一點之後,再來看上面的題就簡單了。題目的代碼我們可以簡化為:
const f0 = function () { console.log(this) } const f1 = f0.bind(globalThis) a.forEach(f1, b)
f0 是初始函數,f1 是包裝函數。那麼在 forEach 進行迭代的時候,雖然指定了 this 是參數 b,但是由於此時的 f1 是一個內部完成了 this binding 的包裝函數,因此其實已經沒有 this 什麼事了,自然 forEach 的 thisArg 也不生效。既然是 bind()
生效,那麼結果自然是輸出全局對象了。 Tip: 下次思考問題的時候,polyfill 可以作為一個着手方向。