JavaScriptCore 与 Promise 那点事

这篇文章将会带你在 JavaScript 环境和 Native 环境中穿来穿去,请保持头脑清醒。文章里面我们用 Objective-C 写原生代码,因为你如果用 Swift 去写 JavaScriptCore 相关代码的话,会相对蛋疼。

本文的上下文是通过 JavaScriptCore 构建一个 JavaScript 和 Native 代码混合的环境,就像 JSBox 那样(硬广结束)。

JSContext & JSExport

首先我们要做的第一件事是把 JavaScript 代码和 Native 代码打通,也就是 Native 可以调用 JavaScript 并拿到结果,JavaScript 也能调用 Native 并拿到结果。

Native -> JavaScript 十分简单,直接通过 JSContext:

JSContext *context = [JSContext new];
JSValue *result = [context evaluateScript:@"var a = 100; var b = 50; a + b;"];
NSLog(@"result: %@", @(result.toInt32)); // --> result: 150

evaluateScript 方法直接执行 JavaScript,返回 JSValue 再转换成 Native 类型。

JavaScript -> Native 的有很多方案,这里我们介绍用 JSExport 这个协议把 Native 对象塞进 JSContext 的方案。

我们有个 Native 类叫 Helper,他长这个样子:

#import <JavaScriptCore/JavaScriptCore.h>

@protocol HelperExport <JSExport>

- (void)test;

@end

@interface Helper : NSObject<HelperExport>

@end

@implementation Helper

- (void)test {
  NSLog(@"test");
}

@end

实现了 JSExport 协议的类能够直接放到 JSContext 里面,就像这样:

JSContext *context = [JSContext new];
context[@"helper"] = [Helper new];

然后你就能在 JavaScript 里面直接用这个 helper 对象了:

[context evaluateScript:@"helper.test();"];

另外,我们还能在 JSContext 里面实现全局的函数,为了测试方便我们创建一个 log:

context[@"log"] = ^(NSString *string) {
  NSLog(@"%@", string);
};

这个地方涉及到一个数据类型转换的问题,我们暂且略过,总之你现在可以这样:

[context evaluateScript:@"log('Hey!');"];

这就是我们如何通过 JavaScript 直接使用 Native 环境的对象,通过 JSExport 导出即可。

这里有个细节,Objective-C/Swift 的函数名风格和 JavaScript 是不一样的,你有两种方案解决这个问题:

  • 统一使用 JSValue 作为参数,效果类似于每次都传递一个字典
  • 使用 JSExportAs 这个宏,这个宏可以帮你把 ObjC 风格的函数声明变成 JavaScript 风格的

导出已有对象

这里有个 Bonus,是我们实现 JavaScript 对象和 Native 对象真正互通的基础,上述内容中 Helper 是我们自己创建的类,所以我们能决定里面有什么方法,我们能自由的实现 JSExport 协议。

问题来了,如果 JavaScript 想要访问 Native 环境中的一个已经存在的类创建的对象怎么办,例如说一个 NSIndexPath?一个能想到的方法是你每次的创建一个 Dictionary 把里面的值封装起来,但是这样做很容易出错而且转来转去极为麻烦。

但是不用担心,因为有 Runtime 的存在,我们完全可以让已经存在的类也遵循 JSExport,例如:

@protocol NSIndexPathExport <JSExport>

@property (nonatomic, assign) NSInteger section;
@property (nonatomic, assign) NSInteger row;
@property (nonatomic, assign) NSInteger item;

@end

...

class_addProtocol(NSIndexPath.class, @protocol(NSIndexPathExport));

完毕,你在 JavaScript 环境里面拿到一个 NSIndexPath 的时候,能像你在 Native 环境一样用他。

Callback

我们刚刚的例子里面有个重要缺陷,JavaScript 环境调用 Native 代码之后无法取得返回的结果,也就是我们常说的无法 Callback。解决这个问题最直观的方式很简单,你只需要记住一点就是 JSValue 可以是一个 JavaScript Function,有了这个观念之后,你会很容易写出这样的代码:

- (void)test:(JSValue *)handler {
  [handler callWithArguments:@[@"Hey!"]];
}

这个方法接受一个 JSValue (Function) 作为参数,他在做了一大堆事情之后通过执行这个 Function 而达到回到 JavaScript 环境的效果(Callback),在使用的时候:

[context evaluateScript:@"helper.test(response => log(response));"];

response 就是上面的 Hey! 所以这个代码最后输出 Hey!。

It works! 但是这并不好,假设我们现在有个方法叫 get,用来执行一个 HTTP GET 请求,返回一段结果之后我们 Callback:

- (void)get:(JSValue *)arguments {
  NSURL *url = [NSURL URLWithString:[arguments[@"url"] toString]];
  NSURLSession *session = [NSURLSession sharedSession];
  [[session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
    NSString *string = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    JSValue *handler = arguments[@"handler"];
    [handler callWithArguments:@[string ?: @""]];
  }] resume];
}

为了示例方便代码都有所简化,请勿认为这是可用的代码,简单说这个代码根据传入的 url 去执行一个 GET 请求,将结果返回到 JavaScript 环境,用起来的时候像是这样:

helper.get({
  url: "https://api.forismatic.com/api/1.0/?method=getQuote&lang=en&format=json",
  handler: response => log(response)
})

这个代码会从 JavaScript 穿到 Native 去执行一个 GET 请求然后穿回 JavaScript 去调用 log 函数,最后再穿回 Native 执行了 NSLog。

这个代码当然 Works,但是如果你要写多个请求,或者你要串联多个类似的方法,你就会写出 Callback Hell

Callback Hell 的意思是异步接口越套越多导致代码越来越往右走而不是往下走,这会导致代码极其难以阅读和维护。

我们只需要实现一个目标,用同步接口的 syntax 写出异步代码,于是 Promise 应运而生,这篇文章当然不是讲 Promise 的原理以及应用的,关于 Promise 以及 async await 的相关介绍,这里有一篇写的不错的文章:https://javascript.info/async

Promise

我们要实现的效果其实是把上面那个 helper.get 变成 Promise 风格的函数,这是个 Native 方法,首先最容易想到的方法是,写一个 JavaScript 的封装就好了:

```js
const helper_async = {
get: parameters => {
return new Promise(resolve => {
parameters.handler = response => resolve(response)
helper.get(parameters)
})
}

top Created with Sketch.