Files
Jason Woltje d9bcdc4a8d feat: Initial agent-skills repo — 4 adapted skills for Mosaic Stack
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>
2026-02-16 16:03:39 -06:00

20 KiB
Raw Permalink Blame History

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>

<!--  useIdSSR 安全的唯一 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-modelVue 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 用于缓存动态组件