Vue3 怎么写自定义组件?从基础到实战一次讲透
做前端项目时,经常需要把重复功能封装成组件,Vue3 里咋写自定义组件?从基础结构到通信、插槽、性能优化,再到实战案例,这篇文章把关键知识点拆成问答形式,新手也能跟着一步步搞懂~
自定义组件到底是什么?和普通组件有啥区别?
自定义组件,就是自己动手封装功能、结构、样式的可复用单元,比如项目里常用的“弹窗组件”“下拉选择器”,都是自定义组件。
“普通组件”和“自定义组件”没有严格界限,“自定义”更强调“自己封装”(区别于 Element Plus 这类 UI 库的现成组件),核心是通过组件化思想,把重复逻辑、界面拆出来,让代码更干净,改需求时只动一个组件就行。
举个例子:电商项目里每个商品卡片长得一样,把“商品卡片”封装成自定义组件,页面里只要写 <GoodsCard :item="xxx" />
就能复用,改样式或逻辑时,只改 GoodsCard 这一个文件,所有页面的商品卡片就同步更新了。
写Vue3自定义组件要分哪几步?
最基础的自定义组件,核心是“写结构、写逻辑、写样式,再注册使用”,以单文件组件(.vue 文件)为例,步骤大概这样:
新建组件文件,搭结构
先建个 .vue
文件(MyButton.vue
),里面分三部分:
<template> <!-- 这里写组件的HTML结构 --> <button class="my-btn">{{ btnText }}</button> </template> <script setup> // 这里写组件的逻辑(数据、方法、props等) defineProps(['btnText']) </script> <style scoped> /* 这里写组件的样式,scoped保证样式只作用于当前组件 */ .my-btn { padding: 8px 16px; } </style>
定义Props/Emits,处理组件逻辑
组件要接收外部数据?用 defineProps
;要向父组件传消息?用 defineEmits
,比如给按钮加点击事件通知父组件:
<template> <button @click="handleClick">{{ btnText }}</button> </template> <script setup> const props = defineProps({ btnText: { type: String, required: true } }) const emit = defineEmits(['btn-click']) const handleClick = () => { emit('btn-click', '点击事件触发啦~') } </script>
注册组件,在其他页面使用
-
局部注册:在要用到的页面里,导入 + 注册:
<script setup> import MyButton from './components/MyButton.vue' </script> <template> <MyButton btnText="提交" @btn-click="handleClick" /> </template>
-
全局注册:在 main.js 里全局注册,所有页面直接用:
import { createApp } from 'vue' import App from './App.vue' import MyButton from './components/MyButton.vue' const app = createApp(App) app.component('MyButton', MyButton) app.mount('#app')
组件间通信咋处理?Props和Emits怎么用?
组件通信是自定义组件的核心!Vue3 里最基础的“父传子用 Props,子传父用 Emits”,还有 v-model 双向绑定,一个个说:
Props:父组件给子组件传数据
定义 Props 时,要明确类型、是否必填、默认值,避免传参出错,比如做个“用户信息卡片”组件:
<script setup> const props = defineProps({ user: { type: Object, // 类型是对象 required: true, // 必须传 default: () => ({ name: '匿名', age: 0 }) // 若没传,用默认值(对象/数组要写函数返回,避免引用问题) }, theme: { type: String, validator: (val) => ['light', 'dark'].includes(val) // 自定义验证规则,只能传light或dark } }) </script> <template> <div class="card" :class="theme"> <h3>{{ user.name }}</h3> <p>年龄:{{ user.age }}</p> </div> </template>
父组件用的时候:<UserCard :user="currentUser" theme="dark" />
Emits:子组件给父组件发消息
子组件触发事件,父组件监听,比如卡片里的“编辑”按钮,点击后通知父组件:
<template> <div class="card"> <button @click="handleEdit">编辑</button> </div> </template> <script setup> const emit = defineEmits(['edit-user']) // 声明要触发的事件 const handleEdit = () => { emit('edit-user', '当前用户ID') // 传参给父组件 } </script>
父组件监听:<UserCard @edit-user="openEditModal" />
v-model 双向绑定(Vue3 更简洁)
以前写 .sync
很麻烦,Vue3 里子组件用 defineModel
能直接实现双向绑定(需 Vue3.4+),开关组件”,父组件控制开关状态,子组件也能改:
子组件:
<template> <button @click="toggle">{{ modelValue ? '开' : '关' }}</button> </template> <script setup> const modelValue = defineModel() // 自动关联v-model的值 const toggle = () => { modelValue.value = !modelValue.value } </script>
父组件用:<Switch v-model="isOpen" />
,不用手动写 :value
和 @update:value
了,超方便~
想让组件更灵活?插槽(Slot)怎么玩?
插槽是让“父组件能自定义子组件部分内容”的神器!比如弹窗组件,不同页面的弹窗标题、内容不一样,用插槽就能灵活配置。
默认插槽:最简单的自定义内容
子组件里留个 <slot>
,父组件在标签里写内容,会自动填充到 slot 位置,卡片组件”的底部操作区:
子组件 Card.vue:
<template> <div class="card"> <header>{{ title }}</header> <main>{{ content }}</main> <footer> <slot>默认内容(父组件没传的话显示这个)</slot> </footer> </div> </template>
父组件用:
<Card title="文章" content="这是文章内容"> <button>点赞</button> <button>收藏</button> </Card>
这样 footer 里就会显示两个按钮,没传的话显示“默认内容”。
具名插槽:多个插槽,指定位置
如果组件有多个可自定义区域(比如弹窗的“标题”和“内容”),用 name
区分,子组件 Popup.vue:
<template> <div class="popup"> <div class="title"> <slot name="title">默认标题</slot> </div> <div class="content"> <slot name="content">默认内容</slot> </div> </div> </template>
父组件用 v-slot:
或 指定插槽名:
<Popup> <template #title> <h2>重要通知</h2> </template> <template #content> <p>系统即将维护,请提前保存数据~</p> </template> </Popup>
作用域插槽:子组件给插槽传数据
子组件里的 slot 可以传数据,父组件用的时候能拿到,列表组件”,每个列表项的样式由父组件定,但数据来自子组件,子组件 List.vue:
<template> <ul> <li v-for="item in list" :key="item.id"> <slot :item="item" /> <!-- 把item传给父组件 --> </li> </ul> </template> <script setup> defineProps(['list']) </script>
父组件用的时候,通过 v-slot="scope"
接收数据:
<List :list="products"> <template #default="scope"> <!-- scope.item 就是子组件传的每个商品数据 --> <div class="product-item"> <img :src="scope.item.img" /> <p>{{ scope.item.name }}</p> </div> </template> </List>
用组合式API封装逻辑,自定义组件咋复用代码?
Vue3 的组合式 API(ref
、computed
、watch
)+ 自定义组合式函数(Composables),能把组件里的重复逻辑抽出来,让自定义组件更简洁。
啥是组合式函数?
就是把一组相关的逻辑(请求数据 + 加载状态 + 错误处理”)封装成一个函数,在多个组件里复用,比如写个 useFetch
处理接口请求:
// useFetch.js import { ref, onMounted } from 'vue' export function useFetch(url) { const data = ref(null) const loading = ref(true) const error = ref(null) const fetchData = async () => { try { const res = await fetch(url) data.value = await res.json() } catch (e) { error.value = e } finally { loading.value = false } } onMounted(fetchData) return { data, loading, error } }
在自定义组件里用组合式函数
比如做个“文章列表组件”,用 useFetch
拿数据:
<template> <div class="article-list"> <div v-if="loading">加载中...</div> <div v-else-if="error">{{ error.message }}</div> <ul v-else> <li v-for="item in data" :key="item.id">{{ item.title }}</li> </ul> </div> </template> <script setup> import { useFetch } from './useFetch.js' const { data, loading, error } = useFetch('https://api.example.com/articles') </script>
这样不管是“文章列表”还是“商品列表”,只要需要请求数据,都能复用 useFetch
,组件里只关心 UI 渲染,逻辑全在组合式函数里,代码清爽多了~
自定义组件性能咋优化?这些细节要注意
自定义组件写多了,性能问题容易冒出来,特别是列表、频繁更新的组件,这些小技巧能帮组件跑得更快:
减少不必要的响应式
Vue3 的响应式是基于 Proxy 的,但有些数据不需要“响应式”(比如纯展示的静态数据),用 shallowRef
或 markRaw
跳过响应式追踪:
<script setup> import { shallowRef } from 'vue' // 比如一个很大的静态配置对象,不需要响应式 const staticConfig = shallowRef({ /* 大量数据 */ }) </script>
用 v-once
如果组件里有“首次渲染后不会变”的内容,加 v-once
让 Vue 只渲染一次,不再追踪更新:
<template> <div class="static-info" v-once> <!-- 这里的内容渲染后永远不变 --> <p>系统版本:{{ version }}</p> <p>更新时间:{{ updateTime }}</p> </div> </template>
事件处理函数缓存
如果组件里的事件处理函数依赖了 props 或 reactive 数据,每次渲染都会生成新函数,导致子组件不必要更新,用 useCallback
缓存函数(但注意,Vue3 的 script setup
里要结合 defineProps
等使用):
<script setup> import { useCallback } from 'vue' const props = defineProps(['item']) // 缓存handleClick,只有props.item变了才重新生成函数 const handleClick = useCallback(() => { console.log('点击了', props.item.id) }, [props.item]) </script> <template> <button @click="handleClick">点击</button> </template>
v-memo 跳过不必要的更新
在列表渲染时,用 v-memo
告诉 Vue:“这些数据没变的话,别更新这部分 DOM”,比如表格的某一列:
<template> <table> <tr v-for="item in list" :key="item.id"> <td v-memo="[item.name]">{{ item.name }}</td> <td>{{ item.price }}</td> </tr> </table> </template>
只有 item.name
变了,才会更新第一列,其他情况跳过,减少 DOM 操作~
实战:做个带搜索的下拉选择组件,巩固知识点
光说不练假把式!现在做个“SearchSelect”组件,需求是:
- 父组件传选项列表(options)
- 输入框搜索,实时过滤选项
- 点击选项,把值传给父组件
- 支持自定义选项的显示内容(用插槽)
分析结构,写基础模板
组件需要:输入框、下拉列表、每个选项,先搭结构:
<template> <div class="search-select"> <!-- 搜索输入框 --> <input v-model="searchVal" placeholder="搜索..." class="search-input" > <!-- 下拉列表 --> <div class="options" v-show="showOptions"> <div v-for="item in filteredOptions" :key="item.value" @click="handleSelect(item)" class="option" > <!-- 插槽:让父组件自定义选项显示 --> <slot :item="item">{{ item.label }}</slot> </div> </div> </div> </template>
处理逻辑(Props、Emits、响应式数据)
用 defineProps
接收 options,defineEmits
触发选择事件,ref
存搜索值和下拉显示状态:
<script setup> import { ref, computed } from 'vue' // 接收父组件的选项列表 const props = defineProps({ options: { type: Array, required: true, default: () => [] } }) // 触发选择事件 const emit = defineEmits(['select']) // 响应式数据:搜索值、下拉是否显示 const searchVal = ref('') const showOptions = ref(false) // 计算属性:过滤选项(搜索值匹配label) const filteredOptions = computed(() => { return props.options.filter(item => item.label.toLowerCase().includes(searchVal.value.toLowerCase()) ) }) // 点击选项的处理函数 const handleSelect = (item) => { emit('select', item.value) // 把值传给父组件 searchVal.value = '' // 清空搜索框 showOptions.value = false // 收起下拉 } // 输入时显示下拉 watch(searchVal, (newVal) => { showOptions.value = newVal.length > 0 }) </script>
样式处理(Scoped CSS)
给组件加样式,区分选中态、 hover 态:
<style scoped> .search-select { width: 200px; position: relative; } .search-input { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; } .options { position: absolute; top: 40px; width: 100%; border: 1px solid #ddd; border-radius: 4px; background: #fff; max-height: 200px; overflow-y: auto; } .option { padding: 8px; cursor: pointer; } .option:hover { background: #f5f5f5; } </style>
父组件使用,测试功能
在页面里导入 SearchSelect,
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。