阅读 782

【愣锤笔记】一篇小短文让你彻底搞懂this、call、apply和bind

跟我左手右手一起慢动作,右手左手慢动作重复。额~貌似哪里有点不对劲哎?让我想想,右手?左手?慢动作??重复???重播???不对不对,是左手call,右手apply,一起来bind this。

额,这都能强扯一波,好吧,让我吐血一波~~~说起了js中的this,确实是个有趣的话题,也是很多小伙伴一开始傻傻分不清的老命题了。算是老梗重提,再来聊聊this吧。

关于this,首先要提的一点就是,它指向一个对象,具体指向谁是由函数运行时所处的上下文决定的。这是最重要的一个概念,也是理解js中this的关键。所谓的上下文,可以理解为函数运行时的环境,例如一个函数在全局中运行,那么它的上下文就是这个全局对象,客户端中这个global对象就是window;函数作为对象的方法运行,那么它的上下文就是该对象。

关于this的指向问题,我们可以大致分为如下几种情景来讨论:

  • 函数作为普通函数调用
  • ES5严格模式下的函数调用
  • 函数作为对象的一个方法调用
  • 构造器中的this(也就是常说的类中的this,但是要搞清楚js是没有类的,是基于原型委托实现的继承,类只是大家习惯性的叫法)
(1)函数作为普通函数调用:大家学习js,对函数应该是再熟悉不过了。函数可是js中的一等公民,人中吕布、马中赤兔啊。

var name1 = 'hello this';
window.name2 = 'hello global';
function func () {
  console.log(this.name1); // 输出:"hello this"
  console.log(this.name2); // 输出:"hello global"
}
func();复制代码

这里的代码大家自然一眼就知道结果了,结果写在了上面的注释里。通过运行结果我们知道,普通函数在全局调用中,this指向全局对象。这里我们定义了一个全局变量name1,和一个window的属性name2,所以this.name1和this.name2如我们的预期指向了这两个值。值得一提的是:定义的全局变量,是被作为全局对象window的属性存在的哦。此时我们打印看下window对象,看图:


(2)ES5严格模式下的函数调用:this不再指向全局对象,而是undefined。

function strictFunc () {
  'use strict'
  console.log(this)
  console.log(this.name)
}
strictFunc()复制代码

我们先看下运行结果:


可以看到,this打印出来的值是undefined,而this.name会直接报错。由此说明,严格模式下,this已经不再指向全局对象,而是undefined值。引用undefinednull值的属性会报Uncaught TypeError错,这点我们在日常开发中需要注意一下,以免因为一个错误导致后面的程序直接挂掉(这是js单线程的原因,一旦程序出错,后面便不会再执行)。特别是我们在拿到一些不是我们决定的数据(例如后台返回的)进行处理的时候,使用对象的属性时最好判断一下,这样在极端情况下,也可以保证我们的程序继续跑下去,而不至于直接挂掉:

obj && obj.name
// 而不是直接取值:
obj.name

或者用try/catch捕获错误:
try {
    const { data } = await api.getArticleList()
} catch {

} finally {

}
复制代码

(3)函数作为对象的方法使用:this指向该对象

var obj = {
  name: 'xiaoming',
  getInfo () {
    return '姓名: ' + this.name;
  }
}
console.log(obj.getInfo()); // 姓名: xiaoming 复制代码

当对象当属性的值是一个函数时,我们会称这个函数是这个对象的一个方法。该方法中的this在运行时指向的是该对象。上面的例子的输出结果也看的清清楚楚,然鹅,没错,就是鹅,现实有时候是会啪啪打脸的,打的响亮亮的、轻脆脆的、绿油油的~哎,我为什么要说绿油油,毛病。下面我简单改写一个上面的代码:

// 还是这个obj,还是熟悉的味道
var obj = {
  name: 'xiaoming',
  getInfo () {
    return '姓名: ' + this.name;
  }
}
// 定义一个引入obj.getInfo的变量
var referenceGetInfo = obj.getInfo;
console.log(referenceGetInfo()); // 输出:姓名:复制代码

最终我们没有拿到预期的name值,打脸了吧,说好了的指向该对象的呢!果然我们男人都是骗子,都是大猪蹄子!

这是为什么呢?我们知道js分为两种数据类型:基本数据类型,如string、number、undefined、null等,引用类型,如object。而像数组、函数等,本质都是对象,所以都是引用类型。函数名只不过是指向该函数在内存中位置的一个引用。所以,这里var referenceGetInfo = obj.getInfo在赋值之后,referenceGetInfo也只是该函数的一个引用。在看referenceGetInfo 的调用位置,是在全局中,所以是作为普通函数调用的。由此this指向window,所以没有值。可以在getInfo函数中,增加如下验证,结果必然是true

console.log(this === window)复制代码

(4)构造器函数中的this:指向该构造器返回的新对象

说起构造器函数,可能感觉会有些生硬,其实就是我们常说的定义类时的那个函数。例如,下面这个最常见的一个类(构造器函数):

// 定义Person类
var Person = function (name, sex) {
  this.name = name;
  this.sex = sex;
}
// 定义Person类的原型对象
Person.prototype = {
  constructor: Person,
  getName: function () {
    return '我叫:' + this.name;
  },
  getSex: function () {
    return '性别:' + this.sex;
  }
}
// 实例化一个p1
var p1 = new Person('愣锤', '男');
// 调用p1的方法
console.log(p1.getName()); // 我叫:愣锤
console.log(p1.getSex()); // 性别:男复制代码

构造器函数本是也是一个函数,如果直接调用该函数,那它和普通函数没什么区别。但是通过new调用之后,那它就成为了构造器函数。构造器函数在实例化时会返回一个新创建的对象,并将this指向该对象。所以this.name的值是"愣锤"。另外这里再提一点,如果你担心用户使用类时忘记加new,可以通过如下方式,强制使用new调用:

var Person = function (name, sex) {
  // 在构造器中增加如下这一行,其余不变
  if (!(this instanceof Person)) return new Person(name, sex);
  this.name = name;
  this.sex = sex;
}复制代码

该行代码判断了当前的this是否是Person类的实例,如果不是则强制返回一个通过new初始化的类。以为如果用户忘记使用new初始化类,那么此时的构造器函数是作为普通函数调用的,this在非严格模式下指向window,肯定不会是Person类的实例,所以我们直接强制返回new初始化。这也是我们在开发类库时可以使用的一个小技巧。

弄明白了js中的this的指向,下面我们再聊聊如何改变this的指向。在js中,改变this指向方法,常见的有如下几种:

  • Function.prototype.call()
  • Function.prototype.apply()
  • Function.prororype.bind()
  • 除此之外,还有eval()、with()等

(1)call()方法和apply()方法都是ES3中就存在的方法,可以改变函数的this指向,两者的功能完全一样,所以这里放在一起说。唯一的区别是两者调用时传入的参数不同,后面会仔细介绍。

// 还是熟悉的味道,还是那个obj
var obj = {
  name: 'xiaoming',
  getInfo (sex) {
    return '姓名: ' + this.name + '性别:' + this.sex || '未知';
  }
}
// 定义另一个obj对象
var otherObj = {
  name: '狗子你变了,你再也不是我认识的那个二狗了!'
}

console.log(obj.getInfo.call(otherObj, '女')); 
// 姓名: 狗子你变了,你再也不是我认识的那个二狗了!性别:女复制代码

我们通过callobj.getInfo方法放在ohterObj这个对象执行,输出了ohterObj.name的值,由此验证了call可以函数this的指向。call()方法接收多个参数: 

  • 第一个参数为可选参数,即this指向的新的上下文对象。如果不传该参数,则指向全局对象。若不传入第一个参数且该方法(getInfo)使用严格模式,this值且undefined,和普通函数的严格模式一样,从undefined上取值会报错。
  • 后面的所有参数都是作为参数传递给方法调用

apply()方法和call的功能一样,只不过传入的参数不一样:

  • 第一个参数为可选参数,和上面👆call的一样
  • 第二个参数是一个参数数组/类数组,数组包含的所有参数都会作为参数传递给该方法调用

用法很简单,和call一样就不多介绍了。但是这里提到了类数组概念,说一下什么是类数组,可以理解为本身不是数组,但是却可以像数组一样拥有length属性(例如函数的arguments对象)。我们没有确切的办法判断一个对象是不是类数组,所以这里我们只能使用js中的鸭子类型来判断。何为鸭子类型:如果它走起路来像鸭子,叫声也像鸭子,我们便认为它就是鸭子。

鸭子类型是js中很重要的一个概念,因为我们此时并不真正关心它是不是鸭子,我们只是想听到鸭子叫/或者看到鸭子走,即我们要的只是它拥有鸭子的行为,至于它是不是鸭子,无所谓呀!!!

所以只要一个对象能拥有数组的行为,我们就可以把它作为数组使用。下面引入underscore中的类数组判断方法说明:

var isArrayLike = function(collection) {

var length = getLength(collection);
  return typeof length == 'number' && length >= 0 && length <= MAX_ARRAY_INDEX;
};复制代码

underscore.js中对类数组的判断其实也是运用了鸭子类型的思想,即判断如果该对象拥有length属性且是number类型,并且length的值大于等于0小于等于一个数组的最大元素个数,那我们就认定他是数组。

好了,有的稍微扯远了。

下面继续apply的实际运用场景,例如柯里化函数:

// 定义一个柯里化函数
var currying = function () {
  var arrPro = Array.prototype,
  fn = arrPro.shift.call(arguments),
  args = arrPro.slice.call(arguments);
    return function () {
    var _args = arrPro.slice.call(arguments);
    return fn.apply(fn, args.concat(_args));
  }
}
// 定义一个返回a+b的函数var add = function (a, b) {
  return a + b;
}
// 将这个求和函数进行柯里化,使其第一项的值恒为5
var curryAdd = currying(add, 5);
var res = curryAdd(4);
console.log(res); // 9复制代码

我们在开发中apply方法和call方法是用的比较多的,例如这里柯里化函数。特别是高阶函数中,函数作为值返回的时候,会经常使用apply这些方法来绑定函数运行时的上下文对象。

我们再看一个更常见的函数节流吧:

// 去抖函数
function debounce (fn, delay) {
  var timer;
  return function () {
    var args = arguments;
    var _this = this;
    timer && clearTimeout(timer);
    timer = setTimeout(function () {
      fn.apply(_this, args);
    }, delay);
  }
}
// 调用,在浏览器窗口滚动的情况下,debounce里的函数并不会被频繁触发,而是滚动结束500ms后触发
window.addEventListener('scroll', debounce(function () {
  console.log('window scroll');
}, 500), false);复制代码

我们在这个去抖函数里,在返回的函数里,使用里定时器,而定时器的第一个参数是一个函数,所以形成里一个局部的函数作用域。为了能保证我们的fn函数中的this的正确指向,我们通过apply改变它的指向。

所谓去抖函数,在一个函数被频繁调用的时候,如果此次调用距离上一次的时间小于我们定下的delay 值,那么取消本次调用。主要用来防止频繁触发的问题,从而提供程序运行性能。注意,上面只是一个函数去抖,真正在提升滚动性能的时候,我们更多的是会将去抖和节流结合起了使用。此处更多地在于演示apply的运用场景,不再多做节流去抖方面的说明。

call方法在v8的实现中,其实是作为apply方法的语法糖,由此,我们可以试着使用apply来模拟一个call方法(并非v8源码实现):

Function.prototype.call = function () {
  var ctx = Array.prototype.shift.apply(arguments);
  return this.apply(ctx, arguments);
}复制代码

我们知道call方法,第一个参数是上下文对象,所以我们的第一件事就是取出参数中的第一个参数ctx,然后把剩余的参数使用apply的方式调用。so,就是这样。

(2)说完了call和apply,下面我们再说一下ES5引入的新方法:Function.prototype.bind

该方法返回一个新的函数,并将该函数的this绑定到指定的上下文环境。接收多个参数:

  • 第一个参数为this绑定到的新上下文环境
  • 后面的参数会作为参数传递给该函数

用法很简单,相信大家都会用:

// 还是那个熟悉的狗子,哦不对,还是那个熟悉的对象
var obj = {
  name: 'xiaoming',
  getInfo (sex, hobby) {
    return '姓名: ' + this.name + ', 性别:' + (sex || '未知') + hobby;
  }
}
// 另外一个狗子,呸呸呸!另外一个对象
var obj2 = {
  name: '我已经不是你认识的狗子了'
}
// 输出:姓名: 我已经不是你认识的狗子了, 性别:男, 兴趣:打球
var newGetInfo = obj.getInfo.bind(obj2, '男');console.log(newGetInfo('打球'));复制代码

可以看到,bind()后返回了一个新函数,并把第一个参数后面的参数传递给了obj.getInfo方法,在运行newGetInfo('打球')时,又继续把参数传递给了obj.getInfo方法。是不是发现它天然支持了函数柯里化,是不是感觉跟我们上面的柯里化函数功能一样?

但是bind方法,是es5引入的,在es3是不支持的。这时候可能会说了,es5已经是主流了,大家也都已经大量使用es6及更高的语法,反正又babel等工具帮我们转换成es5的。没错,但是我们还是要了解其实现的,比如写一个bind方法的profill。做到知其然,知其所以然。

// 如果本身支持bind方法,则使用原生的bind方法,否则我们就实现一个使用
Function.prototype.bind = Function.prototype.bind || function () {
  var fn = this;  var ctx = arguments[0];
  var args = Array.prototype.slice.call(arguments, 1);
  return function () {
    var _args = Array.prototype.slice.call(arguments);
    return fn.apply(ctx, args.concat(_args));
  }
}复制代码

讲到这,相信已经可以将this/call/apply方法搞清楚了。由此还引申出更多的函数节流/去抖/柯里化/反柯里化,还是可以继续深入深究一下的。


关注下面的标签,发现更多相似文章
评论