Vue3 Composition API(核心)
主題:defineExpose()、defineOptions()
簡介
在 Vue 3 中,Composition API 為我們提供了更彈性的邏輯組織方式,而 defineExpose() 與 defineOptions() 則是兩個 官方推薦、但相對較少被提及的輔助函式。
defineExpose()讓子組件 主動曝露 想讓父層存取的內部屬性或方法,解決了原本只能透過ref、emit或provide/inject的限制。defineOptions()則是 在<script setup>中設定組件選項(如name、components、props的預設值等)的新寫法,讓我們不必再切換回普通的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> 中,我們已經可以直接使用 defineProps、defineEmits,但仍有一些 組件選項(例如 name、components、inheritAttrs、customOptions)需要透過 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 個實用範例,從最簡單到進階應用,說明如何在真實專案中使用 defineExpose 與 defineOptions。
範例 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>
重點:父層只能存取
increment與reset,即使子組件內部還有其他私有函式,外部無法直接觸碰。
範例 2:使用 defineOptions 設定 name 與 components
<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:結合 defineExpose 與 ref,實作 表單驗證
子組件 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 default 與 defineOptions |
兩者會互相覆蓋,可能導致組件名稱或註冊失效 | 避免混用:在 <script setup> 中只能使用 defineOptions,若需要 export default,改為普通 <script> |
在 defineExpose 中暴露的函式使用了外部未導出的變數 |
父層呼叫時會因閉包問題產生未預期的錯誤 | 確保暴露的函式只依賴於已在同層宣告的 ref、computed 或參數,或將必要的依賴作為參數傳入 |
最佳實踐小結
- 最小化 API:只暴露必要的功能,保持子組件的封裝性。
- 型別安全:若使用 TypeScript,為
defineExpose的物件加上介面(interface)定義,讓父層在編譯階段就能捕捉錯誤。 - 統一風格:在整個專案中統一使用
<script setup>+defineOptions,減少混用模式造成的維護成本。 - 文件化:為每個
defineExpose暴露的 API 撰寫 JSDoc,讓團隊成員快速了解可用方法。
實際應用場景
| 場景 | 為何選擇 defineExpose / defineOptions |
|---|---|
父子組件需要直接呼叫子組件方法(如彈窗的 open()、close()) |
defineExpose 讓父層只拿到 open、close,不會看到其他內部狀態。 |
| 大型表單的集中驗證 | 子表單透過 defineExpose 暴露 validate,父層在提交時統一呼叫,保持表單邏輯分散而驗證集中。 |
| 插件或 UI 套件開發 | defineOptions 為每個元件自動設定 name,插件安裝時可直接使用 app.component(Component.name, Component),省去手動寫名稱的繁瑣。 |
| 需要關閉自動屬性繼承(如自訂 UI 元件) | inheritAttrs: false 搭配手動 v-bind="$attrs",讓開發者可以精準控制哪些屬性會傳遞,同時仍能透過 defineExpose 提供聚焦等實用方法。 |
| 跨層級的狀態同步(如圖表組件需要父層控制播放/暫停) | 子圖表暴露 play、pause,父層只需呼叫這兩個方法,即可完成控制,避免直接操作子組件內部的 ref。 |
總結
defineExpose()為<script setup>提供了 受控的 API 暴露 機制,讓父層能安全、精準地呼叫子組件方法或取得狀態。defineOptions()把傳統的組件選項(name、components、inheritAttrs等)搬進<script setup>,提升單檔案的可讀性與一致性。- 正確使用這兩個工具可以 降低耦合度、提升可維護性,同時在大型專案或 UI 套件開發時,讓組件的註冊與 API 定義更加統一、可預測。
在日常開發中,建議先從 最小化公開介面 開始,逐步將需要跨層級溝通的功能以 defineExpose 包裝;同時在 組件設定 時,以 defineOptions 替代傳統 export default,保持代碼風格的一致。掌握這兩個 API,將讓你在 Vue 3 的 Composition API 世界裡如虎添翼,寫出更乾淨、易維護的程式碼。祝開發順利 🚀