JavaScript 是一种功能强大的语言,也是构建现代 Web 的基础之一。这种强大的语言也有一些自己的怪癖。例如,你知道 0 === -0
会计算为 true
,或者 Number("")
会返回 0
吗?
有时候,这些怪癖会让你百思不得其解,甚至让你怀疑 Brendan Eich 在发明 JavaScript 的那一天是不是状态不佳。但这里的重点并不是说 JavaScript 是一种糟糕的编程语言,或者如其批评者所说的那样,是一种“邪恶”的语言。所有的编程语言都有某种程度的怪癖,JavaScript 也不例外。
在这篇博客文章中,我们将深入解释一些重要的 JavaScript 面试问题。我的目标是彻底解释这些面试问题,以便我们能够理解背后的基本概念,并希望在面试中解决其他类似的问题。
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
考虑以下的 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]
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
,表示该属性不存在。
当编写 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
函数的作用域。果然,那里存在一个值为3
的a
。因此,控制台语句将输出3
。
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
方法,只留下了返回数字27
的toString
方法。在这种情况下,JavaScript 将依赖 toString
方法进行对象转换。
当在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
中使用对象b
和c
作为键时,两者都转换为相同的字符串表示形式:[object Object]。由于这种行为,第二个赋值a[c] = '456'
;会覆盖第一个赋值a[b] = '123';
。
最终,当我们记录对象a
时,我们观察到以下输出:
{ '[object Object]': '456' }
console.log([] == ![]);
这个有点复杂。那么,你认为输出会是什么呢?
这个问题相当复杂。那么,你认为输出结果会是什么呢?让我们一步一步地来评估。首先,让我们看一下两个操作数的类型:
typeof([]) // "object"
typeof(![]) // "boolean"
对于 []
,它是一个对象,这是可以理解的,因为在JavaScript中,包括数组和函数在内的一切都是对象。但操作数 ![]
是如何具有布尔类型的呢?让我们尝试理解一下。当你使用 !
与一个原始值(primitive value)一起时,会发生以下转换:
Falsy Values
(假值):如果原始值是一个假值(例如 false
、0
、null
、undefined
、NaN
或一个空字符串 ''
),应用 !
将把它转换为 true。在我们的案例中,[]
是一个空数组,这在JavaScript中是一个真值。因为 []
是真值,![]
变成了 false。因此,我们的表达式变为:
[] == ![]
[] == false
现在,让我们继续了解 ==
运算符。当使用 == 运算符比较两个值时,JavaScript会执行“抽象相等性比较算法(Abstract Equality Comparison Algorithm)”。这个算法会考虑比较值的类型并进行必要的转换。
在我们的情况中,让我们把 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
。
现在其中一个操作数的类型是字符串,另一个是数字,算法的第5个条件成立:
如果 Type(x) 是 String,而 Type(y) 是 Number,则返回 ToNumber(x) == y 的比较结果。
因此,我们需要将空字符串 ""
转换为数字,这给了我们一个 0
。
"" == 0
ToNumber("") == 0
0 == 0
最后,两个操作数具有相同的类型和条件1成立。由于两者具有相同的值,最终的输出是:
0 == 0 // true
至此,我们已经利用了强制转换(coercion)来解决了我们探讨的最后几个问题,这是掌握JavaScript和解决面试中这类常见问题的重要概念。我强烈建议你查看我的关于强制转换的详细博客文章。它以清晰和彻底的方式解释了这个概念。这里是链接。