在 2020 年用 Rust 写前端什么体验

10,725 阅读6分钟

Rust 语言是一门有趣的语言,在学习 Rust 后我想找点东西实践下,然后就发现了由 Rust 编写可以用 Rust 编写网页的 Yew 框架。由于对 Rust 相关工具链的不熟悉,我感觉自己回到了刚刚接触 React + Webpack 的时候,一脸懵逼,什么都没有头绪的样子。那个时候,我写了个 Todo 应用来帮助自己熟悉工具链,现在当然是继续重复作为菜鸟时做的事情,写一个 Todo 应用来熟悉工具链!

介绍

什么是 Yew?

Yew 是一个设计先进的 Rust 框架,目的是使用 WebAssembly 来创建多线程的前端 web 应用。

  • 基于组件的框架,可以轻松的创建交互式 UI。拥有 React 或 Elm等框架经验的开发人员在使用 Yew 时会感到得心应手。
  • 高性能 ,前端开发者可以轻易的将工作分流至后端来减少 DOM API 的调用,从而达到异常出色的性能。
  • 支持与 JavaScript 交互 ,允许开发者使用 NPM 包,并与现有的 JavaScript 应用程序结合。

应用外观

我们先来看下应用外观,为了能专注于 使用 Rust 写前端 这一目的,我直接复用了了《 I created the exact same app in React and Vue. Here are the differences 》 的样式代码

目录结构

├── Cargo.lock
├── Cargo.toml
├── README.md
├── docs // 编译后后的文件
|  ├── README.md
|  ├── assets
|  |  ├── rust.svg
|  |  ├── style // 应用 css
|  |  └── yew.svg
|  ├── index.html
|  ├── package.json // 编译产物
|  ├── wasm.d.ts // 编译产物
|  ├── wasm.js // 编译产物
|  ├── wasm_bg.wasm // 编译产物
|  └── wasm_bg.wasm.d.ts // 编译产物
├── src // 应用代码
|  ├── app.rs // 应用入口
|  ├── components // 组件
|  |  ├── mod.rs
|  |  ├── todo
|  |  |  └── mod.rs // Todo 组件
|  |  └── todo_item
|  |     └── mod.rs // TodoItem 组件
|  ├── lib.rs
|  ├── model.rs // 类型存放处
|  └── utils.rs // 一些工具函数
└── tests
   └── web.rs

一个 Yew 组件

一个Yew组件

这就是一个 Yew 组件的样子,我们类比着讲述下现代前端框架的基础概念对应到 Yew 上应该是什么样子

State 存储在哪里?

pub struct Todo {
  link: ComponentLink<Self>,
  list: Vec<model::TodoItem>,
  input: String,
  show_error: bool,
}

impl Component for TodoItem {   
// ... codes
}

你可以近似的认为这声明了 TodoItem 类,带有 link,list,input,show_error 属性,但它还不是一个 Yew 组件!因为它还没有 " ... extends React.Component ",必须加上下方的 impl Component for TodoItem 才能算一个组件

如果要类比成 React 组件,就是这样

class Todo extends React.Component {
	constructor(props) {
		super(props)
		this.state = {
			list: [],
			input: '',
			show_error: false,
		};
	}
}

一个 Yew 的组件的 state 存储在自身,或者近似的认为直接挂载 this 上,而非 this.state 上。

  • .link 属性存储着 ComponentLink ,它是组件和 Yew 沟通的桥梁,我们需要它来触发组件的重渲染,其作用和 React 的 this.setState() 很相似
  • .list 存储着我们需要渲染出来的 todo 列表的数据,它的类型可以近似的认为是 JavaScript 中的 Array
  • .input 存储着我们在 <input /> 框中的输入的内容
  • .show_error 用来控制是否显示错误信息

如何接受父组件的数据

#[derive(Properties, Clone)]
pub struct TodoItemProps {
    pub item: model::TodoItem,
    pub delete: Callback<i32>,
}

pub struct TodoItem {
    link: ComponentLink<Self>,
    props: TodoItemProps,
}

impl Component for TodoItem {   
    // ... codes
}

和 React 一样,Yew 也是单向数据流。通过声明一个新的 Props Struct 类型,并将其赋予到组件上,你就可以通过 .props 访问父组件传递来的数据。上述代码的 Props 声明意味着 TodoItem 这个组件接受 TodoItem 类型的数据和一个回调函数。

并且,由于 Rust 是 强类型 语言,因此在编译期就会检查你传递数据的类型是否 吻合 ,如果不吻合就无法通过编译,提前发现错误。

render 函数

impl Component for TodoItem {   
		// ... codes
    fn view(&self) -> Html {
        // render 函数
        html! {
            <div class="ToDoItem">
                <p class="ToDoItem-Text">{&self.props.item.text}</p>
                <button 
                    onclick={self.link.callback(|_| Msg::OnClick)}
                    class="ToDoItem-Delete"
                >
                { "-" } 
                </button>
            </div>
        }
    }
		// ... codes
}

对应着 React 中 render() 概念的是 view() 方法,这里最值得一提的是你看到的上述代码是 完全符合 Rust 语法规则 的!Rust 拥有强大的宏机制,可以在编译期动态的生成代码。通过利用宏,可以很容易在 Rust 中实现 DSL,而不需要 babel 这样的转译工具

如何更新组件状态

pub enum Msg {
  UpdateInput(String),
  AddTodoItem,
  DeleteItem(i32),
  None,
}

Msg 是一个枚举类型,起到的作用和 Redux 的 Action 很相似,你可以近似的认为上述代码等于以下代码

const updateInputAction = {
	type: 'UpdateInput',
	payload: str,
};
const addTodoItem = {
	type: 'AddTodoItem',
};
const deleteItem = {
	type: 'DeleteItem',
	payload: id,
};

接受 Msg 的是 update() 方法,这个方法很像 shouldComponentUpdate()reducer() 的结合体,我们在 update() 中进行副作用

impl Component for Todo {
  type Message = Msg;
	// ... codes
  fn update(&mut self, msg: Self::Message) -> ShouldRender {
    match msg {
      Msg::UpdateInput(input) => {
        self.input = input;
        true
      },
      Msg::AddTodoItem => {
        if self.input.trim().len() == 0 {
          self.show_error = true;
        } else {
          self.list.push(model::TodoItem {
            id: self.list.len() as i32,
            text: self.input.clone(),
          });
        }
        self.input = String::new();
        true
      },
      Msg::DeleteItem(id) => {
        self.list = self.list.clone().into_iter().filter(|item| item.id != id).collect();
        true
      }
      _ => true
    }
  }
	// ... codes  
}

我们在 <button onclick=self.link.callback(|_| Msg::AddTodoItem) /> 上绑定了 onclick 事件处理的函数,当按钮被点击时,处理函数的返回值会 Msg::AddTodoItem 被发送到 update() 方法,我们根据传入的 Msg 类型来修改自身状态或调用父组件传递的回调函数,返回布尔值来告诉 Yew 是否需要重新渲

如果返回 true , Yew 会去重新执行 view() 函数,因为我们已经修改了自身状态,所以此时 view() 会根据新的状态返回相应的虚拟 DOM 树,就完成数据驱动视图的闭环

Rust 的枚举类型非常富有表现力,再配合上其强大模式匹配功能,相当于你获得了一个绝对类型安全的 Redux。这里顺便说下,通过配合 Typescript ,Redux 也能做到类型安全,但是其写法比较复杂,需要做 类型体操 :),有兴趣的同学可以看看我写的一篇 《如何利用 Typescript 的类型编程自动推断 Redux reducer 的类型

父子间如何通信

#[derive(Properties, Clone)]
pub struct TodoItemProps {
    pub item: model::TodoItem,
    pub delete: Callback<i32>,
}

impl Component for TodoItem {   
		// ... codes
    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        // 单单针对 state 变化的 shouldComponentUpdate
        // 同时起到一个局部 reducer 的作用
        match msg {
            Msg::OnClick => {
                let id = self.props.item.id.clone();
                self.props.delete.emit(id); // 触发回调
                return  false;
            },
        }
        true
    }
		// ... codes
}

和 React 很相似,父子间的通信也是通过回调函数进行的。 <Todo /><TodoItem delete={self.link.callback(|id: i32| Msg::DeleteItem(id))} item={item} />delete 属性上设置了一个闭包函数,当这个函数被子组件执行的时候其返回值 Msg::DeleteItem(id) 会发送到 todo 的 update() 函数,进而更新自身状态,完成通信

问题

Yew 对于 CSS 文件的引入还没有很好的解决方法,因为缺少类似 Webpack 的打包工具,你不能像写 JavaScript 一样,不能简单的通过一句 import 'index.css' 解决。这个项目中的组件 CSS 文件是我在 index.html 文件中手动引入的,我觉得这对于组件化开发是不可接受的

而其他的,诸如异步组件,tree shaking 等现代前端已经习以为常的东西就更是缺少了,这导致 Yew 暂时只能停留在玩具级别,没法上生产环境。不过未来还是值得畅想的,特别是在面向 wasm 的 DOM API 出来后

最后

你可能会很疑惑用 Rust 写网页的意义在哪里,我想了好多理由,但最后觉得还是这句话最有说服力。

"因为山就在那里!"