Toast 是一個提供使用者操作回饋的元件,通常會在幾秒鐘後自動消失。這類元件可以用來通知使用者新增或修改資料的成功或失敗,或者刪除訊息的成功或失敗。有時候還會提供像是 UNDO 按鈕,讓使用者可以撤銷前一個動作。
在各個 UI Library 中,Toast 的名稱可能有所不同。例如,在 Nuxt UI 中,這個元件被稱為 Notification;在 Element Plus 中,有 Notification 和 Message 元件;而在 PrimeVue 中則一樣稱為 Toast。Vuetify 因為遵循 Material Design 規範,將其命名為 Snackbar。
Toast 這個名稱源自其顯示方式和 UI 表現上的特徵。日常生活中的 toast 指烤吐司,這種食物通常在早晨被快速製作和享用。類似地,Toast 消息通常在螢幕上短暫地「彈出」,並在一段時間後自動消失,就像一片烤好的吐司會迅速被拿走一樣。
這個名稱的由來反映了該 UI 元件的設計意圖:向使用者提供即時、短暫且不干擾的通知。這種 UI 設計模式允許應用程式在不打斷用戶主要活動的情況下,向其提供重要的資訊或回饋。因此,Toast 這個名稱形象地表達了這類消息的快速性和短暫性。
Element Plus 的文件則說明,Notification 通常用於被動提醒或系統級別的通知,例如收到推播訊息或帳號即將被登出;而 Message 則用於提供使用者主動操作的回饋,例如新增、修改或刪除資料的成功或失敗。
或許在專案中不一定會區分得這麼詳細,但如果專案比較龐大且有不同的通知類型,這樣的簡單區分有助於溝通協作更加順暢。
在開始實作前,我們先研究各個 UI Library 的 Toast(Message)、Notification 與 Snackbar 元件是如何設計的。
Element Plus
import { h } from 'vue'
import { ElMessage } from 'element-plus'
const open = () => {
ElMessage('This is a message.')
}
const openVn = () => {
ElMessage({
message: h('p', { style: 'line-height: 1; font-size: 14px' }, [
h('span', null, 'Message can be '),
h('i', { style: 'color: teal' }, 'VNode'),
]),
})
}
const openHTML = () => {
ElMessage({
dangerouslyUseHTMLString: true,
message: '<strong>This is <i>HTML</i> string</strong>',
})
}
與其他元件非常不同,ElMessage
在使用時會當作 function 呼叫,而不是當作 component 使用。這意味著我們可以在任意時機點開啟 message,而不需要在 template 中預先準備好元件。此外,ElMessage
也支援傳入 VNode
或是 HTML 作為內容,這為顯示比較複雜結構的 Message 提供了一定的自定義能力。
Vuetify
<template>
<div class="text-center">
<VBtn
color="red-darken-2"
@click="snackbar = true"
>
Open Snackbar
</VBtn>
<VSnackbar
v-model="snackbar"
multi-line
>
{{ text }}
<template v-slot:actions>
<VBtn
color="red"
variant="text"
@click="snackbar = false"
>
Close
</VBtn>
</template>
</VSnackbar>
</div>
</template>
Vuetify 的 <VSnackbar>
使用方式與我們熟悉的元件相似,需要在 template 中預先準備好元件,並透過 v-model
控制開啟與關閉。此外,依循 Material Design 的規範,<VSnackbar>
一次只會出現一個,不會有多個 Snackbar 同時出現。
Nuxt UI
<template>
<div>
<UContainer>
<NuxtPage />
</UContainer>
<UNotifications />
</div>
</template>
在 Nuxt UI 中,使用 Notification 通常需要在最外層(例如 app.vue
)加上 <UNotifications>
作為所有 Notification 的容器。
<script setup lang="ts">
const toast = useToast()
</script>
<template>
<UButton label="Show toast" @click="toast.add({ title: 'Hello world!' })" />
</template>
開啟 Notification 時,不需要在 template 中預先準備好元件,而是使用 useToast
這個 Composable API 來取得開啟 Notification 的方法。
不難發現,許多 UI Library 在處理 Toast 時,都設計成讓使用者可以透過 function 呼叫來開啟元件,差別在於是否需要提供一個容器元件。
因此,這個元件的特別之處在於,除了設計元件本身外,我們還需要設計一個 Composable API 或 helper function,使使用者可以在任意時機點開啟 Toast,而不需要在每個 template 中預先準備好元件。
我們簡單列舉一些 <AtomicToast>
元件的功能需求:
type
來設定 Toast 的樣式,例如 success
、warning
、danger
、info
。message
來設定 Toast 的內容。duration
來設定 Toast 的顯示時間。使用方式如下:
我們選用與 Nuxt UI 類似的方式,透過 Composable API 搭配 <AtomicToasts>
來開啟 Toast。
<template>
<div>
<RouterView />
<AtomicToasts />
</div>
</template>
const { open } = useToast()
const onClick = () => {
open({
type: 'success',
message: 'Hello world!',
})
}
首先,我們將需求中提到的功能整理成 props
的介面,我們會需要下列屬性:
名稱 | 型別 | 預設值 | 說明 |
---|---|---|---|
type |
success , warning , danger , info |
info |
Toast 的樣式 |
message |
string |
Toast 的內容 | |
duration |
number |
3000 |
Toast 的顯示時間 |
interface AtomicToastProps {
message: string;
type?: 'success' | 'warning' | 'danger' | 'info';
duration?: number;
}
const props = withDefaults(defineProps<AtomicToastProps>(), {
type: 'success',
duration: 3000,
});
樣式與 template 結構會依專案需求有所不同,這裡僅提供一個簡單範例。
<template>
<div
class="atomic-toast"
:class="{
[`atomic-toast--${type}`]: !!type,
}"
>
<div class="atomic-toast__container">
<component
:is="ICON_MAP[type]"
class="atomic-notification__status"
fill="var(--toast-color)"
height="20"
width="20"
/>
<p class="atomic-toast__message">
{{ message }}
</p>
<button
class="atomic-toast__close"
type="button"
@click="onClose"
>
<CloseSvg
height="16"
width="16"
/>
</button>
</div>
</div>
</template>
我們的 .atomic-toast
需要使用 position: fixed
來固定在畫面上,這裡設計讓 Toast 出現在右上角。
.atomic-toast {
position: fixed;
top: 16px;
right: 16px;
&__container {
display: flex;
background-color: white;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
}
實現duration
的功能,我們可以使用 setTimeout
來在 duration
時間後自動關閉 Toast。如果設定為 0
則永遠不消失。
const active = ref(false);
let timeout: NodeJS.Timeout | undefined;
const onClose = () => {
active.value = false;
timeout && clearTimeout(timeout);
};
onMounted(() => {
active.value = true;
if (props.duration) {
timeout = setTimeout(onClose, props.duration);
}
});
為了讓 Toast 開啟時能夠有較平滑的效果,我們可以使用 <Transition>
搭配先前設計的動畫效果。
<template>
<Transition name="transition-slide-left">
<div
v-show="active"
class="atomic-toast"
>
<div class="atomic-toast__container">
<!-- 略 -->
</div>
</div>
</Transition>
</template>
有了基礎的樣式後,我們來思考如何使用 Composable API 來開啟 Toast。
我們可以應用在 <AtomicModal>
實作中提到的「資料管理中心」架構來處理。每當呼叫 open
時,就向資料管理中心新增一筆 Toast 資料,這樣就可以在元件內部使用 v-for
來顯示所有的 <AtomicToast>
。
先實作出資料管理中心吧!我們在這裡使用一個陣列來存放所有的 Toast 資料。
const toasts = shallowReactive<Toast[]>([]);
這個 toasts
會提供給 <AtomicToasts>
使用 v-for
來顯示所有的 <AtomicToast>
,因此我們至少需要 key
與 props
,props
可接受的參數與 <AtomicToast>
完全一樣。
import type { AtomicToastProps } from '~/components/AtomicToast.vue';
type Toast = {
key: symbol;
props: AtomicToastProps;
};
接著我們需要 open
function 來開啟 Toast。
open
const open = (options: AtomicToastProps) => {
toasts.push({
key: Symbol(),
props: options,
});
};
我們就初步完成了資料中心的基本設計,接下來將資料中心包成 Vue Plugin 並在初始化時安裝使用。
const TOASTS_MANAGEMENT: InjectionKey<ToastsManager> = Symbol();
const TOASTS: InjectionKey<Toast[]> = Symbol();
export function createToastsManager() {
// 略
const manager: ToastsManager = { open };
return {
app.provide(TOASTS, toasts);
app.provide(TOASTS_MANAGEMENT, manager);
}
}
記得在 Vue 初始化時安裝這個 Plugin。
const toasts = createToastsManager()
const app = createApp(App);
app.use(toasts);
app.mount('#app');
為了方便使用,我們可以再寫兩個 Composable API,一方面方便 <AtomicToasts>
使用,另一方面也讓我們在開發時可以使用 useToast()
而不是 inject(TOASTS_MANAGEMENT)
。
useToasts
export function useToasts() {
const context = inject(TOASTS, null);
if (!context) throw new Error('error');
return context;
}
useToast
export function useToast() {
const context = inject(TOASTS_MANAGEMENT, null);
if (!context) throw new Error('error');
return context;
}
現在我們可以在任意元件中使用 useToast()
回傳的 open
方法來向資料中心新增 Toast 資料,接著實作 <AtomicToasts>
來顯示這些 Toast。
<script setup lang="ts">
import AtomicToast from '~/components/AtomicToast.vue';
import { useToasts } from '~/plugins/toasts';
const toasts = useToasts();
</script>
<template>
<Teleport to="body">
<AtomicToast
v-for="message in toasts"
:key="message.key"
v-bind="message.props"
/>
</Teleport>
</template>
現在 <AtomicToasts>
完成了它的任務,我們已經可以在元件內部開啟 Toast 了。
const { open } = useToast();
const onClick = () => {
open({
type: 'success',
message: 'Hello world!',
});
};
如果一次只開一個 Toast,看起來沒什麼大問題,但如果同時開啟多個 Toast,就會發現它們全部重疊在一起,這是因為我們並沒有給予每個 Toast 不同的偏移。此外,我們也需要考慮前面的 Toast 關閉後,後面的 Toast 如何調整位置,以及 Toast 關閉後從資料中心移除的問題。
我們先從最簡單的步驟開始:Toast 關閉後要從資料中心移除。
由於 <AtomicToast>
有過場動畫處理,所以我們需要等過場動畫結束後再從資料中心移除 Toast。
interface AtomicToastEmits {
(event: 'destroy'): void;
}
const emit = defineEmits<AtomicToastEmits>();
const onAfterLeave = () => emit('destroy');
<template>
<Transition
name="transition-slide-left"
@after-leave="onAfterLeave"
>
<div
v-show="active"
class="atomic-toast"
>
<div class="atomic-toast__container">
<!-- 略 -->
</div>
</div>
</Transition>
</template>
在資料中心裡新增一個 handleDestroy
,處理 Toast 過場動畫結束後觸發的 onDestroy
事件。
const handleDestroy = (key: symbol) => {
const index = toasts.findIndex(message => message.key === key);
if (index === -1) return;
toasts.splice(index, 1);
};
const open = (options: AtomicToastProps) => {
const key = Symbol();
toasts.push({
key,
props: {
...options,
onDestroy: () => handleDestroy(key),
},
});
return key;
};
接著處理:計算每個 Toast 的偏移(offset)。
我們在 <AtomicToasts>
中新增一個 offset
的 props,<AtomicToast>
會依照這個 props 來調整定位的位置。
interface AtomicToastsProps {
offset?: number;
}
const props = withDefaults(defineProps<AtomicToastsProps>(), {
offset: 16,
});
<template>
<Transition name="transition-slide-left">
<div
v-show="active"
class="atomic-toast"
:style="{
'--toast-offset': offset,
}"
>
<div class="atomic-toast__container">
<!-- 略 -->
</div>
</div>
</Transition>
</template>
.atomic-toast {
position: fixed;
top: calc(var(--toast-offset, 16) * 1px);
right: 16px;
}
offset
的計算方式是將前一個 Toast 的 offset
加上前一個 Toast 的高度。
previous.props.offset + previous.element.offsetHeight
為了讓資料中心能夠計算 offset
,我們在 Toast 開始處理入場動畫時,將 Toast 的 DOM 元素存到資料中心。
interface AtomicToastEmits {
(event: 'open', value: Element): void;
}
const emit = defineEmits<AtomicToastEmits>();
const onEnter = (element: Element) => emit('open', element);
<template>
<Transition
name="transition-slide-left"
@enter="onEnter"
>
<div
v-show="active"
class="atomic-toast"
:style="{
'--toast-offset': offset,
}"
>
<div class="atomic-toast__container">
<!-- 略 -->
</div>
</div>
</Transition>
</template>
在資料中心新增一個 handleOpen
,處理 Toast 進場動畫開始時觸發的 onOpen
事件。
const handleOpen = (id: symbol, element: Element) => {
const index = toasts.findIndex(message => message.key === id);
if (index === -1) return;
const message = toasts[index];
message.element = element;
handleUpdateOffset(index);
};
const open = (options: AtomicToastProps) => {
const key = Symbol();
toasts.push({
key,
props: {
...options,
onOpen: (element) => handleOpen(key, element),
},
});
return key;
};
handleOpen
會將 Toast 的 DOM 元素存到資料中心,這樣我們就可以在 handleUpdateOffset
中計算每個 Toast 的 offset
。
const handleUpdateOffset = (index: number, collections = toasts) => {
const previous = collections[index - 1];
let offset = GAP_SIZE;
if (previous) {
offset += previous.props.offset || 0;
offset +=
previous.element instanceof HTMLElement
? previous.element.offsetHeight
: 0;
}
collections[index].props.offset = offset;
};
最後解決:Toast 關閉後重新計算受影響的 offset
。
interface AtomicToastEmits {
(event: 'close'): void;
}
const emit = defineEmits<AtomicToastEmits>();
const onClose = () => {
// 略
emit('close');
}
在資料中心新增一個 handleClose
,處理 Toast 關閉時觸發的 onClose
事件。
不需要全部重新計算,只需找到當前(關閉) Toast 的 index,然後從這個 index 開始重新計算 offset
。
const handleClose = (key: symbol) => {
const index = toasts.findIndex(message => message.key === key);
const cloned = toasts.slice();
cloned.splice(index, 1);
// 從 index 開始更新 offset
for (let i = index; i < cloned.length; i++) {
handleUpdateOffset(i, cloned);
}
};
這樣各個面向的問題都解決了,我們完成了 <AtomicToast>
的實作。
如果我們可以不透過在 App.vue
(app.vue
)中加入 <AtomicToasts>
來渲染 Toast,而是透過 open
(這個段落為了區分改用 notify
)直接開啟 Toast,這樣的使用方式會更加方便。
import notify from '~/helpers/notify'
notify({
type: 'success',
message: 'Hello world!',
})
為了區分差異,這裡使用
notify
為 function 名稱。
既然我們想要省略 <AtomicToasts>
,這就表示我們需要自己處理元件渲染到畫面上的工作。我們可以使用 Vue 提供的 render
與 createVNode
來渲染 <AtomicToast>
元件到畫面上。
import { createVNode, render } from 'vue';
import AtomicToast from '~/components/AtomicToast.vue';
export default function notify(options) {
// 先略過
// 將 Vue Component 轉換成 VNode,並渲染到畫面上
const container = document.createElement('div');
const vnode = createVNode(AtomicToast, props);
render(vnode, container);
document.body.appendChild(container.firstElementChild!);
}
接著,有一些部分需要微調,原本我們的資料管理中心有個 toasts
,它現在純粹用於存放 Toast 資料,而不是用來渲染到畫面上的,所以不需要提供響應能力。
const toasts: Toast[] = [];
// 原本的 open function
export default function notify(options) {
// 先略過
// 將 Vue Component 轉換成 VNode,並渲染到畫面上
const container = document.createElement('div');
const vnode = createVNode(AtomicToast, props);
render(vnode, container);
document.body.appendChild(container.firstElementChild!);
}
Toast
裡面的屬性也需要做一些調整,原本存放的 element
改存放 vnode: VNode
,我們可以透過 vnode
來取得 el
與 Component 上的 props
。
type Toast = {
key: symbol;
props: AtomicToastProps;
vnode?: VNode;
};
接著 handleOpen
的第二個參數原本是接收一個 element
,現在改為接收 vnode
。
const handleOpen = (key: symbol, vnode: VNode) => {
const index = toasts.findIndex(toast => toast.key === key);
if (index === -1) return;
const toast = toasts[index];
toast.vnode = vnode;
handleUpdateOffset(index);
};
handleUpdateOffset
內部也因為我們改存 vnode
而需要做一些調整。
const handleUpdateOffset = (index: number, collections = toasts) => {
const previous = collections[index - 1];
let offset = GAP_SIZE;
if (previous) {
// 這裡改從 node 取得 el(element)與 component 上的 props
const { el, component } = previous.vnode!;
offset += (component?.props.offset as number) || 0;
offset +=
el instanceof HTMLElement
? el.offsetHeight
: 0;
}
const { component } = collections[index].vnode!;
component!.props.offset = offset
};
剩下的 handleClose
與 handleDestroy
直接搬過來即可。
const handleClose = (key: symbol) => {
const index = toasts.findIndex(toast => toast.key === key);
const cloned = toasts.slice();
cloned.splice(index, 1);
// 從 index 開始更新 offset
for (let i = index; i < cloned.length; i++) {
handleUpdateOffset(i, cloned);
}
};
const handleDestroy = (key: symbol) => {
const index = toasts.findIndex(toast => toast.key === key);
if (index === -1) return;
toasts.splice(index, 1);
};
最後我們還有個小地方要調整,因為我們是手動渲染到畫面上,所以在 onDestroy
時需要手動移除元素。
const props = {
onDestroy: () => {
handleDestroy(key);
render(null, container);
}
};
完整的 notify
function 如下:
export default function notify(options: AtomicToastProps) {
const key = Symbol();
const props: ComponentProps<typeof Atomic> = {
...options,
offset: 16,
onOpen: () => handleOpen(key, vnode),
onClose: () => handleClose(key),
onDestroy: () => {
handleDestroy(key);
render(null, container);
}
};
toasts.push({
key,
props,
});
// 將 Vue Component 轉換成 VNode,並渲染到畫面上
const container = document.createElement('div');
const vnode = createVNode(AtomicToast, props);
render(vnode, container);
document.body.appendChild(container.firstElementChild!);
}
這樣我們就完成了 notify
function 的實作,現在我們可以在任意元件中使用 notify
來開啟 Toast。
使用 role="alert"
或 role="status"
來標記 Toast 元件。這些 ARIA 角色會通知輔助技術(如螢幕閱讀器)有新資訊顯示。
通常使用 role="alert"
來標記重要且需要立即告知使用者的訊息,例如錯誤提示;而 role="status"
則用於一般的狀態更新或成功訊息。
<template>
<Transition name="transition-slide-left">
<div
class="atomic-toast"
role="status"
>
<!-- 略 -->
</div>
</Transition>
</template>
aria-live: 這個屬性用於告訴輔助技術如何宣讀更新的內容。aria-live="assertive"
用於高優先級的訊息,會立即打斷當前的發言。而 aria-live="polite"
則用於較低優先級的訊息,僅在輔助技術完成當前發言後再宣讀新內容。
aria-atomic: 設定為 true
時,當 Toast 的內容改變時,整個元素的內容會被重新宣讀,而不僅僅是變更的部分。
預設情況下,如果設定 role="alert"
,這個 Role 本身就預設 aria-live="assertive"
與 aria-atomic="true"
;而 role="status"
這個 Role 本身預設 aria-live="polite"
與 aria-atomic="true"
。
在這篇 <AtomicToast>
元件實作文章中,我們分析了它在不同 UI Library 中的名稱和使用方式。
在實作過程中,我們探討了如何使用 Composable API 和 Vue Plugin 來管理和渲染 Toast 元件。我們實現了一個可以在任意時機點開啟 Toast 的功能,並且計算了每個 Toast 的顯示位置與更新。
在進階功能中,我們也嘗試了 Element Plus 採用的作法,這讓我們不需要 <AtomicToasts>
這個容器元件,只需使用 Vue 的 render
與 createVNode
就可以自行將 <AtomicToast>
渲染到畫面上。
雖然 Element Plus 採用的作法提供了更好的開發體驗,但在設計上相對更為複雜,需要手動控制元件的出現,而非完全依賴響應式設計,這需要非常熟悉 Vue 的 API 才能夠比較從容地掌握。
我自己雖然很喜歡 Element Plus 的這個 API 設計,但在設計上有時候我還是會更偏向選擇有 <AtomicToasts>
的方式,這樣的設計更好地應用了 Vue 的響應式設計,也更容易讓其他開發人員理解。