深入理解JSCore

声明:本文是对美团技术团队唐笛《深入理解JSCore》一文的学习笔记,如要查看原文,请点击文末的“阅读原文”。

无论是目前火热的跨端开发(FB的RN、阿里的Weex等),还是WebView Hybrid混合开发方案,亦或是之前广泛流行的JSPatch(热更新方案),JSCore在其中都发挥了举足轻重的作用。JSCore(全称JavaScriptCore),它建立起了JavaScript和Objective-C之间沟通的桥梁

上世纪90年代初,那时的浏览器只有页面展示的能力,用户并不能与之交互,所以当时人们的上网体验是很差的。1995年,Brendan花了15天的时间写出了JavaScript,由浏览器解释执行,至此,浏览器拥有了基本的交互能力。

JS是一门解释执行的动态脚本语言,因此如何解释执行JS就成为了各大引擎的核心技术,目前市面上比较常见的JS引擎有Google的V8(它被运用在Android操作系统以及Google的Chrome上),以及我们今天的主角JSCore(它被运用在iOS操作系统以及Safari上)

在iOS7之后,JSCore作为一个系统级别的Framework被苹果提供给开发者。JSCore是苹果Safari浏览器内核的重要组成部分

使浏览器能够正常工作最核心的部分就是浏览器内核,每个浏览器都有自己的内核,Safari的内核是WebKit

通过下图我们可以对WebKit有一个整体的了解:

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

WebCore

在上图中,不知你有没有发现,只有WebCore是红色的。这是因为,时至今日,WebKit已经有了很多分支,各大厂家也对之进行了很多优化改造,在WebKit的所有的部分中,唯有WebCore这个部分是唯一保持不变的,WebCore是WebKit中代码最多的部分,也是整个WebKit中最核心的渲染引擎

整个WebKit的渲染流程如下:

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

JSCore

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

我们可以通过一张图来看一下JSCore的工作流程:

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

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

词法分析——Lexer

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

下面是一局很简单的C语言表达式:

sum = 3 + 2;

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

这就是词法分析之后的结果,但是词法分析并不会关注每个Token之间的关系、是否匹配,而是仅仅将他们区分开来,等待“语法分析”将这些Token“串起来”

词法分析函数一般是由语法分析器Parser来调用的,在JSCore中,词法分析器的代码主要集中在parser/Lexer.h、Lexer.cpp中。

语法分析——Parser

无论是英语还是汉语,亦或是其他任何人类语言,其实都是遵循一定的语法来讲出一个又一个词语的。计算机语言也是一样的道理,计算机要理解一门计算机语言,也要理解一个语句的语法。例如如下JS语句:

var sum = 2 + 3;
var a = sum + 5;

Parser会把Lexer词法分析之后生成的Token序列进行语法分析,并生成对应的一棵抽象语法树。这棵树长什么样呢?在这里推荐一个网站:

http://esprima.org/demo/parse.html#

输入JS语句就能立马生成我们所需的抽象语法树:

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

解释执行——LLInt和JIT

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

运行指令字节码(ByteCode)是JS引擎中很核心的部分,各家JS引擎的优化也主要集中于此。JS字节码(Byte Code)的解释执行是一套很复杂的系统,这里只做简单介绍,不做深入研究。

关于JSCore需要注意的两个点

单线程机制

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

事件驱动机制

之前讲到,JS的诞生就是为了让浏览器也拥有一些交互和逻辑处理能力。而JS与浏览器之间的交互是通过事件来实现的,比如浏览器检测到了用户的点击,它会将该点击打包成一个事件并通知JS线程去处理该事件。那么通过该特性,我们可以实现JS的异步编程。当遇到耗时任务的时候,JS会将该耗时任务丢给一个由JS宿主提供的工作线程去处理,当工作线程处理完该耗时任务之后,会发送一个消息通知JS线程该耗时任务已经被执行完,并在JS线程上去执行相应的事件处理程序。需要注意的是:由于JS线程与工作线程不在一个运行环境,因此他们并不共享作用域,工作线程也不能操纵页面window和DOM

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

以上这张图大概描述了JSCore的事件驱动机制,整个JS程序其实就是这样跑起来的。也正是工作线程与事件驱动机制的存在,才让JS有了多线程处理的能力。

iOS中的JSCore

iOS中可以使用JSCore的地方有多处,比如封装在UIWebView中的JSCore、封装在WKWebView中的JSCore(即Nitro)以及系统自带的JSCore。实际上,及时同为JSCore,他们之间也有很多区别。随着JS这门语言的发展,JS的宿主越来越多,比如各种浏览器,甚至是常见于服务器的Node.js(基于V8运行)。

我们今天主要是介绍iOS系统自带的JSCore。

JavaScriptCore.framework默认是没有导入工程的,需要我们手动导入:

然后我们就可以看到JavaScriptCore.framework中所包含的15个头文件了:

其中JSContext、JSValue、JSVirtualMachine和JSExport这四个概念是有必要好好了解一下的。

JSVirtualMachine

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

JSVirtualMachine的译名就是JS虚拟机,接下来我们讨论以下什么是虚拟机。先来看看最出名的虚拟机——JVM(Java虚拟机)。JVM主要做两个事情:

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

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

从功能上来看,一个高级语言虚拟机主要分为两部分:一部分是解释器部分,用来运行高级语言编译生成的ByteCode;另一部分是Runtime运行时,用来负责运行时的内存空间开辟、管理等等。

实际上,JSVM就是一个抽象的JS虚拟机。在APP中我们可以运行多个JSVM来执行不同的任务。而且每一个JSContext都会从属于一个JSVM。需要注意的是,每一个JSVirtualMachine都有自己独立的堆空间,GC也只能处理JSVM内部的对象。所以说,不同的JSVM之间是不能传递值的。

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

JS的GC机制

JS不需要我们去手动管理内存,JS的内存管理使用的是GC机制(Tracing Garbage Collection)。

Tracing Garbage Collection是由GCRoot(JSContext)开始维护的一条引用链,一旦引用链无法触达某对象节点,这个对象就会被回收掉

JSContext

context翻译过来就是“上下文”,那么什么是上下文呢?比如在一篇文章中,我们看到一句话:“他飞快地跑了出去”。如果我们不看上下文的话,我们并不知道这句话到底是啥意思:谁跑了出去?他为什么要跑?

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

JSContext就是JS语言的执行环境,所有JS代码的执行必须在一个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,我们可以搭配JSContext来执行JS代码。

我们还可以通过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中,有一个很重要的只读属性:

@property (readonly, strong) JSValue *globalObject;

该属性返回当前执行JS代码的JSContext的全局对象,该全局对象是JSContext里最核心的部分。我们通过KVC向JSContext对象中取值赋值的时候,实际上都是在跟globalObject这个全局对象做交互,几乎所有的东西都在全局对象里,甚至可以说,JSContext只是globalObject的一层外壳。下面是我取出了JSContext的globalObject,并转成NSDictionary对象:

可以看到,globalObject保存了所有的变量与函数,因此我们也得出一个结论:JS中所谓的全局变量、全局函数,不过是全局对象的属性和函数

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

JSValue

JSValue实例是一个指向JS值的引用指针。在JSContext一节中我们了解到,可以很简单地通过KVC操作JS全局对象,也可以直接获得JS代码执行结果的返回值,这都是因为JSCore帮我们用JSValue在底层自动做了OC和JS的类型转换

每一个JS中的值都存在于一个执行环境中,也就是说,每一个JSValue都存在于一个JSContext中,这就是JSValue的作用域

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;

根据ECMAScript(可以理解为JS标准)的定义:JS中存在两种数据类型的值:一种是基本类型值,它指的是简单的数据段,包括“undefined”、“null”、“string”、“number”、“boolean”;另一种是引用类型值,指的是那些可能由多个值构成的对象,除了上面提到的五种基本数据类型,剩下的都是引用类型值。

接下来聊聊引用类型的互换。

NSDictionary <-> Object object

在OC中,对象是某一个类的实例,但是在JS中并不存在类的概念,JS中只有基本类型和引用类型这两种数据类型,因此在JS中,对象是一个引用类型的实例

ECMA将对象定义为:无序属性的集合,其属性可以包含基本值、对象或者函数。从这个定义我们可以发现,JS中的对象就是无序的键值对,这就类似于OC中的NSDictionary

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

在上面的代码中,我使用字面量语法分别创建了JS中的对象和OC中的NSDictionary,相信这可以更有助理解这两个转换。

NSBlock <-> Function object

    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输出:“拿到了参数:全局变量字符串”

这段代码显示,我在JSContext中赋值了一个命名为globalFunc的Block,并可以在JS代码中将其当做一个函数来直接调用。另外,我还可以通过“typeof”关键字来判断globalFunc在JS中的类型:

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

该例表名,我们传入的block对象在JS中被转成了“function”类型。在JS这门语言中,除了基本数据类型,就是引用类型。函数实际上也是一个Function类型的对象,每个函数名实则是指向一个函数对象的引用。比如我们可以在JS中这样定义一个函数:

var sum = function(num1,num2){   
        return num1 + num2; 
}

NSBlock是一个包裹了函数指针的类,JSCore将FunctionObject转成NSBlock对象,可以说是很合适的

JSExport

我们可以通过实现JSExport协议来开放OC类和他们的实例方法、类方法以及属性给JS用

如果我们想在JS环境中使用OC的类和对象,那么就需要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

然后我们可以将OC对象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中开放出去的方法,如果并未开放出去,如上例中的sayFullName方法,直接调用会报错,因为该方法在JS环境中并未被定义。

总结

JSCore给iOS APP提供了JS可以解释执行的运行环境和资源。于我们开发而言,最重要的就是JSContext和JSValue这两个类,JSContext提供了相互调用的接口,JSValue为相互调用提供数据类型的桥接转换

所有基于JSCore的Hybrid(混合)开发基本都是靠上图的原理来实现相互调用的,区别只在于具体的实现方式和用途的不同,大道至简,理解了该基本流程,其他的方案不过是一些变通,都可以很快掌握。

以上。

原文发布于微信公众号 - iOS小生活(iOSHappyLife)

原文发表时间:2019-06-05

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券