来梳理一下JavaScript中this的脉络
一. this的概念
在Java中,this的概念很明确:指的就是该类对象,并可以通过this来操纵对象属性,比如:
-
案例一
class A{ private int age; public int getAge(int age){ return this.age; } //...构造函数等 } //***** A a = new A(18).getAge(17); //result: 18
由于创建一个对象是一个开辟内存空间的操作,所以一个对象的this是不可以修改的。甚至于我会冒出一句话:一个对象与它的this......
但是在JavaScript中,不同于Java中类与对象的概念,它更加强调于函数与对象的概念,所以我们要探讨的是函数中的this指向。
-
案例二
global.value = 2; var add = function(a, b) { return (a + b); }; var myObject = { value:1, sum: function() { // this.value: 1 function helper() { // this.value: 2 return add(this.value,this.value); } return helper(); } }; console.log(myObject.sum()); //result: 4
为什么结果不是2? 可能我们第一个想到的问题是为什么不是2,而后才会去想为什么会是4。因为这里的this似乎更像是指向的myObject,而this.value也应该指向的是myObject.value。
想要解答这个问题,我们需要对JavaScript中的this进行更深层次的探讨
二、函数中this的探讨
2. 1 决定this对象绑定的因素
我们将以上代码进行修改以更好的进行探讨
-
案例三
global.value = 2; var add = function(a, b) { return (a + b); }; var myObject = { value:1, sum: function() { console.log(this.value); //this.value: 1 let that = this; function helper() { console.log(this.value); //this.value: 2 this改变了!!! return add(that.value,that.value); } return helper(); } }; console.log(myObject.sum()); //result: 2
一个很重要的现象:this改变了!,或者我们可以用一个更严谨的语言:this绑定的对象改变了!
这个现象向我们证明了一件事:函数中的this不是固定的,它不像Java中那样在一个类中的this永远指向创建它的对象。结合我们上一篇作用域的知识,JavaScript引擎的两个阶段:
- 编译阶段
- 执行阶段
可以得出结论:函数中this绑定对象的确定是在执行阶段!
所以函数中this的对象绑定必然和该函数的执行密切相关。然而函数的执行也远远不是我们所想的那般简单,但总结一下就是在哪如何被执行,将其拆解就是两个重要信息:
-
函数的调用位置
在程序中的哪个位置执行,或者说在哪个位置被调用?我们将这个执行位置称为函数调用位置。
-
函数的调用方式
函数是怎么被调用的?是独立调用还是被其它对象调用?
2.2 寻找规律
我们已经知道了this对象绑定的决定性因素,现在我们对其进行尝试来寻找this对象绑定的规律。
第一条因素:函数的调用位置
执行是this绑定的先决条件,但是在哪调用也很重要,举个例子
-
案例四
global.a = 2; global.b = 2; function sum() { return this.a + this.b; } console.log(sum()); //result: 4 global.a = 3; console.log(sum()); //result: 5
可以看出调用位置的重要性,因为绑定的契机是函数调用而不是函数声明
当然执行或者调用也尤为重要,这也会引出我们后续会遇到的问题:多次的执行或者调用函数会使得该函数this绑定的对象不断改变,也就是this绑定对象的对象丢失问题。
第二条因素:函数的调用方式
1. 独立调用(默认绑定)
-
案例五
lobal.a = 2; global.b = 2; function sum() { return this.a+this.b; } console.log(sum()); //result: 4 //this绑定的对象是全局global!
或许单看这个案例感受并不明显,因为没有其它元素的干扰,我们可以向上观察案例三,"单节点"
helper()
执行时this绑定的对象也是全局global。
由此我们可以得出:独立的函数调用this绑定的对象是全局global
当然也有例外:在函数声明使用严格模式的情况下,独立的函数调用this绑定的对象是undefined
-
案例六
function foo() { "use strict"; //在声明中使用严格模式无法将this绑定到全局 console.log(this); // undefined console.log(this.a); //TypeError: Cannot read property 'a' of undefined } global a = 2; foo(); //报错
我们将这种函数独立调用的this绑定方式称为:默认绑定
2. 被其它对象调用(隐式绑定)
首先我们可以向上观察案例三,当中的myObject.sum()
的操作后,函数sum
的this被绑定到了myObject中,我们可以对这个代码进行扩展来进行规律探索
-
案例七
global.value = 2; const add = function(a, b) { return (a + b); }; const inner = { value: 1, sum: function() { // this.value: 1 return add(this.value, this.value); } }; const outer = { value: 10, // this.value: 10 inner: inner }; console.log(outer.inner.sum()); //result: 2
可以看到最终函数
sum
中的this还是绑定到了对象inner上。
由以上可以得出:被其它对象调用的函数会将该函数的this绑定到调用它的对象。
我们将这种this绑定方式称为:隐式绑定
而且我们也可以由outer.inner.sum()
的this绑定结果知道隐式绑定的绑定优先级高于默认绑定,因为显示sum
在这里其实也被体现了,但是最终的结果还是偏向于隐式绑定。
2.3 打破规律
1. 规律的本质
事实上,以上我们所摸索出的规律也不过只是规律罢了,如果我们探索其本质不过还是一个内存指针问题。
譬如:
默认绑定不过是因为它实际运行的区域是在全局,所以this指向的也是全局地址。
隐式绑定不过是因为它是被一个对象调用,运行的区域在对象,所以this指向的也是对象地址。
2. 使用apply和call打破规律(显示绑定)
-
函数
call
的官方定义:function.call(thisArg, arg1, arg2, ...)
thisArg
:可选的:在function
函数运行时使用的this
值。arg1, arg2, ...
:指定的参数列表。 -
函数
apply
的官方定义:func.apply(thisArg, [argsArray])
thisArg
:必选的。在func
函数运行时使用的this
值。argsArray
:可选的。一个数组或者类数组对象,其中的数组元素将作为单独的参数传给func
函数。
由于我们可以明确的指定this绑定的对象,所以它又称为显示绑定。
那么call
和apply
这么做的目的是什么?难道是为了去修改this绑定而去修改this绑定?这显然是不合理的。
事实上它是有实际的存在意义的。不过在介绍意义之前我们得介绍一个概念:
“类似数组”arguments
函数被调用时,会获得一个“免费”配送的参数=>“类似数组”arguments,它接收了该函数的参数列表里的所有参数,并存有参数长度length。我们可以通过arguments来访问这些参数。
-
案例八
现在我们要根据argumens来设计一个函数,这个函数的功能是:返回传入的最大数,如果这个数不大于我们预先设定好的某个值则返回这个值。
我们很容易就能想到以下方案:
function getMax() { const Min_Max = 60; const result = Math.max(1, 2, 3); if (result <= Min_Max) { return Min_Max; } else { return result; } } console.log(getMax()); //result: 60
问题这甚至连个健康的代码都算不上!因为它的输入参数从一开始就是写死的,这种代码可以说是毫无灵活性。那么导致它失去灵活性的原因是什么?我们来观察下
Math.max
的官方定义:Math.max(value1[,value2, ...])
value1, value2, ...
:一组数值可以看到,它的参数只能是一个一个的单个数值,我们可以试想一下,如果
Math.max
能接收数组参数并返回该数组内的最大值。那是不是能提高代码质量,举个错误的例子:// 此为错误代码!!! 仅举例衍生 function getMax() { const Min_Max = 60; const arr = new Array(arguments); //用法错误!!! arr.push(Min_Max); return Math.max(arr); //用法错误!!! } console.log(getMax(1,2,3)); //result: 60
上述代码语法层面是错误的,但却代表了我们的美好展望,因为从这和前一个代码比较起来简直灵活很多了。要实现这个美好展望,我们需要解决两个问题:
- arguments 如何转化为数组
Math.max
如何参数接收数组
幸运的是,apply
能够解决这两个问题:
function getMax() {
const Min_Max = 60;
//因为arguments是一个“类似数组”而不是一个数组结构
//所以我们需要将它转化成数组然后进行数组操作
const arr = Array.prototype.slice.apply(arguments); //arguments:1,2,3
arr.push(Min_Max);
return Math.max.apply(this,arr);
}
console.log(getMax(1,2,3)); //result: 60
这里apply的作用显示的淋漓尽致。极大的利用到了函数Math.max
本身的特质,精简了代码逻辑。如果你还有不懂,可以看我们对它的进一步剖析。
-
问题一的解答:
Array.prototype.slice.apply(arguments);
是如何将“类似数组”转化成数组结构?其实我们通过2.3中apply的定义就已经知道apply会将函数slice
中的this绑定到arguments上,但是仅仅这些我们可能还是不太能理解这个过程。对此,我自己实现了一下函数
slice
:(源码与此有很大不同,点此链接查看源码)Array.prototype._slice = function(start, end) { var result = new Array(); start = start || 0; end = end || this.length; for (let i = start; i < end; i++) { result.push(this[i]); } return result; }; let arr = [1,2,3,4]; console.log(arr._slice(2)); // result: [3,4]
怎么样,现在是不是就很好理解了,其实整个的转换数组分两个步骤:
将新数组中的this绑定到arguments
遍历this(也就是arguments)中的变量生成新数组
-
问题二的解答:
得益于
apply
的定义,apply直接就能将数组向下传递给max的arguments,所以这个问题也迎刃而解。
这就是apply
中关于this的妙用,其实相应的call
也能达到相同的效果。
2. ES6的进阶
其实综合整个案例八,最大的痛点还是这个arguments,如果arguments从一开始就是个数组,我们也无需进行这么繁琐的转换数组操作了。
于是在ES6中有了对于函数的新扩展:rest参数与数组的扩展运算符
-
rest参数
ES6 引入 rest 参数(形式为
...变量名
),用于获取函数的多余参数,这样就不需要使用arguments
对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。 -
数组的扩展运算符
扩展运算符(spread)是三个点(
...
)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。现在我们来重写一下案例八:
-
案例九
function getMax(...args) { //...args 为rest参数,传入时直接为数组 const Min_Max = 60; args.push(Min_Max); return Math.max(...args);//数组的扩展运算符传参 } //普通传参 const result = getMax(1,2,3); //数组的扩展运算符传参 const result = getMax(...[1,2,3]); //result赋值二选一 console.log((result)); // result:60
怎么样,是不是方便了很多。
2.4 this的补充
new 中的this绑定
可能你会觉得我前三种形式已经把所有this绑定的情况说完了,但事实上,不要忘了本质,this绑定的本质在于函数在哪如何被执行,构造函数的调用也属于这个范畴。并且这也是我们生活中普遍用到的一种this绑定方式
-
案例十
function hello() { console.log(`hello${this.name}`); } function Obj(name){ this.name = name; } Obj.prototype.intorduce = hello; const pig = new Obj('大哥'); const dog = new Obj('小弟'); pig.intorduce(); // hello大哥 dog.intorduce(); // hello小弟
没错就是这样,可能乍一看会很容易理解,并且使用上也不会出现纰漏,但其实在这个new的过程中会涉及到一些JavaScript对象原型的知识。
比如说上述:
const person = new Obj('大哥',hello)
我们将这个过程分为以下几个步骤:
在我们对一个构造函数使用new关键字时,javaScript在执行阶段执行到该语句时会根据这个函数创建一个对象。
随后这个对象会和函数的原型进行连接。
随后会把该构造函数调用的this指向该对象,并执行函数内相应逻辑
构造函数将这个对象返回
由此,我们得以改变了构造函数中的this指向以达成自己构建对象的目的
而且由于我们在第二步中对象同函数进行了原型连接,所以在上述案例中被同构造函数构造出的对象都能共享introduce
方法,而不需要在每个对象中都去创建这个函数导致无谓的内存损耗。
那么为什么普通变量没有置于该函数的原型中呢?原因很简单,如果在这个个函数的原型中存放普通变量,那它就会成为一个所有对象的公有变量,但是问题在于,由于每个对象都可以像获取这个变量一样去轻而易举的改变这个变量,以至于它也不能被当作一个公共常量存在。所以它存在的意义几乎没有什么意义。
那为什么上述中的函数hello
要置于构造函数的原型中呢?因为函数相对相比于一个变量而言更加灵活,事实上这也是封装函数的意义所在,即:重复的逻辑,不同的结果。