Skills included: - pr-reviewer: Adapted for Gitea/GitHub via platform-aware scripts (dropped fetch_pr_data.py and add_inline_comment.py, kept generate_review_files.py) - code-review-excellence: Methodology and checklists (React, TS, Python, etc.) - vercel-react-best-practices: 57 rules for React/Next.js performance - tailwind-design-system: Tailwind CSS v4 patterns, CVA, design tokens New shell scripts added to ~/.claude/scripts/git/: - pr-diff.sh: Get PR diff (GitHub gh / Gitea API) - pr-metadata.sh: Get PR metadata as normalized JSON Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
20 KiB
20 KiB
Vue 3 Code Review Guide
Vue 3 Composition API 代码审查指南,覆盖响应性系统、Props/Emits、Watchers、Composables、Vue 3.5 新特性等核心主题。
目录
响应性系统
ref vs reactive 选择
<!-- ✅ 基本类型用 ref -->
<script setup lang="ts">
const count = ref(0)
const name = ref('Vue')
// ref 需要 .value 访问
count.value++
</script>
<!-- ✅ 对象/数组用 reactive(可选)-->
<script setup lang="ts">
const state = reactive({
user: null,
loading: false,
error: null
})
// reactive 直接访问
state.loading = true
</script>
<!-- 💡 现代最佳实践:全部使用 ref,保持一致性 -->
<script setup lang="ts">
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
</script>
解构 reactive 对象
<!-- ❌ 解构 reactive 会丢失响应性 -->
<script setup lang="ts">
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = state // 丢失响应性!
</script>
<!-- ✅ 使用 toRefs 保持响应性 -->
<script setup lang="ts">
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = toRefs(state) // 保持响应性
// 或者直接使用 ref
const count = ref(0)
const name = ref('Vue')
</script>
computed 副作用
<!-- ❌ computed 中产生副作用 -->
<script setup lang="ts">
const fullName = computed(() => {
console.log('Computing...') // 副作用!
otherRef.value = 'changed' // 修改其他状态!
return `${firstName.value} ${lastName.value}`
})
</script>
<!-- ✅ computed 只用于派生状态 -->
<script setup lang="ts">
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
// 副作用放在 watch 或事件处理中
watch(fullName, (name) => {
console.log('Name changed:', name)
})
</script>
shallowRef 优化
<!-- ❌ 大型对象使用 ref 会深度转换 -->
<script setup lang="ts">
const largeData = ref(hugeNestedObject) // 深度响应式,性能开销大
</script>
<!-- ✅ 使用 shallowRef 避免深度转换 -->
<script setup lang="ts">
const largeData = shallowRef(hugeNestedObject)
// 整体替换才会触发更新
function updateData(newData) {
largeData.value = newData // ✅ 触发更新
}
// ❌ 修改嵌套属性不会触发更新
// largeData.value.nested.prop = 'new'
// 需要手动触发时使用 triggerRef
import { triggerRef } from 'vue'
largeData.value.nested.prop = 'new'
triggerRef(largeData)
</script>
Props & Emits
直接修改 props
<!-- ❌ 直接修改 props -->
<script setup lang="ts">
const props = defineProps<{ user: User }>()
props.user.name = 'New Name' // 永远不要直接修改 props!
</script>
<!-- ✅ 使用 emit 通知父组件更新 -->
<script setup lang="ts">
const props = defineProps<{ user: User }>()
const emit = defineEmits<{
update: [name: string]
}>()
const updateName = (name: string) => emit('update', name)
</script>
defineProps 类型声明
<!-- ❌ defineProps 缺少类型声明 -->
<script setup lang="ts">
const props = defineProps(['title', 'count']) // 无类型检查
</script>
<!-- ✅ 使用类型声明 + withDefaults -->
<script setup lang="ts">
interface Props {
title: string
count?: number
items?: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => [] // 对象/数组默认值需要工厂函数
})
</script>
defineEmits 类型安全
<!-- ❌ defineEmits 缺少类型 -->
<script setup lang="ts">
const emit = defineEmits(['update', 'delete']) // 无类型检查
emit('update', someValue) // 参数类型不安全
</script>
<!-- ✅ 完整的类型定义 -->
<script setup lang="ts">
const emit = defineEmits<{
update: [id: number, value: string]
delete: [id: number]
'custom-event': [payload: CustomPayload]
}>()
// 现在有完整的类型检查
emit('update', 1, 'new value') // ✅
emit('update', 'wrong') // ❌ TypeScript 报错
</script>
Vue 3.5 新特性
Reactive Props Destructure (3.5+)
<!-- Vue 3.5 之前:解构会丢失响应性 -->
<script setup lang="ts">
const props = defineProps<{ count: number }>()
// 需要使用 props.count 或 toRefs
</script>
<!-- ✅ Vue 3.5+:解构保持响应性 -->
<script setup lang="ts">
const { count, name = 'default' } = defineProps<{
count: number
name?: string
}>()
// count 和 name 自动保持响应性!
// 可以直接在模板和 watch 中使用
watch(() => count, (newCount) => {
console.log('Count changed:', newCount)
})
</script>
<!-- ✅ 配合默认值使用 -->
<script setup lang="ts">
const {
title,
count = 0,
items = () => [] // 函数作为默认值(对象/数组)
} = defineProps<{
title: string
count?: number
items?: () => string[]
}>()
</script>
defineModel (3.4+)
<!-- ❌ 传统 v-model 实现:冗长 -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
// 需要 computed 来双向绑定
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
</script>
<!-- ✅ defineModel:简洁的 v-model 实现 -->
<script setup lang="ts">
// 自动处理 props 和 emit
const model = defineModel<string>()
// 直接使用
model.value = 'new value' // 自动 emit
</script>
<template>
<input v-model="model" />
</template>
<!-- ✅ 命名 v-model -->
<script setup lang="ts">
// v-model:title 的实现
const title = defineModel<string>('title')
// 带默认值和选项
const count = defineModel<number>('count', {
default: 0,
required: false
})
</script>
<!-- ✅ 多个 v-model -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
</script>
<template>
<!-- 父组件使用:<MyInput v-model:first-name="first" v-model:last-name="last" /> -->
</template>
<!-- ✅ v-model 修饰符 -->
<script setup lang="ts">
const [model, modifiers] = defineModel<string>()
// 检查修饰符
if (modifiers.capitalize) {
// 处理 .capitalize 修饰符
}
</script>
useTemplateRef (3.5+)
<!-- 传统方式:ref 属性与变量同名 -->
<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null)
</script>
<template>
<input ref="inputRef" />
</template>
<!-- ✅ useTemplateRef:更清晰的模板引用 -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const input = useTemplateRef<HTMLInputElement>('my-input')
onMounted(() => {
input.value?.focus()
})
</script>
<template>
<input ref="my-input" />
</template>
<!-- ✅ 动态 ref -->
<script setup lang="ts">
const refKey = ref('input-a')
const dynamicInput = useTemplateRef<HTMLInputElement>(refKey)
</script>
useId (3.5+)
<!-- ❌ 手动生成 ID 可能冲突 -->
<script setup lang="ts">
const id = `input-${Math.random()}` // SSR 不一致!
</script>
<!-- ✅ useId:SSR 安全的唯一 ID -->
<script setup lang="ts">
import { useId } from 'vue'
const id = useId() // 例如:'v-0'
</script>
<template>
<label :for="id">Name</label>
<input :id="id" />
</template>
<!-- ✅ 表单组件中使用 -->
<script setup lang="ts">
const inputId = useId()
const errorId = useId()
</script>
<template>
<label :for="inputId">Email</label>
<input
:id="inputId"
:aria-describedby="errorId"
/>
<span :id="errorId" class="error">{{ error }}</span>
</template>
onWatcherCleanup (3.5+)
<!-- 传统方式:watch 第三个参数 -->
<script setup lang="ts">
watch(source, async (value, oldValue, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
// ...
})
</script>
<!-- ✅ onWatcherCleanup:更灵活的清理 -->
<script setup lang="ts">
import { onWatcherCleanup } from 'vue'
watch(source, async (value) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
// 可以在任意位置调用,不限于回调开头
if (someCondition) {
const anotherResource = createResource()
onWatcherCleanup(() => anotherResource.dispose())
}
await fetchData(value, controller.signal)
})
</script>
Deferred Teleport (3.5+)
<!-- ❌ Teleport 目标必须在挂载时存在 -->
<template>
<Teleport to="#modal-container">
<!-- 如果 #modal-container 不存在会报错 -->
</Teleport>
</template>
<!-- ✅ defer 属性延迟挂载 -->
<template>
<Teleport to="#modal-container" defer>
<!-- 等待目标元素存在后再挂载 -->
<Modal />
</Teleport>
</template>
Watchers
watch vs watchEffect
<script setup lang="ts">
// ✅ watch:明确指定依赖,惰性执行
watch(
() => props.userId,
async (userId) => {
user.value = await fetchUser(userId)
}
)
// ✅ watchEffect:自动收集依赖,立即执行
watchEffect(async () => {
// 自动追踪 props.userId
user.value = await fetchUser(props.userId)
})
// 💡 选择指南:
// - 需要旧值?用 watch
// - 需要惰性执行?用 watch
// - 依赖复杂?用 watchEffect
</script>
watch 清理函数
<!-- ❌ watch 缺少清理函数,可能内存泄漏 -->
<script setup lang="ts">
watch(searchQuery, async (query) => {
const controller = new AbortController()
const data = await fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
results.value = await data.json()
// 如果 query 快速变化,旧请求不会被取消!
})
</script>
<!-- ✅ 使用 onCleanup 清理副作用 -->
<script setup lang="ts">
watch(searchQuery, async (query, _, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort()) // 取消旧请求
try {
const data = await fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
results.value = await data.json()
} catch (e) {
if (e.name !== 'AbortError') throw e
}
})
</script>
watch 选项
<script setup lang="ts">
// ✅ immediate:立即执行一次
watch(
userId,
async (id) => {
user.value = await fetchUser(id)
},
{ immediate: true }
)
// ✅ deep:深度监听(性能开销大,谨慎使用)
watch(
state,
(newState) => {
console.log('State changed deeply')
},
{ deep: true }
)
// ✅ flush: 'post':DOM 更新后执行
watch(
source,
() => {
// 可以安全访问更新后的 DOM
// nextTick 不再需要
},
{ flush: 'post' }
)
// ✅ once: true (Vue 3.4+):只执行一次
watch(
source,
(value) => {
console.log('只会执行一次:', value)
},
{ once: true }
)
</script>
监听多个源
<script setup lang="ts">
// ✅ 监听多个 ref
watch(
[firstName, lastName],
([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`Name changed from ${oldFirst} ${oldLast} to ${newFirst} ${newLast}`)
}
)
// ✅ 监听 reactive 对象的特定属性
watch(
() => [state.count, state.name],
([count, name]) => {
console.log(`count: ${count}, name: ${name}`)
}
)
</script>
模板最佳实践
v-for 的 key
<!-- ❌ v-for 中使用 index 作为 key -->
<template>
<li v-for="(item, index) in items" :key="index">
{{ item.name }}
</li>
</template>
<!-- ✅ 使用唯一标识作为 key -->
<template>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</template>
<!-- ✅ 复合 key(当没有唯一 ID 时)-->
<template>
<li v-for="(item, index) in items" :key="`${item.name}-${item.type}-${index}`">
{{ item.name }}
</li>
</template>
v-if 和 v-for 优先级
<!-- ❌ v-if 和 v-for 同时使用 -->
<template>
<li v-for="user in users" v-if="user.active" :key="user.id">
{{ user.name }}
</li>
</template>
<!-- ✅ 使用 computed 过滤 -->
<script setup lang="ts">
const activeUsers = computed(() =>
users.value.filter(user => user.active)
)
</script>
<template>
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
</template>
<!-- ✅ 或用 template 包裹 -->
<template>
<template v-for="user in users" :key="user.id">
<li v-if="user.active">
{{ user.name }}
</li>
</template>
</template>
事件处理
<!-- ❌ 内联复杂逻辑 -->
<template>
<button @click="items = items.filter(i => i.id !== item.id); count--">
Delete
</button>
</template>
<!-- ✅ 使用方法 -->
<script setup lang="ts">
const deleteItem = (id: number) => {
items.value = items.value.filter(i => i.id !== id)
count.value--
}
</script>
<template>
<button @click="deleteItem(item.id)">Delete</button>
</template>
<!-- ✅ 事件修饰符 -->
<template>
<!-- 阻止默认行为 -->
<form @submit.prevent="handleSubmit">...</form>
<!-- 阻止冒泡 -->
<button @click.stop="handleClick">...</button>
<!-- 只执行一次 -->
<button @click.once="handleOnce">...</button>
<!-- 键盘修饰符 -->
<input @keyup.enter="submit" @keyup.esc="cancel" />
</template>
Composables
Composable 设计原则
// ✅ 好的 composable 设计
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initialValue
// 返回响应式引用和方法
return {
count: readonly(count), // 只读防止外部修改
increment,
decrement,
reset
}
}
// ❌ 不要返回 .value
export function useBadCounter() {
const count = ref(0)
return {
count: count.value // ❌ 丢失响应性!
}
}
Props 传递给 composable
<!-- ❌ 传递 props 到 composable 丢失响应性 -->
<script setup lang="ts">
const props = defineProps<{ userId: string }>()
const { user } = useUser(props.userId) // 丢失响应性!
</script>
<!-- ✅ 使用 toRef 或 computed 保持响应性 -->
<script setup lang="ts">
const props = defineProps<{ userId: string }>()
const userIdRef = toRef(props, 'userId')
const { user } = useUser(userIdRef) // 保持响应性
// 或使用 computed
const { user } = useUser(computed(() => props.userId))
// ✅ Vue 3.5+:直接解构使用
const { userId } = defineProps<{ userId: string }>()
const { user } = useUser(() => userId) // getter 函数
</script>
异步 Composable
// ✅ 异步 composable 模式
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
const execute = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(toValue(url))
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
data.value = await response.json()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
// 响应式 URL 时自动重新获取
watchEffect(() => {
toValue(url) // 追踪依赖
execute()
})
return {
data: readonly(data),
error: readonly(error),
loading: readonly(loading),
refetch: execute
}
}
// 使用
const { data, loading, error, refetch } = useFetch<User[]>('/api/users')
生命周期与清理
// ✅ Composable 中正确处理生命周期
export function useEventListener(
target: MaybeRefOrGetter<EventTarget>,
event: string,
handler: EventListener
) {
// 组件挂载后添加
onMounted(() => {
toValue(target).addEventListener(event, handler)
})
// 组件卸载时移除
onUnmounted(() => {
toValue(target).removeEventListener(event, handler)
})
}
// ✅ 使用 effectScope 管理副作用
export function useFeature() {
const scope = effectScope()
scope.run(() => {
// 所有响应式效果都在这个 scope 内
const state = ref(0)
watch(state, () => { /* ... */ })
watchEffect(() => { /* ... */ })
})
// 清理所有效果
onUnmounted(() => scope.stop())
return { /* ... */ }
}
性能优化
v-memo
<!-- ✅ v-memo:缓存子树,避免重复渲染 -->
<template>
<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<!-- 只有当 item.id === selected 变化时才重新渲染 -->
<ExpensiveComponent :item="item" :selected="item.id === selected" />
</div>
</template>
<!-- ✅ 配合 v-for 使用 -->
<template>
<div
v-for="item in list"
:key="item.id"
v-memo="[item.name, item.status]"
>
<!-- 只有 name 或 status 变化时重新渲染 -->
</div>
</template>
defineAsyncComponent
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
// ✅ 懒加载组件
const HeavyChart = defineAsyncComponent(() =>
import('./components/HeavyChart.vue')
)
// ✅ 带加载和错误状态
const AsyncModal = defineAsyncComponent({
loader: () => import('./components/Modal.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 延迟显示 loading(避免闪烁)
timeout: 3000 // 超时时间
})
</script>
KeepAlive
<template>
<!-- ✅ 缓存动态组件 -->
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
<!-- ✅ 指定缓存的组件 -->
<KeepAlive include="TabA,TabB">
<component :is="currentTab" />
</KeepAlive>
<!-- ✅ 限制缓存数量 -->
<KeepAlive :max="10">
<component :is="currentTab" />
</KeepAlive>
</template>
<script setup lang="ts">
// KeepAlive 组件的生命周期钩子
onActivated(() => {
// 组件被激活时(从缓存恢复)
refreshData()
})
onDeactivated(() => {
// 组件被停用时(进入缓存)
pauseTimers()
})
</script>
虚拟列表
<!-- ✅ 大型列表使用虚拟滚动 -->
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'
const { list, containerProps, wrapperProps } = useVirtualList(
items,
{ itemHeight: 50 }
)
</script>
<template>
<div v-bind="containerProps" style="height: 400px; overflow: auto">
<div v-bind="wrapperProps">
<div v-for="item in list" :key="item.data.id" style="height: 50px">
{{ item.data.name }}
</div>
</div>
</div>
</template>
Review Checklist
响应性系统
- ref 用于基本类型,reactive 用于对象(或统一用 ref)
- 没有解构 reactive 对象(或使用了 toRefs)
- props 传递给 composable 时保持了响应性
- shallowRef/shallowReactive 用于大型对象优化
- computed 中没有副作用
Props & Emits
- defineProps 使用 TypeScript 类型声明
- 复杂默认值使用 withDefaults + 工厂函数
- defineEmits 有完整的类型定义
- 没有直接修改 props
- 考虑使用 defineModel 简化 v-model(Vue 3.4+)
Vue 3.5 新特性(如适用)
- 使用 Reactive Props Destructure 简化 props 访问
- 使用 useTemplateRef 替代 ref 属性
- 表单使用 useId 生成 SSR 安全的 ID
- 使用 onWatcherCleanup 处理复杂清理逻辑
Watchers
- watch/watchEffect 有适当的清理函数
- 异步 watch 处理了竞态条件
- flush: 'post' 用于 DOM 操作的 watcher
- 避免过度使用 watcher(优先用 computed)
- 考虑 once: true 用于一次性监听
模板
- v-for 使用唯一且稳定的 key
- v-if 和 v-for 没有在同一元素上
- 事件处理使用方法而非内联复杂逻辑
- 大型列表使用虚拟滚动
Composables
- 相关逻辑提取到 composables
- composables 返回响应式引用(不是 .value)
- 纯函数不要包装成 composable
- 副作用在组件卸载时清理
- 使用 effectScope 管理复杂副作用
性能
- 大型组件拆分为小组件
- 使用 defineAsyncComponent 懒加载
- 避免不必要的响应式转换
- v-memo 用于昂贵的列表渲染
- KeepAlive 用于缓存动态组件