跳到内容

JS 封装器和 IDL 文件

概述

除了典型的 C++ 翻译单元 (.cpp) 和 C++ 头文件 (.cpp) 以及一些 Objective-C 和 Objective-C++ 文件外,WebCore 还包含数百个 Web IDL (.idl) 文件。Web IDL 是一种接口描述语言,用于定义 WebKit 中实现的 JavaScript API 的结构和行为。

在构建 WebKit 时,一个 perl 脚本 会在 WebKitBuild/Debug/DerivedSources/WebCore/ 目录下生成与这些 IDL 文件对应的 C++ 翻译单元和 C++ 头文件,其中 Debug 是当前的构建配置(例如,它可以是 Release-iphonesimulator)。

这些自动生成的文件以及手动编写的 Source/WebCore/bindings 文件统称为 JS DOM 绑定代码,它们为底层结构和行为用 C++ 编写的对象和概念实现了 JavaScript API。

例如,Node 的 C++ 实现是 Node 类,其 JavaScript 接口由 JSNode 类实现。对于调试构建,该类的声明和大部分定义都在 WebKitBuild/Debug/DerivedSources/WebCore/JSNode.hWebKitBuild/Debug/DerivedSources/WebCore/JSNode.cpp 中自动生成。它还在 Source/WebCore/bindings/js/JSNodeCustom.cpp 中包含一些自定义、手动编写的绑定代码。类似地,Range 接口的 C++ 实现是 Range 类,而其 JavaScript API 由自动生成的 JSRange 类实现(对于调试构建,位于 WebKitBuild/Debug/DerivedSources/WebCore/JSRange.hWebKitBuild/Debug/DerivedSources/WebCore/JSRange.cpp)。我们称这些 JSX 类的实例为 X 的*JS 封装器*。

这些 JS 封装器存在于我们称之为 DOMWrapperWorld 的环境中。每个 DOMWrapperWorld 为每个 C++ 对象都有自己的 JS 封装器。因此,一个 C++ 对象可能在不同的 DOMWrapperWorld 中拥有多个 JS 封装器。最重要的 DOMWrapperWorld 是主 DOMWrapperWorld,它运行 WebKit 加载的网页脚本,而其他 DOMWrapperWorld 通常用于运行浏览器扩展代码和嵌入 WebKit 的应用程序注入的其他代码。JS 封装器图 JSX.h 提供了 toJS 函数,它在给定全局对象DOMWrapperWorld 中为 X 创建一个 JS 封装器,以及 toWrapped 函数,它返回底层的 C++ 对象。例如,NodetoJS 函数定义在 Source/WebCore/bindings/js/JSNodeCustom.h 中。

当给定 C++ 对象已经存在 JS 封装器对象时,toJS 函数将在给定 DOMWrapperWorld哈希映射中找到适当的 JS 封装器。由于哈希映射查找开销较大,一些 WebCore 对象继承自 ScriptWrappable,该类包含一个指向主世界 JS 封装器的内联指针(如果该封装器已创建)。

JS 封装器生命周期管理

通常,JS 封装器通过 WebCore 中所有 JS 封装器都继承的 JSDOMWrapper 模板类中的引用计数机制来保持其底层 C++ 对象的生命周期。但是,C++ 对象不会通过自身保持其在每个世界中对应的 JS 封装器的生命周期,因为这种循环依赖会导致内存泄漏。

WebCore 中有两种主要的机制来保持 JS 封装器的生命周期:

  • 访问子节点 - 当 JavaScriptCore 的垃圾回收器在标记阶段访问某个 JS 封装器时,同时访问需要保持生命周期的其他 JS 封装器或 JS 对象。
  • 可从不透明根访问 - 告知 JavaScriptCore 的垃圾回收器,某个 JS 封装器可从在标记阶段添加到不透明根集合中的不透明根访问。

访问子节点

访问子节点 是当一个 JS 封装器需要保持另一个 JS 封装器或 JS 对象的生命周期时我们使用的机制。

例如,ErrorEvent 对象Source/WebCore/bindings/js/JSErrorEventCustom.cpp 中使用此方法来保持其 "error" IDL 属性的生命周期,如下所示:

template<typename Visitor>
void JSErrorEvent::visitAdditionalChildren(Visitor& visitor)
{
    wrapped().originalError().visit(visitor);
}

DEFINE_VISIT_ADDITIONAL_CHILDREN(JSErrorEvent);

这里,DEFINE_VISIT_ADDITIONAL_CHILDREN 宏生成 visitAdditionalChildren 的模板实例,该函数由 JavaScriptCore 的垃圾回收器调用。当垃圾回收器访问 ErrorEvent 对象的实例时,它还会访问 wrapped().originalError(),即 "error" 属性的 JavaScript 值。

class ErrorEvent final : public Event {
...
    const JSValueInWrappedObject& originalError() const { return m_error; }
    SerializedScriptValue* serializedError() const { return m_serializedError.get(); }
...
    JSValueInWrappedObject m_error;
    RefPtr<SerializedScriptValue> m_serializedError;
    bool m_triedToSerialize { false };
};

请注意,JSValueInWrappedObject 使用 Weak,它本身不会保持引用对象的生命周期。我们不能使用像 Strong 这样的引用类型,因为它本身会保持引用对象的生命周期,而存储的 JS 对象也可能将此 ErrorEvent 对象作为其属性存储。由于垃圾回收器无法知道或清除 Strong 引用或假设版本的 ErrorEvent 中对 ErrorEvent 的属性,它将永远无法收集这两个对象,从而导致内存泄漏。

要使用此方法来保持 JavaScript 对象或封装器的生命周期,请将 JSCustomMarkFunction 添加到 IDL 文件中,然后在 Source/WebCore/bindings/js 下引入 JS*Custom.cpp 文件,并实现 template<typename Visitor> void JS*Event::visitAdditionalChildren(Visitor& visitor),如上述 ErrorEvent 所示。

visitAdditionalChildren 是在主线程运行时并发调用的。在 visitAdditionalChildren 中进行的任何操作都需要是多线程安全的。例如,它不能增加或减少 RefCounted 对象的引用计数,也不能从 CanMakeWeakPtr 创建新的 WeakPtr,因为这些 WTF 类不是线程安全的。

不透明根

可从不透明根访问 是当我们有一个底层 C++ 对象并希望保持其他 C++ 对象的 JS 封装器的生命周期时使用的机制。

为了理解原因,让我们考虑一个 StyleSheet 对象。只要这个对象还活着,我们还需要保持 ownerNode 属性返回的 DOM 节点的生命周期。此外,对象本身也需要保持生命周期,只要所有者节点还活着,因为这个 [StyleSheet 对象] 可以通过所有者节点的 sheet IDL 属性访问。如果我们使用*访问子节点*机制,那么每当垃圾回收器访问这个 StyleSheet 对象时,我们都需要访问所有者节点的每个 JS 封装器;每当垃圾回收器访问所有者节点时,我们都需要访问 StyleSheet 对象的每个 JS 封装器。但要做到这一点,我们需要查询每个 DOMWrapperWorld 的封装器映射,以查看是否存在 JavaScript 封装器。这是一个需要一直发生的昂贵操作,并且在 NodeStyleSheet 对象之间创建了紧密耦合,因为每个 JS 封装器对象都需要知道其他对象的存在。

不透明根 通过让垃圾回收器知道,只要垃圾回收器遇到了该 JavaScript 封装器关心的特定不透明根(即使垃圾回收器没有直接访问该 JavaScript 封装器),该 JavaScript 封装器就需要保持生命周期,从而解决了这些问题。不透明根只是一个 void* 标识符,垃圾回收器在每个标记阶段都会跟踪它,并且它不符合特定的接口或行为。它本来可以是一个任意的整数值,但出于方便起见使用了 void*,因为活动对象的指针值是唯一的。

StyleSheet 对象的情况下,只要垃圾回收器访问 ownerNodeStyleSheet 的 JavaScript 封装器就会告诉垃圾回收器它需要保持生命周期,因为一个它关心的不透明根已经被遇到。

在最简单的模型中,这种情况下的不透明根将是 ownerNode 本身。然而,每个 Node 对象也必须保持其父节点、兄弟节点和子节点的生命周期。为此,每个 Node 都将节点指定为其不透明根。NodeStyleSheet 对象都使用这个唯一的不透明根作为与垃圾回收器通信的方式。

例如,当被要求在 JSStyleSheetCustom.cpp 中访问其子节点时,StyleSheet 对象会告知垃圾回收器这个不透明根。

template<typename Visitor>
void JSStyleSheet::visitAdditionalChildren(Visitor& visitor)
{
    visitor.addOpaqueRoot(root(&wrapped()));
}

这里,void* root(StyleSheet*) 返回 StyleSheet 对象的不透明根,如下所示:

inline void* root(StyleSheet* styleSheet)
{
    if (CSSImportRule* ownerRule = styleSheet->ownerRule())
        return root(ownerRule);
    if (Node* ownerNode = styleSheet->ownerNode())
        return root(ownerNode);
    return styleSheet;
}

然后,在 JSStyleSheet.cpp(对于调试构建,位于 WebKitBuild/Debug/DerivedSources/WebCore/JSStyleSheet.cpp)中,JSStyleSheetOwner(一个用于与垃圾回收器通信的辅助 JavaScript 对象)告诉垃圾回收器,只要垃圾回收器遇到了这个 StyleSheet 的不透明根,JSStyleSheet 就应该保持活动。

bool JSStyleSheetOwner::isReachableFromOpaqueRoots(JSC::Handle<JSC::Unknown> handle, void*, AbstractSlotVisitor& visitor, const char** reason)
{
    auto* jsStyleSheet = jsCast<JSStyleSheet*>(handle.slot()->asCell());
    void* root = WebCore::root(&jsStyleSheet->wrapped());
    if (UNLIKELY(reason))
        *reason = "Reachable from jsStyleSheet";
    return visitor.containsOpaqueRoot(root);
}

通常,使用不透明根来保持 JavaScript 封装器的生命周期涉及两个步骤:

  1. visitAdditionalChildren 中添加不透明根。
  2. 当找到相关不透明根时,在 isReachableFromOpaqueRoots 中返回 true。

第一步可以通过使用前面提到的 JSCustomMarkFunctionvisitAdditionalChildren 来实现。或者,更优选的是,可以将 GenerateAddOpaqueRoot 添加到 IDL 接口中以自动生成此代码。例如,AbortController.idl 使用此 IDL 属性如下:

[
    Exposed=(Window,Worker),
    GenerateAddOpaqueRoot=signal
] interface AbortController {
    [CallWith=ScriptExecutionContext] constructor();

    [SameObject] readonly attribute AbortSignal signal;

    [CallWith=GlobalObject] undefined abort(optional any reason);
};

这里,signal底层 C++ 对象的一个公共成员函数。

class AbortController final : public ScriptWrappable, public RefCounted<AbortController> {
    WTF_MAKE_ISO_ALLOCATED(AbortController);
public:
    static Ref<AbortController> create(ScriptExecutionContext&);
    ~AbortController();

    AbortSignal& signal();
    void abort(JSDOMGlobalObject&, JSC::JSValue reason);

private:
    explicit AbortController(ScriptExecutionContext&);

    Ref<AbortSignal> m_signal;
};

GenerateAddOpaqueRoot 在没有指定任何值的情况下被指定时,它会自动调用 opaqueRoot()

visitAdditionalChildren 类似,添加不透明根是并发进行的,同时主线程正在运行。在 visitAdditionalChildren 中执行的任何操作都需要是多线程安全的。例如,它不能增加或减少 RefCounted 对象的引用计数,也不能从 CanMakeWeakPtr 创建新的 WeakPtr,因为这些 WTF 类的修改操作不是线程安全的。

第二步可以通过在 IDL 文件中添加 CustomIsReachable 并在 JS*Custom.cpp 文件中实现 JS*Owner::isReachableFromOpaqueRoots 来实现。或者,更优选的是,可以将 GenerateIsReachable 添加到 IDL 文件中以自动生成此代码,并带有以下值:

  • 无值 - 将对类型 T 的底层 C++ 对象调用 root(T*) 的结果作为不透明根添加。
  • Impl - 将底层 C++ 对象作为不透明根添加。
  • ReachableFromDOMWindow - 将 window() 返回的 DOMWindow 作为不透明根添加。
  • ReachableFromNavigator - 将 navigator() 返回的 Navigator 作为不透明根添加。
  • ImplDocument - 将 document() 返回的 Document 作为不透明根添加。
  • ImplElementRoot - 将 element() 返回的 Element 的根节点作为不透明根添加。
  • ImplOwnerNodeRoot - 将 ownerNode() 返回的 Node 的根节点作为不透明根添加。
  • ImplScriptExecutionContext - 将 scriptExecutionContext() 返回的 ScriptExecutionContext 作为不透明根添加。

与访问子节点或添加不透明根类似,是否可从不透明根访问是并行检查的。但是,它发生在主线程暂停时,与访问子节点或添加不透明根不同,后者在主线程运行时并发发生。这意味着 JS*Owner::isReachableFromOpaqueRoots 或由 GenerateIsReachable 调用的任何函数中的任何操作都不能具有线程不安全的副作用,例如增加或减少 RefCounted 对象的引用计数,或从 CanMakeWeakPtr 创建新的 WeakPtr,因为这些 WTF 类的修改操作不是线程安全的。

活动 DOM 对象

访问子节点和不透明根是表达 JS 封装器之间生命周期关系的绝佳方式,但在某些情况下,JS 封装器需要独立于其他对象而保持活动。考虑 XMLHttpRequest。在以下示例中,JavaScript 丢失了对 XMLHttpRequest 对象及其事件监听器的所有引用,但当接收到新响应时,对象上将分派一个事件,重新引入对对象的新 JavaScript 引用。也就是说,即使没有与其他“根”对象的任何关联,该对象也能在垃圾回收的标记和清除循环中存活下来。

function fetchURL(url, callback)
{
    const request = new XMLHttpRequest();
    request.addEventListener("load", callback);
    request.open("GET", url);
    request.send();
}

在 WebKit 中,我们认为此类对象具有*挂起活动*。表达这种挂起活动的存在是 ActiveDOMObject 的主要用例。

通过使一个对象继承自 ActiveDOMObject像这样注解 IDL,WebKit 将自动生成 isReachableFromOpaqueRoot 函数,只要 ActiveDOMObject::hasPendingActivity 返回 true,即使垃圾回收器可能在此实例中未遇到任何特定的不透明根,该函数也会返回 true。

XMLHttpRequest 的例子中,只要对象仍然存在活跃的网络活动,hasPendingActivity 就会返回 true。一旦资源完全获取或失败,它就不再有挂起活动。这样,只要有活跃的网络活动,XMLHttpRequest 的 JS 封装器就会保持活跃。

活动 DOM 对象还有一个相关的用例,即当文档进入前进/后退缓存时,以及当整个页面其他原因必须暂停时。

当这种情况发生时,与文档关联的每个活动 DOM 对象都会被暂停。每个活动 DOM 对象都可以利用这个机会准备暂停任何挂起活动;例如,XMLHttpRequest 将停止分派 progress 事件,媒体元素将停止播放。当文档从前进/后退缓存中出来或因其他原因恢复时,每个活动 DOM 对象都会被恢复。此时,每个对象都有机会再次恢复之前挂起的活动。

创建挂起活动

有几种方法可以在活动 DOM 对象上创建挂起活动。

当相关的 Web 标准要求将任务排入队列以执行某些工作时,应使用 ActiveDOMObject 的以下成员函数之一:

这些函数将自动创建挂起活动,直到新入队的任务执行完毕。

或者,可以使用makePendingActivity为活动 DOM 对象创建挂起活动令牌。这将使活动 DOM 对象的挂起活动保持,直到所有令牌都失效。

最后,当存在一个复杂的条件,使得挂起活动存在时,活动 DOM 对象可以重写virtualHasPendingActivity成员函数,并在该条件成立时返回 true。请注意,只要将来有可能以任何方式分派事件或调用 JavaScript,virtualHasPendingActivity 就应该返回 true。换句话说,当对象在 C++ 中执行某些工作时,应该存在一个挂起活动,远远早于任何事件分派的计划。任何时候没有挂起活动时,对象的 JS 封装器都可能被垃圾回收器删除。