主题
第 04 章 · JavaScript 核心机制(原型 / this / 闭包 / 作用域)
一句话开篇:本章带你彻底搞懂"为什么 JS 这么诡异"——原型、this、闭包、作用域,这四块是面试的"灵魂四问",也是你写出靠谱代码的内功。
0. 生活类比(先建立直觉)
把 JS 这门语言想象成一栋"宜家公寓楼":
| JS 概念 | 生活类比 |
|---|---|
| 对象 | 一个个房间 |
| 原型链 | 你找东西时,先翻自己房间,没有就去隔壁"祖宗房间"翻,再没有就去更上一级,直到顶楼为止 |
| 作用域 | 每个房间都有自己的"小账本"(局部变量),还能看到客厅的"大账本"(全局变量) |
| 作用域链 | 找一个名字,先查本房间,没有就出门往外一层层找 |
| 执行上下文 | 你每次进入一个房间时,房管员(JS 引擎)给你发的一份"房间手册" |
| this | 现在"是谁在使用这个工具"——同一把扳手,张三拿就帮张三修,李四拿就帮李四修 |
| 闭包 | 一个"自带小书包"的函数,离开了原来的房间也能从书包里掏出当时的东西 |
记住一句话:JS 里所有"奇怪"的现象,几乎都能用"作用域 + 原型链 + this"这三把刷子解释清楚。
1. 执行上下文与变量提升
1.1 什么是执行上下文(Execution Context)
JS 引擎在执行你的代码前,会先"打草稿"。这份草稿就叫执行上下文,里面记录着:
- 变量环境:声明了哪些变量、函数
- 词法环境:let/const 的块级作用域
- this 指向:当前的 this 是谁
- 作用域链:能看到哪些外层变量
text
全局执行上下文 (GEC)
├── 变量环境: { a: undefined, foo: <function> }
├── 词法环境: { b: <uninitialized> }
├── this: window
└── 作用域链: [ 全局 ]
进入 foo() 时压入栈:
函数执行上下文 (FEC for foo)
├── 变量环境: { x: undefined, arguments: {...} }
├── this: ???
└── 作用域链: [ foo, 全局 ]1.2 调用栈(Call Stack)
执行上下文按"先进后出"压入一个栈,叫调用栈。
text
push foo() → push bar() → bar 执行完 pop → foo 执行完 pop
┌──────┐ ┌──────┐ ┌──────┐
│ bar │ │ bar │ │ │
├──────┤ ├──────┤ ├──────┤
│ foo │ │ foo │ │ foo │
├──────┤ ├──────┤ ├──────┤
│ GEC │ │ GEC │ │ GEC │
└──────┘ └──────┘ └──────┘1.3 变量提升(Hoisting)
JS 引擎"打草稿"时,会把 var 声明和 function 声明提升到作用域顶部。
js
console.log(a); // undefined(不是报错!)
console.log(foo); // [Function: foo]
console.log(b); // ReferenceError(暂时性死区 TDZ)
var a = 1;
function foo() {}
let b = 2;等价于:
js
var a;
function foo() {}
console.log(a); // undefined
console.log(foo);
console.log(b); // 此时 b 在 TDZ 里
let b = 2;| 声明方式 | 是否提升 | 提升后初始值 | 是否有 TDZ |
|---|---|---|---|
var | ✅ | undefined | ❌ |
let | ✅(但不可访问) | 未初始化 | ✅ |
const | ✅(但不可访问) | 未初始化 | ✅ |
function | ✅(连函数体一起) | 函数本身 | ❌ |
class | ✅(但不可访问) | 未初始化 | ✅ |
💡 函数提升优先级高于变量提升。同名时函数会先定义,但后面
var = ...的赋值会覆盖函数。
2. 作用域与作用域链
2.1 三种作用域
text
┌─────────────────────────────────────┐
│ 全局作用域 (Global Scope) │
│ ┌─────────────────────────────────┐ │
│ │ 函数作用域 (Function Scope) │ │
│ │ ┌───────────────────────────┐ │ │
│ │ │ 块级作用域 (Block Scope) │ │ │
│ │ │ let / const / { ... } │ │ │
│ │ └───────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘| 作用域 | 触发方式 | 例子 |
|---|---|---|
| 全局 | 直接写在文件最外层 | var x = 1 |
| 函数 | 函数内部 | function foo() { var y } |
| 块级 | {} + let/const | if (true) { let z } |
2.2 作用域链(Scope Chain)
查找变量时,从当前作用域向外一层层往上找,找到为止;找到全局还没有,报 ReferenceError。
js
const a = 1;
function outer() {
const b = 2;
function inner() {
const c = 3;
console.log(a, b, c); // 1 2 3
// 查找顺序:inner → outer → 全局
}
inner();
}
outer();重点:作用域是"词法的"(Lexical Scope),由代码"写在哪儿"决定,而不是"在哪儿被调用"。
js
const x = 'global';
function foo() {
console.log(x); // 'global',因为 foo 写在全局作用域
}
function bar() {
const x = 'bar';
foo(); // 还是打印 'global',不是 'bar'
}
bar();3. 闭包(Closure)
3.1 通俗理解
闭包 = 函数 + 它出生时所在的环境。
类比:你出差去了外地(函数被传出去执行了),但你包里还揣着家里的钥匙(外层变量),这个"小书包"就是闭包。
3.2 经典例子
js
function makeCounter() {
let count = 0;
return function () {
return ++count;
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3makeCounter 已经执行完了,但内部的 count 因为被返回的函数"引用着",不会被回收——这就是闭包。
3.3 闭包的用途
| 用途 | 例子 |
|---|---|
| 私有变量 | 用闭包封装 count,外部不能直接修改 |
| 函数柯里化 | add(1)(2)(3) |
| 防抖节流 | 用闭包记住 timer 引用 |
| 模块模式 | IIFE + 闭包实现私有方法 |
| React Hooks | useState 内部就是闭包实现的 |
3.4 闭包内存泄漏?
闭包本身不会内存泄漏,但用得不当会导致变量一直被引用、无法回收:
js
function bad() {
const huge = new Array(1000000).fill('🍔');
return function () {
console.log('hi'); // 没用到 huge,但 huge 仍可能被保留
};
}
const fn = bad();
// huge 可能一直在内存里💡 现代 V8 引擎做了优化:只保留闭包真正引用的变量,但你不能完全依赖它。
4. this 指向
4.1 一句话规则
this 指向"调用它的那个对象"——和函数定义在哪、写在哪都没关系,只看"谁调用它"。
4.2 五种绑定规则(按优先级从低到高)
| 规则 | 触发条件 | this 指向 |
|---|---|---|
| 默认绑定 | 直接 foo() | 严格模式 undefined,否则 window |
| 隐式绑定 | obj.foo() | obj |
| 显式绑定 | foo.call(obj) / foo.apply(obj) / foo.bind(obj)() | obj |
| new 绑定 | new Foo() | 新建的实例对象 |
| 箭头函数 | () => {} | 外层作用域的 this(不可改变) |
4.3 例子合集
js
// 1. 默认绑定
function foo() { console.log(this); }
foo(); // window(严格模式下 undefined)
// 2. 隐式绑定
const obj = { name: 'Tom', say() { console.log(this.name); } };
obj.say(); // Tom
// 隐式丢失(坑!)
const fn = obj.say;
fn(); // undefined(this 变成了 window)
// 3. 显式绑定
function greet() { console.log(this.name); }
greet.call({ name: 'Jerry' }); // Jerry
// 4. new 绑定
function Person(name) { this.name = name; }
const p = new Person('Alice');
console.log(p.name); // Alice
// 5. 箭头函数
const obj2 = {
name: 'Bob',
say: () => console.log(this.name) // undefined,箭头函数 this 是外层(这里是 window)
};
obj2.say();4.4 call / apply / bind 三兄弟
| 方法 | 立即执行 | 参数形式 | 返回值 |
|---|---|---|---|
call | ✅ | 一个一个传:fn.call(obj, a, b) | 函数返回值 |
apply | ✅ | 数组传:fn.apply(obj, [a, b]) | 函数返回值 |
bind | ❌ | 一个一个传:fn.bind(obj, a) | 新函数 |
js
function add(a, b) { return this.base + a + b; }
add.call({ base: 10 }, 1, 2); // 13
add.apply({ base: 10 }, [1, 2]); // 13
const bound = add.bind({ base: 10 }, 1);
bound(2); // 135. 原型与原型链
5.1 类比:找东西的"辈分关系"
text
你(实例对象)
│ __proto__
↓
你爸(构造函数 prototype)
│ __proto__
↓
你爷爷(Object.prototype)
│ __proto__
↓
null(祖宗到顶了)找一个属性时,先翻自己口袋,没有就翻爸的口袋,再没有就翻爷爷的,直到 null。
5.2 关键概念
| 名词 | 解释 | 谁有 |
|---|---|---|
prototype | 显式原型,只有函数才有 | 函数 |
__proto__ | 隐式原型,所有对象都有 | 对象 |
constructor | 指回构造函数本身 | 原型对象上 |
核心公式:
js
实例.__proto__ === 构造函数.prototype
构造函数.prototype.constructor === 构造函数5.3 原型链示意
text
function Person(name) { this.name = name }
Person.prototype.sayHi = function() {}
const tom = new Person('Tom')
tom Person.prototype Object.prototype null
│ __proto__ │ __proto__ │ __proto__ ▲
└─────────────────► ├ constructor: Person ──► ├ toString │
└ sayHi └ hasOwnProperty │
│
────────┘5.4 验证
js
function Person(name) { this.name = name; }
Person.prototype.sayHi = function () { console.log('hi, ' + this.name); };
const tom = new Person('Tom');
tom.sayHi(); // hi, Tom
tom.__proto__ === Person.prototype; // true
Person.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true5.5 ES6 class 只是语法糖
js
class Animal {
constructor(name) { this.name = name; }
eat() { console.log(this.name + ' eating'); }
}
// 等价于
function Animal(name) { this.name = name; }
Animal.prototype.eat = function () { console.log(this.name + ' eating'); };5.6 instanceof 原理
a instanceof B 的本质:沿着 a 的原型链一路向上找,看能不能找到 B.prototype。
js
function myInstanceof(left, right) {
let proto = Object.getPrototypeOf(left);
while (proto) {
if (proto === right.prototype) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}6. new 操作符做了什么
js
function myNew(Constructor, ...args) {
// 1. 创建一个空对象,原型指向 Constructor.prototype
const obj = Object.create(Constructor.prototype);
// 2. 把 this 绑到这个新对象上,执行构造函数
const result = Constructor.apply(obj, args);
// 3. 如果构造函数返回的是对象,则用它,否则用新对象
return (typeof result === 'object' && result !== null) ? result : obj;
}7. ⚠️ 易踩的坑
- 坑 1:箭头函数的 this 不能被 call/bind 改变。 箭头函数没有自己的
this,看外层。 - 坑 2:
var在 for 循环里只有一个!for (var i = 0; i < 3; i++) setTimeout(() => console.log(i))打印三个3。换成let就正常了,因为let每次循环创建一个新作用域。 - 坑 3:构造函数返回了对象,
new出来的就是那个对象,不是 this。 - 坑 4:原型链上找不到属性返回
undefined,不是报错。 - 坑 5:修改
prototype会影响所有实例——包括已经创建的(如果是新增方法)。 - 坑 6:bind 多次绑定,只有第一次生效。
fn.bind(a).bind(b)的 this 还是a。 - 坑 7:
call(null)在非严格模式下 this 会变成window。 - 坑 8:
obj.fn解构赋值后调用,this 丢失。const { fn } = obj; fn();
8. 一句话总结
JS 的核心 = 执行上下文告诉你"现在在哪",作用域链告诉你"能看见什么",this 告诉你"谁在调用我",原型链告诉你"找不到属性时去哪儿继续找",闭包让函数带着出生地的环境走天下。
9. 延伸阅读
- 《你不知道的 JavaScript》上卷
- MDN:Inheritance and the prototype chain
- MDN:Closures
- ECMA-262 规范:ECMAScript Language Specification