likes
comments
collection
share

JavaScript - 手写call、apply和bind函数

作者站长头像
站长
· 阅读数 32

前言

无论在面试时还是使用中,难免会遇到改变this指向的问题,这时我们便会想到call、apply、bind,可对于他们的底层是如何实现,大多数人不太清楚,如果你对他们还不了解,先看看mdn的callapplybind

本文尽量用简洁的语言讲解他们的用法,底层实现思路,模拟实现 call、apply、bind

模拟call

使用一个指定的 this 值和单独给出一个或多个参数来调用一个函数。

function.call(this, arg1, arg2, arg3, ...)

根据定义得知,call()方法有两个作用,一是改变 this 指向,另外一个传递参数,如下:

const obj = {
  value: '我是obj.value',
}

function fn(arg) {
  console.log(this.value, arg); // '我是obj.value', 2
}

fn.call(obj, 2);

上面的例子,使用 call()方法使函数fnthis 指向了obj,所以 this.value 的值为 我是obj.value

那么如果不使用 call()方法,该如何实现呢?

call 实现思路

不考虑使用 call、apply、bind 方法,上面例子 fn 函数如何能拿到 obj 里面的 value 值呢?

改造一下上面的例子

const obj = {
  value: '我是obj.value',
  fn: function() {
    console.log(this.value); // '我是obj.value'
  }
}
obj.fn();

这样一改,this 就指向了 obj,根据这个思路,封装一个将传入的 this转换的方法,那么当前 this 的指向就是我们想要的结果。

需要注意fn函数不能写成箭头函数,因为箭头函数没有this。

所以模拟的步骤为:

  1. 将函数设置为传入对象的属性;
  2. 执行该函数;
  3. 删除该属性; 上面的例子就可以改写为:
// 给obj添加属性func
obj.func = fn;
// 执行函数
obj.func();
// 删除添加的属性
delete obj.func;

开始模拟

根据上面的思路,来模拟实现一版 call()方法。

Function.prototype.myCall = function (obj) {
  obj.func = this;
  obj.func();
  delete obj.func;
}

看下这个极简版 call 方法,能否能正确改变 this。

const obj = {
  value: '我是obj.value',
}

function fn(arg) {
  console.log(this.value, arg); // '我是obj.value', undefined
}

fn.myCall(obj, 2);

实现了!!! 根据上面例子,已经能正确改变 this 的指向!!!

但是传入的值却没有拿到,考虑的传入的值是不确定的,只能借助Arguments 对象(通过它可以拿到所有传入的参数)

现在还有两个问题:

  1. 传入的值却没有拿到,我们可以借助Arguments 对象,再加上es6的'...'操作符将事半功倍。
  2. 自带的call()是有返回值的,我们目前还没有,解决这个问题很好办,在封装的 call 方法里面,将执行的函数结果存下来,return 出来即可

模拟call最终版

使用到了es6的语法糖。

Function.prototype.myCall = function () {
  const [context, ...args] = [...arguments];
  // 在传入的对象上设置属性为待执行函数
  context.fn = this;
  // 执行函数 并获取其返回值
  const res = context.fn(...args);
  // 删除属性
  delete context.fn;
  // 返回执行结果
  return res;
}

const obj = {
  value: '我是obj.value',
}

function fn(canshu1,canshu2,canshu3) {
  console.log(this.value);  // 我是obj.value
  return [canshu1,canshu2,canshu3]
}

const xxx = fn.myCall(obj, 2,3,4);
console.log(xxx) // [2,3,4]

到此,模拟实现了 call 方法。

模拟apply

定义: 调用一个具有给定 this 值的函数,及以一个数组的形式提供的参数。

func.apply(thisArg, [argsArray]);

applycall的区别在于传参方式

call 的参数是分开传递,而 apply 则用数组传,如下:

const obj = {
    value: '我是obj.value',
};

function fn() {
    console.log(this.value); // '我是obj.value'
    return [...arguments]
}

console.log(fn.apply(obj, [1, 2])); // [1, 2]

思路

apply 的实现思路和 call 一样,需要考虑的是 apply 只有两个参数,因此,根据 call 的思路实现如下:

Function.prototype.apply1 = function (context, args) {
    // 给传入的对象添加属性,值为当前函数
    context.fn = this;
    // 判断第二个参数是否存在,不存在直接执行,否则拼接参数执行,并存储函数执行结果
    let res = !args ? context.fn() : context.fn(...args)
    // 删除新增属性
    delete context.fn;
    // 返回函数执行结果
    return res;
}

const obj = {
    value: '我是obj.value',
};

function fn() {
    console.log(this.value); // '我是obj.value'
    return [...arguments]
}

console.log(fn.apply(obj, [1, 2])); // [1, 2]

模拟bind

bindcallapply的区别 ,在于callapply是直接执行,而bind 方法会创建一个新的函数,返回这个函数,并允许传入参数。

首先看一个例子。

const obj = {
  value: '我是obj.value',
  fn: function () {
    return this.value;
  },
};

const func = obj.fn;
console.log(func()); // undefined

为什么会输出 undefined 呢?这涉及到了 this 的问题,不清楚的可以看这里

简单来讲,函数的this取决于谁调用它,直接运行func(),相当于是window.func()

window.func()的this自然是windowfunc函数中想要输出window.value,而在window上没有定义value,那输出的值自然就是undefined

此时就发现,this的值总是乱变,那如何让函数中的锁定this为某对象呢?bind就可以。

const obj = {
  value: '我是obj.value',
  fn: function () {
    return this.value;
  },
};
const func = obj.fn;
const bindFunc = func.bind(obj);
console.log(bindFunc()); // 我是obj.value

开始写自己的bind

首先解决 bind 的第一个问题,返回一个函数。

其中改变 this 指向问题,可以使用 call 和 apply 方法,可参照上面的实现方式。

Function.prototype.myBind = function (context) {
  // 将当前函数的this存放起来
  const _self = this;
  return function () {
    // 改变this
    return _self.apply(context);
  };
};

this 改变了后,需要考虑传参问题,参数是不定的,所以我们使用arguments。

// 关于对此行代码的解释,我写在了本文的最下方
// 此刻可以理解为 arguments.toArray().slice(1)
const args = Array.prototype.slice.call(arguments, 1);

参数取到后,将参数传入即可,最终代码如下:

Function.prototype.myBind = function (context) {
    // 将当前函数的this存放起来
    const _self = this;
    // 绑定bind传入的参数,从第二个开始
    const args = Array.prototype.slice.call(arguments, 1);
    return function () {
      // 绑定bind返回新的函数,执行所带的参数
      const bindArgs = Array.prototype.slice.apply(arguments);
      // 改变this
      return _self.apply(context, [...args, ...bindArgs]);
    };
};

const obj = {
    value: "我是obj.value",
    fn: function (value1, value2, value3, value4) {
      console.log("value1:", value1); // 我是传给fn方法的第一个参数
      console.log("value2:", value2); // 俺是第二个
      console.log("value3:", value3); // 呼呼呼,我是第三个
      console.log("value4:", value4); // 也可以传第四个
      return this.value;
    },
};

const func = obj.fn;
const fn = func.myBind(obj, "我是传给fn方法的第一个参数", "俺是第二个");

console.log(fn("呼呼呼,我是第三个", "也可以传第四个"));

这里模拟的 bind 函数不是最终版,在 CDN 上有bind 实现;

对Array.prototype.slice.call(arguments,1) 的简要理解

首先对其进行简要拆分:

Array.prototype 属性表示 Array 构造函数的原型,并允许您向所有Array对象添加新的属性和方法。 slice(start,end) 是Array原型上的方法,可以对一个数组进行截取(从start开始,不包含end;如果只有 start 则截取从 start 到数组结束的所有元素。)并返回一个新的数组(浅拷贝)。

Array.prototype.slice.call(arguments,1) 可以理解为:改变数组slice方法的作用域,使 this 指向arguments对象,call() 方法的第二个参数表示传递给slice的参数即截取数组的起始位置。这样 arguments 类数组就可以使用数组的方法 slice() 了,否则由于 arguments 是类数组并不是真正的数组,他是不可以使用 Array 的相关方法的。

简单理解为: Array.prototype.slice.call(arguments,1) 能够将具有length属性(这一点需要注意,必须包含length属性)的对象转换为数组,简化记忆为:arguments.toArray().slice(1); 但有一个例外,IE下的节点集合它不能转换(因为IE下的dom对象是以com对象的形式实现,js对象和com对象不能进行转换)。