在真實專案裡,訊息傳遞可以分為兩派魔法:
我們用 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.jsimport { 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.jsimport { 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好用的魔法更恰巧對應到這種提示或彈跳視窗上面