深入理解JSCore <转载>

转自: https://tech.meituan.com/deep_understanding_of_jscore.html

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个开放头文件来“管中窥豹”,如下图所示:

深入理解JSCore <转载>_第1张图片
image.png

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

SVirtualMachine

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

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

首先它要做的是把JavaC编译器生成的ByteCode(ByteCode其实就是JVM的虚拟机器指令)生成每台机器所需要的机器指令,让Java程序可执行(如下图)。
第二步,JVM负责整个Java程序运行时所需要的内存空间管理、GC以及Java程序与Native(即C,C++)之间的接口等等。

深入理解JSCore <转载>_第2张图片
image.png

从功能上来看,一个高级语言虚拟机主要分为两部分,一个是解释器部分,用来运行高级语言编译生成的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)开始维护的一条引用链,一旦引用链无法触达某对象节点,这个对象就会被回收掉。如下图所示:

深入理解JSCore <转载>_第3张图片
image.png

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对象,如下图:

深入理解JSCore <转载>_第4张图片
image.png

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

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

深入理解JSCore <转载>_第5张图片
image.png

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 
- (NSString *)fullName;//fullName用来拼接firstName和lastName,并返回全名
@end

@interface JSExportPerson : NSObject 

- (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 <转载>_第6张图片
图片11

利用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

你可能感兴趣的:(深入理解JSCore <转载>)