「一起写轮子」14问带你实现apply和call

修仙大橙子

由于callapply只是入参时的差别,因此只要搞定了callapply就迎刃而解了。

在实现call之前,我们需要搞清楚下面的一些问题:

什么是call

call的定义是什么?

下面是MDN对call的定义:

原版:
image.png

中文:
image.png

call的参数是什么?

下面是MDN对call参数的解释:

原版:
image.png

中文:
image.png

第一个参数是thisArg,就是我们在定义中提到的,指定的this值。

这里有个需要注意的地方就是:在非严格模式下,当thisnull或者undefined时,将会自动替换为全局对象。

call的返回值是什么?

下面是MDN对call返回值的定义:

原版:
image.png

中文:
image.png

如何实现call

下面我们将分别介绍ES6的解决办法和ES6之前的解决办法

TIPS: 部分示例代码为了简洁,会混用两种不同的写法

应该解决哪些问题?

基于上面三个问题的答案,实际上不难看出需要解决哪些问题

  • 如何改变函数的this
image.png
  • 如何处理传入参数?
image.png
  • 如何处理返回值问题?
image.png

如何改变函数的this?

我们将自己实现的myCall,添加到Function的原型上:

Function.prototype.myCall = function (thisArg) {
  thisArg.fnName = this; // ①
  thisArg.fnName(); // ②
};

通过上面的代码,我们完成了第一个问题,用下面的例子测试一下代码:

const person = {
  age: 25
};

function showAge() {
  console.log(this.age);
}

// 输出:25
showAge.myCall(person);

在①中:

this是当前的调用myCall方法的函数,也就是例子中的showAge函数。

此时将调用myCall方法的函数放到thisArg.fnName上(相当于在对象上多加了一个属性),也就是例子中的person.fnName上。

经过处理后,示例的代码类似于:

const person = {
  age: 25,
  fnName() {
    console.log(this.age);
  }
};

这个时候,this的指向实际上就已经改变了。

因此在②中,调用thisArg.fnName()后,我们就可以很好的解决当前的问题。

但是不要着急,我们还有几个问题没有解决:

  1. 上面的做法时间上影响了原来的对象(因为我们添加了一个属性),如何消除该影响?
  2. thisArg上有相同命名的属性,此时可能会有冲突,我们该怎么办?

如何消除副作用?

很简单,我们只要使用delete,删除掉这个属性即可,这样在上面的例子中,我们后期添加的fnName就不会影响原来的对象了。

下面是进阶版的myCall代码:

Function.prototype.myCall = function (thisArg) {
  thisArg.fnName = this;
  thisArg.fnName();
  delete thisArg.fnName;
};

thisArg上有相同命名的属性,此时可能会有冲突,我们该怎么办?

如果想让设置上的属性值唯一,我们可以有两种方案:

  1. 使用ES6中的Symbol
  2. 生成一个随机的属性名称并使用(如果发现对象上有该属性,就再生成一个新的)。
使用Symbol

使用Symbol非常简单,下面代码就可以轻松实现:

Function.prototype.myCall = function (thisArg) {
  const fnName = Symbol();
  thisArg[fnName] = this;
  thisArg[fnName]();
  delete thisArg[fnName];
};
使用随机属性名

默认生成6位随机字符串
检查属性是否冲突,如果有冲突,重新生成

function getRandomKey(length = 6) {
  var randomKey = "";

  for (var i = 0; i < length; i++) {
      // 生成0~9和a-z的随机字符串
    randomKey += ((Math.random() * 36) | 0).toString(36);
  }

  return randomKey;
}

function checkRandomKey(key, obj) {
  // 检查当前生成的key值是否已经存在于obj中
  return obj[key] === undefined ? key : checkRandomKey(getRandomKey(), obj);
}

将上面的代码放入myCall中,我们可以得到下面的代码:

Function.prototype.myCall = function (thisArg) {
  function getRandomKey(length = 6) {
    var randomKey = "";

    for (let i = 0; i < length; i++) {
      randomKey += ((Math.random() * 36) | 0).toString(36);
    }

    return randomKey;
  }

  function checkRandomKey(key, obj) {
    return obj[key] === undefined ? key : checkRandomKey(getRandomKey(), obj);
  }
  
  var fnName = checkRandomKey(getRandomKey(), thisArg);
  thisArg[fnName] = this;
  thisArg[fnName]();
  delete thisArg[fnName];
};

如何处理传入参数?

对于传入参数的处理,同样有两种办法来实现

  1. 通过剩余参数获取传入值,解构获取的参数值并传入函数内
  2. 使用arguments对象以及eval函数

剩余参数+解构

在ES6中,剩余参数允许我们将一个不定数量的参数表示为一个数组。

把剩余参数得到的数组,解构后传给需要调用的函数,即可解决这个问题。

下面是升级后的myCall代码:

Function.prototype.myCall = function (thisArg, ...args) { // ①
  const fnName = Symbol();
  thisArg[fnName] = this;
  thisArg[fnName](...args); // ②
  delete thisArg[fnName];
};

现在用一个例子,来测试一下上方的代码:

const person = {
  age: 25
};

function showInfo(name, location) {
  console.log(name);
  console.log(location);
  console.log(this.age);
}

// 输出:"zhangsan" "beijing" 25
showInfo.myCall(person, 'zhangsan', 'beijing');

在①中,通过剩余参数,示例中的namelocation参数最终会成为一个数组:["zhangsan", "beijing"]

在②中,将这个数组解构后,传入调用的函数内。

arguments对象 + eval函数

在ES6之前,对于获取函数内不定参数的场景,通常会使用argument对象(这个对象是一个类数组)拿到所有传入的参数,处理后,使用字符串拼接,最终通过eval函数运行。

下面的代码将会获取除thisArg之外所有的参数:

  // 初始化参数数组
  var args = [];

  // 忽略第一个thisArg参数
  for (var i = 1; i < arguments.length; i++) {
    args.push("arguments[" + i + "]")
  }
  
  // 循环完成后将会得到数组: ["arguments[1], arguments[2]", ...]

我们执行eval代码,即可完成函数调用

eval("thisArg[fnName](" + args + ")");

刚刚不是说,eval是处理字符串,但是为啥在这直接传入数组进入,难到不会报错么?

其实并不会,eval执行时,会自动调用Array.prototype.toString()方法来处理数组。

此时我们的myCall代码将会升级为:

Function.prototype.myCall = function (thisArg) {
  var fnName = Symbol(); // 此处可替换为随机生成key方案,为了演示代码的简洁性,使用了ES6
  var args = [];
  
  for (var i = 1; i < arguments.length; i++) {
    args.push("arguments[" + i + "]")
  }
  
  thisArg[fnName] = this;

  eval("thisArg[fnName](" + args + ")");
  
  delete thisArg[fnName];
};

如何处理返回值问题?

相较于之前的问题来说,这个问题算是最容易解决的。我们只要把函数执行的返回值,直接返回就可以了。

Function.prototype.myCall = function (thisArg, ...args) {
  const fnName = Symbol();
  let result;
  
  thisArg[fnName] = this;
  result = thisArg[fnName](...args);
  
  delete thisArg[fnName];
  
  return result
};

当函数有返回值时,最终的返回值将会赋值给result,并最终作为myCall的返回值。 当函数没有返回值时,此时将其赋值给一个变量时,值为undefined

因此满足上方最初的定义。

如何处理thisArg默认值的问题?

如果thisArg并没有传入或者传入null时,则默认绑定全局变量,因此我们可以将myCall的代码升级为:

Function.prototype.myCall = function (thisArg, ...args) {
  thisArg = thisArg || window;
  const fnName = Symbol();
  let result;
  
  thisArg[fnName] = this;
  result = thisArg[fnName](...args);
  
  delete thisArg[fnName];
  
  return result
};

什么是apply?

applycall的不同点在于:

中文:
image.png

英文:
image.png

注意:在ES5之后,apply也接受类数组参数。如果需要传入类数组,请注意兼容性问题。

如何通过改动myCall实现myApply?

不兼容类数组:

Function.prototype.myApply = function (thisArg, argsArray) { // ①
  thisArg = thisArg || window || global;
  const fnName = Symbol();
  let result;
  
  thisArg[fnName] = this;

  result = !argsArray ? thisArg[fnName]() : thisArg[fnName](...argsArray); // ②
  
  delete thisArg[fnName];
  
  return result
};

在①中,将剩余参数(...args)替换为argsArray参数

②中,在调用函数时,解构数组即可。如果未传入参数,则直接调用

兼容类数组

Function.prototype.myApply = function (thisArg, argsArray) {
  thisArg = thisArg || window || global;
  const fnName = Symbol();
  let result;
  
  thisArg[fnName] = this;

  if (!argsArray) {
    result = thisArg[fnName]()
  } else {
    result = Array.isArray(argsArray) ? thisArg[fnName](...argsArray) : thisArg[fnName](...Array.from(argsArray))  // ①
  }
  
  delete thisArg[fnName];
  
  return result
};

在①中:

我们判断为类数组时,将其转化为数组
如果判断是数组,则直接解构。
apply仅介绍了es6实现方法,你同样也可以使用es3实现,有兴趣的同学可以自己试一试。

拓展阅读

如果对上面介绍的某些知识点不是非常理解,希望你在读完本篇文章后可以阅读下面的相关文章

参考资料

你同样可以阅读下面的文章来加深理解,本文同样参考自下面文章:

阅读 1.3k

前端小站
前端的内容小记

前端工程师

102 声望
10 粉丝
0 条评论

前端工程师

102 声望
10 粉丝
文章目录
宣传栏