跳到主要内容
预计阅读 37 分钟

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. callapply 最本质的区别是什么?能否只用其中一个实现另一个?

点击查看答案

最本质的区别只有传参形式不同: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 做了几件事:

  1. 把函数挂到传入的 context 上(让它变成这个对象的方法)
  2. 调用这个方法,此时根据 隐式绑定规则,this 就指向了这个对象
  3. 调用结束后,把挂上去的临时方法删除
  4. 返回函数执行的结果

最小可用版本

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;
};

边界与细节

面试中,如果你能主动提到这些点,会是很大的加分项:

  1. context 为 null/undefined 时的处理
    • 非严格模式下,fn.call(null) 等价于 fn.call(window)(浏览器)
    • globalThis 可以同时兼容浏览器和 Node
  2. 避免属性名覆盖
    • 如果直接用固定属性名,比如 _fn,有可能覆盖原对象上的同名属性
    • 使用 Symbol 作为 key,可以确保唯一性
  3. this 不是函数的情况
    • 规范要求 call/apply 的调用者必须是可调用的
    • 可以加一道类型判断,给出更友好的错误信息:
if (typeof fn !== 'function') {
  throw new TypeError('myCall must be called on a function');
}

第二部分:手写 apply

理解了 callapply 就非常简单了,本质只是参数形式不同

实现思路

  1. 处理 context(同 call
  2. 把函数挂到 context 上
  3. 如果传入的第二个参数是数组或类数组,就展开调用
  4. 删除临时属性

代码实现

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(基础版)

bindcall / apply 要复杂一些,原因有两个:

  • 不会立即执行函数,而是返回一个新的函数
  • 这个新函数需要支持 参数拼接(柯里化)

我们先实现一个基础版:

  • 支持 this 绑定
  • 支持参数预置
  • 先不处理 new 场景

实现思路

  1. 保存原函数引用 fn
  2. 返回一个新的函数 boundFn
  3. 调用 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

几个关键信息:

  1. new BoundPerson() 时,this 指向实例 p,而不是 obj
  2. 实例 p 既是 Person 的实例,也是 BoundPerson 的实例

行为拆解

想要模拟这个行为,我们需要做到:

  1. 返回的 boundFn 在被 new 调用时:
    • this 应该指向新创建的实例
    • 忽略 bind 传入的 context
  2. 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

非严格模式下,如果 thisArgnullundefined,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 这一大块基础盘。


本章思维导图

JS:手写 call / apply / bind
  • 目标
    • 复习三者原生行为
    • 手写 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 结合起来,面试时如果你能自然地写出来,说明你对两个知识点都理解透了。


自我检测

读完全章后,对照下面的清单逐条打勾。如果某一项还模糊,建议回到对应章节重新过一遍。

  • 能用一句话说清 callapplybind 三者的核心区别
  • 理解手写 myCall 的核心思路:临时挂载 → 隐式绑定 → 删除
  • 知道为什么用 Symbol 做临时 key,而不是字符串
  • 知道 contextnull / undefined 时,非严格模式和严格模式下 this 分别指向什么
  • 能解释 myApplymyCall 的实现差异,以及类数组参数的处理方式
  • 能写出 myBind 基础版:绑定 this + 参数预置(柯里化)
  • 能写出 myBind 进阶版:正确处理 new 调用场景
  • 理解为什么 bind 返回的函数不能用箭头函数
  • 理解”bind 只生效一次”的原因
  • 能独立完成上面”练习挑战”中的至少两道题

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90