【ArchSummit架构师峰会】探讨数据与人工智能相互驱动的关系>>> 了解详情
写点什么

深入理解 JSCore

  • 2020-02-25
  • 本文字数:13715 字

    阅读完需:约 45 分钟

深入理解JSCore

背景

动态化作为移动客户端技术的一个重要分支,一直是业界积极探索的方向。目前业界流行的动态化方案,如 Facebook 的 React Native,阿里巴巴的 Weex 都采用了前端系的 DSL 方案,而它们在 iOS 系统上能够顺利的运行,都离不开一个背后的功臣:JavaScriptCore(以下简称 JSCore),它建立起了 Objective-C(以下简称 OC)和 JavaScript(以下简称 JS)两门语言之间沟通的桥梁。无论是这些流行的动态化方案,还是 WebView Hybrid 方案,亦或是之前广泛流行的 JSPatch,JSCore 都在其中发挥了举足轻重的作用。作为一名 iOS 开发工程师,了解 JSCore 已经逐渐成为了必备技能之一。

从浏览器谈起

在 iOS 7 之后,JSCore 作为一个系统级 Framework 被苹果提供给开发者。JSCore 作为苹果的浏览器引擎 WebKit 中重要组成部分,这个 JS 引擎已经存在多年。如果想去追本溯源,探究 JSCore 的奥秘,那么就应该从 JS 这门语言的诞生,以及它最重要的宿主-Safari 浏览器开始谈起。

JavaScript 历史简介

JavaScript 诞生于 1995 年,它的设计者是 Netscape 的 Brendan Eich,而此时的 Netscape 正是浏览器市场的霸主。


而二十多年前,当时人们在浏览网页的体验极差,因为那会儿的浏览器几乎只有页面的展示能力,没有和用户的交互逻辑处理能力。所以即使一个必填输入框传空,也需要经过服务端验证,等到返回结果之后才给出响应,再加上当时的网速很慢,可能半分钟过去了,返回的结果是告诉你某个必填字段未填。所以 Brendan 花了十天写出了 JavaScript,由浏览器解释执行,从此之后浏览器也有了一些基本的交互处理能力,以及表单数据验证能力。


而 Brendan 可能没有想到,在二十多年后的今天。JS 这门解释执行的动态脚本语言,不光成为前端届的“正统”,还入侵了后端开发领域,在编程语言排行榜上进入前三甲,仅次于 Python 和 Java。而如何解释执行 JS,则是各家引擎的核心技术。目前市面上比较常见的 JS 引擎有 Google 的 V8(它被运用在 Android 操作系统以及 Google 的 Chrome 上),以及我们今天的主角–JSCore(它被运用在 iOS 操作系统以及 Safari 上)。

WebKit

我们每天都会接触浏览器,使用浏览器进行工作、娱乐。让浏览器能够正常工作最核心的部分就是浏览器的内核,每个浏览器都有自己的内核,Safari 的内核就是 WebKit。WebKit 诞生于 1998 年,并于 2005 年由 Apple 公司开源,Google 的 Blink 也是在 WebKit 的分支上进行开发的。


WebKit 由多个重要模块组成,通过下图我们可以对 WebKit 有个整体的了解:



简单点讲,WebKit 就是一个页面渲染以及逻辑处理引擎,前端工程师把 HTML、JavaScript、CSS 这“三驾马车”作为输入,经过 WebKit 的处理,就输出成了我们能看到以及操作的 Web 页面。从上图我们可以看出来,WebKit 由图中框住的四个部分组成。而其中最主要的就是 WebCore 和 JSCore(或者是其它 JS 引擎),这两部分我们会分成两个小章节详细讲述。除此之外,WebKit Embedding API 是负责浏览器 UI 与 WebKit 进行交互的部分,而 WebKit Ports 则是让 Webkit 更加方便的移植到各个操作系统、平台上,提供的一些调用 Native Library 的接口,比如在渲染层面,在 iOS 系统中,Safari 是交给 CoreGraphics 处理,而在 Android 系统中,Webkit 则是交给 Skia。

WebCore

在上面的 WebKit 组成图中,我们可以发现只有 WebCore 是红色的。这是因为时至今日,WebKit 已经有很多的分支以及各大厂家也进行了很多优化改造,唯独 WebCore 这个部分是所有 WebKit 共享的。WebCore 是 WebKit 中代码最多的部分,也是整个 WebKit 中最核心的渲染引擎。那首先我们来看看整个 WebKit 的渲染流程:



首先浏览器通过 URL 定位到了一堆由 HTML、CSS、JS 组成的资源文件,通过加载器(这个加载器的实现也很复杂,在此不多赘述)把资源文件给 WebCore。之后 HTML Parser 会把 HTML 解析成 DOM 树,CSS Parser 会把 CSS 解析成 CSSOM 树。最后把这两棵树合并,生成最终需要的渲染树,再经过布局,与具体 WebKit Ports 的渲染接口,把渲染树渲染输出到屏幕上,成为了最终呈现在用户面前的 Web 页面。

JSCore

概述

终于讲到我们这期的主角 – JSCore。JSCore 是 WebKit 默认内嵌的 JS 引擎,之所以说是默认内嵌,是因为很多基于 WebKit 分支开发的浏览器引擎都开发了自家的 JS 引擎,其中最出名的就是 Chrome 的 V8。这些 JS 引擎的使命都相同,那就是解释执行 JS 脚本。而从上面的渲染流程图我们可以看到,JS 和 DOM 树之间存在着互相关联,这是因为浏览器中的 JS 脚本最主要的功能就是操作 DOM 树,并与之交互。同样的,我们也通过一张图看下它的工作流程:



可以看到,相比静态编译语言生成语法树之后,还需要进行链接,装载生成可执行文件等操作,解释型语言在流程上要简化很多。这张流程图右边画框的部分就是 JSCore 的组成部分:Lexer、Parser、LLInt 以及 JIT 的部分(之所以 JIT 的部分是用橙色标注,是因为并不是所有的 JSCore 中都有 JIT 部分)。接下来我们就搭配整个工作流程介绍每一部分,它主要分为以下三个部分:词法分析、语法分析以及解释执行。


PS:严格的讲,语言本身并不存在编译型或者是解释型,因为语言只是一些抽象的定义与约束,并不要求具体的实现,执行方式。这里讲 JS 是一门“解释型语言”只是 JS 一般是被 JS 引擎动态解释执行,而并不是语言本身的属性。

词法分析 – Lexer

词法分析很好理解,就是把一段我们写的源代码分解成 Token 序列的过程,这一过程也叫分词。在 JSCore,词法分析是由 Lexer 来完成(有的编译器或者解释器把分词叫做 Scanner)。


这是一句很简单的 C 语言表达式:


sum = 3 + 2; 
复制代码


将其标记化之后可以得到下表的内容:


元素标记类型
sum标识符
=赋值操作符
3数字
+加法操作符
2数字
;语句结束


这就是词法分析之后的结果,但是词法分析并不会关注每个 Token 之间的关系,是否匹配,仅仅是把它们区分开来,等待语法分析来把这些 Token“串起来”。词法分析函数一般是由语法分析器(Parser)来进行调用的。在 JSCore 中,词法分析器 Lexer 的代码主要集中在 parser/Lexer.h、Lexer.cpp 中。

语法分析 – Parser

跟人类语言一样,我们讲话的时候其实是按照约定俗成,交流习惯按照一定的语法讲出一个又一个词语。那类比到计算机语言,计算机要理解一门计算机语言,也要理解一个语句的语法。例如以下一段 JS 语句:


var sum = 2 + 3;var a = sum + 5;
复制代码


Parser 会把 Lexer 分析之后生成的 token 序列进行语法分析,并生成对应的一棵抽象语法树(AST)。这个树长什么样呢?在这里推荐一个网站:esprima Parser,输入 JS 语句可以立马生成我们所需的 AST。例如,以上语句就被生成这样的一棵树:



之后,ByteCodeGenerator 会根据 AST 来生成 JSCore 的字节码,完成整个语法解析步骤。

解释执行 – LLInt 和 JIT

JS 源代码经过了词法分析和语法分析这两个步骤,转成了字节码,其实就是经过任何一门程序语言必经的步骤–编译。但是不同于我们编译运行 OC 代码,JS 编译结束之后,并不会生成存放在内存或者硬盘之中的目标代码或可执行文件。生成的指令字节码,会被立即被 JSCore 这台虚拟机进行逐行解释执行。


运行指令字节码(ByteCode)是 JS 引擎中很核心的部分,各家 JS 引擎的优化也主要集中于此。JSByteCode 的解释执行是一套很复杂的系统,特别是加入了 OSR 和多级 JIT 技术之后,整个解释执行变的越来越高效,并且让整个 ByteCode 的执行在低延时之间和高吞吐之间有个很好的平衡:由低延时的 LLInt 来解释执行 ByteCode,当遇到多次重复调用或者是递归,循环等条件会通过 OSR 切换成 JIT 进行解释执行(根据具体触发条件会进入不同的 JIT 进行动态解释)来加快速度。由于这部分内容较为复杂,而且不是本文重点,故只做简单介绍,不做深入的讨论。

JSCore 值得注意的 Feature

除了以上部分,JSCore 还有几个值得注意的 Feature。

基于寄存器的指令集结构

JSCore 采用的是基于寄存器的指令集结构,相比于基于栈的指令集结构(比如有些 JVM 的实现),因为不需要把操作结果频繁入栈出栈,所以这种架构的指令集执行效率更高。但是由于这样的架构也造成内存开销更大的问题,除此之外,还存在移植性弱的问题,因为虚拟机中的虚拟寄存器需要去匹配到真实机器中 CPU 的寄存器,可能会存在真实 CPU 寄存器不足的问题。


基于寄存器的指令集结构通常都是三地址或者二地址的指令集,例如:


i = a + b;//转成三地址指令:add i,a,b; //把a寄存器中的值和b寄存器中的值相加,存入i寄存器
复制代码


在三地址的指令集中的运算过程是把 a 和 b 分别 mov 到两个寄存器,然后把这两个寄存器的值求和之后,存入第三个寄存器。这就是三地址指令运算过程。


而基于栈的一般都是零地址指令集,因为它的运算不依托于具体的寄存器,而是使用对操作数栈和具体运算符来完成整个运算。

单线程机制

值得注意的是,整个 JS 代码是执行在一条线程里的,它并不像我们使用的 OC、Java 等语言,在自己的执行环境里就能申请多条线程去处理一些耗时任务来防止阻塞主线程。JS 代码本身并不存在多线程处理任务的能力。但是为什么 JS 也存在多线程异步呢?强大的事件驱动机制,是让 JS 也可以进行多线程处理的关键。

事件驱动机制

之前讲到,JS 的诞生就是为了让浏览器也拥有一些交互,逻辑处理能力。而 JS 与浏览器之间的交互是通过事件来实现的,比如浏览器检测到发生了用户点击,会传递一个点击事件通知 JS 线程去处理这个事件。


那通过这一特性,我们可以让 JS 也进行异步编程,简单来讲就是遇到耗时任务时,JS 可以把这个任务丢给一个由 JS 宿主提供的工作线程(WebWorker)去处理。等工作线程处理完之后,会发送一个 message 让 JS 线程知道这个任务已经被执行完了,并在 JS 线程上去执行相应的事件处理程序。(但是需要注意,由于工作线程和 JS 线程并不在一个运行环境,所以它们并不共享一个作用域,故工作线程也不能操作 window 和 DOM。)


JS 线程和工作线程,以及浏览器事件之间的通信机制叫做事件循环(EventLoop),类似于 iOS 的 runloop。它有两个概念,一个是 Call Stack,一个是 Task Queue。当工作线程完成异步任务之后,会把消息推到 Task Queue,消息就是注册时的回调函数。当 Call Stack 为空的时候,主线程会从 Task Queue 里取一条消息放入 Call Stack 来执行,JS 主线程会一直重复这个动作直到消息队列为空。



以上这张图大概描述了 JSCore 的事件驱动机制,整个 JS 程序其实就是这样跑起来的。这个其实跟空闲状态下的 iOS Runloop 有点像,当基于 Port 的 Source 事件唤醒 runloop 之后,会去处理当前队列里的所有 source 事件。JS 的事件驱动,跟消息队列其实是“异曲同工”。也正因为工作线程和事件驱动机制的存在,才让 JS 有了多线程异步能力。

iOS 中的 JSCore

iOS7 之后,苹果对 WebKit 中的 JSCore 进行了 Objective-C 的封装,并提供给所有的 iOS 开发者。JSCore 框架给 Swift、OC 以及 C 语言编写的 App 提供了调用 JS 程序的能力。同时我们也可以使用 JSCore 往 JS 环境中去插入一些自定义对象。


iOS 中可以使用 JSCore 的地方有多处,比如封装在 UIWebView 中的 JSCore,封装在 WKWebView 中的 JSCore,以及系统提供的 JSCore。实际上,即使同为 JSCore,它们之间也存在很多区别。因为随着 JS 这门语言的发展,JS 的宿主越来越多,有各种各样的浏览器,甚至是常见于服务端的 Node.js(基于 V8 运行)。随时使用场景的不同,以及 WebKit 团队自身不停的优化,JSCore 逐渐分化出不同的版本。除了老版本的 JSCore,还有 2008 年宣布的运行在 Safari、WKWebView 中的 Nitro(SquirrelFish)等等。而在本文中,我们主要介绍 iOS 系统自带的 JSCore Framework。


iOS官方文档对 JSCore 的介绍很简单,其实主要就是给 App 提供了调用 JS 脚本的能力。我们首先通过 JSCore Framework 的 15 个开放头文件来“管中窥豹”,如下图所示:



乍一看,概念很多。但是除去一些公共头文件以及一些很细节的概念,其实真正常用的并不多,笔者认为很有必要了解的概念只有 4 个:JSVM,JSContext,JSValue,JSExport。鉴于讲述这些概念的文章已经有很多,本文尽量从一些不同的角度(比如原理,延伸对比等)去解释这些概念。

JSVirtualMachine

一个 JSVirtualMachine(以下简称 JSVM)实例代表了一个自包含的 JS 运行环境,或者是一系列 JS 运行所需的资源。该类有两个主要的使用用途:一是支持并发的 JS 调用,二是管理 JS 和 Native 之间桥对象的内存。


JSVM 是我们要学习的第一个概念。官方介绍 JSVM 为 JavaScript 的执行提供底层资源,而从类名直译过来,一个 JSVM 就代表一个 JS 虚拟机,我们在上面也提到了虚拟机的概念,那我们先讨论一下什么是虚拟机。首先我们可以看看(可能是)最出名的虚拟机——JVM(Java 虚拟机)。


JVM 主要做两个事情:


  1. 首先它要做的是把 JavaC 编译器生成的 ByteCode(ByteCode 其实就是 JVM 的虚拟机器指令)生成每台机器所需要的机器指令,让 Java 程序可执行(如下图)。

  2. 第二步,JVM 负责整个 Java 程序运行时所需要的内存空间管理、GC 以及 Java 程序与 Native(即 C,C++)之间的接口等等。



从功能上来看,一个高级语言虚拟机主要分为两部分,一个是解释器部分,用来运行高级语言编译生成的 ByteCode,还有一部分则是 Runtime 运行时,用来负责运行时的内存空间开辟、管理等等。实际上,JSCore 常常被认为是一个 JS 语言的优化虚拟机,它做着 JVM 类似的事情,只是相比静态编译的 Java,它还多承担了把 JS 源代码编译成字节码的工作。


既然 JSCore 被认为是一个虚拟机,那 JSVM 又是什么?实际上,JSVM 就是一个抽象的 JS 虚拟机,让开发者可以直接操作。在 App 中,我们可以运行多个 JSVM 来执行不同的任务。而且每一个 JSContext(下节介绍)都从属于一个 JSVM。但是需要注意的是每个 JSVM 都有自己独立的堆空间,GC 也只能处理 JSVM 内部的对象(在下节会简单讲解 JS 的 GC 机制)。所以说,不同的 JSVM 之间是无法传递值的。


值得注意的还有,在上面的章节中,我们提到的 JS 单线程机制。这意味着,在一个 JSVM 中,只有一条线程可以跑 JS 代码,所以我们无法使用 JSVM 进行多线程处理 JS 任务。如果我们需要多线程处理 JS 任务的场景,就需要同时生成多个 JSVM,从而达到多线程处理的目的。

JS 的 GC 机制

JS 同样也不需要我们去手动管理内存。JS 的内存管理使用的是 GC 机制(Tracing Garbage Collection)。不同于 OC 的引用计数,Tracing Garbage Collection 是由 GCRoot(Context)开始维护的一条引用链,一旦引用链无法触达某对象节点,这个对象就会被回收掉。如下图所示:


JSContext

一个 JSContext 表示了一次 JS 的执行环境。我们可以通过创建一个 JSContext 去调用 JS 脚本,访问一些 JS 定义的值和函数,同时也提供了让 JS 访问 Native 对象,方法的接口。


JSContext 是我们在实际使用 JSCore 时,经常用到的概念之一。”Context”这个概念我们都或多或少的在其它开发场景中见过,它最常被翻译成“上下文”。那什么是上下文?比如在一篇文章中,我们看到一句话:“他飞快的跑了出去。”但是如果我们不看上下文的话,我们并不知道这句话究竟是什么意思:谁跑了出去?他是谁?他为什么要跑?


写计算机理解的程序语言跟写文章是相似的,我们运行任何一段语句都需要有这样一个“上下文”的存在。比如之前外部变量的引入、全局变量、函数的定义、已经分配的资源等等。有了这些信息,我们才能准确的执行每一句代码。


同理,JSContext 就是 JS 语言的执行环境,所有 JS 代码的执行必须在一个 JSContext 之中,在 WebView 中也是一样,我们可以通过 KVC 的方式获取当时 WebView 的 JSContext。通过 JSContext 运行一段 JS 代码十分简单,如下面这个例子:


  JSContext *context = [[JSContext alloc] init];  [context evaluateScript:@"var a = 1;var b = 2;"];  NSInteger sum = [[context evaluateScript:@"a + b"] toInt32];//sum=3
复制代码


借助 evaluateScript API,我们就可以在 OC 中搭配 JSContext 执行 JS 代码。它的返回值是 JS 中最后生成的一个值,用属于当前 JSContext 中的 JSValue(下一节会有介绍)包裹返回。


我们还可以通过 KVC 的方式,给 JSContext 塞进去很多全局对象或者全局函数:


  JSContext *context = [[JSContext alloc] init];              context[@"globalFunc"] = ^() {    NSArray *args = [JSContext currentArguments];    for (id obj in args) {      NSLog(@"拿到了参数:%@", obj);    }  };       context[@"globalProp"] = @"全局变量字符串";  [context evaluateScript:@"globalFunc(globalProp)"];//console输出:“拿到了参数:全局变量字符串”
复制代码


这是一个很好用而且很重要的特性,有很多著名的借助 JSCore 的框架如 JSPatch,都利用了这个特性去实现一些很巧妙的事情。在这里我们不过多探讨可以利用它做什么,而是去研究它究竟是怎样运作的。在 JSContext 的 API 中,有一个值得注意的只读属性 – JSValue 类型的 globalObject。它返回当前执行 JSContext 的全局对象,例如在 WebKit 中,JSContext 就会返回当前的 Window 对象。而这个全局对象其实也是 JSContext 最核心的东西,当我们通过 KVC 方式与 JSContext 进去取值赋值的时候,实际上都是在跟这个全局对象做交互,几乎所有的东西都在全局对象里,可以说,JSContext 只是 globalObject 的一层壳。对于上述两个例子,本文取了 context 的 globalObject,并转成了 OC 对象,如下图:



可以看到这个 globalObject 保存了所有的变量与函数,这更加印证了上文的说法(至于为什么 globalObject 对应 OC 对象是 NSDictionary 类型,我们将在下节中讲述)。所以我们还能得出另外一个结论,JS 中所谓的全局变量,全局函数不过是全局对象的属性和函数。


同时值得注意的是,每个 JSContext 都从属于一个 JSVM。我们可以通过 JSContext 的只读属性 – virtualMachine 获得当前 JSContext 绑定的 JSVM。JSContext 和 JSVM 是多对一的关系,一个 JSContext 只能绑定一个 JSVM,但是一个 JSVM 可以同时持有多个 JSContext。而上文中我们提到,每个 JSVM 同时只有整个一个线程来执行 JS 代码,所以综合来看,一次简单的通过 JSCore 运行 JS 代码,并在 Native 层获取返回值的过程大致如下:


JSValue

JSValue 实例是一个指向 JS 值的引用指针。我们可以使用 JSValue 类,在 OC 和 JS 的基础数据类型之间相互转换。同时我们也可以使用这个类,去创建包装了 Native 自定义类的 JS 对象,或者是那些由 Native 方法或者 Block 提供实现 JS 方法的 JS 对象。


在 JSContext 一节中,我们接触了大量的 JSValue 类型的变量。在 JSContext 一节中我们了解到,我们可以很简单的通过 KVC 操作 JS 全局对象,也可以直接获得 JS 代码执行结果的返回值(同时每一个 JS 中的值都存在于一个执行环境之中,也就是说每个 JSValue 都存在于一个 JSContext 之中,这也就是 JSValue 的作用域),都是因为 JSCore 帮我们用 JSValue 在底层自动做了 OC 和 JS 的类型转换。


JSCore 一共提供了如下 10 种类型互换:


  Objective-C type |  JavaScript type --------------------+---------------------     nil     |   undefined    NSNull    |    null    NSString   |    string    NSNumber   |  number, boolean   NSDictionary  |  Object object    NSArray    |  Array object    NSDate    |   Date object    NSBlock    |  Function object      id     |  Wrapper object     Class    | Constructor object
复制代码


同时还提供了对应的互换 API(节选):


+ (JSValue *)valueWithDouble:(double)value inContext:(JSContext *)context;+ (JSValue *)valueWithInt32:(int32_t)value inContext:(JSContext *)context;- (NSArray *)toArray;- (NSDictionary *)toDictionary;
复制代码


在讲类型转换前,我们先了解一下 JS 这门语言的变量类型。根据 ECMAScript(可以理解为 JS 的标准)的定义:JS 中存在两种数据类型的值,一种是基本类型值,它指的是简单的数据段。第二种是引用类型值,指那些可能由多个值构成的对象。基本类型值包括”undefined”,”nul”,”Boolean”,”Number”,”String”(是的,String 也是基础类型),除此之外都是引用类型。对于前五种基础类型的互换,应该没有太多要讲的。接下来会重点讲讲引用类型的互换:

NSDictionary <–> Object

在上节中,我们把 JSContext 的 globalObject 转换成 OC 对象,发现是 NSDictionary 类型。要搞清楚这个转换,首先我们对 JS 这门语言面向对象的特性进行一个简单的了解。在 JS 中,对象就是一个引用类型的实例。与我们熟悉的 OC、Java 不一样,对象并不是一个类的实例,因为在 JS 中并不存在类的概念。ECMA 把对象定义为:无序属性的集合,其属性可以包含基本值、对象或者函数。从这个定义我们可以发现,JS 中的对象就是无序的键值对,这和 OC 中的 NSDictionary,Java 中的 HashMap 何其相似。


    var person = { name: "Nicholas",age: 17};//JS中的person对象    NSDictionary *person = @{@"name":@"Nicholas",@"age":@17};//OC中的person dictionary
复制代码


在上面的实例代码中,笔者使用了类似的方式创建了 JS 中的对象(在 JS 中叫“对象字面量”表示法)与 OC 中的 NSDictionary,相信可以更有助理解这两个转换。

NSBlock <–> Function Object

在上节的例子中,笔者在 JSContext 赋值了一个”globalFunc”的 Block,并可以在 JS 代码中当成一个函数直接调用。我还可以使用”typeof”关键字来判断 globalFunc 在 JS 中的类型:


  NSString *type = [[context evaluateScript:@"typeof globalFunc"] toString];//type的值为"function"
复制代码


通过这个例子,我们也能发现传入的 Block 对象在 JS 中已经被转成了”function”类型。”Function Object”这个概念对于我们写惯传统面向对象语言的开发者来说,可能会比较晦涩。而实际上,JS 这门语言,除了基本类型以外,就是引用类型。函数实际上也是一个”Function”类型的对象,每个函数名实则是指向一个函数对象的引用。比如我们可以这样在 JS 中定义一个函数:


        var sum = function(num1,num2){                return num1 + num2;         }
复制代码


同时我们还可以这样定义一个函数(不推荐):


        var sum = new Function("num1","num2","return num1 + num2");
复制代码


按照第二种写法,我们就能很直观的理解到函数也是对象,它的构造函数就是 Function,函数名只是指向这个对象的指针。而 NSBlock 是一个包裹了函数指针的类,JSCore 把 Function Object 转成 NSBlock 对象,可以说是很合适的。

JSExport

实现 JSExport 协议可以开放 OC 类和它们的实例方法,类方法,以及属性给 JS 调用。


除了上一节提到的几种特殊类型的转换,我们还剩下 NSDate 类型,与 id、class 类型的转换需要弄清楚。而 NSDate 类型无需赘述,所以我们在这一节重点要弄清楚后两者的转换。


而通常情况下,我们如果想在 JS 环境中使用 OC 中的类和对象,需要它们实现 JSExport 协议,来确定暴露给 JS 环境中的属性和方法。比如我们需要向 JS 环境中暴露一个 Person 的类与获取名字的方法:


@protocol PersonProtocol <JSExport>- (NSString *)fullName;//fullName用来拼接firstName和lastName,并返回全名@end
@interface JSExportPerson : NSObject <PersonProtocol> - (NSString *)sayFullName;//sayFullName方法
@property (nonatomic, copy) NSString *firstName;@property (nonatomic, copy) NSString *lastName;
@end
复制代码


然后,我们可以把一个 JSExportPerson 的一个实例传入 JSContext,并且可以直接执行 fullName 方法:


  JSExportPerson *person = [[JSExportPerson alloc] init];  context[@"person"] = person;  person.firstName = @"Di";  person.lastName =@"Tang";  [context evaluateScript:@"log(person.fullName())"];//调Native方法,打印出person实例的全名  [context evaluateScript:@"person.sayFullName())"];//提示TypeError,'person.sayFullName' is undefined
复制代码


这就是一个很简单的使用 JSExport 的例子,但请注意,我们只能调用在该对象在 JSExport 中开放出去的方法,如果并未开放出去,如上例中的”sayFullName”方法,直接调用则会报 TypeError 错误,因为该方法在 JS 环境中并未被定义。


讲完 JSExport 的具体使用方法,我们来看看我们最开始的问题。当一个 OC 对象传入 JS 环境之后,会转成一个 JSWrapperObject。那问题来了,什么是 JSWrapperObject?在 JSCore 的源码中,我们可以找到一些线索。首先在 JSCore 的 JSValue 中,我们可以发现这样一个方法:


@method@abstract Create a JSValue by converting an Objective-C object.@discussion The resulting JSValue retains the provided Objective-C object.@param value The Objective-C object to be converted.@result The new JSValue.*/+ (JSValue *)valueWithObject:(id)value inContext:(JSContext *)context;
复制代码


这个 API 可以传入任意一个类型的 OC 对象,然后返回一个持有该 OC 对象的 JSValue。那这个过程肯定涉及到 OC 对象到 JS 对象的互换,所以我们只要分析一下这个方法的源码(基于这个分支进行分析)。由于源码实现过长,我们只需要关注核心代码,在 JSContext 中有一个”wrapperForObjCObject”方法,而实际上它又是调用了 JSWrapperMap 的”jsWrapperForObject”方法,这个方法就可以解答所有的疑惑:


//接受一个入参object,并返回一个JSValue- (JSValue *)jsWrapperForObject:(id)object{  //对于每个对象,有专门的jsWrapper  JSC::JSObject* jsWrapper = m_cachedJSWrappers.get(object);  if (jsWrapper)    return [JSValue valueWithJSValueRef:toRef(jsWrapper) inContext:m_context];  JSValue *wrapper;  //如果该对象是个类对象,则会直接拿到classInfo的constructor为实际的Value  if (class_isMetaClass(object_getClass(object)))    wrapper = [[self classInfoForClass:(Class)object] constructor];  else {    //对于普通的实例对象,由对应的classInfo负责生成相应JSWrappper同时retain对应的OC对象,并设置相应的Prototype    JSObjCClassInfo* classInfo = [self classInfoForClass:[object class]];    wrapper = [classInfo wrapperForObject:object];  }  JSC::ExecState* exec = toJS([m_context JSGlobalContextRef]);  //将wrapper的值写入JS环境  jsWrapper = toJS(exec, valueInternalValue(wrapper)).toObject(exec);  //缓存object的wrapper对象  m_cachedJSWrappers.set(object, jsWrapper);  return wrapper;}
复制代码


在我们创建”JSWrapperObject”的对象过程中,我们会通过 JSWrapperMap 来为每个传入的对象创建对应的 JSObjCClassInfo。这是一个非常重要的类,它有这个类对应 JS 对象的原型(Prototype)与构造函数(Constructor)。然后由 JSObjCClassInfo 去生成具体 OC 对象的 JSWrapper 对象,这个 JSWrapper 对象中就有一个 JS 对象所需要的所有信息(即 Prototype 和 Constructor)以及对应 OC 对象的指针。之后,把这个 jsWrapper 对象写入 JS 环境中,即可在 JS 环境中使用这个对象了。这也就是”JSWrapperObject”的真面目。而我们上文中提到,如果传入的是类,那么在 JS 环境中会生成 constructor 对象,那么这点也很容易从源码中看到,当检测到传入的是类的时候(类本身也是个对象),则会直接返回 constructor 属性,这也就是”constructor object”的真面目,实际上就是一个构造函数。


那现在还有两个问题,第一个问题是,OC 对象有自己的继承关系,那么在 JS 环境中如何描述这个继承关系?第二个问题是,JSExport 的方法和属性,又是如何让 JS 环境中调用的呢?


我们先看第一个问题,继承关系要如何解决?在 JS 中,继承是通过原型链来实现,那什么是原型呢?原型对象是一个普通对象,而且就是构造函数的一个实例。所有通过该构造函数生成的对象都共享这一个对象,当查找某个对象的属性值,结果不存在时,这时就会去对象的原型对象继续找寻,是否存在该属性,这样就达到了一个封装的目的。我们通过一个 Person 原型对象快速了解:


//原型对象是一个普通对象,而且就是Person构造函数的一个实例。所有Person构造函数的实例都共享这一个原型对象。Person.prototype = {  name: 'tony stark',  age: 48,  job: 'Iron Man',  sayName: function() {   alert(this.name);  }}
复制代码


而原型链就是 JS 中实现继承的关键,它的本质就是重写构造函数的原型对象,链接另一个构造函数的原型对象。这样查找某个对象的属性,会沿着这条原型链一直查找下去,从而达到继承的目的。我们通过一个例子快速了解一下:


        function mammal (){}        mammal.prototype.commonness = function(){                alert('哺乳动物都用肺呼吸');        }; 
function Person() {} Person.prototype = new mammal();//原型链的生成,Person的实例也可以访问commonness属性了 Person.prototype.name = 'tony stark'; Person.prototype.age = 48; Person.prototype.job = 'Iron Man'; Person.prototype.sayName = function() { alert(this.name); }
var person1 = new Person(); person1.commonness(); // 弹出'哺乳动物都用肺呼吸' person1.sayName(); // 'tony stark'
复制代码


而我们在生成对象的 classinfo 的时候(具体代码见”allocateConstructorAndPrototypeWithSuperClassInfo”),还会生成父类的 classInfo。对每个实现过 JSExport 的 OC 类,JSContext 里都会提供一个 prototype。比如 NSObject 类,在 JS 里面就会有对应的 Object Prototype。对于其它的 OC 类,会创建对应的 Prototype,这个 prototype 的内部属性[Prototype]会指向为这个 OC 类的父类创建的 Prototype。这个 JS 原型链就能反应出对应 OC 类的继承关系,在上例中,Person.prototype 被赋值为一个 mammal 的实例对象,即原型的链接过程。


讲完第一个问题,我们再来看看第二个问题。那 JSExport 是如何暴露 OC 方法到 JS 环境的呢?这个问题的答案同样出现在我们生成对象的 classInfo 的时候:


    Protocol *exportProtocol = getJSExportProtocol();    forEachProtocolImplementingProtocol(m_class, exportProtocol, ^(Protocol *protocol){      copyPrototypeProperties(m_context, m_class, protocol, prototype);      copyMethodsToObject(m_context, m_class, protocol, NO, constructor);    });
复制代码


对于每个声明在 JSExport 里的属性和方法,classInfo 会在 prototype 和 constructor 里面存入对应的 property 和 method。之后我们就可以通过具体的 methodName 和 PropertyName 生成的 setter 和 getter 方法,来获取实际的 SEL。最后就可以让 JSExport 中的方法和属性得到正确的访问。所以简单点讲,JSExport 就是负责把这些方法打个标,以 methodName 为 key,SEL 为 value,存入一个 map(prototype 和 constructor 本质上就是一个 Map)中去,之后就可以通过 methodName 拿到对应的 SEL 进行调用。这也就解释了上例中,我们调用一个没有在 JSExport 中开放的方法会显示 undefined,因为生成的对象里根本没有这个 key。

总结

JSCore 给 iOS App 提供了 JS 可以解释执行的运行环境与资源。对于我们实际开发而言,最主要的就是 JSContext 和 JSValue 这两个类。JSContext 提供互相调用的接口,JSValue 为这个互相调用提供数据类型的桥接转换。让 JS 可以执行 Native 方法,并让 Native 回调 JS,反之亦然。



利用 JSCore,我们可以做很多有想象空间的事。所有基于 JSCore 的 Hybrid 开发基本就是靠上图的原理来实现互相调用,区别只是具体的实现方式和用途不大相同。大道至简,只要正确理解这个基本流程,其它的所有方案不过是一些变通,都可以很快掌握。

一些引申阅读

JSPatch 的对象和方法没有实现 JSExport 协议,JS 是如何调 OC 方法的?

JS 调 OC 并不是通过 JSExport。通过 JSExport 实现的方式有诸多问题,我们需要先写好 Native 的类,并实现 JSExport 协议,这个本身就不能满足“Patch”的需求。


所以 JSPatch 另辟蹊径,使用了 OC 的 Runtime 消息转发机制做这个事情,如下面这一个简单的 JSPatch 调用代码:


require('UIView') var view = UIView.alloc().init() 
复制代码


  1. require 在全局作用域里生成 UIView 变量,来表示这个对象是一个 OCClass。

  2. 通过正则把.alloc()改成._c(‘alloc’),来进行方法收口,最终会调用_methodFunc()把类名、对象、MethodName 通过在 Context 早已定义好的 Native 方法,传给 OC 环境。

  3. 最终调用 OC 的 CallSelector 方法,底层通过从 JS 环境拿到的类名、方法名、对象之后,通过 NSInvocation 实现动态调用。


JSPatch 的通信并没有通过 JSExport 协议,而是借助 JSCore 的 Context 与 JSCore 的类型转换和 OC 的消息转发机制来完成动态调用,实现思路真的很巧妙。

桥方法的实现是怎么通过 JSCore 交互的?

市面上常见的桥方法调用有两种:


  1. 通过 UIWebView 的 delegate 方法:shouldStartLoadWithRequest 来处理桥接 JS 请求。JSRequest 会带上 methodName,通过 WebViewBridge 类调用该 method。执行完之后,会使用 WebView 来执行 JS 的回调方法,当然实际上也是调用的 WebView 中的 JSContext 来执行 JS,完成整个调用回调流程。

  2. 通过 UIWebView 的 delegate 方法:在 webViewDidFinishLoadwebViewDidFinishLoad 里通过 KVC 的方式获取 UIWebView 的 JSContext,然后通过这个 JSContext 设置已经准备好的桥方法供 JS 环境调用。

参考资料

  1. 《JavaScript 高级程序设计》

  2. Webkit Architecture

  3. 虚拟机随谈1:解释器…

  4. 戴铭:深入剖析 WebKit

  5. JSCore-Wiki

  6. [知乎Tw93]iOS中的JSCore

作者简介

  • 唐笛,美团点评高级工程师。2017 年加入原美团,目前作为外卖 iOS 团队主力开发,主要负责移动端基础设施建设,动态化等方向相关推进工作,致力于提升移动端研发效率与研发质量。


2020-02-25 20:32660

评论

发布
暂无评论
发现更多内容

Apache Flink ML 2.2.0 发布公告

Apache Flink

大数据 flink 实时计算

Go 语言快速入门指南:GET 和 POST 方法

宇宙之一粟

Go HTTP post GET 三周年连更

C语言system函数判断返回错误

linux大本营

C语言

qemu怎么用

linux大本营

qemu

【源码分析】【seata】at模式分布式事务-tm实现逻辑

如果晴天

源码分析 分布式事务 seata Seata框架

手语识别:解锁语言交流的新时代

来自四九城儿

九层天塔DApp合约系统开发搭建

薇電13242772558

NFT

没有主机跑开源软件?速来牧云助手免费领一台!

百川云开发者

云计算 开源 云主机 教程 免费

Apple 的 plist 编辑器入门指南:基础操作与高级功能详解

雪奈椰子

为开发者搭建创新舞台 OpenHarmony创新赛正式启动

最新动态

世优科技AI数字人产品“世优BOTA”发布!全面提升AI虚拟员工能力

联营汇聚

FBEC大会 | 瑞云科技 CTO 赵志杰:元宇宙时代的基础设施——实时云渲染

3DCAT实时渲染

实时渲染 实时云渲染 实时渲染云

Split to Be Slim: 论文复现

华为云开发者联盟

人工智能 华为云 论文 华为云开发者联盟 企业号 4 月 PK 榜

华为云GaussDB坚持技术引领,以数字化转型激活金融科技新动能

华为云开发者联盟

数据库 后端 华为云 华为云开发者联盟 企业号 4 月 PK 榜

常用工具

流火

从零学习SDK(9)SDK的局限

MobTech袤博科技

有道CEO周枫:当我们谈论大模型时,应该关注哪些新能力?

有道技术团队

【FAQ】统一扫码服务常见问题及解答

HMS Core

HMS Core

2023云数据库技术沙龙MySQL x ClickHouse专场成功举办

NineData

MySQL 腾讯云 阿里云 云数据库 技术沙龙

Rust、Go 和 Swift 在性能和并发性方面有何差异?

非凸科技

Go swift 性能 编程语言 高并发

浅析低代码开发的典型应用构建场景

力软低代码开发平台

编程中最难的就是命名?这几招教你快速上手

阿里巴巴云原生

阿里云 编程 云原生

AI数字人产品“世优BOTA”发布会落幕,世优科技纪智辉演讲回顾

联营汇聚

低代码平台虽臭名远扬,但用起来却真香

这我可不懂

低代码 JNPF

文本搜索工具ack与grep

坚果

Linux 三周年连更

发送GET请求时,浏览器请求的参数是指什么

linux大本营

HTTP url GET

赋能开发者,开放原子全球开源峰会期待你的声音!

开放原子开源基金会

大数据 开源 开源软件

终于有人把动态规划、冒泡排序、二叉树、链表、栈全部讲清楚了

架构师之道

算法

如何通过C#/VB.NET代码将PowerPoint转换为HTML

在下毛毛雨

html PowerPoint 文档转换

DevOps 与研发效能资深技术专家张乐:研发效能的升维思考与降维执行

万事ONES

使用Plist编辑器——简单入门指南

深入理解JSCore_文化 & 方法_美团技术团队_InfoQ精选文章