JavaScript engines are much like buses: you wait years for one, then two shiny new ones show up at once! ——— Rob Palmer

Meta 于 2019 年正式开源了 Hermes,并在 RN 0.64 版本时全面接入了 iOS 和 Android 双端。如今 Hermes 作为 RN 当前的御用 JS Engine,其 no JIT 与能够直接加载与生成字节码的特性(支持动态分发的字节码,非 QuickJS 只能那种 hardcode 在 C 的字节码),可以说是专门为 Hybrid UI 框架而打造的。

Cpp 通过 vm 调用 js 函数流程

直接使用 hermes 在命令行控制台执行流程

// 加载运行时
std::shared_ptr<vm::Runtime> runtime = vm::Runtime::create(config);

// 加载 GCScope
vm::GCScope gcScope(*runtime);

至此,整个运行时初始化完成。

那我们如何在运行时直接执行代码呢?如果是使用 Hermes 命令行工具,则 Hermes 会使用 eval 函数来调用你在命令行执行的 JS。

// 简化了中间非常多的步骤比如错误处理等,只保留了核心逻辑

// 你的代码
string code = "";

// 获取 global 对象
auto global = runtime->getGlobal();
// 获取 eval 函数
auto propRes = vm::JSObject::getNamed_RJS(
      global, *runtime, vm::Predefined::getSymbolID(vm::Predefined::eval));

// 通过符号创建 handle,即一个可在 Cpp 调用的 js eval 函数。
auto evalFn = runtime->makeHandle<vm::Callable>(std::move(*propRes));

// 调用 evalFn 函数,传入 code,即 eval(code) 的 cpp 侧调用
auto callRes = evalFn->executeCall1(
	evalFn,
	*runtime,
  global,
  vm::StringPrimitive::createNoThrow(*runtime, code).getHermesValue() // 代码的字符串转为 Hermes value
)

// 生成包含执行结果字符串的 llvm ArrayRef
auto u16ref = vm::StringPrimitive::createStringView(
            *runtime, vm::Handle<vm::StringPrimitive>::vmcast(resHandle))
            .getUTF16Ref(tmp));

// narrowStr 为执行结果
std::string narrowStr;

// 自带的 UTF16 转 UTF8 函数
convertUTF16ToUTF8WithReplacements(narrowStr, u16ref);

在这些代码里,最让人感到疑惑的是 evalFn->executeCall1 这个方法,我们深入探索一下。

// 这是一个比较简单的 helper 方法,实际上会递归调用解释器
CallResult<PseudoHandle<>> Callable::executeCall1(
    Handle<Callable> selfHandle,
    Runtime &runtime,
    Handle<> thisArgHandle,
    HermesValue param1,
    bool construct) {
  ScopedNativeCallFrame newFrame{
      runtime,
      1,
      selfHandle.getHermesValue(),
      construct ? selfHandle.getHermesValue()
                : HermesValue::encodeUndefinedValue(),
      *thisArgHandle};
  if (LLVM_UNLIKELY(newFrame.overflowed()))
    return runtime.raiseStackOverflow(Runtime::StackOverflowKind::NativeStack);
  newFrame->getArgRef(0) = param1;

	// 本质上是 selfHandle->getVT()->call(selfHandle, runtime)
  return call(selfHandle, runtime);
}

这里引入了一个概念——ScopedNativeCallFrame 即一个原生可调用的作用域的帧,因为在 JS 里,每一个函数都是一个闭包,闭包可以在函数声明的作用域内捕获变量,所以我们要给运行时添加这个帧,让它知道我们有啥变量。

闭包捕获的函数变量被分配到堆上的一个作用域中,该作用域作为上下文的一部分传递给闭包。闭包可以访问所有嵌套层次的变量,这意味着它们可以访问多个作用域。我们通过嵌套作用域并将它们链接在一起来实现这个特性。每个作用域(捕获的变量集)也有一个对调用函数作用域的引用。闭包可以通过加载每个作用域的父作用域来访问不同嵌套层次的变量。

调用 newFrame 构造函数创建新的 frame,并绑定到 runtime 上,设置参数数量与参数数值之后,就可以通过 call 方法调用。并且我们可以看到每次 call 的之前,都会检测 frame 数量的增加是否 overflow。

selfHandle->getVT() 调用后得到一个为 CallableVTable 的结构体。

struct CallableVTable : public ObjectVTable {
  /// Create a new object instance to be passed as the 'this' argument when
  /// invoking the constructor. Overriding this method allows creation of
  /// different underlying native objects.
  CallResult<PseudoHandle<JSObject>> (*newObject)(
      Handle<Callable> selfHandle,
      Runtime &runtime,
      Handle<JSObject> parentHandle);

  /// Call the callable with arguments already on the stack.
  CallResult<PseudoHandle<>> (
      *call)(Handle<Callable> selfHandle, Runtime &runtime);
};

TODO: 后面补充 Handle 和 Callable 的数据结构