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 | class SimpleClass{ |
代码本身非常简单易懂,在同步调用环境下, 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 | class SimpleClass{ |
一句话总结: self 是在类/结构体等结构内才有的概念,一个函数仅在类内声明时,才会默认“创建” self 。
JavaScript 中的 this
接着我们来用 JavaScript 实现上面的代码:
1 | const print = console.log |
首先,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 | class Event{ |
这是一个非常简单常见的回调实现,接下来我们看看 JavaScript 实现版本:
1 | const print = console.log |
上面的代码几乎就是 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 | this.trigger = function(){ |
执行时的上下文正是 Event 类的实例(注意这里显示传入给 listener 的 this 并不是指定其上下文的,仅仅是一个参数而已),如果我们需要达成与 Swift 代码一样的功能,解决思路就是:帮这个代码块“捕获”正确的 this :
方案1:
1 | this.listen = function(toEvt){ |
方案2:
1 | this.listen = function(toEvt){ |
当然, ES6 还推出了一个新的特性,叫箭头函数,也可以完成 this 的正确绑定。
此例中,导致 Swift 与 JavaScript 表现如此不同的原因在于代码块对其引用的变量的“捕获”机制的不同。
再多一步
在 iOS 的世界中,“私有”是一个非常重要的概念(尽管 Objective-C 并不能真正意义上的实现私有),所以自然而然的我也就想到了要试试 JavaScript 的对应实现,实现的方法可以参看这篇文章 ,接着我们来考虑下面的代码:
1 | const print = console.log |
上述代码一共有两点值得讨论,先聚焦到 publicMethod2
中,这里 JavaScript 的表现更像 Objective-C 而不是 Swift ,同类之间的函数调用依然要显示指定调用方才能正常,因此必须写成 this.publicMethod()
才能编译通过。
接着再看 publicMethod
,方法中直接调用了 privateMethod
,先大致说一下用上述代码实现的“私有”的本质:
由于 function Person()
本来就是一个函数,因此在其函数体内定义的 privateVal
以及 function privateMethod
其实相当于函数作用域内的 局部变量/函数 ,在函数体之外无法访问,而如果我们将对 this.publicMethod
的赋值语句拆分成:
1 | function innerPublicMethod(){ |
这样就很好理解为什么此处可以不用并且不能将 privateMethod()
写成 this.privateMethod()
了,因为此处,仅仅是 一个局部函数调用同作用域下的另一个局部函数 而已,但由此引出的一个新的问题是,由于 this.publicMethod
引用了 innerPublicMethod
,所以在 Person 构造函数的作用域外我们仍然可以通过 this.publicMethod()
来执行 innerPublicMethod
中的目标代码,但 privateVal
和 privateMethod
此时都已经不在函数作用域内了,为什么还能正常的被访问/调用呢?这个知识点涉及到一个叫做 作用域链 的概念,网上查到的相关资料都不大易懂,这里结合 《 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 | function Person(){ |
总结
本篇粗浅地通过几个例子,对比 Swift 以及 JavaScript 中的 self/this 的异同之处,希望能够通过对比的方式,理解好 JavaScript 中的 this ,从而减少在后续学习开发中可能犯下的错误。同时,在长久的 Cocoa 开发之后,突然面对 JavaScript 这样一个非面向对象的动态语言,我现在仍然在“用 JavaScript 书写着 Cocoa 的套路”,如何转变这么久以来养成的编程思想,从而真正发挥 JavaScript 的能力,也是一项非常大的挑战。