如何实现一个乞丐版JSBox (一) 引擎篇

4,925 阅读7分钟

前言

代码地址

JavaScriptCore介绍

整个引擎是建立在JavaScriptCore上创建的,这里对它做一个简单的介绍。JavaScriptCore 提供了js和native交互的能力。你可以不通过浏览器直接执行一段js的代码,你也可以直接往js里注入一个原生的对象。需要注意一点JavaScriptCore里没有Dom window之类的这些内容。

JSValue

JSValue就是js环境里的对象。它可能是任何的类型,可能是数组,可能是字符串也可能是一个js的方法。js和原生数据传递的时候有一套基础的类型转换的对应表。JavaScriptCore会帮我们做一层基础的转换。

   Objective-C type  |   JavaScript type
 --------------------+---------------------
         nil         |     undefined
        NSNull       |        null
       NSString      |       string
       NSNumber      |   number, boolean
     NSDictionary    |   Object object
       NSArray       |    Array object
        NSDate       |     Date object
       NSBlock (1)   |   Function object (1)
          id (2)     |   Wrapper object (2)
        Class (3)    | Constructor object (3)

JSValue也有一些toXXX方法能够将js数据转换为原生的数据。

JSContext

我们会大量的使用到JSContext,介绍一下简单的用法:

用法一

JSContext *context = [[JSContext alloc] init];
[context evaluateScript:@"var a = 'hello word';"];
NSLog(@"%@",[context[@"a"] toString]);

这段代码里我先在js环境里声明了一个a变量,然后通过context拿到了这个对象打印了对象的值。这里的这个变量也可以是一个js的方法。如果是一个方法可以通过callWithArguments:直接调用。

用法二

native:

 context[@"log"] = ^(JSValue *value) {
    NSLog(@"%@",[value toString]);
 };

js:

log('hello word');

这段代码里我们先往js注入了一个加log的方法,这个方法接受一个参数。然后js里直接通过log这个名字就能调用这个方法。方法的实现就是block里的代码逻辑。

用法三

我们可以直接拿到js里定义的方法,直接在native调用。 js

var sum = function(a,b) {
    return a + b
}

native

[context[@"sum"] callWithArguments:@[@(1),@(2)]];

JSExport

通过JSExport我们能直接把native的对象传递给js。js可以直接拿到属性调用对象的方法。具体使用方式如下

@protocol studentExport <JSExport>
@property (nonatomic, strong) NSString *name;
@property (nonatomic, assign) NSInteger age;
- (void)study;
@end

@interface student : NSObject <studentExport>
@property (nonatomic, strong) NSString *name;
@property (nonatomic, assign) NSInteger age;
- (void)study;
@end

我们创建了一个继承自JSExport的协议,协议里实现了两个属性和一个方法。然后我们创建的对象继承自这个协议实现了协议里的方法和属性。这样一来如果我们创建一个student对象,然后把这个对象传递给js的时候,js能够直接拿到name age属性,并且能够直接调用study方法。非常的神奇~

JSBox基础用法介绍

接下去看的过程中,如果有一些内容有些疑问的话你可以先看看JSBox文档

$ui.render({
  props: {
    id: "label",
    title: "Hello, World!"
  },
  views: [
    {
      type: "label",
      porps: {
          text : 'hello word'
      }
      layout: function(make, view) {
        make.center.equalTo(view.super)
        make.size.equalTo($size(100, 100))
      }
    }
  ]
})

上面这段代码实现的功能就是弹出一个控制器,在这个控制器当中添加了一个label。我们传递进去了一个js的对象。首先这个对象有一个props的属性,这个属性一看就是一些当前控制器的一些属性,比如title肯定就是设置标题了。之后是一个views数组,显而易见这个views里存放的是一些view的数据结构。里面是一个type属性这个属性就是对应着当前view的类型,layout就是对应着当前view的布局。porps里存放着view的属性。

JSBox基础功能实现细节

结合上面提到的JavaScriptCore的使用,我们很容易就能推断出JSContext肯定需要定义一个方法来和$ui.render这个方法调用相对应的方法。我们传递进去了一个js对象,上面我们已经对这个数据结构进行了一下大致的分析。接下来就是分析一下要如何解析这个对象并显示这个对象。传递到native的jsvalue对象我们可以通过jsvalue[@'xxx'] 这样的方式拿到具体的数据,拿到的这个数据可以是js方法也可以是一些基础的数据。

控件的创建

结合上面的分析,控件的创建也就是通过拿到jsvalue里的views参数然后解析出views数组里的每个view,通过view的type属性和文档里的原生控件一一对应创建出控件就可以了。

控件的属性

属性的赋值

赋值操作相对理解还是很容易如果这个属性名和原生想要赋值的属性名一一对应我们只需要用kvc设置一下就ok了。如果名字和属性不一致我们只需用category加一个当前名字的属性,在set方法里做正确的参数设置就ok了。

属性的获取

属性的获取要求js能够拿到原生对象的属性,要实现这个功能我们需要用到JSExport,我们需要把支持获取的属性都添加到自定义继承自JSExport的协议里,然后创建一个category继承这个协议。这样一来我们把原生对象传递给js的时候,js端就能拿到属性。

@protocol ZHNJSBoxUILabelExport <JSExport>
@property (nonatomic, copy) NSString *text;
@property (nonatomic, strong) UIFont *font;
@property (nonatomic, strong) UIColor *textColor;
@property (nonatomic, strong) UIColor *shadowColor;
@property (nonatomic, assign) NSInteger align;
@property (nonatomic, assign) NSInteger lines;
@property (nonatomic, assign) BOOL autoFontSize;
@end

@interface UILabel (ZHNJSBoxUILabel) <ZHNJSBoxUILabelExport>
@property (nonatomic, assign) NSInteger align;
@property (nonatomic, assign) NSInteger lines;
@property (nonatomic, assign) BOOL autoFontSize;
@end

控件的位置

layout: function(make, view) {
    make.center.equalTo(view.super)
    make.size.equalTo($size(100, 100))
}

作为iOS开发我们一眼就能看出来,这是Masonry的写法。js里面想要make.center.equalTo(view.super)调用不出错,我们需要make里有center里有属性center里有equalTo方法。想要实现这个功能有以下两种方式

方式一

JSExportMASConstraintMaker添加所需要的属性,给MASConstraint添加方法。有一个需要注意的点是JSBox里统一用的是equalTo方法,但是Masonry里类似height,width等等的基础数据类型是需要mas_equalTo把传递的值包一下,所以需要特殊处理。equalTo传递进去的参数也需要做一层转换,原生是采用mas_xxx而JSBox追求简洁是直接取消了前面的mas_

方式二

方式一思路理清楚了,代码实现起来是没啥大的难度的。但是我最后发现它需要去改动Masonry这个库的细节。所以我尝试去看看有没有其他的方式来实现,最后我尝试用的是JSPatch的实现方式通过正则匹配然后让属性走一个统一的方法,方法走一个统一的方法。make.center.equalTo(view.super) 正则完的结构是 make.__lp('center').__lr('equalTo')('view.super')。我们要明确一个点就是原生Masonry实现链式调用是通过属性+block的方式的,也就是.left 或者.right等等之类的属性我们是可以用[make left]来代替的。我们先给js的基类添加__lp__lr两个方法。属性都会走统一的方法把属性名传递给原生,原生直接用[maker performSelector:NSSelectorFromString(property)]的方式调用就ok了。方法稍微有些不同,在js端我们需要把__lr('equalTo')('view.super')合成一个方法的调用。

var args = Array.prototype.slice.call(arguments);
return oc_LayoutRelation(slf,methodName,args[0]);

拿到方法名和参数传递给原生调用。原生先用[maker performSelector:NSSelectorFromString(seletName)]拿到block,然后在直接调用block返回参数就ok了。

控件的事件

前面已经提到了,js可以直接传递一个方法到native。native拿到这个方法直接callWithArguments:就直接可以了。也就是说我们只需要把这个js方法保存一下。原生方法的逻辑里调用一下这个js方法就ok了。这个地方遇到了一个比较蛋疼的内存问题,首先JavaScriptCore它有一个自己的内存管理机制,然后native也有一个内存管理机制。如果我们直接把传递进来的jsvalue设为属性,那么当js端想要释放这个js对象的时候,它会发现它的内存被原生管理了,所以就没有权限释放那么它就会直接奔溃。翻了一下文档,发现有一个叫JSManagedValue这个对象对内部的jsvalue是一个weak引用,看着好像是解决引用问题的。试了一下之后发现,它不会对js对象的生命周期产生影响,也就是说js对象被释放了之后我们在native是拿不到这个方法了。

一筹莫展的时候我去看了一下JSPatch的实现,JSPAtch用的是一个全局的字典来存放。因为JSPAtch的JSContext是一个单例对象,也就是说它里的JSValue的释放是和整个app的生命周期绑定在一起了。所以不存在说上面的问题。但是我们这里的JSContext显然是要针对每一个脚本的,所以还是不太一样的。又一筹莫展的被卡了好几天没找到方法,然后我尝试去看了下weex的代码,整个项目工程量有点大,没很仔细看但是我发现它调用这些事件方法的时候都是通过context['name']拿到js的方法然后直接调用的。结合JSPatch里的代码我想到,当解析到一个JS方法的时候我可以往js的基类对象里添加这么一个方法属性。然后需要用到的时候通过名字拿到这个方法就ok了。这样这个方法的生命周期就和JSContext绑定在一起了,当它释放的时候那么这些方法也就被释放了。当然JSContext里搞一个全局的字典存一下方法也是可行的。

想法发散

按照我现在的眼光看来,其实类似的框架基础的实现思路是类似的。类似weex 小程序之类的只是在这个的基础上加了一层编译操作,你可以直接编写前端代码,然后它们最终会把这些前端代码编译成js的代码。如果你有一些动态化的需求,但是你又不想引入weex之类的很重的框架,你其实可以自己尝试去实现一套自己的动态化框架。

总结

上面大致介绍了一些基本的实现思路和一些问题。这篇主要讲的是JSBox的基础引擎,我仿的差不多只实现了1/100。下面可能还会写一篇文章分析一下如何去实现一个简单的代码编辑器,敬请期待!!!