Skip to content

this、call、apply、bind

this 是 JavaScript 面试里的高频考点,本质上考的是一句话:

普通函数的 this 由调用方式决定,箭头函数的 this 由定义位置决定。


1. 对 this 的理解

this 不是在函数定义时写死的,大多数情况下它会在函数真正被调用时确定指向。

先记住一条总原则:

  • 普通函数:看调用位置
  • 箭头函数:看外层作用域

对于普通函数,常见的绑定规则有 4 种,优先级通常是:

new 绑定 > 显式绑定 > 隐式绑定 > 默认绑定

需要注意:箭头函数不参与这 4 条规则,它没有自己的 this

(1)默认绑定(“无主裸奔”状态)

默认绑定发生在函数被独立调用的时候。什么叫独立调用?就是函数名前面没有任何前缀(没有 .,也没有 new),光秃秃地直接加上括号执行,比如 foo()

你可以把这种函数想象成一个没有签约任何公司的自由职业者。当他开始干活时,谁算是他的老板(this)呢?这完全取决于当前代码所处的“社会环境”:

js
function foo() {
  console.log(this);
}

// 案发现场:光秃秃地调用,前面什么都没有
foo();

⚖️ 两种不同的“社会环境”:

  1. 🌍 宽松环境(非严格模式,默认情况): 系统会好心地给你分配一个“兜底的大老板”——全局对象

    • 在浏览器环境里,这个大老板叫 window
    • 在 Node.js 环境里,这个大老板叫 global
  2. 👮 严格环境(开启了严格模式 'use strict'): 规矩森严,没老板就是没老板,系统绝不随便找个全局对象来背锅。

    • 此时 this 直接就是 undefined
js
'use strict'; // 开启严格审查

function strictFoo() {
  console.log(this);
}

strictFoo(); // 👮 铁面无私,结果是 undefined

💡 一句话总结(背诵重点):

只要看到函数是“裸调用”(像 fn() 这样直接加括号执行的),不用多想,它的 this 要么是全局大管家(window/global),要么就是 undefined(严格模式下)。

(2)隐式绑定(“谁发号施令,谁就是老板”)

当函数被包含在一个对象中,并且作为该对象的方法被调用时,this 就会指向调用它的那个对象。

继续用“雇佣兵”的比喻:当雇佣兵挂靠在某个老板(对象)手下,并且老板亲自下达指令(通过点号 . 让他开火时,他这一刻效忠的 this 就是这个老板。

js
const obj = {
  name: 'Alice', // 雇佣兵的老板
  sayHi() {
    console.log(this.name);
  },
};

// 🔫 老板 Alice 亲自发号施令
obj.sayHi(); // Alice

🏢 嵌套调用(层层外包)只看“直接上司”: 如果对象里面套了对象,发生了链式调用(比如 A.B.fn())。记住,雇佣兵只认最后把枪递给他、直接给他下指令的那个人。他不关心背后还有多大的大老板。

js
const obj1 = {
  name: '大老板_obj1',
  obj2: {
    name: '直接上司_obj2',
    fn() {
      console.log(this.name);
    },
  },
};

// 🔫 真正扣动扳机的是 obj2
obj1.obj2.fn(); // 结果是 obj2(直接上司),不是 obj1

💡 记忆小口诀(一招制敌):就近原则! 别管前面的对象链有多长,在执行开枪 fn() 的那一瞬间,谁紧紧贴在点号(.)的左边this 就是谁。

(3)隐式绑定丢失(“脱离宿主的失忆”)

在 JavaScript 中,对象里面的普通函数就像是一个 “容易失忆的雇佣兵”。他根本不在乎自己是在哪被定义的,他只看执行开枪的那一瞬间,是谁在发号施令(即调用时函数名前面有没有 .)。

js
const obj = {
  name: 'Alice',
  // 这个函数就是那个雇佣兵
  sayHi() {
    console.log(this.name);
  },
};

// 💣 危机发生:把雇佣兵单独拉出来赋值给新变量
const fn = obj.sayHi; 

// 💣 失忆了:此时开枪,雇佣兵发现自己处于“裸奔”状态
fn(); // 结果不是 Alice,而是 undefined(或严格模式下报错)

🔍 为什么会“失忆”(丢失绑定)?

  • 斩断了联系: 在 JS 里,函数是独立存在于内存中的。obj.sayHi 只是 obj 用一根线牵住了这个函数。当你写下 const fn = obj.sayHi 时,你其实是斩断了这根线,把函数本体单独抽离了出来。

  • “裸调用”: 最后执行 fn() 的时候,函数名前面没有任何宿主对象(没有 obj.)。this 瞬间迷失,只能默认指向全局环境(window)或 undefined(严格模式)。

🚨 面试中最常见的 3 大“案发现场”:

只要函数脱离了宿主被单独调用,就会触发隐式绑定丢失。

  1. 赋值给新变量后再调用: const fn = obj.sayHi; fn();
  2. 作为回调函数传参: setTimeout(obj.sayHi, 1000)(把函数交给了定时器,1 秒后定时器是在内部“裸调用”了它,老板 obj 已经不在场了)。
  3. 对象的解构赋值: const { sayHi } = obj; sayHi();(原理等同于第 1 条)。

🛡️ 如何防止失忆?(三大保命解药):

  1. 用箭头函数包一层(最推荐):setTimeout(() => obj.sayHi(), 1000)(原理:利用闭包,确保真正在执行时,依然带有 obj. 前缀。)

  2. bind 签“死契约”: setTimeout(obj.sayHi.bind(obj), 1000)(原理:用 bind 强行把 this 永久锁定为 obj,怎么传都不会丢。)

  3. 不折腾,直接就地调用: 老老实实写 obj.sayHi()

(4)显式绑定(“强制”借用)

你可以通过 callapplybind 这三个自带的方法,强行改变函数运行时的 this 指向。

打个比方:greet 函数是一台工具机器,它需要一个带有 name 属性的对象来运转。 通过显式绑定,你可以把 person 对象 “强行塞进” 这台机器里当 this 用。

js
function greet(city, job) {
  console.log(`${this.name} 来自 ${city},职业是 ${job}`);
}

const person = { name: 'Bob' }; // 这就是我们要强行塞进去的 this

// 1. call: 逗号分隔传参
greet.call(person, '上海', '前端');

// 2. apply: 数组打包传参
greet.apply(person, ['北京', '工程师']);

// 3. bind: 预先绑定好参数,稍后再用
const boundFn = greet.bind(person, '杭州'); 
boundFn('开发');

三剑客的核心区别(背诵重点):

方法执行时机参数传递格式适用场景
call立即执行arg1, arg2...(一个个传)明确知道有几个零散的参数时
apply立即执行[arg1, arg2...]Array 数组)参数原本就是存在一个数组里时
bind稍后执行 (返回一个被锁定 this新函数)call 相同用在回调函数(如 React 事件处理或定时器)中,防止 this 丢失

💡 记忆小口诀:

  • Apply 传 Array(数组)。
  • bind 是“绑定”,绑好了放那儿,需要调用时再调用。

(5)new 绑定(“工厂流水线”模式)

当你在一个函数前面加上 new 关键字时,它就不再是一个普通的函数了,而是变成了一张 “生产图纸”(构造函数)。

在这个场景下,this 指向的是流水线上正在组装的那个全新产品

js
// 这是一张“生产图纸”
function Person(name) {
  // 这里的 this,指向的是那个刚被流水线推出来的新躯壳
  this.name = name; 
}

// 启动流水线!
const p = new Person('Alice');
console.log(p.name); // Alice

🔍 揭秘:加上 new 之后,JavaScript 在暗中做了哪 4 件事?

当你写下 new 的那一瞬间,JS 引擎就像一个自动化工厂,偷偷为你执行了以下“四大暗箱操作”:

  1. “建个空壳”(创建新对象): 在内存里凭空制造一个全新的、没有任何属性的空对象 {}

  2. “认祖归宗”(连接原型链): 把这个空对象的隐式原型(__proto__),连接到构造函数的 prototype 上。(作用:让新产品能继承这张图纸的“祖传手艺”/公共方法)。

  3. “偷梁换柱”(绑定 this 并加工): 将函数内部的 this 死死地绑定在这个新空壳上,然后开始执行函数体里面的代码。(作用:给空壳装配零件,比如执行 this.name = 'Alice',空壳就有了名字)。

  4. “打包出厂”(自动返回结果): 就算你的函数里没写 return,流水线也会自动把这个加工好的新对象返回给你。(⚠️ 特例: 除非你的函数非要自己 return 一个其他的对象/数组/函数,那出厂的就是你指定的东西,这个新躯壳就被无情丢弃了)。

💡 一句话总结:new 绑定的力量极其强大,它的优先级非常高。只要用了 newthis无条件指向那个被全新创造出来的实例对象。

(6)箭头函数的 this

箭头函数没有自己的 this,它的 this编写定义时就已经永久锁定。它会向外层寻找,直到捕获 最近一层真实作用域(普通函数作用域或全局作用域)this

js
const obj = {
  name: 'Charlie',
  normal() {
    console.log(this.name);
  },
  arrow: () => {
    console.log(this.name);
  },
};

obj.normal(); // Charlie
obj.arrow();  // 通常是 undefined 或空(绝不是 Charlie)

为什么 obj.arrow() 会失效?

  • 对象字面量 {} 本身不创造作用域:在 JavaScript 中,只有函数和全局环境才能产生供 this 绑定的作用域。
  • 穿透效应arrow 函数在定义时向外寻找 this,直接穿透了透明的 obj 对象大括号,最终将其 this 绑定到了全局环境(如浏览器里的 window)。

核心结论与最佳实践:

  • 不适合:直接作为对象的普通方法(会导致 this 意外指向全局)。
  • 极度适合:作为回调函数使用(可以完美继承并保留外层包裹函数的 this)。
js
function Timer() {
  this.count = 0;

  // 这里的箭头函数在定义时向外找,刚好被包裹在 Timer 函数的作用域内。
  // 于是它成功捕获并锁定了 Timer 的 this(即未来的实例对象)。
  setInterval(() => {
    this.count++;
    console.log(this.count);
  }, 1000);
}

2.几个高频易错点

1. 箭头函数不能用 callapplybind 改变 this

js
const obj = { name: 'Alice' };

const fn = () => {
  console.log(this);
};

fn.call(obj); // this 不会变成 obj

原因是箭头函数的 this 在定义时就确定了。

2. bind 绑定后,继续 bind 基本不会改掉原来的 this

js
function foo() {
  console.log(this.name);
}

const fn1 = foo.bind({ name: 'A' });
const fn2 = fn1.bind({ name: 'B' });

fn2(); // A

后一次 bind 还能继续预置参数,但不能把前一次绑定好的 this 再改掉

3. new 调用绑定函数时,会忽略 bind 传入的 this

js
function Person(name) {
  this.name = name;
}

const BoundPerson = Person.bind({ name: 'outer' }, 'Alice');
const p = new BoundPerson();

console.log(p.name); // Alice

这里真正生效的是 new 绑定,而不是 bind 时传入的对象。

4. 顶层 this 依赖运行环境

常见结论:

  • 浏览器普通 script 顶层:通常是 window
  • ES Module 顶层:undefined
  • Node.js CommonJS 顶层:通常是 module.exports

所以面试回答时不要把“顶层 this 一定等于 window”说死。


2. call()、apply()、bind() 的区别

三剑客的共同点(核心使命):

  1. 它们都是函数天生自带的方法(存在于 Function.prototype 上)。

  2. 它们的第一个参数永远都是你想指定的那个 this 对象(也就是你想让函数去“挂靠”的老板)。

  3. 它们的核心目的都是“借鸡生蛋”:让普通函数能够借用其他对象的方法,或者强制改变原本的执行环境。

⚔️ 核心区别(一表看懂):

方法是否立即执行?传参方式(除 this 外)返回值记忆口诀 / 特点
call(立刻开枪)arg1, arg2, ... (逗号逐个传)原函数的执行结果Call 像打电话,一句话一句话逐个讲。
apply(立刻开枪)[arg1, arg2, ...] (打包成数组传)原函数的执行结果Apply 传 Array(数组)。
bind(按兵不动)call 相同 (逗号逐个传)一个被永久锁定 this 的新函数Bind 像签卖身契,绑好了放那儿,以后再用

1.call 的使用场景(精准点射)

特点: 明确知道有几个零散的参数,且需要立刻执行函数。

你可以把它想象成“打电话交办任务”,老板是谁(this),任务细节是什么(参数),一件一件说清楚,说完立刻去办。

js
function introduce(age, hobby) {
  console.log(`我是 ${this.name},今年 ${age} 岁,喜欢 ${hobby}。`);
}

const person = { name: 'Alice' };

// 把 introduce 机器借给 person 用,明确传入 18 和 '读书'
introduce.call(person, 18, '读书'); 
// 输出: 我是 Alice,今年 18 岁,喜欢 读书。

2.apply 的使用场景(打包批处理)

特点: 手里已经有一个数组(或类数组、arguments),需要把数组里的元素拆解开,喂给函数,且需要立刻执行

它最大的魔力在于能 “自动拆包”。比如 Math.max() 原本是不接收数组的,只能接收零散的数字。你可以用 apply 巧妙地骗过它:

js
function getMax() {
  // arguments 是一个类数组对象 [1, 3, 2]
  // Math.max 本身不支持直接传数组,但 apply 会自动把它拆解成 Math.max(1, 3, 2)
  return Math.max.apply(null, arguments); 
}

console.log(getMax(1, 3, 2)); // 3

💡 补充说明(现代前端的平替): 随着 ES6 的普及,apply 经常被更为优雅的扩展运算符(... 所替代:

js
const nums = [1, 2, 3];

// 老写法:用 apply 拆包
sum.apply(null, nums); 

// 新写法:用 ... 拆包配合 call(或直接调用)
sum.call(null, ...nums);

(3)bind 的使用场景(未雨绸缪)

特点: 不想立刻执行,而是需要派生出一个“永远不会失忆(丢失 this)”的新函数,留给未来某个时刻调用(最常用于回调函数、定时器、事件监听)。

回到我们之前说的“隐式绑定丢失”问题。当你要把一个对象的方法作为回调传给别人时,为了防止雇佣兵“失忆”,你需要用 bind 提前给他签一份死契约。

js
const button = {
  text: '提交',
  click() {
    console.log(this.text);
  },
};

// 💣 错误示范:直接传,会导致 this 丢失,变成 undefined
// setTimeout(button.click, 1000); 

// ✅ 正确示范:用 bind 创造一个 this 永远指向 button 的新函数(克隆人)
const safeHandler = button.click.bind(button);

// 1秒后执行克隆人,this 依然稳如泰山
setTimeout(safeHandler, 1000); // 1秒后输出: 提交

🔧 进阶考点(柯里化/预设参数):bind 不仅可以绑死 this,还可以提前绑死一部分参数

js
function sum(a, b, c) {
  return a + b + c;
}

// 提前绑死 this 为 null,并且把第一个参数 a 永久锁定为 10
const plusTen = sum.bind(null, 10);

// 以后调用 plusTen 时,只需要传剩下的 b 和 c
console.log(plusTen(2, 3)); // 10 + 2 + 3 = 15

3. 手写 callapplybind

需要先说明一个边界:

  • 这些代码是简化版实现
  • 重点在理解“临时挂载函数并执行”的思路
  • 它们不完全等价于原生实现,尤其是严格模式函数、内建函数、length/name 属性等细节

为了让这两个方法的表述看起来像是一个系列,我帮你统一了它们的结构、变量命名、代码注释风格以及核心原理解析。这样对比着看会更加清晰易懂。

(1)手写 call

call 的核心原理是:将当前函数作为目标对象的一个临时属性进行调用,从而利用隐式绑定的规则改变 this 指向,执行完毕后再删除该临时属性。

  1. 校验调用者:确保调用 myCall 的是一个函数。
  2. 确定 this 指向:处理传入的上下文对象,兼容空值和基本数据类型。
  3. 临时挂载函数:用 Symbol 创建唯一键,把当前函数挂载到目标对象上。
  4. 执行并传参:利用展开语法(...)将散列参数传入并执行。
  5. 清理并返回:删除临时属性,返回执行结果。
js
Function.prototype.myCall = function(context, ...args) {
  // 1. 校验调用者:防伪验证,确保调用 myCall 的是一个函数
  if (typeof this !== 'function') {
    throw new TypeError(this + ' is not a function');
  }

  // 2. 确定 this 指向
  // == null 可以同时命中 null 和 undefined,此时默认指向 globalThis
  // Object() 可以将基本类型(如数字、字符串)转化为包装对象,以便挂载属性
  const thisArg = context == null ? globalThis : Object(context);
  
  // 3. 临时挂载函数
  // 使用 Symbol 生成独一无二的属性名,防止覆盖目标对象上原有的同名属性
  const fnSymbol = Symbol('fn');
  thisArg[fnSymbol] = this;

  // 4. 执行并传参
  // 将收集到的散列参数 args 通过展开语法 (...args) 传给原函数
  const result = thisArg[fnSymbol](...args);

  // 5. 清理战场并返回
  // 注意:删除的是 thisArg 上的临时属性,而不是原始的 context
  delete thisArg[fnSymbol];
  return result;
};

(2)手写 apply

applycall 的核心逻辑完全一致,唯一的差别在于参数的接收与处理方式call 接收散列参数,而 apply 接收一个数组或类数组对象。

除了第 3 步需要专门“处理参数列表”外,其余步骤与 call 丝毫不差。我们需要严格模拟原生 apply 对第二个参数的类型校验。

js
Function.prototype.myApply = function (context, args) {
  // 1. 校验调用者
  if (typeof this !== 'function') {
    throw new TypeError(this + ' is not a function');
  }

  // 2. 确定 this 指向 (与 call 完全一致)
  const thisArg = context == null ? globalThis : Object(context);
  
  // 3. 处理参数列表 (这是与 call 唯一不同的地方)
  let argList = [];
  if (args != null) {
    // 严格模拟原生:如果传入了参数,但既不是对象也不是函数(如传入数字 123),则报错
    if (typeof args !== 'object' && typeof args !== 'function') {
      throw new TypeError('CreateListFromArrayLike called on non-object');
    }
    // 将对象或类数组(如 arguments)转化为真实的数组
    argList = Array.from(args);
  }

  // 4. 临时挂载并执行 (与 call 逻辑一致,只是传入的是处理好的 argList)
  const fnSymbol = Symbol('fn');
  thisArg[fnSymbol] = this;
  const result = thisArg[fnSymbol](...argList);
  
  // 5. 清理战场并返回
  delete thisArg[fnSymbol];
  return result;
};

无论是手写 call 还是 apply,都有以下几个高频考点/亮点:

  • context == null 的妙用:利用双等号的隐式类型转换,一次性排除了 nullundefined,将其统一指向全局对象 globalThis
  • Object(context) 的必要性:如果开发者传入的是 1'hello',直接赋值属性会失败。Object() 会将它们变成包装类(如 Number {1}),从而允许挂载临时函数。
  • Symbol() 的防冲突机制:如果强行使用固定的字符串作为键名(比如 thisArg.fn = this),很有可能会覆盖掉目标对象本身自带的 fn 属性。Symbol 完美解决了这个安全隐患。
  • Array.from(args) 的严谨性 (仅限 apply):没有简单粗暴地使用 ...args,而是使用了 Array.from。这保证了即使用户传入的是一个类数组对象(比如 DOM 节点列表或函数内的 arguments),代码也能像原生 apply 一样正常运转。

(3)手写 bind

bind 的核心不仅在于改变 this 指向,更在于它的 “延迟执行”(返回一个新函数)和 “参数柯里化”(预置参数),以及最容易被忽略的——兼容 new 关键字调用

  1. 校验调用者:确保调用 myBind 的是一个函数。
  2. 闭包保存原函数:保存 this(即原函数),因为在返回的新函数内部,this 的指向会发生改变。
  3. 返回新函数并处理 this
    • 如果是 new 调用,this 应该指向新创建的实例。
    • 如果是普通调用,this 指向 myBind 传入的 context
  4. 拼接参数:将预置的 bindArgs 和调用时的 callArgs 合并传给原函数。
  5. 维护原型链:保证 new 出来的实例能继承原函数原型上的方法。
js
Function.prototype.myBind = function (context, ...bindArgs) {
  // 1. 校验调用者
  if (typeof this !== 'function') {
    throw new TypeError('myBind must be called on a function');
  }

  // 2. 闭包保存原函数
  const targetFn = this;

  // 3. 定义要返回的绑定函数
  function boundFn(...callArgs) {
    // 核心难点:判断当前函数是否是被 new 调用的
    // 如果是 new 调用,this 会指向实例对象(this instanceof boundFn 为 true)
    // 此时 thisArg 应该使用实例对象 (this),否则使用预绑定的 context
    const isNew = this instanceof boundFn;
    const thisArg = isNew ? this : context;

    // 4. 拼接参数并执行(这里借用了原生的 apply)
    // 你用的 bindArgs.concat(callArgs) 非常好,这里替换为等价的展开语法,视觉上更现代
    return targetFn.apply(thisArg, [...bindArgs, ...callArgs]);
  }

  // 5. 维护原型链 (精髓所在!)
  // 箭头函数没有 prototype,所以需要判断
  if (targetFn.prototype) {
    // 使用 Object.create 干净地继承原函数的原型,避免与原函数共用同一个原型对象导致相互污染
    boundFn.prototype = Object.create(targetFn.prototype);
    
    // 规范操作:修复 constructor 的指向,使其指向绑定后的函数
    Object.defineProperty(boundFn.prototype, 'constructor', {
      value: boundFn,
      writable: true,
      configurable: true,
    });
  }

  return boundFn;
};

(4)验证手写版本

你的基础测试用例写得很好,为了彻底验证我们手写的 callapply 和特别是 bind 对于 new 操作符的兼容性,我扩展了你的测试用例:

js
// --- 准备测试环境 ---
function Person(name, age) {
  this.name = name;
  this.age = age;
  console.log(`执行构造: 名字=${this.name}, 年龄=${this.age}, this指向:`, this);
}
Person.prototype.sayHi = function() {
  console.log(`${this.name} 说:你好!`);
}

const obj = { name: 'Alice' };

// --- 1. 测试 myCall 和 myApply ---
console.log('--- 测试 myCall ---');
Person.myCall(obj, 'Bob', 25); 
// 输出: 执行构造: 名字=Bob, 年龄=25, this指向: { name: 'Bob', age: 25, [Symbol(fn)]: [Function: Person] }
// (注:执行完后 Symbol 会被删掉,所以最终 obj 变成了 { name: 'Bob', age: 25 })

console.log('\n--- 测试 myApply ---');
Person.myApply(obj, ['Charlie', 30]);

// --- 2. 测试 myBind 的基础调用 (预置参数) ---
console.log('\n--- 测试 myBind (普通调用) ---');
const boundPerson = Person.myBind(obj, 'David'); 
// 预置了 name 为 'David',调用时只需传 age
boundPerson(35); 
// 输出: 执行构造: 名字=David, 年龄=35, this指向: obj对象

// --- 3. 高级测试:测试 myBind 后的函数作为构造函数 (new 调用) ---
console.log('\n--- 测试 myBind (new 调用) ---');
// 当使用 new 调用时,myBind 传入的 obj 应该被忽略,this 必须指向新创建的实例
const newInstance = new boundPerson(40); 
// 输出: 执行构造: 名字=David, 年龄=40, this指向: boundFn {} (一个全新的实例)

// 验证原型链是否继承成功(这是你写的 Object.create 发挥作用的地方)
console.log('是否成功继承原型方法?', typeof newInstance.sayHi === 'function'); // true
newInstance.sayHi(); // David 说:你好!

这四部曲(call, apply, bind 以及测试用例)整理下来,不仅逻辑严密,而且循序渐进,非常适合作为个人技术沉淀或者面试复习的核心资料。你的基础打得很扎实!

(5)bind 遇到 new 的验证

js
function Person(name, age) {
  this.name = name;
  this.age = age;
}

const BoundPerson = Person.myBind({ name: 'ignore' }, 'Alice');
const p = new BoundPerson(18);

console.log(p.name); // Alice
console.log(p.age); // 18
console.log(p instanceof Person); // true

4. 面试速记

  1. this 的指向主要取决于函数的调用方式,不取决于书写位置。
  2. 普通函数有 4 种常见绑定规则:默认绑定、隐式绑定、显式绑定、new 绑定,优先级通常是 new > 显式 > 隐式 > 默认。
  3. 箭头函数没有自己的 this,它会继承外层作用域的 this,所以 callapplybind 无法修改箭头函数的 this
  4. callapply 都会立即执行,区别在于参数形式不同;bind 不会立即执行,而是返回一个绑定后的新函数。
  5. bind 返回的函数如果被 new 调用,会忽略绑定时传入的 this,但预置参数仍然有效。

5. 参考资料

评论区

欢迎留言、补充或勘误。

xiaoba.blog