182 lines
4.0 KiB
Markdown
182 lines
4.0 KiB
Markdown
# Vue 代码范式
|
|
|
|
仅在项目版本和既有约定支持时使用以下范式。复制前替换领域名称、错误处理和组件库 API。
|
|
|
|
## 类型安全组件契约
|
|
|
|
```vue
|
|
<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
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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、并发与恢复策略。
|
|
|
|
## 行为导向组件测试
|
|
|
|
```ts
|
|
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 等关键状态按变更补充断言。
|
|
|