Javascript篇 | 手写call,bind,apply
前言
在前面几章里,我们从闭包与作用域聊到 this 指向与执行上下文,又在上一章深入拆解了 Promise 的状态机制和异步控制流。这一章,我们把视角拉回函数调用本身,来看一道经典的加分题:手写实现 call / apply / bind。
这类题在一线大厂的 JavaScript 面试中出场率非常高,原因有两个:
- 能考察你对 this 绑定规则、函数调用过程 是否真正理解
- 能顺带考察你对 原型、参数处理、边界情况 的掌握程度
读完本章,你不仅能在面试中从容手写出这几个方法,还能举一反三,写出自己封装的工具函数。
诊断自测
在正式开始之前,先用几道小题测测自己目前的掌握程度。不用怕答错,这里只是帮你定位薄弱点,读完全章再回头看会更有感觉。
1. 下面这段代码输出什么?为什么?
const user = {
age: 25,
getAge: function () {
return this.age;
},
};
const unboundGetAge = user.getAge;
console.log(unboundGetAge());
console.log(unboundGetAge.call(user));
点击查看答案
unboundGetAge()输出undefined(非严格模式下 this 指向全局对象,全局对象上没有age属性)。unboundGetAge.call(user)输出25,因为call显式地把 this 绑定到了user。
核心考点:函数脱离对象后 this 丢失,call 可以重新绑定 this。
2. call 和 apply 最本质的区别是什么?能否只用其中一个实现另一个?
点击查看答案
最本质的区别只有传参形式不同:call 逐个传参,apply 把参数放在数组/类数组里一次性传入。除此之外行为完全一致。
完全可以互相实现,比如:
// 用 call 实现 apply
Function.prototype.myApply2 = function (ctx, args) {
return this.call(ctx, ...(args || []));
};
3. 用 bind 绑定过 this 的函数,再用 new 去调用,this 最终指向谁?
点击查看答案
指向 new 创建出来的实例对象,而不是 bind 传入的 thisArg。这是因为 ES 规范规定了 new 绑定的优先级高于 bind 绑定。如果你的手写 bind 没考虑这一点,面试官往往会追问。
先从一道面试题开始
先看一道很常见的题目:
Function.prototype.myCall = function (context, ...args) {
// 请在这里补全代码
};
Function.prototype.myApply = function (context, args) {
// 请在这里补全代码
};
Function.prototype.myBind = function (context, ...args) {
// 请在这里补全代码
};
function foo(a, b) {
console.log(this.name, a, b);
}
const obj = { name: 'obj' };
foo.myCall(obj, 1, 2); // 输出?
foo.myApply(obj, [3, 4]); // 输出?
const bar = foo.myBind(obj, 5);
bar(6); // 输出?
面试官一般会要求:
- 至少手写出
myCall/myApply - 进阶一点,会要求
myBind支持 柯里化 + new 调用不改变 this
在真正开始写代码之前,我们先回顾一下这三个方法的行为。
回顾:call / apply / bind 的“官方”行为
call
- 作用:改变函数 this 指向,并立即执行函数
- 语法:
fn.call(thisArg, arg1, arg2, ...) - 返回值:函数执行结果
function foo(a, b) {
console.log(this.name, a, b);
}
const obj = { name: 'obj' };
foo.call(obj, 1, 2); // obj 1 2
apply
- 作用:改变函数 this 指向,并立即执行函数
- 语法:
fn.apply(thisArg, [arg1, arg2, ...]) - 返回值:函数执行结果
foo.apply(obj, [1, 2]); // obj 1 2
和 call 的差别只有两点:
- 参数以 数组 形式传入
- 其他行为完全一致
bind
- 作用:绑定 this,返回一个新的函数,不会立刻执行
- 语法:
const newFn = fn.bind(thisArg, arg1, arg2, ...) - 特点:
- 支持 预先传入部分参数(柯里化)
- 返回的函数如果被
new调用,this 指向实例,忽略 bind 传入的 thisArg
function foo(a, b) {
console.log(this.name, a, b);
}
const obj = { name: 'obj' };
const bar = foo.bind(obj, 1);
bar(2); // obj 1 2
const person = new bar(3); // this 指向实例 person,而不是 obj
理解了“官方行为”,我们接下来就可以按行为一步步拆解实现。
第一部分:手写 call
核心思路
从行为出发,call 做了几件事:
- 把函数挂到传入的
context上(让它变成这个对象的方法) - 调用这个方法,此时根据 隐式绑定规则,this 就指向了这个对象
- 调用结束后,把挂上去的临时方法删除
- 返回函数执行的结果
最小可用版本
Function.prototype.myCall = function (context, ...args) {
// 1. this 就是当前被调用的函数
const fn = this;
// 2. 处理 context 为 null/undefined 的情况,按照规范应指向全局对象(浏览器是 window)
context = context || globalThis; // globalThis 在浏览器和 Node 中都可用
// 3. 为 context 创建一个临时属性,用于保存函数引用
const key = Symbol('fn'); // 用 Symbol 避免属性名冲突
context[key] = fn;
// 4. 执行函数
const result = context[key](...args);
// 5. 删除临时属性
delete context[key];
// 6. 返回执行结果
return result;
};
边界与细节
面试中,如果你能主动提到这些点,会是很大的加分项:
- context 为 null/undefined 时的处理
- 非严格模式下,
fn.call(null)等价于fn.call(window)(浏览器) - 用
globalThis可以同时兼容浏览器和 Node
- 非严格模式下,
- 避免属性名覆盖
- 如果直接用固定属性名,比如
_fn,有可能覆盖原对象上的同名属性 - 使用
Symbol作为 key,可以确保唯一性
- 如果直接用固定属性名,比如
- this 不是函数的情况
- 规范要求
call/apply的调用者必须是可调用的 - 可以加一道类型判断,给出更友好的错误信息:
- 规范要求
if (typeof fn !== 'function') {
throw new TypeError('myCall must be called on a function');
}
第二部分:手写 apply
理解了 call,apply 就非常简单了,本质只是参数形式不同。
实现思路
- 处理 context(同
call) - 把函数挂到 context 上
- 如果传入的第二个参数是数组或类数组,就展开调用
- 删除临时属性
代码实现
Function.prototype.myApply = function (context, args) {
const fn = this;
if (typeof fn !== 'function') {
throw new TypeError('myApply must be called on a function');
}
context = context || globalThis;
const key = Symbol('fn');
context[key] = fn;
let result;
if (args == null) {
// 没传参数,或者传了 null / undefined
result = context[key]();
} else {
if (!Array.isArray(args) && typeof args !== 'object' && typeof args.length !== 'number') {
throw new TypeError('CreateListFromArrayLike called on non-object');
}
result = context[key](...args);
}
delete context[key];
return result;
};
面试时,如果时间有限,可以先给出一个简化版:只处理数组参数,不深究“类数组”。如果面试官继续追问,再补充类数组的处理方式。
第三部分:手写 bind(基础版)
bind 比 call / apply 要复杂一些,原因有两个:
- 它 不会立即执行函数,而是返回一个新的函数
- 这个新函数需要支持 参数拼接(柯里化)
我们先实现一个基础版:
- 支持 this 绑定
- 支持参数预置
- 先不处理
new场景
实现思路
- 保存原函数引用
fn - 返回一个新的函数
boundFn - 调用
boundFn时:- 拼接
bind时传入的参数 + 调用时传入的参数 - 使用
fn.apply执行,绑定 this 为 context
- 拼接
基础版代码
Function.prototype.myBind = function (context, ...bindArgs) {
const fn = this;
if (typeof fn !== 'function') {
throw new TypeError('myBind must be called on a function');
}
return function (...callArgs) {
const args = [...bindArgs, ...callArgs];
return fn.apply(context, args);
};
};
这一版已经能通过大部分“简单”版本的考察。但在稍微高阶一点的面试中,面试官往往会往下追问:
如果我用
new去调用你 bind 之后的函数,this 应该指向哪里?
这就涉及到 bind 的进阶行为。
第四部分:手写 bind(进阶版,支持 new)
先看原生 bind 的表现
function Person(name) {
this.name = name;
}
const obj = { name: 'obj' };
const BoundPerson = Person.bind(obj);
const p = new BoundPerson('Alice');
console.log(p.name); // 'Alice'
console.log(p instanceof Person); // true
console.log(p instanceof BoundPerson); // true
几个关键信息:
- 用
new BoundPerson()时,this 指向实例 p,而不是 obj - 实例
p既是Person的实例,也是BoundPerson的实例
行为拆解
想要模拟这个行为,我们需要做到:
- 返回的
boundFn在被new调用时:- this 应该指向新创建的实例
- 忽略 bind 传入的
context
boundFn.prototype与 原函数fn.prototype建立原型链关系,保证instanceof判断成立
关键技巧:判断是否通过 new 调用
在 JS 中,可以通过 instanceof 来判断:
boundFn.prototype = Object.create(fn.prototype);
function boundFn(...callArgs) {
const args = [...bindArgs, ...callArgs];
// 如果当前 this 是 boundFn 的实例,说明是通过 new 调用
const isNewCall = this instanceof boundFn;
return fn.apply(isNewCall ? this : context, args);
}
完整进阶版实现
Function.prototype.myBind = function (context, ...bindArgs) {
const fn = this;
if (typeof fn !== 'function') {
throw new TypeError('myBind must be called on a function');
}
function boundFn(...callArgs) {
const args = [...bindArgs, ...callArgs];
// 通过 new 调用时:this 是 boundFn 的实例
const isNewCall = this instanceof boundFn;
return fn.apply(isNewCall ? this : context || globalThis, args);
}
// 原型继承:确保通过 new 调用时,原型链不丢失
if (fn.prototype) {
boundFn.prototype = Object.create(fn.prototype);
// 修正 constructor 指向,属于“錦上添花”的细节
boundFn.prototype.constructor = boundFn;
}
return boundFn;
};
到这里,一个相对完整、接近规范行为的 bind 就实现好了。
第五部分:综合对比与使用场景
三者行为对比
| 方法 | 是否立即执行 | 参数形式 | this 绑定时机 | 典型场景 |
|---|---|---|---|---|
| call | 是 | 逐个传参 | 调用时 | 简单改变 this,调用一次函数 |
| apply | 是 | 数组 / 类数组 | 调用时 | 参数已是数组,如 Math.max.apply |
| bind | 否,返回新函数 | 可预置一部分参数 | 定义 bind 时 | 事件回调、函数柯里化、延迟执行 |
常见应用:借用其他对象的方法
1. 数组方法借用
function likeArray() {
console.log(Array.prototype.slice.call(arguments));
}
likeArray(1, 2, 3); // [1, 2, 3]
理解了 call 的内部实现,本质就是把 arguments 暂时“伪装”成一个数组,然后调用 slice。
2. 事件回调中绑定 this
class Button {
constructor(text) {
this.text = text;
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
console.log(this.text);
}
}
在 React / 原生 DOM 事件中,bind 被广泛用于固定回调中的 this 指向。理解了上面的实现,你就知道它背后做了什么事情。
常见误区
在面试和日常开发中,下面这些误区出现频率很高。逐条对照一下,看看你是否踩过坑。
误区一:bind 后的函数再次 bind 可以改变 this
很多人以为可以”覆盖”之前的绑定,但实际上 bind 只生效一次。
function greet() {
console.log(this.greeting);
}
const a = { greeting: 'Hello' };
const b = { greeting: 'Hola' };
const greetA = greet.bind(a);
const greetB = greetA.bind(b);
greetA(); // 'Hello'
greetB(); // 'Hello' —— 仍然是 a,不是 b!
原因:第一次 bind 返回的闭包内部已经把 context 锁死了。后续再 bind 只是在外面再包了一层函数,但里层的 apply(a, ...) 永远不会变。
误区二:call 的第一个参数传 null 时,this 就是 null
在非严格模式下,如果 thisArg 是 null 或 undefined,this 会自动指向全局对象(浏览器中是 window)。只有在严格模式下,this 才会真的是 null。
function showThis() {
console.log(this);
}
showThis.call(null);
// 非严格模式:window(浏览器)/ global(Node)
// 严格模式:null
所以手写 myCall 时写 context = context || globalThis 其实只模拟了非严格模式的行为。在面试中主动提到这个区别,会让面试官觉得你对规范理解很到位。
误区三:手写 call 时直接用字符串属性名做临时 key
有些同学会写 context._fn = fn,这在大多数场景下能跑,但会出问题:
const obj = {
_fn: '我是原始属性,别覆盖我!',
value: 42,
};
function showValue() {
return this.value;
}
// 如果手写的 myCall 用了 context._fn = fn ...
showValue.myCall(obj); // 调用结束后 obj._fn 被 delete 了,原始属性丢失
正确做法是用 Symbol() 作为临时 key,这样绝对不会和原有属性冲突。
误区四:手写 bind 时用箭头函数作为返回值
箭头函数没有自己的 this,也不能被 new 调用。如果返回的是箭头函数,就永远无法正确处理 new 场景。
// 错误示范
Function.prototype.badBind = function (context, ...bindArgs) {
const fn = this;
// 箭头函数没有 prototype,也不能用 new 调用
return (...callArgs) => fn.apply(context, [...bindArgs, ...callArgs]);
};
const Ctor = function (x) { this.x = x; };
const Bound = Ctor.badBind({});
const instance = new Bound(1); // TypeError: Bound is not a constructor
所以手写 bind 返回的必须是普通函数声明 / 函数表达式,不能是箭头函数。
小结
这一章,我们从一道经典面试题出发,完整实现了:
Function.prototype.myCall- 利用“把函数挂到对象上再调用”的思路
- 处理
null/undefined指向全局对象 - 使用
Symbol避免属性名冲突
Function.prototype.myApply- 与
call的唯一区别是参数形式 - 额外提到“类数组”参数的处理
- 与
Function.prototype.myBind- 基础版:绑定 this + 参数预置
- 进阶版:支持
new调用,保证原型链不丢失
如果你能在面试现场:
- 先用 1~2 句话准确说清 call / apply / bind 的行为和差异
- 再像本章这样,一步步写出接近规范的实现,并主动提到各种边界和细节
那么这一道题,对你来说不仅仅是“拿分”,更是一个向面试官展示代码功底 + 规范意识 + 细节敏感度的绝佳机会。
下一章,我们会继续沿着 JavaScript 篇的主线,拆解更多高频手写题和底层机制,帮你在面试中稳稳拿下 Javascript 这一大块基础盘。
本章思维导图
- 目标
- 复习三者原生行为
- 手写 myCall / myApply / myBind
- call
- 功能:改 this + 立即执行
- 关键实现:临时挂到 context 上执行再删除
- apply
- 功能:和 call 一样
- 差异:参数用数组 / 类数组
- bind
- 功能:返回绑定过 this 的新函数
- 基础版:预置参数 + 延迟执行
- 进阶版:支持 new,new 绑定优先于 bind
- 使用场景
- 数组方法借用
- 事件回调中绑定 this
练习挑战
光看不练假把式。下面三道题从易到难,建议先自己动手写,写完再看参考思路。
挑战一:实现 myCall,但不允许使用 Symbol
限制条件:不能使用 Symbol,也不能使用 ES6+ 的展开运算符(...),只能用 ES5 语法。你需要想办法生成一个”几乎不会冲突”的临时属性名,并用 eval 或其他方式把参数传进去。
点击查看思路提示
- 临时属性名可以用时间戳 + 随机数拼接,比如
'__myCall_' + Date.now() + Math.random(),虽然不如Symbol完美,但面试中这样回答能体现你理解问题本质。 - 参数传递可以拼字符串:
context[key](args[0], args[1], ...),或者构造一个参数字符串用eval/new Function执行。
Function.prototype.myCallES5 = function (context) {
context = context || window;
var key = '__myCall_' + Date.now() + Math.random();
context[key] = this;
var args = [];
for (var i = 1; i < arguments.length; i++) {
args.push('arguments[' + i + ']');
}
var result = eval('context[key](' + args.join(',') + ')');
delete context[key];
return result;
};
挑战二:实现一个 softBind
原生 bind 的特点是”一次绑定,永不可改”。请实现一个 softBind:默认绑定 this 到指定对象,但如果调用时 this 已经被显式绑定到其他对象(不是全局对象),则尊重那个新的 this。
function hello() {
console.log('Hello, ' + this.who);
}
const defObj = { who: 'default' };
const otherObj = { who: 'other' };
const softHello = hello.softBind(defObj);
softHello(); // 'Hello, default'
softHello.call(otherObj); // 'Hello, other' —— 允许被 call 覆盖
softHello.call(null); // 'Hello, default' —— null 时回退到默认绑定
点击查看思路提示
关键在于返回的函数内部做一个判断:如果当前 this 是全局对象或 undefined,就用默认的 context;否则用当前的 this。
Function.prototype.softBind = function (defaultCtx, ...bindArgs) {
const fn = this;
return function (...callArgs) {
// this 是全局对象或 undefined 时,回退到 defaultCtx
const finalCtx =
!this || this === globalThis ? defaultCtx : this;
return fn.apply(finalCtx, [...bindArgs, ...callArgs]);
};
};
挑战三:实现 promisifiedCall
实现一个 promisifiedCall,它的用法类似 call,但返回一个 Promise。如果函数执行成功就 resolve 返回值,如果函数抛出异常就 reject。
function divide(a, b) {
if (b === 0) throw new Error('除数不能为 0');
return a / b;
}
const mathCtx = { precision: 2 };
promisifiedCall(divide, mathCtx, 10, 2)
.then((res) => console.log(res)); // 5
promisifiedCall(divide, mathCtx, 10, 0)
.catch((err) => console.log(err.message)); // '除数不能为 0'
点击查看思路提示
其实就是在手写 call 的外面包一层 Promise:
function promisifiedCall(fn, context, ...args) {
return new Promise((resolve, reject) => {
try {
const key = Symbol('fn');
context = context || globalThis;
context[key] = fn;
const result = context[key](...args);
delete context[key];
resolve(result);
} catch (err) {
reject(err);
}
});
}
这道题把手写 call 和 Promise 结合起来,面试时如果你能自然地写出来,说明你对两个知识点都理解透了。
自我检测
读完全章后,对照下面的清单逐条打勾。如果某一项还模糊,建议回到对应章节重新过一遍。
- 能用一句话说清
call、apply、bind三者的核心区别 - 理解手写
myCall的核心思路:临时挂载 → 隐式绑定 → 删除 - 知道为什么用
Symbol做临时 key,而不是字符串 - 知道
context为null/undefined时,非严格模式和严格模式下 this 分别指向什么 - 能解释
myApply和myCall的实现差异,以及类数组参数的处理方式 - 能写出
myBind基础版:绑定 this + 参数预置(柯里化) - 能写出
myBind进阶版:正确处理new调用场景 - 理解为什么
bind返回的函数不能用箭头函数 - 理解”bind 只生效一次”的原因
- 能独立完成上面”练习挑战”中的至少两道题
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90