JavaScript 的知识体系庞杂,面试题往往不仅考察你是否知道答案,更看重你是否理解其底层的运行机制。为了避免死记硬背,我们需要从 JavaScript 引擎的设计逻辑出发来解构这些常考点。
以下为你系统梳理的基础与高级核心考点、经典代码陷阱及完整的推演思路:
一、 数据类型与隐式转换陷阱
JavaScript 是一门弱类型语言,在发生比较和运算时,引擎会频繁进行隐式类型转换。这是初中级面试的重灾区。
经典考题:
JavaScript
console.log([] == ![]);
console.log(typeof null);
答案与解析思路:
-
[] == ![]结果为true。-
推演逻辑:
-
首先,遇到
!逻辑非运算符。空数组[]是一个真值(truthy),所以![]会被转化为false。此时等式变为[] == false。 -
当等号一边是布尔值时,JS 引擎会先将布尔值转为数字。
false转化为0。等式变为[] == 0。 -
当对象(数组也是对象)与数字比较时,对象会调用内部的
ToPrimitive机制,先尝试调用valueOf(),如果是数组,通常返回数组本身;接着调用toString(),[].toString()得到空字符串""。等式变为"" == 0。 -
字符串与数字比较,字符串转化为数字。
""转化为0。 -
最终变为
0 == 0,结果为true。
-
-
-
typeof null结果为"object"。- 推演逻辑:这是一个著名的历史遗留 bug。在 JS 的最初版本中,数据在底层以 32 位机器码表示,其中前 3 位表示类型。对象的类型标签是
000。而null在大多数平台下的内存表示全是0,所以引擎错误地将其判断为object。由于修复这个 bug 会破坏大量现存的 Web 代码,这个规范被永久保留了下来。
- 推演逻辑:这是一个著名的历史遗留 bug。在 JS 的最初版本中,数据在底层以 32 位机器码表示,其中前 3 位表示类型。对象的类型标签是
二、 作用域、闭包与变量生命周期
闭包的本质是函数在定义时的词法作用域被保留了下来,使得函数即使在词法作用域之外执行,依然能访问原作用域的变量。
经典考题:
JavaScript
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
答案与解析思路:
-
结果:一秒后几乎同时输出三个
3。 -
推演逻辑:
-
var声明的变量存在函数作用域或全局作用域,不存在块级作用域。因此,循环中的i是全局变量。 -
setTimeout是异步宏任务。当循环极快地执行完毕时,全局变量i的值已经变成了3。 -
一秒钟后,事件循环将三个回调函数推入执行栈。此时它们顺着作用域链寻找
i,找到的是同一个已经变为3的全局i。
-
-
解决方案:
-
方案一(最常用):将
var改为let。let具有块级作用域,每次迭代都会创建一个新的词法环境,setTimeout内部的闭包会捕获当前这一轮循环的i。 -
方案二:使用立即执行函数(IIFE),通过函数传参的方式强行切断与全局变量的引用绑定,保存当前的值。
-
三、 this 绑定的动态性
this 的指向是一个极为关键的分水岭。其核心逻辑是:普通函数的 this 取决于函数被调用的位置和方式,而不是声明的位置;箭头函数的 this 则完全由外层词法作用域决定。
经典考题:
JavaScript
var length = 10;
function fn() {
console.log(this.length);
}
var obj = {
length: 5,
method: function(fn) {
fn();
arguments[0]();
}
};
obj.method(fn, 1);
答案与解析思路:
-
结果:输出
10(在浏览器非严格模式下),然后输出2。 -
推演逻辑:
-
执行
obj.method(fn, 1)。进入method函数内部。 -
第一次调用
fn():这是一个裸调用(默认绑定)。在非严格模式下,this指向全局对象window。所以this.length就是全局的length,即10。 -
第二次调用
arguments[0]():这里非常隐蔽。arguments[0]实际上就是传入的fn。通过对象.属性()的方式调用,属于隐式绑定。此时fn内部的this指向了arguments这个对象。 -
arguments对象的length属性代表传入的实参个数。调用obj.method(fn, 1)时传入了 2 个参数,所以arguments.length为2。
-
四、 原型与面向对象(基于委托的继承)
JavaScript 的继承本质上并不是类的复制,而是对象之间的“行为委托”。当读取对象属性时,如果找不到,引擎会顺着内部的 [[Prototype]] 链条向上查找。
经典考题: 手写 new 操作符的底层实现。
面试官通常通过让你手写 new 来考察你是否真正理解了对象、构造函数和原型链之间的三角关系。
答案与解析思路:
JavaScript
function myNew(Constructor, ...args) {
// 1. 创建一个全新的空对象,并将其隐式原型指向构造函数的显式原型
const obj = Object.create(Constructor.prototype);
// 2. 将构造函数内部的 this 绑定到这个新对象上,并执行构造函数
const result = Constructor.apply(obj, args);
// 3. 判断构造函数的返回值。如果是对象或函数,则返回该结果;否则返回我们刚创建的新对象
return (result !== null && (typeof result === 'object' || typeof result === 'function')) ? result : obj;
}
- 推演逻辑:
new的核心不仅在于分配内存,更在于建立原型关联和执行初始化逻辑。最后一步的返回值判断经常被忽略:如果构造函数主动返回了一个复杂的引用类型,new表达式的结果就是那个引用类型;如果返回的是基本数据类型(如return 1),引擎会忽略它,依然返回新实例。
五、 异步与事件循环(Event Loop)
JavaScript 是单线程的,通过事件循环机制来处理并发。你需要极其清晰地区分同步代码、微任务(Microtask)和宏任务(Macrotask)。
经典考题:
JavaScript
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
}, 0);
new Promise((resolve) => {
console.log('4');
resolve();
}).then(() => {
console.log('5');
});
console.log('6');
答案与解析思路:
-
结果:
1, 4, 6, 5, 2, 3。 -
推演逻辑(执行栈的时间线分析):
-
第一步(执行全局同步代码):
-
打印
1。 -
遇到
setTimeout,将其回调推入宏任务队列。 -
遇到
new Promise,传入的执行器函数是同步执行的,立刻打印4,然后resolve()被调用。 -
.then()属于微任务,其回调被推入微任务队列。 -
打印
6。
-
-
第二步(清空当前微任务队列):
- 此时主栈清空,引擎去微任务队列检查,发现有之前推入的
.then()回调,执行并打印5。
- 此时主栈清空,引擎去微任务队列检查,发现有之前推入的
-
第三步(执行下一个宏任务):
-
微任务清空后,引擎从宏任务队列中取出一个任务(即之前的
setTimeout回调)执行。 -
打印
2。 -
在执行这个宏任务期间,又遇到了
Promise.resolve().then(),这会向当前的微任务队列追加一个任务。 -
这个宏任务执行完毕,引擎立刻检查微任务队列,发现刚追加的任务,执行并打印
3。
-
-
这五个维度构成了 JavaScript 最核心的底层逻辑网络。理解了这些,面对变种题目也能推演出来。