跳至主要内容

在 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(); // mock function for demonstration purposes
});

在幕后,浏览器做了一些非常巧妙的事情。当过渡开始时,它会捕获页面的当前状态并截取屏幕截图。然后,它会保留该屏幕截图,同时 DOM 正在更新。DOM 更新完成后,它会捕获新状态,并在两个状态之间进行动画过渡。

虽然目前它仅在 Chrome(以及其他基于 Chromium 的浏览器)中实现,但WebKit 也表示支持它。即使您使用的是不受支持的浏览器,它也是渐进增强的一个完美候选者,因为我们始终可以回退到非动画导航。

需要注意的是,视图过渡是一个浏览器 API,而不是 SvelteKit API。onNavigate 是我们今天将使用的唯一 SvelteKit 特定 API。其他所有内容都可以在您编写 Web 代码的任何地方使用!有关视图过渡 API 的更多信息,我强烈推荐 Jake Archibald 编写的Chrome 说明文档

onNavigate 的工作原理

在学习如何编写视图过渡之前,让我们重点介绍使这一切成为可能的函数:onNavigate

直到最近,SvelteKit 还有两个导航生命周期函数:beforeNavigate,它在导航开始之前触发,以及afterNavigate,它在导航后页面更新后触发。SvelteKit 1.24 引入了第三个函数:onNavigate,它将在每次导航时触发,紧接在渲染新页面之前。重要的是,它将在页面数据加载完成后运行——由于启动视图过渡会阻止与页面的任何交互,因此我们希望尽可能晚地启动它。

您还可以从 onNavigate 返回一个 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) => { // do some work immediately before the navigation completes // optionally return a promise to delay navigation until it resolves 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 应该完成导航。重要的是,导航必须等到我们启动视图过渡>之后才完成——浏览器需要对旧状态进行快照,以便能够过渡到新状态。

最后,在视图过渡回调函数内部,我们等待 SvelteKit 完成导航,方法是等待 navigation.completenavigation.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 优秀的关于视图过渡的 Chrome 开发者文章,如果您想了解可以使用此 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 {
	// preserve any customizations you have here
	namespace App {
		// interface Error {}
		// interface Locals {}
		// interface PageData {}
		// interface Platform {}
	}

	// add these lines
	interface ViewTransition {
		updateCallbackDone: Promise<void>;
		ready: Promise<void>;
		finished: Promise<void>;
		skipTransition: () => void;
	}

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

export {};

过渡单个元素

我们刚刚了解了如何通过为元素提供 view-transition-name 将其与页面其余部分的动画分开。设置 view-transition-name 还会指示浏览器在过渡完成后将其平滑地动画到新位置。view-transition-name 充当唯一标识符,以便浏览器可以识别来自旧状态和新状态的匹配元素。

让我们看看它是如何工作的——我们的演示应用程序的导航有一个小三角形指示当前页面。现在,在导航后,它会突然出现在新位置。让我们为它提供一个 view-transition-name,以便浏览器将其动画到新位置。

src/routes/Header.svelte 中,找到创建活动页面指示器的 CSS 规则,并为其提供一个 view-transition-name

li[aria-current='page']::before {
	/* other existing rules */
	view-transition-name: active-page;
}

通过添加这行代码,指示器现在将平滑地滑动到新位置,而不是跳跃。

(您可能很容易错过差异——请查看屏幕顶部的那个小的移动三角形指示器!)

减少运动

在 Web 上实现动画时,尊重用户的运动偏好非常重要。仅仅因为您可以实现极端的页面过渡并不意味着您应该这样做。要为偏好减少运动的用户禁用所有页面过渡,您可以将以下内容添加到全局 styles.css

@media (prefers-reduced-motion) {
	::view-transition-group(*),
	::view-transition-old(*),
	::view-transition-new(*) {
		animation: none !important;
	}
}

虽然这可能是最安全的选择,但减少运动并不一定意味着没有动画。相反,您可以根据具体情况考虑您的视图过渡。例如,也许我们禁用了滑动动画,但保留了默认的交叉淡入淡出(不涉及运动)。您可以通过将要禁用的 ::view-transition 规则包装在 prefers-reduced-motion: no-preference 媒体查询中来实现这一点

@media (prefers-reduced-motion: no-preference) {
	: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;
	}
}

下一步是什么?

如您所见,SvelteKit 并没有对视图过渡的工作原理进行太多抽象——您直接与浏览器的内置 document.startViewTransition::view-transition API 交互,而不是像 Nuxt 和 Astro 中那样的框架抽象。我们热切地期待着了解人们最终如何在 SvelteKit 应用程序中使用视图过渡,以及在未来是否有必要添加我们自己的更高级别的抽象。

资源

您可以在 GitHub 上 找到本文的演示代码,以及 部署到 Vercel 的在线版本。以下是一些您可能觉得有用的其他视图过渡资源