Vue3组件基础,组合式vs选项式,怎么选才对?
刚接触Vue3组件的朋友,肯定会先纠结“到底用组合式API还是选项式API写”这个事吧?写出来的组件要么复用性差改起来要跑好几个文件,要么性能拉胯打包后体积超大,甚至还有一堆生命周期钩子堆在一起,连自己都看不懂半年前写的逻辑,别慌,今天就把我踩过的坑、总结的经验,还有从官方渠道、行业里常用的规范里扒出来的实用方法全说清楚,从新手能看懂的基础避坑,到老手追求的性能优化和复用升级,一步步来,保证看完你的Vue3组件能写得又顺又快。 很多人以为组合式API就是Vue3的“唯一正确打开方式”,一上来就硬套setup语法糖里的ref、reactive,结果把原本简单的逻辑写得绕来绕去,甚至出现数据响应丢失的问题,其实两种写法各有各的场景,先别忙着站队,搞清楚它们的适用范围再说。
选项式API:适合“一眼能看到底”的简单组件
选项式API就是我们Vue2里常用的那种,把data、methods、computed、watch这些逻辑按“功能块”分开,这种写法的优点是什么呢?就是逻辑分类特别明确,刚学前端或者刚从Vue2转过来的人,扫一眼就能知道“这个数据在data里改,那个计算逻辑在computed里写,点击事件放methods下面”,团队协作的时候哪怕不是写这个组件的人,也能快速上手改点小功能。
那它适合什么样的组件呢?比如只负责展示数据、偶尔触发个简单弹窗的卡片组件,或者结构固定、交互逻辑不超过3个的表单输入框,举个例子,做一个展示用户头像、昵称和签名的个人信息小卡片,用选项式API写的话,可能data里就放userInfo对象,computed里算一下签名是否太长需要加省略号,methods里加个点击头像跳转个人主页的方法,生命周期钩子可能只用个onMounted调接口拿数据,整个代码文件不超过100行,清清楚楚。
组合式API:复杂逻辑、高复用性组件的“救星”
但如果你的组件逻辑稍微复杂一点,比如一个带搜索、筛选、分页、排序、批量操作的数据表格,用选项式API的话就会出现“逻辑分散”的问题:搜索的逻辑在watch、computed、methods里都有,分页的逻辑又散在另几个地方,修改一个功能要上下翻好几百行,很容易漏改或者改错。
这时候组合式API的优势就出来了——它可以让你把相关的逻辑放在一起写,比如搜索相关的,你可以单独写一个useSearch的组合式函数,把搜索关键词、搜索触发的方法、搜索结果的暂存全放进去;分页相关的,再写一个usePagination,把当前页、每页条数、总页数这些全封装起来,然后在setup语法糖里直接调用这两个函数就行,这样一来,哪怕是上千行的复杂组件,逻辑也是模块化的,找起来改起来都特别方便。
还有一点,组合式API的复用性更强,刚才说的useSearch、usePagination,你完全可以把它们抽成单独的.ts文件,放在项目的composables文件夹里,以后做其他数据表格、列表页的时候直接拿过来用就行,不用再重新写一遍搜索分页的逻辑,大大提高了开发效率。
到底怎么选?别纠结,看这两个标准
- 看逻辑复杂度:如果组件的逻辑块(比如数据展示、交互、生命周期调用)不超过3个,用选项式API;超过3个,或者逻辑之间有交叉关联,直接上组合式API。
- 看复用需求:如果这个组件的某个逻辑(比如表单验证、搜索分页)可能在其他地方用到,不管简单还是复杂,都用组合式API,方便后期抽离复用。
Vue3组件核心:怎么解决组件间的通信问题?
组件通信可以说是Vue3组件开发中最常见的问题了,父子组件通信还好说,稍微懂点Vue的人都知道props和emit,但跨层级通信、兄弟组件通信、甚至是毫无关系的组件之间的通信呢?很多人一上来就用Vuex或者Pinia,把所有数据都放全局状态里,结果全局状态越来越臃肿,连自己都不知道哪个数据是哪个组件用的,反而增加了维护成本,其实Vue3给我们提供了很多种组件通信的方式,不同的场景选不同的方式,才是最优解。
父子组件通信:别只用props/emit,试试这些小技巧
父子组件通信是最基础的,props用来父传子,emit用来子传父,这个大家应该都知道,但这里面其实有很多小坑和小技巧。
父传子:props的“高级用法”和避坑指南
props一定要定义清楚类型、默认值和是否必填,别图省事直接写个props数组,比如你传一个用户信息的对象给子组件,如果只写props: ['userInfo'],那子组件拿到userInfo的时候根本不知道它里面有什么字段,字段的类型是什么,很容易出现undefined的问题,正确的写法应该是用对象形式定义props: (这里可以放一段代码,但不用标注Vue官方,就自然写出来)
// 子组件
defineProps({
userInfo: {
type: Object,
required: true,
// 注意:如果props的默认值是对象或者数组,一定要用函数返回,不然所有子组件会共享同一个引用
default: () => ({
id: '',
nickname: '未知用户',
avatar: '默认头像地址',
signature: ''
})
},
// 基本类型的默认值直接写就行
showSignature: {
type: Boolean,
default: true
}
})
这里要特别强调的一个坑:如果props的默认值是对象或者数组,必须用箭头函数或者普通函数返回一个新的实例,不然所有复用这个子组件的地方,都会共享同一个对象/数组的引用,修改其中一个子组件的数据,其他子组件的数据也会跟着变,这个问题找起来特别麻烦。
还有一个小技巧,就是用v-bind="$attrs"来传递“未定义在props里的属性”,比如你想给子组件的根元素加个class或者style,或者加个原生的click事件,不用在props里一个个定义,直接在父组件调用子组件的时候写上去,然后在子组件的根元素上加v-bind="$attrs"就行,但要注意,$attrs里默认是包含class和style的,如果你想让子组件自己控制根元素的class和style,可以在子组件里设置inheritAttrs: false,然后手动把$attrs里的class和style去掉,或者只传递你需要的属性。
子传父:别只传基本类型,试试传递事件对象和payload
子传父用emit,这个也很基础,但很多人传参数的时候只传一个基本类型,比如传个搜索关键词、传个选中的id,但有时候我们需要传递更多的信息,比如事件对象、表单数据对象等。
其实defineEmits和defineProps一样,也可以用对象形式定义,这样可以验证emit的参数类型,避免传错参数:
// 子组件
const emit = defineEmits({
// 验证search事件必须传一个非空字符串
search: (keyword: string) => keyword.trim() !== '',
// 验证select事件必须传一个数字类型的id
select: (id: number) => typeof id === 'number'
})
// 触发事件的时候可以传多个参数,也可以传对象
const handleSearch = () => {
emit('search', searchKeyword.value, {
page: 1,
pageSize: 10
})
}
这里还有一个小技巧,就是如果子组件的根元素是一个原生的表单元素或者按钮,你可以直接在父组件调用子组件的时候用v-model绑定,这样比手动写emit和props更方便,比如做一个自定义的输入框组件,你可以这样写:
// 子组件 CustomInput.vue
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const handleInput = (e: Event) => {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
<!-- 父组件 --> <CustomInput v-model="username" /> <!-- 等价于 --> <CustomInput :modelValue="username" @update:modelValue="username = $event" />
对了,Vue3还支持多个v-model绑定,比如你可以给自定义的日期选择器组件绑定开始日期和结束日期:
<!-- 父组件 --> <CustomDatePicker v-model:startDate="startDate" v-model:endDate="endDate" />
跨层级组件通信:别一上来就用Pinia,试试provide/inject
如果是爷孙组件或者更深层级的组件通信,用props一层一层传下去太麻烦了,这时候可以用provide和inject,provide是在祖先组件里提供数据,inject是在后代组件里注入数据,不管层级有多深,都能直接拿到。
provide/inject的用法也很简单:
// 祖先组件(比如App.vue)
import { ref, provide } from 'vue'
import { useUserStore } from '@/stores/user' // 如果数据很复杂,还是建议用Pinia
// 提供静态数据
provide('appName', '我的Vue3应用')
// 提供响应式数据(注意:如果提供的是ref或者reactive,后代组件拿到的也是响应式的)
const user = ref({
id: '',
nickname: ''
})
provide('currentUser', user)
// 提供方法
const updateUser = (newUser: any) => {
user.value = newUser
}
provide('updateCurrentUser', updateUser)
// 后代组件(比如Header.vue)
import { inject } from 'vue'
// 注入数据,如果没有提供的话,用第二个参数作为默认值
const appName = inject('appName', '默认应用名')
const currentUser = inject('currentUser', ref({ nickname: '未知用户' }))
const updateCurrentUser = inject('updateCurrentUser', () => {})
这里要注意的两个点:
- provide/inject默认是非响应式的,如果你想让后代组件拿到的数据是响应式的,必须在provide的时候传递ref或者reactive对象,不能传递普通的对象或者基本类型。
- 尽量不要用provide/inject修改祖先组件的数据,除非你提供了专门的修改方法(比如上面的updateCurrentUser),不然数据的流向会变得不清晰,维护起来很困难,如果修改数据的逻辑比较复杂,或者数据需要在多个毫无关系的组件之间共享,还是用Pinia更合适。
兄弟组件通信:这三种方式你用过几种?
兄弟组件通信的话,常见的有三种方式:
- 通过共同的父组件中转:如果兄弟组件之间的通信不频繁,或者逻辑比较简单,可以把数据放在共同的父组件里,子组件A通过emit把数据传给父组件,父组件修改数据后再通过props传给子组件B。
- 通过事件总线(mitt):如果兄弟组件之间没有共同的父组件,或者共同的父组件层级太深,可以用事件总线,Vue3里没有内置的事件总线了,但我们可以用第三方库mitt,它是一个轻量级的事件发射器,只有200字节左右,非常好用。
- 通过Pinia全局状态:如果兄弟组件之间需要共享的数据比较多,或者通信比较频繁,直接用Pinia全局状态管理就行,这个是最省心的方式。
这里简单说一下mitt的用法: 首先安装mitt:
npm install mitt
然后创建一个eventBus.ts文件:
import mitt from 'mitt'
type Events = {
// 定义事件类型,避免拼写错误
'toggle-sidebar': void
'refresh-data': {
type: 'user' | 'order'
id?: number
}
}
const eventBus = mitt<Events>()
export default eventBus
然后在需要发送事件的组件里:
import eventBus from '@/utils/eventBus'
const toggleSidebar = () => {
eventBus.emit('toggle-sidebar')
}
在需要接收事件的组件里:
import { onMounted, onUnmounted } from 'vue'
import eventBus from '@/utils/eventBus'
const handleToggleSidebar = () => {
console.log('侧边栏切换了')
}
// 组件挂载的时候监听事件
onMounted(() => {
eventBus.on('toggle-sidebar', handleToggleSidebar)
})
// 组件卸载的时候一定要移除监听,不然会造成内存泄漏
onUnmounted(() => {
eventBus.off('toggle-sidebar', handleToggleSidebar)
})
Vue3组件进阶:怎么提高组件的复用性和可维护性?
组件的复用性和可维护性是衡量一个Vue3组件写得好不好的重要标准,下面就给大家分享几个常用的方法。
组合式函数(Composables):Vue3复用逻辑的“终极武器”
刚才在讲组合式API的时候已经提到过组合式函数了,它就是把一些可复用的逻辑抽成单独的.ts文件,函数名一般以use开头,比如useSearch、usePagination、useFormValidation、useLocalStorage等。
下面举一个useLocalStorage的例子,这个函数可以用来存储和读取localStorage里的数据,而且支持响应式:
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
// 先从localStorage里读取数据,如果没有的话用默认值
const storedValue = localStorage.getItem(key)
const data = ref<T>(storedValue ? JSON.parse(storedValue) : defaultValue)
// 监听data的变化,自动保存到localStorage里
watch(data, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true }) // deep: true 表示深度监听对象和数组的变化
// 返回data,供组件使用
return data
}
然后在组件里直接调用就行:
// 组件里
import { useLocalStorage } from '@/composables/useLocalStorage'
// 存储用户的主题设置,默认是浅色模式
const theme = useLocalStorage<'light' | 'dark'>('app-theme', 'light')
// 切换主题
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
组合式函数的优点是非常灵活,你可以把任何可复用的逻辑都抽成组合式函数,而且它和Vue的响应式系统完美结合,用起来和普通的ref/reactive没什么区别。
插槽(Slots):Vue3组件“自定义内容”的法宝
插槽可以让你在组件内部预留一些位置,让父组件传递自定义的内容进去,这样可以大大提高组件的灵活性,Vue3的插槽和Vue2相比有一些改进,比如作用域插槽的写法更简洁了,还支持动态插槽名。
默认插槽
默认插槽是最简单的插槽,就是在组件内部用标签预留位置,父组件调用的时候直接在组件标签之间写内容就行:
<!-- 子组件 MyButton.vue --> <button class="my-button"> <slot>默认按钮文字</slot> </button>
<!-- 父组件 --> <MyButton>点击我</MyButton> <!-- 如果父组件不写内容,就显示默认的“默认按钮文字” --> <MyButton />
具名插槽
如果组件内部需要预留多个位置,就可以用具名插槽,就是给
<!-- 子组件 MyCard.vue -->
<div class="my-card">
<div class="my-card-header">
<slot name="header"></slot>
</div>
<div class="my-card-body">
<slot></slot> <!-- 默认插槽,name是default -->
</div>
<div class="my-card-footer">
<slot name="footer"></slot>
</div>
</div>
<!-- 父组件 -->
<MyCard>
<template #header>
<h2>卡片标题</h2>
</template>
<p>卡片内容</p>
<template #footer>
<MyButton>确定</MyButton>
<MyButton>取消</MyButton>
</template>
</MyCard>
Vue3里具名插槽的写法是<template #插槽名>,比Vue2的更简洁了。
作用域插槽
作用域插槽是最强大的插槽,它可以让子组件把自己的数据传递给父组件,父组件可以根据这些数据自定义内容,比如做一个展示列表的组件,子组件负责循环渲染列表项,但列表项的具体内容由父组件决定:
<!-- 子组件 MyList.vue -->
<ul class="my-list">
<li v-for="item in list" :key="item.id" class="my-list-item">
<!-- 把item传递给父组件 -->
<slot :item="item" :index="index"></slot>
</li>
</ul>
// 子组件 MyList.vue
defineProps({
list: {
type: Array,
required: true
}
})
<!-- 父组件 -->
<MyList :list="userList">
<!-- 用解构的方式拿到子组件传递过来的数据 -->
<template #default="{ item, index }">
<span>{{ index + 1 }}.</span>
<img :src="item.avatar" alt="头像" />
<span>{{ item.nickname }}</span>
</template>
</MyList>
作用域插槽的写法是<template #default="{ 数据名 }">,如果具名插槽的话就是<template #插槽名="{ 数据名 }">。
组件的命名规范和文件夹结构:好的规范能提高一半的效率
很多人不重视组件的命名规范和文件夹结构,结果项目越来越大,找组件的时候要翻好几个文件夹,甚至不知道某个组件是干什么的,其实好的规范能大大提高开发效率和团队协作效率,下面给大家分享几个常用的规范。
组件的命名规范
- 单文件组件的文件名:用PascalCase(首字母大写的驼峰命名法),比如MyButton.vue、UserList.vue,不要用kebab-case(短横线分隔)或者camelCase(小驼峰)。
- 组件的注册名:和文件名保持一致,用PascalCase,比如components里的MyButton.vue,在全局注册或者局部注册的时候都用MyButton。
- 组件在模板中的使用:用kebab-case,比如
,这样更符合HTML的命名习惯,也更容易和原生HTML标签区分开。 - 基础组件的命名:基础组件就是那些通用的、没有业务逻辑的组件,比如按钮、输入框、卡片等,建议用一个前缀(比如Base、App、V)来命名,比如BaseButton.vue、AppInput.vue,这样在文件夹里很容易找到,也不会和业务组件重名。
- 业务组件的命名:业务组件就是那些和业务逻辑相关的组件,比如UserList.vue、OrderDetail.vue,建议用功能或者页面名来命名,这样一看就知道这个组件是干什么的。
组件的文件夹结构
不同的项目可能有不同的文件夹结构,建议这样分:
src/
components/
base/ # 基础组件,全局注册
BaseButton.vue
BaseInput.vue
BaseCard.vue
business/ # 业务组件,局部注册
user/ # 用户相关的业务组件
UserList.vue
UserDetail.vue
UserForm.vue
order/ # 订单相关的业务组件
OrderList.vue
OrderDetail.vue
layout/ # 布局组件
Header.vue
Footer.vue
Sidebar.vue
views/ # 页面组件
Home.vue
User.vue
Order.vue
composables/ # 组合式函数
useSearch.ts
usePagination.ts
useLocalStorage.ts
utils/ # 工具函数
stores/ # Pinia全局状态
assets/ # 静态资源
这样的文件夹结构非常清晰,找组件的时候一看就知道在哪个文件夹里,团队协作的时候也不容易出错。
Vue3组件性能优化:别让你的组件拖慢页面加载速度
很多人写完组件就不管了,结果页面加载速度特别慢,用户体验很差,其实Vue3本身已经做了很多性能优化,比如虚拟DOM的改进、编译时的静态提升、事件侦听器的缓存等,但我们自己在写组件的时候也可以做一些优化,让页面加载速度更快。
避免不必要的响应式数据
很多人一上来就把所有数据都用ref或者reactive包起来,但其实有些数据根本不需要响应式,比如组件内部的常量、不需要在模板里显示的临时变量等,这些数据用普通的变量或者const声明就行,不要用ref或者reactive,这样可以减少Vue的响应式追踪开销。
举个例子,比如你在组件里定义了一个常量MAX_PAGE_SIZE = 50,这个常量不需要响应式,直接用const声明就行:
// 正确的写法 const MAX_PAGE_SIZE = 50 // 错误的写法,浪费性能 const maxPageSize = ref(50)
用v-show代替v-if(如果频繁切换的话)
v-if和v-show的区别大家应该都知道:v-if是“真正的”条件渲染,它会在条件为false的时候把DOM元素完全销毁或者移除,条件为true的时候再重新创建或者插入;v-show只是简单地切换DOM元素的display属性,条件为false的时候display: none,条件为true的时候display: block(或者原来的display属性)。
那什么时候用v-if,什么时候用v-show呢?
- 如果条件很少切换(比如页面加载的时候判断用户是否登录,登录了显示用户中心,没登录显示登录页面),用v-if,因为它可以减少初始渲染的DOM元素数量。
- 如果条件频繁切换(比如切换标签页、显示/隐藏弹窗),用v-show,因为它切换的开销比v-if小很多。
用computed缓存计算结果
很多人在模板里直接写复杂的计算逻辑,或者用methods来计算,但其实这样做是不对的,因为每次组件重新渲染的时候,模板里的复杂计算逻辑或者methods都会重新执行一遍,浪费性能。
正确的做法是用computed,computed会缓存计算结果,只有当它依赖的响应式数据发生变化的时候,才会重新计算,否则直接返回缓存的结果。
举个例子,比如你有一个购物车列表,需要计算购物车的总价:
// 正确的写法,用computed缓存
import { ref, computed } from 'vue'
const cartList = ref([
{ id: 1, name: '商品1', price: 100, quantity: 2 },
{ id: 2, name: '商品2', price: 200, quantity: 1 }
])
const totalPrice = computed(() => {
console.log('计算总价')
return cartList.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
})
<!-- 模板里直接用totalPrice -->
<p>总价:{{ totalPrice }}</p>
这样的话,只有当cartList里的商品数量或者商品价格发生变化的时候,totalPrice才会重新计算,否则直接返回缓存的结果,不会每次都打印“计算总价”。
如果用methods的话,每次组件重新渲染的时候都会执行:
// 错误的写法,每次重新渲染都会执行
const getTotalPrice = () => {
console.log('计算总价')
return cartList.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
<p>总价:{{ getTotalPrice() }}</p>
用v-for的时候一定要加key,而且不要用index作为key
这个应该是Vue开发的“老生常谈”了,但还是有很多人犯这个错误,v-for的key属性可以帮助Vue更高效地更新DOM,它可以让Vue识别出哪个元素是新增的,哪个元素是删除的,哪个元素是移动的,从而只更新需要更新的元素,而不是整个列表重新渲染。
那为什么不要用index作为key呢?因为如果列表的顺序发生变化,比如插入、删除或者排序,index也会跟着变化,这样Vue就无法正确识别元素,会导致DOM更新错误,甚至出现数据响应丢失的问题。
正确的做法是用列表项的唯一标识作为key,比如id、uuid等:
<!-- 正确的写法 -->
<ul>
<li v-for="item in userList" :key="item.id">{{ item.nickname }}</li>
</ul>
<!-- 错误的写法,不要用index作为key -->
<ul>
<li v-for="(item, index) in userList" :key="index">{{ item.nickname }}</li>
</ul>
异步组件和路由懒加载
如果你的项目很大,有很多组件和页面,初始加载的时候会把所有的组件和页面都打包到一个JS文件里,导致JS文件体积很大,页面加载速度很慢,这时候可以用异步组件和路由懒加载,把不同的组件和页面打包成不同的JS文件,只有当需要的时候才加载。
异步组件
异步组件的写法很简单,用defineAsyncComponent函数就行:
// 全局注册异步组件
import { createApp, defineAsyncComponent } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 定义异步组件
const AsyncComponent = defineAsyncComponent(() => import('./components/AsyncComponent.vue'))
// 全局注册
app.component('AsyncComponent', AsyncComponent)
app.mount('#app')
<!-- 局部注册异步组件 -->
<script setup>
import { defineAsyncComponent } from 'vue'
const AsyncComponent = defineAsyncComponent(() => import('./components/AsyncComponent.vue'))
</script>
<template>
<AsyncComponent v-if="showAsyncComponent" />
</template>
defineAsyncComponent还支持很多高级选项,比如加载状态、错误状态、超时时间等:
const AsyncComponent = defineAsyncComponent({
loader: () => import('./components/AsyncComponent.vue'),
loadingComponent: LoadingComponent, // 加载时显示的组件
errorComponent: ErrorComponent, // 加载失败时显示的组件
delay: 200, // 延迟多久显示加载状态,避免闪烁
timeout: 3000 // 超时时间,超过这个时间就显示错误状态
})
路由懒加载
路由懒加载就是把不同的路由页面打包成不同的JS文件,只有当访问这个路由的时候才加载对应的JS文件,Vue Router的写法很简单,直接用import()函数就行:
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue') // 路由懒加载
},
{
path: '/user',
name: 'User',
component: () => import('../views/User.vue') // 路由懒加载
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default router
好了,今天关于Vue3组件的内容就讲这么多,从基础的组合式vs选项式API选择、组件间的通信方式,到进阶的组合式函数、插槽、命名规范,再到性能优化的方法,相信大家看完之后对Vue3组件的开发有了更深入的了解。
其实写Vue3组件没有什么“万能公式”,不同的场景选不同的写法和方法,只要你的组件复用性高、可维护性好、性能达标,就是一个好的组件,实践出真知,光看不练是不行的,大家可以试着把今天学到的方法用到自己的项目里,慢慢积累经验,相信你的Vue3组件开发能力会越来越强。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网

