Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

指南:Islands

Guide: Islands

Leptos 0.5 引入了新的 islands 特性。本指南将带你了解 islands 特性及其核心概念,并实现一个使用 islands 架构的演示应用。

Leptos 0.5 introduced the new islands feature. This guide will walk through the islands feature and core concepts, while implementing a demo app using the islands architecture.

Islands 架构

The Islands Architecture

主流的 JavaScript 前端框架(React、Vue、Svelte、Solid、Angular)最初都是作为构建客户端渲染单页应用(SPA)的框架而诞生的。初始页面加载渲染为 HTML,然后进行水合(hydrate),随后的导航直接在客户端处理。(因此被称为“单页”:即使之后有客户端路由,一切都源于服务器的一次页面加载。)这些框架后来都增加了服务端渲染(SSR)以缩短初始加载时间、优化 SEO 并提升用户体验。

The dominant JavaScript frontend frameworks (React, Vue, Svelte, Solid, Angular) all originated as frameworks for building client-rendered single-page apps (SPAs). The initial page load is rendered to HTML, then hydrated, and subsequent navigations are handled directly in the client. (Hence “single page”: everything happens from a single page load from the server, even if there is client-side routing later.) Each of these frameworks later added server-side rendering to improve initial load times, SEO, and user experience.

这意味着默认情况下,整个应用都是可交互的。这也意味着整个应用必须作为 JavaScript 发送到客户端才能进行水合。Leptos 也遵循了这一模式。

This means that by default, the entire app is interactive. It also means that the entire app has to be shipped to the client as JavaScript in order to be hydrated. Leptos has followed this same pattern.

你可以在关于 服务端渲染 的章节中了解更多。 You can read more in the chapters on server-side rendering.

但也可以朝相反的方向工作。与其采用一个完全交互式的应用,在服务器上将其渲染为 HTML,然后在浏览器中进行水合,你可以从一个纯 HTML 页面开始,并添加小块交互区域。这是 2010 年代之前任何网站或应用的传统格式:你的浏览器向服务器发起一系列请求,服务器返回每个新页面的 HTML 作为响应。在“单页应用”(SPA)兴起之后,这种方法相比之下有时被称为“多页应用”(MPA)。

But it’s also possible to work in the opposite direction. Rather than taking an entirely-interactive app, rendering it to HTML on the server, and then hydrating it in the browser, you can begin with a plain HTML page and add small areas of interactivity. This is the traditional format for any website or app before the 2010s: your browser makes a series of requests to the server and returns the HTML for each new page in response. After the rise of “single-page apps” (SPA), this approach has sometimes become known as a “multi-page app” (MPA) by comparison.

“Islands 架构”(孤岛架构)这一术语最近出现,用于描述从服务端渲染的 HTML 页面“海洋”开始,并在整个页面中添加交互式“孤岛(islands)”的方法。

The phrase “islands architecture” has emerged recently to describe the approach of beginning with a “sea” of server-rendered HTML pages, and adding “islands” of interactivity throughout the page.

延伸阅读

Additional Reading

本指南的其余部分将探讨如何在 Leptos 中使用 islands。关于该方法的更多背景信息,请查看以下文章:

The rest of this guide will look at how to use islands with Leptos. For more background on the approach in general, check out some of the articles below:

激活 Islands 模式

Activating Islands Mode

让我们从一个新的 cargo-leptos 应用开始:

Let’s start with a fresh cargo-leptos app:

cargo leptos new --git leptos-rs/start-axum

在此示例中,Actix 和 Axum 之间应该没有实质性区别。 There should be no real differences between Actix and Axum in this example.

我将在后台运行

I’m just going to run

cargo leptos build

同时启动编辑器并继续编写代码。

in the background while I fire up my editor and keep writing.

我要做的第一件事是在 Cargo.toml 中添加 islands 特性。我只需要在 leptos crate 中添加它。

The first thing I’ll do is to add the islands feature in my Cargo.toml. I only need to add this to the leptos crate.

leptos = { version = "0.7", features = ["islands"] }

接下来,我将修改从 src/lib.rs 导出的 hydrate 函数。我将删除调用 leptos::mount::hydrate_body(App) 的那行,并将其替换为:

Next I’m going to modify the hydrate function exported from src/lib.rs. I’m going to remove the line that calls leptos::mount::hydrate_body(App) and replace it with

leptos::mount::hydrate_islands();

这将按顺序水合每个单独的 island,而不是运行整个应用程序并水合它创建的视图。

Rather than running the whole application and hydrating the view that it creates, this will hydrate each individual island, in order.

app.rsshell 函数中,我们还需要在 HydrationScripts 组件中添加 islands=true

In app.rs, in the shell functions, we’ll also need to add islands=true to the HydrationScripts component:

<HydrationScripts options islands=true/>

好了,现在启动 cargo leptos watch 并访问 http://localhost:3000(或其他地址)。

Okay, now fire up your cargo leptos watch and go to http://localhost:3000 (or wherever).

点击按钮,然后……

Click the button, and...

什么都没发生!

Nothing happens!

太完美了。

Perfect.

注意

入门模板在其 hydrate() 函数定义中包含 use app::*;。一旦切换到 islands 模式,你就不再使用导入的主 App 函数,因此你可能认为可以删除它。(事实上,如果你不删,Rust lint 工具可能会发出警告!)

然而,如果你使用的是工作区(workspace)设置,这可能会导致问题。我们使用 wasm-bindgen 为每个函数独立导出入口点。根据我的经验,如果你使用工作区设置,并且 frontend crate 中没有任何东西实际使用 app crate,那么这些绑定将无法正确生成。点击此处查看更多讨论

使用 Islands

Using Islands

什么都没发生,因为我们刚刚彻底颠覆了应用的思维模型。应用现在默认是纯 HTML 的,而不是默认可交互且水合一切,我们需要主动选择加入交互性。

Nothing happens because we’ve just totally inverted the mental model of our app. Rather than being interactive by default and hydrating everything, the app is now plain HTML by default, and we need to opt into interactivity.

这对 WASM 二进制文件大小有很大影响:如果我以发布模式编译,这个应用的 WASM 只有区区 24kb(未压缩),而相比之下,非 islands 模式下为 274kb。(对于一个“Hello, world!”来说 274kb 相当大,这其实是所有与客户端路由相关的代码,而在演示中并未使用。)

This has a big effect on WASM binary sizes: if I compile in release mode, this app is a measly 24kb of WASM (uncompressed), compared to 274kb in non-islands mode. (274kb is quite large for a “Hello, world!” It’s really just all the code related to client-side routing, which isn’t being used in the demo.)

当我们点击按钮时,什么都没发生,因为我们的整个页面都是静态的。

When we click the button, nothing happens, because our whole page is static.

那么我们该如何让事情发生呢?

So how do we make something happen?

让我们把 HomePage 组件变成一个 island!

Let’s turn the HomePage component into an island!

这是之前的非交互版本:

Here was the non-interactive version:

#[component]
fn HomePage() -> impl IntoView {
    // 创建响应式值以更新按钮
    // Creates a reactive value to update the button
    let count = RwSignal::new(0);
    let on_click = move |_| *count.write() += 1;

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <button on:click=on_click>"Click Me: " {count}</button>
    }
}

这是交互版本:

Here’s the interactive version:

#[island]
fn HomePage() -> impl IntoView {
    // 创建响应式值以更新按钮
    // Creates a reactive value to update the button
    let count = RwSignal::new(0);
    let on_click = move |_| *count.write() += 1;

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <button on:click=on_click>"Click Me: " {count}</button>
    }
}

现在当我点击按钮时,它起作用了!

Now when I click the button, it works!

#[island] 宏的工作方式与 #[component] 宏完全相同,不同之处在于在 islands 模式下,它将其指定为交互式 island。如果我们再次检查二进制文件大小,发布模式下未压缩为 166kb;虽然比 24kb 的完全静态版本大得多,但比 355kb 的完全水合版本小得多。

The #[island] macro works exactly like the #[component] macro, except that in islands mode, it designates this as an interactive island. If we check the binary size again, this is 166kb uncompressed in release mode; much larger than the 24kb totally static version, but much smaller than the 355kb fully-hydrated version.

如果你现在打开页面的源代码,你会看到你的 HomePage island 已经被渲染为一个特殊的 <leptos-island> HTML 元素,它指定了应该使用哪个组件来水合它:

If you open up the source for the page now, you’ll see that your HomePage island has been rendered as a special <leptos-island> HTML element which specifies which component should be used to hydrate it:

<leptos-island data-component="HomePage_7432294943247405892">
  <h1>Welcome to Leptos!</h1>
  <button>
    Click Me:
    <!>0
  </button>
</leptos-island>

只有这个 <leptos-island> 内部的代码才会被编译为 WASM,水合时也只有该代码会运行。

Only code for what's inside this <leptos-island> is compiled to WASM, only that code runs when hydrating.

有效地使用 Islands

Using Islands Effectively

请记住,只有 #[island] 内部的代码才需要被编译成 WASM 并发送到浏览器。这意味着 islands 应该尽可能小且具体。例如,我的 HomePage 最好拆分为一个普通组件和一个 island:

Remember that only code within an #[island] needs to be compiled to WASM and shipped to the browser. This means that islands should be as small and specific as possible. My HomePage, for example, would be better broken apart into a regular component and an island:

#[component]
fn HomePage() -> impl IntoView {
    view! {
        <h1>"Welcome to Leptos!"</h1>
        <Counter/>
    }
}

#[island]
fn Counter() -> impl IntoView {
    // 创建响应式值以更新按钮
    // Creates a reactive value to update the button
    let (count, set_count) = signal(0);
    let on_click = move |_| *set_count.write() += 1;

    view! {
        <button on:click=on_click>"Click Me: " {count}</button>
    }
}

现在 <h1> 不需要包含在客户端包中,也不需要水合。这现在看起来像是一个微不足道的区别;但请注意,你现在可以向 HomePage 本身添加任意数量的静态 HTML 内容,而 WASM 二进制文件的大小将保持完全相同。

Now the <h1> doesn’t need to be included in the client bundle, or hydrated. This seems like a silly distinction now; but note that you can now add as much inert HTML content as you want to the HomePage itself, and the WASM binary size will remain exactly the same.

在普通水合模式下,你的 WASM 二进制文件大小随应用的大小/复杂度而增长。在 islands 模式下,你的 WASM 二进制文件随应用中交互性的多少而增长。你可以在 islands 之外添加任意数量的非交互式内容,它不会增加二进制文件的大小。

In regular hydration mode, your WASM binary size grows as a function of the size/complexity of your app. In islands mode, your WASM binary grows as a function of the amount of interactivity in your app. You can add as much non-interactive content as you want, outside islands, and it will not increase that binary size.

解锁超能力

Unlocking Superpowers

所以,WASM 二进制文件大小减少 50% 固然很好。但真正的意义是什么呢?

So, this 50% reduction in WASM binary size is nice. But really, what’s the point?

当你结合两个关键事实时,意义就显现出来了:

The point comes when you combine two key facts:

  1. #[component] 函数内部的代码现在在服务器上运行,除非你在 island 中使用它。*

  2. 子组件和属性可以从服务器传递给 islands,而无需包含在 WASM 二进制文件中。

  3. Code inside #[component] functions now only runs on the server, unless you use it in an island.*

  4. Children and props can be passed from the server to islands, without being included in the WASM binary.

这意味着你可以直接在组件体中运行仅限服务器运行的代码,并将其直接传递到子组件中。在完全水合的应用中,某些需要服务器函数和 Suspense 复杂配合的任务,在 islands 中可以内联完成。

This means you can run server-only code directly in the body of a component, and pass it directly into the children. Certain tasks that take a complex blend of server functions and Suspense in fully-hydrated apps can be done inline in islands.

* 这个“除非你在 island 中使用它”很重要。#[component] 组件并非只在服务器上运行。更准确地说,它们是“共享组件”,只有当它们被用在 #[island] 的主体中时,才会被编译进 WASM 二进制文件。但如果你不在 island 中使用它们,它们就不会在浏览器中运行。 * This “unless you use it in an island” is important. It is not the case that #[component] components only run on the server. Rather, they are “shared components” that are only compiled into the WASM binary if they’re used in the body of an #[island]. But if you don’t use them in an island, they won’t run in the browser.

在本演示的其余部分,我们将依赖第三个事实:

We’re going to rely on a third fact in the rest of this demo:

  1. 上下文(context)可以在彼此独立的 islands 之间传递。

  2. Context can be passed between otherwise-independent islands.

所以,与其用计数器演示,不如让我们做点更有趣的东西:一个从服务器文件读取数据的选项卡(tabs)界面。

So, instead of our counter demo, let’s make something a little more fun: a tabbed interface that reads data from files on the server.

向 Islands 传递服务端子组件

Passing Server Children to Islands

Islands 最强大的地方之一是你可以将服务端渲染的子组件传递给 island,而 island 无需了解它们的任何信息。Islands 只水合它们自己的内容,而不水合传递给它们的子组件。

One of the most powerful things about islands is that you can pass server-rendered children into an island, without the island needing to know anything about them. Islands hydrate their own content, but not children that are passed to them.

正如 React 的 Dan Abramov(在非常类似的 RSC 背景下)所说,islands 并不真的是岛屿:它们是甜甜圈。你可以直接将仅限服务器的内容传递到“甜甜圈洞”中,从而允许你创建极小的交互式环礁,其两侧都被惰性的服务器 HTML 海洋包围。

As Dan Abramov of React put it (in the very similar context of RSCs), islands aren’t really islands: they’re donuts. You can pass server-only content directly into the “donut hole,” as it were, allowing you to create tiny atolls of interactivity, surrounded on both sides by the sea of inert server HTML.

在下面包含的演示代码中,我添加了一些样式,将所有服务器内容显示为浅蓝色的“海洋”,将所有 islands 显示为浅绿色的“陆地”。希望这有助于你想象我所说的内容! In the demo code included below, I added some styles to show all server content as a light-blue “sea,” and all islands as light-green “land.” Hopefully that will help picture what I’m talking about!

继续演示:我将创建一个 Tabs 组件。在选项卡之间切换需要一些交互性,所以这当然会是一个 island。我们现在先从简单的开始:

To continue with the demo: I’m going to create a Tabs component. Switching between tabs will require some interactivity, so of course this will be an island. Let’s start simple for now:

#[island]
fn Tabs(labels: Vec<String>) -> impl IntoView {
    let buttons = labels
        .into_iter()
        .map(|label| view! { <button>{label}</button> })
        .collect_view();
    view! {
        <div style="display: flex; width: 100%; justify-content: space-between;">
            {buttons}
        </div>
    }
}

哎呀。这给了我一个错误:

Oops. This gives me an error

error[E0463]: can't find crate for `serde`
  --> src/app.rs:43:1
   |
43 | #[island]
   | ^^^^^^^^^ can't find crate

简单的修复:让我们运行 cargo add serde --features=derive#[island] 宏在这里想要引入 serde,因为它需要序列化和反序列化 labels 属性。

Easy fix: let’s cargo add serde --features=derive. The #[island] macro wants to pull in serde here because it needs to serialize and deserialize the labels prop.

现在让我们更新 HomePage 以使用 Tabs

Now let’s update the HomePage to use Tabs.

#[component]
fn HomePage() -> impl IntoView {
	// 这些是我们要读取的文件
    // these are the files we’re going to read
    let files = ["a.txt", "b.txt", "c.txt"];
	// 选项卡标签将只是文件名
	// the tab labels will just be the file names
	let labels = files.iter().copied().map(Into::into).collect();
    view! {
        <h1>"Welcome to Leptos!"</h1>
        <p>"Click any of the tabs below to read a recipe."</p>
        <Tabs labels/>
    }
}

如果你查看 DOM 检查器,你会看到该 island 现在类似于:

If you take a look in the DOM inspector, you’ll see the island is now something like

<leptos-island
  data-component="Tabs_1030591929019274801"
  data-props='{"labels":["a.txt","b.txt","c.txt"]}'
>
  <div style="display: flex; width: 100%; justify-content: space-between;;">
    <button>a.txt</button>
    <button>b.txt</button>
    <button>c.txt</button>
    <!---->
  </div>
</leptos-island>

我们的 labels 属性正被序列化为 JSON 并存储在 HTML 属性中,以便用于水合 island。

Our labels prop is getting serialized to JSON and stored in an HTML attribute so it can be used to hydrate the island.

现在让我们添加一些选项卡。目前,Tab island 将非常简单:

Now let’s add some tabs. For the moment, a Tab island will be really simple:

#[island]
fn Tab(index: usize, children: Children) -> impl IntoView {
    view! {
        <div>{children()}</div>
    }
}

目前,每个选项卡都只是一个包裹其子组件的 <div>

Each tab, for now will just be a <div> wrapping its children.

我们的 Tabs 组件也将获得一些子组件:目前,让我们把它们全部显示出来。

Our Tabs component will also get some children: for now, let’s just show them all.

#[island]
fn Tabs(labels: Vec<String>, children: Children) -> impl IntoView {
    let buttons = labels
        .into_iter()
        .map(|label| view! { <button>{label}</button> })
        .collect_view();
    view! {
        <div style="display: flex; width: 100%; justify-content: space-around;">
            {buttons}
        </div>
        {children()}
    }
}

好的,现在让我们回到 HomePage。我们要创建选项卡列表放入我们的选项卡框中。

Okay, now let’s go back into the HomePage. We’re going to create the list of tabs to put into our tab box.

#[component]
fn HomePage() -> impl IntoView {
    let files = ["a.txt", "b.txt", "c.txt"];
    let labels = files.iter().copied().map(Into::into).collect();
	let tabs = move || {
        files
            .into_iter()
            .enumerate()
            .map(|(index, filename)| {
                let content = std::fs::read_to_string(filename).unwrap();
                view! {
                    <Tab index>
                        <h2>{filename.to_string()}</h2>
                        <p>{content}</p>
                    </Tab>
                }
            })
            .collect_view()
    };

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <p>"Click any of the tabs below to read a recipe."</p>
        <Tabs labels>
            <div>{tabs()}</div>
        </Tabs>
    }
}

呃……什么?

Uh... What?

如果你习惯使用 Leptos,你就知道你不能这样做。组件主体中的所有代码都必须在服务器上运行(以渲染为 HTML)并在浏览器中运行(以水合),所以你不能直接调用 std::fs;它会发生 panic,因为在浏览器中无法访问本地文件系统(当然更不能访问服务器文件系统!)。这将是一场安全噩梦!

If you’re used to using Leptos, you know that you just can’t do this. All code in the body of components has to run on the server (to be rendered to HTML) and in the browser (to hydrate), so you can’t just call std::fs; it will panic, because there’s no access to the local filesystem (and certainly not to the server filesystem!) in the browser. This would be a security nightmare!

除非……等等。我们现在处于 islands 模式。这个 HomePage 组件真的只在服务器上运行。所以,事实上,我们可以像这样使用普通的服务器代码。

Except... wait. We’re in islands mode. This HomePage component really does only run on the server. So we can, in fact, just use ordinary server code like this.

这是一个愚蠢的例子吗? 是的!在 .map() 中同步读取三个不同的本地文件在现实生活中并不是一个好的选择。这里的重点只是为了证明这绝对是仅限服务器的内容。 Is this a dumb example? Yes! Synchronously reading from three different local files in a .map() is not a good choice in real life. The point here is just to demonstrate that this is, definitely, server-only content.

去吧,在项目根目录下创建三个名为 a.txtb.txtc.txt 的文件,并填入你喜欢的任何内容。

Go ahead and create three files in the root of the project called a.txt, b.txt, and c.txt, and fill them in with whatever content you’d like.

刷新页面,你应该就能在浏览器中看到内容。编辑文件并再次刷新;内容将会更新。

Refresh the page and you should see the content in the browser. Edit the files and refresh again; it will be updated.

你可以将仅限服务器的内容从 #[component] 传递到 #[island] 的子组件中,而 island 无需知道任何关于如何访问该数据或渲染该内容的信息。

You can pass server-only content from a #[component] into the children of an #[island], without the island needing to know anything about how to access that data or render that content.

这非常重要。 向 islands 传递服务器 children 意味着你可以保持 islands 的精简。理想情况下,你不想在页面的整个大块周围套上 #[island]。你应该将该块拆分为一个交互式部分(可以是 #[island])和一大堆额外的服务器内容(作为 children 传递给该 island),这样就可以将页面交互部分的非交互子部分排除在 WASM 二进制文件之外。

This is really important. Passing server children to islands means that you can keep islands small. Ideally, you don’t want to slap an #[island] around a whole chunk of your page. You want to break that chunk out into an interactive piece, which can be an #[island], and a bunch of additional server content that can be passed to that island as children, so that the non-interactive subsections of an interactive part of the page can be kept out of the WASM binary.

在 Islands 之间传递上下文

Passing Context Between Islands

这些现在还不算真正的“选项卡”:它们只是时刻显示每一个选项卡。所以让我们为我们的 TabsTab 组件添加一些简单的逻辑。

These aren’t really “tabs” yet: they just show every tab, all the time. So let’s add some simple logic to our Tabs and Tab components.

我们将修改 Tabs 以创建一个简单的 selected 信号。我们通过上下文提供读取端,并在有人点击我们的按钮时设置信号的值。

We’ll modify Tabs to create a simple selected signal. We provide the read half via context, and set the value of the signal whenever someone clicks one of our buttons.

#[island]
fn Tabs(labels: Vec<String>, children: Children) -> impl IntoView {
    let (selected, set_selected) = signal(0);
    provide_context(selected);

    let buttons = labels
        .into_iter()
        .enumerate()
        .map(|(index, label)| view! {
            <button on:click=move |_| set_selected.set(index)>
                {label}
            </button>
        })
        .collect_view();
// ...

让我们修改 Tab island,使用该上下文来显示或隐藏自己:

And let’s modify the Tab island to use that context to show or hide itself:

#[island]
fn Tab(index: usize, children: Children) -> impl IntoView {
    let selected = expect_context::<ReadSignal<usize>>();
    view! {
        <div
            style:background-color="lightgreen"
            style:padding="10px"
            style:display=move || if selected.get() == index {
                "block"
            } else {
                "none"
            }
        >
            {children()}
        </div>
    }
}

现在选项卡的行为完全符合我的预期。Tabs 通过上下文将信号传递给每个 Tab,后者使用它来确定自己是否应该打开。

Now the tabs behave exactly as I’d expect. Tabs passes the signal via context to each Tab, which uses it to determine whether it should be open or not.

这就是为什么在 HomePage 中,我把 let tabs = move || 做成了一个函数,并像 {tabs()} 这样调用它:以这种懒加载的方式创建选项卡意味着当每个 Tab 去寻找它时,Tabs island 已经提供了 selected 上下文。 That’s why in HomePage, I made let tabs = move || a function, and called it like {tabs()}: creating the tabs lazily this way meant that the Tabs island would already have provided the selected context by the time each Tab went looking for it.

我们的完整选项卡演示大约是 200kb(未压缩):虽然不是世界上最小的演示,但仍比我们开始时使用客户端路由的“Hello, world”小得多!出于好奇,我构建了同样但不使用 islands 模式、而是使用 #[server] 函数和 Suspense 的演示,结果超过了 400kb。所以这再次节省了大约 50% 的二进制文件大小。而且这个应用只包含了极少的仅限服务器的内容:记住,随着我们添加更多的仅限服务器的组件和页面,这 200kb 不会增加。

Our complete tabs demo is about 200kb uncompressed: not the smallest demo in the world, but still significantly smaller than the “Hello, world” using client side routing that we started with! Just for kicks, I built the same demo without islands mode, using #[server] functions and Suspense. and it was over 400kb. So again, this was about a 50% savings in binary size. And this app includes quite minimal server-only content: remember that as we add additional server-only components and pages, this 200kb will not grow.

概览

Overview

这个演示可能看起来非常基础。事实确实如此。但有一些立竿见影的收获:

This demo may seem pretty basic. It is. But there are a number of immediate takeaways:

  • WASM 二进制文件大小减少 50%,这意味着客户端的可交互时间和初始加载时间有了可衡量的改进。

  • 降低了数据序列化成本。 创建资源并在客户端读取意味着你需要序列化数据,以便将其用于水合。如果你还在 Suspense 中读取该数据以创建 HTML,你最终会得到“双重数据”,即完全相同的数据既被渲染为 HTML,又被序列化为 JSON,增加了响应的大小,从而减慢了响应速度。

  • 可以轻松使用仅限服务器的 API,只需在 #[component] 内部使用,就像它是运行在服务器上的普通、原生 Rust 函数一样——在 islands 模式下,它确实是!

  • 减少了加载服务器数据的 #[server] / create_resource / Suspense 样板代码。

  • 50% WASM binary size reduction, which means measurable improvements in time to interactivity and initial load times for clients.

  • Reduced data serialization costs. Creating a resource and reading it on the client means you need to serialize the data, so it can be used for hydration. If you’ve also read that data to create HTML in a Suspense, you end up with “double data,” i.e., the same exact data is both rendered to HTML and serialized as JSON, increasing the size of responses, and therefore slowing them down.

  • Easily use server-only APIs inside a #[component] as if it were a normal, native Rust function running on the server—which, in islands mode, it is!

  • Reduced #[server]/create_resource/Suspense boilerplate for loading server data.

未来探索

Future Exploration

islands 特性反映了目前前端 Web 框架探索的最前沿成果。就目前而言,我们的 islands 方法与 Astro 非常相似(在其最近支持视图过渡(View Transitions)之前):它允许你构建传统的服务端渲染、多页应用,并能非常无缝地集成交互式的 islands。

The islands feature reflects work at the cutting edge of what frontend web frameworks are exploring right now. As it stands, our islands approach is very similar to Astro (before its recent View Transitions support): it allows you to build a traditional server-rendered, multi-page app and pretty seamlessly integrate islands of interactivity.

有一些小的改进将很容易添加。例如,我们可以做一些非常类似于 Astro 的视图过渡方法:

There some small improvements that will be easy to add. For example, we can do something very much like Astro's View Transitions approach:

  • 为 islands 应用添加客户端路由,方法是从服务器获取后续导航并用新 HTML 文档替换旧文档。

  • 使用视图过渡(View Transitions)API 在新旧文档之间添加动画过渡。

  • 支持显式的持久化 islands,即你可以用唯一 ID 标记的 islands(类似于视图中组件上的 persist:searchbar),它们可以从旧文档复制到新文档,而不会丢失其当前状态。

  • add client-side routing for islands apps by fetching subsequent navigations from the server and replacing the HTML document with the new one

  • add animated transitions between the old and new document using the View Transitions API

  • support explicit persistent islands, i.e., islands that you can mark with unique IDs (something like persist:searchbar on the component in the view), which can be copied over from the old to the new document without losing their current state

还有其他更大的架构变动,我目前还不确定

There are other, larger architectural changes that I’m not sold on yet.

附加信息

Additional Information

查看 islands 示例路线图Hackernews 演示 以获取更多讨论。

Check out the islands example, roadmap, and Hackernews demo for additional discussion.

演示代码

Demo Code

use leptos::prelude::*;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <main style="background-color: lightblue; padding: 10px">
            <HomePage/>
        </main>
    }
}

/// 渲染应用的首页。
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
    let files = ["a.txt", "b.txt", "c.txt"];
    let labels = files.iter().copied().map(Into::into).collect();
    let tabs = move || {
        files
            .into_iter()
            .enumerate()
            .map(|(index, filename)| {
                let content = std::fs::read_to_string(filename).unwrap();
                view! {
                    <Tab index>
                        <div style="background-color: lightblue; padding: 10px">
                            <h2>{filename.to_string()}</h2>
                            <p>{content}</p>
                        </div>
                    </Tab>
                }
            })
            .collect_view()
    };

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <p>"Click any of the tabs below to read a recipe."</p>
        <Tabs labels>
            <div>{tabs()}</div>
        </Tabs>
    }
}

#[island]
fn Tabs(labels: Vec<String>, children: Children) -> impl IntoView {
    let (selected, set_selected) = signal(0);
    provide_context(selected);

    let buttons = labels
        .into_iter()
        .enumerate()
        .map(|(index, label)| {
            view! {
                <button on:click=move |_| set_selected.set(index)>
                    {label}
                </button>
            }
        })
        .collect_view();
    view! {
        <div
            style="display: flex; width: 100%; justify-content: space-around;\
            background-color: lightgreen; padding: 10px;"
        >
            {buttons}
        </div>
        {children()}
    }
}

#[island]
fn Tab(index: usize, children: Children) -> impl IntoView {
    let selected = expect_context::<ReadSignal<usize>>();
    view! {
        <div
            style:background-color="lightgreen"
            style:padding="10px"
            style:display=move || if selected.get() == index {
                "block"
            } else {
                "none"
            }
        >
            {children()}
        </div>
    }
}