MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Vue Pinia 如何结合TypeScript增强类型安全性

2022-01-202.2k 阅读

1. 理解 Vue Pinia 和 TypeScript

1.1 Vue Pinia 简介

Vue Pinia 是 Vue.js 应用程序的状态管理库。它提供了一种简单且可扩展的方式来管理应用程序的状态。与 Vuex 类似,但 Pinia 具有更简洁的 API、更好的 TypeScript 支持以及对 Vue 3 的原生适配。例如,Pinia 的 Store 定义非常直观,如下代码展示了一个简单的 Pinia Store 定义:

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  actions: {
    increment() {
      this.count++
    }
  }
})

在上述代码中,我们通过 defineStore 定义了一个名为 counter 的 Store,它包含一个状态 count 和一个操作 increment 用于增加 count 的值。

1.2 TypeScript 基础

TypeScript 是 JavaScript 的超集,它为 JavaScript 添加了静态类型系统。这意味着在代码编写阶段就可以发现类型错误,而不是在运行时。例如,定义一个简单的函数并指定参数和返回值类型:

function addNumbers(a: number, b: number): number {
  return a + b
}

在这个函数中,ab 被明确指定为 number 类型,函数返回值也被指定为 number 类型。如果调用这个函数时传入非数字类型的参数,TypeScript 编译器会报错。

2. 在 Vue Pinia 中引入 TypeScript

2.1 创建 Vue 项目并安装依赖

首先,我们使用 Vue CLI 创建一个新的 Vue 项目,并确保选择 TypeScript 支持。

vue create my - pinia - ts - project

在创建过程中,选择使用 TypeScript。

安装 Pinia:

npm install pinia

2.2 配置 TypeScript 支持

在项目根目录下的 tsconfig.json 文件中,确保配置了合适的选项。例如,strict 模式开启可以使 TypeScript 更加严格地检查类型:

{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "exclude": ["node_modules", "dist"]
}

3. 定义类型化的 Pinia Store

3.1 类型化 State

为了增强类型安全性,我们需要为 Pinia Store 的 state 定义类型。假设我们有一个用户信息的 Store,首先定义 state 的类型:

interface UserState {
  name: string
  age: number
  email: string
}

然后在定义 Store 时使用这个类型:

import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    name: '',
    age: 0,
    email: ''
  }),
  actions: {
    updateUser(newName: string, newAge: number, newEmail: string) {
      this.name = newName
      this.age = newAge
      this.email = newEmail
    }
  }
})

在上述代码中,state 函数的返回值类型被明确指定为 UserState。这样,当我们访问 this.namethis.age 等属性时,TypeScript 可以准确地知道它们的类型,避免错误。

3.2 类型化 Actions

对于 actions,同样要明确参数和返回值类型。在上面的 updateUser 方法中,参数 newNamenewAgenewEmail 的类型都被明确指定,并且由于方法没有返回值,所以返回类型为 void。如果我们有一个返回值的方法,比如获取用户全名:

interface UserState {
  firstName: string
  lastName: string
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    firstName: '',
    lastName: ''
  }),
  actions: {
    getFullName(): string {
      return `${this.firstName} ${this.lastName}`
    }
  }
})

这里 getFullName 方法返回值类型被指定为 string

3.3 类型化 Getters

getters 也可以进行类型化。假设我们在用户 Store 中有一个计算属性 isAdult 用于判断用户是否成年:

interface UserState {
  age: number
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    age: 0
  }),
  getters: {
    isAdult: (state): boolean => state.age >= 18
  }
})

在这个例子中,isAdult 这个 getter 的返回值类型被指定为 boolean,并且它接收 state 参数,其类型与 state 定义的类型一致。

4. 在 Vue 组件中使用类型化的 Pinia Store

4.1 在 Setup 函数中使用

在 Vue 组件的 setup 函数中使用类型化的 Pinia Store 非常直观。假设我们有一个显示用户信息的组件:

<template>
  <div>
    <p>Name: {{ user.name }}</p>
    <p>Age: {{ user.age }}</p>
    <p>Email: {{ user.email }}</p>
    <button @click="updateUser">Update User</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { useUserStore } from '@/stores/user'

export default defineComponent({
  setup() {
    const userStore = useUserStore()
    const updateUser = () => {
      userStore.updateUser('New Name', 25, 'new@example.com')
    }
    return {
      user: userStore,
      updateUser
    }
  }
})
</script>

在上述代码中,通过 useUserStore() 获取 Store 实例,并在 updateUser 函数中调用 updateUser 方法。由于 Store 是类型化的,TypeScript 可以检查方法调用时传入参数的类型是否正确。

4.2 在 Composition API 中的高级用法

我们还可以利用 Vue 的 Composition API 对 Store 进行更灵活的操作。例如,我们可以对 Store 的状态进行解构,并使用 watch 来监听状态变化:

<template>
  <div>
    <p>Name: {{ name }}</p>
    <p>Age: {{ age }}</p>
    <button @click="incrementAge">Increment Age</button>
  </div>
</template>

<script lang="ts">
import { defineComponent, watch } from 'vue'
import { useUserStore } from '@/stores/user'

export default defineComponent({
  setup() {
    const userStore = useUserStore()
    const { age, incrementAge } = userStore
    const name = userStore.name

    watch(age, (newAge) => {
      console.log(`Age has changed to ${newAge}`)
    })

    return {
      name,
      age,
      incrementAge
    }
  }
})
</script>

这里我们对 userStore 中的 ageincrementAgename 进行了解构。并且通过 watch 监听 age 的变化。由于 userStore 是类型化的,解构出来的属性和方法类型也都是明确的。

5. 处理复杂类型和泛型

5.1 复杂类型的 State

state 包含复杂类型时,类型定义也会相应复杂。例如,假设我们的用户 Store 中 state 包含一个用户地址数组,每个地址又是一个包含详细信息的对象:

interface Address {
  street: string
  city: string
  zipCode: string
}

interface UserState {
  name: string
  age: number
  addresses: Address[]
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    name: '',
    age: 0,
    addresses: []
  }),
  actions: {
    addAddress(newAddress: Address) {
      this.addresses.push(newAddress)
    }
  }
})

在这个例子中,Address 接口定义了地址的结构,UserState 接口中的 addresses 属性是 Address 类型的数组。addAddress 方法接收一个 Address 类型的参数。

5.2 泛型在 Pinia Store 中的应用

泛型可以使我们的 Pinia Store 更加通用。假设我们有一个用于缓存数据的 Store,数据类型可以是任意的:

import { defineStore } from 'pinia'

interface CacheState<T> {
  data: T | null
  isLoading: boolean
}

export const useCacheStore = <T>() => {
  return defineStore(`cache - ${Math.random().toString(36).substring(7)}`, {
    state: (): CacheState<T> => ({
      data: null,
      isLoading: false
    }),
    actions: {
      async fetchData(fetchFunction: () => Promise<T>) {
        this.isLoading = true
        try {
          this.data = await fetchFunction()
        } catch (error) {
          console.error('Error fetching data:', error)
        } finally {
          this.isLoading = false
        }
      }
    }
  })
}

在上述代码中,我们定义了一个泛型 Store useCacheStoreCacheState 接口使用了泛型 T 来表示数据的类型。fetchData 方法接收一个返回 Promise<T> 的函数,用于获取数据。这样,我们可以根据不同的数据类型创建不同的缓存 Store:

// 使用缓存 Store 缓存用户数据
interface User {
  name: string
  age: number
}

const useUserCacheStore = useCacheStore<User>()

// 使用缓存 Store 缓存产品数据
interface Product {
  id: number
  name: string
  price: number
}

const useProductCacheStore = useCacheStore<Product>()

6. 类型安全的模块导入和导出

6.1 正确导入和导出 Store

在 TypeScript 环境下,正确的模块导入和导出对于类型安全至关重要。当我们定义好 Pinia Store 后,在其他组件或模块中导入时,要确保路径和命名正确。例如,在 user.ts 文件中定义了 useUserStore

// user.ts
import { defineStore } from 'pinia'

interface UserState {
  name: string
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    name: ''
  })
})

在另一个组件中导入:

<template>
  <div>
    <p>User Name: {{ user.name }}</p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { useUserStore } from '@/stores/user'

export default defineComponent({
  setup() {
    const user = useUserStore()
    return {
      user
    }
  }
})
</script>

注意 @/stores/user 这个导入路径,要确保它与项目的实际目录结构匹配。如果路径错误,TypeScript 可能无法正确识别 Store 的类型。

6.2 处理类型声明文件

在一些情况下,我们可能需要自定义类型声明文件(.d.ts)来辅助 TypeScript 进行类型检查。例如,如果我们使用了一些第三方库与 Pinia 结合,而这些库没有提供完整的类型定义。假设我们有一个自定义的插件 pinia - custom - plugin,它扩展了 Pinia Store 的功能,我们可以创建一个 pinia - custom - plugin.d.ts 文件:

import { StoreDefinition } from 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    customProperty: string
  }

  export function useCustomStore(): StoreDefinition<any, any, any, any>
}

在上述代码中,我们通过 declare module 'pinia' 来扩展 Pinia 的类型定义。PiniaCustomProperties 接口定义了一个自定义属性 customPropertyuseCustomStore 函数定义了一个自定义的 Store 定义函数。这样,在使用这个插件时,TypeScript 可以正确识别相关的类型。

7. 解决常见的类型问题

7.1 “Property does not exist on type” 错误

当我们在组件中访问 Pinia Store 的属性或方法时,有时会遇到 “Property does not exist on type” 这样的错误。这通常是因为类型定义不一致或者没有正确导入类型。例如,如果我们在 Store 中定义了一个方法 fetchData,但在组件中调用时 TypeScript 报错说该方法不存在:

<template>
  <div>
    <button @click="fetchData">Fetch Data</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { useUserStore } from '@/stores/user'

export default defineComponent({
  setup() {
    const userStore = useUserStore()
    const fetchData = () => {
      userStore.fetchData() // 报错:Property 'fetchData' does not exist on type 'ReturnType<typeof useUserStore>'
    }
    return {
      fetchData
    }
  }
})
</script>

解决这个问题,首先要确保 fetchData 方法确实在 useUserStore 中定义,并且类型定义正确:

// user.ts
import { defineStore } from 'pinia'

interface UserState {
  data: any
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    data: null
  }),
  actions: {
    async fetchData() {
      // 模拟数据获取
      this.data = await Promise.resolve({ message: 'Data fetched' })
    }
  }
})

如果方法定义正确,还要检查导入路径是否正确,以及是否在 tsconfig.json 中正确配置了文件的包含和排除。

7.2 类型推断问题

有时候 TypeScript 的类型推断可能不准确,导致一些类型错误难以发现。例如,当我们使用数组方法对 Store 中的数组进行操作时:

interface UserState {
  friends: string[]
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    friends: []
  }),
  actions: {
    addFriend(newFriend: string) {
      this.friends.push(newFriend)
    },
    removeFriend(index: number) {
      const newFriends = this.friends.filter((_, i) => i!== index)
      this.friends = newFriends // 这里可能会出现类型推断问题,如果 newFriends 类型推断错误
    }
  }
})

removeFriend 方法中,filter 方法返回一个新的数组,但如果 TypeScript 对 filter 返回值的类型推断错误,可能会导致 this.friends = newFriends 这一行出现类型不匹配的问题。为了解决这个问题,我们可以明确指定 newFriends 的类型:

interface UserState {
  friends: string[]
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    friends: []
  }),
  actions: {
    addFriend(newFriend: string) {
      this.friends.push(newFriend)
    },
    removeFriend(index: number) {
      let newFriends: string[] = this.friends.filter((_, i) => i!== index)
      this.friends = newFriends
    }
  }
})

通过明确指定 newFriends 的类型为 string[],可以避免类型推断错误。

8. 测试类型化的 Pinia Store

8.1 单元测试

对于类型化的 Pinia Store,我们可以使用测试框架(如 Jest)进行单元测试。在测试中,要确保 Store 的状态和方法的类型行为符合预期。例如,测试 userStoreupdateUser 方法:

import { describe, it, expect } from 'vitest'
import { useUserStore } from '@/stores/user'

describe('User Store', () => {
  it('should update user correctly', () => {
    const userStore = useUserStore()
    userStore.updateUser('John Doe', 30, 'john@example.com')
    expect(userStore.name).toBe('John Doe')
    expect(userStore.age).toBe(30)
    expect(userStore.email).toBe('john@example.com')
  })
})

在这个测试中,我们调用 updateUser 方法并验证 nameageemail 属性是否更新正确。由于 Store 是类型化的,测试过程中如果传入错误类型的参数,TypeScript 会报错,这进一步增强了测试的可靠性。

8.2 集成测试

集成测试可以验证 Pinia Store 在整个 Vue 应用中的行为。我们可以使用测试库(如 Vue Test Utils)结合 Jest 进行集成测试。例如,测试一个使用了 userStore 的组件:

<template>
  <div>
    <p>Name: {{ user.name }}</p>
    <button @click="updateUser">Update User</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { useUserStore } from '@/stores/user'

export default defineComponent({
  setup() {
    const userStore = useUserStore()
    const updateUser = () => {
      userStore.updateUser('Jane Smith', 25, 'jane@example.com')
    }
    return {
      user: userStore,
      updateUser
    }
  }
})
</script>

测试代码如下:

import { mount } from '@vue/test - utils'
import UserComponent from '@/components/UserComponent.vue'
import { useUserStore } from '@/stores/user'

describe('UserComponent', () => {
  it('should update user when button is clicked', () => {
    const wrapper = mount(UserComponent)
    const userStore = useUserStore()
    wrapper.find('button').trigger('click')
    expect(userStore.name).toBe('Jane Smith')
    expect(userStore.age).toBe(25)
    expect(userStore.email).toBe('jane@example.com')
  })
})

在这个集成测试中,我们挂载了 UserComponent 组件,并模拟点击按钮来验证 userStore 的状态是否正确更新。通过类型化的 Store,我们可以在测试过程中更好地确保组件与 Store 之间的交互符合预期的类型。