模拟实现 apply 和 call 方法

先来通过 MDN 认识下 call 和 apply

语法

func.apply(thisArg, [argsArray])

参数

thisArg:可选的,func 函数运行的时使用的 this

⚠️

  • 如果这个函数处于非严格模式下 指定其为 null 或者 undefined 时 this 绑定会应用默认规则这在分析 js 指向问题时有提到
  • 如果 thisArg 是原始值会被包装称对象 .apply(2) 会被包装成.apply(Number(2))

argsArray:可选的。一个数组或者类数组对象,其中的数组元素将作为单独的参数传给 func 函数。如果该参数的值为 null 或 undefined,则表示不需要传入任何参数。从 ECMAScript 5 开始可以使用类数组对象


返回值
调用有指定 this 值和参数的函数的结果


几个有用的例子感受下 apply 的魔力

求数组最大最小值

聪明的 apply 用法允许你在某些本来需要写成遍历数组变量的任务中使用内建的函数

使用 Math.max/Math.min 来找出一个数组中的最大 / 最小值

1
2
3
4
5
6
var numbers = [5, 6, 2, 3, 7];
var max = Math.max.apply(null, numbers);
console.log(max);

var min = Math.min.apply(null, numbers);
console.log(min);

apply 设置的 this 值

1
2
3
4
5
6
7
8
var doSth = function(a, b){
console.log(this);
console.log([a, b]);
}
doSth.apply(null, [1, 2]); // this是window // [1, 2]
doSth.apply(0, [1, 2]); // this 是 Number(0) // [1, 2]
doSth.apply(true); // this 是 Boolean(true) // [undefined, undefined]
doSth.call(undefined, 1, 2); // this 是 window // [1, 2]

用 apply 将一个数组添加到另一个数组

如果我们传递一个数组来推送,它实际上会将该数组作为单个元素添加,而不是单独添加元素,因此我们最终得到一个数组内的数组
concat 确实具有我们想要的行为,但它实际上并不附加到现有数组,而是创建并返回一个新数组
apply 就能简单实现

1
2
3
4
5
var array = ['a', 'b'];
var elements = [0, 1, 2];
array.push.apply(array,elements) // ["a", "b", 0, 1, 2]
//array.push(elements) //) ["a", "b", 0, 1, 2, Array(3)]
console.log(array);

call () 与 apply () 非常相似

fun.call(thisArg, arg1, arg2, ...)

call 和 apply 的不同点

  • apply 只接收两个参数,第二个参数可以是数组也可以是类数组,其实也可以是对象,后续的参数忽略不计
  • call 接收第二个及以后一系列的参数

小结

重新认识了 call 和 apply 会发现
它们作用都是一样的,改变函数里的 this 指向为第一个参数 thisArg,如果明确有多少参数,那可以用 call,不明确则可以使用 apply。也就是说完全可以不使用 call,而使用 apply 代替,我们只需要模拟实现 applycall 可以根据参数个数都放在一个数组中,给到 apply 即可


模拟实现的准备工作

模拟之前 我们先得看看 ES5 规范 关于 apply 摘抄以下几条

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Function.prototype.apply (thisArg, argArray)

当以 thisArg 和 argArray 为参数在一个 func 对象上调用 apply 方法,采用如下步骤:

1.如果 IsCallable(func) 是 false, 则抛出一个 TypeError 异常。

2.如果 argArray 是 null 或 undefined, 则返回提供 thisArg 作为 this 值并以空参数列表调用 func 的 [[Call]] 内部方法的结果。

3.返回提供 thisArg 作为 this 值并以空参数列表调用 func 的 [[Call]] 内部方法的结果。

4.如果 Type(argArray) 不是 Object, 则抛出一个 TypeError 异常。
...
9.提供 thisArg 作为 this 值并以 argList 作为参数列表,调用 func 的 [[Call]] 内部方法,返回结果。

apply 方法的 length 属性是 2。

10.在外面传入的 thisArg 值会修改并成为 this 值。thisArg 是 undefined 或 null 时它会被替换成全局对象,所有其他值会被应用 ToObject 并将结果作为 this 值,这是第三版引入的更改

结合上文和规范 ,明确了要解决的问题,我们如何将函数里的 this(一般指向 window)指向第一个参数 thisArg 呢
不由得想起来了介绍 this 指向那一篇文章
那就采用隐式绑定呀,也就是说 既然他现有的上下文环境是 window(全局作用域), 那我们就手动给他创建一个非全局上下文

看看这个熟悉的例子

1
2
3
4
5
6
7
8
9
10
11
12
var doSth = function(){
console.log(this);
console.log(this.name);
console.log(arguments);
}
var student = {
name: 'yishu',
doSth: doSth,
};
student.doSth(1, 2); // this === student // true // 'yishu' // [1, 2]

doSth.apply(student, [1, 2]); // this === student // true // 'yishu' // [1, 2]

你能看出来什么?
在对象 student 上加一个函数 doSth,再执行这个函数,这个函数里的 this 就指向了这个对象

那我们就模拟这个对象,给他添加一个函数,使用函数调用之后再删除它

第一版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 浏览器环境 非严格模式
function getGlobalObject(){
return this;
}

Function.prototype.applyFn = function apply(thisArg,argsArray){
// 1.如果 `IsCallable(func)` 是 `false`, 则抛出一个 `TypeError` 异常。
if(typeof this !='function'){
throw new TypeError(this + 'is not function')
}
// 1.如果 `IsCallable(func)` 是 `false`, 则抛出一个 `TypeError` 异常。
if(typeof argsArray === 'undefined' || argsArray === null){
argsArray = [];
}

//3.如果 Type(argArray) 不是 Object, 则抛出一个 TypeError 异常 .
if(argsArray !== new Object(argsArray)){
throw new TypeError('CreateListFromArrayLike called on non-object');
}
//4.改变this的指向 在外面传入的 thisArg 值会修改并成为 this 值 如果传入的是 undefined或者null 则this指向应用默认绑定
if(typeof thisArg === 'undefined' || thisArg === null){
// ES3: thisArg 是 undefined 或 null 时它会被替换成全局对象 浏览器里是window
thisArg = getGlobalObject();
}
//开始表演
thisArg = new Object(thisArg);
thisArg.fn = this;
//接收返回值
var fnResult = thisArg.fn(...argsArray);
delete thisArg.fn;
return fnResult;
}


var doSth = function(){
console.log(this);
console.log(this.name);
console.log(arguments);
}
var student = {
name: '马小莹',
//doSth: doSth, //我们主要模拟了这个函数
};
doSth.applyFn(student, [1, 2]);

// {name: "马小莹", doSth: ƒ, fn: ƒ}
// 马小莹
// Arguments(2) [1, 2, callee: ƒ, Symbol(Symbol.iterator): ƒ]

看起来很完美,那它有没有问题呢? 其实是有的

.fn 函数同名覆盖问题,thisArg 对象上有 fn,那就被覆盖了然后被删除了

那我们就找一个唯一值的函数名

1
2
3
4
5
6
7
thisArg = new Object(thisArg);
var _fn = '__fn' + new Date().getTime();
thisArg[_fn] = this;
//接收返回值
var fnResult = thisArg[_fn](...argsArray);
delete thisArg[_fn];
return fnResult;

到现在 简单版本的 apply 已经实现了,现实业务场景不需要去模拟实现 call和apply, 毕竟是 ES3 就提供的方法

既然实现了 apply,call 也就简单了,原理就是拿到 call 的参数 转换成数组,然后调用 applyFn

1
2
3
4
5
6
7
8
9

Function.prototype.applyFn = function apply(thisArg){
var argsArray = [];
var argumentsLength = arguments.length;
for(var i = 0; i < argumentsLength - 1; i++){
argsArray.push(arguments[i + 1]);
}
return this.applyFn(thisArg, argsArray);
}

总结

  1. 通过 MDN 认识 call 和 apply,阅读 ES5 规范,到模拟实现 apply,再实现 call