基于猫狗大战奥特曼,再手写一次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)); }; };