二次封装组件的原则与方法原则

二次封装组件的原则与方法原则

Posted by hdj on November 4, 2024

原则

减少重复代码:封装复用逻辑

原则:通过封装复用逻辑,避免在多个地方重复编写相同的代码片段。

实现示例:针对常用的输入框组件,可以将其通用功能(如验证、双向绑定)提取为单一的自定义指令或混合函数,以提升代码复用性。 ###组件开发原则:保持单一职责 原则:每个组件应专注于完成一种特定功能,避免添加与组件主要功能无关的逻辑。

实现示例:封装按钮组件时,只需关注样式和事件绑定,而将复杂的表单逻辑交由专门的表单组件处理。

提供清晰的接口:规范 props 和事件

原则:定义合理的 props 和 events,避免过多参数,保持接口的简洁性和易用性。

实现示例:组件的所有配置项应通过 props 提供,而状态变化或交互操作则通过 $emit 通知父组件。例如,按钮组件可以通过 @click 事件传递点击事件,而不暴露内部逻辑。

支持扩展性:提供灵活的接口设计

原则:为封装组件提供插槽(slot)、动态样式或回调接口,以便开发者根据需求扩展功能。

实现示例:通过插槽和自定义 props,使组件能够适应不同场景。例如,表格组件可以提供一个 custom-column 插槽,允许用户自定义特定列的显示内容。

遵循团队规范:统一命名和代码风格

原则:在封装组件时,应严格遵守团队的命名、代码风格和功能设计规范,以确保代码的一致性和可维护性。

实现示例:在 Vue 组件中,props 使用驼峰命名(如 modelName),而事件则遵循类似 update:modelValue 的命名约定,确保与主流框架和社区最佳实践保持一致。

总结

通过遵循单一职责、减少重复代码、提供清晰接口、支持扩展性以及统一规范的原则,我们可以显著提升组件的质量、可维护性和复用性,从而构建高效且易于协作的微前端应用系统。

方法

  • 继承属性:使用 v-bind="$attrs" 来接收和传递父组件传递的属性。常用于向原组件传递未声明的属性。

  • 继承事件:使用 emits 配置声明事件,或者通过 $emit 进行事件的触发和传播。

  • 继承方法:使用 ref 来暴露内部方法给父组件,通过 defineExpose 明确暴露。

  • 继承插槽:使用 slots 对象来接收和转发插槽内容。

  • vue2与vue3的区别:在 vue3 中,取消了listeners这个组件实例的属性,将其事件的监听都整合到了attrs上,因此直接通过v-bind=$attrs属性就可以进行props属性和event事件的透传

  • 在 vue2 中,需要用到 $slots(插槽) 和 $scopedSlots(作用域插槽)

  • 在 vue3 中,取消了作用域插槽 $scopedSlots,将所有插槽都统一在 `$slots

v-model

大家应该都知道v-model只是一个语法糖,实际就是给组件定义了modelValue属性和监听update:modelValue事件,所以我们以前要实现数据双向绑定需要给子组件定义一个modelValue属性,并且在子组件内要更新modelValue值时需要emit出去一个update:modelValue事件,将新的值作为第二个字段传出去。

原因是因为从vue2开始就已经是单向数据流,在子组件中是不能直接修改props中的值。而是应该由子组件中抛出一个事件,由父组件去监听这个事件,然后去修改父组件中传递给props的变量。如果这里我们给input输入框直接加一个v-model="props.modelValue",那么其实是在子组件内直接修改props中的modelValue

如果父组件和子组件中都使用了 v-model,并且绑定的是同一个变量,这个时候就会出问题了,因为子组件直接更改了父组件的数据,违背了单向数据流,这样会导致如果出现数据问题不好调试,无法定位出现问题的根源。

第一种方法:将 v-model 拆开,通过 emit 让父组件去修改数据

第二种方法:使用计算属性的 get set 方法

但是如果子组件中有多个表单项,不管是上面哪种方法,都要写很多重复的代码

<!-- 父组件 -->
<template>
  <my-input v-model="formList"></my-input>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const formList = ref({
  text: '',
  password: '',
  name: ''
})
</script>

<!-- 子组件 -->
<template>
  <el-input v-model="name"></el-input>
  <el-input v-model="text"></el-input>
  <el-input v-model="password"></el-input>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps({
  modelValue: {
    type: Object,
    default: () => {},
  }
});

const emit = defineEmits(['update:modelValue']);

const name = computed(() => {
  get() {
    return props.modelValue.name
  },
  set(val) {
    emit('update:modelValue', {
      ...props.modelValue,
      name: val
    })
  }
})

const text = computed(() => {
  get() {
    return props.modelValue.text
  },
  set(val) {
    emit('update:modelValue', {
      ...props.modelValue,
      text: val
    })
  }
})

const password = computed(() => {
  get() {
    return props.modelValue.password
  },
  set(val) {
    emit('update:modelValue', {
      ...props.modelValue,
      password: val
    })
  }
})
</script>

上面使用计算属性监听单个属性,所以需要每个属性都写一遍,我们可以考虑在计算属性中监听整个对象:

<!-- 父组件 -->
<template>
  <my-input v-model="formList"></my-input>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const formList = ref({
  text: '',
  password: '',
  name: ''
})
</script>

<!-- 子组件 -->
<template>
  <el-input v-model="modelList.name"></el-input>
  <el-input v-model="modelList.text"></el-input>
  <el-input v-model="modelList.password"></el-input>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps({
  modelValue: {
    type: Object,
    default: () => {},
  }
});

const emit = defineEmits(['update:modelValue']);

const modelList = computed(() => {
  get() {
    return props.modelValue
  },
  set(val) {
    emit('update:modelValue', val)
  }
})
</script>

读取属性的时候能正常调用 get,但是设置属性的时候却无法触发 set,原因是 modelList.value = xxx,才会触发 set,而 modelList.value.name = xxx,无法触发。这个时候,Proxy 代理对象可以完美的解决这个问题:

<!-- 父组件 -->
<template>
  <my-input v-model="formList"></my-input>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const formList = ref({
  text: '',
  password: '',
  name: ''
})
</script>

<!-- 子组件 -->
<template>
  <el-input v-model="modelList.name"></el-input>
  <el-input v-model="modelList.text"></el-input>
  <el-input v-model="modelList.password"></el-input>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps({
  modelValue: {
    type: Object,
    default: () => {},
  }
});

const emit = defineEmits(['update:modelValue']);

const modelList = computed(() => {
  get() {
    return new Proxy(props.modelValue, {
      get(target, key) {
        return Reflect.get(target, key)
      },
      set(target, key, value) {
        emit('update:modelValue',{
          ...target,
          [key]: value
        })
        return true
      }
    })
  },
  set(val) {
    emit('update:modelValue', val)
  }
})
</script>

我们还可以考虑把这段代码进行封装,可以在多处引入进行使用: useVModel.ts,其实 vueuse 里面有提供了这么一个方法,基本的逻辑是一样的。

useVModel

<!-- 父组件 -->
<template>
  <my-input v-model="formList"></my-input>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const formList = ref({
  text: '',
  password: '',
  name: ''
})
</script>

<!-- 子组件 -->
<template>
  <el-input v-model="modelList.name"></el-input>
  <el-input v-model="modelList.text"></el-input>
  <el-input v-model="modelList.password"></el-input>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useVModel } from './useVModel.ts'

const props = defineProps({
  modelValue: {
    type: Object,
    default: () => {},
  }
});

const emit = defineEmits(['update:modelValue']);

const modelList = useVModel(props, 'modelValue', emit)
</script>

使用defineModel实现数据双向绑定

defineModel是一个宏,所以不需要从vue中import导入,直接使用就可以了。这个宏可以用来声明一个双向绑定 prop,通过父组件的 v-model 来使用

<template>
  <CommonInput v-model="inputValue" />
</template>

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

const inputValue = ref();
</script>
<template>
  <input v-model="model" />
</template>

<script setup lang="ts">
const model = defineModel();
model.value = "xxx";
</script>

在上面的例子中我们直接将defineModel的返回值使用v-model绑定到input输入框上面,无需定义 modelValue 属性和监听 update:modelValue 事件,代码更加简洁。defineModel的返回值是一个ref,我们可以在子组件中修改model变量的值,并且父组件中的inputValue变量的值也会同步更新,这样就可以实现双向绑定。

原理:defineModel其实就是在子组件内定义了一个叫model的ref变量和modelValue的props,并且watch了props中的modelValue。当props中的modelValue的值改变后会同步更新model变量的值。并且当在子组件内改变model变量的值后会抛出update:modelValue事件,父组件收到这个事件后就会更新父组件中对应的变量值。

例子

<template>
  <my-input v-model="inputValue" ref="myInputRef" :name="'lyw'" :year="18" @input="onInput">
    <!-- 动态插槽定义 -->
    <template #prefix>
      <span>Prefix Content</span>
    </template>
    <template #suffix="{ year }">
      <span>END: </span>
    </template>
    <template #default="{ name }">
      <p>Default Content: </p>
    </template>
  </my-input>
  <div>当前输入的值是:</div>
</template>

<script lang="ts" setup>
import MyInput from '@/components/MyInput.vue'
import { onMounted } from 'vue'
import { ref } from 'vue'

const inputValue = ref('')
const myInputRef = ref()
function onInput(value: string) {
  console.log('Input value:', value)
}
onMounted(() => {
  // 光标聚焦
  // 避免ref 链式调用,比如 this.$refs.tableRef.$refs.table.clearSort()
  // 前提:子组件把方法暴露
  myInputRef.value.focus()
})
</script>
<template>
  <!-- 简化:直接使用 computed 绑定到 v-bind -->
  <el-input v-model="localValue" v-bind="$attrs" ref="inputRef">
    <!-- 动态插槽 -->
    <template v-for="(_slot, slotName) in $slots" #[slotName]="slotProps">
      <slot :name="slotName" v-bind="slotProps"></slot>
    </template>
  </el-input>
</template>

<script lang="ts" setup>
import { onMounted } from 'vue'
import { computed, ref } from 'vue'

// 定义 props 和 emits
const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})
const emit = defineEmits(['update:modelValue'])

// 使用 computed 来同步 props 和 emits
const localValue = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})
const inputRef = ref('')
let exposeObj: Record<string, any> = {}
const getMethod = () => {
  const entries = Object.entries(inputRef.value)
  for (const [method, fn] of entries) {
    exposeObj[method] = fn
  }
}
onMounted(getMethod)
defineExpose(exposeObj)
</script>

一、组件封装五大核心原则

一、组件封装五大核心原则

1. 单一职责原则

技术要点:每个组件应聚焦单一功能点,避免功能耦合
示例场景

  • ✅ 推荐做法:按钮组件仅处理样式/交互,不承载表单验证逻辑

  • ❌ 反例:在按钮组件中实现axios请求封装

2. DRY原则(减少重复代码)

技术要点:通过高阶组件或组合式API抽象通用逻辑
实现方案

// 通用输入框封装示例
export default {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  template: `
    <input 
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      class="custom-input"
    >
  `
}

3. 接口清晰原则

技术规范

  • Props使用camelCase命名(如inputValue

  • 事件命名遵循Vue官方规范(如update:modelValue

  • 为props提供TypeScript类型定义和默认值

4. 可扩展性原则

技术方案

  • 提供具名插槽(named slots)

  • 支持CSS自定义属性(CSS Variables)

  • 暴露组件实例方法(通过defineExpose)

5. 团队规范适配原则

实施建议

  • 建立团队组件文档(推荐使用Storybook)

  • 统一组件前缀(如BaseButtonBizTable

  • 制定Props命名白皮书

二、组件继承关键技术(Vue2/Vue3对比实现)

1. 属性透传方案

功能 Vue2实现 Vue3实现
属性透传 v-bind="$attrs" v-bind="$attrs"
事件透传 v-on="$listeners" 已整合到$attrs
插槽透传 $scopedSlots 统一使用$slots

2. 方法暴露方案

// Vue3 Composition API
const formMethods = {
  validate() { /*...*/ },
  reset() { /*...*/ }
}
defineExpose(formMethods)

// Vue2 Options API
export default {
  methods: { ...formMethods }
}

3. 动态插槽实践

<!-- 封装可扩展的布局组件 -->
<template>
  <div class="container">
    <header v-if="$slots.header">
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
  </div>
</template>

运行 HTML

三、版本适配指南

Vue2开发者注意事项

  1. 作用域插槽必须使用$scopedSlots访问

  2. 事件监听器需通过$listeners单独处理

  3. 使用.sync修饰符实现双向绑定

Vue3优化特性

  1. 组合式API实现逻辑复用(推荐使用<script setup>

  2. v-model支持多个绑定源

  3. 样式穿透改用:deep()选择器


优化说明

  1. 增加版本对比表格,提升技术方案的可比性

  2. 补充TypeScript类型定义等现代开发实践

  3. 使用代码注释说明关键配置项

  4. 添加版本适配专章,明确不同版本的技术实现

  5. 优化技术术语表述的准确性(如DRY原则的规范表述)

  6. 增加推荐代码格式(添加template标签包裹示例)

建议后续可补充:

  1. 组件单元测试示例

  2. 实际项目中的目录结构示例

  3. 结合VueUse的工具函数应用案例

+ 保持单一职责 原则:每个组件应专注于完成一种特定功能,不要添加与组件主要功能无关的逻辑。 举例:封装一个按钮组件时,应该专注于样式和事件绑定,而不是处理复杂的表单逻辑。 + 尽量减少重复代码 原则:通过封装复用逻辑,减少多处使用同样代码的情况。 举例:可以对常用的输入框组件进行封装,将其通用功能提取为单一组件。 + 提供清晰的接口 原则:定义合理的 props 和 events,避免过多的参数,保持易用性和清晰度。 举例:组件需要的所有配置项应通过 props 提供,事件通过 $emit 通知父组件。 + 支持扩展性 原则:为封装组件提供插槽、动态样式或回调接口,让开发者可以扩展功能。 举例:提供 slot 和 props,使组件可以适应不同场景。 + 遵循团队规范 原则:封装组件时应符合团队的命名、代码风格和功能设计规范。 举例:Vue 组件的 props 使用驼峰命名,事件使用 update:modelValue 风格

好的,我需要帮助用户优化他们提供的Vue组件封装原则部分。首先,我需要回顾用户之前提供的原始内容和优化后的版本,了解之前的改动方向。用户现在给出了另一个需要优化的原则列表,看起来是原始内容的一部分。我的任务是根据之前的优化策略,进一步提升这段内容的文案质量。

首先,分析用户提供的原始原则部分。每个原则目前用列表项呈现,每个条目下有原则和举例。结构比较清晰,但可能可以进一步优化技术准确性、可读性和结构层次。

接下来,回顾之前的优化版本,用户曾将原则分成核心原则和技术方案,使用更明确的标题,添加代码示例,版本对比等。现在需要将同样的优化策略应用到当前的原则部分。

首先检查每个原则的技术表述是否正确。例如,“保持单一职责”是否准确,是否有更专业的术语。可能需要强调“单一职责原则”作为正式术语,而不仅仅是描述。同时,举例部分是否具体,能否用更典型的例子。

然后考虑结构清晰度。当前的结构是列表项,每个包含原则和举例。优化后可能使用更分明的标题,例如使用小标题将每个原则分开,增加技术要点和示例的区分,使用代码块或更明显的视觉元素。

可读性方面,拆分长句,使用项目符号或更简洁的语言。例如,原举例中的“封装一个按钮组件时,应该专注于样式和事件绑定,而不是处理复杂的表单逻辑”可以更简洁,或分成多个点。

SEO优化方面,可能需要合理增加关键词,如“Vue组件封装原则”、“最佳实践”等,但用户当前内容可能更偏向技术指南,SEO可能不是重点,但可以适当考虑。

代码示例规范方面,用户之前的优化版本中添加了代码示例,这里是否需要在原则部分添加代码?例如,在“提供清晰的接口”原则中,可以添加一个props和$emit的代码示例,增强可操作性。

版本适配性方面,是否需要指出某些原则在Vue2和Vue3中的差异?例如,在“遵循团队规范”中提到的事件命名风格,是否适用于Vue3的更新,比如update:modelValue是Vue3的语法,可能需要说明。

交互元素方面,是否可以建议添加图示或Codepen链接?但当前内容可能不需要,但用户之前的优化版本有提到,可能需要保持一致性。

现在,根据这些分析,我需要逐一优化每个原则。

1. 保持单一职责:

- 原原则表述正确,但可以更正式地称为“单一职责原则”,并强调SOLID原则中的单一职责。

- 示例中的按钮组件例子可以更具体,比如提到基础组件与业务组件的区分,或添加代码示例展示正确的按钮组件结构。

2. 尽量减少重复代码:

- 原原则提到DRY原则,但用户之前的优化版本已使用DRY作为标题,这里可能需要统一术语。

- 示例中的输入框封装可以添加代码示例,如展示如何提取通用输入框组件,使用props和v-model。

3. 提供清晰的接口:

- 强调接口设计的重要性,props命名规范,事件命名规范。

- 添加代码示例,展示props定义和事件触发的代码片段,可能包括TypeScript类型。

4. 支持扩展性:

- 提到插槽、作用域插槽、CSS变量、暴露方法等具体技术手段。

- 示例可以展示插槽的使用,或者如何通过props传递回调函数。

5. 遵循团队规范:

- 强调团队协作的重要性,可能添加工具推荐,如ESLint配置,或命名规范示例。

- 示例中的事件命名可以对比Vue2和Vue3的不同,如Vue3使用update:modelValue,而Vue2使用.sync修饰符。

结构方面,可以将每个原则作为子标题,每个下面分技术要点、实现方案、示例代码、注意事项等部分,增强层次感。

技术准确性方面,检查术语是否正确,例如“$emit”是否正确使用,Vue3中是否有变化,是否需要区分版本。

版本适配方面,在涉及Vue特定语法时,如事件命名,说明适用的版本,或对比Vue2和Vue3的不同处理方式。

代码规范方面,确保示例代码格式正确,使用正确的语法高亮,添加注释解释关键点。

最后,确保优化后的内容与用户之前的优化版本风格一致,使用类似的标题结构、代码示例格式,以及版本对比的说明。

以下是优化后的技术原则部分,采用分层结构并增强技术指导性:


<button :class=”variant” @click=”$emit(‘click’)”>

XD </button> </template>


## 2\. DRY原则 (避免重复)

### 复用方案

+   逻辑复用:组合式函数封装(Vue3 Composition API)
    
+   模板复用:Renderless组件设计模式
    
+   配置驱动:通过JSON Schema生成重复组件
    

### 最佳实践

// useFormInput.js export function useFormInput(initialValue) { const value = ref(initialValue) const setValue = (newVal) => value.value = newVal return { value, setValue } }

// 在组件中复用 import { useFormInput } from ‘./hooks’ const { value: inputVal } = useFormInput(‘’)


## 3\. 接口清晰化原则

### 设计规范

| 接口类型 | 命名规范 | 类型声明 |
| --- | --- | --- |
| Props | camelCase | defineProps+TS |
| Events | kebab-case | defineEmits+TS |
| Slots | 语义化命名 | defineSlots+TS |

### 类型安全示例

defineProps<{ modelValue: string maxLength?: number }>()

defineEmits<{ (e: ‘update:modelValue’, value: string): void (e: ‘submit’): void }>() ```

4. 可扩展性原则

扩展机制

  1. 插槽系统

    <template>
      <div class="card">
        <slot name="header"></slot>
        <slot :data="innerData"></slot>
      </div>
    </template>
    
  2. CSS扩展

    .card {
      padding: var(--card-padding, 16px);
    }
    
  3. 方法暴露

    defineExpose({
     reset: () => { /*...*/ }
    })
    

5. 团队规范统一原则

标准化方案

  • 命名体系

    # 基础组件
    BaseButton.vue
    BaseIcon.vue
        
    # 业务组件
    AuthLoginForm.vue
    UserProfileCard.vue
    
  • 文档驱动

    ## Button Props
    | 属性名     | 类型   | 默认值    |
    |----------|-------|---------|
    | variant  | enum  | primary |
    
  • 工具约束

    // .eslintrc
    {
      "vue/component-name-in-template-casing": ["error", "PascalCase"]
    }