JavaScript是前端開發中非常重要的一門語言,瀏覽器是他主要執行的地方。JavaScript是一個非常有意思的語言,但是他有很多一些概念,大家經常都會忽略。比如說,原型,閉包,原型鏈,事件迴圈等等這些概念,很多JS開發人員都研究不多。
所以今天,就來和大家看看下面幾個問題,大家可以先思考一下,嘗試作答。
八道面試題
問題1:下面這段程式碼,瀏覽器控制檯上會列印什麼?
問題2:如果我們使用 let 或 const 代替 var,輸出是否相同
問題3:“newArray”中有哪些元素?
問題4:如果我們在瀏覽器控制檯中執行'foo'函式,是否會導致堆疊溢位錯誤?
問題5: 如果在控制檯中執行以下函式,頁面(選項卡) 是否會有響應
問題6: 我們能否以某種方式為下面的語句使用展開運算而不導致型別錯誤
問題7:執行以下程式碼片段時,控制檯上會列印什麼?
問題8:xGetter() 會列印什麼值?
答案
前面的問題我們都舉例出來了,接下來我們會從頭到尾,一個個來分析我們這些問題的答案,給大家一些學習的思路
問題1:
使用var關鍵字宣告的變數在JavaScript中會被提升,並在記憶體中開闢空間,由於沒有賦值,無法定義數值型別,所以分配預設值undefined。var宣告的變數,真正的數值初始化,是發生在你確定賦值的位置。同時,我們要知道,var宣告的變數是函式作用域的,也就是我們需要區分區域性變數和全域性變數,而let和const是塊作用域的。所以我們這道題的執行過程是這樣的:
var a = 10; // 全域性作用域,全域性變數。a=10
function foo() {
// var a
//的宣告將被提升到到函式的頂部。
// 比如:var a
console.log(a); // 列印 undefined
// 實際初始化值20只發生在這裡
var a = 20; // local scope
}
圖解在下面,好理解一點
所以問題1的答案是:undefined
問題 2:
let和const宣告可以讓變數在其作用域上受限於它所在的塊、語句或表示式中。和var不同的地方在於,這兩個宣告的變數,不會被提升。並且我們會有一個稱為暫時死區(TDZ)。如果訪問TDZ中的變數的話,就會報ReferenceError,因為他們的的作用域是在他們宣告的位置的,不會有提升。所以必須在執行到宣告的位置才能訪問。
var a = 10; // 全域性使用域
function foo() { // TDZ 開始
// 建立了未初始化的'a'
console.log(a); // ReferenceError
// TDZ結束,'a'僅在此處初始化,值為20
let a = 20;
}
圖解:
問題2答案:ReferenceError: a is not defined
問題3:
這個問題,是迴圈結構會給大家帶來一種塊級作用域的誤區,在for的迴圈的頭部使用var宣告的變數,就是單個宣告的變數繫結(單個儲存空間)。在迴圈過程中,這個var宣告的i變數是會隨迴圈變化的。但是在迴圈中執行的陣列push方法,最後實際上是push了i最終迴圈結束的3這個值。所以最後push進去的全都是3。
// 誤解作用域:認為存在塊級作用域
var array = [];
for (var i = 0; i < 3; i++) {
// 三個箭頭函式體中的每個'i'都指向相同的繫結,
// 這就是為什麼它們在迴圈結束時返回相同的值'3'。
array.push(() => i);
}
var newArray = array.map(el => el());
console.log(newArray); // [3, 3, 3]
圖解:
如果想記錄每一次迴圈的值下來,可以使用let宣告一個具有塊級作用域的變數,這樣為每個迴圈迭代建立一個新的繫結。
// 使用ES6塊級作用域
var array = [];
for (let i = 0; i < 3; i++) {
// 這一次,每個'i'指的是一個新的的繫結,並保留當前的值。
// 因此,每個箭頭函式返回一個不同的值。
array.push(() => i);
}
var newArray = array.map(el => el());
console.log(newArray); // [0, 1, 2]
還有解決這個問題的另外一種解決方案就是使用閉包就好了。
let array = [];
for (var i = 0; i < 3; i++) {
array[i] = (function(x) {
return function() {
return x;
};
})(i);
}
const newArray = array.map(el => el());
console.log(newArray); // [0, 1, 2]
問題3答案:3,3,3
問題4
JavaScript的併發模式基於我們常說的”事件迴圈“。
瀏覽器是提供執行時環境來給我們執行JS程式碼的。瀏覽器的主要組成包括有呼叫堆疊,事件迴圈,任務佇列和WEB API。像什麼常用的定時器setTimeout,setInterval這些全域性函式就不是JavaScript的一部分,而是WEB API給我們提供的。
JS呼叫棧是後進先出(LIFO)的。引擎每次從堆疊中取出一個函式,然後從上到下依次執行程式碼。每當它遇到一些非同步程式碼,如setTimeout,它就把它交給Web API(箭頭1)。因此,每當事件被觸發時,callback 都會被髮送到任務佇列(箭頭2)。
事件迴圈(Event loop)不斷地監視任務佇列(Task Queue),並按它們排隊的順序一次處理一個回撥。每當呼叫堆疊(call stack)為空時,Event loop獲取回撥並將其放入堆疊(stack )(箭頭3)中進行處理。請記住,如果呼叫堆疊不是空的,則事件迴圈不會將任何回撥推入堆疊。
好了,現在有了前面這些知識,我們可以看一下這道題的講解過程:
實現步驟:
- 呼叫 foo()會將foo函式放入呼叫堆疊(call stack)。
- 在處理內部程式碼時,JS引擎遇到setTimeout。
- 然後將foo回撥函式傳遞給WebAPIs(箭頭1)並從函式返回,呼叫堆疊再次為空
- 計時器被設定為0,因此foo將被髮送到任務佇列(箭頭2)。
- 由於呼叫堆疊是空的,事件迴圈將選擇foo回撥並將其推入呼叫堆疊進行處理。
- 程式再次重複,堆疊不會溢位。
問題4答案:堆疊不會溢位。
問題5:
在很多時候,很多做前端開發的同學都是認為迴圈事件圖中就只會有一個任務列表。但事實上不是這樣的,我們是可以有多個任務列表的。由瀏覽器選擇其中一個佇列並在該佇列進行處理回撥。
從底層來看,JavaScript中是可以有巨集認為和微任務的,比如說setTimeout回撥是巨集任務,而Promise回撥是微任務。
他們有什麼區別呢?
主要的區別在於他們的執行方式。巨集任務在單個迴圈週期中一次一個低堆入堆疊,但是微任務佇列總是在執行後返回到事件之前清空。所以,如果你以處理條目的速度向這個佇列新增條目,那麼你就永遠在處理微任務。只有當微任務佇列為空時,事件迴圈才會重新渲染頁面。
然後我們再回到我們前面講的問題5中:
function foo() {
return Promise.resolve().then(foo);
};
我們這段程式碼,每次我們去呼叫【foo】的時候,都會在微任務佇列上加另一個【foo】的回撥,因此事件迴圈沒辦法繼續去處理其他的事件了(比如說滾動,點選事件等等),直到該佇列完全清空位置。因此,不會執行渲染,會被阻止。
問題5答案:不會響應。
問題6:
在我們做面試題的時候,展開語法和for-of語句去遍歷iterable物件定義要遍歷的資料。其中我們要使用迭代器的時候,Array和Map都是有預設迭代操作的內建迭代器的。
但是,物件是不可迭代的,也就是我們這道題裡的,這是一個物件的集合。但是我們可以使用iterable和iterator協議來把它變成可以迭代的。
在我們研究物件的時候,如果一個物件他實現了@@iterator方法,那麼它就是可以迭代的。這意味著這個物件(在他的原型鏈上的一個物件)必須是又@@iterator鍵的屬性的,然後我們就可以利用這個鍵,通過常量Symbol.iterator獲得。
下面是這道題的舉例寫法:
var obj = { x: 1, y: 2, z: 3 };
obj[Symbol.iterator] = function() {
// iterator 是一個具有 next 方法的物件,
// 它的返回至少有一個物件
// 兩個屬性:value&done。
// 返回一個 iterator 物件
return {
next: function() {
if (this._countDown === 3) {
const lastValue = this._countDown;
return { value: this._countDown, done: true };
}
this._countDown = this._countDown + 1;
return { value: this._countDown, done: false };
},
_countDown: 0
};
};
[...obj]; // 列印 [1, 2, 3]
問題6答案:如上是一種方案,可以避免TypeError異常。
問題7:
在看這個問題的時候,我們要先理解for-in迴圈遍歷本身的可列舉屬性和物件從原來的原型繼承來的屬性。可列舉屬性是可以在for-in迴圈期間可以訪問的屬性。
當我們知道這個知識點前提了之後,我們在看這道題,你就知道這道題列印的其實就是隻能列印這些特定的屬性。
var obj = { a: 1, b: 2 }; //a,b 都是可列舉屬性
// 將{c:3}設定為'obj'的原型,
// 並且我們知道for-in 迴圈也迭代 obj 繼承的屬性
// 從它的原型,'c'也可以被訪問。
Object.setPrototypeOf(obj, { c: 3 });
// 我們在'obj'中定義了另外一個屬性'd',
// 但是將'enumerable'可列舉設定為false。 這意味著'd'將被忽略。
Object.defineProperty(obj, "d", { value: 4, enumerable: false });
//所以最後使用for-in遍歷這個物件集合,那就是隻能遍歷出可列舉屬性
for (let prop in obj) {
console.log(prop);
}
// 也就是隻能列印
// a
// b
// c
圖解
問題7答案:a、b、c
問題8:
首先我們可以看到var x是一個全域性遍歷,在不是嚴格模式下,這個X就直接是window物件的屬性了。在這段程式碼裡,我們最重要是要理解this的物件指向問題,this始終是指向呼叫方法的物件的。所以,在foo,xGetter()的情況下,this指向的是foo物件,返回的就是在foo中的屬性x,值就是90。但是在xGetter()的情況下,他是直接呼叫的foo的getx()方法,但是其中this的指向是在xGetter的作用域,就是指向的window物件中,這時指向的就是全域性變數x了,值也就是10。
var x = 10; // 全域性變數
var foo = {
x: 90,//foo物件的內部屬性
getX: function() {
return this.x;
}
};
foo.getX(); // 此時是指向的foo物件,
//所以列印的是X屬性 值就是90
let xGetter = foo.getX;//xGetter是在全域性作用域,
//這裡的this就是指向window物件
xGetter(); // 列印 10
問題8答案:10
最後
ok,我們的8道問題都解決了,如果你前面寫的答案全部都正確,那麼你非常棒!去面試前端工作起碼12k起步了。就算做不出來或者做錯了也沒有關係,我們都是不斷通過犯錯來學習的,一步步的理解錯誤,理解背後的原因,才能進步。
更多技術好文,前端開發學習教程,歡迎關注公眾號【前端研究所】看更多前端技術文章!