Vue + TypeScript

Vue 3 是用 TypeScript 編寫的,提供了出色的 TypeScript 支援。使用 TypeScript 可以獲得更好的 IDE 支援、程式碼提示和型別檢查。

建立專案

使用 create-vue 建立支援 TypeScript 的專案:

npm create vue@latest

# 選擇 "Add TypeScript?" -> Yes

或手動加入 TypeScript:

npm install -D typescript vue-tsc

單文件元件中使用 TypeScript

<script setup> 中加入 lang="ts"

<script setup lang="ts">
import { ref } from 'vue'

// 型別推斷
const count = ref(0)  // Ref<number>
const message = ref('Hello')  // Ref<string>

// 明確型別
const items = ref<string[]>([])
const user = ref<User | null>(null)
</script>

定義 Props

執行時宣告

<script setup lang="ts">
const props = defineProps({
  title: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  },
  items: {
    type: Array as PropType<string[]>,
    default: () => []
  }
})
</script>

型別宣告(推薦)

<script setup lang="ts">
interface Props {
  title: string
  count?: number
  items?: string[]
  user?: {
    name: string
    age: number
  }
}

const props = defineProps<Props>()
</script>

有預設值的 Props

<script setup lang="ts">
interface Props {
  title: string
  count?: number
  items?: string[]
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => []
})
</script>

定義 Emits

執行時宣告

<script setup lang="ts">
const emit = defineEmits(['update', 'delete'])

// 帶驗證
const emit = defineEmits({
  update: (value: string) => true,
  delete: (id: number) => id > 0
})
</script>

型別宣告(推薦)

<script setup lang="ts">
// 函式簽名語法
const emit = defineEmits<{
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
}>()

// Vue 3.3+ 更簡潔的語法
const emit = defineEmits<{
  update: [value: string]
  delete: [id: number]
}>()
</script>

定義 Ref

<script setup lang="ts">
import { ref, reactive } from 'vue'

// 基本型別
const count = ref<number>(0)
const message = ref<string>('')
const isActive = ref<boolean>(false)

// 複雜型別
interface User {
  id: number
  name: string
  email: string
}

const user = ref<User | null>(null)
const users = ref<User[]>([])

// reactive
const state = reactive<{
  count: number
  users: User[]
}>({
  count: 0,
  users: []
})
</script>

定義 Computed

<script setup lang="ts">
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// 型別會自動推斷
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})

// 明確指定型別
const fullName = computed<string>(() => {
  return `${firstName.value} ${lastName.value}`
})

// 可寫 computed
const fullName = computed({
  get(): string {
    return `${firstName.value} ${lastName.value}`
  },
  set(value: string) {
    const names = value.split(' ')
    firstName.value = names[0]
    lastName.value = names[1]
  }
})
</script>

Template Refs

<script setup lang="ts">
import { ref, onMounted } from 'vue'

// DOM 元素
const inputRef = ref<HTMLInputElement | null>(null)

onMounted(() => {
  inputRef.value?.focus()
})

// 元件實例
import MyComponent from './MyComponent.vue'
const compRef = ref<InstanceType<typeof MyComponent> | null>(null)
</script>

<template>
  <input ref="inputRef" type="text">
  <MyComponent ref="compRef" />
</template>

事件處理

<script setup lang="ts">
function handleClick(event: MouseEvent) {
  console.log(event.clientX, event.clientY)
}

function handleInput(event: Event) {
  const target = event.target as HTMLInputElement
  console.log(target.value)
}

function handleSubmit(event: Event) {
  event.preventDefault()
  // ...
}
</script>

<template>
  <button @click="handleClick">Click</button>
  <input @input="handleInput">
  <form @submit="handleSubmit">...</form>
</template>

Provide / Inject

<!-- 父元件 -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import type { InjectionKey, Ref } from 'vue'

interface User {
  name: string
  age: number
}

// 定義 key 並指定型別
export const userKey: InjectionKey<Ref<User>> = Symbol('user')

const user = ref<User>({ name: 'John', age: 30 })
provide(userKey, user)
</script>
<!-- 子元件 -->
<script setup lang="ts">
import { inject } from 'vue'
import { userKey } from './parent.vue'

// 型別會正確推斷
const user = inject(userKey)

// 帶預設值
const user = inject(userKey, ref({ name: 'Default', age: 0 }))
</script>

defineExpose

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

// 暴露的內容會有正確的型別
defineExpose({
  count,
  increment
})
</script>

defineModel

<script setup lang="ts">
// 基本
const model = defineModel<string>()

// 必填
const model = defineModel<string>({ required: true })

// 有預設值
const model = defineModel<number>({ default: 0 })

// 具名
const title = defineModel<string>('title')
</script>

Composables 的型別

// composables/useCounter.ts
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'

interface UseCounterReturn {
  count: Ref<number>
  doubled: ComputedRef<number>
  increment: () => void
  decrement: () => void
}

export function useCounter(initialValue = 0): UseCounterReturn {
  const count = ref(initialValue)
  const doubled = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  return {
    count,
    doubled,
    increment,
    decrement
  }
}
// composables/useFetch.ts
import { ref, watchEffect, toValue } from 'vue'
import type { Ref, MaybeRefOrGetter } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  isLoading: Ref<boolean>
}

export function useFetch<T>(url: MaybeRefOrGetter<string>): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const isLoading = ref(false)

  watchEffect(async () => {
    isLoading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url))
      data.value = await response.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      isLoading.value = false
    }
  })

  return { data, error, isLoading }
}

Pinia Store 的型別

// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

export const useUserStore = defineStore('user', () => {
  const user = ref<User | null>(null)
  const token = ref<string | null>(null)

  const isLoggedIn = computed(() => !!token.value)

  async function login(email: string, password: string): Promise<void> {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email, password })
    })
    const data = await response.json()
    user.value = data.user
    token.value = data.token
  }

  function logout(): void {
    user.value = null
    token.value = null
  }

  return {
    user,
    token,
    isLoggedIn,
    login,
    logout
  }
})

Vue Router 的型別

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    title?: string
    roles?: string[]
  }
}

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'home',
    component: () => import('../views/Home.vue'),
    meta: {
      title: '首頁'
    }
  },
  {
    path: '/admin',
    name: 'admin',
    component: () => import('../views/Admin.vue'),
    meta: {
      requiresAuth: true,
      roles: ['admin']
    }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

在元件中使用:

<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

// route.meta 會有正確的型別
console.log(route.meta.requiresAuth)  // boolean | undefined
console.log(route.meta.roles)         // string[] | undefined
</script>

全域屬性型別

// main.ts
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 添加全域屬性
app.config.globalProperties.$http = axios
app.config.globalProperties.$filters = {
  currency(value: number): string {
    return `$${value.toFixed(2)}`
  }
}

// 型別擴展
declare module 'vue' {
  interface ComponentCustomProperties {
    $http: typeof axios
    $filters: {
      currency(value: number): string
    }
  }
}

tsconfig.json 設定

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,

    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "preserve",

    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,

    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

常用型別

import type {
  // 響應式
  Ref,
  ComputedRef,
  ShallowRef,
  ToRef,
  ToRefs,
  UnwrapRef,

  // Props
  PropType,

  // 元件
  Component,
  DefineComponent,

  // 其他
  InjectionKey,
  MaybeRef,
  MaybeRefOrGetter,

  // 事件
  VNode
} from 'vue'

最佳實踐

  1. 優先使用型別宣告:對於 props 和 emits,使用泛型語法更簡潔
  2. 善用型別推斷:Vue 的型別推斷很強,不需要處處標註型別
  3. 定義介面:將複雜型別提取成介面
  4. 使用 strict 模式:在 tsconfig 中啟用嚴格模式
  5. 善用 IDE:VS Code + Volar 提供最佳的開發體驗