手写call、apply和bind函数

手写call、apply和bind函数

2021年04月08日 17:15 ·  阅读 637

前言

提到改变 this 的指向,首先想到的方式就是 call、apply 和 bind。对于每种方式底层是如何实现,大多数人不太清楚,如果你还不清楚他们的用法,请移步callapplybind。本文会简单讲解他们的用法,底层实现思路,及模拟实现 call、apply、bind。

call

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

    function.call(this, arg1, arg2, arg3, ...)
复制代码

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

const obj = {
  value: 1,
}

function fn(arg) {
  console.log(this.value, arg); // 1, 2
}

fn.call(obj, 2);
复制代码

上面的例子,使用 call()方法使函数fn的 this 指向了obj,所以 this.value 的值为 1。那么如果不使用 call()方法,该如何实现呢?

2、call 实现思路

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

const obj = {
  value: 1,
  fn: function() {
    console.log(this.value); // 1
  }
}
obj.fn();
复制代码

这样一改,this 就指向了 obj,根据这个思路,可以封装一个方法,将传入的 this,转换成这样的方式,那么当前 this 的指向就是我们想要的结果。需要注意fn函数不能写成箭头函数,因为箭头函数没有this。所以模拟的步骤为:

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

3、模拟 call 方法

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

Function.prototype.call1 = function (context) {
  context.func = this;
  context.func();
  delete context.func;
}
复制代码

实现了简化版 call 方法,来试验下是否能正确改变 this。

const obj = {
  value: 1,
}

function fn(arg) {
  console.log(this.value, arg); // 1, undefined
}

fn.call1(obj, 2);
复制代码

根据上面例子,已经能正确改变 this 的指向,但是传入的值却没有拿到,该怎么办呢?考虑的传入的值是不确定的,只能借助Arguments 对象。通过它可以拿到所有传入的参数。

4、模拟 call 方法第二版

上面提到可以通过 arguments 解决传入参数不定长问题,如下:

const res = [];
// 因为第一个参数是传入的this,故这里从i = 1开始遍历
for (let i = 1; i < arguments.length; i++) {
    res.push(arguments[i]);
}
// 或者
const args = [...arguments].splice(0, 1);
复制代码

这样就能拿到所有的参数,接下来我们是不是将拿到的参数放到函数里面执行就可以了吗?先试一下:

Function.prototype.call1 = function(context) {
    // 初始化存储函数参数
    const res = [];
    // 改变当前函数的this指向
    context.fn = this;

    for (let i = 1; i < arguments.length; i++) {
        res.push(arguments[i]);
    }

    context.fn(res.join(','));
    delete context.fn;
}
const obj = {
    value: 1,
}
function fn(val1, val2) {
    console.log(this.value, ...arguments); // 1 "1,2,200,[object Object]"
}
fn.call1(obj, [1,2], 200, {a: 1});
复制代码

结果和我们期望的不一致,函数的参数被转换成了一个字符串,那么怎么出来才能达到想要的结果呢?这儿可以考虑两种方式处理

第一种方式 es6 的“...”操作符,如下:

Function.prototype.call1 = function(context) {
    // 初始化存储函数参数
    const res = [];
    // 改变当前函数的this指向
    context.fn = this;

    for (let i = 1; i < arguments.length; i++) {
        res.push(arguments[i]);
    }

    context.fn(...res);
    delete context.fn;
}
const obj = {
    value: 1,
}
function fn(val1, val2) {
    console.log(this.value, ...arguments);  // 1 [1, 2] 200 {a: 1}
}
fn.call1(obj, [1,2], 200, {a: 1});
复制代码

第二种方式,借助eval方法,eval 会将传入的字符串当做 JavaScript 代码执行。那么我们可以考虑将要执行的函数,拼装成字符串,然后通过 eval 执行即可。思路如下:

 Function.prototype.call1 = function(context) {
    // 初始化,用于存放参数
    const args = [];
    // 在传入的对象上设置属性为待执行函数
    context.fn = this;

    for (let i = 1; i < arguments.length; i++) {
        // 将参数从第二个开始,拼装成待执行的字符串参数列表
        args.push(`arguments[${i}]`);
    }

    eval(`context.fn(${args})`);

    delete context.fn;
 }
  const obj = {
    value: 1,
  };
  function fn(val1, val2) {
    console.log(this.value, ...arguments); // 1 [1, 2] 200 {a: 1}
  }
  fn.call1(obj, [1, 2], 200, { a: 1 });
复制代码

到这里,通过模拟实现 call 方法,已经能实现改变 this,传入参数,是不是就完了呢?可能会有这样一种情况,如果函数本身会有返回值,还是用吗?如下:

  const obj = {
    value: 1,
  };
  function fn() {
    const args = [...arguments];
    console.log(this.value, ...args); // 1 [1, 2] 200 {a: 1}
    return {
      c: args,
    };
  }
  console.log(fn.call1(obj, [1, 2], 200, { a: 1 })); // undefined
复制代码

此时,执行函数的返回值为undefined,解决这个问题很好办,在封装的 call 方法里面,将执行的函数结果存下来,return 出来即可,如下:

Function.prototype.call1 = function (context) {
    // 初始化,用于存放参数
    const args = [];
    // 在传入的对象上设置属性为待执行函数
    context.fn = this;

    for (let i = 1; i < arguments.length; i++) {
      // 将参数从第二个开始,拼装成待执行的字符串参数列表
      args.push(`arguments[${i}]`);
    }

    eval(`context.fn(${args})`);
    delete context.fn;
  };

  const obj = {
    value: 1,
  };
  function fn() {
    const args = [...arguments];
    console.log(this.value, ...args); // 1 [1, 2] 200 {a: 1}
    return {
      c: args[0],
    };
  }
  console.log(fn.call1(obj, [1, 2], 200, { a: 1 })); // {c: [1, 2]}
复制代码

模拟 call 终版

上面的方式都是使用 eval 来实现 call 方法,es6 提供了很多语法糖,个人比较喜欢 es6 的实现方式,比较简洁,故终版使用的 es6 的实现方式。

Function.prototype.call1 = function () {
    // 初始化,获取传入的this对象和后续所有参数
    const [context, ...args] = [...arguments];
    // 在传入的对象上设置属性为待执行函数
    context.fn = this;
    // 执行函数
    const res = context.fn(args);
    // 删除属性
    delete context.fn;

    // 返回执行结果
    return res;
  };
复制代码

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

模拟 apply 方法

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

func.apply(thisArg, [argsArray]);
复制代码

从定义上知道,apply 相比于 call 方法,区别在与 this 后面的参数,call 后面的有一个或多个参数,而 apply 只有两个参数,第二个参数是一个数组,如下:

const obj = {
    value: 1,
};

function fn() {
    console.log(this.value); // 1

    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: 1,
};

function fn() {
    console.log(this.value); // 1

    return [...arguments]
}

console.log(fn.apply(obj, [1, 2])); // [1, 2]
复制代码

模拟 bind

定义:创建一个新的函数,在 bind 被调用时,这个新函数的 this 被指定为 bind()的第一个参数,而其余参数将作为新函数的参数,供调用时使用。

function.bind(thisArg[, arg1[, arg2[, ...]]])
复制代码

bind 相比于 call、apply 有较大的区别,bind 方法会创建一个新的函数,返回一个函数,并允许传入参数。首先看一个例子。

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

const func = obj.fn;
console.log(func()); // undefined
复制代码

为什么值是 undefined 呢?这会涉及到 this 的问题,不清楚的可以看这里,简单来讲,函数的调用决定 this 的值,即运行时绑定。这里声明了 func 用于存放 obj.fn,再执行 func()方法时,当前的 this 指向的是 window 是,故值为 undefined。改如何处理才能达到预期的值呢?这时 bind 即将登场。

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

const func = obj.fn;
const bindFunc = func.bind(obj);
console.log(bindFunc()); // 1
复制代码

模拟 bind 第一版

首先解决 bind 的第一个问题,返回一个函数,可通过闭包的模式实现,改变 this 指向问题,可以使用 call 和 apply 方法,可参照上面的实现方式。

Function.prototype.bind1 = function (context) {
  // 将当前函数的this存放起来
  const _self = this;

  return function () {
    // 改变this
    return _self.apply(context);
  };
};
复制代码

this 改变了后,需要考虑第二个问题,传参,bind 的参数,从第二个到第 n 个函数,存在参数不定的情况,结合上面 call 的实现方式,解决这个问题如下:

// 方法1, 从第二个参数开始
const args = Array.prototype.slice.call(arguments, 1);

// 方法2
const [context, ...args] = [...arguments];
复制代码

参数取到后,将参数传入即可,如下:

Function.prototype.bind1 = 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: 1,
  fn: function () {
    return {
      value: this.value,
      args: [...arguments],
    };
  },
};

const func = obj.fn;
const bindFunc = func.bind1(obj, 1, 2);
console.log(bindFunc(3)); // { value: 1, args: [1, 2, 3]}
复制代码

到这里,bind 的模拟已经完成一半,为什么说完成一半呢?功能已经实现,考虑到有这样一种情况,将绑定的 bind 返回的新函数作为构造函数使用,使用new操作符去创建一个由目标函数创建的新实例。当绑定函数是用来构建一个值的,原来提供的 this 就会被忽略。什么意思呢?先看下面例子:

var value = 1;
var obj = {
  value: 100,
};
function Person(name, age) {
  this.name = name;
  this.age = age;

  console.log(this.value); // undefined
  console.log(name); // jack
  console.log(age); // 35
}

var bindPerson = Person.bind1(obj, "jack");
var bp = new bindPerson(35);
复制代码

从上面的结果可以看出,尽管已经在全局和 obj 上定义了 value 值,但是构造函数 Person 中拿到的 this.value 仍然是 undefined 值,说明 this 的绑定失效了,为什么会出现这样的情况呢? 出现这样的情况是因为关键字new造成的,当程序遇到 new 会进行如下的操作:

  1. 创建一个空的简单的 JavaScript 对象(如{});
  2. 设置该对象的 constructor 到另外一个对象;
  3. 将步骤 1 创建的对象作为 this 的上下文;
  4. 如果函数没有返回对象则返回 this; 这样就明白为什么 this.value 的值为 undefined 了,当前的 this 指向的是 bp,bp 上并没有 value 属性,所以为 undefined。

模拟 bind 终版

Function.prototype.bind1 = function (context) {
  // 将当前函数的this存放起来
  var _self = this;
  // 绑定bind传入的参数,从第二个开始
  var args = Array.prototype.slice.call(arguments, 1);

  // 声明一个空的构造函数
  function fNOP() {}

  var fBound = function () {
    // 绑定bind返回新的函数,执行所带的参数
    const bindArgs = Array.prototype.slice.apply(arguments);
    // 合并数组
    args.push.apply(args, bindArgs);
    // 作为普通函数,this指向Window
    // 作为构造函数,this指向实例
    return _self.apply(this instanceof fNOP ? this : context, args);
  };

  if (this.prototype) {
    // 修改返回函数的prototype为绑定函数的prototype,实例就可以继承绑定函数的原型中的值
    fNOP.prototype = this.prototype;
  }

  // FNOP继承fBound
  fBound.prototype = new fNOP();

  return fBound;
};
复制代码

在这个方法里面用到了原型与原型链、继承等知识,不清楚的可以转到到《JavaScript 之深入原型与原型链》《JavaScript 之深入多种继承方式及优缺点》。 到这里,还需要思考调用 bind 不是函数怎么办?报个错就好了

  if (typeof this !== "function") {
  throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
  }
复制代码

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

参考文献

[0]《Function.prototype.call()》

[1]《Function.prototype.bind()》

[2]《Function.prototype.apply()》

[3]《JavaScript深入之bind的模拟实现》

[4]《JavaScript深入之call和apply的模拟实现》

分类:
前端
标签:

posted on 2022-05-13 11:28  漫思  阅读(48)  评论(0编辑  收藏  举报

导航