原文:https://zhehuaxuan.github.io/2019/02/26/JavaScript进阶之模拟call和apply/
作者:zhehuaxuan
目的
在JavaScript中有三种方式来改变this
的作用域call
,apply
和bind
。它们在前端开发中很有用。比如:继承,React的事件绑定等,本文先讲用法,再讲原理,最后自己模拟,旨在对这块内容有系统性掌握。
Function.prototype.call()
在MDN中对call()
解释如下:call()
允许为不同的对象分配和调用属于一个对象的函数/方法。
也就是说:一个函数,只要调用call()
方法,就可以把对象以参数传递给函数。
如果还是不明白,不急!我们先来写一个call()
函数最简单的用法:
function source(){
console.log(this.name); //打印 xuan
}
let destination = {
name:"xuan"
};
console.log(source.call(destination));
上述代码会打印出destination
的name
属性,也就是说source()
函数通过调用call()
,source()
函数中的this
<=>destination
对应起来。类似于实现destination.source()的效果。
好,明白基本用法,再来看下面的例子:
function source(age,gender){
console.log(this.name);
console.log(age);
console.log(gender);
}
let destination = {
name:"xuan"
};
console.log(source.call(destination,18,"male"));//call本身没有返回任何值,故undefined
打印效果如下:
我们可以看到call()
支持传参,而且是以arg1,arg2,...
的形式传入。我们看到最后还还输出一个undefined
,说明现在调用source.call(…args)
没有返回值。
我们现在给source
函数添加返回值:
function source(age,gender){
console.log(this.name);
console.log(age);
console.log(gender);
//添加一个返回值对象
return {
age:age,
gender:gender,
name:this.name
}
}
let destination = {
name:"xuan"
};
console.log(source.call(destination,18,"male"));
打印结果:
果不其然!call()
函数的返回值就是source
函数的返回值。
所以call()
函数的作用总结如下:
- 改变this的指向
- 支持对函数传参
- 调用call的函数返回什么,call返回什么。
模拟Function.prototype.call()
根据call()
的作用,我们一步一步进行模拟。我们先把上面的部分代码摘抄下来:
function source(age,gender){
console.log(this.name);
console.log(age);
console.log(gender);
//添加一个返回值对象
return {
age:age,
gender:gender,
name:this.name
}
}
let destination = {
name:"xuan"
};
现在只要实现一个函数call1()
并使用下面方式
console.log(source.call1(destination));
如果得出的结果和call()
函数一样,那就没问题了。
现在我们来模拟第一步:
改变this的指向。
假设我们destination的结构是这样的:
let destination = {
name:"xuan",
source:function(age,gender){
console.log(this.name);
console.log(age);
console.log(gender);
//添加一个返回值对象
return {
age:age,
gender:gender,
name:this.name
}
}
}
我们执行destination.source(18,"male");
就可以在source()
函数中把正确的结果打印出来并且返回我们想要的值。
现在我们的目的就是:给destination对象添加一个source属性,然后添加参数执行它。
我们定义如下:
Function.prototype.call1 = function(ctx){
ctx.fn = this; //ctx为destination this指向source 那么就是destination.fn = source;
ctx.fn(); // 执行函数
delete ctx.fn; //在删除这个属性
}
console.log(source.call1(destination,18,"male"));
打印效果如下:
我们发现this的指向已经改变了,但是我们传入的参数还没有处理。
第二步:
支持对函数传参。
我们使用ES6语法修改如下:
Function.prototype.call1 =function(ctx,...args){
ctx.fn = this;
ctx.fn(...args);
delete ctx.fn;
}
console.log(source.call1(destination,18,"male"));
打印效果如下:
参数出现了。
第三步:
调用call的函数返回什么,call返回什么
我们再修改一下:
Function.prototype.call1 =function(ctx,...args){
ctx.fn = this || window; //防止ctx为null的情况
let res = ctx.fn(...args);
delete ctx.fn;
return res;
}
console.log(source.call1(destination,18,"male"));
打印效果如下:
现在我们实现了call
的效果!
模拟Function.prototype.apply()
apply()
函数的作用和call()
函数一样,只是传参的方式不一样。apply
的用法可以查看MDN,MDN这么说的:apply() 方法调用一个具有给定this
值的函数,以及作为一个数组(或类似数组对象)提供的参数。
apply()
函数的第二个参数是一个数组,数组是调用apply()
的函数的参数。
function source(age,gender){
console.log(this.name);
console.log(age);
console.log(gender);
return {
age:age,
gender:gender,
name:this.name
}
}
let destination = {
name:"xuan"
};
console.log(source.apply(destination,[18,"male"]));
效果和call()
是一样的。既然只是传参不一样,我们把模拟call()
函数的代码稍微改改:
Function.prototype.apply1 =function(ctx,args){
ctx.fn = this || window;
args = args || [];
let res = ctx.fn(...args);
delete ctx.fn;
return res;
}
console.log(source.apply1(destination,[18,'male']));
执行效果如下:
apply()
函数的模拟完成。
Function.prototype.bind()
bind()
的作用,我们引用MDN:bind()
方法会创建一个新函数。当这个新函数被调用时,bind()
的第一个参数将作为它运行时的 this
对象,之后的一序列参数将会在传递的实参前传入作为它的参数。
我们看下述代码:
function source(age,gender){
console.log(this.name);
console.log(age);
console.log(gender);
return {
age:age,
gender:gender,
name:this.name
}
}
let destination = {
name:"xuan"
};
var res = source.bind(destination,18,"male");
console.log(res());
console.log("==========================")
var res1 = source.bind(destination,18);
console.log(res1("male"));
console.log("==========================")
var res2 = source.bind(destination);
console.log(res2(18,"male"));
打印效果如下:
我们发现bind
函数跟apply
和call
有两个区别:
1.bind返回的是函数,虽然也有call和apply的作用,但是需要在调用函数时生效 2.bind中也可以添加参数
注:bind还支持new语法,下面会展开。
我们先根据上述2点区别来模拟bind
函数。
模拟Function.prototype.bind()
和模拟call一样,现摘抄下面的代码:
function source(age,gender){
console.log(this.name);
console.log(age);
console.log(gender);
//添加一个返回值对象
return {
age:age,
gender:gender,
name:this.name
}
}
let destination = {
name:"xuan"
};
然后我们定义一个函数bind1
,如果执行下面的代码能够返回和bind
函数一样的值,就达到我们的目的。
var res = source.bind1(destination,18);
console.log(res("male"));
首先我们定义一个bind1函数,因为返回值是一个函数,所以我们可以这么写:
Function.prototype.bind1 = function(ctx,...args){
var that = this;//外层的this通过闭包传入内部函数中
return function(){
//将外层函数的参数和内层函数的参数合并
var all_args = [...args].concat([...arguments]);
//apply改变ctx的指向
return that.apply(ctx,all_args);
}
}
打印效果如下:
这里我们利用闭包,把外层函数的ctx
和参数args
传到内层函数,再将内外传递的参数合并,然后使用apply()
或call()
函数,将其返回。
当我们调用res("male")
时,因为外层ctx
和args
还是会存在内存当中,所以调用时,前面的ctx
也就是source
,args
也就是18,再将传入的”male”跟18合并[18,'male']
,执行source.apply(destination,[18,'male']);
返回函数结果即可。bind()
的模拟完成!
但是bind
除了上述用法,还可以有如下用法:
function source(age,gender){
console.log(this.name);
console.log(age);
console.log(gender);
//添加一个返回值对象
return {
age:age,
gender:gender,
name:this.name
}
}
let destination = {
name:"xuan"
};
var res = source.bind(destination,18);
var person = new res("male");
console.log(person);
打印效果如下:
我们发现bind
函数支持new
关键字,调用的时候this
的绑定失效了,那么new
之后,this
指向哪里呢?我们来试一下,代码如下:
function source(age,gender){
console.log(this);
}
let destination = {
name:"xuan"
};
var res = source.bind(destination,18);
console.log(new res("male"));
console.log(res("male"));
执行new
的时候,我们发现虽然bind
的第一个参数是destination
,但是this
是指向source
的。
如上所示,不用new
的话,this
指向destination
。
好,现在再来回顾一下我们的bind1
实现:
Function.prototype.bind1 = function(ctx,...args){
var that = this;
return function(){
//将外层函数的参数和内层函数的参数合并
var all_args = [...args].concat([...arguments]);
//因为ctx是外层的this指针,在外层我们使用一个变量that引用进来
return that.apply(ctx,all_args);
}
}
如果我们使用:
var res = source.bind(destination,18);
console.log(new res("male"));
如果执行上述代码,我们的ctx
还是destination
,也就是说这个时候下面的source
函数中的ctx
还是指向destination
。而根据Function.prototype.bind
的用法,这时this
应该是指向source
自身。
我们先把部分代码抄下来:
function source(age,gender){
console.log(this.name);
console.log(age);
console.log(gender);
//添加一个返回值对象
return {
age:age,
gender:gender,
name:this.name
}
}
let destination = {
name:"xuan"
};
我们改一下bind1函数:
Function.prototype.bind1 = function (ctx, ...args) {
var that = this;//that肯定是source
//定义了一个函数
let f = function () {
//将外层函数的参数和内层函数的参数合并
var all_args = [...args].concat([...arguments]);
//因为ctx是外层的this指针,在外层我们使用一个变量that引用进来
var real_ctx = this instanceof f ? this : ctx;
return that.apply(real_ctx, all_args);
}
//函数的原型指向source的原型,这样执行new f()的时候this就会通过原型链指向source
f.prototype = this.prototype;
//返回函数
return f;
}
我们执行
var res = source.bind1(destination,18);
console.log(new res("male"));
效果如下:
已经达到我们的效果!
现在分析一下上述实现的代码:
//调用var res = source.bind1(destination,18)时的代码分析
Function.prototype.bind1 = function (ctx, ...args) {
var that = this;//that肯定是source
//定义了一个函数
let f = function () {
... //内部先不管
}
//函数的原型指向source的原型,这样执行new f()的时候this就会指向一个新的对象,这个对象通过原型链指向source,这正是我们上面执行apply的时候需要传入的参数
f.prototype = this.prototype;
//返回函数
return f;
}
f()函数的内部实现分析:
//new res("male")相当于运行new f("male");下面进行函数的运行态分析
let f = function () {
console.log(this);//这个时候打印this就是一个_proto_指向f.prototype的对象,因为f.prototype==>source.prototype,所以this._proto_==>source.prototype
//将外层函数的参数和内层函数的参数合并
var all_args = [...args].concat([...arguments]);
//正常不用new的时候this指向当前调用处的this指针(在全局环境中执行,this就是window对象);使用new的话这个this对象的原型链上有一个类型是f的原型对象。
//那么判断一下,如果this instanceof f,那么real_ctx=this,否则real_ctx=ctx;
var real_ctx = this instanceof f ? this : ctx;
//现在把真正分配给source函数的对象传入
return that.apply(real_ctx, all_args);
}
至此bind()
函数的模拟实现完毕!如有不对之处,欢迎拍砖!您的宝贵意见是我写作的动力,谢谢大家。