壹 ❀ 引
我在 五种绑定策略彻底弄懂this 一文中,我们提到call,apply,bind
属于显示绑定,这三个方法都能直接修改this指向。其中call与apply比较特殊,它们在修改this的同时还会直接执行方法,而bind只是返回一个修改完this的boundFunction
并未执行,那么今天我们来讲讲如果通过JavaScript模拟实现call与apply方法。
贰 ❀ 关于call与apply
贰 ✿ 壹 call与apply区别
除了都能改变this指向并执行函数,call与apply唯一区别在于参数不同,具体如下:
1var fn = function (arg1, arg2) {
2 // do something
3};
4
5fn.call(this, arg1, arg2); // 参数散列
6fn.apply(this, [arg1, arg2]) // 参数使用数组包裹
call第一参数为this指向,后续散列参数均为函数调用所需形参,而在apply中这些参数被包裹在一个数组中。
贰 ✿ 贰 使用场景
call与apply在日常开发中非常实用,我们在此列举几个实用的例子。
检验数据类型:
1function type(obj) {
2 var regexp = /\s(\w+)\]/;
3 var result = regexp.exec(Object.prototype.toString.call(obj))[1];
4 return result;
5};
6
7console.log(type([123]));//Array
8console.log(type('123'));//String
9console.log(type(123));//Number
10console.log(type(null));//Null
11console.log(type(undefined));//Undefined
数组取最大/小值:
1var arr = [11, 1, 0, 2, 3, 5];
2// 取最大
3var max1 = Math.max.call(null, ...arr);
4var max2 = Math.max.apply(null, arr);
5// 取最小
6var min1 = Math.min.call(null, ...arr);
7var min2 = Math.min.apply(null, arr);
8
9console.log(max1); //11
10console.log(max2); //11
11console.log(min1); //0
12console.log(min2); //0
函数arguments类数组操作:
1var fn = function () {
2 var arr = Array.prototype.slice.call(arguments);
3 console.log(arr); //[1, 2, 3, 4]
4};
5fn(1, 2, 3, 4);
关于这两个方法实用简单说到这里,毕竟本文的核心主旨是手动实现call与apply方法,我们接着说。
叁 ❀ 实现一个call方法
我们从一个简单的例子解析call方法
1var name = '时间跳跃';
2var obj = {
3 name: '听风是风'
4};
5
6function fn() {
7 console.log(this.name);
8};
9fn(); //时间跳跃
10fn.call(obj); //听风是风
在这个例子中,call方法主要做了两件事:
- 修改了
this
指向,比如fn()
默认指向window
,所以输出时间跳跃
- 执行了函数
fn
叁 ✿ 壹 改变this并执行方法
先说第一步改变this
怎么实现,其实很简单,只要将方法fn
添加成对象obj
的属性不就好了。所以我们可以这样:
1//模拟call方法
2Function.prototype.call_ = function (obj) {
3 obj.fn = this; // 此时this就是函数fn
4 obj.fn(); // 执行fn
5 delete obj.fn; //删除fn
6};
7fn.call_(obj); // 听风是风
注意,这里的call_
是我们模拟的call
方法,我们来解释模拟方法中做了什么。
- 我们通过
Function.prototype.call_
的形式绑定了call_
方法,所以所有函数都可以直接访问call_
。 fn.call_
属于this隐式绑定,所以在执行时call_
时内部this
指向fn
,这里的obj.fn = this
就是将方法fn
赋予成了obj
的一条属性。obj
现在已经有了fn
方法,执行obj.fn
,因为隐式绑定的问题,fn
内部的this
指向obj
,所以输出了听风是风
。- 最后通过
delete
删除了obj
上的fn
方法,毕竟执行完不删除会导致obj
上的属性越来越多。
叁 ✿ 贰 传参
我们成功改变了this指向并执行了方法,但仍有一个问题待解决,call_
无法接受参数。
其实也不难,我们知道函数有一个arguments
属性,代指函数接收的所有参数,它是一个类数组,比如下方例子:
1Function.prototype.call_ = function (obj) {
2 console.log(arguments);
3};
4fn.call_(obj, 1, 2, 3);// [{name:'听风是风'},1,2,3...]
很明显arguments
第一位参数是我们需要让this指向的对象,所以从下标1开始才是真正的函数参数,这里就得对arguments
进行加工,将下标1之后的参数剪切出来。
有同学肯定就想到了arguments.splice
,前面说了arguments并非数组,所以不支持Array
方法。没关系,不是还有Array.prototype.slice.call(arguments)
吗,转一次数组再用。很遗憾,我们现在是在模拟call
方法,也不行。那就用最保险的for
循环吧,如下:
1Function.prototype.call_ = function (obj) {
2 var args = [];
3 // 注意i从1开始
4 for (var i = 1, len = arguments.length; i < len; i++) {
5 args.push(arguments[i]);
6 };
7 console.log(args);// [1, 2, 3]
8};
9fn.call_(obj, 1, 2, 3);
数组也不能直接作为参数传递给函数,有同学可能想到array.join
字符拼接方法,这也存在一个问题,比如我们是希望传递参数1 2 3
三个参数进去,但经过join
方法拼接,它会变成一个参数"1,2,3"
,函数此时接受的就只有一个参数了。
所以这里我们不得不借用恶魔方法eval,看个简单的例子:
1var fn = function (a, b, c) {
2 console.log(a + b + c);
3};
4var arr = [1, 2, 3];
5
6fn(1, 2, 3);//6
7eval("fn(" + arr + ")");//6
你一定有疑问,为什么这里数组arr
都不分割一下,fn
在执行时又如何分割数组呢?其实eval
在执行时会将变量转为字符串,这里隐性执行了arr.toString()
。来看个有趣的对比:
1console.log([1, 2, 3].toString()); //"1,2,3"
2console.log([1, 2, 3].join(',')); //"1,2,3"
可以看出“eval帮我们做了数组处理,这里就不需要再使用
join方法了,因此
eval(“fn(” + arr + “)”)可以看成
eval(“fn(1,2,3)”)`。
我们整理下上面的思路,改写后的模拟方法就是这样:
1var name = '时间跳跃';
2var obj = {
3 name: '听风是风'
4};
5
6function fn(a, b, c) {
7 console.log(a + b + c + this.name);
8};
9//模拟call方法
10Function.prototype.call_ = function (obj) {
11 var args = [];
12 // 注意i从1开始
13 for (var i = 1, len = arguments.length; i < len; i++) {
14 args.push(arguments[i]);
15 };
16 obj.fn = this; // 此时this就是函数fn
17 eval("obj.fn(" + args + ")"); // 执行fn
18 delete obj.fn; //删除fn
19};
20fn.call_(obj, "我的", "名字", "是");
可以了吗?很遗憾,这段代码会报错。因为我们传递的后三个参数都是字符串。在args.push(arguments[i])
这一步我们提前将字符串进行了解析,这就导致eval
在执行时,表达式变成了eval("obj.fn(我的,名字,是)");
设想一下我们普通调用函数的形式是这样obj.fn("我的","名字","是")
,所以对于eval而言就像传递了三个没加引号的字符串,无法进行解析。
不信我们可以传递三个数字,比如:
1fn.call_(obj, 1,2,3); // 6听风是风
因为数字不管加不加引号,作为函数参数都是可解析的,而字符串不加引号,那就被认为是一个变量,而不存在我的
这样的变量,自然就报错了。
怎么办呢?其实我们可以在args.push(arguments[i])
这里先不急着解析,改写成这样:
1args.push("arguments[" + i + "]");
遍历完成的数组args最终就是这个样子["arguments[1]","arguments[2]","arguments[3]"]
,当执行eval
时,arguments[1]
此时确实是作为一个变量存在不会报错,于是被eval
解析成了一个真正的字符传递给了函数。
所以改写后的call_
应该是这样:
1var name = '时间跳跃';
2var obj = {
3 name: '听风是风'
4};
5
6function fn(a, b, c) {
7 console.log(a + b + c + this.name);
8};
9//模拟call方法
10Function.prototype.call_ = function (obj) {
11 var args = [];
12 // 注意i从1开始
13 for (var i = 1, len = arguments.length; i < len; i++) {
14 args.push("arguments[" + i + "]");
15 };
16 obj.fn = this; // 此时this就是函数fn
17 eval("obj.fn(" + args + ")"); // 执行fn
18 delete obj.fn; //删除fn
19};
20fn.call_(obj, "我的", "名字", "是"); // 我的名字是听风是风
叁 ✿ 叁 考虑特殊this指向
我们知道,当call
第一个参数为undefined
或者null
时,this默认指向window
,所以上面的方法还不够完美,我们进行最后一次改写,考虑传递参数是否是有效对象:
1var name = '时间跳跃';
2var obj = {
3 name: '听风是风'
4};
5
6function fn(a, b, c) {
7 console.log(a + b + c + this.name);
8};
9//模拟call方法
10Function.prototype.call_ = function (obj) {
11 //判断是否为null或者undefined,同时考虑传递参数不是对象情况
12 obj = obj ? Object(obj) : window;
13 var args = [];
14 // 注意i从1开始
15 for (var i = 1, len = arguments.length; i < len; i++) {
16 args.push("arguments[" + i + "]");
17 };
18 obj.fn = this; // 此时this就是函数fn
19 eval("obj.fn(" + args + ")"); // 执行fn
20 delete obj.fn; //删除fn
21};
22fn.call_(obj, "我的", "名字", "是"); // 我的名字是听风是风
23fn.call_(null, "我的", "名字", "是"); // 我的名字是时间跳跃
24fn.call_(undefined, "我的", "名字", "是"); // 我的名字是时间跳跃
那么到这里,对于call
方法的模拟就完成了。
肆 ❀ 实现一个apply方法
apply
方法因为接受的参数是一个数组,所以模拟起来就更简单了,理解了call
实现,我们就直接上代码:
1var name = '时间跳跃';
2var obj = {
3 name: '听风是风'
4};
5
6function fn(a, b, c) {
7 console.log(a + b + c + this.name);
8};
9//模拟call方法
10Function.prototype.apply_ = function (obj, arr) {
11 obj = obj ? Object(obj) : window;
12 obj.fn = this;
13 if (!arr) {
14 obj.fn();
15 } else {
16 var args = [];
17 // 注意这里的i从0开始
18 for (var i = 0, len = arr.length; i < len; i++) {
19 args.push("arr[" + i + "]");
20 };
21 eval("obj.fn(" + args + ")"); // 执行fn
22 };
23 delete obj.fn; //删除fn
24};
25fn.apply_(obj, ["我的", "名字", "是"]); // 我的名字是听风是风
26fn.apply_(null, ["我的", "名字", "是"]); // 我的名字是时间跳跃
27fn.apply_(undefined, ["我的", "名字", "是"]); // 我的名字是时间跳跃
伍 ❀ 总
上述代码总有些繁杂,我们来总结下这两个方法:
1// call模拟
2Function.prototype.call_ = function (obj) {
3 //判断是否为null或者undefined,同时考虑传递参数不是对象情况
4 obj = obj ? Object(obj) : window;
5 var args = [];
6 // 注意i从1开始
7 for (var i = 1, len = arguments.length; i < len; i++) {
8 args.push("arguments[" + i + "]");
9 };
10 obj.fn = this; // 此时this就是函数fn
11 var result = eval("obj.fn(" + args + ")"); // 执行fn
12 delete obj.fn; //删除fn
13 return result;
14};
15// apply模拟
16Function.prototype.apply_ = function (obj, arr) {
17 obj = obj ? Object(obj) : window;
18 obj.fn = this;
19 var result;
20 if (!arr) {
21 result = obj.fn();
22 } else {
23 var args = [];
24 // 注意这里的i从0开始
25 for (var i = 0, len = arr.length; i < len; i++) {
26 args.push("arr[" + i + "]");
27 };
28 result = eval("obj.fn(" + args + ")"); // 执行fn
29 };
30 delete obj.fn; //删除fn
31 return result;
32};
如果允许使用ES6,使用拓展运算符会简单很多,实现如下:
1// ES6 call
2Function.prototype.call_ = function (obj) {
3 obj = obj ? Object(obj) : window;
4 obj.fn = this;
5 // 利用拓展运算符直接将arguments转为数组
6 let args = [...arguments].slice(1);
7 let result = obj.fn(...args);
8
9 delete obj.fn
10 return result;
11};
12// ES6 apply
13Function.prototype.apply_ = function (obj, arr) {
14 obj = obj ? Object(obj) : window;
15 obj.fn = this;
16 let result;
17 if (!arr) {
18 result = obj.fn();
19 } else {
20 result = obj.fn(...arr);
21 };
22
23 delete obj.fn
24 return result;
25};