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>
925 lines
20 KiB
Markdown
925 lines
20 KiB
Markdown
# Vue 3 Code Review Guide
|
||
|
||
> Vue 3 Composition API 代码审查指南,覆盖响应性系统、Props/Emits、Watchers、Composables、Vue 3.5 新特性等核心主题。
|
||
|
||
## 目录
|
||
|
||
- [响应性系统](#响应性系统)
|
||
- [Props & Emits](#props--emits)
|
||
- [Vue 3.5 新特性](#vue-35-新特性)
|
||
- [Watchers](#watchers)
|
||
- [模板最佳实践](#模板最佳实践)
|
||
- [Composables](#composables)
|
||
- [性能优化](#性能优化)
|
||
- [Review Checklist](#review-checklist)
|
||
|
||
---
|
||
|
||
## 响应性系统
|
||
|
||
### ref vs reactive 选择
|
||
|
||
```vue
|
||
<!-- ✅ 基本类型用 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 对象
|
||
|
||
```vue
|
||
<!-- ❌ 解构 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 副作用
|
||
|
||
```vue
|
||
<!-- ❌ 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 优化
|
||
|
||
```vue
|
||
<!-- ❌ 大型对象使用 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
|
||
|
||
```vue
|
||
<!-- ❌ 直接修改 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 类型声明
|
||
|
||
```vue
|
||
<!-- ❌ 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 类型安全
|
||
|
||
```vue
|
||
<!-- ❌ 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
|
||
<!-- 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+)
|
||
|
||
```vue
|
||
<!-- ❌ 传统 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+)
|
||
|
||
```vue
|
||
<!-- 传统方式: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+)
|
||
|
||
```vue
|
||
<!-- ❌ 手动生成 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+)
|
||
|
||
```vue
|
||
<!-- 传统方式: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+)
|
||
|
||
```vue
|
||
<!-- ❌ Teleport 目标必须在挂载时存在 -->
|
||
<template>
|
||
<Teleport to="#modal-container">
|
||
<!-- 如果 #modal-container 不存在会报错 -->
|
||
</Teleport>
|
||
</template>
|
||
|
||
<!-- ✅ defer 属性延迟挂载 -->
|
||
<template>
|
||
<Teleport to="#modal-container" defer>
|
||
<!-- 等待目标元素存在后再挂载 -->
|
||
<Modal />
|
||
</Teleport>
|
||
</template>
|
||
```
|
||
|
||
---
|
||
|
||
## Watchers
|
||
|
||
### watch vs watchEffect
|
||
|
||
```vue
|
||
<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 清理函数
|
||
|
||
```vue
|
||
<!-- ❌ 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 选项
|
||
|
||
```vue
|
||
<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>
|
||
```
|
||
|
||
### 监听多个源
|
||
|
||
```vue
|
||
<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
|
||
|
||
```vue
|
||
<!-- ❌ 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 优先级
|
||
|
||
```vue
|
||
<!-- ❌ 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>
|
||
```
|
||
|
||
### 事件处理
|
||
|
||
```vue
|
||
<!-- ❌ 内联复杂逻辑 -->
|
||
<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 设计原则
|
||
|
||
```typescript
|
||
// ✅ 好的 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
|
||
|
||
```vue
|
||
<!-- ❌ 传递 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
|
||
|
||
```typescript
|
||
// ✅ 异步 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')
|
||
```
|
||
|
||
### 生命周期与清理
|
||
|
||
```typescript
|
||
// ✅ 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
|
||
|
||
```vue
|
||
<!-- ✅ 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
|
||
|
||
```vue
|
||
<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
|
||
|
||
```vue
|
||
<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>
|
||
```
|
||
|
||
### 虚拟列表
|
||
|
||
```vue
|
||
<!-- ✅ 大型列表使用虚拟滚动 -->
|
||
<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 用于缓存动态组件
|