子节点投影(Projecting Children)

在构建组件时,你可能会遇到需要通过多层组件“投影”子节点的情况。

问题

考虑以下代码:

pub fn NestedShow<F, IV>(fallback: F, children: ChildrenFn) -> impl IntoView
where
    F: Fn() -> IV + Send + Sync + 'static,
    IV: IntoView + 'static,
{
    view! {
        <Show
            when=|| todo!()
            fallback=|| ()
        >
            <Show
                when=|| todo!()
                fallback=fallback
            >
                {children()}
            </Show>
        </Show>
    }
}

这段代码很直观:如果内部条件为 true,我们希望显示 children;如果不是,则显示 fallback。如果外部条件为 false,我们渲染 ()(即什么都不显示)。

换句话说,我们希望将 <NestedShow/> 的子节点通过外部的 <Show/> 组件传递,成为内部 <Show/> 的子节点。这就是所谓的“投影”。

但是,这段代码无法编译。

error[E0525]: expected a closure that implements the `Fn` trait, but this closure only implements `FnOnce`

每个 <Show/> 需要多次构造其 children。第一次构造外部 <Show/> 的子节点时,它会移动 fallbackchildren 到内部 <Show/> 的调用中,但之后这些值无法用于构造未来的外部 <Show/> 子节点。

细节

如果你只想知道解决方案,可以直接跳到下一节。

为了真正理解这里的问题,可以查看 view 宏的展开形式。以下是简化版:

Show(
    ShowProps::builder()
        .when(|| todo!())
        .fallback(|| ())
        .children({
            // 这里将 `children` 和 `fallback` 移动到闭包中
            ::leptos::children::ToChildren::to_children(move || {
                Show(
                    ShowProps::builder()
                        .when(|| todo!())
                        // 这里消费了 `fallback`
                        .fallback(fallback)
                        .children({
                            // 这里捕获了 `children`
                            ::leptos::children::ToChildren::to_children(
                                move || children(),
                            )
                        })
                        .build(),
                )
            })
        })
        .build(),
)

所有组件都拥有它们的 props;因此在这种情况下 <Show/> 无法调用,因为它仅捕获了 fallbackchildren 的引用。

解决方案

<Suspense/><Show/> 都接受 ChildrenFn,即它们的 children 应该实现 Fn 类型,这样它们可以通过不可变引用多次调用。这意味着我们不需要拥有 childrenfallback 的所有权,只需要能够传递 'static 引用即可。

我们可以通过使用 StoredValue 原语解决这个问题。StoredValue 本质上是将值存储在反应式系统中,将所有权交给框架,并提供类似信号的 Copy'static 引用,供我们通过特定方法访问或修改。

以下是改进后的代码:

pub fn NestedShow<F, IV>(fallback: F, children: ChildrenFn) -> impl IntoView
where
    F: Fn() -> IV + Send + Sync + 'static,
    IV: IntoView + 'static,
{
    let fallback = StoredValue::new(fallback);
    let children = StoredValue::new(children);

    view! {
        <Show
            when=|| todo!()
            fallback=|| ()
        >
            <Show
                when=move || todo!()
                fallback=move || fallback.read_value()()
            >
                {children.read_value()()}
            </Show>
        </Show>
    }
}

在顶层,我们将 fallbackchildren 存储在 NestedShow 的反应式作用域中。然后,我们可以将这些引用传递到 <Show/> 组件的其他层中,并在那里调用它们。

最后一点

这种方法之所以有效,是因为 <Show/> 只需要它们子节点的不可变引用(.read_value() 可以提供),而不需要所有权。

在某些情况下,你可能需要通过一个接收 ChildrenFn 的函数投影具有所有权的 props,并且该函数需要被多次调用。这种情况下,你可以使用 view 宏中的 clone: 帮助方法。

以下是一个示例:

#[component]
pub fn App() -> impl IntoView {
    let name = "Alice".to_string();
    view! {
        <Outer>
            <Inner>
                <Inmost name=name.clone()/>
            </Inner>
        </Outer>
    }
}

#[component]
pub fn Outer(children: ChildrenFn) -> impl IntoView {
    children()
}

#[component]
pub fn Inner(children: ChildrenFn) -> impl IntoView {
    children()
}

#[component]
pub fn Inmost(name: String) -> impl IntoView {
    view! {
        <p>{name}</p>
    }
}

即使使用了 name=name.clone(),也会报错:

cannot move out of `name`, a captured variable in an `Fn` closure

这是因为它通过多层子节点传递,而子节点需要多次运行,并且没有明显的方法将其克隆到子节点中。

这种情况下,clone: 语法非常有用。调用 clone:name 会在将 name 移动到 <Inner/> 的子节点之前先克隆它,从而解决所有权问题。

view! {
    <Outer>
        <Inner clone:name>
            <Inmost name=name.clone()/>
        </Inner>
    </Outer>
}

由于 view 宏的复杂性,这类问题可能会让人难以理解或调试。但总体来说,总有解决方法。