Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
yygmind opened this issue Dec 4, 2018 · 15 comments

Comments

@yygmind
Copy link
Owner

yygmind commented Dec 4, 2018

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

call() 和 apply()

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

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

举个例子:

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

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

使用场景

下面列举一些常用用法:

1、合并两个数组

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, ...]

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

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 方法。

3、验证是否是数组

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

4、类数组对象(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, ...] 

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

  • 1、具有:指向对象元素的数字索引下标和 length 属性
  • 2、不具有:比如 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代码可以快速方便地通过类型化数组来操作原始的二进制数据,这将会非常有帮助。

一句话就是,可以更快的操作复杂数据。

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

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()主要有以下两点

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

模拟实现第一步

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

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

foo.bar(); // 1

这个改动就可以实现:改变了this的指向并且执行了函数bar

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

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

所以只要实现下面3步就可以模拟实现了。

  • 1、将函数设置为对象的属性:foo.fn = bar
  • 2、执行函数:foo.fn()
  • 3、删除函数: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个细节需要注意:

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

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

// 第三版
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。

https://github.com/yygmind/blog

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

@cbbfcd
Copy link

cbbfcd commented Jan 25, 2019

差一个bind

@yygmind yygmind changed the title 【进阶3-3期】深度广度解析 call 和 apply 原理、使用场景及实现 【进阶3-3期】深度解析 call 和 apply 原理、使用场景及实现 Feb 11, 2019
@yygmind
Copy link
Owner Author

yygmind commented Feb 11, 2019

@C-somnus
Copy link

C-somnus commented Feb 23, 2019

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);
}

// 是不是应该加个arr 是不是数组的判读
if (arr instanceof Array) {
result = context.fn(...arr)
} else {
result = context.fn()
}

delete context.fn
return result;

}

@liaofujia
Copy link

这是关于apply的实现
var result;
// 判断是否存在第二个参数
if (!arr) {
result = context.fn();
} else {
如果第二个参数是空的话,这里执行函数,干吗要返回result

@weileilei945
Copy link

看完之后更理解call了

@GrayFrost
Copy link

call的模拟实现,一开始的例子里的变量用不同值表示比较好。

var value = 1; //改成其他值
var foo = {
    value: 1
};

@Terry-bear
Copy link

感觉可以出一本书了。
关于push的问题,是不是说如果出现数组太大的情况,如果不循环调用,会出现合并后丢参的情况呢?

@sailei1
Copy link

sailei1 commented Jul 8, 2019

call apply 增加了函数的灵活性,使JS更加灵活, 但这样做加深了this的复杂性。估计后期出bind 跟现在的箭头函数 就是为了缓解这一现象。

@StevenX911
Copy link

JavaScript类型化数组是一种类似数组的对象,并提供了一种用于访问原始二进制数据的机制。

个人感觉类型化数组才是真正的数组,只不过在JS中定义的数组承担了更多角色,既能充当栈(push/pop),又能作为队列(unshift/push),但是后一句怎么理解?如何就能访问原始二进制的数据?

@kangenpingmai
Copy link

想问一下,模拟call的时候,context怎么就是拿的第一个参数啊
执行这句 context.fn = this;
这个context不是代表传入的所有参数吗

@weileilei945
Copy link

想问一下,模拟call的时候,context怎么就是拿的第一个参数啊
执行这句 context.fn = this;
这个context不是代表传入的所有参数吗

因为call第一个参数代表把this指向谁

@diff-c
Copy link

diff-c commented Mar 16, 2020

你好,我想问下,Function.prototype.call.bind和Function.prototype.bind这样写有什么区别吗?

@TechnicalGod
Copy link

既然你前几篇都特意声明 “ eval ” 性能差,你为什么演示还用这个呢,是否会误导

@parkeeers
Copy link

想问一下,模拟call的时候,context怎么就是拿的第一个参数啊
执行这句 context.fn = this;
这个context不是代表传入的所有参数吗

  1. 谁调用this,this就指向谁
  2. fn.call(context, 1, 2, ,3) == context.fn(1,2,3)
  3. 第二步实现了context调用fn,就意味着fn中的this指向了context

@Chorer
Copy link

Chorer commented May 20, 2021

call 的实现有几个问题:

  • 传进去的 context 如果是 null 或者 undefined 的话,this 会指向全局对象,这里全局对象不一定是 window 吧(非浏览器环境),window 换成 globalThis 可能更好
  • 如果 context 是 false 的话,最终 this 会指向全局对象,但实际上它应该指向 Boolean {false}
  • 在一开始应该先检查 context 是否是函数,不是的话要抛出一个错误

apply 的实现,要检查传进去的是不是数组(或者类数组对象),不是数组的话也要抛出错误

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests