Skip to main content

状态管理

如果您习惯于构建仅客户端的应用程序,在跨服务端和客户端的应用中进行状态管理可能会让人感到望而生畏。本节提供了一些避免常见陷阱的建议。

避免在服务端共享状态

浏览器是有状态的 — 状态在用户与应用程序交互时存储在内存中。相反,服务端是无状态的 — 响应的内容完全取决于请求的内容。

从概念上来说是这样的。实际上,服务端通常是长期运行的,并由多个用户共享。因此,避免在共享变量中存储数据非常重要。例如,考虑以下代码:

+page.server
let let user: anyuser;

/** @type {import('./$types').PageServerLoad} */
export function 
function load(): {
    user: any;
}
@type{import('./$types').PageServerLoad}
load
() {
return { user: anyuser }; } /** @satisfies {import('./$types').Actions} */ export const
const actions: {
    default: ({ request }: {
        request: any;
    }) => Promise<void>;
}
@satisfies{import('./$types').Actions}
actions
= {
default: ({ request }: {
    request: any;
}) => Promise<void>
default
: async ({ request: anyrequest }) => {
const const data: anydata = await request: anyrequest.formData(); // 永远不要这样做! let user: anyuser = { name: anyname: const data: anydata.get('name'), embarrassingSecret: anyembarrassingSecret: const data: anydata.get('secret') }; } };
import type { 
type PageServerLoad = (event: Kit.ServerLoadEvent<Record<string, any>, Record<string, any>, string | null>) => MaybePromise<void | Record<string, any>>
type PageServerLoad = (event: Kit.ServerLoadEvent<Record<string, any>, Record<string, any>, string | null>) => MaybePromise<void | Record<string, any>>
PageServerLoad
,
type Actions = {
    [x: string]: Kit.Action<Record<string, any>, void | Record<string, any>, string | null>;
}
type Actions = {
    [x: string]: Kit.Action<Record<string, any>, void | Record<string, any>, string | null>;
}
Actions
} from './$types';
let let user: anyuser; export const const load: PageServerLoadload:
type PageServerLoad = (event: Kit.ServerLoadEvent<Record<string, any>, Record<string, any>, string | null>) => MaybePromise<void | Record<string, any>>
type PageServerLoad = (event: Kit.ServerLoadEvent<Record<string, any>, Record<string, any>, string | null>) => MaybePromise<void | Record<string, any>>
PageServerLoad
= () => {
return { user: anyuser }; }; export const
const actions: {
    default: ({ request }: Kit.RequestEvent<Record<string, any>, string | null>) => Promise<void>;
}
actions
= {
default: ({ request }: Kit.RequestEvent<Record<string, any>, string | null>) => Promise<void>default: async ({ request: Request

The original request object

request
}) => {
const const data: FormDatadata = await request: Request

The original request object

request
.Body.formData(): Promise<FormData>formData();
// 永远不要这样做! let user: anyuser = { name: FormDataEntryValue | nullname: const data: FormDatadata.FormData.get(name: string): FormDataEntryValue | nullget('name'), embarrassingSecret: FormDataEntryValue | nullembarrassingSecret: const data: FormDatadata.FormData.get(name: string): FormDataEntryValue | nullget('secret') }; } } satisfies
type Actions = {
    [x: string]: Kit.Action<Record<string, any>, void | Record<string, any>, string | null>;
}
type Actions = {
    [x: string]: Kit.Action<Record<string, any>, void | Record<string, any>, string | null>;
}
Actions
;

user 变量被所有连接到这个服务器的人共享。如果 Alice 提交了一个尴尬的秘密,而 Bob 在她之后访问页面,Bob 就会知道 Alice 的秘密。此外,当 Alice 当天晚些时候返回网站时,服务器可能已经重启,丢失了她的数据。

相反,您应该使用 cookies 对用户进行认证,并将数据持久化到数据库中。

load 函数中不要有副作用

出于同样的原因,您的 load 函数应该是纯函数 — 没有副作用(除了偶尔的 console.log(...))。例如,您可能会想在 load 函数中写入 store 或全局状态,以便在组件中使用这个值:

+page
import { 
const user: {
    set: (value: any) => void;
}
user
} from '$lib/user';
/** @type {import('./$types').PageLoad} */ export async function function load(event: LoadEvent<Record<string, any>, Record<string, any> | null, Record<string, any>, string | null>): MaybePromise<void | Record<string, any>>
@type{import('./$types').PageLoad}
load
({
fetch: {
    (input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
    (input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>;
}

fetch is equivalent to the native fetch web API, with a few additional features:

  • It can be used to make credentialed requests on the server, as it inherits the cookie and authorization headers for the page request.
  • It can make relative requests on the server (ordinarily, fetch requires a URL with an origin when used in a server context).
  • Internal requests (e.g. for +server.js routes) go directly to the handler function when running on the server, without the overhead of an HTTP call.
  • During server-side rendering, the response will be captured and inlined into the rendered HTML by hooking into the text and json methods of the Response object. Note that headers will not be serialized, unless explicitly included via filterSerializedResponseHeaders
  • During hydration, the response will be read from the HTML, guaranteeing consistency and preventing an additional network request.

You can learn more about making credentialed requests with cookies here

fetch
}) {
const const response: Responseresponse = await fetch: (input: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response> (+1 overload)fetch('/api/user'); // 永远不要这样做!
const user: {
    set: (value: any) => void;
}
user
.set: (value: any) => voidset(await const response: Responseresponse.Body.json(): Promise<any>json());
}
import { 
const user: {
    set: (value: any) => void;
}
user
} from '$lib/user';
import type { type PageLoad = (event: LoadEvent<Record<string, any>, Record<string, any> | null, Record<string, any>, string | null>) => MaybePromise<void | Record<string, any>>PageLoad } from './$types'; export const const load: PageLoadload: type PageLoad = (event: LoadEvent<Record<string, any>, Record<string, any> | null, Record<string, any>, string | null>) => MaybePromise<void | Record<string, any>>PageLoad = async ({
fetch: {
    (input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
    (input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>;
}

fetch is equivalent to the native fetch web API, with a few additional features:

  • It can be used to make credentialed requests on the server, as it inherits the cookie and authorization headers for the page request.
  • It can make relative requests on the server (ordinarily, fetch requires a URL with an origin when used in a server context).
  • Internal requests (e.g. for +server.js routes) go directly to the handler function when running on the server, without the overhead of an HTTP call.
  • During server-side rendering, the response will be captured and inlined into the rendered HTML by hooking into the text and json methods of the Response object. Note that headers will not be serialized, unless explicitly included via filterSerializedResponseHeaders
  • During hydration, the response will be read from the HTML, guaranteeing consistency and preventing an additional network request.

You can learn more about making credentialed requests with cookies here

fetch
}) => {
const const response: Responseresponse = await fetch: (input: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response> (+1 overload)fetch('/api/user'); // 永远不要这样做!
const user: {
    set: (value: any) => void;
}
user
.set: (value: any) => voidset(await const response: Responseresponse.Body.json(): Promise<any>json());
};

与前面的例子一样,这将一个用户的信息放在了所有用户共享的地方。相反,应该直接返回数据...

+page
/** @type {import('./$types').PageServerLoad} */
export async function 
function load({ fetch }: {
    fetch: any;
}): Promise<{
    user: any;
}>
@type{import('./$types').PageServerLoad}
load
({ fetch: anyfetch }) {
const const response: anyresponse = await fetch: anyfetch('/api/user'); return { user: anyuser: await const response: anyresponse.json() }; }
import type { 
type PageServerLoad = (event: Kit.ServerLoadEvent<Record<string, any>, Record<string, any>, string | null>) => MaybePromise<void | Record<string, any>>
type PageServerLoad = (event: Kit.ServerLoadEvent<Record<string, any>, Record<string, any>, string | null>) => MaybePromise<void | Record<string, any>>
PageServerLoad
} from './$types';
export const const load: PageServerLoadload:
type PageServerLoad = (event: Kit.ServerLoadEvent<Record<string, any>, Record<string, any>, string | null>) => MaybePromise<void | Record<string, any>>
type PageServerLoad = (event: Kit.ServerLoadEvent<Record<string, any>, Record<string, any>, string | null>) => MaybePromise<void | Record<string, any>>
PageServerLoad
= async ({
fetch: {
    (input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
    (input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>;
}

fetch is equivalent to the native fetch web API, with a few additional features:

  • It can be used to make credentialed requests on the server, as it inherits the cookie and authorization headers for the page request.
  • It can make relative requests on the server (ordinarily, fetch requires a URL with an origin when used in a server context).
  • Internal requests (e.g. for +server.js routes) go directly to the handler function when running on the server, without the overhead of an HTTP call.
  • During server-side rendering, the response will be captured and inlined into the rendered HTML by hooking into the text and json methods of the Response object. Note that headers will not be serialized, unless explicitly included via filterSerializedResponseHeaders
  • During hydration, the response will be read from the HTML, guaranteeing consistency and preventing an additional network request.

You can learn more about making credentialed requests with cookies here

fetch
}) => {
const const response: Responseresponse = await fetch: (input: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response> (+1 overload)fetch('/api/user'); return { user: anyuser: await const response: Responseresponse.Body.json(): Promise<any>json() }; };

...然后将它传递给需要它的组件,或使用 page.data

如果您不使用 SSR,那么就不会有意外将一个用户数据暴露给另一个用户的风险。但您仍然应该避免在 load 函数中产生副作用 — 这样您的应用程序会更容易理解。

使用带上下文的状态和 stores

您可能会疑惑,如果我们不能使用全局状态,我们如何使用 page.data 和其他 app 状态(或 app stores)。答案是 app 状态和 app stores 在服务端使用 Svelte 的 context API — 状态(或 store)通过 setContext 附加到组件树上,当您订阅时,通过 getContext 检索它。我们可以用同样的方式处理我们自己的状态:

src/routes/+layout
<script>
	import { setContext } from 'svelte';

	/** @type {{ data: import('./$types').LayoutData }} */
	let { data } = $props();

	// 将引用我们状态的函数
	// 传递给上下文,供子组件访问
	setContext('user', () => data.user);
</script>
<script lang="ts">
	import { setContext } from 'svelte';
	import type { LayoutData } from './$types';

	let { data }: { data: LayoutData } = $props();

	// 将引用我们状态的函数
	// 传递给上下文,供子组件访问
	setContext('user', () => data.user);
</script>
src/routes/user/+page
<script>
	import { getContext } from 'svelte';

	// 从上下文中获取 user store
	const user = getContext('user');
</script>

<p>Welcome {user().name}</p>
<script lang="ts">
	import { getContext } from 'svelte';

	// 从上下文中获取 user store
	const user = getContext('user');
</script>

<p>Welcome {user().name}</p>

我们传递一个函数到 setContext 以保持跨边界的响应性。在这里阅读更多相关信息

Legacy mode

您也可以使用 svelte/store 中的 stores 来实现这一点,但在使用 Svelte 5 时,建议使用通用响应性。

在通过 SSR 渲染页面时,在更深层次的页面或组件中更新基于上下文的状态值不会影响父组件中的值,因为在状态值更新时父组件已经被渲染完成。

相比之下,在客户端(当启用 CSR 时,这是默认设置)这个值会被传播,层级更高的组件、页面和布局会对新值作出反应。因此,为了避免在水合过程中状态更新时值”闪烁”,通常建议将状态向下传递给组件,而不是向上传递。

如果您不使用 SSR(并且可以保证将来也不需要使用 SSR),那么您可以安全地将状态保存在共享模块中,而无需使用 context API。

组件和页面状态会被保留

当您在应用程序中导航时,SvelteKit 会复用现有的布局和页面组件。例如,如果您有这样的路由...

src/routes/blog/[slug]/+page
<script>
	/** @type {{ data: import('./$types').PageData }} */
	let { data } = $props();

	// 这段代码有 BUG!
	const wordCount = data.content.split(' ').length;
	const estimatedReadingTime = wordCount / 250;
</script>

<header>
	<h1>{data.title}</h1>
	<p>Reading time: {Math.round(estimatedReadingTime)} minutes</p>
</header>

<div>{@html data.content}</div>
<script lang="ts">
	import type { PageData } from './$types';

	let { data }: { data: PageData } = $props();

	// 这段代码有 BUG!
	const wordCount = data.content.split(' ').length;
	const estimatedReadingTime = wordCount / 250;
</script>

<header>
	<h1>{data.title}</h1>
	<p>Reading time: {Math.round(estimatedReadingTime)} minutes</p>
</header>

<div>{@html data.content}</div>

...那么从 /blog/my-short-post 导航到 /blog/my-long-post 不会导致布局、页面和其他组件被销毁和重新创建。相反,data 属性(以及 data.titledata.content)将会更新(就像任何其他 Svelte 组件一样),而且因为代码不会重新运行,像 onMountonDestroy 这样的生命周期方法不会重新运行,estimatedReadingTime 也不会重新计算。

相反,我们需要使这个值变成响应式

src/routes/blog/[slug]/+page
<script>
	/** @type {{ data: import('./$types').PageData }} */
	let { data } = $props();

	let wordCount = $derived(data.content.split(' ').length);
	let estimatedReadingTime = $derived(wordCount / 250);
</script>
<script lang="ts">
	import type { PageData } from './$types';

	let { data }: { data: PageData } = $props();

	let wordCount = $derived(data.content.split(' ').length);
	let estimatedReadingTime = $derived(wordCount / 250);
</script>

如果您需要在导航后重新运行 onMountonDestroy 中的代码,您可以分别使用 afterNavigatebeforeNavigate

像这样复用组件意味着侧边栏滚动状态等会被保留,您可以轻松地在变化的值之间实现动画效果。如果您确实需要在导航时完全销毁并重新挂载一个组件,您可以使用这种模式:

<script>
	import { page } from '$app/state';
</script>

{#key page.url.pathname}
	<BlogPost title={data.title} content={data.title} />
{/key}

在 URL 中存储状态

如果您有需要让状态能够在页面重新加载后依然保持,比如表格上的过滤器或排序规则,URL 搜索参数(如 ?sort=price&order=ascending)是存储它们的好地方。您可以把它们放在 <a href="..."><form action="..."> 属性中,或通过 goto('?key=value') 以编程的方式设置它们。它们可以在 load 函数中通过 url 参数访问,在组件中通过 page.url.searchParams 访问。

在快照中存储临时状态

某些 UI 状态,比如”列表是否展开?”,是可以丢弃的 — 如果用户导航离开或刷新页面,状态丢失并不要紧。在某些情况下,您确实希望在用户导航到另一个页面并返回时数据能够保持,但将状态存储在 URL 或数据库中会显得过度。对于这种情况,SvelteKit 提供了 快照,让您可以将组件状态与历史记录条目关联起来。

在 GitHub 编辑此页面

上一页 下一页