js 10道面试题

37 阅读18分钟

第一关:基础类型与类型判断

面试题: JavaScript 中有哪些数据类型?如何准确地判断一个变量是否为“数组”?


💡 考察点提示:

  1. 基本类型 vs 引用类型(尤其是 ES6 以后新增的类型)。
  2. typeof 的局限性(为什么 typeof [] 不行?)。
  3. 最严谨的判断方案

引用类型,引用基本类型是什么?然后基本类型就比如像那个 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 这种最严谨的方法。

为了让你的回答在面试官听来更加“专业且成体系”,我稍微帮你复盘补充几个细节:

💎 细节补充

  1. 基本类型补全:除了 Number, String,记得提一下 Boolean, null, undefined 以及 ES6 新增的 Symbol 和 ES10 新增的 BigInt

  2. 特殊的 null:面试官可能会追问:“为什么 typeof nullobject?”。

    • 标准回答:这是 JS 最初实现的一个 Bug,在 JS 的早期版本中,使用 32 位系统,为了性能考虑,用低位字节存储变量的类型信息,000 开头代表对象,而 null 全是 0,所以被错误判断为了对象。
  3. 内存机制的精准描述

    • 基本类型:值直接存储在**栈(Stack)**中,占据固定大小的空间。
    • 引用类型:指针(内存地址)存储在中,实际内容存储在**堆(Heap)**中。

第二关:作用域与闭包(进阶常客)

这道题是区分初级和中级开发者的分水岭。

面试题: 什么是闭包(Closure) ?它是如何产生的?请举一个你实际开发中用到闭包的例子,并说明如果使用不当会有什么后果?


💡 考察点提示:

  1. 定义:函数嵌套 + 内部函数引用外部变量。
  2. 词法作用域:JS 是如何根据代码位置确定变量访问权限的。
  3. 应用场景:数据私有化、防抖/节流、柯里化。
  4. 风险:内存泄露。

什么是闭包(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

底层执行过程:

  1. 执行 outer():JS 引擎会创建一个执行上下文。里面存着变量 a
  2. 遇到 inner 定义时:JS 发现 inner 引用了 a。于是,它会产生一个特殊的记录(在 V8 引擎里叫 [[Scopes]]),把 a 存进去。
  3. 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. 总结成面试“金句”

如果面试官说你讲得太抽象,你就用这三句话总结:

  1. 本质:闭包是函数和其声明时的词法环境的组合。
  2. 核心:它让函数可以“跨越时空”访问原本属于另一个作用域的变量。
  3. 代价:由于变量被留在内存中不被回收,所以要注意手动释放(如 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. 事件循环是怎么转起来的?

  1. 第一步(执行同步代码) :厨师先看手里的单子,把能立刻炒好的菜(比如 console.log)全部炒完。
  2. 第二步(微任务优先) :厨师炒完手里的菜,会先看一眼身边的**“特快传菜通道”**(微任务,比如 Promise.then)。只要这里有菜,他会优先全部处理掉。
  3. 第三步(宏任务排队) :特快通道空了,厨师才会去**“普通出菜口”**(宏任务,比如 setTimeout)拿那一单。
  4. 循环:厨师拿了普通单子开始炒,炒完又去检查“特快通道”,如此反复。这就是事件循环

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
  • 第一轮(炒同步菜) :先打印 14
  • 第二轮(检查特快通道) :看到 3 在排队,打印 3
  • 第三轮(检查普通出口) :看到 2 好了,打印 2

5. 为什么要注意它?(面试防坑点)

  • 不要堵塞厨师:如果你写一个死循环,厨师就一直卡在那道菜上。哪怕“特快通道”和“普通出口”全是菜,也没人去取,餐厅就死机了(浏览器卡死)。
  • 微任务更急:只要有“特快菜”源源不断进来,厨师就永远不会去拿“普通出菜口”里的 setTimeout

2. 面试官的“连环炮”:forEachmap 有啥区别?

这是最基础也最常问的:

  • forEach:只是去“遍历”一下数组。它就像你去巡视果园,数数苹果,但你不带走任何东西。它没有返回值
  • map:是去“加工”数组。你不仅巡视果园,还把每个苹果洗干净装进新篮子,最后带走这筐新苹果。它会返回一个新数组

3. 给面试官露一手:如何“扁平化”数组?

面试题场景: 如果你拿到一个很乱的嵌套数组,比如 [1, [2, [3, 4]]],想把它变成平整的 [1, 2, 3, 4],你会怎么办?

大白话答案

  • 现代写法:直接用 flat() 方法。比如 arr.flat(Infinity),不管嵌套多少层,直接拍扁。
  • 老牌写法:如果面试官非要你手写,你就说用 reduce 配合递归

第六关:ES6 常用新特性

现在的 JS 面试,如果不聊 ES6(新版 JS 标准),那就不叫面试。

面试题: 除了上面说的数组方法,你在平时开发中还常用到哪些 ES6 的新语法

💡 提示(这些名字你应该听过,试着挑两个说说):

  1. 箭头函数 () => {}
  2. 解构赋值 const { name } = person
  3. 模板字符串 `我的名字是 ${name}`
  4. 展开运算符 ...args

第七关:箭头函数 vs 普通函数

面试题: 箭头函数 () => {} 和普通的 function 有什么区别?为什么我们在写 React 或一些回调函数时喜欢用箭头函数?


💡 不专业的“大白话”解释:

  1. this 的脾气不一样

    • 普通函数:很有主见。它的 this 指向谁,取决于谁调用它。就像一个雇佣兵,谁给钱(调用)就跟谁姓。
    • 箭头函数:非常随和。它自己没有 this,它会直接**管上一层(爸爸辈)**借 this 来用。就像一个乖孩子,家里人姓什么他就姓什么。
  2. 能不能当“老板”(构造函数)

    • 普通函数可以用 new 来创建一个对象。
    • 箭头函数不行,你强行用 new 去点它,它会报错给你看。

面试金句: “箭头函数没有自己的 this,它的 this 是在定义时就决定好的,指向外层作用域。”


第八关:展开运算符 ... (三个点)

面试题: 你在代码里经常用到那“三个点” ... 吗?它都能干啥?


💡 不专业的“大白话”解释:

这三个点就像是**“把包装拆掉”**。

  1. 合并数组/对象

    • 你有两篮水果 [苹果, 橘子][香蕉]
    • 你想并在一起?直接 [...篮子1, ...篮子2]。包装一拆,水果全倒进一个新篮子里。
  2. 浅拷贝(防干扰)

    • 你想复制一个对象,但不想改了副本后影响原件。
    • const 新人 = { ...旧人 }。这就相当于把旧人的衣服口袋里的东西全掏出来,塞进新人的新衣服里。

第九关:怎么处理报错?(Promise 报错)

面试题: 如果一个接口请求失败了(异步报错),你平时是怎么抓到这个错误的?


💡 不专业的“大白话”解释:

  1. 老办法(Promise.then)

    • 在后面接一个 .catch()。就像是接力赛,最后一个人专门负责捡掉在地上的棒子。
  2. 新办法(Async/Await)

    • try...catch
    • 把可能出事的代码塞进 try { ... } 盒子里。如果里面炸了,代码会立刻跳到 catch { ... } 块里,程序不会直接崩溃死掉。

第十关:原型与原型链(底层逻辑)

面试题:

  1. 什么是 prototype(原型)__proto__(原型链) ?它们之间是什么关系?
  2. 如果我有一个对象 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(爷爷有)

总结

  1. 原型(prototype) :每个函数 / 对象都有一个原型对象,相当于 “父辈” 的属性 / 方法集合,供子对象继承使用。
  2. 原型链:当访问一个对象的属性 / 方法时,会先查自身,自身没有就顺着 __proto__(原型链链路)往上找,直到 Object.prototypenull,这个层层查找的链路就是原型链。
  3. 核心逻辑:原型链的本质是 “继承查找机制”,就像生活中晚辈找长辈求助,直到找到能解决问题的人(或找到最顶层仍没有则返回 undefined)。

回答得非常出色!你用“找长辈求助”这个比喻简直是神来之笔,面试官最喜欢听到这种能把复杂底层逻辑讲得通俗易懂的回答。

不过,这里有一个极其微小但重要的细节,我得帮你纠正一下,防止面试官在细节上“卡”你:

小纠正: > 查找的链路其实是通过 __proto__ (隐式原型)连接的,而不是 prototype

  • prototype:是给函数用的(像是一份建设蓝图)。
  • __proto__ :是给实例对象用的(像是找爸爸的线索)。

寻找路径: me.__proto__ -> Person.prototype -> Person.prototype.__proto__ -> Object.prototype

手写 Promise 的核心思想

既然你提到了异步,也提到了代码结构,面试官这时候可能会想看你对 Promise 的理解深不深。

面试题: 如果你要自己实现一个简易版的 Promise,你觉得它的内部最起码要维护哪几个状态?当我在外面调用 .then() 的时候,Promise 内部发生了什么?


💡 提示(不专业的“大白话”):

  1. 三个状态:还没结果、成功了、失败了。(用英文怎么说?)
  2. 状态转换:状态一旦变了,还能再改吗?
  3. 小本本(回调队列) :如果 then 执行的时候,异步结果还没回来,Promise 该怎么处理这些后续的任务?

这题一点没有难度,然后手写 Promise 的核心思想:首先就是区分三个状态,一个是等待态(pending,还没成功),然后是成功态(fulfilled/result 状态),然后失败的就是拒绝态(rejected 状态)。

第二个关键点:Promise 的状态变了还能改吗?Promise 的状态一旦变了是改不了的,一旦状态确定就无法修改。

那如果 Promise 处于 pending 状态时,异步结果还没回来,Promise 怎么处理后续的 then/catch 这些回调任务?还有 Promise 内置的异步结构,这个我不太清楚,你可以给我解释一下。


核心问题解释:Promise 处理异步回调的底层逻辑

1. 回调队列的本质:“先存后执行”

当 Promise 还处于pending状态(异步结果没回来)时,调用then/catch添加的回调函数,不会立即执行,而是会被 Promise 内部收集起来,存入一个 “回调队列”(可以理解为待执行的任务清单)。

只有当 Promise 的状态从pending变为fulfilledrejected时,才会按照添加顺序,依次执行队列里对应的回调函数。

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('同步代码先执行'); // 先输出:同步代码先执行

总结

  1. Promise 状态规则:只有pending能转为fulfilled/rejected,状态一旦变更就永久不可修改。
  2. pending 状态的回调处理:then/catch 的回调会被存入内部队列,等待状态变更后批量执行。
  3. 异步核心:Promise 回调属于微任务,会在同步代码执行完、宏任务执行前执行,这是其内置异步结构的关键。