9

如果你已经阅读过了上手指南,那么你已经知道了如何作为一个单独的虚拟机使用 V8 ,并且熟悉了一些 V8 中的关键概念,如句柄上下文。在本文档中,还将继续深入讨论这些概念并且介绍其他一些在你的 C++ 应用中使用 V8 的关键点。

V8 的 API 提供了编译和执行脚本,访问 C++ 方法和数据结构,处理错误和启用安全检查的函数。你的应用可以像使用其他的 C++ 库一样使用 V8 。你的 C++ 应用可以通过引入头文件 include/v8.h 来访问 V8 API 。

当你想要优化你的应用时,V8 设计概要文档可以提供很多有用的背景知识。

前言

这篇文档的受众是那些想要在自己的 C++ 程序中使用 V8 JavaScript 引擎的人。它将能帮助你在 JavaScript 中使用你应用中的 C++ 对象和方法,亦能帮助你在 C++ 应用中使用 JavaScript 对象和方法。

句柄和垃圾回收

一个句柄提供了对在堆中的一个 JavaScript 对象地址的引用。V8 的垃圾回收器会在该对象不能再次被访问到时,将其回收。在垃圾回收的过程中,垃圾回收器可能会改变对象在堆中的位置。当垃圾回收器移动对象时,所有引用到该对象的句柄也会被一同更新。

当一个对象在 JavaScript 中已经不可被访问并且没有任何指向它的句柄时,它就会被垃圾回收。V8 的垃圾回收机制是 V8 性能表现的关键。更多信息可参阅 V8 设计概要文档

句柄分为许多种:

  • 本地句柄会被分配在栈中,并且当对应的析构函数被调用时,它也会被删除。这些本地句柄的生命周期取决于它对应的句柄域(handle scope),句柄域通常在一个函数调用的开始被创建。当句柄域被删除时,垃圾回收器就可以清除之前分配在该句柄域中的所有句柄了,因为它们不能再被 JavaScript 或其他句柄所访问到。这也正是在上手指南中使用的句柄。
    本地句柄可通过类 Local<SomeType> 创建。

注意:句柄栈并不是 C++ 调用栈中的一部分,但句柄域却在 C++ 栈中。故句柄域不可通过 new 关键字来分配。

  • 持久句柄提供了对分配在堆中的 JavaScript 对象的引用。当你需要在超过一次函数中保持对一个对象的引用时,或者句柄的生命周期与 C++ 的块级域不相符时,你应使用持久句柄。例如,在 Google Chrome 中,持久句柄被用来引用 DOM 节点。一个持久句柄可以通过 PersistentBase::SetWeak ,变为弱(weak)持久句柄,当一个对象所剩的唯一引用是来自于一个弱持久句柄时,便会触发垃圾回收。

    • 一个 UniquePersistent<SomeType> 依赖于 C++ 构造函数和析构函数来管理下层对象的生命周期。

    • 一个 Persistent<SomeType> 可以通过自身的构造函数来创建,但必须手动地通过 Persistent::Reset 来清除。

  • 还有两种很少会被使用到的句柄,在这里我们仅对它们做一个简单的介绍:

    • 永久(Eternal)句柄是一种为被认为永远不会被删除的 JavaScript 对象所设计的持久句柄。它的创建开销更低,因为它不需要被垃圾回收。

    • Persistent<SomeType>UniquePersistent<SomeType> 都不能被复制,所以它们不能作为 C++ 11 之前的标准库容器中的值来使用。PersistentValueMapPersistentValueVector 为它们提供了容器类,提供了类似于集合和向量的语义。

当然,每当你为了创建一个对象而创建一个本地句柄时,往往可能会导致创建了许多句柄。这就是句柄域所存在的意义。你可以将句柄域视作一个保存了许多句柄的容器。当句柄域的析构函数被调用时,所有它里面的句柄都会被从栈中移除。正如你所期望的,这些句柄做指向的对象随后就可以被垃圾回收。

回到我们在上手指南中的例子,在下面的图示中,你可以看到句柄栈和堆中的对象。值得注意的是,Context::New() 的返回值是一个本地句柄,然后我们基于它创建了一个新的持久句柄,用以阐述持久句柄的用途。

local_persist_handles_review.png

HandleScope::~HandleScope 析构函数被调用时,该句柄域便会被删除。所有被句柄域中的句柄所引用的对象,都将可以在下次垃圾回收时被删除,如果没有其他对于它们的引用存在。垃圾回收器同样还会删除堆中的 source_objscript_obj 对象,因为它们不被任何句柄所引用,并且也不可被 JavaScript 访问。由于 context 对象是一个持久句柄,所以当句柄域退出时,它并不会被移除,唯一可以删除它的办法就是调用它的 Reset 方法。

注意:后文中的句柄如果不加注明,都指的是本地句柄。

在这个模型下,有一个非常常见的陷阱需要注意:你不可以直接地在一个声明了句柄域的函数中返回一个本地句柄。如果你这么做了,那么你试图返回的本地句柄,将会在函数返回之前,在句柄域的析构函数中被删除。正确的做法是使用 EscapableHandleScope 来代替 HandleScope 创建句柄域,然后调用 Escape 方法,并且传入你想要返回的句柄。例子:

// 这个函数会返回一个带有 x,y 和 z 三个元素的新数组
Local<Array> NewPointArray(int x, int y, int z) {
  v8::Isolate* isolate = v8::Isolate::GetCurrent();

  // 我们将会创建一些临时的句柄,所以我们先创建一个句柄域
  EscapableHandleScope handle_scope(isolate);

  // 创建一个空数组
  Local<Array> array = Array::New(isolate, 3);

  // 如果在创建数组时产生异常,则返回一个空数组
  if (array.IsEmpty())
    return Local<Array>();

  // 填充数组
  array->Set(0, Integer::New(isolate, x));
  array->Set(1, Integer::New(isolate, y));
  array->Set(2, Integer::New(isolate, z));

  // 通过 Escape 返回该数组
  return handle_scope.Escape(array);
}

Escape 方法复制参数中的值至一个封闭的域中,然后删除其他本地句柄,最后返回这个可以被安全返回的新句柄副本。

上下文

在 V8 中,上下文是一个允许多个分别独立的,不相关的 JavaScript 应用在一个单独的 V8 实例中运行的执行环境。你必须为每一个你想要执行的 JavaScript 代码指定一个上下文。

这样做是必要的么?这么做的原因是,JavaScript 本身提供了一组内建的工具函数和对象,但它们又可以被 JavaScript 代码所修改。例如,两个完全没有关联的 JavaScript 函数同时修改了一个全局对象,那么可能就会造成不可预期的后果。

从 CPU 时间和内存的角度来看,创建一个拥有指定数量的内建对象的执行上下文似乎开销很大。但是,V8 的缓存机制可以确保,虽然创建的第一个上下文开销非常大,但后续的上下文创建的开销都会小很多。这是因为第一次创建上下文时,需要创建内建对象和解析内建的 JavaScript 代码,而后续的上下文创建则只需为它们的上下文创建内建对象即可。如果开启了 V8 的快照特性(可通过选项 snapshot=yes 开启,默认值即为开启),第一次创建上下文的时间花销也会被极大的优化,因为快照中包含了这些所需的内建 JavaScript 代码已然被编译后的版本。除了垃圾回收外,V8 的缓存也是 V8 性能表现的关键,更多详情可参阅V8 设计概要文档

当你创建了一个上下文后,你可以随意地进入和离开它,没有次数的限制。当你已经在上下文 A 中时,你还可以再次进入另一个上下文 B ,这以为着当前的上下文环境变成了 B 。当你离开了 B 后,A 就再次成为了当前上下文。如图示:

intro_contexts.png

需要注意的是,JavaScript 内建工具函数和对象是相互独立的。当你创建一个上下文时,你可以同时设置可选的安全标识(security token)。更多详情请参阅下文的安全模型章节。

在 V8 中使用上下文的最初动机是,在一个浏览器中,每一个窗口和 iframe 都需要有各自独立的 JavaScript 环境。

模板

一个模板即为一个上下文中 JavaScript 函数和对象的蓝图。你可以在 JavaScript 对象内使用一个模板来包裹 C++ 函数和数据结构,致使它们可以被 JavaScript 脚本所操纵。例如,Google Chrome 使用模板来将 C++ DOM 节点包裹为 JavaScript 对象,然后在全局命名空间下注册函数。你可以创建一个模板集合,然后在不同的上下文中使用它。模板的数量并没有限制,但是在一个指定的上下文中,每一个模板都只允许有一个它的实例。

在 JavaScript 中,函数和对象间有强烈的二元性。在 Java 或 C++ 中,如果要创建一个新类型的对象,你需要首先定义一个新的类。而在 JavaScript 中,你需要定义一个新的函数,然后把这个函数视作一个构造函数。一个 JavaScript 对象的外形和功能都与它的构造函数关系密切。这些也都反应在了 V8 模板的工作方式中。模板分为两种类型:

  • 函数模板
    一个函数模板就是一个独立函数的蓝图。在一个你想要实例化 JavaScript 函数的上下文中,你可以通过调用模板的 GetFunction 方法来创建一个模板的 JavaScript 实例。当 JavaScript 函数实例被调用时,你还可以为模板关联一个 C++ 回调函数一同被调用。

  • 对象模板
    每一个函数模板都有一个与之关联的对象模板。对象模板用来配置将这个函数作为构造函数而创建的对象。你可以为对象模板关联两种类型的 C++ 回调:

    • 访问器回调会在指定对象原型被脚本访问时被调用。

    • 拦截器回调会在任何对象原型被脚本访问时被调用。

访问器和拦截器的详情会在后文中继续讨论。

下面的例子中,我们将创建一个关联全局对象的模板,然后设置一些内建的全局函数。

// 创建一个关联全局对象的模板,然后设置一些内建的全局函数。
Local<ObjectTemplate> global = ObjectTemplate::New(isolate);
global->Set(String::NewFromUtf8(isolate, "log"), FunctionTemplate::New(isolate, LogCallback));

Persistent<Context> context = Context::New(isolate, NULL, global);

该例子取自于 process.cc 中的 JsHttpProcessor::Initialiser

访问器

访问器为当一个 JavaScript 对象原型被脚本访问时,执行的一个 C++ 回调函数,它计算并返回一个值。访问器需要通过一个对象模板来配置,通过它的 SetAccessor 方法。这个方法的第一个参数为关联的属性,最后一个参数为当脚本试图读写这个属性时执行的回调。

访问的复杂度取决于你想要其控制的数据类型:

  • 访问静态全局变量

  • 访问动态变量

访问静态全局变量

假设有两个名为 xy 的 C++ 整形变量,它们需要成为一个上下文的 JavaScript 中的全局变量。为了达成这个目的,当脚本读或写这些变量时,你需要调用 C++ 访问器函数。这些访问器函数使用 Integer::New 来把 C++ 整形数转换为 JavaScript 整形数,并且使用 Int32Value 来把 JavaScript 整形数转换为 C++ 整形数。例子:

  void XGetter(Local<String> property,
                const PropertyCallbackInfo<Value>& info) {
    info.GetReturnValue().Set(x);
  }

  void XSetter(Local<String> property, Local<Value> value,
               const PropertyCallbackInfo<Value>& info) {
    x = value->Int32Value();
  }

  // YGetter/YSetter 十分类似,这里就省略了

  Local<ObjectTemplate> global_templ = ObjectTemplate::New(isolate);
  global_templ->SetAccessor(String::NewFromUtf8(isolate, "x"), XGetter, XSetter);
  global_templ->SetAccessor(String::NewFromUtf8(isolate, "y"), YGetter, YSetter);
  Persistent<Context> context = Context::New(isolate, NULL, global_templ);

注意代码中的对象模板和上下文几乎在同时创建。模板可以提前创建好,然后在任意数量的上下文中使用它。

访问动态变量

在上面的例子中,变量是静态和全局的。那么,如果数据是动态的,像浏览器中的 DOM 树这样呢?假设我们有一个 C++ 类 Point,它有两个属性 xy

  class Point {
   public:
    Point(int x, int y) : x_(x), y_(y) { }
    int x_, y_;
  }

为了让任意数量的 C++ point 实例可以通过 JavaScript 访问,我们需要为每一个 C++ point 实例创建一个 JavaScript 对象。这可以通过外部(external)值和内部(internal)属性共同办到。

首先创建一个对象模板,用以包裹 point 实例:

  Local<ObjectTemplate> point_templ = ObjectTemplate::New(isolate);

每一个 JavaScript 中的 point 对象都保持了对 C++ 对象的引用,因为它以内部属性的方式被包裹。这些属性不可通过 JavaScript 访问,只能通过 C++ 代码访问到。一个对象可以有任意数量的内部属性,这个数量需通过以下方法来设置:

  point_templ->SetInternalFieldCount(1);

上面的例子中,内部属性的数量被设置为了 1,表明这对象有一个内部属性,索引值为 0。

向模板添加 xy 访问器:

  point_templ.SetAccessor(String::NewFromUtf8(isolate, "x"), GetPointX, SetPointX);
  point_templ.SetAccessor(String::NewFromUtf8(isolate, "y"), GetPointY, SetPointY);

接下来,我们通过创建一个新的模板实例来包裹 C++ point 实例,然后将内部属性 0 设置为 p 的外部包裹。

  Point* p = ...;
  Local<Object> obj = point_templ->NewInstance();
  obj->SetInternalField(0, External::New(isolate, p));

一个外部对象仅被用来在内部属性中存储引用。JavaScript 对象不能直接地引用 C++ 对象,所以外部值就像从 JavaScript 到 C++ 的“一座桥梁”。所以外部值是句柄的相反面,因为句柄的作用是让我们在 C++ 中可以获取 JavaScript 对象的引用。

以下便是 x 的读和写访问器的定义,y 的定义和 x 的十分类似,只需将 x 替换为 y 即可:

  void GetPointX(Local<String> property,
                 const PropertyCallbackInfo<Value>& info) {
    Local<Object> self = info.Holder();
    Local<External> wrap = Local<External>::Cast(self->GetInternalField(0));
    void* ptr = wrap->Value();
    int value = static_cast<Point*>(ptr)->x_;
    info.GetReturnValue().Set(value);
  }

  void SetPointX(Local<String> property, Local<Value> value,
                 const PropertyCallbackInfo<Value>& info) {
    Local<Object> self = info.Holder();
    Local<External> wrap = Local<External>::Cast(self->GetInternalField(0));
    void* ptr = wrap->Value();
    static_cast<Point*>(ptr)->x_ = value->Int32Value();
  }

访问器抽象了对于 C++ point 对象的引用和对其的读写操作。这样这些访问器就可以被用于任意数量的被包裹后的 point 对象中了。

拦截器

你还可以在一个脚本访问任意对象属性时,设置一个回调函数。这些回调函数称为拦截器。拦截器分为两种类型:

  • 具名属性拦截器,它会在访问名称为字符串的属性时被调用,如浏览器环境中的 document.theFormName.elementName

  • 索引属性拦截器,它会在访问索引属性时被调用,如浏览器环境中的 document.forms.elements[0]

V8 源码中的 process.cc 文件中,包含了一个拦截器的使用实例。下面例子中的 SetNamedPropertyHandler 设置了 MapGetMapSet 这两个拦截器:

Local<ObjectTemplate> result = ObjectTemplate::New(isolate);
result->SetNamedPropertyHandler(MapGet, MapSet);

MapGet 拦截器源码如下:

void JsHttpRequestProcessor::MapGet(Local<String> name,
                                    const PropertyCallbackInfo<Value>& info) {
  // Fetch the map wrapped by this object.
  map<string, string> *obj = UnwrapMap(info.Holder());

  // Convert the JavaScript string to a std::string.
  string key = ObjectToString(name);

  // Look up the value if it exists using the standard STL idiom.
  map<string, string>::iterator iter = obj->find(key);

  // If the key is not present return an empty handle as signal.
  if (iter == obj->end()) return;

  // Otherwise fetch the value and wrap it in a JavaScript string.
  const string &value = (*iter).second;
  info.GetReturnValue().Set(String::NewFromUtf8(value.c_str(), String::kNormalString, value.length()));
}

和访问器一样,特定的回调函数会在一个属性被访问后触发。它和访问器的区别就是,访问器会回调仅会在一个特定的属性被访问时触发,而拦截器回调则会在任意属性被访问时触发。

安全模型

“同源策略”(首次出现于网景浏览器 2.0 中),用于阻止从另一个“源”中加载脚本或文档到本地“源”里。这个源的概念中包含了域名(www.example.com),协议(http 或 https)和端口(如 www.example.com:81 和 www.example.com 不同源)。以上部分全部一样,才能被视为同源。如果没了这层保护,许多网页就可以会遭到其他恶意网页的攻击。

在 V8 中,“源”即为上下文。在一个上下文中访问另一个上下文默认是不被允许的。如果一定访问,那么必须使用安全标识(security tokens)或安全回调(security callbacks)。一个安全标识可以是任意类型的值,但通常是一个 symbol 或一个唯一字符串。当你设置一个上下文时,可以通过 SetSecurityToken 可选地设置一个安全标识。如果你没有明确地指明一个安全标识,那么 V8 将会为该上下文自动生成一个。

当试图去访问一个全局变量时,V8 的安全系统首先会去检查被访问的全局变量的上下文的安全标识与访问代码的上下文的安全标识是否一致,若一致,则允许访问。如果安全标识不一致,那么 V8 将会触发一个回调函数来判断这个访问是否该被允许。你可以通过在对象模板的方法 SetAccessCheckCallbacks ,来设置这个安全回调。这个回调的参数为,将会被访问的对象,将会被访问的属性名,和访问的类型(如读,写或删除)并且返回值即表示是否允许这次访问。

在 Google Chrome 中,这套安全机制运用在以下几处:window.focus()window.blur()window.close()window.locationwindow.open()history.forward()history.back()history.go()

异常

当一个错误发生时,V8 将会抛出一个异常。例如,当一个脚本或函数试图去读取一个不存在的属性时,或一个非函数对象被调用时。

如果一次操作失败了,V8 将会返回空句柄。因为在进一步操作前,检查返回值是否是空句柄就变得尤为重要。我们可以通过本地句柄类(Local)的成员函数 IsEmpty() 来进行检查。

你也可以通过 TryCatch 类捕获异常,例子:

  TryCatch trycatch(isolate);
  Local<Value> v = script->Run();
  if (v.IsEmpty()) {
    Local<Value> exception = trycatch.Exception();
    String::Utf8Value exception_str(exception);
    printf("Exception: %s\n", *exception_str);
    // ...
  }

如果返回值是一个空句柄,并且你没有使用 TryCatch ,那么你的代码必须要终止。如果你使用了 TryCatch ,那么你的代码则可以继续执行。

继承

JavaScript 是第一个不基于类的面向对象编程语言。它使用了基于原型的继承。这对于一直使用传统面向对象编程语言(如 C++ 和 Java)的程序员来说,可能会有些困惑。

传统的面向对象编程语言(如 C++ 和 Java)通常基于两个概念:类和继承。JavaScript 是一个基于原型的编程语言,所以它和传统的面向对象编程语言不同,它只有对象。JavaScript 并不原生支持基于类声明的继承。但是 JavaScript 的原型机制简化了为实例添加自定义属性和方法的过程。在 JavaScript 中,你可以为单个实例添加自定义的属性。例子:

// 创建一个对象 bicycle
function bicycle(){
}
// 创建一个名为 roadbike 的实例
var roadbike = new bicycle()
// 为 roadbike 定义一个自定义属性 wheels
roadbike.wheels = 2

自定义属性仅仅存在于当前这个实例中。如果我们创建了另一个 bicycle 实例,如 mountainbikemountainbike.wheels 将会是 undefined

某些时候,这就是我们想要的。而又有些时候,我们想要为所有的实例都添加上这个属性。因为毕竟所有的自行车都有轮子。这是我们就会使用到原型机制。我们只需为对象的 prototype 属性上添加我们想要的自定义属性即可:

// 创建一个对象 bicycle
function bicycle(){
}
// 将 wheels 属性添加到对象的原型上
bicycle.prototype.wheels = 2

这样,所有的 bicycle 实例都将会拥有 wheels 属性。

在 V8 的模板中,做法也是一样的。每一个 FunctionTemplate 类实例都有一个 PrototypeTemplate 方法来给出函数的原型。你可以在其上添加属性,为这些属性关联 C++ 函数。都会影响到该模板关联所有的实例中。例子:

 Local<FunctionTemplate> biketemplate = FunctionTemplate::New(isolate);
 biketemplate->PrototypeTemplate().Set(
     String::NewFromUtf8(isolate, "wheels"),
     FunctionTemplate::New(isolate, MyWheelsMethodCallback)->GetFunction();
 )

上面的代码将会使所有的 biketemplate 实例拥有一个 wheels 方法。当该方法被调用时,C++ 函数 MyWheelsMethodCallback 就会执行。

V8 的 FunctionTemplate 类提供了一个公开成员函数 Inherit() ,当你想要一个函数模板继承于另一个函数模板时,你可以使用它,例子:

void Inherit(Local<FunctionTemplate> parent);

最后

原文链接:https://developers.google.com/v8/embed


菜菜蔡伟
4k 声望283 粉丝

保持平常心。