Vue defineExpose
在 <script setup> 中,元件預設是「封閉」的,父元件無法透過 template ref 存取子元件的內部屬性和方法。defineExpose 用來明確暴露特定的屬性和方法給父元件。
為什麼需要 defineExpose?
使用 <script setup> 時
<!-- Child.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<!-- Parent.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import Child from './Child.vue'
const childRef = ref(null)
onMounted(() => {
// ❌ 無法存取!因為 <script setup> 預設是封閉的
console.log(childRef.value.count) // undefined
childRef.value.increment() // 報錯
})
</script>
<template>
<Child ref="childRef" />
</template>
使用 defineExpose 暴露
<!-- Child.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
const privateData = ref('這是私有的')
function increment() {
count.value++
}
function privateMethod() {
console.log('私有方法')
}
// 明確暴露給父元件
defineExpose({
count,
increment
})
// privateData 和 privateMethod 不會被暴露
</script>
<!-- Parent.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import Child from './Child.vue'
const childRef = ref(null)
onMounted(() => {
// ✅ 可以存取暴露的內容
console.log(childRef.value.count) // 0
childRef.value.increment() // 可以呼叫
// ❌ 無法存取未暴露的內容
console.log(childRef.value.privateData) // undefined
})
</script>
<template>
<Child ref="childRef" />
</template>
暴露響應式資料
暴露的 ref 會自動解包:
<!-- Child.vue -->
<script setup>
import { ref, reactive } from 'vue'
const count = ref(0)
const state = reactive({
name: 'Vue',
version: 3
})
defineExpose({
count,
state
})
</script>
<!-- Parent.vue -->
<script setup>
import { ref, onMounted } from 'vue'
const childRef = ref(null)
onMounted(() => {
// ref 會自動解包
console.log(childRef.value.count) // 0(不是 { value: 0 })
// reactive 物件直接存取
console.log(childRef.value.state.name) // 'Vue'
})
</script>
實際應用
表單元件
<!-- FormComponent.vue -->
<script setup>
import { ref, reactive } from 'vue'
const form = reactive({
name: '',
email: '',
message: ''
})
const errors = ref({})
function validate() {
errors.value = {}
if (!form.name) {
errors.value.name = '請輸入姓名'
}
if (!form.email) {
errors.value.email = '請輸入 Email'
}
if (!form.email.includes('@')) {
errors.value.email = '請輸入有效的 Email'
}
return Object.keys(errors.value).length === 0
}
function reset() {
form.name = ''
form.email = ''
form.message = ''
errors.value = {}
}
function getData() {
return { ...form }
}
// 暴露給父元件使用
defineExpose({
validate,
reset,
getData
})
</script>
<template>
<form>
<div>
<input v-model="form.name" placeholder="姓名">
<span class="error">{{ errors.name }}</span>
</div>
<div>
<input v-model="form.email" type="email" placeholder="Email">
<span class="error">{{ errors.email }}</span>
</div>
<div>
<textarea v-model="form.message" placeholder="訊息"></textarea>
</div>
</form>
</template>
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import FormComponent from './FormComponent.vue'
const formRef = ref(null)
function handleSubmit() {
if (formRef.value.validate()) {
const data = formRef.value.getData()
console.log('提交資料:', data)
// 提交後重置
formRef.value.reset()
}
}
</script>
<template>
<FormComponent ref="formRef" />
<button @click="handleSubmit">送出</button>
</template>
Modal 對話框
<!-- Modal.vue -->
<script setup>
import { ref } from 'vue'
const isOpen = ref(false)
const title = ref('')
const content = ref('')
function open(options = {}) {
title.value = options.title || '提示'
content.value = options.content || ''
isOpen.value = true
}
function close() {
isOpen.value = false
}
defineExpose({
open,
close
})
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="modal-overlay" @click.self="close">
<div class="modal">
<h2>{{ title }}</h2>
<p>{{ content }}</p>
<button @click="close">關閉</button>
</div>
</div>
</Teleport>
</template>
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import Modal from './Modal.vue'
const modalRef = ref(null)
function showModal() {
modalRef.value.open({
title: '通知',
content: '操作成功!'
})
}
</script>
<template>
<button @click="showModal">開啟 Modal</button>
<Modal ref="modalRef" />
</template>
輪播元件
<!-- Carousel.vue -->
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
items: {
type: Array,
required: true
}
})
const currentIndex = ref(0)
const currentItem = computed(() => props.items[currentIndex.value])
const hasNext = computed(() => currentIndex.value < props.items.length - 1)
const hasPrev = computed(() => currentIndex.value > 0)
function next() {
if (hasNext.value) {
currentIndex.value++
}
}
function prev() {
if (hasPrev.value) {
currentIndex.value--
}
}
function goTo(index) {
if (index >= 0 && index < props.items.length) {
currentIndex.value = index
}
}
// 暴露導航方法
defineExpose({
next,
prev,
goTo,
currentIndex
})
</script>
<template>
<div class="carousel">
<slot :item="currentItem" :index="currentIndex"></slot>
<div class="indicators">
<button
v-for="(_, index) in items"
:key="index"
:class="{ active: index === currentIndex }"
@click="goTo(index)"
>
{{ index + 1 }}
</button>
</div>
</div>
</template>
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import Carousel from './Carousel.vue'
const carouselRef = ref(null)
const images = [
{ src: '/img1.jpg', title: '圖片 1' },
{ src: '/img2.jpg', title: '圖片 2' },
{ src: '/img3.jpg', title: '圖片 3' }
]
</script>
<template>
<Carousel ref="carouselRef" :items="images">
<template #default="{ item }">
<img :src="item.src" :alt="item.title">
</template>
</Carousel>
<div class="controls">
<button @click="carouselRef.prev()">上一張</button>
<button @click="carouselRef.next()">下一張</button>
</div>
</template>
表格元件
<!-- DataTable.vue -->
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
data: Array,
columns: Array
})
const selectedRows = ref([])
const sortKey = ref('')
const sortOrder = ref('asc')
const sortedData = computed(() => {
if (!sortKey.value) return props.data
return [...props.data].sort((a, b) => {
const aVal = a[sortKey.value]
const bVal = b[sortKey.value]
const modifier = sortOrder.value === 'asc' ? 1 : -1
if (aVal < bVal) return -1 * modifier
if (aVal > bVal) return 1 * modifier
return 0
})
})
function selectAll() {
selectedRows.value = props.data.map((_, i) => i)
}
function clearSelection() {
selectedRows.value = []
}
function getSelectedData() {
return selectedRows.value.map(i => props.data[i])
}
function sortBy(key) {
if (sortKey.value === key) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortOrder.value = 'asc'
}
}
defineExpose({
selectAll,
clearSelection,
getSelectedData,
sortBy,
selectedRows
})
</script>
<template>
<table>
<thead>
<tr>
<th>
<input
type="checkbox"
:checked="selectedRows.length === data.length"
@change="selectedRows.length === data.length ? clearSelection() : selectAll()"
>
</th>
<th v-for="col in columns" :key="col.key" @click="sortBy(col.key)">
{{ col.title }}
<span v-if="sortKey === col.key">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in sortedData" :key="index">
<td>
<input
type="checkbox"
:checked="selectedRows.includes(index)"
@change="selectedRows.includes(index)
? selectedRows = selectedRows.filter(i => i !== index)
: selectedRows.push(index)"
>
</td>
<td v-for="col in columns" :key="col.key">
{{ row[col.key] }}
</td>
</tr>
</tbody>
</table>
</template>
TypeScript 支援
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
function reset() {
count.value = 0
}
// 暴露的型別會自動推斷
defineExpose({
count,
increment,
reset
})
</script>
父元件使用時:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Child from './Child.vue'
// 取得元件實例型別
const childRef = ref<InstanceType<typeof Child> | null>(null)
onMounted(() => {
// 有完整的型別提示
childRef.value?.increment()
console.log(childRef.value?.count)
})
</script>
最佳實踐
- 最小化暴露:只暴露必要的屬性和方法
- 暴露方法而非狀態:優先暴露方法,讓子元件控制自己的狀態
- 文件化 API:記錄暴露的方法和用途
- 避免過度使用:大多數情況下,應該使用 props 和 events 通訊
<script setup>
// ✅ 好:只暴露必要的公開 API
defineExpose({
open,
close,
validate
})
// ❌ 避免:暴露太多內部實作細節
defineExpose({
internalState,
privateHelper,
_cache,
// ...
})
</script>