主题
第 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 种绑定方式:
- 默认绑定:直接调用
foo(),严格模式下是undefined,非严格是window/global - 隐式绑定:
obj.foo(),this 指向obj - 显式绑定:
call/apply/bind,this 指向传入的对象 - new 绑定:
new Foo(),this 指向新建的实例 - 箭头函数:没有自己的 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
标准答案:
| 维度 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | ✅ 提升并初始化为 undefined | ✅ 提升但 TDZ | ✅ 提升但 TDZ |
| 重复声明 | ✅ 允许 | ❌ 报错 | ❌ 报错 |
| 重新赋值 | ✅ | ✅ | ❌(引用类型内部可改) |
| 顶层声明挂 window | ✅ | ❌ | ❌ |
变量提升:JS 引擎在执行前先扫一遍代码,把 var 和 function 声明"提"到作用域顶部。
通俗理解: 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)和柯里化的区别?(答:偏函数是固定一部分参数生成新函数;柯里化是必然每次只接受一个参数)