被難倒了! 針對高階前端的8個級JavaScript面試問題

發表於2023-09-27
首發於公眾號 大遷世界,歡迎關注。? 每週7篇實用的前端文章 ?️ 分享值得關注的開發工具 ?分享個人創業過程中的趣事

JavaScript 是一種功能強大的語言,也是構建現代 Web 的基礎之一。這種強大的語言也有一些自己的怪癖。例如,你知道 0 === -0 會計算為 true,或者 Number("") 會返回 0 嗎?

有時候,這些怪癖會讓你百思不得其解,甚至讓你懷疑 Brendan Eich 在發明 JavaScript 的那一天是不是狀態不佳。但這裡的重點並不是說 JavaScript 是一種糟糕的程式語言,或者如其批評者所說的那樣,是一種“邪惡”的語言。所有的程式語言都有某種程度的怪癖,JavaScript 也不例外。

在這篇部落格文章中,我們將深入解釋一些重要的 JavaScript 面試問題。我的目標是徹底解釋這些面試問題,以便我們能夠理解背後的基本概念,並希望在面試中解決其他類似的問題。

1- 仔細觀察 + 和 - 運算子

console.log(1 + '1' - 1);  

你能猜到在上面這種情況下,JavaScript 的 + 和 - 運算子會有什麼行為嗎?

當 JavaScript 遇到 1 + '1' 時,它會使用 + 運算子來處理這個表示式。+ 運算子有一個有趣的特性,那就是當其中一個運算元是字串時,它更傾向於執行字串的連線。在我們的例子中,'1' 是一個字串,因此 JavaScript 隱式地將數字 1 轉換為字串。因此,1 + '1' 變成了 '1' + '1',結果是字串 '11'。

現在,我們的等式是 '11' - 1。- 運算子的行為正好相反。它更傾向於執行數字減法,而不考慮運算元的型別。當運算元不是數字型別時,JavaScript 會執行隱式轉換,將它們轉換為數字。在這種情況下,'11' 被轉換為數字值 11,表示式簡化為 11 - 1。

綜合考慮:

'11' - 1 = 11 - 1 = 10

2- 陣列元素的複製

考慮以下的 JavaScript 程式碼,並嘗試找出其中的問題:

function duplicate(array) {
  for (var i = 0; i < array.length; i++) {
    array.push(array[i]);
  }
  return array;
}

const arr = [1, 2, 3];
const newArr = duplicate(arr);
console.log(newArr);

在這段程式碼片段中,我們需要建立一個新陣列,該陣列包含輸入陣列的重複元素。初步檢查後,程式碼似乎透過複製原始陣列 arr 中的每個元素來建立一個新陣列 newArr。然而,在 duplicate 函式內部出現了一個嚴重的問題。

duplicate 函式使用迴圈來遍歷給定陣列中的每個專案。但在迴圈內部,它使用 push() 方法在陣列末尾新增新元素。這導致陣列每次都會變長,從而產生一個問題:迴圈永遠不會停止。因為陣列長度不斷增加,迴圈條件(i < array.length)始終為真。這使得迴圈無限進行下去,導致程式陷入僵局。

為了解決由於陣列長度增長而導致的無限迴圈問題,可以在進入迴圈之前將陣列的初始長度儲存在一個變數中。然後,可以使用這個初始長度作為迴圈迭代的限制。這樣,迴圈只會針對陣列中的原始元素進行,並不會受到由於新增重複項而導致陣列增長的影響。以下是修改後的程式碼:

function duplicate(array) {
  var initialLength = array.length; // 儲存初始長度
  for (var i = 0; i < initialLength; i++) {
    array.push(array[i]); // 推入每個元素的副本
  }
  return array;
}

const arr = [1, 2, 3];
const newArr = duplicate(arr);
console.log(newArr);

輸出將顯示陣列末尾的重複元素,並且迴圈不會導致無限迴圈:

[1, 2, 3, 1, 2, 3]

3- prototype 和 proto 的區別

prototype 屬性是與 JavaScript 中的建構函式相關聯的屬性。建構函式用於在 JavaScript 中建立物件。當您定義一個建構函式時,還可以將屬性和方法附加到其 prototype 屬性上。這些屬性和方法然後變得可以被該建構函式建立的所有物件例項訪問。因此,prototype 屬性充當共享方法和屬性的通用儲存庫。

考慮以下程式碼片段:

// 建構函式
function Person(name) {
  this.name = name;
}

// 新增一個方法到 prototype
Person.prototype.sayHello = function() {
  console.log(`Hello, my name is ${this.name}.`);
};

// 建立例項
const person1 = new Person("Haider Wain");
const person2 = new Person("Omer Asif");

// 呼叫共享的方法
person1.sayHello();  // 輸出:Hello, my name is Haider Wain.
person2.sayHello();  // 輸出:Hello, my name is Omer Asif.

另一方面,__proto__ 屬性,通常讀作 "dunder proto",存在於每一個 JavaScript 物件中。在 JavaScript 中,除了原始型別外,一切都可以被視為物件。每個這樣的物件都有一個原型,該原型作為對另一個物件的引用。__proto__ 屬性簡單地是對這個原型物件的引用。

當你試圖訪問物件上的一個屬性或方法時,JavaScript 會進行查詢過程來找到它。這個過程主要涉及兩個步驟:

物件的自有屬性:JavaScript 首先檢查物件自身是否直接擁有所需的屬性或方法。如果在物件內找到了該屬性,則直接訪問和使用。
原型鏈查詢:如果在物件自身沒有找到該屬性,JavaScript 將檢視物件的原型(由 __proto__ 屬性引用)並在那裡搜尋該屬性。這個過程會遞迴地沿著原型鏈進行,直到找到該屬性或直到查詢達到 Object.prototype
如果在 Object.prototype 中甚至沒有找到該屬性,JavaScript 將返回 undefined,表示該屬性不存在。

4-作用域

當編寫 JavaScript 程式碼時,理解作用域的概念非常重要。作用域指的是變數在程式碼的不同部分的可訪問性或可見性。下面我們透過一個程式碼片段來更仔細地瞭解這個概念:

function foo() {
  console.log(a);
}

function bar() {
  var a = 3;
  foo();
}

var a = 5;
bar();

程式碼定義了兩個函式 foo()bar(),以及一個值為5的變數 a。所有這些宣告都發生在全域性作用域中。在bar()函式內部,宣告瞭一個變數a並賦值為 3。那麼當bar()函式被呼叫時,你認為會輸出哪個值的a

當JavaScript引擎執行這段程式碼時,全域性變數a被宣告並賦值為5。然後呼叫了bar()函式。在bar()函式內部,宣告瞭一個區域性變數a並賦值為3。這個區域性變數a與全域性變數a是不同的。之後,從bar()函式內部呼叫了foo()函式。

foo()函式內部,console.log(a)語句試圖輸出變數a的值。由於在foo()函式的作用域內沒有定義區域性變數a,JavaScript會查詢作用域鏈以找到最近的名為a的變數。

現在,我們來解答JavaScript將在哪裡搜尋變數a的問題。它會查詢bar函式的作用域嗎,還是會探索全域性作用域?事實證明,JavaScript會在全域性作用域中搜尋,這種行為是由一個叫做詞法作用域的概念驅動的。

詞法作用域是指函式或變數在程式碼中被編寫時的作用域。當我們定義了foo函式,它被賦予了訪問自己的區域性作用域和全域性作用域的許可權。這一特性在我們無論在哪裡呼叫foo函式時都是一致的,無論是在bar函式內部還是在其他模組中執行。詞法作用域並不是由我們在哪裡呼叫函式來決定的。

最終結果是,輸出始終是全域性作用域中找到的a的值,在這個例子中是5

然而,如果我們在bar函式內部定義了foo函式,情況就會有所不同:

function bar() {
  var a = 3;

  function foo() {
    console.log(a);
  }

  foo();
}

var a = 5;
bar();

在這種情況下,foo 的詞法作用域將包括三個不同的作用域:它自己的區域性作用域,bar 函式的作用域,以及全域性作用域。詞法作用域是由你在原始碼中放置程式碼的位置在編譯時決定的。

當這段程式碼執行時,foo 位於 bar 函式內部。這種安排改變了作用域的動態。現在,當foo試圖訪問變數a時,它首先會在自己的區域性作用域內進行搜尋。由於沒有找到a,它會擴大搜尋範圍到bar函式的作用域。果然,那裡存在一個值為3a。因此,控制檯語句將輸出3

5-物件強制型別轉換

const obj = {
  valueOf: () => 42,
  toString: () => 27
};
console.log(obj + '');

一個引人入勝的方面是探究JavaScript如何處理物件轉換為基本值,例如字串、數字或布林值。這是一個有趣的問題,測試你是否瞭解物件的強制型別轉換。

在像字串連線或算術運算這樣的場景中與物件一起工作時,這種轉換至關重要。為了實現這一點,JavaScript 依賴兩個特殊的方法:valueOftoString

valueOf 方法是JavaScript物件轉換機制的一個基礎部分。當一個物件在需要基本值的上下文中被使用時,JavaScript 首先會在物件內部查詢valueOf方法。在valueOf方法不存在或不返回適當的基本值的情況下,JavaScript會退回到toString方法。這個方法負責提供物件的字串表示形式。

回到我們最初的程式碼片段:

const obj = {
  valueOf: () => 42,
  toString: () => 27
};

console.log(obj + '');

當我們執行這段程式碼時,物件obj被轉換為一個基本值。在這種情況下,valueOf 方法返回42,然後由於與空字串的連線,它被隱式地轉換為字串。因此,程式碼的輸出將是 42

然而,在valueOf方法不存在或不返回適當的基本值的情況下,JavaScript會退回到toString方法。讓我們修改之前的示例:

const obj = {
  toString: () => 27
};

console.log(obj + '');

在這裡,我們已經移除了 valueOf 方法,只留下了返回數字27toString方法。在這種情況下,JavaScript 將依賴 toString 方法進行物件轉換。

6-理解物件鍵(Object Keys)

當在JavaScript中使用物件時,理解鍵是如何在其他物件的上下文中被處理和分配的非常重要。考慮以下程式碼片段,並花點時間猜測輸出:

let a = {};
let b = { key: 'test' };
let c = { key: 'test' };

a[b] = '123';
a[c] = '456';

console.log(a);

乍一看,這段程式碼似乎應該生成一個具有兩個不同鍵值對的物件a。然而,由於JavaScript對物件鍵的處理方式,結果完全不同。

JavaScript 使用預設的toString()方法將物件鍵轉換為字串。為什麼呢? 在JavaScript中,物件鍵總是字串(或 symbols),或者透過隱式強制轉換自動轉換為字串。當你在物件中使用除字串之外的任何值(例如,數字、物件或符號)作為鍵時,JavaScript將在使用它作為鍵之前內部將該值轉換為其字串表示形式。

因此,當我們在物件a中使用物件bc作為鍵時,兩者都轉換為相同的字串表示形式:[object Object]。由於這種行為,第二個賦值a[c] = '456';會覆蓋第一個賦值a[b] = '123';

最終,當我們記錄物件a時,我們觀察到以下輸出:

{ '[object Object]': '456' }

7-雙等號運算子

console.log([] == ![]);

這個有點複雜。那麼,你認為輸出會是什麼呢?

這個問題相當複雜。那麼,你認為輸出結果會是什麼呢?讓我們一步一步地來評估。首先,讓我們看一下兩個運算元的型別:

typeof([]) // "object"
typeof(![]) // "boolean"

對於 [],它是一個物件,這是可以理解的,因為在JavaScript中,包括陣列和函式在內的一切都是物件。但運算元 ![] 是如何具有布林型別的呢?讓我們嘗試理解一下。當你使用 ! 與一個原始值(primitive value)一起時,會發生以下轉換:

  • Falsy Values(假值):如果原始值是一個假值(例如 false0nullundefinedNaN 或一個空字串 ''),應用 ! 將把它轉換為 true。
  • Truthy Values(真值):如果原始值是一個真值(即任何不是假值的值),應用 ! 將把它轉換為 false。

在我們的案例中,[] 是一個空陣列,這在JavaScript中是一個真值。因為 [] 是真值,![] 變成了 false。因此,我們的表示式變為:

[] == ![]
[] == false

現在,讓我們繼續瞭解 == 運算子。當使用 == 運算子比較兩個值時,JavaScript會執行“抽象相等性比較演算法(Abstract Equality Comparison Algorithm)”。這個演算法會考慮比較值的型別並進行必要的轉換。

image.png

在我們的情況中,讓我們把 x 記作 []y 記作 ![]。我們檢查了 xy 的型別,並發現 x 是物件,y 是布林值。由於 y 是布林值,x 是物件,演算法的第7個條件被應用:

如果 Type(y) 是 Boolean,則返回 x == ToNumber(y) 的比較結果。

這意味著如果其中一個型別是布林值,我們需要在比較之前將其轉換為數字。ToNumber(y) 的值是多少呢?如我們所見,[] 是一個真值,取反使其變為 false。因此,Number(false)0

[] == false
[] == Number(false)
[] == 0

現在我們有了 [] == 0 的比較,這次演算法的第8個條件起作用:

如果 Type(x) 是 String 或 Number,而 Type(y) 是 Object,則返回 x == ToPrimitive(y) 的比較結果。

基於這個條件,如果其中一個運算元是物件,我們必須將其轉換為一個原始值。這就是“ToPrimitive演算法”出現的地方。我們需要將 x(即 [])轉換為一個原始值。陣列在JavaScript中是物件。當將物件轉換為原始值時,valueOftoString 方法會起作用。在這種情況下,valueOf 返回陣列本身,這不是一個有效的原始值。因此,我們轉向 toString 以獲取輸出。將 toString 方法應用於空陣列會得到一個空字串,這是一個有效的原始值:

[] == 0
[].toString() == 0
"" == 0

將空陣列轉換為字串給了我們一個空字串 "",現在我們面對的比較是:"" == 0

現在其中一個運算元的型別是字串,另一個是數字,演算法的第5個條件成立:

如果 Type(x) 是 String,而 Type(y) 是 Number,則返回 ToNumber(x) == y 的比較結果。

因此,我們需要將空字串 "" 轉換為數字,這給了我們一個 0

"" == 0
ToNumber("") == 0
0 == 0

最後,兩個運算元具有相同的型別和條件1成立。由於兩者具有相同的值,最終的輸出是:

0 == 0 // true

至此,我們已經利用了強制轉換(coercion)來解決了我們探討的最後幾個問題,這是掌握JavaScript和解決面試中這類常見問題的重要概念。我強烈建議你檢視我的關於強制轉換的詳細部落格文章。它以清晰和徹底的方式解釋了這個概念。這裡是連結。

交流

首發於公眾號 大遷世界,歡迎關注。? 每週一篇實用的前端文章 ?️ 分享值得關注的開發工具 ❓ 有疑問?我來回答

本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

相關文章