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

深刻学习js系列是本身阶段性成长的见证,但愿经过文章的形式更加严谨、客观地梳理js的相关知识,也但愿可以帮助更多的前端开发的朋友解决问题,期待咱们的共同进步。javascript

若是以为本系列不错,欢迎点赞、评论、转发,您的支持就是我坚持的最大动力。前端


开篇

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

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 一股脑的推动去就能够了。app

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

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

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方法的对象还须要知足如下两个条件: 一、对象自己要能够存取属性 二、对象的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
复制代码

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

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

模拟实现第一步:

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

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

这个时候 this 就指向了 foo

可是这样却给 foo 自己添加了一个属性,这样可不行!

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

因此咱们的模拟的步骤能够分为:

一、将函数设置为对象的属性。 二、执行这个函数。 三、删除这个函数。

以上的例子就是 :

// 第一步
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%,还有两个小点须要注意:

一、this 参数能够传递 null,当为 null 的时候,视为指向 window

举一个例子:

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

虽然这个例子自己是否是使用 call 的结果都同样

二、函数是能够有返回值的

举个例子:

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系列目录

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

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

相关文章
相关标签/搜索