0%

谈一谈Yew

距离上一篇写一个Yew版的UI库,已经一个多月了。一直在写Yew,有些认知已经变化了,总结一下。

类组件

结构式组件(Struct components),我更喜欢叫它类组件,以下是一个计数器的例子。

use yew::prelude::*;

pub enum Msg {
    Add,
    Sub
}

pub struct CounterPage {
    counter: i32
}

impl Component for CounterPage {
    type Message = Msg;
    type Properties = ();

    fn create(_ctx: &Context<Self>) -> Self {
        Self {
            counter:0
        }
    }

    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
        match msg {
            Msg::Add=>{
                self.counter += 1;
                true
            },
            Msg::Sub=>{
                self.counter -= 1;
                true
            }
        }
    }
    fn view(&self, ctx: &Context<Self>) -> Html {
        html! {
            <div>
                <h1>{ "Counter测试" }</h1>
                <div>{"counter: "}{self.counter}</div>
                <div>
	                <button onclick={ctx.link().callback(move|_|Msg::Add)}>{ "增加" }</button>
	                <button onclick={ctx.link().callback(move|_|Msg::Sub)}>{ "减少" }</button>
                </div>
            </div>
        }
    }
}

这种形式的重点是生命周期,上面的例子只使用了最常用的createupdateview这三个方法。下面是所有的生命周期的方法:

pub trait BaseComponent: Sized + 'static {
    /// The Component's Message.
    type Message: 'static;

    /// The Component's Properties.
    type Properties: Properties;

    /// Creates a component.
    fn create(ctx: &Context<Self>) -> Self;

    /// Updates component's internal state.
    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool;

    /// React to changes of component properties.
    fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool;

    /// Returns a component layout to be rendered.
    fn view(&self, ctx: &Context<Self>) -> HtmlResult;

    /// Notified after a layout is rendered.
    fn rendered(&mut self, ctx: &Context<Self>, first_render: bool);

    /// Notified before a component is destroyed.
    fn destroy(&mut self, ctx: &Context<Self>);

    /// Prepares the server-side state.
    fn prepare_state(&self) -> Option<String>;
}

不过Yew的类组件在状态更新方面跟React不一样。比如React:

// 初始化状态
this.state = {
	counter: 0
}
// 设置状态
this.setSatate({
	counter: 1
})

Yew主要依赖于消息,每次要更新状态先发送消息,然后在update方法里面处理消息并决定视图是否需要重新渲染。

fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
        match msg {
            Msg::Add=>{
                self.counter += 1;
                true
            },
            Msg::Sub=>{
                self.counter -= 1;
                true
            }
        }
}

这种模式实际上是受到了Elm这个语言的影响。这个是Elm官网上的Button例子

module Main exposing (..)

-- Press buttons to increment and decrement a counter.
--
-- Read how it works:
--   https://guide.elm-lang.org/architecture/buttons.html
--

import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)

-- MAIN
main =
  Browser.sandbox { init = init, update = update, view = view }

-- MODEL
type alias Model = Int

init : Model
init =
  0

-- UPDATE
type Msg
  = Increment
  | Decrement

update : Msg -> Model -> Model
update msg model =
  case msg of
    Increment ->
      model + 1

    Decrement ->
      model - 1

-- VIEW
view : Model -> Html Msg
view model =
  div []
    [ button [ onClick Decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick Increment ] [ text "+" ]
    ]

函数式组件

这个受到React的函数式组件的启发,上面计数器的例子可以这样写:

#[function_component]
pub fn CounterPage() -> Html {
    let counter = use_state(|| 0);

    let add_callback = {
        let counter_clone = counter.clone();
        Callback::from(move |_| {
            counter_clone.set(*counter_clone+1);
        })
    };

    let sub_callback = {
        let counter_clone = counter.clone();
        Callback::from(move |_| {
            counter_clone.set(*counter_clone-1);
        })
    };

    html! {
     <div>
        <h1>{ "Counter测试" }</h1>
        <div>{"counter: "}{*counter}</div>
        <div>
            <button onclick={add_callback}>{ "增加" }</button>
            <button onclick={sub_callback}>{ "减少" }</button>
        </div>
    </div>
    }
}

从形式上这个会比类组件更加的简洁。Yew函数式组件是一种完整构建组件的方法,主要是依赖Hooks,比如上面的use_state,还有很多比如use_node_refuse_effect_with_deps。如果你用过React的函数式组件,那么只要适当的参考下Yew的相关文档,很容易上手。不熟悉函数式组件的,建议阅读最新的React函数式文档,或者读一本书,比如我读的是《React学习手册第二版》。

如何选择?

Yew的官方文档这样描述:

You are currently reading about function components - the recommended way to write components when starting with Yew and when writing simple presentation logic.
There is a more advanced, but less accessible, way to write components - Struct components. They allow very detailed control, though you will not need that level of detail most of the time.

来源

这给人感觉类组件更强大,我一开始使用类组件,但是后来因为用到了上下文消息的监听的问题,见这个例子contexts,里面的发布消息,是用函数式组件写的,当时没有反应过来如何用类组件写发布消息,后来发现类组件也很简单,这是后话。后面我就有意开始使用函数式组件,发现用函数式组件似乎实现起来也问题不大,所以后面的组件又都是函数式组件写的。

函数式组件一开始我觉得,也有跟类组件类似的生命周期的东西,虽然在组织逻辑复杂的代码方面似乎没有类组件方便,后来我慢慢的使用类似过程式的方式,把一些相似的逻辑拆分到方法里面。如下:

#[function_component]
pub fn YELTag(props: &YELTagProps) -> Html {
    let on_click = {
        let on_click = props.on_click.clone();
        Callback::from(move |e| {
            on_click.emit(e);
        })
    };

    let on_click_clone = on_click.clone();

    html! {
        <span
            class={get_span_classes(props)}
            style={if props.color.is_empty() {"".to_string()} else {format!("background-color: {}", props.color)}}
            onclick={on_click}
            >
                {props.children.clone()}
                if props.closable {
                    <i class="el-tag__close el-icon-close" onclick={ on_click_clone }></i>
                }
            </span>
    }
}

fn get_span_classes(props: &YELTagProps) -> Vec<String> {
    let mut span_classes = vec!["el-tag".to_string()];
    if let Some(t) = props.tag_type.clone() {
        span_classes.push(format!("el-tag--{}", t));
    }

    if let Some(e) = props.effect.clone() {
        span_classes.push(format!("el-tag--{}", e));
    }

    if let Some(s) = props.size.clone() {
        span_classes.push(format!("el-tag--{}", s));
    }

    if props.hit {
        span_classes.push("is-hit".to_string());
    }

    span_classes
}

class单独通过fn get_span_classes(props: &YELTagProps) -> Vec<String>来处理,这样就可以避免函数组件内代码的膨胀。

但就在刚发布这篇文章后的第二天,我遇到了如下的问题。

#[function_component]
pub fn YELInputNumber(props: &YELInputNumberProps) -> Html {
    let on_decrease = {
        let value = props.value.clone();
        Callback::from(move |e| {
            let v = decrease(props, value);
            // log!("v:", v);
        })
    };
    let on_increase = { Callback::from(|e| {}) };
    html! {
        <div class={get_div_classes(props)}>
            if props.controls {
                <span
                    class="el-input-number__decrease"
                    role="button"
                    onclick={on_decrease}
                >
                <i class={format!("el-icon-{}", {
                    if get_controls_at_right(props) {
                        "arrow-down"
                    } else {
                        "minus"
                    }
                })}></i>
                </span>
                <span
                    class="el-input-number__increase"
                    role="button"
                    onclick={on_increase}
                >
                <i class={format!("el-icon-{}", {
                    if get_controls_at_right(props) {
                        "arrow-down"
                    } else {
                        "plus"
                    }
                })}></i>
                </span>
            }
            <YELInput value={get_display_value(props)}/>
        </div>
    }
}

fn decrease(props: &YELInputNumberProps, val: f64) -> f64 {
    let precision_factor = js_sys::Math::pow(10.0, get_num_precision(props));

    let num = precision_factor * val - precision_factor * props.step as f64;
    return to_precision(props, num, None);
}

fn to_precision(props: &YELInputNumberProps, num: f64, precision_option: Option<i32>) -> f64 {
    let precision = if let Some(p) = precision_option {
        p
    } else {
        get_num_precision(props) as i32
    };
    let a = js_sys::Math::round(num * js_sys::Math::pow(10.0, precision as f64));
    let b = js_sys::Math::pow(10.0, precision as f64);
    return a / b;
}

fn get_precision(val: f64) -> i32 {
    let value_string = val.to_string();
    let dot_position = value_string.find('.');
    if let Some(p) = dot_position {
        return (value_string.len() as i32) - (p as i32) - 1;
    }
    0
}

这段代码会报错,因为我在闭包中使用了props这个引用,这个引用不满足'Static的这个要求。

 |   pub fn YELInputNumber(props: &YELInputNumberProps) -> Html {
   |                         -----  - let's call the lifetime of this reference `'1`
   |                         |
   |                         `props` is a reference that is only valid in the function body
...
41 | /         Callback::from(move |e| {
42 | |             let v = decrease(props, value);
43 | |             // log!("v:", v);
44 | |         })
   | |          ^
   | |          |
   | |__________`props` escapes the function body here
   |            argument requires that `'1` must outlive `'static`

出错的代码:

#[function_component]
pub fn YELInputNumber(props: &YELInputNumberProps) -> Html {
    let on_decrease = {
        let value = props.value.clone();
        Callback::from(move |e| {
            let v = decrease(props, value);
            // log!("v:", v);
        })
    };

这之前使用闭包都是复制了props的值,没想道这次因为使用了props引用本身却出错了。因为decrease这个方法会调用其他的方法,也需要props中的参数。这个错误似乎无法解决,props这个参数我无法修改成'Static的,因为这个是Yew自己提供的。一种就是我彻底把所有后续用到的参数复制出来,通过decrease传给下面的调用,即使decrease本身不需要这些参数,但这样一来反而增加了复杂性。但如果改成类组件的话,就不存在上面的问题,每个类组件可以包含props属性,在实例方法中可以调用。

通过以上这个问题,打破了我原来觉得函数式组件是一种完备方法的结论,即使我努力去尝试适应,但函数式组件在复杂逻辑代码中有它的缺陷,这终究是Rust本身的决定,也是框架的问题。我相信JS版本没有这样的问题,所以React中函数式组件的可以大行其道有它的道理,但是在Yew中却受到语言特性的限制。

总结

类组件和函数式都可以使用,两者是不同思想,见上分析。虽然Yew的函数式组件看起来跟React的函数式组件很像,但还是有限制的,所以如果遇到问题,那么回到类组件的方式,至少可以解决问题。但为什么不直接使用类组件就得了,所以Yew团队也不知道怎么想的,创造了两种方式,但又不是很完美,反而增加了困扰,或许我根本不需要了解React。

Vue 用户

我就是Vue的用户,更准确的是应该Vue 2用户,Vue 3还没有来得及学习。Element UI 也是基于Vue2的组件库。一开始我觉得这只是JS和Rust的不同,很多时候我都是用Vue的思维在学习使用Yew这个框架,但渐渐发现Yew与Vue在设计思想上有很大不同,本质上这实际上是React与Vue的不同,尤其是React的函数式组件思想。

Rust vs JavaScript

动态性

在写Table这个组件时,JS版的Table中使用很多动态获取的东西,这种方式对于Rust而言就无法做到,首先是数组,一般都是固定类型,所以这里需要使用范型,范型问题不大。动态性,我想到了反射这样的东西,官方提供反射功能很有限,是一种编译时的反射。后来也找到了一些第三方的库,不过最后发现还是要结合范型,并不能动态的判断类型,进行值的转换。

另外比如Element UI组件的属性有些同时支持Number、String、Function,我一开始想到用范型,但是在实际使用中还需要动态的判断类型,这个Rust无法做到,目前这类属性的实现还没有好的方法。

确定性

使用Rust好处就是类型确定性,比如还是组件的属性,JS版使用的是字符串,这个实际上就有个验证的问题。但是如果用Rust写的话,可以直接使用枚举类型。

总结

用Rust写前端,大部分问题实际上就跟你用C#或者Java写前端一样,只不过Rust对比后者而言少了更多的动态性,另外就是它的所有权法则,早期的时候,非常不习惯,顿不顿就被编译器暂停了。Rust项目编译慢,这个确实是个问题,我的电脑都是32G的8核的配置。

题外话:从前端的角度,TypeScript确实弥补了JavaScript在类型上的问题,这也是很多大项目转向了TypeScript。