🔥 手撕call/apply/bind:从ES6用法到手写实现,吃透this指向核心
在JavaScript中,this 指向的绑定是前端开发绕不开的核心知识点,而 call、apply、bind 作为改变函数执行上下文的"三剑客",既是解决 this 丢失问题的常用手段,也是面试中考察JS基础的高频题。
本文将从原生ES6用法 、手写实现源码 、核心差异深度对比三个维度,结合实战源码案例,带你彻底掌握这三个方法,看完就能手写面试题!
一、核心共性:本质都是改变this指向
!NOTE\] 核心结论 `call`、`apply`、`bind` 的**本质作用完全一致** ------修改函数执行时 `this` 的指向,让函数能在指定的上下文环境中执行。
比如对象内部的方法,this 原本指向对象本身;而全局函数的 this 默认指向 window(浏览器环境),通过这三个方法可强制改变这一规则:
javascript
// 基础原理示例:this指向原对象
const obj = {
name: '测试1',
name2: '测试2',
name3: '测试3',
say: function() {
this.name2 = this.name + this.name3 // this指向obj
}
};
二、逐个拆解:ES6用法 + 手写实现 + 核心逻辑
下面针对每个方法,先展示原生ES6用法,再给出手写实现源码,最后解析核心逻辑,让你知其然也知其所以然。
1. call:参数列表传参,立即执行
① 原生ES6用法
javascript
// ES6 原生call用法
function say() {
console.log(this.name)
}
const obj = { name: 'Alice' }
say.call(obj) // 输出:Alice
call 接收第一个参数为绑定的this上下文 ,后续为逗号分隔的参数列表 ,调用后立即执行函数并返回结果。
② 手写实现源码
javascript
// 手写 myCall 实现
Function.prototype.myCall = function(receive, ...args) {
// 处理默认上下文:非严格模式下,null/undefined指向window
receive = receive || window
// 将原函数挂载到目标对象上(this指向原函数)
receive.fn = this
// 执行函数并传递参数列表,获取结果
const result = receive.fn(...args)
// 清理临时属性,避免污染目标对象
delete receive.fn
// 返回函数执行结果(与原生call行为一致)
return result
}
// 手写myCall测试案例
function testFun(param1, param2) {
return param1 + this.a + param2
}
const obj1 = { a: '测试1' }
console.log(testFun.myCall(obj1, '测试0', '测试2')) // 输出:测试0测试1测试2
③ 核心逻辑解析
!TIP\] 关键逻辑 * `receive = receive || window`:处理边界情况,当传入的上下文为 `null/undefined` 时,非严格模式下默认绑定 `window`; * `receive.fn = this`:核心技巧------将原函数(`this` 指向调用myCall的函数)挂载到目标对象上,通过"对象.方法"调用让 `this` 指向目标对象; * `delete receive.fn`:避免临时属性污染目标对象,执行完立即删除; * `...args`:ES6剩余参数,接收所有逗号分隔的参数列表,适配多参数场景。
2. apply:数组传参,立即执行
apply 与 call 唯一的区别是参数传递形式,执行时机和返回值完全一致。
① 原生ES6用法
apply 接收第一个参数为绑定的this上下文 ,第二个参数为数组/类数组对象 ,调用后立即执行函数并返回结果。
!WARNING\] 原生特性 若第二个参数非数组且非undefined,会抛出TypeError,这是原生apply的核心特性。
② 手写实现源码
javascript
// 手写 myApply 实现
Function.prototype.myApply = function(context, args) {
// 处理默认上下文
context = context || window
// 将原函数挂载到目标对象
context.fn = this
let result
// 核心:处理数组参数
if (!args) {
// 无参数时直接执行
result = context.fn();
} else if (Array.isArray(args)) {
// 数组参数解构传递
result = context.fn(...args)
} else {
// 非数组参数抛出类型错误(符合原生行为)
throw new TypeError('CreateListFromArrayLike called on non-object');
}
// 清理临时属性
delete context.fn;
return result
}
// 手写myApply测试案例
const obj = { name1: 'ceshi1' }
function testFun(param1, param2) {
return param1 + this.name1 + param2
}
console.log(testFun.myApply(obj, ['测试2', '测试3'])) // 输出:测试2ceshi1测试3
③ 核心逻辑解析
- 与myCall的核心差异:
args必须是数组,通过Array.isArray()校验,符合原生apply的参数规则; context.fn(...args):将数组参数解构为参数列表,本质是借用call的参数传递逻辑;- 错误抛出:严格校验参数类型,保证手写实现与原生行为一致。
3. bind:参数分批传,返回新函数(不立即执行)
bind 是三者中最特殊的一个,核心差异是不立即执行函数,而是返回绑定了this的新函数。
① 原生ES6用法
bind 接收第一个参数为绑定的this上下文 ,后续为可选的提前绑定参数,调用后返回一个新函数,只有执行新函数时才会触发原函数执行,且支持参数分批传递(柯里化特性)。
② 手写实现源码
javascript
// 手写 myBind 实现
Function.prototype.myBind = function(context, ...bindArgs) {
const self = this; // 保存原函数引用,避免this丢失
// 返回新函数(核心:不立即执行)
function boundFn(...callArgs) {
// 关键判断:是否作为构造函数调用
// new调用时,this instanceof boundFn 为 true,this 指向实例
// 否则,this 指向绑定的 context
const thisArg = this instanceof boundFn ? this : context;
// 执行原函数:合并绑定参数+调用参数,通过apply传递
return self.apply(thisArg, bindArgs.concat(callArgs));
}
// 继承原函数的prototype,保证new实例能访问原原型方法
boundFn.prototype = Object.create(self.prototype);
return boundFn;
};
// 手写myBind测试案例
function say(greeting, punctuation) {
console.log(greeting + ', ' + this.name + punctuation);
}
const person = { name: 'Alice' };
// 绑定this和第一个参数,返回新函数(不执行)
const boundSay = say.myBind(person, 'Hello');
boundSay('!'); // 执行新函数,输出:Hello, Alice!
// 特殊场景:作为构造函数使用
function Person(name) {
this.name = name;
}
Person.prototype.sayName = function() {
console.log(this.name);
};
const BoundPerson = Person.myBind({ name: 'Bob' });
const p = new BoundPerson('Charlie');
p.sayName(); // 输出:Charlie(this指向实例,而非绑定的对象)
③ 核心逻辑解析
!TIP\] 核心难点 * `const self = this`:保存原函数引用,避免后续嵌套函数中this指向丢失; * 返回新函数 `boundFn`:核心差异------不立即执行,而是返回绑定后的新函数; * `bindArgs.concat(callArgs)`:支持参数分批传递(绑定阶段传一部分,调用阶段传一部分); * `this instanceof boundFn`:关键判断------当新函数被`new`调用时,this指向实例而非绑定的context,符合原生bind的构造函数特性; * `boundFn.prototype = Object.create(self.prototype)`:继承原函数的原型链,保证new实例能访问原函数的原型方法。
三、深度对比:三者核心差异(补充版)
| 特性 | call | apply | bind |
|---|---|---|---|
| 参数形式 | 逗号分隔的参数列表 | 单个数组/类数组参数 | 支持分批传参(绑定+调用阶段) |
| 执行时机 | 立即执行 | 立即执行 | 不立即执行,返回新函数 |
| 返回值 | 函数执行结果 | 函数执行结果 | 绑定this的新函数 |
| 构造函数场景 | 无特殊处理(极少用) | 无特殊处理(极少用) | 自动适配,this指向实例 |
| 手写源码核心差异 | 直接解构剩余参数传参 | 校验数组参数后解构传参 | 需返回新函数、合并参数、适配new |
| 原生场景适配 | 多独立参数且需立即执行 | 参数为数组且需立即执行 | 需提前绑定this,延迟执行 |
补充:源码层面的核心区别
- call vs apply :手写源码的唯一差异在参数处理逻辑 ------call直接用剩余参数
...args接收列表,apply需校验数组参数后解构; - bind vs call/apply :bind的手写源码更复杂,多了三个核心逻辑:
- 返回新函数而非立即执行;
- 合并"绑定阶段参数"和"调用阶段参数";
- 适配构造函数场景,保证new调用时this指向实例。
总结 📝
call和apply核心差异仅在参数形式,均立即执行并返回函数结果;bind核心差异是不立即执行,返回新函数,支持分批传参且适配构造函数场景;- 手写实现的核心逻辑是:通过"函数挂载到目标对象→调用函数→清理挂载"改变this,bind需额外处理新函数、参数合并、new适配。