这三个概念可以说是JS面试的"必考题",也是很多人从入门到放弃的拦路虎。今天用最通俗的方式聊聊我的理解。
前言
说实话,刚开始学JavaScript的时候,这三个概念真的让我头大。每次觉得自己懂了,过几天又忘了,面试的时候一紧张就乱说。
后来我把《JavaScript高级程序设计》(俗称红宝书)和《你不知道的JavaScript》(YDKJS)这两本书反复啃了几遍,终于算是把这块硬骨头啃下来了。今天把我的理解分享出来,希望能帮到同样困惑的小伙伴们。
这三个东西看似独立,其实都指向JavaScript的一个核心设计哲学------关系。原型链是对象之间的关系,闭包是函数和作用域的关系,this是函数和调用者的关系。理解了这一点,很多东西就通了。
一、原型链:找不到就往上问
1.1 先抛个结论
原型链就是JavaScript的"继承"机制------对象自己没有某个属性时,会沿着一条链往上找,找到就返回,找不到就继续找,直到null为止。
就这么简单。但很多人(包括以前的我)容易把它想复杂了。
1.2 三个角色要分清
先看个关系:
javascript
构造函数 Person
│
│ new
▼
实例对象 ──→ Person.prototype ──→ Object.prototype ──→ null
这里面有三个角色:
- 构造函数 :就是那个
function Person() {},用来生产对象的"工厂" - 原型对象 :
Person.prototype,所有实例共享的属性和方法都放这儿 - 实例对象 :
new Person()出来的那个东西
它们之间的关系是这样的:
javascript
let p = new Person();
p.__proto__ === Person.prototype // true,实例的__proto__指向原型
Person.prototype.constructor === Person // true,原型的constructor指向构造函数
Person.prototype.__proto__ === Object.prototype // true,原型也是对象,所以它也有原型
Object.prototype.__proto__ === null // true,到头了
我当时理解这个关系的时候,画了好多遍图,最后发现记住一句话就行:实例通过__proto__找到原型,原型通过constructor找到构造函数,原型自己也是对象,所以它还有原型,一路往上直到null。
1.3 属性查找过程
当你访问p.name的时候,JS引擎会这么干:
- 先在
p自己身上找 → 有就返回 - 没找到?去
p.__proto__(也就是Person.prototype)上找 - 还没有?去
Person.prototype.__proto__(也就是Object.prototype)上找 - 还没有?到
null了,返回undefined
这个过程就是原型链查找 。红宝书里讲得很详细,YDKJS则强调了一个点:原型链的本质是"委托",不是"复制"。对象A没有某个属性,它不是把属性复制过来,而是"委托"给原型去查找。
1.4 一个经典的坑
这个坑我踩过好几次,分享给大家:
javascript
function Person() {}
Person.prototype.friends = ['Alice', 'Bob'];
let a = new Person();
let b = new Person();
a.friends.push('Charlie');
console.log(b.friends); // ['Alice', 'Bob', 'Charlie'] 😱
卧槽,b的friends怎么也被改了?
原因很简单:引用类型放在原型上,所有实例共享同一个引用。你改的不是a的friends,而是原型上的friends,所以b也受影响。
解决方案:引用类型属性在构造函数里初始化,别放原型上。
javascript
function Person() {
this.friends = ['Alice', 'Bob']; // 每个实例都有自己的friends
}
1.5 红宝书 vs YDKJS
这两本书对原型链的侧重点不太一样:
| 视角 | 红宝书 | YDKJS |
|---|---|---|
| 重点 | 继承模式(原型链继承、组合继承等) | 原型链的本质是委托 |
| 核心观点 | 通过prototype实现对象间的关系 | [[Get]]操作沿原型链查找,是行为委托机制 |
| 关键提醒 | 原型上的引用类型会被所有实例共享 | Object.create()创建干净的原型关联 |
我个人觉得YDKJS的视角更"本质",红宝书更"实用"。两本结合起来看效果最好。
二、闭包:函数带着出生证明出门
2.1 一句话定义
闭包 = 函数 + 它能记住的外部变量。
函数被定义的时候,会"打包"周围的作用域。即使外部函数已经执行完毕,内部函数仍然能访问那些变量。
2.2 词法作用域是前提
要理解闭包,先得理解词法作用域。
简单说就是:函数的作用域在写代码的时候就确定了,不是在调用的时候确定的。
javascript
function outer() {
let name = 'JavaScript';
function inner() {
console.log(name); // inner在定义时就知道自己能访问name
}
return inner;
}
let fn = outer();
fn(); // 'JavaScript' ------ outer已经执行完了,但name还在!
这就是闭包。inner函数"记住"了它出生时的环境。
我当时学这块的时候,最大的困惑是:outer都执行完了,name变量不应该被回收吗?
答案是:不会,因为inner还引用着name,垃圾回收器不会回收被引用的变量。
这就是闭包存在的根本原因。
2.3 闭包的实际用途
闭包不是什么高深的东西,我们每天都在用。
用途一:数据私有化
javascript
function createCounter() {
let count = 0; // 外部拿不到这个count
return {
increment() { return ++count; },
decrement() { return --count; },
getCount() { return count; }
};
}
let counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.count; // undefined,拿不到!
这个模式在模块化编程里特别常见,把数据"藏"起来,只暴露操作方法。
用途二:函数工厂(柯里化)
javascript
function multiply(x) {
return function(y) {
return x * y;
};
}
let double = multiply(2);
let triple = multiply(3);
double(5); // 10
triple(5); // 15
每次调用multiply都会创建一个新的闭包,各自记住自己的x。
用途三:事件处理
javascript
function setupButton(label) {
let clicked = false;
document.getElementById('btn').addEventListener('click', function() {
if (!clicked) {
console.log(label + '被点击了');
clicked = true;
}
});
}
事件处理函数通过闭包记住了label和clicked。
2.4 经典的循环陷阱
这个题我面试被问过好几次:
javascript
// ❌ 错误写法
for (var i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
// 输出:4, 4, 4(不是1, 2, 3!)
为啥是三个4?因为var没有块级作用域,三个回调函数共享同一个i,循环结束时i = 4。
解决方案:
javascript
// ✅ 方案1:用let(推荐)
for (let i = 1; i <= 3; i++) {
setTimeout(function() {
console.log(i);
}, 1000);
}
// 输出:1, 2, 3
// ✅ 方案2:IIFE创建新作用域
for (var i = 1; i <= 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 1000);
})(i);
}
ES6之后用let就完事了,简单粗暴。
2.5 红宝书 vs YDKJS
| 视角 | 红宝书 | YDKJS |
|---|---|---|
| 定义 | 有权访问另一个函数作用域中变量的函数 | 函数在定义时的词法环境组合 |
| 重点 | 闭包的用法(模块模式、私有变量) | 闭包的本质是作用域气泡不会被GC回收 |
| 关键提醒 | 闭包可能导致内存泄漏,要及时解除引用 | 循环中的闭包陷阱源于对作用域理解不足 |
YDKJS对闭包本质的挖掘更深,红宝书更注重实践。
三、this指向:谁叫我,我就是谁
3.1 核心原则
this不是写代码的时候确定的,是调用的时候确定的。谁调用这个函数,this就指向谁。
这句话我背了好久,但真正理解是在写了大量代码之后。
3.2 四条绑定规则
YDKJS把this的绑定规则总结为四条,按优先级从低到高:
规则一:默认绑定(最低优先级)
javascript
function sayThis() {
console.log(this);
}
sayThis(); // 浏览器中是window,严格模式下是undefined
没有任何修饰的函数调用,this指向全局对象(非严格模式)或undefined(严格模式)。
规则二:隐式绑定
javascript
let obj = {
name: 'JS',
say() {
console.log(this.name);
}
};
obj.say(); // 'JS' ------ this指向obj
通过对象调用函数(obj.fn()),this指向那个对象。
⚠️ 但这里有个大坑:隐式丢失
javascript
let fn = obj.say;
fn(); // undefined ------ 函数被"单独拿出来了",变成默认绑定!
我以前经常在这里翻车。把方法赋值给变量后,它就不再是"obj的方法"了,就是一个普通函数,this丢失了。
规则三:显式绑定(call/apply/bind)
javascript
function greet() {
console.log(this.name);
}
let person = { name: 'Alice' };
greet.call(person); // 'Alice'
greet.apply(person); // 'Alice'
let bound = greet.bind(person);
bound(); // 'Alice'
手动指定this的指向。call和apply立即执行,bind返回新函数。
三者的区别:
| 方法 | 参数形式 | 是否立即执行 |
|---|---|---|
| call | fn.call(thisArg, arg1, arg2) |
✅ 立即执行 |
| apply | fn.apply(thisArg, [arg1, arg2]) |
✅ 立即执行 |
| bind | fn.bind(thisArg, arg1) |
❌ 返回新函数 |
规则四:new绑定(最高优先级)
javascript
function Person(name) {
this.name = name;
}
let p = new Person('Bob');
console.log(p.name); // 'Bob'
使用new调用函数时,this指向新创建的对象。
new到底做了什么?
- 创建一个空对象
{} - 把这个空对象的
[[Prototype]]指向构造函数的prototype - 把
this绑定到这个空对象 - 执行构造函数里的代码
- 如果构造函数返回对象就用那个对象,否则返回新创建的对象
3.3 优先级总结
arduino
new绑定 > 显式绑定 > 隐式绑定 > 默认绑定
记住这个顺序,面试的时候能快速判断this指向。
3.4 箭头函数是个特例
箭头函数没有自己的this,它的this继承自定义时 外层作用域的this。
javascript
let obj = {
name: 'JS',
say: () => {
console.log(this.name); // undefined
},
sayLater() {
setTimeout(() => {
console.log(this.name); // 'JS'
}, 100);
}
};
obj.say(); // undefined ------ 箭头函数的this是外层的this(window)
obj.sayLater(); // 'JS' ------ 箭头函数继承了sayLater的this
关键区别:
- 普通函数:
this取决于怎么调用 - 箭头函数:
this取决于在哪定义
所以箭头函数特别适合用在回调里,不用再担心this丢失的问题。
3.5 this陷阱速查表
| 场景 | this指向 |
|---|---|
obj.fn() |
obj |
fn() |
全局 / undefined(严格模式) |
fn.call(obj) |
obj |
fn.bind(obj)() |
obj |
new Fn() |
新创建的对象 |
setTimeout(fn) |
全局 / undefined(严格模式) |
() => {} |
定义时外层的this |
| 事件回调 | 绑定事件的DOM元素 |
四、三者的内在联系
写到这儿,不知道大家有没有发现,这三个概念其实都指向JavaScript的一个核心设计------关系的建立与查找。
| 概念 | 核心问题 | 查找方向 | 确定时机 |
|---|---|---|---|
| 原型链 | 属性在哪儿? | 当前对象 → 原型 → ... | 定义时 |
| 闭包 | 变量能访问吗? | 当前作用域 → 外层作用域 → ... | 定义时 |
| this | 函数为谁服务? | 调用方式决定 | 调用时 |
原型链和闭包都是定义时确定 的,this是调用时确定的。理解了这一点,很多问题就豁然开朗了。
五、来道面试题练练手
这是一道经典的综合题,把原型链、变量提升、this都考到了:
javascript
function Foo() {
getName = function() { console.log(1); };
return this;
}
Foo.getName = function() { console.log(2); };
Foo.prototype.getName = function() { console.log(3); };
var getName = function() { console.log(4); };
function getName() { console.log(5); }
Foo.getName(); // ?
getName(); // ?
Foo().getName(); // ?
getName(); // ?
new Foo.getName(); // ?
new Foo().getName(); // ?
答案和解析:
javascript
Foo.getName(); // 2 ------ 静态属性,直接访问
getName(); // 4 ------ 变量提升后,函数声明被var赋值覆盖
Foo().getName(); // 1 ------ Foo()没有new,this指向window,修改了全局getName
getName(); // 1 ------ 被上一步改了
new Foo.getName(); // 2 ------ 先取Foo.getName,再new这个函数
new Foo().getName(); // 3 ------ new Foo()返回实例,沿原型链找到3
这道题的几个关键点:
- 区分静态属性和原型属性 :
Foo.getNamevsFoo.prototype.getName - 变量提升 :
function getName会被var getName = ...覆盖 - this指向 :
Foo()没有new,this指向window - 运算符优先级 :
new Foo.getName()是new (Foo.getName)()
写在最后
这三个概念可以说是JavaScript的基石,理解了它们,面向对象和函数式编程的大门就打开了。
最后送大家三句话:
- 原型链:找不到属性就往上问,一直问到null
- 闭包:函数带着出生证明出门,到哪都记得自己从哪来
- this:谁叫我,我就是谁(箭头函数除外,它出生就认了干爹)
希望这篇分享对你有帮助!有问题欢迎评论区交流
相关书籍推荐:
- 《JavaScript高级程序设计》(红宝书)------ 全面系统
- 《你不知道的JavaScript》(上中卷)------ 深入本质