第一关:基础类型与类型判断
面试题: JavaScript 中有哪些数据类型?如何准确地判断一个变量是否为“数组”?
💡 考察点提示:
- 基本类型 vs 引用类型(尤其是 ES6 以后新增的类型)。
typeof的局限性(为什么typeof []不行?)。- 最严谨的判断方案。
引用类型,引用基本类型是什么?然后基本类型就比如像那个 number,然后 string,然后这种就属于是基本类型。然后引用类型就是像 array、object、function,然后这种的话是引用类型。 然后引用类型和基本类型的区别是什么?然后引用类型,他就是栈和堆的区别。引用类型就对应的是一个堆。然后就是比如引用类型对应的是一个内存地址,就对应一个内存地址。然后比如你两个对象,然后他同时可以指向同一个内存地址,然后你修改了一个对象,然后另外一个对象的值也会变。然后这就是引用类型和基本类型的区别。 然后 typeof 的局限性是什么?typeof 的局限性的话就基本上就是属于那个。typeof 的局限性就是比如你 typeof 判断基本类型是可以的,但是你判断那个引用类型,比如 typeof array,typeof object,它都返回的 object,所以说 typeof 判断一个数组就不行。如果你一般判断数组的话就用 Array.isArray。 如果是最严谨的判断方法是什么?然后你判断数组,用 Array.isArray,然后最严谨的判断方法用 Object.prototype.toString.call,这个是所有的类型都可以判断的,这是最严谨的一个判断方法。
回答得非常棒!你已经掌握了这道题 85% 以上的核心要点。你的表述非常清晰,特别是提到了栈和堆的内存分配以及 Object.prototype.toString.call 这种最严谨的方法。
为了让你的回答在面试官听来更加“专业且成体系”,我稍微帮你复盘补充几个细节:
💎 细节补充
-
基本类型补全:除了 Number, String,记得提一下 Boolean, null, undefined 以及 ES6 新增的 Symbol 和 ES10 新增的 BigInt。
-
特殊的 null:面试官可能会追问:“为什么
typeof null是object?”。- 标准回答:这是 JS 最初实现的一个 Bug,在 JS 的早期版本中,使用 32 位系统,为了性能考虑,用低位字节存储变量的类型信息,
000开头代表对象,而null全是 0,所以被错误判断为了对象。
- 标准回答:这是 JS 最初实现的一个 Bug,在 JS 的早期版本中,使用 32 位系统,为了性能考虑,用低位字节存储变量的类型信息,
-
内存机制的精准描述:
- 基本类型:值直接存储在**栈(Stack)**中,占据固定大小的空间。
- 引用类型:指针(内存地址)存储在栈中,实际内容存储在**堆(Heap)**中。
第二关:作用域与闭包(进阶常客)
这道题是区分初级和中级开发者的分水岭。
面试题: 什么是闭包(Closure) ?它是如何产生的?请举一个你实际开发中用到闭包的例子,并说明如果使用不当会有什么后果?
💡 考察点提示:
- 定义:函数嵌套 + 内部函数引用外部变量。
- 词法作用域:JS 是如何根据代码位置确定变量访问权限的。
- 应用场景:数据私有化、防抖/节流、柯里化。
- 风险:内存泄露。
什么是闭包(B8),然后什么是闭包?它如何产生的?然后闭包是如何产生的? 然后,首先我讲用代码实际例子来举例一下,什么是闭包。就假设你有一个函数,然后你这个函数内部定义了一个变量,然后同时再把这个变量给暴露出去,然后这其实就是产生了一个闭包。 因为闭包的话,再举一下我实际开发中为什么要用闭包的例子,一般来说就是你如果想私有化变量,然后就用闭包。但是闭包的话要注意一下,就是 JS 它有垃圾回收机制。 然后就比如你如果不用的变量的话,是会被清空掉的,然后 JS 的话,你不用的变量,它会被清空掉的。所以 JS 中,当你用闭包的时候,你要注意垃圾回收的问题。 然后就是你如果闭包用多了,然后它有很多的变量都不会被垃圾回收掉,所以说使用不当它就会对性能造成一些影响,这时候……
你说得对,光看文字描述“打包带走”确实很难在脑子里构建模型。面试时,如果你能边说边在纸上画图,或者用具体的执行过程来解释,面试官会觉得你底层逻辑非常扎实。
为了让你不再觉得抽象,我们把闭包拆解为**“人话”+“内存模型”**。
1. 为什么叫“闭包”?(形象理解)
你可以把 作用域 想象成一个房间。
- 正常情况:函数执行完,房间就拆迁了(内存释放)。
- 闭包情况:函数执行完了,但它里面的“小函数”被你带到了房间外面。因为这个“小函数”手里还拿着房间柜子的钥匙,为了让这把钥匙有用,系统不能拆掉这个房间。
这个**“没被拆掉的房间 + 钥匙”**,合起来就叫闭包。
2. 内存层面的“打包带走”到底发生了什么?
我们用一段最简单的代码来看:
JavaScript
function outer() {
let a = 1;
function inner() {
console.log(a); // 引用了外部的 a
}
return inner;
}
const fn = outer();
fn(); // 执行时依然能拿到 a
底层执行过程:
- 执行
outer()时:JS 引擎会创建一个执行上下文。里面存着变量a。 - 遇到
inner定义时:JS 发现inner引用了a。于是,它会产生一个特殊的记录(在 V8 引擎里叫[[Scopes]]),把a存进去。 outer()执行结束时:按理说outer的整个空间都要被销毁。但因为fn(即inner)还在外面活着,且它身上带着那个[[Scopes]]记录,JS 引擎会强行把a这个变量留在内存里,不让它被垃圾回收。
3. 实际开发:用闭包解决“状态丢失”
面试官最喜欢问:你什么时候必须用闭包?
例子:计数器 如果你用全局变量,任何人都能改你的数值;如果你用局部变量,函数执行完数值就重置为 0 了。
JavaScript
function createCounter() {
let count = 0; // 这个变量被“锁”在了闭包里,外部无法直接修改
return {
add: function() { count++; console.log(count); },
get: function() { return count; }
}
}
const counter = createCounter();
counter.add(); // 1
counter.add(); // 2
// 外部无法通过 counter.count = 100 来破坏它
4. 总结成面试“金句”
如果面试官说你讲得太抽象,你就用这三句话总结:
- 本质:闭包是函数和其声明时的词法环境的组合。
- 核心:它让函数可以“跨越时空”访问原本属于另一个作用域的变量。
- 代价:由于变量被留在内存中不被回收,所以要注意手动释放(如
fn = null),防止内存泄漏。
事件循环(Event Loop)
什么是事件循环(视觉循环)?就是 JS 它是同步的,JS 是单线程语言,然后它分同步和异步。 比如你写一个 console.log (1),然后再写一个 console.log (4),它执行顺序肯定是 1 先执行,1 先被打印出来,然后 4 再被打印出来。但这时候如果有异步任务,那会怎么办?就比如你写一个 setTimeout 和 setInterval,或者一个请求函数,这种有异步任务的情况下: JS 会先执行同步的代码,然后再执行异步的代码。异步任务有一个异步队列,当 JS 运行到 setTimeout 这种异步代码时,会先把这部分代码放到异步队列里去。只有当同步任务全部执行完,才会去异步队列里拿到对应的函数来执行。 同时这个异步队列还分为宏任务和微任务,比如 Promise 这种就是微任务,而 setTimeout、setInterval 这种是宏任务。一般来说微任务的执行顺序比宏任务要快一点,比如 Promise.then 的代码会先执行。举例来说,如果你有两段代码,第一个是 Promise.then,第二个是 setTimeout,那么会先执行第一个 Promise 相关的代码,执行完之后才会执行第二个 setTimeout 的代码。这就是事件循环(英文的 loop,世界循环)。 事件循环的具体应用场景有哪些?
如何优化事件循环的性能?
事件循环和多线程有什么区别?
没问题,咱们把“事件循环”(Event Loop)这个高大上的词儿拆掉,换成餐厅点餐的故事,你就彻底秒懂了。
1. 故事背景:一家只有一个厨师的餐厅
JavaScript 就像一个只有一个厨师的餐厅。这个厨师(JS 主线程)动作极快,但他同一时间只能炒一个菜(单线程)。
为了让餐厅不排长队,他是这么干活的:
2. 核心角色
- 服务员(执行栈) :负责接单。如果是简单的凉菜(同步任务),厨师马上做了给客人。
- 后厨定时器/烤箱(异步任务处理) :如果是需要炖两小时的肉或者需要等 5 分钟的炸鸡(异步任务),厨师不会站在那等,而是定个闹钟,转身去炒下一道菜。
- 出菜口(任务队列) :菜好了,闹钟响了,服务员会把做好的菜放在“出菜口”排队,等厨师手头的菜炒完了,就来这里取下一单。
3. 事件循环是怎么转起来的?
- 第一步(执行同步代码) :厨师先看手里的单子,把能立刻炒好的菜(比如
console.log)全部炒完。 - 第二步(微任务优先) :厨师炒完手里的菜,会先看一眼身边的**“特快传菜通道”**(微任务,比如
Promise.then)。只要这里有菜,他会优先全部处理掉。 - 第三步(宏任务排队) :特快通道空了,厨师才会去**“普通出菜口”**(宏任务,比如
setTimeout)拿那一单。 - 循环:厨师拿了普通单子开始炒,炒完又去检查“特快通道”,如此反复。这就是事件循环。
4. 回头看你的代码为什么是 1 -> 4 -> 3 -> 2?
JavaScript
console.log('1'); // 厨师立刻炒了,打印 1
setTimeout(() => { // 这是一个大炸鸡,定个闹钟放一边,属于“普通出菜口”
console.log('2');
}, 0);
Promise.resolve().then(() => { // 这是一道特快菜,属于“特快传菜通道”
console.log('3');
});
console.log('4'); // 厨师立刻炒了,打印 4
- 第一轮(炒同步菜) :先打印
1和4。 - 第二轮(检查特快通道) :看到
3在排队,打印3。 - 第三轮(检查普通出口) :看到
2好了,打印2。
5. 为什么要注意它?(面试防坑点)
- 不要堵塞厨师:如果你写一个死循环,厨师就一直卡在那道菜上。哪怕“特快通道”和“普通出口”全是菜,也没人去取,餐厅就死机了(浏览器卡死)。
- 微任务更急:只要有“特快菜”源源不断进来,厨师就永远不会去拿“普通出菜口”里的
setTimeout。
2. 面试官的“连环炮”:forEach 和 map 有啥区别?
这是最基础也最常问的:
forEach:只是去“遍历”一下数组。它就像你去巡视果园,数数苹果,但你不带走任何东西。它没有返回值。map:是去“加工”数组。你不仅巡视果园,还把每个苹果洗干净装进新篮子,最后带走这筐新苹果。它会返回一个新数组。
3. 给面试官露一手:如何“扁平化”数组?
面试题场景: 如果你拿到一个很乱的嵌套数组,比如 [1, [2, [3, 4]]],想把它变成平整的 [1, 2, 3, 4],你会怎么办?
大白话答案:
- 现代写法:直接用
flat()方法。比如arr.flat(Infinity),不管嵌套多少层,直接拍扁。 - 老牌写法:如果面试官非要你手写,你就说用
reduce配合递归。
第六关:ES6 常用新特性
现在的 JS 面试,如果不聊 ES6(新版 JS 标准),那就不叫面试。
面试题: 除了上面说的数组方法,你在平时开发中还常用到哪些 ES6 的新语法?
💡 提示(这些名字你应该听过,试着挑两个说说):
- 箭头函数
() => {} - 解构赋值
const { name } = person - 模板字符串
`我的名字是 ${name}` - 展开运算符
...args
第七关:箭头函数 vs 普通函数
面试题: 箭头函数 () => {} 和普通的 function 有什么区别?为什么我们在写 React 或一些回调函数时喜欢用箭头函数?
💡 不专业的“大白话”解释:
-
this的脾气不一样:- 普通函数:很有主见。它的
this指向谁,取决于谁调用它。就像一个雇佣兵,谁给钱(调用)就跟谁姓。 - 箭头函数:非常随和。它自己没有
this,它会直接**管上一层(爸爸辈)**借this来用。就像一个乖孩子,家里人姓什么他就姓什么。
- 普通函数:很有主见。它的
-
能不能当“老板”(构造函数) :
- 普通函数可以用
new来创建一个对象。 - 箭头函数不行,你强行用
new去点它,它会报错给你看。
- 普通函数可以用
面试金句: “箭头函数没有自己的 this,它的 this 是在定义时就决定好的,指向外层作用域。”
第八关:展开运算符 ... (三个点)
面试题: 你在代码里经常用到那“三个点” ... 吗?它都能干啥?
💡 不专业的“大白话”解释:
这三个点就像是**“把包装拆掉”**。
-
合并数组/对象:
- 你有两篮水果
[苹果, 橘子]和[香蕉]。 - 你想并在一起?直接
[...篮子1, ...篮子2]。包装一拆,水果全倒进一个新篮子里。
- 你有两篮水果
-
浅拷贝(防干扰) :
- 你想复制一个对象,但不想改了副本后影响原件。
const 新人 = { ...旧人 }。这就相当于把旧人的衣服口袋里的东西全掏出来,塞进新人的新衣服里。
第九关:怎么处理报错?(Promise 报错)
面试题: 如果一个接口请求失败了(异步报错),你平时是怎么抓到这个错误的?
💡 不专业的“大白话”解释:
-
老办法(Promise.then) :
- 在后面接一个
.catch()。就像是接力赛,最后一个人专门负责捡掉在地上的棒子。
- 在后面接一个
-
新办法(Async/Await) :
- 用
try...catch。 - 把可能出事的代码塞进
try { ... }盒子里。如果里面炸了,代码会立刻跳到catch { ... }块里,程序不会直接崩溃死掉。
- 用
第十关:原型与原型链(底层逻辑)
面试题:
- 什么是
prototype(原型) 和__proto__(原型链) ?它们之间是什么关系? - 如果我有一个对象
person,当我访问person.toString()时,JS 引擎是怎么找到这个方法的?(请描述这个寻找的过程)。
什么是原型?什么是原型链?
原型就是
prototype,原型链就是通过.prototype层层查找的链路。我用现实生活中的例子来解释:假设我有一个对象或者构造函数,然后我调用
toString()这个方法,这个方法是从哪里来的?首先会从原型链上找 —— 如果用现实生活举例,我是家里最小的那个,我先看自己本身有没有定义这个方法;如果没有,我就通过原型链(.prototype)找我爸爸,问他有没有这个方法,他有就直接用;如果他没有,我就继续往上找,比如爸爸再找爷爷,一直往上找。用代码举例的话,这个查找过程最后会找到
Object.prototype这个原型(相当于家里辈分最高的人),看这里有没有这个方法。这就是原型和原型链的核心逻辑。补充代码示例(直观理解原型链查找)
javascript
运行
// 1. 定义一个构造函数(相当于“我”) function Person() { // 自身没有定义toString方法 } // 2. 创建实例(“我”的具体对象) const me = new Person(); // 3. 调用toString(),自身没有,开始原型链查找 console.log(me.toString()); // 输出 "[object Object]" // 原型链查找路径: // me → Person.prototype(爸爸,无toString)→ Object.prototype(爷爷,有toString)→ null(查找终止) console.log(Person.prototype.hasOwnProperty('toString')); // false(爸爸没有) console.log(Object.prototype.hasOwnProperty('toString')); // true(爷爷有)总结
- 原型(prototype) :每个函数 / 对象都有一个原型对象,相当于 “父辈” 的属性 / 方法集合,供子对象继承使用。
- 原型链:当访问一个对象的属性 / 方法时,会先查自身,自身没有就顺着
__proto__(原型链链路)往上找,直到Object.prototype或null,这个层层查找的链路就是原型链。- 核心逻辑:原型链的本质是 “继承查找机制”,就像生活中晚辈找长辈求助,直到找到能解决问题的人(或找到最顶层仍没有则返回 undefined)。
回答得非常出色!你用“找长辈求助”这个比喻简直是神来之笔,面试官最喜欢听到这种能把复杂底层逻辑讲得通俗易懂的回答。
不过,这里有一个极其微小但重要的细节,我得帮你纠正一下,防止面试官在细节上“卡”你:
小纠正: > 查找的链路其实是通过
__proto__(隐式原型)连接的,而不是prototype。
prototype:是给函数用的(像是一份建设蓝图)。__proto__:是给实例对象用的(像是找爸爸的线索)。寻找路径:
me.__proto__->Person.prototype->Person.prototype.__proto__->Object.prototype。
手写 Promise 的核心思想
既然你提到了异步,也提到了代码结构,面试官这时候可能会想看你对 Promise 的理解深不深。
面试题: 如果你要自己实现一个简易版的 Promise,你觉得它的内部最起码要维护哪几个状态?当我在外面调用 .then() 的时候,Promise 内部发生了什么?
💡 提示(不专业的“大白话”):
- 三个状态:还没结果、成功了、失败了。(用英文怎么说?)
- 状态转换:状态一旦变了,还能再改吗?
- 小本本(回调队列) :如果
then执行的时候,异步结果还没回来,Promise 该怎么处理这些后续的任务?
这题一点没有难度,然后手写 Promise 的核心思想:首先就是区分三个状态,一个是等待态(pending,还没成功),然后是成功态(fulfilled/result 状态),然后失败的就是拒绝态(rejected 状态)。
第二个关键点:Promise 的状态变了还能改吗?Promise 的状态一旦变了是改不了的,一旦状态确定就无法修改。
那如果 Promise 处于 pending 状态时,异步结果还没回来,Promise 怎么处理后续的 then/catch 这些回调任务?还有 Promise 内置的异步结构,这个我不太清楚,你可以给我解释一下。
核心问题解释:Promise 处理异步回调的底层逻辑
1. 回调队列的本质:“先存后执行”
当 Promise 还处于pending状态(异步结果没回来)时,调用then/catch添加的回调函数,不会立即执行,而是会被 Promise 内部收集起来,存入一个 “回调队列”(可以理解为待执行的任务清单)。
只有当 Promise 的状态从pending变为fulfilled或rejected时,才会按照添加顺序,依次执行队列里对应的回调函数。
2. 内置异步结构:微任务执行机制
Promise 的回调(then/catch/finally)不是同步执行,也不是普通的宏任务,而是属于微任务,会被加入 JS 的微任务队列:
- 同步代码执行完毕后,先清空所有微任务队列,再执行宏任务(如 setTimeout);
- 这就是为什么 Promise.then 总是比 setTimeout 先执行的原因。
3. 手写 Promise 核心代码(简化版,体现回调队列 + 状态不可变)
javascript
运行
// 定义三个状态常量
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
class MyPromise {
constructor(executor) {
this.status = PENDING; // 初始状态为pending
this.value = undefined; // 成功的结果
this.reason = undefined; // 失败的原因
this.onFulfilledCallbacks = []; // 成功回调队列
this.onRejectedCallbacks = []; // 失败回调队列
// 成功回调:状态只能从pending→fulfilled
const resolve = (value) => {
if (this.status === PENDING) { // 状态不可变的核心:只有pending时能改状态
this.status = FULFILLED;
this.value = value;
// 状态变更后,执行所有成功回调(微任务)
queueMicrotask(() => {
this.onFulfilledCallbacks.forEach(cb => cb(this.value));
});
}
};
// 失败回调:状态只能从pending→rejected
const reject = (reason) => {
if (this.status === PENDING) {
this.status = REJECTED;
this.reason = reason;
// 状态变更后,执行所有失败回调(微任务)
queueMicrotask(() => {
this.onRejectedCallbacks.forEach(cb => cb(this.reason));
});
}
};
try {
executor(resolve, reject); // 执行传入的执行器函数
} catch (e) {
reject(e); // 执行器报错直接reject
}
}
// then方法:收集回调或立即执行
then(onFulfilled, onRejected) {
// 如果状态还是pending,把回调存入队列
if (this.status === PENDING) {
this.onFulfilledCallbacks.push(onFulfilled);
this.onRejectedCallbacks.push(onRejected);
}
// 如果状态已成功,直接执行成功回调(微任务)
else if (this.status === FULFILLED) {
queueMicrotask(() => onFulfilled(this.value));
}
// 如果状态已失败,直接执行失败回调(微任务)
else if (this.status === REJECTED) {
queueMicrotask(() => onRejected(this.reason));
}
}
}
// 测试:异步场景下的回调处理
const p = new MyPromise((resolve) => {
// 模拟异步操作(1秒后返回结果)
setTimeout(() => {
resolve('异步结果');
}, 1000);
});
// 此时Promise还是pending,回调会被存入队列
p.then((res) => {
console.log('成功:', res); // 1秒后输出:成功: 异步结果
});
console.log('同步代码先执行'); // 先输出:同步代码先执行
总结
- Promise 状态规则:只有
pending能转为fulfilled/rejected,状态一旦变更就永久不可修改。 - pending 状态的回调处理:then/catch 的回调会被存入内部队列,等待状态变更后批量执行。
- 异步核心:Promise 回调属于微任务,会在同步代码执行完、宏任务执行前执行,这是其内置异步结构的关键。