分享 8 个关于高级前端的 JavaScript 面试题

JavaScript/前端
316
0
0
2024-02-02
标签   前端面试

英文 | https://levelup.gitconnected.com/8-advanced-javascript-interview-questions-for-senior-roles-c59e1b0f83e1

JavaScript 是一种功能强大的语言,是网络的主要构建块之一。这种强大的语言也有一些怪癖。例如,您是否知道 0 === -0 的计算结果为 true,或者 Number("") 的结果为 0?

问题是,有时这些怪癖会让你摸不着头脑,甚至质疑 Brendon 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。然而,重复函数本身出现了一个关键问题。

重复函数使用循环来遍历给定数组中的每个项目。但在循环内部,它使用 push() 方法在数组末尾添加一个新元素。这使得数组每次都变得更长,从而产生循环永远不会停止的问题。循环条件 (i < array.length) 始终保持为 true,因为数组不断变大。这使得循环永远持续下去,导致程序卡住。

为了解决数组长度不断增长导致无限循环的问题,可以在进入循环之前将数组的初始长度存储在变量中。

然后,您可以使用该初始长度作为循环迭代的限制。这样,循环将仅针对数组中的原始元素运行,并且不会因添加重复项而受到数组增长的影响。这是代码的修改版本:

function duplicate(array) {
  var initialLength = array.length; // Store the initial length
  for (var i = 0; i < initialLength; i++) {
    array.push(array[i]); // Push a duplicate of each element
  }
  return array;
}

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

输出将显示数组末尾的重复元素,并且循环不会导致无限循环:

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

3、原型和__proto__之间的区别

原型属性是与 JavaScript 中的构造函数相关的属性。构造函数用于在 JavaScript 中创建对象。定义构造函数时,还可以将属性和方法附加到其原型属性。

然后,从该构造函数创建的对象的所有实例都可以访问这些属性和方法。因此,prototype 属性充当在实例之间共享的方法和属性的公共存储库。

考虑以下代码片段:

// Constructor function
function Person(name) {
  this.name = name;
}

// Adding a method to the prototype
Person.prototype.sayHello = function() {
  console.log(`Hello, my name is ${this.name}.`);
};

// Creating instances
const person1 = new Person("Haider Wain");
const person2 = new Person("Omer Asif");

// Calling the shared method
person1.sayHello();  // Output: Hello, my name is Haider Wain.
person2.sayHello();  // Output: Hello, my name is Omer Asif.

在此示例中,我们有一个名为 Person 的构造函数。通过使用 sayHello 之类的方法扩展 Person.prototype,我们将此方法添加到所有 Person 实例的原型链中。这允许 Person 的每个实例访问和利用共享方法。而不是每个实例都有自己的方法副本。

另一方面, __proto__ 属性(通常发音为“dunder proto”)存在于每个 JavaScript 对象中。在 JavaScript 中,除了原始类型之外,所有东西都可以被视为对象。这些对象中的每一个都有一个原型,用作对另一个对象的引用。__proto__ 属性只是对此原型对象的引用。当原始对象不具备属性和方法时,原型对象用作属性和方法的后备源。默认情况下,当您创建对象时,其原型设置为 Object.prototype。

当您尝试访问对象的属性或方法时,JavaScript 会遵循查找过程来查找它。这个过程涉及两个主要步骤:

对象自己的属性:JavaScript 首先检查对象本身是否直接拥有所需的属性或方法。如果在对象中找到该属性,则直接访问和使用它。

原型链查找:如果在对象本身中找不到该属性,JavaScript 将查看该对象的原型(由 __proto__ 属性引用)并在那里搜索该属性。此过程在原型链上递归地继续,直到找到属性或查找到达 Object.prototype。

如果即使在 Object.prototype 中也找不到该属性,JavaScript 将返回 undefined,表明该属性不存在。

4、范围

编写 JavaScript 代码时,理解作用域的概念很重要。范围是指代码不同部分中变量的可访问性或可见性。在继续该示例之前,如果您不熟悉提升以及 JavaScript 代码的执行方式,可以从此链接了解它。这将帮助您更详细地了解 JavaScript 代码的工作原理。

让我们仔细看看代码片段:

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

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

var a = 5;
bar();

该代码定义了 2 个函数 foo() 和 bar() 以及一个值为 5 的变量 a。所有这些声明都发生在全局范围内。在 bar() 函数内部,声明了一个变量 a 并赋值为 3。那么当调用 thebar() 函数时,你认为它会打印 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 函数的范围。你瞧,a 存在,其值为 3。因此,控制台语句将打印 3。

5、对象强制

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

值得探索的一个有趣的方面是 JavaScript 如何处理对象到原始值(例如字符串、数字或布尔值)的转换。这是一个有趣的问题,测试您是否知道强制转换如何与对象一起使用。

在字符串连接或算术运算等场景中处理对象时,这种转换至关重要。为了实现这一点,JavaScript 依赖于两个特殊的方法:valueOf 和 toString。

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 方法,只留下 toString 方法,该方法返回数字 27。在这种情况下,JavaScript 将诉诸 toString 方法进行对象转换。

6、理解对象键

在 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 中,对象键始终是字符串(或符号),或者它们通过隐式强制转换自动转换为字符串。当您使用字符串以外的任何值(例如数字、对象或符号)作为对象中的键时,JavaScript 会在将该值用作键之前在内部将该值转换为其字符串表示形式。

因此,当我们使用对象 b 和 c 作为对象 a 中的键时,两者都会转换为相同的字符串表示形式:[object Object]。由于这种行为,第二个赋值 a[b] = '123'; 将覆盖第一个赋值 a[c] = '456';。

现在,让我们逐步分解代码:

  • let a = {};:初始化一个空对象a。
  • let b = { key: 'test' };: 创建一个对象 b,其属性键值为 'test'。
  • let c = { key: 'test' };: 定义另一个与 b 结构相同的对象 c。
  • a[b] = '123';:将对象a中键为[object Object]的属性设置为值'123'。
  • a[c] = '456';:将对象 a 中键 [object Object] 相同属性的值更新为 '456',替换之前的值。

两个分配都使用相同的键字符串 [object Object]。结果,第二个赋值会覆盖第一个赋值设置的值。

当我们记录对象 a 时,我们观察到以下输出:

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

7、==运算符

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

这个有点复杂。那么,您认为输出会是什么?让我们逐步评估一下。让我们首先看看两个操作数的类型:

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

对于[]来说它是一个对象,这是可以理解的。JavaScript 中的一切都是对象,包括数组和函数。但是操作数![]如何具有布尔类型呢?让我们试着理解这一点。当你使用 ! 对于原始值,会发生以下转换:

假值:如果原始值是假值(例如 false、0、null、undefined、NaN 或空字符串 ''),则应用 ! 会将其转换为 true。

真值:如果原始值是真值(任何非假值),则应用!会将其转换为 false。

在我们的例子中,[] 是一个空数组,它是 JavaScript 中的真值。由于 [] 为真,所以 ![] 变为假。所以,我们的表达式就变成了:

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

现在让我们继续了解 == 运算符。每当使用 == 运算符比较 2 个值时,JavaScript 就会执行抽象相等比较算法。

该算法有以下步骤:

正如您所看到的,该算法考虑了比较值的类型并执行必要的转换。

对于我们的例子,我们将 x 表示为 [],将 y 表示为 ![]。我们检查了 x 和 y 的类型,发现 x 是对象,y 是布尔值。由于 y 是布尔值,x 是对象,因此应用抽象相等比较算法中的条件 7:

如果 Type(y) 为 Boolean,则返回 x == ToNumber(y) 的比较结果。

这意味着如果其中一种类型是布尔值,我们需要在比较之前将其转换为数字。ToNumber(y) 的值是多少?正如我们所看到的,[] 是一个真值,否定则使其为假。结果,Number(false)为0。

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

现在我们有了比较 [] == 0,这次条件 8 开始发挥作用:

如果 Type(x) 是 String 或 Number 并且 Type(y) 是 Object,则返回比较结果 x == ToPrimitive(y)。

基于这个条件,如果其中一个操作数是对象,我们必须将其转换为原始值。这就是 ToPrimitive 算法发挥作用的地方。我们需要将 [] x 转换为原始值。数组是 JavaScript 中的对象。正如我们之前所看到的,当将对象转换为基元时,valueOf 和 toString 方法就会发挥作用。

在这种情况下, 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

到目前为止,我们在探索的最后几个问题中使用了强制转换,这是掌握 JavaScript 和在面试中解决此类问题的重要概念,这些问题往往会被问到很多。

8、闭包

这是与闭包相关的最著名的面试问题之一:

const arr = [10, 12, 15, 21];
for (var i = 0; i < arr.length; i++) {
  setTimeout(function() {
    console.log('Index: ' + i + ', element: ' + arr[i]);
  }, 3000);
}

如果您知道输出,那就好了。那么,让我们尝试理解这个片段。从表面上看,这段代码片段将为我们提供以下输出:

Index: 0, element: 10
Index: 1, element: 12
Index: 2, element: 15
Index: 3, element: 21

但这里的情况并非如此。由于闭包的概念以及 JavaScript 处理变量作用域的方式,实际的输出会有所不同。当延迟 3000 毫秒后执行 setTimeout 回调时,它们都将引用同一个变量 i,循环完成后该变量的最终值为 4。结果,代码的输出将是:

Index: 4, element: undefined
Index: 4, element: undefined
Index: 4, element: undefined
Index: 4, element: undefined

出现此行为的原因是 var 关键字没有块作用域,并且 setTimeout 回调捕获对同一 i 变量的引用。当回调执行时,它们都会看到 i 的最终值,即 4,并尝试访问未定义的 arr[4]。

为了实现所需的输出,您可以使用 let 关键字为循环的每次迭代创建一个新范围,确保每个回调捕获 i 的正确值:

const arr = [10, 12, 15, 21];
for (let i = 0; i < arr.length; i++) {
  setTimeout(function() {
    console.log('Index: ' + i + ', element: ' + arr[i]);
  }, 3000);
}

通过此修改,您将获得预期的输出:

Index: 0, element: 10
Index: 1, element: 12
Index: 2, element: 15
Index: 3, element: 21

使用 let 在每次迭代中为 i 创建一个新的绑定,确保每个回调引用正确的值。

通常,开发人员已经熟悉涉及 let 关键字的解决方案。然而,面试有时会更进一步,挑战你在不使用 let 的情况下解决问题。在这种情况下,另一种方法是通过立即调用循环内的函数(IIFE)来创建闭包。这样,每个函数调用都有自己的 i 副本。您可以这样做:

const arr = [10, 12, 15, 21];
for (var i = 0; i < arr.length; i++) {
  (function(index) {
    setTimeout(function() {
      console.log('Index: ' + index + ', element: ' + arr[index]);
    }, 3000);
  })(i);
}

在此代码中,立即调用的函数 (function(index) { ... })(i); 为每次迭代创建一个新范围,捕获 i 的当前值并将其作为索引参数传递。这确保每个回调函数都有自己单独的索引值,防止与闭包相关的问题并为您提供预期的输出:

Index: 0, element: 10
Index: 1, element: 12
Index: 2, element: 15
Index: 3, element: 21

最后总结

以上就是我今天这篇文章想与您分享的8个关于JS的前端面试题, 我希望这篇文章对您的面试准备之旅有所帮助。

如果您还有任何疑问,请在留言区给我们留言,我们一起交流学习进步。