如何封装组件

2025-01-27 前端

一、什么是组件封装

组件封装是将 UI 和业务逻辑打包成一个可复用、可独立维护的单元,对外暴露清晰的接口(Props/Events/Slots),隐藏内部实现细节。

┌──────────────────────────────┐
│         封装的组件             │
│                              │
│   Props(输入)→  ┌──────┐    │
│                  │ 内部  │    │
│                  │ 实现  │ → Events(输出)│
│   Slots(插槽)→  │ 细节  │    │
│                  └──────┘    │
│       (样式、状态、逻辑)    │
└──────────────────────────────┘

封装 = 高内聚 + 低耦合:组件内部高度聚合,对外依赖最小化。


二、为什么要封装组件

原因 说明
复用性 一次编写,多处使用,减少重复代码
可维护性 修改只影响组件内部,不影响外部
可测试性 独立组件更容易编写单元测试
协作效率 团队成员可以并行开发不同组件
一致性 统一 UI 风格和交互行为

三、Props 接口设计

Props 是组件的"输入参数",是组件封装的第一步。

3.1 基本原则

<script setup>
// ✅ 好的 Props 设计:语义化、类型明确、有默认值
const props = defineProps({
  title: {
    type: String,
    required: true,
    default: ''
  },
  size: {
    type: String,
    validator: (v) => ['small', 'medium', 'large'].includes(v),
    default: 'medium'
  },
  disabled: {
    type: Boolean,
    default: false
  }
})
</script>

3.2 用对象代替多个离散参数

// ❌ 不好:多个离散参数,调用方难以记忆
<UserCard
  name="张三"
  age="25"
  avatar="/img.jpg"
  email="..."
  phone="..."
/>

// ✅ 好:关联数据放在一起
<UserCard :user="userData" />

// 或者用 config 对象分组
<DataTable
  :columns="tableColumns"
  :data="tableData"
  :pagination="{
    page: 1,
    pageSize: 20,
    total: 100
  }"
/>

3.3 使用布尔 Props 的正确姿势

// ✅ 推荐:语义化的布尔 prop
<Button loading />
<Button disabled />
<Button readonly />

// ❌ 避免:含义模糊的字符串枚举
<Button status="loading" />
<Button state="disabled" />

布尔 Prop 的传入即视为 true:

<Button loading />          <!-- loading = true -->
<Button :loading="true" />  <!-- 同上 -->
<Button :loading="false" /> <!-- loading = false -->

3.4 单向数据流

Props 是只读的,子组件不能直接修改父组件的数据:

// ❌ 错误:直接修改 prop
const props = defineProps(['count'])
props.count++  // 警告!

// ✅ 正确:通过事件通知父组件修改
const emit = defineEmits(['update:count'])
function handleClick() {
  emit('update:count', props.count + 1)
}

配合 v-model 实现双向绑定:

<!-- 父组件 -->
<Counter v-model:count="pageCount" />

<!-- 子组件 -->
<script setup>
const props = defineProps(['count'])
const emit = defineEmits(['update:count'])
</script>

四、事件与通信

4.1 自定义事件

组件通过事件向父组件传递信息:

<!-- SearchInput.vue -->
<script setup>
const emit = defineEmits(['search', 'clear', 'focus'])

function handleSearch(value) {
  emit('search', value)
}

function handleClear() {
  emit('clear')
}
</script>

<template>
  <div class="search-input">
    <input @input="handleSearch($event.target.value)" />
    <button @click="handleClear">清除</button>
  </div>
</template>

4.2 事件命名规范

// ✅ 推荐:kebab-case 事件名(HTML 原生风格)
emit('update:model-value')
emit('item-selected')
emit('before-close')

// ❌ 避免:camelCase(在模板中不直观)
emit('updateModelValue')
emit('itemSelected')
emit('beforeClose')

4.3 暴露组件方法(Expose)

某些场景下父组件需要直接调用子组件的方法:

<!-- FormInput.vue -->
<script setup>
import { ref } from 'vue'

const inputRef = ref(null)

function focus() {
  inputRef.value?.focus()
}

function validate() {
  // 校验逻辑...
  return true
}

// 暴露给父组件
defineExpose({ focus, validate })
</script>

<!-- 父组件调用 -->
<script setup>
const formRef = ref(null)
formRef.value?.focus()
formRef.value?.validate()
</script>

五、Slot 插槽机制

插槽让组件的内容区域可定制,实现"骨架固定 + 内容灵活"。

5.1 基础插槽

5.2 作用域插槽

插槽可以访问子组件的数据:


六、样式封装

6.1 Scoped CSS

Vue 的 scoped 通过添加 data 属性实现样式隔离:

<style scoped>
/* 只影响当前组件,不影响子组件 */
.card {
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

/* 深度选择器:穿透到子组件 */
:deep(.table-cell) {
  padding: 12px;
}

/* 插槽内容样式 */
:slotted(.custom-header) {
  font-weight: bold;
}
</style>

6.2 CSS Modules

/* Button.module.css */
.button {
  display: inline-flex;
  align-items: center;
  padding: 8px 16px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}

.primary {
  background: #1890ff;
  color: #fff;
}

.danger {
  background: #ff4d4f;
  color: #fff;
}
// React 中使用
import styles from './Button.module.css'

function Button({ variant = 'primary', children }) {
  return (
    <button
      className={`${styles.button} ${styles[variant]}`}
    >
      {children}
    </button>
  )
}

6.3 CSS-in-JS

// styled-components 示例
import styled from 'styled-components'

const StyledButton = styled.button<{ $variant: 'primary' | 'danger' }>`
  display: inline-flex;
  align-items: center;
  padding: 8px 16px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  background: ${props => props.$variant === 'danger' ? '#ff4d4f' : '#1890ff'};
  color: white;

  &:hover {
    opacity: 0.9;
  }

  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
`

七、组件组合模式

7.1 复合组件模式

将相关组件组合在一起,通过命名空间组织:

<!-- 复合组件:Select + Option -->
<Select v-model="selected">
  <SelectOption value="1">选项一</SelectOption>
  <SelectOption value="2">选项二</SelectOption>
  <SelectOption value="3">选项三</SelectOption>
</Select>

<!-- 实现思路 -->
<script setup>
// Select.vue
import { provide, ref } from 'vue'

const selected = defineModel()
provide('selectValue', selected)

// SelectOption.vue
import { inject } from 'vue'
const selectValue = inject('selectValue')
const isSelected = computed(() => selectValue.value === props.value)
</script>

7.2 渲染函数与 Render Props

7.3 高阶组件(HOC)

// 高阶组件:给组件添加通用能力
function withLoading<T>(WrappedComponent: React.ComponentType<T>) {
  return function WithLoadingComponent(props: T & { loading?: boolean }) {
    if (props.loading) {
      return <div className="loading-spinner">加载中...</div>
    }
    return <WrappedComponent {...props} />
  }
}

// 使用
const UserListWithLoading = withLoading(UserList)
<UserListWithLoading loading={isLoading} users={users} />

八、组件封装检查清单

8.1 设计阶段

  • [ ] 这个组件有明确的单一职责吗?
  • [ ] Props 的命名是否语义化、类型是否完整?
  • [ ] 是否有合理的默认值?
  • [ ] 是否提供了必要的插槽/事件?

8.2 实现阶段

  • [ ] 样式是否隔离?(scoped / CSS Modules)
  • [ ] 是否处理了边界情况?(空数据、加载中、错误)
  • [ ] 是否避免直接修改 Props?
  • [ ] 事件命名是否符合规范?

8.3 测试阶段

// Vue 组件测试示例
import { mount } from '@vue/test-utils'
import Button from './Button.vue'

describe('Button', () => {
  it('渲染默认状态', () => {
    const wrapper = mount(Button, {
      props: { label: '提交' }
    })
    expect(wrapper.text()).toContain('提交')
  })

  it('禁用状态下触发点击', async () => {
    const wrapper = mount(Button, {
      props: { label: '提交', disabled: true }
    })
    await wrapper.trigger('click')
    expect(wrapper.emitted('click')).toBeFalsy()
  })

  it('loading 状态显示加载图标', () => {
    const wrapper = mount(Button, {
      props: { label: '保存', loading: true }
    })
    expect(wrapper.find('.loading-icon').exists()).toBe(true)
  })
})

九、实战示例:封装一个确认弹窗


十、总结

组件封装是前端工程化的核心技能,好的封装需要做到:

  1. 接口清晰 — Props 语义化、类型完整、有默认值
  2. 内容灵活 — 利用插槽/Slots 让调用方自定义渲染
  3. 样式隔离 — scoped / CSS Modules / CSS-in-JS
  4. 单向数据流 — 不直接修改 Props,通过事件通信
  5. 边界处理 — 空状态、加载中、错误状态都要考虑
  6. 可测试 — 单元测试覆盖核心功能

封装的核心思想:暴露"什么"(What),隐藏"怎么实现"(How)。