12k
All articles

在 Svelte 中创建 Toast 通知

用 writable store 或 svelte-sonner 在 Svelte 中创建 toast 通知,并涵盖 Svelte 5 语法、无障碍和自动关闭。

OpenReplay Team
OpenReplay Team
在 Svelte 中创建 Toast 通知

Svelte 中的 toast 通知是一种短暂出现在 UI 之上的小型消息,用于确认某个操作或报告错误,并在超时后自行消失。

我们中的大多数人都是在深夜写下第一个 toast 的——就在表单提交成功之后,页面却纹丝不动,仿佛什么都没发生。你有两条可靠的路径:用一个 writable store 加一个容器组件自己搭建一套轻量系统,或者直接引入一个有人维护的库。本文两种方式都会展示,附带可直接复制粘贴的代码,涵盖无障碍访问,并指出那些较早的 Svelte 4 教程中使用了如今在 Svelte 5 里已被弃用的语法之处。

下文的所有内容都面向 Svelte 5,即当前的稳定主版本,自 2024 年 10 月起稳定发布。凡是 Svelte 4 有差异的地方,均会在文中就地注明。

要点速览

  • 在 Svelte 5 中,toast store 几乎没有变化(writable([]) 依然可用),但 toast 组件必须迁移:export let 变为 $propson:click 变为 onclick<slot /> 变为 snippet,而 createEventDispatcher 则由回调 prop 取代。
  • 要让屏幕阅读器播报 toast,需将其渲染在带有 aria-live 的容器内(信息/成功用 polite,错误用 assertive)。在每个 toast 上使用 role="alert" 是该容器方案的替代方案,而非补充:两者同时使用可能导致同一条消息被播报两次。
  • crypto.randomUUID() 为每个 toast 生成不会冲突的 id,并在手动移除时清除其自动消失定时器,这样手动关闭的 toast 就绝不会触发一次过期的移除操作。
  • svelte-sonner 通过 npm i svelte-sonner 安装,在应用根部以 <Toaster /> 渲染一次,之后即可在任何地方通过 toast()toast.success()toast.error()toast.promise() 触发。
  • 当你希望零依赖并完全掌控时,就自己动手实现;当你希望开箱即用地获得 promise toast、滑动关闭、主题化和无障碍支持时,就选择 svelte-sonner。

什么是 toast,什么时候该用它?

toast 是一种短暂的、非阻塞式的反馈:成功/错误/信息类消息,可以堆叠、按定时器自动消失,并且不会像模态框那样打断用户。当你需要确认表单提交、暴露异步错误或者告知后台操作已完成时,就可以使用 toast。但不要把用户必须采取行动或绝不能错过的内容放进 toast,那类内容应该放在内联提示或对话框里,因为 toast 可能在被读到之前就自动消失了。

如何用 store 在 Svelte 中构建一套 toast 系统?

自建系统的核心是一个持有 toast 对象数组的 writable store,外加可以在任何地方调用的 addToast/dismissToast 辅助函数。Svelte store 在 Svelte 5 中依然可用,因此这一模式并未被弃用。更新的 .svelte.ts runes 写法更符合当下惯例,但并非必需。

// src/lib/toast-store.js
import { writable } from 'svelte/store';

export const toasts = writable([]);
const timers = new Map();

export function addToast(toast) {
  const id = crypto.randomUUID();
  const defaults = { id, type: 'info', dismissible: true, timeout: 3000 };
  const t = { ...defaults, ...toast };

  toasts.update((all) => [t, ...all]);

  if (t.timeout) {
    timers.set(id, setTimeout(() => dismissToast(id), t.timeout));
  }
  return id;
}

export function dismissToast(id) {
  const timer = timers.get(id);
  if (timer) {
    clearTimeout(timer);   // stop a stale auto-dismiss from firing later
    timers.delete(id);
  }
  toasts.update((all) => all.filter((t) => t.id !== id));
}

这里有两个正确性细节值得注意。ID 由 crypto.randomUUID() 生成而非 Math.random(),因此不会发生冲突(它只在安全上下文中可用,也就是 HTTPS 或 localhost)。此外,每个 toast 的定时器都记录在一个 Map 中,并在手动关闭时被清除,这样点击关闭按钮就绝不会留下一个指向已被移除的 toast 的 setTimeout

接下来,容器渲染这个数组,以 id 作为 key,并向每个 toast 传入一个关闭回调:

<!-- src/lib/Toasts.svelte -->
<script>
  import Toast from './Toast.svelte';
  import { toasts, dismissToast } from './toast-store.js';
</script>

<section class="toast-container" role="region" aria-live="polite" aria-label="Notifications">
  {#each $toasts as toast (toast.id)}
    <Toast {...toast} ondismiss={() => dismissToast(toast.id)} />
  {/each}
</section>

<style>
  .toast-container {
    position: fixed; top: 1rem; left: 0; right: 0;
    display: flex; flex-direction: column; align-items: center;
    gap: 0.5rem; z-index: 1000; pointer-events: none;
  }
</style>

子组件 Toast.svelte 全程采用 Svelte 5 的写法:用 $props() 接收输入,用 onclick 绑定事件,并用回调 prop 处理关闭:

<!-- src/lib/Toast.svelte (Svelte 5) -->
<script>
  import { fade } from 'svelte/transition';
  let { message, type = 'info', dismissible = true, ondismiss } = $props();
</script>

<article class="toast {type}" transition:fade>
  <p>{message}</p>
  {#if dismissible}
    <button class="close" onclick={() => ondismiss?.()} aria-label="Dismiss notification">×</button>
  {/if}
</article>

<style>
  .toast { display: flex; gap: 1rem; width: 20rem; padding: 0.75rem 1.25rem;
    border-radius: 0.25rem; color: white; pointer-events: auto; }
  .info { background: SteelBlue; }
  .success { background: SeaGreen; }
  .error { background: IndianRed; }
  .close { margin-left: auto; background: none; border: 0; color: inherit;
    font-size: 1.25rem; cursor: pointer; }
</style>

在根布局中挂载一次 <Toasts />,之后即可在任何地方触发:

import { addToast } from '$lib/toast-store.js';
addToast({ message: 'Saved!', type: 'success' });

Svelte 4 与 Svelte 5:发生变化的语法

如果你在照搬某篇较早的 dev.to 教程,store 部分是可以直接迁移的,但组件部分不行。在 Svelte 5 中,export let$props 取代on:click 变成 onclick 属性,<slot /> 被 snippet 取代。最重要的是,createEventDispatcher 已被弃用:关闭按钮应当调用一个回调 prop(ondismiss?.()),而不是派发事件。Svelte 4 版本的 Toast.svelte 开头会是 export let type = 'info'import { createEventDispatcher },并使用 on:click={() => dispatch('dismiss')}——在 Svelte 5 项目中,这些全都是已被弃用的模式。

变体、定位与无障碍访问

有三个 UX 细节区分了「能用的 toast」和「好用的 toast」:变体、过渡动画和屏幕阅读器支持。变体不过是把一个 type 字段映射到背景色上(info/success/error);来自 svelte/transitionfade 过渡负责进出场动画;而一个 position: fixedz-index 较高的容器则让 toast 稳稳地浮在页面之上。

无障碍访问值得单独拿出来讲。要让 toast 被播报,你有两种方式,而且应该只选其中一种。给每个 toast 加 role="alert" 隐含了 aria-live="assertive",浏览器确实会对 alert 节点做特殊处理:MDN 指出,其内容在大多数情况下都会被播报,包括节点在页面加载后被注入的情形。问题在于,这一行为会因浏览器与屏幕阅读器的组合不同而有所差异,因此一个已经存在于 DOM 中的持久性实时区域(live region)是更可预测的选择——这也是上面代码中容器带有 aria-live="polite"、而 toast 本身不带任何 role 的原因。信息类和成功类消息使用 polite,让播报排在用户当前操作之后;对于需要立即引起注意的错误,则将某个容器(或另设一个区域)切换为 assertive

要避免的错误是把两者结合起来。MDN 警告说,同时使用 aria-liverole="alert" 会导致 iOS 上的 VoiceOver 重复朗读;而在一个 polite 区域内渲染一个 assertive 的 alert,同样会引发重复播报。对 toast 实现的会话回放常常暴露出这样一种失败模式:toast 触发了、自动消失了,却从未被用户感知到——没有实时区域,就意味着什么都没有被播报。

改用现成的库:svelte-sonner

svelte-sonner 是那条「即插即用」的路径,而且它就是为 Svelte 5 打造的。它是 Emil Kowalski 的 Sonner 的 Svelte 移植版,沿用了同样有主见的默认配置。安装这个包,在应用根部附近挂载一个 <Toaster />,之后你在代码库任何其他地方触发的每一个 toast 都会渲染在其中。

<script>
  import { Toaster, toast } from 'svelte-sonner';
</script>

<Toaster richColors closeButton position="top-center" duration={5000} />

<button onclick={() => toast.success('Event has been created')}>Success</button>
<button onclick={() => toast.error('Event has not been created')}>Error</button>

这份依赖换来的回报是 toast.promise():它先以加载状态出现,待 promise 结束后再自动替换为成功或错误消息。这正是自己手写起来最繁琐的那个模式:

toast.promise(saveEvent(), {
  loading: 'Saving…',
  success: (data) => `${data.name} saved!`,
  error: 'Could not save'
});

<Toaster /> 接受 positionrichColorscloseButtonduration 等 props;若使用 Tailwind,则可以通过传入一个包含 unstyled: trueclasses 映射的 toastOptions 对象来自行设置 toast 样式。滑动关闭和键盘聚焦(⌥/alt + T)均已内置。npm i svelte-sonner 会解析到 1.x 版本;该项目发布说明中最新的条目是 v1.1.1,修复了一个「设置为永不过期的 toast 在被更新时立即消失」的 bug。

另有两个备选方案。svelte-french-toast 值得了解,但其已发布的稳定版本还停留在 Svelte 4 时代,因此 Svelte 5 用户需要使用诸如 svelte-hot-french-toast 这样的 fork。另一个是 @zerodevx/svelte-toast,其当前的 v0 系列声明的 peer dependencies 覆盖了 Svelte 3、4 和 5。

自己实现还是用 svelte-sonner:如何选择

当你希望零依赖、完全掌控标记结构,或者想借此学习 Svelte store 时,就自己实现;当你希望开箱即用地获得 promise toast、滑动关闭、主题化和无障碍支持时,就选择 svelte-sonner。

需求自己实现svelte-sonner
依赖一个包
标记控制完全掌控通过 toastOptionsunstyled + classes
Promise toast需手动搭建内置 toast.promise()
滑动关闭自行实现内置
无障碍访问需自行接入 aria-live已处理
支持 Svelte 5是(配合 runes/回调 prop)是,原生支持

在各个库中,svelte-sonner 直接面向 Svelte 5;原版 svelte-french-toast 属于 Svelte 4 时代;而 @zerodevx/svelte-toast 的 v0 系列可跨 Svelte 3、4、5 使用。

如果你的需求只是带自动消失的成功/错误/信息提示,那就从基于 store 的版本开始。它大概只有 60 行代码,还能让你掌握 store 模式。一旦你需要基于 promise 的反馈或滑动手势,就安装 svelte-sonner 并删掉自定义代码。无论选哪种,都请先把 aria-live 区域接好;这是那种最容易被跳过、缺失了又很难被察觉的细节。

常见问题

createEventDispatcher 在 Svelte 5 中还能用吗?

它仍然可以运行,但在 Svelte 5 中已被弃用,因此现有使用它的组件依然能正常工作,而新代码不应再采用。对于像 toast 关闭这类事件的派发,官方的替代方案是回调 prop,例如传入一个 ondismiss 函数,并在关闭按钮中调用 ondismiss?.()。Svelte 文档将回调 prop 和 $host() rune 列为推荐替代方案。

应该给每个 toast 加 role='alert',还是让容器使用 aria-live?

两种方式都可行,但只能选其一,不要同时使用。浏览器会对 role='alert' 做特殊处理,在大多数情况下即使节点是在页面加载后插入的,也会播报其内容,不过这一行为因浏览器与屏幕阅读器的组合而异。一个已经存在于 DOM 中、带有 aria-live 的持久容器是更可预测的选择:信息类和成功类用 aria-live='polite',错误类用 'assertive'。两者同时使用则有重复播报的风险,MDN 指出,同时使用 aria-live 和 role='alert' 会导致 iOS 上的 VoiceOver 重复朗读。

对于 Svelte 5,svelte-sonner 和 svelte-french-toast 有什么区别?

svelte-sonner 直接面向 Svelte 5,安装的是 1.x 版本,具备 promise toast、滑动关闭、richColors 和关闭按钮等功能。已发布的稳定版 svelte-french-toast 属于 Svelte 4 时代,其最后一个稳定版本早于 Svelte 5,因此 Svelte 5 用户需要使用诸如 svelte-hot-french-toast 这样的 fork。svelte-french-toast 存在一个 2.0.0-alpha 版本,但尚未作为 npm 稳定版发布。

在 Svelte 5 中我还能继续用 writable store 管理 toast,还是必须改用 runes?

writable store 在 Svelte 5 中仍然可用且未被弃用,因此用 writable([]) 加上 add 和 dismiss 辅助函数构建的 toasts store 完全有效。在 .svelte.ts 文件中使用 runes 是共享响应式状态的更新惯用模式,但并非强制。必须迁移到 Svelte 5 语法的是消费该 store 的组件,而不是 store 本身。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.