Leptos: 基于 Rust 的 Modern Web 框架

avatar
前端工程师 @字节跳动,公众号: zoomdong

前言的前言

前端开发目前也有不少基础的工具链在基于 Rust 重写,例如 SWC(主要用于 JS Compiler 场景,目前使用比较多的场景是替代 Babel)、Oxc(JS / TS 的编译器套件,它提供了更多的能力,例如 parser、linter、formatter,目前使用较多的还是 linter 场景,例如取代 eslint),还有笔者自己组开源的 Rspack(Rust 重写 Webpack,伟大的项目,无需多言)。

当然前端两大网红方向也开始去尝试在一些常见的场景开始用到 Rust 了,例如 Vue 官方正在用 Rust 去重写 Rollup,这个新的 Rust bundler 叫 Rolldown。React 官方新推出的实验特性 React Compiler,背后也有一个基于 Rust 的开发版本。这个如果后续有空,笔者会去介绍一下 React Compiler 的 Rust 架构。

可能 Rust 重写这个话题在2、3年前还算得上比较新奇的消息,但在现在这个时间节点来看也属于是屡见不鲜了。

不过虽然前面铺垫了这么多,但这篇文章要介绍的这个框架却和前面这些工具链并没有太大的交集,下面开始进入正题。

前言

Leptos 是个基于 Rust 的 Web 框架(leptos.dev/),作为前端开发者,可… React、Solid 这一类的前端框架。不过 Leptos 是基于 Rust,而这些框架基本都基于 JS / TS。如果你是个前端开发者,我觉得 Leptos 或许是个不错的学习和上手 Rust 的框架,因为它和目前市面上流行的 SPA 框架都有很多相似之处,而且 Leptos 框架对 Web 开发者比较友好,很多写法都比较类似 JS 框架。

不过从设计原理上看,Leptos 的设计哲学和 SolidJS 还是比较接近的,两者都通过 Signal 来提供响应式的数据交互方式,并且同样不通过 v-dom 去进行数据更新。

在 benchmark 上,它对比诸位前端框架还是有一些的优势:

benchmark 数据来源: krausest.github.io/js-framewor…

Leptos 中的一些概念例如响应式数据、JSX、Component 等这对于一些前端框架来说也是一些比较熟悉的话题,当这些东西出现在一个 Rust 写的框架的时候,还是比较新鲜的。

下面笔者将介绍一下如何快速开始一个 Leptos 项目,以及 Leptos 这个框架本身的一些概念性的内容,本文不会讲解一些特别难理解的底层原理内容,是一篇介绍性文章。

快速开始

因为 Leptos 是个 Rust 框架,因此需要保证我们有个 Rust 的开发环境,Leptos 框架推荐使用 Nightly 版本的 Rust,因为它们响应式数据的简便写法(settergetter )的一些写法需要 Nightly Rust 来提供。

同时需要安装 Trunk,它是个 Rust 开发的 用于构建 Wasm Web 应用的 Bundler。Leptos 在开发过程中的 DevServer 也是由 Trunk 来提供。

cargo install trunk

然后我们初始化一个 Rust 项目,给项目安装上 leptos 依赖:

cargo init leptos-demo && cd -
cargo add leptos --features=csr, nightly

然后会得到如下的目录结构:

├── Cargo.lock
├── Cargo.toml
├── src
│ └── main.rs

我们需要自己给 leptos 创建一个 html 模版文件,因此 Trunk 构建的时候需要一个单独的 html 文件去实现所有的资源的构建和打包,index.html 放在根目录下就行:

<!DOCTYPE html>
<html>
  <head></head>
  <body></body>
</html>

在开始使用 leptos 之前,我们还需要安装一个 rust 的 wasm 库,因为前面有提到,Leptos 是基于 wasm web 产物,因此我们还需要 Rust 编译成 wasm 的 target:

rustup target add wasm32-unknown-unknown

在处理完这些之后,项目目录如下:

.
├── Cargo.lock
├── Cargo.toml
├── index.html
├── src
│   └── main.rs

然后你可以在 main.rs 中编写一段简单的代码:

use leptos::*;

fn main() {
  mount_to_body(|| view! { <p>"Hello, world"</p> })
}

直接在项目根目录下运行: trunk serve --open ,Trunk 会自动编译项目然后在浏览器中自动打开对应的页面(类似于 Webpack devServer)。

那么从这里开始之后,一个正常的 Leptos 应用就运行起来了。下面笔者会针对 Leptos 中的一些概念进行介绍。

DSL

Leptos 采用的是一种 JSX-Like 的语法来作为 Template,这对 React / SolidJS 的开发者来说,是一种比较熟悉的模版语法了。我们以一个 Counter Demo 来作为例子,在 Lepots 中写法大概如下:

// lib.rs
use leptos::*;

#[component]
pub fn Counter(
  initial_value: i32,
  step: i32,
) -> impl IntoView {
  let (value, set_value) = create_signal(initial_value);
  
  view! {
    <div>
      <button on:click=move |_| set_value(0)>"Clear"</button>
      <button on:click=move |_| set_value.update(|value| *value -= step)>
        "-1"
      </button>
      <span>"Value: " {value} "!"</span>
      <button on:click=move |_| set_value.update(|value| *value += step)>
        "+1"
      </button>
    </div>
  }
}

// main.rs
use counter::Counter;
use leptos::*;
pub fn main() {
  mount_to_body(|| {
    view! {
      <Counter initial_value=0 step=1 />
    }
  })
}

这里我们可以看到 view 这个宏(编译宏对于 Rust 语言来说也是一种 DSL...)中的 JSX 代码,这里应该叫 RSX(JSX-like 的表达形式,但基于 Rust)。最早版本的时候,leptos 用到了一个社区的 rust crates 叫 syn-rsx 去做 RSX 语法的 Parser 工作,但后来可能是因为 Leptos 需要支持 Hot Reload 的能力,syn 这个库提供的数据结构不方便 leptos 去做状态存储,后来改成了自己实现的一套 DSL Parser。

从前端开发者视角来看,这种写法某种程度上和一些 JSX 框架相似度还是比较类似的。

Component

Leptos 提供了一个 #[component] 的编译宏去帮助开发者在一个 Leptos 应用中去写一些用户自定义的组件,这个宏主要用于帮助标注哪些函数在 Leptos 中是要被当成组件来处理的,同时被标注的函数需要具备以下特征:

function App() -> impl IntoView 
  • 和普通函数一样,可以传0到多个参数(类型没具体限制)
  • 返回值类型要求是 impl IntoView ,这个类型可以理解为一个基类,这个类型需要通过 Lepots 框架本身提供的 view 宏来做返回

Leptos 的组件类似于 SolidJS 的组件,它只会执行一次,不像 React 的 Render 函数,会在 diff 过程中重复执行多次(Leptos 和 Solid 一样,基于响应式数据追踪,没有 Diff)。

Leptos 通过 view 这个编译宏来让用户在组件中写 JSX-like 的结构:

let (count, set_count) = create_signal(0);
view! {
  <button
    on:click=move |_| { set_count(3) }
  >
    "Click: "
    {move || count()}
  </button>
}

在 Leptos 中,会写到很多的 Rust 闭包函数代码,例如上述示例中的 move |_| {set_count(3)} 以及 {move || count()} ,这里 on:click 后面的函数闭包就是正常的交互逻辑执行。

而后面展示的 count 相关的闭包,在 Leptos 框架则是表示这个数据是响应式的,前面有提到因为 Leptos 本身的组件函数体是只会渲染执行一次,只有当这里 count 的写法是闭包函数的时候,leptos 框架才会去追踪 count 这个值本身的更新。

如果这里直接去去 count 这个值的话,那么它就只能渲染一次,并且后续当 count 本身发生变化的时候,它也不会更新:

view! {
  <button>"Click: " {count()}</button>
}

不过如果你使用的是 Rust Nightly 版本,那么这里的闭包函数可以直接简写成:

view! {
  <button>{count}</button>
}

这里 count()count 两者做的事情是非常不一样的,count 传递的是个函数,而 count() 传递的是个 i32 类型的值,前者具备数据的响应式,而后者则不具备。

关于 Leptos 的响应式数据后面笔者在响应式数据一节再做介绍。

Component Props

Leptos 的 Component Props 基本和正常的 JSX 框架类似,最多就是在 Rust 语言上的写法有些许不同:

// comp.rs
#[component]
fn ProgressBar(progress: ReadSignal<i32>) -> impl IntoView {
  view! {
    <progress max="50" value=count />
  }
}

// main.rs
#[component]
fn App() -> impl IntoView {
  let (count, set_count) = create_signal(0);
  view! {
    <ProgressBar progress=count/>
  }
}

在 leptos 中,组件也可以像 React 一样进行父子间的通信,Leptos 框架提供了四种方式:

  1. 传一个 WriteSignal
  2. 函数回调
  3. 事件监听
  4. Context API

前两种方式其实比较类似,举个例子,假如我们想在子组件中更新父组件的数据:

#[component]
pub fn App() -> impl IntoView {
  let (tog, set_tog) = create_signal(false);
  view! {
    <p>{tog}</p>
    <ButtonA setter=set_tog />
  }
}

#[component]
pub fn ButtonA(setter: WriteSignal<bool>) -> impl IntoView {
  view! {
    <button
      on:click=move |_| setter.update(|value| *value = !*value)
    >
      "Tog"
    </button>
  }
}

我们可以像这样传个可写的 Signal 方法到子组件,然后在子组件中触发这个 Signal 的更新,从而更新到父组件中的 tog 值。

或者我们也可以改成在父组件中直接传这个 WriteSignal 方法,子组件去触发这个方法的回调:

<ButtonB on_click=move |_| set_tog.update(|value| *value = !*value)/>

这样子组件的函数签名就应该为:

#[component]
pub fn ButtonB(#[prop(into)] on_click: Callback<MouseEvent>) -> impl IntoView {
  view! {
    <button on:click=on_click /> 
  }
}

对于 Event Listener 和 Context ,Leptos 则是分别提供了不同的框架侧 API,Evenet Linster 规定了在组件上添加一个 on: 开头的监听器,可以直接映射到原生的 DOM API 事件,例如我们在上面的 ButtonB 中添加 on:click 那么这个组件中的 button element 则会监听到这个点击事件。这种方式只对一些简单的组件写法比较好用,如果追求复杂,那还是用函数回调。

Context 和 React 解决问题的场景比较类似,都是来解决复杂嵌套组件中的组件通信问题,Leptos 提供了两个 Context API,provide_contextuse_context 来创建 Context 和对应的组件中消费 Context,这里不做过介绍,感兴趣可以直接参考 Leptos 文档。

Component Children

Leptos 中有两种方式来将一个组件传递到另一个组件中:

  • render props : 这种方式和 React 比较类似,传一个函数返回值是 view 的 props 就行
  • 通过 children prop,children 是个特殊的组件属性,专门用于处理这种组件传递的情况

举个组件的例子:

#[component]
pub fn TakesChildren<F, IV> (
  render_prop: F,
  children: Children,
) -> impl IntoView 
where 
  F: Fn() -> IV,
  IV: IntoView 
{
  view! {
    <h2>"Render Prop"</h2>
    {render_prop()}
    
    <h2>"Children"</h2>
    {children()}
  }
}

那么我们可以直接这样调用组件:

view! {
  <TakesChildren render_prop=|view! {<p>"prop here"</p>}|>
    // 下面的内容会传给 children 
    "text"
    <span>"span"</span>
  </TakesChildren>
}

Reactivity

响应式数据这个在目前非常多的前端框架都有对应的概念,例如 Vue、Solid、Svelte 包括 Angular 最近也开始做基于 Signal 的响应式数据更新。前段时间一个很火的 TC39 提案: github.com/tc39/propos… Signal 成为 JavaScript 语言标准。

Signal

Leptos 本身的响应式数据同样也是基于 Signal,在前面的一些例子中,已经用到了 Signal 相关的 API:

const (count, set_count) = create_signal(0);
set_count(1);

前面提到可以通过 count() 拿到对应的 count 值,实际上,这是 count.get() 的一种简便写法,set_count 同样也可以写成 set_count.set(1)

如果你想写类似于派生的 Signal 写法,即 signalA 变化的时候 signalB 也跟着一起变,可以用以下的写法:

let (sigA, set_sigA) = create_signal(1);
let sigB = move || sigA() * 2;
// or
let sigB = create_memo(move |_| sigA() * 2);

Effect

除了 create_signal 创建普通 Signal 的方法以及 create_memo 创建派生 Signal。Leptos 同经典前端框架一样,还有个一个用于处理副作用的的 Signal API create_effect

它用于处理在响应式数据发生变化后重新执行对应的代码片段。effectsignal 两者是相互依赖的,没有 effectsignal 就只能在响应式系统内部发生变化而无法和外部形成交互而被观察到。如果没有 signal ,那么 effect 就只会执行一次,而无法做到重复调用:

let (a, set_a) = create_signal(0);
let (b, set_b) = create_signal(0);

create_effect(move |_| {
  log::debug!("Value: {}", a());
});

create_effect 函数在被调用的时候会带有一个参数,这个参数会包括上次运行时的返回值。如果是第一次调用,那这个值是 None。

同时 effect 会对它内部使用到的 signal 进行自动依赖追踪。不同于 React 的 useEffect() hooks 需要自行传递依赖数组来决定重新执行 effect 的时机。Leptos 这里的机制同 Solid、Preact 等框架比较类似,并不需要显示的去传递依赖。

Styling

在前端框架中,Styling 其实是个不太和框架绑定的话题,像 Vue、Svelte 提供了一些内置的方式来把 CSS 样式限定在特定的组件里面,React、Solid 则是没有提供关于 CSS 的一些限定方案,而是依赖框架生态系统中的库去做一些样式方案(例如 styled-components)。

Leptos 框架本身同样也没有提供关于 CSS 相关的框架层修改方案,只是提供了一些基础的工具,允许用户来构建样式库。

TailwindCSS

这个是前端目前比较流行的一个 CSS 库,它本质上提供了一些全局的、比较基础的 class 集合(utility ),它可以让用户仅仅通过它本身定义好的 class 去实现页面的样式效果。TailwindCSS 提供了一个 CLI 用于扫描文件中的 Tailwind 相关的 class 名称然后帮你把对应的 CSS bundle 到产物里面去。

在 Leptos 中可以这样使用 TailwindCSS:

#[component]
fn Home() {} -> impl IntoView {
  let (count, set_count) = create_signal(0);
  
  view! {
    <main class="my-0 max-w-3xl">
      <button 
        class="px-5 py-3"
        on:click=move |_| set_count.update(|cnt| *cnt += 1)
      >
        {move || if count == 0 {
          "Click".to_string()
        } else {
          count().to_string()
        }}
      </button>
    </main>
  }
}

Leptos 在官方仓库的 example 中有关于 Leptos 集成 Tailwindcss 的示例 Case。同时 cargo-leptos 这个包本身也集成了一些 Tailwind 支持,你可以直接把这个当作 Tailwind CLI 的替代品来用。

除了这 TailwindCSS 这种方案外,Leptos 还提供了几种基于 Rust 开发的 CSS 库方案,例如 stylancestylers 等。

简单介绍一下,因为目前 leptos 这个框架本身有的样式方案比较少...

Stylers

Stylers(github.com/abishekatp/…) 是个编译时的 CSS 方案,它允许你在组件中写对应的 CSS 语法,然后在编译的时候把这些 CSS 提取到 CSS 文件中(有点像 Webpack 的 mini-css-extract-plugin),然后你可以把这些 CSS 文件导入到对应的产物中去,这样可以减少 leptos 应用程序的 WASM 产物的二进制文件大小。

写法可以参考:

use stylers:style;

#[component]
pub fn App() -> impl IntoView {
  let style_class = style! {
    "App",
    div.one { color: red; }
    
    div { border: 1px solid black; }
  };
  
  view! {
    class=style_class,
    <div class="one">
     test
    </div>
  }
}

Stylance

上面的 Stylers 提供了一种在 Rust 代码中编写内联 CSS 的方式,Stylance(github.com/basro/styla…) 则提供了一种在组件外写 CSS 文件,然后在组件导入对应的样式文件的方式:

import_style!(style, "app.css");

#[component]
fn HomePage() -> impl IntoView {
  view! {
    <div class=style::jumbotron />
  }
}

样式文件可以直接这么写:

.jumbotron {
  background: blue;
}

这种方式对目前 cargo-leptos 和 trunk 的热重载功能比较友好,因为在更新了 CSS 文件之后,它会立马在浏览器上进行更新(样式内容被打包进去了)。

其他的能力

除了前面提到的一些基础的 CSR 能力之外,Leptos 还建设了一些其他一些常见的框架能力,例如 Router ,leptos 专门提供了一个 leptos_routercrate 去实现路由的能力,这个在使用上和 react-router 也比较类似。

Leptos 同样也建设了 SSR 的能力,这块笔者没有做过多的研究,感兴趣的读者可以自行去学习参考: book.leptos.dev/ssr/index.h…

一些问题

在我学习过这个框架之后,是能在这个框架中看到很多 SolidJS 的影子的,当然作者也在 Leptos 的架构文档中承认了他们参考了一些 SolidJS 代码。例如 Leptos 的响应式设计、 leptos_router 都是直接 port 了 solid-router。如果只是作为 Rust 的学习开发使用,我会选择使用 Leptos。但如果是在逻辑和业务数据结构更复杂的使用场景,我觉得 Leptos 的开发成本不会如 Solid = =,但如果使用 leptos 可能会是一种不错的尝试。

另外还有一点 Leptos 因为产物最后是把 Rust 代码构建成了二进制的 WASM 的包,这类 WASM 应用有个比较大的问题在于对于 WASM 拆包要比对 JS 拆包要困难很多,目前基本没有比较好的实践去拆分和动态加载 Rust/wasm-bindgen 文件,这基本等同于一整个 wasm 文件要在应用的首屏加载的时候全加载出来(不过 WASM 格式的文件可以流式编译,它的编译速度要比 JS 同等体积下要快许多)。

目前也可以通过配置生成环境 relase 的方法去压缩 wasm 产物的体积,这种也有比较多的优化手段。

同时 Leptos 目前在生产环境使用的也比较少,并没有特别多的线上项目去为之背书,不过如果作为学习 Rust 的项目,那我觉得 Leptos 其实还算是个不错的选择。