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.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>

最佳實踐

  1. 最小化暴露:只暴露必要的屬性和方法
  2. 暴露方法而非狀態:優先暴露方法,讓子元件控制自己的狀態
  3. 文件化 API:記錄暴露的方法和用途
  4. 避免過度使用:大多數情況下,應該使用 props 和 events 通訊
<script setup>
// ✅ 好:只暴露必要的公開 API
defineExpose({
  open,
  close,
  validate
})

// ❌ 避免:暴露太多內部實作細節
defineExpose({
  internalState,
  privateHelper,
  _cache,
  // ...
})
</script>