iOS 程序员如何理解 JS 中的 this

前言

本文是 iOS 程序员的 JavaScript 之旅 系列的第一篇,这个系列主要用于记录我在学习 JavaScript 中的一点心得,文中内容由于参考文献版本不一定是最新 & 个人理解水平有限,可能出现一些错误,还请谅解。如果有任何讨论 / 建议 / 意见,欢迎评论留言或是邮件联系。

基础知识

如果你是完完全全的 JavaScript 小白,那么我建议你先对 JavaScript 的语法进行学习,你可以参考 MDN 上的基础教程,或是阅读其他的书籍进行学习,而我正在阅读的是 《 JavaScript 编程全解 》 ;如果你和我一样,以一个 iOS 程序员的角度初学 JavaScript ,并且对 JavaScript 中的 this 感到疑惑的话,那么这篇文章或许会对你有帮助。

this ? what is this ?

由于 Playground 更适合演示语法,因此本文将以 Swift 为主体语言示例。

为了更好地诠释 JavaScript 与 Swift/Objective-C 的区别,本文不使用 ES6 新推出的 class 系列关键字来声明 JavaScript 中的类

从我们熟悉的 this 讲起

无论你是熟悉 Objective-C 还是 Swift ,在 iOS 程序开发中,我们接触到的 this 实质上是以 self 来表示的,考虑如下的代码:

1
2
3
4
5
6
7
8
9
10
class SimpleClass{
let val = "I am a swift instance"

func log(){
print("\(self.val)")
}
}

let simple = SimpleClass()
simple.log() // I am a swift instance

代码本身非常简单易懂,在同步调用环境下, Swift 甚至允许我们不需要显示指定 self 就可以访问自己的成员变量,但实际上, log 函数是如何访问到 self 的呢?其实我们的任何一个函数,都可以看作形如:

1
func someFunc(... , self:AnyObject ){}

的实现(在 Objective-C 中,形如 -(void)method(id self, SEL _cmd, id arg1, id arg2, ...) ),也就是说即使函数在声明的时候并没有任何参数,编译器依然会偷偷加上参数 self ,从而实现 self 的绑定。

但是需要注意的是,身为一个面向对象的编程语言, self 概念仅仅存在与类中,全局函数或闭包,并没有这样的特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class SimpleClass{
let val = "I am a swift instance"

var log : (() -> ()) = {
print("\(self.val)") // error: use of unresolved identifier 'self'
}
}

func globalLog(){
print("\(self)") // error: use of unresolved identifier 'self'
}

let simple = SimpleClass()
simple.log = {
print("\(self.val)") // error: use of unresolved identifier 'self'
}

simple.log()

一句话总结: self 是在类/结构体等结构内才有的概念,一个函数仅在类内声明时,才会默认“创建” self 。

JavaScript 中的 this

接着我们来用 JavaScript 实现上面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const print = console.log

function SimpleClass(){
this.val = "I am a javascript instance"
this.log = function(){
print(this.val)
}
}

function globalLog(){
print(this.val)
}
globalLog() // undefined

var simple = new SimpleClass()
simple.log() // I am a javascript instance

simple.log = function(){
print("new log , " + this.val)
}

simple.log() // new log , I am a javascript instance

首先,JavaScript 中的函数实现与 Swift(其实绝大多数语言都这样)的实现相似,同样会隐式的在函数参数中添加一个 this ,了解 JavaScript 中的 call/apply 方法的都知道,其实 object.func() 是一个语法糖,等价与 object.func.call/apply(object) ,将调用方作为 this 参数传递给方法。

而与 Swift 最大的不同点在于, 在 JavaScript 中, this 是一个泛化的概念,因为 JavaScript 并不是一个面向对象的语言,因此其代码在运行时,并不以“类的实例”为单位,所以其运行上下文可以多种多样,不同环境的不同情况可以看 这里 ,所以,在任何地方声明的函数/闭包,均有 this 参数(实际上,任何地方都有 this ,因为一个 JavaScript 文件就运行在一个大的对象容器中)。

一句话总结:与 Swift 中的 self 一样,this 实际是为了提供一个运行时的上下文,无论是 self 还是 this ,其作用都可以视为在访问 self.xxx / this.xxx 的时候,为编译器提供一个寻找 xxx 的位置。

截止到这里,其实除了语法和语言设计初衷差异之外,并没有发生什么意料之外的事情。我们接着探索。

this , where is this ?

this 在哪里

在 iOS 开发的世界中,非常常用的一个概念就是“回调”,iOS 中实现回调的方法有很多,可以设置 delegate ,可以监听 Notification ,也可以传入闭包,而现在让我们聚焦到利用闭包实现回调的方法中,通常我们希望某个事件执行完成之后,调用闭包并传入一些事件执行结果让我们进行处理,看看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Event{
var status = false // false == failed , true == success
var lisenter : ((_ event:Event) -> ())?

func trigger(){
status = true
lisenter?(self)
}
}

class Listener{
func handle(success:Bool){
print(success ? "success" : "failed")
}

func listen(event :Event){
event.lisenter = {
event in
self.handle(success: event.status)
}
}
}

let event = Event()
let listener = Listener()

listener.listen(event: event)
event.trigger() // success

这是一个非常简单常见的回调实现,接下来我们看看 JavaScript 实现版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const print = console.log

function Event(){
this.status = false
// this.listener = undefined //无需显示赋值为undefined
this.trigger = function(){
this.status = true
this.listener(this)
}
}

function Listener(){
this.handle = function(success){
print(success == true ? 'success' : 'failed')
}
this.listen = function(toEvt){
toEvt.listener = function(evt){
print(this)
this.handle(evt.status) // TypeError: this.handle is not a function
}
}
}

var evt = new Event()
var listener = new Listener()

listener.listen(evt)
evt.trigger()

上面的代码几乎就是 Swift 版本的直接翻译,唯一的不同在于我在回调处打印了 this ,上述代码会在 this.handle(evt.status) 处报错,给出的原因是 this.handle 不是一个函数

为什么会有这样的问题呢?我们要从稍微深层一点的角度来说起:

在 Swift/Objective-C 中,一个闭包能够“捕获”其中引用的外部变量(Blocks Can Capture Values from the Enclosing Scope),在 iOS 的世界中,每一个闭包在内存中都有着自己对应的一张“捕获表”,其中存储了自身代码块中所引用的变量指针,所以在实际运行的时候,闭包中的 self 可以正确的指向我们在编写程序的时候所想着的那个 self 。

而在 JavaScript 的世界中却不是这样的,像前文提到的,JavaScript 的任何地方都可以存在 this ,而任意的变量名都会根据代码运行时的作用域从内到外寻找,所以 JavaScript 中没有类似的“捕获”机制,而是根据代码运行时的上下文动态确定 this 。而上述代码的回调中的 print(this) 的输出 Event { status: true, trigger: [Function], listener: [Function] } 证实了这一点,语句 toEvt.listener = function(evt){ ... } 将一个函数对象赋值给 listener,而回调触发代码:

1
2
3
4
this.trigger = function(){
this.status = true
this.listener(this)
}

执行时的上下文正是 Event 类的实例(注意这里显示传入给 listener 的 this 并不是指定其上下文的,仅仅是一个参数而已),如果我们需要达成与 Swift 代码一样的功能,解决思路就是:帮这个代码块“捕获”正确的 this :

方案1:

1
2
3
4
5
6
this.listen = function(toEvt){
var me = this
toEvt.listener = function(evt){
me.handle(evt.status)
}
}

方案2:

1
2
3
4
5
6
this.listen = function(toEvt){
var handleByMe = this.handle.bind(this)
toEvt.listener = function(evt){
handleByMe(evt.status)
}
}

当然, ES6 还推出了一个新的特性,叫箭头函数,也可以完成 this 的正确绑定。

此例中,导致 Swift 与 JavaScript 表现如此不同的原因在于代码块对其引用的变量的“捕获”机制的不同。

再多一步

在 iOS 的世界中,“私有”是一个非常重要的概念(尽管 Objective-C 并不能真正意义上的实现私有),所以自然而然的我也就想到了要试试 JavaScript 的对应实现,实现的方法可以参看这篇文章 ,接着我们来考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const print = console.log

function Person(){
var privateVal = 'private value'
this.name = 'Unknown'
function privateMethod(){
print(this.name)
print(privateVal)
}
this.publicMethod = function(){
privateMethod()
}
this.publicMethod2 = function(){
// publicMethod() //编译不通过
this.publicMethod()
}
}

var john = new Person()
john.publicMethod2() // 输出 :undefined
// private value

上述代码一共有两点值得讨论,先聚焦到 publicMethod2 中,这里 JavaScript 的表现更像 Objective-C 而不是 Swift ,同类之间的函数调用依然要显示指定调用方才能正常,因此必须写成 this.publicMethod() 才能编译通过。

接着再看 publicMethod ,方法中直接调用了 privateMethod ,先大致说一下用上述代码实现的“私有”的本质:

由于 function Person() 本来就是一个函数,因此在其函数体内定义的 privateVal 以及 function privateMethod 其实相当于函数作用域内的 局部变量/函数 ,在函数体之外无法访问,而如果我们将对 this.publicMethod 的赋值语句拆分成:

1
2
3
4
function innerPublicMethod(){
privateMethod()
}
this.publicMethod = innerPublicMethod

这样就很好理解为什么此处可以不用并且不能将 privateMethod() 写成 this.privateMethod() 了,因为此处,仅仅是 一个局部函数调用同作用域下的另一个局部函数 而已,但由此引出的一个新的问题是,由于 this.publicMethod 引用了 innerPublicMethod ,所以在 Person 构造函数的作用域外我们仍然可以通过 this.publicMethod() 来执行 innerPublicMethod 中的目标代码,但 privateValprivateMethod 此时都已经不在函数作用域内了,为什么还能正常的被访问/调用呢?这个知识点涉及到一个叫做 作用域链 的概念,网上查到的相关资料都不大易懂,这里结合 《 JavaScript 编程全解 》 中对于作用域链的解释以及我个人的理解,大致说一下这个概念:

在 JavaScript 中,调用函数时会隐式地生成 Call 对象,方便起见,我们将调用函数 f 时生成的 Call 对象称作 Call-f 对象。

若函数 f 中声明了相应的内部函数 g ,则生成 Call-f 对象的同时会生成一个与函数 g 对应的 Function 对象,以名称 g 作为 Call-f 对象的属性。

函数内的变量名查找时按照:自身调用生成的 Call 对象 –> 外层函数 Call 对象 –> … –> 全局对象的顺序查找,这一机制被称为作用域链。

从上面的描述,我们可以得到两个有用的信息:

  • 函数调用时,其 内部 的函数和变量会被存于自己的 Call 对象中。
  • 接上述情况,若函数出现嵌套的情况, 内部函数 有能力 反向向外 查找其外层函数的 Call 对象。

结合我们的例子,可以画出一副这样的图:

var john = new Person() 这一个语句执行的时候,一共生成了四个对象(还有一个 publicMethod2 的 Function 对象),正是 Function 对象对 Call-p 对象( Call-Person 的简写)的引用,保证了这里的“私有”变量和函数的正确调用,另外提一点,未画出的 publicMethod2 的 Function 对象引用的也是同一个 Call-p 对象。

至此,我们已经弄清楚了这种方法为何能够实现“私有”了,最后的一个问题就是,我们在私有函数中输出的 this.name 为何是 undefined

诚然,当代码跑到 privateMethod() 这一句的时候,此时运行上下文中的 this 就是我们的 john 实例,但由于此处不能写成 this.privateMethod() ,像这样直接调用 privateMethod 而没有指定上下文的写法,将会默认地将 全局对象 当作函数运行的上下文。要解决这个问题也很简单,依然是帮助这个函数确定正确的上下文即可。

1
2
3
4
5
6
7
8
9
function Person(){
...
this.publicMethod = function(){
privateMethod.call(this)
}
...
}
...
john.publicMethod2() // 输出 Unknown

总结

本篇粗浅地通过几个例子,对比 Swift 以及 JavaScript 中的 self/this 的异同之处,希望能够通过对比的方式,理解好 JavaScript 中的 this ,从而减少在后续学习开发中可能犯下的错误。同时,在长久的 Cocoa 开发之后,突然面对 JavaScript 这样一个非面向对象的动态语言,我现在仍然在“用 JavaScript 书写着 Cocoa 的套路”,如何转变这么久以来养成的编程思想,从而真正发挥 JavaScript 的能力,也是一项非常大的挑战。