zf-fe

✍️ Tangxt ⏳ 2020-06-12 🏷️ JS 专题

21-综合专题之THIS的五种情况2(重写内置的CALL、APPLY、BIND)

★前言

箭头函数是无法通过call等方法来指定其里边所谓的 this 的,毕竟箭头函数不存在 this ……

简单来说,你这样做:

var f = () => {
  console.log(this)
}
f.call('hi') // -> this -> window

// 全局this变量
this // -> window

箭头函数与this

题外话:

箭头函数是 Function 的实例,所以箭头函数也是可以使用 callapply 等这样的方法的,只是我们无法像new一个普通函数那样,new一个箭头函数罢了,简单来说,箭头函数就是无法成为一个实例的类!它只能被当作跑一段代码的过程封装!


★ 情况五:call/apply/bind 改变 this 指向

规律:

THIS5:基于 call/apply/bind 可以改变函数中this的指向(强行改变),当然,箭头函数除外!


Q:ES6写一个方法?

let obj = {
  fn: function() {}
}

let obj1 = {
  fn() {}
}

这两种写法是等价的,都是普通函数方法,注意这后者可不是箭头函数哈!


call/apply

相同点:

唯一区别:

执行函数,传递的参数方式有区别,call是一个个的传递,而apply则是把需要传递的参数放到数组中整体传递,如这样:

func.call([context],10,20)
func.apply([context],[10,20])

形式上来看,apply只管俩参数,而call则可以管很多个参数,但它们俩的代码执行效果是一样的!

如:

function func(x, y) {
  console.log(this, x, y);
}

func.call("call: ", 1, 2); // -> String {"call: "} 1 2

// func.apply("apply: ", 1,2); // -> 如果你这样传,会报「Uncaught TypeError: CreateListFromArrayLike called on non-object」这样的错误

func.apply("apply: ", [1, 2]); // -> String {"apply: "} 1 2

◇bind

call/apply 都是改变this的同时直接把函数执行了,而bind不是立即执行函数,属于预先改变this和传递一些内容 -> “柯理化”(大函数里边返回小函数,该小函数供外边使用)

为什么需要bind?

有这么一段代码:

let obj = {
  fn(x, y) {
    console.log(this, x, y);
  },
};
setTimeout(obj.fn, 1000, 1, 2);

我们要确定 obj.fnthis 指向为 obj ,而不是 window

那么我们就不能用 callapply 了,毕竟它们俩是立即执行的,所以这个时候我们就得用上 bind 了,而且也不需要为定时器追加参数了……

因此,可有:

setTimeout(obj.fn.bind(obj, 1, 2), 1000);

Q:重写bind?

~(function anonymous(proto) {
  function bind(context) {
    // context may be null or undefined
    // 为啥不这样写 context = context || window? -> 如果传的是''和0等这样的falsy值呢?
    if (context == undefined) {
      context = window;
    }
    // 获取传递的实参集合
    // 这算是一个小技巧吧,可谓方法借用……当然这可不是随意借用的,你这arguments可符合某种数据结构才行,如可遍历的……
    var args = [].slice.call(arguments, 1);
    // 需要最终执行的函数 -> 那个callback
    var _this = this;
    return function anonymous() {
      // 为啥要有这行代码呢? -> 因为返回的这个函数有可能被传入参数,如事件对象:
      // document.body.onclick = obj.fn.bind(window, 10, 20)
      // document.body.onclick = anonymous -> 浏览器会传个事件对象给这个anonymous,所以你得找个容器接收一下
      var amArg = [].slice.call(arguments, 0);

      // 为啥要用apply? -> 因为args是个数组哈!一般都会把像ev这样的内置参数扔到数组的最后,真正的bind源码也是这样写的……
      _this.apply(context, args.concat(amArg));
    };
  }
  proto.bind = bind;
})(Function.prototype);

如果你不考虑内部传参的话,其实这bind的写法还可以更简单……即小函数不用搞个 amArg 哈!

代码测试:

let obj = {
  fn(...args) {
    console.log(this, args);
  },
};
setTimeout(obj.fn, 1000, 1, 2);
setTimeout(obj.fn.bind(obj, 1, 2), 1000);
document.body.onclick = obj.fn.bind(obj, 1, 2);

结果:

bind重写姿势一

用ES6姿势重写一下?

!(function(proto) {
  //经过一些大佬们的测试:apply的性能不如call,<= 3 个参数用call,否则考虑用apply
  function bind(context = window, ...args) {
    return (...amArg) => this.call(context, ...args.concat(amArg));
  }
  proto.bind = bind;
})(Function.prototype);

一个bind一行代码就完事了(不考虑new的情况,毕竟从返回箭头函数这个侧面就可以看出了)……

代码测试:

let obj = {
  fn(...args) {
    console.log(this, args);
  },
};
setTimeout(obj.fn, 1000, 1, 2); // -> window、[1,2]
setTimeout(obj.fn.bind(obj, 1, 2), 1000); // -> obj、[1,2]
document.body.onclick = obj.fn.bind(obj, 1, 2); // -> obj、[1, 2, MouseEvent]

这次测试让我回忆起,连续bind的那种情况,讲真,我是无法理解为啥连续bind之后,最终callback的执行,会把所有的预设的参数都给拿到!

obj.fn 当作是一个普通对象过来看待,即便它本质上是一个函数!但我们这样 obj.fn.bind() 使用它,那么就可以把其当作是普通对象来看,而不是一个函数,不然,会有种奇怪的感觉! -> 一个对象调用它的 bind 方法,返回了一个函数 -> 你看是不是很符合直觉? -> 如果你这样:「一个函数调用了它的 bind 方法,返回了一个函数」 -> 是不是感到很奇怪?

function add(...args) {
  console.log(args);
  return args.reduce((x, y) => {
    return x + y;
  });
}
let res = add.bind(null, 1, 2).bind(null, 3, 4).bind(null, 5, 6)(); // -> [1, 2, 3, 4, 5, 6]
console.log(res); // -> 21

连续bind

如果我不用去管bind的内部实现原理,单纯从使用上看的话,那么我不需要考虑连续bind会出现递归的效果,我只管最后执行的callback,我们对其传了6个参数,如果是事件触发执行的callback,那么还会有一个「事件对象参数」 -> 这就类似于 add.bind(null,1,2,3,4,5,6) or add.bind(null,...[1,2,3,4,5,6]) -> 我不知道是否可以把其理解为无中生有的递归现象!

形象想想的话 -> 第一次bind我成为了爸爸(返回儿子) -> 第二次bind我成了爷爷(返回孙子) -> 第三次bind我成了太爷爷(返回曾孙)……

题外话:

关于展开运算符的使用:

function fn(...args) {
  console.log(args)
}

let a = [1, 2, 3]
let b = [4, 5, 6]

// 展开运算符,把数组展开成一个个参数 -> 优先级level1
// API调用仔不想一个个传,于是用一个数组,然后展开之,这其实和形参是一一对应的
fn(...a.concat(b)) // -> [1, 2, 3, 4, 5, 6]

形参: ...args -> 实参: ...a.concat(b) -> args[1, 2, 3, 4, 5, 6]


★重写 call 和 apply

◇重写 call

原理:用到 xxx.fn() 这种姿势来指定 this

1、明确知道API调用仔传的 context 是引用类型值

简单重写call:

!(function(proto) {
  function call(context = window, ...args) {
    //=>必须保证context是引用类型
    context.$fn = this;
    let result = context.$fn(...args);
    delete context.$fn;
    return result;
  }
  proto.call = call;
})(Function.prototype);

测试:

function fn(a, b) {
  console.log(this);
  console.log(a, b);
}
fn.call({}, 1, 2); // -> {$fn: ƒ}
fn.call(1, 1, 2); // -> Uncaught TypeError: context.$fn is not a function

2、不知道API调用仔传的 context 是啥类型的值

有校验this值的类型的call:

!(function(proto) {
  function call(context = window, ...args) {
    // null的typoof也是object -> 所以单独拎出来校验!
    context === null ? (context = window) : null;
    let type = typeof context;
    // 如果context不是null,不是[]、不是{}、也不是function,更不是symbol,那么这个context肯定是基本类型值啦!
    if (type !== "object" && type !== "function" && type !== "symbol") {
      //=>基本类型值
      switch (type) {
        case "number":
          context = new Number(context);
          break;
        case "string":
          context = new String(context);
          break;
        case "boolean":
          context = new Boolean(context);
          break;
      }
    }
    context.$fn = this;
    let result = context.$fn(...args);
    // 当你对call传一个变量对象的时候,这才会有意义,如let o = {} -> fn.call(o) -> o -> {},但this -> { $fn: f }
    // 原生的call方法,this还是 -> {}
    // 说白了,我们写的这个call方法,不会污染o变量,但会污染this
    delete context.$fn;
    return result;
  }
  proto.call = call;
})(Function.prototype);

测试:

function fn(a, b) {
  console.log(this);
  console.log(a, b);
}
fn.call({}, 1, 2);
fn.call(1, 1, 2);

结果:

call测试结果

如果我这样呢?

fn.call(undefined, 1, 2); // -> this -> window

那么fn里边的 this 就是 window 啦! -> 这一点我是没有预料到的,我不知道 function call(context = window) 这样对 context 给默认值的操作,在面对传参是 undefined 的情况下,会选择 context 值为 window ,而对于 null 传参则不是 window -> 对于你这个重写的 call 的第一个形参而言,你不传参或者给个 undefined 都会选择 window 这个默认值

ES6默认值

总之,对于这种姿势: function call(context = window){} ,你调用call方法,不传参数或传 undefinedcontext 的值都是 window

◇重写apply

原理:call和apply只有一个区别——参数传多个还是用数组收拢 -> apply就是call的语法糖!

function apply(context = window, args) {
  context.$fn = this;
  // 用展开运算符,展开args这个数组就完事儿了
  let result = context.$fn(...args);
  delete context.$fn;
  return result;
}

Q:为什么老师会讲bind/call/apply这些方法的源码重写呢?

因为很多大公司都会叫你用原生JS重写一些bind/call/apply -> 目的:通过这个考察你对 this 、面向对象以及柯里化函数的整个掌握情况……


★一道阿里面试题

~ function() {
  function call(context) {
    context = context || window;
    let args = [].slice.call(arguments, 1),
      result;
    context.$fn = this;
    result = context.$fn(...args);
    delete context.$fn;
    return result;
  }
  Function.prototype.call = call;
}();

function fn1() {
  console.log(1);
}

function fn2() {
  console.log(2);
}
fn1.call(fn2);
fn1.call.call(fn2);
Function.prototype.call(fn1);
Function.prototype.call.call(fn1);

改一下那个call方法:

!(function(proto) {
  function call(context = window, ...args) {
    //=>必须保证context是引用类型
    context.$fn = this;
    let result = context.$fn(...args);
    delete context.$fn;
    return result;
  }
  proto.call = call;
})(Function.prototype);

结果:

1、2、匿名空函数执行、1

做法:

规律:

注意点:

★总结

★Q&A

1)call 和 apply 的性能?

参数是数组( [] )还是散列(普通对象结构 {} )?

apply用的call可不是原生的call方法,而是apply自己内部重写的call方法!

在参数为3个(包含3)以内时,优先使用 call 方法进行事件的处理。而当参数过多(多于3个)时,才考虑使用 apply 方法。

题外话,关于API的选择问题:

js call和apply的性能有差别吗? - 贺师俊的回答 - 知乎

既然有两个完成相同事情的操作,但却长得不同的api,就是为不同的需求准备的

所以请按照你的需求使用,而不是为了所谓性能强行转换成另一种。

要知道如果你自己可以通过转换获得性能提升,没有理由引擎不能做这种优化,只是早晚的事情。

最后,在最新的引擎上两者性能是基本一样的。


➹:为什么 call 比 apply 快? - 掘金

➹:call 和 apply 的区别是什么,哪个性能更好一些? - 掘金

➹:jsPerf: JavaScript performance playground

➹:js call和apply的性能有差别吗? - 知乎

2)call重写?

// 为了让每一个函数都可以调取这个方法了
Function.prototype.changeThis = function changeThis(context, ...args) {
  // THIS:当前要执行并且改变THIS指向的函数
  // CONTEXT特殊情况的处理:不传递是window,传递null/undefined也让其是window,传递非对象或者函数类型值,我们需要让其变为对象或者函数
  context == null ? context = window : null;

  //=> 为了过滤出传入参数是基本类型的情况
  if (typeof context !== "object" && typeof context !== "function") {
    //=> 运用构造函数常见一个基本数据类型的实例;
    //=> 目的是为了在下面context[uniqueKey] 时,由于基本数据类型不能运用对象的“点”或者“[]”存储属性时报错的问题
    context = new context.constructor(context);
  }

  //=> 利用模版字符串和时间戳生成一个唯一的属性名
  let uniqueKey = `$$${new Date().getTime()}` ;

  //=> 给参数中新增个uniqueKey属性与调用的函数关联
  context[uniqueKey] = this;

  //=> 让调用的函数执行
  let result = context[uniqueKey](...args);

  //=> 删除新增的属性
  delete context[uniqueKey];

  //=> 把函数执行的结果 return 出去
  return result;
};

➹:重写内置call - 掘金

➹:重写 Polyfill 之 call、apply、bind - 掘金