在开始研究JS的函数调用方式和this指向问题之前,我们可以先看一段代码。
function Func () {
this.a = function () {
console.log(1)
}
Func.a = function () {
console.log(2)
}
}
Func.prototype.a = function () {
console.log(3)
}
Func.a = function () {
console.log(4)
}
Func.a()
let obj = new Func()
obj.a()
Func.a()
这段代码的执行结果是 4、1、2。接下来会一步一步地分析出,为什么这段代码的执行结果4、1、2。
1. 函数是一等公民
函数是一等公民,或者说函数是第一类对象的意思是:
JS中函数拥有对象的所有能力,也因此函数可被最为任意其他类型对象来对待。
函数能:
- 通过字面量创建。
let f = function (){ /* something */} - 赋值给其他变量,数组项或其他对象的属性。同时也具有动态创建和分配属性的能力。
- 作为函数的参数,或者返回值来传递。
由于函数是一等公民,在声明函数(声明式,通过字面量或者函数构造器创建)时,都生成了一个对象。文章开头的代码块中,执行到第一个函数调用(16行)时,本质时调用了Func这个对象的a属性方法。而在13行处,为Func这个属性挂载了a属性方法。这里会产生疑惑的是,在第五行同样为Func挂载了a属性,为什么第一个打印的不是2呢?因为Func作为一个函数,本身并没有被调用,所以第5行所在的函数内的代码块并没有被执行。在调用Func.a时,执行的方法就应该是第13行声明的函数,打印结果4。
函数是一等公民是函数式编程中的重要概念。从这个特性可以延伸出很多有趣的玩法。比如回调函数,存储函数,函数缓存。
2. 函数调用方式
函数的调用方式对函数内代码的执行有很大的影响。主要是体现在this指向和以及函数上下文是如何建立起来的。
函数的调用方式大致有下面四种:
- 作为一个函数被直接调用。
- 作为一个方法,关联在一个对象上调用。(可实现面向对象思想)
- 作为一个构造函数被调用。
- 通过函数的apply、call或bind方法调用。
2.1 作为函数被直接调用
在这种情况下,this的指向较为简单直接,在浏览器环境中,指向window或者undiluted,取决于是否处于严格模式。
2.2 作为一个方法,关联在一个对象上调用
函数在被调用的时候,会隐式的传递一个this参数。函数作为方法被调用时,父级对象会作为this传递进方法进行调用。利用这个特性可以实现存储函数。 如果我们要实现一个功能,可以随时添加一个对象到一个列表中。但是有可能同一个对象会被重复添加,这里并不希望被重复添加。当然也可以每次添加的时候循环整个数组判断。也可以利用面向对象的思想,实现一个具有数据和行为的对象,即上文中提到的存储函数。
let store = {
nextId : 1,
cache: {},
add: function (obj) {
if(!obj._store_id) {
obj._store_id= this.nextId++
this.cache[obj._store_id] = obj
return true
}
}
}
let a = {name: test}
store.add(a)
add函数作为store的方法被调用,在方法中的this指向即store对象。这样做实现了对行为和数据的封装,避免了变量名的全局污染。但是也有不好的地方就是:store不是一个可以任意实例化的对象。
!! 注意这里的add方法不能使用箭头函数来定义,具体的原因在后文的箭头函数中阐述。
以上是比较简单的一种情形。想象一下如果有一个对象obj,obj有一个属性方法getThis,返回this,也就是obj。然后在定义一个对象obj2,通用希望有一个个obj一样的getThis方法,为了避免重复代码,定义时直接复用了obj.getThis方法。
let obj = {
getThis: function () {
return this
}
}
let obj2 = {
getThis: obj.getThis
}
调用obj.getThis()毫无疑问返回的是obj对象。但是调用obj2.getThis()呢?
答案时obj2。这个答案让人十分欣慰,因为这样,我们可以合理地复用一些定义。
这里也体现了匿名函数this的指向问题:指向被调用时的上下文。也就是说,this时在被调用时才动态确定,将被挂载的父级对象作为this,隐式的传递进函数。我们大可以在不同的地方创建函数,然后在对象创建时,为属性方法赋值(不要使用箭头函数创建!!)。
2.3 作为构造函数调用
作为构造函数使用时,需要配合new关键字使用。
let obj = new Object
要研究作为构造函数使用时,哪些过程涉及this指向,我们可以先弄清楚:在使用new关键字时,js究竟干了什么。
- 构造一个空对象,并作为
this参数,传递给构造函数,作为构造函数的上下文。 - 构造函数执行完毕,这个对象作为
new语句的返回值(除了构造函数有返回值且是一个对象除外)。 - 将这个对象的
__proto__指向构造函数的原型prototype(关于这一点的拓展可以看另一篇文章——理解__proto__ 和 prototype 是什么关系)。
回到开头的代码中。第17行,使用new关键字,进行了构造函数的调用,生成了实例对象obj。在构造函数中对this添加了a方法,打印出1。这个地方还有点值得讨论的是,在第9行对Func的原型对象添加了a方法。Js在属性查找时,先查找对象本身的属性,再沿着原型链对每一个节点进行查找。所以,如果在构造函数中不对this添加加a方法,第二次将会调用到原型上的对应方法。所以第二次对a调用,输出是1。
在构造函数中,还对自己本身Func.a的方法进行了更改,所以第三次对a调用,输出是2。
注意:
- 如果构造函数返回一个对象,则该对象将作为整个表达式的值返回。而传入构造函数的this将被丢弃。
- 但是,如果构造函数的返回值是非对象类型,则忽略返回值,返回新创建的对象。
2.4 使用call、apply方法调用
如果想要显示地指定一个函数执行的上下文,可以使用call、apply方法调用。这两个方法是JS函数类型Function的内置方法。调用方式并不复杂,没啥好说的。
let juggle= function (...nums) {
return this.total += nums.reduce((total, num) => total + num, 0)
}
let counter = {total: 0}
juggle.call(counter, 1,2,3)
juggle.apply(counter, [1,2,3])
apply() 和call()的区别就是调用参数前者以数组的方式传入,后者是参数列表的方式。下面是apply/call的模拟实现。
Function.prototype.myCall = function (context, ...args) {
// 避免属性名冲突
let prop= getHashCode(this.toString())
context[prop] = this
let result = context[hashStr](...args)
delete context[hashStr]
return result
}
3. bind 方法 和 箭头函数
3.1 bind方法
bind方法也是JS函数类型Function的内置方法。
let juggle2Counter = juggle.bind(counter)
所有的函数均可以访问bind方法,可以创建并返回一个新函数,并绑定在传入的对象上。
在创建返回函数内,this指向被绑定的对象。调用bind方法不会修改原始函数,而是创建了一个新函数。
3.2 箭头函数
箭头函数是ES6中新增的语法。前面提到匿名函数或者普通函数,在调用时都会隐式地传入this和arguments两个参数,但是箭头函数不会。
箭头函数的this与声明所在的上下文相同。
这个特性能让我们绕开很多问题。比如当一个函数作为回调函数传入其他代码块执行时,箭头函数内部的this能保证访问的是我们定义的时候想要的this。但是如果过于大意,也会有其他的问题。
let obj = {
getThis: () => this
}
assert(obj.getThis() == obj,'ok')
如果上述代码的getThis使用匿名函数定义那么毫无问题,this在调用时确定,上下文是obj。但是使用箭头函数,在定义时就确定了。在对象初始化时,this的指向是window/undefined。也就是说,当我们通过定义对象的方式实现面向对象或者代码封装时,不要使用箭头函数定义属性方法。在vue2.x中,methods内的属性方法中,如果希望this指向当前vue组件的实例,不能使用箭头函数就是这个原因。因为vue2.x中是使用对象的方式定义组件的。
以上就是关于JS函数调用的相关内容和this指向的一些细节。大部分参照《JavaScript忍者秘籍》第四章以及MDN。欢迎指正。