前言
我相信很多人会将 this
和作用域混淆在一起理解,其实它们完全是两回事。
例如 this.xxx
和 console.log(xxx)
有什么不同呢?前者是查找当前 this
所指对象上的 xxx
属性,后者是在当前作用域链上查找变量 xxx
。
var foo = { bar: 'property', // 属性 fn: function () { var bar = 'variable' // 变量 console.log(this.bar) // 这里的 this.bar 永远不会取到 bar 变量, // 可能从 foo 对象或 window 对象上查找 bar 属性(视乎函数调用方式) } } foo.fn() // "property"
作用域与函数的调用方式无关,而 this
则与函数调用方式相关。
正文
一、this 是什么?
在 MDN 上原话是:
The
this
keyword refers to the current object the code is being written inside.
翻译过来就是:在 JavaScript 中,关键字 this
指向了当前代码运行时的对象。
如果我是刚接触 JavaScript 的话,看到这句话应该会有很多问号???
二、为什么需要设计 this?
假设我们有一个 person
对象,该对象有一个 name
属性和 sayHi()
方法。然后我们的需求是,sayHi()
方法要打印出如下信息:
var person = { name: 'Frankie', sayHi: function() { // 若该方法的功能是,打印出:Hi, my name is Frankie. // 要如何实现? } }
- 方案一
最愚蠢的方案,如下:
var person = { name: 'Frankie', sayHi: function(name) { console.log('Hi, my name is ' + name + '.') } } person.sayHi(person.name) // Hi, my name is Frankie.
- 方案二
方案一在每次调用方法都需要传入 person.name
参数,还不如传入一个 person
参数。试图优化一下:
var person = { name: 'Frankie', sayHi: function(context) { console.log('Hi, my name is ' + context.name + '.') } } person.sayHi(person) // Hi, my name is Frankie.
先卖个关子,这种方案看着是不是跟 Function.prototype.call() 有点相似?
- 方案三
在方案二里方法的调用方式,看着还是有点不雅。能不能把形参 context
也省略掉,然后通过 person.sayHi()
直接调用方法?
当然可以,但此时的形参 context
要换成 JavaScript 中的保留关键字 this
。
var person = { name: 'Frankie', sayHi: function() { console.log('Hi, my name is ' + this.name + '.') } } person.sayHi() // Hi, my name is Frankie.
但是为什么 this.name
的值等于 person.name
的属性值呢?还有 this
哪来的?
原来
this
指向函数运行时所在的环境(执行上下文)。
实际上,this
可以理解为函数中的第一个形参(看不见的形参),在你调用 person.sayHi()
的时候,JavaScript 引擎会自动帮你把 person
绑定到 this
上。所以当你通过 this.name
访问属性时,其实就是 person.name
。
三、了解 this
到目前为止,好像 this
也没那么神秘,没那么难理解嘛!
注意,本小节示例均在非严格模式下,除非有特殊说明。
再看:
var person = { name: 'Frankie', sayHi: function() { console.log('Hi, my name is ' + this.name + '.') } } var name = 'Mandy' var sayHi = person.sayHi // 写法一 person.sayHi() // Hi, my name is Frankie. // 写法二,Why??? sayHi() // Hi, my name is Mandy.
上面示例中,person.sayHi
和 sayHi
明明都指向同一个函数,但为什么执行结果不一样呢?
原因就是函数内部使用了 this
关键字。上面提到 this
指的是函数运行时所在的环境。对于 person.sayHi()
来说,sayHi
运行在 person
环境,所以 this
指向了 person
;对于 sayHi()
来说,sayHi
运行在全局环境,所以 this
指向了全局环境。(可在 sayHi
函数体内打印 this
来对比)
那么,函数的运行环境是怎么决定的呢?
内存的数据结构
下面先了解一下 JavaScript 内存的数据结构。JavaScript 之所以有 this
的设计,跟内存里面的数据结构有关系。
var person = { name: 'Frankie' }
上面的示例将一个对象赋值给变量 person
。JavaScript 引擎会先在内存里面,生成一个对象 { name: 'Frankie' }
,然后把这个对象的内存地址赋值给变量 person
。
也就是说,变量 person
是一个地址(reference)。后面如果要读取 person.name
,JavaScript 引擎先从 person
拿到内存地址,然后再从该地址读出原始的对象,返回它的 name
属性。
原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。举例来说,上面的例子 name
属性,实际上是以下面的形式保存的。
{ name: { [[value]]: 'Frankie', [[writable]]: true, [[enumerable]]: true, [[configurable]]: true } }
这样的结构是很清晰的,问题在于属性的值可能是一个函数。
var person = { sayHi: function() {} }
这时,JavaScript 引擎会将函数单独保存在内存中,然后再将函数的地址赋值给 sayHi
属性的 value
属性。
{ name: { [[value]]: 函数的地址, [[writable]]: true, [[enumerable]]: true, [[configurable]]: true } }
由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行。
var fn = function() {} var obj = { fn: fn } // 单独执行 fn() // obj 环境执行 obj.fn()
环境变量
JavaScript 允许在函数体内部,引用当前环境的其他变量。
var sayHi = function() { console.log('Hi, my name is ' + name + '.') }
上面示例中,函数体里面使用了变量 name
。该变量由运行环境提供。
现在问题来了,由于函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获取当前的运行环境(context)。所以, this
就出现了,它的设计目的就是在函数内部,指代函数当前的运行环境。
var sayHi = function() { console.log('Hi, my name is ' + this.name + '.') }
上面的示例中,函数体内部的 this.name
就是指当前运行环境的 。
var sayHi = function() { console.log('Hi, my name is ' + this.name + '.') } var name = 'Mandy' var person = { name: 'Frankie', sayHi: sayHi } // 单独执行 sayHi() // Hi, my name is Mandy. // person 环境执行 person.sayHi() // Hi, my name is Frankie.
上面的示例中,函数 sayHi
在全局环境执行,this.name
指向全局环境的 name
。
在 person
环境执行,this.name
指向 person.name
。
回到本小节开头的示例中:
var person = { name: 'Frankie', sayHi: function() { console.log('Hi, my name is ' + this.name + '.') } } var name = 'Mandy' var sayHi = person.sayHi // 写法一 person.sayHi() // Hi, my name is Frankie. // 写法二,Why??? sayHi() // Hi, my name is Mandy.
person.sayHi()
是通过 person
找到 sayHi
,所以就是在 person
环境执行。一旦 var sayHi = person.sayHi
,变量 sayHi
就直接指向函数本身,所以 sayHi()
就变成在全局环境执行。
在 JavaScript 中,this
的四种绑定规则,而以上的示例 this
均属于默认绑定的。
四、函数调用
上面有提到 Function.prototype.call(),下面先聊聊这个方法。
顺便提一下,
call()
和apply()
方法类似,区别只有一个,call()
方法接受的是参数列表,而apply()
方法接受的是一个参数数组。
call()
允许为不同的对象分配和调用属于一个对象的函数/方法。
1. call 语法
function.call(thisArg, arg1, arg2, ...)
- thisArg(可选)
在 function 函数运行时使用的this
值。请注意,this
可能不是该方法看到的实际值。
在非严格模式下,若参数指定为null
或undefined
,this
会自动替换为指向全局对象。 - arg1, arg2, ...
指定的参数列表。
2. 使用 call 方法调用函数,且不指定第一参数
此时分为两种情况:严格模式和非严格模式,两者在 this
的绑定上有区别。
// 非严格模式下 var thing = 'world' function hello(thing) { console.log('hello ' + this.thing) } hello.call() // hello world // 等价于以下三种方式,均打印出 hello world hello() hello.call(null) hello.call(undefined)
但在严格模式下,
this
的值将会是undefined
。
所以下面示例会报错。
// 严格模式下 'use strict' var thing = 'world' function hello(thing) { console.log('hello ' + this.thing) } hello.call() // TypeError: Cannot read property 'thing' of undefined
3. 普通函数调用(小结)
在 JavaScript 中,其实所有的函数原始的调用方式是这样的:
function.call(thisArg, arg1, arg2, ...)
function fn() { } // 加糖调用 fn() fn(x) // 脱糖调用(desugar)& 严格模式 fn.call(undefined) fn.call(undefined, x) // 脱糖调用(desugar)& 非严格模式 fn.call(window) fn.call(window, x)
fn()
其实就是 fn.call()
的语法糖形式。
注意,当函数被调用时,函数的
this
值才会被绑定。
普通函数调用可以总结成这样:
fn(...args) // 等价于 fn.call(window [ES5-strict: undefined], ...args) (function() {})() // 等价于 (function() {}).call(window [ES5-strict: undefined])
4. 成员函数调用(方法)
这也是一种非常常见的调用方式,又回到开头的那个示例。
var person = { name: 'Frankie', sayHi: function() { console.log('Hi, my name is ' + this.name + '.') } } person.sayHi() // Hi, my name is Frankie. // 等价于 person.sayHi.call(person) // Hi, my name is Frankie.
根据前面讲述的函数在内存中的数据结构,以上示例跟下面动态地将 sayHi
方法绑定到 person
上是一致的。
function sayHi() { console.log('Hi, my name is ' + this.name + '.') } var person = { name: 'Frankie' } var name = 'Mandy' // 动态添加到 person 上 person.sayHi = sayHi person.sayHi() // Hi, my name is Frankie. sayHi() // Hi, my name is Mandy.
以上两者区别是,前一个例子的 sayHi
函数只能通过变量 person
在内存中根据变量存放的对象地址找到对象,该对象下有一个 sayHi
属性,该属性的 [[value]]
存放的函数地址,所以只能通过 person.sayHi()
进行调用。而后一个例子中 sayHi
函数除了前面提到的调用方式外,还可以直接通过变量 sayHi
去调用该函数(sayHi()
)。
注意,
sayHi
函数并没有在编写代码时确定this
的指向。this
总是在函数被调用时,根据其调用的环境进行设置。
四、this 的绑定方式
this
是 JavaScript 的一个关键字,它是函数运行时,在函数体内部自动生成的一个对象,只能在函数体内部使用。
function fn() { this.x = 1 }
上面示例中,函数 fn
运行时,内部会自动有一个 this
可以使用。
函数的不同使用场景,this
会有不同的值。总的来说,this
就是函数运行时所在的环境对象。
1. 普通函数调用(默认绑定)
这是函数最常用的用法了,属于全局性调用,因此 this
就代表全局对象。
注意,全局对象在不同宿主环境下是有区别的。在浏览器环境下全局对象是
window
,而在 Node 环境下,全局对象是global
。
var x = 1 function fn() { console.log(this.x) } fn() // 1
注意,如果使用了 ES6 的 let
或 const
去声明变量 x
时,结果又稍微有点不同了!
let x = 1 function fn() { // 注意,若严格模式下,this 为 undefined,所以执行 this.x 就会报错 console.log(this.x) } fn() // undefined
原因很简单。首先在 fn
运行时,this
仍然指向 window
,只不过 window
对象下没有 x
属性,所以打印了 undefined
。
为什么会这样呢?
// 以下两种方式,其实都往 window 对象添加了 x、y 属性,并赋值。 x = 1 var y = 2 // 但在 ES6 之后,做出了改变 // 使用了 let、const 或者 class 来定义变量或类,都不会往顶层对象添加对应属性 let x = 1 const y = 2 class Fn {}
2. 作为对象方法调用(隐式绑定)
函数还作为某个对象的方法调用,这时 this
就指向这个上级对象。
function fn() { console.log(this.x) } var obj = { x: 1, fn: fn } obj.fn() // 1 // 这里我又再次提一下,但不解释了。如果还不懂,从头再看一遍。 fn() // undefined
3. 通过 call、apply 调用(显式绑定)
这两个方法的参数以及区别不再赘述,上面已经讲过了。
var x = 0 function fn() { console.log(this.x) } var obj = { x: 1, fn: fn } obj.fn.call() // 0,此时 this 指向全局对象 obj.fn.call(obj) // 1,此时 this 指向 obj 对象
4. 作为构造函数调用(new 绑定)
所谓构造函数,就是通过这个函数,可以生成一个新对象。这时,this
就指向这个新对象(实例)。
function Fn() { this.x = 1 } var obj = new Fn() console.log(obj.x) // 1
为了表明这时 this
不是指向全局对象,我们修改一下代码:
var x = 'global' function Fn() { this.x = 'local' } var obj = new Fn() console.log(obj.x) // "local" console.log(x) // "global"
根据结果,我们可以看到全局变量 x
没有发生变化。
以上几种绑定方式,优先级如下:
- 只要使用
new
关键字调用,无论是否还有call
、apply
绑定,this
均指向实例化对象。- 通过
call
、apply
或者bind
显式绑定,this
指向该绑定对象(第一个参数缺省时,根据是否为严格模式,this
指向全局对象或者undefined
)。- 函数通过上下文对象调用,
this
指向(最后)调用它的对象。- 如以上均没有,则会默认绑定。严格模式下,
this
指向undefined
,否则指向全局对象。
五、其他
1. 箭头函数
箭头函数,没有自己的 this
,arguments
,super
或 new.target
,并且它不能用作构造函数。由于没有 this
,因此它不能绑定 this
。
const obj = { x: 1, fn1: () => console.log(this.x, this), fn2: function() { console.log(this.x, this) } } obj.fn1() // undefined, Window{...} obj.fn1.call(obj) // undefined, Window{...} obj.fn2() // 1, Object{...}
2. 事件处理函数
在事件处理函数中,不同的使用方式,会导致 this
指向不同的对象,你知道吗?
六:思考题
抛下两个题,可以分析分析为什么结果不一样。
例一:
var length = 1 function foo() { console.log(this.length) } const obj = { length: 2, bar: function (cb) { cb() } } obj.bar(foo, 'p1', 'p2') // 1
例二:
var length = 1 function foo() { console.log(this.length) } const obj = { length: 2, bar: function (cb) { arguments[0]() } } obj.bar(foo, 'p1', 'p2') // 3
七、参考
- Uncurrying “this” in JavaScript
- A different way of understanding this in JavaScript
- JavaScript 的 this 原理
- Understanding JavaScript Function Invocation and "this"
- The this keyword