前端 TypeScript 入门2

前端 TypeScript 入门2

上一篇中,我们了解了 TS 常用语法,但是在Vue3项目实际开发中,会发现很多 TS 代码看不懂。本篇以实际 Vue3 项目为例,抽取出其中绝大多数 TS 常见写法,快速进入实战。

一、API 层的 TypeScript 用法

1.1 定义接口数据结构(interface)

在项目中,我们使用 interface 定义后端返回的数据结构。

// src/api/system/user/profile.ts
export interface ProfileVO {
  id: number
  username: string
  nickname: string
  dept: {
    id: number
    name: string
  }
  // roles 是一个数组,数组里的每一项都是包含 id 和 name 两个字段的对象
  roles: {
    id: number
    name: string
  }[]
  posts: {
    id: number
    name: string
  }[]
  email: string
  sex: number
  status: number
  remark: string
  createTime: Date
}

关键点:

  • [] 表示数组类型,如 roles: {...}[] 表示角色数组
  • 嵌套对象直接在 interface 中定义,如 dept: { id: number; name: string }
  • Date 类型表示日期时间

1.2 可选属性与联合类型

// src/api/system/user/profile.ts
export interface UserProfileUpdateReqVO {
  nickname?: string // ? 表示可选属性
  email?: string
  mobile?: string
  sex?: number
  avatar?: string
}

// src/api/system/sms/smsLog/index.ts
export interface SmsLogVO {
  id: number | null // 联合类型:可以是 number 或 null
  channelId: number | null
  templateParams: Map<string, object> | null 
  sendStatus: number | null
  sendTime: Date | null
}

关键点:

  • ? 表示该属性可选,可以不传
  • | 表示联合类型,值可以是多种类型之一
  • nullundefined 常用于表示空值
  • Map<string, object>: 键是 string、值是 object 的 Map

1.3 接口继承(extends)

// src/api/system/tenant/index.ts
export interface TenantPageReqVO extends PageParam {
  name?: string
  contactName?: string
  contactMobile?: string
  status?: number
  createTime?: Date[]
}

关键点:

  • extends PageParam 继承了分页参数(pageNo, pageSize)
  • 继承后可以添加自己的属性
  • PageParam 是全局类型,定义在 types/global.d.ts
// global.d.ts
export {}
declare global {
  interface PageParam {
    pageSize?: number
    pageNo?: number
  }
}

TypeScript 是如何找到这个 global.d.ts 的:

{
  "compilerOptions": {
    // 告诉 TS:类型声明文件的根目录在这两个地方:
    "typeRoots": ["./node_modules/@types/", "./types"]
  },
  // 参与编译/类型检查的文件包括 types 目录下所有 .d.ts
  "include": [
    "src",
    "types/**/*.d.ts",
    "src/types/auto-imports.d.ts",
    "src/types/auto-components.d.ts"
  ]
}

1.4 API 函数的类型标注

// src/api/system/user/index.ts
// 查询用户详情 - 参数和返回值都有类型
export const getUser = (id: number) => {
  return request.get({ url: '/system/user/get?id=' + id })
}

// 新增用户 - data 参数类型为 UserVO
export const createUser = (data: UserVO) => {
  return request.post({ url: '/system/user/create', data })
}

// 修改用户
export const updateUser = (data: UserVO) => {
  return request.put({ url: '/system/user/update', data })
}

// 批量删除 - 参数是数字数组
export const deleteUserList = (ids: number[]) => {
  return request.delete({ url: '/system/user/delete-list', params: { ids: ids.join(',') } })
}

关键点:

  • 参数类型写在参数名后面:(id: number)
  • 对象类型参数用 interface:(data: UserVO)
  • 数组类型:ids: number[]

1.5 async/await 与 Promise 类型

// src/api/system/post/index.ts
// 异步返回一个 Promise,这个 Promise 最终会 resolve 成 PostVO 对象组成的数组。
// 返回 Promise<PostVO[]>。
export const getSimplePostList = async (): Promise<PostVO[]> => {
  return await request.get({ url: '/system/post/simple-list' })
}

// 查询详情
export const getPost = async (id: number) => {
  return await request.get({ url: '/system/post/get?id=' + id })
}

// 删除
export const deletePost = async (id: number) => {
  return await request.delete({ url: '/system/post/delete?id=' + id })
}

关键点:

  • async 函数自动返回 Promise
  • Promise<PostVO[]> 明确返回的数据类型
  • await 等待异步操作完成

二、Store(Pinia)层的 TypeScript 用法

2.1 定义 Store 的 State 类型

// src/store/modules/user.ts
export interface CompanyVO {
  pid: string
  companyName: string
  isDefault: number
}

interface UserVO {
  id: number
  avatar: string
  nickname: string
  deptId: number
  companyList: CompanyVO[]
  sex?: number
  position?: string
}

interface UserInfoVO {
  permissions: Set<string> // Set 类型
  roles: string[]
  isSetUser: boolean
  user: UserVO
}

export const useUserStore = defineStore('admin-user', {
  state: (): UserInfoVO => ({
    permissions: new Set<string>(),
    roles: [],
    isSetUser: false,
    user: {
      id: 0,
      avatar: '',
      nickname: '',
      deptId: 0,
      companyList: []
    }
  })
  // ... getters 和 actions
})

关键点:

  • state: (): UserInfoVO => (...) 定义 state 返回类型是 UserInfoVO。主要是为了类型检查和提示,不一定会被你显式“拿出来用”。
  • Set<string> 表示字符串集合。Set 中的值必须是 string 类型。

2.2 Getters 的类型

// src/store/modules/user.ts
export const useUserStore = defineStore('admin-user', {
  // ... state
  getters: {
    getPermissions(): Set<string> {
      return this.permissions
    },
    getRoles(): string[] {
      return this.roles
    },
    getIsSetUser(): boolean {
      return this.isSetUser
    },
    getUser(): UserVO {
      return this.user
    }
  }
})

关键点:

  • getter 函数后面标注返回类型
  • 使用 this 访问 state

2.3 Actions 的类型标注

// src/store/modules/user.ts
export const useUserStore = defineStore('admin-user', {
  // ... state, getters
  actions: {
    async setUserInfoAction() {
      if (!getAccessToken()) {
        this.resetState()
        return null
      }
      let userInfo = wsCache.get(CACHE_KEY.USER)
      if (!userInfo) {
        userInfo = await getInfo()
      }
      this.permissions = new Set(userInfo.permissions || [])
      this.roles = userInfo.roles
      this.user = userInfo.user
    },

    async setUserAvatarAction(avatar: string) {
      const userInfo = wsCache.get(CACHE_KEY.USER)
      this.user.avatar = avatar
      userInfo.user.avatar = avatar
      wsCache.set(CACHE_KEY.USER, userInfo)
    },

    async loginOut() {
      try {
        await loginOut()
      } catch (error) {
        console.error('登出接口调用失败:', error)
      } finally {
        removeToken()
        deleteUserCache()
        this.resetState()
      }
    }
  }
})

关键点:

  • action 函数参数需要类型:(avatar: string)
  • async action 返回 Promise
  • 通过 this 修改 state

2.4 Map 类型的使用

// src/store/modules/mall/kefu.ts
interface MallKefuInfoVO {
  conversationList: KeFuConversationRespVO[]
  conversationMessageList: Map<number, KeFuMessageRespVO[]> // Map 类型
}

export const useMallKefuStore = defineStore('mall-kefu', {
  state: (): MallKefuInfoVO => ({
    conversationList: [],
    conversationMessageList: new Map<number, KeFuMessageRespVO[]>()
  }),
  getters: {
    // 返回函数的 getter
    getConversationMessageList(): (conversationId: number) => KeFuMessageRespVO[] | undefined {
      return (conversationId: number) => this.conversationMessageList.get(conversationId)
    }
    /*
    等价:
    type GetMsgFn = (conversationId: number) => KeFuMessageRespVO[] | undefined

    getConversationMessageList(): GetMsgFn {
      return (conversationId: number) => this.conversationMessageList.get(conversationId)
    }
    */
  },
  actions: {
    saveMessageList(conversationId: number, messageList: KeFuMessageRespVO[]) {
      this.conversationMessageList.set(conversationId, messageList)
    }
  }
})

关键点:

  • Map<number, KeFuMessageRespVO[]> 键是 number,值是数组
  • getter 可以返回函数
  • 使用 .get().set() 操作 Map
  • (conversationId: number) => KeFuMessageRespVO[] | undefined: 类型是函数,返回值是KeFuMessageRespVO[]或undefined

2.5 复杂 State 定义

// src/store/modules/app.ts
interface AppState {
  breadcrumb: boolean
  breadcrumbIcon: boolean
  collapse: boolean
  uniqueOpened: boolean
  hamburger: boolean
  screenfull: boolean
  size: boolean
  locale: boolean
  message: boolean
  tagsView: boolean
  tagsViewImmerse: boolean
  tagsViewIcon: boolean
  logo: boolean
  fixedHeader: boolean
  greyMode: boolean
  pageLoading: boolean
  layout: LayoutType
  title: string
  userInfo: string
  isDark: boolean
  currentSize: ElementPlusSize
  sizeMap: ElementPlusSize[]
  mobile: boolean
  footer: boolean
  theme: ThemeTypes
  fixedMenu: boolean
}

export const useAppStore = defineStore('app', {
  state: (): AppState => {
    return {
      userInfo: 'userInfo',
      sizeMap: ['default', 'large', 'small'],
      mobile: false,
      title: import.meta.env.VITE_APP_TITLE,
      pageLoading: false
      // ... 其他属性
    }
  }
})

关键点:

  • 布尔类型属性集中定义
  • 自定义类型:LayoutType, ElementPlusSize, ThemeTypes
  • 使用 import.meta.env 获取环境变量

三、Views(Vue 组件)层的 TypeScript 用法

3.1 defineOptions 和 ref

// src/views/system/role/index.vue
<script lang="ts" setup>
import * as RoleApi from '@/api/system/role'

defineOptions({ name: 'SystemRole' })

const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化

const loading = ref(true)  // 布尔值
const total = ref(0)       // 数字
const list = ref([])       // 数组
</script>

关键点:

  • defineOptions 定义组件选项
  • ref 自动推断类型
  • 不需要显式标注简单类型

3.2 明确 ref 类型

// src/views/system/user/index.vue
<script lang="ts" setup>
import * as UserApi from '@/api/system/user'

const loading = ref(true)
const total = ref(0)
const list = ref([])  // 可以推断,但不明确

// 更好的写法:明确类型
const loading = ref<boolean>(true)
const total = ref<number>(0)
const list = ref<UserApi.UserVO[]>([])  // 明确是 UserVO 数组
const companyTreeData = ref<Tree[]>([])  // Tree 是全局类型
</script>

关键点:

  • ref<类型>(初始值) 明确类型
  • 数组类型:ref<UserVO[]>([])
  • 使用导入的 API 类型

3.3 reactive 定义复杂对象

// src/views/system/user/index.vue
<script lang="ts" setup>
const queryParams = reactive({
  pageNo: 1,
  pageSize: 10,
  username: undefined,
  mobile: undefined,
  status: undefined,
  deptId: undefined,
  createTime: []
})
</script>

关键点:

  • reactive 用于复杂对象
  • 自动推断类型
  • 适合查询参数对象

3.4 defineProps 类型定义

// src/views/system/user/UserForm.vue
<script lang="ts" setup>
// 方式 1:使用 defineProps<>()
const props = defineProps<{
  companyTreeData?: any[]
  fixedCompany?: { name: string; pid: number } | null
}>()

// 访问 props
const companyTreeData = computed(() => props.companyTreeData || [])
const fixedCompany = computed(() => props.fixedCompany || null)
</script>
// src/views/system/companyTree/detail.vue
<script setup lang="ts">
import { ref, defineProps } from 'vue'

// 方式 2:定义带默认值的 props
const props = defineProps({
  title: {
    type: String,
    default: ''
  }
})
</script>

关键点:

  • defineProps<{}>() 泛型方式定义 props
  • 可选属性用 ?
  • 联合类型:{ name: string; pid: number } | null
  • computed 包装 props 进行计算

defineProps语法:

// 孩子没脾气
defineProps(['persons'])

// 接收+限制类型
defineProps<{persons:Persons}>()

// 接收+限制类型+限制必要性 —— 可以不传
defineProps<{persons?:Persons}>()

// 接收+限制类型+限制必要性+默认值
import {withDefaults} from 'vue'
withDefaults(defineProps<{persons?:Persons}>(), {
  persons: () => []
})

3.5 defineEmits 类型定义

// src/views/system/user/CompanyTree.vue
<script lang="ts" setup>
// 定义 emit 类型
const emits = defineEmits<{
  (e: 'node-click', row: any): void
}>()

// 触发事件
const handleNodeClick = (row: any) => {
  emits('node-click', row)
}
</script>

关键点:

  • (e: '事件名', 参数: 类型): void
  • 可以定义多个事件
  • 使用 emits('事件名', 参数) 触发

声明一个名为 'node-click'的事件的三种写法(区别是类型检查严格程度):

defineEmits(['node-click'])

// 定义一个名为 'node-click'​ 的事件。这是一个函数签名形式的定义
(e: 'node-click', row: any): void

const emit = defineEmits<{
  // 新语法:'事件名': [参数1类型, 参数2类型?]
  'node-click': [row: any]  
}>();

3.6 FormRules 表单验证类型

// src/views/system/user/ResetPasswordForm.vue
<script lang="ts" setup>
import { FormRules } from 'element-plus'

const formData = ref({
  id: undefined as number | undefined,
  username: '',
  password: ''
})

const formRules = reactive<FormRules>({
  password: [
    { required: true, message: '新密码不能为空', trigger: 'blur' },
    { min: 6, max: 16, message: '密码长度为 6-16 位', trigger: 'blur' },
    {
      pattern: /^(...$/,
      message: '密码需包含...',
      trigger: 'blur'
    }
  ]
})
</script>

关键点:

  • FormRules 来自 element-plus
  • reactive<FormRules> 定义验证规则
  • as number | undefined 类型断言。手动告诉 TS 这个值的类型是 number | undefined(联合类型)。
  • id: undefined as number | undefined 初始值是 undefined,但后面可能会被赋值成 number,所以类型标注为 number | undefined

3.7 组件 ref 类型

// src/views/system/user/index.vue
<script lang="ts" setup>
import { ElTree } from 'element-plus'

const treeRef = ref<InstanceType<typeof ElTree>>()

// 调用组件方法
treeRef.value?.filter(val)
</script>

关键点:

  • InstanceType<typeof ElTree> 获取组件实例类型
    • ref():创建一个响应式引用,类型是 T
    • T 就是 InstanceType
    • typeof ElTree:获取 ElTree 组件的构造函数类型
    • InstanceType:从构造函数类型中提取实例类型
  • ?. 可选链操作符,避免 undefined 报错

3.8 computed 计算属性类型

// src/views/system/user/UserForm.vue
<script lang="ts" setup>
const props = defineProps<{
  companyTreeData?: any[]
}>()

// 自动推断返回类型
const companyTreeData = computed(() => props.companyTreeData || [])

// 明确返回类型
const isValid = computed<boolean>(() => {
  return formData.value.password.length >= 8
})
</script>

关键点:

  • computed(() => {}) 自动推断
  • computed<类型>(() => {}) 明确类型

四、全局类型定义(types)

4.1 全局工具类型

// types/global.d.ts
declare global {
  // 函数类型
  interface Fn<T = any> {
    (...arg: T[]): T
  }

  // 可空类型
  /*
  - type Xxx = ...:和 ype Message = number | string 一样,都是起一个类型别名
  - T:是一个类型参数,占位的,不是具体类型
  - 示例:
    - type Nullable<T> = T | null
    - type A = Nullable<string>   // string | null
    - type B = Nullable<number>   // number | null
  */
  type Nullable<T> = T | null

  // 元素引用类型。声明一个“可以为 null 的 DOM 引用类型”,T 必须是 HTMLElement 的子类型,默认用 HTMLDivElement。
  type ElRef<T extends HTMLElement = HTMLDivElement> = Nullable<T>

  // 记录类型(对象)
  /*
    如果不考虑 null/undefined 的边界情况,可以简化成:
    type Recordable<T = any, K = string> = Record<K, T>

    - Record<K, V> 是 TypeScript 内置的工具类型
      - 一个对象,键的类型是 K,值的类型是 V
      - 示例:
        type User = Record<string, any>
        // 等价于:{ [key: string]: any }

        const user: User = {
          name: '张三',
          age: 18,
          job: '工程师'
        }
    - 条件类型:K extends null | undefined ? string : K
      - 如果 K 是 null 或 undefined → 键类型用 string
      - 否则 → 键类型就用 K 本身
    - 最终结果:Record<键类型, T>
  */
  type Recordable<T = any, K = string> = Record<K extends null | undefined ? string : K, T>

  // 组件引用类型
  /*
    做一层语义封装,让代码更清晰。

    不用别名:
      const dialogRef = ref<InstanceType<typeof ElDialog>>()
      const tableRef = ref<InstanceType<typeof ElTable>>()
    用别名(用 ComponentRef):
      const dialogRef = ref<ComponentRef<typeof ElDialog>>()
      const tableRef = ref<ComponentRef<typeof ElTable>>()
  */
  type ComponentRef<T> = InstanceType<T>

  // 分页参数
  interface PageParam {
    pageSize?: number
    pageNo?: number
  }

  // 分页结果
  interface PageResult<T> {
    list: T
    total: number
  }

  // 树结构
  interface Tree {
    id: number
    name: string
    children?: Tree[] | any[]
  }
}

关键点:

  • declare global 声明全局类型
  • 泛型类型:Nullable<T>, PageResult<T>
  • 在任何文件中都可以直接使用

4.2 表单 Schema 类型

// src/types/form.d.ts
export type FormValueType = string | number | string[] | number[] | boolean | undefined | null

export type FormItemProps = {
  labelWidth?: string | number
  required?: boolean
  rules?: Recordable
  error?: string
  showMessage?: boolean
  inlineMessage?: boolean
  style?: CSSProperties
}

export type FormSchema = {
  field: string
  label?: string
  labelMessage?: string
  colProps?: ColProps
  /*
    定义一个可选属性 componentProps,它的类型是两个类型的交叉
      - componentProps?:可选属性
      - { slots?: Recordable } & ComponentProps:交叉类型(&)
      - 交叉示例:
        type A = { name: string }
        type B = { age: number }
        type C = A & B  // { name: string; age: number }

        const obj: C = {
          name: '张三',
          age: 18
        }
      - { slots?: Recordable } & ComponentProps
        - 必须包含 ComponentProps 里的所有属性
        - 同时可以有一个可选的 slots 属性,类型是 Recordable(也就是 Record<string, any>)
  */
  componentProps?: { slots?: Recordable } & ComponentProps
  formItemProps?: FormItemProps
  component?: ComponentName
  value?: FormValueType
  hidden?: boolean
  // 返回值是 AxiosPromise<T>(也就是 Promise<AxiosResponse<T>>),resolve 的值是 AxiosResponse<T>,而 response.data 的类型是 T。
  api?: <T = any>() => AxiosPromise<T>
}

关键点:

  • type 定义类型别名
  • 联合类型:string | number | boolean
  • 泛型函数:<T = any>() => AxiosPromise<T>

五、实用工具函数的 TypeScript

5.1 函数参数和返回值类型

// src/utils/index.ts

// 字符串转换
export const humpToUnderline = (str: string): string => {
  return str.replace(/([A-Z])/g, '-$1').toLowerCase()
}

// 设置 CSS 变量
export const setCssVar = (prop: string, val: any, dom = document.documentElement) => {
  dom.style.setProperty(prop, val)
}

// 数组查找(泛型函数)
export const findIndex = <T = Recordable>(ary: Array<T>, fn: Fn): number => {
  if (ary.findIndex) {
    return ary.findIndex(fn)
  }
  let index = -1
  ary.some((item: T, i: number, ary: Array<T>) => {
    const ret: T = fn(item, i, ary)
    if (ret) {
      index = i
      return ret
    }
  })
  return index
}

关键点:

  • (参数: 类型): 返回类型 => {}
  • 泛型函数:<T = Recordable>
  • 默认参数:dom = document.documentElement

5.2 时间格式化函数

// src/utils/index.ts
export function formatTime(time: Date | number | string, fmt: string) {
  if (!time) return ''
  else {
    const date = new Date(time)
    const o = {
      'M+': date.getMonth() + 1,
      'd+': date.getDate(),
      'H+': date.getHours(),
      'm+': date.getMinutes(),
      's+': date.getSeconds(),
      'q+': Math.floor((date.getMonth() + 3) / 3),
      S: date.getMilliseconds()
    }
    // ... 格式化逻辑
    return fmt
  }
}

关键点:

  • 联合类型参数:Date | number | string
  • 对象字面量类型自动推断

5.3 数字和金额处理

// src/utils/index.ts

// 数组求和
export const getSumValue = (values: number[]): number => {
  return values.reduce((prev, curr) => {
    const value = Number(curr)
    if (!Number.isNaN(value)) {
      return prev + curr
    } else {
      return prev
    }
  }, 0)
}

// 元转分
export const yuanToFen = (amount: string | number): number => {
  return convertToInteger(amount)
}

// 分转元
export const fenToYuan = (price: string | number): string => {
  return formatToFraction(price)
}

// ERP 格式化数字
export const erpNumberFormatter = (num: number | string | undefined, digit: number) => {
  if (num == null) {
    return ''
  }
  if (typeof num === 'string') {
    num = parseFloat(num)
  }
  if (isNaN(num)) {
    return ''
  }
  return num.toFixed(digit)
}

关键点:

  • 参数支持多种类型:string | number
  • 返回值类型明确:: number: string
  • undefined 处理

六、Hooks(组合式函数)的 TypeScript

6.1 useTable Hook

// src/hooks/web/useTable.ts
interface ResponseType<T = any> {
  list: T[]
  total?: number
}

interface UseTableConfig<T = any> {
  getListApi: (option: any) => Promise<T>
  delListApi?: (option: any) => Promise<T>
  exportListApi?: (option: any) => Promise<T>
  response?: ResponseType
  defaultParams?: Recordable
  props?: TableProps
}

interface TableObject<T = any> {
  pageSize: number
  currentPage: number
  total: number
  tableList: T[]
  params: any
  loading: boolean
  exportLoading: boolean
  currentRow: Nullable<T>
}
/*
  - reactive<TableObject<T>> 表示创建一个响应式对象,它的类型是 TableObject<T>
  - 初始值是 { pageSize: 10, ... },必须符合 TableObject<T> 的结构
*/
export const useTable = <T = any>(config?: UseTableConfig<T>) => {
  const tableObject = reactive<TableObject<T>>({
    pageSize: 10,
    currentPage: 1,
    total: 10,
    tableList: [],
    params: {
      ...(config?.defaultParams || {})
    },
    loading: true,
    exportLoading: false,
    currentRow: null
  })

  const paramsObj = computed(() => {
    return {
      ...tableObject.params,
      pageSize: tableObject.pageSize,
      pageNo: tableObject.currentPage
    }
  })

  const methods = {
    getList: async () => {
      tableObject.loading = true
      const res = await config?.getListApi(unref(paramsObj)).finally(() => {
        tableObject.loading = false
      })
      if (res) {
        // 不管 res 原本是什么类型,我强制把它当成 ResponseType 来用
        tableObject.tableList = (res as unknown as ResponseType).list
        tableObject.total = (res as unknown as ResponseType).total ?? 0
      }
    }
    // ... 其他方法
  }

  return {
    tableObject,
    methods
  }
}

关键点:

  • 泛型 Hook:<T = any>
  • 接口定义配置和状态
  • reactive<类型> 定义响应式对象
  • 双重类型断言:as unknown as ResponseType
    • 先断言成 unknown
    • 再断言成 ResponseType
    • 用来强制转换原本不兼容的类型,绕过 TS 检查

6.2 组件引用类型

// src/hooks/web/useTable.ts
import { ElTable } from 'element-plus'
/*
  它是Table 组件实例 + TableExpose 接口的交叉类型
  既是 Table 组件的实例,又包含 TableExpose 里定义的方法。等价于:
  type TableRef = InstanceType<typeof Table> & TableExpose
*/
const tableRef = ref<typeof Table & TableExpose>()
const elTableRef = ref<ComponentRef<typeof ElTable>>()

const register = (ref: typeof Table & TableExpose, elRef: ComponentRef<typeof ElTable>) => {
  tableRef.value = ref
  elTableRef.value = elRef
}

关键点:

  • typeof 获取类型
  • & 交叉类型(同时满足多个类型)
  • ComponentRef<T> 组件实例类型

typeof 用法:

function createUser(name: string, age: number) {
  return {
    name,
    age,
    sayHello() {
      console.log(`Hi, I'm ${name}`)
    }
  }
}

type CreateUserFn = typeof createUser

const myCreateUser: CreateUserFn = (n, a) => {
  return { age: a, sayHello() {} }  // ❌ 报错。缺少name
}

七、常见类型使用技巧

7.1 类型断言

// 断言为特定类型
const value = someValue as string

// 先断言为 unknown,再断言为目标类型
const data = res as unknown as ResponseType

// 表单数据的类型断言
/*
  - formData.value.id:某个表单数据的 id 字段
  - undefined:赋的值是 undefined
  - as number | undefined:告诉 TS,这个 id 的类型是 number | undefined

  如果直接写:formData.value.id = undefined,TS 可能推断 id 的类型是 undefined,后面你想给它赋数字时会报错

  其实不用断言:id: number | undefined
*/
formData.value.id = undefined as number | undefined

7.2 可选链和空值合并

// 可选链:?.
treeRef.value?.filter(val)
config?.getListApi(params)

// 空值合并:??
const total = response.total ?? 0
const lang = wsCache.get(CACHE_KEY.LANG) || 'zh-CN'

7.3 数组和对象的类型

// 数组类型
const list: string[] = []
const roles: number[] = [1, 2, 3]
const users: UserVO[] = []

// 对象类型
const obj: { [key: string]: any } = {}
const params: Recordable = {}

// Map 和 Set
const map = new Map<string, UserVO>()
const set = new Set<string>()

7.4 函数类型

// 函数类型定义
/*
  Callback 是一个函数类型,这个函数接收一个 data 参数(任意类型),不返回任何值
    - (data: any) => void:这是一个函数类型
    - 示例:
      type Callback = (data: any) => void

      // 1. 定义一个符合 Callback 类型的函数
      const handleSuccess: Callback = (data) => {
        console.log('成功:', data)
      }
*/
type Callback = (data: any) => void
type ApiFunction = (params: any) => Promise<any>

// 箭头函数
const handleClick = (id: number): void => {
  console.log(id)
}

// async 函数
const fetchData = async (id: number): Promise<UserVO> => {
  const res = await api.getUser(id)
  return res
}

八、项目中的高级 TypeScript 用法

8.1 泛型约束

// 约束泛型必须包含某些属性
/*
  K extends keyof T:K 必须是 T 的键之一。
  - 不安全的写法:
    function getPropertyUnsafe(obj: any, key: string) {
      return obj[key]  // 返回值类型是 any,不安全
    }

    const value = getPropertyUnsafe(user, 'xxx')  // ✅ 不报错,但运行时可能 undefined
*/
function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key]
}

// 使用示例
const user = { name: '张三', age: 20 }
const name = getProperty(user, 'name') // OK
const gender = getProperty(user, 'gender') // 错误:gender 不在 user 中

8.2 工具类型

// Partial:所有属性变为可选
/*
  // 原始类型
  interface UserVO {
    id: number
    name: string
  }

  // 使用 Partial
  type PartialUser = Partial<UserVO>

  // 等价于手动写:
  type PartialUser = {
    id?: number
    name?: string
  }
*/
type PartialUser = Partial<UserVO>

// Required:所有属性变为必填
type RequiredUser = Required<UserVO>

// Pick:选择部分属性
type UserBasic = Pick<UserVO, 'id' | 'username' | 'nickname'>

// Omit:排除部分属性
type UserWithoutPassword = Omit<UserVO, 'password'>

// Record:创建对象类型
/*
  使用 TypeScript 内置工具类型 Record<K, V> 创建一个"键值对映射"类型
    - Record<K, V>:创建一个对象类型
    - Record<number, UserVO>
    
  等价于手动写:
    type UserMap = {
      [key: number]: UserVO
    }

  示例:
    interface UserVO {
      id: number
      name: string
      email: string
    }

    type UserMap = Record<number, UserVO>

    // 使用
    const users: UserMap = {
      1: { id: 1, name: '张三', email: 'zhang@example.com' },
      2: { id: 2, name: '李四', email: 'li@example.com' },
      100: { id: 100, name: '王五', email: 'wang@example.com' }
    }

    // 访问
    const user1 = users[1]      // UserVO 类型
    const user2 = users[2]      // UserVO 类型

    // ❌ 键必须是 number
    const invalid: UserMap = {
      'abc': { id: 1, name: '张三', email: 'zhang@example.com' }  // 报错
    }
*/
type UserMap = Record<number, UserVO>

8.3 条件类型

// 根据条件选择类型
type IsString<T> = T extends string ? true : false

// 使用示例
type A = IsString<string> // true
type B = IsString<number> // false

// 项目中的使用
type Recordable<T = any, K = string> = Record<K extends null | undefined ? string : K, T>

8.4 模板字面量类型

// 动态生成类型
type EventName = 'click' | 'change' | 'input'
// Capitalize<T> 是 TS 内置工具类型:type A = Capitalize<'click'>   // 'Click'
type EventHandler = `on${Capitalize<EventName>}`
// 结果:'onClick' | 'onChange' | 'onInput'

九、实战案例

案例 1:用户管理页面

// src/views/system/user/index.vue
<script lang="ts" setup>
import * as UserApi from '@/api/system/user'
import * as CompanyApi from '@/api/system/company'

defineOptions({ name: 'SystemUser' })

const message = useMessage()
const { t } = useI18n()

// 列表状态
const loading = ref<boolean>(true)
const total = ref<number>(0)
const list = ref<UserApi.UserVO[]>([])
const companyTreeData = ref<Tree[]>([])

// 查询参数
const queryParams = reactive({
  pageNo: 1,
  pageSize: 10,
  username: undefined as string | undefined,
  mobile: undefined as string | undefined,
  status: undefined as number | undefined,
  deptId: undefined as number | undefined,
  createTime: [] as Date[]
})

// 获取用户列表
const getList = async () => {
  loading.value = true
  try {
    const data = await UserApi.getUserPage(queryParams)
    list.value = data.list
    total.value = data.total
  } finally {
    loading.value = false
  }
}

// 删除用户
const handleDelete = async (id: number) => {
  await message.delConfirm()
  await UserApi.deleteUser(id)
  message.success(t('common.delSuccess'))
  await getList()
}

// 组件挂载后加载数据
onMounted(() => {
  getList()
})
</script>

案例 2:表单组件

// src/views/system/user/UserForm.vue
<script lang="ts" setup>
import { FormRules } from 'element-plus'
import * as UserApi from '@/api/system/user'
import * as PostApi from '@/api/system/post'

const { t } = useI18n()
const message = useMessage()

// Props
const props = defineProps<{
  companyTreeData?: any[]
  fixedCompany?: { name: string; pid: number } | null
}>()

// Emits
const emits = defineEmits<{
  (e: 'success'): void
}>()

// 表单数据
const formData = ref<UserApi.UserVO>({
  id: undefined,
  username: '',
  nickname: '',
  password: '',
  deptId: undefined,
  postIds: [],
  email: '',
  mobile: '',
  sex: undefined,
  status: 0
})

// 表单验证规则
const formRules = reactive<FormRules>({
  username: [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
  nickname: [{ required: true, message: '...', trigger: 'blur' }],
  password: [
    { required: true, message: '密码不能为空', trigger: 'blur' },
    { min: 8, max: 16, message: '...', trigger: 'blur' }
  ],
  email: [
    { required: true, message: '邮箱不能为空', trigger: 'blur' },
    { type: 'email', message: '...', trigger: 'blur' }
  ],
  mobile: [
    { required: true, message: '手机号不能为空', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '...', trigger: 'blur' }
  ]
})

const formRef = ref()
const dialogVisible = ref<boolean>(false)
const formLoading = ref<boolean>(false)
const formType = ref<'create' | 'update'>('create')
const postList = ref<PostApi.PostVO[]>([])

// 打开表单
const open = async (type: 'create' | 'update', id?: number) => {
  dialogVisible.value = true
  formType.value = type
  resetForm()

  if (id) {
    formLoading.value = true
    try {
      formData.value = await UserApi.getUser(id)
    } finally {
      formLoading.value = false
    }
  }

  // 加载岗位列表
  postList.value = await PostApi.getSimplePostList()
}

// 提交表单
const submitForm = async () => {
  const form = unref(formRef)
  if (!form) return

  await form.validate()
  formLoading.value = true
  try {
    const data = unref(formData)
    if (formType.value === 'create') {
      await UserApi.createUser(data)
      message.success(t('common.createSuccess'))
    } else {
      await UserApi.updateUser(data)
      message.success(t('common.updateSuccess'))
    }
    dialogVisible.value = false
    emits('success')
  } finally {
    formLoading.value = false
  }
}

// 重置表单
const resetForm = () => {
  formData.value = {
    id: undefined,
    username: '',
    nickname: '',
    password: '',
    deptId: undefined,
    postIds: [],
    email: '',
    mobile: '',
    sex: undefined,
    status: 0
  }
  formRef.value?.resetFields()
}

defineExpose({ open })
</script>

案例 3:Pinia Store

// src/store/modules/dict.ts
import { defineStore } from 'pinia'
import { store } from '../index'
import { DictDataVO } from '@/api/system/dict/types'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import { getSimpleDictDataList } from '@/api/system/dict/dict.data'

const { wsCache } = useCache('sessionStorage')

export interface DictValueType {
  value: any
  label: string
  clorType?: string
  cssClass?: string
}

export interface DictTypeType {
  dictType: string
  dictValue: DictValueType[]
}

export interface DictState {
  dictMap: Map<string, any>
  isSetDict: boolean
}

export const useDictStore = defineStore('dict', {
  state: (): DictState => ({
    dictMap: new Map<string, any>(),
    isSetDict: false
  }),

  getters: {
    getDictMap(): Recordable {
      const dictMap = wsCache.get(CACHE_KEY.DICT_CACHE)
      if (dictMap) {
        this.dictMap = dictMap
      }
      return this.dictMap
    },
    getIsSetDict(): boolean {
      return this.isSetDict
    }
  },

  actions: {
    async setDictMap() {
      const dictMap = wsCache.get(CACHE_KEY.DICT_CACHE)
      if (dictMap) {
        this.dictMap = dictMap
        this.isSetDict = true
      } else {
        const res = await getSimpleDictDataList()
        const dictDataMap = new Map<string, any>()

        res.forEach((dictData: DictDataVO) => {
          const enumValueObj = dictDataMap[dictData.dictType]
          if (!enumValueObj) {
            dictDataMap[dictData.dictType] = []
          }

          dictDataMap[dictData.dictType].push({
            value: dictData.value,
            label: dictData.label,
            colorType: dictData.colorType,
            cssClass: dictData.cssClass
          })
        })

        this.dictMap = dictDataMap
        this.isSetDict = true
        wsCache.set(CACHE_KEY.DICT_CACHE, dictDataMap, { exp: 60 })
      }
    },

    getDictByType(type: string) {
      if (!this.isSetDict) {
        this.setDictMap()
      }
      return this.dictMap[type]
    },

    async resetDict() {
      wsCache.delete(CACHE_KEY.DICT_CACHE)
      await this.setDictMap()
    }
  }
})

export const useDictStoreWithOut = () => {
  return useDictStore(store)
}

十、常见错误和解决方案

错误 1:类型 "xxx" 上不存在属性 "yyy"

// 错误示例
const user = { name: '张三' }
console.log(user.age) // 错误:类型"{name: string}"上不存在属性"age"

// 解决方案 1:定义完整类型
interface User {
  name: string
  age?: number // 可选属性
}
const user: User = { name: '张三' }
console.log(user.age) // OK

// 解决方案 2:类型断言
console.log((user as any).age) // OK,但不推荐

错误 2:类型 "undefined" 不能赋值给类型 "xxx"

// 错误示例
const formData = ref<UserVO>({}) // 错误

// 解决方案:属性设为可选或提供默认值
const formData = ref<Partial<UserVO>>({}) // 使用 Partial

// 或者提供完整默认值
const formData = ref<UserVO>({
  id: undefined,
  username: '',
  nickname: ''
})

错误 3:参数 "xxx" 隐式具有 "any" 类型

// 错误示例
const handleClick = (item) => {
  // 错误
  console.log(item.id)
}

// 解决方案:显式标注类型
const handleClick = (item: UserVO) => {
  console.log(item.id)
}

错误 4:无法调用可能是 "undefined" 的对象

// 错误示例
formRef.value.validate() // 错误:可能是 undefined

// 解决方案 1:可选链
formRef.value?.validate()

// 解决方案 2:判断后调用
if (formRef.value) {
  formRef.value.validate()
}

// 解决方案 3:非空断言(确定不为空时使用)
// 我向 TypeScript 保证:在这里 formRef.value 一定不是 null 或 undefined,你放心当成非空来用。
formRef.value!.validate()

十一、最佳实践总结

1. 类型定义原则

  • ✅ 优先使用 interface 定义对象结构
  • ✅ 使用 type 定义联合类型、交叉类型
  • ✅ 简单类型可以让 TS 自动推断
  • ✅ 复杂类型明确标注

2. API 层

  • ✅ 为每个接口定义 VO/DTO 类型
  • ✅ API 函数参数和返回值标注类型
  • ✅ 使用 async/awaitPromise<T>

3. Store 层

  • ✅ 定义 State、Getters、Actions 的类型
  • ✅ 使用 interface 定义 State 结构
  • ✅ Getters 明确返回类型
  • ✅ Actions 参数标注类型

4. Views 层

  • ✅ 使用 ref<T>()reactive<T>() 标注类型
  • defineProps<{}>() 定义 Props 类型
  • defineEmits<{}>() 定义 Emits 类型
  • ✅ 使用 FormRules 定义表单验证

5. 工具函数

  • ✅ 参数和返回值都要标注类型
  • ✅ 复杂函数使用泛型
  • ✅ 联合类型处理多种输入

6. 错误处理

  • ✅ 使用可选链 ?. 避免 undefined 错误
  • ✅ 使用空值合并 ?? 提供默认值
  • ✅ 适时使用类型断言 as
  • ⚠️ 避免过度使用 any

附录:常用类型速查表

类型 说明 示例
string 字符串 const name: string = '张三'
number 数字 const age: number = 20
boolean 布尔值 const loading: boolean = true
array 数组 const list: string[] = []
object 对象 const obj: { id: number } = { id: 1 }
any 任意类型 const data: any = {}
unknown 未知类型 const data: unknown = {}
void 无返回值 const fn = (): void => {}
null const data: null = null
undefined 未定义 const data: undefined = undefined
never 永不返回 const fn = (): never => { throw new Error() }
Promise<T> Promise const fn = (): Promise<string> => {}
Ref<T> Vue Ref const count = ref<number>(0)
ComputedRef<T> Vue Computed const double = computed<number>(() => count.value * 2)
Nullable<T> 可空类型 const id: Nullable<number> = null
Recordable 对象 const obj: Recordable = {}
PageParam 分页参数 const params: PageParam = { pageNo: 1, pageSize: 10 }

结束语

本文档覆盖了项目中 绝大多数 的 TypeScript 使用场景。建议:

  1. 先理解基础概念(interface、type、泛型)
  2. 在实际编写代码时参考对应章节
  3. 遇到错误时查看"常见错误和解决方案"
  4. 多看项目代码,模仿学习

TypeScript 的核心是类型安全,合理使用类型可以:

  • ✅ 减少运行时错误
  • ✅ 提升代码可维护性
  • ✅ 提供更好的 IDE 智能提示
  • ✅ 让代码更加规范
posted @ 2025-12-23 18:31  彭加李  阅读(7)  评论(0)    收藏  举报