【进阶3-3期】深度解析 call 和 apply 原理、使用场景及实现

更新:谢谢你们的支持,最近折腾了一个博客官网出来,方便你们系统阅读,后续会有更多内容和更多优化,猛戳这里查看html

------ 如下是正文 ------前端

以前文章详细介绍了 this 的使用,不了解的查看【进阶3-1期】。node

call() 和 apply()

call() 方法调用一个函数, 其具备一个指定的 this 值和分别地提供的参数(参数的列表)。webpack

call()apply()的区别在于,call()方法接受的是若干个参数的列表,而apply()方法接受的是一个包含多个参数的数组git

举个例子:github

var func = function(arg1, arg2) {
     ...
};

func.call(this, arg1, arg2); // 使用 call,参数列表
func.apply(this, [arg1, arg2]) // 使用 apply,参数数组
复制代码

使用场景

下面列举一些经常使用用法:web

一、合并两个数组

var vegetables = ['parsnip', 'potato'];
var moreVegs = ['celery', 'beetroot'];

// 将第二个数组融合进第一个数组
// 至关于 vegetables.push('celery', 'beetroot');
Array.prototype.push.apply(vegetables, moreVegs);
// 4

vegetables;
// ['parsnip', 'potato', 'celery', 'beetroot']
复制代码

当第二个数组(如示例中的 moreVegs )太大时不要使用这个方法来合并数组,由于一个函数可以接受的参数个数是有限制的。不一样的引擎有不一样的限制,JS核心限制在 65535,有些引擎会抛出异常,有些不抛出异常但丢失多余参数。面试

如何解决呢?方法就是将参数数组切块后循环传入目标方法算法

function concatOfArray(arr1, arr2) {
    var QUANTUM = 32768;
    for (var i = 0, len = arr2.length; i < len; i += QUANTUM) {
        Array.prototype.push.apply(
            arr1, 
            arr2.slice(i, Math.min(i + QUANTUM, len) )
        );
    }
    return arr1;
}

// 验证代码
var arr1 = [-3, -2, -1];
var arr2 = [];
for(var i = 0; i < 1000000; i++) {
    arr2.push(i);
}

Array.prototype.push.apply(arr1, arr2);
// Uncaught RangeError: Maximum call stack size exceeded

concatOfArray(arr1, arr2);
// (1000003) [-3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]
复制代码

二、获取数组中的最大值和最小值

var numbers = [5, 458 , 120 , -215 ]; 
Math.max.apply(Math, numbers);   //458 
Math.max.call(Math, 5, 458 , 120 , -215); //458

// ES6
Math.max.call(Math, ...numbers); // 458
复制代码

为何要这么用呢,由于数组 numbers 自己没有 max方法,可是 Math有呀,因此这里就是借助 call / apply 使用 Math.max 方法。跨域

三、验证是不是数组

function isArray(obj){ 
    return Object.prototype.toString.call(obj) === '[object Array]';
}
isArray([1, 2, 3]);
// true

// 直接使用 toString()
[1, 2, 3].toString(); 	// "1,2,3"
"123".toString(); 		// "123"
123.toString(); 		// SyntaxError: Invalid or unexpected token
Number(123).toString(); // "123"
Object(123).toString(); // "123"
复制代码

能够经过toString() 来获取每一个对象的类型,可是不一样对象的 toString()有不一样的实现,因此经过 Object.prototype.toString() 来检测,须要以 call() / apply() 的形式来调用,传递要检查的对象做为第一个参数。

另外一个验证是不是数组的方法

var toStr = Function.prototype.call.bind(Object.prototype.toString);
function isArray(obj){ 
    return toStr(obj) === '[object Array]';
}
isArray([1, 2, 3]);
// true

// 使用改造后的 toStr
toStr([1, 2, 3]); 	// "[object Array]"
toStr("123"); 		// "[object String]"
toStr(123); 		// "[object Number]"
toStr(Object(123)); // "[object Number]"
复制代码

上面方法首先使用 Function.prototype.call函数指定一个 this 值,而后 .bind 返回一个新的函数,始终将 Object.prototype.toString 设置为传入参数。其实等价于 Object.prototype.toString.call()

这里有一个前提toString()方法没有被覆盖

Object.prototype.toString = function() {
    return '';
}
isArray([1, 2, 3]);
// false
复制代码

四、类数组对象(Array-like Object)使用数组方法

var domNodes = document.getElementsByTagName("*");
domNodes.unshift("h1");
// TypeError: domNodes.unshift is not a function

var domNodeArrays = Array.prototype.slice.call(domNodes);
domNodeArrays.unshift("h1"); // 505 不一样环境下数据不一样
// (505) ["h1", html.gr__hujiang_com, head, meta, ...] 
复制代码

类数组对象有下面两个特性

  • 一、具备:指向对象元素的数字索引下标和 length 属性
  • 二、不具备:好比 pushshiftforEach 以及 indexOf等数组对象具备的方法

要说明的是,类数组对象是一个对象。JS中存在一种名为类数组的对象结构,好比 arguments 对象,还有DOM API 返回的 NodeList 对象都属于类数组对象,类数组对象不能使用 push/pop/shift/unshift 等数组方法,经过 Array.prototype.slice.call 转换成真正的数组,就可使用 Array下全部方法。

类数组对象转数组的其余方法:

// 上面代码等同于
var arr = [].slice.call(arguments);

ES6:
let arr = Array.from(arguments);
let arr = [...arguments];
复制代码

Array.from() 能够将两类对象转为真正的数组:类数组对象和可遍历(iterable)对象(包括ES6新增的数据结构 Set 和 Map)。

PS扩展一:为何经过 Array.prototype.slice.call() 就能够把类数组对象转换成数组?

其实很简单,sliceArray-like 对象经过下标操做放进了新的 Array 里面。

下面代码是 MDN 关于 slice 的Polyfill,连接 Array.prototype.slice()

Array.prototype.slice = function(begin, end) {
      end = (typeof end !== 'undefined') ? end : this.length;

      // For array like object we handle it ourselves.
      var i, cloned = [],
        size, len = this.length;

      // Handle negative value for "begin"
      var start = begin || 0;
      start = (start >= 0) ? start : Math.max(0, len + start);

      // Handle negative value for "end"
      var upTo = (typeof end == 'number') ? Math.min(end, len) : len;
      if (end < 0) {
        upTo = len + end;
      }

      // Actual expected size of the slice
      size = upTo - start;

      if (size > 0) {
        cloned = new Array(size);
        if (this.charAt) {
          for (i = 0; i < size; i++) {
            cloned[i] = this.charAt(start + i);
          }
        } else {
          for (i = 0; i < size; i++) {
            cloned[i] = this[start + i];
          }
        }
      }

      return cloned;
    };
  }
复制代码

PS扩展二:经过 Array.prototype.slice.call() 就足够了吗?存在什么问题?

低版本IE下不支持经过Array.prototype.slice.call(args)将类数组对象转换成数组,由于低版本IE(IE < 9)下的DOM对象是以 com 对象的形式实现的,js对象与 com 对象不能进行转换。

兼容写法以下:

function toArray(nodes){
    try {
        // works in every browser except IE
        return Array.prototype.slice.call(nodes);
    } catch(err) {
        // Fails in IE < 9
        var arr = [],
            length = nodes.length;
        for(var i = 0; i < length; i++){
            // arr.push(nodes[i]); // 两种均可以
            arr[i] = nodes[i];
        }
        return arr;
    }
}
复制代码

PS 扩展三:为何要有类数组对象呢?或者说类数组对象是为何解决什么问题才出现的?

JavaScript类型化数组是一种相似数组的对象,并提供了一种用于访问原始二进制数据的机制。 Array存储的对象能动态增多和减小,而且能够存储任何JavaScript值。JavaScript引擎会作一些内部优化,以便对数组的操做能够很快。然而,随着Web应用程序变得愈来愈强大,尤为一些新增长的功能例如:音频视频编辑,访问WebSockets的原始数据等,很明显有些时候若是使用JavaScript代码能够快速方便地经过类型化数组来操做原始的二进制数据,这将会很是有帮助。

一句话就是,能够更快的操做复杂数据。

五、调用父构造函数实现继承

function SuperType(){
    this.color=["red", "green", "blue"];
}
function SubType(){
    // 核心代码,继承自SuperType
    SuperType.call(this);
}

var instance1 = new SubType();
instance1.color.push("black");
console.log(instance1.color);
// ["red", "green", "blue", "black"]

var instance2 = new SubType();
console.log(instance2.color);
// ["red", "green", "blue"]
复制代码

在子构造函数中,经过调用父构造函数的call方法来实现继承,因而SubType的每一个实例都会将SuperType 中的属性复制一份。

缺点:

  • 只能继承父类的实例属性和方法,不能继承原型属性/方法
  • 没法实现复用,每一个子类都有父类实例函数的副本,影响性能

更多继承方案查看我以前的文章。JavaScript经常使用八种继承方案

call的模拟实现

如下内容参考自 JavaScript深刻之call和apply的模拟实现

先看下面一个简单的例子

var value = 1;
var foo = {
    value: 1
};

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

bar.call(foo); // 1
复制代码

经过上面的介绍咱们知道,call()主要有如下两点

  • 一、call()改变了this的指向
  • 二、函数 bar 执行了

模拟实现第一步

若是在调用call()的时候把函数 bar()添加到foo()对象中,即以下

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

foo.bar(); // 1
复制代码

这个改动就能够实现:改变了this的指向而且执行了函数bar

可是这样写是有反作用的,即给foo额外添加了一个属性,怎么解决呢?

解决方法很简单,用 delete 删掉就行了。

因此只要实现下面3步就能够模拟实现了。

  • 一、将函数设置为对象的属性:foo.fn = bar
  • 二、执行函数:foo.fn()
  • 三、删除函数:delete foo.fn

代码实现以下:

// 初版
Function.prototype.call2 = function(context) {
    // 首先要获取调用call的函数,用this能够获取
    context.fn = this; 		// foo.fn = bar
    context.fn();			// foo.fn()
    delete context.fn;		// delete foo.fn
}

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

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

bar.call2(foo); // 1
复制代码

完美!

模拟实现第二步

初版有一个问题,那就是函数 bar 不能接收参数,因此咱们能够从 arguments中获取参数,取出第二个到最后一个参数放到数组中,为何要抛弃第一个参数呢,由于第一个参数是 this

类数组对象转成数组的方法上面已经介绍过了,可是这边使用ES3的方案来作。

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

参数数组搞定了,接下来要作的就是执行函数 context.fn()

context.fn( args.join(',') ); // 这样不行
复制代码

上面直接调用确定不行,args.join(',')会返回一个字符串,并不会执行。

这边采用 eval方法来实现,拼成一个函数。

eval('context.fn(' + args +')')
复制代码

上面代码中args 会自动调用 args.toString() 方法,由于'context.fn(' + args +')'本质上是字符串拼接,会自动调用toString()方法,以下代码:

var args = ["a1", "b2", "c3"];
console.log(args);
// ["a1", "b2", "c3"]

console.log(args.toString());
// a1,b2,c3

console.log("" + args);
// a1,b2,c3
复制代码

因此说第二个版本就实现了,代码以下:

// 第二版
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
复制代码

完美!!

模拟实现第三步

还有2个细节须要注意:

  • 一、this 参数能够传 null 或者 undefined,此时 this 指向 window
  • 二、this 参数能够传基本类型数据,原生的 call 会自动用 Object() 转换
  • 三、函数是能够有返回值的

实现上面的三点很简单,代码以下

// 第三版
Function.prototype.call2 = function (context) {
    context = context ? Object(context) : window; // 实现细节 1 和 2
    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; // 实现细节 2
}

// 测试一下
var value = 2;

var obj = {
    value: 1
}

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

function foo() {
    console.log(this);
}

bar.call2(null); // 2
foo.call2(123); // Number {123, fn: ƒ}

bar.call2(obj, 'kevin', 18);
// 1
// {
// value: 1,
// name: 'kevin',
// age: 18
// }
复制代码

完美!!!

call和apply模拟实现汇总

call的模拟实现

ES3:

Function.prototype.call = function (context) {
    context = context ? Object(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;
}
复制代码

ES6:

Function.prototype.call = function (context) {
  context = context ? Object(context) : window; 
  context.fn = this;

  let args = [...arguments].slice(1);
  let result = context.fn(...args);

  delete context.fn
  return result;
}
复制代码

apply的模拟实现

ES3:

Function.prototype.apply = function (context, arr) {
    context = context ? Object(context) : window; 
    context.fn = this;

    var result;
    // 判断是否存在第二个参数
    if (!arr) {
        result = context.fn();
    } else {
        var args = [];
        for (var i = 0, len = arr.length; i < len; i++) {
            args.push('arr[' + i + ']');
        }
        result = eval('context.fn(' + args + ')');
    }

    delete context.fn
    return result;
}
复制代码

ES6:

Function.prototype.apply = function (context, arr) {
    context = context ? Object(context) : window; 
    context.fn = this;
  
    let result;
    if (!arr) {
        result = context.fn();
    } else {
        result = context.fn(...arr);
    }
      
    delete context.fn
    return result;
}
复制代码

思考题

callapply 的模拟实现有没有问题?欢迎思考评论。


PS: 上期思考题留到下一期讲解,下一期介绍重点介绍 bind 原理及实现

参考

JavaScript深刻之call和apply的模拟实现

MDN之Array.prototype.push()

MDN之Function.prototype.apply()

MDN之Array.prototype.slice()

MDN之Array.isArray()

JavaScript经常使用八种继承方案

深刻浅出 妙用Javascript中apply、call、bind

进阶系列目录

  • 【进阶1期】 调用堆栈
  • 【进阶2期】 做用域闭包
  • 【进阶3期】 this全面解析
  • 【进阶4期】 深浅拷贝原理
  • 【进阶5期】 原型Prototype
  • 【进阶6期】 高阶函数
  • 【进阶7期】 事件机制
  • 【进阶8期】 Event Loop原理
  • 【进阶9期】 Promise原理
  • 【进阶10期】Async/Await原理
  • 【进阶11期】防抖/节流原理
  • 【进阶12期】模块化详解
  • 【进阶13期】ES6重难点
  • 【进阶14期】计算机网络概述
  • 【进阶15期】浏览器渲染原理
  • 【进阶16期】webpack配置
  • 【进阶17期】webpack原理
  • 【进阶18期】前端监控
  • 【进阶19期】跨域和安全
  • 【进阶20期】性能优化
  • 【进阶21期】VirtualDom原理
  • 【进阶22期】Diff算法
  • 【进阶23期】MVVM双向绑定
  • 【进阶24期】Vuex原理
  • 【进阶25期】Redux原理
  • 【进阶26期】路由原理
  • 【进阶27期】VueRouter源码解析
  • 【进阶28期】ReactRouter源码解析

交流

进阶系列文章汇总以下,内有优质前端资料,以为不错点个star。

github.com/yygmind/blo…

我是木易杨,网易高级前端工程师,跟着我每周重点攻克一个前端面试重难点。接下来让我带你走进高级前端的世界,在进阶的路上,共勉!

相关文章
相关标签/搜索