问题场景
很多前端工程师能写业务,但一旦遇到这三连问就露怯:
- "
__proto__和prototype到底什么关系?instanceof怎么判断的?" - "对象的方法解构出来调用,
this为什么变成了undefined?" - "闭包不是能记住变量吗?为什么线上内存一直涨、页面越来越卡?"
这三者------原型链、this、闭包 ------其实是同一套 JS 核心机制 的三个侧面:对象模型、作用域、引用 。它们不是孤立知识点,而是理解 JS 底层的地基。本文把它们打通来讲,并用真实案例说明闭包内存泄漏怎么排查和修复。
原理深入
1. 原型链:对象如何"继承"与"查找"
每个 JS 对象都有一个隐藏属性 [[Prototype]](通过 __proto__ 访问),指向另一个对象 (或 null)。当你访问 obj.prop 时,JS 引擎的查找顺序是:
text
obj 自身 → 自身没有 → 沿 __proto__ 到原型对象 → 再没有 → 再往上 → 直到 null(返回 undefined)
js
const obj = { a: 1 };
console.log(obj.toString()); // 自身没有 toString,沿原型找到 Object.prototype.toString
console.log(obj.__proto__); // Object.prototype
console.log(obj.__proto__.__proto__); // null(原型链尽头)
构造函数、prototype、实例的关系(这是最核心的图):
js
function Person(name) {
this.name = name; // 构造时给实例赋值
}
Person.prototype.say = function () { return this.name; };
const p = new Person('轩哥');
// 三者关系:
console.log(p.__proto__ === Person.prototype); // true
console.log(Person.prototype.constructor === Person); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(p instanceof Person); // true(沿原型链找到 prototype)
new 到底做了什么?(手写版见下方实战)
- 创建一个新对象,
__proto__指向构造函数的 prototype。 - 以这个对象为
this调用构造函数。 - 如果构造函数返回了对象则用它,否则用创建的对象。
class 只是语法糖,底层仍是原型链:
js
class Animal { speak() { return '...'; } }
class Dog extends Animal { speak() { return '汪'; } }
const d = new Dog();
console.log(d.speak()); // 汪
console.log(Dog.prototype.__proto__ === Animal.prototype); // true(继承链)
2. this:调用时决定,不看定义
this 永远在函数被调用那一刻 才确定,跟"在哪里定义"无关。JS 给 this 定了四条规则(优先级从低到高):
js
// 规则1:普通函数调用 → 严格模式 undefined / 非严格 window(浏览器) 或 global(Node)
function f() { return this; }
console.log(f()); // undefined(严格) 或 window
// 规则2:对象方法调用 → 指向对象
const obj = { name: 'o', getName() { return this.name; } };
console.log(obj.getName()); // 'o'(obj 调用 → this=obj)
// 规则3:显式绑定 → call/apply/bind 指定的对象
function g() { return this; }
console.log(g.call({ x: 1 }).x); // 1
const bound = g.bind({ y: 2 });
console.log(bound().y); // 2
// 规则4:new 调用 → 指向新创建的实例
function P(name) { this.name = name; }
const ins = new P('n');
console.log(ins.name); // 'n'
// 箭头函数:没有自己的 this,继承外层词法作用域的 this
const obj2 = { v: 1, fn: () => { return this; } }; // 箭头函数 this 是外层(window/undefined)
console.log(obj2.fn() === globalThis); // true(不是 obj2)
最高频的坑:方法解构后 this 丢失
js
const counter = {
count: 0,
add() { this.count++; return this; },
};
const { add } = counter; // 解构出 add,脱离对象
add(); // this 变成 undefined(严格)/window → this.count 报错或不对
// 修复方式:
// ① bind 绑定
const addBound = counter.add.bind(counter);
// ② 箭头函数包裹(不保留 this,但调用方语义不同)
const addArrow = () => counter.add();
React 里尤其常见: class 组件的事件回调里 this.handleClick 如果没 bind,this 就丢了;函数组件用 useCallback 则没这问题。
3. 闭包:变量如何"越狱"存活
闭包 = 函数 + 它引用的外层变量。当内层函数引用了外层作用域的变量,即使外层函数已经返回,这个变量依然存活------因为它被闭包引用着,无法被 GC 回收。
js
function counter() {
let count = 0;
return () => ++count; // 闭包:捕获了外层 count
}
const c = counter();
console.log(c()); // 1
console.log(c()); // 2 ------ count 没有消失,被闭包"记住"了
内存模型 :普通局部变量在函数返回时销毁;但闭包引用的变量被提升到堆上,只要闭包仍被引用,变量就存活。
4. 闭包内存泄漏:真实案例与排查(重点)
闭包不是罪,滥用闭包 + 长期持有引用才是泄漏根源。三种典型泄漏场景:
场景 A:全局/长生命周期持有闭包引用
js
// 泄漏:全局数组永远持有闭包,闭包又引用大对象
const listeners = [];
function setup() {
const bigData = new Array(100000).fill('x'); // 大对象
function handler() { console.log(bigData.length); } // 闭包引用 bigData
listeners.push(handler); // 闭包被全局持有 → bigData 永不释放
}
setup(); // 调用一次,bigData 永远占内存
场景 B:事件监听器 + 闭包(最常见)
js
function attach() {
const user = fetchCurrentUser(); // 大对象
document.addEventListener('click', () => {
// 闭包引用 user;只要页面不卸载,这个闭包永远在 → user 永不回收
sendMetric(user);
});
}
attach(); // 每次 attach 都新增一个引用了大对象的监听器,且没移除
场景 C:定时器 + 闭包
js
let timer;
function start() {
const snapshot = new Date(); // 或大对象
timer = setInterval(() => {
console.log(snapshot); // 闭包引用 snapshot
}, 1000);
}
// 如果从不 clearInterval,snapshot 和回调永远存活
排查方法:
- Chrome DevTools → Memory → Heap Snapshot:对比几次操作前后的快照,看内存是否一直涨(创建快照→操作→再快照→对比)。
- Allocation instrumentation(分配时间线):看哪些对象持续分配未被回收。
- 打开 Performance 跑几次场景,看 JS heap 曲线是否持续上升不回落。
- 搜代码里的
addEventListener/setInterval/ 全局数组 push,确认是否有对应的removeEventListener/clearInterval/ 移除引用。
修复:
js
// 事件监听:移除监听 + 不再用大引用
function attachBetter() {
const user = fetchCurrentUser();
function handler() { sendMetric(user); }
document.addEventListener('click', handler);
// 组件卸载/不再需要时:
return () => document.removeEventListener('click', handler);
}
// 定时器:结束时 clearInterval
function startBetter() {
...
const t = setInterval(fn, 1000);
// 适当时候 clearInterval(t)
}
实战代码
手写 new(验证原型链 + this 绑定)
js
function myNew(ctor, ...args) {
// 1. 创建对象,__proto__ 指向 ctor.prototype(原型链)
const obj = Object.create(ctor.prototype);
// 2. 以 obj 为 this 调用构造函数(this 规则4)
const ret = ctor.apply(obj, args);
// 3. 构造函数返回对象则用之,否则用 obj
return (typeof ret === 'object' && ret !== null) ? ret : obj;
}
function Person(name) { this.name = name; }
Person.prototype.say = function () { return this.name; };
const p = myNew(Person, 'x');
console.log(p instanceof Person, p.say()); // true 'x'
eslint 帮你发现 this/闭包问题
bash
# 相关规则:no-this-alias、no-use-before-define;类组件用 bind 提醒
npx eslint src/ --ext .js,.jsx
要点总结
- 原型链 :
__proto__层层向上,属性查找沿链到 null;instanceof沿链找 prototype;class 是语法糖。 - this :调用时决定,四规则
普通调用 < 方法调用 < call/apply/bind < new;箭头函数无 this,继承外层。 - 闭包:函数 + 引用外层变量,变量存活到引用者释放;捕获"引用"不是"值"。
- 内存泄漏三源:全局持有闭包、事件监听+闭包、定时器+闭包 → 都通过"长期引用"让大对象无法 GC。
- 排查:Heap Snapshot 对比、Allocation timeline、看 JS heap 曲线;搜事件/定时器是否有释放。
- 修复:及时 removeEventListener / clearInterval / 解除全局引用。
- 三者统一在"对象模型 + 作用域 + 引用"一套模型下,理解其一可联动理解其余。
一句话:原型链是"对象怎么找属性",this 是"函数执行看谁",闭包是"变量怎么活下来"------它们共享同一套"对象+作用域+引用"的地基,并且共同指向同一个敌人:不当的长期引用(既是闭包存活的机制,也是内存泄漏的根源)。