Vue 與 TypeScript 實戰
Vue + TypeScript 的重點不是把所有地方都加上型別,而是讓元件 API、資料流、Composable、Store、API 回應都能被約束。好的型別設計會讓錯誤提早在開發階段出現,壞的型別設計則會讓專案充滿 any 和錯誤的安全感。
一句話回答:Vue + TypeScript 實戰要先把元件邊界型別定清楚:Props、Emits、v-model、Template Ref、Composable 回傳值、API response、Pinia store 都要有明確型別;同時要避免濫用 any、過度相信後端資料,以及用型別斷言掩蓋真實的 null/undefined 狀態。
一、 必考觀念
1. TypeScript 解決什麼問題?
在 Vue 專案裡,TypeScript 最有價值的地方通常是邊界:
- 父元件傳 props 給子元件。
- 子元件 emit 事件給父元件。
v-model傳值與更新事件。- Composable 的輸入輸出。
- API response。
- Pinia store。
- Template ref。
- 第三方套件封裝。
這些地方如果沒有型別,很容易變成「傳錯欄位、emit 錯 payload、API 回來資料不符合預期」。
2. 型別不是 runtime 驗證
這點非常重要。
type User = {
id: string
name: string
}
const user = await fetchUser() as User這只是在告訴 TypeScript:「相信我,它是 User。」
但如果後端真的回:
{
"id": 123,
"nickname": "Ada"
}TypeScript 不會在 runtime 幫你擋。
面試時可以說:
TypeScript 能約束開發時的型別,但不能保證 API runtime 資料一定正確。重要資料仍然需要後端契約、runtime schema validation,或至少在 API layer 做資料轉換。
3. 不要用 any 把問題蓋掉
any 會讓 TypeScript 放棄檢查。
const user: any = await fetchUser()
user.profile.name.firstName這看起來很自由,但實際上和沒用 TypeScript 差不多。
更好的做法是:
type User = {
id: string
name: string
email?: string
}
const user = ref<User | null>(null)讓 null、optional、不同狀態都被誠實表達。
二、 Props 型別
1. defineProps 基本寫法
<script setup lang="ts">
type Props = {
title: string
count?: number
disabled?: boolean
}
const props = defineProps<Props>()
</script>這樣父層如果少傳必填 props,或傳錯型別,TypeScript 會提示。
2. withDefaults
如果 props 有預設值:
<script setup lang="ts">
type Props = {
size?: 'small' | 'medium' | 'large'
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
disabled: false,
})
</script>這比在元件內到處寫 fallback 更清楚。
3. Props 解構要小心
很多人會寫:
const { title } = defineProps<{
title: string
}>()現代 Vue 對 <script setup> 的 reactive props destructure 有編譯支援,但面試時仍要知道:props 本身是響應式來源,解構行為要看 Vue 版本與編譯能力。
保守寫法:
const props = defineProps<{
title: string
}>()
const displayTitle = computed(() => props.title.trim())如果團隊成員對版本差異不熟,保守寫法更容易理解。
三、 Emits 型別
1. defineEmits 約束事件名稱與 payload
<script setup lang="ts">
const emit = defineEmits<{
save: [payload: UserForm]
cancel: []
'update:modelValue': [value: string]
}>()
function submit() {
emit('save', {
name: 'Ada',
email: 'ada@example.com',
})
}
</script>這樣可以檢查:
- event name 是否正確。
- payload 是否符合。
- 沒有 payload 的事件是否誤傳參數。
2. 不要讓 emit payload 變成 unknown blob
不理想:
const emit = defineEmits<{
change: [value: any]
}>()更好:
type ChangePayload = {
id: string
checked: boolean
}
const emit = defineEmits<{
change: [payload: ChangePayload]
}>()事件 payload 是元件 API 的一部分,應該清楚穩定。
延伸讀:Vue 元件通訊方式有哪些及原理。
四、 v-model 與 defineModel
1. 傳統 v-model 型別
子元件:
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function update(value: string) {
emit('update:modelValue', value)
}
</script>父元件:
<BaseInput v-model="keyword" />2. defineModel
Vue 3.4+ 可以使用 defineModel 簡化:
<script setup lang="ts">
const model = defineModel<string>({
required: true,
})
</script>使用時:
<input v-model="model" />多個 model:
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')延伸讀:v-model 雙向綁定的原理。
3. 表單型別要區分 Draft 和 DTO
表單草稿不一定等於 API payload。
type UserForm = {
name: string
email: string
roleIds: string[]
}
type UpdateUserPayload = {
name: string
email: string
roles: string[]
}送出前轉換:
function toPayload(form: UserForm): UpdateUserPayload {
return {
name: form.name.trim(),
email: form.email.trim(),
roles: form.roleIds,
}
}延伸讀:Vue 表單、非同步狀態與資料流設計。
五、 Template Ref 型別
1. DOM ref
<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null)
function focus() {
inputRef.value?.focus()
}
</script>
<template>
<input ref="inputRef" />
</template>一定要包含 null,因為元件 mount 前 ref 還沒有值,v-if false 時也可能是 null。
延伸讀:Vue 中 ref 的作用。
2. Component ref
如果要拿子元件暴露的方法:
子元件:
<script setup lang="ts">
function reset() {
// reset internal state
}
defineExpose({
reset,
})
</script>父元件:
import UserForm from './UserForm.vue'
const formRef = ref<InstanceType<typeof UserForm> | null>(null)
function resetForm() {
formRef.value?.reset()
}注意:Component ref 是命令式 API,適合 focus、reset、scrollTo 這類場景,不適合一般資料同步。
六、 Composable 泛型
1. useRequest 泛型
export function useRequest<TData>(
request: () => Promise<TData>
) {
const data = ref<TData | null>(null)
const loading = ref(false)
const error = ref<unknown>(null)
async function execute() {
loading.value = true
error.value = null
try {
data.value = await request()
} catch (err) {
error.value = err
} finally {
loading.value = false
}
}
return {
data,
loading,
error,
execute,
}
}使用:
const { data: user, execute } = useRequest<User>(() => {
return fetchUser(userId.value)
})這樣 user 的型別會是 Ref<User | null>。
2. useSelection 泛型
export function useSelection<T extends string | number>() {
const selected = ref<Set<T>>(new Set())
function toggle(id: T) {
if (selected.value.has(id)) {
selected.value.delete(id)
return
}
selected.value.add(id)
}
function clear() {
selected.value.clear()
}
return {
selected,
toggle,
clear,
}
}使用:
const { selected, toggle } = useSelection<string>()
toggle('user-1')泛型能讓 Composable 保持通用,同時不犧牲型別。
延伸讀:Composition API 實戰與 Composable 設計。
七、 API Response 型別
1. API layer 要定義 DTO
type UserDto = {
id: string
name: string
email: string | null
}
type User = {
id: string
displayName: string
email: string
}不要讓後端 DTO 到處流進 UI。
可以在 API layer 轉換:
function toUser(dto: UserDto): User {
return {
id: dto.id,
displayName: dto.name,
email: dto.email ?? '',
}
}這樣 UI 面對的是穩定的前端模型。
2. 不要過度相信 API 型別
就算你寫了:
async function fetchUser(): Promise<UserDto> {
const response = await fetch('/api/user')
return response.json()
}這也只是 TypeScript 層面的承諾。
如果是高風險資料,可以用 runtime schema validation,例如在 API layer 驗證後再回傳。
3. Error 型別也要設計
不要只丟 unknown 到畫面。
可以在 API client 統一轉換:
type ApiError = {
message: string
code?: string
fields?: Record<string, string>
}表單頁就能根據 fields 回填欄位錯誤。
八、 Pinia Store 型別
1. Setup Store 的型別通常自然推導
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const isLogin = computed(() => Boolean(user.value && token.value))
function logout() {
user.value = null
token.value = null
}
return {
user,
token,
isLogin,
logout,
}
})Pinia 會根據回傳值推導 store 型別。
2. Option Store 要注意 state 型別
export const useUserStore = defineStore('user', {
state: () => ({
user: null as User | null,
loading: false,
}),
actions: {
async fetchUser(id: string) {
this.loading = true
try {
this.user = await fetchUserApi(id)
} finally {
this.loading = false
}
},
},
})如果直接寫 user: null,TypeScript 可能推導得太窄,所以要明確告訴它 User | null。
延伸讀:Vue 狀態管理:Pinia、Vuex 與狀態歸屬。
九、 Slot Props 型別
1. Slot props 也是元件 API
在 Vue 3.3+ 可以用 defineSlots 描述 slot 型別:
<script setup lang="ts">
type Row = {
id: string
name: string
}
defineSlots<{
default(props: { row: Row }): unknown
empty(): unknown
}>()
</script>這能讓使用者在寫 slot 時得到更好的提示。
2. 不要暴露不穩定內部細節
Slot props 一旦被父層使用,就變成公開 API。
<template #default="{ row, selected, toggle }">row、selected、toggle 都應該是穩定且有語意的型別。
延伸讀:Vue 插槽設計:Slot、Scoped Slot 與 Headless Component。
十、 常見陷阱
1. ref(null) 推導太窄
不理想:
const user = ref(null)這會讓 user 的型別太窄。
更好:
const user = ref<User | null>(null)2. 濫用 as
const user = response as User如果你只是為了讓錯誤消失,這很危險。
型別斷言應該用在你真的知道型別比 TypeScript 更精準的地方,而不是逃避檢查。
3. 把 optional 當必填
type User = {
profile?: {
avatar: string
}
}
user.profile.avatar應該處理 undefined:
user.profile?.avatar或在資料轉換層補上預設值。
4. 後端 DTO 直接進 UI
後端欄位常會變,或有 null、snake_case、狀態碼。
UI 更適合面對穩定模型。
API DTO
-> API layer transform
-> frontend model
-> component5. 為了型別犧牲可讀性
有些型別可以很精細,但如果團隊看不懂,維護成本也高。
資深工程師要能判斷:這裡需要精準型別,還是簡單清楚的型別就夠。
十一、 追問題庫
Q1:Vue 裡 props 怎麼寫 TypeScript?
使用 defineProps<Props>(),有預設值時搭配 withDefaults。
如果 props 是公開元件 API,建議抽出明確的 Props type。
Q2:emit 怎麼約束 payload?
使用 defineEmits 的物件 tuple 型別:
const emit = defineEmits<{
save: [payload: UserForm]
cancel: []
}>()Q3:Template ref 為什麼要加 null?
因為 ref 在 mount 前還不存在,條件渲染時也可能被設回 null。型別應該誠實表達生命週期。
Q4:API 回傳已經有 TypeScript 型別,還要驗證嗎?
TypeScript 不會驗證 runtime 資料。如果資料重要,仍要依靠後端契約、API layer 轉換或 runtime schema validation。
Q5:什麼時候可以用 any?
可以用在非常靠近外部邊界、且短暫過渡的地方,但應該盡快收斂成 unknown、明確型別或 validated data。
大範圍使用 any 會讓 TypeScript 失去價值。
十二、 實作題
題目:設計一個型別安全的 UserForm
需求:
- props 接收
modelValue。 - emit
update:modelValue和submit。 - 表單型別和 API payload 分離。
- template ref 可以 focus email input。
可以這樣設計:
<script setup lang="ts">
type UserForm = {
name: string
email: string
roleIds: string[]
}
type UpdateUserPayload = {
name: string
email: string
roles: string[]
}
const props = defineProps<{
modelValue: UserForm
submitting?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: UserForm]
submit: [payload: UpdateUserPayload]
}>()
const emailRef = ref<HTMLInputElement | null>(null)
function updateField<K extends keyof UserForm>(
key: K,
value: UserForm[K]
) {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
})
}
function toPayload(form: UserForm): UpdateUserPayload {
return {
name: form.name.trim(),
email: form.email.trim(),
roles: form.roleIds,
}
}
function submit() {
emit('submit', toPayload(props.modelValue))
}
function focusEmail() {
emailRef.value?.focus()
}
defineExpose({
focusEmail,
})
</script>面試時可以補充:
updateField用泛型約束 key 和 value 的對應關係。UserForm是 UI draft,UpdateUserPayload是 API payload。emailRef需要HTMLInputElement | null。- 暴露給父層的方法要少而穩定。
十三、 資深視角
1. 型別設計是系統邊界設計
Vue + TypeScript 最值得投入的是邊界:
- 元件 API。
- API client。
- Store。
- Composable。
- 路由 meta。
- 表單 payload。
這些地方型別清楚,系統會穩很多。
2. 型別不能取代資料治理
如果後端資料不穩定,只靠 as User 沒用。
成熟專案會建立:
- API DTO。
- transform layer。
- runtime validation。
- error format。
- shared schema 或 contract。
TypeScript 是防線之一,不是唯一防線。
3. 不要追求炫技型別
有些型別技巧很漂亮,但團隊維護不了。
好型別應該:
- 能說明資料意圖。
- 能擋住常見錯誤。
- 不讓使用者難以理解。
- 不需要大量
as才能使用。
總結
| 問題 | 回答重點 |
|---|---|
| Props 型別 | defineProps<Props>(),預設值用 withDefaults |
| Emits 型別 | defineEmits 約束事件名稱與 payload |
v-model 型別 | 傳統 modelValue + update:modelValue,或 defineModel |
| Template Ref | DOM ref / Component ref 都要處理 null |
| Composable 泛型 | 讓可復用邏輯保留型別安全 |
| API 型別 | DTO 和前端 model 分離,不盲信 runtime data |
| Pinia 型別 | Setup Store 自然推導,Option Store 注意 state 初始值 |
一句完整的面試回答可以是:
Vue + TypeScript 我會先把元件邊界型別定清楚:props 用
defineProps,預設值用withDefaults,emit 用defineEmits約束事件名稱和 payload,v-model可以用modelValue/update:modelValue或defineModel。Template ref 要誠實處理 null,Component ref 可以用InstanceType<typeof Component>。Composable 可以用泛型保留輸入輸出型別,Pinia Setup Store 通常能自然推導。API 型別要區分後端 DTO 和前端 model,不要只用as相信後端資料;重要資料要在 API layer 轉換或驗證。TypeScript 的價值是讓錯誤在開發階段暴露,而不是用any把問題藏起來。