Files
2026-07-25 23:45:09 +08:00

4.0 KiB

Vue 代码范式

仅在项目版本和既有约定支持时使用以下范式。复制前替换领域名称、错误处理和组件库 API。

类型安全组件契约

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

interface UserSummary {
  id: string
  displayName: string
}

interface Props {
  user: UserSummary
  selected?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  selected: false,
})

const emit = defineEmits<{
  select: [userId: string]
}>()

const accessibleName = computed(() => `选择用户 ${props.user.displayName}`)

function handleSelect(): void {
  emit('select', props.user.id)
}
</script>

<template>
  <button
    type="button"
    :aria-pressed="selected"
    :aria-label="accessibleName"
    @click="handleSelect"
  >
    {{ user.displayName }}
  </button>
</template>

要点:

  • 将 props 和 emits 作为公共契约。
  • 使用稳定业务 id 发出事件,不把完整可变内部对象泄露出去。
  • 使用原生 button 获得键盘和语义能力。
  • 若项目 Vue 版本支持且团队采用响应式 props 解构,可用等价的解构默认值写法。

带竞态保护的 composable

import { readonly, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'

interface User {
  id: string
  name: string
}

interface UserService {
  getUser(userId: string, signal: AbortSignal): Promise<User>
}

export function useUser(
  userId: MaybeRefOrGetter<string>,
  service: UserService,
) {
  const data = ref<User | null>(null)
  const error = ref<Error | null>(null)
  const isLoading = ref(false)

  watch(
    () => toValue(userId),
    async (currentUserId, _previousUserId, onCleanup) => {
      const controller = new AbortController()
      onCleanup(() => controller.abort())

      isLoading.value = true
      error.value = null

      try {
        data.value = await service.getUser(currentUserId, controller.signal)
      } catch (cause: unknown) {
        if (!controller.signal.aborted) {
          error.value = cause instanceof Error
            ? cause
            : new Error('Unknown user loading error')
        }
      } finally {
        if (!controller.signal.aborted) {
          isLoading.value = false
        }
      }
    },
    { immediate: true },
  )

  return {
    data: readonly(data),
    error: readonly(error),
    isLoading: readonly(isLoading),
  }
}

要点:

  • 通过依赖参数隔离网络边界,便于测试。
  • 对参数变化取消旧请求,避免过期结果覆盖新状态。
  • 只暴露 readonly 状态。
  • 实际项目应使用已有错误归一化,不重复定义通用错误层。

最小 Pinia Store

import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', () => {
  const itemIds = ref<string[]>([])
  const itemCount = computed(() => itemIds.value.length)

  function addItem(itemId: string): void {
    if (!itemIds.value.includes(itemId)) {
      itemIds.value.push(itemId)
    }
  }

  function clear(): void {
    itemIds.value = []
  }

  return {
    itemIds,
    itemCount,
    addItem,
    clear,
  }
})

要点:

  • 只保存不可推导的最小 state。
  • 用 computed 表达 getter。
  • 通过 action 表达状态变化。
  • 真实 store 若执行请求,需补齐 loading、error、并发与恢复策略。

行为导向组件测试

import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import UserSelectButton from './UserSelectButton.vue'

describe('UserSelectButton', () => {
  it('emits the selected user id when activated', async () => {
    const wrapper = mount(UserSelectButton, {
      props: {
        user: { id: 'user-42', displayName: 'Ada' },
      },
    })

    await wrapper.get('button').trigger('click')

    expect(wrapper.emitted('select')).toEqual([['user-42']])
  })
})

要点:

  • 通过用户可操作元素交互。
  • 验证公开事件和业务结果,不访问组件内部 ref。
  • 对无障碍名称、loading、disabled 和 error 等关键状态按变更补充断言。