iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Vue.js

需求至上的 Vue 魔法之旅系列 第 26

# Day 19 : 雙重傳送門的召喚術:Teleport + Modal + Toast 一次到位

  • 分享至 

  • xImage
  •  

前言|為何需要這兩大神器?

在真實專案裡,訊息傳遞可以分為兩派魔法:

  • 非阻斷提醒(Toast)- 像是AI或是輔助天使提醒:飛過耳邊的小提醒,幾秒自動離場。適合「已儲存、背景同步、輕度錯誤」這類非阻斷訊息。
  • 阻斷確認(Modal)- 向結界一樣:把畫面停下來,請你做決定(確認/取消)。適合「刪除、不可逆、權限」這類需要使用者承諾的情境。

我們用 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的變化https://ithelp.ithome.com.tw/upload/images/20251010/20121052rughU3Kaam.png

流程圖(全域錯誤→吐司通道)

程式碼的流程非常直覺
就是

  1. import 這個alert component (modal 或是toast )
  2. 然後把error message拋出去就對了

https://ithelp.ithome.com.tw/upload/images/20251010/20121052ifQvhCEAiS.png


今天要新增/修改哪些檔案?

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。


Vue Teleport 召喚學:深度小抄

Teleport 是什麼?
把組件的 DOM **「傳送」**到別處(如 <body>),避免被父層樣式/層級壓住。Modal/Toast 天生適合被 Teleport 到全域層。

為什麼要用?

  • 避免 z-index 地獄:無論頁面結構多深,彈窗總能在最上層呈現。
  • 事件/狀態仍屬原組件:被傳送的內容依然能取用當前組件的變數與方法。
  • 與 Transition 絕配:先傳送,再進行入場/退場動畫。

基本語法:

<Teleport to="body">
  <div class="modal">...</div>
</Teleport>

完整程式碼(可直接複製)

1) 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 }
})

2) 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>

3) 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 }
})

4) 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>

5) 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)
  }
)

6) 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>

如何在任意組件施法

阻斷式(結界)Alert / Confirm

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: '需要登入' })

非阻斷式(輕語精靈)Toast

import { useToastStore } from '@/stores/toastStore'
const toast = useToastStore()

toast.success('訂單送出成功!')
toast.error('伺服器忙線中,稍後再試')
toast.warn('今日上限 3 杯,請明日再來')
toast.info('背景同步完成')

今天的 CSS 與可近性(SR/Reduced-Motion)要點

區塊 目的 關鍵語法與說明
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-paneltransform + 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"

驗收清單(把召喚圈畫完整)

功能驗收

  1. 送出訂單 → 右上角出現綠色成功 Toast,數秒自動收起。
  2. 點「刪除」→ 彈出 Confirm Modal,按「是的,刪除」後刪除並顯示成功 Toast。
  3. 任何 API 失敗 → 全域攔截器自動顯示紅色錯誤 Toast。
  4. 動效都支援 prefers-reduced-motion,在系統開啟「減少動態效果」時不閃不抖。

https://ithelp.ithome.com.tw/upload/images/20251010/20121052Fc3d1FrtWR.png

https://ithelp.ithome.com.tw/upload/images/20251010/20121052RZ1Lt0HP2M.png

https://ithelp.ithome.com.tw/upload/images/20251010/20121052D48yDFK4hX.png
使用者體驗驗收

  • Toast 非阻斷、Modal 阻斷且焦點不外漏。
  • 樣式一致、文案一致,對全站行為有統一語彙。

最佳實踐(魔法守則)

  • 阻斷要節制:真的需要使用者決策才開 Modal;其餘統一走 Toast 或就地錯誤文案。
  • 不要轟炸:Toast 可設隊列上限與冷卻,重要訊息才彈。
  • 錯誤集中處理:攔截器統一轉換錯誤訊息,避免每處 try/catch
  • 易於國際化:將 title/message 換成 $t('…') 之後就能連動 i18n。
  • 可測試:Store 與 Host 分離,單元測試可以直接驗證 store 的 items/open 與回呼行為。
    day19 code

小結|你今天得到的召喚卷軸

  • 一套通用的訊息系統:Toast(輕)+Modal(重),在任何組件一行施法。
  • 不侵入既有邏輯:只需新增 4 個檔案、在 App.vue 掛 2 個 Host、在 http.js 加攔截器。
  • 跨層級、帶過場、顧可近性:Teleport 輕鬆突破層級、Transition 微動優雅、ARIA 與 Reduced Motion 友善可近性。
  • 可延伸:之後能無縫接 i18n 主題化、可插槽內容、甚至在 Modal 中嵌入任意元件(訂單詳情、二次驗證等)。

這個也算是vue好用的魔法更恰巧對應到這種提示或彈跳視窗上面


上一篇
Day 18: 過場魔法:讓畫面不再閃爍,流暢如吟唱的法咒
下一篇
Day 20: 占星水晶球:後台資料視覺化(Vue + vue-chartjs + Chart.js)
系列文
需求至上的 Vue 魔法之旅27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言