Vue3中实用的组件封装知识

625 阅读3分钟

前言:本文主要讲解项目中的Vue3的组件封装技巧,比较基础,但是也很实用!篇幅可能会有点长,想把能介绍的都介绍,方便更好理解封装的逻辑,数据的处理。希望本文能给掘友们带来帮助。

下面就开始演示(可以根据目录选择相应内容阅读)

setup介绍文档

注意vue版本!!!
下面代码:js 采用,<script setup> </script> 编译时语法糖,相比普通script具有
更简洁代码,更少样版内容,
而且还可以使用纯ts声明props和自定义事件,
以及更好的运行时性能。

创建一下vue应用

npx create vue@latest //这里版本最新是vue 3.4

image.png

简单的组件封装及使用

在src下创建一个xxxComps.vue

<template>
    <div>{{_value}}</div>
    <input v-model="_value" />
</template>

<script setup>
import{ref} from "vue";

const _value = ref(""); 
</script>

然后在App.vue中引入一下

<template>
    <XxxComps />
    <!-- 使用了两次组件,创建了两个实例,实例的数据是互不影响的 -->
    <XxxComps />
</template>

<script setup>
import XxxComps from "./xxxComps.vue";
</script>

image.png

下面再升级一下难度,传值

组件的值传递

准备

为了方便演示,加入一下antdesign vue

如若有配置,此步骤可以跳过。

npm install ant-design-vue --save //4.2.3版本

简单配置一下按需导入【文档链接1】【文档链接2】 【文档链接3】

npm i less -D //css预处理器
npm i unplugin-vue-components -D //按需加载组件
npm i unplugin-auto-import -D //自动导入例如ref reactive这些
npm i vite-plugin-style-import -D //按需加载样式

在vite.config.ts中配置一下

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
import AutoImport from 'unplugin-auto-import/vite'
import { createStyleImportPlugin, AndDesignVueResolve } from 'vite-plugin-style-import'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [AntDesignVueResolver({ importStyle: 'less' })]
    }),
    createStyleImportPlugin({
      resolves: [AndDesignVueResolve()], //按需导入antdVue样式
      libs: [
        {
          libraryName: 'ant-design-vue',
          esModule: true,
          resolveStyle: (name) => {
            return `ant-design-vue/es/${name}/style/index`
          }
        }
      ]
    }),
    AutoImport({
      imports: [
        'vue', // 自动导入vue的API
        'vue-router'
        // '@vueuse/core' // 可选,导入 VueUse 库中的 API
      ],
      include: [/\.[tj]sx?$/, /\.vue$/, /\.vue\?vue/, /\.md$/],
      dts: true // 生成 `auto-imports.d.ts` 声明文件
    })
  ],
  css: {
    preprocessorOptions: {
      less: {
        javascriptEnabled: true
      }
    }
  },
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

ts问题解决

//解决引入vue文件无法找到模块问题
//无法找到模块“./App.vue”的声明文件。“d:/dataSource/TaoVue/src/App.vue”隐式拥有 "any" 类型。ts(7016)
//在tsconfig.json文件include下添加"shims-vue.d.ts"
"include": [ 
    "src/**/*.ts", 
    "src/**/*.d.ts", 
    "src/**/*.tsx", 
    "src/**/*.vue", 
    "shims-vue.d.ts", // 确保包含这个文件
    "auto-imports.d.ts"
],
//根目录创建shims-vue.d.ts
declare module '*.vue' {
  import { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}


//无法找到文件 Cannot find package 'consola'

npm install consola -D
//是一个用于增强 Node.js 和浏览器环境下日志记录功能的库

配置好后重新启动项目

弹窗类型的组件

这里涉及到3个宏:defineProps、defineEmits、defineExpose

在src下创建api文件夹,然后创建一个message.ts文件模拟请求。

export const getMessageData = () => {
  return new Promise((res) => {
    setTimeout(() => {
      res({
        data: {
          title: '',
          name: 'kk',
          age: 25,
          phone: '135888999111',
          list: ['red', 'blue']
        }
      })
    }, 1500)
  })
}

在components文件夹新建一个Modal文件,以及Modal文件里面创建一个baseModal.vue

<template>
  <div ref="modalRef"></div>
  <a-modal
    :open="visible"
    :title="title"
    @cancel="visible = false"
    @ok="okFn"
    :getContainer="() => $refs.modalRef"
  >
    <a-form :mode="formState">
      <a-form-item label="年龄" name="age">
        <a-input-number v-model:value="formState.age" />
      </a-form-item>
      <a-form-item label="电话" name="phone">
        <a-input v-model:value="formState.phone" />
      </a-form-item>
      <a-form-item label="喜欢的颜色" name="list">
        <a-select v-model:value="formState.list" mode="multiple">
          <a-select-option value="red">红色</a-select-option>
          <a-select-option value="blue">蓝色</a-select-option>
          <a-select-option value="orange">黄色</a-select-option>
          <a-select-option value="white">白色</a-select-option>
        </a-select>
      </a-form-item>
    </a-form>
  </a-modal>
</template>

<script setup lang="ts">
const visible = ref(false)

const props = defineProps({
  title: {
    type: String,
    default: '弹窗标题'
  }
})

const emits = defineEmits(['formState'])

const formState = reactive({ age: '', phone: '', list: [] })
/**
 *
 * @param bl bl:boolean 而不是 bl: Boolean,因为Boolean是对象,创建和处理对象会有额外的开销,而boolean原始类型,性能更好
 */
const openModal = (bl: boolean, data: Object) => {
  visible.value = bl
  Object.assign(formState, data)
}

/**
 *  弹窗的提交方法
 */
const okFn = () => {
  emits('formState', formState)
  visible.value = false
}

defineExpose({ openModal })
</script>

</script>

这里使用了defineProps宏,结合父组件的v-bind,进行父组件向子传递了title这个数据。同时也将弹窗控制open的方法通过defineExport宏暴露出去给父组件调用。 通过defineEmits宏,结合父组件的v-on,进行子组件向父组件传递数据。

在App.vue中导入组件使用

<template>
  <div>
    <a-form>
      <a-form-item label="名称" name="name">
        <a-input v-model:value="formState.name"></a-input>
      </a-form-item>
      <a-form-item label="年龄" name="age">
        <a-input-number v-model:value="formState.age"></a-input-number>
      </a-form-item>
      <a-form-item label="电话" name="phone">
        <a-input v-model:value="formState.phone"></a-input>
      </a-form-item>
      <a-form-item label="喜欢的颜色" name="list">
        <a-select v-model:value="formState.list" mode="multiple">
          <a-select-option value="red">红色</a-select-option>
          <a-select-option value="blue">蓝色</a-select-option>
          <a-select-option value="orange">黄色</a-select-option>
          <a-select-option value="white">白色</a-select-option>
        </a-select>
      </a-form-item>
    </a-form>
    <div>
      <a-button @click="() => getMessageDataFn()" :loading="loading">请求数据</a-button>
      <a-button @click="openModal">弹窗启动</a-button>
    </div>
  </div>

  <BaseModal ref="baseRef" title="APP弹窗" @formState="getFormState"></BaseModal>
</template>

<script setup>
import { getMessageData } from '@/api/message.ts'
import BaseModal from './components/Modal/baseModal.vue'

const baseRef = ref(null) //获取组件实例,在onMounted之后拿到defineExpose暴露的数据

//请求数据
const loading = ref(false)
const formState = reactive({})
const getMessageDataFn = async () => {
  loading.value = true
  const res = await getMessageData()
  Object.assign(formState, res.data)
  loading.value = false
}

//打开弹窗
const openModal = () => {
  //通过组件实例获取 defineExpose暴露的数据
  baseRef.value.openModal(true, formState)
}

//获取弹窗返回的数据
const getFormState = (value) => {
  console.log(value)
}

运行npm run dev,请求数据

image.png 此时修改年龄还有电话这些基本类型数据,对父组件基本类型数据是没什么影响(就是影响父组件数据同步改变),因为Object.assign浅拷贝基本类型的是值。但是数组是浅拷贝引用的,为什么改变数组,父组件的数组同样是没影响的呢?我认为是antdesign 的select组件值返回的是新数组,引用发生改变,就不会导致父组件数组的值改变。这边就不去深究了,感兴趣的可以去看看antdesign select的源码。

其实为了更好的代码健壮性,安全性,还有更好的维护,遵循vue的单项数据流原则。在组件封装时,最好就是组件要什么数据就给什么数据,然后组件再深拷贝,使其数据断开关联。

//安装lodash工具
npm i lodash -D
npm i @types/lodash -D

//tsconfig.json配置
  "compilerOptions": {
   + "typeRoots": ["./node_modules/@types"],
   + "types": ["lodash"]
  }

然后对baseModal.vue代码进行一些修改。

import _ from 'lodash'

const openModal = (bl: boolean, data: Object) => {
  visible.value = bl
  Object.assign(formState, _.cloneDeep(data))
}

const okFn = () => {
  emits('formState', _.cloneDeep(formState))
  visible.value = false
}

像是弹窗这类型组件,一般都有删除,更新,新增这些操作在里面,当完成这些操作时,还是需要去更新列表的数据的。列表的数据一般都在父组件发起请求的。所以可以这么操作:

在App.vue中

//打开弹窗
const openModal = () => {
  //通过组件实例获取 defineExpose暴露的数据
  //getMessageDataFn可以当做回调函数传给子组件。
  baseRef.value.openModal(true, formState, getMessageDataFn)
}

在baseModal.vue中

+  let callBack: Function //记录父组件的回调函数
const openModal = (bl: boolean, data: Object,fn: Function) => {
  visible.value = bl
+  callBack = fn;
  Object.assign(formState, _.cloneDeep(data))
}

const okFn = () => {
  emits('formState', _.cloneDeep(formState))
  //执行回调函数
+  callBack()
  visible.value = false
}
}

深层组件的数据传递

深层组件数据传递,主要有:provide + inject、vuex、pinia

provide + inject

同样,为了更好的代码健壮性,安全性,还有更好的维护,遵循vue的单项数据流原则,建议在父组件的值,就在父组件修改,子组件只读。那怎么实现呢?

为了方便。下面都写创建什么目录和文件,直接用文件路径表示吧

// src/app.vue
<template>
  <router-view></router-view>
</template>

<script setup lang="ts"></script>
// src/router/index.ts routes新增
{
  path: '/message',
  name: 'message',
  component: () => import('../views/message/index.vue')
}
// src/views/message/index.vue
<template>
  <div style="background-color: skyblue; padding: 10px">
    <div>父组件formState: {{ formState }}</div>
    <Base />
  </div>
</template>

<script setup lang="ts">
import Base from './comps/base.vue'
const formState = ref({ name: 'Mr.k' })

const changeName = (value: string) => {
  formState.value.name = value
}

provide('formData', readonly(formState)) //只读,避免数据被污染
provide('changeName', changeName) //处理formState数据的逻辑保留在父组件。
</script>
// src/views/message/comps/base.vue
<template>
  <div style="background-color: #ccc; padding: 10px">
    <div>子组件</div>
    <Base1 />
  </div>
</template>

<script setup lang="ts">
import Base1 from './base1.vue'
</script>
// src/views/message/comps/base1.vue
<template>
  <div style="background-color: #eceff7; padding: 10px">
    孙组件formState:{{ formState }}
    <a-button @click="() => callback('123')">改变父组件的name</a-button>
  </div>
</template>

<script setup lang="ts">
const formState = inject('formData')

//inject 有三个参数,第一个是key,第二个是默认值,第三个是布尔值,true表示第二个参数被当做一个工厂函数
const callback = inject<Function>('changeName', Function)
</script>

一般在封装组件库提供给别人使用时,推荐symbol作为注入名,避免潜在冲突。 具体步骤请参考:文档

image.png

数据双向绑定

数据的双向绑定,依旧是遵循着单向数据流传输原则。 这里主要介绍三种方法的使用

1. defineEmits:update语法糖

// src/views/message/index.vue
<template>
  <div style="background-color: skyblue; padding: 10px">
    <div>父组件formState: {{ formState }}</div>
    <UpdateInput v-model:formData="formState" />
  </div>
</template>

<script setup lang="ts">
import UpdateInput from './comps/updateInput.vue'

const formState = ref({ name: 'coder' })
</script>
// src/views/message/comps/updateInput.vue
<template>
  <div>
    <span>SON:{{ formState }}</span>
    <a-input v-model:value="formState.name" @change="changeValue" />
  </div>
</template>

<script setup lang="ts">
import { cloneDeep } from 'lodash'
const props = defineProps({
  formData: {
    type: Object,
    default: () => ({})
  }
})

const emits = defineEmits(['update:formData'])
const propsFormData = computed(() => props.formData)
const formState = ref({ name: '' })

watch(
  () => propsFormData.value,
  (value: { name: string }) => {
    formState.value = cloneDeep(value)
  },
  { immediate: true }
)

const changeValue = () => {
  emits('update:formData', cloneDeep(formState.value))
}
</script>

image.png 2. vueUse:useVModels link

npm i @vueuse/core
// src/views/message/index.vue
<template>
  <div style="background-color: skyblue; padding: 10px">
    <div>父组件name: {{ name }}</div>
    <UpdateInput v-model:name="name" />
  </div>
</template>

<script setup lang="ts">
import UpdateInput from './comps/updateInput.vue'

const name = ref('coder')
</script>
// src/views/message/comps/updateInput.vue
<template>
  <div>
    <span>SON:{{ name }}</span>
    <a-input v-model:value="name" />
  </div>
</template>

<script setup lang="ts">
import { cloneDeep } from 'lodash'
import { useVModels } from '@vueuse/core'
const props = defineProps({
  name: {
    type: String,
    default: () => ({})
  }
})
const emits = defineEmits(['update:name'])
const { name } = useVModels(props, emits)
</script>

image.png

3. vue3.4+版本: defineModel link

// src/views/message/index.vue
<template>
  <div style="background-color: skyblue; padding: 10px">
    <div>父组件name: {{ name }}</div>
    <UpdateInput v-model:name="name" />
  </div>
</template>

<script setup lang="ts">
import UpdateInput from './comps/updateInput.vue'
const name = ref('coder')
</script>
// src/views/message/comps/updateInput.vue
<template>
  <div>
    <span>SON:{{ name }}</span>
    <a-input v-model:value="name" />
  </div>
</template>

<script setup lang="ts">
const name = defineModel('name', { type: String, default: '' })
</script>

懒加载组件

defineAnsyComponent 定义一个异步组件,它在运行时是懒加载的。参数可以是一个异步加载函数,或是对加载行为进行更具体定制的一个选项对象。

一般用于弹窗组件,抽屉组件这些(不需要在初始化完成展示的页面) 还有结合Teleport投送标签使用等等。

使用起来也很简单:

const Modal = defineAsynComponent(() => import("./modal.vue"));

插槽的使用

  • 默认插槽
  • 具名插槽
  • 作用域插槽
  • 条件插槽
  • 动态插槽
// src/views/message/index.vue
<template>
  <div style="background-color: skyblue; padding: 10px">
    <SlotModule>
      <template #default>
        <div class="father">父组件使用默认插槽</div>
      </template>
      <template #one>
        <div class="father">父组件使用具名插槽</div>
      </template>
      <template #two>
        <div class="father">父组件使用条件插槽</div>
      </template>
      <template #three="props">
        <div class="father">父组件使用作用域插槽:{{ props.count }}</div>
      </template>
    </SlotModule>
  </div>
</template>

<script setup lang="ts">
import SlotModule from './comps/slotModule.vue'
</script>

<style lang="less" scoped>
.father {
  padding: 20px;
  margin: 10px;
  background-color: skyblue;
}
</style>
// src/views/message/comps/slotModule.vue
<template>
  <div class="slot">
    <div>
      <span>默认插槽</span>
      <slot>
        <div>默认插槽</div>
      </slot>
    </div>
    <div>
      <span>具名插槽</span>
      <slot name="one">
        <div>具名插槽</div>
      </slot>
    </div>
    <div v-if="$slots.two">
      <span>条件插槽</span>
      <slot name="two">
        <div>条件插槽</div>
      </slot>
    </div>
    <div>
      <span>作用域插槽</span>
      <slot name="three" :count="111">
        <div>作用域插槽</div>
      </slot>
    </div>
  </div>
</template>

<script setup></script>

<style lang="less" scoped>
.slot {
  display: flex;
  flex-direction: row;
  gap: 30px;
  & > div {
    height: 300px;
    width: 300px;
    background-color: pink;
  }
}
</style>

image.png

巧妙使用样式透传

比如我们针对于antdesign vue的a-input再次封装。 首先我们看一下它的api

image.png

通过透传,将这些attributes传递到a-input上,注意透传是不包含已经声明的props或者emits

// src/views/message/index.vue
<template>
  <div style="background-color: skyblue; padding: 10px">
    <div>父组件name: {{ name }}</div>
    <UpdateInput v-model:name="name" :maxlength="20" showCount @change="inputChange" />
  </div>
</template>

<script setup lang="ts">
import UpdateInput from './comps/updateInput.vue'

const inputChange = (e: any) => {
  console.log(e)
}

const name = ref('coder')
</script>
// src/views/message/comps/updateInput.vue
<template>
  <div>
    <span>SON:{{ $attrs }}</span>
    <a-input v-model:value="name" v-bind="$attrs" />
  </div>
</template>

<script setup lang="ts">
import { useAttrs } from 'vue'
const name = defineModel('name', { type: String, default: '' })

console.log(useAttrs())

onUpdated(() => {
  console.log('updated', useAttrs())
})
</script>

image.png

总结

每一个组件的封装其实都是基础知识的拼凑,牢固的基础知识,会使你在封装组件的时候得心应手。