网页上用 Rust 渲染十万个待办事项有多快?

网页上用 Rust 渲染十万个待办事项有多快?

因为 WebAssembly 的出现,很多的编程语言被带到了 Web,进入了更多前端er的视野,Rust 就为其中之一。本文将使用 Dodrio渲染十万个待办事项并随机消灭一半( 灭霸本霸),抱着学习使用的心态顺带测试一下它的速度。


Dodrio 是一个用 Rust 和 WebAssembly 编写的虚拟 DOM 库。它利用了 Wasm 的线性内存和 Rust 的低层次控制 api ,围绕指针碰撞(bump allocation)的方式来设计虚拟 DOM 渲染机制。初步的基准测试结果表明它比现有的虚拟 DOM 库性能都高。
link:alloyteam.com/2020/01/d

开始前

白话简介一下相关名词,建议跳过

WebAssembly

是一种编译目标,将 C/C++/Rust/Go 等语言的编译为二进制格式后可供 Javascript 使用。因其跳过了 JavaScript 运作的 Parser 阶段,能带来性能上的提升。

WebAssembly 是被设计成 JavaScript 的完善与补充,而不是一个替代品。

虚拟 DOM (Virtual DOM)

将 DOM 状态生成一份虚拟的树结构,在需要更新的时候,使用差异(diff)算法来尽可能减少调用 DOM 的相关方法(因为性能不好),通常缓存没有变更的组件来避免重新渲染。 Virtual DOM 在重复渲染大量数据的时候你能明显感觉到提升,但并不意味着任何场景用了就会带来性能的飞跃,这一点在后文有做简单的测试。

手动斜眼( ﹁ ﹁ ) ,WebAssembly 和 Virtual DOM 都能提升性能,那用 Rust 的 vdom 库来渲染咱的待办列表岂不是快(♂)上加快(♂)。


React vs 原生

在使用 Rust 编写之前,为了打消咱的好奇,决定先测试一下咱们常用的 React(没错,它也是使用了 Virtual DOM 并还带 了一把)和原生的差距。

测试目的

原生 JS 和 采用了 vdom 的框架渲染大量数据的时间上的差距。

测试方式

测试方式为渲染十万个待办列表,然后统计点击第一次随机消灭到完成渲染所需要的时间。

Round 1

使用 create-react-app 创建一个模板项目,修改 App.js:

function App() {
  const size = 100 * 100 * 10;
  const [todoList, setTodoList] = useState(
    Array(size)
      .fill(1)
      .map((_, index) => `待办事项${index}`)
  );

  function onDelete() {
    setTodoList(todoList.filter(() => Math.random() > 0.5));
  }

  return (
    <div className="App">
      <button onClick={onDelete}>随机消灭 Todo</button>
      <ul>
        {todoList.map((todo, index) => (
          <li key={index}> {todo} </li>
        ))}
      </ul>
    </div>
  );
}

原生则使用拼接 DOM 字符串然后使用 innerHTML 的插入方式。

渲染结果(都挺慢的,转半天 ):


我们打开 Chrome 的 Performance 工具,对两个页面进行性能分析,经过多次记录随机消灭(一响指的事儿)的时长,得到以下结果:



实际上在简单渲染文本的情况下,两者都是 4000ms 左右 (Loading + Scripting + Painting),没有太大差距。也验证了并不是使用了虚拟 DOM 就起飞了~。

Round 2

我们尝试给两者都加点料,给文本前面加个小图片(不带颜色的)。

<li key={index}>
    <img
        src="https://avatars0.githubusercontent.com/u/33797740?s=48&v=4"
        alt=""
    />
    {todo}
</li>

渲染结果(这次加载的更慢了 ):


测试结果:


多次记录后发现,在同一环境下,React 稳定在 6000ms 上下,而原生的时长绝大多数时候都超过了 8000ms。

以上是我触手可测的两种方式,接下来使用 Rust + Dodrio 画上页面来测试。


用 Rust 来画页面

本文的重点从这里开始 ,且假装大家已经有了 Rust 的相关环境以及了解过 Rust 的基本概念。

直接导入 .rs 文件

Rust 可以编译成 WebAssembly 让 Javascript 调用这我们知道了,可有没有更方便一点的呢,最好是直接导入 .rs(Rust的后缀) 文件。

你别说,在前端生态如此繁荣、各种工具链花样百出的今天,还真有。

Parcel 就是其中之一,它除了能帮我们处理 wasm 文件,也可以处理直接导入的 rs 文件。

贴一段它官网的例子:

// 同步导入
import { add } from './add.rs'
console.log(add(2, 3))
// 异步导入
const { add } = await import('./add.rs')
console.log(add(2, 3))

// 在 Rust 侧,你只需要确保函数名不是 mangled 而且函数是 public 的即可。

// #[no_mangle]
// pub fn add(a: i32, b: i32) -> i32 {
//   return a + b
// }

还,还有更方便的吗?我懒。

rustwasm 提供了一个 rust-parcel-template ,可以试试。

Parcel 很好,但我选择 Webpack

Rust + WebAssembly + Webpack = ❤️

避免偏题,我们直接使用 rust-webpack-template 生成模板项目。

执行 npm init rust-webpack my-app ,用 VSCode 打开项目。 目录结构如下:


我们主要关注四个文件:

  • js/index,js

里面只有一行 import("../pkg/index.js").catch(console.error); ,用来导入被插件处理过的 WebAssembly。

  • src/lib.rs

Rust 代码的入口,我们也将把逻辑写在这个文件。

  • Cargo.toml

Rust 的包管理文件,作用和楼下的那货相当。

  • package.json

我们在 devDependencies 中能找到 @wasm-tool/wasm-pack-plugin,配合上 webpack-dev-server,让我们修改 Rust 代码的时候也能像写网页一样,享受到热更新的服务。

使用 Dodrio

添加依赖

Cargo.toml 中新增:

[dependencies]
dodrio = "0.1.0"

# 模板的只声明了 "console"
# 而我们还需要用到其他的
[dependencies.web-sys]
features = [
  "Document",
  "HtmlElement",
  "Node",
  "Window"
]

src/lib.rs 中使用依赖:

use dodrio::{builder::*, bumpalo, Node, Render};

定义 Todo

定义待办事项的结构体,仅需要一个标题即可。

struct Todo {
    title: String,
}

impl Todo {
    pub fn new(title: String) -> Self {
        Todo { title: title }
    }
}

impl Render for Todo {
    fn render<'a, 'bump>(&'a self, bump: &'bump bumpalo::Bump) -> Node<'bump>
    where
        'a: 'bump,
    {
        // 这一层层的包裹,似曾相识  
        li(bump)
            .children([
                img(bump)
                    .attr(
                        "src",
                        "https://avatars0.githubusercontent.com/u/33797740?s=48&v=4",
                    )
                    .finish(),
                text(bumpalo::format!(in bump, "{}", self.title).into_bump_str()),
            ])
            .finish()
    }
}

Rust 调用 Javascript

为了演示 Rust 调用 Javascript 的方法,我们将过滤需要使用的判断随机数的函数放到 JavaScript 中编写,在 Rust 中导入:

// src/lib.rs

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = rustFns)]
    pub fn is_del() -> bool;
}
wasm_bindgen 是 Rust 官方的一个包,提供 wasm 和 JavaScript 上层交互的能力 文档:github.com/rustwasm/was
// js/index.js

// 简单粗暴的挂在 window 下
window.rustFns = {
  is_del: () => Math.random() > 0.5
};

import("../pkg/index.js").catch(console.error);

定义 TodoList

struct TodoList {
    list: Vec<Todo>,
}

impl TodoList {
    // 声明一个供按钮回调使用的函数
    // .filter 中就调用了来自 JavaScript 的方法
    pub fn set_list(&mut self) {
        let the_list: Vec<Todo> = self
            .list
            .drain(..)
            .into_iter()
            .filter(|_todo| is_del())
            .collect::<Vec<_>>();

        self.list = the_list;
    }
}

impl Render for TodoList {
    fn render<'a, 'bump>(&'a self, bump: &'bump bumpalo::Bump) -> Node<'bump>
    where
        'a: 'bump,
    {
        use dodrio::bumpalo::collections::Vec;

        // 定义一个Vec
        let mut list = Vec::with_capacity_in(self.list.len(), bump);

        // render 所有的 todo
        list.extend(self.list.iter().map(|t| t.render(bump)));

        div(bump)
            .children([
                // 声明一个按钮
                button(bump)
                    .on("click", |root, vdom, _event| {
                        let todos = root.unwrap_mut::<TodoList>();
                        todos.set_list();
                        // 在下一帧重新渲染
                        vdom.schedule_render();
                    })
                    .children([text("随机消灭 Todo")])
                    .finish(),
                // 整一个 ul 再把 所有的 todo 放进去
                ul(bump).children(list).finish(),
            ])
            .finish()
    }
}

以上就定义好了我们需要用到的所有内容,部分代码参考 Dodrio 示例 ,尽可能的简单地描绘出我们需要的结构。

启动函数

// 类似与很多语言(除了 js)的主函数
#[wasm_bindgen(start)]
pub fn main_js() {
    let window = web_sys::window().unwrap();
    let document = window.document().unwrap();
    let body = document.body().unwrap();

    // 生成十万个待办
    let vec: Vec<Todo> = (1..100 * 100 * 10)
        .map(|num| {
            let mut title = String::from("待办事项");
            title.push_str(&num.to_string());
            Todo::new(title)
        })
        .collect();

    // 绑定到 body 上
    let vdom = dodrio::Vdom::new(&body, TodoList { list: vec });

    // 一直运行虚拟 DOM 及其侦听器,不会卸载它
    vdom.forget()
}

不出意外,进项目的根目录起 yarn start 就能跑啦。

测试性能

伴随着的激动的心,颤抖的手,点下了 Record。

但我马上又取消了操作。。

发现了 Ctrl + E 的快捷键怎么能不用它,重来!

聚焦开发者工具 - Ctrl + E - 点击随机消灭 todo 按钮 ~

一气呵成,熟练的宛如老手 ~


1, 2, 3, 4, 5 ...

经过多次测试。


Scripting + Rendering + Painiting 总时长平均在四秒以上; 对比之前的两种方式:

  • (Rust + Dodrio): 4000ms - 5000ms
  • React: 6000ms 左右
  • 原生: 8000ms 以上

ps: 测试结果因机而已,只适用于做一个浅显对比。

总结

Rust 还是挺有意思的, 无论是对于前端的友好性或者像所有权(ownership)这种让 Rust 无需垃圾回收(garbage collector)的特性,都是吸引我的点,也推荐前端小伙伴们去了解一哈。 好在使用 Dodrio 的过程中不涉及到很多的 Rust 语法(否则就没这篇文章了),顺利完成了这次测试并实际体验了一下 Rust In Web,哪儿不对还请看官多多担待,告辞。

另外,Dodrio 的作者温馨提醒到:

I reiterate that Dodrio is in a very experimental state. It probably has bugs, and no one is using it in production.

再告辞...

Ref


Makeflow (makeflow.com) 是以流程为核心的项目管理工具,让研发团队能更容易地落地和改善工作流,提升任务流转体验及效率。如果你正为了研发流程停留在口头讨论、无法落实而烦恼,Makeflow 或许是一个可以尝试的选项。如果你认为 Makeflow 缺少了某些必要的特性,或者有任何建议反馈,可以通过 GitHub语雀或页面客服与我们建立连接。

编辑于 2020-03-10

文章被以下专栏收录