在真實專案裡,訊息傳遞可以分為兩派魔法:
我們用 Teleport 把 UI 傳送到 <body>
最上層,擺脫 z-index 地獄;用 Transition 讓現身/退場如絲綢般順滑;以 Pinia 管控全域狀態,達到「在任何組件只要一行就能呼叫 toast/modal」的標準化體驗。你將獲得一致、可測試、可國際化的全站訊息系統。
這個當好就是一個最好的範例
因為我們前端工程師就是要讓使用者體驗更完美~
有時候一些提醒除了可以避免掉user操作失敗(彈跳視窗)
或是簡易的提醒(右上角的toast)
都可以讓我們操作這個網頁變得更加人性化喔~!!
與其說這是剛好這也是vue-teleport的魔法~!!!
需求 | 使用者 | 目的 | 功能點 |
---|---|---|---|
刪除前二次確認 | 祕書/管理者 | 避免誤刪 | modal.confirm({ title, message, onOk }) |
送單成功小提醒 | 一般使用者 | 給正向回饋 | toast.success('已送出') |
API 失敗提示 | 所有人 | 快速理解失敗原因 | Axios response 攔截器 → toast.error(msg) |
權限不足阻斷 | 一般使用者 | 導向登入或說明 | modal.alert('需登入') |
多筆錯誤彙整 | 客服/祕書 | 不被多個 alert 轟炸 | Toast queue + 自動收起、可手動關閉 |
按照老慣例
我們一樣先畫出時序圖吧
來看看整個modal的變化
程式碼的流程非常直覺
就是
src/
components/
ModalHost.vue ← 全域模態窗傳送門 + 過場 + ARIA
ToastHost.vue ← 全域吐司傳送門 + 過場 + aria-live
stores/
modalStore.js ← Modal 狀態(alert/confirm/custom)
toastStore.js ← Toast 隊列(success/info/warn/error)
services/
http.js ← Axios 實例(加入錯誤攔截器→toast)
App.vue ← 掛載 <ModalHost/> 與 <ToastHost/>
你已經有
http.js
的話,只要把「攔截器」那段貼進去即可,不會破壞既有 API。
Teleport 是什麼?
把組件的 DOM **「傳送」**到別處(如 <body>
),避免被父層樣式/層級壓住。Modal/Toast 天生適合被 Teleport 到全域層。
為什麼要用?
基本語法:
<Teleport to="body">
<div class="modal">...</div>
</Teleport>
src/stores/modalStore.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
/**
* 一次只顯示一個 Modal;如需多層可把 queue 換成陣列。
* modal.open({ type, title, message, okText, cancelText, onOk, onCancel, content })
*/
export const useModalStore = defineStore('modal', () => {
const open = ref(false)
const payload = ref({
type: 'confirm', // 'confirm' | 'alert' | 'custom'
title: '',
message: '',
okText: '確認',
cancelText: '取消',
onOk: null,
onCancel: null,
content: null // 自定義渲染函數/組件(示範先不用)
})
function show(opts = {}) {
payload.value = Object.assign({
type: 'confirm',
title: '',
message: '',
okText: '確認',
cancelText: '取消',
onOk: null,
onCancel: null,
content: null
}, opts)
open.value = true
}
function alert(message, opts = {}) {
show({ type: 'alert', message, title: opts.title || '提示', okText: opts.okText || '知道了', onOk: opts.onOk || null })
}
function confirm(opts) {
// opts: { title, message, okText, cancelText, onOk, onCancel }
show(Object.assign({ type: 'confirm' }, opts))
}
function close() { open.value = false }
async function ok() {
const fn = payload.value.onOk
close()
if (typeof fn === 'function') await fn()
}
async function cancel() {
const fn = payload.value.onCancel
close()
if (typeof fn === 'function') await fn()
}
return { open, payload, show, alert, confirm, close, ok, cancel }
})
src/components/ModalHost.vue
<script setup>
import { useModalStore } from '../stores/modalStore'
const modal = useModalStore()
function onBackdrop(e){
// 只有點到黑幕才關閉,不影響內容點擊
if (e.target === e.currentTarget) {
if (modal.payload.type === 'alert') {
modal.ok()
} else {
modal.cancel()
}
}
}
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="modal.open" class="modal-backdrop" @click="onBackdrop">
<div class="modal-panel" role="dialog" aria-modal="true" :aria-label="modal.payload.title || 'Dialog'">
<header class="modal-header" v-if="modal.payload.title">
<h3>{{ modal.payload.title }}</h3>
</header>
<section class="modal-body">
<p v-if="modal.payload.message">{{ modal.payload.message }}</p>
<component v-if="modal.payload.content" :is="modal.payload.content" />
</section>
<footer class="modal-actions">
<button v-if="modal.payload.type==='confirm'" class="btn" @click="modal.cancel">{{ modal.payload.cancelText }}</button>
<button class="btn primary" @click="modal.ok">{{ modal.payload.okText }}</button>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
display: grid; place-items: center; z-index: 10000;
}
.modal-panel {
width: min(560px, 92vw); background: #fff; color: #111; border-radius: 14px;
border: 1px solid #e5e7eb; box-shadow: 0 24px 80px rgba(0,0,0,.22);
overflow: hidden;
}
.modal-header { padding: 14px 16px; border-bottom: 1px solid #f1f5f9; }
.modal-body { padding: 16px; line-height: 1.6; }
.modal-actions { display:flex; gap:8px; justify-content:flex-end; padding: 12px 16px; border-top: 1px solid #f1f5f9; }
/* 過場動畫 */
.modal-enter-from { opacity: 0; }
.modal-enter-active, .modal-leave-active { transition: opacity .18s ease; }
.modal-leave-to { opacity: 0; }
.modal-panel { transform: translateY(4px) scale(.98); transition: transform .18s ease; }
.modal-enter-active .modal-panel { transform: translateY(0) scale(1); }
.modal-leave-active .modal-panel { transform: translateY(-2px) scale(.985); }
@media (prefers-reduced-motion: reduce) {
.modal-enter-active, .modal-leave-active, .modal-panel { transition-duration: 0s; }
}
/* 簡易按鈕 */
.btn { padding: 8px 12px; border-radius: 10px; border: 1px solid #e5e7eb; background:#fff; cursor: pointer; }
.btn.primary { background:#111827; color:#fff; border-color:#111827; }
.btn:hover { filter: brightness(0.98); }
</style>
src/stores/toastStore.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
let idSeed = 1
export const useToastStore = defineStore('toast', () => {
const items = ref([]) // [{ id, type, message, duration }]
function push({ type = 'info', message = '', duration = 3000 } = {}) {
const id = idSeed++
items.value.push({ id, type, message, duration })
setTimeout(() => remove(id), duration)
}
function remove(id) { items.value = items.value.filter(t => t.id !== id) }
const info = (m, d) => push({ type: 'info', message: m, duration: d })
const success = (m, d) => push({ type: 'success', message: m, duration: d })
const warn = (m, d) => push({ type: 'warn', message: m, duration: d })
const error = (m, d) => push({ type: 'error', message: m, duration: d })
return { items, push, remove, info, success, warn, error }
})
src/components/ToastHost.vue
<script setup>
import { useToastStore } from '../stores/toastStore'
const toast = useToastStore()
</script>
<template>
<Teleport to="body">
<div class="toast-root" role="region" aria-live="polite" aria-atomic="false">
<TransitionGroup name="toast" tag="div" class="toast-stack">
<div v-for="t in toast.items" :key="t.id" class="toast" :data-type="t.type">
<span class="toast-icon" aria-hidden="true">●</span>
<span class="toast-msg">{{ t.message }}</span>
<button class="toast-close" @click="toast.remove(t.id)" aria-label="關閉">✕</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-root { position: fixed; inset: 12px 12px auto auto; z-index: 10001; pointer-events: none; }
.toast-stack { display: grid; gap: 8px; }
.toast {
pointer-events: auto;
display: grid; grid-auto-flow: column; align-items: center; gap: 8px;
min-width: 260px; max-width: 380px; padding: 10px 12px;
border-radius: 12px; background: #fff; border: 1px solid #e5e7eb;
box-shadow: 0 8px 20px rgba(0,0,0,.08); font-size: 14px; color: #222;
}
.toast[data-type="success"] { border-color: #22c55e33; background: #f0fdf4; }
.toast[data-type="info"] { border-color: #3b82f633; background: #eff6ff; }
.toast[data-type="warn"] { border-color: #f59e0b33; background: #fffbeb; }
.toast[data-type="error"] { border-color: #ef444433; background: #fef2f2; }
.toast-icon { width: 8px; height: 8px; border-radius: 999px; }
.toast[data-type="success"] .toast-icon { background:#22c55e; }
.toast[data-type="info"] .toast-icon { background:#3b82f6; }
.toast[data-type="warn"] .toast-icon { background:#f59e0b; }
.toast[data-type="error"] .toast-icon { background:#ef4444; }
.toast-close { margin-left: 8px; border: none; background: transparent; cursor: pointer; opacity: .6; }
.toast-close:hover { opacity: 1; }
/* 過場 */
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(-6px) scale(.98); }
.toast-enter-active, .toast-leave-active { transition: all .16s ease; }
.toast-move { transition: transform .16s ease; }
@media (prefers-reduced-motion: reduce) {
.toast-enter-active, .toast-leave-active, .toast-move { transition-duration: 0s; }
}
</style>
src/services/http.js
(加入錯誤攔截器 → Toast)import axios from 'axios'
export const http = axios.create({
baseURL: 'http://localhost:3000',
timeout: 15000,
})
// 攔截成功:可加全域 loading 統計(略)
http.interceptors.response.use(
(resp) => resp,
async (error) => {
const msg = error?.response?.data?.error || error.message || '發生錯誤'
// 動態 import 避免循環依賴(在 SSR/測試也安全)
const { useToastStore } = await import('../stores/toastStore')
useToastStore().error(msg)
return Promise.reject(error)
}
)
src/App.vue
(全域掛載兩個 Host)<script setup>
import ModalHost from './components/ModalHost.vue'
import ToastHost from './components/ToastHost.vue'
</script>
<template>
<main class="page">
<header class="site-header">
<h1>飲料點單系統</h1>
<!-- 你的導覽/語系/登入按鈕... -->
</header>
<router-view />
<!-- 全域傳送門:任何地方呼叫都會在這裡呈現 -->
<ModalHost />
<ToastHost />
</main>
</template>
import { useModalStore } from '@/stores/modalStore'
const modal = useModalStore()
// 刪除前確認
function askDelete(order) {
modal.confirm({
title: '確認刪除',
message: `確定要刪除「${order.name}」的訂單嗎?此動作無法復原。`,
okText: '是的,刪除',
cancelText: '取消',
onOk: async () => {
await OrderService.remove(order.id)
const { useToastStore } = await import('@/stores/toastStore')
useToastStore().success('已刪除')
}
})
}
// 單純提示
modal.alert('權限不足,請先登入。', { title: '需要登入' })
import { useToastStore } from '@/stores/toastStore'
const toast = useToastStore()
toast.success('訂單送出成功!')
toast.error('伺服器忙線中,稍後再試')
toast.warn('今日上限 3 杯,請明日再來')
toast.info('背景同步完成')
區塊 | 目的 | 關鍵語法與說明 |
---|---|---|
Modal 遮罩 | 鋪滿螢幕、置中、上層渲染 | position: fixed; inset: 0; display:grid; place-items:center; background: rgba(0,0,0,.4); z-index:10000; |
Modal 過場 | 柔順現身/退場 | .modal-enter-from/active/leave-to + 內層 .modal-panel 的 transform + transition 讓彈窗微升降、縮放 |
Toast 堆疊 | 右上角固定、可點擊 | .toast-root{ position:fixed; inset:12px 12px auto auto; pointer-events:none } ;單顆 toast 開啟 pointer-events:auto |
Toast 過場 | 插入/移除/重新排序(FLIP) | .toast-enter-from/active/leave-to 與 .toast-move 讓 TransitionGroup 自動平滑位移 |
動效降低 | 照顧暈動敏感使用者 | @media (prefers-reduced-motion: reduce) { transition-duration: 0s; animation: none; } |
ARIA 可近性 | 讓螢幕閱讀器讀得到 | Modal:role="dialog" aria-modal="true" ;Toast 容器:role="region" aria-live="polite" |
功能驗收
prefers-reduced-motion
,在系統開啟「減少動態效果」時不閃不抖。
使用者體驗驗收
try/catch
。title/message
換成 $t('…')
之後就能連動 i18n。items/open
與回呼行為。App.vue
掛 2 個 Host、在 http.js
加攔截器。Modal 中嵌入任意元件
(訂單詳情、二次驗證等)。這個也算是vue好用的魔法更恰巧對應到這種提示或彈跳視窗上面