Skip to content

第 04 章 · 面试题集(JS 核心机制)

本章是面试重灾区,几乎 90% 的初中级前端面试都会问到原型/this/闭包。每道题都务必能"手写出来"。


Q1:什么是闭包?请说出闭包的应用场景

考察点:作用域、内存管理、函数式编程

标准答案: 闭包是指一个函数能够访问其词法作用域之外的变量,即使外层函数已经执行完毕。本质是函数 + 出生时所在的词法环境的组合。

通俗理解: 闭包就像一个"自带小书包"的函数——出门在外(被传到任何地方执行),还能从书包里掏出当初创建它的家里的东西。

代码示例

js
function makeCounter() {
  let count = 0;
  return () => ++count;
}
const c = makeCounter();
c(); // 1
c(); // 2

加分回答

  • 应用场景:私有变量(封装)、防抖节流、柯里化、模块模式(IIFE)、React Hooks(useState 内部就是闭包)
  • 闭包不是内存泄漏,但保留对外层变量的引用会导致 GC 无法回收
  • 现代 V8 引擎做了优化:闭包只保留实际被引用的变量

追问

  • 循环里 var i + setTimeout 为什么打印同一个值?怎么修?(答:let / IIFE / 闭包参数)
  • React 的"陈旧闭包问题"是什么?(答:函数组件每次渲染都生成新闭包,事件回调里的 state 是当时那次渲染的值)

Q2:this 的指向有哪几种情况?

考察点:this 绑定规则、call/apply/bind

标准答案: 按优先级从低到高,this 有 5 种绑定方式:

  1. 默认绑定:直接调用 foo(),严格模式下是 undefined,非严格是 window/global
  2. 隐式绑定obj.foo(),this 指向 obj
  3. 显式绑定call/apply/bind,this 指向传入的对象
  4. new 绑定new Foo(),this 指向新建的实例
  5. 箭头函数:没有自己的 this,沿用外层作用域的 this,且不可被 call/bind 改变

优先级:箭头函数 > new > 显式 > 隐式 > 默认

通俗理解: this 就是问"现在谁在拿着这把锤子"。同一把锤子,张三拿就帮张三敲钉,李四拿就帮李四敲。但箭头函数是"刻字版锤子"——出厂就刻死了主人是谁,谁拿都没用。

代码示例

js
const obj = {
  name: 'Tom',
  say() { console.log(this.name); },
  sayArrow: () => console.log(this.name)
};
obj.say();              // 'Tom'(隐式绑定)
obj.sayArrow();         // undefined(箭头函数,this = 外层 = window)
const fn = obj.say;
fn();                   // undefined(隐式丢失,变成默认绑定)
fn.call(obj);           // 'Tom'(显式绑定)

加分回答

  • DOM 事件回调中,addEventListener 里的 this 默认指向触发事件的元素
  • 类的方法没有自动绑定 this,所以 React class 组件需要 this.handleClick = this.handleClick.bind(this) 或用箭头函数

追问

  • 手写一个 bind 函数?
  • setTimeout(obj.fn, 1000)this 是什么?(答:window,因为传入的是函数引用,调用时是默认绑定)

Q3:手写 call / apply / bind

考察点:this 绑定原理、Symbol、闭包

标准答案 + 代码

js
// myCall
Function.prototype.myCall = function (context, ...args) {
  context = context || globalThis;
  const fnKey = Symbol('fn');
  context[fnKey] = this;        // this 是被调用的函数
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

// myApply
Function.prototype.myApply = function (context, args = []) {
  context = context || globalThis;
  const fnKey = Symbol('fn');
  context[fnKey] = this;
  const result = context[fnKey](...args);
  delete context[fnKey];
  return result;
};

// myBind
Function.prototype.myBind = function (context, ...args1) {
  const fn = this;
  return function (...args2) {
    // 如果作为 new 调用,this 是新对象,应忽略 context
    if (this instanceof arguments.callee) {
      return new fn(...args1, ...args2);
    }
    return fn.apply(context, [...args1, ...args2]);
  };
};

通俗理解call 的本质就是"把函数临时塞到对象上调用一次再删掉",this 自然就指向那个对象了。

加分回答

  • Symbol 作为 key 防止覆盖原对象的同名属性
  • bind 要考虑 new 的情况——如果 bind 后的函数被 new 调用,this 应是新实例,不是绑定的 context

追问

  • bind 多次能改变 this 吗?(答:不能,只有第一次有效)
  • call/apply 性能差异?(答:现代引擎几乎没差,曾经 call 略快,因为 apply 要展开数组)

Q4:什么是原型链?

考察点:JS 继承机制、__proto__prototype

标准答案: 每个对象都有一个 __proto__ 属性指向它的原型对象,原型对象本身又是对象,也有自己的 __proto__,这样一层层向上形成的链条就是原型链。访问对象属性时,先查自身,没有就沿着原型链向上找,直到 Object.prototype.__proto__ === null 为止。

通俗理解: 原型链就像家族族谱。你想找一样东西,先翻自己房间,没有就去问爸爸(__proto__),爸爸没有就问爷爷,爷爷没有就问祖宗,到了 null 这就是顶了。

代码示例

js
function Person(name) { this.name = name; }
Person.prototype.sayHi = function () { console.log('hi ' + this.name); };

const p = new Person('Tom');
p.sayHi();                                    // hi Tom
p.__proto__ === Person.prototype;             // true
Person.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null;          // true

加分回答

  • prototype 只有函数有;__proto__ 所有对象都有
  • ES6 class 是原型链的语法糖
  • 推荐用 Object.getPrototypeOf(obj) 替代 __proto__(后者非标准)
  • instanceof 的实现就是基于原型链查找

追问

  • 手写 instanceof
  • Function.__proto__ === Function.prototype 是 true 还是 false?(答:true,函数也是对象,而 Function 是构造函数)

Q5:手写 new 操作符

考察点:构造函数原理、原型继承

代码

js
function myNew(Constructor, ...args) {
  // 1. 创建空对象,原型链指向构造函数 prototype
  const obj = Object.create(Constructor.prototype);
  // 2. 执行构造函数,绑定 this 到新对象
  const result = Constructor.apply(obj, args);
  // 3. 如果构造函数返回了对象,则用它;否则返回新对象
  return (typeof result === 'object' && result !== null) ? result : obj;
}

function Person(name) { this.name = name; }
const tom = myNew(Person, 'Tom');
console.log(tom.name); // Tom

通俗理解new 干了 4 件事——搭骨架、连族谱、注入魂、看遗嘱:搭一个空对象、连上原型链、把构造函数当魂注进去、最后看构造函数有没有自己返回对象(有就用,没有就用新建的)。

加分回答

  • 如果构造函数返回的是基本类型(数字、字符串等),new 会忽略它,仍返回 this
  • 如果构造函数显式返回 null,会被忽略(typeof null === 'object' 但加了 result !== null

追问

  • 箭头函数能 new 吗?(答:不能,因为箭头函数没有自己的 this 和 prototype)

Q6:var、let、const 的区别?什么是变量提升?

考察点:作用域、TDZ

标准答案

维度varletconst
作用域函数作用域块级作用域块级作用域
变量提升✅ 提升并初始化为 undefined✅ 提升但 TDZ✅ 提升但 TDZ
重复声明✅ 允许❌ 报错❌ 报错
重新赋值❌(引用类型内部可改)
顶层声明挂 window

变量提升:JS 引擎在执行前先扫一遍代码,把 varfunction 声明"提"到作用域顶部。

通俗理解var 像不带门的房间,整个楼层都能看见;let/const 像带门的小房间,出门就看不见了。变量提升就像演唱会前先给所有歌手挂工牌——var 挂的是"待登记",let/const 挂的是"禁止入内"。

代码示例

js
console.log(a); // undefined
console.log(b); // ReferenceError(TDZ)

var a = 1;
let b = 2;

追问

  • TDZ(暂时性死区)是什么?(答:let/const 声明前的区域,访问会报错)
  • 为什么要引入 let/const?(答:解决 var 的作用域穿透、循环闭包、全局污染问题)

Q7:循环里 var + setTimeout 为什么打印同一个值?

考察点:闭包、作用域、事件循环

标准答案var 是函数作用域,3 次循环共用同一个 i。当 setTimeout 回调执行时(同步代码已经跑完),i 已经是 3,所以打印三个 3。

代码示例

js
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出:3 3 3

通俗理解: 就像三个人都拿了同一张银行卡,等他们去刷卡(setTimeout 回调)的时候,卡里的余额(i)早被花光改成 3 了。

解决方案

js
// 方案 1:let 块级作用域
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
} // 0 1 2

// 方案 2:IIFE 闭包
for (var i = 0; i < 3; i++) {
  (function (i) {
    setTimeout(() => console.log(i), 0);
  })(i);
}

// 方案 3:setTimeout 第三个参数
for (var i = 0; i < 3; i++) {
  setTimeout(console.log, 0, i);
}

加分回答

  • let 在循环中每次迭代都会创建一个新的绑定,这是 ES6 规范规定的
  • 这道题考察了三个知识点:变量提升、闭包、事件循环

追问

  • 为什么 let 在循环里能解决?引擎做了什么?(答:每次循环创建新的词法环境)

Q8:箭头函数和普通函数的区别?

考察点:箭头函数特性

标准答案

特性普通函数箭头函数
this调用时决定定义时决定(外层作用域)
arguments✅ 有❌ 没有,用 ...args
prototype✅ 有❌ 没有
能否 new
能否 call/bind 改 this❌(call 能传参但 this 改不了)
能否做生成器(function*
能否做 method 短写是不同的语法不能用作有 this 的方法

代码示例

js
const obj = {
  name: 'Tom',
  say() { console.log(this.name); },           // Tom
  sayArrow: () => console.log(this.name)        // undefined
};

通俗理解: 普通函数像变形金刚——能根据使用者改变形态(this);箭头函数像出厂前刻好字的工具——主人是谁定死了。

追问

  • 哪些场景不该用箭头函数?(答:对象的方法、事件处理需要拿到 element 的、需要 new 的)
  • 哪些场景最适合用箭头函数?(答:回调、map/filter/reduce、需要保持外层 this 的场景)

Q9:ES6 class 是真正的类吗?和 ES5 构造函数有什么区别?

考察点:原型链、class 语法糖

标准答案: ES6 class 本质上是构造函数 + 原型链的语法糖,并不是 Java 那种"真正的类"。但它做了一些额外约束:

维度ES5 构造函数ES6 class
调用方式可直接调用 Person()必须 new,否则报错
提升函数提升不会被提升(TDZ)
默认严格模式
方法可枚举✅(绑在 prototype 上)❌(不可枚举)
静态方法函数属性static 关键字
继承手动改 prototype + 借用构造函数extends + super

代码示例

js
class Animal {
  constructor(name) { this.name = name; }
  eat() { console.log(this.name + ' eating'); }
}

// 等价于
function Animal(name) { this.name = name; }
Animal.prototype.eat = function () { /* ... */ };

typeof Animal; // 'function'

追问

  • extends 的实现原理?(答:Object.setPrototypeOf(子.prototype, 父.prototype) + Object.setPrototypeOf(子, 父),分别处理实例继承和静态继承)
  • super 怎么实现的?

Q10:JS 的执行上下文和调用栈是什么?

考察点:JS 执行机制、内存模型

标准答案执行上下文(Execution Context) 是 JS 引擎执行代码前创建的"运行环境",包含:

  • 变量环境(VariableEnvironment):var 声明、function 声明
  • 词法环境(LexicalEnvironment):let/const、形参
  • this 绑定
  • 外层引用(OuterEnvironmentReference,构成作用域链)

执行上下文按"先进后出"压入调用栈(Call Stack)

  • 全局执行上下文(GEC)最先入栈,全程只有一个
  • 每调用一个函数,就创建一个函数执行上下文(FEC)压入栈
  • 函数执行完弹出栈

通俗理解: 执行上下文就是 JS 给每段代码发的"运行许可证",调用栈就是这些许可证的"待办事项盒子"——后放进去的先处理。

代码示例

js
function foo() {
  console.log('foo');
  bar();
}
function bar() {
  console.log('bar');
}
foo();

// 调用栈变化:
// [GEC] → [GEC, foo] → [GEC, foo, bar] → [GEC, foo] → [GEC]

加分回答

  • 栈溢出(Stack Overflow):递归没出口时,栈一直压入会报 Maximum call stack size exceeded
  • 一个进程一个调用栈;Worker 是独立的线程,独立栈
  • 异步任务不进入调用栈,它们走事件循环(见第 05 章)

追问

  • 调用栈和事件循环的关系?
  • 浏览器的栈大小是多少?(答:通常 1MB 左右,因引擎和系统而异)

Q11(加餐):什么是函数柯里化?

考察点:闭包、函数式编程

标准答案: 柯里化(Currying)是把"接收多个参数的函数"转换成"接收单个参数、返回新函数"的技术。

代码示例

js
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    return (...args2) => curried.apply(this, [...args, ...args2]);
  };
}

const add = (a, b, c) => a + b + c;
const cAdd = curry(add);
cAdd(1)(2)(3);   // 6
cAdd(1, 2)(3);   // 6
cAdd(1, 2, 3);   // 6

通俗理解: 柯里化就像点奶茶——一次说不全,分次告诉店员要"中杯-去冰-三分糖",最后给你做出来。

应用场景

  • 参数复用:const log = curry(...); const error = log('error', ...)
  • 延迟执行
  • 函数组合(compose / pipe)

追问

  • 偏函数(partial application)和柯里化的区别?(答:偏函数是固定一部分参数生成新函数;柯里化是必然每次只接受一个参数)