状态管理
如果您习惯于构建仅客户端的应用程序,在跨服务端和客户端的应用中进行状态管理可能会让人感到望而生畏。本节提供了一些避免常见陷阱的建议。
避免在服务端共享状态
浏览器是有状态的 — 状态在用户与应用程序交互时存储在内存中。相反,服务端是无状态的 — 响应的内容完全取决于请求的内容。
从概念上来说是这样的。实际上,服务端通常是长期运行的,并由多个用户共享。因此,避免在共享变量中存储数据非常重要。例如,考虑以下代码:
let let user: any
user;
/** @type {import('./$types').PageServerLoad} */
export function function load(): {
user: any;
}
load() {
return { user: any
user };
}
/** @satisfies {import('./$types').Actions} */
export const const actions: {
default: ({ request }: {
request: any;
}) => Promise<void>;
}
actions = {
default: ({ request }: {
request: any;
}) => Promise<void>
default: async ({ request: any
request }) => {
const const data: any
data = await request: any
request.formData();
// 永远不要这样做!
let user: any
user = {
name: any
name: const data: any
data.get('name'),
embarrassingSecret: any
embarrassingSecret: const data: any
data.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: any
user;
export const const load: PageServerLoad
load: 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: any
user };
};
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: FormData
data = await request: Request
The original request object
request.Body.formData(): Promise<FormData>
formData();
// 永远不要这样做!
let user: any
user = {
name: FormDataEntryValue | null
name: const data: FormData
data.FormData.get(name: string): FormDataEntryValue | null
get('name'),
embarrassingSecret: FormDataEntryValue | null
embarrassingSecret: const data: FormData
data.FormData.get(name: string): FormDataEntryValue | null
get('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 或全局状态,以便在组件中使用这个值:
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>>
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: Response
response = 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) => void
set(await const response: Response
response.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: PageLoad
load: 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: Response
response = 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) => void
set(await const response: Response
response.Body.json(): Promise<any>
json());
};
与前面的例子一样,这将一个用户的信息放在了所有用户共享的地方。相反,应该直接返回数据...
/** @type {import('./$types').PageServerLoad} */
export async function function load({ fetch }: {
fetch: any;
}): Promise<{
user: any;
}>
load({ fetch: any
fetch }) {
const const response: any
response = await fetch: any
fetch('/api/user');
return {
user: any
user: await const response: any
response.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: PageServerLoad
load: 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: Response
response = await fetch: (input: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response> (+1 overload)
fetch('/api/user');
return {
user: any
user: await const response: Response
response.Body.json(): Promise<any>
json()
};
};
...然后将它传递给需要它的组件,或使用 page.data
。
如果您不使用 SSR,那么就不会有意外将一个用户数据暴露给另一个用户的风险。但您仍然应该避免在 load
函数中产生副作用 — 这样您的应用程序会更容易理解。
使用带上下文的状态和 stores
您可能会疑惑,如果我们不能使用全局状态,我们如何使用 page.data
和其他 app 状态(或 app stores)。答案是 app 状态和 app stores 在服务端使用 Svelte 的 context API — 状态(或 store)通过 setContext
附加到组件树上,当您订阅时,通过 getContext
检索它。我们可以用同样的方式处理我们自己的状态:
<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>
<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 会复用现有的布局和页面组件。例如,如果您有这样的路由...
<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.title
和 data.content
)将会更新(就像任何其他 Svelte 组件一样),而且因为代码不会重新运行,像 onMount
和 onDestroy
这样的生命周期方法不会重新运行,estimatedReadingTime
也不会重新计算。
相反,我们需要使这个值变成响应式:
<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>
如果您需要在导航后重新运行
onMount
和onDestroy
中的代码,您可以分别使用 afterNavigate 和 beforeNavigate。
像这样复用组件意味着侧边栏滚动状态等会被保留,您可以轻松地在变化的值之间实现动画效果。如果您确实需要在导航时完全销毁并重新挂载一个组件,您可以使用这种模式:
<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 提供了 快照,让您可以将组件状态与历史记录条目关联起来。