Code前端首页关于Code前端联系我们

Vue3 怎么写自定义组件?从基础到实战一次讲透

terry 3周前 (09-08) 阅读数 43 #Vue
文章标签 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(refcomputedwatch)+ 自定义组合式函数(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 的,但有些数据不需要“响应式”(比如纯展示的静态数据),用 shallowRefmarkRaw 跳过响应式追踪:

<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前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门