JavaScript是构建网络的主要基石之一。这个强大的语言也有自己的怪癖。例如,您知道0 === -0
计算为true,或者Number("")
产生0吗?
问题在于,这些怪癖有时会让你抓耳挠腮,甚至质疑Brendon Eich发明JavaScript的那一天是不是high了。当然,这里的重点不是说JavaScript是一种糟糕的编程语言,也不是像它的批评者所说的那样它是邪恶的。所有编程语言都或多或少地有一些奇怪之处,JavaScript也不例外。
在这篇博文中,我们将深入解析一些重要的JavaScript面试题。我的目标是全面地解释这些面试题,以便我们可以理解其中的基本概念,并希望能解决面试中类似的其他问题。
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; // 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 prototype和__proto__的区别
prototype
属性是与JavaScript中的构造函数相关联的属性。 构造函数用于在JavaScript中创建对象。 定义构造函数时,你还可以将属性和方法附加到其prototype
属性上。 然后这些属性和方法对从该构造函数创建的所有实例对象都可访问。 因此,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.
另一方面,__proto__
属性通常发音为“dunder proto”,存在于每个JavaScript对象中。 在JavaScript中,除了原始类型之外的所有内容都可以被视为对象。 这些对象中的每一个都有一个原型,该原型充当对另一个对象的引用。 __proto__
属性仅仅是对该原型对象的引用。 当原始对象不具备属性或方法时,将使用原型对象作为回退来源。 默认情况下,创建对象时,其原型设置为Object.prototype
。
当尝试访问对象上的属性或方法时,JavaScript遵循查找流程来查找它。 此过程涉及两个主要步骤:
-
对象自身的属性: JavaScript首先检查对象本身是否直接拥有所需的属性或方法。 如果在对象中找到该属性,则直接访问和使用该属性。
-
原型链查找:如果没有在对象本身中找到该属性,则JavaScript查看对象的原型(由
__proto__
属性引用),并在其中搜索该属性。 此过程对原型链进行递归,直到找到该属性或直到查找达到Object.prototype
为止。
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
。 那么,您认为当调用 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
函数的作用域。 不出所料,a
存在于那里,其值为 3。 因此,控制台语句将打印 3
。
5 对象强制
一个有趣的方面是探索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
方法,只留下返回数字 27
的 toString
方法。 在这种情况下,JavaScript 将使用 toString
方法转换对象。
6 理解对象键
在使用 JavaScript 的对象时,掌握键在其他对象上下文中如何处理和分配非常重要。 考虑以下代码段,并花些时间猜测输出:
乍一看,这段代码似乎应该产生一个具有两个不同键值对的对象 a
。 然而,由于 JavaScript 处理对象键的方式,结果与预期相去甚远。
JavaScript 使用默认的 toString()
方法将对象键转换为字符串。但是为什么呢? 在 JavaScript 中,对象键始终是字符串(或符号),或者它们通过隐式强制转换自动转换为字符串。 当将字符串(例如数字、对象或符号)之外的值用作对象中的键时,JavaScript 将在将该值用作键之前在内部将该值转换为其字符串表示。
因此,当我们在对象 a
中使用对象 b
和 c
作为键时,两者都变为相同的字符串表示:[object Object]
。 由于这种行为,第二次分配 a[b] = '123';
将覆盖第一次分配 a[c] = '456';
。 让我们一步一步地分解代码:
let a = {};
: 初始化一个空对象a
。let b = { key: 'test' };
: 创建一个具有属性key
的对象b
,该属性的值为'test'
。let c = { key: 'test' };
: 定义与b
具有相同结构的另一个对象c
。a[b] = '123';
: 将值'123'
设置为对象a
中键为[object Object]
的属性。a[c] = '456';
: 使用相同的键字符串[object Object]
更新对象a
中相同属性的值,替换之前的值。
当我们记录对象 a
时,我们观察到以下输出:
{ '[object Object]': '456' }
7 双等号运算符
这是一个有点复杂的问题。那么,你认为输出会是什么?让我们一步一步来评估。首先,让我们开始查看两个操作数的类型:
typeof([]) // "object"
typeof(![]) // "boolean"
对于 []
它是一个 object
,这是可以理解的。因为在 JavaScript 中所有的都是对象,包括数组和函数。但是操作数 ![]
如何具有 boolean
类型呢?让我们试着理解这一点。当你对一个基本值使用 !
时,会发生以下转换:
- 假值: 如果原始值是一个假值(如
false
、0
、null
、undefined
、NaN
或空字符串''
),应用!
会将其转换为true
。 - 真值: 如果原始值是一个真值(任何非假的值),应用
!
会将其转换为false
。
在我们的例子中,[]
是一个空数组,在 JavaScript 中它是一个真值。由于 []
是真值,所以 ![]
变为 false
。所以我们的表达式变成了:
[] == ![]
[] == false
现在让我们继续理解 ==
操作符。当用 ==
操作符比较两个值时,JavaScript 会执行抽象相等比较算法。该算法有以下步骤:
抽象相等比较算法
如你所见,该算法考虑被比较值的类型并进行必要的转换。
对于我们的例子,让我们用 x
表示 []
,用 y
表示 ![]
。我们检查了 x
和 y
的类型,发现 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 中是对象。如我们之前看到的,在将对象转换为基本值时,valueOf
和 toString
方法会发挥作用。在这种情况下,valueOf
返回数组本身,这不是一个有效的基本值。因此,我们转到 toString
来获取输出。对空数组应用 toString
方法会得到一个空字符串,这是一个有效的基本值:
[] == 0
[].toString() == 0
"" == 0
将空数组转换为字符串给我们一个空字符串 ""
,现在我们面临比较:"" == 0
。
现在其中一个操作数是 string
类型,另一个是 number
类型,条件 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 处理变量作用域的方式,实际输出将不同。当 setTimeout
回调在 3000 毫秒的延迟后执行时,它们都会引用同一个变量 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]
,即 undefined
。
为了达到期望的输出,你可以使用 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
参数传递。这确保了每个回调函数都获得自己独立的 index
值,防止了与闭包相关的问题,并给你预期的输出:
Index: 0, element: 10
Index: 1, element: 12
Index: 2, element: 15
Index: 3, element: 21
感谢您的阅读。我希望这篇文章对你的面试准备有所帮助