本文 AI 產出,尚未審核

Vue3 Composition API(核心)

主題:defineExpose()defineOptions()


簡介

在 Vue 3 中,Composition API 為我們提供了更彈性的邏輯組織方式,而 defineExpose()defineOptions() 則是兩個 官方推薦、但相對較少被提及的輔助函式。

  • defineExpose() 讓子組件 主動曝露 想讓父層存取的內部屬性或方法,解決了原本只能透過 refemitprovide/inject 的限制。
  • defineOptions() 則是 <script setup> 中設定組件選項(如 namecomponentsprops 的預設值等)的新寫法,讓我們不必再切換回普通的 export default {} 形式。

掌握這兩個工具,能讓 Composition API 的開發體驗更完整,且在大型專案中維持清晰的 API 與組件邊界。接下來,我們會一步步拆解概念、示範實作,並探討實務上如何避免常見陷阱。


核心概念

1. defineExpose()

為何需要 defineExpose()

<script setup> 中,所有在頂層宣告的變數、函式 預設都是私有 的。父層若想直接呼叫子組件的方法,只能靠 ref 取得子組件實例,再透過 instance.method 方式存取。然而,這樣的做法會把子組件的全部實例暴露給父層,增加耦合度

defineExpose() 允許我們 選擇性地暴露 某些 API,讓父層只能看到我們允許的部分,類似於 TypeScript 的 public 修飾子。

基本語法

<script setup>
import { ref } from 'vue'

const count = ref(0)

// 只想讓父層看到 increase 方法
function increase() {
  count.value++
}

// 透過 defineExpose 暴露 increase
defineExpose({
  increase
})
</script>

注意defineExpose 必須在 <script setup> 中使用,且只能接受一個物件參數,物件的屬性會成為子組件對外的「公共」介面。

2. defineOptions()

為何需要 defineOptions()

<script setup> 中,我們已經可以直接使用 definePropsdefineEmits,但仍有一些 組件選項(例如 namecomponentsinheritAttrscustomOptions)需要透過 export default {} 方式設定。

defineOptions() 把這些傳統的選項 搬進 <script setup>,讓檔案結構更一致,避免在同一個檔案中同時出現 <script setup> 與普通 <script>

基本語法

<script setup>
defineOptions({
  name: 'MyButton',
  inheritAttrs: false,
  // 直接在此引用其他子組件
  components: {
    Icon: () => import('./Icon.vue')
  }
})
</script>

小技巧defineOptions 只接受一個物件,裡面的屬性與 export default 中的寫法完全相同,Vue 會在編譯階段自動合併。


程式碼範例

以下提供 5 個實用範例,從最簡單到進階應用,說明如何在真實專案中使用 defineExposedefineOptions

範例 1:父子組件的簡易互動

子組件 Counter.vue(使用 defineExpose

<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment(step = 1) {
  count.value += step
}

function reset() {
  count.value = 0
}

// 暴露想讓父層呼叫的兩個方法
defineExpose({
  increment,
  reset
})
</script>

<template>
  <div>Count: {{ count }}</div>
</template>

父組件 App.vue

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

const counterRef = ref(null)

function handleClick() {
  // 只可以呼叫子組件暴露的 method
  counterRef.value?.increment(5)
}
</script>

<template>
  <Counter ref="counterRef" />
  <button @click="handleClick">加 5</button>
</template>

重點:父層只能存取 incrementreset,即使子組件內部還有其他私有函式,外部無法直接觸碰。


範例 2:使用 defineOptions 設定 namecomponents

<script setup>
defineOptions({
  name: 'UserCard',
  components: {
    Avatar: () => import('./Avatar.vue')
  }
})

const props = defineProps({
  username: String,
  avatarUrl: String
})
</script>

<template>
  <div class="card">
    <Avatar :src="avatarUrl" />
    <p>{{ username }}</p>
  </div>
</template>

技巧:即使在 <script setup> 中,我們仍可透過 defineOptions 把子組件 Avatar 直接註冊,保持單檔案的完整性。


範例 3:結合 defineExposeref,實作 表單驗證

子組件 LoginForm.vue

<script setup>
import { ref, computed } from 'vue'

const email = ref('')
const password = ref('')

// 簡易驗證規則
const emailError = computed(() => {
  return email.value && !/.+@.+\..+/.test(email.value)
    ? '請輸入正確的 Email 格式'
    : ''
})
const passwordError = computed(() => {
  return password.value && password.value.length < 6
    ? '密碼至少 6 個字元'
    : ''
})

// 父層可呼叫的驗證方法
function validate() {
  return !emailError.value && !passwordError.value
}

// 暴露 validate 讓父層決定何時驗證
defineExpose({ validate })
</script>

<template>
  <form @submit.prevent>
    <input v-model="email" placeholder="Email" />
    <p class="error">{{ emailError }}</p>

    <input type="password" v-model="password" placeholder="Password" />
    <p class="error">{{ passwordError }}</p>
  </form>
</template>

父層 App.vue

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

const formRef = ref(null)

function submit() {
  if (formRef.value?.validate()) {
    // 這裡才會送出
    alert('表單驗證通過!')
  } else {
    alert('請先修正表單錯誤')
  }
}
</script>

<template>
  <LoginForm ref="formRef" />
  <button @click="submit">送出</button>
</template>

實務意義:父層不需要知道子組件內部的驗證邏輯,只要呼叫 validate(),即可取得驗證結果,保持 關注點分離


範例 4:在 defineOptions 中使用 inheritAttrs: false,配合 defineExpose 控制屬性傳遞

<script setup>
defineOptions({
  name: 'CustomInput',
  inheritAttrs: false   // 不自動把父層 attrs 加到根元素
})

const props = defineProps({
  modelValue: String
})

const emit = defineEmits(['update:modelValue'])

// 暴露 focus 方法給父層
const inputRef = ref(null)
function focus() {
  inputRef.value?.focus()
}
defineExpose({ focus })

function onInput(e) {
  emit('update:modelValue', e.target.value)
}
</script>

<template>
  <!-- 手動綁定想要傳遞的 attrs -->
  <input
    ref="inputRef"
    :value="modelValue"
    @input="onInput"
    v-bind="$attrs"
  />
</template>

父層使用

<CustomInput
  v-model="username"
  placeholder="請輸入使用者名稱"
  class="my-input"
  @focus="onFocus"
/>

說明inheritAttrs: false 讓我們可以自行決定哪些屬性要傳遞,而 defineExpose 則提供 focus 方法,讓父層能以 程式化 方式聚焦輸入框。


範例 5:全局組件註冊與 defineOptions 的結合(插件化)

假設我們要寫一個 插件,在安裝時自動註冊多個組件,且每個組件都使用 defineOptions 設定 name

插件檔 my-ui-plugin.js

import { App } from 'vue'

// 假設有三個組件
import Button from './components/Button.vue'
import Dialog from './components/Dialog.vue'
import Tooltip from './components/Tooltip.vue'

export default {
  install(app: App) {
    // 直接使用 defineOptions 中的 name
    app.component(Button.name, Button)
    app.component(Dialog.name, Dialog)
    app.component(Tooltip.name, Tooltip)
  }
}

Button.vue(使用 defineOptions

<script setup>
defineOptions({
  name: 'MyButton'   // 這個名稱會被插件自動使用
})

// 內部邏輯...
</script>

<template>
  <button class="my-button"><slot /></button>
</template>

最佳實踐:使用 defineOptions 為組件命名,可避免手動寫 export default { name: '...' },同時讓插件安裝程式碼更乾淨。


常見陷阱與最佳實踐

陷阱 可能的後果 解決方案或最佳實踐
忘記在 <script setup> 中呼叫 defineExpose 父層透過 ref 仍能存取全部實例,導致不必要的耦合 只在需要暴露的情況下使用 defineExpose,且僅列出必要的 API
暴露過多成員 讓子組件的內部實作細節外泄,未來改動會破壞父層 最小化公開介面:只暴露方法或屬性,盡量避免直接暴露 ref 本身
defineOptions 中寫錯屬性名稱(如 inheritAttrs 拼寫錯誤) 編譯不會報錯,但行為不如預期,屬性仍會被繼承 使用 IDE 的 TypeScript 提示或 Vue 官方的 type 定義,確保屬性名稱正確
同時使用 export defaultdefineOptions 兩者會互相覆蓋,可能導致組件名稱或註冊失效 避免混用:在 <script setup> 中只能使用 defineOptions,若需要 export default,改為普通 <script>
defineExpose 中暴露的函式使用了外部未導出的變數 父層呼叫時會因閉包問題產生未預期的錯誤 確保暴露的函式只依賴於已在同層宣告的 refcomputed 或參數,或將必要的依賴作為參數傳入

最佳實踐小結

  1. 最小化 API:只暴露必要的功能,保持子組件的封裝性。
  2. 型別安全:若使用 TypeScript,為 defineExpose 的物件加上介面(interface)定義,讓父層在編譯階段就能捕捉錯誤。
  3. 統一風格:在整個專案中統一使用 <script setup> + defineOptions,減少混用模式造成的維護成本。
  4. 文件化:為每個 defineExpose 暴露的 API 撰寫 JSDoc,讓團隊成員快速了解可用方法。

實際應用場景

場景 為何選擇 defineExpose / defineOptions
父子組件需要直接呼叫子組件方法(如彈窗的 open()close() defineExpose 讓父層只拿到 openclose,不會看到其他內部狀態。
大型表單的集中驗證 子表單透過 defineExpose 暴露 validate,父層在提交時統一呼叫,保持表單邏輯分散而驗證集中。
插件或 UI 套件開發 defineOptions 為每個元件自動設定 name,插件安裝時可直接使用 app.component(Component.name, Component),省去手動寫名稱的繁瑣。
需要關閉自動屬性繼承(如自訂 UI 元件) inheritAttrs: false 搭配手動 v-bind="$attrs",讓開發者可以精準控制哪些屬性會傳遞,同時仍能透過 defineExpose 提供聚焦等實用方法。
跨層級的狀態同步(如圖表組件需要父層控制播放/暫停) 子圖表暴露 playpause,父層只需呼叫這兩個方法,即可完成控制,避免直接操作子組件內部的 ref

總結

  • defineExpose()<script setup> 提供了 受控的 API 暴露 機制,讓父層能安全、精準地呼叫子組件方法或取得狀態。
  • defineOptions() 把傳統的組件選項(namecomponentsinheritAttrs 等)搬進 <script setup>,提升單檔案的可讀性與一致性。
  • 正確使用這兩個工具可以 降低耦合度、提升可維護性,同時在大型專案或 UI 套件開發時,讓組件的註冊與 API 定義更加統一、可預測。

在日常開發中,建議先從 最小化公開介面 開始,逐步將需要跨層級溝通的功能以 defineExpose 包裝;同時在 組件設定 時,以 defineOptions 替代傳統 export default,保持代碼風格的一致。掌握這兩個 API,將讓你在 Vue 3 的 Composition API 世界裡如虎添翼,寫出更乾淨、易維護的程式碼。祝開發順利 🚀