Skip to main content

解锁 SvelteKit 1.24 中的视图过渡

使用 onNavigate 实现流畅的页面过渡

最近,视图过渡 API 在 Web 开发圈引起了广泛关注,这绝非偶然。它简化了两个页面状态之间动画的过程,特别适用于页面过渡。

然而,到目前为止,你很难在 SvelteKit 应用中使用这个 API,因为在导航生命周期的正确位置插入它是很困难的。SvelteKit 1.24 引入了一个新的 onNavigate 生命周期钩子,使视图过渡的集成更加简单——让我们深入了解一下。

视图过渡如何工作

你可以通过调用 document.startViewTransition 并传递一个更新 DOM 的回调来触发视图过渡。就我们今天的目的来说,SvelteKit 将在用户导航时更新 DOM。一旦回调完成,浏览器将过渡到新的页面状态——默认情况下,它会在新旧状态之间进行交叉淡入淡出。

var document: Documentdocument.startViewTransition(async () => {
	await const domUpdate: () => Promise<void>domUpdate(); // 演示用的伪函数
});

在幕后,浏览器做了一些非常聪明的事情。当过渡开始时,它会捕获当前页面状态并拍摄一个截图。在 DOM 更新的过程中,它会保持该截图的显示。一旦 DOM 更新完成,它会捕获新的状态,并在两个状态之间进行动画过渡。

虽然目前这个功能仅在 Chrome(以及其他基于 Chromium 的浏览器)中实现,WebKit 也持支持态度。即使你使用的是不支持的浏览器,这也是一个逐步增强的完美候选,因为我们总是可以回退到非动画式的导航。

需要注意的是,视图过渡是一个浏览器 API,而不是 SvelteKit 的功能。onNavigate 是我们今天唯一会用到的 SvelteKit 特定的 API,其他的可以在任何 Web 开发中使用!关于视图过渡 API 的更多信息,我强烈推荐 Jake Archibald 的 Chrome explainer

onNavigate 如何工作

在学习如何编写视图过渡之前,我们先来看一下让这一切成为可能的函数:onNavigate

在最近,SvelteKit 有两个导航生命周期函数:beforeNavigate,它在导航开始之前触发;另一个是 afterNavigate,它在导航后的页面更新完成后触发。SvelteKit 1.24 引入了第三个:onNavigate,它将在每次导航时触发,在新页面呈现出来之前立即运行。重要的是,它会在页面数据加载完成之后运行——因为启动视图过渡会阻止页面交互,我们希望尽可能晚地启动它。

你还可以从 onNavigate 返回一个 Promise,这会延迟导航的完成,直到 Promise 被解析。这允许我们在视图过渡开始之前推迟完成导航。

function function delayNavigation(): Promise<unknown>delayNavigation() {
	return new 
var Promise: PromiseConstructor
new <unknown>(executor: (resolve: (value: unknown) => void, reject: (reason?: any) => void) => void) => Promise<unknown>

Creates a new Promise.

@paramexecutor A callback used to initialize the promise. This callback is passed two arguments: a resolve callback used to resolve the promise with a value or the result of another promise, and a reject callback used to reject the promise with a provided reason or error.
Promise
((res: (value: unknown) => voidres) => function setTimeout(callback: (args: void) => void, ms?: number): NodeJS.Timeout (+2 overloads)setTimeout(res: (value: unknown) => voidres, 100));
} onNavigate(async (navigation) => { // 在导航完成前立即执行一些操作 // 可选地返回一个 Promise,以便延迟导航直到其被解析 return function delayNavigation(): Promise<unknown>delayNavigation(); });

说到这里,让我们看看如何在 SvelteKit 应用中使用视图过渡。

开始使用视图过渡

要查看视图过渡的实际效果,最好的方式是自己试一试。你可以通过在本地终端运行 npm create svelte@latest 或者在 StackBlitz 中创建一个 SvelteKit 演示应用。确保使用支持视图过渡 API 的浏览器。应用运行后,在 src/routes/+layout.svelte 文件的脚本块中添加以下内容。

import { function onNavigate(callback: (navigation: import("@sveltejs/kit").OnNavigate) => MaybePromise<void | (() => void)>): void

A lifecycle function that runs the supplied callback immediately before we navigate to a new URL except during full-page navigations.

If you return a Promise, SvelteKit will wait for it to resolve before completing the navigation. This allows you to — for example — use document.startViewTransition. Avoid promises that are slow to resolve, since navigation will appear stalled to the user.

If a function (or a Promise that resolves to a function) is returned from the callback, it will be called once the DOM has updated.

onNavigate must be called during a component initialization. It remains active as long as the component is mounted.

onNavigate
} from '$app/navigation';
function onNavigate(callback: (navigation: import("@sveltejs/kit").OnNavigate) => MaybePromise<void | (() => void)>): void

A lifecycle function that runs the supplied callback immediately before we navigate to a new URL except during full-page navigations.

If you return a Promise, SvelteKit will wait for it to resolve before completing the navigation. This allows you to — for example — use document.startViewTransition. Avoid promises that are slow to resolve, since navigation will appear stalled to the user.

If a function (or a Promise that resolves to a function) is returned from the callback, it will be called once the DOM has updated.

onNavigate must be called during a component initialization. It remains active as long as the component is mounted.

onNavigate
((navigation: OnNavigatenavigation) => {
if (!var document: Documentdocument.startViewTransition) return; return new
var Promise: PromiseConstructor
new <void | (() => void)>(executor: (resolve: (value: void | (() => void) | PromiseLike<void | (() => void)>) => void, reject: (reason?: any) => void) => void) => Promise<void | (() => void)>

Creates a new Promise.

@paramexecutor A callback used to initialize the promise. This callback is passed two arguments: a resolve callback used to resolve the promise with a value or the result of another promise, and a reject callback used to reject the promise with a provided reason or error.
Promise
((resolve: (value: void | (() => void) | PromiseLike<void | (() => void)>) => voidresolve) => {
var document: Documentdocument.startViewTransition(async () => { resolve: (value: void | (() => void) | PromiseLike<void | (() => void)>) => voidresolve(); await navigation: OnNavigatenavigation.Navigation.complete: Promise<void>

A promise that resolves once the navigation is complete, and rejects if the navigation fails or is aborted. In the case of a willUnload navigation, the promise will never resolve

complete
;
}); }); });

这样,每次导航都会触发视图过渡。你可以立即看到效果——默认情况下,浏览器会在新旧页面之间交叉淡入淡出。

代码解析

这段代码看起来可能有点复杂——如果你感兴趣,我可以逐行解析它,但目前你只需知道,添加它后你就可以在导航期间与视图过渡 API 进行交互。

正如前面提到的,onNavigate 回调会在导航后新页面呈现之前立即运行。在回调中,我们检查 document.startViewTransition 是否存在。如果不存在(即浏览器不支持),我们会提前退出。

然后我们返回一个 Promise,延迟导航的完成,直到视图过渡启动为止。我们使用 Promise 构造函数,以便我们可以控制 Promise 的解析时间。

return new 
var Promise: PromiseConstructor
new <unknown>(executor: (resolve: (value: unknown) => void, reject: (reason?: any) => void) => void) => Promise<unknown>

Creates a new Promise.

@paramexecutor A callback used to initialize the promise. This callback is passed two arguments: a resolve callback used to resolve the promise with a value or the result of another promise, and a reject callback used to reject the promise with a provided reason or error.
Promise
((resolve: (value: unknown) => voidresolve) => {
var document: Documentdocument.startViewTransition(async () => { resolve: (value: unknown) => voidresolve(); await navigation.complete; }); });

在 Promise 构造函数中,我们启动了视图过渡。在视图过渡的回调中,我们解析了刚刚返回的 Promise,这表示 SvelteKit 应该完成导航。这里关键的一点是,导航需要等到视图过渡启动之后完成——浏览器需要捕获旧状态的快照,以便过渡到新状态。

最后,在视图过渡回调中,我们通过等待 navigation.complete 确保 SvelteKit 完成导航。一旦 navigation.complete 被解析,新页面就会加载到 DOM 中,浏览器可以在两个状态之间进行动画过渡。

虽然有些繁琐,但通过不进行抽象化处理,我们允许你直接操作视图过渡,进行你需要的任何定制。

使用 CSS 自定义过渡

我们还可以使用 CSS 动画来自定义页面过渡。在 +layout.svelte 文件的样式块中添加以下 CSS 规则。

@keyframes fade-in {
	from {
		opacity: 0;
	}
}

@keyframes fade-out {
	to {
		opacity: 0;
	}
}

@keyframes slide-from-right {
	from {
		transform: translateX(30px);
	}
}

@keyframes slide-to-left {
	to {
		transform: translateX(-30px);
	}
}

:root::view-transition-old(root) {
	animation:
		90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
		300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}

:root::view-transition-new(root) {
	animation:
		210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
		300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}

导航页面时,旧页面将淡出并滑向左侧,新页面将淡入并从右侧滑入。这些动画样式源自 Jake Archibald 的优秀文章,如果你想了解这个 API 的所有功能,非常值得一读。

需要注意的是,我们必须在 ::view-transition 伪元素前添加 :root——这些元素只存在于文档的根节点,所以我们不希望 Svelte 将它们作用域化到组件内部。

你可能注意到,整个页面都会滑入和滑出,即使新旧页面的头部相同。为了实现更流畅的过渡,我们可以为头部元素指定一个唯一的 view-transition-name,这样它就会单独动画,而不是与页面其余部分一起动画。在 src/routes/Header.svelte 文件中,找到 header 的 CSS 选择器,并添加视图过渡名称。

header {
	display: flex;
	justify-content: space-between;
	view-transition-name: header;
}

这样,头部在导航时不会过渡进出页面,但页面其他部分会动画过渡。

修复类型提示

由于 startViewTransition 并非所有浏览器都支持,你的 IDE 可能不知道它的存在。为了消除错误并获得正确的类型提示,将以下内容添加到 app.d.ts 文件中:

declare global {
	// 保留现有的自定义内容
	namespace App {
		// interface Error {}
		// interface Locals {}
		// interface PageData {}
		// interface Platform {}
	}

	// 添加以下几行
	interface ViewTransition {
		updateCallbackDone: Promise<void>;
		ready: Promise<void>;
		finished: Promise<void>;
		skipTransition: () => void;
	}

	interface Document {
		startViewTransition(updateCallback: () => Promise<void>): ViewTransition;
	}
}

export {};