深入学习js之——call和apply#10

1,084 阅读12分钟

深入学习js系列是自己阶段性成长的见证,希望通过文章的形式更加严谨、客观地梳理js的相关知识,也希望能够帮助更多的前端开发的朋友解决问题,期待我们的共同进步。

如果觉得本系列不错,欢迎点赞、评论、转发,您的支持就是我坚持的最大动力。


开篇

ECMAScript3 给 Function 的原型定义了两个方法,他们是 Function.prototype.callFunction.prototype.apply 在实际开发中特别是在一些函数式风格的代码书写中,call 和 apply 方法尤其重要。

call 和 apply 的区别

Function.prototype.callFunction.prototype.apply都是非常常用的方法,他们的作用一模一样,区别仅仅是传入的参数形式不同。

apply 接收两个参数,第一个参数指定了函数体内部 this 对象的指向,第二个参数为一个带下标的集合,这个集合可以是数组,也可以为类数组,apply 方法把这个集合中的元素作为参数传递给被调用的函数。

var func = function(a, b, c) {
  console.log([a, b, c]); // => [1,2,3]
};
func.apply(null, [1, 2, 3]);

在这段代码中,参数 1,2,3 被放在一个数组中一起传递给 func 函数,他们分别对应 func 参数列表中的 a, b, c

call 传入的参数数量不固定,跟 apply 相同的是,第一个参数也是代表函数体内的 this 指向,从第二个参数开始往后,每个参数被依次传入函数:

var func = function(a, b, c) {
  console.log([a, b, c]); // 输出 [1,2,3]
};
func.call(null, 1, 2, 3);

当调用一个函数时候,js 的解析器并不会计较形参和实参的数量、类型以及顺序上的区别,js 的参数在内部就是用一个数组来表示的,从这个意义上面来说,call 比 apply 的使用率更高,我们不必关心具体有多少参数被传入函数,只要使用 call 一股脑的推进去就可以了。

apply 是包装在 call 上面的一颗语法糖,如果我们明确的知道了函数接收多少个参数,而且想一目了然的表达形参和实参的对应关系,那么就可以使用 apply 来传递参数。

当我们使用 call 或者 apply 的时候,如果我们传入的第一个参数为 null,函数体内部的 this 会指向默认的宿主对象,在浏览器中则是 window:

var func = function(a, b, c) {
  alert(this === window); // true
};
func.apply(null, [1, 2, 3]);

但是在严格模式下面,函数体内部的 this 还是 null

var func = function(a, b, c) {
  "use strict";
  alert(this === null); // 输出true
};

func.apply(null, [1, 2, 3]);

有时候我们使用 call 或者 apply 的目标并不是在于指定 this 指向而是另有用途 比如借用其他对象的方法,那么我们可以传入 null 来代替某一个具体的对象;

Math.max.apply(null, [1, 2, 4, 5]); // 输出5

写到这里我们总结一下:

他们俩之间的差别在于参数的区别,call 和 aplly 的第一个参数都是要改变上下文的对象,而 call 从第二个参数开始以参数列表的形式展现,apply 则是把除了改变上下文对象的参数放在一个数组里面作为它的第二个参数。

call 和 apply 的用途

1.改变 this 指向:

call 和 apply 最常见的用途就是改变函数内部的 this 指向,我们看个例子:

var obj1 = {
  name: "louis"
};

var obj2 = {
  name: "jack"
};

window.name = "window";

var getName = function() {
  alert(this.name);
};

getName(); //输出 window
getName.call(obj1); // 输出 louis
getName.call(obj2); // 输出 jack

当执行 getName.call(obj1)这句代码的时候,getName 函数体内的 this 指向 obj1 对象,所以此处的

var getName = function () {
  alert(this.name);
}

实际上相当于:

var getName = function () {
  alert(obj1.name); // 输出louis
}

实际开发中,我们会经常遇到 this。指向被不经意改变的场景,比如有一个 div 节点,div 节点的 onclick 事件中的 this 指向本来是指向这个 div 的:

document.getElementById("div1").onclick = function() {
  alert(this.id); // div1
};

假如该事件中有一个内部函数 func,在事件内部调用 func 的时候,func 函数体内部的 this 就指向了 window 而不是我们预期的 div,见如下代码;

document.getElementById("div1").onclick = function() {
  alert(this.id); // 输出:div1
  var func = function() {
    alert(this.id); // 输出:undefined  window 上面没有id 属性
  };
  func();
};

这个时候我们可以使用 call 来修正 func 函数内部的 this,使其依然指向 div:

document.getElementById("div1").onclick = function() {
  var func = function() {
    alert(this.id); // 输出:div1
  };
  func.call(this);
};

2.Function.prototype.bind

大部分的高级浏览器都实现了内置的 Function.prototype.bind, 用来指定函数内部的 this 指向即使没有原生的 Function.prototype.bind 实现,我们来模拟一个也不是难事。

Function.prototype.bind = function(context) {
  var self = this; // 保存原函数
  return function() {
    // 返回一个新的函数
    return self.apply(context, arguments);
    // 执行新的函数的时候,会把之前传入的context 当作新函数体内的this
  };
};

var obj = {
  name: "sven"
};

var func = function() {
  alert(this.name); // 输出:sven
}.bind(obj);
func();

我们通过Function.prototype.bind来包装'func'函数,并且传入一个对象 context 当做参数,这个 context 就是我们想要修正的 this 对象。

Function.prototype.bind的内部实现中,我们先把 func 函数的引用保存起来,然后返回一个新的函数。当我们在将来执行 func 函数时,实际上先执行的是这个刚刚返回的新函数。在新函数内部,self.apply(context,arguments)这句代码才是执行原来的 func 函数,并且指定 context 对象为 func 函数体内的 this。

3.借用其他对象的方法

我们知道,杜鹃既不会筑巢,也不会孵雏,而是把自己的蛋寄托给云雀等其他鸟类,让它们代为孵化和养育。同样,在 JavaScript 中也存在类似的借用现象。

借用方法的第一种场景是“借用构造函数”,通过这种技术,可以实现类似于继承的效果:

function Parent(value) {
  this.val = value;
}
Parent.prototype.getValue = function() {
  console.log(this.val);
};
function Child(value) {
  Parent.call(this, value);
}
Child.prototype = new Parent();

const child = new Child(1);

child.getValue(); // 1
child instanceof Parent; // true

借用方法的第二种运用场景跟我们的关系更加紧密。

函数的参数列表 arguments 是一个类数组对象,虽然它也有"下标",但是它并非真正的数组,所以也不能像数组一样进行排序操作或者往集合里面添加一个新的元素,这种情况下,我们常常使用 Array.prototype 对象上面的方法,比如想往 auguments 中添加一个新的元素,通常会借用 Array.prototype.push:

(function() {
  Array.prototype.push.call(arguments, 3);
  console.log(arguments); // [1,2,3]
})(1, 2);

在操作 arguments 的时候,我们经常非常频繁地找 Array.prototype 对象借用方法。

想把 arguments 转成真正数组的时候,可以借用Array.prototype.slice 方法;想要截取 arguments 列表中的头一个元素的时候,又可以借用Array.prototype.shift方法,那么这种机制的内部实现原理是什么呢?我们可以看看 V8 引擎源码,我们以 Array.prptotype.push()为例子,看看具体实现:

  function ArrayPush(){
    var n = TO_UINT32( this.length );    // 被push的对象的length
    var m = %_ArgumentsLength();     // push的参数个数
    for (var i = 0; i < m; i++) {
        this[ i + n ] = %_Arguments( i );   // 复制元素     (1)
    }
    this.length = n + m;      // 修正length属性的值    (2)
    return this.length;”
  }

通过这段代码可以看到,Array.prototype.push 实际上是一个属性复制的过程,把参数按照下标依次添加到push的对象上面,顺便修改了这个对象的length属性,至于被修改的对象是谁,到底是数组还是类数组对象,这一点并不重要

按照这种推断,我们可以把”任意“的对象传入 Array.prototype.push;

var a = {};
Array.prototype.call(a,'first');

console.log(a.length);// 输出 1
console.log(a[0]); // first

前面之所以把"任意"两个字加了双引号,是因为可以借用Array.prototype.push方法的对象还需要满足以下两个条件: 1、对象本身要可以存取属性 2、对象的length属性可以读写。

对于第一个条件,对象本身存取属性并没有问题,但是如果借用Array.prototype.push方法的不是一个object类型数据而是一个number类型的数据呢?因为number是基本数据类型,我们无法在number 身上存取其他的数据,那么从下面的测试代码可以发现,一个number类型的数据是不能借用到Array.prototype.push 方法:

var a = 1;
Array.prototype.push.call(a,'first');
console.log(a.length);// 输出 undefined
console.log(a[0]); // 输出 undefined

对于第二个条件,函数的length 属性就是一个只读的属性,表示形参的个数,我们尝试把一个函数当做this传入 Array.prototype.push:

var func = function(){}
Array.prototype.push.call(func,'first');
console.log(func.length);

报错:cannot assign to read only property ‘length’ of function(){}

call的模拟实现

为了实现 call 我们首先用一句话简单的介绍一下 call :

call() 方法在使用一个指定的 this 值和若干个指定的参数值的前提下调用某个函数或者方法

举一个例子:

var foo = {
  value: 1
};

function bar() {
  console.log(this.value);
}

bar.call(foo); // 1

这里需要注意两点: 1、call 改变了 this 的指向,指向到了 foo 2、bar 函数执行了

接下来我们尝试模拟实现 call 的这个功能:

模拟实现第一步:

试想当我们调用 call 的时候,把 foo 对象改造如下:

var foo = {
  value: 1,
  bar: function() {
    console.log(this.value);
  }
};

这个时候 this 就指向了 foo

但是这样却给 foo 本身添加了一个属性,这样可不行!

不过没有关系,我们使用 delete 删除了就行

所以我们的模拟的步骤可以分为:

1、将函数设置为对象的属性。 2、执行这个函数。 3、删除这个函数。

以上的例子就是 :

// 第一步
foo.fn = bar;
// 第二步
foo.fn();
// 第三步
delete foo.fn;

fn 是对象的属性名,反正最后也要删除它,所以起成什么名字无所谓 根据这个思路,我们可以尝试写一版,call2 函数:

// 第一版
Function.prototype.call2 = function(context) {
  // 首先要获取调用call的函数,用this可以获取
  context.fn = this;
  context.fn();
  delete context.fn;
};

// 测试一下
var foo = {
  value: 1
};

function bar() {
  console.log(this.value);
}

bar.call(foo); // 1;

上述代码中, 因为一个函数调用了 call2 这个函数,因此在call2 函数的内部可以拿到这个this,同时这个this 指向的就是调用call2的函数 我们模拟的目的也是将这个函数作为参数添加进context这个被绑定的对象上面

模拟实现第二步

最一开始我们说了,call 函数还能给定参数执行函数,举一个例子:

var foo = {
  value: 1
};

function bar(name, age) {
  console.log(name);
  console.log(age);
  console.log(this.value);
}
bar.call(foo, "kevin", 18);
// kevin
// 18
// 1

注意:传入的参数并不确定,这可怎么办? 不急,我们可以从 Arguments 对象中取值,取出第二个到最后一个参数,然后放到一个数组里面。

比如这样:

  // arguments = {
  //   0:foo,
  //   1:'kevin',
  //   2:18,
  //   lenght:3
  // }
  因为arguments 是类数组对象,所以可以使用for 循环

  var args = [];
  for( var i = 1;len = arguments.length;i<len;i++){
    args.push('arguments['+ i +']');
  }

  // 执行之后 arguments 为 ["arguments[1]","arguments[2]","[arguments[3]"]

不定长的参数的问题解决了,接着我们要把这个参数数组放到要执行的函数的参数里面去,这里我们使用 eval 方法拼接成一个函数,类似于这样:

eval("context.fn(" + args + ")");

这里 args 会自动调用 Array.toString() 这个方法。

这里的eval可能不是那么容易理解,这里做一个简单的补充说明:

eval函数接收的参数是一个字符串(这点非常重要) 定义和用法:

eval() 函数可计算某个字符串,并执行其中的的 JavaScript 代码。

语法:

eval(string)

string必需。要计算的字符串,其中含有要计算的 JavaScript 表达式或要执行的语句。该方法只接受原始字符串作为参数,如果 string 参数不是原始字符串,那么该方法将不作任何改变地返回。因此请不要为 eval() 函数传递 String 对象来作为参数。

简单来说吧,就是用JavaScript的解析引擎来解析这一堆字符串里面的内容,这么说吧,你可以这么理解,你把eval看成是<script>标签。

eval('function Test(a,b,c,d){console.log(a,b,c,d)};Test(1,2,3,4)')

所以我们第二版刻克服了两个问题,代码如下:


Function.prototype.call2 = function(context){
  context.fn = this;
  var args = [];
  for( var i = 1;len = arguments.length;i<len;i++){
    args.push('arguments['+ i +']');
  }

  eval('context.fn('+args+')');
  delete context.fn;
}

//测试
var foo = {
  value:1
};

function bar(name,age){
  console.log(name);
  console.log(age);
  console.log(this.value);
}
bar.call2(foo,'kevin',18);
// kevin
// 18
// 1

模拟第三步骤

模拟代码已经完成了 80%,还有两个小点需要注意:

1、this 参数可以传递 null,当为 null 的时候,视为指向 window

举一个例子:

var value = 1;
function bar() {
  console.log(this.value);
}
bar.call(null); // 1

虽然这个例子本身是不是使用 call 的结果都一样

2、函数是可以有返回值的

举个例子:

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

console.log(bar.call(obj, "kevin", 18));
// Object{
//  value:1,
//  name:'kevin',
//  age:18
// }

不过都很好解决,让我们直接看第三版也就是最最后一版的代码:

// 第三版
Function.prototype.call2 = function(context) {
  var context = context || window;
  context.fn = this;

  var args = [];
  for (var i = 1, len = arguments.length; i < len; i++) {
    args.push("arguments[" + i + "]");
  }

  var result = eval("context.fn(" + args + ")");

  delete context.fn;
  return result;
};
// 测试一下
var value = 2;

var obj = {
  value: 1
};

function bar(name, age) {
  console.log(this.value);
  return {
    value: this.value,
    name: name,
    age: age
  };
}

bar.call2(null); // 2

console.log(bar.call2(obj, "kevin", 18));
// 1
// Object {
//    value: 1,
//    name: 'kevin',
//    age: 18
// }

到这里 我们完成了 call 的模拟实现,给自己一个  赞。

apply 的模拟实现

apply 的模拟实现和 call 类似,在这里直接给出代码:

// 测试一下
var value = 2;

var obj = {
  value: 1
};

function bar(name, age) {
  console.log(this.value);
  return {
    value: this.value,
    name: name,
    age: age
  };
}

bar.call2(null); // 2

console.log(bar.call2(obj, "kevin", 18));
// 1
// Object {
//    value: 1,
//    name: 'kevin',
//    age: 18
// }

深入学习JavaScript系列目录

欢迎添加我的个人微信讨论技术和个体成长。

欢迎关注我的个人微信公众号——指尖的宇宙,更多优质思考干货