1. Vue3 组件通信基础:props 的进阶用法
在 Vue3 项目中,props 仍然是父子组件通信的基石。与 Vue2 相比,Vue3 的 props 系统在类型推导和默认值处理上有了显著改进。我们先来看一个典型的 props 声明:
javascript复制// 子组件
const props = defineProps({
title: {
type: String,
required: true,
validator: (value) => ['primary', 'secondary'].includes(value)
},
count: {
type: Number,
default: 0
}
})
注意:在组合式 API 中,defineProps 是编译器宏,不需要显式导入。在选项式 API 中,props 声明方式与 Vue2 相同。
1.1 响应式 props 的最佳实践
Vue3 中 props 默认是响应式的,但直接修改 props 会触发警告。正确的处理方式应该是:
- 使用计算属性:当需要对 prop 进行转换时
javascript复制const normalizedTitle = computed(() => props.title.toUpperCase())
- 转换为本地 ref:当需要修改但保留响应性时
javascript复制const localCount = ref(props.count)
watchEffect(() => {
localCount.value = props.count // 同步父组件更新
})
- 使用 v-model:实现父子组件双向绑定
javascript复制// 父组件
<ChildComponent v-model:count="parentCount" />
// 子组件
const emit = defineEmits(['update:count'])
function updateCount(newVal) {
emit('update:count', newVal)
}
1.2 TypeScript 下的强类型 props
在 TypeScript 项目中,我们可以获得更完善的类型支持:
typescript复制interface Props {
id: number
items: Array<{ id: number; text: string }>
callback?: (result: string) => void
}
const props = defineProps<Props>()
提示:当需要为泛型 props 设置默认值时,可以使用 withDefaults 编译器宏:
typescript复制withDefaults(defineProps<Props>(), {
items: () => [],
callback: () => {}
})
2. Ref 在组件通信中的妙用
2.1 模板引用 vs 组件引用
Vue3 的 ref 系统比 Vue2 更强大,主要分为两种使用场景:
- DOM 元素引用:
javascript复制const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => {
inputRef.value?.focus()
})
- 子组件引用:
javascript复制// 父组件
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
// 调用子组件方法
childRef.value?.someMethod()
警告:过度使用 ref 调用子组件方法会导致组件耦合,应该优先考虑 props/emit 通信。
2.2 expose 的合理使用
Vue3 组件默认不会暴露所有内部方法,需要通过 defineExpose 显式暴露:
javascript复制// 子组件
const internalState = ref('secret')
const publicMethod = () => {...}
defineExpose({
publicMethod
})
2.3 ref 在循环中的陷阱
在 v-for 中使用 ref 时,得到的将是一个 ref 数组:
javascript复制const itemRefs = ref([])
<template v-for="item in list">
<div :ref="el => itemRefs.value.push(el)"></div>
</template>
技巧:使用函数式 ref 可以更灵活地处理动态引用。
3. Router 通信与状态管理
3.1 路由参数传递的三种方式
- 动态路由参数:
javascript复制// 路由配置
{ path: '/user/:id', component: UserDetail }
// 组件内获取
const route = useRoute()
const userId = computed(() => route.params.id)
- 查询参数:
javascript复制router.push({ path: '/search', query: { q: 'vue' } })
// 组件内获取
const searchQuery = computed(() => route.query.q)
- 状态参数(不会出现在 URL 中):
javascript复制router.push({
path: '/detail',
state: { fromHome: true }
})
// 组件内获取
const fromHome = computed(() => route.state?.fromHome)
3.2 路由守卫中的数据预处理
可以在导航守卫中预处理数据:
javascript复制router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !store.state.user) {
return {
path: '/login',
query: { redirect: to.fullPath }
}
}
})
3.3 保持页面状态的技巧
- keep-alive 缓存:
javascript复制<router-view v-slot="{ Component }">
<keep-alive :include="['Home', 'Profile']">
<component :is="Component" />
</keep-alive>
</router-view>
- 滚动行为控制:
javascript复制const router = createRouter({
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else if (to.hash) {
return { el: to.hash }
} else {
return { top: 0 }
}
}
})
4. 跨组件通信的进阶方案
4.1 Provide/Inject 的典型应用
适合深层嵌套组件通信:
javascript复制// 祖先组件
const theme = ref('dark')
provide('theme', theme)
// 后代组件
const theme = inject('theme', 'light') // 默认值
技巧:为注入值提供 Symbol 作为 key 可以避免命名冲突:
javascript复制const ThemeSymbol = Symbol()
provide(ThemeSymbol, theme)
4.2 自定义事件总线(谨慎使用)
虽然 Vue3 移除了 $on/$off,但仍可实现事件总线:
javascript复制// eventBus.js
import mitt from 'mitt'
export const emitter = mitt()
// 组件A
emitter.emit('event-name', payload)
// 组件B
emitter.on('event-name', (payload) => {...})
警告:事件总线容易导致难以追踪的数据流,建议只在特定场景使用。
4.3 组合式函数封装通信逻辑
将通信逻辑封装为可复用的组合式函数:
javascript复制// useCounter.js
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
return {
count,
increment
}
}
// 组件A
const { count, increment } = useCounter()
// 组件B
const { count } = useCounter() // 独立状态
5. 实战:电商平台典型通信场景
5.1 商品列表到详情页的通信
javascript复制// 列表页
function goToDetail(product) {
router.push({
name: 'ProductDetail',
params: { id: product.id },
state: { fromList: true }
})
}
// 详情页
const route = useRoute()
const productId = computed(() => route.params.id)
const fromList = computed(() => route.state?.fromList)
5.2 购物车状态全局管理
javascript复制// store/cart.js
export const useCartStore = defineStore('cart', {
state: () => ({
items: []
}),
actions: {
addItem(product) {
const existing = this.items.find(item => item.id === product.id)
existing ? existing.quantity++ : this.items.push({ ...product, quantity: 1 })
}
}
})
// 组件中使用
const cartStore = useCartStore()
cartStore.addItem(product)
5.3 表单验证的跨组件通信
javascript复制// useFormValidation.js
export function useFormValidation() {
const errors = ref({})
const validate = (field, isValid) => {
errors.value[field] = !isValid
}
const hasErrors = computed(() =>
Object.values(errors.value).some(Boolean)
)
return {
errors,
validate,
hasErrors
}
}
// 父组件
const { errors, hasErrors } = useFormValidation()
// 子表单组件
const { validate } = useFormValidation()
const isValid = computed(() => !!inputValue.value)
watch(isValid, (val) => validate('username', val))
6. 性能优化与调试技巧
6.1 减少不必要的响应式更新
javascript复制// 使用 shallowRef 避免深层响应式
const largeObject = shallowRef({ ... })
// 使用 markRaw 标记非响应式对象
const staticData = markRaw({ ... })
6.2 调试工具的使用
-
Vue Devtools:
- 查看组件层级结构
- 检查 props 和 emits
- 跟踪 ref 和 reactive 状态
-
路由调试:
javascript复制router.afterEach((to, from) => {
console.log(`Navigated from ${from.path} to ${to.path}`)
})
6.3 内存泄漏预防
- 清理事件监听器:
javascript复制onMounted(() => {
const handler = () => {...}
window.addEventListener('resize', handler)
onUnmounted(() => {
window.removeEventListener('resize', handler)
})
})
- 避免循环引用:
javascript复制// 错误示例
const parent = ref(null)
const child = ref(null)
parent.value = { child }
child.value = { parent }
// 正确做法
const parent = shallowRef(null)
const child = shallowRef(null)
parent.value = { getChild: () => child.value }
child.value = { getParent: () => parent.value }
7. 常见问题解决方案
7.1 props 变更但视图不更新
可能原因及解决方案:
-
对象/数组直接修改:
javascript复制// 错误 props.user.name = 'new' // 正确 emit('update:user', { ...props.user, name: 'new' }) -
v-for 中缺少 key:
javascript复制<Item v-for="item in items" :key="item.id" />
7.2 路由切换时组件状态丢失
解决方案:
- 使用 keep-alive 缓存组件
- 将状态提升到父组件或 Pinia 存储
- 使用路由的 state 属性临时保存
7.3 ref 获取不到 DOM 元素
排查步骤:
- 确保组件已挂载(在 onMounted 后访问)
- 检查 ref 名称是否正确
- 确保元素没有被 v-if 条件隐藏
- 在动态组件中使用 nextTick 等待更新
javascript复制onMounted(async () => {
await nextTick()
console.log(inputRef.value) // 现在应该可以访问
})
8. 最佳实践总结
-
通信方式选择优先级:
- 父子组件:props/emit > v-model > ref
- 兄弟组件:共同父级 > 状态管理 > 事件总线
- 跨层级:provide/inject > 状态管理
-
类型安全:
- 为所有 props 定义 TypeScript 接口
- 为 emit 事件定义类型
- 为 provide/inject 使用 Symbol 键
-
性能考量:
- 避免深层响应式数据
- 合理使用 shallowRef/markRaw
- 及时清理事件监听器
-
可维护性:
- 将复杂通信逻辑封装为组合式函数
- 为自定义事件添加详细文档
- 避免直接操作子组件内部状态
在实际项目中,我发现将通信逻辑集中管理(如使用 Pinia)可以显著降低组件间的耦合度。特别是在大型项目中,明确的数据流向规则能让团队协作更加顺畅。对于频繁交互的组件,合理使用 v-model 和 provide/inject 可以简化代码结构,而需要跨路由共享的状态则更适合放在全局存储中。
