从“谁调用指向谁”到“手写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,于是就有了新对象。
相关推荐
小蜜蜂dry2 小时前
nestjs实战-登录、鉴权(二)
前端·后端·nestjs
全栈王校长2 小时前
Nest 文件上传 - 就是增强版的 el-upload
前端·后端·nestjs
ZC跨境爬虫2 小时前
海南大学交友平台开发实战 day10(后端向前端输出_前端读取数据全流程联调+日志调试落地)
前端·python·sqlite·html·状态模式
xiaotao1312 小时前
CSS中的Grid 布局
前端·css
cc_heart2 小时前
antdv-next/x:面向 Vue 的 AI 组件体系
前端·javascript·vue.js
竹林8182 小时前
RainbowKit快速集成多链钱包连接:从“一键连接”到“多链切换”的实战踩坑
前端·javascript
用户81274828151202 小时前
android使用uinput节点任意注入鼠标事件-重学安卓input子系统
前端
用户69371750013842 小时前
AI来了,同事们的效率为什么差这么多?
android·前端·ai编程
凡小烦2 小时前
从定制化页签tab到compose列表使用
android·前端