Вложенная Маршрутизации

Мы только что задали следующий набор маршрутов:

<Routes>
  <Route path="/" view=Home/>
  <Route path="/users" view=Users/>
  <Route path="/users/:id" view=UserProfile/>
  <Route path="/*any" view=NotFound/>
</Routes>

Тут есть некоторое дублирование: /users и /users/:id. Это нормально для мелкого приложения, но как вы наверное уже поняли, это плохо масштабируется. Вот было бы классно если бы маршруты могли быть вложенными?

Барабанная дробь... они могут!

<Routes>
  <Route path="/" view=Home/>
  <Route path="/users" view=Users>
    <Route path=":id" view=UserProfile/>
  </Route>
  <Route path="/*any" view=NotFound/>
</Routes>

Но подождите. Поведение приложения немного изменилось.

Следующий подраздел один из самых важных во всём руководстве по маршрутизации. Прочтите его внимательно и смело задавайте вопросы если чего-то не поняли.

Вложенные маршруты как макет

Вложенные маршруты это одна из форм макета, а не способ задания маршрутов.

Скажем иначе: Цель задания вложенных маршрутов главным образом не в том, чтобы избежать повторений при наборе путей в определениях маршрутов. Она в действительности в том, чтобы сказать маршрутизатору отображать несколько <Route/> на странице одновременно, бок о бок.

Давайте оглянёмся на наш практический пример.

<Routes>
  <Route path="/users" view=Users/>
  <Route path="/users/:id" view=UserProfile/>
</Routes>

Это значит:

  • Если зайдешь на /users, получишь компонент <Users/>.
  • Если зайдёшь на /users/3, получишь компонент <UserProfile/> (с параметром id равным 3; об этом позже)

Скажем мы используем вместо этого вложенные маршруты:

<Routes>
  <Route path="/users" view=Users>
    <Route path=":id" view=UserProfile/>
  </Route>
</Routes>

Это значит:

  • Если зайти на /users/3, путь подходит к двум <Route/>: <Users/> и <UserProfile/>.
  • Если зайти на /users, путь ни к чему не подходит.

Нужно добавить fallback-маршрут.

<Routes>
  <Route path="/users" view=Users>
    <Route path=":id" view=UserProfile/>
    <Route path="" view=NoUser/>
  </Route>
</Routes>

Теперь:

  • Если зайти на /users/3, путь подходит к <Users/> и <UserProfile/>.
  • Если зайти на /users, путь подходит к <Users/> and <NoUser/>.

Другими словами, использовании вложенных маршрутов, каждый путь может подходить к нескольким маршрутам: каждый URL может рендерить view предоставленные разными компонентами <Route/> одновременно, на одной странице.

Это может показаться контринтуитивным, но этот подход очень силён, в силу причин, продемонстрированных далее.

Зачем нужна Вложенная Маршрутизация?

Зачем этим заморачиваться?

Большинство Веб-приложений содержат уровни навигации, соответствующие разным частям макета. К примеру, в почтовом приложении может быть URL типа /contacts/greg, показывающий список контактов в левой части экрана, и детали контакта Greg в правой части. Список контактов и детали контакта должны всегда быть одновременно видны на экране. Если контакт не выбран, возможно стоит показать небольшой текст с инструкцией.

Этого легко добиться с помощь вложенных маршрутов

<Routes>
  <Route path="/contacts" view=ContactList>
    <Route path=":id" view=ContactInfo/>
    <Route path="" view=|| view! {
      <p>"Select a contact to view more info."</p>
    }/>
  </Route>
</Routes>

Можно пойти дальше вглубь. Предположим хочется, чтобы были вкладки для адреса контакта, почты/телефона, и переписки с ним. Можно добавить ещё один набор вложенных маршрутов внутри :id:

<Routes>
  <Route path="/contacts" view=ContactList>
    <Route path=":id" view=ContactInfo>
      <Route path="" view=EmailAndPhone/>
      <Route path="address" view=Address/>
      <Route path="messages" view=Messages/>
    </Route>
    <Route path="" view=|| view! {
      <p>"Select a contact to view more info."</p>
    }/>
  </Route>
</Routes>

На главной страница веб-сайта Remix, фреймворкa на React от создателей React Router, есть прекрасный визуальный пример если промотать вниз, с тремя уровнями вложенной маршрутизации: Sales > Invoices > an invoice.

<Outlet/>

Родительные маршруты автоматически не рендерят свои вложенные маршруты. В конце концов, это обычные компоненты; они не знают точно где им следует рендерить дочерние элементы и "просто присобачим их в конец родительного компонента" это плохой ответ.

Вместо этого, вы говорите родительному компоненту где рендерить любые вложенные компоненты используя компонент <Outlet/>. <Outlet/> попросту показывает одно из двух:

  • если нет подходящего вложенного маршрута, он ничего не показывает
  • если есть подходящий, он показывает его view

Вот и всё! Но важно знать и помнить, что это частая причина вопроса “Почему же это не работает?” Если не предоставить <Outlet/>, то вложенный маршрут не будет отображён.

#[component]
pub fn ContactList() -> impl IntoView {
  let contacts = todo!();

  view! {
    <div style="display: flex">
      // the contact list
      <For each=contacts
        key=|contact| contact.id
        children=|contact| todo!()
      />
      // the nested child, if any
      // don’t forget this!
      <Outlet/>
    </div>
  }
}

Рефакторинг Определений Маршрутов

Можно не задавать все маршруты в одном месте если не хочется. Любой <Route/> и его дочерние элементы можно перенести в отдельный компонент.

Вышеуказанный пример можно разбить на два отдельных компонента:

#[component]
fn App() -> impl IntoView {
  view! {
    <Router>
      <Routes>
        <Route path="/contacts" view=ContactList>
          <ContactInfoRoutes/>
          <Route path="" view=|| view! {
            <p>"Select a contact to view more info."</p>
          }/>
        </Route>
      </Routes>
    </Router>
  }
}

#[component(transparent)]
fn ContactInfoRoutes() -> impl IntoView {
  view! {
    <Route path=":id" view=ContactInfo>
      <Route path="" view=EmailAndPhone/>
      <Route path="address" view=Address/>
      <Route path="messages" view=Messages/>
    </Route>
  }
}

Второй компонент имеет пометку #[component(transparent)], это означит, что он просто возвращает данные, а не view: в данном случае это структура RouteDefinition, которую возвращает <Route/>. Пока стоит пометка #[component(transparent)], этот под-маршрут может быть объявлен где угодно, и при этом вставлен как компонент в дерево маршрутов.

Вложенная Маршрутизация и Производительность

Задумка хорошая, но всё же, в чём соль?

Производительность.

В библиотеке с мелкозернистой реактивностью, такой как Leptos, всегда важно делать как можно меньшее количества рендеринга. Поскольку мы работаем с реальными узлами DOM, а не сравнивам изменения через виртуальный DOM, мы хотим "перерендривать" компоненты как можно реже. Со вложенной маршрутизацией этого чрезвычайно просто достичь.

Представьте наш пример со списком контактов. Если перейти от Greg к Alice, затем к Bob и обратно к Greg, контактная информация должна меняться при каждом переходе. Но <ContactList/> вовсе не должен повторно рендериться. Это не только экономит производительность, но и поддерживает состояние UI. К примеру, если вверху <ContactList/> расположена поисковая строка, то переход от Greg к Alice и к Bob не сбросит строку поиска.

Фактически, в данном случае даже не нужно повторно рендерить компонент <Contact/> при переходе между контактами. Роутер будет лишь реактивно обновлять параметр :id по мере навигации, позволяя делать мелкозернистые обновления. При навигации по контактам одиночные текстовые узлы будут обновляться: имя контакта, адрес и так далее, без какого-либо дополнительного ререндеринга.

Эта песочница содержит пару This sandbox includes a couple features (like nested routing) discussed in this section and the previous one, and a couple we’ll cover in the rest of this chapter. The router is such an integrated system that it makes sense to provide a single example, so don’t be surprised if there’s anything you don’t understand.


[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/16-router-0-5-4xp4zz?file=%2Fsrc%2Fmain.rs%3A102%2C2)

<noscript>
  Please enable JavaScript to view examples.
</noscript>

<template>
  <iframe src="https://codesandbox.io/p/sandbox/16-router-0-5-4xp4zz?file=%2Fsrc%2Fmain.rs%3A102%2C2" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>

CodeSandbox Source
use leptos::*;
use leptos_router::*;

#[component]
fn App() -> impl IntoView {
    view! {
        <Router>
            <h1>"Contact App"</h1>
            // this <nav> will show on every routes,
            // because it's outside the <Routes/>
            // note: we can just use normal <a> tags
            // and the router will use client-side navigation
            <nav>
                <h2>"Navigation"</h2>
                <a href="/">"Home"</a>
                <a href="/contacts">"Contacts"</a>
            </nav>
            <main>
                <Routes>
                    // / just has an un-nested "Home"
                    <Route path="/" view=|| view! {
                        <h3>"Home"</h3>
                    }/>
                    // /contacts has nested routes
                    <Route
                        path="/contacts"
                        view=ContactList
                      >
                        // if no id specified, fall back
                        <Route path=":id" view=ContactInfo>
                            <Route path="" view=|| view! {
                                <div class="tab">
                                    "(Contact Info)"
                                </div>
                            }/>
                            <Route path="conversations" view=|| view! {
                                <div class="tab">
                                    "(Conversations)"
                                </div>
                            }/>
                        </Route>
                        // if no id specified, fall back
                        <Route path="" view=|| view! {
                            <div class="select-user">
                                "Select a user to view contact info."
                            </div>
                        }/>
                    </Route>
                </Routes>
            </main>
        </Router>
    }
}

#[component]
fn ContactList() -> impl IntoView {
    view! {
        <div class="contact-list">
            // here's our contact list component itself
            <div class="contact-list-contacts">
                <h3>"Contacts"</h3>
                <A href="alice">"Alice"</A>
                <A href="bob">"Bob"</A>
                <A href="steve">"Steve"</A>
            </div>

            // <Outlet/> will show the nested child route
            // we can position this outlet wherever we want
            // within the layout
            <Outlet/>
        </div>
    }
}

#[component]
fn ContactInfo() -> impl IntoView {
    // we can access the :id param reactively with `use_params_map`
    let params = use_params_map();
    let id = move || params.with(|params| params.get("id").cloned().unwrap_or_default());

    // imagine we're loading data from an API here
    let name = move || match id().as_str() {
        "alice" => "Alice",
        "bob" => "Bob",
        "steve" => "Steve",
        _ => "User not found.",
    };

    view! {
        <div class="contact-info">
            <h4>{name}</h4>
            <div class="tabs">
                <A href="" exact=true>"Contact Info"</A>
                <A href="conversations">"Conversations"</A>
            </div>

            // <Outlet/> here is the tabs that are nested
            // underneath the /contacts/:id route
            <Outlet/>
        </div>
    }
}

fn main() {
    leptos::mount_to_body(App)
}