
Vue官方Skills是一套被严重低估的最佳实践指南。本文深度解读vue-best-practicesSkill,涵盖从响应式核心、组件模式到性能优化的22个实用技巧,每个技巧都配有清晰的正确/错误对比。这些技巧不是"推荐做法",而是Vue团队多年沉淀下来的正确做事方式。
引言:为什么这套Skills值得你花时间
Vue的文档已经很完善了,但文档告诉你的是"怎么用"。而这套Skills告诉你的是"怎么用对"。
举几个例子:
shallowRefvsref,什么时候该用哪个?v-ifvsv-show,真的只是"条件渲染vs显示隐藏"这么简单?- 为什么你写的
watch(useAttrs()...)从来不触发?
这套Skills的独特之处在于:它不是教你概念,而是直接告诉你错误模式和正确模式。每个知识点都有BAD/GOOD对比,看完就能用。
本文基于vue-best-practicesSkill的所有参考资料编写,涵盖22个最佳实践,覆盖组件、响应式、性能、动画等维度。
md
---
name: vue-best-practices
description: MUST be used for Vue.js tasks. Strongly recommends Composition API with `<script setup>` and TypeScript as the standard approach. Covers Vue 3, SSR, Volar, vue-tsc. Load for any Vue, .vue files, Vue Router, Pinia, or Vite with Vue work. ALWAYS use Composition API unless the project explicitly requires Options API.
license: MIT
metadata:
author: github.com/vuejs-ai
version: "18.0.0"
---
# Vue 最佳实践工作流
使用此 Skill 作为指令集。除非用户明确要求不同顺序,否则请按顺序遵循此工作流。
## 核心原则
- **保持状态可预测:** 一个真实数据源,派生其他一切。
- **让数据流显式化:** Props 向下传递,Events 向上传递,适用于大多数场景。
- **倾向于小而聚焦的组件:** 更容易测试、复用和维护。
- **避免不必要的重渲染:** 明智地使用 computed 属性和 watchers。
- **可读性很重要:** 编写清晰、自文档化的代码。
## 1) 编码前先确认架构(必须)
- 默认技术栈:Vue 3 + Composition API + `<script setup lang="ts">`。
- 如果项目明确使用 Options API,在有相关 Skill 的情况下加载 `vue-options-api-best-practices` Skill。
- 如果项目明确使用 JSX,在有相关 Skill 的情况下加载 `vue-jsx-best-practices` Skill。
### 1.1 必须阅读的核心参考资料(必须)
- 在实现任何 Vue 任务之前,请务必阅读并应用这些核心参考资料:
- `references/reactivity.md`
- `references/sfc.md`
- `references/component-data-flow.md`
- `references/composables.md`
- 在整个任务期间保持这些参考资料处于活跃的工作上下文中,而不仅仅是在出现特定问题时才查阅。
### 1.2 编码前规划组件边界(必须)
对于任何非平凡的功能,在实现前创建简要的组件地图。
- 用一句话定义每个组件的单一职责。
- 将入口/根和路由级视图组件默认作为组合层。
- 将功能 UI 和功能逻辑从入口/根/视图组件中移出,除非任务本身就是一个有意为之的小型单文件演示。
- 在组件地图中定义每个子组件的 props/emits 契约。
- 当添加超过一个组件时,倾向于按功能文件夹布局(`components/<feature>/...`、`composables/use<Feature>.ts`)。
## 2) 应用必要的 Vue 基础知识(必须)
这些是必要的、必须掌握的基础知识。使用在第 `1.1` 节中已加载的核心参考资料,将它们应用到每个 Vue 任务中。
### 响应式
- 来自 `1.1` 的必读参考:[reactivity](references/reactivity.md)
- 保持源状态最小化(`ref`/`reactive`),用 `computed` 派生所有可派生值。
- 需要时用 watchers 处理副作用。
- 避免在模板中重新计算昂贵的逻辑。
### SFC 结构和模板安全
- 来自 `1.1` 的必读参考:[sfc](references/sfc.md)
- 保持 SFC 各部分按此顺序排列:`<script>` → `<template>` → `<style>`。
- 保持 SFC 职责聚焦;拆分大型组件。
- 保持模板声明式;将分支/派生逻辑移到 script 中。
- 应用 Vue 模板安全规则(`v-html`、列表渲染、条件渲染选择)。
### 保持组件职责聚焦
当组件有 **超过一个明确职责** 时进行拆分(例如:数据编排 + UI,或多个独立的 UI 区域)。
- 倾向于 **更小的组件 + composables**,而不是一个"超大组件"
- 将 **UI 区域** 移入子组件(props 入,events 出)。
- 将 **状态/副作用** 移入 composables(`useXxx()`)。
应用客观的拆分触发条件。当 **任意** 条件为真时拆分组件:
- 它同时拥有编排/状态和多个区域的大量展示性标记。
- 它有 3+ 个不同的 UI 区域(例如:表单、过滤器、列表、底部/状态)。
- 模板块被重复或可能变得可复用(条目行、卡片、列表条目)。
入口/根和路由视图规则:
- 保持入口/根和路由视图组件精简:应用 shell/布局、提供者连接、功能组合。
- 当功能包含独立部分时,不要将完整功能实现放在入口/根/视图组件中。
- 对于 CRUD/列表功能(待办、表格、目录、收件箱),至少拆分到:
- 功能容器组件
- 输入/表单组件
- 列表(和/或条目)组件
- 底部/操作或过滤器/状态组件
- 只允许非常小的临时演示使用单文件实现;如果选择了单文件,明确说明为什么不需要拆分。
### 组件数据流
- 来自 `1.1` 的必读参考:[component-data-flow](references/component-data-flow.md)
- 使用 props 向下、events 向上的模型作为主要模式。
- 只对真正的双向组件契约使用 `v-model`。
- 只对深层树依赖或共享上下文使用 provide/inject。
- 用 `defineProps`、`defineEmits` 和 `InjectionKey` 保持契约显式且带类型。
### Composables
- 来自 `1.1` 的必读参考:[composables](references/composables.md)
- 当逻辑被复用、是状态化的或副作用较重时,提取到 composables 中。
- 保持 composable API 小巧、带类型且可预测。
- 将功能逻辑与展示组件分离。
## 3) 只有在需求明确时才考虑可选功能
### 3.1 标准可选功能
不要默认添加这些。只在需求存在时加载匹配的参考。
- Slots:父组件需要控制子组件内容/布局时 -> [component-slots](references/component-slots.md)
- Fallthrough attributes:包装器/基础组件必须安全转发 attrs/events 时 -> [component-fallthrough-attrs](references/component-fallthrough-attrs.md)
- 内置组件 `<KeepAlive>` 用于有状态视图缓存 -> [component-keep-alive](references/component-keep-alive.md)
- 内置组件 `<Teleport>` 用于浮层/传送门 -> [component-teleport](references/component-teleport.md)
- 内置组件 `<Suspense>` 用于异步子树回退边界 -> [component-suspense](references/component-suspense.md)
- 动画相关功能:选择与所需运动行为最匹配的简单方法。
- 内置组件 `<Transition>` 用于入场/离场效果 -> [transition](references/component-transition.md)
- 内置组件 `<TransitionGroup>` 用于列表变动的动画 -> [transition-group](references/component-transition-group.md)
- 基于类的动画用于非入场/离场效果 -> [animation-class-based-technique](references/animation-class-based-technique.md)
- 状态驱动动画用于用户输入驱动的动画 -> [animation-state-driven-technique](references/animation-state-driven-technique.md)
### 3.2 较少使用的可选功能
只在有明确的产品或技术需求时才使用这些。
- 指令:当行为是 DOM 特定的,且不适合用 composable/组件实现时 -> [directives](references/directives.md)
- 异步组件:重型/很少使用的 UI 应该懒加载 -> [component-async](references/component-async.md)
- 渲染函数:只在模板无法表达需求时使用 -> [render-functions](references/render-functions.md)
- 插件:当行为必须在应用范围内安装时 -> [plugins](references/plugins.md)
- 状态管理模式:跨功能边界的应用级共享状态 -> [state-management](references/state-management.md)
## 4) 在行为正确后运行性能优化
性能工作是功能完成后的处理。在核心行为实现并验证之前不要优化。
- 大列表渲染瓶颈 -> [perf-virtualize-large-lists](references/perf-virtualize-large-lists.md)
- 静态子树不必要地重渲染 -> [perf-v-once-v-memo-directives](references/perf-v-once-v-memo-directives.md)
- 热路径中过度抽象 -> [perf-avoid-component-abstraction-in-lists](references/perf-avoid-component-abstraction-in-lists.md)
- 昂贵的更新被触发过于频繁 -> [updated-hook-performance](references/updated-hook-performance.md)
## 5) 完成前的最终自检
- 核心行为正常工作且符合需求。
- 所有必读参考资料都已阅读并应用。
- 响应式模型是最小化且可预测的。
- SFC 结构和模板规则得到遵循。
- 组件职责聚焦且拆分合理,必要时进行了拆分。
- 入口/根和路由视图组件保持作为组合层,除非有明确的小演示例外。
- 组件拆分决策是明确的且可辩护的(职责边界清晰)。
- 数据流契约是显式的且带类型的。
- 在复用/复杂度合理的地方使用了 composables。
- 适用的地方将状态/副作用移入了 composables。
- 只在需求明确时才使用可选功能。
- 性能更改只在功能完成后才应用。
第一部分:响应式核心(Reactivity Core)
1. shallowRef vs ref:不是选哪个的问题
很多人以为这是性能优化选项,其实不是。这是一个关于"更新语义"的选择。
typescript
// 场景:你经常替换整个值(重新获取、重置等)→ 用 ref
const user = ref(null)
user.value = await fetchUser() // 触发更新,整个user被替换
// 场景:你只替换顶层,但内部属性会变(不可变数据风格)→ 用 shallowRef
const user = shallowRef(null)
user.value = { name: 'Alice', age: 30 }
user.value.age = 31 // ❌ 不触发更新
user.value = { ...user.value, age: 31 } // ✅ 触发更新
// 场景:外部库实例、class实例 → 用 shallowRef
const canvas = shallowRef(new Canvas())
typescript
// 什么时候用 reactive
// 场景:表单、单个状态对象,经常就地修改属性
const form = reactive({
username: '',
password: '',
remember: false
})
form.username = 'alice' // 方便
// ❌ 避免:form = reactive({...}) 整个替换
核心原则 :ref()适合经常重新赋值的场景,reactive()适合就地修改的场景,shallowRef()适合你不希望Vue深层代理的对象。
2. computed的五个正确用法
① 永远不要在computed里写副作用
typescript
// ❌ BAD
const doubled = computed(() => {
if (count.value > 10) console.warn('太大了!')
return count.value * 2
})
// ✅ GOOD - 用watch处理副作用
const doubled = computed(() => count.value * 2)
watch(count, (val) => {
if (val > 10) console.warn('太大了!')
})
② 过滤/排序不要写在模板里
vue
<!-- ❌ BAD - 每次渲染都重新计算 -->
<li v-for="item in items.filter(item => item.active).sort(...)">
{{ item.name }}
</li>
<!-- ❌ BAD - 调用函数也是同样的问题 -->
<li v-for="item in getSortedItems()">
<!-- ✅ GOOD -->
<script setup>
const visibleItems = computed(() =>
items.value
.filter(item => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
)
</script>
<template>
<li v-for="item in visibleItems">
</template>
③ 用computed处理动态class/style
vue
<!-- ❌ BAD -->
<button :class="{ 'btn-primary': type === 'primary' && !disabled, 'btn-disabled': disabled }">
<!-- ✅ GOOD -->
<script setup>
const buttonClasses = computed(() => ({
btn: true,
[`btn-${props.type}`]: !props.disabled,
'btn-disabled': props.disabled
}))
</script>
<template>
<button :class="buttonClasses">
</template>
3. watch的正确姿势
① 用immediate: true替代重复的onMounted调用
typescript
// ❌ BAD
const userId = ref(1)
onMounted(() => loadUser(userId.value))
watch(userId, (id) => loadUser(id))
// ✅ GOOD
watch(userId, (id) => loadUser(id), { immediate: true })
② 异步清理是搜索框的救星
typescript
// 当用户快速输入时,取消上一个请求
const query = ref('')
const results = ref<string[]>([])
watch(query, async (q, _prev, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort()) // 关键:自动取消
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
})
results.value = await res.json()
})
4. reactive的正确打开方式
禁止直接解构
typescript
// ❌ BAD - 丢失响应式
const { count } = reactive({ count: 0 })
// ✅ GOOD - 用toRefs保持响应式
const state = reactive({ count: 0 })
const { count } = toRefs(state)
watch(count, ...) // 现在能watch了
watch的正确姿势
typescript
// ❌ BAD - 传了非getter值
watch(state.count, () => {...})
// ✅ GOOD - 用getter
watch(() => state.count, () => {...})
// 或
watch(count, () => {...}) // 通过toRefs解构出来的ref
第二部分:组件模式(Component Patterns)
5. Slots的五个正确做法
① 用简写语法 # 替代 v-slot:
vue
<!-- ❌ BAD -->
<MyComponent>
<template v-slot:header>...</template>
</MyComponent>
<!-- ✅ GOOD -->
<MyComponent>
<template #header>...</template>
</MyComponent>
② 可选插槽要加v-if检查
vue
<!-- ❌ BAD - 没有header时依然渲染空的header标签 -->
<template>
<article class="card">
<header class="card-header">
<slot name="header" />
</header>
</article>
</template>
<!-- ✅ GOOD -->
<template>
<article class="card">
<header v-if="$slots.header" class="card-header">
<slot name="header" />
</header>
</article>
</template>
③ TypeScript项目用defineSlots定义插槽类型
vue
<script setup lang="ts">
defineProps<{ products: Product[] }>()
defineSlots<{
default(props: { product: Product; index: number }): any
empty(): any
}>()
</script>
④ 给插槽提供兜底内容
vue
<!-- ❌ BAD -->
<button type="submit" class="btn-primary">
<slot />
</button>
<!-- ✅ GOOD -->
<button type="submit" class="btn-primary">
<slot>Submit</slot>
</button>
⑤ 纯逻辑复用优先用Composables
vue
<!-- ❌ BAD - 用renderless组件做纯逻辑复用 -->
<MouseTracker v-slot="{ x, y }">
<p>{{ x }}, {{ y }}</p>
</MouseTracker>
<!-- ✅ GOOD -->
<script setup>
const { x, y } = useMouse()
</script>
<template>
<p>{{ x }}, {{ y }}</p>
</template>
6. 组件数据流的正确理解
Props是单向的,永远不要在子组件里修改props
vue
<!-- ❌ BAD -->
<script setup>
const props = defineProps({ count: Number })
function increment() {
props.count++ // ❌ 禁止
}
</script>
<!-- ✅ GOOD - 正确的做法 -->
<script setup>
const props = defineProps({ count: Number })
const emit = defineEmits(['update'])
function increment() {
emit('update', props.count + 1)
}
</script>
用defineModel简化v-model(Vue 3.4+)
vue
<!-- ❌ BAD - Vue 3.4之前的写法 -->
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input :value="props.modelValue" @input="emit('update:modelValue', $event.target.value)" />
</template>
<!-- ✅ GOOD - Vue 3.4+ -->
<script setup>
const model = defineModel({ type: String })
</script>
<template>
<input v-model="model" />
</template>
Provide/Inject用Symbol Keys避免冲突
typescript
// ❌ BAD - 字符串key可能冲突
provide('theme', theme)
// ✅ GOOD - Symbol唯一性 + TypeScript类型支持
import type { InjectionKey } from 'vue'
export const themeKey: InjectionKey<Theme> = Symbol('theme')
provide(themeKey, theme)
7. Fallthrough Attributes的正确访问方式
属性名要用方括号访问
vue
<script setup>
const attrs = useAttrs()
// ❌ 这些都访问不到
attrs.data-testid // 语法错误
attrs.dataTestid // undefined
attrs['on-click'] // undefined
attrs['@click'] // undefined
// ✅ 正确的访问方式
attrs['data-testid']
attrs['aria-label']
attrs.onClick // 事件监听器用onX
</script>
useAttrs()不是响应式的
vue
<script setup>
// ❌ BAD - 这个watch永远不会触发
watch(() => attrs.class, (newVal) => {
console.log(newVal) // Never runs
})
// ✅ GOOD - 用onUpdated
onUpdated(() => {
console.log('Latest attrs:', attrs)
})
// ✅ 或者:promote到props
const props = defineProps({ class: String })
watch(() => props.class, (newVal) => {...})
</script>
第三部分:异步与缓存
8. 异步组件的懒加载策略
在SSR中使用延迟水合
vue
<script setup>
import {
defineAsyncComponent,
hydrateOnVisible, // 进入视口时水合
hydrateOnIdle // 空闲时水合
} from 'vue'
// 评论组件:用户滚动到视口才水合
const AsyncComments = defineAsyncComponent({
loader: () => import('./Comments.vue'),
hydrate: hydrateOnVisible({ rootMargin: '100px' })
})
// 页脚:5ms空闲时水合
const AsyncFooter = defineAsyncComponent({
loader: () => import('./Footer.vue'),
hydrate: hydrateOnIdle(5000)
})
</script>
防止加载闪烁
vue
<script setup>
// ❌ BAD - delay:0 会在网络快时闪一下
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
delay: 0 // 太短了
})
// ✅ GOOD - delay:200 足够短不显眼,足够长不闪动
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 默认值,通常最合适
timeout: 30000
})
</script>
9. KeepAlive的正确使用
不是所有缓存都是好的
vue
<!-- ✅ 应该用KeepAlive的场景: -->
<!-- 标签页切换 -->
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
<!-- ❌ 不该用KeepAlive的场景: -->
<!-- 搜索页 - 用户期望每次新鲜结果 -->
<!-- 地图/大表格 - 内存消耗大 -->
<!-- 敏感流程 - 退出时必须清理 -->
必须设置max限制缓存大小
vue
<!-- ❌ BAD - 无限缓存 -->
<KeepAlive>
<component :is="currentView" />
</KeepAlive>
<!-- ✅ GOOD - 最多缓存5个 -->
<KeepAlive :max="5">
<component :is="currentView" />
</KeepAlive>
缓存失效的正确方式
vue
<script setup>
const currentView = ref('Dashboard')
const viewKeys = reactive({ Dashboard: 0, Settings: 0 })
function invalidateCache(view) {
viewKeys[view]++
}
</script>
<template>
<KeepAlive>
<component :is="currentView" :key="`${currentView}-${viewKeys[currentView]}`" />
</KeepAlive>
</template>
10. Suspense的嵌套与触发机制
必须给default和fallback都包一个根节点
vue
<!-- ❌ BAD -->
<Suspense>
<AsyncHeader />
<AsyncList />
<template #fallback>
<LoadingSpinner />
<LoadingHint />
</template>
</Suspense>
<!-- ✅ GOOD -->
<Suspense>
<div> <!-- 必须包一层 -->
<AsyncHeader />
<AsyncList />
</div>
<template #fallback>
<div>
<LoadingSpinner />
<LoadingHint />
</div>
</template>
</Suspense>
Pending状态只在根节点变化时触发
vue
<!-- ❌ BAD - 异步工作发生在深层,但Suspense只跟踪根节点 -->
<Suspense>
<TabContainer>
<AsyncDashboard v-if="tab === 'dashboard'" />
<AsyncSettings v-else />
</TabContainer>
</Suspense>
<!-- ✅ GOOD - 根节点是动态的 -->
<Suspense>
<component :is="tabs[tab]" :key="tab" />
</Suspense>
组件嵌套顺序
vue
<!-- ✅ 正确的嵌套顺序:RouterView -> Transition -> KeepAlive -> Suspense -->
<RouterView v-slot="{ Component }">
<Transition mode="out-in">
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>Loading...</template>
</Suspense>
</KeepAlive>
</Transition>
</RouterView>
第四部分:性能优化
11. v-once和v-memo:跳过不必要的更新
v-once:静态内容只渲染一次
vue
<template>
<!-- ❌ BAD - 每次渲染都检查 -->
<div class="terms-content">
<h1>Terms of Service</h1>
<p>Version: {{ termsVersion }}</p>
</div>
<!-- ✅ GOOD - 渲染一次后跳过 -->
<div class="terms-content" v-once>
<h1>Terms of Service</h1>
<p>Version: {{ termsVersion }}</p>
</div>
</template>
v-memo:列表项选择性更新
vue
<template>
<!-- ❌ BAD - selectedId变化时所有1000项都重渲染 -->
<div v-for="item in list" :key="item.id">
<div :class="{ selected: item.id === selectedId }">
<ExpensiveComponent :data="item" />
</div>
</div>
<!-- ✅ GOOD - 只有选中项变化的那两项重渲染 -->
<div
v-for="item in list"
:key="item.id"
v-memo="[item.id === selectedId]"
>
<div :class="{ selected: item.id === selectedId }">
<ExpensiveComponent :data="item" />
</div>
</div>
</template>
12. 大列表虚拟化:50+项就开始考虑
vue
<!-- ❌ BAD - 渲染10000项 = 10000个DOM节点 -->
<template>
<div class="user-list">
<UserCard v-for="user in users" :key="user.id" :user="user" />
</div>
</template>
<!-- ✅ GOOD - 只渲染可见的~20项 -->
<template>
<RecycleScroller class="user-list" :items="users" :item-size="80" key-field="id" v-slot="{ item }">
<UserCard :user="item" />
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
</script>
<style scoped>
.user-list {
height: 600px; /* 必须有固定高度 */
}
</style>
推荐库对比:
| 库 | 适用场景 |
|---|---|
vue-virtual-scroller |
通用场景,最流行 |
@tanstack/vue-virtual |
复杂布局,headless设计 |
vue-virtual-scroll-grid |
网格布局 |
13. 避免列表中的过度组件抽象
vue
<!-- ❌ BAD - 每项UserCard创建5个组件实例 -->
<!-- UserCard.vue -->
<template>
<Card> <!-- 组件#1 -->
<CardHeader> <!-- 组件#2 -->
<UserAvatar /> <!-- 组件#3 -->
</CardHeader>
<CardBody> <!-- 组件#4 -->
<Text>{{ user.name }}</Text>
</CardBody>
</Card>
</template>
<!-- 100个用户 = 500个组件实例 -->
<!-- ✅ GOOD - 扁平化结构 -->
<template>
<div class="user-card">
<div class="card-header">
<img :src="user.avatar" :alt="user.name" class="avatar" />
</div>
<div class="card-body">
<span class="user-name">{{ user.name }}</span>
</div>
</div>
</template>
<!-- 100个用户 = 100个组件实例 -->
14. updated钩子里的禁忌
vue
<script setup>
// ❌ BAD - updated里调用API,每次渲染都触发
onUpdated(() => {
fetch('/api/sync', { method: 'POST', body: JSON.stringify(items.value) })
})
// ❌ BAD - 在updated里修改状态 = 无限循环
onUpdated(() => {
renderCount.value++ // 触发更新 → 再次调用onUpdated → 无限循环!
})
// ✅ GOOD - 用watch精确控制
watch(items, (newItems) => {
syncToServer(newItems)
}, { deep: true })
// ✅ GOOD - 只用于DOM同步
onUpdated(() => {
if (scrollContainer.value) {
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
}
})
</script>
第五部分:样式与动画
15. SFC样式规范
始终用scoped样式
vue
<!-- ❌ BAD -->
<style>
button { border-radius: 999px; } /* 全局污染 */
</style>
<!-- ✅ GOOD -->
<style scoped>
.btn-primary { border-radius: 999px; }
</style>
用class选择器而非元素选择器
vue
<!-- ❌ BAD - 元素选择器性能差,且样式脆弱 -->
<style scoped>
article { max-width: 800px; }
h1 { font-size: 2rem; }
</style>
<!-- ✅ GOOD -->
<style scoped>
.article { max-width: 800px; }
.article-title { font-size: 2rem; }
</style>
v-if vs v-show的选择标准
vue
<template>
<!-- 频繁切换 → 用v-show,保持DOM只切换display -->
<ComplexPanel v-show="isPanelOpen" />
<!-- 很少显示/初始成本高 → 用v-if,初始渲染时才创建 -->
<AdminPanel v-if="isAdmin" />
</template>
16. Transition组件的正确用法
只包裹单个元素
vue
<!-- ❌ BAD -->
<Transition name="fade">
<h3>Title</h3>
<p>Description</p>
</Transition>
<!-- ✅ GOOD -->
<Transition name="fade">
<div>
<h3>Title</h3>
<p>Description</p>
</div>
</Transition>
同元素类型切换要加key
vue
<!-- ❌ BAD - 相同<p>标签,Vue复用元素,不触发动画 -->
<Transition name="fade">
<p v-if="isActive">Active</p>
<p v-else>Inactive</p>
</Transition>
<!-- ✅ GOOD -->
<Transition name="fade" mode="out-in">
<p v-if="isActive" key="active">Active</p>
<p v-else key="inactive">Inactive</p>
</Transition>
只使用transform和opacity做动画
vue
<style>
/* ❌ BAD - 触发重排重绘,性能差 */
.slide-enter-active,
.slide-leave-active {
transition: height 0.3s ease;
}
/* ✅ GOOD - GPU加速,只触发重绘 */
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
</style>
17. TransitionGroup的列表动画
必须用稳定唯一key
vue
<!-- ❌ BAD - 用index做key会导致动画错位 -->
<TransitionGroup name="list" tag="ul">
<li v-for="(item, index) in items" :key="index">
</TransitionGroup>
<!-- ✅ GOOD -->
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
</TransitionGroup>
交错动画
vue
<template>
<TransitionGroup
tag="ul"
:css="false"
@before-enter="onBeforeEnter"
@enter="onEnter"
>
<li v-for="(item, index) in items" :key="item.id" :data-index="index">
{{ item.name }}
</li>
</TransitionGroup>
</template>
<script setup>
function onBeforeEnter(el) {
el.style.opacity = 0
el.style.transform = 'translateY(12px)'
}
function onEnter(el, done) {
const delay = Number(el.dataset.index) * 80 // 每项延迟80ms
setTimeout(() => {
el.style.transition = 'all 0.25s ease'
el.style.opacity = 1
el.style.transform = 'translateY(0)'
setTimeout(done, 250)
}, delay)
}
</script>
18. 状态驱动的CSS动画
鼠标跟随
vue
<template>
<div class="container" @mousemove="onMousemove">
<div class="follower" :style="{ transform: `translate(${x}px, ${y}px)` }" />
</div>
</template>
<script setup>
const x = ref(0)
const y = ref(0)
function onMousemove(e) {
const rect = e.currentTarget.getBoundingClientRect()
x.value = e.clientX - rect.left
y.value = e.clientY - rect.top
}
</script>
<style>
.follower {
transition: transform 0.1s ease-out; /* 平滑跟随 */
pointer-events: none;
}
</style>
19. 基于类的反馈动画
shake、pulse等效果
vue
<template>
<div :class="{ shake: showError }">
<button @click="submitForm">Submit</button>
<span v-if="showError">Error occurred!</span>
</div>
</template>
<script setup>
const showError = ref(false)
function submitForm() {
if (!isValid()) {
showError.value = true
setTimeout(() => showError.value = false, 820) // 匹配动画时长
}
}
</script>
<style>
.shake {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translate3d(0, 0, 0); /* 启用GPU加速 */
}
</style>
用animationend自动清理
vue
<script setup>
const isAnimating = ref(false)
function triggerAnimation() {
isAnimating.value = true
// 动画结束时自动重置,不需要setTimeout
}
</script>
<template>
<div :class="{ animate: isAnimating }" @animationend="isAnimating = false">
Content
</div>
</template>
第六部分:工具与扩展
20. 指令(Directives)的正确姿势
只用于DOM访问
typescript
// ❌ BAD - 复杂行为应该用组件或composable
const vShowPassword = {
mounted(el) {
el.addEventListener('click', () => {
el.type = el.type === 'password' ? 'text' : 'password'
})
}
}
// ✅ GOOD - 函数简写用于单钩子
const vFocus = (el) => el.focus()
// ✅ GOOD - 完整对象用于多钩子+清理
const vResize = {
mounted(el) {
const observer = new ResizeObserver(() => {})
observer.observe(el)
el._observer = observer
},
unmounted(el) {
el._observer?.disconnect() // 必须清理
}
}
TypeScript类型增强
typescript
import type { Directive } from 'vue'
type HighlightValue = string
export const vHighlight = {
mounted(el, binding) {
el.style.backgroundColor = binding.value
}
} satisfies Directive<HTMLElement, HighlightValue>
declare module 'vue' {
interface ComponentCustomProperties {
vHighlight: typeof vHighlight
}
}
SSR要实现getSSRProps
typescript
const vTooltip = {
mounted(el, binding) {
el.setAttribute('data-tooltip', binding.value)
el.classList.add('has-tooltip')
},
getSSRProps(binding) { // 必须实现,避免水合不匹配
return {
'data-tooltip': binding.value,
class: 'has-tooltip'
}
}
}
21. 插件(Plugins)的正确写法
符合app.use()契约
typescript
import type { App, Plugin } from 'vue'
interface MyOptions {
apiKey: string
debug?: boolean
}
// ✅ GOOD - 对象形式
const myPlugin: Plugin<[MyOptions]> = {
install(app: App, options: MyOptions) {
app.provide(serviceKey, createService(options))
}
}
// ✅ GOOD - 函数形式
function simplePlugin(app: App, options?: { message: string }) {
app.config.globalProperties.$greet = () => options?.message ?? 'Hello!'
}
app.use(myPlugin, { apiKey: 'xxx', debug: true })
用Symbol Keys防止冲突
typescript
import type { InjectionKey } from 'vue'
import type { AxiosInstance } from 'axios'
export const httpKey: InjectionKey<AxiosInstance> = Symbol('http')
export const configKey: InjectionKey<AppConfig> = Symbol('appConfig')
// 提供注入helper,缺失时抛出明确错误
export function useAuth(): AuthService {
const auth = inject(authKey)
if (!auth) {
throw new Error('Auth plugin not installed. Did you forget app.use(authPlugin)?')
}
return auth
}
22. 渲染函数(Render Functions)的必要模式
优先用模板,只在必要时用渲染函数
vue
<!-- ❌ BAD - 简单场景用渲染函数 -->
<script setup>
import { h, ref } from 'vue'
const count = ref(0)
const render = () => h('div', `Count: ${count.value}`)
</script>
<!-- ✅ GOOD - 用模板 -->
<script setup>
const count = ref(0)
</script>
<template>
<div>Count: {{ count }}</div>
</template>
列表必须有key
javascript
// ❌ BAD
return () => h('ul',
items.value.map(item => h('li', item.name))
)
// ✅ GOOD
return () => h('ul',
items.value.map(item => h('li', { key: item.id }, item.name))
)
事件修饰符要用withModifiers
javascript
import { h, withModifiers, withKeys } from 'vue'
// ❌ BAD
const handleClick = (e) => {
e.stopPropagation()
e.preventDefault()
}
// ✅ GOOD
h('button', {
onClick: withModifiers(handleClick, ['stop', 'prevent'])
}, 'Click')
总结:这些技巧的共同主题
通读这22个最佳实践,你会发现几个贯穿始终的主题:
1. 响应式要精确
- 什么时候用
ref/reactive/shallowRef不是随意的,是根据更新模式决定的 watch要精准触发,computed要纯净无副作用
2. 组件边界要清晰
- Props down, events up是铁律
- Slots是API设计,不是实现技巧
- Provide/Inject用Symbol避免魔法字符串
3. 性能要测量后优化
v-once/v-memo是给真正需要优化的地方用的- 大列表虚拟化有明确阈值(50+项)
- 组件抽象不是越少越好,也不是越多越好
4. SSR有额外的坑
- 异步组件要考虑水合策略
- 指令需要
getSSRProps - 状态管理不能用运行时单例
5. 动画要选对工具
- 入离场 → Transition
- 列表 → TransitionGroup
- 停留在DOM里的反馈 → class绑定
这套Skills的真正价值在于:它把Vue团队的踩坑经验整理成了可以直接照着做的模式。下次你写Vue代码时,可以对照检查清单看看是否用对了这些模式。