Skip to content

AI 協作:讓 AI 動手、我們把關

重新認識 Vue.js: 008 天絕對看不完的 Vue.js 3.x 指南

這一章的技術可以組合出不少日常元件。本節是全書第一個「AI 協作」實例:把需求交給裝上 vue skill 的 AI,再用這幾章學到的原理逐項把關。以最經典的 modal 為例。

AI 協作:用 vue skill 加速

情境:需要一個可重複使用的購買確認 modal:<Teleport> 傳送到 body、內容用 slots 開放、進出場用 <Transition> 淡入淡出,並符合基本可存取性。

做法:在安裝好 vue-skills 的 AI 工具中(安裝與觸發方式見第一章),下指令 use vue-best-practices,接著給完整提示詞:

建立 PurchaseModal.vue:用 Teleport 傳送到 body,Transition 淡入淡出;標題用具名 slot、正文用預設 slot;開關狀態由父元件持有並以 prop 傳入,關閉一律 emit('close');支援 Escape 關閉;加上 role="dialog"、aria-modal="true" 與 aria-labelledby。

AI 產出(節錄):結構大致正確:TeleportTransition + overlay、ARIA 屬性、slots 都在。但看起來能用,正是最需要警惕的時刻。這次的產出藏著兩個典型問題:

js
// 問題一:AI 自己開了一個 visible 狀態,子元件自己關自己
const visible = ref(true)

function close() {
  visible.value = false   // 父元件的開關完全不知情
}

// 問題二:Escape 監聽掛上去了,卸載時沒有清
onMounted(() => {
  window.addEventListener('keydown', onKeydown)
})
// 少了對應的 removeEventListener

人工驗證重點

  • 問題一違反單向資料流:明明要求「開關由父元件持有」,AI 仍寫成子元件自管 visible,因為「自己關自己」在單檔 demo 裡看起來能動。狀態應由父元件持有,子元件只 emit('close')
  • 問題二踩到上一章的清理紀律:在哪裡建立監聽,就要在對應時機清掉,否則元件每重新掛載一次,就多留一個永遠不會消失的全域監聽器。
  • 其餘逐項照清單驗:role="dialog"aria-modal="true" 與可存取名稱是否都在;Escape 能否關閉;開啟後焦點是否移入 modal、關閉後是否還原;焦點是否被限制在 dialog 內(Tab/Shift+Tab 不外洩)、開啟期間背景是否不可互動;<style scoped> 是否污染全域;slot 作用域是否正確。其中焦點限制與背景互動這兩項,AI 產出經常缺漏。

修正後的可執行版本收在下方摺疊區塊。AI 的產出是起點,不是成品;vue-skills 是社群維護的輔助工作流,不是本書的規範來源,驗證的依據永遠是 Vue 官方文件與你對這幾章原理的理解。

修正後的完整範例(教學簡化版)

src/components/PurchaseModal.vue

vue
<script setup>
import { nextTick, useTemplateRef, watch } from 'vue'

const props = defineProps({
  open: { type: Boolean, default: false }   // 開關狀態由父元件持有
})

const emit = defineEmits(['close'])

const dialogEl = useTemplateRef('dialog')
let previousActive = null

function onKeydown(event) {
  if (event.key === 'Escape') {
    emit('close')   // 子元件只回報,不自己改狀態
  }
}

watch(() => props.open, async (open, _, onCleanup) => {
  if (open) {
    previousActive = document.activeElement

    window.addEventListener('keydown', onKeydown)
    onCleanup(() => {
      window.removeEventListener('keydown', onKeydown)
    })

    await nextTick()            // 等 modal 渲染出來,才有東西可以對焦
    dialogEl.value?.focus()
  } else {
    previousActive?.focus()     // 關閉後把焦點還給原本的元素
  }
}, { immediate: true })
</script>

<template>
  <Teleport to="body">
    <Transition name="fade">
      <div v-if="open" class="overlay">
        <div
          ref="dialog"
          class="modal"
          role="dialog"
          aria-modal="true"
          aria-labelledby="purchase-modal-title"
          tabindex="-1"
        >
          <h2 id="purchase-modal-title">
            <slot name="title">確認購買</slot>
          </h2>
          <slot />
          <button type="button" @click="emit('close')">
            關閉
          </button>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.overlay {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  background: rgb(15 23 42 / 0.5);
}

.modal {
  background: #fff;
  border-radius: 8px;
  padding: 1.5rem;
  display: grid;
  gap: 0.75rem;
}

.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

src/App.vue

vue
<script setup>
import { ref } from 'vue'
import PurchaseModal from './components/PurchaseModal.vue'

const showModal = ref(false)
</script>

<template>
  <main class="app">
    <button type="button" @click="showModal = true">
      購買
    </button>

    <PurchaseModal :open="showModal" @close="showModal = false">
      <template #title>確定要購買《重新認識 Vue.js》嗎?</template>
      <p>結帳金額 NT$620。</p>
    </PurchaseModal>
  </main>
</template>

<style scoped>
.app {
  padding: 2rem;
}
</style>

跟本章 Teleport 一節的版本相比,這裡把 v-if 移進了子元件內部(以 open prop 控制),<Transition> 的退場動畫才播得出來;開關狀態仍然由父元件持有。

鍵盤監聽器也跟著 open 的狀態管理:modal 開啟時才註冊,關閉或元件卸載時,watcher 的清理函式會自動移除。這樣即使頁面同時掛載多個尚未開啟的 modal,也不會在 window 上留下無作用的監聽器。

教學簡化版的明確缺口:這個版本沒有實作焦點限制(Tab 仍可能跑到背景)、背景也未設為不可互動(如 inert 屬性),所以它還不是完整的 accessible modal。實務上可用原生 inert<dialog> 元素或成熟的 headless UI 套件補齊這兩塊。

這一章到此告一段落:slots、provide/inject、template refs、動態與非同步元件、Teleport、Suspense、Transition,元件的組合工具都在手上了。第五章我們處理另一種重複:不是畫面的重複,而是邏輯的重複,也就是 composables。