[本文为Chrome团队的haraken@(Kentaro Hara)写的一篇blink的介绍文档的翻译。原始文档在Google Docs上。]

[目前这个文档还在完善中,许多参考链接里文档也还没有写。]

为Blink项目工作并不容易。对于新的Blink开发者来说不容易,因为Blink引入了许多特有的概念和编码规则以实现高效的渲染。对于经验丰富的Blink开发者来说也不容易,因为Blink非常庞大,对性能、内存和安全性极为敏感。

本文档旨在提供一个“Blink是如何工作的”概述,希望能帮助Blink开发者快速熟悉架构:

  • 本文档不是一个Blink详细架构和编码规则的全面教程。相反,本文档简明地描述了Blink的基本面,这些基本面在短期内不太可能发生变化,并提供了一些资源链接,如果您想了解更多信息,可以进一步阅读。

  • 本文档不介绍特定功能(如ServiceWorkers,编辑)。相反,该文档介绍了在代码库中的许多地方都用到的基础功能(如内存管理,V8 API)。

有关Blink开发的更多信息,请参阅Chromium wiki

Blink是做什么的

Blink是一个Web渲染引擎。粗略地说,Blink实现了一个浏览器标签页里显示的所有内容:

  • 实现Web标准(如HTML标准),包括DOM,CSS和Web IDL
  • 嵌入V8运行JavaScript
  • 从底层网络栈请求资源
  • 构建DOM树
  • 计算样式和布局
  • 嵌入Chrome Compositor绘制图形

Blink通过content public API集成到Chromium,Android WebView和Opera等用户中。

从代码库的角度来看,Blink通常指的是//third_party/blink/。从项目角度来看,Blink通常是指实现Web平台功能的项目。实现Web平台功能的代码在//third_party/ blink///content/renderer///content/browser/等地方。

进程/线程架构

进程

Chromium是多进程架构。Chromium有一个browser进程和N个在沙盒中的renderer进程。 Blink在renderer进程中运行。

有多少个renderer进程会被创建?出于安全原因,隔离跨站点iframe之间的内存地址非常重要(这称为Site Isolation)。理论上,应该为每个站点创建一个renderer进程。 然而实际上,当用户打开太多标签页或设备没有足够内存时,为每个站点创建一个renderer进程会消耗太多资源。这时一个renderer进程可能被从不同站点加载的多个iframe或标签页共享。这意味着同一个标签页中的iframe可能由不同的renderer进程托管,不同标签页中的iframe也可能由同一renderer进程托管。renderer进程,iframe和标签页之间没有1对1映射关系。

鉴于renderer进程在沙箱中运行,Blink需要请求browser进程进行系统调用(如文件访问,播放音频)和访问用户数据(如cookie,密码)。这个browser进程和renderer进程间的通信由Mojo实现。(注:过去我们使用的是Chromium IPC, 有些地方仍在使用它。但是它已经被弃用,并且在底层使用Mojo。) 在Chromium方面,正在进行的 Servicification工作正在将browser进程抽象为一组service。从Blink的角度来看,Blink可以使用Mojo与这些service和browser进程进行交互。

更多信息:

  • 多进程架构
  • Blink中的Mojo编程: platform/mojo/MojoProgrammingInBlink.md

线程

renderer进程中创建了多少个线程?

Blink有一个主线程,N个worker线程和几个内部线程。

几乎所有重要的事情都发生在主线程。所有JavaScript(worker除外),DOM,CSS,样式和布局计算都在主线程上运行。Blink经过高度优化,可以最大限度地提高主线程的性能,主要是基于单线程架构。

Blink可能会创建多个worker线程来运行Web WorkersServiceWorkerWorklet

Blink和V8可能会创建一些内部线程来处理WebAudio,数据库,GC等等等。

对于跨线程通信,必须使用PostTask API进行消息传递。除了几个出于性能原因需要使用的地方,不鼓励使用共享内存。这就是为什么Blink代码库中看不到很多MutexLocks的原因。

更多信息:

Blink的初始化和终止化(finalization)

Blink由BlinkInitializer::Initialize()初始化。必须在执行任何Blink代码之前调用BlinkInitializer::Initialize()。

另一方面,Blink永远不会终止,也就是说,renderer进程会不进行清理强制结束。一个原因是性能考虑。另一个原因是,通常很难以优雅的顺序清理renderer进程中的所有内容(不值得在这里投入精力)。

目录结构

Content Public API是让embedder嵌入渲染引擎的API层。Content public API必须仔细维护,因为它们会暴露给embedder使用。

Blink public API是向Chromium暴露//third_party/blink/的功能的一层API。这一层API只是从WebKit继承而来的历史包袱。在WebKit时代,Chromium和Safari共享了WebKit的实现,因此需要一层API将Webkit的功能暴露给Chromium和Safari。现在Chromium是//third_party/blink/的唯一embedder了,这一层API就没有意义了。我们正在将web平台代码从Chromium移动到Blink,以减少Blink public API的数量(这个项目叫做Onion Soup)。

目录结构和依赖关系

//third_party/blink/具有以下目录。这些目录的更详细定义,请参阅此文档

  • platform/

    Blink底层功能的集合,从core/中重构而来。如几何和图形工具库。

  • core/modules/

    Web标准中定义的所有Web平台功能的实现。core/中实现了与DOM紧密结合的功能。modules/实现了更多独立的功能,如WebAudio,IndexedDB。

  • bindings/core/bindings/modules/

    理论上bindings/core/core/的一部分,bindings/modules/modules/的一部分。大量使用V8 API的文件会放在bingings中。

  • controller/

    一组使用core/modules/的高层库。如devtools的前端。

依赖关系按以下顺序:

  • Chromium => controller / => modules/和bindings/modules/ => core/和bindings/core/ => platform/ => 底层元素(primitive),如//base,//v8和//cc

Blink小心地维护了暴露于//third_party/blink/的底层元素的列表。

更多信息:

WTF

WTF是一个“Blink特有的基础库”,位于platform/wtf/。我们试图尽可能地在Chromium和Blink之间统一编码元素(primitive),因此WTF应该很小。 WTF是必需的,因为有许多类型,容器和宏需要针对Blink的工作负载和Oilpan(Blink GC)进行优化。如果在WTF中定义了类型,则Blink必须使用WTF类型而不是// base或std库中定义的类型。最常用的是vector,hashset,hashmap和string。 Blink应该使用WTF::VectorWTF::HashSetWTF::HashMapWTF::StringWTF::AtomicString而不是std::vectorstd::*setstd::*mapstd::string

更多信息:

内存管理

就Blink而言,你需要关心三个内存分配器:

  • PartitionAlloc
  • Oilpan(也就是Blink GC)
  • malloc(不推荐)

你可以使用USING_FAST_MALLOC()在PartitionAlloc的heap上分配一个对象:

class SomeObject {
  USING_FAST_MALLOC(SomeObject);
  static std :: unique_ptr<SomeObject> Create(){
    return std :: make_unique<SomeObject>(); //在PartitionAlloc的堆上分配。
  }
};

PartitionAlloc分配的对象的生命周期应由scoped_refptr<>std::unique_ptr<>管理。强烈不建议手动管理生命周期。Blink中禁止手动delete

你可以使用GarbageCollected在Oilpan的堆上分配一个对象:

class SomeObject:public GarbageCollected<SomeObject> {
  static SomeObject* Create(){
    return new SomeObject; //在Oilpan的堆上分配。
  }
};

Oilpan分配的对象的生命周期由垃圾回收自动管理。你必须使用特殊的指针(如Member<>Persistent<>)来保存Oilpan堆上的对象。请参阅这个API参考以熟悉有关Oilpan的编程限制。最重要的限制是不允许在Oilpan的对象的析构函数中操作任何其他Oilpan的对象(因为无法保证销毁顺序)。

如果既不使用USING_FAST_MALLOC()也不使用GarbageCollected,则会在系统malloc的堆上分配对象。Blink强烈建议不要这样做。所有Blink对象应由PartitionAlloc或Oilpan分配:

  • 默认情况下使用Oilpan。
  • 仅当 1) 对象的生命周期非常清楚并且std::unique_ptr<>足够用,2) 在Oilpan上分配对象引入了很多复杂性或者 3) 在Oilpan上分配对象给垃圾收集运行时增加了许多不必要的压力时,才使用PartitionAlloc。

无论你是使用PartitionAlloc还是Oilpan,你都必须非常小心,不要创建悬空指针(注:强烈建议不要使用原始指针)或造成内存泄漏。

更多信息:

任务调度

为了提高渲染引擎的响应能力,blink的task应该尽量的异步执行。任何同步的IPC/Mojo/可能需要几毫秒的操作都需要避免(尽管有些操作是不可避免的,如用户的JavaScript执行)。

renderer进程中的所有task都应该设置适当的类型post到Blink Scheduler,如下所示:

//将task post到一帧的scheduler,类型为kNetworking
frame->GetTaskRunner(TaskType::kNetworking)->PostTask(..., WTF::Bind(&Function));

Blink Scheduler维护多个任务队列并巧妙地确定任务的优先级,以最大化用户感知的性能。指定正确的任务类型非常重要,能使Blink Scheduler正确且巧妙地安排任务。

更多信息:

  • 如何发布任务:platform/scheduler/PostTask.md

Page,Frame,Docuemnt,DOMWindow等

概念

Page,Frame,Document,ExecutionContext和DOMWindow是以下概念:

  • Page对应于标签页(OOPIF没有打开的情况。下面会解释OOPIF)。每个renderer进程可能包含多个标签页。
  • Frame对应于main frame或iframe。每个Page可能包含多个树形层次结构排列的Frame。
  • DOMWindow对应于JavaScript中的window对象。每个Frame都有一个DOMWindow。
  • Document对应于JavaScript中window.document对象。每个Frame都有一个Document。
  • ExecutionContext是一个用来抽象Document(用于主线程)和WorkerGlobalScope(用于worker线程)的概念。

renderer进程:Page = 1:N

Page:Frame = 1:M

在任何时间点,Frame:DOMWindow:Document(或ExecutionContext) = 1:1:1,但映射可能会随时间而变化。如以下代码:

iframe.contentWindow.location.href="https://example.com";

在这种情况下,将为https://example.com创建一个新的Window和一个新的Document。然而Frame可能被重复使用。

(注:确切地说,在某些情况下会创建一个新Document,但Window和Frame会被重用。还有更复杂的情况。)

更多信息:

  • core/frame/FrameLifecycle.md

OOPIF(out of process iframe)

网站隔离(Site Isolation)使blink更安全,但也更复杂:)。网站隔离的想法是为每个网站创建一个renderer进程。(这里网站是网页的注册域名+1级子域名及其URL scheme。例如,https://mail.example.com和https://chat.example.com位于同一网站,但https://noodles.com和https://pumpkins.com不是。)如果一个页面包含一个跨站点iframe,那么该页面可能由两个renderer进程托管。考虑以下页面:

<!-- https://example.com -->
<body>
<iframe src="https://example2.com"></iframe>
</body>

main frame和iframe可能由不同的renderer进程托管。在这个renderer进程的frame由LocalFrame表示,而不在这个renderer进程的frame由RemoteFrame表示。

从main frame的角度来看,main frame是LocalFrame,iframe是RemoteFrame。从iframe的角度来看,main frame是RemoteFrame,iframe是LocalFrame。

LocalFrame和RemoteFrame(可能存在于不同的renderer进程中)之间的通信通过browser进程处理。

更多信息:

Detached Frame/Document

Frame/Document可能处于分离状态。考虑以下情况:

doc = iframe.contentDocument;
iframe.remove(); // iframe与DOM树分离。
doc.createElement("div"); //但你仍然可以在分离的Frame上运行脚本。

棘手的事实是你仍然可以在分离的Frame上运行脚本或执行DOM操作。由于Frame已经分离,大多数DOM操作都会失败并抛出错误。不幸的是,分离的Frame上的行为在浏览器之间并不一致,也没有在标准中明确定义。基本上JavaScript应该继续运行,但是大多数DOM操作都应该失败,除了一些适当的例外,例如:

void someDOMOperation(...) {
  if(!script_state_->ontextIsValid()) { // Frame已经分离
    ...; // 设置异常等
    return;
  }
}

这意味着,在通常情况下Blink需要在frame被分离时做一些清理操作。你可以通过继承ContextLifecycleObserver来做到这一点:

class SomeObject : public GarbageCollected<SomeObject>, public ContextLifecycleObserver {
  void ContextDestroyed() override {
    //在这里执行清理操作。
  }
  ~SomeObject() {
    //在这里进行清理操作并不是一个好主意,因为这样做已经太晚了。另外,析构函数不允许触及Oilpan堆上的任何其他对象。
  }
};

Web IDL binding

当JavaScript访问node.firstChild时,node.h中的Node::firstChild()会被调用。这是如何工作的?我们来看看node.firstChild是如何工作的。

首先,您需要根据标准定义一个IDL文件

// node.idl
interface: EventTarget {
  [...] readonly attribute Node? firstChild;
};

Web IDL的语法在Web IDL标准中定义。[...]叫做IDL扩展属性。一些IDL扩展属性在Web IDL标准中定义,其他的是Blink特有的IDL扩展属性。除了Blink特有的IDL扩展属性,IDL文件应以遵守标准的方式编写(也就是只需从标准中复制和粘贴)。

其次,你需要为Node定义一个C++类并为firstChild实现一个C++的getter:

class EventTarget : public ScriptWrappable { //所有暴露给JavaScript的类都必须从ScriptWrappable继承。
  ...;
};

class Node : public EventTarget {
  DEFINE_WRAPPERTYPEINFO(); //所有具有IDL文件的类都必须具有此宏。
  Node* firstChild() const {return first_child_; }
};

在一般情况下,这就够了。构建node.idl时,IDL编译器会自动为Node接口和Node.firstChild生成Blink-V8绑定。自动生成的绑定在//src/out/{Debug,Release}/gen/third_party/blink/renderer/bindings/core/v8/v8_node.h。当JavaScript调用node.firstChild时,V8调用v8_node.h中的V8Node::firstChildAttributeGetterCallback(),然后它调用上面定义的Node::firstChild()

更多信息:

Isolate,Context,World

当你编写V8 API有关的代码时,了解Isolate,Context和World的概念非常重要。它们分别由代码库中的v8::Isolatev8::ContextDOMWrapperWorld表示。

Isolate对应于一个物理线程。Isolate:Blink中的物理线程 = 1:1。主线程有自己的Isolate。worker线程有自己的Isolate。

Context对应于全局对象(对Frame来说,它是Frame的window对象)。由于每个Frame都有自己的window对象,因此renderer进程中有多个Context。当调用V8 API时,必须确保处于正确的Context中。否则,v8::Isolate::GetCurrentContext()将返回错误的上下文,在最坏的情况下,它会泄漏对象并导致安全问题。

World概念是为了支持Chrome扩展程序内容脚本。World不与Web标准中的任何内容对应。内容脚本希望与网页共享DOM,但出于安全原因,内容脚本的JavaScript对象必须与网页的JavaScript堆隔离。 (一个内容脚本的JavaScript堆也必须与另一个内容脚本的JavaScript堆隔离。)为了实现隔离,主线程为网页创建一个main world,为每个内容脚本创建一个隔离的world。main world和隔离的world可以访问相同的C++ DOM对象,但它们的JavaScript对象是隔离的。通过为一个C++DOM对象创建多个V8 wrapper来实现这种隔离。即每个World一个V8 wrapper。

Context,World和Frame之间有什么关系?

想象一下主线程上有N个World(一个main world + (N - 1)个隔离的world)。然后一个Frame应该有N个window对象,每个对象用于一个world。Context是对应于window对象的概念。这意味着当我们有M个Frame和N个World时,我们有M * N个Context(但是Context延迟创建的)。

对于worker,只有一个World和一个全局对象。因此,只有一个Context。

同样,当使用V8 API时,应该非常小心使用正确的Context。否则,你最终会在隔离的World之间泄漏JavaScript对象并导致安全灾难(如A.com的扩展可以操纵B.com的扩展)。

更多信息:

V8 API

//v8/include/v8.h中定义了很多V8 API。由于V8 API很低层并且难以正确使用,platform/bindings/提供了一些包了V8 API的辅助类。你应该考虑尽可能多地使用辅助类。如果你的代码必须大量使用V8 API,那么这些文件应该放在bindings/{core,modules}中。

V8使用句柄指向V8对象。最常见的句柄是v8::Local<>,用于指向机器堆栈中的V8对象。必须在机器堆栈上分配v8::HandleScope后才能使用v8::Local<>。不应在机器堆栈外使用v8::Local<>

void function() {
  v8::HandleScope scope;
  v8::Local<v8::Object> object = ...;  // 这是对的
}

class SomeObject : public GarbageCollected<SomeObject> {
  v8::Local<v8::Object> object_;  // 这是错的
};

如果要从机器堆栈外部指向V8对象,则需要使用wrapper tracing。但是,你必须非常小心,不要使用它创建引用循环。通常,V8 API很难使用。如果你不知道自己在做什么,请询问blink-review-bindings@

更多信息:

  • 如何使用V8 API和辅助类: platform/bindings/HowToUseV8FromBlink.md

V8 wrapper

每个C++ DOM对象(如Node)都有相应的V8 wrapper。准确地说,每个World中的每个C++ DOM对象都有相应的V8 wrapper。

V8 wrapper对它们对应的C++ DOM对象有强引用。但是,C++ DOM对象对V8 wrapper只有弱引用。因此,如果您希望将V8 wrapper保持一段生存时间,则必须显式地执行这个操作。否则,V8 wrapper将被过早回收,V8 wrapper上的JS属性将丢失…

div = document.getElementbyId("div");
child = div.firstChild;
child.foo = "bar";
child = null;
gc();  //如果我们什么都不做,那么|firstChild|的V8 wrapper将被GC回收
assert(div.firstChild.foo === "bar");  //...这会失败

如果我们不做任何事情, child会被GC回收,因此child.foo丢失。为了使div.firstChild的V8 wrapper保持生存,我们必须添加一种机制,保证只要div所在的DOM树能被V8访问,div.firstChild的V8 wrapper就能保持生存。

有两种方法可以保持V8 wrapper存活: ActiveScriptWrappablewrapper tracing

更多信息:

渲染管线

从Blink收到HTML文件,到像素显示在屏幕上,经过了很长的一段旅程。渲染管线的架构如下。

阅读这个优秀的演示文档,以了解渲染管道的每个阶段所作的工作。(我不认为我能写出比这更好的解释了:-)

更多信息:

问题?

如果你有任何问题,请在[email protected](一般性问题)或[email protected] (与架构相关的问题)上提问。我们很乐意提供帮助!:D