this 和 Reference type

JS 的作用域问题

在讨论 this 之前,需要先阐述现代语言中的两种底层作用域:

  1. 词法作用域
  2. 动态作用域

JavaScript 是词法作用域,让我们从一段代码开始观察:

javascript
1function fn() {
2  console.log(a)
3}
4function fn2() {
5  var a = 1
6  fn()
7}
8fn2()
9// Uncaught ReferenceError: a is not defined

上面的代码会输出ReferenceError,意味着变量 a 的引用没有被找到。

这说明函数执行时,它并不会在当前所在的环境下去找寻该作用域下的变量。

即 fn()调用时,在 fn 内部的变量 a并不是 fn2函数里的a。

这里的词法作用域指的是,当我们写下function fn(){...}时,这个函数所能访问的作用域已经被创建并且指定到了——全局作用域。在全局作用域下没有变量a,于是抛出一个“引用错误”。

修改成以下代码,我们可以证明这点:fn里的变量 a访问到的是全局作用域下的 a。

js
1var a = 'global scope variable a'
2function fn() {
3  console.log(a)
4}
5function fn2() {
6  var a = 1
7  fn()
8}
9fn2()
10// global scope variable a

动态作用域则不需要关心书写时函数定义在哪里,而是考虑代码在哪运行。

如果 JavaScript 是动态作用域,那么当fn函数调用时才会生成它能访问的作用域。

这意味上上面代码打印出来的结果会变成1。

现在,我们来区分一下词法作用域和动态作用域的差别:

  • 词法作用域更关心的是书写,代码写在哪其实就定义好了作用域
  • 动态作用域不关心书写,而是你在哪调用,哪就产生作用域

那么,JavaScript 中有没有一种东西跟动态作用域一样是随着执行环境变化而变化的呢?

答案是——this

this 全面解析

this 既不指向函数自身也不指向函数的词法作用域。

this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

函数的调用位置

调用位置是函数在代码中被调用的位置(而不是声明的位置)。只有仔细分析调用位置才能回答这个问题:这个 this 到底引用的是什么?

要找调用位置,不单单是找到调用位置的代码那么简单,因为有时候我们可能难以通过单纯的代码来找位置。

我们需要通过调用栈来找到调用位置。

什么是当前调用栈?当前调用栈就是代码执行时候所处在哪个当前函数,而调用位置就是当前执行函数之前调用的位置。

继续用一段代码来分析:

js
1function fn(){
2		// *2 调用fn时的当前调用栈 此时fn调用位置是全局作用域
3	...
4    fn1() // *3 fn1的调用位置
5}
6function fn1(){
7	// *4 当前调用栈从fn => fn1
8	...
9    fn2() // *5 fn2的调用位置
10}
11function fn2(){
12	// *6 当前调用栈从fn1 => fn2
13	...
14}
15fn() // *1 全局作用域 ƒ调用 fn

从上面的代码中,我们标记出每个函数的调用位置——即当前调用函数之前调用的位置。

这对于分析 this 的绑定规则很有用。原因是我们需要从调用位置分析函数的作用域,从而解决 this 到底是什么的问题。

默认 this 绑定

在非 strict 模式下,this 在函数中默认绑定给 window 对象

javascript
1function fn() {
2  console.log(this.a)
3}
4var a = 2
5fn() // 2

独立函数调用时,这里的 this 指向 window 顶级变量。

隐式绑定

当函数作为某个对象的属性被调用时,this 隐式绑定到该对象上

javascript
1var obj = {
2  name: 'qiu',
3  fn: fn,
4}
5function fn() {
6  console.log(this.name)
7}
8obj.fn() // 'qiu'

当使用obj.fn调用函数时,打印出qiu,说明 this 变成了obj。

也可以说函数被调用时obj的上下文继承给了 fn 函数

隐式丢失

javascript
1var obj = {
2  name: 'qiu',
3  fn: fn,
4}
5function fn() {
6  console.log(this.name)
7}
8var baz = obj.fn
9baz() // 打出空白

通过代码,我们可以得知,在obj.fn赋值给baz后,this丢失了,不再是原来的obj,说明运行环境的变化导致this会丢失。

我们应该透过运行环境来分析其中的代码,避免被困扰。

javascript
1var obj = {
2  name: 'qiu',
3  fn: fn,
4}
5function fn() {
6  console.log(this.name)
7}
8var name = 'global a'
9function fn2(f) {
10  f()
11}
12fn2(fn) // global a
13fn2(obj.fn) // global a

上面的代码中,传入的参数obj.fn就是一种赋值,它类似与bar=obj.fn,只是这时候把值传递了函数fn2的参数f,从这时候开始,this就已经丢失了。

在 [Reference Type](##Reference Type)章节中,我们还有更细节的探讨。

随着运行环境的变化,this 会不断丢失,但最后都会指向默认绑定的 window。

setTimeout

setTimeout是属于 window 对象的,调用函数就类似于隐式绑定。

js
1var obj = {
2  name: 'yanxi',
3  fn: fn,
4}
5function fn() {
6  var name = 'qiu'
7  console.log(this.name)
8}
9var name = 'global'
10window.setTimeout(obj.fn, 1000) // global

当obj.fn赋值给setTimeout的callback参数时,this丢失了。

显式绑定

使用 call、apply、bind 来显式绑定,这几个函数的第一个参数是给 this 准备的,第一个参数传什么,this 就是什么。

javascript
1var obj = {
2  name: 'yanxi',
3  fn: fn,
4}
5function fn() {
6  var name = 'qiu'
7  console.log(this.name)
8}
9var name = 'global'
10window.setTimeout(obj.fn.bind(obj), 1000) // yanxi

new 绑定

对于 new 之后发生了什么,可以看这篇博客当我们 new 一个构造函数时,发生了什么? 简单概括一下:

  • 创造一个对象
  • 把新创造的对象放到 this 上
  • 将对象的原型链与自身的 prototype 相连
  • 把构造函数的属性方法赋值给对象
  • 返回这个对象
javascript
1function foo(a) {
2  this.a = a
3}
4var obj = new foo('qiu')

想象一下,使用 new 来调用foo(..)时,我们会构造一个新对象并把它绑定到foo(..)调用中的 this 上

javascript
1// 变成了
2function foo(a) {
3  var newObj = {} //创造了新对象
4  newObj.a = a //把this改成新对象
5  return newObj //返回出去
6}
7var obj = foo('qiu')

事实上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。

箭头函数下的词法 this

箭头函数就是词法作用域的 this,而并非类似动态作用域的 this。

javascript
1function fn() {
2  return () => {
3    console.log(this.a) //继承外层函数fn中的this
4  }
5}
6var a = 1
7var obj = { a: 2, fn: fn }
8var baz = obj.fn()
9baz() //2

上面的代码读取时,会取外层的 fn 中的 this,且像 bind 一样牢牢绑定。

java
1const fn=()=>{
2	console.log(this.a)
3}
4var obj={
5  a:2,
6  fn:fn
7}
8var a=3
9obj.fn() //3
10var fn2=obj.fn
11fn2() //3
12obj.fn.call(obj) //3 不是2!
13fn.call(obj) //依然是3

上面的代码中,箭头函数直接在词法阶段捕获外层的this——window 对象。

不管怎样,this 只会绑定外层的 window,哪怕连 call 也改变不了。

由于这种特性,非常不建议在箭头函数中使用 this,那样可能会写出让开发者产生疑惑的代码。

使用 this 建议

熟悉上面的规则后,在书面规范方面我们应该遵守统一书写风格。

要么完全避免this使用,必要时采用self = this,来保存this指针。

要么就拥抱this,一直使用bind书写显式来告诉所有人,你这里的this是指 xxx,因为并非所有人都能搞懂 this 的怪异乱象。

Reference Type

为什么 this 会丢失

一个动态执行的方法调用也可能会丢失 this。

js
1let user = {
2  name: 'John',
3  hi() {
4    alert(this.name)
5  },
6  bye() {
7    alert('Bye')
8  },
9}
10
11user.hi() // 正常运行
12
13// 现在让我们基于 name 来选择调用 user.hi 或 user.bye
14;(user.name == 'John' ? user.hi : user.bye)() // Error!

在最后一行有个在 user.hi 和 user.bye 中做选择的条件(三元)运算符。当前情形下的结果是 user.hi。

接着该方法被通过 () 立刻调用。但是并不能正常工作!

如你所见,此处调用导致了一个错误,因为在该调用中 "this" 的值变成了 undefined。

也就是说,在三元运算符里的这段代码动态运行时,user.hi里的 this 丢失了。

Reference type 解读

从obj.method()语句中,我们做了两个操作:

  • 通过.读取了obj中method属性的值
  • 通过()调用了它

通过[this 全面解析](##this 全面解析),我们已经知道以下代码是必定产生 this 丢失的:

js
1let user = {
2  name: 'John',
3  hi() {
4    alert(this.name)
5  },
6}
7
8// 把获取方法和调用方法拆成两行
9let hi = user.hi
10hi() // 报错了,因为 this 的值是 undefined

这里 hi = user.hi 把函数赋值给了一个变量,接下来在最后一行它是完全独立的,所以这里没有 this。

为确保 user.hi() 调用正常运行,JavaScript 玩了个小把戏 —— 点 '.' 返回的不是一个函数,而是一个特殊的 Reference Type 的值。

Reference Type 是 ECMA 中的一个“规范类型”。我们不能直接使用它,但它被用在 JavaScript 语言内部。

Reference Type 的值是一个三个值的组合 (base, name, strict),其中:

  • base 是对象。
  • name 是属性名。
  • strict 在 use strict 模式下为 true。

对属性 user.hi 访问的结果不是一个函数,而是一个 Reference Type 的值。对于 user.hi,在严格模式下是:

js
1// Reference Type 的值
2user, 'hi', true

当 () 被在 Reference Type 上调用时,它们会接收到关于对象和对象的方法的完整信息,然后可以设置正确的 this(在此处 =user)。

Reference Type 是一个特殊的“中间人”内部类型,目的是从 . 传递信息给 () 调用

任何例如赋值 hi = user.hi 等其他的操作,都会将 Reference Type 作为一个整体丢弃掉,而会取 user.hi(一个函数)的值并继续传递。所以任何后续操作都“丢失”了 this。

因此,this 的值仅在函数直接被通过点符号 obj.method() 或方括号 obj['method']() 语法(此处它们作用相同)调用时才被正确传递。

当通过三元运算符运算结果时,user.hi()也被折成了两行:

  • (user.hi)
  • ()

Reference type 在这个过程中也就被抛弃掉了。

总结

JavaScript 是基于词法作用域的语言,但它也有类似动态作用域的特性——this 会动态改变,并且带着调用位置的信息。

this 有很多规则:

  1. 默认绑定 window
  2. 隐式绑定到对象
  3. 存在隐式丢失问题
  4. setTimeout 等全局对象调度函数下,默认绑定到 window
  5. 用 bind、apply、call 等能够显式绑定 this
  6. new 构造函数时,会默认将新创建的对象作为 this,调用构造函数
  7. 箭头函数下,this 又会变成词法性质,不再动态改变并且不能被改变

隐式丢失的原因是 JavaScript 在用Object.method读取属性时,返回的不是属性的值,而是Referenct type值,其中存储着属性的值和它的来源对象的完整信息。

这是为了随后的方法调用 () 获取来源对象,然后将 this 设为它。

只有Object.method()或方括号 obj['method']() 时,这个Reference type才会将关于对象和对象的方法完整信息带过来,并设置上正确的 this。

其他情况下的读取操作,都会将Reference type给整个抛弃掉,原先应该返回的Reference Type会自动变成属性的值,this 在这时候也就丢失了。