Appearance
AI 協作:讓 AI 動手、我們把關
這一章的技術可以組合出不少日常元件。本節是全書第一個「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 產出(節錄):結構大致正確:Teleport + Transition + 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。
