Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

基于猫狗大战奥特曼,再手写一次apply、call和bind #30

Open
daodaolee opened this issue Mar 31, 2021 · 0 comments
Open

基于猫狗大战奥特曼,再手写一次apply、call和bind #30

daodaolee opened this issue Mar 31, 2021 · 0 comments

Comments

@daodaolee
Copy link
Owner

今天刷题的时候看到一个有关 callapply 的奇葩描述,觉得挺有意思的,于是重新把 callapply 的逻辑手写了一遍,温故而知新~

大概是这样的:

  • 猫吃鱼,狗吃肉,奥特曼打小怪兽
  • 狗吃鱼:猫.吃鱼.call(狗, 鱼)
  • 猫打小怪兽:奥特曼.打小怪兽.call(猫, 小怪兽)

这么说还确实有几分道理,下面就通过这个描述重新手写一下 applybind!

前提

首先准备三个对象:奥特曼

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 方法,大概的逻辑是这样的:

  1. 传入的第一个参数被当做上下文,这里是狗
  2. 狗添加一个吃鱼方法,指向猫的吃鱼,也就是猫的this
  3. 狗当然也可以吃各种鱼
  4. 吃完之后,狗删除吃鱼这个方法,因为本不属于它,只是借用
    按照上面的逻辑,我们可以这样写:
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

applycall 用法基本类似,区别就在于,第二个参数是数组,我们可以这样写:

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

既然 callapply 都实现了,那稍微有点难度的 bind 也来实现一下好了,毕竟它们是 铁三角 嘛。

我们先来捋一下 bind 都有哪些东西:

  1. bind 也是用来转换 this 的指向的。
  2. bind 不会像它们两个一样立即执行,而是返回了一个绑定 this 的新函数,需要再次调用才可以执行。
  3. bind 支持函数柯里化。
  4. bind 返回的新函数的 this 是无法更改的,callapply 也不可以。

我们一步一步来写,首先写一个最简单的:

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));
  };
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant