从“谁调用指向谁”到“手写Bind源码”,彻底搞懂JavaScript的this机制

JavaScript中的this,大概是前端圈子里最让人"又爱又恨"的概念了。爱它,是因为它带来了极大的灵活性,让函数可以随意"借用";恨它,是因为它像个变色龙,指哪打哪,稍不留神就指向了全局对象window,让你对着undefined发呆。

很多开发者背熟了"谁调用指向谁"的口诀,但遇到复杂场景依然会翻车。今天,我们不妨跳出死记硬背的怪圈,像面试一样,把这个"磨人的小妖精"彻底看透。

核心机制:this的"四象限"法则(附权威出处)

首先,我们要纠正一个直觉上的误区:this的指向,不取决于函数定义在哪里,而完全取决于函数被调用的方式

如果把JavaScript的执行上下文比作一个舞台,那么this就是舞台上的聚光灯。灯打在哪里,取决于导演(调用者)怎么安排,而不是演员(函数)站在哪。

** 官方出处在哪里?**

这些规则并不是社区总结的"野路子",而是有着严格的官方定义。在 ECMAScript 规范(ECMA-262)中,有一个核心抽象操作叫做 OrdinaryCallBindThis 以及 ResolveThisBinding

规范中明确定义了 this 绑定的优先级逻辑,简单来说就是:

  1. 箭头函数 :继承外层作用域的 this(词法作用域)。
  2. new 绑定 :通过 `` 内部方法创建新对象并绑定 this
  3. 显式绑定 :通过 `` 内部方法中的 thisArgument 强制指定。
  4. 隐式/默认绑定:作为兜底规则,依据调用位置决定。

我们可以把这复杂的规范总结为以下四种(优先级由高到低):

1. new绑定(优先级最高)

当你使用new关键字调用函数时,JavaScript引擎会执行一系列操作:创建一个新对象、将原型连接、执行构造函数,并最终将this绑定到这个新创建的对象上。

ini 复制代码
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'):thisundefined

深度解析:bind的"霸道"与call的"临时"

在显式绑定中,bind 是一个非常特殊的存在。很多开发者知道它能改变 this,但往往忽略了它的不可篡改性

bind 的绑定是"一锤子买卖"。

当你使用 bind 创建了一个新函数后,这个新函数的 this 就被永久锁定 了。后续无论你如何使用 callapply 甚至再次 bind,都无法改变它第一次被绑定的 this 指向。

ini 复制代码
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, 参数) 的逻辑。

你在外部再怎么折腾(用 callapply),都穿透不了这层"保护壳"。

唯一的例外: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和索引,但没有数组的方法(如slicemap)。

但是,Array.prototype.slice函数内部并不检查this是不是真正的数组,它只关心this有没有length和索引。

于是,我们通过call,强行把slice函数内部的this指向了arguments

javascript 复制代码
function logArgs() {
  // 借用数组的 slice 方法,将 arguments 转为真数组
  const args = Array.prototype.slice.call(arguments);
  console.log(args);
}

这就是"反柯里化"的雏形:把原本属于特定对象的方法,解放出来,变成一个通用的函数。

终极武器:箭头函数

ES6的箭头函数是this机制的一个"特例"。它没有自己的this,它的this继承自定义时所在的词法作用域。

这意味着:

  • 它不受调用方式影响。
  • 它无法被callapplybind修改。
  • 它完美解决了回调函数中this丢失的问题。
javascript 复制代码
const obj = {
  name: 'Tom',
  sayHi: function() {
    setTimeout(() => {
      // 这里的 this 继承自 sayHi 的 this,即 obj
      console.log(this.name);
    }, 1000);
  }
};

硬核原理:手写源码大揭秘

为了彻底理解 this 的底层机制,我们不仅要会用,还要能手写。下面我们将亲手实现 callapplybindnew,看看它们内部到底是如何操作 this 的。

1. 手写 myCall
call 的核心逻辑是:将函数作为对象的一个临时属性,然后通过该对象调用这个函数,最后删除这个临时属性。

javascript 复制代码
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
applycall 几乎一样,唯一的区别是参数接收形式不同(数组形式)。

ini 复制代码
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 的情况。

javascript 复制代码
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 操作符其实做了四件事,我们把它翻译成代码:

javascript 复制代码
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 的"套娃"游戏

ini 复制代码
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 的巅峰对决

ini 复制代码
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:箭头函数的"顽固"

ini 复制代码
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 的"隐式丢失"

javascript 复制代码
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 事件中的"指向变动"

xml 复制代码
<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,于是就有了新对象。
相关推荐
猫猫不是喵喵.2 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~3 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华3 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子6 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅6 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni6 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学7 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师8 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc888 小时前
微软EDGE浏览器功能学习
前端·学习·edge