跳至主要內容

狀態管理

如果您習慣建置僅限客戶端的應用程式,那麼跨越伺服器和客戶端的應用程式中的狀態管理可能會讓人感到畏懼。本節提供一些避免常見陷阱的技巧。

避免在伺服器上共享狀態

瀏覽器是有狀態的 - 當使用者與應用程式互動時,狀態會儲存在記憶體中。另一方面,伺服器是無狀態的 - 回應的內容完全由請求的內容決定。

在概念上是如此。實際上,伺服器通常是長期存在的,並由多個使用者共享。因此,重要的是不要將資料儲存在共享變數中。例如,請考慮以下程式碼

+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(); // NEVER DO THIS! 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();
// NEVER DO THIS! 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,以便可以在元件中使用 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'); // NEVER DO THIS!
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'); // NEVER DO THIS!
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 函式中產生副作用 - 這樣您的應用程式會更容易理解。

在 context 中使用 stores

您可能會想知道,如果我們無法使用自己的 stores,我們如何能夠使用 $page.data 和其他 應用程式 stores。答案是,伺服器上的應用程式 stores 使用 Svelte 的 context API - store 會使用 setContext 附加到元件樹,當您訂閱時,會使用 getContext 擷取它。我們可以使用我們自己的 stores 來執行相同的操作

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

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

	// Create a store and update it when necessary...
	const user = writable(data.user);
	$effect.pre(() => {
		user.set(data.user);
	});

	// ...and add it to the context for child components to access
	setContext('user', user);
</script>
<script lang="ts">
	import { setContext } from 'svelte';
	import { writable } from 'svelte/store';
	import type { LayoutData } from './$types';

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

	// Create a store and update it when necessary...
	const user = writable(data.user);
	$effect.pre(() => {
		user.set(data.user);
	});

	// ...and add it to the context for child components to access
	setContext('user', user);
</script>
src/routes/user/+page
<script>
	import { getContext } from 'svelte';

	// Retrieve user store from context
	const user = getContext('user');
</script>

<p>Welcome {$user.name}</p>

在頁面透過 SSR 呈現時,更新較深層頁面或元件中基於 context 的 store 值,不會影響父元件中的值,因為當 store 值更新時,父元件已經被呈現了。相反地,在客戶端(當啟用 CSR 時,這是預設值)該值將被傳播,並且層次結構中較高的元件、頁面和版面配置將會對新值做出反應。因此,為了避免在 hydration 期間狀態更新時出現值「閃爍」,通常建議將狀態向下傳遞到元件中,而不是向上傳遞。

如果您不使用 SSR(並且可以保證將來不需要使用 SSR),則可以安全地將狀態保留在共享模組中,而無需使用 context API。

元件和頁面狀態會被保留

當您在應用程式中導覽時,SvelteKit 會重複使用現有的版面配置和頁面元件。例如,如果您有類似這樣的路由...

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

	// THIS CODE IS BUGGY!
	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();

	// THIS CODE IS BUGGY!
	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 prop (以及 data.titledata.content 的延伸) 將會更新(就像任何其他 Svelte 元件一樣),並且由於程式碼沒有重新執行,生命週期方法(如 onMountonDestroy)不會重新執行,estimatedReadingTime 也將不會重新計算。

相反地,我們需要使該值 具有反應性

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

	let wordCount = $state(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 = $state(data.content.split(' ').length);
	let estimatedReadingTime = $derived(wordCount / 250);
</script>

如果您的 onMountonDestroy 中的程式碼必須在導覽後再次執行,則可以使用 afterNavigatebeforeNavigate 分別。

重複使用類似這樣的元件表示保留了側邊欄捲動狀態之類的項目,並且您可以在變更值之間輕鬆進行動畫處理。如果您確實需要在導覽時完全銷毀並重新掛載元件,則可以使用此模式

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

將狀態儲存在 URL 中

如果您有應該在重新載入後倖存下來和/或影響 SSR 的狀態,例如表格上的篩選器或排序規則,則 URL 搜尋參數(例如 ?sort=price&order=ascending)是放置它們的好地方。您可以將它們放在 <a href="..."><form action="..."> 屬性中,或透過 goto('?key=value') 以程式方式設定它們。它們可以在 load 函式內透過 url 參數存取,並且可以在元件內透過 $page.url.searchParams 存取。

將暫時狀態儲存在快照中

某些 UI 狀態,例如「手風琴是否打開?」,是可處置的 - 如果使用者導覽離開或重新整理頁面,狀態是否遺失並不重要。在某些情況下,您確實希望在使用者導覽到不同頁面並返回時保留資料,但將狀態儲存在 URL 中或資料庫中會過於繁瑣。為此,SvelteKit 提供了 快照,可讓您將元件狀態與歷史記錄項目相關聯。

在 GitHub 上編輯此頁面