使用 Rust 和 Wasm 构建快速、基于 Bump Allocation 的虚拟 DOM

Dodrio 是一个使用 Rust 和 WebAssembly 编写的虚拟 DOM 库。它利用了 Wasm 的线性内存和 Rust 的底层控制,通过围绕 Bump Allocation 设计虚拟 DOM 渲染来实现。初步基准测试结果表明它拥有同类最佳的性能。

背景

虚拟 DOM 库

虚拟 DOM 库为 Web 的命令式 DOM 提供了一个声明式接口。用户通过生成虚拟 DOM 树结构来描述所需的 DOM 状态,库负责使 Web 页面上的物理 DOM 反映用户生成的虚拟 DOM 树。库使用一些 diffing 算法来减少它们调用的昂贵 DOM 突变方法的数量。此外,它们往往具有缓存功能,以进一步避免不必要地重新渲染未更改的组件并重新 diffing 相同的子树。

Bump Allocation

Bump Allocation 是一种快速但有限的内存分配方法。分配器维护一块内存和一个指向该块内部的指针。要分配一个对象,分配器将指针向上舍入到对象的对其方式,添加对象的尺寸,并进行一个快速测试,以确保指针没有溢出并仍然指向内存块内。分配只需要少量指令。同样,一次性释放所有对象的速度也很快:将指针重置回块的开头。

Bump Allocation 的缺点是,在其他对象仍在使用的情况下,没有通用的方法来释放单个对象并回收其内存区域。

这些权衡使得 Bump Allocation 非常适合阶段式分配。也就是说,一组对象将在同一个程序阶段全部分配、一起使用,最后一起释放。

Bump Allocation 的伪代码
bump_allocate(size, align):
    aligned_pointer = round_up_to(self.pointer, align)
    new_pointer = aligned_pointer + size
    if no overflow and new_pointer < self.end_of_chunk:
        self.pointer = new_pointer
        return aligned_pointer
    else:
        handle_allocation_failure()

从用户的角度来看 Dodrio

首先,我们应该清楚地了解 Dodrio 是什么和不是什么。Dodrio 只是一个虚拟 DOM 库。它不是一个完整的框架。它不提供状态管理,例如 Redux 存储和操作或双向绑定。它不是构建 Web 应用程序时遇到的所有问题的完整解决方案。

使用 Dodrio 应该对之前使用过 Rust 或虚拟 DOM 库的任何人来说都比较熟悉。为了定义如何将 struct 渲染成 HTML,用户实现 dodrio::Render 特性,该特性接收对 self 的不可变引用,并返回一个虚拟 DOM 树。

Dodrio 使用 Builder 模式 来创建虚拟 DOM 节点。我们打算支持可选的 JSX 样式、内联 HTML 模板语法和编译时过程宏,但我们将其留作 未来工作

dodrio::Render 特性接口中的 'a'bump 生命期以及 where 'a: 'bump 子句强制执行 self 引用比 Bump Allocation 区域和返回的虚拟 DOM 树存活更久。这意味着,如果 self 包含一个字符串,例如,返回的虚拟 DOM 可以通过引用安全地使用该字符串,而不是将它复制到 Bump Allocation 区域中。Rust 的生命期和借用使我们能够积极地进行节省成本的优化,同时静态地保证它们的安全性。

Dodrio 的“Hello, World!”示例
struct Hello {
    who: String,
}

impl Render for Hello {
    fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
    where
        'a: 'bump,
    {
        span(bump)
            .children([text("Hello, "), text(&self.who), text("!")])
            .finish()
    }
}

事件处理程序被赋予对根 dodrio::Render 组件、用于调度重新渲染的虚拟 DOM 实例句柄以及 DOM 事件本身的引用。

Dodrio 中递增计数器的示例
struct Counter {
    count: u32,
}

impl Render for Counter {
    fn render<'a, 'bump>(&'a self, bump: &'bump Bump) -> Node<'bump>
    where
        'a: 'bump,
    {
        let count = bumpalo::format!(in bump, "{}", self.count);
        div(bump)
            .children([
                text(count.into_bump_str()),
                button(bump)
                    .on("click", |root, vdom, _event| {
                        let counter = root.unwrap_mut::<Counter>();
                        counter.count += 1;
                        vdom.schedule_render();
                    })
                    .children([text("+")])
                    .finish(),
            ])
            .finish()
    }
}

此外,Dodrio 还提供了一个概念验证 API,用于在 JavaScript 中定义渲染组件。这反映了 Rust 和 Wasm 生态系统对 JavaScript 的强大集成故事,这使得对 Rust 的增量移植和异构的多语言应用程序成为可能,其中只有性能最敏感的代码路径是用 Rust 编写的。

在 JavaScript 中定义的 Dodrio 渲染组件
class Greeting {
  constructor(who) {
    this.who = who;
  }

  render() {
    return {
      tagName: "p",
      attributes: [{ name: "class", value: "greeting" }],
      listeners: [{ on: "click", callback: this.onClick.bind(this) }],
      children: [
        "Hello, ",
        {
          tagName: "strong",
          children: [this.who],
        },
      ],
    };
  }

  async onClick(vdom, event) {
    // Be more excited!
    this.who += "!";

    // Schedule a re-render.
    await vdom.render();

    console.log("re-rendering finished!");
  }
}
使用在 JavaScript 中定义的渲染组件
#[wasm_bindgen]
extern "C" {
    // Import the JS `Greeting` class.
    #[wasm_bindgen(extends = Object)]
    type Greeting;

    // And the `Greeting` class's constructor.
    #[wasm_bindgen(constructor)]
    fn new(who: &str) -> Greeting;
}

// Construct a JS rendering component from a `Greeting` instance.
let js = JsRender::new(Greeting::new("World"));

最后,Dodrio 公开了安全的公共接口,而且我们在编写 Dodrio 渲染组件时从未感到有必要使用 unsafe

内部设计

Dodrio 中的虚拟 DOM 树渲染和 diffing 都利用了 Bump Allocation。渲染从组件状态构造基于 Bump Allocation 的虚拟 DOM 树。Diffing 将 DOM 突变批处理到一个基于 Bump Allocation 的“更改列表”中,该列表在 diffing 完成后一次性应用于物理 DOM。这种设计旨在最大化分配吞吐量,而分配吞吐量通常是虚拟 DOM 库的性能瓶颈,并最大限度地减少 Wasm、JavaScript 和本地 DOM 函数之间的来回切换,这应该可以改善时间缓存局部性并避免越界调用。

渲染到双缓冲 Bump Allocation 区域

虚拟 DOM 渲染表现出我们可以利用 Bump Allocation 的阶段

  1. 一个虚拟 DOM 树是由 Render 实现构建的,
  2. 它与旧的虚拟 DOM 树进行 diffing,
  3. 保存到下次渲染新的虚拟 DOM 树时,
  4. 当它与新的虚拟 DOM 树进行 diffing 时,
  5. 最后,它和它的所有节点都被销毁。

这个过程无限循环。

虚拟 DOM 树的生命期和随时间的操作
        ------------------- Time ------------------->
Tree 0: [ render | ------ | diff ]
Tree 1:          [ render | diff | ------ | diff ]
Tree 2:                          [ render | diff | ------ | diff ]
Tree 3:                                          [ render | diff | ------ | diff ]
...

在任何给定时间,只有两个虚拟 DOM 树是活动的。因此,我们可以双缓冲两个 Bump Allocation 区域,它们在包含新或旧虚拟 DOM 树的角色之间来回切换

  1. 一个虚拟 DOM 树被渲染到 Bump 区域 A 中,
  2. Bump 区域 A 中的新虚拟 DOM 树与 Bump 区域 B 中的旧虚拟 DOM 树进行 diffing,
  3. Bump 区域 B 的 Bump 指针被重置,
  4. Bump 区域 A 和 B 被交换。
用于虚拟 DOM 树渲染的双缓冲 Bump Allocation 区域
        ------------------- Time ------------------->
Arena A: [ render | ------ | diff | reset | render | diff | -------------- | diff | reset | render | diff ...
Arena B:          [ render | diff | -------------- | diff | reset | render | diff | -------------- | diff ...

Diffing 和更改列表

Dodrio 使用一个简单的、单遍算法来 diff 虚拟 DOM 树。它同步遍历旧树和新树,并在旧树和新树之间存在属性、监听器或子级差异时构建一个包含 DOM 突变操作的更改列表。它目前没有使用任何复杂的算法来最小化更改列表中的操作数量,例如最长公共子序列或耐心 diffing。

更改列表是在 diffing 过程中构建的,应用于物理 DOM,然后被销毁。下次渲染新的虚拟 DOM 树时,该过程将重复。由于在任何时候最多只有一个更改列表是活动的,因此我们对所有更改列表使用单个 Bump Allocation 区域。

更改列表的 DOM 突变操作被编码为 自定义堆栈机的指令。虽然指令的辨别符始终是 32 位整数,但指令的尺寸是可变的,因为一些指令具有立即数,而另一些则没有。机器的堆栈包含物理 DOM 节点(文本节点和元素),立即数对 UTF-8 字符串的指针和长度进行编码。

指令是在 Rust 和 Wasm 侧发出的,然后在 JavaScript 中批量解释并应用于物理 DOM。每个解释特定指令的 JavaScript 函数都接受四个参数

  1. 对表示堆栈机的 JavaScript ChangeList 类的引用,
  2. 从 Wasm 内存中解码字符串的 Uint8Array 视图,
  3. 从 Wasm 内存中解码立即数的 Uint32Array 视图,
  4. 以及指令的立即数(如果有)所在的偏移量 i

它返回 Wasm 内存的 32 位视图中下一个指令的编码位置的新偏移量。

有用于以下方面的指令

  • 创建、删除和替换元素和文本节点,
  • 添加、删除和更新属性和事件监听器,
  • 以及遍历 DOM。

例如,AppendChild 指令没有立即数,但预期堆栈顶部有两个节点。它从堆栈中弹出第一个节点,然后调用 Node.prototype.appendChild,并将弹出的节点作为子节点,并将现在位于堆栈顶部的节点作为父节点。

发出 AppendChild 指令
// Allocate an instruction with zero immediates.
fn op0(&self, discriminant: ChangeDiscriminant) {
    self.bump.alloc(discriminant as u32);
}

/// Immediates: `()`
///
/// Stack: `[... Node Node] -> [... Node]`
pub fn emit_append_child(&self) {
    self.op0(ChangeDiscriminant::AppendChild);
}
解释 AppendChild 指令
function appendChild(changeList, mem8, mem32, i) {
    const child = changeList.stack.pop();
    top(changeList.stack).appendChild(child);
    return i;
}

另一方面,SetText 指令预期堆栈顶部有一个文本节点,并且不修改堆栈。它有一个作为指针和长度立即数编码的字符串。它解码字符串,并调用 Node.prototype.textContent 设置器函数,用解码后的字符串更新文本节点的文本内容。

发出 SetText 指令
// Allocate an instruction with two immediates.
fn op2(&self, discriminant: ChangeDiscriminant, a: u32, b: u32) {
    self.bump.alloc([discriminant as u32, a, b]);
}

/// Immediates: `(pointer, length)`
///
/// Stack: `[... TextNode] -> [... TextNode]`
pub fn emit_set_text(&self, text: &str) {
    self.op2(
        ChangeDiscriminant::SetText,
        text.as_ptr() as u32,
        text.len() as u32,
    );
}
解释 SetText 指令
function setText(changeList, mem8, mem32, i) {
    const pointer = mem32[i++];
    const length = mem32[i++];
    const str = string(mem8, pointer, length);
    top(changeList.stack).textContent = str;
    return i;
}

初步基准测试

为了了解 Dodrio 相对于其他库的速度,我们将其加入到 Elm 的“闪电般快速的 HTML”基准测试 中,该测试比较了不同库实现的 TodoMVC 的渲染速度。他们声称这种方法是公平的,基准测试结果应该可以推广。他们还主观地评估了优化实现以提高性能的难易程度(例如,在 React 中添加适当的 `shouldComponentUpdate` 提示,以及在 Elm 中添加 `lazy` 包装器)。我们遵循相同的测试方法,并禁用了 Dodrio 默认开启的每帧动画渲染防抖,这与 Elm 实现的测试条件一致。

也就是说,这些基准测试结果存在一些需要注意的地方。React 实现存在阻止其完成基准测试的错误,因此我们没有在下面列出其度量结果。如果您好奇,可以查看原始的 Elm 基准测试结果,了解它与这里测量的其他一些库相比如何。其次,我们尝试将基准测试更新到每个库的最新版本,但很快就陷入了困境,因此 _这个基准测试没有使用每个库的最新版本_。

言归正传,让我们看看基准测试结果。我们在 Linux 上的 Firefox 67 中运行了这些测试。数值越低越好,表示渲染时间越快。

基准测试结果
Benchmark results graph

优化? 毫秒
Ember 2.6.3 3542
Angular 1.5.8 2856
Angular 2 2743
Elm 0.16 4295
Elm 0.17 3170
Dodrio 0.1 预览版 2181
Angular 1.5.8 3175
Angular 2 2371
Elm 0.16 4229
Elm 0.17 2696

**Dodrio 是基准测试中测量的最快库。**但这并不意味着 Dodrio 始终会在所有场景中都最快 - 这无疑是错误的。但这些结果验证了 Dodrio 的设计,并表明它已经具备了同类最佳的性能。此外,还有空间可以使其更快。

  • Dodrio 是一款全新的库,还没有像其他测试过的库那样投入多年的开发工作。我们还没有对 Dodrio 进行任何认真的分析或优化工作!

  • 基准测试中使用的 Dodrio TodoMVC 实现没有使用像其他实现那样使用 `shouldComponentUpdate` 样式的优化。这些技术仍然可供 Dodrio 用户使用,但您需要更少地使用它们,因为惯用的实现已经足够快了。

未来工作

到目前为止,我们还没有投资于打磨 Dodrio 的人体工程学。我们希望探索添加 类型安全的 HTML 模板,这些模板最终会归结为 Dodrio 虚拟 DOM 树构建器的调用。

此外,我们还可以通过以下几种方式来提高 Dodrio 的性能。

为了改善人体工程学并进一步提升性能,我们希望在投入更多精力之前,先收集来自实际使用中的反馈意见。

Evan Czaplicki 指出了另一个基准测试——`krausest/js-framework-benchmark`——我们可以用它来进一步评估 Dodrio 的性能。我们期待为 Dodrio 实现这个基准测试,并收集更多测试案例和对性能的见解。

在未来,WebAssembly 主机绑定提案 将使我们能够在 Rust 和 Wasm 中解释更改列表的操作,而无需通过 JavaScript 调用 DOM 方法。

结论

Dodrio 是一款新的虚拟 DOM 库,旨在利用 Wasm 线性内存和 Rust 低级控制的优势,通过广泛使用快速碰撞分配来实现。如果您想了解更多关于 Dodrio 的信息,我们鼓励您查看它的 代码库示例

感谢 Luke WagnerAlex Crichton 对 Dodrio 设计的贡献,以及他们参与的头脑风暴和橡皮鸭调试会议。我们还与 React、Elm 和 Ember 团队的核心开发者讨论了这些想法中的许多,感谢他们为这些讨论最终带来的 Dodrio 设计背景和理解提供了帮助。最后,感谢 Jason OrendorffLin ClarkTill SchneidereitAlex CrichtonLuke WagnerEvan CzaplickiRobin Heggelund Hansen 对本文初稿的宝贵反馈。

关于 Nick Fitzgerald

我喜欢计算、自行车、嘻哈、书籍和笔式绘图仪。我的代词是 he/him。

Nick Fitzgerald 的更多文章…


9 条评论

  1. Taylor Hunt

    碰撞分配器似乎非常适合 DOM 差异比较,但我很好奇:如果差异比较和重新渲染分为不同的步骤——与 requestAnimationFrame()、requestIdleCallback() 或其他调度机制配合使用——它是否仍然是理想的?例如,React 宣布永远打算在元素位于视窗外部时延迟重新渲染。

    我必须承认,我最感兴趣的是这个库是否真的以某种三头普通/飞行系宝可梦命名。

    2019 年 3 月 14 日 下午 1:12

    1. Nick Fitzgerald

      调度会很有趣,但到目前为止还没有成为重点。主要是在试验基于碰撞分配的设计。

      是的,这个名字的灵感来自宝可梦。有三个碰撞分配器:两个用于渲染到,一个用于差异比较。这似乎与 Dodrio 的三个头非常吻合;)

      2019 年 3 月 15 日 下午 6:12

  2. Vladan

    在这个基准测试中,ToDoMVC 工作负载是在 JavaScript 还是 Rust 中实现的?

    2019 年 3 月 14 日 下午 1:51

    1. Nick Fitzgerald

      Dodrio TodoMVC 实现是这个:https://github.com/fitzgen/dodrio/tree/master/examples/todomvc

      2019 年 3 月 15 日 下午 6:10

  3. archnode

    太棒了!我真的很期待尝试一下。感谢您的辛勤工作。

    2019 年 3 月 15 日 上午 11:52

  4. Sabir Ansari

    精彩的文章!!!

    2019 年 3 月 16 日 上午 1:31

  5. Jim Hurd

    在两个缓冲区之间切换是否意味着您必须在每帧都重建整个 vdom 树?Flutter 和 React 都会创建新的 vdom,同时重用之前树的大部分,而且似乎有一些场景会导致这种重用成为一个巨大的胜利(O(lg(n)) 与 O(n))?

    2019 年 3 月 16 日 上午 5:29

    1. Nick Fitzgerald

      `dodrio::Cached` 组合器可以让您在不同帧之间重用和缓存 vdom 树。

      2019 年 3 月 18 日 上午 9:07

  6. Richard Dodd

    这是很棒的工作。我目前正在用 JavaScript 编写一个网站,而且我正淹没在弱类型海洋中。鸭子类型不适合扩展!希望很快就能用 Rust 来做所有这些事情。

    2019 年 3 月 24 日 下午 3:45

本文的评论已关闭。