JavaScript中的this,大概是前端圈子里最让人“又爱又恨”的概念了。爱它,是因为它带来了极大的灵活性,让函数可以随意“借用”;恨它,是因为它像个变色龙,指哪打哪,稍不留神就指向了全局对象window,让你对着undefined发呆。
很多开发者背熟了“谁调用指向谁”的口诀,但遇到复杂场景依然会翻车。今天,我们不妨跳出死记硬背的怪圈,像面试一样,把这个“磨人的小妖精”彻底看透。
核心机制:this的“四象限”法则(附权威出处)
首先,我们要纠正一个直觉上的误区:this的指向,不取决于函数定义在哪里,而完全取决于函数被调用的方式。
如果把JavaScript的执行上下文比作一个舞台,那么this就是舞台上的聚光灯。灯打在哪里,取决于导演(调用者)怎么安排,而不是演员(函数)站在哪。
** 官方出处在哪里?**
这些规则并不是社区总结的“野路子”,而是有着严格的官方定义。在 ECMAScript 规范(ECMA-262)中,有一个核心抽象操作叫做 OrdinaryCallBindThis 以及 ResolveThisBinding。
规范中明确定义了 this 绑定的优先级逻辑,简单来说就是:
- 箭头函数:继承外层作用域的
this(词法作用域)。 new绑定:通过 `` 内部方法创建新对象并绑定this。- 显式绑定:通过 `` 内部方法中的
thisArgument强制指定。 - 隐式/默认绑定:作为兜底规则,依据调用位置决定。
我们可以把这复杂的规范总结为以下四种(优先级由高到低):
1. new绑定(优先级最高)
当你使用new关键字调用函数时,JavaScript引擎会执行一系列操作:创建一个新对象、将原型连接、执行构造函数,并最终将this绑定到这个新创建的对象上。
function Person(name) {
this.name = name; // this 指向新实例
}
const p = new Person('Jack');
2. 显式绑定(call/apply/bind)
这是最直接的手段,你明确告诉JS引擎:“嘿,这个函数执行的时候,this必须是这个对象。”
call:立即执行,参数逐个传入。apply:立即执行,参数以数组形式传入。bind:不立即执行,返回一个this被永久锁定的新函数。
3. 隐式绑定(上下文调用)
当函数作为对象的方法被调用时(如obj.foo()),this指向该对象。这是最符合直觉的,但也是最容易出问题的(后面会讲)。
4. 默认绑定(兜底规则)
如果以上都不是,那么就是独立函数调用。
- 非严格模式:
this指向全局对象(浏览器中是window)。 - 严格模式('use strict'):
this为undefined。
深度解析:bind的“霸道”与call的“临时”
在显式绑定中,bind 是一个非常特殊的存在。很多开发者知道它能改变 this,但往往忽略了它的不可篡改性。
bind 的绑定是“一锤子买卖”。
当你使用 bind 创建了一个新函数后,这个新函数的 this 就被永久锁定了。后续无论你如何使用 call、apply 甚至再次 bind,都无法改变它第一次被绑定的 this 指向。
const obj1 = { name: '张三' };
const obj2 = { name: '李四' };
function sayHi() {
console.log(`你好,我是 ${this.name}`);
}
// 1. 使用 bind 创建一个新函数,this 永久锁定为 obj1
const boundSayHi = sayHi.bind(obj1);
// 2. 尝试用 call 强行修改 this 为 obj2
boundSayHi.call(obj2);
// 输出: "你好,我是 张三" <-- 这里的 this 依然是 obj1,call 失效了!
// 3. 尝试再次 bind 修改为 obj2
const doubleBound = boundSayHi.bind(obj2);
doubleBound();
// 输出: "你好,我是 张三" <-- 再次 bind 也无效,第一次绑定才是王道
为什么 bind 这么“霸道”?
这其实是由 bind 的实现机制决定的。当调用 func.bind(obj) 时,JavaScript 引擎返回了一个全新的闭包函数。这个新函数内部保存了对 obj 的引用,并且硬编码了执行逻辑。当你调用这个“绑定函数”时,它内部会直接执行类似 原函数.apply(被锁定的obj, 参数) 的逻辑。
你在外部再怎么折腾(用 call 或 apply),都穿透不了这层“保护壳”。
唯一的例外:new
虽然 bind 锁死了 this,但如果你用 new 来调用这个被 bind 过的函数,new 的优先级更高,this 会指向新生成的实例,而不是 bind 锁定的对象。
灵魂拷问:为什么要设计得这么复杂?
你可能会问:为什么不像Java那样,this永远指向实例对象呢?
这要追溯到JavaScript诞生的那个“疯狂的十天”。Brendan Eich在设计JS时,为了让这门语言既轻量又灵活,引入了这种动态绑定机制。
1. 历史背景与执行机制
JavaScript的变量查找(自由变量)是静态的,遵循词法作用域,看的是“函数写在哪”;而this的绑定是动态的,属于执行上下文,看的是“函数怎么调”。这种设计让JS在诞生之初就具备了极强的“函数借用”能力。
2. 鸭子类型与灵活性
这种机制让JS实现了著名的“鸭子类型”——只要一个对象长得像鸭子(有length属性,有索引),走起来像鸭子,那它就能被当成鸭子用。这为后来的“借用方法”奠定了基础。
3. 错误的修正
当然,这种设计也有副作用。在非严格模式下,独立函数调用会导致this默认指向window,极易造成全局变量污染。ES5引入的严格模式,以及ES6引入的箭头函数,都是为了解决这些“历史遗留问题”。
进阶玩法:万物皆可“借”
理解了this的动态本质,我们就能解锁JavaScript的高级用法。
案例:Array.prototype.slice.call(arguments)
这是前端面试中的经典考题,也是老代码中常见的“魔术”。
arguments是一个类数组对象,它有length和索引,但没有数组的方法(如slice、map)。
但是,Array.prototype.slice函数内部并不检查this是不是真正的数组,它只关心this有没有length和索引。
于是,我们通过call,强行把slice函数内部的this指向了arguments。
function logArgs() {
// 借用数组的 slice 方法,将 arguments 转为真数组
const args = Array.prototype.slice.call(arguments);
console.log(args);
}
这就是“反柯里化”的雏形:把原本属于特定对象的方法,解放出来,变成一个通用的函数。
终极武器:箭头函数
ES6的箭头函数是this机制的一个“特例”。它没有自己的this,它的this继承自定义时所在的词法作用域。
这意味着:
- 它不受调用方式影响。
- 它无法被
call、apply、bind修改。 - 它完美解决了回调函数中
this丢失的问题。
const obj = {
name: 'Tom',
sayHi: function() {
setTimeout(() => {
// 这里的 this 继承自 sayHi 的 this,即 obj
console.log(this.name);
}, 1000);
}
};
硬核原理:手写源码大揭秘
为了彻底理解 this 的底层机制,我们不仅要会用,还要能手写。下面我们将亲手实现 call、apply、bind 和 new,看看它们内部到底是如何操作 this 的。
1. 手写 myCall
call 的核心逻辑是:将函数作为对象的一个临时属性,然后通过该对象调用这个函数,最后删除这个临时属性。
Function.prototype.myCall = function(context, ...args) {
// 1. 处理 context,默认指向 window (浏览器环境)
// 如果传入 null 或 undefined,也指向 window
context = context || window;
// 2. 创建一个唯一的属性名 (Symbol),防止覆盖 context 上原有的同名属性
const fnSymbol = Symbol('fn');
// 3. 将当前函数(即调用 myCall 的函数,通过 this 指向)赋值给 context
context[fnSymbol] = this;
// 4. 执行这个函数。此时函数内的 this 就指向了 context,并传入参数 args
const result = context[fnSymbol](...args);
// 5. 删除临时属性,保持 context 的“洁净”
delete context[fnSymbol];
// 6. 返回函数的执行结果
return result;
};
2. 手写 myApply
apply 和 call 几乎一样,唯一的区别是参数接收形式不同(数组形式)。
Function.prototype.myApply = function(context, argsArray) {
// 1. 处理 context,默认指向 window
context = context || window;
// 2. 创建唯一属性名
const fnSymbol = Symbol('fn');
// 3. 将当前函数赋值给 context
context[fnSymbol] = this;
let result;
// 4. 核心区别:判断参数是否为数组,并决定是否展开
if (Array.isArray(argsArray)) {
// 如果是数组,就展开参数调用
result = context[fnSymbol](...argsArray);
} else {
// 如果没有参数或参数不是数组,直接调用
result = context[fnSymbol]();
}
// 5. 删除临时属性
delete context[fnSymbol];
// 6. 返回执行结果
return result;
};
3. 手写 myBind
bind 稍微复杂一点,它需要返回一个函数(闭包),并且要处理 new 的情况。
Function.prototype.myBind = function(context, ...bindArgs) {
const originalFunc = this; // 1. 保存调用 bind 的原函数
// 2. 返回一个新的函数(闭包)
const boundFunc = function(...callArgs) {
// 3. 判断是否通过 new 调用 boundFunc
// 如果是 new 调用,this 指向新创建的实例;否则指向 bind 传入的 context
const isNew = this instanceof boundFunc;
const finalThis = isNew ? this : context;
// 4. 合并参数:bind 时传入的参数 + 调用时传入的参数 (实现柯里化)
const finalArgs = [...bindArgs, ...callArgs];
// 5. 使用 apply 执行原函数,并传入合并后的 this 和参数
return originalFunc.apply(finalThis, finalArgs);
};
// 6. 维护原型链,确保 new boundFunc() 时能继承原函数的原型
boundFunc.prototype = Object.create(originalFunc.prototype);
// 7. 返回这个新的绑定函数
return boundFunc;
};
4. 手写 myNew
new 操作符其实做了四件事,我们把它翻译成代码:
function myNew(constructor, ...args) {
// 1. 创建一个全新的空对象
const obj = {};
// 2. 将这个新对象的原型 () 指向构造函数的 prototype 属性
// 这样新对象就能访问构造函数原型上的方法
Object.setPrototypeOf(obj, constructor.prototype);
// 3. 执行构造函数,并将函数内的 this 绑定到新创建的对象 obj 上
const result = constructor.apply(obj, args);
// 4. 判断构造函数的返回值:
// 如果构造函数显式返回了一个对象,则 new 表达式的结果就是这个对象
// 否则,返回新创建的 obj
return (typeof result === 'object' && result !== null) ? result : obj;
}
通过手写这些代码,你会发现 this 的魔法其实就是属性赋值和函数调用的组合拳。
实战演练:几道例题检验你的理解
光说不练假把式,来看几道经典的面试题,看看你是否真的掌握了 this 的奥义。
例题 1:多次 bind 的“套娃”游戏
function foo() {
console.log(this.a);
}
const obj1 = { a: 1 };
const obj2 = { a: 2 };
const obj3 = { a: 3 };
const bar = foo.bind(obj1).bind(obj2).bind(obj3);
bar(); // 输出什么?
输出:1
解析:正如前文所述,
bind是硬绑定,且不可篡改。第一次foo.bind(obj1)返回的新函数已经将this锁定为obj1。后续的.bind(obj2)和.bind(obj3)只是在包装这个已经锁定的函数,无法改变其内部的this指向。
例题 2:new 与 bind 的巅峰对决
function Person(name) {
this.name = name;
}
const obj = { name: 'GlobalObj' };
const BoundPerson = Person.bind(obj);
const p = new BoundPerson('Jack');
console.log(p.name); // 输出什么?
console.log(obj.name); // 输出什么?
输出:'Jack', 'GlobalObj'
解析:虽然
bind锁定了this,但new的优先级更高。当使用new BoundPerson()时,JS 引擎会创建一个新的空对象,并忽略bind锁定的obj,将this指向这个新实例。因此p.name是 'Jack',而原obj未受影响。
例题 3:箭头函数的“顽固”
const obj = {
name: 'Tom',
arrowFn: () => {
console.log(this.name);
}
};
const anotherObj = { name: 'Jerry' };
obj.arrowFn.call(anotherObj); // 输出什么?
输出:undefined (或 window.name)
解析:箭头函数没有自己的
this,它继承自定义时的外层作用域(在这里是全局作用域)。因此,无论你用call试图把它指向谁,它都“油盐不进”,依然指向定义时的window(非严格模式下)。
例题 4:setTimeout 的“隐式丢失”
const user = {
name: 'Jack',
sayName: function() {
console.log(this.name);
}
};
setTimeout(user.sayName, 1000); // 输出什么?
输出:undefined (或 window)
解析:这是最经典的陷阱。
setTimeout接收的是一个函数引用user.sayName。当定时器触发时,这个函数是作为独立函数被调用的(相当于window.sayName()),发生了隐式丢失,this指向了全局对象。
修正:使用箭头函数setTimeout(() => this.sayName(), 1000)或setTimeout(user.sayName.bind(user), 1000)。
例题 5:DOM 事件中的“指向变动”
<button id="btn">点击我</button>
<script>
const btn = document.getElementById('btn');
btn.onclick = function() {
console.log(this); // 输出什么?
}
</script>
输出:
<button id="btn">...</button>解析:在 DOM 事件处理函数中,
this通常指向触发事件的 DOM 元素。这是浏览器环境的特殊规则(属于隐式绑定的一种变体)。但如果你把事件处理函数写成箭头函数,this就会指向定义时的window,导致无法获取按钮元素。
避坑指南与总结
在实际开发中,我们还需要注意class语法下的陷阱。
- 普通方法:定义在原型上,
this动态绑定,可以借用。 - 箭头函数属性:定义在实例上,
this在定义时锁死,无法借用。
最后,送大家一张this绑定的优先级天梯图,助你彻底告别this困惑:
箭头函数(词法作用域) > new绑定 > bind硬绑定 > call/apply显式绑定 > 隐式绑定 > 默认绑定
理解this,本质上就是理解JavaScript的执行上下文和原型链机制。希望这篇文章能帮你把这块知识拼图完整地拼好!
程序员黑话
- 箭头函数是“子承父业”,生来就随爹。
bind是“卖身契”,一签终身,除非你“重生”(new)。call/apply是“临时工”,用完即走,不留痕迹。- 普通函数是“墙头草”,谁调用它就倒向谁。
new是“创世神”,它说要有this,于是就有了新对象。