父子组件通信
Parent-Child Communication
你可以将你的应用程序看作一棵嵌套的组件树。每个组件处理自己的局部状态并管理用户界面的一部分,因此组件往往是相对独立的。
You can think of your application as a nested tree of components. Each component handles its own local state and manages a section of the user interface, so components tend to be relatively self-contained.
但有时,你需要在父组件与其子组件之间进行通信。例如,假设你定义了一个 <FancyButton/> 组件,它在 <button/> 的基础上添加了一些样式、日志或其他功能。你想在 <App/> 组件中使用 <FancyButton/>。但是你该如何在这两者之间进行通信呢?
Sometimes, though, you’ll want to communicate between a parent component and its
child. For example, imagine you’ve defined a <FancyButton/> component that adds
some styling, logging, or something else to a <button/>. You want to use a
<FancyButton/> in your <App/> component. But how can you communicate between
the two?
将状态从父组件传递到子组件很容易。我们在关于组件和属性的内容中已经介绍过一部分。基本上,如果你想让父组件与子组件通信,你可以将 ReadSignal 或 Signal 作为 prop 传递。
It’s easy to communicate state from a parent component to a child component. We
covered some of this in the material on components and props.
Basically if you want the parent to communicate to the child, you can pass either a
ReadSignal or
Signal as a prop.
但另一个方向呢?子组件如何将有关事件或状态更改的通知发送回父组件?
But what about the other direction? How can a child send notifications about events or state changes back up to the parent?
在 Leptos 中,父子通信有四种基本模式。
There are four basic patterns of parent-child communication in Leptos.
1. 传递 WriteSignal
1. Pass a WriteSignal
一种方法是简单地将 WriteSignal 从父组件传递到子组件,并在子组件中对其进行更新。这让你可以从子组件操纵父组件的状态。
One approach is simply to pass a WriteSignal from the parent down to the child, and update
it in the child. This lets you manipulate the state of the parent from the child.
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = signal(false);
view! {
<p>"Toggled? " {toggled}</p>
<ButtonA setter=set_toggled/>
}
}
#[component]
pub fn ButtonA(setter: WriteSignal<bool>) -> impl IntoView {
view! {
<button
on:click=move |_| setter.update(|value| *value = !*value)
>
"Toggle"
</button>
}
}
这种模式很简单,但你应该谨慎使用:到处传递 WriteSignal 会使代码难以推敲。在这个例子中,当你阅读 <App/> 时,很明显你正在移交修改 toggled 的能力,但完全不清楚它将在何时或如何改变。在这个局部的迷你示例中它很容易理解,但如果你发现自己在整个代码中都像这样传递 WriteSignal,你真的应该考虑这是否会让编写“意大利面条式代码”变得太容易了。
This pattern is simple, but you should be careful with it: passing around a WriteSignal
can make it hard to reason about your code. In this example, it’s pretty clear when you
read <App/> that you are handing off the ability to mutate toggled, but it’s not at
all clear when or how it will change. In this small, local example it’s easy to understand,
but if you find yourself passing around WriteSignals like this throughout your code,
you should really consider whether this is making it too easy to write spaghetti code.
2. 使用回调
2. Use a Callback
另一种方法是向子组件传递一个回调函数:比如 on_click。
Another approach would be to pass a callback to the child: say, on_click.
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = signal(false);
view! {
<p>"Toggled? " {toggled}</p>
<ButtonB on_click=move |_| set_toggled.update(|value| *value = !*value)/>
}
}
#[component]
pub fn ButtonB(on_click: impl FnMut(MouseEvent) + 'static) -> impl IntoView {
view! {
<button on:click=on_click>
"Toggle"
</button>
}
}
你会注意到,虽然 <ButtonA/> 被赋予了 WriteSignal 并决定如何修改它,但 <ButtonB/> 只是触发一个事件:修改操作发生在 <App/> 中。这样做的好处是保持局部状态局部化,防止出现乱如麻的状态修改。但也意味着修改该信号的逻辑需要存在于 <App/> 中,而不是 <ButtonB/> 中。这些都是现实中的权衡,而非简单的对错之分。
You’ll notice that whereas <ButtonA/> was given a WriteSignal and decided how to mutate it,
<ButtonB/> simply fires an event: the mutation happens back in <App/>. This has the advantage
of keeping local state local, preventing the problem of spaghetti mutation. But it also means
the logic to mutate that signal needs to exist up in <App/>, not down in <ButtonB/>. These
are real trade-offs, not a simple right-or-wrong choice.
3. 使用事件监听器
3. Use an Event Listener
你实际上可以用稍微不同的方式来编写方案 2。如果回调直接映射到原生 DOM 事件上,你可以直接在 <App/> 的 view 宏中使用组件的地方添加一个 on: 监听器。
You can actually write Option 2 in a slightly different way. If the callback maps directly onto
a native DOM event, you can add an on: listener directly to the place you use the component
in your view macro in <App/>.
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = signal(false);
view! {
<p>"Toggled? " {toggled}</p>
// 注意是 on:click 而不是 on_click
// 这与 HTML 元素的事件监听器语法相同
// note the on:click instead of on_click
// this is the same syntax as an HTML element event listener
<ButtonC on:click=move |_| set_toggled.update(|value| *value = !*value)/>
}
}
#[component]
pub fn ButtonC() -> impl IntoView {
view! {
<button>"Toggle"</button>
}
}
这让你在 <ButtonC/> 中写的代码比在 <ButtonB/> 中少得多,并且仍然能为监听器提供类型正确的事件。这是通过在 <ButtonC/> 返回的每个元素上添加一个 on: 事件监听器来实现的:在本例中,只是那一个 <button>。
This lets you write way less code in <ButtonC/> than you did for <ButtonB/>,
and still gives a correctly-typed event to the listener. This works by adding an
on: event listener to each element that <ButtonC/> returns: in this case, just
the one <button>.
当然,这只适用于你直接传递给组件中渲染元素的实际 DOM 事件。对于不直接映射到元素的更复杂逻辑(比如你创建了 <ValidatedForm/> 并想要一个 on_valid_form_submit 回调),你应该使用方案 2。
Of course, this only works for actual DOM events that you’re passing directly through
to the elements you’re rendering in the component. For more complex logic that
doesn’t map directly onto an element (say you create <ValidatedForm/> and want an
on_valid_form_submit callback) you should use Option 2.
4. 提供上下文 (Context)
4. Providing a Context
这个版本实际上是方案 1 的变体。假设你有一个深度嵌套的组件树:
This version is actually a variant on Option 1. Say you have a deeply-nested component tree:
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = signal(false);
view! {
<p>"Toggled? " {toggled}</p>
<Layout/>
}
}
#[component]
pub fn Layout() -> impl IntoView {
view! {
<header>
<h1>"My Page"</h1>
</header>
<main>
<Content/>
</main>
}
}
#[component]
pub fn Content() -> impl IntoView {
view! {
<div class="content">
<ButtonD/>
</div>
}
}
#[component]
pub fn ButtonD() -> impl IntoView {
todo!()
}
现在 <ButtonD/> 不再是 <App/> 的直接子组件,所以你不能简单地将 WriteSignal 传递给它的 props。你可以做一些有时被称为“prop 钻取 (prop drilling)”的事情,即在两者之间的每一层都添加一个 prop:
Now <ButtonD/> is no longer a direct child of <App/>, so you can’t simply
pass your WriteSignal to its props. You could do what’s sometimes called
“prop drilling,” adding a prop to each layer between the two:
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = signal(false);
view! {
<p>"Toggled? " {toggled}</p>
<Layout set_toggled/>
}
}
#[component]
pub fn Layout(set_toggled: WriteSignal<bool>) -> impl IntoView {
view! {
<header>
<h1>"My Page"</h1>
</header>
<main>
<Content set_toggled/>
</main>
}
}
#[component]
pub fn Content(set_toggled: WriteSignal<bool>) -> impl IntoView {
view! {
<div class="content">
<ButtonD set_toggled/>
</div>
}
}
#[component]
pub fn ButtonD(set_toggled: WriteSignal<bool>) -> impl IntoView {
todo!()
}
这太乱了。<Layout/> 和 <Content/> 并不需要 set_toggled;它们只是把它传递给 <ButtonD/>。但我需要重复声明三次这个 prop。这不仅令人厌烦,而且难以维护:想象一下我们添加了一个“半切换 (half-toggled)”选项,并且 set_toggled 的类型需要更改为 enum。我们必须在三个地方进行更改!
This is a mess. <Layout/> and <Content/> don’t need set_toggled; they just
pass it through to <ButtonD/>. But I need to declare the prop in triplicate.
This is not only annoying but hard to maintain: imagine we add a “half-toggled”
option and the type of set_toggled needs to change to an enum. We have to change
it in three places!
难道没有某种方法可以跨级传递吗?
Isn’t there some way to skip levels?
有的!
There is!
4.1 上下文 API (Context API)
4.1 The Context API
通过使用 provide_context 和 use_context,你可以提供跨越层级的数据。上下文由你提供的数据类型(在本例中为 WriteSignal<bool>)标识,它们存在于一个遵循 UI 树轮廓的自上而下的树中。在这个例子中,我们可以使用上下文来跳过不必要的 prop 钻取。
You can provide data that skips levels by using provide_context
and use_context. Contexts are identified
by the type of the data you provide (in this example, WriteSignal<bool>), and they exist in a top-down
tree that follows the contours of your UI tree. In this example, we can use context to skip the
unnecessary prop drilling.
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = signal(false);
// 与此组件的所有子组件共享 `set_toggled`
// share `set_toggled` with all children of this component
provide_context(set_toggled);
view! {
<p>"Toggled? " {toggled}</p>
<Layout/>
}
}
// 省略 <Layout/> 和 <Content/>
// 在此版本中,请删除每个组件上的 `set_toggled` 参数
// <Layout/> and <Content/> omitted
// To work in this version, drop the `set_toggled` parameter on each
#[component]
pub fn ButtonD() -> impl IntoView {
// use_context 向上搜索上下文树,希望能找到一个 `WriteSignal<bool>`
// 在本例中,我使用 .expect(),因为我知道我已经提供了它
// use_context searches up the context tree, hoping to
// find a `WriteSignal<bool>`
// in this case, I .expect() because I know I provided it
let setter = use_context::<WriteSignal<bool>>().expect("to have found the setter provided");
view! {
<button
on:click=move |_| setter.update(|value| *value = !*value)
>
"Toggle"
</button>
}
}
同样的注意事项也适用于此,就像应用于 <ButtonA/> 一样:到处传递 WriteSignal 应当谨慎,因为它允许你从代码的任意部分修改状态。但如果操作得当,这可能是 Leptos 中全局状态管理最有效的技术之一:只需在需要它的最高层级提供状态,并在其下任何需要的地方使用它。
The same caveats apply to this as to <ButtonA/>: passing a WriteSignal
around should be done with caution, as it allows you to mutate state from
arbitrary parts of your code. But when done carefully, this can be one of
the most effective techniques for global state management in Leptos: simply
provide the state at the highest level you’ll need it, and use it wherever
you need it lower down.
请注意,这种方法没有性能上的缺点。因为你传递的是细粒度的响应式信号,所以当你更新它时,中间组件(<Layout/> 和 <Content/>)什么都不会发生。你是在 <ButtonD/> 和 <App/> 之间直接通信。事实上——这就是细粒度响应式的力量——你是在 <ButtonD/> 中的按钮点击与 <App/> 中的单个文本节点之间直接通信。就好像组件本身根本不存在一样。而且,嗯……在运行时,它们确实不存在。全程都只是信号和副作用。
Note that there are no performance downsides to this approach. Because you
are passing a fine-grained reactive signal, nothing happens in the intervening
components (<Layout/> and <Content/>) when you update it. You are communicating
directly between <ButtonD/> and <App/>. In fact—and this is the power of
fine-grained reactivity—you are communicating directly between a button click
in <ButtonD/> and a single text node in <App/>. It’s as if the components
themselves don’t exist at all. And, well... at runtime, they don’t. It’s just
signals and effects, all the way down.
请注意,这种方法做出了一个重要的权衡:在 provide_context 和 use_context 之间你不再拥有类型安全性。在子组件中接收正确的上下文是一个运行时检查(参见 use_context.expect(...))。在重构期间,编译器不会像之前的方案那样引导你。
Note that this approach makes an important tradeoff: You don't have type-safety
anymore between provide_context and use_context. Receiving the right context
in the child component is a runtime check (see use_context.expect(...)). The
compiler won't guide you during a refactoring, as it does with the earlier approaches.
CodeSandbox 源代码
use leptos::{ev::MouseEvent, prelude::*};
// 这展示了子组件与父组件通信的四种不同方式:
// 1) <ButtonA/>: 将 WriteSignal 作为子组件属性之一传递,
// 由子组件写入,父组件读取
// 2) <ButtonB/>: 将闭包作为子组件属性之一传递,供子组件调用
// 3) <ButtonC/>: 向组件添加 `on:` 事件监听器
// 4) <ButtonD/>: 提供一个在组件中使用的上下文(而不是 prop 钻取)
#[derive(Copy, Clone)]
struct SmallcapsContext(WriteSignal<bool>);
#[component]
pub fn App() -> impl IntoView {
// 几个信号,用于在我们的 <p> 上切换四个类
let (red, set_red) = signal(false);
let (right, set_right) = signal(false);
let (italics, set_italics) = signal(false);
let (smallcaps, set_smallcaps) = signal(false);
// newtype 模式在这里不是 *必须* 的,但是一个良好的实践
// 它避免了与未来可能存在的其他 `WriteSignal<bool>` 上下文混淆
// 并使在 ButtonD 中引用它变得更容易
provide_context(SmallcapsContext(set_smallcaps));
view! {
<main>
<p
// class: 属性接收 F: Fn() => bool,这些信号都实现了 Fn()
class:red=red
class:right=right
class:italics=italics
class:smallcaps=smallcaps
>
"Lorem ipsum sit dolor amet."
</p>
// 按钮 A:传递信号 setter
<ButtonA setter=set_red/>
// 按钮 B:传递闭包
<ButtonB on_click=move |_| set_right.update(|value| *value = !*value)/>
// 按钮 C:使用常规事件监听器
// 在像这样的组件上设置事件监听器会将其应用于该组件返回的每个顶级元素
<ButtonC on:click=move |_| set_italics.update(|value| *value = !*value)/>
// 按钮 D 从上下文而不是 props 获取其 setter
<ButtonD/>
</main>
}
}
/// 按钮 A 接收一个信号 setter 并自行更新信号
#[component]
pub fn ButtonA(
/// 点击按钮时将切换的信号。
setter: WriteSignal<bool>,
) -> impl IntoView {
view! {
<button
on:click=move |_| setter.update(|value| *value = !*value)
>
"Toggle Red"
</button>
}
}
/// 按钮 B 接收一个闭包
#[component]
pub fn ButtonB(
/// 点击按钮时将调用的回调。
on_click: impl FnMut(MouseEvent) + 'static,
) -> impl IntoView
{
view! {
<button
on:click=on_click
>
"Toggle Right"
</button>
}
}
/// 按钮 C 是一个占位符:它渲染一个按钮但不处理其点击。
/// 相反,父组件添加一个事件监听器。
#[component]
pub fn ButtonC() -> impl IntoView {
view! {
<button>
"Toggle Italics"
</button>
}
}
/// 按钮 D 与按钮 A 非常相似,但我们不是将 setter 作为 prop 传递,
/// 而是从上下文中获取它
#[component]
pub fn ButtonD() -> impl IntoView {
let setter = use_context::<SmallcapsContext>().unwrap().0;
view! {
<button
on:click=move |_| setter.update(|value| *value = !*value)
>
"Toggle Small Caps"
</button>
}
}
fn main() {
leptos::mount::mount_to_body(App)
}