由于call
和apply
只是入参时的差别,因此只要搞定了call
,apply
就迎刃而解了。
在实现call
之前,我们需要搞清楚下面的一些问题:
什么是call
?
call
的定义是什么?
下面是MDN对call
的定义:
原版:
中文:
call
的参数是什么?
下面是MDN对call
参数的解释:
原版:
中文:
第一个参数是thisArg
,就是我们在定义中提到的,指定的this
值。
这里有个需要注意的地方就是:在非严格模式下,当this
为null
或者undefined
时,将会自动替换为全局对象。
call
的返回值是什么?
下面是MDN对call
返回值的定义:
原版:
中文:
如何实现call
?
下面我们将分别介绍ES6的解决办法和ES6之前的解决办法
TIPS: 部分示例代码为了简洁,会混用两种不同的写法
应该解决哪些问题?
基于上面三个问题的答案,实际上不难看出需要解决哪些问题
- 如何改变函数的
this
?
- 如何处理传入参数?
- 如何处理返回值问题?
如何改变函数的this?
我们将自己实现的myCall
,添加到Function
的原型上:
Function.prototype.myCall = function (thisArg) {
thisArg.fnName = this; // ①
thisArg.fnName(); // ②
};
通过上面的代码,我们完成了第一个问题,用下面的例子测试一下代码:
const person = {
age: 25
};
function showAge() {
console.log(this.age);
}
// 输出:25
showAge.myCall(person);
在①中:
this
是当前的调用myCall
方法的函数,也就是例子中的showAge
函数。
此时将调用myCall方法的函数放到thisArg.fnName
上(相当于在对象上多加了一个属性),也就是例子中的person.fnName
上。
经过处理后,示例的代码类似于:
const person = {
age: 25,
fnName() {
console.log(this.age);
}
};
这个时候,this的指向实际上就已经改变了。
因此在②中,调用thisArg.fnName()
后,我们就可以很好的解决当前的问题。
但是不要着急,我们还有几个问题没有解决:
- 上面的做法时间上影响了原来的对象(因为我们添加了一个属性),如何消除该影响?
thisArg
上有相同命名的属性,此时可能会有冲突,我们该怎么办?
如何消除副作用?
很简单,我们只要使用delete
,删除掉这个属性即可,这样在上面的例子中,我们后期添加的fnName
就不会影响原来的对象了。
下面是进阶版的myCall
代码:
Function.prototype.myCall = function (thisArg) {
thisArg.fnName = this;
thisArg.fnName();
delete thisArg.fnName;
};
thisArg上有相同命名的属性,此时可能会有冲突,我们该怎么办?
如果想让设置上的属性值唯一,我们可以有两种方案:
- 使用ES6中的
Symbol
- 生成一个随机的属性名称并使用(如果发现对象上有该属性,就再生成一个新的)。
使用Symbol
使用Symbol
非常简单,下面代码就可以轻松实现:
Function.prototype.myCall = function (thisArg) {
const fnName = Symbol();
thisArg[fnName] = this;
thisArg[fnName]();
delete thisArg[fnName];
};
使用随机属性名
默认生成6位随机字符串
检查属性是否冲突,如果有冲突,重新生成
function getRandomKey(length = 6) {
var randomKey = "";
for (var i = 0; i < length; i++) {
// 生成0~9和a-z的随机字符串
randomKey += ((Math.random() * 36) | 0).toString(36);
}
return randomKey;
}
function checkRandomKey(key, obj) {
// 检查当前生成的key值是否已经存在于obj中
return obj[key] === undefined ? key : checkRandomKey(getRandomKey(), obj);
}
将上面的代码放入myCall
中,我们可以得到下面的代码:
Function.prototype.myCall = function (thisArg) {
function getRandomKey(length = 6) {
var randomKey = "";
for (let i = 0; i < length; i++) {
randomKey += ((Math.random() * 36) | 0).toString(36);
}
return randomKey;
}
function checkRandomKey(key, obj) {
return obj[key] === undefined ? key : checkRandomKey(getRandomKey(), obj);
}
var fnName = checkRandomKey(getRandomKey(), thisArg);
thisArg[fnName] = this;
thisArg[fnName]();
delete thisArg[fnName];
};
如何处理传入参数?
对于传入参数的处理,同样有两种办法来实现
- 通过
剩余参数
获取传入值,解构
获取的参数值并传入函数内 - 使用
arguments对象
以及eval函数
剩余参数+解构
在ES6中,剩余参数允许我们将一个不定数量的参数表示为一个数组。
把剩余参数得到的数组,解构后传给需要调用的函数,即可解决这个问题。
下面是升级后的myCall
代码:
Function.prototype.myCall = function (thisArg, ...args) { // ①
const fnName = Symbol();
thisArg[fnName] = this;
thisArg[fnName](...args); // ②
delete thisArg[fnName];
};
现在用一个例子,来测试一下上方的代码:
const person = {
age: 25
};
function showInfo(name, location) {
console.log(name);
console.log(location);
console.log(this.age);
}
// 输出:"zhangsan" "beijing" 25
showInfo.myCall(person, 'zhangsan', 'beijing');
在①中,通过剩余参数,示例中的name
和location
参数最终会成为一个数组:["zhangsan", "beijing"]
。
在②中,将这个数组解构后,传入调用的函数内。
arguments对象 + eval函数
在ES6之前,对于获取函数内不定参数的场景,通常会使用argument对象
(这个对象是一个类数组)拿到所有传入的参数,处理后,使用字符串拼接,最终通过eval
函数运行。
下面的代码将会获取除thisArg
之外所有的参数:
// 初始化参数数组
var args = [];
// 忽略第一个thisArg参数
for (var i = 1; i < arguments.length; i++) {
args.push("arguments[" + i + "]")
}
// 循环完成后将会得到数组: ["arguments[1], arguments[2]", ...]
我们执行eval代码,即可完成函数调用
eval("thisArg[fnName](" + args + ")");
刚刚不是说,eval是处理字符串,但是为啥在这直接传入数组进入,难到不会报错么?
其实并不会,eval执行时,会自动调用Array.prototype.toString()
方法来处理数组。
此时我们的myCall
代码将会升级为:
Function.prototype.myCall = function (thisArg) {
var fnName = Symbol(); // 此处可替换为随机生成key方案,为了演示代码的简洁性,使用了ES6
var args = [];
for (var i = 1; i < arguments.length; i++) {
args.push("arguments[" + i + "]")
}
thisArg[fnName] = this;
eval("thisArg[fnName](" + args + ")");
delete thisArg[fnName];
};
如何处理返回值问题?
相较于之前的问题来说,这个问题算是最容易解决的。我们只要把函数执行的返回值,直接返回就可以了。
Function.prototype.myCall = function (thisArg, ...args) {
const fnName = Symbol();
let result;
thisArg[fnName] = this;
result = thisArg[fnName](...args);
delete thisArg[fnName];
return result
};
当函数有返回值时,最终的返回值将会赋值给result
,并最终作为myCall
的返回值。 当函数没有返回值时,此时将其赋值给一个变量时,值为undefined
。
因此满足上方最初的定义。
如何处理thisArg默认值的问题?
如果thisArg
并没有传入或者传入null
时,则默认绑定全局变量,因此我们可以将myCall
的代码升级为:
Function.prototype.myCall = function (thisArg, ...args) {
thisArg = thisArg || window;
const fnName = Symbol();
let result;
thisArg[fnName] = this;
result = thisArg[fnName](...args);
delete thisArg[fnName];
return result
};
什么是apply?
apply
与call
的不同点在于:
中文:
英文:
注意:在ES5之后,apply也接受类数组参数。如果需要传入类数组,请注意兼容性问题。
如何通过改动myCall实现myApply?
不兼容类数组:
Function.prototype.myApply = function (thisArg, argsArray) { // ①
thisArg = thisArg || window || global;
const fnName = Symbol();
let result;
thisArg[fnName] = this;
result = !argsArray ? thisArg[fnName]() : thisArg[fnName](...argsArray); // ②
delete thisArg[fnName];
return result
};
在①中,将剩余参数
(...args)替换为argsArray
参数
②中,在调用函数时,解构数组即可。如果未传入参数,则直接调用
兼容类数组
Function.prototype.myApply = function (thisArg, argsArray) {
thisArg = thisArg || window || global;
const fnName = Symbol();
let result;
thisArg[fnName] = this;
if (!argsArray) {
result = thisArg[fnName]()
} else {
result = Array.isArray(argsArray) ? thisArg[fnName](...argsArray) : thisArg[fnName](...Array.from(argsArray)) // ①
}
delete thisArg[fnName];
return result
};
在①中:
我们判断为类数组时,将其转化为数组
如果判断是数组,则直接解构。apply
仅介绍了es6实现方法,你同样也可以使用es3实现,有兴趣的同学可以自己试一试。
拓展阅读
如果对上面介绍的某些知识点不是非常理解,希望你在读完本篇文章后可以阅读下面的相关文章
参考资料
你同样可以阅读下面的文章来加深理解,本文同样参考自下面文章:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。