再来理解ts中的this

Author Avatar
老增 09月 24,2019

学习 js 的时候, 得时刻怀有一种 “我是谁” 的思想觉悟。

背景

这两天在用TS 改造过的 Koa 来写个小项目时, 发现如果想要把控制器方法改成非Static的话,所有的流程都正常, 但是只要访问方法, 并且使用成员属性的话就发生这样的报错:

TypeError: Cannot read property 'service' of undefined

控制器代码如下:

class HomeController {
  service:HomeService
  constructor(){
    this.service = new HomeService()
  }
  async hello (ctx) {
    ctx.body = await this.service.hello()
  }
}

一开始百思不得其解, 一直以为是路由注册方式的问题, 换了很多中方法去修改, 最终都没有解决。 于是采取 "统统推倒"策略。 法放弃自己一切猜测, 回过头来看报错内容本身, 这一看, 看出一脸不好意思...

报错信息说 undefined 找不到叫 'service' 的属性。 说明 undefined 的不是 service, 而是调用 service 的对象(在代码里面是this) , 也就是说,从这个报错里面,并不能得出结论说是 service 初始化失败 (之前没有看清楚,就一直钻在这个牛角尖里面出不来), 真正的原因是 this 本身根本就没有定义。

js 中的 this

想到这里,于是便很容易想到 js 中 this 的鬼畜逻辑。 MDN 里面说到这样一段:

与其他语言相比,函数的 this 关键字在 JavaScript 中的表现略有不同,此外,在严格模式和非严格模式之间也会有一些差别。

在绝大多数情况下,函数的调用方式决定了this 的值。this不能在执行期间被赋值,并且在每次函数被调用时this的值也可能会不同。

如果没有使用 bind 进行绑定或者箭头解析函数, 那么 this 的指向一般有以下几种:

  1. 全局环境下, this 指向的是全局对象, 在浏览器中就是 window , 在node中就是默认全局对象(global)
  2. 在函数内使用(符合我们是上面的情况), this 的指向由调用模式来决定:
    1. 直接调用方法的话, 非严格模式下只想全局对象, 严格模式下为 undefined
    2. 作为对象的方法调用的话, this 指向调用该函数的对象
    3. 作为构造函数的话, this 也是指向构造函数所在的对象
  3. 如果要想把 this 的值从一个环境传到另一个,就要用 call 或者apply 方法。
  4. ES中新增的bind方法会创建一个具有相同函数体和作用域的函数,但是在这个新函数中,this将永久地被绑定到了bind的第一个参数,无论这个函数是如何被调用的,且多次bind只会在第一次生效
  5. 箭头函数中,this与封闭词法环境的this保持一致。 也就是说, 这种情况下this会被设置为它创建时的环境, 例如对象调用 obj.bar() 那么 bar 方法内的 this则会指向 obj 对象

更详细的信息可以看 MDN 文档 或者 阮一峰的文章

ts 中的 this

从 js 的规则来看, 似乎把方法改为箭头函数或者使用 bind 方法来绑定 this 都能够解决问题。 由于 hello 方法是一个接口,调用的方式写在 koa 的内部,我们不方便直接去修改, 所以先择箭头函数试试水, 于是把 hello 方法改成箭头函数试试水。 结果问题解决!

但, 问题结束了吗?

我们知道, ts 是 js 的超集,所以按照上面的理解, 我们大体认为这便是 ts 中 this 的指向规则也是对的。 ts 代码最终还是会编译成 js 代码来运行的。 那么按照这样改的话, 为什么就可以生效了了呢?其实只要把 ts 代码通过转成对应的 js 代码就很清晰了。

复盘一下代码:(可以在 Typescript Playground 中查看

class User {
    name:string = 'Fia'
    sayhi () {t
        console.log('hi ' + this.name)
    }

    saybye = () => {
        console.log('bye ' + this.name)
    }
}


let u = new User;
let hi = u.sayhi
let bye = u.saybye

u.sayhi() // hi Fia
hi() // undefined
bye() // bye Fia

其中 User 的定义, 生成的 js 代码如下 (ES2015)

class User {
    constructor() {
        this.name = 'Fia';
        this.saybye = () => {
            console.log('bye ' + this.name);
        };
    }
    sayhi() {
        console.log('hi ' + this.name);
    }
}

注: 生成 ES5 的代码要比上面这个更繁琐一些, 不过却更加清晰, 放在末尾供参考, 那真叫一个一目了然!

从上面生成的代码不难看出,普通的方法和通过箭头函数定义的方法的区别就是, 箭头函数的方法是在构造函数内被定义的, 跟类的普通属性(name) 一样, 而上面提到在箭头函数中, this与封闭环境的 this 一致。通俗一点来说, 这个函数创建时候 this 指向哪里, 那么函数内的 this 便指向那里。

而在构造函数中, this 指向的是对象本身。 所以在函数里面取 this.name 的逻辑便是我们创建对象的时候初始化的值。或者更粗暴的说, 因为“与封闭环境一致”, 而上面一行定义的 this.name 在方法里面自然就是生效的。

而普通方法 sayhi() 呢?当函数作为对象方法被调用时, 函数内部的 this 时有调用者决定的, 而不受函数定义的方式或位置影响。所以, 当我们使用 o.sayhi() 的方式来调用时, this 依旧指向的时对象 o ,而在把它赋值给 hi 后,调用者便不是 o 了, 此时, 函数相当于被当作纯函数调用, this 指向的是全局, 在严格模式下则是 undefined , 因此, 才会报前面开头那个错误。

需要再次明确的是,这不是 let hi = u.sayhi 这句的问题, 而是 hi() 的问题。 也就是前面所讲this 指向是由调用者决定的, 我们可以再例子代码中再写下面几行:

let test = {name: 'test', hi: hi}
test.hi()

运行结果: hi test

因为此时方法的调用者是 test , 所以 this.name = 'test'

附:

例子生成 ES5 的代码

var User = (function () {
 function User() {
     var _this = this;
     this.name = 'Fia';
     this.saybye = function () {
         console.log('bye ' + _this.name);
     };
 }
 User.prototype.sayhi = function () {
     console.log('hi ' + this.name);
 };
 return User;
}());

参考资料:

https://stackoverflow.com/questions/38245450/angular2-components-this-is-undefined-when-executing-callback-function

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/this

http://www.ruanyifeng.com/blog/2010/04/using_this_keyword_in_javascript.html