基于猫狗大战奥特曼,再手写一次apply、call和bind~
温故而知新,再看不会你把我头拧下来!
今天刷题的时候看到一个有关 call 和 apply 的奇葩描述,觉得挺有意思的,于是重新把 call 和 apply 的逻辑手写了一遍,温故而知新~
大概是这样的:
- 猫吃鱼,狗吃肉,奥特曼打小怪兽
- 狗吃鱼:猫.吃鱼.call(狗, 鱼)
- 猫打小怪兽:奥特曼.打小怪兽.call(猫, 小怪兽)
这么说还确实有几分道理,下面就通过这个描述重新手写一下 apply 和 bind!
前提
首先准备三个对象:猫,狗,奥特曼:
let cat = {
name: "猫",
eatFish() {
console.log(`${this.name} 吃鱼中!`);
},
};
let dog = {
name: "狗",
eatMeat() {
console.log(`${this.name} 吃肉中!`);
},
};
let ultraman = {
name: "迪迦",
fight() {
console.log(`${this.name} 打小怪兽中!`);
},
};
准备好之后,我们先来实现一下call。
call
狗吃鱼 的话需要这样使用:猫.吃鱼.call(狗, 鱼),可以看出来调用 call 的是 猫 上面的 吃鱼 方法,而参数是 狗 和 鱼,所以应该是这样使用:
cat.eatFish.call(dog, "狗");
对于 call 方法,大概的逻辑是这样的:
- 传入的第一个参数被当做上下文,这里是狗
- 狗添加一个吃鱼方法,指向猫的吃鱼,也就是猫的this
- 狗当然也可以吃各种鱼
- 吃完之后,狗删除吃鱼这个方法,因为本不属于它,只是借用 按照上面的逻辑,我们可以这样写:
Function.prototype.defineCall = function (context, ...args) {
// 不传狗,默认是window
var context = context || window;
// 狗添加一个方法,指向猫的吃鱼方法,也就是this
context.fn = this;
// 狗可以吃各种鱼,也就是可能有多个参数
let result = context.fn(...args);
// 删除狗会吃鱼
delete context.fn;
return result;
};
这样,一个自定义的 call 基本上就完成啦!现在来测试一下:
cat.eatFish.defineCall(dog, "狗");
ultraman.fight.defineCall(cat, "猫");
// output:
// 狗 吃鱼中!
// 猫 打小怪兽中!
现在 狗 可以 吃鱼 了,猫 可以 打小怪兽 了!
现在我们让狗多吃几种鱼,我们先来简单改一下猫的吃鱼:
let cat = {
name: "猫",
eatFish(...args) {
console.log(`${this.name} 吃鱼中!吃的是:${args}`);
},
};
然后我们再这样调用:
cat.eatFish.defineCall(dog, "三文鱼", "金枪鱼", "鲨鱼");
// output:
// 狗 吃鱼中!吃的是:三文鱼,金枪鱼,鲨鱼
这样就可以吃各种鱼了,当然是用arguments 来操作参数也是可以的。
apply
apply 和 call 用法基本类似,区别就在于,第二个参数是数组,我们可以这样写:
Function.prototype.defineApply = function (context, arr) {
var context = context || window;
let result;
context.fn = this;
if (!arr) {
// 如果没传参数,就直接执行
result = context.fn();
} else {
//如果有参数就执行
result = context.fn(...arr);
}
delete context.fn;
return result;
};
现在再来调用一下,看看写的对不对:
cat.eatFish.apply(dog, ["狗"]);
ultraman.fight.apply(cat, ["猫"]);
// output:
// 狗 吃鱼中!
// 猫 打小怪兽中!
成功!🎉
bind
既然 call 和 apply 都实现了,那稍微有点难度的 bind 也来实现一下好了,毕竟它们是 铁三角 嘛。
我们先来捋一下 bind 都有哪些东西:
bind也是用来转换this的指向的。bind不会像它们两个一样立即执行,而是返回了一个绑定this的新函数,需要再次调用才可以执行。bind支持函数柯里化。bind返回的新函数的this是无法更改的,call和apply也不可以。
我们一步一步来写,首先写一个最简单的:
Function.prototype.defineBind = function (obj) {
// 如果不存this,执行期间可能this就指向了window
let fn = this;
return function () {
fn.apply(obj);
};
};
然后给它加上传参的功能,变成这样:
Function.prototype.defineBind = function (obj) {
//第0位是this,所以得从第一位开始裁剪
let args = Array.prototype.slice.call(arguments, 1);
// 如果不存this,执行期间可能this就指向了window
let fn = this;
return function () {
fn.apply(obj, args);
};
};
接着给它加上柯里化:
Function.prototype.defineBind = function (obj) {
//第0位是this,所以得从第一位开始裁剪
let args = Array.prototype.slice.call(arguments, 1);
let fn = this;
return function () {
//二次调用我们也抓取arguments对象
let params = Array.prototype.slice.call(arguments);
//注意concat的顺序
fn.apply(obj, args.concat(params));
};
};
现在的 defineBind 差不多已经 初具bind形 了,让它升级成真正的 bind,还有一个细节:
返回的回调函数也可以通过
new的形式去构造,但是在构造过程中,它的this会被忽略,而返回的实例仍然能继承构造函数的构造器属性和原型属性,并且可以正常接收属性(也就是只丢失了this,其他都是正常的)。
这个意思其实就是让我们自定义 this 的判断和原型继承,所以比较难的来了,先了解一点:构造函数的实例的构造器指向构造函数本身:
function Fn(){};
let o = new Fn();
console.log(o.constructor === Fn);
//true
并且在构造函数运行时,内部的 this 是指向实例的(谁调用,this 就指向谁),所以 this.constructor 是指向构造函数的:
function Fn() {
console.log(this.constructor === Fn);
//true
};
let o = new Fn();
console.log(o.constructor === Fn);
//true
那是不是就可以通过改变 this.contructor 的指向来改变原型继承呢?
答案当然是对的!当返回函数作为构造函数的时候,this 指向的应该是实例,当返回函数作为普通函数的时候,this 指向的有应该是当前上下文:
Function.prototype.defineBind = function (obj) {
let args = Array.prototype.slice.call(arguments, 1);
let fn = this;
let bound = function () {
let params = Array.prototype.slice.call(arguments);
//通过constructor判断调用方式,为true this指向实例,否则为obj
fn.apply(this.constructor === fn ? this : obj, args.concat(params));
};
//原型链继承
bound.prototype = fn.prototype;
return bound;
};
这样,一个 bind 基本上就结束了,而且返回的构造函数所产生的实例也不会影响到构造函数。
但是!直接修改实例原型会影响构造函数!
那这个怎么办呢?要是构造函数的原型里啥都没有就好了,这样就不会相互影响了……blablabla……
写一个小例子,用一个中介,让构造函数的原型只能影响到实例,影响不到其他东西:
function Fn() {
this.name = "123";
this.sayAge = function () {
console.log(this.age);
};
}
Fn.prototype.age = 26;
// 创建一个空白函数Fn1,单纯的拷贝Fn的prototype
let Fn1 = function () {};
Fn1.prototype = Fn.prototype;
let Fn2 = function () {};
Fn2.prototype = new Fn1();
给Fn2加了一层 __proto__ 的方式,让Fn2的原型指向了一个实例,而实例的原型是Fn,这样Fn2的改变就不会影响到Fn了(当然通过 __proto__.__proto__ 还是一样能修改)!
Function.prototype.defineBind = function (obj) {
let args = Array.prototype.slice.call(arguments, 1);
let fn = this;
//创建中介函数
let fn_ = function () {};
// 上面说的Fn2就是这里的bound
let bound = function () {
let params = Array.prototype.slice.call(arguments);
//通过constructor判断调用方式,为true this指向实例,否则为obj
fn.apply(this.constructor === fn ? this : obj, args.concat(params));
};
fn_.prototype = fn.prototype;
bound.prototype = new fn_();
return bound;
};
最后再用一个报错润色一下:
Function.prototype.defineBind = function (obj) {
if (typeof this !== "function") {
throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
};
let args = Array.prototype.slice.call(arguments, 1);
let fn = this;
//创建中介函数
let fn_ = function () {};
// 上面说的Fn2就是这里的bound
let bound = function () {
let params = Array.prototype.slice.call(arguments);
//通过constructor判断调用方式,为true this指向实例,否则为obj
fn.apply(this.constructor === fn ? this : obj, args.concat(params));
};
fn_.prototype = fn.prototype;
bound.prototype = new fn_();
return bound;
};
手写 bind 完毕!
最后用狗吃鱼来验证一下:
let cat = {
name: "猫",
eatFish(...args) {
console.log(`${this.name} 吃鱼中!吃的是:${args}`);
}
};
let dog = {
name: "狗"
};
cat.eatFish.defineBind(dog, "三文鱼", "金枪鱼")("鲨鱼");
// output:
// 狗 吃鱼中!吃的是:三文鱼,金枪鱼,鲨鱼
最后再附上一个es6版本的手写bind,大家可以过一下,还是比较清晰的:
Function.prototype.defineBind = function (context, ...rest) {
if (typeof this !== "function") {
throw new Error("Function.prototype.bind - what is trying to be bound is not callable");
}
var self = this;
return function F(...args) {
if (this instanceof F) {
return new self(...rest, ...args);
}
return self.apply(context, rest.concat(args));
};
};
参考资料:
我的公众号:道道里的前端栈,分享前端知识,嚼碎的感觉真奇妙~求关注